feat: Refactor of Go backend code

This commit is contained in:
Flavio Fois
2026-02-05 22:41:02 +01:00
parent aef5c317df
commit 307966565a
8 changed files with 1462 additions and 887 deletions

View File

@@ -37,11 +37,15 @@ EMLy is built using the **Wails v2** framework, which combines a Go backend with
├─────────────────────────────────────────────────────────┤
│ Wails Bridge (Auto-generated TypeScript bindings) │
├─────────────────────────────────────────────────────────┤
│ Backend (Go)
│ ├── App Logic (app.go)
│ ├── Email Parsing (backend/utils/mail/)
│ ├── Windows APIs (screenshot, debugger detection)
── File Operations
│ Backend (Go - Modular Architecture)
│ ├── app.go - Core struct & lifecycle
│ ├── app_mail.go - Email parsing (EML/MSG/PEC)
│ ├── app_viewer.go - Viewer window management
── app_screenshot.go - Window capture
│ ├── app_bugreport.go - Bug reporting system │
│ ├── app_settings.go - Settings import/export │
│ ├── app_system.go - Windows system utilities │
│ └── backend/utils/ - Shared utilities │
└─────────────────────────────────────────────────────────┘
```
@@ -71,7 +75,13 @@ EMLy is built using the **Wails v2** framework, which combines a Go backend with
```
EMLy/
├── app.go # Main application logic
├── app.go # Core App struct, lifecycle, and configuration
├── app_mail.go # Email reading methods (EML, MSG, PEC)
├── app_viewer.go # Viewer window management (image, PDF, EML)
├── app_screenshot.go # Screenshot capture functionality
├── app_bugreport.go # Bug report creation and submission
├── app_settings.go # Settings import/export
├── app_system.go # Windows system utilities (registry, encoding)
├── main.go # Application entry point
├── logger.go # Logging utilities
├── wails.json # Wails configuration
@@ -117,6 +127,12 @@ EMLy/
│ │ │ ├── wailsjs/ # Auto-generated Go bindings
│ │ │ ├── types/ # TypeScript types
│ │ │ └── utils/ # Utility functions
│ │ │ └── mail/ # Email utilities (modular)
│ │ │ ├── index.ts # Barrel export
│ │ │ ├── constants.ts # IFRAME_UTIL_HTML, CONTENT_TYPES, etc.
│ │ │ ├── data-utils.ts # arrayBufferToBase64, createDataUrl
│ │ │ ├── attachment-handlers.ts # openPDFAttachment, openImageAttachment
│ │ │ └── email-loader.ts # loadEmailFromPath, processEmailBody
│ │ └── messages/ # i18n translation files
│ │ ├── en.json
│ │ └── it.json
@@ -153,41 +169,104 @@ if strings.Contains(arg, "--view-image") {
### Application Core (`app.go`)
The `App` struct is the main application controller, exposed to the frontend via Wails bindings.
The `App` struct is the main application controller, exposed to the frontend via Wails bindings. The code is organized into multiple files for maintainability.
#### Key Properties
```go
type App struct {
ctx context.Context
ctx context.Context // Wails application context
StartupFilePath string // File opened via command line
CurrentMailFilePath string // Currently loaded mail file
openImagesMux sync.Mutex // Mutex for image viewer tracking
openImages map[string]bool // Track open image viewers
openPDFsMux sync.Mutex // Mutex for PDF viewer tracking
openPDFs map[string]bool // Track open PDF viewers
openEMLsMux sync.Mutex // Mutex for EML viewer tracking
openEMLs map[string]bool // Track open EML viewers
}
```
#### Core Methods
#### Backend File Organization
The Go backend is split into logical files:
| File | Purpose |
|------|---------|
| `app.go` | Core App struct, constructor, lifecycle methods (startup/shutdown), configuration |
| `app_mail.go` | Email reading: `ReadEML`, `ReadMSG`, `ReadPEC`, `ReadMSGOSS`, `ShowOpenFileDialog` |
| `app_viewer.go` | Viewer windows: `OpenImageWindow`, `OpenPDFWindow`, `OpenEMLWindow`, `OpenPDF`, `OpenImage`, `GetViewerData` |
| `app_screenshot.go` | Screenshots: `TakeScreenshot`, `SaveScreenshot`, `SaveScreenshotAs` |
| `app_bugreport.go` | Bug reports: `CreateBugReportFolder`, `SubmitBugReport`, `zipFolder` |
| `app_settings.go` | Settings I/O: `ExportSettings`, `ImportSettings` |
| `app_system.go` | System utilities: `CheckIsDefaultEMLHandler`, `OpenDefaultAppsSettings`, `ConvertToUTF8`, `OpenFolderInExplorer` |
#### Core Methods by Category
**Lifecycle & Configuration (`app.go`)**
| Method | Description |
|--------|-------------|
| `startup(ctx)` | Wails startup callback, saves context |
| `shutdown(ctx)` | Wails shutdown callback for cleanup |
| `QuitApp()` | Terminates the application |
| `GetConfig()` | Returns application configuration from `config.ini` |
| `SaveConfig(cfg)` | Saves configuration to `config.ini` |
| `GetStartupFile()` | Returns file path passed via command line |
| `SetCurrentMailFilePath()` | Updates the current mail file path |
| `ReadEML(path)` | Parses an EML file and returns email data |
| `ReadMSG(path)` | Parses an MSG file and returns email data |
| `GetMachineData()` | Returns system information |
| `IsDebuggerRunning()` | Checks if a debugger is attached |
**Email Reading (`app_mail.go`)**
| Method | Description |
|--------|-------------|
| `ReadEML(path)` | Parses a standard .eml file |
| `ReadMSG(path, useExternal)` | Parses a Microsoft .msg file |
| `ReadPEC(path)` | Parses PEC (Italian certified email) files |
| `ShowOpenFileDialog()` | Opens native file picker for EML/MSG files |
| `OpenImageWindow(data, filename)` | Opens image in new viewer window |
| `OpenPDFWindow(data, filename)` | Opens PDF in new viewer window |
| `OpenEMLWindow(data, filename)` | Opens EML attachment in new window |
**Viewer Windows (`app_viewer.go`)**
| Method | Description |
|--------|-------------|
| `OpenImageWindow(data, filename)` | Opens image in built-in viewer |
| `OpenPDFWindow(data, filename)` | Opens PDF in built-in viewer |
| `OpenEMLWindow(data, filename)` | Opens EML attachment in new EMLy window |
| `OpenImage(data, filename)` | Opens image with system default app |
| `OpenPDF(data, filename)` | Opens PDF with system default app |
| `GetViewerData()` | Returns viewer data for viewer mode detection |
**Screenshots (`app_screenshot.go`)**
| Method | Description |
|--------|-------------|
| `TakeScreenshot()` | Captures window screenshot as base64 PNG |
| `SubmitBugReport(input)` | Creates bug report with screenshot and system info |
| `SaveScreenshot()` | Saves screenshot to temp directory |
| `SaveScreenshotAs()` | Opens save dialog for screenshot |
**Bug Reports (`app_bugreport.go`)**
| Method | Description |
|--------|-------------|
| `CreateBugReportFolder()` | Creates folder with screenshot and mail file |
| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive |
**Settings (`app_settings.go`)**
| Method | Description |
|--------|-------------|
| `ExportSettings(json)` | Exports settings to JSON file |
| `ImportSettings()` | Imports settings from JSON file |
| `IsDebuggerRunning()` | Checks if a debugger is attached |
| `QuitApp()` | Terminates the application |
**System Utilities (`app_system.go`)**
| Method | Description |
|--------|-------------|
| `CheckIsDefaultEMLHandler()` | Checks if EMLy is default for .eml files |
| `OpenDefaultAppsSettings()` | Opens Windows default apps settings |
| `ConvertToUTF8(string)` | Converts string to valid UTF-8 |
| `OpenFolderInExplorer(path)` | Opens folder in Windows Explorer |
### Email Parsing (`backend/utils/mail/`)
@@ -315,6 +394,62 @@ if (att.contentType.startsWith("image/")) {
}
```
### Frontend Mail Utilities (`lib/utils/mail/`)
The frontend email handling code is organized into modular utility files:
| File | Purpose |
|------|---------|
| `index.ts` | Barrel export for all mail utilities |
| `constants.ts` | Constants: `IFRAME_UTIL_HTML`, `CONTENT_TYPES`, `PEC_FILES`, `EMAIL_EXTENSIONS` |
| `data-utils.ts` | Data conversion: `arrayBufferToBase64`, `createDataUrl`, `looksLikeBase64`, `tryDecodeBase64` |
| `attachment-handlers.ts` | Attachment opening: `openPDFAttachment`, `openImageAttachment`, `openEMLAttachment` |
| `email-loader.ts` | Email loading: `loadEmailFromPath`, `openAndLoadEmail`, `processEmailBody`, `isEmailFile` |
#### Key Functions
**Data Utilities** (`data-utils.ts`)
```typescript
// Convert ArrayBuffer to base64 string
function arrayBufferToBase64(buffer: unknown): string;
// Create data URL for file downloads
function createDataUrl(contentType: string, base64Data: string): string;
// Check if string looks like base64 encoded content
function looksLikeBase64(content: string): boolean;
// Attempt to decode base64, returns null on failure
function tryDecodeBase64(content: string): string | null;
```
**Attachment Handlers** (`attachment-handlers.ts`)
```typescript
// Open PDF using built-in or external viewer based on settings
async function openPDFAttachment(base64Data: string, filename: string): Promise<AttachmentHandlerResult>;
// Open image using built-in or external viewer based on settings
async function openImageAttachment(base64Data: string, filename: string): Promise<AttachmentHandlerResult>;
// Open EML attachment in new EMLy window
async function openEMLAttachment(base64Data: string, filename: string): Promise<AttachmentHandlerResult>;
```
**Email Loader** (`email-loader.ts`)
```typescript
// Load email from file path, handles EML/MSG/PEC detection
async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult>;
// Open file dialog and load selected email
async function openAndLoadEmail(): Promise<LoadEmailResult>;
// Process email body (decode base64, fix encoding)
async function processEmailBody(body: string): Promise<string>;
// Check if file path is a valid email file
function isEmailFile(filePath: string): boolean;
```
### Settings Page (`(app)/settings/+page.svelte`)
Organized into cards with various configuration options:

972
app.go

File diff suppressed because it is too large Load Diff

281
app_bugreport.go Normal file
View File

@@ -0,0 +1,281 @@
// Package main provides bug reporting functionality for EMLy.
// This file contains methods for creating bug reports with screenshots,
// email files, and system information.
package main
import (
"archive/zip"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"time"
"emly/backend/utils"
)
// =============================================================================
// Bug Report Types
// =============================================================================
// BugReportResult contains paths to the generated bug report files.
type BugReportResult struct {
// FolderPath is the path to the bug report folder in temp
FolderPath string `json:"folderPath"`
// ScreenshotPath is the path to the captured screenshot file
ScreenshotPath string `json:"screenshotPath"`
// MailFilePath is the path to the copied mail file (empty if no mail loaded)
MailFilePath string `json:"mailFilePath"`
}
// BugReportInput contains the user-provided bug report details.
type BugReportInput struct {
// Name is the user's name
Name string `json:"name"`
// Email is the user's email address for follow-up
Email string `json:"email"`
// Description is the detailed bug description
Description string `json:"description"`
// ScreenshotData is the base64-encoded PNG screenshot (captured before dialog opens)
ScreenshotData string `json:"screenshotData"`
}
// SubmitBugReportResult contains the result of submitting a bug report.
type SubmitBugReportResult struct {
// ZipPath is the path to the created zip file
ZipPath string `json:"zipPath"`
// FolderPath is the path to the bug report folder
FolderPath string `json:"folderPath"`
}
// =============================================================================
// Bug Report Methods
// =============================================================================
// CreateBugReportFolder creates a folder in temp with screenshot and optionally
// the current mail file. This is used for the legacy bug report flow.
//
// Returns:
// - *BugReportResult: Paths to created files
// - error: Error if folder creation or file operations fail
func (a *App) CreateBugReportFolder() (*BugReportResult, error) {
// Create unique folder name with timestamp
timestamp := time.Now().Format("20060102_150405")
folderName := fmt.Sprintf("emly_bugreport_%s", timestamp)
// Create folder in temp directory
tempDir := os.TempDir()
bugReportFolder := filepath.Join(tempDir, folderName)
if err := os.MkdirAll(bugReportFolder, 0755); err != nil {
return nil, fmt.Errorf("failed to create bug report folder: %w", err)
}
result := &BugReportResult{
FolderPath: bugReportFolder,
}
// Take and save screenshot
screenshotResult, err := a.TakeScreenshot()
if err != nil {
return nil, fmt.Errorf("failed to take screenshot: %w", err)
}
screenshotData, err := base64.StdEncoding.DecodeString(screenshotResult.Data)
if err != nil {
return nil, fmt.Errorf("failed to decode screenshot: %w", err)
}
screenshotPath := filepath.Join(bugReportFolder, screenshotResult.Filename)
if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil {
return nil, fmt.Errorf("failed to save screenshot: %w", err)
}
result.ScreenshotPath = screenshotPath
// Copy currently loaded mail file if one exists
if a.CurrentMailFilePath != "" {
mailData, err := os.ReadFile(a.CurrentMailFilePath)
if err != nil {
// Log but don't fail - screenshot is still valid
Log("Failed to read mail file for bug report:", err)
} else {
mailFilename := filepath.Base(a.CurrentMailFilePath)
mailFilePath := filepath.Join(bugReportFolder, mailFilename)
if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil {
Log("Failed to copy mail file for bug report:", err)
} else {
result.MailFilePath = mailFilePath
}
}
}
return result, nil
}
// SubmitBugReport creates a complete bug report with user input, saves all files,
// and creates a zip archive ready for submission.
//
// The bug report includes:
// - User-provided description (report.txt)
// - Screenshot (captured before dialog opens)
// - Currently loaded mail file (if any)
// - System information (hostname, OS version, hardware ID)
//
// Parameters:
// - input: User-provided bug report details including pre-captured screenshot
//
// Returns:
// - *SubmitBugReportResult: Paths to the zip file and folder
// - error: Error if any file operation fails
func (a *App) SubmitBugReport(input BugReportInput) (*SubmitBugReportResult, error) {
// Create unique folder name with timestamp
timestamp := time.Now().Format("20060102_150405")
folderName := fmt.Sprintf("emly_bugreport_%s", timestamp)
// Create folder in temp directory
tempDir := os.TempDir()
bugReportFolder := filepath.Join(tempDir, folderName)
if err := os.MkdirAll(bugReportFolder, 0755); err != nil {
return nil, fmt.Errorf("failed to create bug report folder: %w", err)
}
// Save the pre-captured screenshot (captured before dialog opened)
if input.ScreenshotData != "" {
screenshotData, err := base64.StdEncoding.DecodeString(input.ScreenshotData)
if err != nil {
Log("Failed to decode screenshot:", err)
} else {
screenshotPath := filepath.Join(bugReportFolder, fmt.Sprintf("emly_screenshot_%s.png", timestamp))
if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil {
Log("Failed to save screenshot:", err)
}
}
}
// Copy the mail file if one is loaded
if a.CurrentMailFilePath != "" {
mailData, err := os.ReadFile(a.CurrentMailFilePath)
if err != nil {
Log("Failed to read mail file for bug report:", err)
} else {
mailFilename := filepath.Base(a.CurrentMailFilePath)
mailFilePath := filepath.Join(bugReportFolder, mailFilename)
if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil {
Log("Failed to copy mail file for bug report:", err)
}
}
}
// Create the report.txt file with user's description
reportContent := fmt.Sprintf(`EMLy Bug Report
================
Name: %s
Email: %s
Description:
%s
Generated: %s
`, input.Name, input.Email, input.Description, time.Now().Format("2006-01-02 15:04:05"))
reportPath := filepath.Join(bugReportFolder, "report.txt")
if err := os.WriteFile(reportPath, []byte(reportContent), 0644); err != nil {
return nil, fmt.Errorf("failed to save report file: %w", err)
}
// Get and save machine/system information
machineInfo, err := utils.GetMachineInfo()
if err == nil && machineInfo != nil {
sysInfoContent := fmt.Sprintf(`System Information
==================
Hostname: %s
OS: %s
Version: %s
Hardware ID: %s
External IP: %s
`, machineInfo.Hostname, machineInfo.OS, machineInfo.Version, machineInfo.HWID, machineInfo.ExternalIP)
sysInfoPath := filepath.Join(bugReportFolder, "system_info.txt")
if err := os.WriteFile(sysInfoPath, []byte(sysInfoContent), 0644); err != nil {
Log("Failed to save system info:", err)
}
}
// Create zip archive of the folder
zipPath := bugReportFolder + ".zip"
if err := zipFolder(bugReportFolder, zipPath); err != nil {
return nil, fmt.Errorf("failed to create zip file: %w", err)
}
return &SubmitBugReportResult{
ZipPath: zipPath,
FolderPath: bugReportFolder,
}, nil
}
// =============================================================================
// Helper Functions
// =============================================================================
// zipFolder creates a zip archive containing all files from the source folder.
// Directories are traversed recursively but stored implicitly (no directory entries).
//
// Parameters:
// - sourceFolder: Path to the folder to zip
// - destZip: Path where the zip file should be created
//
// Returns:
// - error: Error if any file operation fails
func zipFolder(sourceFolder, destZip string) error {
// Create the zip file
zipFile, err := os.Create(destZip)
if err != nil {
return err
}
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
// Walk through the folder and add all files
return filepath.Walk(sourceFolder, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip the root folder itself
if path == sourceFolder {
return nil
}
// Get relative path for the zip entry
relPath, err := filepath.Rel(sourceFolder, path)
if err != nil {
return err
}
// Skip directories (they're created implicitly)
if info.IsDir() {
return nil
}
// Create the file entry in the zip
writer, err := zipWriter.Create(relPath)
if err != nil {
return err
}
// Read and write the file content
fileContent, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = writer.Write(fileContent)
return err
})
}

88
app_mail.go Normal file
View File

@@ -0,0 +1,88 @@
// Package main provides email reading functionality for EMLy.
// This file contains methods for reading EML, MSG, and PEC email files.
package main
import (
"emly/backend/utils/mail"
)
// =============================================================================
// Email Reading Methods
// =============================================================================
// ReadEML reads a standard .eml file and returns the parsed email data.
// EML files are MIME-formatted email messages commonly exported from email clients.
//
// Parameters:
// - filePath: Absolute path to the .eml file
//
// Returns:
// - *internal.EmailData: Parsed email with headers, body, and attachments
// - error: Any parsing errors
func (a *App) ReadEML(filePath string) (*internal.EmailData, error) {
return internal.ReadEmlFile(filePath)
}
// ReadPEC reads a PEC (Posta Elettronica Certificata) .eml file.
// PEC emails are Italian certified emails that contain an inner email message
// wrapped in a certification envelope with digital signatures.
//
// This method extracts and returns the inner original email, ignoring the
// certification wrapper (daticert.xml and signature files are available as attachments).
//
// Parameters:
// - filePath: Absolute path to the PEC .eml file
//
// Returns:
// - *internal.EmailData: The inner original email content
// - error: Any parsing errors
func (a *App) ReadPEC(filePath string) (*internal.EmailData, error) {
return internal.ReadPecInnerEml(filePath)
}
// ReadMSG reads a Microsoft Outlook .msg file and returns the email data.
// MSG files use the CFB (Compound File Binary) format, which is a proprietary
// format used by Microsoft Office applications.
//
// This method uses an external converter to properly parse the MSG format
// and extract headers, body, and attachments.
//
// Parameters:
// - filePath: Absolute path to the .msg file
// - useExternalConverter: Whether to use external conversion (currently always true)
//
// Returns:
// - *internal.EmailData: Parsed email data
// - error: Any parsing or conversion errors
func (a *App) ReadMSG(filePath string, useExternalConverter bool) (*internal.EmailData, error) {
// The useExternalConverter parameter is kept for API compatibility
// but the implementation always uses the internal MSG reader
return internal.ReadMsgFile(filePath)
}
// ReadMSGOSS reads a .msg file using the open-source parser.
// This is an alternative entry point that explicitly uses the OSS implementation.
//
// Parameters:
// - filePath: Absolute path to the .msg file
//
// Returns:
// - *internal.EmailData: Parsed email data
// - error: Any parsing errors
func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) {
return internal.ReadMsgFile(filePath)
}
// ShowOpenFileDialog displays the system file picker dialog filtered for email files.
// This allows users to browse and select .eml or .msg files to open.
//
// The dialog is configured with filters for:
// - EML files (*.eml)
// - MSG files (*.msg)
//
// Returns:
// - string: The selected file path, or empty string if cancelled
// - error: Any dialog errors
func (a *App) ShowOpenFileDialog() (string, error) {
return internal.ShowFileDialog(a.ctx)
}

164
app_screenshot.go Normal file
View File

@@ -0,0 +1,164 @@
// Package main provides screenshot functionality for EMLy.
// This file contains methods for capturing, saving, and exporting screenshots
// of the application window.
package main
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"emly/backend/utils"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// =============================================================================
// Screenshot Types
// =============================================================================
// ScreenshotResult contains the captured screenshot data and metadata.
type ScreenshotResult struct {
// Data is the base64-encoded PNG image data
Data string `json:"data"`
// Width is the image width in pixels
Width int `json:"width"`
// Height is the image height in pixels
Height int `json:"height"`
// Filename is the suggested filename for saving
Filename string `json:"filename"`
}
// =============================================================================
// Screenshot Methods
// =============================================================================
// TakeScreenshot captures the current EMLy application window and returns it as base64 PNG.
// This uses Windows GDI API to capture the window contents, handling DWM composition
// for proper rendering of modern Windows applications.
//
// The method automatically detects whether the app is in main mode or viewer mode
// and captures the appropriate window.
//
// Returns:
// - *ScreenshotResult: Contains base64 PNG data, dimensions, and suggested filename
// - error: Error if window capture or encoding fails
func (a *App) TakeScreenshot() (*ScreenshotResult, error) {
// Determine window title based on current mode
windowTitle := "EMLy - EML Viewer for 3gIT"
// Check if running in viewer mode
for _, arg := range os.Args {
if strings.Contains(arg, "--view-image") {
windowTitle = "EMLy Image Viewer"
break
}
if strings.Contains(arg, "--view-pdf") {
windowTitle = "EMLy PDF Viewer"
break
}
}
// Capture the window using Windows GDI API
img, err := utils.CaptureWindowByTitle(windowTitle)
if err != nil {
return nil, fmt.Errorf("failed to capture window: %w", err)
}
// Encode to PNG and convert to base64
base64Data, err := utils.ScreenshotToBase64PNG(img)
if err != nil {
return nil, fmt.Errorf("failed to encode screenshot: %w", err)
}
// Build result with metadata
bounds := img.Bounds()
timestamp := time.Now().Format("20060102_150405")
return &ScreenshotResult{
Data: base64Data,
Width: bounds.Dx(),
Height: bounds.Dy(),
Filename: fmt.Sprintf("emly_screenshot_%s.png", timestamp),
}, nil
}
// SaveScreenshot captures and saves the screenshot to the system temp directory.
// This is a convenience method that captures and saves in one step.
//
// Returns:
// - string: The full path to the saved screenshot file
// - error: Error if capture or save fails
func (a *App) SaveScreenshot() (string, error) {
// Capture the screenshot
result, err := a.TakeScreenshot()
if err != nil {
return "", err
}
// Decode base64 data
data, err := base64.StdEncoding.DecodeString(result.Data)
if err != nil {
return "", fmt.Errorf("failed to decode screenshot data: %w", err)
}
// Save to temp directory
tempDir := os.TempDir()
filePath := filepath.Join(tempDir, result.Filename)
if err := os.WriteFile(filePath, data, 0644); err != nil {
return "", fmt.Errorf("failed to save screenshot: %w", err)
}
return filePath, nil
}
// SaveScreenshotAs captures a screenshot and opens a save dialog for the user
// to choose where to save it.
//
// Returns:
// - string: The selected save path, or empty string if cancelled
// - error: Error if capture, dialog, or save fails
func (a *App) SaveScreenshotAs() (string, error) {
// Capture the screenshot first
result, err := a.TakeScreenshot()
if err != nil {
return "", err
}
// Open save dialog with PNG filter
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: result.Filename,
Title: "Save Screenshot",
Filters: []runtime.FileFilter{
{
DisplayName: "PNG Images (*.png)",
Pattern: "*.png",
},
},
})
if err != nil {
return "", fmt.Errorf("failed to open save dialog: %w", err)
}
// User cancelled
if savePath == "" {
return "", nil
}
// Decode base64 data
data, err := base64.StdEncoding.DecodeString(result.Data)
if err != nil {
return "", fmt.Errorf("failed to decode screenshot data: %w", err)
}
// Save to selected location
if err := os.WriteFile(savePath, data, 0644); err != nil {
return "", fmt.Errorf("failed to save screenshot: %w", err)
}
return savePath, nil
}

100
app_settings.go Normal file
View File

@@ -0,0 +1,100 @@
// Package main provides settings import/export functionality for EMLy.
// This file contains methods for exporting and importing application settings
// as JSON files.
package main
import (
"fmt"
"os"
"strings"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// =============================================================================
// Settings Export/Import Methods
// =============================================================================
// ExportSettings opens a save dialog and exports the provided settings JSON
// to the selected file location.
//
// The dialog is pre-configured with:
// - Default filename: emly_settings.json
// - Filter for JSON files
//
// Parameters:
// - settingsJSON: The JSON string containing all application settings
//
// Returns:
// - string: The path where settings were saved, or empty if cancelled
// - error: Error if dialog or file operations fail
func (a *App) ExportSettings(settingsJSON string) (string, error) {
// Open save dialog with JSON filter
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: "emly_settings.json",
Title: "Export Settings",
Filters: []runtime.FileFilter{
{
DisplayName: "JSON Files (*.json)",
Pattern: "*.json",
},
},
})
if err != nil {
return "", fmt.Errorf("failed to open save dialog: %w", err)
}
// User cancelled
if savePath == "" {
return "", nil
}
// Ensure .json extension
if !strings.HasSuffix(strings.ToLower(savePath), ".json") {
savePath += ".json"
}
// Write the settings file
if err := os.WriteFile(savePath, []byte(settingsJSON), 0644); err != nil {
return "", fmt.Errorf("failed to write settings file: %w", err)
}
return savePath, nil
}
// ImportSettings opens a file dialog for the user to select a settings JSON file
// and returns its contents.
//
// The dialog is configured to only show JSON files.
//
// Returns:
// - string: The JSON content of the selected file, or empty if cancelled
// - error: Error if dialog or file operations fail
func (a *App) ImportSettings() (string, error) {
// Open file dialog with JSON filter
openPath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Import Settings",
Filters: []runtime.FileFilter{
{
DisplayName: "JSON Files (*.json)",
Pattern: "*.json",
},
},
})
if err != nil {
return "", fmt.Errorf("failed to open file dialog: %w", err)
}
// User cancelled
if openPath == "" {
return "", nil
}
// Read the settings file
data, err := os.ReadFile(openPath)
if err != nil {
return "", fmt.Errorf("failed to read settings file: %w", err)
}
return string(data), nil
}

146
app_system.go Normal file
View File

@@ -0,0 +1,146 @@
// Package main provides system-level utilities for EMLy.
// This file contains methods for Windows registry access, character encoding
// conversion, and file system operations.
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"unicode/utf8"
"golang.org/x/sys/windows/registry"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
// =============================================================================
// Windows Default App Handler
// =============================================================================
// CheckIsDefaultEMLHandler checks if EMLy is registered as the default handler
// for .eml files in Windows.
//
// This works by:
// 1. Getting the current executable path
// 2. Reading the UserChoice registry key for .eml files
// 3. Finding the command associated with the chosen ProgId
// 4. Comparing the command with our executable
//
// Returns:
// - bool: True if EMLy is the default handler
// - error: Error if registry access fails
func (a *App) CheckIsDefaultEMLHandler() (bool, error) {
// Get current executable path for comparison
exePath, err := os.Executable()
if err != nil {
return false, err
}
exePath = strings.ToLower(exePath)
// Open the UserChoice key for .eml extension
// This is where Windows stores the user's chosen default app
k, err := registry.OpenKey(
registry.CURRENT_USER,
`Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.eml\UserChoice`,
registry.QUERY_VALUE,
)
if err != nil {
// Key doesn't exist - user hasn't made a specific choice
// or system default is active (which is usually not us)
return false, nil
}
defer k.Close()
// Get the ProgId (program identifier) for the chosen app
progId, _, err := k.GetStringValue("ProgId")
if err != nil {
return false, err
}
// Find the command associated with this ProgId
classKeyPath := fmt.Sprintf(`%s\shell\open\command`, progId)
classKey, err := registry.OpenKey(registry.CLASSES_ROOT, classKeyPath, registry.QUERY_VALUE)
if err != nil {
return false, fmt.Errorf("unable to find command for ProgId %s", progId)
}
defer classKey.Close()
// Get the command string
cmd, _, err := classKey.GetStringValue("")
if err != nil {
return false, err
}
// Compare command with our executable
// Check if the command contains our executable name
cmdLower := strings.ToLower(cmd)
if strings.Contains(cmdLower, strings.ToLower(filepath.Base(exePath))) {
return true, nil
}
return false, nil
}
// OpenDefaultAppsSettings opens the Windows Settings app to the Default Apps page.
// This allows users to easily set EMLy as the default handler for email files.
//
// Returns:
// - error: Error if launching settings fails
func (a *App) OpenDefaultAppsSettings() error {
cmd := exec.Command("cmd", "/c", "start", "ms-settings:defaultapps")
return cmd.Start()
}
// =============================================================================
// Character Encoding
// =============================================================================
// ConvertToUTF8 attempts to convert a string to valid UTF-8.
// If the string is already valid UTF-8, it's returned as-is.
// Otherwise, it assumes Windows-1252 encoding (common for legacy emails)
// and attempts to decode it.
//
// This is particularly useful for email body content that may have been
// encoded with legacy Western European character sets.
//
// Parameters:
// - s: The string to convert
//
// Returns:
// - string: UTF-8 encoded string
func (a *App) ConvertToUTF8(s string) string {
// If already valid UTF-8, return as-is
if utf8.ValidString(s) {
return s
}
// Assume Windows-1252 (superset of ISO-8859-1)
// This is the most common encoding for legacy Western European text
decoder := charmap.Windows1252.NewDecoder()
decoded, _, err := transform.String(decoder, s)
if err != nil {
// Return original if decoding fails
return s
}
return decoded
}
// =============================================================================
// File System Operations
// =============================================================================
// OpenFolderInExplorer opens the specified folder in Windows Explorer.
// This is used to show the user where bug report files are saved.
//
// Parameters:
// - folderPath: The path to the folder to open
//
// Returns:
// - error: Error if launching explorer fails
func (a *App) OpenFolderInExplorer(folderPath string) error {
cmd := exec.Command("explorer", folderPath)
return cmd.Start()
}

429
app_viewer.go Normal file
View File

@@ -0,0 +1,429 @@
// Package main provides viewer window functionality for EMLy.
// This file contains methods for opening attachments in viewer windows
// or with external applications.
package main
import (
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// =============================================================================
// Viewer Data Types
// =============================================================================
// ImageViewerData contains the data needed to display an image in the viewer window.
type ImageViewerData struct {
// Data is the base64-encoded image data
Data string `json:"data"`
// Filename is the original filename of the image
Filename string `json:"filename"`
}
// PDFViewerData contains the data needed to display a PDF in the viewer window.
type PDFViewerData struct {
// Data is the base64-encoded PDF data
Data string `json:"data"`
// Filename is the original filename of the PDF
Filename string `json:"filename"`
}
// ViewerData is a union type that contains either image or PDF viewer data.
// Used by the viewer page to determine which type of content to display.
type ViewerData struct {
// ImageData is set when viewing an image (mutually exclusive with PDFData)
ImageData *ImageViewerData `json:"imageData,omitempty"`
// PDFData is set when viewing a PDF (mutually exclusive with ImageData)
PDFData *PDFViewerData `json:"pdfData,omitempty"`
}
// =============================================================================
// Built-in Viewer Window Methods
// =============================================================================
// OpenEMLWindow opens an EML attachment in a new EMLy window.
// The EML data is saved to a temp file and a new EMLy instance is launched.
//
// This method tracks open EML files to prevent duplicate windows for the same file.
// The tracking is released when the viewer window is closed.
//
// Parameters:
// - base64Data: Base64-encoded EML file content
// - filename: The original filename of the EML attachment
//
// Returns:
// - error: Error if the file is already open or if launching fails
func (a *App) OpenEMLWindow(base64Data string, filename string) error {
// Check if this EML is already open
a.openEMLsMux.Lock()
if a.openEMLs[filename] {
a.openEMLsMux.Unlock()
return fmt.Errorf("eml '%s' is already open", filename)
}
a.openEMLs[filename] = true
a.openEMLsMux.Unlock()
// Decode base64 data
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
a.openEMLsMux.Lock()
delete(a.openEMLs, filename)
a.openEMLsMux.Unlock()
return fmt.Errorf("failed to decode base64: %w", err)
}
// Save to temp file with timestamp to avoid conflicts
tempDir := os.TempDir()
timestamp := time.Now().Format("20060102_150405")
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s_%s", "emly_attachment", timestamp, filename))
if err := os.WriteFile(tempFile, data, 0644); err != nil {
a.openEMLsMux.Lock()
delete(a.openEMLs, filename)
a.openEMLsMux.Unlock()
return fmt.Errorf("failed to write temp file: %w", err)
}
// Launch new EMLy instance with the file path
exe, err := os.Executable()
if err != nil {
a.openEMLsMux.Lock()
delete(a.openEMLs, filename)
a.openEMLsMux.Unlock()
return fmt.Errorf("failed to get executable path: %w", err)
}
cmd := exec.Command(exe, tempFile)
if err := cmd.Start(); err != nil {
a.openEMLsMux.Lock()
delete(a.openEMLs, filename)
a.openEMLsMux.Unlock()
return fmt.Errorf("failed to start viewer: %w", err)
}
// Monitor process in background to release lock when closed
go func() {
cmd.Wait()
a.openEMLsMux.Lock()
delete(a.openEMLs, filename)
a.openEMLsMux.Unlock()
}()
return nil
}
// OpenImageWindow opens an image attachment in a new EMLy viewer window.
// The image data is saved to a temp file and a new EMLy instance is launched
// with the --view-image flag.
//
// This method tracks open images to prevent duplicate windows for the same file.
//
// Parameters:
// - base64Data: Base64-encoded image data
// - filename: The original filename of the image
//
// Returns:
// - error: Error if the image is already open or if launching fails
func (a *App) OpenImageWindow(base64Data string, filename string) error {
// Check if this image is already open
a.openImagesMux.Lock()
if a.openImages[filename] {
a.openImagesMux.Unlock()
return fmt.Errorf("image '%s' is already open", filename)
}
a.openImages[filename] = true
a.openImagesMux.Unlock()
// Decode base64 data
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
a.openImagesMux.Lock()
delete(a.openImages, filename)
a.openImagesMux.Unlock()
return fmt.Errorf("failed to decode base64: %w", err)
}
// Save to temp file
tempDir := os.TempDir()
timestamp := time.Now().Format("20060102_150405")
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
if err := os.WriteFile(tempFile, data, 0644); err != nil {
a.openImagesMux.Lock()
delete(a.openImages, filename)
a.openImagesMux.Unlock()
return fmt.Errorf("failed to write temp file: %w", err)
}
// Launch new EMLy instance in image viewer mode
exe, err := os.Executable()
if err != nil {
a.openImagesMux.Lock()
delete(a.openImages, filename)
a.openImagesMux.Unlock()
return fmt.Errorf("failed to get executable path: %w", err)
}
cmd := exec.Command(exe, "--view-image="+tempFile)
if err := cmd.Start(); err != nil {
a.openImagesMux.Lock()
delete(a.openImages, filename)
a.openImagesMux.Unlock()
return fmt.Errorf("failed to start viewer: %w", err)
}
// Monitor process in background to release lock when closed
go func() {
cmd.Wait()
a.openImagesMux.Lock()
delete(a.openImages, filename)
a.openImagesMux.Unlock()
}()
return nil
}
// OpenPDFWindow opens a PDF attachment in a new EMLy viewer window.
// The PDF data is saved to a temp file and a new EMLy instance is launched
// with the --view-pdf flag.
//
// This method tracks open PDFs to prevent duplicate windows for the same file.
//
// Parameters:
// - base64Data: Base64-encoded PDF data
// - filename: The original filename of the PDF
//
// Returns:
// - error: Error if the PDF is already open or if launching fails
func (a *App) OpenPDFWindow(base64Data string, filename string) error {
// Check if this PDF is already open
a.openPDFsMux.Lock()
if a.openPDFs[filename] {
a.openPDFsMux.Unlock()
return fmt.Errorf("pdf '%s' is already open", filename)
}
a.openPDFs[filename] = true
a.openPDFsMux.Unlock()
// Decode base64 data
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
a.openPDFsMux.Lock()
delete(a.openPDFs, filename)
a.openPDFsMux.Unlock()
return fmt.Errorf("failed to decode base64: %w", err)
}
// Save to temp file
tempDir := os.TempDir()
tempFile := filepath.Join(tempDir, filename)
if err := os.WriteFile(tempFile, data, 0644); err != nil {
a.openPDFsMux.Lock()
delete(a.openPDFs, filename)
a.openPDFsMux.Unlock()
return fmt.Errorf("failed to write temp file: %w", err)
}
// Launch new EMLy instance in PDF viewer mode
exe, err := os.Executable()
if err != nil {
a.openPDFsMux.Lock()
delete(a.openPDFs, filename)
a.openPDFsMux.Unlock()
return fmt.Errorf("failed to get executable path: %w", err)
}
cmd := exec.Command(exe, "--view-pdf="+tempFile)
if err := cmd.Start(); err != nil {
a.openPDFsMux.Lock()
delete(a.openPDFs, filename)
a.openPDFsMux.Unlock()
return fmt.Errorf("failed to start viewer: %w", err)
}
// Monitor process in background to release lock when closed
go func() {
cmd.Wait()
a.openPDFsMux.Lock()
delete(a.openPDFs, filename)
a.openPDFsMux.Unlock()
}()
return nil
}
// =============================================================================
// External Application Methods
// =============================================================================
// OpenPDF saves a PDF to temp and opens it with the system's default PDF application.
// This is used when the user prefers external viewers over the built-in viewer.
//
// Parameters:
// - base64Data: Base64-encoded PDF data
// - filename: The original filename of the PDF
//
// Returns:
// - error: Error if saving or launching fails
func (a *App) OpenPDF(base64Data string, filename string) error {
if base64Data == "" {
return fmt.Errorf("no data provided")
}
// Decode base64 data
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return fmt.Errorf("failed to decode base64: %w", err)
}
// Save to temp file with timestamp for uniqueness
tempDir := os.TempDir()
timestamp := time.Now().Format("20060102_150405")
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
if err := os.WriteFile(tempFile, data, 0644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// Open with Windows default application
cmd := exec.Command("cmd", "/c", "start", "", tempFile)
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
return nil
}
// OpenImage saves an image to temp and opens it with the system's default image viewer.
// This is used when the user prefers external viewers over the built-in viewer.
//
// Parameters:
// - base64Data: Base64-encoded image data
// - filename: The original filename of the image
//
// Returns:
// - error: Error if saving or launching fails
func (a *App) OpenImage(base64Data string, filename string) error {
if base64Data == "" {
return fmt.Errorf("no data provided")
}
// Decode base64 data
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return fmt.Errorf("failed to decode base64: %w", err)
}
// Save to temp file with timestamp for uniqueness
tempDir := os.TempDir()
timestamp := time.Now().Format("20060102_150405")
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
if err := os.WriteFile(tempFile, data, 0644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// Open with Windows default application
cmd := exec.Command("cmd", "/c", "start", "", tempFile)
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
return nil
}
// =============================================================================
// Viewer Mode Detection
// =============================================================================
// GetImageViewerData checks CLI arguments and returns image data if running in image viewer mode.
// This is called by the viewer page on startup to get the image to display.
//
// Returns:
// - *ImageViewerData: Image data if in viewer mode, nil otherwise
// - error: Error if reading the image file fails
func (a *App) GetImageViewerData() (*ImageViewerData, error) {
for _, arg := range os.Args {
if strings.HasPrefix(arg, "--view-image=") {
filePath := strings.TrimPrefix(arg, "--view-image=")
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read image file: %w", err)
}
// Return as base64 for consistent frontend handling
encoded := base64.StdEncoding.EncodeToString(data)
return &ImageViewerData{
Data: encoded,
Filename: filepath.Base(filePath),
}, nil
}
}
return nil, nil
}
// GetPDFViewerData checks CLI arguments and returns PDF data if running in PDF viewer mode.
// This is called by the viewer page on startup to get the PDF to display.
//
// Returns:
// - *PDFViewerData: PDF data if in viewer mode, nil otherwise
// - error: Error if reading the PDF file fails
func (a *App) GetPDFViewerData() (*PDFViewerData, error) {
for _, arg := range os.Args {
if strings.HasPrefix(arg, "--view-pdf=") {
filePath := strings.TrimPrefix(arg, "--view-pdf=")
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read PDF file: %w", err)
}
// Return as base64 for consistent frontend handling
encoded := base64.StdEncoding.EncodeToString(data)
return &PDFViewerData{
Data: encoded,
Filename: filepath.Base(filePath),
}, nil
}
}
return nil, nil
}
// GetViewerData checks CLI arguments and returns viewer data for any viewer mode.
// This is a unified method that detects both image and PDF viewer modes.
//
// Returns:
// - *ViewerData: Contains either ImageData or PDFData depending on mode
// - error: Error if reading the file fails
func (a *App) GetViewerData() (*ViewerData, error) {
for _, arg := range os.Args {
// Check for image viewer mode
if strings.HasPrefix(arg, "--view-image=") {
filePath := strings.TrimPrefix(arg, "--view-image=")
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read image file: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(data)
return &ViewerData{
ImageData: &ImageViewerData{
Data: encoded,
Filename: filepath.Base(filePath),
},
}, nil
}
// Check for PDF viewer mode
if strings.HasPrefix(arg, "--view-pdf=") {
filePath := strings.TrimPrefix(arg, "--view-pdf=")
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read PDF file: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(data)
return &ViewerData{
PDFData: &PDFViewerData{
Data: encoded,
Filename: filepath.Base(filePath),
},
}, nil
}
}
return nil, nil
}