Compare commits

..

6 Commits

Author SHA1 Message Date
Flavio Fois
ea43cd715a feat: add CLAUDE.md for project guidance and architecture overview 2026-02-05 22:43:34 +01:00
Flavio Fois
307966565a feat: Refactor of Go backend code 2026-02-05 22:41:02 +01:00
Flavio Fois
aef5c317df feat: refactor MailViewer and add utility functions for email handling and attachment processing 2026-02-05 22:25:35 +01:00
Flavio Fois
c0c1fbb08f feat: add comprehensive application documentation for EMLy 2026-02-05 22:19:45 +01:00
Flavio Fois
f551efd5bf feat: add export and import settings functionality with UI integration 2026-02-05 22:19:39 +01:00
Flavio Fois
6a44eba7ca feat: Implement bug reporting feature with screenshot capture
- Added a new utility for capturing screenshots on Windows.
- Introduced a bug report dialog in the frontend with fields for name, email, and bug description.
- Integrated screenshot capture functionality into the bug report dialog.
- Added localization for bug report messages in English and Italian.
- Updated package dependencies to include html2canvas for potential future use.
- Created a new UI dialog component structure for better organization and reusability.
- Enhanced the app layout to accommodate the new bug report feature.
2026-02-05 21:38:51 +01:00
36 changed files with 3833 additions and 737 deletions

113
CLAUDE.md Normal file
View File

@@ -0,0 +1,113 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
EMLy is a Windows desktop email viewer for `.eml` and `.msg` files built with Wails v2 (Go backend + SvelteKit/Svelte 5 frontend). It supports viewing email content, attachments, and Italian PEC (Posta Elettronica Certificata) certified emails.
## Build Commands
```bash
# Development mode with hot-reload
wails dev
# Production build (outputs to build/bin/EMLy.exe)
wails build
# Build for specific platform
wails build -platform windows/amd64
# Frontend type checking
cd frontend && bun run check
# Go tests
go test ./...
# Install frontend dependencies (also runs automatically on build)
cd frontend && bun install
```
## Architecture
### Wails Framework
- Go backend methods are automatically exposed to frontend via generated TypeScript bindings
- Bindings are generated in `frontend/src/lib/wailsjs/go/main/App.ts`
- All App methods with exported names become callable from TypeScript
- Wails runtime provides window control, dialogs, and event system
### Backend Structure (Go)
The `App` struct is the main controller, split across files by domain:
| File | Responsibility |
|------|----------------|
| `app.go` | Core struct, lifecycle (startup/shutdown), configuration |
| `app_mail.go` | Email reading: ReadEML, ReadMSG, ReadPEC, ShowOpenFileDialog |
| `app_viewer.go` | Viewer windows: OpenImageWindow, OpenPDFWindow, OpenEMLWindow |
| `app_screenshot.go` | Window capture using Windows GDI APIs |
| `app_bugreport.go` | Bug report creation with screenshots and system info |
| `app_settings.go` | Settings import/export to JSON |
| `app_system.go` | Windows registry, encoding conversion, file explorer |
Email parsing lives in `backend/utils/mail/`:
- `eml_reader.go` - Standard EML parsing
- `msg_reader.go` - Microsoft MSG (CFB format) parsing
- `mailparser.go` - MIME multipart handling
### Frontend Structure (SvelteKit + Svelte 5)
**Routes** (file-based routing):
- `(app)/` - Main app with sidebar layout, titlebar, footer
- `(app)/settings/` - Settings page
- `image/` - Standalone image viewer (launched with `--view-image=<path>`)
- `pdf/` - Standalone PDF viewer (launched with `--view-pdf=<path>`)
**Key patterns**:
- Svelte 5 runes: `$state`, `$effect`, `$derived`, `$props` (NOT legacy stores in components)
- State classes in `stores/*.svelte.ts` using `$state` for reactive properties
- Traditional Svelte stores in `stores/app.ts` for simple global state
- shadcn-svelte components in `lib/components/ui/`
**Mail utilities** (`lib/utils/mail/`):
- Modular utilities for email loading, attachment handling, data conversion
- Barrel exported from `index.ts`
### Multi-Window Support
The app spawns separate viewer processes:
- Main app uses single-instance lock with ID `emly-app-lock`
- Image/PDF viewers get unique IDs per file, allowing multiple concurrent viewers
- CLI args: `--view-image=<path>` or `--view-pdf=<path>` trigger viewer mode
### Internationalization
Uses ParaglideJS with compile-time type-safe translations:
- Translation files: `frontend/messages/en.json`, `it.json`
- Import: `import * as m from "$lib/paraglide/messages"`
- Usage: `{m.mail_open_btn_text()}`
## Key Patterns
### Calling Go from Frontend
```typescript
import { ReadEML, ShowOpenFileDialog } from "$lib/wailsjs/go/main/App";
const filePath = await ShowOpenFileDialog();
const email = await ReadEML(filePath);
```
### Event Communication
```typescript
import { EventsOn } from "$lib/wailsjs/runtime/runtime";
EventsOn("launchArgs", (args: string[]) => { /* handle file from second instance */ });
```
### Email Body Rendering
Email bodies render in sandboxed iframes with links disabled for security.
## Important Conventions
- Windows-only: Uses Windows APIs (registry, GDI, user32.dll)
- Bun package manager for frontend (not npm/yarn)
- Frontend assets embedded in binary via `//go:embed all:frontend/build`
- Custom frameless window with manual titlebar implementation

