1 Commits

79 changed files with 443 additions and 4222 deletions

View File

@@ -1,13 +0,0 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.gnu.org)",
"Bash(go run:*)",
"Bash(go build:*)",
"Bash(go doc:*)",
"Bash(go test:*)"
]
}
}

View File

@@ -252,8 +252,7 @@ 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, attempts server upload |
| `UploadBugReport(folderPath, input)` | Uploads bug report files to configured API server via multipart POST |
| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive |
**Settings (`app_settings.go`)**
@@ -673,42 +672,7 @@ Complete bug reporting system:
3. Includes current mail file if loaded
4. Gathers system information
5. Creates ZIP archive in temp folder
6. Attempts to upload to the bug report API server (if configured)
7. Falls back to local ZIP if server is unreachable
8. Shows server confirmation with report ID, or local path with upload warning
#### 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)
- **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
- **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"
```
6. Shows path and allows opening folder
### 5. Settings Management

View File

@@ -5,13 +5,8 @@ package main
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
@@ -55,12 +50,6 @@ 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"`
}
// =============================================================================
@@ -244,161 +233,10 @@ External IP: %s
return nil, fmt.Errorf("failed to create zip file: %w", err)
}
result := &SubmitBugReportResult{
return &SubmitBugReportResult{
ZipPath: zipPath,
FolderPath: bugReportFolder,
}
// Attempt to upload to the bug report API server
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
}, nil
}
// =============================================================================

View File

@@ -3,7 +3,7 @@
package main
import (
"emly/backend/utils/mail"
internal "emly/backend/utils/mail"
)
// =============================================================================
@@ -73,47 +73,6 @@ 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.
//
@@ -127,3 +86,50 @@ func (a *App) ReadAuto(filePath string) (*internal.EmailData, error) {
func (a *App) ShowOpenFileDialog() (string, error) {
return internal.ShowFileDialog(a.ctx)
}
func (a *App) ShowOpenFolderDialog() (string, error) {
return internal.ShowFolderDialog(a.ctx)
}
// SaveAttachment saves an attachment to the configured download folder.
// Uses EXPORT_ATTACHMENT_FOLDER from config.ini if set,
// otherwise falls back to WEBVIEW2_DOWNLOAD_PATH, then to default Downloads folder.
// After saving, opens Windows Explorer to show the saved file.
//
// Parameters:
// - filename: The name to save the file as
// - base64Data: The base64-encoded attachment data
//
// Returns:
// - string: The full path where the file was saved
// - error: Any file system errors
func (a *App) SaveAttachment(filename string, base64Data string) (string, error) {
// Try to get configured export folder first
folderPath := a.GetExportAttachmentFolder()
// If not set, try to get WEBVIEW2_DOWNLOAD_PATH from config
if folderPath == "" {
config := a.GetConfig()
if config != nil && config.EMLy.WebView2DownloadPath != "" {
folderPath = config.EMLy.WebView2DownloadPath
}
}
savedPath, err := internal.SaveAttachmentToFolder(filename, base64Data, folderPath)
if err != nil {
return "", err
}
return savedPath, nil
}
// OpenExplorerForPath opens Windows Explorer to show the specified file or folder.
//
// Parameters:
// - path: The full path to open in Explorer
//
// Returns:
// - error: Any execution errors
func (a *App) OpenExplorerForPath(path string) error {
return internal.OpenFileExplorer(path)
}

View File

@@ -128,3 +128,41 @@ func (a *App) SetUpdateCheckerEnabled(enabled bool) error {
return nil
}
// SetExportAttachmentFolder updates the EXPORT_ATTACHMENT_FOLDER setting in config.ini
// based on the user's preference from the GUI settings.
//
// Parameters:
// - folderPath: The path to the folder where attachments should be exported
//
// Returns:
// - error: Error if loading or saving config fails
func (a *App) SetExportAttachmentFolder(folderPath string) error {
// Load current config
config := a.GetConfig()
if config == nil {
return fmt.Errorf("failed to load config")
}
// Update the setting
config.EMLy.ExportAttachmentFolder = folderPath
// Save config back to disk
if err := a.SaveConfig(config); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
// GetExportAttachmentFolder returns the EXPORT_ATTACHMENT_FOLDER setting from config.ini
//
// Returns:
// - string: The path to the export folder, or empty string if not set
func (a *App) GetExportAttachmentFolder() string {
config := a.GetConfig()
if config == nil {
return ""
}
return config.EMLy.ExportAttachmentFolder
}

View File

@@ -22,8 +22,9 @@ 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"`
WebView2UserDataPath string `ini:"WEBVIEW2_USERDATA_PATH"`
WebView2DownloadPath string `ini:"WEBVIEW2_DOWNLOAD_PATH"`
ExportAttachmentFolder string `ini:"EXPORT_ATTACHMENT_FOLDER"`
}
// LoadConfig reads the config.ini file at the given path and returns a Config struct

View File

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

@@ -2,6 +2,13 @@ package internal
import (
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -16,6 +23,14 @@ var EmailDialogOptions = runtime.OpenDialogOptions{
ShowHiddenFiles: false,
}
var FolderDialogOptions = runtime.OpenDialogOptions{
Title: "Select Folder",
Filters: []runtime.FileFilter{
{DisplayName: "Folders", Pattern: "*"},
},
ShowHiddenFiles: false,
}
func ShowFileDialog(ctx context.Context) (string, error) {
filePath, err := runtime.OpenFileDialog(ctx, EmailDialogOptions)
if err != nil {
@@ -23,3 +38,86 @@ func ShowFileDialog(ctx context.Context) (string, error) {
}
return filePath, nil
}
func ShowFolderDialog(ctx context.Context) (string, error) {
folderPath, err := runtime.OpenDirectoryDialog(ctx, FolderDialogOptions)
if err != nil {
return "", err
}
return folderPath, nil
}
// SaveAttachmentToFolder saves a base64-encoded attachment to the specified folder.
// If folderPath is empty, uses the user's Downloads folder as default.
// Expands environment variables in the format %%VAR%% or %VAR%.
//
// Parameters:
// - filename: The name to save the file as
// - base64Data: The base64-encoded file content
// - folderPath: Optional custom folder path (uses Downloads if empty)
//
// Returns:
// - string: The full path where the file was saved
// - error: Any file system or decoding errors
func SaveAttachmentToFolder(filename string, base64Data string, folderPath string) (string, error) {
// Decode base64 data
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return "", fmt.Errorf("failed to decode attachment data: %w", err)
}
// Use configured folder or default to Downloads
targetFolder := folderPath
if targetFolder == "" {
targetFolder = filepath.Join(os.Getenv("USERPROFILE"), "Downloads")
} else {
// Expand environment variables (%%VAR%% or %VAR% format)
re := regexp.MustCompile(`%%([^%]+)%%|%([^%]+)%`)
targetFolder = re.ReplaceAllStringFunc(targetFolder, func(match string) string {
varName := strings.Trim(match, "%")
return os.Getenv(varName)
})
}
// Ensure the target folder exists
if err := os.MkdirAll(targetFolder, 0755); err != nil {
return "", fmt.Errorf("failed to create target folder: %w", err)
}
// Create full path
fullPath := filepath.Join(targetFolder, filename)
// Save the file
if err := os.WriteFile(fullPath, data, 0644); err != nil {
return "", fmt.Errorf("failed to save attachment: %w", err)
}
return fullPath, nil
}
// OpenFileExplorer opens Windows Explorer and selects the specified file.
// Uses the /select parameter to highlight the file in Explorer.
// If the path is a directory, opens the directory without selecting anything.
//
// Parameters:
// - filePath: The full path to the file or directory to open in Explorer
//
// Returns:
// - error: Any execution errors
func OpenFileExplorer(filePath string) error {
// Check if path is a directory or file
info, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("failed to stat path: %w", err)
}
if info.IsDir() {
// Open directory
cmd := exec.Command("explorer.exe", filePath)
return cmd.Start()
}
// Open and select file
cmd := exec.Command("explorer.exe", "/select,", filePath)
return cmd.Start()
}

View File

@@ -1,47 +0,0 @@
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

@@ -1,58 +0,0 @@
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

@@ -1,67 +0,0 @@
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

@@ -1,78 +0,0 @@
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

@@ -1,241 +0,0 @@
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

@@ -1,209 +0,0 @@
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

@@ -1,273 +0,0 @@
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

@@ -1,97 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,444 +0,0 @@
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

@@ -1,59 +0,0 @@
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

@@ -7,5 +7,6 @@ LANGUAGE = it
UPDATE_CHECK_ENABLED = false
UPDATE_PATH =
UPDATE_AUTO_CHECK = true
BUGREPORT_API_URL = "http://localhost:3000"
BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63"
WEBVIEW2_USERDATA_PATH =
WEBVIEW2_DOWNLOAD_PATH = %%USERPROFILE%%\Documents\EMLy_Attachments
EXPORT_ATTACHMENT_FOLDER =

View File

@@ -5,10 +5,7 @@
"": {
"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",
@@ -190,7 +187,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.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@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-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="],
@@ -252,8 +249,6 @@
"@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=="],
@@ -266,8 +261,6 @@
"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=="],
@@ -284,8 +277,6 @@
"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=="],
@@ -316,8 +307,6 @@
"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=="],
@@ -426,8 +415,6 @@
"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=="],
@@ -446,8 +433,6 @@
"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=="],
@@ -484,8 +469,6 @@
"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

