17 Commits

Author SHA1 Message Date
Lyz Coote
9b9e01b107 feat: refactor screenshot capture implementation and update UI reload labels 2026-02-18 16:58:48 +01:00
Lyz Coote
af7d8a0792 feat: implement application restart functionality and update UI reload options 2026-02-18 13:16:33 +01:00
be2c3b749c Merge pull request 'Merge Bug Report and Update System into main' (#1) from bug-report into main
Reviewed-on: #1
2026-02-17 11:23:09 +01:00
Flavio Fois
b106683712 feat: update version to 1.6.0 and enhance update system configuration 2026-02-17 11:21:26 +01:00
Flavio Fois
6a27663e72 feat: update TODO list with additional security fixes 2026-02-16 09:41:21 +01:00
Flavio Fois
828adcfcc2 feat: add heartbeat check for bug report API and enhance logging throughout the application 2026-02-16 08:54:29 +01:00
Flavio Fois
894e8d9e51 feat: implement user account management features including enable/disable functionality and real-time presence tracking 2026-02-16 07:43:31 +01:00
Flavio Fois
a89b18d434 feat: implement user management features including creation, updating, and deletion of users
- Added user management routes and logic in `+page.server.ts` for creating, updating, resetting passwords, and deleting users.
- Created a user management interface in `+page.svelte` with dialogs for user actions.
- Integrated password validation and hashing using `@node-rs/argon2`.
- Updated database schema to include a `user` table with necessary fields.
- Seeded a default admin user during database migration if no users exist.
- Added necessary dependencies in `package.json`.
2026-02-15 13:03:58 +01:00
Flavio Fois
1fd15a737b fix: update bug report API URL and adjust docker-compose configuration 2026-02-14 23:50:55 +01:00
Flavio Fois
b20b46d666 feat: configure network settings for services and add cloudflared service 2026-02-14 23:13:06 +01:00
Flavio Fois
40340ce32a fix: update package dependencies and adjust filter input width 2026-02-14 23:06:14 +01:00
Flavio Fois
0aaa026429 fix: update MySQL image to use the latest stable version 2026-02-14 23:03:06 +01:00
Flavio Fois
492db8fcf8 feat: initialize dashboard with bug reporting functionality
- Add HTML structure for the dashboard application.
- Create database schema for bug reports and associated files.
- Implement database connection using Drizzle ORM with MySQL.
- Add utility functions for class names, byte formatting, and date formatting.
- Create error handling page for the application.
- Implement layout and main page structure with navigation and report listing.
- Add server-side logic for loading reports with pagination and filtering.
- Create report detail page with metadata, description, and file attachments.
- Implement API endpoints for downloading reports and files, refreshing report counts, and managing report statuses.
- Set up SvelteKit configuration and TypeScript support.
- Configure Vite for SvelteKit and Tailwind CSS integration.
- Update Docker Compose configuration for the dashboard service.
- Create systemd service for managing the dashboard server.
2026-02-14 23:01:08 +01:00
Flavio Fois
c2052595cb feat: add refresh functionality for bug reports and improve pagination logic 2026-02-14 22:58:23 +01:00
Flavio Fois
c6c27f2f30 feat: initialize dashboard with bug reporting system
- Add Tailwind CSS for styling and custom theme variables.
- Create HTML structure for the dashboard with dark mode support.
- Implement database schema for bug reports and associated files using Drizzle ORM.
- Set up database connection with MySQL and environment variables.
- Create utility functions for class names, byte formatting, and date formatting.
- Develop error handling page for the dashboard.
- Implement layout and routing for the dashboard, including pagination and filtering for bug reports.
- Create API endpoints for downloading reports and files.
- Add functionality for viewing, updating, and deleting bug reports.
- Set up Docker configuration for the dashboard service.
- Include TypeScript configuration and Vite setup for the project.
2026-02-14 21:35:27 +01:00
Flavio Fois
d510c24b69 feat: implement bug report submission with server upload functionality
- Updated documentation to include new API server details and configuration options.
- Enhanced `SubmitBugReport` method to attempt server upload and handle errors gracefully.
- Added `UploadBugReport` method to handle multipart file uploads to the API server.
- Introduced new API server with MySQL backend for managing bug reports.
- Implemented rate limiting and authentication for the API.
- Created database schema and migration scripts for bug report storage.
- Added admin routes for managing bug reports and files.
- Updated frontend to reflect changes in bug report submission and success/error messages.
2026-02-14 21:07:53 +01:00
Flavio Fois
54a3dff1c2 Add TNEF handling and email loading improvements
- Implement TNEF extraction and recursive parsing in new `tnef_reader.go` and associated tests.
- Create tests for TNEF extraction scenarios in `tnef_diag_test.go`, `tnef_diag7_test.go`, and `tnef_diag8_test.go`.
2026-02-14 09:03:41 +01:00
213 changed files with 9295 additions and 196 deletions

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.gnu.org)",
"Bash(go run:*)",
"Bash(go build:*)",
"Bash(go doc:*)",
"Bash(go test:*)",
"WebFetch(domain:lucia-auth.com)",
"WebFetch(domain:v3.lucia-auth.com)",
"Bash(bun install:*)",
"Bash(bunx svelte-kit sync:*)",
"Bash(bun run check:*)"
]
}
}

279
AUDIT.md Normal file
View File

@@ -0,0 +1,279 @@
# EMLy Security Audit
**Date:** 2026-02-16
**Scope:** Main EMLy desktop application (Go backend + SvelteKit frontend). Server directory excluded.
---
## Critical (2)
### CRIT-1: API Key Committed to Repository
**File:** `config.ini:11`
`BUGREPORT_API_KEY` is in a tracked file and distributed with the binary. It is also returned to the frontend via `GetConfig()` and included in every bug report's `configData` field. Anyone who inspects the installed application directory, the repository, or the binary can extract this key.
**Risk:** Unauthorized access to the bug report API; potential abuse of any API endpoints authenticated by this key.
**Recommendation:** Rotate the key immediately. Stop distributing it in `config.ini`. Source it from an encrypted credential store or per-user environment variable. Strip it from the `GetConfig()` response to the frontend.
---
### CRIT-2: Path Traversal via Attachment Filename
**Files:** `app_viewer.go:83,153,223,285,321`
Email attachment filenames are used unsanitized in temp file paths. A malicious email could craft filenames like `../../malicious.exe` or absolute paths. `OpenPDFWindow` (line 223) is the worst offender: `filepath.Join(tempDir, filename)` with no timestamp prefix at all.
```go
// OpenPDFWindow — bare filename, no prefix
tempFile := filepath.Join(tempDir, filename)
// OpenImageWindow — timestamp prefix but still unsanitized
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename))
```
**Risk:** Overwriting arbitrary temp files; potential privilege escalation if a writable autorun target path can be hit.
**Recommendation:** Sanitize attachment filenames with `filepath.Base()` + a character allowlist `[a-zA-Z0-9._-]` before using them in temp paths.
---
## High (5)
### HIGH-1: Command Injection in `OpenURLInBrowser`
**File:** `app_system.go:156-159`
```go
func (a *App) OpenURLInBrowser(url string) error {
cmd := exec.Command("cmd", "/c", "start", "", url)
return cmd.Start()
}
```
Passes unsanitized URL to `cmd /c start`. A `file:///` URL or shell metacharacters (`&`, `|`) can execute arbitrary commands.
**Risk:** Arbitrary local file execution; command injection via crafted URL.
**Recommendation:** Validate that the URL uses `https://` scheme before passing it. Consider using `rundll32.exe url.dll,FileProtocolHandler` instead of `cmd /c start`.
---
### HIGH-2: Unsafe Path in `OpenFolderInExplorer`
**File:** `app_system.go:143-146`
```go
func (a *App) OpenFolderInExplorer(folderPath string) error {
cmd := exec.Command("explorer", folderPath)
return cmd.Start()
}
```
Raw frontend string passed to `explorer.exe` with no validation. This is a public Wails method callable from any frontend code.
**Risk:** Unexpected explorer behavior with crafted paths or UNC paths.
**Recommendation:** Validate that `folderPath` is a local directory path and exists before passing to explorer.
---
### HIGH-3: Iframe Sandbox Escape — Email Body XSS
**File:** `frontend/src/lib/components/MailViewer.svelte:387`
```svelte
<iframe
srcdoc={mailState.currentEmail.body + iframeUtilHtml}
sandbox="allow-same-origin allow-scripts"
/>
```
`allow-scripts` + `allow-same-origin` together allow embedded email scripts to remove the sandbox attribute entirely and access the parent Wails window + all Go backend bindings. MDN explicitly warns against this combination.
**Risk:** Full XSS in the Wails WebView context; arbitrary Go backend method invocation from a malicious email.
**Recommendation:** Remove `allow-same-origin` from the iframe sandbox. Replace `iframeUtilHtml` script injection with a `postMessage`-based approach from the parent so `allow-scripts` can also be removed entirely.
---
### HIGH-4: Arbitrary Code Execution via `InstallUpdateSilentFromPath`
**File:** `app_update.go:573-643`
This exposed Wails method accepts an arbitrary UNC/local path from the frontend, copies the binary to temp, and executes it with UAC elevation (`/ALLUSERS`). There is no signature verification, no path allowlist, and no checksum validation.
**Risk:** Any attacker who can call this method (e.g., via XSS from HIGH-3) can execute any binary with administrator rights.
**Recommendation:** Restrict to validated inputs — check that installer paths match a known allowlist or have a valid code signature before execution.
---
### HIGH-5: Race Condition on `updateStatus`
**File:** `app_update.go:55-65`
```go
var updateStatus = UpdateStatus{ ... }
```
Global mutable variable accessed from multiple goroutines (startup check goroutine, frontend calls to `CheckForUpdates`, `DownloadUpdate`, `GetUpdateStatus`, `InstallUpdateSilent`) without any mutex protection. TOCTOU races possible on `Ready`/`InstallerPath` fields.
**Risk:** Installing from an empty path; checking stale ready status; data corruption.
**Recommendation:** Protect `updateStatus` with a `sync.RWMutex` or replace with an atomic struct/channel-based state machine.
---
## Medium (7)
### MED-1: API Key Leaked in Bug Reports
**Files:** `frontend/src/lib/components/BugReportDialog.svelte:92-101`, `logger.go:72-81`
`captureConfig()` calls `GetConfig()` and serializes the entire config including `BUGREPORT_API_KEY` into `configData`. This is sent to the remote API, written to `config.json` in the temp folder, and included in the zip. The `FrontendLog` function also logs all frontend output verbatim — any accidental `console.log(config)` would write the key to the log file.
**Recommendation:** Filter out `BUGREPORT_API_KEY` before serializing config data. Redact sensitive fields in `FrontendLog`.
---
### MED-2: No TLS Validation on API Requests
**Files:** `app_heartbeat.go:28-37`, `app_bugreport.go:376-384`
Both HTTP clients use the default transport with no certificate pinning and no enforcement of minimum TLS versions. The API URL from `config.ini` is not validated to be HTTPS before making requests. Bug report uploads contain PII (name, email, hostname, HWID, screenshot, email file) and the API key header.
**Recommendation:** Validate that `apiURL` starts with `https://`. Consider certificate pinning for the bug report API.
---
### MED-3: Raw Frontend String Written to Disk
**File:** `app_settings.go:31-63`
`ExportSettings` writes the raw `settingsJSON` string from the frontend to any user-chosen path with no content validation. A compromised frontend (e.g., via HIGH-3 XSS) could write arbitrary content.
**Recommendation:** Validate that `settingsJSON` is well-formed JSON matching the expected settings schema before writing.
---
### MED-4: Imported Settings Not Schema-Validated
**Files:** `app_settings.go:73-100`, `frontend/src/lib/stores/settings.svelte.ts:37`
Imported settings JSON is merged into the settings store via spread operator without schema validation. An attacker-supplied settings file could manipulate `enableAttachedDebuggerProtection` or inject unexpected values.
**Recommendation:** Validate imported JSON against the `EMLy_GUI_Settings` schema. Reject unknown keys.
---
### MED-5: `isEmailFile` Accepts Any String
**File:** `frontend/src/lib/utils/mail/email-loader.ts:42-44`
```typescript
export function isEmailFile(filePath: string): boolean {
return filePath.trim().length > 0;
}
```
Any non-empty path passes validation and is sent to the Go backend for parsing, including paths to executables or sensitive files.
**Recommendation:** Check file extension against `EMAIL_EXTENSIONS` before passing to backend.
---
### MED-6: PATH Hijacking via `wmic` and `reg`
**File:** `backend/utils/machine-identifier.go:75-99`
`wmic` and `reg.exe` are resolved via PATH. If PATH is manipulated, a malicious binary could be executed instead. `wmic` is also deprecated since Windows 10 21H1.
**Recommendation:** Use full paths (`C:\Windows\System32\wbem\wmic.exe`, `C:\Windows\System32\reg.exe`) or replace with native Go syscalls/WMI COM interfaces.
---
### MED-7: Log File Grows Unboundedly
**File:** `logger.go:35`
The log file is opened in append mode with no size limit, rotation, or truncation. Frontend console output is forwarded to the logger, so verbose emails or a tight log loop can fill disk.
**Recommendation:** Implement log rotation (e.g., max 10MB, keep 3 rotated files) or use a library like `lumberjack`.
---
## Low (7)
### LOW-1: Temp Files Written with `0644` Permissions
**Files:** `app_bugreport.go`, `app_viewer.go`, `app_screenshot.go`
All temp files (screenshots, mail copies, attachments) are written with `0644`. Sensitive email content in predictable temp paths (`emly_bugreport_<timestamp>`) could be read by other processes.
**Recommendation:** Use `0600` for temp files containing sensitive content.
---
### LOW-2: Log Injection via `FrontendLog`
**File:** `logger.go:72-81`
`level` and `message` are user-supplied with no sanitization. Newlines in `message` can inject fake log entries. No rate limiting.
**Recommendation:** Strip newlines from `message`. Consider rate-limiting frontend log calls.
---
### LOW-3: `OpenPDFWindow` File Collision
**File:** `app_viewer.go:223`
Unlike other viewer methods, `OpenPDFWindow` uses the bare filename with no timestamp prefix. Two PDFs with the same name silently overwrite each other.
**Recommendation:** Add a timestamp prefix consistent with the other viewer methods.
---
### LOW-4: Single-Instance Lock Exposes File Path
**File:** `main.go:46-50`
Lock ID includes the full file path, which becomes a named mutex visible system-wide. Other processes can enumerate it to discover what files are being viewed.
**Recommendation:** Hash the file path before using it in the lock ID.
---
### LOW-5: External IP via Unauthenticated HTTP
**File:** `backend/utils/machine-identifier.go:134-147`
External IP fetched from `api.ipify.org` without certificate pinning. A MITM can spoof the IP. The request also reveals EMLy usage to the third-party service.
**Recommendation:** Consider making external IP lookup optional or using multiple sources.
---
### LOW-6: `GetConfig()` Exposes API Key to Frontend
**File:** `app.go:150-158`
Public Wails method returns the full `Config` struct including `BugReportAPIKey`. Any frontend JavaScript can retrieve it.
**Recommendation:** Create a `GetSafeConfig()` that omits sensitive fields, or strip the API key from the returned struct.
---
### LOW-7: Attachment Filenames Not Sanitized in Zip
**File:** `app_bugreport.go:422-465`
Email attachment filenames copied into the bug report folder retain their original names (possibly containing traversal sequences). These appear in the zip archive sent to the server.
**Recommendation:** Sanitize filenames with `filepath.Base()` before copying into the bug report folder.
---
## Info (4)
### INFO-1: `allow-same-origin` Could Be Removed from Iframe
**File:** `frontend/src/lib/components/MailViewer.svelte:387`
If `iframeUtilHtml` script injection were replaced with `postMessage`, both `allow-scripts` and `allow-same-origin` could be removed entirely.
### INFO-2: Unnecessary `cmd.exe` Shell Invocation
**File:** `app_system.go:92-94`
`ms-settings:` URIs can be launched via `rundll32.exe url.dll,FileProtocolHandler` without invoking `cmd.exe`, reducing shell attack surface.
### INFO-3: `unsafe.Pointer` Without Size Guards
**Files:** `backend/utils/file-metadata.go:115`, `backend/utils/screenshot_windows.go:94-213`
Cast to `[1 << 20]uint16` array and slicing by `valLen` is potentially out-of-bounds if the Windows API returns an unexpected length.
### INFO-4: No Memory Limits on Email Parsing
**Files:** `backend/utils/mail/mailparser.go`, `eml_reader.go`
All email parts read into memory via `io.ReadAll` with no size limit. A malicious `.eml` with a gigabyte-sized attachment would exhaust process memory. Consider `io.LimitReader`.