842
DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,842 @@
# EMLy Application Documentation
EMLy is a desktop email viewer application built for 3gIT, designed to open and display `.eml` and `.msg` email files on Windows. It provides a modern, user-friendly interface for viewing email content, attachments, and metadata.
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Technology Stack](#technology-stack)
3. [Project Structure](#project-structure)
4. [Backend (Go)](#backend-go)
5. [Frontend (SvelteKit)](#frontend-sveltekit)
6. [State Management](#state-management)
7. [Internationalization (i18n)](#internationalization-i18n)
8. [UI Components](#ui-components)
9. [Key Features](#key-features)
10. [Build & Development](#build--development)
---
## Architecture Overview
EMLy is built using the **Wails v2** framework, which combines a Go backend with a web-based frontend. This architecture allows:
- **Go Backend**: Handles file operations, Windows API calls, email parsing, and system interactions
- **Web Frontend**: Provides the user interface using SvelteKit with Svelte 5
- **Bridge**: Wails automatically generates TypeScript bindings for Go functions, enabling seamless communication
```
┌─────────────────────────────────────────────────────────┐
│ EMLy Application │
├─────────────────────────────────────────────────────────┤
│ Frontend (SvelteKit + Svelte 5) │
│ ├── Routes & Pages │
│ ├── UI Components (shadcn-svelte) │
│ ├── State Management (Svelte 5 Runes) │
│ └── i18n (ParaglideJS) │
├─────────────────────────────────────────────────────────┤
│ Wails Bridge (Auto-generated TypeScript bindings) │
├─────────────────────────────────────────────────────────┤
│ 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 │
└─────────────────────────────────────────────────────────┘
```
---
## Technology Stack
### Backend
- **Go 1.21+**: Primary backend language
- **Wails v2**: Desktop application framework
- **Windows Registry API**: For checking default file handlers
- **GDI/User32 APIs**: For screenshot functionality
### Frontend
- **SvelteKit**: Application framework
- **Svelte 5**: UI framework with Runes ($state, $derived, $effect, $props)
- **TypeScript**: Type-safe JavaScript
- **shadcn-svelte**: UI component library
- **Tailwind CSS**: Utility-first CSS framework
- **ParaglideJS**: Internationalization
- **Lucide Icons**: Icon library
- **Bun**: JavaScript runtime and package manager
---
## Project Structure
```
EMLy/
├── 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
├── backend/
│ └── utils/
│ ├── mail/
│ │ ├── eml_reader.go # EML file parsing
│ │ ├── msg_reader.go # MSG file parsing
│ │ ├── mailparser.go # MIME email parsing
│ │ └── file_dialog.go # File dialog utilities
│ ├── screenshot_windows.go # Windows screenshot capture
│ ├── debug_windows.go # Debugger detection
│ ├── ini-reader.go # Configuration file parsing
│ ├── machine-identifier.go # System info collection
│ └── file-metadata.go # File metadata utilities
├── frontend/
│ ├── src/
│ │ ├── routes/ # SvelteKit routes
│ │ │ ├── +layout.svelte # Root layout
│ │ │ ├── +error.svelte # Error page
│ │ │ ├── (app)/ # Main app route group
│ │ │ │ ├── +layout.svelte # App layout with sidebar
│ │ │ │ ├── +page.svelte # Mail viewer page
│ │ │ │ └── settings/
│ │ │ │ └── +page.svelte # Settings page
│ │ │ ├── image/ # Image viewer route
│ │ │ │ ├── +layout.svelte
│ │ │ │ └── +page.svelte
│ │ │ └── pdf/ # PDF viewer route
│ │ │ ├── +layout.svelte
│ │ │ └── +page.svelte
│ │ ├── lib/
│ │ │ ├── components/ # Svelte components
│ │ │ │ ├── MailViewer.svelte
│ │ │ │ ├── SidebarApp.svelte
│ │ │ │ ├── UnsavedBar.svelte
│ │ │ │ └── ui/ # shadcn-svelte components
│ │ │ ├── stores/ # State management
│ │ │ │ ├── app.ts
│ │ │ │ ├── mail-state.svelte.ts
│ │ │ │ └── settings.svelte.ts
│ │ │ ├── paraglide/ # i18n runtime
│ │ │ ├── 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
│ ├── static/ # Static assets
│ └── package.json
└── config.ini # Application configuration
```
---
## Backend (Go)
### Entry Point (`main.go`)
The application starts in `main.go`, which:
1. Initializes the logger
2. Parses command-line arguments for:
- `.eml` or `.msg` files to open on startup
- `--view-image=<path>` for image viewer mode
- `--view-pdf=<path>` for PDF viewer mode
3. Configures Wails with window options
4. Sets up single-instance lock to prevent multiple main windows
5. Binds the `App` struct for frontend access
```go
// Single instance with unique ID based on mode
uniqueId := "emly-app-lock"
if strings.Contains(arg, "--view-image") {
uniqueId = "emly-viewer-" + arg
windowTitle = "EMLy Image Viewer"
}
```
### Application Core (`app.go`)
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 // 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
}
```
#### 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 |
| `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 |
**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 |
| `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 |
**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/`)
#### EML Reader (`eml_reader.go`)
Reads standard `.eml` files using the `mailparser.go` MIME parser.
#### MSG Reader (`msg_reader.go`)
Handles Microsoft Outlook `.msg` files using external conversion.
#### Mail Parser (`mailparser.go`)
A comprehensive MIME email parser that handles:
- Multipart messages (mixed, alternative, related)
- Text and HTML bodies
- Attachments with proper content-type detection
- Embedded files (inline images)
- Various content transfer encodings (base64, quoted-printable, 7bit, 8bit)
The `EmailData` structure returned to the frontend:
```go
type EmailData struct {
Subject string
From string
To []string
Cc []string
Bcc []string
Body string // HTML or text body
Attachments []AttachmentData
IsPec bool // Italian certified email
}
```
### Screenshot Utility (`backend/utils/screenshot_windows.go`)
Captures the application window using Windows GDI APIs:
- Uses `FindWindowW` to locate window by title
- Uses `DwmGetWindowAttribute` for DPI-aware window bounds
- Creates compatible DC and bitmap for capture
- Returns image as base64-encoded PNG
---
## Frontend (SvelteKit)
### Route Structure
SvelteKit uses file-based routing. The `(app)` folder is a route group that applies the main app layout.
```
routes/
├── +layout.svelte # Root layout (minimal)
├── +error.svelte # Global error page
├── (app)/ # Main app group
│ ├── +layout.svelte # App layout with titlebar, sidebar, footer
│ ├── +layout.ts # Server data loader
│ ├── +page.svelte # Main mail viewer page
│ └── settings/
│ ├── +page.svelte # Settings page
│ └── +layout.ts # Settings data loader
├── image/ # Standalone image viewer
│ ├── +layout.svelte
│ └── +page.svelte
└── pdf/ # Standalone PDF viewer
├── +layout.svelte
└── +page.svelte
```
### Main App Layout (`(app)/+layout.svelte`)
The app layout provides:
1. **Custom Titlebar**: Windows-style titlebar with minimize/maximize/close buttons
- Draggable for window movement
- Double-click to maximize/restore
2. **Sidebar Provider**: Collapsible navigation sidebar
3. **Footer Bar**: Quick access icons for:
- Toggle sidebar
- Navigate to home
- Navigate to settings
- Open bug report dialog
- Reload application
4. **Bug Report Dialog**: Complete bug reporting system with:
- Screenshot capture on dialog open
- Name, email, description fields
- System info collection
- Creates ZIP archive with all data
5. **Toast Notifications**: Using svelte-sonner
6. **Debugger Protection**: Detects attached debuggers and can quit if detected
### Mail Viewer (`(app)/+page.svelte` + `MailViewer.svelte`)
The mail viewer is split into two parts:
- `+page.svelte`: Page wrapper that initializes mail state from startup file
- `MailViewer.svelte`: Core email viewing component
#### MailViewer Features
- **Empty State**: Shows "Open EML/MSG File" button when no email loaded
- **Email Header**: Displays subject, from, to, cc, bcc fields
- **PEC Badge**: Shows green badge for Italian certified emails
- **Attachments Bar**: Horizontal scrollable list of attachments with type-specific icons
- **Email Body**: Rendered in sandboxed iframe for security
- **Loading Overlay**: Shows spinner during file loading
#### Attachment Handling
```typescript
// Different handlers based on file type
if (att.contentType.startsWith("image/")) {
// Opens in built-in or external viewer based on settings
await OpenImageWindow(base64Data, filename);
} else if (att.filename.toLowerCase().endsWith(".pdf")) {
// Opens in built-in or external PDF viewer
await OpenPDFWindow(base64Data, filename);
} else if (att.filename.toLowerCase().endsWith(".eml")) {
// Opens in new EMLy instance
await OpenEMLWindow(base64Data, filename);
} else {
// Download as file
<a href={dataUrl} download={filename}>...</a>
}
```
### 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:
1. **Language Settings**
- Radio buttons for English/Italian
- Triggers full page reload on change
2. **Export/Import Settings**
- Export current settings to JSON file
- Import settings from JSON file
3. **Preview Page Settings**
- Supported image types (JPG, JPEG, PNG)
- Toggle built-in image viewer
- Toggle built-in PDF viewer
4. **Danger Zone** (Hidden by default, revealed by clicking settings 10 times rapidly)
- Open DevTools hint
- Reload application
- Reset to defaults
- Debugger protection toggle (disabled in production)
- Version information display
#### Unsaved Changes Detection
The settings page tracks changes and shows a persistent toast when there are unsaved modifications:
```typescript
$effect(() => {
const dirty = !isSameSettings(normalizeSettings(form), lastSaved);
unsavedChanges.set(dirty);
if (dirty) {
showUnsavedChangesToast({
onSave: saveToStorage,
onReset: resetToLastSaved,
});
}
});
```
---
## State Management
EMLy uses a combination of Svelte 5 Runes and traditional Svelte stores.
### Mail State (`stores/mail-state.svelte.ts`)
Uses Svelte 5's `$state` rune for reactive email data:
```typescript
class MailState {
currentEmail = $state<internal.EmailData | null>(null);
setParams(email: internal.EmailData | null) {
this.currentEmail = email;
}
clear() {
this.currentEmail = null;
}
}
export const mailState = new MailState();
```
### Settings Store (`stores/settings.svelte.ts`)
Manages application settings with localStorage persistence:
```typescript
class SettingsStore {
settings = $state<EMLy_GUI_Settings>({ ...defaults });
hasHydrated = $state(false);
load() { /* Load from localStorage */ }
save() { /* Save to localStorage */ }
update(newSettings: Partial<EMLy_GUI_Settings>) { /* Merge and save */ }
reset() { /* Reset to defaults */ }
}
```
Settings schema:
```typescript
interface EMLy_GUI_Settings {
selectedLanguage: "en" | "it";
useBuiltinPreview: boolean;
useBuiltinPDFViewer: boolean;
previewFileSupportedTypes: string[];
enableAttachedDebuggerProtection: boolean;
}
```
### App Store (`stores/app.ts`)
Traditional Svelte writable stores for UI state:
```typescript
export const dangerZoneEnabled = writable<boolean>(false);
export const unsavedChanges = writable<boolean>(false);
export const sidebarOpen = writable<boolean>(true);
export const bugReportDialogOpen = writable<boolean>(false);
export const events = writable<AppEvent[]>([]);
```
---
## Internationalization (i18n)
EMLy uses **ParaglideJS** for compile-time type-safe translations.
### Translation Files
Located in `frontend/messages/`:
- `en.json` - English translations
- `it.json` - Italian translations
### Message Format
```json
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"mail_no_email_selected": "No email selected",
"mail_open_eml_btn": "Open EML/MSG File",
"settings_title": "Settings",
"settings_language_english": "English",
"settings_language_italian": "Italiano"
}
```
### Usage in Components
```typescript
import * as m from "$lib/paraglide/messages";
// In template
<h1>{m.settings_title()}</h1>
<button>{m.mail_open_eml_btn()}</button>
```
### Changing Language
```typescript
import { setLocale } from "$lib/paraglide/runtime";
await setLocale("it", { reload: false });
location.reload(); // Page reload required for full update
```
---
## UI Components
EMLy uses **shadcn-svelte**, a port of shadcn/ui for Svelte. Components are located in `frontend/src/lib/components/ui/`.
### Available Components
| Component | Usage |
|-----------|-------|
| `Button` | Primary buttons with variants (default, destructive, outline, ghost) |
| `Card` | Container with header, content, footer sections |
| `Dialog` | Modal dialogs for bug reports, confirmations |
| `AlertDialog` | Confirmation dialogs with cancel/continue actions |
| `Switch` | Toggle switches for boolean settings |
| `Checkbox` | Multi-select checkboxes |
| `RadioGroup` | Single-select options (language selection) |
| `Label` | Form labels |
| `Input` | Text input fields |
| `Textarea` | Multi-line text input |
| `Separator` | Visual dividers |
| `Sidebar` | Collapsible navigation sidebar |
| `Tooltip` | Hover tooltips |
| `Sonner` | Toast notifications |
| `Badge` | Status badges |
| `Skeleton` | Loading placeholders |
### Custom Components
| Component | Purpose |
|-----------|---------|
| `MailViewer.svelte` | Email display with header, attachments, body |
| `SidebarApp.svelte` | Navigation sidebar with menu items |
| `UnsavedBar.svelte` | Unsaved changes notification bar |
---
## Key Features
### 1. Email Viewing
- Parse and display EML and MSG files
- Show email metadata (from, to, cc, bcc, subject)
- Render HTML email bodies in sandboxed iframe
- List and handle attachments with type-specific actions
### 2. Attachment Handling
- **Images**: Open in built-in viewer or external app
- **PDFs**: Open in built-in viewer or external app
- **EML files**: Open in new EMLy window
- **Other files**: Download directly
### 3. Multi-Window Support
The app can spawn separate viewer windows:
- Image viewer (`--view-image=<path>`)
- PDF viewer (`--view-pdf=<path>`)
- EML viewer (new app instance with file path)
Each viewer has a unique instance ID to allow multiple concurrent viewers.
### 4. Bug Reporting
Complete bug reporting system:
1. Captures screenshot when dialog opens
2. Collects user input (name, email, description)
3. Includes current mail file if loaded
4. Gathers system information
5. Creates ZIP archive in temp folder
6. Shows path and allows opening folder
### 5. Settings Management
- Language selection (English/Italian)
- Built-in viewer preferences
- Supported file type configuration
- Export/import settings as JSON
- Reset to defaults
### 6. Security Features
- Debugger detection and protection
- Sandboxed iframe for email body
- Single-instance lock for main window
- Disabled link clicking in email body
### 7. PEC Support
Special handling for Italian Posta Elettronica Certificata (PEC):
- Detects PEC emails
- Shows signed mail badge
- Handles P7S signature files
- Processes daticert.xml metadata
---
## Build & Development
### Prerequisites
- Go 1.21+
- Node.js 18+ or Bun
- Wails CLI v2
### Development
```bash
# Install frontend dependencies
cd frontend && bun install
# Run in development mode
wails dev
```
### Building
```bash
# Build for Windows
wails build -platform windows/amd64
# Output: build/bin/EMLy.exe
```
### Configuration
`wails.json` configures the build:
```json
{
"name": "EMLy",
"frontend:install": "bun install",
"frontend:build": "bun run build",
"frontend:dev:watcher": "bun run dev",
"fileAssociations": [
{
"ext": "eml",
"name": "Email Message",
"description": "EML File"
}
]
}
```
### File Association
The app registers as a handler for `.eml` files via Windows file associations, configured in `wails.json`.
---
## Wails Bindings
Wails automatically generates TypeScript bindings for Go functions. These are located in `frontend/src/lib/wailsjs/`.
### Generated Files
- `go/main/App.ts` - TypeScript functions calling Go methods
- `go/models.ts` - TypeScript types for Go structs
- `runtime/runtime.ts` - Wails runtime functions
### Usage Example
```typescript
import { ReadEML, ShowOpenFileDialog } from "$lib/wailsjs/go/main/App";
import type { internal } from "$lib/wailsjs/go/models";
// Open file dialog
const filePath = await ShowOpenFileDialog();
// Parse email
const email: internal.EmailData = await ReadEML(filePath);
```
### Runtime Events
Wails provides event system for Go-to-JS communication:
```typescript
import { EventsOn } from "$lib/wailsjs/runtime/runtime";
// Listen for second instance launch
EventsOn("launchArgs", (args: string[]) => {
// Handle file opened from second instance
});
```
---
## Error Handling
### Frontend
Toast notifications for user-facing errors:
```typescript
import { toast } from "svelte-sonner";
import * as m from "$lib/paraglide/messages";
try {
await ReadEML(filePath);
} catch (error) {
toast.error(m.mail_error_opening());
}
```
### Backend
Errors are returned to frontend and logged:
```go
func (a *App) ReadEML(filePath string) (*internal.EmailData, error) {
data, err := internal.ReadEmlFile(filePath)
if err != nil {
Log("Failed to read EML:", err)
return nil, err
}
return data, nil
}
```
---
## Debugging
### Development Mode
In dev mode (`wails dev`):
- Hot reload enabled
- Debug logs visible
- DevTools accessible via Ctrl+Shift+F12
- Danger Zone always visible in settings
### Production
- Debugger protection can terminate app if debugger detected
- Danger Zone hidden by default
- Access Danger Zone by clicking settings link 10 times within 4 seconds
---
## License & Credits
EMLy is developed by FOISX @ 3gIT.

567
app.go
View File

@@ -1,40 +1,60 @@
// Package main provides the core EMLy application.
// EMLy is a desktop email viewer for .eml and .msg files built with Wails v2.
package main package main
import ( import (
"context" "context"
"encoding/base64"
"fmt"
"os" "os"
"os/exec"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time"
"unicode/utf8"
"golang.org/x/sys/windows/registry"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
"emly/backend/utils" "emly/backend/utils"
internal "emly/backend/utils/mail"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
// App struct // =============================================================================
// App - Core Application Structure
// =============================================================================
// App is the main application struct that holds the application state and
// provides methods that are exposed to the frontend via Wails bindings.
//
// The struct manages:
// - Application context for Wails runtime calls
// - File paths for startup and currently loaded emails
// - Tracking of open viewer windows to prevent duplicates
type App struct { type App struct {
ctx context.Context // ctx is the Wails application context, used for runtime calls like dialogs
ctx context.Context
// StartupFilePath is set when the app is launched with an email file argument
StartupFilePath string StartupFilePath string
openImagesMux sync.Mutex
openImages map[string]bool // CurrentMailFilePath tracks the currently loaded mail file path
openPDFsMux sync.Mutex // Used for bug reports to include the relevant email file
openPDFs map[string]bool CurrentMailFilePath string
openEMLsMux sync.Mutex
openEMLs map[string]bool // openImages tracks which images are currently open in viewer windows
// The key is the filename, preventing duplicate viewers for the same file
openImagesMux sync.Mutex
openImages map[string]bool
// openPDFs tracks which PDFs are currently open in viewer windows
openPDFsMux sync.Mutex
openPDFs map[string]bool
// openEMLs tracks which EML attachments are currently open in viewer windows
openEMLsMux sync.Mutex
openEMLs map[string]bool
} }
// NewApp creates a new App application struct // =============================================================================
// Constructor & Lifecycle
// =============================================================================
// NewApp creates and initializes a new App instance.
// This is called from main.go before the Wails application starts.
func NewApp() *App { func NewApp() *App {
return &App{ return &App{
openImages: make(map[string]bool), openImages: make(map[string]bool),
@@ -43,11 +63,22 @@ func NewApp() *App {
} }
} }
// startup is called when the app starts. The context is saved // startup is called by Wails when the application starts.
// so we can call the runtime methods // It receives the application context which is required for Wails runtime calls.
//
// This method:
// - Saves the context for later use
// - Syncs CurrentMailFilePath with StartupFilePath if a file was opened via CLI
// - Logs the startup mode (main app vs viewer window)
func (a *App) startup(ctx context.Context) { func (a *App) startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
// Sync CurrentMailFilePath with StartupFilePath if a file was opened via command line
if a.StartupFilePath != "" {
a.CurrentMailFilePath = a.StartupFilePath
}
// Check if this instance is running as a viewer (image/PDF) rather than main app
isViewer := false isViewer := false
for _, arg := range os.Args { for _, arg := range os.Args {
if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") { if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") {
@@ -57,22 +88,45 @@ func (a *App) startup(ctx context.Context) {
} }
if isViewer { if isViewer {
Log("Second instance launch") Log("Viewer instance started")
} else { } else {
Log("Wails startup") Log("EMLy main application started")
} }
} }
// shutdown is called by Wails when the application is closing.
// Used for cleanup operations.
func (a *App) shutdown(ctx context.Context) {
// Best-effort cleanup - currently no resources require explicit cleanup
}
// QuitApp terminates the application.
// It first calls Wails Quit to properly close the window,
// then forces an exit with a specific code.
func (a *App) QuitApp() {
runtime.Quit(a.ctx)
// Exit with code 133 (133 + 5 = 138, SIGTRAP-like exit)
os.Exit(133)
}
// =============================================================================
// Configuration Management
// =============================================================================
// GetConfig loads and returns the application configuration from config.ini.
// Returns nil if the configuration cannot be loaded.
func (a *App) GetConfig() *utils.Config { func (a *App) GetConfig() *utils.Config {
cfgPath := utils.DefaultConfigPath() cfgPath := utils.DefaultConfigPath()
cfg, err := utils.LoadConfig(cfgPath) cfg, err := utils.LoadConfig(cfgPath)
if err != nil { if err != nil {
Log("Failed to load config for version:", err) Log("Failed to load config:", err)
return nil return nil
} }
return cfg return cfg
} }
// SaveConfig persists the provided configuration to config.ini.
// Returns an error if saving fails.
func (a *App) SaveConfig(cfg *utils.Config) error { func (a *App) SaveConfig(cfg *utils.Config) error {
cfgPath := utils.DefaultConfigPath() cfgPath := utils.DefaultConfigPath()
if err := utils.SaveConfig(cfgPath, cfg); err != nil { if err := utils.SaveConfig(cfgPath, cfg); err != nil {
@@ -82,455 +136,44 @@ func (a *App) SaveConfig(cfg *utils.Config) error {
return nil return nil
} }
func (a *App) shutdown(ctx context.Context) { // =============================================================================
// Best-effort cleanup. // Startup File Management
// =============================================================================
// GetStartupFile returns the file path if the app was launched with an email file argument.
// Returns an empty string if no file was specified at startup.
func (a *App) GetStartupFile() string {
return a.StartupFilePath
} }
func (a *App) QuitApp() { // SetCurrentMailFilePath updates the path of the currently loaded mail file.
runtime.Quit(a.ctx) // This is called when the user opens a file via the file dialog.
// Generate exit code 138 func (a *App) SetCurrentMailFilePath(filePath string) {
os.Exit(133) // 133 + 5 (SIGTRAP) a.CurrentMailFilePath = filePath
} }
// GetCurrentMailFilePath returns the path of the currently loaded mail file.
// Used by bug reports to include the relevant email file.
func (a *App) GetCurrentMailFilePath() string {
return a.CurrentMailFilePath
}
// =============================================================================
// System Information
// =============================================================================
// GetMachineData retrieves system information about the current machine.
// Returns hostname, OS version, hardware ID, etc.
func (a *App) GetMachineData() *utils.MachineInfo { func (a *App) GetMachineData() *utils.MachineInfo {
data, _ := utils.GetMachineInfo() data, _ := utils.GetMachineInfo()
return data return data
} }
// GetStartupFile returns the file path if the app was opened with a file argument // IsDebuggerRunning checks if a debugger is attached to the application.
func (a *App) GetStartupFile() string { // Used for anti-debugging protection in production builds.
return a.StartupFilePath
}
// ReadEML reads a .eml file and returns the email data
func (a *App) ReadEML(filePath string) (*internal.EmailData, error) {
return internal.ReadEmlFile(filePath)
}
// ReadPEC reads a PEC .eml file and returns the inner email data
func (a *App) ReadPEC(filePath string) (*internal.EmailData, error) {
return internal.ReadPecInnerEml(filePath)
}
// ReadMSG reads a .msg file and returns the email data
func (a *App) ReadMSG(filePath string, useExternalConverter bool) (*internal.EmailData, error) {
if useExternalConverter {
return internal.ReadMsgFile(filePath)
}
return internal.ReadMsgFile(filePath)
}
// ReadMSGOSS reads a .msg file and returns the email data
func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) {
return internal.ReadMsgFile(filePath)
}
// ShowOpenFileDialog shows the file open dialog for EML files
func (a *App) ShowOpenFileDialog() (string, error) {
return internal.ShowFileDialog(a.ctx)
}
// OpenEMLWindow saves EML to temp and opens a new instance of the app
func (a *App) OpenEMLWindow(base64Data string, filename string) error {
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()
// 1. Decode base64
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)
}
// 2. Save to temp file
tempDir := os.TempDir()
// Use timestamp or unique ID to avoid conflicts if multiple files have same name
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)
}
// 3. Launch new instance
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)
}
// Run EMLy with the file path as argument
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 a new window instance to display the image
func (a *App) OpenImageWindow(base64Data string, filename string) error {
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()
// 1. Decode base64
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)
}
// 2. Save to temp file
tempDir := os.TempDir()
// Use timestamp to make unique
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)
}
// 3. Launch new instance
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 new window instance to display the PDF
func (a *App) OpenPDFWindow(base64Data string, filename string) error {
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()
// 1. Decode base64
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)
}
// 2. Save to temp file
tempDir := os.TempDir()
// Use timestamp to make unique
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s", 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)
}
// 3. Launch new instance
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
}
// OpenPDF saves PDF to temp and opens with default app
func (a *App) OpenPDF(base64Data string, filename string) error {
if base64Data == "" {
return fmt.Errorf("no data provided")
}
// 1. Decode base64
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return fmt.Errorf("failed to decode base64: %w", err)
}
// 2. Save to temp file
tempDir := os.TempDir()
// Use timestamp to make unique
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)
}
// 3. Open with default app (Windows)
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 image to temp and opens with default app (Windows)
func (a *App) OpenImage(base64Data string, filename string) error {
if base64Data == "" {
return fmt.Errorf("no data provided")
}
// 1. Decode base64
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return fmt.Errorf("failed to decode base64: %w", err)
}
// 2. Save to temp file
tempDir := os.TempDir()
// Use timestamp to make unique
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)
}
// 3. Open with default app (Windows)
cmd := exec.Command("cmd", "/c", "start", "", tempFile)
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
return nil
}
type ImageViewerData struct {
Data string `json:"data"`
Filename string `json:"filename"`
}
type PDFViewerData struct {
Data string `json:"data"`
Filename string `json:"filename"`
}
type ViewerData struct {
ImageData *ImageViewerData `json:"imageData,omitempty"`
PDFData *PDFViewerData `json:"pdfData,omitempty"`
}
// GetImageViewerData checks CLI args and returns image data if in viewer mode
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 text file: %w", err)
}
// Return encoded base64 so frontend can handle it same way
encoded := base64.StdEncoding.EncodeToString(data)
return &ImageViewerData{
Data: encoded,
Filename: filepath.Base(filePath),
}, nil
}
}
return nil, nil
}
// GetPDFViewerData checks CLI args and returns pdf data if in viewer mode
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 text file: %w", err)
}
// Return encoded base64 so frontend can handle it same way
encoded := base64.StdEncoding.EncodeToString(data)
return &PDFViewerData{
Data: encoded,
Filename: filepath.Base(filePath),
}, nil
}
}
return nil, nil
}
func (a *App) GetViewerData() (*ViewerData, 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 text file: %w", err)
}
// Return encoded base64 so frontend can handle it same way
encoded := base64.StdEncoding.EncodeToString(data)
return &ViewerData{
ImageData: &ImageViewerData{
Data: encoded,
Filename: filepath.Base(filePath),
},
}, nil
}
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 text file: %w", err)
}
// Return encoded base64 so frontend can handle it same way
encoded := base64.StdEncoding.EncodeToString(data)
return &ViewerData{
PDFData: &PDFViewerData{
Data: encoded,
Filename: filepath.Base(filePath),
},
}, nil
}
}
return nil, nil
}
// CheckIsDefaultEMLHandler verifies if the current executable is the default handler for .eml files
func (a *App) CheckIsDefaultEMLHandler() (bool, error) {
// 1. Get current executable path
exePath, err := os.Executable()
if err != nil {
return false, err
}
// Normalize path for comparison
exePath = strings.ToLower(exePath)
// 2. Open UserChoice key for .eml
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 implies user hasn't made a specific choice or system default is active (not us usually)
return false, nil
}
defer k.Close()
// 3. Get ProgId
progId, _, err := k.GetStringValue("ProgId")
if err != nil {
return false, err
}
// 4. Find the command for 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()
cmd, _, err := classKey.GetStringValue("")
if err != nil {
return false, err
}
// 5. Compare command with our executable
cmdLower := strings.ToLower(cmd)
// Basic check: does the command contain our executable name?
// In a real scenario, parsing the exact path respecting quotes would be safer,
// but checking if our specific exe path is present is usually sufficient.
if strings.Contains(cmdLower, strings.ToLower(filepath.Base(exePath))) {
// More robust: escape backslashes and check presence
// cleanExe := strings.ReplaceAll(exePath, `\`, `\\`)
// For now, depending on how registry stores it (short path vs long path),
// containment of the filename is a strong indicator if the filename is unique enough (emly.exe)
return true, nil
}
return false, nil
}
// OpenDefaultAppsSettings opens the Windows default apps settings page
func (a *App) OpenDefaultAppsSettings() error {
cmd := exec.Command("cmd", "/c", "start", "ms-settings:defaultapps")
return cmd.Start()
}
func (a *App) IsDebuggerRunning() bool { func (a *App) IsDebuggerRunning() bool {
if a == nil { if a == nil {
return false return false
} }
return utils.IsDebugged() return utils.IsDebugged()
} }
func (a *App) ConvertToUTF8(s string) string {
if utf8.ValidString(s) {
return s
}
// If invalid UTF-8, assume Windows-1252 (superset of ISO-8859-1)
decoder := charmap.Windows1252.NewDecoder()
decoded, _, err := transform.String(decoder, s)
if err != nil {
return s // Return as-is if decoding fails
}
return decoded
}

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
}