@@ -219,7 +219,10 @@
"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: ",
"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"
"settings_custom_download_label": "Custom Attachment Download",
"settings_custom_download_hint": "Save attachments to a custom folder and open Explorer automatically",
"settings_custom_download_info": "Info: When enabled, attachments will be saved to the folder configured below (or WEBVIEW2_DOWNLOAD_PATH if not set) and Windows Explorer will open to show the file. When disabled, uses browser's default download behavior.",
"settings_export_folder_label": "Select a folder to save exported attachments",
"settings_export_folder_hint": "Choose a default location for saving attachments that you export from emails (instead of the Downloads folder)",
"settings_select_folder_button": "Select folder"
}

View File

@@ -219,7 +219,11 @@
"mail_download_btn_label": "Scarica",
"mail_download_btn_title": "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"
"settings_custom_download_label": "Download Personalizzato Allegati",
"settings_custom_download_hint": "Salva gli allegati in una cartella personalizzata e apri automaticamente Esplora Risorse",
"settings_custom_download_info": "Info: Quando abilitato, gli allegati verranno salvati nella cartella configurata di seguito (o WEBVIEW2_DOWNLOAD_PATH se non impostata) e Windows Explorer si aprirà per mostrare il file. Quando disabilitato, usa il comportamento di download predefinito del browser.",
"settings_export_folder_label": "Seleziona una cartella per salvare gli allegati esportati",
"settings_export_folder_hint": "Scegli una posizione predefinita per salvare gli allegati che esporti dalle email (invece della cartella Download)",
"settings_select_folder_button": "Seleziona cartella"
}

View File

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

View File

