From 6a343769e5b96b00833ff3f93a732e72563b4320 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Thu, 12 Feb 2026 22:20:25 +0100 Subject: [PATCH] feat: implement custom attachment download feature with folder selection and automatic opening of Explorer --- app_mail.go | 49 +++++++- app_settings.go | 38 ++++++ backend/utils/ini-reader.go | 3 + backend/utils/mail/file_dialog.go | 98 +++++++++++++++ config.ini | 3 + frontend/messages/en.json | 8 +- frontend/messages/it.json | 10 +- frontend/src/lib/components/MailViewer.svelte | 45 +++++-- frontend/src/lib/stores/settings.svelte.ts | 2 + frontend/src/lib/types.d.ts | 12 +- .../src/routes/(app)/settings/+page.svelte | 117 +++++++++++++++++- main.go | 56 +++++++++ 12 files changed, 417 insertions(+), 24 deletions(-) diff --git a/app_mail.go b/app_mail.go index 003e2ff..9dde686 100644 --- a/app_mail.go +++ b/app_mail.go @@ -3,7 +3,7 @@ package main import ( - "emly/backend/utils/mail" + internal "emly/backend/utils/mail" ) // ============================================================================= @@ -86,3 +86,50 @@ func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) { func (a *App) ShowOpenFileDialog() (string, error) { return internal.ShowFileDialog(a.ctx) } + +func (a *App) ShowOpenFolderDialog() (string, error) { + return internal.ShowFolderDialog(a.ctx) +} + +// SaveAttachment saves an attachment to the configured download folder. +// Uses EXPORT_ATTACHMENT_FOLDER from config.ini if set, +// otherwise falls back to WEBVIEW2_DOWNLOAD_PATH, then to default Downloads folder. +// After saving, opens Windows Explorer to show the saved file. +// +// Parameters: +// - filename: The name to save the file as +// - base64Data: The base64-encoded attachment data +// +// Returns: +// - string: The full path where the file was saved +// - error: Any file system errors +func (a *App) SaveAttachment(filename string, base64Data string) (string, error) { + // Try to get configured export folder first + folderPath := a.GetExportAttachmentFolder() + + // If not set, try to get WEBVIEW2_DOWNLOAD_PATH from config + if folderPath == "" { + config := a.GetConfig() + if config != nil && config.EMLy.WebView2DownloadPath != "" { + folderPath = config.EMLy.WebView2DownloadPath + } + } + + savedPath, err := internal.SaveAttachmentToFolder(filename, base64Data, folderPath) + if err != nil { + return "", err + } + + return savedPath, nil +} + +// OpenExplorerForPath opens Windows Explorer to show the specified file or folder. +// +// Parameters: +// - path: The full path to open in Explorer +// +// Returns: +// - error: Any execution errors +func (a *App) OpenExplorerForPath(path string) error { + return internal.OpenFileExplorer(path) +} diff --git a/app_settings.go b/app_settings.go index 7230b41..b9359c4 100644 --- a/app_settings.go +++ b/app_settings.go @@ -128,3 +128,41 @@ func (a *App) SetUpdateCheckerEnabled(enabled bool) error { return nil } + +// SetExportAttachmentFolder updates the EXPORT_ATTACHMENT_FOLDER setting in config.ini +// based on the user's preference from the GUI settings. +// +// Parameters: +// - folderPath: The path to the folder where attachments should be exported +// +// Returns: +// - error: Error if loading or saving config fails +func (a *App) SetExportAttachmentFolder(folderPath string) error { + // Load current config + config := a.GetConfig() + if config == nil { + return fmt.Errorf("failed to load config") + } + + // Update the setting + config.EMLy.ExportAttachmentFolder = folderPath + + // Save config back to disk + if err := a.SaveConfig(config); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + return nil +} + +// GetExportAttachmentFolder returns the EXPORT_ATTACHMENT_FOLDER setting from config.ini +// +// Returns: +// - string: The path to the export folder, or empty string if not set +func (a *App) GetExportAttachmentFolder() string { + config := a.GetConfig() + if config == nil { + return "" + } + return config.EMLy.ExportAttachmentFolder +} diff --git a/backend/utils/ini-reader.go b/backend/utils/ini-reader.go index 44be631..5b2e7c4 100644 --- a/backend/utils/ini-reader.go +++ b/backend/utils/ini-reader.go @@ -22,6 +22,9 @@ type EMLyConfig struct { UpdateCheckEnabled string `ini:"UPDATE_CHECK_ENABLED"` UpdatePath string `ini:"UPDATE_PATH"` UpdateAutoCheck string `ini:"UPDATE_AUTO_CHECK"` + WebView2UserDataPath string `ini:"WEBVIEW2_USERDATA_PATH"` + WebView2DownloadPath string `ini:"WEBVIEW2_DOWNLOAD_PATH"` + ExportAttachmentFolder string `ini:"EXPORT_ATTACHMENT_FOLDER"` } // LoadConfig reads the config.ini file at the given path and returns a Config struct diff --git a/backend/utils/mail/file_dialog.go b/backend/utils/mail/file_dialog.go index 12f1271..36b8ccd 100644 --- a/backend/utils/mail/file_dialog.go +++ b/backend/utils/mail/file_dialog.go @@ -2,6 +2,13 @@ package internal import ( "context" + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" "github.com/wailsapp/wails/v2/pkg/runtime" ) @@ -16,6 +23,14 @@ var EmailDialogOptions = runtime.OpenDialogOptions{ ShowHiddenFiles: false, } +var FolderDialogOptions = runtime.OpenDialogOptions{ + Title: "Select Folder", + Filters: []runtime.FileFilter{ + {DisplayName: "Folders", Pattern: "*"}, + }, + ShowHiddenFiles: false, +} + func ShowFileDialog(ctx context.Context) (string, error) { filePath, err := runtime.OpenFileDialog(ctx, EmailDialogOptions) if err != nil { @@ -23,3 +38,86 @@ func ShowFileDialog(ctx context.Context) (string, error) { } return filePath, nil } + +func ShowFolderDialog(ctx context.Context) (string, error) { + folderPath, err := runtime.OpenDirectoryDialog(ctx, FolderDialogOptions) + if err != nil { + return "", err + } + return folderPath, nil +} + +// SaveAttachmentToFolder saves a base64-encoded attachment to the specified folder. +// If folderPath is empty, uses the user's Downloads folder as default. +// Expands environment variables in the format %%VAR%% or %VAR%. +// +// Parameters: +// - filename: The name to save the file as +// - base64Data: The base64-encoded file content +// - folderPath: Optional custom folder path (uses Downloads if empty) +// +// Returns: +// - string: The full path where the file was saved +// - error: Any file system or decoding errors +func SaveAttachmentToFolder(filename string, base64Data string, folderPath string) (string, error) { + // Decode base64 data + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return "", fmt.Errorf("failed to decode attachment data: %w", err) + } + + // Use configured folder or default to Downloads + targetFolder := folderPath + if targetFolder == "" { + targetFolder = filepath.Join(os.Getenv("USERPROFILE"), "Downloads") + } else { + // Expand environment variables (%%VAR%% or %VAR% format) + re := regexp.MustCompile(`%%([^%]+)%%|%([^%]+)%`) + targetFolder = re.ReplaceAllStringFunc(targetFolder, func(match string) string { + varName := strings.Trim(match, "%") + return os.Getenv(varName) + }) + } + + // Ensure the target folder exists + if err := os.MkdirAll(targetFolder, 0755); err != nil { + return "", fmt.Errorf("failed to create target folder: %w", err) + } + + // Create full path + fullPath := filepath.Join(targetFolder, filename) + + // Save the file + if err := os.WriteFile(fullPath, data, 0644); err != nil { + return "", fmt.Errorf("failed to save attachment: %w", err) + } + + return fullPath, nil +} + +// OpenFileExplorer opens Windows Explorer and selects the specified file. +// Uses the /select parameter to highlight the file in Explorer. +// If the path is a directory, opens the directory without selecting anything. +// +// Parameters: +// - filePath: The full path to the file or directory to open in Explorer +// +// Returns: +// - error: Any execution errors +func OpenFileExplorer(filePath string) error { + // Check if path is a directory or file + info, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("failed to stat path: %w", err) + } + + if info.IsDir() { + // Open directory + cmd := exec.Command("explorer.exe", filePath) + return cmd.Start() + } + + // Open and select file + cmd := exec.Command("explorer.exe", "/select,", filePath) + return cmd.Start() +} diff --git a/config.ini b/config.ini index 601a4f8..a290522 100644 --- a/config.ini +++ b/config.ini @@ -7,3 +7,6 @@ LANGUAGE = it UPDATE_CHECK_ENABLED = false UPDATE_PATH = UPDATE_AUTO_CHECK = true +WEBVIEW2_USERDATA_PATH = +WEBVIEW2_DOWNLOAD_PATH = %%USERPROFILE%%\Documents\EMLy_Attachments +EXPORT_ATTACHMENT_FOLDER = diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 969600e..ff87e3e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -218,5 +218,11 @@ "pdf_error_no_data_desc": "No PDF data provided. Please open this window from the main EMLy application.", "pdf_error_timeout": "Timeout loading PDF. The worker might have failed to initialize.", "pdf_error_parsing": "Error parsing PDF: ", - "pdf_error_rendering": "Error rendering page: " + "pdf_error_rendering": "Error rendering page: ", + "settings_custom_download_label": "Custom Attachment Download", + "settings_custom_download_hint": "Save attachments to a custom folder and open Explorer automatically", + "settings_custom_download_info": "Info: When enabled, attachments will be saved to the folder configured below (or WEBVIEW2_DOWNLOAD_PATH if not set) and Windows Explorer will open to show the file. When disabled, uses browser's default download behavior.", + "settings_export_folder_label": "Select a folder to save exported attachments", + "settings_export_folder_hint": "Choose a default location for saving attachments that you export from emails (instead of the Downloads folder)", + "settings_select_folder_button": "Select folder" } diff --git a/frontend/messages/it.json b/frontend/messages/it.json index ad7f190..71eb70d 100644 --- a/frontend/messages/it.json +++ b/frontend/messages/it.json @@ -218,6 +218,12 @@ "pdf_error_rendering": "Errore nel rendering della pagina: ", "mail_download_btn_label": "Scarica", "mail_download_btn_title": "Scarica", - "mail_download_btn_text": "Scarica" - + "mail_download_btn_text": "Scarica", + "settings_custom_download_label": "Download Personalizzato Allegati", + "settings_custom_download_hint": "Salva gli allegati in una cartella personalizzata e apri automaticamente Esplora Risorse", + "settings_custom_download_info": "Info: Quando abilitato, gli allegati verranno salvati nella cartella configurata di seguito (o WEBVIEW2_DOWNLOAD_PATH se non impostata) e Windows Explorer si aprirà per mostrare il file. Quando disabilitato, usa il comportamento di download predefinito del browser.", + "settings_export_folder_label": "Seleziona una cartella per salvare gli allegati esportati", + "settings_export_folder_hint": "Scegli una posizione predefinita per salvare gli allegati che esporti dalle email (invece della cartella Download)", + "settings_select_folder_button": "Seleziona cartella" } + diff --git a/frontend/src/lib/components/MailViewer.svelte b/frontend/src/lib/components/MailViewer.svelte index 74844b6..354ce5b 100644 --- a/frontend/src/lib/components/MailViewer.svelte +++ b/frontend/src/lib/components/MailViewer.svelte @@ -15,6 +15,7 @@ import { onDestroy, onMount } from 'svelte'; import { toast } from 'svelte-sonner'; import { EventsOn, WindowShow, WindowUnminimise } from '$lib/wailsjs/runtime/runtime'; + import { SaveAttachment, OpenExplorerForPath } from '$lib/wailsjs/go/main/App'; import { mailState } from '$lib/stores/mail-state.svelte'; import * as m from '$lib/paraglide/messages'; import { dev } from '$app/environment'; @@ -61,19 +62,41 @@ mailState.clear(); } - function onDownloadAttachments() { + async function onDownloadAttachments() { if (!mailState.currentEmail || !mailState.currentEmail.attachments) return; - mailState.currentEmail.attachments.forEach((att) => { - const base64 = arrayBufferToBase64(att.data); - const dataUrl = createDataUrl(att.contentType, base64); - const link = document.createElement('a'); - link.href = dataUrl; - link.download = att.filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }); + // Check if custom download behavior is enabled + const useCustomDownload = settingsStore.settings.useCustomAttachmentDownload ?? false; + + if (useCustomDownload) { + // Use backend SaveAttachment (saves to configured folder and opens Explorer) + try { + let lastSavedPath = ''; + for (const att of mailState.currentEmail.attachments) { + const base64 = arrayBufferToBase64(att.data); + lastSavedPath = await SaveAttachment(att.filename, base64); + toast.success(`Saved: ${att.filename}`); + } + // Open Explorer to show the folder where files were saved + if (lastSavedPath) { + await OpenExplorerForPath(lastSavedPath); + } + } catch (err) { + toast.error(`Failed to save attachments: ${err}`); + } + } else { + // Use browser default download (downloads to browser's default folder) + mailState.currentEmail.attachments.forEach((att) => { + const base64 = arrayBufferToBase64(att.data); + const dataUrl = createDataUrl(att.contentType, base64); + const link = document.createElement('a'); + link.href = dataUrl; + link.download = att.filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); + } } async function onOpenMail() { diff --git a/frontend/src/lib/stores/settings.svelte.ts b/frontend/src/lib/stores/settings.svelte.ts index bba82ca..6202324 100644 --- a/frontend/src/lib/stores/settings.svelte.ts +++ b/frontend/src/lib/stores/settings.svelte.ts @@ -18,6 +18,8 @@ const defaults: EMLy_GUI_Settings = { reduceMotion: false, theme: "dark", increaseWindowButtonsContrast: false, + exportAttachmentFolder: "", + useCustomAttachmentDownload: false, }; class SettingsStore { diff --git a/frontend/src/lib/types.d.ts b/frontend/src/lib/types.d.ts index 3647e29..416029d 100644 --- a/frontend/src/lib/types.d.ts +++ b/frontend/src/lib/types.d.ts @@ -5,15 +5,17 @@ type SupportedFileTypePreview = "jpg" | "jpeg" | "png"; interface EMLy_GUI_Settings { selectedLanguage: SupportedLanguages = "en" | "it"; useBuiltinPreview: boolean; - useBuiltinPDFViewer?: boolean; - previewFileSupportedTypes?: SupportedFileTypePreview[]; - enableAttachedDebuggerProtection?: boolean; + useBuiltinPDFViewer: boolean; + previewFileSupportedTypes: SupportedFileTypePreview[]; + enableAttachedDebuggerProtection: boolean; useDarkEmailViewer?: boolean; enableUpdateChecker?: boolean; musicInspirationEnabled?: boolean; reduceMotion?: boolean; - theme?: "light" | "dark"; - increaseWindowButtonsContrast?: boolean; + theme: "light" | "dark"; + increaseWindowButtonsContrast: boolean; + exportAttachmentFolder?: string; + useCustomAttachmentDownload?: boolean; } type SupportedLanguages = "en" | "it"; diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index 60cb7c6..e9a3a6e 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -6,7 +6,7 @@ import { Label } from "$lib/components/ui/label"; import { Separator } from "$lib/components/ui/separator"; import { Switch } from "$lib/components/ui/switch"; - import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle, Sun, Moon } from "@lucide/svelte"; + import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle, Sun, Moon, FolderArchive } from "@lucide/svelte"; import type { EMLy_GUI_Settings } from "$lib/types"; import { toast } from "svelte-sonner"; import { It, Us } from "svelte-flags"; @@ -25,8 +25,9 @@ import { setLocale } from "$lib/paraglide/runtime"; import { mailState } from "$lib/stores/mail-state.svelte.js"; import { dev } from '$app/environment'; - import { ExportSettings, ImportSettings, CheckForUpdates, DownloadUpdate, InstallUpdate, GetUpdateStatus, SetUpdateCheckerEnabled } from "$lib/wailsjs/go/main/App"; + import { ExportSettings, ImportSettings, CheckForUpdates, DownloadUpdate, InstallUpdate, SetUpdateCheckerEnabled, ShowOpenFolderDialog, GetExportAttachmentFolder, SetExportAttachmentFolder } from "$lib/wailsjs/go/main/App"; import { EventsOn, EventsOff } from "$lib/wailsjs/runtime/runtime"; + import Input from "$lib/components/ui/input/input.svelte"; let { data } = $props(); let config = $derived(data.config); @@ -44,6 +45,8 @@ reduceMotion: false, theme: "dark", increaseWindowButtonsContrast: false, + exportAttachmentFolder: "", + useCustomAttachmentDownload: false, }; async function setLanguage( @@ -82,6 +85,8 @@ reduceMotion: s.reduceMotion ?? defaults.reduceMotion ?? false, theme: s.theme || defaults.theme || "light", increaseWindowButtonsContrast: s.increaseWindowButtonsContrast ?? defaults.increaseWindowButtonsContrast ?? false, + exportAttachmentFolder: s.exportAttachmentFolder || defaults.exportAttachmentFolder || "", + useCustomAttachmentDownload: s.useCustomAttachmentDownload ?? defaults.useCustomAttachmentDownload ?? false, }; } @@ -94,6 +99,8 @@ !!a.useDarkEmailViewer === !!b.useDarkEmailViewer && !!a.enableUpdateChecker === !!b.enableUpdateChecker && !!a.reduceMotion === !!b.reduceMotion && + !!a.exportAttachmentFolder === !!b.exportAttachmentFolder && + !!a.useCustomAttachmentDownload === !!b.useCustomAttachmentDownload && (a.theme ?? "light") === (b.theme ?? "light") && !!a.increaseWindowButtonsContrast === !!b.increaseWindowButtonsContrast && JSON.stringify(a.previewFileSupportedTypes?.sort()) === @@ -142,6 +149,7 @@ sessionStorage.removeItem("debugWindowInSettings"); dangerZoneEnabled.set(false); LogDebug("Reset danger zone setting to false."); + await SetExportAttachmentFolder(""); } catch { toast.error(m.settings_toast_reset_failed()); return; @@ -195,10 +203,10 @@ }); // Sync update checker setting to backend config.ini - let previousUpdateCheckerEnabled = form.enableUpdateChecker; $effect(() => { (async () => { if (!browser) return; + let previousUpdateCheckerEnabled = form.enableUpdateChecker; if (form.enableUpdateChecker !== previousUpdateCheckerEnabled) { try { await SetUpdateCheckerEnabled(form.enableUpdateChecker ?? true); @@ -221,6 +229,52 @@ previousTheme = form.theme; }); + // Load export attachment folder from config.ini on startup + $effect(() => { + if (!browser) return; + (async () => { + try { + const configFolder = await GetExportAttachmentFolder(); + if (configFolder && configFolder.trim() !== "") { + form.exportAttachmentFolder = configFolder; + // Also update lastSaved to avoid triggering unsaved changes + lastSaved = { ...lastSaved, exportAttachmentFolder: configFolder }; + } + } catch (err) { + console.error("Failed to load export folder from config:", err); + } + })(); + }); + + async function openFolderDialog(): Promise { + try { + const result = await ShowOpenFolderDialog(); + if (result) { + return result; + } + } catch (err) { + console.error("Failed to open folder dialog:", err); + toast.error("Failed to open folder dialog."); + } + return null; + } + + async function selectExportFolder() { + const folder = await openFolderDialog(); + if (folder) { + // Save to form state + form.exportAttachmentFolder = folder; + // Save to config.ini + try { + await SetExportAttachmentFolder(folder); + toast.success("Export folder updated!"); + } catch (err) { + console.error("Failed to save export folder:", err); + toast.error("Failed to save export folder to config."); + } + } + } + async function exportSettings() { try { const settingsJSON = JSON.stringify(form, null, 2); @@ -344,7 +398,7 @@ }); -
+
@@ -692,6 +746,61 @@ {m.settings_preview_pdf_builtin_info()}