View File

@@ -0,0 +1,259 @@
package utils
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
gdi32 = syscall.NewLazyDLL("gdi32.dll")
dwmapi = syscall.NewLazyDLL("dwmapi.dll")
// user32 functions
getForegroundWindow = user32.NewProc("GetForegroundWindow")
getWindowRect = user32.NewProc("GetWindowRect")
getClientRect = user32.NewProc("GetClientRect")
getDC = user32.NewProc("GetDC")
releaseDC = user32.NewProc("ReleaseDC")
findWindowW = user32.NewProc("FindWindowW")
getWindowDC = user32.NewProc("GetWindowDC")
printWindow = user32.NewProc("PrintWindow")
clientToScreen = user32.NewProc("ClientToScreen")
// gdi32 functions
createCompatibleDC = gdi32.NewProc("CreateCompatibleDC")
createCompatibleBitmap = gdi32.NewProc("CreateCompatibleBitmap")
selectObject = gdi32.NewProc("SelectObject")
bitBlt = gdi32.NewProc("BitBlt")
deleteDC = gdi32.NewProc("DeleteDC")
deleteObject = gdi32.NewProc("DeleteObject")
getDIBits = gdi32.NewProc("GetDIBits")
// dwmapi functions
dwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
)
// RECT structure for Windows API
type RECT struct {
Left int32
Top int32
Right int32
Bottom int32
}
// POINT structure for Windows API
type POINT struct {
X int32
Y int32
}
// BITMAPINFOHEADER structure
type BITMAPINFOHEADER struct {
BiSize uint32
BiWidth int32
BiHeight int32
BiPlanes uint16
BiBitCount uint16
BiCompression uint32
BiSizeImage uint32
BiXPelsPerMeter int32
BiYPelsPerMeter int32
BiClrUsed uint32
BiClrImportant uint32
}
// BITMAPINFO structure
type BITMAPINFO struct {
BmiHeader BITMAPINFOHEADER
BmiColors [1]uint32
}
const (
SRCCOPY = 0x00CC0020
DIB_RGB_COLORS = 0
BI_RGB = 0
PW_CLIENTONLY = 1
PW_RENDERFULLCONTENT = 2
DWMWA_EXTENDED_FRAME_BOUNDS = 9
)
// CaptureWindowByHandle captures a screenshot of a specific window by its handle
func CaptureWindowByHandle(hwnd uintptr) (*image.RGBA, error) {
if hwnd == 0 {
return nil, fmt.Errorf("invalid window handle")
}
// Try to get the actual window bounds using DWM (handles DPI scaling better)
var rect RECT
ret, _, _ := dwmGetWindowAttribute.Call(
hwnd,
uintptr(DWMWA_EXTENDED_FRAME_BOUNDS),
uintptr(unsafe.Pointer(&rect)),
uintptr(unsafe.Sizeof(rect)),
)
// Fallback to GetWindowRect if DWM fails
if ret != 0 {
ret, _, err := getWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect)))
if ret == 0 {
return nil, fmt.Errorf("GetWindowRect failed: %v", err)
}
}
width := int(rect.Right - rect.Left)
height := int(rect.Bottom - rect.Top)
if width <= 0 || height <= 0 {
return nil, fmt.Errorf("invalid window dimensions: %dx%d", width, height)
}
// Get window DC
hdcWindow, _, err := getWindowDC.Call(hwnd)
if hdcWindow == 0 {
return nil, fmt.Errorf("GetWindowDC failed: %v", err)
}
defer releaseDC.Call(hwnd, hdcWindow)
// Create compatible DC
hdcMem, _, err := createCompatibleDC.Call(hdcWindow)
if hdcMem == 0 {
return nil, fmt.Errorf("CreateCompatibleDC failed: %v", err)
}
defer deleteDC.Call(hdcMem)
// Create compatible bitmap
hBitmap, _, err := createCompatibleBitmap.Call(hdcWindow, uintptr(width), uintptr(height))
if hBitmap == 0 {
return nil, fmt.Errorf("CreateCompatibleBitmap failed: %v", err)
}
defer deleteObject.Call(hBitmap)
// Select bitmap into DC
oldBitmap, _, _ := selectObject.Call(hdcMem, hBitmap)
defer selectObject.Call(hdcMem, oldBitmap)
// Try PrintWindow first (works better with layered/composited windows)
ret, _, _ = printWindow.Call(hwnd, hdcMem, PW_RENDERFULLCONTENT)
if ret == 0 {
// Fallback to BitBlt
ret, _, err = bitBlt.Call(
hdcMem, 0, 0, uintptr(width), uintptr(height),
hdcWindow, 0, 0,
SRCCOPY,
)
if ret == 0 {
return nil, fmt.Errorf("BitBlt failed: %v", err)
}
}
// Prepare BITMAPINFO
bmi := BITMAPINFO{
BmiHeader: BITMAPINFOHEADER{
BiSize: uint32(unsafe.Sizeof(BITMAPINFOHEADER{})),
BiWidth: int32(width),
BiHeight: -int32(height), // Negative for top-down DIB
BiPlanes: 1,
BiBitCount: 32,
BiCompression: BI_RGB,
},
}
// Allocate buffer for pixel data
pixelDataSize := width * height * 4
pixelData := make([]byte, pixelDataSize)
// Get the bitmap bits
ret, _, err = getDIBits.Call(
hdcMem,
hBitmap,
0,
uintptr(height),
uintptr(unsafe.Pointer(&pixelData[0])),
uintptr(unsafe.Pointer(&bmi)),
DIB_RGB_COLORS,
)
if ret == 0 {
return nil, fmt.Errorf("GetDIBits failed: %v", err)
}
// Convert BGRA to RGBA
img := image.NewRGBA(image.Rect(0, 0, width, height))
for i := 0; i < len(pixelData); i += 4 {
img.Pix[i+0] = pixelData[i+2] // R <- B
img.Pix[i+1] = pixelData[i+1] // G <- G
img.Pix[i+2] = pixelData[i+0] // B <- R
img.Pix[i+3] = pixelData[i+3] // A <- A
}
return img, nil
}
// CaptureForegroundWindow captures the currently focused window
func CaptureForegroundWindow() (*image.RGBA, error) {
hwnd, _, _ := getForegroundWindow.Call()
if hwnd == 0 {
return nil, fmt.Errorf("no foreground window found")
}
return CaptureWindowByHandle(hwnd)
}
// CaptureWindowByTitle captures a window by its title
func CaptureWindowByTitle(title string) (*image.RGBA, error) {
titlePtr, err := syscall.UTF16PtrFromString(title)
if err != nil {
return nil, fmt.Errorf("failed to convert title: %v", err)
}
hwnd, _, _ := findWindowW.Call(0, uintptr(unsafe.Pointer(titlePtr)))
if hwnd == 0 {
return nil, fmt.Errorf("window with title '%s' not found", title)
}
return CaptureWindowByHandle(hwnd)
}
// ScreenshotToBase64PNG captures a window and returns it as a base64-encoded PNG string
func ScreenshotToBase64PNG(img *image.RGBA) (string, error) {
if img == nil {
return "", fmt.Errorf("nil image provided")
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return "", fmt.Errorf("failed to encode PNG: %v", err)
}
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// CaptureWindowToBase64 is a convenience function that captures a window and returns base64 PNG
func CaptureWindowToBase64(hwnd uintptr) (string, error) {
img, err := CaptureWindowByHandle(hwnd)
if err != nil {
return "", err
}
return ScreenshotToBase64PNG(img)
}
// CaptureForegroundWindowToBase64 captures the foreground window and returns base64 PNG
func CaptureForegroundWindowToBase64() (string, error) {
img, err := CaptureForegroundWindow()
if err != nil {
return "", err
}
return ScreenshotToBase64PNG(img)
}
// CaptureWindowByTitleToBase64 captures a window by title and returns base64 PNG
func CaptureWindowByTitleToBase64(title string) (string, error) {
img, err := CaptureWindowByTitle(title)
if err != nil {
return "", err
}
return ScreenshotToBase64PNG(img)
}

View File

@@ -89,5 +89,36 @@
"mail_pdf_already_open": "The PDF is already open in another window.", "mail_pdf_already_open": "The PDF is already open in another window.",
"settings_danger_debugger_protection_label": "Enable attached debugger protection", "settings_danger_debugger_protection_label": "Enable attached debugger protection",
"settings_danger_debugger_protection_hint": "This will prevent the app from being debugged by an attached debugger.", "settings_danger_debugger_protection_hint": "This will prevent the app from being debugged by an attached debugger.",
"settings_danger_debugger_protection_info": "Info: This actions are currently not configurable and is always enabled for private builds." "settings_danger_debugger_protection_info": "Info: This actions are currently not configurable and is always enabled for private builds.",
"bugreport_title": "Report a Bug",
"bugreport_description": "Describe what you were doing when the bug occurred and what you expected to happen instead.",
"bugreport_name_label": "Name",
"bugreport_name_placeholder": "Your name",
"bugreport_email_label": "Email",
"bugreport_email_placeholder": "your.email@example.com",
"bugreport_text_label": "Bug Description",
"bugreport_text_placeholder": "Describe the bug in detail...",
"bugreport_info": "Your message, email file (if loaded), screenshot, and system information will be included in the report.",
"bugreport_screenshot_label": "Attached Screenshot:",
"bugreport_cancel": "Cancel",
"bugreport_submit": "Submit Report",
"bugreport_submitting": "Creating report...",
"bugreport_success_title": "Bug Report Created",
"bugreport_success_message": "Your bug report has been saved to:",
"bugreport_copy_path": "Copy Path",
"bugreport_open_folder": "Open Folder",
"bugreport_close": "Close",
"bugreport_error": "Failed to create bug report.",
"bugreport_copied": "Path copied to clipboard!",
"settings_export_import_title": "Export / Import Settings",
"settings_export_import_description": "Export your current settings to a file or import settings from a previously exported file.",
"settings_export_button": "Export Settings",
"settings_export_hint": "Save your current settings to a JSON file.",
"settings_import_button": "Import Settings",
"settings_import_hint": "Load settings from a previously exported JSON file.",
"settings_export_success": "Settings exported successfully!",
"settings_export_error": "Failed to export settings.",
"settings_import_success": "Settings imported successfully!",
"settings_import_error": "Failed to import settings.",
"settings_import_invalid": "Invalid settings file."
} }