View File

@@ -1,5 +1,12 @@
# Changelog EMLy
## 1.6.0 (2026-02-17)
1) Implementazione in sviluppo del sistema di aggiornamento automatico e manuale, con supporto per canali di rilascio (stable/beta) e gestione delle versioni. (Ancora non attivo di default, in fase di test)
## 1.5.5 (2026-02-14)
1) Aggiunto il supporto al caricamento dei bug report su un server esterno, per facilitare la raccolta e gestione dei report da parte degli sviluppatori. (Con fallback locale in caso di errore)
2) Aggiunto il supporto alle mail con formato TNEF/winmail.dat, per estrarre gli allegati correttamente.
## 1.5.4 (2026-02-10)
1) Aggiunti i pulsanti "Download" al MailViewer, PDF e Image viewer, per scaricare il file invece di aprirlo direttamente.
2) Refactor del sistema di bug report.

View File

@@ -81,6 +81,7 @@ EMLy/
├── app_viewer.go # Viewer window management (image, PDF, EML)
├── app_screenshot.go # Screenshot capture functionality
├── app_bugreport.go # Bug report creation and submission
├── app_heartbeat.go # Bug report API heartbeat check
├── app_settings.go # Settings import/export
├── app_system.go # Windows system utilities (registry, encoding)
├── main.go # Application entry point
@@ -199,6 +200,7 @@ The Go backend is split into logical files:
| `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_heartbeat.go` | API heartbeat: `CheckBugReportAPI` |
| `app_settings.go` | Settings I/O: `ExportSettings`, `ImportSettings` |
| `app_system.go` | System utilities: `CheckIsDefaultEMLHandler`, `OpenDefaultAppsSettings`, `ConvertToUTF8`, `OpenFolderInExplorer` |
| `app_update.go` | Update system: `CheckForUpdates`, `DownloadUpdate`, `InstallUpdate`, `GetUpdateStatus` |
@@ -252,7 +254,9 @@ The Go backend is split into logical files:
| Method | Description |
|--------|-------------|
| `CreateBugReportFolder()` | Creates folder with screenshot and mail file |
| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive |
| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive, attempts server upload |
| `UploadBugReport(folderPath, input)` | Uploads bug report files to configured API server via multipart POST |
| `CheckBugReportAPI()` | Checks if the bug report API is reachable via /health endpoint (3s timeout) |
**Settings (`app_settings.go`)**
@@ -672,7 +676,54 @@ Complete bug reporting system:
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
6. Checks if the bug report API is online via heartbeat (`CheckBugReportAPI`)
7. If online, attempts to upload to the bug report API server
8. Falls back to local ZIP if server is offline or upload fails
9. Shows server confirmation with report ID, or local path with upload warning
#### Heartbeat Check (`app_heartbeat.go`)
Before uploading a bug report, the app sends a GET request to `{BUGREPORT_API_URL}/health` with a 3-second timeout. If the API doesn't respond with status 200, the upload is skipped entirely and only the local ZIP is created. The `CheckBugReportAPI()` method is also exposed to the frontend for UI status checks.
#### Bug Report API Server
A separate API server (`server/` directory) receives bug reports:
- **Stack**: Bun.js + ElysiaJS + MySQL 8
- **Deployment**: Docker Compose (`docker compose up -d` from `server/`)
- **Auth**: Static API key for clients (`X-API-Key`), static admin key (`X-Admin-Key`)
- **Rate limiting**: HWID-based, configurable (default 5 reports per 24h)
- **Logging**: Structured file logging to `logs/api.log` with format `[date] - [time] - [source] - message`
- **Endpoints**: `POST /api/bug-reports` (client), `GET/DELETE /api/admin/bug-reports` (admin)
#### Bug Report Dashboard
A web dashboard (`dashboard/` directory) for browsing, triaging, and downloading bug reports:
- **Stack**: SvelteKit (Svelte 5) + TailwindCSS v4 + Drizzle ORM + Bun.js
- **Deployment**: Docker service in `server/docker-compose.yml`, port 3001
- **Database**: Connects directly to the same MySQL database via Drizzle ORM (read/write)
- **Features**:
- Paginated reports list with status filter and search (hostname, user, name, email)
- Report detail view with metadata, description, system info (collapsible JSON), and file list
- Status management (new → in_review → resolved → closed)
- Inline screenshot preview for attached screenshots
- Individual file download and bulk ZIP download (all files + report metadata)
- Report deletion with confirmation dialog
- Dark mode UI matching EMLy's aesthetic
- **Authentication**: Session-based auth with Lucia v3 + Drizzle ORM adapter
- Default admin account: username `admin`, password `admin` (seeded on first migration)
- Password hashing with argon2 via `@node-rs/argon2`
- Session cookies with automatic refresh
- Role-based access: `admin` and `user` roles
- **User Management**: Admin-only `/users` page for creating/deleting dashboard users
- **Development**: `cd dashboard && bun install && bun dev` (localhost:3001)
#### Configuration (config.ini)
```ini
[EMLy]
BUGREPORT_API_URL="https://your-server.example.com"
BUGREPORT_API_KEY="your-api-key"
```
### 5. Settings Management
@@ -920,6 +971,30 @@ In dev mode (`wails dev`):
---
## Dashboard Features
### ZIP File Upload
The dashboard supports uploading `.zip` files created by EMLy's `SubmitBugReport` feature when the API upload fails. Accessible via the "Upload ZIP" button on the reports list page, it parses `report.txt` (name, email, description), `system_info.txt` (hostname, OS, HWID, IP), and imports all attached files (screenshots, mail files, localStorage, config) into the database as a new bug report.
**API Endpoint**: `POST /api/reports/upload` - Accepts multipart form data with a `.zip` file.
### User Enable/Disable
Admins can temporarily disable user accounts without deleting them. Disabled users cannot log in and active sessions are invalidated. The `user` table has an `enabled` BOOLEAN column (default TRUE). Toggle is available in the Users management page. Restrictions: admins cannot disable themselves or other admin users.
### Active Users / Presence Tracking
Real-time presence tracking using Server-Sent Events (SSE). Connected users are tracked in-memory with heartbeat updates every 15 seconds. The layout header shows avatar indicators for other active users with tooltips showing what they're viewing. The report detail page shows who else is currently viewing the same report.
**Endpoints**:
- `GET /api/presence` - SSE stream for real-time presence updates
- `POST /api/presence/heartbeat` - Client heartbeat with current page/report info
**Client Store**: `$lib/stores/presence.svelte.ts` - Svelte 5 reactive store managing SSE connection and heartbeats.
---
## License & Credits
EMLy is developed by FOISX @ 3gIT.

10
TODO.md
View File

@@ -6,7 +6,15 @@
# Existing Features
- [ ] Add seperated "Updater" binary, that will start on User login (via Scheduled Task), with a silent install mode.
- [x] Attach localStorage, config file to the "Bug Reporter" ZIP file, to investigate the issue with the user enviroment.
- [ ] Auto-send the "Bug Reporter" ZIP file to the support team, to investigate the issue with the user enviroment.
- [x] Auto-send the "Bug Reporter" ZIP file to the support team, to investigate the issue with the user enviroment.
# Bugs
- [ ] Missing i18n for Toast notifications (to investigate)
# Security
- [ ] Fix HIGH-1
- [ ] Fix HIGH-2
- [ ] Fix MED-3
- [ ] Fix MED-4
- [ ] Fix MED-7

27
app.go
View File

@@ -4,8 +4,10 @@ package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"strings"
"sync"
"time"
@@ -141,6 +143,31 @@ func (a *App) QuitApp() {
os.Exit(133)
}
// RestartApp performs a full application restart, including the Go backend.
// It schedules a new process via PowerShell with a short delay to ensure the
// single-instance lock is released before the new instance starts, then exits.
func (a *App) RestartApp() error {
exe, err := os.Executable()
if err != nil {
Log("RestartApp: failed to get executable path:", err)
return err
}
// Escape single quotes in the path for PowerShell string literal
safePath := strings.ReplaceAll(exe, "'", "''")
script := fmt.Sprintf(`Start-Sleep -Seconds 1; Start-Process '%s'`, safePath)
cmd := exec.Command("powershell", "-WindowStyle", "Hidden", "-Command", script)
if err := cmd.Start(); err != nil {
Log("RestartApp: failed to schedule restart:", err)
return err
}
Log("RestartApp: scheduled restart, quitting current instance...")
runtime.Quit(a.ctx)
return nil
}
// =============================================================================
// Configuration Management
// =============================================================================

View File

@@ -5,8 +5,13 @@ package main
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
@@ -50,6 +55,12 @@ type SubmitBugReportResult struct {
ZipPath string `json:"zipPath"`
// FolderPath is the path to the bug report folder
FolderPath string `json:"folderPath"`
// Uploaded indicates whether the report was successfully uploaded to the server
Uploaded bool `json:"uploaded"`
// ReportID is the server-assigned report ID (0 if not uploaded)
ReportID int64 `json:"reportId"`
// UploadError contains the error message if upload failed (empty on success)
UploadError string `json:"uploadError"`
}
// =============================================================================
@@ -233,10 +244,166 @@ External IP: %s
return nil, fmt.Errorf("failed to create zip file: %w", err)
}
return &SubmitBugReportResult{
result := &SubmitBugReportResult{
ZipPath: zipPath,
FolderPath: bugReportFolder,
}, nil
}
// Attempt to upload to the bug report API server (only if reachable)
if !a.CheckBugReportAPI() {
Log("Bug report API is offline, skipping upload")
result.UploadError = "Bug report API is offline"
} else {
reportID, uploadErr := a.UploadBugReport(bugReportFolder, input)
if uploadErr != nil {
Log("Bug report upload failed (falling back to local zip):", uploadErr)
result.UploadError = uploadErr.Error()
} else {
result.Uploaded = true
result.ReportID = reportID
}
}
return result, nil
}
// UploadBugReport uploads the bug report files from the temp folder to the
// configured API server. Returns the server-assigned report ID on success.
//
// Parameters:
// - folderPath: Path to the bug report folder containing the files
// - input: Original bug report input with user details
//
// Returns:
// - int64: Server-assigned report ID
// - error: Error if upload fails or API is not configured
func (a *App) UploadBugReport(folderPath string, input BugReportInput) (int64, error) {
// Load config to get API URL and key
cfgPath := utils.DefaultConfigPath()
cfg, err := utils.LoadConfig(cfgPath)
if err != nil {
return 0, fmt.Errorf("failed to load config: %w", err)
}
apiURL := cfg.EMLy.BugReportAPIURL
apiKey := cfg.EMLy.BugReportAPIKey
if apiURL == "" {
return 0, fmt.Errorf("bug report API URL not configured")
}
if apiKey == "" {
return 0, fmt.Errorf("bug report API key not configured")
}
// Build multipart form
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// Add text fields
writer.WriteField("name", input.Name)
writer.WriteField("email", input.Email)
writer.WriteField("description", input.Description)
// Add machine identification fields
machineInfo, err := utils.GetMachineInfo()
if err == nil && machineInfo != nil {
writer.WriteField("hwid", machineInfo.HWID)
writer.WriteField("hostname", machineInfo.Hostname)
// Add system_info as JSON string
sysInfoJSON, jsonErr := json.Marshal(machineInfo)
if jsonErr == nil {
writer.WriteField("system_info", string(sysInfoJSON))
}
}
// Add current OS username
if currentUser, userErr := os.UserHomeDir(); userErr == nil {
writer.WriteField("os_user", filepath.Base(currentUser))
}
// Add files from the folder
fileRoles := map[string]string{
"screenshot": "screenshot",
"mail_file": "mail_file",
"localStorage.json": "localstorage",
"config.json": "config",
}
entries, _ := os.ReadDir(folderPath)
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
// Determine file role
var role string
for pattern, r := range fileRoles {
if filename == pattern {
role = r
break
}
}
// Match screenshot and mail files by prefix/extension
if role == "" {
if filepath.Ext(filename) == ".png" {
role = "screenshot"
} else if filepath.Ext(filename) == ".eml" || filepath.Ext(filename) == ".msg" {
role = "mail_file"
}
}
if role == "" {
continue // skip report.txt and system_info.txt (sent as fields)
}
filePath := filepath.Join(folderPath, filename)
fileData, readErr := os.ReadFile(filePath)
if readErr != nil {
continue
}
part, partErr := writer.CreateFormFile(role, filename)
if partErr != nil {
continue
}
part.Write(fileData)
}
writer.Close()
// Send HTTP request
endpoint := apiURL + "/api/bug-reports"
req, err := http.NewRequest("POST", endpoint, &buf)
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-API-Key", apiKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated {
return 0, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var response struct {
Success bool `json:"success"`
ReportID int64 `json:"report_id"`
}
if err := json.Unmarshal(body, &response); err != nil {
return 0, fmt.Errorf("failed to parse response: %w", err)
}
return response.ReportID, nil
}
// =============================================================================

