Compare commits
6 Commits
d9e848d3f4
...
ea43cd715a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea43cd715a | ||
|
|
307966565a | ||
|
|
aef5c317df | ||
|
|
c0c1fbb08f | ||
|
|
f551efd5bf | ||
|
|
6a44eba7ca |
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal 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
842
DOCUMENTATION.md
Normal 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
567
app.go
@@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"emly/backend/utils"
|
||||
internal "emly/backend/utils/mail"
|
||||
|
||||
"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 {
|
||||
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
|
||||
openImagesMux sync.Mutex
|
||||
openImages map[string]bool
|
||||
openPDFsMux sync.Mutex
|
||||
openPDFs map[string]bool
|
||||
openEMLsMux sync.Mutex
|
||||
openEMLs map[string]bool
|
||||
|
||||
// CurrentMailFilePath tracks the currently loaded mail file path
|
||||
// Used for bug reports to include the relevant email file
|
||||
CurrentMailFilePath string
|
||||
|
||||
// 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 {
|
||||
return &App{
|
||||
openImages: make(map[string]bool),
|
||||
@@ -43,11 +63,22 @@ func NewApp() *App {
|
||||
}
|
||||
}
|
||||
|
||||
// startup is called when the app starts. The context is saved
|
||||
// so we can call the runtime methods
|
||||
// startup is called by Wails when the application starts.
|
||||
// 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) {
|
||||
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
|
||||
for _, arg := range os.Args {
|
||||
if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") {
|
||||
@@ -57,22 +88,45 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
|
||||
if isViewer {
|
||||
Log("Second instance launch")
|
||||
Log("Viewer instance started")
|
||||
} 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 {
|
||||
cfgPath := utils.DefaultConfigPath()
|
||||
cfg, err := utils.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
Log("Failed to load config for version:", err)
|
||||
Log("Failed to load config:", err)
|
||||
return nil
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// SaveConfig persists the provided configuration to config.ini.
|
||||
// Returns an error if saving fails.
|
||||
func (a *App) SaveConfig(cfg *utils.Config) error {
|
||||
cfgPath := utils.DefaultConfigPath()
|
||||
if err := utils.SaveConfig(cfgPath, cfg); err != nil {
|
||||
@@ -82,455 +136,44 @@ func (a *App) SaveConfig(cfg *utils.Config) error {
|
||||
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() {
|
||||
runtime.Quit(a.ctx)
|
||||
// Generate exit code 138
|
||||
os.Exit(133) // 133 + 5 (SIGTRAP)
|
||||
// SetCurrentMailFilePath updates the path of the currently loaded mail file.
|
||||
// This is called when the user opens a file via the file dialog.
|
||||
func (a *App) SetCurrentMailFilePath(filePath string) {
|
||||
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 {
|
||||
data, _ := utils.GetMachineInfo()
|
||||
return data
|
||||
}
|
||||
|
||||
// GetStartupFile returns the file path if the app was opened with a file argument
|
||||
func (a *App) GetStartupFile() string {
|
||||
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()
|
||||
}
|
||||
|
||||
// IsDebuggerRunning checks if a debugger is attached to the application.
|
||||
// Used for anti-debugging protection in production builds.
|
||||
func (a *App) IsDebuggerRunning() bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
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
281
app_bugreport.go
Normal 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
88
app_mail.go
Normal 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
164
app_screenshot.go
Normal 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
100
app_settings.go
Normal 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
146
app_system.go
Normal 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
429
app_viewer.go
Normal 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
|
||||
}
|
||||
259
backend/utils/screenshot_windows.go
Normal file
259
backend/utils/screenshot_windows.go
Normal 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)
|
||||
}
|
||||
@@ -89,5 +89,36 @@
|
||||
"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_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."
|
||||
}
|
||||
|
||||
@@ -89,5 +89,36 @@
|
||||
"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_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."
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
"vite-plugin-devtools-json": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/html2canvas": "^1.0.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"svelte-flags": "^3.0.1",
|
||||
"svelte-sonner": "^1.0.7"
|
||||
|
||||
@@ -1,216 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { X, MailOpen, Image, FileText, File, ShieldCheck, Shield, Signature, FileCode, Loader2 } from "@lucide/svelte";
|
||||
import { ShowOpenFileDialog, ReadEML, OpenPDF, OpenImageWindow, OpenPDFWindow, OpenImage, ReadMSG, ReadPEC, OpenEMLWindow, ConvertToUTF8 } from "$lib/wailsjs/go/main/App";
|
||||
import type { internal } from "$lib/wailsjs/go/models";
|
||||
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 { settingsStore } from "$lib/stores/settings.svelte";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
import { dev } from "$app/environment";
|
||||
import { isBase64, isHtml } from "$lib/utils";
|
||||
import {
|
||||
X,
|
||||
MailOpen,
|
||||
Image,
|
||||
FileText,
|
||||
File,
|
||||
ShieldCheck,
|
||||
Signature,
|
||||
FileCode,
|
||||
Loader2,
|
||||
} from '@lucide/svelte';
|
||||
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 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() {
|
||||
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() {
|
||||
isLoading = true;
|
||||
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) {
|
||||
console.error("Failed to read EML file:", error);
|
||||
toast.error(m.mail_error_opening());
|
||||
} finally {
|
||||
isLoading = false;
|
||||
loadingText = "";
|
||||
}
|
||||
} else {
|
||||
isLoading = false;
|
||||
loadingText = "";
|
||||
const result = await openAndLoadEmail();
|
||||
|
||||
if (result.cancelled) {
|
||||
isLoading = false;
|
||||
loadingText = '';
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (typeof buffer === "string") return buffer; // Already base64 string
|
||||
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);
|
||||
}
|
||||
return "";
|
||||
async function handleOpenPDF(base64Data: string, filename: string) {
|
||||
await openPDFAttachment(base64Data, filename);
|
||||
}
|
||||
|
||||
async function handleOpenImage(base64Data: string, filename: string) {
|
||||
await openImageAttachment(base64Data, filename);
|
||||
}
|
||||
|
||||
async function handleOpenEML(base64Data: string, filename: string) {
|
||||
await openEMLAttachment(base64Data, filename);
|
||||
}
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
@@ -218,6 +91,102 @@
|
||||
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>
|
||||
|
||||
<div class="panel fill" aria-label="Events">
|
||||
@@ -227,8 +196,10 @@
|
||||
<div class="loading-text">{loadingText}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="events" role="log" aria-live="polite">
|
||||
{#if mailState.currentEmail === null}
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<MailOpen size="48" strokeWidth={1} />
|
||||
@@ -239,7 +210,9 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Email View -->
|
||||
<div class="email-view">
|
||||
<!-- Header -->
|
||||
<div class="email-header-content">
|
||||
<div class="subject-row">
|
||||
<div class="email-subject">
|
||||
@@ -253,7 +226,7 @@
|
||||
title={m.mail_open_btn_title()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<MailOpen size="15" ></MailOpen>
|
||||
<MailOpen size="15" />
|
||||
{m.mail_open_btn_text()}
|
||||
</button>
|
||||
<button
|
||||
@@ -269,77 +242,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta Grid -->
|
||||
<div class="email-meta-grid">
|
||||
<span class="label">{m.mail_from()}</span>
|
||||
<span class="value">{mailState.currentEmail.from}</span>
|
||||
|
||||
{#if mailState.currentEmail.to && mailState.currentEmail.to.length > 0}
|
||||
<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 mailState.currentEmail.cc && mailState.currentEmail.cc.length > 0}
|
||||
<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 mailState.currentEmail.bcc && mailState.currentEmail.bcc.length > 0}
|
||||
<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 mailState.currentEmail.isPec}
|
||||
<span class="label">{m.mail_sign_label()}</span>
|
||||
<span class="value"><span class="pec-badge" title="Posta Elettronica Certificata">
|
||||
<ShieldCheck size="14" />
|
||||
PEC
|
||||
</span></span>
|
||||
<span class="value">
|
||||
<span class="pec-badge" title="Posta Elettronica Certificata">
|
||||
<ShieldCheck size="14" />
|
||||
PEC
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div class="email-attachments">
|
||||
<span class="att-section-label">{m.mail_attachments()}</span>
|
||||
<div class="att-list">
|
||||
{#if mailState.currentEmail.attachments && mailState.currentEmail.attachments.length > 0}
|
||||
{#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
|
||||
class="att-btn image"
|
||||
onclick={() => openImageHandler(arrayBufferToBase64(att.data), att.filename)}
|
||||
onclick={() => handleOpenImage(base64, att.filename)}
|
||||
>
|
||||
<Image size="14" />
|
||||
<span class="att-name">{att.filename}</span>
|
||||
</button>
|
||||
{:else if att.contentType === "application/pdf" || att.filename.toLowerCase().endsWith(".pdf")}
|
||||
<button
|
||||
class="att-btn pdf"
|
||||
onclick={() => openPDFHandler(arrayBufferToBase64(att.data), att.filename)}
|
||||
>
|
||||
<FileText />
|
||||
{:else if isPdf}
|
||||
<button class="att-btn pdf" onclick={() => handleOpenPDF(base64, att.filename)}>
|
||||
<FileText size="14" />
|
||||
<span class="att-name">{att.filename}</span>
|
||||
</button>
|
||||
{:else if att.filename.toLowerCase().endsWith(".eml")}
|
||||
<button
|
||||
class="att-btn eml"
|
||||
onclick={() => openEMLHandler(arrayBufferToBase64(att.data), att.filename)}
|
||||
>
|
||||
{:else if isEml}
|
||||
<button class="att-btn eml" onclick={() => handleOpenEML(base64, att.filename)}>
|
||||
<MailOpen size="14" />
|
||||
<span class="att-name">{att.filename}</span>
|
||||
</button>
|
||||
{:else if mailState.currentEmail.isPec && att.filename.toLowerCase().endsWith(".p7s")}
|
||||
{:else if isPecSig}
|
||||
<a
|
||||
class="att-btn file"
|
||||
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`}
|
||||
href={createDataUrl(att.contentType, base64)}
|
||||
download={att.filename}
|
||||
>
|
||||
<Signature size="14" />
|
||||
<span class="att-name">{att.filename}</span>
|
||||
</a>
|
||||
{:else if mailState.currentEmail.isPec && att.filename.toLowerCase() === "daticert.xml"}
|
||||
{:else if isPecCert}
|
||||
<a
|
||||
class="att-btn file"
|
||||
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`}
|
||||
href={createDataUrl(att.contentType, base64)}
|
||||
download={att.filename}
|
||||
>
|
||||
<FileCode size="14" />
|
||||
@@ -348,14 +328,14 @@
|
||||
{:else}
|
||||
<a
|
||||
class="att-btn file"
|
||||
href={`data:${att.contentType};base64,${arrayBufferToBase64(att.data)}`}
|
||||
href={createDataUrl(att.contentType, base64)}
|
||||
download={att.filename}
|
||||
>
|
||||
{#if att.contentType.startsWith("image/")}
|
||||
<Image size="14" />
|
||||
{:else}
|
||||
<File size="14" />
|
||||
{/if}
|
||||
{#if isImage}
|
||||
<Image size="14" />
|
||||
{:else}
|
||||
<File size="14" />
|
||||
{/if}
|
||||
<span class="att-name">{att.filename}</span>
|
||||
</a>
|
||||
{/if}
|
||||
@@ -363,12 +343,13 @@
|
||||
{:else}
|
||||
<span class="att-empty">{m.mail_no_attachments()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Body -->
|
||||
<div class="email-body-wrapper">
|
||||
<iframe
|
||||
srcdoc={mailState.currentEmail.body + iFrameUtilHTML}
|
||||
srcdoc={mailState.currentEmail.body + IFRAME_UTIL_HTML}
|
||||
title="Email Body"
|
||||
class="email-iframe"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
@@ -377,7 +358,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -397,14 +378,17 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Make sure internal loader spins if not using class-based animation library like Tailwind */
|
||||
:global(.spinner) {
|
||||
animation: spin 1s linear infinite;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
@@ -492,10 +476,10 @@
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.subject-row .btn {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.email-meta-grid {
|
||||
@@ -562,15 +546,30 @@
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.att-btn.image { 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:hover { color: #fca5a5; }
|
||||
.att-btn.image {
|
||||
color: #60a5fa;
|
||||
border-color: rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
.att-btn.image:hover {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.att-btn.eml { color: hsl(49, 80%, 49%); border-color: rgba(224, 206, 39, 0.3); }
|
||||
.att-btn.eml:hover { color: hsl(49, 80%, 65%); }
|
||||
.att-btn.pdf {
|
||||
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:hover {
|
||||
color: hsl(49, 80%, 65%);
|
||||
}
|
||||
|
||||
.att-name {
|
||||
white-space: nowrap;
|
||||
@@ -633,7 +632,8 @@
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.browse-btn:disabled, .btn:disabled {
|
||||
.browse-btn:disabled,
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -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} />
|
||||
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal 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}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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} />
|
||||
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal 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} />
|
||||
34
frontend/src/lib/components/ui/dialog/index.ts
Normal file
34
frontend/src/lib/components/ui/dialog/index.ts
Normal 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,
|
||||
};
|
||||
7
frontend/src/lib/components/ui/textarea/index.ts
Normal file
7
frontend/src/lib/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./textarea.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Textarea,
|
||||
};
|
||||
23
frontend/src/lib/components/ui/textarea/textarea.svelte
Normal file
23
frontend/src/lib/components/ui/textarea/textarea.svelte
Normal 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>
|
||||
@@ -5,6 +5,7 @@ const storedDebug = browser ? sessionStorage.getItem("debugWindowInSettings") ==
|
||||
export const dangerZoneEnabled = writable<boolean>(storedDebug);
|
||||
export const unsavedChanges = writable<boolean>(false);
|
||||
export const sidebarOpen = writable<boolean>(true);
|
||||
export const bugReportDialogOpen = writable<boolean>(false);
|
||||
|
||||
export type AppEvent = {
|
||||
id: string;
|
||||
|
||||
94
frontend/src/lib/utils/mail/attachment-handlers.ts
Normal file
94
frontend/src/lib/utils/mail/attachment-handlers.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
31
frontend/src/lib/utils/mail/constants.ts
Normal file
31
frontend/src/lib/utils/mail/constants.ts
Normal 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;
|
||||
77
frontend/src/lib/utils/mail/data-utils.ts
Normal file
77
frontend/src/lib/utils/mail/data-utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
163
frontend/src/lib/utils/mail/email-loader.ts
Normal file
163
frontend/src/lib/utils/mail/email-loader.ts
Normal 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;
|
||||
}
|
||||
37
frontend/src/lib/utils/mail/index.ts
Normal file
37
frontend/src/lib/utils/mail/index.ts
Normal 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';
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page, navigating } from "$app/state";
|
||||
import { beforeNavigate, goto } from "$app/navigation";
|
||||
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 { onMount } from "svelte";
|
||||
import * as m from "$lib/paraglide/messages.js";
|
||||
@@ -17,10 +17,20 @@
|
||||
PanelRightOpen,
|
||||
House,
|
||||
Settings,
|
||||
Bug,
|
||||
Loader2,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
CheckCircle,
|
||||
Camera,
|
||||
} from "@lucide/svelte";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
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 {
|
||||
WindowMinimise,
|
||||
@@ -30,7 +40,7 @@
|
||||
Quit,
|
||||
} from "$lib/wailsjs/runtime/runtime";
|
||||
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";
|
||||
|
||||
let versionInfo: utils.Config | null = $state(null);
|
||||
@@ -38,6 +48,20 @@
|
||||
let isDebugerOn: boolean = $state(false);
|
||||
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() {
|
||||
isMaximized = await WindowIsMaximised();
|
||||
}
|
||||
@@ -126,6 +150,92 @@
|
||||
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();
|
||||
</script>
|
||||
|
||||
@@ -246,6 +356,16 @@
|
||||
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
|
||||
data-sveltekit-reload
|
||||
href="/"
|
||||
@@ -256,6 +376,7 @@
|
||||
>
|
||||
<RefreshCcwDot />
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="display:none">
|
||||
@@ -265,6 +386,135 @@
|
||||
</a>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<style>
|
||||
@@ -498,4 +748,26 @@
|
||||
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>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<section class="center" aria-label="Overview">
|
||||
<section class="center" aria-label="Overview" id="main-content-app">
|
||||
<MailViewer />
|
||||
</section>
|
||||
</div>
|
||||
@@ -36,26 +36,4 @@
|
||||
flex-direction: column;
|
||||
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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { ChevronLeft, Flame } from "@lucide/svelte";
|
||||
import { ChevronLeft, Flame, Download, Upload } from "@lucide/svelte";
|
||||
import type { EMLy_GUI_Settings } from "$lib/types";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { It, Us } from "svelte-flags";
|
||||
@@ -25,6 +25,7 @@
|
||||
import { setLocale } from "$lib/paraglide/runtime";
|
||||
import { mailState } from "$lib/stores/mail-state.svelte.js";
|
||||
import { dev } from '$app/environment';
|
||||
import { ExportSettings, ImportSettings } from "$lib/wailsjs/go/main/App";
|
||||
|
||||
let { data } = $props();
|
||||
let config = $derived(data.config);
|
||||
@@ -173,6 +174,42 @@
|
||||
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>
|
||||
|
||||
<div class="min-h-[calc(100vh-1rem)] from-background to-muted/30">
|
||||
@@ -244,6 +281,52 @@
|
||||
</Card.Content>
|
||||
</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.Header class="space-y-1">
|
||||
<Card.Title>{m.settings_preview_page_title()}</Card.Title>
|
||||
|
||||
Reference in New Issue
Block a user