View File

@@ -89,5 +89,36 @@
"mail_pdf_already_open": "Il file PDF è già aperto in una finestra separata.", "mail_pdf_already_open": "Il file PDF è già aperto in una finestra separata.",
"settings_danger_debugger_protection_label": "Abilita protezione da debugger", "settings_danger_debugger_protection_label": "Abilita protezione da debugger",
"settings_danger_debugger_protection_hint": "Questo impedirà che il debug dell'app venga eseguito da un debugger collegato.", "settings_danger_debugger_protection_hint": "Questo impedirà che il debug dell'app venga eseguito da un debugger collegato.",
"settings_danger_debugger_protection_info": "Info: Questa azione non è attualmente configurabile ed è sempre abilitata per le build private." "settings_danger_debugger_protection_info": "Info: Questa azione non è attualmente configurabile ed è sempre abilitata per le build private.",
"bugreport_title": "Segnala un Bug",
"bugreport_description": "Descrivi cosa stavi facendo quando si è verificato il bug e cosa ti aspettavi che accadesse.",
"bugreport_name_label": "Nome",
"bugreport_name_placeholder": "Il tuo nome",
"bugreport_email_label": "Email",
"bugreport_email_placeholder": "tua.email@esempio.com",
"bugreport_text_label": "Descrizione del Bug",
"bugreport_text_placeholder": "Descrivi il bug in dettaglio...",
"bugreport_info": "Il tuo messaggio, il file email (se caricato), lo screenshot e le informazioni di sistema saranno inclusi nella segnalazione.",
"bugreport_screenshot_label": "Screenshot Allegato:",
"bugreport_cancel": "Annulla",
"bugreport_submit": "Invia Segnalazione",
"bugreport_submitting": "Creazione segnalazione...",
"bugreport_success_title": "Segnalazione Bug Creata",
"bugreport_success_message": "La tua segnalazione bug è stata salvata in:",
"bugreport_copy_path": "Copia Percorso",
"bugreport_open_folder": "Apri Cartella",
"bugreport_close": "Chiudi",
"bugreport_error": "Impossibile creare la segnalazione bug.",
"bugreport_copied": "Percorso copiato negli appunti!",
"settings_export_import_title": "Esporta / Importa Impostazioni",
"settings_export_import_description": "Esporta le impostazioni correnti in un file o importa impostazioni da un file precedentemente esportato.",
"settings_export_button": "Esporta Impostazioni",
"settings_export_hint": "Salva le impostazioni correnti in un file JSON.",
"settings_import_button": "Importa Impostazioni",
"settings_import_hint": "Carica impostazioni da un file JSON precedentemente esportato.",
"settings_export_success": "Impostazioni esportate con successo!",
"settings_export_error": "Impossibile esportare le impostazioni.",
"settings_import_success": "Impostazioni importate con successo!",
"settings_import_error": "Impossibile importare le impostazioni.",
"settings_import_invalid": "File impostazioni non valido."
} }