45
app_heartbeat.go Normal file
View File

@@ -0,0 +1,45 @@
// Package main provides heartbeat checking for the bug report API.
package main
import (
"fmt"
"net/http"
"time"
"emly/backend/utils"
)
// CheckBugReportAPI sends a GET request to the bug report API's /health
// endpoint with a short timeout. Returns true if the API responds with
// status 200, false otherwise. This is exposed to the frontend.
func (a *App) CheckBugReportAPI() bool {
cfgPath := utils.DefaultConfigPath()
cfg, err := utils.LoadConfig(cfgPath)
if err != nil {
Log("Heartbeat: failed to load config:", err)
return false
}
apiURL := cfg.EMLy.BugReportAPIURL
if apiURL == "" {
Log("Heartbeat: bug report API URL not configured")
return false
}
endpoint := apiURL + "/health"
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(endpoint)
if err != nil {
Log("Heartbeat: API unreachable:", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
Log(fmt.Sprintf("Heartbeat: API returned status %d", resp.StatusCode))
return false
}
return true
}

View File

@@ -73,6 +73,47 @@ func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) {
return internal.ReadMsgFile(filePath)
}
// DetectEmailFormat inspects the file's binary content to determine its format,
// regardless of the file extension. Returns "eml", "msg", or "unknown".
//
// Parameters:
// - filePath: Absolute path to the file to inspect
//
// Returns:
// - string: Detected format ("eml", "msg", or "unknown")
// - error: Any file I/O errors
func (a *App) DetectEmailFormat(filePath string) (string, error) {
format, err := internal.DetectEmailFormat(filePath)
return string(format), err
}
// ReadAuto automatically detects the email file format from its binary content
// and delegates to the appropriate reader (ReadEML/ReadPEC for EML, ReadMSG for MSG).
//
// Parameters:
// - filePath: Absolute path to the email file
//
// Returns:
// - *internal.EmailData: Parsed email data
// - error: Any parsing or detection errors
func (a *App) ReadAuto(filePath string) (*internal.EmailData, error) {
format, err := internal.DetectEmailFormat(filePath)
if err != nil {
return nil, err
}
switch format {
case internal.FormatMSG:
return internal.ReadMsgFile(filePath)
default: // FormatEML or FormatUnknown try PEC first, fall back to plain EML
data, err := internal.ReadPecInnerEml(filePath)
if err != nil {
return internal.ReadEmlFile(filePath)
}
return data, nil
}
}
// ShowOpenFileDialog displays the system file picker dialog filtered for email files.
// This allows users to browse and select .eml or .msg files to open.
//

View File

@@ -81,15 +81,11 @@ func (a *App) CheckForUpdates() (UpdateStatus, error) {
updateStatus.LastCheckTime = time.Now().Format("2006-01-02 15:04:05")
runtime.EventsEmit(a.ctx, "update:status", updateStatus)
defer func() {
updateStatus.Checking = false
runtime.EventsEmit(a.ctx, "update:status", updateStatus)
}()
// Get current version from config
config := a.GetConfig()
if config == nil {
updateStatus.ErrorMessage = "Failed to load configuration"
updateStatus.Checking = false
return updateStatus, fmt.Errorf("failed to load config")
}
@@ -99,6 +95,7 @@ func (a *App) CheckForUpdates() (UpdateStatus, error) {
// Check if updates are enabled
if config.EMLy.UpdateCheckEnabled != "true" {
updateStatus.ErrorMessage = "Update checking is disabled"
updateStatus.Checking = false
return updateStatus, fmt.Errorf("update checking is disabled in config")
}
@@ -106,6 +103,7 @@ func (a *App) CheckForUpdates() (UpdateStatus, error) {
updatePath := strings.TrimSpace(config.EMLy.UpdatePath)
if updatePath == "" {
updateStatus.ErrorMessage = "Update path not configured"
updateStatus.Checking = false
return updateStatus, fmt.Errorf("UPDATE_PATH is empty in config.ini")
}
@@ -113,6 +111,7 @@ func (a *App) CheckForUpdates() (UpdateStatus, error) {
manifest, err := a.loadUpdateManifest(updatePath)
if err != nil {
updateStatus.ErrorMessage = fmt.Sprintf("Failed to load manifest: %v", err)
updateStatus.Checking = false
return updateStatus, err
}
@@ -150,6 +149,7 @@ func (a *App) CheckForUpdates() (UpdateStatus, error) {
updateStatus.CurrentVersion, currentChannel)
}
updateStatus.Checking = false
return updateStatus, nil
}

View File

@@ -22,6 +22,8 @@ type EMLyConfig struct {
UpdateCheckEnabled string `ini:"UPDATE_CHECK_ENABLED"`
UpdatePath string `ini:"UPDATE_PATH"`
UpdateAutoCheck string `ini:"UPDATE_AUTO_CHECK"`
BugReportAPIURL string `ini:"BUGREPORT_API_URL"`
BugReportAPIKey string `ini:"BUGREPORT_API_KEY"`
}
// LoadConfig reads the config.ini file at the given path and returns a Config struct

View File

@@ -146,6 +146,9 @@ func ReadEmlFile(filePath string) (*EmailData, error) {
})
}
// Expand any TNEF (winmail.dat) attachments into their contained files.
attachments = expandTNEFAttachments(attachments)
isPec := hasDatiCert && hasSmime
// Format From
@@ -267,6 +270,9 @@ func ReadPecInnerEml(filePath string) (*EmailData, error) {
})
}
// Expand any TNEF (winmail.dat) attachments into their contained files.
attachments = expandTNEFAttachments(attachments)
isPec := hasDatiCert && hasSmime
// Format From

View File

@@ -0,0 +1,47 @@
package internal
import (
"bytes"
"os"
)
// EmailFormat represents the detected format of an email file.
type EmailFormat string
const (
FormatEML EmailFormat = "eml"
FormatMSG EmailFormat = "msg"
FormatUnknown EmailFormat = "unknown"
)
// msgMagic is the OLE2/CFB compound file header signature used by .msg files.
var msgMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
// DetectEmailFormat identifies the email file format by inspecting the file's
// binary magic bytes, regardless of the file extension.
//
// Supported formats:
// - "msg": Microsoft Outlook MSG (OLE2/CFB compound file)
// - "eml": Standard MIME email (RFC 5322)
// - "unknown": Could not determine format
func DetectEmailFormat(filePath string) (EmailFormat, error) {
f, err := os.Open(filePath)
if err != nil {
return FormatUnknown, err
}
defer f.Close()
buf := make([]byte, 8)
n, err := f.Read(buf)
if err != nil || n < 1 {
return FormatUnknown, nil
}
// MSG files start with the OLE2 Compound File Binary magic bytes.
if n >= 8 && bytes.Equal(buf[:8], msgMagic) {
return FormatMSG, nil
}
// EML files are plain-text MIME messages; assume EML for anything else.
return FormatEML, nil
}

View File

@@ -0,0 +1,58 @@
package internal
import (
"bytes"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/teamwork/tnef"
)
func TestTNEFAttributes(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
f, _ := os.Open(testFile)
defer f.Close()
outerEmail, _ := Parse(f)
var innerData []byte
for _, att := range outerEmail.Attachments {
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
innerData, _ = io.ReadAll(att.Data)
break
}
}
innerEmail, _ := Parse(bytes.NewReader(innerData))
for _, att := range innerEmail.Attachments {
data, _ := io.ReadAll(att.Data)
if strings.ToLower(att.Filename) != "winmail.dat" {
continue
}
decoded, _ := tnef.Decode(data)
fmt.Printf("MAPI Attributes (%d):\n", len(decoded.Attributes))
for _, attr := range decoded.Attributes {
dataPreview := fmt.Sprintf("%d bytes", len(attr.Data))
if len(attr.Data) < 200 {
dataPreview = fmt.Sprintf("%q", attr.Data)
}
fmt.Printf(" Name=0x%04X Data=%s\n", attr.Name, dataPreview)
}
// Check Body/BodyHTML from TNEF data struct fields
fmt.Printf("\nBody len: %d\n", len(decoded.Body))
fmt.Printf("BodyHTML len: %d\n", len(decoded.BodyHTML))
// Check attachment details
for i, ta := range decoded.Attachments {
fmt.Printf("Attachment[%d]: title=%q dataLen=%d\n", i, ta.Title, len(ta.Data))
}
}
}

View File

@@ -0,0 +1,67 @@
package internal
import (
"bytes"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/teamwork/tnef"
)
func TestTNEFAllSizes(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
f, _ := os.Open(testFile)
defer f.Close()
outerEmail, _ := Parse(f)
var innerData []byte
for _, att := range outerEmail.Attachments {
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
innerData, _ = io.ReadAll(att.Data)
break
}
}
innerEmail, _ := Parse(bytes.NewReader(innerData))
for _, att := range innerEmail.Attachments {
data, _ := io.ReadAll(att.Data)
if strings.ToLower(att.Filename) != "winmail.dat" {
continue
}
decoded, _ := tnef.Decode(data)
totalAttrSize := 0
for _, attr := range decoded.Attributes {
totalAttrSize += len(attr.Data)
fmt.Printf(" Attr 0x%04X: %d bytes\n", attr.Name, len(attr.Data))
}
totalAttSize := 0
for _, ta := range decoded.Attachments {
totalAttSize += len(ta.Data)
}
fmt.Printf("\nTotal TNEF data: %d bytes\n", len(data))
fmt.Printf("Total attribute data: %d bytes\n", totalAttrSize)
fmt.Printf("Total attachment data: %d bytes\n", totalAttSize)
fmt.Printf("Accounted: %d bytes\n", totalAttrSize+totalAttSize)
fmt.Printf("Missing: %d bytes\n", len(data)-totalAttrSize-totalAttSize)
// Try raw decode to check for nested message/attachment objects
fmt.Printf("\nBody: %d, BodyHTML: %d\n", len(decoded.Body), len(decoded.BodyHTML))
// Check attachment[0] content
if len(decoded.Attachments) > 0 {
a0 := decoded.Attachments[0]
fmt.Printf("\nAttachment[0] Title=%q Data (hex): %x\n", a0.Title, a0.Data)
}
}
}

View File

@@ -0,0 +1,78 @@
package internal
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"os"
"strings"
"testing"
)
func TestTNEFRawScan(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
f, _ := os.Open(testFile)
defer f.Close()
outerEmail, _ := Parse(f)
var innerData []byte
for _, att := range outerEmail.Attachments {
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
innerData, _ = io.ReadAll(att.Data)
break
}
}
innerEmail, _ := Parse(bytes.NewReader(innerData))
for _, att := range innerEmail.Attachments {
data, _ := io.ReadAll(att.Data)
if strings.ToLower(att.Filename) != "winmail.dat" {
continue
}
fmt.Printf("TNEF raw size: %d bytes\n", len(data))
// Verify signature
if len(data) < 6 {
t.Fatal("too short")
}
sig := binary.LittleEndian.Uint32(data[0:4])
key := binary.LittleEndian.Uint16(data[4:6])
fmt.Printf("Signature: 0x%08X Key: 0x%04X\n", sig, key)
offset := 6
attrNum := 0
for offset < len(data) {
if offset+9 > len(data) {
fmt.Printf(" Truncated at offset %d\n", offset)
break
}
level := data[offset]
attrID := binary.LittleEndian.Uint32(data[offset+1 : offset+5])
attrLen := binary.LittleEndian.Uint32(data[offset+5 : offset+9])
levelStr := "MSG"
if level == 0x02 {
levelStr = "ATT"
}
fmt.Printf(" [%03d] offset=%-8d level=%s id=0x%08X len=%d\n",
attrNum, offset, levelStr, attrID, attrLen)
// Move past: level(1) + id(4) + len(4) + data(attrLen) + checksum(2)
offset += 1 + 4 + 4 + int(attrLen) + 2
attrNum++
if attrNum > 200 {
fmt.Println(" ... stopping at 200 attributes")
break
}
}
}
}

View File