@@ -6,24 +6,16 @@
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, CloudUpload, AlertTriangle } from "@lucide/svelte";
import { CheckCircle, Copy, FolderOpen, Camera, Loader2 } 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);
@@ -36,9 +28,6 @@
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
);
@@ -111,9 +100,6 @@
isSubmitting = false;
isSuccess = false;
resultZipPath = "";
uploadedToServer = false;
serverReportId = 0;
uploadError = "";
}
async function handleBugReportSubmit(event: Event) {
@@ -137,11 +123,8 @@
});
resultZipPath = result.zipPath;
uploadedToServer = result.uploaded;
serverReportId = result.reportId;
uploadError = result.uploadError;
isSuccess = true;
console.log("Bug report created:", result.zipPath, "uploaded:", result.uploaded);
console.log("Bug report created:", result.zipPath);
} catch (err) {
console.error("Failed to create bug report:", err);
toast.error(m.bugreport_error());
@@ -179,31 +162,15 @@
<!-- Success State -->
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
{#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}
<CheckCircle class="h-5 w-5 text-green-500" />
{m.bugreport_success_title()}
</Dialog.Title>
<Dialog.Description>
{#if uploadedToServer}
{m.bugreport_uploaded_success({ reportId: String(serverReportId) })}
{:else}
{m.bugreport_success_message()}
{/if}
{m.bugreport_success_message()}
</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

@@ -15,6 +15,7 @@
import { onDestroy, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { EventsOn, WindowShow, WindowUnminimise } from '$lib/wailsjs/runtime/runtime';
import { SaveAttachment, OpenExplorerForPath } from '$lib/wailsjs/go/main/App';
import { mailState } from '$lib/stores/mail-state.svelte';
import * as m from '$lib/paraglide/messages';
import { dev } from '$app/environment';
@@ -61,19 +62,41 @@
mailState.clear();
}
function onDownloadAttachments() {
async function onDownloadAttachments() {
if (!mailState.currentEmail || !mailState.currentEmail.attachments) return;
mailState.currentEmail.attachments.forEach((att) => {
const base64 = arrayBufferToBase64(att.data);
const dataUrl = createDataUrl(att.contentType, base64);
const link = document.createElement('a');
link.href = dataUrl;
link.download = att.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// Check if custom download behavior is enabled
const useCustomDownload = settingsStore.settings.useCustomAttachmentDownload ?? false;
if (useCustomDownload) {
// Use backend SaveAttachment (saves to configured folder and opens Explorer)
try {
let lastSavedPath = '';
for (const att of mailState.currentEmail.attachments) {
const base64 = arrayBufferToBase64(att.data);
lastSavedPath = await SaveAttachment(att.filename, base64);
toast.success(`Saved: ${att.filename}`);
}
// Open Explorer to show the folder where files were saved
if (lastSavedPath) {
await OpenExplorerForPath(lastSavedPath);
}
} catch (err) {
toast.error(`Failed to save attachments: ${err}`);
}
} else {
// Use browser default download (downloads to browser's default folder)
mailState.currentEmail.attachments.forEach((att) => {
const base64 = arrayBufferToBase64(att.data);
const dataUrl = createDataUrl(att.contentType, base64);
const link = document.createElement('a');
link.href = dataUrl;
link.download = att.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
}
async function onOpenMail() {

View File

@@ -18,6 +18,8 @@ const defaults: EMLy_GUI_Settings = {
reduceMotion: false,
theme: "dark",
increaseWindowButtonsContrast: false,
exportAttachmentFolder: "",
useCustomAttachmentDownload: false,
};
class SettingsStore {

View File

@@ -5,15 +5,17 @@ type SupportedFileTypePreview = "jpg" | "jpeg" | "png";
interface EMLy_GUI_Settings {
selectedLanguage: SupportedLanguages = "en" | "it";
useBuiltinPreview: boolean;
useBuiltinPDFViewer?: boolean;
previewFileSupportedTypes?: SupportedFileTypePreview[];
enableAttachedDebuggerProtection?: boolean;
useBuiltinPDFViewer: boolean;
previewFileSupportedTypes: SupportedFileTypePreview[];
enableAttachedDebuggerProtection: boolean;
useDarkEmailViewer?: boolean;
enableUpdateChecker?: boolean;
musicInspirationEnabled?: boolean;
reduceMotion?: boolean;
theme?: "light" | "dark";
increaseWindowButtonsContrast?: boolean;
theme: "light" | "dark";
increaseWindowButtonsContrast: boolean;
exportAttachmentFolder?: string;
useCustomAttachmentDownload?: boolean;
}
type SupportedLanguages = "en" | "it";

View File

@@ -6,8 +6,6 @@ import {
ReadEML,
ReadMSG,
ReadPEC,
ReadAuto,
DetectEmailFormat,
ShowOpenFileDialog,
SetCurrentMailFilePath,
ConvertToUTF8,
@@ -25,8 +23,7 @@ export interface LoadEmailResult {
}
/**
* Determines the email file type from the path extension (best-effort hint).
* Use DetectEmailFormat (backend) for reliable format detection.
* Determines the email file type from the path
*/
export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
const lowerPath = filePath.toLowerCase();
@@ -36,57 +33,18 @@ export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
}
/**
* Checks if a file path looks like an email file by extension.
* Returns true also for unknown extensions so the backend can attempt parsing.
* Checks if a file path is a valid email file
*/
export function isEmailFile(filePath: string): boolean {
return filePath.trim().length > 0;
return getEmailFileType(filePath) !== null;
}
/**
* 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.
*
* Loads an email from a file path
* @param filePath - Path to the email file
* @returns LoadEmailResult with the email data or error
*/
export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult> {
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) {
@@ -102,6 +60,7 @@ export async function loadEmailFromPathLegacy(filePath: string): Promise<LoadEma
if (fileType === 'msg') {
email = await ReadMSG(filePath, true);
} else {
// Try PEC first, fall back to regular EML
try {
email = await ReadPEC(filePath);
} catch {
@@ -109,6 +68,7 @@ export async function loadEmailFromPathLegacy(filePath: string): Promise<LoadEma
}
}
// Process body if needed (decode base64)
if (email?.body) {
const trimmed = email.body.trim();
if (looksLikeBase64(trimmed)) {
@@ -119,11 +79,18 @@ export async function loadEmailFromPathLegacy(filePath: string): Promise<LoadEma
}
}
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,7 +33,6 @@ export {
getEmailFileType,
isEmailFile,
loadEmailFromPath,
loadEmailFromPathLegacy,
openAndLoadEmail,
processEmailBody,
type LoadEmailResult,

View File

@@ -1,29 +1,15 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {main} from '../models';
import {utils} from '../models';
import {main} 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>;
@@ -32,26 +18,14 @@ 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>;
@@ -60,12 +34,8 @@ 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>;
@@ -76,16 +46,4 @@ 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,34 +2,10 @@
// 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);
}
@@ -38,10 +14,6 @@ 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']();
}
@@ -58,30 +30,10 @@ 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']();
}
@@ -94,10 +46,6 @@ 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);
}
@@ -114,18 +62,10 @@ 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);
}
@@ -146,30 +86,6 @@ 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,44 +242,6 @@ 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;
@@ -308,70 +270,6 @@ 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;
@@ -819,10 +717,6 @@ 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);
@@ -834,10 +728,6 @@ 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

@@ -6,7 +6,7 @@
import { Label } from "$lib/components/ui/label";
import { Separator } from "$lib/components/ui/separator";
import { Switch } from "$lib/components/ui/switch";
import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle, Sun, Moon } from "@lucide/svelte";
import { ChevronLeft, Flame, Download, Upload, RefreshCw, CheckCircle2, AlertCircle, Sun, Moon, FolderArchive } from "@lucide/svelte";
import type { EMLy_GUI_Settings } from "$lib/types";
import { toast } from "svelte-sonner";
import { It, Us } from "svelte-flags";
@@ -25,8 +25,9 @@
import { setLocale } from "$lib/paraglide/runtime";
import { mailState } from "$lib/stores/mail-state.svelte.js";
import { dev } from '$app/environment';
import { ExportSettings, ImportSettings, CheckForUpdates, DownloadUpdate, InstallUpdate, GetUpdateStatus, SetUpdateCheckerEnabled } from "$lib/wailsjs/go/main/App";
import { ExportSettings, ImportSettings, CheckForUpdates, DownloadUpdate, InstallUpdate, SetUpdateCheckerEnabled, ShowOpenFolderDialog, GetExportAttachmentFolder, SetExportAttachmentFolder } from "$lib/wailsjs/go/main/App";
import { EventsOn, EventsOff } from "$lib/wailsjs/runtime/runtime";
import Input from "$lib/components/ui/input/input.svelte";
let { data } = $props();
let config = $derived(data.config);
@@ -44,6 +45,8 @@
reduceMotion: false,
theme: "dark",
increaseWindowButtonsContrast: false,
exportAttachmentFolder: "",
useCustomAttachmentDownload: false,
};
async function setLanguage(
@@ -82,6 +85,8 @@
reduceMotion: s.reduceMotion ?? defaults.reduceMotion ?? false,
theme: s.theme || defaults.theme || "light",
increaseWindowButtonsContrast: s.increaseWindowButtonsContrast ?? defaults.increaseWindowButtonsContrast ?? false,
exportAttachmentFolder: s.exportAttachmentFolder || defaults.exportAttachmentFolder || "",
useCustomAttachmentDownload: s.useCustomAttachmentDownload ?? defaults.useCustomAttachmentDownload ?? false,
};
}
@@ -94,6 +99,8 @@
!!a.useDarkEmailViewer === !!b.useDarkEmailViewer &&
!!a.enableUpdateChecker === !!b.enableUpdateChecker &&
!!a.reduceMotion === !!b.reduceMotion &&
!!a.exportAttachmentFolder === !!b.exportAttachmentFolder &&
!!a.useCustomAttachmentDownload === !!b.useCustomAttachmentDownload &&
(a.theme ?? "light") === (b.theme ?? "light") &&
!!a.increaseWindowButtonsContrast === !!b.increaseWindowButtonsContrast &&
JSON.stringify(a.previewFileSupportedTypes?.sort()) ===
@@ -142,6 +149,7 @@
sessionStorage.removeItem("debugWindowInSettings");
dangerZoneEnabled.set(false);
LogDebug("Reset danger zone setting to false.");
await SetExportAttachmentFolder("");
} catch {
toast.error(m.settings_toast_reset_failed());
return;
@@ -195,10 +203,10 @@
});
// Sync update checker setting to backend config.ini
let previousUpdateCheckerEnabled = form.enableUpdateChecker;
$effect(() => {
(async () => {
if (!browser) return;
let previousUpdateCheckerEnabled = form.enableUpdateChecker;
if (form.enableUpdateChecker !== previousUpdateCheckerEnabled) {
try {
await SetUpdateCheckerEnabled(form.enableUpdateChecker ?? true);
@@ -221,6 +229,52 @@
previousTheme = form.theme;
});
// Load export attachment folder from config.ini on startup
$effect(() => {
if (!browser) return;
(async () => {
try {
const configFolder = await GetExportAttachmentFolder();
if (configFolder && configFolder.trim() !== "") {
form.exportAttachmentFolder = configFolder;
// Also update lastSaved to avoid triggering unsaved changes
lastSaved = { ...lastSaved, exportAttachmentFolder: configFolder };
}
} catch (err) {
console.error("Failed to load export folder from config:", err);
}
})();
});
async function openFolderDialog(): Promise<string | null> {
try {
const result = await ShowOpenFolderDialog();
if (result) {
return result;
}
} catch (err) {
console.error("Failed to open folder dialog:", err);
toast.error("Failed to open folder dialog.");
}
return null;
}
async function selectExportFolder() {
const folder = await openFolderDialog();
if (folder) {
// Save to form state
form.exportAttachmentFolder = folder;
// Save to config.ini
try {
await SetExportAttachmentFolder(folder);
toast.success("Export folder updated!");
} catch (err) {
console.error("Failed to save export folder:", err);
toast.error("Failed to save export folder to config.");
}
}
}
async function exportSettings() {
try {
const settingsJSON = JSON.stringify(form, null, 2);
@@ -344,7 +398,7 @@
});
</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"
>
@@ -692,6 +746,61 @@
{m.settings_preview_pdf_builtin_info()}
</p>
</div>
<Separator />
<div class="space-y-3">
<div class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4">
<div>
<div class="font-medium">{m.settings_custom_download_label()}</div>
<div class="text-sm text-muted-foreground">
{m.settings_custom_download_hint()}
</div>
</div>
<Switch
id="use-custom-attachment-download"
bind:checked={form.useCustomAttachmentDownload}
class="cursor-pointer hover:cursor-pointer"
/>
</div>
<p class="text-xs text-muted-foreground mt-2">
{m.settings_custom_download_info()}
</p>
</div>
{#if form.useCustomAttachmentDownload}
<Separator />
<div class="space-y-3">
<div class="rounded-lg border bg-card p-4 space-y-3">
<div>
<div class="font-medium">
{m.settings_export_folder_label()}
</div>
<div class="text-sm text-muted-foreground">
{m.settings_export_folder_hint()}
</div>
</div>
<div class="flex items-center gap-2">
<Input
type="text"
placeholder="%USERPROFILE%\Documents\EMLy_Attachments"
class="flex-1"
readonly
bind:value={form.exportAttachmentFolder}
/>
<Button
variant="outline"
class="cursor-pointer hover:cursor-pointer"
onclick={selectExportFolder}
>
<FolderArchive class="size-4 mr-2" />
{m.settings_select_folder_button()}
</Button>
</div>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>

3
go.mod
View File

@@ -4,7 +4,6 @@ go 1.24.4
require (
github.com/jaypipes/ghw v0.21.2
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
@@ -31,8 +30,6 @@ require (
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

9
go.sum
View File

@@ -1,4 +1,3 @@
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=
@@ -48,7 +47,6 @@ 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=
@@ -67,13 +65,6 @@ 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=

56
main.go
View File

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

View File

@@ -1,19 +0,0 @@
# 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
View File

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

View File

@@ -1,13 +0,0 @@
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"]

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,34 +0,0 @@
{
"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": {
"@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",
"svelte": "^5.51.1",
"svelte-check": "^4.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^6.4.1"
},
"dependencies": {
"drizzle-orm": "^0.38.4",
"mysql2": "^3.17.1",
"bits-ui": "^1.8.0",
"clsx": "^2.1.1",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^0.3.1",
"jszip": "^3.10.1",
"lucide-svelte": "^0.469.0"
},
"type": "module"
}

View File

@@ -1,42 +0,0 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme {
--color-background: hsl(222.2 84% 4.9%);
--color-foreground: hsl(210 40% 98%);
--color-card: hsl(222.2 84% 4.9%);
--color-card-foreground: hsl(210 40% 98%);
--color-popover: hsl(222.2 84% 4.9%);
--color-popover-foreground: hsl(210 40% 98%);
--color-primary: hsl(217.2 91.2% 59.8%);
--color-primary-foreground: hsl(222.2 47.4% 11.2%);
--color-secondary: hsl(217.2 32.6% 17.5%);
--color-secondary-foreground: hsl(210 40% 98%);
--color-muted: hsl(217.2 32.6% 17.5%);
--color-muted-foreground: hsl(215 20.2% 65.1%);
--color-accent: hsl(217.2 32.6% 17.5%);
--color-accent-foreground: hsl(210 40% 98%);
--color-destructive: hsl(0 62.8% 30.6%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(217.2 32.6% 17.5%);
--color-input: hsl(217.2 32.6% 17.5%);
--color-ring: hsl(224.3 76.3% 48%);
--radius: 0.5rem;
--color-sidebar: hsl(222.2 84% 3.5%);
--color-sidebar-foreground: hsl(210 40% 98%);
--color-sidebar-border: hsl(217.2 32.6% 12%);
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
}
* {
border-color: var(--color-border);
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
}

View File

@@ -1,12 +0,0 @@
<!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

@@ -1,54 +0,0 @@
import {
mysqlTable,
int,
varchar,
text,
json,
mysqlEnum,
timestamp,
customType
} from 'drizzle-orm/mysql-core';
const longblob = customType<{ data: Buffer }>({
dataType() {
return 'longblob';
}
});
export const bugReports = mysqlTable('bug_reports', {
id: int('id').autoincrement().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull(),
description: text('description').notNull(),
hwid: varchar('hwid', { length: 255 }).notNull().default(''),
hostname: varchar('hostname', { length: 255 }).notNull().default(''),
os_user: varchar('os_user', { length: 255 }).notNull().default(''),
submitter_ip: varchar('submitter_ip', { length: 45 }).notNull().default(''),
system_info: json('system_info'),
status: mysqlEnum('status', ['new', 'in_review', 'resolved', 'closed']).notNull().default('new'),
created_at: timestamp('created_at').notNull().defaultNow(),
updated_at: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
});
export const bugReportFiles = mysqlTable('bug_report_files', {
id: int('id').autoincrement().primaryKey(),
report_id: int('report_id')
.notNull()
.references(() => bugReports.id, { onDelete: 'cascade' }),
file_role: mysqlEnum('file_role', [
'screenshot',
'mail_file',
'localstorage',
'config',
'system_info'
]).notNull(),
filename: varchar('filename', { length: 255 }).notNull(),
mime_type: varchar('mime_type', { length: 127 }).notNull().default('application/octet-stream'),
file_size: int('file_size').notNull().default(0),
data: longblob('data').notNull(),
created_at: timestamp('created_at').notNull().defaultNow()
});
export type BugReport = typeof bugReports.$inferSelect;
export type BugReportFile = typeof bugReportFiles.$inferSelect;
export type BugReportStatus = BugReport['status'];

View File

@@ -1,16 +0,0 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import * as schema from '$lib/schema';
import { env } from '$env/dynamic/private';
const pool = mysql.createPool({
host: env.MYSQL_HOST || 'localhost',
port: Number(env.MYSQL_PORT) || 3306,
user: env.MYSQL_USER || 'emly',
password: env.MYSQL_PASSWORD,
database: env.MYSQL_DATABASE || 'emly_bugreports',
connectionLimit: 10,
idleTimeout: 60000
});
export const db = drizzle(pool, { schema, mode: 'default' });

View File

@@ -1,39 +0,0 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
export const statusColors: Record<string, string> = {
new: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
in_review: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
resolved: 'bg-green-500/20 text-green-400 border-green-500/30',
closed: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30'
};
export const statusLabels: Record<string, string> = {
new: 'New',
in_review: 'In Review',
resolved: 'Resolved',
closed: 'Closed'
};

View File

@@ -1,14 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
</script>
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
<h1 class="text-6xl font-bold text-muted-foreground">{$page.status}</h1>
<p class="mt-4 text-lg text-muted-foreground">{$page.error?.message || 'Something went wrong'}</p>
<a
href="/"
class="mt-6 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Back to Reports
</a>
</div>

View File

@@ -1,15 +0,0 @@
import type { LayoutServerLoad } from './$types';
import { db } from '$lib/server/db';
import { bugReports } from '$lib/schema';
import { eq, count } from 'drizzle-orm';
export const load: LayoutServerLoad = async () => {
const [result] = await db
.select({ count: count() })
.from(bugReports)
.where(eq(bugReports.status, 'new'));
return {
newCount: result.count
};
};

View File

@@ -1,61 +0,0 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
import { Bug, LayoutDashboard } from 'lucide-svelte';
let { children, data } = $props();
</script>
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="flex w-56 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
<div class="flex items-center gap-2 border-b border-sidebar-border px-4 py-4">
<Bug class="h-6 w-6 text-primary" />
<span class="text-lg font-semibold">EMLy Dashboard</span>
</div>
<nav class="flex-1 px-2 py-3">
<a
href="/"
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors {$page.url
.pathname === '/'
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'}"
>
<LayoutDashboard class="h-4 w-4" />
Reports
</a>
</nav>
<div class="border-t border-sidebar-border px-4 py-3 text-xs text-muted-foreground">
EMLy Bug Reports
</div>
</aside>
<!-- Main content -->
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Top bar -->
<header
class="flex h-14 items-center justify-between border-b border-border bg-card px-6"
>
<h1 class="text-lg font-semibold">
{#if $page.url.pathname === '/'}
Bug Reports
{:else if $page.url.pathname.startsWith('/reports/')}
Report Detail
{:else}
Dashboard
{/if}
</h1>
{#if data.newCount > 0}
<div class="flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-1.5 text-sm text-blue-400">
<span class="inline-block h-2 w-2 rounded-full bg-blue-400"></span>
{data.newCount} new {data.newCount === 1 ? 'report' : 'reports'}
</div>
{/if}
</header>
<!-- Page content -->
<main class="flex-1 overflow-auto p-6">
{@render children()}
</main>
</div>
</div>

View File

@@ -1,70 +0,0 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { bugReports, bugReportFiles } from '$lib/schema';
import { eq, like, or, count, sql, desc, and } from 'drizzle-orm';
export const load: PageServerLoad = async ({ url }) => {
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
const pageSize = Math.min(50, Math.max(10, Number(url.searchParams.get('pageSize')) || 20));
const status = url.searchParams.get('status') || '';
const search = url.searchParams.get('search') || '';
const conditions = [];
if (status && ['new', 'in_review', 'resolved', 'closed'].includes(status)) {
conditions.push(eq(bugReports.status, status as 'new' | 'in_review' | 'resolved' | 'closed'));
}
if (search) {
conditions.push(
or(
like(bugReports.hostname, `%${search}%`),
like(bugReports.os_user, `%${search}%`),
like(bugReports.name, `%${search}%`),
like(bugReports.email, `%${search}%`)
)
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
// Get total count
const [{ total }] = await db
.select({ total: count() })
.from(bugReports)
.where(where);
// Get paginated reports with file count
const reports = await db
.select({
id: bugReports.id,
name: bugReports.name,
email: bugReports.email,
hostname: bugReports.hostname,
os_user: bugReports.os_user,
status: bugReports.status,
created_at: bugReports.created_at,
file_count: count(bugReportFiles.id)
})
.from(bugReports)
.leftJoin(bugReportFiles, eq(bugReports.id, bugReportFiles.report_id))
.where(where)
.groupBy(bugReports.id)
.orderBy(desc(bugReports.created_at))
.limit(pageSize)
.offset((page - 1) * pageSize);
return {
reports,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize)
},
filters: {
status,
search
}
};
};

View File

@@ -1,193 +0,0 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { statusColors, statusLabels, formatDate } from '$lib/utils';
import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw } from 'lucide-svelte';
let { data } = $props();
let searchInput = $state('');
let statusFilter = $state('');
$effect(() => {
searchInput = data.filters.search;
statusFilter = data.filters.status;
});
function applyFilters() {
const params = new URLSearchParams();
if (searchInput) params.set('search', searchInput);
if (statusFilter) params.set('status', statusFilter);
params.set('page', '1');
goto(`/?${params.toString()}`);
}
function goToPage(p: number) {
const params = new URLSearchParams($page.url.searchParams);
params.set('page', String(p));
goto(`/?${params.toString()}`);
}
function clearFilters() {
searchInput = '';
statusFilter = '';
goto('/');
}
async function refreshReports() {
try {
await invalidateAll();
} catch (err) {
console.error('Failed to refresh reports:', err);
}
}
</script>
<div class="space-y-4">
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<div class="relative flex-1 min-w-50 max-w-sm">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search hostname, user, name, email..."
bind:value={searchInput}
onkeydown={(e) => e.key === 'Enter' && applyFilters()}
class="w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<select
bind:value={statusFilter}
onchange={applyFilters}
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All statuses</option>
<option value="new">New</option>
<option value="in_review">In Review</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<button
onclick={applyFilters}
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Filter class="h-4 w-4" />
Filter
</button>
<button
onclick={refreshReports}
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<RefreshCcw class="h-4 w-4" />
Refresh
</button>
{#if data.filters.search || data.filters.status}
<button
onclick={clearFilters}
class="rounded-md border border-input px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Clear
</button>
{/if}
</div>
<!-- Table -->
<div class="overflow-hidden rounded-lg border border-border">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border bg-muted/50">
<th class="px-4 py-3 text-left font-medium text-muted-foreground">ID</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Hostname</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Reporter</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Files</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
</tr>
</thead>
<tbody>
{#each data.reports as report (report.id)}
<tr
class="border-b border-border transition-colors hover:bg-muted/30 cursor-pointer"
onclick={() => goto(`/reports/${report.id}`)}
>
<td class="px-4 py-3 font-mono text-muted-foreground">#{report.id}</td>
<td class="px-4 py-3">{report.hostname || '—'}</td>
<td class="px-4 py-3">{report.os_user || '—'}</td>
<td class="px-4 py-3">
<div>{report.name}</div>
<div class="text-xs text-muted-foreground">{report.email}</div>
</td>
<td class="px-4 py-3">
<span
class="inline-flex rounded-full border px-2 py-0.5 text-xs font-medium {statusColors[report.status]}"
>
{statusLabels[report.status]}
</span>
</td>
<td class="px-4 py-3">
{#if report.file_count > 0}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<Paperclip class="h-3.5 w-3.5" />
{report.file_count}
</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
<td class="px-4 py-3 text-muted-foreground">{formatDate(report.created_at)}</td>
</tr>
{:else}
<tr>
<td colspan="7" class="px-4 py-12 text-center text-muted-foreground">
No reports found.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.pagination.totalPages > 1}
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
Showing {(data.pagination.page - 1) * data.pagination.pageSize + 1} to {Math.min(
data.pagination.page * data.pagination.pageSize,
data.pagination.total
)} of {data.pagination.total} reports
</p>
<div class="flex items-center gap-1">
<button
onclick={() => goToPage(data.pagination.page - 1)}
disabled={data.pagination.page <= 1}
class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
>
<ChevronLeft class="h-4 w-4" />
</button>
{#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p}
{#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)}
<button
onclick={() => goToPage(p)}
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-sm {p ===
data.pagination.page
? 'bg-primary text-primary-foreground'
: 'border border-input hover:bg-accent'}"
>
{p}
</button>
{:else if p === data.pagination.page - 2 || p === data.pagination.page + 2}
<span class="px-1 text-muted-foreground">...</span>
{/if}
{/each}
<button
onclick={() => goToPage(data.pagination.page + 1)}
disabled={data.pagination.page >= data.pagination.totalPages}
class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
{/if}
</div>

View File

@@ -1,68 +0,0 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { bugReports, bugReportFiles } from '$lib/schema';
import { eq } from 'drizzle-orm';
import JSZip from 'jszip';
export const GET: RequestHandler = async ({ params }) => {
const id = Number(params.id);
if (isNaN(id)) throw error(400, 'Invalid report ID');
const [report] = await db
.select()
.from(bugReports)
.where(eq(bugReports.id, id))
.limit(1);
if (!report) throw error(404, 'Report not found');
const files = await db
.select()
.from(bugReportFiles)
.where(eq(bugReportFiles.report_id, id));
const zip = new JSZip();
// Add report metadata as text file
const reportText = [
`Bug Report #${report.id}`,
`========================`,
``,
`Name: ${report.name}`,
`Email: ${report.email}`,
`Hostname: ${report.hostname}`,
`OS User: ${report.os_user}`,
`HWID: ${report.hwid}`,
`IP: ${report.submitter_ip}`,
`Status: ${report.status}`,
`Created: ${report.created_at.toISOString()}`,
`Updated: ${report.updated_at.toISOString()}`,
``,
`Description:`,
`------------`,
report.description,
``,
...(report.system_info
? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)]
: [])
].join('\n');
zip.file('report.txt', reportText);
// Add all files
for (const file of files) {
const folder = file.file_role;
zip.file(`${folder}/${file.filename}`, file.data);
}
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
return new Response(new Uint8Array(zipBuffer), {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="report-${id}.zip"`,
'Content-Length': String(zipBuffer.length)
}
});
};

View File

@@ -1,28 +0,0 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { bugReportFiles } from '$lib/schema';
import { eq, and } from 'drizzle-orm';
export const GET: RequestHandler = async ({ params }) => {
const reportId = Number(params.id);
const fileId = Number(params.fileId);
if (isNaN(reportId) || isNaN(fileId)) throw error(400, 'Invalid ID');
const [file] = await db
.select()
.from(bugReportFiles)
.where(and(eq(bugReportFiles.id, fileId), eq(bugReportFiles.report_id, reportId)))
.limit(1);
if (!file) throw error(404, 'File not found');
return new Response(new Uint8Array(file.data), {
headers: {
'Content-Type': file.mime_type,
'Content-Disposition': `inline; filename="${file.filename}"`,
'Content-Length': String(file.file_size)
}
});
};

View File

@@ -1,13 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { bugReports } from '$lib/schema';
import { count } from 'drizzle-orm';
export const GET: RequestHandler = async () => {
const [{ total }] = await db
.select({ total: count() })
.from(bugReports);
return json({ total });
};

View File

@@ -1,44 +0,0 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { bugReports, bugReportFiles } from '$lib/schema';
import { eq } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
const id = Number(params.id);
if (isNaN(id)) throw error(400, 'Invalid report ID');
const [report] = await db
.select()
.from(bugReports)
.where(eq(bugReports.id, id))
.limit(1);
if (!report) throw error(404, 'Report not found');
const files = await db
.select({
id: bugReportFiles.id,
report_id: bugReportFiles.report_id,
file_role: bugReportFiles.file_role,
filename: bugReportFiles.filename,
mime_type: bugReportFiles.mime_type,
file_size: bugReportFiles.file_size,
created_at: bugReportFiles.created_at
})
.from(bugReportFiles)
.where(eq(bugReportFiles.report_id, id));
return {
report: {
...report,
system_info: report.system_info ? JSON.stringify(report.system_info, null, 2) : null,
created_at: report.created_at.toISOString(),
updated_at: report.updated_at.toISOString()
},
files: files.map((f) => ({
...f,
created_at: f.created_at.toISOString()
}))
};
};

View File

@@ -1,279 +0,0 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { statusColors, statusLabels, formatDate, formatBytes } from '$lib/utils';
import {
ArrowLeft,
Download,
Trash2,
FileText,
Image,
Monitor,
Settings,
Database,
ChevronDown,
ChevronRight,
Mail
} from 'lucide-svelte';
let { data } = $props();
let showDeleteDialog = $state(false);
let showSystemInfo = $state(false);
let statusUpdating = $state(false);
let deleting = $state(false);
const roleIcons: Record<string, typeof FileText> = {
screenshot: Image,
mail_file: Mail,
localstorage: Database,
config: Settings,
system_info: Monitor
};
const roleLabels: Record<string, string> = {
screenshot: 'Screenshot',
mail_file: 'Mail File',
localstorage: 'Local Storage',
config: 'Config',
system_info: 'System Info'
};
async function updateStatus(newStatus: string) {
statusUpdating = true;
try {
const res = await fetch(`/reports/${data.report.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
if (res.ok) await invalidateAll();
} finally {
statusUpdating = false;
}
}
async function deleteReport() {
deleting = true;
try {
const res = await fetch(`/reports/${data.report.id}`, { method: 'DELETE' });
if (res.ok) goto('/');
} finally {
deleting = false;
}
}
</script>
<div class="space-y-6">
<!-- Back button -->
<a
href="/"
class="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="h-4 w-4" />
Back to Reports
</a>
<!-- Header card -->
<div class="rounded-lg border border-border bg-card p-6">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3">
<h2 class="text-xl font-semibold">Report #{data.report.id}</h2>
<span
class="inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium {statusColors[data.report.status]}"
>
{statusLabels[data.report.status]}
</span>
</div>
<p class="mt-1 text-sm text-muted-foreground">
Submitted by {data.report.name} ({data.report.email})
</p>
</div>
<div class="flex items-center gap-2">
<!-- Status selector -->
<select
value={data.report.status}
onchange={(e) => updateStatus(e.currentTarget.value)}
disabled={statusUpdating}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
<option value="new">New</option>
<option value="in_review">In Review</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<!-- Download ZIP -->
<a
href="/api/reports/{data.report.id}/download"
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Download class="h-4 w-4" />
ZIP
</a>
<!-- Delete -->
<button
onclick={() => (showDeleteDialog = true)}
class="inline-flex items-center gap-1.5 rounded-md border border-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground bg-destructive hover:bg-destructive/80"
>
<Trash2 class="h-4 w-4" />
Delete
</button>
</div>
</div>
<!-- Metadata grid -->
<div class="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<p class="text-xs font-medium text-muted-foreground">Hostname</p>
<p class="mt-1 text-sm">{data.report.hostname || '—'}</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground">OS User</p>
<p class="mt-1 text-sm">{data.report.os_user || '—'}</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground">HWID</p>
<p class="mt-1 font-mono text-xs break-all">{data.report.hwid || '—'}</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground">IP Address</p>
<p class="mt-1 text-sm">{data.report.submitter_ip || '—'}</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground">Created</p>
<p class="mt-1 text-sm">{formatDate(data.report.created_at)}</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground">Updated</p>
<p class="mt-1 text-sm">{formatDate(data.report.updated_at)}</p>
</div>
</div>
</div>
<!-- Description -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="text-sm font-medium text-muted-foreground">Description</h3>
<p class="mt-2 whitespace-pre-wrap text-sm">{data.report.description}</p>
</div>
<!-- System Info -->
{#if data.report.system_info}
<div class="rounded-lg border border-border bg-card">
<button
onclick={() => (showSystemInfo = !showSystemInfo)}
class="flex w-full items-center gap-2 px-6 py-4 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
>
{#if showSystemInfo}
<ChevronDown class="h-4 w-4" />
{:else}
<ChevronRight class="h-4 w-4" />
{/if}
System Information
</button>
{#if showSystemInfo}
<div class="border-t border-border px-6 py-4">
<pre class="overflow-auto rounded-md bg-muted/50 p-4 text-xs">{data.report.system_info}</pre>
</div>
{/if}
</div>
{/if}
<!-- Files -->
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="mb-4 text-sm font-medium text-muted-foreground">
Attached Files ({data.files.length})
</h3>
{#if data.files.length > 0}
<!-- Screenshot previews -->
{@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')}
{#if screenshots.length > 0}
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
{#each screenshots as file}
<div class="overflow-hidden rounded-md border border-border">
<img
src="/api/reports/{data.report.id}/files/{file.id}"
alt={file.filename}
class="w-full"
/>
<div class="px-3 py-2 text-xs text-muted-foreground">{file.filename}</div>
</div>
{/each}
</div>
{/if}
<div class="overflow-hidden rounded-md border border-border">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border bg-muted/50">
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Role</th>
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Filename</th>
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Size</th>
<th class="px-4 py-2 text-right text-xs font-medium text-muted-foreground">Action</th>
</tr>
</thead>
<tbody>
{#each data.files as file}
{@const Icon = roleIcons[file.file_role] || FileText}
<tr class="border-b border-border last:border-0">
<td class="px-4 py-2">
<span class="inline-flex items-center gap-1.5 text-muted-foreground">
<Icon class="h-3.5 w-3.5" />
{roleLabels[file.file_role] || file.file_role}
</span>
</td>
<td class="px-4 py-2 font-mono text-xs">{file.filename}</td>
<td class="px-4 py-2 text-muted-foreground">{formatBytes(file.file_size)}</td>
<td class="px-4 py-2 text-right">
<a
href="/api/reports/{data.report.id}/files/{file.id}"
class="inline-flex items-center gap-1 rounded-md border border-input px-2 py-1 text-xs hover:bg-accent"
>
<Download class="h-3 w-3" />
Download
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-sm text-muted-foreground">No files attached.</p>
{/if}
</div>
</div>
<!-- Delete confirmation dialog -->
{#if showDeleteDialog}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onkeydown={(e) => e.key === 'Escape' && (showDeleteDialog = false)}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="absolute inset-0" onclick={() => (showDeleteDialog = false)}></div>
<div class="relative rounded-lg border border-border bg-card p-6 shadow-xl max-w-md w-full">
<h3 class="text-lg font-semibold">Delete Report</h3>
<p class="mt-2 text-sm text-muted-foreground">
Are you sure you want to delete report #{data.report.id}? This will permanently remove the
report and all attached files. This action cannot be undone.
</p>
<div class="mt-4 flex justify-end gap-2">
<button
onclick={() => (showDeleteDialog = false)}
class="rounded-md border border-input px-4 py-2 text-sm hover:bg-accent"
>
Cancel
</button>
<button
onclick={deleteReport}
disabled={deleting}
class="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/80 disabled:opacity-50"
>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -1,39 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { bugReports } from '$lib/schema';
import { eq } from 'drizzle-orm';
export const PATCH: RequestHandler = async ({ params, request }) => {
const id = Number(params.id);
if (isNaN(id)) throw error(400, 'Invalid report ID');
const body = await request.json();
const { status } = body;
if (!['new', 'in_review', 'resolved', 'closed'].includes(status)) {
throw error(400, 'Invalid status');
}
const [result] = await db
.update(bugReports)
.set({ status })
.where(eq(bugReports.id, id));
if (result.affectedRows === 0) throw error(404, 'Report not found');
return json({ success: true });
};
export const DELETE: RequestHandler = async ({ params }) => {
const id = Number(params.id);
if (isNaN(id)) throw error(400, 'Invalid report ID');
const [result] = await db
.delete(bugReports)
.where(eq(bugReports.id, id));
if (result.affectedRows === 0) throw error(404, 'Report not found');
return json({ success: true });
};

View File

@@ -1,12 +0,0 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;

View File

@@ -1,14 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View File

@@ -1,7 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});

View File

@@ -1,54 +0,0 @@
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
api:
build: .
ports:
- "${PORT:-3000}:3000"
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}
depends_on:
mysql:
condition: service_healthy
dashboard:
build: ./dashboard
ports:
- "${DASHBOARD_PORT:-3001}:3000"
environment:
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: ${MYSQL_USER:-emly}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
depends_on:
mysql:
condition: service_healthy
volumes:
mysql_data:

View File

@@ -1,16 +0,0 @@
[Unit]
Description=EMLy Bug Report Server (Docker Compose)
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/emly-server
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
Restart=on-failure
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target

View File

@@ -1,17 +0,0 @@
{
"name": "emly-bugreport-server",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"elysia": "^1.2.0",
"mysql2": "^3.11.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.0.0"
}
}

View File

@@ -1,24 +0,0 @@
export const config = {
mysql: {
host: process.env.MYSQL_HOST || "localhost",
port: parseInt(process.env.MYSQL_PORT || "3306"),
user: process.env.MYSQL_USER || "emly",
password: process.env.MYSQL_PASSWORD || "",
database: process.env.MYSQL_DATABASE || "emly_bugreports",
},
apiKey: process.env.API_KEY || "",
adminKey: process.env.ADMIN_KEY || "",
port: parseInt(process.env.PORT || "3000"),
rateLimit: {
max: parseInt(process.env.RATE_LIMIT_MAX || "5"),
windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"),
},
} as const;
// Validate required config on startup
export function validateConfig(): void {
if (!config.apiKey) throw new Error("API_KEY is required");
if (!config.adminKey) throw new Error("ADMIN_KEY is required");
if (!config.mysql.password)
throw new Error("MYSQL_PASSWORD is required");
}

View File

@@ -1,28 +0,0 @@
import mysql from "mysql2/promise";
import { config } from "../config";
let pool: mysql.Pool | null = null;
export function getPool(): mysql.Pool {
if (!pool) {
pool = mysql.createPool({
host: config.mysql.host,
port: config.mysql.port,
user: config.mysql.user,
password: config.mysql.password,
database: config.mysql.database,
waitForConnections: true,
connectionLimit: 10,
maxIdle: 5,
idleTimeout: 60000,
});
}
return pool;
}
export async function closePool(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -1,37 +0,0 @@
import { readFileSync } from "fs";
import { join } from "path";
import { getPool } from "./connection";
export async function runMigrations(): Promise<void> {
const pool = getPool();
const schemaPath = join(import.meta.dir, "schema.sql");
const schema = readFileSync(schemaPath, "utf-8");
// Split on semicolons, filter empty statements
const statements = schema
.split(";")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const statement of statements) {
await pool.execute(statement);
}
// Additive migrations for existing databases
const alterMigrations = [
`ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER hwid`,
`ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname`,
`ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname)`,
`ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user)`,
];
for (const migration of alterMigrations) {
try {
await pool.execute(migration);
} catch {
// Column/index already exists — safe to ignore
}
}
console.log("Database migrations completed");
}

View File

@@ -1,38 +0,0 @@
CREATE TABLE IF NOT EXISTS `bug_reports` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`hwid` VARCHAR(255) NOT NULL DEFAULT '',
`hostname` VARCHAR(255) NOT NULL DEFAULT '',
`os_user` VARCHAR(255) NOT NULL DEFAULT '',
`submitter_ip` VARCHAR(45) NOT NULL DEFAULT '',
`system_info` JSON NULL,
`status` ENUM('new', 'in_review', 'resolved', 'closed') NOT NULL DEFAULT 'new',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_status` (`status`),
INDEX `idx_hwid` (`hwid`),
INDEX `idx_hostname` (`hostname`),
INDEX `idx_os_user` (`os_user`),
INDEX `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `bug_report_files` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`report_id` INT UNSIGNED NOT NULL,
`file_role` ENUM('screenshot', 'mail_file', 'localstorage', 'config', 'system_info') NOT NULL,
`filename` VARCHAR(255) NOT NULL,
`mime_type` VARCHAR(127) NOT NULL DEFAULT 'application/octet-stream',
`file_size` INT UNSIGNED NOT NULL DEFAULT 0,
`data` LONGBLOB NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `fk_report` FOREIGN KEY (`report_id`) REFERENCES `bug_reports`(`id`) ON DELETE CASCADE,
INDEX `idx_report_id` (`report_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `rate_limit_hwid` (
`hwid` VARCHAR(255) PRIMARY KEY,
`window_start` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`count` INT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,43 +0,0 @@
import { Elysia } from "elysia";
import { config, validateConfig } from "./config";
import { runMigrations } from "./db/migrate";
import { closePool } from "./db/connection";
import { bugReportRoutes } from "./routes/bugReports";
import { adminRoutes } from "./routes/admin";
// Validate environment
validateConfig();
// Run database migrations
await runMigrations();
const app = new Elysia()
.onError(({ error, set }) => {
console.error("Unhandled error:", error);
set.status = 500;
return { success: false, message: "Internal server error" };
})
.get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() }))
.use(bugReportRoutes)
.use(adminRoutes)
.listen({
port: config.port,
maxBody: 50 * 1024 * 1024, // 50MB
});
console.log(
`EMLy Bug Report API running on http://localhost:${app.server?.port}`
);
// Graceful shutdown
process.on("SIGINT", async () => {
console.log("Shutting down...");
await closePool();
process.exit(0);
});
process.on("SIGTERM", async () => {
console.log("Shutting down...");
await closePool();
process.exit(0);
});

View File

@@ -1,24 +0,0 @@
import { Elysia } from "elysia";
import { config } from "../config";
export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
{ as: "scoped" },
({ headers, error }) => {
const key = headers["x-api-key"];
if (!key || key !== config.apiKey) {
return error(401, { success: false, message: "Invalid or missing API key" });
}
return {};
}
);
export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive(
{ as: "scoped" },
({ headers, error }) => {
const key = headers["x-admin-key"];
if (!key || key !== config.adminKey) {
return error(401, { success: false, message: "Invalid or missing admin key" });
}
return {};
}
);

View File

@@ -1,70 +0,0 @@
import { Elysia } from "elysia";
import { getPool } from "../db/connection";
import { config } from "../config";
const excludedHwids = new Set<string>([
// Add HWIDs here for development testing
"95e025d1-7567-462e-9354-ac88b965cd22",
]);
export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive(
{ as: "scoped" },
// @ts-ignore
async ({ body, error }) => {
const hwid = (body as { hwid?: string })?.hwid;
if (!hwid || excludedHwids.has(hwid)) {
// No HWID provided or excluded, skip rate limiting
return {};
}
const pool = getPool();
const windowMs = config.rateLimit.windowHours * 60 * 60 * 1000;
const now = new Date();
// Get current rate limit entry
const [rows] = await pool.execute(
"SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?",
[hwid]
);
const entries = rows as { window_start: Date; count: number }[];
if (entries.length === 0) {
// First request from this HWID
await pool.execute(
"INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)",
[hwid, now]
);
return {};
}
const entry = entries[0];
const windowStart = new Date(entry.window_start);
const elapsed = now.getTime() - windowStart.getTime();
if (elapsed > windowMs) {
// Window expired, reset
await pool.execute(
"UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?",
[now, hwid]
);
return {};
}
if (entry.count >= config.rateLimit.max) {
const retryAfterMs = windowMs - elapsed;
const retryAfterMin = Math.ceil(retryAfterMs / 60000);
return error(429, {
success: false,
message: `Rate limit exceeded. Try again in ${retryAfterMin} minutes.`,
});
}
// Increment count
await pool.execute(
"UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?",
[hwid]
);
return {};
}
);

View File

@@ -1,104 +0,0 @@
import { Elysia, t } from "elysia";
import { adminKeyGuard } from "../middleware/auth";
import {
listBugReports,
getBugReport,
getFile,
deleteBugReport,
updateBugReportStatus,
} from "../services/bugReportService";
import type { BugReportStatus } from "../types";
export const adminRoutes = new Elysia({ prefix: "/api/admin" })
.use(adminKeyGuard)
.get(
"/bug-reports",
async ({ query }) => {
const page = parseInt(query.page || "1");
const pageSize = Math.min(parseInt(query.pageSize || "20"), 100);
const status = query.status as BugReportStatus | undefined;
return await listBugReports({ page, pageSize, status });
},
{
query: t.Object({
page: t.Optional(t.String()),
pageSize: t.Optional(t.String()),
status: t.Optional(
t.Union([
t.Literal("new"),
t.Literal("in_review"),
t.Literal("resolved"),
t.Literal("closed"),
])
),
}),
detail: { summary: "List bug reports (paginated)" },
}
)
.get(
"/bug-reports/:id",
async ({ params, error }) => {
const result = await getBugReport(parseInt(params.id));
if (!result) return error(404, { success: false, message: "Report not found" });
return result;
},
{
params: t.Object({ id: t.String() }),
detail: { summary: "Get bug report with file metadata" },
}
)
.patch(
"/bug-reports/:id/status",
async ({ params, body, error }) => {
const updated = await updateBugReportStatus(
parseInt(params.id),
body.status
);
if (!updated)
return error(404, { success: false, message: "Report not found" });
return { success: true, message: "Status updated" };
},
{
params: t.Object({ id: t.String() }),
body: t.Object({
status: t.Union([
t.Literal("new"),
t.Literal("in_review"),
t.Literal("resolved"),
t.Literal("closed"),
]),
}),
detail: { summary: "Update bug report status" },
}
)
.get(
"/bug-reports/:id/files/:fileId",
async ({ params, error, set }) => {
const file = await getFile(parseInt(params.id), parseInt(params.fileId));
if (!file)
return error(404, { success: false, message: "File not found" });
set.headers["content-type"] = file.mime_type;
set.headers["content-disposition"] =
`attachment; filename="${file.filename}"`;
return new Response(file.data);
},
{
params: t.Object({ id: t.String(), fileId: t.String() }),
detail: { summary: "Download a bug report file" },
}
)
.delete(
"/bug-reports/:id",
async ({ params, error }) => {
const deleted = await deleteBugReport(parseInt(params.id));
if (!deleted)
return error(404, { success: false, message: "Report not found" });
return { success: true, message: "Report deleted" };
},
{
params: t.Object({ id: t.String() }),
detail: { summary: "Delete a bug report and its files" },
}
);

View File

@@ -1,101 +0,0 @@
import { Elysia, t } from "elysia";
import { apiKeyGuard } from "../middleware/auth";
import { hwidRateLimit } from "../middleware/rateLimit";
import { createBugReport, addFile } from "../services/bugReportService";
import type { FileRole } from "../types";
const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [
{ field: "screenshot", role: "screenshot", mime: "image/png" },
{ field: "mail_file", role: "mail_file", mime: "application/octet-stream" },
{ field: "localstorage", role: "localstorage", mime: "application/json" },
{ field: "config", role: "config", mime: "application/json" },
];
export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
.use(apiKeyGuard)
.use(hwidRateLimit)
.post(
"/",
async ({ body, request, set }) => {
const { name, email, description, hwid, hostname, os_user, system_info } = body;
// Parse system_info — may arrive as a JSON string or already-parsed object
let systemInfo: Record<string, unknown> | null = null;
if (system_info) {
if (typeof system_info === "string") {
try {
systemInfo = JSON.parse(system_info);
} catch {
systemInfo = null;
}
} else if (typeof system_info === "object") {
systemInfo = system_info as Record<string, unknown>;
}
}
// Get submitter IP from headers or connection
const submitterIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"unknown";
// Create the bug report
const reportId = await createBugReport({
name,
email,
description,
hwid: hwid || "",
hostname: hostname || "",
os_user: os_user || "",
submitter_ip: submitterIp,
system_info: systemInfo,
});
// Process file uploads
for (const { field, role, mime } of FILE_ROLES) {
const file = body[field as keyof typeof body];
if (file && file instanceof File) {
const buffer = Buffer.from(await file.arrayBuffer());
await addFile({
report_id: reportId,
file_role: role,
filename: file.name || `${field}.bin`,
mime_type: file.type || mime,
file_size: buffer.length,
data: buffer,
});
}
}
set.status = 201;
return {
success: true,
report_id: reportId,
message: "Bug report submitted successfully",
};
},
{
type: "multipart/form-data",
body: t.Object({
name: t.String(),
email: t.String(),
description: t.String(),
hwid: t.Optional(t.String()),
hostname: t.Optional(t.String()),
os_user: t.Optional(t.String()),
system_info: t.Optional(t.Any()),
screenshot: t.Optional(t.File()),
mail_file: t.Optional(t.File()),
localstorage: t.Optional(t.File()),
config: t.Optional(t.File()),
}),
response: {
201: t.Object({
success: t.Boolean(),
report_id: t.Number(),
message: t.String(),
}),
},
detail: { summary: "Submit a bug report" },
}
);

View File

@@ -1,163 +0,0 @@
import type { ResultSetHeader, RowDataPacket } from "mysql2";
import { getPool } from "../db/connection";
import type {
BugReport,
BugReportFile,
BugReportListItem,
BugReportStatus,
FileRole,
PaginatedResponse,
} from "../types";
export async function createBugReport(data: {
name: string;
email: string;
description: string;
hwid: string;
hostname: string;
os_user: string;
submitter_ip: string;
system_info: Record<string, unknown> | null;
}): Promise<number> {
const pool = getPool();
const [result] = await pool.execute<ResultSetHeader>(
`INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.name,
data.email,
data.description,
data.hwid,
data.hostname,
data.os_user,
data.submitter_ip,
data.system_info ? JSON.stringify(data.system_info) : null,
]
);
return result.insertId;
}
export async function addFile(data: {
report_id: number;
file_role: FileRole;
filename: string;
mime_type: string;
file_size: number;
data: Buffer;
}): Promise<number> {
const pool = getPool();
const [result] = await pool.execute<ResultSetHeader>(
`INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data)
VALUES (?, ?, ?, ?, ?, ?)`,
[
data.report_id,
data.file_role,
data.filename,
data.mime_type,
data.file_size,
data.data,
]
);
return result.insertId;
}
export async function listBugReports(opts: {
page: number;
pageSize: number;
status?: BugReportStatus;
}): Promise<PaginatedResponse<BugReportListItem>> {
const pool = getPool();
const { page, pageSize, status } = opts;
const offset = (page - 1) * pageSize;
let whereClause = "";
const params: unknown[] = [];
if (status) {
whereClause = "WHERE br.status = ?";
params.push(status);
}
const [countRows] = await pool.execute<RowDataPacket[]>(
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
params
);
const total = (countRows[0] as { total: number }).total;
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT br.*, COUNT(bf.id) as file_count
FROM bug_reports br
LEFT JOIN bug_report_files bf ON bf.report_id = br.id
${whereClause}
GROUP BY br.id
ORDER BY br.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
);
return {
data: rows as BugReportListItem[],
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
export async function getBugReport(
id: number
): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> {
const pool = getPool();
const [reportRows] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM bug_reports WHERE id = ?",
[id]
);
if ((reportRows as unknown[]).length === 0) return null;
const [fileRows] = await pool.execute<RowDataPacket[]>(
"SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?",
[id]
);
return {
report: reportRows[0] as BugReport,
files: fileRows as Omit<BugReportFile, "data">[],
};
}
export async function getFile(
reportId: number,
fileId: number
): Promise<BugReportFile | null> {
const pool = getPool();
const [rows] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?",
[fileId, reportId]
);
if ((rows as unknown[]).length === 0) return null;
return rows[0] as BugReportFile;
}
export async function deleteBugReport(id: number): Promise<boolean> {
const pool = getPool();
const [result] = await pool.execute<ResultSetHeader>(
"DELETE FROM bug_reports WHERE id = ?",
[id]
);
return result.affectedRows > 0;
}
export async function updateBugReportStatus(
id: number,
status: BugReportStatus
): Promise<boolean> {
const pool = getPool();
const [result] = await pool.execute<ResultSetHeader>(
"UPDATE bug_reports SET status = ? WHERE id = ?",
[status, id]
);
return result.affectedRows > 0;
}

View File

@@ -1,57 +0,0 @@
export type BugReportStatus = "new" | "in_review" | "resolved" | "closed";
export type FileRole =
| "screenshot"
| "mail_file"
| "localstorage"
| "config"
| "system_info";
export interface BugReport {
id: number;
name: string;
email: string;
description: string;
hwid: string;
hostname: string;
os_user: string;
submitter_ip: string;
system_info: Record<string, unknown> | null;
status: BugReportStatus;
created_at: Date;
updated_at: Date;
}
export interface BugReportFile {
id: number;
report_id: number;
file_role: FileRole;
filename: string;
mime_type: string;
file_size: number;
data?: Buffer;
created_at: Date;
}
export interface BugReportListItem {
id: number;
name: string;
email: string;
description: string;
hwid: string;
hostname: string;
os_user: string;
submitter_ip: string;
status: BugReportStatus;
created_at: Date;
updated_at: Date;
file_count: number;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"declaration": true,
"types": ["bun"]
},
"include": ["src/**/*.ts"]
}