+ + + +
+
+
+
{m.settings_custom_download_label()}
+
+ {m.settings_custom_download_hint()} +
+
+ +
+

+ {m.settings_custom_download_info()} +

+
+ + {#if form.useCustomAttachmentDownload} + + +
+
+
+
+ {m.settings_export_folder_label()} +
+
+ {m.settings_export_folder_hint()} +
+
+
+ + +
+
+
+ {/if} diff --git a/main.go b/main.go index 0f59780..069f5c4 100644 --- a/main.go +++ b/main.go @@ -4,11 +4,14 @@ import ( "embed" "log" "os" + "path/filepath" + "regexp" "strings" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/windows" "github.com/wailsapp/wails/v2/pkg/runtime" ) @@ -33,6 +36,12 @@ func main() { } defer CloseLogger() + // Load config.ini to get WebView2 paths + configPath := filepath.Join(filepath.Dir(os.Args[0]), "config.ini") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + configPath = "config.ini" // fallback to current directory + } + // Check for custom args args := os.Args uniqueId := "emly-app-lock" @@ -74,6 +83,49 @@ func main() { } // Create application with options + // Configure WebView2 DataPath (user data folder) + userDataPath := filepath.Join(os.Getenv("APPDATA"), "EMLy") // default + downloadPath := filepath.Join(os.Getenv("USERPROFILE"), "Downloads") // default + + // Helper function to expand Windows-style environment variables + expandEnvVars := func(path string) string { + // Match %%VAR%% or %VAR% patterns and replace with actual values + re := regexp.MustCompile(`%%([^%]+)%%|%([^%]+)%`) + return re.ReplaceAllStringFunc(path, func(match string) string { + varName := strings.Trim(match, "%") + return os.Getenv(varName) + }) + } + + // Load paths from config.ini if available + if cfg, err := os.ReadFile(configPath); err == nil { + // Simple INI parsing for these specific values + lines := strings.Split(string(cfg), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "WEBVIEW2_USERDATA_PATH") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + path := strings.TrimSpace(parts[1]) + if path != "" { + userDataPath = expandEnvVars(path) + } + } + } else if strings.HasPrefix(line, "WEBVIEW2_DOWNLOAD_PATH") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + path := strings.TrimSpace(parts[1]) + if path != "" { + downloadPath = expandEnvVars(path) + } + } + } + } + } + + log.Printf("WebView2 UserDataPath: %s", userDataPath) + log.Printf("WebView2 DownloadPath: %s", downloadPath) + err := wails.Run(&options.App{ Title: windowTitle, Width: windowWidth, @@ -94,6 +146,10 @@ func main() { MinWidth: 964, MinHeight: 690, Frameless: frameless, + Windows: &windows.Options{ + WebviewUserDataPath: userDataPath, + WebviewBrowserPath: "", // Empty = use system Edge WebView2 + }, }) if err != nil {