@@ -0,0 +1,241 @@
package internal
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"os"
"strings"
"testing"
)
func TestTNEFMapiProps(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
f, _ := os.Open(testFile)
defer f.Close()
outerEmail, _ := Parse(f)
var innerData []byte
for _, att := range outerEmail.Attachments {
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
innerData, _ = io.ReadAll(att.Data)
break
}
}
innerEmail, _ := Parse(bytes.NewReader(innerData))
for _, att := range innerEmail.Attachments {
rawData, _ := io.ReadAll(att.Data)
if strings.ToLower(att.Filename) != "winmail.dat" {
continue
}
// Navigate to the first attachment's attAttachment (0x9005) block
// From the raw scan: [011] offset=12082 + header(9bytes) = 12091 for data
// Actually let's re-scan to find it properly
offset := 6
for offset < len(rawData) {
if offset+9 > len(rawData) {
break
}
level := rawData[offset]
attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5])
attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9]))
dataStart := offset + 9
// attAttachment = 0x00069005, we want the FIRST one (for attachment group 1)
if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 {
fmt.Printf("Found attAttachment at offset %d, len=%d\n", offset, attrLen)
parseMapiProps(rawData[dataStart:dataStart+attrLen], t)
break
}
offset += 9 + attrLen + 2
}
}
}
func parseMapiProps(data []byte, t *testing.T) {
if len(data) < 4 {
t.Fatal("too short for MAPI props")
}
count := binary.LittleEndian.Uint32(data[0:4])
fmt.Printf("MAPI property count: %d\n", count)
offset := 4
for i := 0; i < int(count) && offset+4 <= len(data); i++ {
propTag := binary.LittleEndian.Uint32(data[offset : offset+4])
propType := propTag & 0xFFFF
propID := (propTag >> 16) & 0xFFFF
offset += 4
// Handle named properties (ID >= 0x8000)
if propID >= 0x8000 {
// Skip GUID (16 bytes) + kind (4 bytes)
if offset+20 > len(data) {
break
}
kind := binary.LittleEndian.Uint32(data[offset+16 : offset+20])
offset += 20
if kind == 0 { // MNID_ID
offset += 4 // skip NamedID
} else { // MNID_STRING
if offset+4 > len(data) {
break
}
nameLen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4 + nameLen
// Pad to 4-byte boundary
if nameLen%4 != 0 {
offset += 4 - nameLen%4
}
}
}
var valueSize int
switch propType {
case 0x0002: // PT_SHORT
valueSize = 4 // padded to 4
case 0x0003: // PT_LONG
valueSize = 4
case 0x000B: // PT_BOOLEAN
valueSize = 4
case 0x0040: // PT_SYSTIME
valueSize = 8
case 0x001E: // PT_STRING8
if offset+4 > len(data) {
return
}
// count=1, then length, then data padded
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(data) {
return
}
slen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
strData := ""
if offset+slen <= len(data) && slen < 200 {
strData = string(data[offset : offset+slen])
}
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X STRING8 len=%d val=%q\n", i, propID, propType, slen, strData)
offset += slen
if slen%4 != 0 {
offset += 4 - slen%4
}
}
continue
case 0x001F: // PT_UNICODE
if offset+4 > len(data) {
return
}
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(data) {
return
}
slen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X UNICODE len=%d\n", i, propID, propType, slen)
offset += slen
if slen%4 != 0 {
offset += 4 - slen%4
}
}
continue
case 0x0102: // PT_BINARY
if offset+4 > len(data) {
return
}
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(data) {
return
}
blen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X BINARY len=%d\n", i, propID, propType, blen)
offset += blen
if blen%4 != 0 {
offset += 4 - blen%4
}
}
continue
case 0x000D: // PT_OBJECT
if offset+4 > len(data) {
return
}
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(data) {
return
}
olen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X OBJECT len=%d\n", i, propID, propType, olen)
// Peek at first 16 bytes (GUID)
if offset+16 <= len(data) {
fmt.Printf(" GUID: %x\n", data[offset:offset+16])
}
offset += olen
if olen%4 != 0 {
offset += 4 - olen%4
}
}
continue
case 0x1003: // PT_MV_LONG
if offset+4 > len(data) {
return
}
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_LONG count=%d\n", i, propID, propType, cnt)
offset += cnt * 4
continue
case 0x1102: // PT_MV_BINARY
if offset+4 > len(data) {
return
}
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
totalSize := 0
for j := 0; j < cnt; j++ {
if offset+4 > len(data) {
return
}
blen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
offset += 4
totalSize += blen
offset += blen
if blen%4 != 0 {
offset += 4 - blen%4
}
}
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_BINARY count=%d totalSize=%d\n", i, propID, propType, cnt, totalSize)
continue
default:
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X (unknown type)\n", i, propID, propType)
return
}
if valueSize > 0 {
if propType == 0x0003 && offset+4 <= len(data) {
val := binary.LittleEndian.Uint32(data[offset : offset+4])
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X LONG val=%d (0x%X)\n", i, propID, propType, val, val)
} else {
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X size=%d\n", i, propID, propType, valueSize)
}
offset += valueSize
}
}
}

View File

@@ -0,0 +1,209 @@
package internal
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/teamwork/tnef"
)
func TestTNEFNestedMessage(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
f, _ := os.Open(testFile)
defer f.Close()
outerEmail, _ := Parse(f)
var innerData []byte
for _, att := range outerEmail.Attachments {
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
innerData, _ = io.ReadAll(att.Data)
break
}
}
innerEmail, _ := Parse(bytes.NewReader(innerData))
for _, att := range innerEmail.Attachments {
rawData, _ := io.ReadAll(att.Data)
if strings.ToLower(att.Filename) != "winmail.dat" {
continue
}
// Navigate to attAttachment (0x9005) for first attachment
offset := 6
for offset < len(rawData) {
if offset+9 > len(rawData) {
break
}
level := rawData[offset]
attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5])
attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9]))
dataStart := offset + 9
if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 {
mapiData := rawData[dataStart : dataStart+attrLen]
// Parse MAPI props to find PR_ATTACH_DATA_OBJ (0x3701)
embeddedData := extractPRAttachDataObj(mapiData)
if embeddedData == nil {
t.Fatal("could not find PR_ATTACH_DATA_OBJ")
}
fmt.Printf("PR_ATTACH_DATA_OBJ total: %d bytes\n", len(embeddedData))
fmt.Printf("First 32 bytes after GUID: %x\n", embeddedData[16:min2(48, len(embeddedData))])
// Check if after the 16-byte GUID there's a TNEF signature
afterGuid := embeddedData[16:]
if len(afterGuid) >= 4 {
sig := binary.LittleEndian.Uint32(afterGuid[0:4])
fmt.Printf("Signature after GUID: 0x%08X (TNEF=0x223E9F78)\n", sig)
if sig == 0x223E9F78 {
fmt.Println("It's a nested TNEF stream!")
decoded, err := tnef.Decode(afterGuid)
if err != nil {
fmt.Printf("Nested TNEF decode error: %v\n", err)
} else {
fmt.Printf("Nested Body: %d bytes\n", len(decoded.Body))
fmt.Printf("Nested BodyHTML: %d bytes\n", len(decoded.BodyHTML))
fmt.Printf("Nested Attachments: %d\n", len(decoded.Attachments))
for i, na := range decoded.Attachments {
fmt.Printf(" [%d] %q (%d bytes)\n", i, na.Title, len(na.Data))
}
fmt.Printf("Nested Attributes: %d\n", len(decoded.Attributes))
}
} else {
// Try as raw MAPI attributes (no TNEF wrapper)
fmt.Printf("Not a TNEF stream. First byte: 0x%02X\n", afterGuid[0])
// Check if it's a count of MAPI properties
if len(afterGuid) >= 4 {
propCount := binary.LittleEndian.Uint32(afterGuid[0:4])
fmt.Printf("First uint32 (possible prop count): %d\n", propCount)
}
}
}
break
}
offset += 9 + attrLen + 2
}
}
}
func extractPRAttachDataObj(mapiData []byte) []byte {
if len(mapiData) < 4 {
return nil
}
count := int(binary.LittleEndian.Uint32(mapiData[0:4]))
offset := 4
for i := 0; i < count && offset+4 <= len(mapiData); i++ {
propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4])
propType := propTag & 0xFFFF
propID := (propTag >> 16) & 0xFFFF
offset += 4
// Handle named props
if propID >= 0x8000 {
if offset+20 > len(mapiData) {
return nil
}
kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20])
offset += 20
if kind == 0 {
offset += 4
} else {
if offset+4 > len(mapiData) {
return nil
}
nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4 + nameLen
if nameLen%4 != 0 {
offset += 4 - nameLen%4
}
}
}
switch propType {
case 0x0002: // PT_SHORT
offset += 4
case 0x0003: // PT_LONG
offset += 4
case 0x000B: // PT_BOOLEAN
offset += 4
case 0x0040: // PT_SYSTIME
offset += 8
case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE
if offset+4 > len(mapiData) {
return nil
}
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(mapiData) {
return nil
}
slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4 + slen
if slen%4 != 0 {
offset += 4 - slen%4
}
}
case 0x0102: // PT_BINARY
if offset+4 > len(mapiData) {
return nil
}
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(mapiData) {
return nil
}
blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4 + blen
if blen%4 != 0 {
offset += 4 - blen%4
}
}
case 0x000D: // PT_OBJECT
if offset+4 > len(mapiData) {
return nil
}
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(mapiData) {
return nil
}
olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4
if propID == 0x3701 {
// This is PR_ATTACH_DATA_OBJ!
return mapiData[offset : offset+olen]
}
offset += olen
if olen%4 != 0 {
offset += 4 - olen%4
}
}
default:
return nil
}
}
return nil
}
func min2(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,273 @@
package internal
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/teamwork/tnef"
)
func TestTNEFRecursiveExtract(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
f, _ := os.Open(testFile)
defer f.Close()
outerEmail, _ := Parse(f)
var innerData []byte
for _, att := range outerEmail.Attachments {
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
innerData, _ = io.ReadAll(att.Data)
break
}
}
innerEmail, _ := Parse(bytes.NewReader(innerData))
for _, att := range innerEmail.Attachments {
rawData, _ := io.ReadAll(att.Data)
if strings.ToLower(att.Filename) != "winmail.dat" {
continue
}
fmt.Println("=== Level 0 (top TNEF) ===")
atts, body := recursiveExtract(rawData, 0)
fmt.Printf("\nTotal extracted attachments: %d\n", len(atts))
for i, a := range atts {
fmt.Printf(" [%d] %q (%d bytes)\n", i, a.Title, len(a.Data))
}
fmt.Printf("Body HTML len: %d\n", len(body))
if len(body) > 0 && len(body) < 500 {
fmt.Printf("Body: %s\n", body)
}
}
}
func recursiveExtract(tnefData []byte, depth int) ([]*tnef.Attachment, string) {
prefix := strings.Repeat(" ", depth)
decoded, err := tnef.Decode(tnefData)
if err != nil {
fmt.Printf("%sDecode error: %v\n", prefix, err)
return nil, ""
}
// Collect body
bodyHTML := string(decoded.BodyHTML)
bodyText := string(decoded.Body)
// Check for RTF body in attributes
for _, attr := range decoded.Attributes {
if attr.Name == 0x1009 {
fmt.Printf("%sFound PR_RTF_COMPRESSED: %d bytes\n", prefix, len(attr.Data))
}
if attr.Name == 0x1000 {
fmt.Printf("%sFound PR_BODY: %d bytes\n", prefix, len(attr.Data))
if bodyText == "" {
bodyText = string(attr.Data)
}
}
if attr.Name == 0x1013 || attr.Name == 0x1035 {
fmt.Printf("%sFound PR_BODY_HTML/PR_HTML: %d bytes\n", prefix, len(attr.Data))
if bodyHTML == "" {
bodyHTML = string(attr.Data)
}
}
}
fmt.Printf("%sAttachments: %d, Body: %d, BodyHTML: %d\n",
prefix, len(decoded.Attachments), len(bodyText), len(bodyHTML))
var allAttachments []*tnef.Attachment
// Collect real attachments (skip placeholders)
for _, a := range decoded.Attachments {
if a.Title == "Untitled Attachment" && len(a.Data) < 200 {
fmt.Printf("%sSkipping placeholder: %q (%d bytes)\n", prefix, a.Title, len(a.Data))
continue
}
allAttachments = append(allAttachments, a)
}
// Now scan for embedded messages in raw TNEF
embeddedStreams := findEmbeddedTNEFStreams(tnefData)
for i, stream := range embeddedStreams {
fmt.Printf("%s--- Recursing into embedded message %d (%d bytes) ---\n", prefix, i, len(stream))
subAtts, subBody := recursiveExtract(stream, depth+1)
allAttachments = append(allAttachments, subAtts...)
if bodyHTML == "" && subBody != "" {
bodyHTML = subBody
}
}
if bodyHTML != "" {
return allAttachments, bodyHTML
}
return allAttachments, bodyText
}
func findEmbeddedTNEFStreams(tnefData []byte) [][]byte {
var streams [][]byte
// Navigate through TNEF attributes
offset := 6
for offset+9 < len(tnefData) {
level := tnefData[offset]
attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5])
attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9]))
dataStart := offset + 9
if dataStart+attrLen > len(tnefData) {
break
}
// attAttachment (0x9005) at attachment level
if level == 0x02 && attrID == 0x00069005 && attrLen > 100 {
mapiData := tnefData[dataStart : dataStart+attrLen]
embedded := extractPRAttachDataObj2(mapiData)
if embedded != nil && len(embedded) > 22 {
// Skip 16-byte GUID, check for TNEF signature
afterGuid := embedded[16:]
if len(afterGuid) >= 4 {
sig := binary.LittleEndian.Uint32(afterGuid[0:4])
if sig == 0x223E9F78 {
streams = append(streams, afterGuid)
}
}
}
}
offset += 9 + attrLen + 2
}
return streams
}
func extractPRAttachDataObj2(mapiData []byte) []byte {
if len(mapiData) < 4 {
return nil
}
count := int(binary.LittleEndian.Uint32(mapiData[0:4]))
offset := 4
for i := 0; i < count && offset+4 <= len(mapiData); i++ {
propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4])
propType := propTag & 0xFFFF
propID := (propTag >> 16) & 0xFFFF
offset += 4
if propID >= 0x8000 {
if offset+20 > len(mapiData) {
return nil
}
kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20])
offset += 20
if kind == 0 {
offset += 4
} else {
if offset+4 > len(mapiData) {
return nil
}
nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4 + nameLen
if nameLen%4 != 0 {
offset += 4 - nameLen%4
}
}
}
switch propType {
case 0x0002:
offset += 4
case 0x0003:
offset += 4
case 0x000B:
offset += 4
case 0x0040:
offset += 8
case 0x001E, 0x001F:
if offset+4 > len(mapiData) {
return nil
}
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(mapiData) {
return nil
}
slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4 + slen
if slen%4 != 0 {
offset += 4 - slen%4
}
}
case 0x0102:
if offset+4 > len(mapiData) {
return nil
}
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(mapiData) {
return nil
}
blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4 + blen
if blen%4 != 0 {
offset += 4 - blen%4
}
}
case 0x000D:
if offset+4 > len(mapiData) {
return nil
}
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(mapiData) {
return nil
}
olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4
if propID == 0x3701 {
return mapiData[offset : offset+olen]
}
offset += olen
if olen%4 != 0 {
offset += 4 - olen%4
}
}
case 0x1003:
if offset+4 > len(mapiData) {
return nil
}
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4 + cnt*4
case 0x1102:
if offset+4 > len(mapiData) {
return nil
}
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4
for j := 0; j < cnt; j++ {
if offset+4 > len(mapiData) {
return nil
}
blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
offset += 4 + blen
if blen%4 != 0 {
offset += 4 - blen%4
}
}
default:
return nil
}
}
return nil
}