View File

@@ -36,7 +36,9 @@
"vite-plugin-devtools-json": "^1.0.0" "vite-plugin-devtools-json": "^1.0.0"
}, },
"dependencies": { "dependencies": {
"@types/html2canvas": "^1.0.0",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"html2canvas": "^1.4.1",
"pdfjs-dist": "^5.4.624", "pdfjs-dist": "^5.4.624",
"svelte-flags": "^3.0.1", "svelte-flags": "^3.0.1",
"svelte-sonner": "^1.0.7" "svelte-sonner": "^1.0.7"

View File

@@ -1,216 +1,89 @@
<script lang="ts"> <script lang="ts">
import { X, MailOpen, Image, FileText, File, ShieldCheck, Shield, Signature, FileCode, Loader2 } from "@lucide/svelte"; import {
import { ShowOpenFileDialog, ReadEML, OpenPDF, OpenImageWindow, OpenPDFWindow, OpenImage, ReadMSG, ReadPEC, OpenEMLWindow, ConvertToUTF8 } from "$lib/wailsjs/go/main/App"; X,
import type { internal } from "$lib/wailsjs/go/models"; MailOpen,
import { sidebarOpen } from "$lib/stores/app"; Image,
import { onDestroy, onMount } from "svelte"; FileText,
import { toast } from "svelte-sonner"; File,
import { EventsOn, WindowShow, WindowUnminimise } from "$lib/wailsjs/runtime/runtime"; ShieldCheck,
import { mailState } from "$lib/stores/mail-state.svelte"; Signature,
import { settingsStore } from "$lib/stores/settings.svelte"; FileCode,
import * as m from "$lib/paraglide/messages"; Loader2,
import { dev } from "$app/environment"; } from '@lucide/svelte';
import { isBase64, isHtml } from "$lib/utils"; import { sidebarOpen } from '$lib/stores/app';
import { onDestroy, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { EventsOn, WindowShow, WindowUnminimise } from '$lib/wailsjs/runtime/runtime';
import { mailState } from '$lib/stores/mail-state.svelte';
import * as m from '$lib/paraglide/messages';
import { dev } from '$app/environment';
// Import refactored utilities
import {
IFRAME_UTIL_HTML,
CONTENT_TYPES,
PEC_FILES,
arrayBufferToBase64,
createDataUrl,
openPDFAttachment,
openImageAttachment,
openEMLAttachment,
openAndLoadEmail,
loadEmailFromPath,
processEmailBody,
isEmailFile,
} from '$lib/utils/mail';
// ============================================================================
// State
// ============================================================================
let unregisterEvents = () => {}; let unregisterEvents = () => {};
let isLoading = $state(false); let isLoading = $state(false);
let loadingText = $state(""); let loadingText = $state('');
// ============================================================================
let iFrameUtilHTML = "<style>body{margin:0;padding:20px;font-family:sans-serif;} a{pointer-events:none!important;cursor:default!important;}</style><script>function handleWheel(event){if(event.ctrlKey){event.preventDefault();}}document.addEventListener('wheel',handleWheel,{passive:false});<\/script>"; // Event Handlers
// ============================================================================
function onClear() { function onClear() {
mailState.clear(); mailState.clear();
} }
$effect(() => {
const process = async () => {
if (mailState.currentEmail?.body) {
let content = mailState.currentEmail.body;
// 1. Try to decode if not HTML
if (!isHtml(content)) {
const clean = content.replace(/[\s\r\n]+/g, '');
if (isBase64(clean)) {
try {
const decoded = window.atob(clean);
content = decoded;
} catch (e) {
console.warn("Failed to decode base64 body:", e);
}
}
}
// 2. If it is HTML (original or decoded), try to fix encoding
if (isHtml(content)) {
try {
const fixed = await ConvertToUTF8(content);
if (fixed) {
content = fixed;
}
} catch (e) {
console.warn("Failed to fix encoding:", e);
}
}
// 3. Update if changed
if (content !== mailState.currentEmail.body) {
mailState.currentEmail.body = content;
}
}
if(dev) {
console.log(mailState.currentEmail)
}
console.info("Current email changed:", mailState.currentEmail?.subject);
if(mailState.currentEmail !== null) {
sidebarOpen.set(false);
}
};
process();
})
onDestroy(() => {
if (unregisterEvents) unregisterEvents();
});
onMount(async () => {
// Listen for second instance args
unregisterEvents = EventsOn("launchArgs", async (args: string[]) => {
console.log("got event launchArgs:", args);
if (args && args.length > 0) {
for (const arg of args) {
const lowerArg = arg.toLowerCase();
if (lowerArg.endsWith(".eml") || lowerArg.endsWith(".msg")) {
console.log("Loading file from second instance:", arg);
isLoading = true;
loadingText = m.layout_loading_text();
try {
let emlContent;
if (lowerArg.endsWith(".msg")) {
loadingText = m.mail_loading_msg_conversion();
emlContent = await ReadMSG(arg, true);
} else {
// EML handling
try {
emlContent = await ReadPEC(arg);
} catch (e) {
console.warn("ReadPEC failed, trying ReadEML:", e);
emlContent = await ReadEML(arg);
}
if (emlContent && emlContent.body) {
const trimmed = emlContent.body.trim();
const clean = trimmed.replace(/[\s\r\n]+/g, '');
if (clean.length > 0 && clean.length % 4 === 0 && /^[A-Za-z0-9+/]+=*$/.test(clean)) {
try {
emlContent.body = window.atob(clean);
} catch (e) { }
}
}
}
mailState.setParams(emlContent);
sidebarOpen.set(false);
WindowUnminimise();
WindowShow();
} catch (error) {
console.error("Failed to load email:", error);
toast.error("Failed to load email file");
} finally {
isLoading = false;
loadingText = "";
}
break;
}
}
}
});
});
async function openPDFHandler(base64Data: string, filename: string) {
try {
if (settingsStore.settings.useBuiltinPDFViewer) {
await OpenPDFWindow(base64Data, filename);
} else {
await OpenPDF(base64Data, filename);
}
} catch (error: string | any) {
if(error.includes(filename) && error.includes("already open")) {
toast.error(m.mail_pdf_already_open());
return;
}
console.error("Failed to open PDF:", error);
toast.error(m.mail_error_pdf());
}
}
async function openImageHandler(base64Data: string, filename: string) {
try {
if (settingsStore.settings.useBuiltinPreview) {
await OpenImageWindow(base64Data, filename);
} else {
await OpenImage(base64Data, filename);
}
} catch (error) {
console.error("Failed to open image:", error);
toast.error(m.mail_error_image());
}
}
async function openEMLHandler(base64Data: string, filename: string) {
try {
await OpenEMLWindow(base64Data, filename);
} catch (error) {
console.error("Failed to open EML:", error);
toast.error("Failed to open EML attachment");
}
}
async function onOpenMail() { async function onOpenMail() {
isLoading = true; isLoading = true;
loadingText = m.layout_loading_text(); loadingText = m.layout_loading_text();
const result = await ShowOpenFileDialog();
if (result && result.length > 0) {
// Handle opening the mail file
try {
// If the file is .eml, otherwise if is .msg, read accordingly
let email: internal.EmailData;
if(result.toLowerCase().endsWith(".msg")) {
loadingText = m.mail_loading_msg_conversion();
email = await ReadMSG(result, true);
} else {
email = await ReadEML(result);
}
mailState.setParams(email);
sidebarOpen.set(false);
} catch (error) { const result = await openAndLoadEmail();
console.error("Failed to read EML file:", error);
toast.error(m.mail_error_opening()); if (result.cancelled) {
} finally { isLoading = false;
isLoading = false; loadingText = '';
loadingText = ""; return;
}
} else {
isLoading = false;
loadingText = "";
} }
if (result.success && result.email) {
mailState.setParams(result.email);
sidebarOpen.set(false);
} else if (result.error) {
console.error('Failed to read email file:', result.error);
toast.error(m.mail_error_opening());
}
isLoading = false;
loadingText = '';
} }
function arrayBufferToBase64(buffer: any): string { async function handleOpenPDF(base64Data: string, filename: string) {
if (typeof buffer === "string") return buffer; // Already base64 string await openPDFAttachment(base64Data, filename);
if (Array.isArray(buffer)) { }
let binary = "";
const bytes = new Uint8Array(buffer); async function handleOpenImage(base64Data: string, filename: string) {
const len = bytes.byteLength; await openImageAttachment(base64Data, filename);
for (let i = 0; i < len; i++) { }
binary += String.fromCharCode(bytes[i]);
} async function handleOpenEML(base64Data: string, filename: string) {
return window.btoa(binary); await openEMLAttachment(base64Data, filename);
}
return "";
} }
function handleWheel(event: WheelEvent) { function handleWheel(event: WheelEvent) {
@@ -218,6 +91,102 @@
event.preventDefault(); event.preventDefault();
} }
} }
// ============================================================================
// Effects
// ============================================================================
// Process email body when current email changes
$effect(() => {
const processCurrentEmail = async () => {
if (mailState.currentEmail?.body) {
const processedBody = await processEmailBody(mailState.currentEmail.body);
if (processedBody !== mailState.currentEmail.body) {
mailState.currentEmail.body = processedBody;
}
}
if (dev) {
console.debug('emailObj:', mailState.currentEmail);
}
console.info('Current email changed:', mailState.currentEmail?.subject);
if (mailState.currentEmail !== null) {
sidebarOpen.set(false);
}
};
processCurrentEmail();
});
// ============================================================================
// Lifecycle
// ============================================================================
onMount(async () => {
// Listen for second instance args (when another file is opened while app is running)
unregisterEvents = EventsOn('launchArgs', async (args: string[]) => {
console.log('got event launchArgs:', args);
if (!args || args.length === 0) return;
for (const arg of args) {
if (isEmailFile(arg)) {
console.log('Loading file from second instance:', arg);
isLoading = true;
loadingText = m.layout_loading_text();
// Check if MSG file for special loading text
if (arg.toLowerCase().endsWith('.msg')) {
loadingText = m.mail_loading_msg_conversion();
}
const result = await loadEmailFromPath(arg);
if (result.success && result.email) {
mailState.setParams(result.email);
sidebarOpen.set(false);
WindowUnminimise();
WindowShow();
} else if (result.error) {
console.error('Failed to load email:', result.error);
toast.error('Failed to load email file');
}
isLoading = false;
loadingText = '';
break;
}
}
});
});
onDestroy(() => {
if (unregisterEvents) {
unregisterEvents();
}
});
// ============================================================================
// Helpers
// ============================================================================
function getAttachmentClass(att: { contentType: string; filename: string }): string {
if (att.contentType.startsWith(CONTENT_TYPES.IMAGE)) return 'image';
if (att.contentType === CONTENT_TYPES.PDF || att.filename.toLowerCase().endsWith('.pdf'))
return 'pdf';
if (att.filename.toLowerCase().endsWith('.eml')) return 'eml';
return 'file';
}
function isPecSignature(filename: string, isPec: boolean): boolean {
return isPec && filename.toLowerCase().endsWith(PEC_FILES.SIGNATURE);
}
function isPecCertificate(filename: string, isPec: boolean): boolean {
return isPec && filename.toLowerCase() === PEC_FILES.CERTIFICATE;
}
</script> </script>
<div class="panel fill" aria-label="Events"> <div class="panel fill" aria-label="Events">
@@ -227,8 +196,10 @@
<div class="loading-text">{loadingText}</div> <div class="loading-text">{loadingText}</div>
</div> </div>
{/if} {/if}
<div class="events" role="log" aria-live="polite"> <div class="events" role="log" aria-live="polite">
{#if mailState.currentEmail === null} {#if mailState.currentEmail === null}
<!-- Empty State -->
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"> <div class="empty-icon">
<MailOpen size="48" strokeWidth={1} /> <MailOpen size="48" strokeWidth={1} />
@@ -239,7 +210,9 @@
</button> </button>
</div> </div>
{:else} {:else}
<!-- Email View -->
<div class="email-view"> <div class="email-view">
<!-- Header -->
<div class="email-header-content"> <div class="email-header-content">
<div class="subject-row"> <div class="subject-row">
<div class="email-subject"> <div class="email-subject">
@@ -253,7 +226,7 @@
title={m.mail_open_btn_title()} title={m.mail_open_btn_title()}
disabled={isLoading} disabled={isLoading}
> >
<MailOpen size="15" ></MailOpen> <MailOpen size="15" />
{m.mail_open_btn_text()} {m.mail_open_btn_text()}
</button> </button>
<button <button
@@ -269,77 +242,84 @@
</div> </div>
</div> </div>
<!-- Meta Grid -->
<div class="email-meta-grid"> <div class="email-meta-grid">
<span class="label">{m.mail_from()}</span> <span class="label">{m.mail_from()}</span>
<span class="value">{mailState.currentEmail.from}</span> <span class="value">{mailState.currentEmail.from}</span>
{#if mailState.currentEmail.to && mailState.currentEmail.to.length > 0} {#if mailState.currentEmail.to && mailState.currentEmail.to.length > 0}
<span class="label">{m.mail_to()}</span> <span class="label">{m.mail_to()}</span>
<span class="value">{mailState.currentEmail.to.join(", ")}</span> <span class="value">{mailState.currentEmail.to.join(', ')}</span>
{/if} {/if}
{#if mailState.currentEmail.cc && mailState.currentEmail.cc.length > 0} {#if mailState.currentEmail.cc && mailState.currentEmail.cc.length > 0}
<span class="label">{m.mail_cc()}</span> <span class="label">{m.mail_cc()}</span>
<span class="value">{mailState.currentEmail.cc.join(", ")}</span> <span class="value">{mailState.currentEmail.cc.join(', ')}</span>
{/if} {/if}
{#if mailState.currentEmail.bcc && mailState.currentEmail.bcc.length > 0} {#if mailState.currentEmail.bcc && mailState.currentEmail.bcc.length > 0}
<span class="label">{m.mail_bcc()}</span> <span class="label">{m.mail_bcc()}</span>
<span class="value">{mailState.currentEmail.bcc.join(", ")}</span> <span class="value">{mailState.currentEmail.bcc.join(', ')}</span>
{/if} {/if}
{#if mailState.currentEmail.isPec} {#if mailState.currentEmail.isPec}
<span class="label">{m.mail_sign_label()}</span> <span class="label">{m.mail_sign_label()}</span>
<span class="value"><span class="pec-badge" title="Posta Elettronica Certificata"> <span class="value">
<ShieldCheck size="14" /> <span class="pec-badge" title="Posta Elettronica Certificata">
PEC <ShieldCheck size="14" />
</span></span> PEC
</span>
</span>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Attachments -->
<div class="email-attachments"> <div class="email-attachments">
<span class="att-section-label">{m.mail_attachments()}</span> <span class="att-section-label">{m.mail_attachments()}</span>
<div class="att-list"> <div class="att-list">
{#if mailState.currentEmail.attachments && mailState.currentEmail.attachments.length > 0} {#if mailState.currentEmail.attachments && mailState.currentEmail.attachments.length > 0}
{#each mailState.currentEmail.attachments as att} {#each mailState.currentEmail.attachments as att}
{#if att.contentType.startsWith("image/")} {@const base64 = arrayBufferToBase64(att.data)}
{@const isImage = att.contentType.startsWith(CONTENT_TYPES.IMAGE)}
{@const isPdf =
att.contentType === CONTENT_TYPES.PDF ||
att.filename.toLowerCase().endsWith('.pdf')}
{@const isEml = att.filename.toLowerCase().endsWith('.eml')}
{@const isPecSig = isPecSignature(att.filename, mailState.currentEmail.isPec)}
{@const isPecCert = isPecCertificate(att.filename, mailState.currentEmail.isPec)}
{#if isImage}
<button <button
class="att-btn image" class="att-btn image"
onclick={() => openImageHandler(arrayBufferToBase64(att.data), att.filename)} onclick={() => handleOpenImage(base64, att.filename)}
> >
<Image size="14" /> <Image size="14" />
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</button> </button>
{:else if att.contentType === "application/pdf" || att.filename.toLowerCase().endsWith(".pdf")} {:else if isPdf}
<button <button class="att-btn pdf" onclick={() => handleOpenPDF(base64, att.filename)}>
class="att-btn pdf" <FileText size="14" />
onclick={() => openPDFHandler(arrayBufferToBase64(att.data), att.filename)}
>
<FileText />
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</button> </button>
{:else if att.filename.toLowerCase().endsWith(".eml")} {:else if isEml}
<button <button class="att-btn eml" onclick={() => handleOpenEML(base64, att.filename)}>
class="att-btn eml"
onclick={() => openEMLHandler(arrayBufferToBase64(att.data), att.filename)}
>
<MailOpen size="14" /> <MailOpen size="14" />
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</button> </button>
{:else if mailState.currentEmail.isPec && att.filename.toLowerCase().endsWith(".p7s")} {:else if isPecSig}
<a <a
class="att-btn file" class="att-btn file"
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`} href={createDataUrl(att.contentType, base64)}
download={att.filename} download={att.filename}
> >
<Signature size="14" /> <Signature size="14" />
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</a> </a>
{:else if mailState.currentEmail.isPec && att.filename.toLowerCase() === "daticert.xml"} {:else if isPecCert}
<a <a
class="att-btn file" class="att-btn file"
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`} href={createDataUrl(att.contentType, base64)}
download={att.filename} download={att.filename}
> >
<FileCode size="14" /> <FileCode size="14" />
@@ -348,14 +328,14 @@
{:else} {:else}
<a <a
class="att-btn file" class="att-btn file"
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`} href={createDataUrl(att.contentType, base64)}
download={att.filename} download={att.filename}
> >
{#if att.contentType.startsWith("image/")} {#if isImage}
<Image size="14" /> <Image size="14" />
{:else} {:else}
<File size="14" /> <File size="14" />
{/if} {/if}
<span class="att-name">{att.filename}</span> <span class="att-name">{att.filename}</span>
</a> </a>
{/if} {/if}
@@ -363,12 +343,13 @@
{:else} {:else}
<span class="att-empty">{m.mail_no_attachments()}</span> <span class="att-empty">{m.mail_no_attachments()}</span>
{/if} {/if}
</div>
</div> </div>
</div>
<!-- Email Body -->
<div class="email-body-wrapper"> <div class="email-body-wrapper">
<iframe <iframe
srcdoc={mailState.currentEmail.body + iFrameUtilHTML} srcdoc={mailState.currentEmail.body + IFRAME_UTIL_HTML}
title="Email Body" title="Email Body"
class="email-iframe" class="email-iframe"
sandbox="allow-same-origin allow-scripts" sandbox="allow-same-origin allow-scripts"
@@ -377,7 +358,7 @@
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
<style> <style>
@@ -397,14 +378,17 @@
gap: 16px; gap: 16px;
} }
/* Make sure internal loader spins if not using class-based animation library like Tailwind */
:global(.spinner) { :global(.spinner) {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
.loading-text { .loading-text {
@@ -494,8 +478,8 @@
} }
.subject-row .btn { .subject-row .btn {
height: 28px; height: 28px;
padding: 0 8px; padding: 0 8px;
} }
.email-meta-grid { .email-meta-grid {
@@ -563,14 +547,29 @@
color: #fff; color: #fff;
} }
.att-btn.image { color: #60a5fa; border-color: rgba(96, 165, 250, 0.3); } .att-btn.image {
.att-btn.image:hover { color: #93c5fd; } color: #60a5fa;
border-color: rgba(96, 165, 250, 0.3);
}
.att-btn.image:hover {
color: #93c5fd;
}
.att-btn.pdf { color: #f87171; border-color: rgba(248, 113, 113, 0.3); } .att-btn.pdf {
.att-btn.pdf:hover { color: #fca5a5; } color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
}
.att-btn.pdf:hover {
color: #fca5a5;
}
.att-btn.eml { color: hsl(49, 80%, 49%); border-color: rgba(224, 206, 39, 0.3); } .att-btn.eml {
.att-btn.eml:hover { color: hsl(49, 80%, 65%); } color: hsl(49, 80%, 49%);
border-color: rgba(224, 206, 39, 0.3);
}
.att-btn.eml:hover {
color: hsl(49, 80%, 65%);
}
.att-name { .att-name {
white-space: nowrap; white-space: nowrap;
@@ -633,7 +632,8 @@
border-color: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.25);
} }
.browse-btn:disabled, .btn:disabled { .browse-btn:disabled,
.btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
pointer-events: none; pointer-events: none;

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg leading-none font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -0,0 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
};

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
"data-slot": dataSlot = "textarea",
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
bind:value
{...restProps}
></textarea>

View File

@@ -5,6 +5,7 @@ const storedDebug = browser ? sessionStorage.getItem("debugWindowInSettings") ==
export const dangerZoneEnabled = writable<boolean>(storedDebug); export const dangerZoneEnabled = writable<boolean>(storedDebug);
export const unsavedChanges = writable<boolean>(false); export const unsavedChanges = writable<boolean>(false);
export const sidebarOpen = writable<boolean>(true); export const sidebarOpen = writable<boolean>(true);
export const bugReportDialogOpen = writable<boolean>(false);
export type AppEvent = { export type AppEvent = {
id: string; id: string;

View File

@@ -0,0 +1,94 @@
/**
* Handlers for opening different attachment types
*/
import {
OpenPDF,
OpenPDFWindow,
OpenImage,
OpenImageWindow,
OpenEMLWindow,
} from '$lib/wailsjs/go/main/App';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from 'svelte-sonner';
import * as m from '$lib/paraglide/messages';
export interface AttachmentHandlerResult {
success: boolean;
error?: string;
}
/**
* Opens a PDF attachment using either built-in or external viewer
* @param base64Data - Base64 encoded PDF data
* @param filename - Name of the PDF file
*/
export async function openPDFAttachment(
base64Data: string,
filename: string
): Promise<AttachmentHandlerResult> {
try {
if (settingsStore.settings.useBuiltinPDFViewer) {
await OpenPDFWindow(base64Data, filename);
} else {
await OpenPDF(base64Data, filename);
}
return { success: true };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Check if PDF is already open
if (errorMessage.includes(filename) && errorMessage.includes('already open')) {
toast.error(m.mail_pdf_already_open());
return { success: false, error: 'already_open' };
}
console.error('Failed to open PDF:', error);
toast.error(m.mail_error_pdf());
return { success: false, error: errorMessage };
}
}
/**
* Opens an image attachment using either built-in or external viewer
* @param base64Data - Base64 encoded image data
* @param filename - Name of the image file
*/
export async function openImageAttachment(
base64Data: string,
filename: string
): Promise<AttachmentHandlerResult> {
try {
if (settingsStore.settings.useBuiltinPreview) {
await OpenImageWindow(base64Data, filename);
} else {
await OpenImage(base64Data, filename);
}
return { success: true };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to open image:', error);
toast.error(m.mail_error_image());
return { success: false, error: errorMessage };
}
}
/**
* Opens an EML attachment in a new EMLy window
* @param base64Data - Base64 encoded EML data
* @param filename - Name of the EML file
*/
export async function openEMLAttachment(
base64Data: string,
filename: string
): Promise<AttachmentHandlerResult> {
try {
await OpenEMLWindow(base64Data, filename);
return { success: true };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to open EML:', error);
toast.error('Failed to open EML attachment');
return { success: false, error: errorMessage };
}
}

View File

@@ -0,0 +1,31 @@
/**
* HTML/CSS injected into the email body iframe for styling and security
* - Removes default body margins
* - Disables link clicking for security
* - Prevents Ctrl+Wheel zoom in iframe
*/
export const IFRAME_UTIL_HTML = `<style>body{margin:0;padding:20px;font-family:sans-serif;} a{pointer-events:none!important;cursor:default!important;}</style><script>function handleWheel(event){if(event.ctrlKey){event.preventDefault();}}document.addEventListener('wheel',handleWheel,{passive:false});<\/script>`;
/**
* Supported email file extensions
*/
export const EMAIL_EXTENSIONS = {
EML: '.eml',
MSG: '.msg',
} as const;
/**
* Attachment content type prefixes/patterns
*/
export const CONTENT_TYPES = {
IMAGE: 'image/',
PDF: 'application/pdf',
} as const;
/**
* Special PEC (Italian Certified Email) file names
*/
export const PEC_FILES = {
SIGNATURE: '.p7s',
CERTIFICATE: 'daticert.xml',
} as const;

View File

@@ -0,0 +1,77 @@
/**
* Utility functions for mail data conversion and processing
*/
/**
* Converts an ArrayBuffer or byte array to a base64 string
* @param buffer - The buffer to convert (can be string, array, or ArrayBuffer)
* @returns Base64 encoded string
*/
export function arrayBufferToBase64(buffer: unknown): string {
// Already a base64 string
if (typeof buffer === 'string') {
return buffer;
}
// Handle array of bytes
if (Array.isArray(buffer)) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
// Handle ArrayBuffer
if (buffer instanceof ArrayBuffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
return '';
}
/**
* Creates a data URL for downloading attachments
* @param contentType - MIME type of the attachment
* @param base64Data - Base64 encoded data
* @returns Data URL string
*/
export function createDataUrl(contentType: string, base64Data: string): string {
return `data:${contentType};base64,${base64Data}`;
}
/**
* Checks if a string looks like valid base64 content
* @param content - String to check
* @returns True if the content appears to be base64 encoded
*/
export function looksLikeBase64(content: string): boolean {
const clean = content.replace(/[\s\r\n]+/g, '');
return (
clean.length > 0 &&
clean.length % 4 === 0 &&
/^[A-Za-z0-9+/]+=*$/.test(clean)
);
}
/**
* Attempts to decode base64 content
* @param content - Base64 string to decode
* @returns Decoded string or null if decoding fails
*/
export function tryDecodeBase64(content: string): string | null {
try {
const clean = content.replace(/[\s\r\n]+/g, '');
return window.atob(clean);
} catch {
return null;
}
}

View File

@@ -0,0 +1,163 @@
/**
* Email loading and processing utilities
*/
import {
ReadEML,
ReadMSG,
ReadPEC,
ShowOpenFileDialog,
SetCurrentMailFilePath,
ConvertToUTF8,
} from '$lib/wailsjs/go/main/App';
import type { internal } from '$lib/wailsjs/go/models';
import { isBase64, isHtml } from '$lib/utils';
import { looksLikeBase64, tryDecodeBase64 } from './data-utils';
export interface LoadEmailResult {
success: boolean;
email?: internal.EmailData;
filePath?: string;
error?: string;
cancelled?: boolean;
}
/**
* Determines the email file type from the path
*/
export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
const lowerPath = filePath.toLowerCase();
if (lowerPath.endsWith('.eml')) return 'eml';
if (lowerPath.endsWith('.msg')) return 'msg';
return null;
}
/**
* Checks if a file path is a valid email file
*/
export function isEmailFile(filePath: string): boolean {
return getEmailFileType(filePath) !== null;
}
/**
* Loads an email from a file path
* @param filePath - Path to the email file
* @returns LoadEmailResult with the email data or error
*/
export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult> {
const fileType = getEmailFileType(filePath);
if (!fileType) {
return {
success: false,
error: 'Invalid file type. Only .eml and .msg files are supported.',
};
}
try {
let email: internal.EmailData;
if (fileType === 'msg') {
email = await ReadMSG(filePath, true);
} else {
// Try PEC first, fall back to regular EML
try {
email = await ReadPEC(filePath);
} catch {
email = await ReadEML(filePath);
}
}
// Process body if needed (decode base64)
if (email?.body) {
const trimmed = email.body.trim();
if (looksLikeBase64(trimmed)) {
const decoded = tryDecodeBase64(trimmed);
if (decoded) {
email.body = decoded;
}
}
}
return {
success: true,
email,
filePath,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to load email:', error);
return {
success: false,
error: errorMessage,
};
}
}
/**
* Opens a file dialog and loads the selected email
* @returns LoadEmailResult with the email data or error
*/
export async function openAndLoadEmail(): Promise<LoadEmailResult> {
try {
const filePath = await ShowOpenFileDialog();
if (!filePath || filePath.length === 0) {
return { success: false, cancelled: true };
}
const result = await loadEmailFromPath(filePath);
if (result.success && result.email) {
// Track the current mail file path for bug reports
await SetCurrentMailFilePath(filePath);
}
return result;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to open email file:', error);
return {
success: false,
error: errorMessage,
};
}
}
/**
* Processes and fixes the email body content
* - Decodes base64 content if needed
* - Fixes character encoding issues
* @param body - The raw email body
* @returns Processed body content
*/
export async function processEmailBody(body: string): Promise<string> {
if (!body) return body;
let content = body;
// 1. Try to decode if not HTML
if (!isHtml(content)) {
const clean = content.replace(/[\s\r\n]+/g, '');
if (isBase64(clean)) {
const decoded = tryDecodeBase64(clean);
if (decoded) {
content = decoded;
}
}
}
// 2. If it is HTML (original or decoded), try to fix encoding
if (isHtml(content)) {
try {
const fixed = await ConvertToUTF8(content);
if (fixed) {
content = fixed;
}
} catch (e) {
console.warn('Failed to fix encoding:', e);
}
}
return content;
}

View File

@@ -0,0 +1,37 @@
/**
* Mail utilities barrel export
*/
// Constants
export {
IFRAME_UTIL_HTML,
EMAIL_EXTENSIONS,
CONTENT_TYPES,
PEC_FILES,
} from './constants';
// Data utilities
export {
arrayBufferToBase64,
createDataUrl,
looksLikeBase64,
tryDecodeBase64,
} from './data-utils';
// Attachment handlers
export {
openPDFAttachment,
openImageAttachment,
openEMLAttachment,
type AttachmentHandlerResult,
} from './attachment-handlers';
// Email loader
export {
getEmailFileType,
isEmailFile,
loadEmailFromPath,
openAndLoadEmail,
processEmailBody,
type LoadEmailResult,
} from './email-loader';

View File

@@ -3,7 +3,7 @@
import { page, navigating } from "$app/state"; import { page, navigating } from "$app/state";
import { beforeNavigate, goto } from "$app/navigation"; import { beforeNavigate, goto } from "$app/navigation";
import { locales, localizeHref } from "$lib/paraglide/runtime"; import { locales, localizeHref } from "$lib/paraglide/runtime";
import { unsavedChanges, sidebarOpen } from "$lib/stores/app"; import { unsavedChanges, sidebarOpen, bugReportDialogOpen } from "$lib/stores/app";
import "../layout.css"; import "../layout.css";
import { onMount } from "svelte"; import { onMount } from "svelte";
import * as m from "$lib/paraglide/messages.js"; import * as m from "$lib/paraglide/messages.js";
@@ -17,10 +17,20 @@
PanelRightOpen, PanelRightOpen,
House, House,
Settings, Settings,
Bug,
Loader2,
Copy,
FolderOpen,
CheckCircle,
Camera,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { Separator } from "$lib/components/ui/separator/index.js"; import { Separator } from "$lib/components/ui/separator/index.js";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { buttonVariants } from "$lib/components/ui/button/index.js"; import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { import {
WindowMinimise, WindowMinimise,
@@ -30,7 +40,7 @@
Quit, Quit,
} from "$lib/wailsjs/runtime/runtime"; } from "$lib/wailsjs/runtime/runtime";
import { RefreshCcwDot } from "@lucide/svelte"; import { RefreshCcwDot } from "@lucide/svelte";
import { IsDebuggerRunning, QuitApp } from "$lib/wailsjs/go/main/App"; import { IsDebuggerRunning, QuitApp, TakeScreenshot, SubmitBugReport, OpenFolderInExplorer } from "$lib/wailsjs/go/main/App";
import { settingsStore } from "$lib/stores/settings.svelte.js"; import { settingsStore } from "$lib/stores/settings.svelte.js";
let versionInfo: utils.Config | null = $state(null); let versionInfo: utils.Config | null = $state(null);
@@ -38,6 +48,20 @@
let isDebugerOn: boolean = $state(false); let isDebugerOn: boolean = $state(false);
let isDebbugerProtectionOn: boolean = $state(true); let isDebbugerProtectionOn: boolean = $state(true);
// Bug report form state
let userName = $state("");
let userEmail = $state("");
let bugDescription = $state("");
// Bug report screenshot state
let screenshotData = $state("");
let isCapturing = $state(false);
// Bug report UI state
let isSubmitting = $state(false);
let isSuccess = $state(false);
let resultZipPath = $state("");
async function syncMaxState() { async function syncMaxState() {
isMaximized = await WindowIsMaximised(); isMaximized = await WindowIsMaximised();
} }
@@ -126,6 +150,92 @@
applyTheme(stored === "light" ? "light" : "dark"); applyTheme(stored === "light" ? "light" : "dark");
}); });
// Bug report dialog effects
$effect(() => {
if ($bugReportDialogOpen) {
// Capture screenshot immediately when dialog opens
captureScreenshot();
} else {
// Reset form when dialog closes
resetBugReportForm();
}
});
async function captureScreenshot() {
isCapturing = true;
try {
const result = await TakeScreenshot();
screenshotData = result.data;
console.log("Screenshot captured:", result.width, "x", result.height);
} catch (err) {
console.error("Failed to capture screenshot:", err);
} finally {
isCapturing = false;
}
}
function resetBugReportForm() {
userName = "";
userEmail = "";
bugDescription = "";
screenshotData = "";
isCapturing = false;
isSubmitting = false;
isSuccess = false;
resultZipPath = "";
}
async function handleBugReportSubmit(event: Event) {
event.preventDefault();
if (!bugDescription.trim()) {
toast.error("Please provide a bug description.");
return;
}
isSubmitting = true;
try {
const result = await SubmitBugReport({
name: userName,
email: userEmail,
description: bugDescription,
screenshotData: screenshotData
});
resultZipPath = result.zipPath;
isSuccess = true;
console.log("Bug report created:", result.zipPath);
} catch (err) {
console.error("Failed to create bug report:", err);
toast.error(m.bugreport_error());
} finally {
isSubmitting = false;
}
}
async function copyBugReportPath() {
try {
await navigator.clipboard.writeText(resultZipPath);
toast.success(m.bugreport_copied());
} catch (err) {
console.error("Failed to copy path:", err);
}
}
async function openBugReportFolder() {
try {
const folderPath = resultZipPath.replace(/\.zip$/, "");
await OpenFolderInExplorer(folderPath);
} catch (err) {
console.error("Failed to open folder:", err);
}
}
function closeBugReportDialog() {
$bugReportDialogOpen = false;
}
syncMaxState(); syncMaxState();
</script> </script>
@@ -246,6 +356,16 @@
class="hover:opacity-100 transition-opacity" class="hover:opacity-100 transition-opacity"
/> />
<Separator orientation="vertical" />
<Bug
size="16"
onclick={() => {
$bugReportDialogOpen = !$bugReportDialogOpen;
}}
style="cursor: pointer; opacity: 0.7;"
class="hover:opacity-100 transition-opacity"
/>
<a <a
data-sveltekit-reload data-sveltekit-reload
href="/" href="/"
@@ -256,6 +376,7 @@
> >
<RefreshCcwDot /> <RefreshCcwDot />
</a> </a>
</div> </div>
<div style="display:none"> <div style="display:none">
@@ -265,6 +386,135 @@
</a> </a>
{/each} {/each}
</div> </div>
<!-- Bug Report Dialog -->
<Dialog.Root bind:open={$bugReportDialogOpen}>
<Dialog.Content class="sm:max-w-[500px] w-full max-h-[80vh] overflow-y-auto custom-scrollbar">
{#if isSuccess}
<!-- Success State -->
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<CheckCircle class="h-5 w-5 text-green-500" />
{m.bugreport_success_title()}
</Dialog.Title>
<Dialog.Description>
{m.bugreport_success_message()}
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="bg-muted rounded-md p-3">
<code class="text-xs break-all select-all">{resultZipPath}</code>
</div>
<div class="flex gap-2">
<Button variant="outline" class="flex-1" onclick={copyBugReportPath}>
<Copy class="h-4 w-4 mr-2" />
{m.bugreport_copy_path()}
</Button>
<Button variant="outline" class="flex-1" onclick={openBugReportFolder}>
<FolderOpen class="h-4 w-4 mr-2" />
{m.bugreport_open_folder()}
</Button>
</div>
</div>
<Dialog.Footer>
<Button onclick={closeBugReportDialog}>
{m.bugreport_close()}
</Button>
</Dialog.Footer>
{:else}
<!-- Form State -->
<form onsubmit={handleBugReportSubmit}>
<Dialog.Header>
<Dialog.Title>{m.bugreport_title()}</Dialog.Title>
<Dialog.Description>
{m.bugreport_description()}
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="bug-name">{m.bugreport_name_label()}</Label>
<Input
id="bug-name"
placeholder={m.bugreport_name_placeholder()}
bind:value={userName}
disabled={isSubmitting}
/>
</div>
<div class="grid gap-2">
<Label for="bug-email">{m.bugreport_email_label()}</Label>
<Input
id="bug-email"
type="email"
placeholder={m.bugreport_email_placeholder()}
bind:value={userEmail}
disabled={isSubmitting}
/>
</div>
<div class="grid gap-2">
<Label for="bug-description">{m.bugreport_text_label()}</Label>
<Textarea
id="bug-description"
placeholder={m.bugreport_text_placeholder()}
bind:value={bugDescription}
disabled={isSubmitting}
class="min-h-[120px]"
/>
</div>
<!-- Screenshot Preview -->
<div class="grid gap-2">
<Label class="flex items-center gap-2">
<Camera class="h-4 w-4" />
{m.bugreport_screenshot_label()}
</Label>
{#if isCapturing}
<div class="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 class="h-4 w-4 animate-spin" />
Capturing...
</div>
{:else if screenshotData}
<div class="border rounded-md overflow-hidden">
<img
src="data:image/png;base64,{screenshotData}"
alt="Screenshot preview"
class="w-full h-32 object-cover object-top opacity-80 hover:opacity-100 transition-opacity cursor-pointer"
/>
</div>
{:else}
<div class="text-muted-foreground text-sm">
No screenshot available
</div>
{/if}
</div>
<p class="text-muted-foreground text-sm">
{m.bugreport_info()}
</p>
</div>
<Dialog.Footer>
<button type="button" class={buttonVariants({ variant: "outline" })} disabled={isSubmitting} onclick={closeBugReportDialog}>
{m.bugreport_cancel()}
</button>
<Button type="submit" disabled={isSubmitting || isCapturing}>
{#if isSubmitting}
<Loader2 class="h-4 w-4 mr-2 animate-spin" />
{m.bugreport_submitting()}
{:else}
{m.bugreport_submit()}
{/if}
</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
</div> </div>
<style> <style>
@@ -498,4 +748,26 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
:global(.custom-scrollbar::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
:global(.custom-scrollbar::-webkit-scrollbar-track) {
background: transparent;
}
:global(.custom-scrollbar::-webkit-scrollbar-thumb) {
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
:global(.custom-scrollbar::-webkit-scrollbar-thumb:hover) {
background: rgba(255, 255, 255, 0.2);
}
:global(.custom-scrollbar::-webkit-scrollbar-corner) {
background: transparent;
}
</style> </style>

View File

@@ -12,7 +12,7 @@
</script> </script>
<div class="page"> <div class="page">
<section class="center" aria-label="Overview"> <section class="center" aria-label="Overview" id="main-content-app">
<MailViewer /> <MailViewer />
</section> </section>
</div> </div>
@@ -36,26 +36,4 @@
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
::-webkit-scrollbar-corner {
background: transparent;
}
</style> </style>

View File

@@ -6,7 +6,7 @@
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import { Switch } from "$lib/components/ui/switch"; import { Switch } from "$lib/components/ui/switch";
import { ChevronLeft, Flame } from "@lucide/svelte"; import { ChevronLeft, Flame, Download, Upload } from "@lucide/svelte";
import type { EMLy_GUI_Settings } from "$lib/types"; import type { EMLy_GUI_Settings } from "$lib/types";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { It, Us } from "svelte-flags"; import { It, Us } from "svelte-flags";
@@ -25,6 +25,7 @@
import { setLocale } from "$lib/paraglide/runtime"; import { setLocale } from "$lib/paraglide/runtime";
import { mailState } from "$lib/stores/mail-state.svelte.js"; import { mailState } from "$lib/stores/mail-state.svelte.js";
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { ExportSettings, ImportSettings } from "$lib/wailsjs/go/main/App";
let { data } = $props(); let { data } = $props();
let config = $derived(data.config); let config = $derived(data.config);
@@ -173,6 +174,42 @@
previousDangerZoneEnabled = $dangerZoneEnabled; previousDangerZoneEnabled = $dangerZoneEnabled;
})(); })();
}); });
async function exportSettings() {
try {
const settingsJSON = JSON.stringify(form, null, 2);
const result = await ExportSettings(settingsJSON);
if (result) {
toast.success(m.settings_export_success());
}
} catch (err) {
console.error("Failed to export settings:", err);
toast.error(m.settings_export_error());
}
}
async function importSettings() {
try {
const result = await ImportSettings();
if (result) {
try {
const imported = JSON.parse(result) as EMLy_GUI_Settings;
// Validate that it looks like a valid settings object
if (typeof imported === 'object' && imported !== null) {
form = normalizeSettings(imported);
toast.success(m.settings_import_success());
} else {
toast.error(m.settings_import_invalid());
}
} catch {
toast.error(m.settings_import_invalid());
}
}
} catch (err) {
console.error("Failed to import settings:", err);
toast.error(m.settings_import_error());
}
}
</script> </script>
<div class="min-h-[calc(100vh-1rem)] from-background to-muted/30"> <div class="min-h-[calc(100vh-1rem)] from-background to-muted/30">
@@ -244,6 +281,52 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<Card.Root>
<Card.Header class="space-y-1">
<Card.Title>{m.settings_export_import_title()}</Card.Title>
<Card.Description>{m.settings_export_import_description()}</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4"
>
<div>
<div class="font-medium">{m.settings_export_button()}</div>
<div class="text-sm text-muted-foreground">
{m.settings_export_hint()}
</div>
</div>
<Button
variant="outline"
class="cursor-pointer hover:cursor-pointer"
onclick={exportSettings}
>
<Download class="size-4 mr-2" />
{m.settings_export_button()}
</Button>
</div>
<Separator />
<div
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4"
>
<div>
<div class="font-medium">{m.settings_import_button()}</div>
<div class="text-sm text-muted-foreground">
{m.settings_import_hint()}
</div>
</div>
<Button
variant="outline"
class="cursor-pointer hover:cursor-pointer"
onclick={importSettings}
>
<Upload class="size-4 mr-2" />
{m.settings_import_button()}
</Button>
</div>
</Card.Content>
</Card.Root>
<Card.Root> <Card.Root>
<Card.Header class="space-y-1"> <Card.Header class="space-y-1">
<Card.Title>{m.settings_preview_page_title()}</Card.Title> <Card.Title>{m.settings_preview_page_title()}</Card.Title>