View File

@@ -0,0 +1,97 @@
package internal
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/teamwork/tnef"
)
func TestTNEFDeepAttachment(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
f, _ := os.Open(testFile)
defer f.Close()
outerEmail, _ := Parse(f)
var innerData []byte
for _, att := range outerEmail.Attachments {
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
innerData, _ = io.ReadAll(att.Data)
break
}
}
innerEmail, _ := Parse(bytes.NewReader(innerData))
for _, att := range innerEmail.Attachments {
rawData, _ := io.ReadAll(att.Data)
if strings.ToLower(att.Filename) != "winmail.dat" {
continue
}
// Dig to level 2: top → embedded[0] → embedded[0]
streams0 := findEmbeddedTNEFStreams(rawData)
if len(streams0) == 0 {
t.Fatal("no embedded streams at level 0")
}
streams1 := findEmbeddedTNEFStreams(streams0[0])
if len(streams1) == 0 {
t.Fatal("no embedded streams at level 1")
}
// Decode level 2
decoded2, err := tnef.Decode(streams1[0])
if err != nil {
t.Fatalf("level 2 decode: %v", err)
}
fmt.Printf("Level 2 attachments: %d\n", len(decoded2.Attachments))
for i, a := range decoded2.Attachments {
fmt.Printf(" [%d] title=%q size=%d\n", i, a.Title, len(a.Data))
if len(a.Data) > 20 {
fmt.Printf(" first 20 bytes: %x\n", a.Data[:20])
// Check for EML, MSG, TNEF signatures
if len(a.Data) >= 4 {
sig := binary.LittleEndian.Uint32(a.Data[0:4])
if sig == 0x223E9F78 {
fmt.Println(" -> TNEF stream!")
}
}
if len(a.Data) >= 8 && bytes.Equal(a.Data[:8], []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}) {
fmt.Println(" -> MSG (OLE2) file!")
}
// Check if text/EML
if a.Data[0] < 128 && a.Data[0] >= 32 {
preview := string(a.Data[:min2(200, len(a.Data))])
if strings.Contains(preview, "From:") || strings.Contains(preview, "Content-Type") || strings.Contains(preview, "MIME") || strings.Contains(preview, "Received:") {
fmt.Printf(" -> Looks like an EML file! First 200 chars: %s\n", preview)
} else {
fmt.Printf(" -> Text data: %.200s\n", preview)
}
}
}
}
// Also check level 2's attAttachment for embedded msgs
streams2 := findEmbeddedTNEFStreams(streams1[0])
fmt.Printf("\nLevel 2 embedded TNEF streams: %d\n", len(streams2))
// Check all MAPI attributes at level 2
fmt.Println("\nLevel 2 MAPI attributes:")
for _, attr := range decoded2.Attributes {
fmt.Printf(" 0x%04X: %d bytes\n", attr.Name, len(attr.Data))
// PR_BODY
if attr.Name == 0x1000 && len(attr.Data) < 500 {
fmt.Printf(" PR_BODY: %s\n", string(attr.Data))
}
}
}
}

View File

@@ -0,0 +1,79 @@
package internal
import (
"bytes"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/teamwork/tnef"
)
func TestTNEFDiag(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
f, _ := os.Open(testFile)
defer f.Close()
// Parse the PEC outer envelope
outerEmail, err := Parse(f)
if err != nil {
t.Fatalf("parse outer: %v", err)
}
// Find postacert.eml
var innerData []byte
for _, att := range outerEmail.Attachments {
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
innerData, _ = io.ReadAll(att.Data)
break
}
}
if innerData == nil {
t.Fatal("no postacert.eml found")
}
// Parse inner email
innerEmail, err := Parse(bytes.NewReader(innerData))
if err != nil {
t.Fatalf("parse inner: %v", err)
}
fmt.Printf("Inner attachments: %d\n", len(innerEmail.Attachments))
for i, att := range innerEmail.Attachments {
data, _ := io.ReadAll(att.Data)
fmt.Printf(" [%d] filename=%q contentType=%q size=%d\n", i, att.Filename, att.ContentType, len(data))
if strings.ToLower(att.Filename) == "winmail.dat" ||
strings.Contains(strings.ToLower(att.ContentType), "ms-tnef") {
fmt.Printf(" Found TNEF! First 20 bytes: %x\n", data[:min(20, len(data))])
fmt.Printf(" isTNEFData: %v\n", isTNEFData(data))
decoded, err := tnef.Decode(data)
if err != nil {
fmt.Printf(" TNEF decode error: %v\n", err)
continue
}
fmt.Printf(" TNEF Body len: %d\n", len(decoded.Body))
fmt.Printf(" TNEF BodyHTML len: %d\n", len(decoded.BodyHTML))
fmt.Printf(" TNEF Attachments: %d\n", len(decoded.Attachments))
for j, ta := range decoded.Attachments {
fmt.Printf(" [%d] title=%q size=%d\n", j, ta.Title, len(ta.Data))
}
fmt.Printf(" TNEF Attributes: %d\n", len(decoded.Attributes))
}
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,444 @@
package internal
import (
"encoding/binary"
"mime"
"path/filepath"
"strings"
"github.com/teamwork/tnef"
)
// tnefMagic is the TNEF file signature (little-endian 0x223E9F78).
var tnefMagic = []byte{0x78, 0x9F, 0x3E, 0x22}
const maxTNEFDepth = 10
// isTNEFData returns true if the given byte slice starts with the TNEF magic number.
func isTNEFData(data []byte) bool {
return len(data) >= 4 &&
data[0] == tnefMagic[0] &&
data[1] == tnefMagic[1] &&
data[2] == tnefMagic[2] &&
data[3] == tnefMagic[3]
}
// isTNEFAttachment returns true if an attachment is a TNEF-encoded winmail.dat.
// Detection is based on filename, content-type, or the TNEF magic bytes.
func isTNEFAttachment(att EmailAttachment) bool {
filenameLower := strings.ToLower(att.Filename)
if filenameLower == "winmail.dat" {
return true
}
ctLower := strings.ToLower(att.ContentType)
if strings.Contains(ctLower, "application/ms-tnef") ||
strings.Contains(ctLower, "application/vnd.ms-tnef") {
return true
}
return isTNEFData(att.Data)
}
// extractTNEFAttachments decodes a TNEF blob and returns the files embedded
// inside it, recursively following nested embedded MAPI messages.
func extractTNEFAttachments(data []byte) ([]EmailAttachment, error) {
return extractTNEFRecursive(data, 0)
}
func extractTNEFRecursive(data []byte, depth int) ([]EmailAttachment, error) {
if depth > maxTNEFDepth {
return nil, nil
}
decoded, err := tnef.Decode(data)
if err != nil {
return nil, err
}
var attachments []EmailAttachment
// Collect non-placeholder file attachments from the library output.
for _, att := range decoded.Attachments {
if len(att.Data) == 0 {
continue
}
// Skip the small MAPI placeholder text ("L'allegato è un messaggio
// incorporato MAPI 1.0...") that Outlook inserts for embedded messages.
if isEmbeddedMsgPlaceholder(att) {
continue
}
filename := att.Title
if filename == "" || filename == "Untitled Attachment" {
filename = inferFilename(att.Data)
}
attachments = append(attachments, EmailAttachment{
Filename: filename,
ContentType: mimeTypeFromFilename(filename),
Data: att.Data,
})
}
// Recursively dig into embedded MAPI messages stored in
// attAttachment (0x9005) → PR_ATTACH_DATA_OBJ (0x3701).
for _, stream := range findEmbeddedTNEFStreamsFromRaw(data) {
subAtts, _ := extractTNEFRecursive(stream, depth+1)
attachments = append(attachments, subAtts...)
}
return attachments, nil
}
// isEmbeddedMsgPlaceholder returns true if the attachment is a tiny placeholder
// that Outlook generates for embedded MAPI messages ("L'allegato è un messaggio
// incorporato MAPI 1.0" or equivalent in other languages).
func isEmbeddedMsgPlaceholder(att *tnef.Attachment) bool {
if len(att.Data) > 300 {
return false
}
lower := strings.ToLower(string(att.Data))
return strings.Contains(lower, "mapi 1.0") ||
strings.Contains(lower, "embedded message") ||
strings.Contains(lower, "messaggio incorporato")
}
// inferFilename picks a reasonable filename based on the data's magic bytes.
func inferFilename(data []byte) string {
if looksLikeEML(data) {
return "embedded_message.eml"
}
if isTNEFData(data) {
return "embedded.dat"
}
if len(data) >= 8 {
if data[0] == 0xD0 && data[1] == 0xCF && data[2] == 0x11 && data[3] == 0xE0 {
return "embedded_message.msg"
}
}
return "attachment.dat"
}
// looksLikeEML returns true if data starts with typical RFC 5322 headers.
func looksLikeEML(data []byte) bool {
if len(data) < 20 {
return false
}
// Quick check: must start with printable ASCII
if data[0] < 32 || data[0] > 126 {
return false
}
prefix := strings.ToLower(string(data[:min(200, len(data))]))
return strings.HasPrefix(prefix, "mime-version:") ||
strings.HasPrefix(prefix, "from:") ||
strings.HasPrefix(prefix, "received:") ||
strings.HasPrefix(prefix, "date:") ||
strings.HasPrefix(prefix, "content-type:") ||
strings.HasPrefix(prefix, "return-path:")
}
// expandTNEFAttachments iterates over the attachment list and replaces any
// TNEF-encoded winmail.dat entries with the files they contain. Attachments
// that are not TNEF are passed through unchanged.
func expandTNEFAttachments(attachments []EmailAttachment) []EmailAttachment {
var result []EmailAttachment
for _, att := range attachments {
if isTNEFAttachment(att) {
extracted, err := extractTNEFAttachments(att.Data)
if err == nil && len(extracted) > 0 {
result = append(result, extracted...)
continue
}
// If extraction fails, keep the original blob.
}
result = append(result, att)
}
return result
}
// ---------------------------------------------------------------------------
// Raw TNEF attribute scanner — extracts nested TNEF streams from embedded
// MAPI messages that the teamwork/tnef library does not handle.
// ---------------------------------------------------------------------------
// findEmbeddedTNEFStreamsFromRaw scans the raw TNEF byte stream for
// attAttachment (0x00069005) attribute blocks, parses their MAPI properties,
// and extracts any PR_ATTACH_DATA_OBJ (0x3701) values that begin with a
// TNEF signature.
func findEmbeddedTNEFStreamsFromRaw(tnefData []byte) [][]byte {
if len(tnefData) < 6 || !isTNEFData(tnefData) {
return nil
}
var streams [][]byte
offset := 6 // skip TNEF signature (4) + key (2)
for offset+9 < len(tnefData) {
level := tnefData[offset]
attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5])
attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9]))
dataStart := offset + 9
if dataStart+attrLen > len(tnefData) || attrLen < 0 {
break
}
// attAttachment (0x00069005) at attachment level (0x02)
if level == 0x02 && attrID == 0x00069005 && attrLen > 100 {
mapiData := tnefData[dataStart : dataStart+attrLen]
embedded := extractPRAttachDataObjFromMAPI(mapiData)
if embedded != nil && len(embedded) > 22 {
// Skip the 16-byte IID_IMessage GUID
afterGuid := embedded[16:]
if isTNEFData(afterGuid) {
streams = append(streams, afterGuid)
}
}
}
// level(1) + id(4) + len(4) + data(attrLen) + checksum(2)
offset += 9 + attrLen + 2
}
return streams
}
// extractPRAttachDataObjFromMAPI parses a MAPI properties block (from an
// attAttachment attribute) and returns the raw value of PR_ATTACH_DATA_OBJ
// (property ID 0x3701, type PT_OBJECT 0x000D).
func extractPRAttachDataObjFromMAPI(data []byte) []byte {
if len(data) < 4 {
return nil
}
count := int(binary.LittleEndian.Uint32(data[0:4]))
off := 4
for i := 0; i < count && off+4 <= len(data); i++ {
propTag := binary.LittleEndian.Uint32(data[off : off+4])
propType := propTag & 0xFFFF
propID := (propTag >> 16) & 0xFFFF
off += 4
// Named properties (ID >= 0x8000) have extra GUID + kind fields.
if propID >= 0x8000 {
if off+20 > len(data) {
return nil
}
kind := binary.LittleEndian.Uint32(data[off+16 : off+20])
off += 20
if kind == 0 { // MNID_ID
off += 4
} else { // MNID_STRING
if off+4 > len(data) {
return nil
}
nameLen := int(binary.LittleEndian.Uint32(data[off : off+4]))
off += 4 + nameLen
off += padTo4(nameLen)
}
}
off = skipMAPIPropValue(data, off, propType, propID)
if off < 0 {
return nil // parse error
}
// If skipMAPIPropValue returned a special sentinel, extract it.
// We use a hack: skipMAPIPropValue can't return the data directly,
// so we handle PT_OBJECT / 0x3701 inline below.
}
// Simpler approach: re-scan specifically for 0x3701.
return extractPRAttachDataObjDirect(data)
}
// extractPRAttachDataObjDirect re-scans the MAPI property block and
// returns the raw value of PR_ATTACH_DATA_OBJ (0x3701, PT_OBJECT).
func extractPRAttachDataObjDirect(data []byte) []byte {
if len(data) < 4 {
return nil
}
count := int(binary.LittleEndian.Uint32(data[0:4]))
off := 4
for i := 0; i < count && off+4 <= len(data); i++ {
propTag := binary.LittleEndian.Uint32(data[off : off+4])
propType := propTag & 0xFFFF
propID := (propTag >> 16) & 0xFFFF
off += 4
// Skip named property headers.
if propID >= 0x8000 {
if off+20 > len(data) {
return nil
}
kind := binary.LittleEndian.Uint32(data[off+16 : off+20])
off += 20
if kind == 0 {
off += 4
} else {
if off+4 > len(data) {
return nil
}
nameLen := int(binary.LittleEndian.Uint32(data[off : off+4]))
off += 4 + nameLen
off += padTo4(nameLen)
}
}
switch propType {
case 0x0002: // PT_SHORT (padded to 4)
off += 4
case 0x0003, 0x000A: // PT_LONG, PT_ERROR
off += 4
case 0x000B: // PT_BOOLEAN (padded to 4)
off += 4
case 0x0004: // PT_FLOAT
off += 4
case 0x0005: // PT_DOUBLE
off += 8
case 0x0006: // PT_CURRENCY
off += 8
case 0x0007: // PT_APPTIME
off += 8
case 0x0014: // PT_I8
off += 8
case 0x0040: // PT_SYSTIME
off += 8
case 0x0048: // PT_CLSID
off += 16
case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE
off = skipCountedBlobs(data, off)
case 0x0102: // PT_BINARY
off = skipCountedBlobs(data, off)
case 0x000D: // PT_OBJECT
if off+4 > len(data) {
return nil
}
cnt := int(binary.LittleEndian.Uint32(data[off : off+4]))
off += 4
for j := 0; j < cnt; j++ {
if off+4 > len(data) {
return nil
}
olen := int(binary.LittleEndian.Uint32(data[off : off+4]))
off += 4
if propID == 0x3701 && off+olen <= len(data) {
return data[off : off+olen]
}
off += olen
off += padTo4(olen)
}
case 0x1002: // PT_MV_SHORT
off = skipMVFixed(data, off, 4)
case 0x1003: // PT_MV_LONG
off = skipMVFixed(data, off, 4)
case 0x1005: // PT_MV_DOUBLE
off = skipMVFixed(data, off, 8)
case 0x1014: // PT_MV_I8
off = skipMVFixed(data, off, 8)
case 0x1040: // PT_MV_SYSTIME
off = skipMVFixed(data, off, 8)
case 0x101E, 0x101F: // PT_MV_STRING8, PT_MV_UNICODE
off = skipCountedBlobs(data, off)
case 0x1048: // PT_MV_CLSID
off = skipMVFixed(data, off, 16)
case 0x1102: // PT_MV_BINARY
off = skipCountedBlobs(data, off)
default:
// Unknown type, can't continue
return nil
}
if off < 0 || off > len(data) {
return nil
}
}
return nil
}
// skipCountedBlobs advances past a MAPI value that stores count + N
// length-prefixed blobs (used by PT_STRING8, PT_UNICODE, PT_BINARY, and
// their multi-valued variants).
func skipCountedBlobs(data []byte, off int) int {
if off+4 > len(data) {
return -1
}
cnt := int(binary.LittleEndian.Uint32(data[off : off+4]))
off += 4
for j := 0; j < cnt; j++ {
if off+4 > len(data) {
return -1
}
blen := int(binary.LittleEndian.Uint32(data[off : off+4]))
off += 4 + blen
off += padTo4(blen)
}
return off
}
// skipMVFixed advances past a multi-valued fixed-size property
// (count followed by count*elemSize bytes).
func skipMVFixed(data []byte, off int, elemSize int) int {
if off+4 > len(data) {
return -1
}
cnt := int(binary.LittleEndian.Uint32(data[off : off+4]))
off += 4 + cnt*elemSize
return off
}
// skipMAPIPropValue is a generic value skipper (unused in the current flow
// but kept for completeness).
func skipMAPIPropValue(data []byte, off int, propType uint32, _ uint32) int {
switch propType {
case 0x0002:
return off + 4
case 0x0003, 0x000A, 0x000B, 0x0004:
return off + 4
case 0x0005, 0x0006, 0x0007, 0x0014, 0x0040:
return off + 8
case 0x0048:
return off + 16
case 0x001E, 0x001F, 0x0102, 0x000D:
return skipCountedBlobs(data, off)
case 0x1002, 0x1003:
return skipMVFixed(data, off, 4)
case 0x1005, 0x1014, 0x1040:
return skipMVFixed(data, off, 8)
case 0x1048:
return skipMVFixed(data, off, 16)
case 0x101E, 0x101F, 0x1102:
return skipCountedBlobs(data, off)
default:
return -1
}
}
// padTo4 returns the number of padding bytes needed to reach a 4-byte boundary.
func padTo4(n int) int {
r := n % 4
if r == 0 {
return 0
}
return 4 - r
}
// ---------------------------------------------------------------------------
// MIME type helper
// ---------------------------------------------------------------------------
// mimeTypeFromFilename guesses the MIME type from a file extension.
// Falls back to "application/octet-stream" when the type is unknown.
func mimeTypeFromFilename(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
if ext == "" {
return "application/octet-stream"
}
t := mime.TypeByExtension(ext)
if t == "" {
return "application/octet-stream"
}
// Strip any parameters (e.g. "; charset=utf-8")
if idx := strings.Index(t, ";"); idx != -1 {
t = strings.TrimSpace(t[:idx])
}
return t
}

View File

@@ -0,0 +1,59 @@
package internal
import (
"fmt"
"os"
"testing"
)
func TestReadEmlWithTNEF(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
// First try the PEC reader (this is a PEC email)
email, err := ReadPecInnerEml(testFile)
if err != nil {
t.Fatalf("ReadPecInnerEml failed: %v", err)
}
fmt.Printf("Subject: %s\n", email.Subject)
fmt.Printf("From: %s\n", email.From)
fmt.Printf("Attachment count: %d\n", len(email.Attachments))
hasWinmailDat := false
for i, att := range email.Attachments {
fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data))
if att.Filename == "winmail.dat" {
hasWinmailDat = true
}
}
if hasWinmailDat {
t.Error("winmail.dat should have been expanded into its contained attachments")
}
if len(email.Attachments) == 0 {
t.Error("expected at least one attachment after TNEF expansion")
}
}
func TestReadEmlFallback(t *testing.T) {
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Skip("test EML file not present")
}
// Also verify the plain EML reader path
email, err := ReadEmlFile(testFile)
if err != nil {
t.Fatalf("ReadEmlFile failed: %v", err)
}
fmt.Printf("[EML] Subject: %s\n", email.Subject)
fmt.Printf("[EML] Attachment count: %d\n", len(email.Attachments))
for i, att := range email.Attachments {
fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data))
}
}

View File

@@ -8,32 +8,18 @@ import (
"image/png"
"syscall"
"unsafe"
"github.com/kbinani/screenshot"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
gdi32 = syscall.NewLazyDLL("gdi32.dll")
dwmapi = syscall.NewLazyDLL("dwmapi.dll")
user32 = syscall.NewLazyDLL("user32.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")
getForegroundWindow = user32.NewProc("GetForegroundWindow")
getWindowRect = user32.NewProc("GetWindowRect")
findWindowW = user32.NewProc("FindWindowW")
// dwmapi functions
dwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
@@ -47,39 +33,7 @@ type RECT struct {
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
)
@@ -113,82 +67,10 @@ func CaptureWindowByHandle(hwnd uintptr) (*image.RGBA, error) {
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
// Using kbinani/screenshot to capture the rectangle on screen
img, err := screenshot.CaptureRect(image.Rect(int(rect.Left), int(rect.Top), int(rect.Right), int(rect.Bottom)))
if err != nil {
return nil, fmt.Errorf("screenshot.CaptureRect failed: %v", err)
}
return img, nil

View File

@@ -1,9 +1,11 @@
[EMLy]
SDK_DECODER_SEMVER = 1.3.2
SDK_DECODER_RELEASE_CHANNEL = stable
GUI_SEMVER = 1.5.4
SDK_DECODER_SEMVER = 1.4.2
SDK_DECODER_RELEASE_CHANNEL = beta
GUI_SEMVER = 1.6.1
GUI_RELEASE_CHANNEL = beta
LANGUAGE = it
UPDATE_CHECK_ENABLED = false
UPDATE_PATH =
UPDATE_AUTO_CHECK = true
UPDATE_CHECK_ENABLED = true
UPDATE_PATH = "\\dc-rm2\logo\update"
UPDATE_AUTO_CHECK = false
BUGREPORT_API_URL = "https://api.emly.ffois.it"
BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63"

View File

@@ -5,7 +5,10 @@
"": {
"name": "frontend",
"dependencies": {
"@rollup/rollup-win32-arm64-msvc": "^4.57.1",
"@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",
@@ -187,7 +190,7 @@
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="],
@@ -249,6 +252,8 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/html2canvas": ["@types/html2canvas@1.0.0", "", { "dependencies": { "html2canvas": "*" } }, "sha512-BJpVf+FIN9UERmzhbtUgpXj6XBZpG67FMgBLLoj9HZKd9XifcCpSV+UnFcwTZfEyun4U/KmCrrVOG7829L589w=="],
"@types/node": ["@types/node@24.10.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
@@ -261,6 +266,8 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"bits-ui": ["bits-ui@2.15.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
@@ -277,6 +284,8 @@
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
@@ -307,6 +316,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
@@ -415,6 +426,8 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
@@ -433,6 +446,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
@@ -469,6 +484,8 @@
"paneforge/svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="],
"rollup/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
"mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],

View File

@@ -41,9 +41,12 @@
"settings_danger_reset_label": "Reset App Data",
"settings_danger_reset_hint": "This will clear all your settings and return the app to its default state.",
"settings_danger_reset_button": "Reset data",
"settings_danger_reload_label": "Reload App",
"settings_danger_reload_hint": "Reloads the application interface. Useful if the UI becomes unresponsive.",
"settings_danger_reload_button": "Reload",
"settings_danger_reload_ui_label": "Reload UI",
"settings_danger_reload_app_label": "Reload App",
"settings_danger_reload_all_label": "Reload UI/App",
"settings_danger_reload_hint": "Reloads the application interface. Useful if it becomes unresponsive.",
"settings_danger_reload_button_ui": "Reload UI",
"settings_danger_reload_button_app": "Reload app",
"settings_danger_reset_dialog_title": "Are you absolutely sure?",
"settings_danger_reset_dialog_description": "This action cannot be undone. This will permanently delete your current settings and return the app to its default state.",
"settings_danger_reset_dialog_cancel": "Cancel",
@@ -218,5 +221,8 @@
"pdf_error_no_data_desc": "No PDF data provided. Please open this window from the main EMLy application.",
"pdf_error_timeout": "Timeout loading PDF. The worker might have failed to initialize.",
"pdf_error_parsing": "Error parsing PDF: ",
"pdf_error_rendering": "Error rendering page: "
"pdf_error_rendering": "Error rendering page: ",
"bugreport_uploaded_success": "Your bug report has been uploaded successfully! Report ID: #{reportId}",
"bugreport_upload_failed": "Could not upload to server. Your report has been saved locally instead.",
"bugreport_uploaded_title": "Bug Report Uploaded"
}

View File

@@ -41,9 +41,12 @@
"settings_danger_reset_label": "Reimposta Dati App",
"settings_danger_reset_hint": "Questo cancellerà tutte le tue impostazioni e riporterà l'app allo stato predefinito.",
"settings_danger_reset_button": "Reimposta dati",
"settings_danger_reload_label": "Ricarica App",
"settings_danger_reload_hint": "Ricarica l'interfaccia dell'applicazione. Utile se l'UI non risponde.",
"settings_danger_reload_button": "Ricarica",
"settings_danger_reload_ui_label": "Ricarica UI",
"settings_danger_reload_app_label": "Ricarica App",
"settings_danger_reload_all_label": "Ricarica UI/App",
"settings_danger_reload_hint": "Ricarica l'interfaccia dell'applicazione. Utile se non risponde.",
"settings_danger_reload_button_ui": "Ricarica UI",
"settings_danger_reload_button_app": "Ricarica app",
"settings_danger_reset_dialog_title": "Sei assolutamente sicuro?",
"settings_danger_reset_dialog_description": "Questa azione non può essere annullata. Questo eliminerà permanentemente le tue impostazioni attuali e riporterà l'app allo stato predefinito.",
"settings_danger_reset_dialog_cancel": "Annulla",
@@ -218,6 +221,8 @@
"pdf_error_rendering": "Errore nel rendering della pagina: ",
"mail_download_btn_label": "Scarica",
"mail_download_btn_title": "Scarica",
"mail_download_btn_text": "Scarica"
"mail_download_btn_text": "Scarica",
"bugreport_uploaded_success": "La tua segnalazione è stata caricata con successo! ID segnalazione: #{reportId}",
"bugreport_upload_failed": "Impossibile caricare sul server. La segnalazione è stata salvata localmente.",
"bugreport_uploaded_title": "Segnalazione Bug Caricata"
}

View File

@@ -1 +1 @@
3c4a64d0cfb34e86fac16fceae842e43
1697d40a08e09716b8c29ddebeabd1ad

View File

@@ -6,16 +6,24 @@
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 { CheckCircle, Copy, FolderOpen, Camera, Loader2 } from "@lucide/svelte";
import { CheckCircle, Copy, FolderOpen, Camera, Loader2, CloudUpload, AlertTriangle } from "@lucide/svelte";
import { toast } from "svelte-sonner";
import { TakeScreenshot, SubmitBugReport, OpenFolderInExplorer, GetConfig } from "$lib/wailsjs/go/main/App";
import { browser } from "$app/environment";
import { dev } from "$app/environment";
// Bug report form state
let userName = $state("");
let userEmail = $state("");
let bugDescription = $state("");
// Auto-fill form in dev mode
$effect(() => {
if (dev && $bugReportDialogOpen && !userName) {
userName = "Test User";
userEmail = "test@example.com";
bugDescription = "This is a test bug report submitted from development mode.";
}
});
// Bug report screenshot state
let screenshotData = $state("");
let isCapturing = $state(false);
@@ -28,6 +36,9 @@
let isSubmitting = $state(false);
let isSuccess = $state(false);
let resultZipPath = $state("");
let uploadedToServer = $state(false);
let serverReportId = $state(0);
let uploadError = $state("");
let canSubmit: boolean = $derived(
bugDescription.trim().length > 0 && userName.trim().length > 0 && userEmail.trim().length > 0 && !isSubmitting && !isCapturing
);
@@ -100,6 +111,9 @@
isSubmitting = false;
isSuccess = false;
resultZipPath = "";
uploadedToServer = false;
serverReportId = 0;
uploadError = "";
}
async function handleBugReportSubmit(event: Event) {
@@ -123,8 +137,11 @@
});
resultZipPath = result.zipPath;
uploadedToServer = result.uploaded;
serverReportId = result.reportId;
uploadError = result.uploadError;
isSuccess = true;
console.log("Bug report created:", result.zipPath);
console.log("Bug report created:", result.zipPath, "uploaded:", result.uploaded);
} catch (err) {
console.error("Failed to create bug report:", err);
toast.error(m.bugreport_error());
@@ -162,15 +179,31 @@
<!-- 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()}
{#if uploadedToServer}
<CloudUpload class="h-5 w-5 text-green-500" />
{m.bugreport_uploaded_title()}
{:else}
<CheckCircle class="h-5 w-5 text-green-500" />
{m.bugreport_success_title()}
{/if}
</Dialog.Title>
<Dialog.Description>
{m.bugreport_success_message()}
{#if uploadedToServer}
{m.bugreport_uploaded_success({ reportId: String(serverReportId) })}
{:else}
{m.bugreport_success_message()}
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
{#if uploadError}
<div class="flex items-start gap-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md p-3">
<AlertTriangle class="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
<p class="text-sm text-yellow-600 dark:text-yellow-400">{m.bugreport_upload_failed()}</p>
</div>
{/if}
<div class="bg-muted rounded-md p-3">
<code class="text-xs break-all select-all">{resultZipPath}</code>
</div>

View File

@@ -6,6 +6,8 @@ import {
ReadEML,
ReadMSG,
ReadPEC,
ReadAuto,
DetectEmailFormat,
ShowOpenFileDialog,
SetCurrentMailFilePath,
ConvertToUTF8,
@@ -23,7 +25,8 @@ export interface LoadEmailResult {
}
/**
* Determines the email file type from the path
* Determines the email file type from the path extension (best-effort hint).
* Use DetectEmailFormat (backend) for reliable format detection.
*/
export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
const lowerPath = filePath.toLowerCase();
@@ -33,18 +36,57 @@ export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
}
/**
* Checks if a file path is a valid email file
* Checks if a file path looks like an email file by extension.
* Returns true also for unknown extensions so the backend can attempt parsing.
*/
export function isEmailFile(filePath: string): boolean {
return getEmailFileType(filePath) !== null;
return filePath.trim().length > 0;
}
/**
* Loads an email from a file path
* Loads an email from a file path.
* Uses ReadAuto so the backend detects the format from the file's binary
* content, regardless of extension. Falls back to the legacy per-format
* readers only when the caller explicitly requests them.
*
* @param filePath - Path to the email file
* @returns LoadEmailResult with the email data or error
*/
export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult> {
if (!filePath?.trim()) {
return { success: false, error: 'No file path provided.' };
}
try {
// ReadAuto detects the format (EML/PEC/MSG) by magic bytes and dispatches
// to the appropriate reader. This works for any extension, including
// unconventional ones like winmail.dat or no extension at all.
const email = await ReadAuto(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 };
}
}
/**
* Loads an email using the explicit per-format readers (legacy path).
* Prefer loadEmailFromPath for new code.
*/
export async function loadEmailFromPathLegacy(filePath: string): Promise<LoadEmailResult> {
const fileType = getEmailFileType(filePath);
if (!fileType) {
@@ -60,7 +102,6 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
if (fileType === 'msg') {
email = await ReadMSG(filePath, true);
} else {
// Try PEC first, fall back to regular EML
try {
email = await ReadPEC(filePath);
} catch {
@@ -68,7 +109,6 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
}
}
// Process body if needed (decode base64)
if (email?.body) {
const trimmed = email.body.trim();
if (looksLikeBase64(trimmed)) {
@@ -79,18 +119,11 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
}
}
return {
success: true,
email,
filePath,
};
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,
};
return { success: false, error: errorMessage };
}
}

View File

@@ -33,6 +33,7 @@ export {
getEmailFileType,
isEmailFile,
loadEmailFromPath,
loadEmailFromPathLegacy,
openAndLoadEmail,
processEmailBody,
type LoadEmailResult,

View File

@@ -1,15 +1,29 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {utils} from '../models';
import {main} from '../models';
import {utils} from '../models';
import {internal} from '../models';
export function CheckForUpdates():Promise<main.UpdateStatus>;
export function CheckIsDefaultEMLHandler():Promise<boolean>;
export function ConvertToUTF8(arg1:string):Promise<string>;
export function CreateBugReportFolder():Promise<main.BugReportResult>;
export function DetectEmailFormat(arg1:string):Promise<string>;
export function DownloadUpdate():Promise<string>;
export function ExportSettings(arg1:string):Promise<string>;
export function FrontendLog(arg1:string,arg2:string):Promise<void>;
export function GetConfig():Promise<utils.Config>;
export function GetCurrentMailFilePath():Promise<string>;
export function GetImageViewerData():Promise<main.ImageViewerData>;
export function GetMachineData():Promise<utils.MachineInfo>;
@@ -18,14 +32,26 @@ export function GetPDFViewerData():Promise<main.PDFViewerData>;
export function GetStartupFile():Promise<string>;
export function GetUpdateStatus():Promise<main.UpdateStatus>;
export function GetViewerData():Promise<main.ViewerData>;
export function ImportSettings():Promise<string>;
export function InstallUpdate(arg1:boolean):Promise<void>;
export function InstallUpdateSilent():Promise<void>;
export function InstallUpdateSilentFromPath(arg1:string):Promise<void>;
export function IsDebuggerRunning():Promise<boolean>;
export function OpenDefaultAppsSettings():Promise<void>;
export function OpenEMLWindow(arg1:string,arg2:string):Promise<void>;
export function OpenFolderInExplorer(arg1:string):Promise<void>;
export function OpenImage(arg1:string,arg2:string):Promise<void>;
export function OpenImageWindow(arg1:string,arg2:string):Promise<void>;
@@ -34,8 +60,12 @@ export function OpenPDF(arg1:string,arg2:string):Promise<void>;
export function OpenPDFWindow(arg1:string,arg2:string):Promise<void>;
export function OpenURLInBrowser(arg1:string):Promise<void>;
export function QuitApp():Promise<void>;
export function ReadAuto(arg1:string):Promise<internal.EmailData>;
export function ReadEML(arg1:string):Promise<internal.EmailData>;
export function ReadMSG(arg1:string,arg2:boolean):Promise<internal.EmailData>;
@@ -46,4 +76,16 @@ export function ReadPEC(arg1:string):Promise<internal.EmailData>;
export function SaveConfig(arg1:utils.Config):Promise<void>;
export function SaveScreenshot():Promise<string>;
export function SaveScreenshotAs():Promise<string>;
export function SetCurrentMailFilePath(arg1:string):Promise<void>;
export function SetUpdateCheckerEnabled(arg1:boolean):Promise<void>;
export function ShowOpenFileDialog():Promise<string>;
export function SubmitBugReport(arg1:main.BugReportInput):Promise<main.SubmitBugReportResult>;
export function TakeScreenshot():Promise<main.ScreenshotResult>;

View File

@@ -2,10 +2,34 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CheckForUpdates() {
return window['go']['main']['App']['CheckForUpdates']();
}
export function CheckIsDefaultEMLHandler() {
return window['go']['main']['App']['CheckIsDefaultEMLHandler']();
}
export function ConvertToUTF8(arg1) {
return window['go']['main']['App']['ConvertToUTF8'](arg1);
}
export function CreateBugReportFolder() {
return window['go']['main']['App']['CreateBugReportFolder']();
}
export function DetectEmailFormat(arg1) {
return window['go']['main']['App']['DetectEmailFormat'](arg1);
}
export function DownloadUpdate() {
return window['go']['main']['App']['DownloadUpdate']();
}
export function ExportSettings(arg1) {
return window['go']['main']['App']['ExportSettings'](arg1);
}
export function FrontendLog(arg1, arg2) {
return window['go']['main']['App']['FrontendLog'](arg1, arg2);
}
@@ -14,6 +38,10 @@ export function GetConfig() {
return window['go']['main']['App']['GetConfig']();
}
export function GetCurrentMailFilePath() {
return window['go']['main']['App']['GetCurrentMailFilePath']();
}
export function GetImageViewerData() {
return window['go']['main']['App']['GetImageViewerData']();
}
@@ -30,10 +58,30 @@ export function GetStartupFile() {
return window['go']['main']['App']['GetStartupFile']();
}
export function GetUpdateStatus() {
return window['go']['main']['App']['GetUpdateStatus']();
}
export function GetViewerData() {
return window['go']['main']['App']['GetViewerData']();
}
export function ImportSettings() {
return window['go']['main']['App']['ImportSettings']();
}
export function InstallUpdate(arg1) {
return window['go']['main']['App']['InstallUpdate'](arg1);
}
export function InstallUpdateSilent() {
return window['go']['main']['App']['InstallUpdateSilent']();
}
export function InstallUpdateSilentFromPath(arg1) {
return window['go']['main']['App']['InstallUpdateSilentFromPath'](arg1);
}
export function IsDebuggerRunning() {
return window['go']['main']['App']['IsDebuggerRunning']();
}
@@ -46,6 +94,10 @@ export function OpenEMLWindow(arg1, arg2) {
return window['go']['main']['App']['OpenEMLWindow'](arg1, arg2);
}
export function OpenFolderInExplorer(arg1) {
return window['go']['main']['App']['OpenFolderInExplorer'](arg1);
}
export function OpenImage(arg1, arg2) {
return window['go']['main']['App']['OpenImage'](arg1, arg2);
}
@@ -62,10 +114,18 @@ export function OpenPDFWindow(arg1, arg2) {
return window['go']['main']['App']['OpenPDFWindow'](arg1, arg2);
}
export function OpenURLInBrowser(arg1) {
return window['go']['main']['App']['OpenURLInBrowser'](arg1);
}
export function QuitApp() {
return window['go']['main']['App']['QuitApp']();
}
export function ReadAuto(arg1) {
return window['go']['main']['App']['ReadAuto'](arg1);
}
export function ReadEML(arg1) {
return window['go']['main']['App']['ReadEML'](arg1);
}
@@ -86,6 +146,30 @@ export function SaveConfig(arg1) {
return window['go']['main']['App']['SaveConfig'](arg1);
}
export function SaveScreenshot() {
return window['go']['main']['App']['SaveScreenshot']();
}
export function SaveScreenshotAs() {
return window['go']['main']['App']['SaveScreenshotAs']();
}
export function SetCurrentMailFilePath(arg1) {
return window['go']['main']['App']['SetCurrentMailFilePath'](arg1);
}
export function SetUpdateCheckerEnabled(arg1) {
return window['go']['main']['App']['SetUpdateCheckerEnabled'](arg1);
}
export function ShowOpenFileDialog() {
return window['go']['main']['App']['ShowOpenFileDialog']();
}
export function SubmitBugReport(arg1) {
return window['go']['main']['App']['SubmitBugReport'](arg1);
}
export function TakeScreenshot() {
return window['go']['main']['App']['TakeScreenshot']();
}

View File

@@ -242,6 +242,44 @@ export namespace internal {
export namespace main {
export class BugReportInput {
name: string;
email: string;
description: string;
screenshotData: string;
localStorageData: string;
configData: string;
static createFrom(source: any = {}) {
return new BugReportInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.email = source["email"];
this.description = source["description"];
this.screenshotData = source["screenshotData"];
this.localStorageData = source["localStorageData"];
this.configData = source["configData"];
}
}
export class BugReportResult {
folderPath: string;
screenshotPath: string;
mailFilePath: string;
static createFrom(source: any = {}) {
return new BugReportResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.folderPath = source["folderPath"];
this.screenshotPath = source["screenshotPath"];
this.mailFilePath = source["mailFilePath"];
}
}
export class ImageViewerData {
data: string;
filename: string;
@@ -270,6 +308,70 @@ export namespace main {
this.filename = source["filename"];
}
}
export class ScreenshotResult {
data: string;
width: number;
height: number;
filename: string;
static createFrom(source: any = {}) {
return new ScreenshotResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.data = source["data"];
this.width = source["width"];
this.height = source["height"];
this.filename = source["filename"];
}
}
export class SubmitBugReportResult {
zipPath: string;
folderPath: string;
static createFrom(source: any = {}) {
return new SubmitBugReportResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.zipPath = source["zipPath"];
this.folderPath = source["folderPath"];
}
}
export class UpdateStatus {
currentVersion: string;
availableVersion: string;
updateAvailable: boolean;
checking: boolean;
downloading: boolean;
downloadProgress: number;
ready: boolean;
installerPath: string;
errorMessage: string;
releaseNotes?: string;
lastCheckTime: string;
static createFrom(source: any = {}) {
return new UpdateStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.currentVersion = source["currentVersion"];
this.availableVersion = source["availableVersion"];
this.updateAvailable = source["updateAvailable"];
this.checking = source["checking"];
this.downloading = source["downloading"];
this.downloadProgress = source["downloadProgress"];
this.ready = source["ready"];
this.installerPath = source["installerPath"];
this.errorMessage = source["errorMessage"];
this.releaseNotes = source["releaseNotes"];
this.lastCheckTime = source["lastCheckTime"];
}
}
export class ViewerData {
imageData?: ImageViewerData;
pdfData?: PDFViewerData;
@@ -717,6 +819,10 @@ export namespace utils {
SDKDecoderReleaseChannel: string;
GUISemver: string;
GUIReleaseChannel: string;
Language: string;
UpdateCheckEnabled: string;
UpdatePath: string;
UpdateAutoCheck: string;
static createFrom(source: any = {}) {
return new EMLyConfig(source);
@@ -728,6 +834,10 @@ export namespace utils {
this.SDKDecoderReleaseChannel = source["SDKDecoderReleaseChannel"];
this.GUISemver = source["GUISemver"];
this.GUIReleaseChannel = source["GUIReleaseChannel"];
this.Language = source["Language"];
this.UpdateCheckEnabled = source["UpdateCheckEnabled"];
this.UpdatePath = source["UpdatePath"];
this.UpdateAutoCheck = source["UpdateAutoCheck"];
}
}
export class Config {

View File

@@ -298,8 +298,8 @@
href="/"
class={`${buttonVariants({ variant: "destructive" })} cursor-pointer hover:cursor-pointer`}
style="text-decoration: none; margin-left: auto; height: 24px; font-size: 12px; padding: 0 8px;"
aria-label={m.settings_danger_reload_button()}
title={m.settings_danger_reload_button() + " app"}
aria-label={m.settings_danger_reload_button_ui()}
title={m.settings_danger_reload_button_ui()}
>
<RefreshCcwDot />
</a>
@@ -308,8 +308,8 @@
href="#"
class={`${buttonVariants({ variant: "destructive" })} cursor-pointer hover:cursor-pointer`}
style="text-decoration: none; height: 24px; font-size: 12px; padding: 0 8px;"
aria-label={m.settings_danger_reload_button()}
title={m.settings_danger_reload_button() + " app"}
aria-label={m.settings_danger_reload_button_ui()}
title={m.settings_danger_reload_button_ui() }
onclick={() => {
$bugReportDialogOpen = !$bugReportDialogOpen;
}}

View File

@@ -6,7 +6,7 @@
import { Label } from "$lib/components/ui/label";
import { Separator } from "$lib/components/ui/separator";
import { Switch } from "$lib/components/ui/switch";
import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle, Sun, Moon } from "@lucide/svelte";
import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle, Sun, Moon, RefreshCcw } from "@lucide/svelte";
import type { EMLy_GUI_Settings } from "$lib/types";
import { toast } from "svelte-sonner";
import { It, Us } from "svelte-flags";
@@ -25,7 +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, CheckForUpdates, DownloadUpdate, InstallUpdate, GetUpdateStatus, SetUpdateCheckerEnabled } from "$lib/wailsjs/go/main/App";
import { ExportSettings, ImportSettings, CheckForUpdates, DownloadUpdate, InstallUpdate, GetUpdateStatus, SetUpdateCheckerEnabled, RestartApp } from "$lib/wailsjs/go/main/App";
import { EventsOn, EventsOff } from "$lib/wailsjs/runtime/runtime";
let { data } = $props();
@@ -195,10 +195,14 @@
});
// Sync update checker setting to backend config.ini
let previousUpdateCheckerEnabled = form.enableUpdateChecker;
let previousUpdateCheckerEnabled = $state<boolean | undefined>(undefined);
$effect(() => {
(async () => {
if (!browser) return;
if (previousUpdateCheckerEnabled === undefined) {
previousUpdateCheckerEnabled = form.enableUpdateChecker;
return;
}
if (form.enableUpdateChecker !== previousUpdateCheckerEnabled) {
try {
await SetUpdateCheckerEnabled(form.enableUpdateChecker ?? true);
@@ -257,6 +261,16 @@
}
}
async function restartEntireApp() {
try {
await RestartApp();
} catch(e) {
toast.error("Error while trying to reload the app");
console.error(e);
}
}
// Update System State
type UpdateStatus = {
currentVersion: string;
@@ -344,7 +358,7 @@
});
</script>
<div class="min-h-[calc(100vh-1rem)] bg-gradient-to-b from-background to-muted/30">
<div class="min-h-[calc(100vh-1rem)] bg-linear-to-b from-background to-muted/30">
<div
class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6 sm:px-6 sm:py-10 opacity-80"
>
@@ -858,21 +872,31 @@
class="flex items-center justify-between gap-4 rounded-lg border border-destructive/30 bg-card p-4"
>
<div class="space-y-1">
<Label class="text-sm">{m.settings_danger_reload_label()}</Label>
<Label class="text-sm">{m.settings_danger_reload_all_label()}</Label>
<div class="text-sm text-muted-foreground">
{m.settings_danger_reload_hint()}
</div>
</div>
<a
data-sveltekit-reload
href="/"
class={`${buttonVariants({ variant: "destructive" })} cursor-pointer hover:cursor-pointer`}
style="text-decoration: none;"
>
{m.settings_danger_reload_button()}
</a>
<div class="flex items-center gap-2">
<a
data-sveltekit-reload
href="/"
class={`${buttonVariants({ variant: "destructive" })} cursor-pointer hover:cursor-pointer`}
style="text-decoration: none;"
>
{m.settings_danger_reload_button_ui()}
</a>
<Button
class={`${buttonVariants({ variant: "destructive" })} cursor-pointer hover:cursor-pointer`}
onclick={restartEntireApp}
style="text-decoration: none;"
>
{m.settings_danger_reload_button_app()}
</Button>
</div>
</div>
<Separator />
<div

7
go.mod
View File

@@ -4,6 +4,8 @@ go 1.24.4
require (
github.com/jaypipes/ghw v0.21.2
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32
github.com/wailsapp/wails/v2 v2.11.0
golang.org/x/sys v0.40.0
golang.org/x/text v0.22.0
@@ -12,24 +14,29 @@ require (
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/gen2brain/shm v0.1.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jaypipes/pcidb v1.1.1 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jezek/xgb v1.1.1 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad // indirect
github.com/teamwork/utils v1.0.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect

18
go.sum
View File

@@ -1,8 +1,11 @@
github.com/Strum355/go-difflib v1.1.0/go.mod h1:r1cVg1JkGsTWkaR7At56v7hfuMgiUL8meTLwxFzOmvE=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
@@ -19,6 +22,10 @@ github.com/jaypipes/pcidb v1.1.1/go.mod h1:x27LT2krrUgjf875KxQXKB0Ha/YXLdZRVmw6h
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
@@ -35,6 +42,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@@ -47,6 +56,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -65,6 +75,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teamwork/test v0.0.0-20190410143529-8897d82f8d46/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA=
github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad h1:25sEr0awm0ZPancg5W5H5VvN7PWsJloUBpii10a9isw=
github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA=
github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32 h1:j15wq0XPAY/HR/0+dtwUrIrF2ZTKbk7QIES2p4dAG+k=
github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32/go.mod h1:v7dFaQrF/4+curx7UTH9rqTkHTgXqghfI3thANW150o=
github.com/teamwork/utils v1.0.0 h1:30WqhSbZ9nFhaJSx9HH+yFLiQvL64nqAOyyl5IxoYlY=
github.com/teamwork/utils v1.0.0/go.mod h1:3Fn0qxFeRNpvsg/9T1+btOOOKkd1qG2nPYKKcOmNpcs=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -86,6 +103,7 @@ golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -1,6 +1,6 @@
#define ApplicationName 'EMLy'
#define ApplicationVersion GetVersionNumbersString('EMLy.exe')
#define ApplicationVersion '1.5.4_beta'
#define ApplicationVersion '1.6.0_beta'
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"

19
server/.env.example Normal file
View File

@@ -0,0 +1,19 @@
# MySQL
MYSQL_HOST=mysql
MYSQL_PORT=3306
MYSQL_USER=emly
MYSQL_PASSWORD=change_me_in_production
MYSQL_DATABASE=emly_bugreports
MYSQL_ROOT_PASSWORD=change_root_password
# API Keys
API_KEY=change_me_client_key
ADMIN_KEY=change_me_admin_key
# Server
PORT=3000
DASHBOARD_PORT=3001
# Rate Limiting
RATE_LIMIT_MAX=5
RATE_LIMIT_WINDOW_HOURS=24

11
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
.env
dist/
*.log
# Dashboard
dashboard/node_modules/
dashboard/.svelte-kit/
dashboard/build/
dashboard/.env
dashboard/bun.lock

13
server/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM oven/bun:alpine
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile || bun install
COPY tsconfig.json ./
COPY src/ ./src/
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]

85
server/compose-dev.yml Normal file
View File

@@ -0,0 +1,85 @@
services:
mysql:
image: mysql:lts
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
MYSQL_USER: ${MYSQL_USER:-emly}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
emly:
ipv4_address: 172.16.32.2
api:
build: .
environment:
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: ${MYSQL_USER:-emly}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
API_KEY: ${API_KEY}
ADMIN_KEY: ${ADMIN_KEY}
PORT: 3000
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
volumes:
- ./logs/api:/app/logs
restart: on-failure
depends_on:
mysql:
condition: service_healthy
networks:
emly:
ipv4_address: 172.16.32.3
dashboard:
build: ./dashboard
environment:
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: ${MYSQL_USER:-emly}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
volumes:
- ./logs/dashboard:/app/logs
depends_on:
mysql:
condition: service_healthy
networks:
emly:
ipv4_address: 172.16.32.4
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN_DEV}
depends_on:
- api
- dashboard
restart: unless-stopped
networks:
emly:
ipv4_address: 172.16.32.5
volumes:
mysql_data:
networks:
emly:
driver: bridge
ipam:
config:
- subnet: 172.16.32.0/24
gateway: 172.16.32.1

85
server/compose-prod.yml Normal file
View File

@@ -0,0 +1,85 @@
services:
mysql:
image: mysql:lts
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
MYSQL_USER: ${MYSQL_USER:-emly}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
emly:
ipv4_address: 172.16.32.2
api:
build: .
environment:
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: ${MYSQL_USER:-emly}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
API_KEY: ${API_KEY}
ADMIN_KEY: ${ADMIN_KEY}
PORT: 3000
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
volumes:
- ./logs/api:/app/logs
restart: on-failure
depends_on:
mysql:
condition: service_healthy
networks:
emly:
ipv4_address: 172.16.32.3
dashboard:
build: ./dashboard
environment:
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: ${MYSQL_USER:-emly}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
volumes:
- ./logs/dashboard:/app/logs
depends_on:
mysql:
condition: service_healthy
networks:
emly:
ipv4_address: 172.16.32.4
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
- api
- dashboard
restart: unless-stopped
networks:
emly:
ipv4_address: 172.16.32.5
volumes:
mysql_data:
networks:
emly:
driver: bridge
ipam:
config:
- subnet: 172.16.32.0/24
gateway: 172.16.32.1

View File

@@ -0,0 +1,6 @@
# MySQL Connection
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=emly
MYSQL_PASSWORD=change_me_in_production
MYSQL_DATABASE=emly_bugreports

5
server/dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.svelte-kit
build
.env
bun.lock

View File

@@ -0,0 +1,9 @@
FROM oven/bun:alpine
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile || bun install
COPY . .
RUN bun run build
ENV NODE_ENV=production
EXPOSE 3000
CMD ["bun", "build/index.js"]

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src\\app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/schema.ts',
dialect: 'mysql',
dbCredentials: {
host: process.env.MYSQL_HOST || 'localhost',
port: Number(process.env.MYSQL_PORT) || 3306,
user: process.env.MYSQL_USER || 'emly',
password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'emly_bugreports'
}
});

View File

@@ -0,0 +1,41 @@
{
"name": "emly-dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev --port 3001",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/kit": "^2.51.0",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.2.3",
"drizzle-kit": "^0.31.9",
"svelte": "^5.51.1",
"svelte-check": "^4.4.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^6.4.1"
},
"dependencies": {
"@lucia-auth/adapter-drizzle": "^1.1.0",
"@node-rs/argon2": "^2.0.2",
"drizzle-orm": "^0.38.4",
"lucia": "^3.2.2",
"mysql2": "^3.17.1",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"jszip": "^3.10.1",
"lucide-svelte": "^0.469.0"
},
"type": "module"
}

View File

@@ -0,0 +1,121 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

10
server/dashboard/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare global {
namespace App {
interface Locals {
user: import('lucia').User | null;
session: import('lucia').Session | null;
}
}
}
export {};

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,60 @@
import type { Handle } from '@sveltejs/kit';
import { lucia } from '$lib/server/auth';
import { initLogger, Log } from '$lib/server/logger';
// Initialize dashboard logger
initLogger();
export const handle: Handle = async ({ event, resolve }) => {
const ip =
event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
event.request.headers.get('x-real-ip') ||
event.getClientAddress?.() ||
'unknown';
Log('HTTP', `${event.request.method} ${event.url.pathname} from ${ip}`);
const sessionId = event.cookies.get(lucia.sessionCookieName);
if (!sessionId) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
const { session, user } = await lucia.validateSession(sessionId);
if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id);
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes
});
}
if (!session) {
Log('AUTH', `Invalid session from ip=${ip}`);
const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes
});
}
// If user is disabled, invalidate their session and clear cookie
if (session && user && !user.enabled) {
Log('AUTH', `Disabled user rejected: username=${user.username} ip=${ip}`);
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes
});
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
event.locals.user = user;
event.locals.session = session;
return resolve(event);
};

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
{...restProps}
/>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogPortal from "./alert-dialog-portal.svelte";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props();
</script>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-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}
/>
</AlertDialogPortal>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
import Root from "./alert-dialog.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
};

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

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

View File

@@ -0,0 +1,15 @@
<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="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<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="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<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="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
<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="empty-content"
class={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<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="empty-description"
class={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

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

View File

@@ -0,0 +1,41 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const emptyMediaVariants = tv({
base: "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
});
export type EmptyMediaVariant = VariantProps<typeof emptyMediaVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
variant = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: EmptyMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="empty-icon"
data-variant={variant}
class={cn(emptyMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty-title"
class={cn("text-lg font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<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="empty"
class={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,22 @@
import Root from "./empty.svelte";
import Header from "./empty-header.svelte";
import Media from "./empty-media.svelte";
import Title from "./empty-title.svelte";
import Description from "./empty-description.svelte";
import Content from "./empty-content.svelte";
export {
Root,
Header,
Media,
Title,
Description,
Content,
//
Root as Empty,
Header as EmptyHeader,
Media as EmptyMedia,
Title as EmptyTitle,
Description as EmptyDescription,
Content as EmptyContent,
};

View File

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

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

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

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,37 @@
import Root from "./select.svelte";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};

Some files were not shown because too many files have changed in this diff Show More