From 0cda0a26fc48ca1dbf8772df42d9106085ae1930 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Wed, 4 Feb 2026 19:57:31 +0100 Subject: [PATCH] v1.2.2 --- .gitignore | 6 +- app.go | 93 +++- backend/utils/mail/eml_pec_reader.go | 121 +++++ backend/utils/mail/eml_reader.go | 57 +- backend/utils/mail/file_dialog.go | 12 +- backend/utils/mail/msg_parser.go | 261 +++++++++ backend/utils/mail/msg_reader.go | 148 +++++ backend/utils/mailparser.go | 512 ++++++++++++++++++ config.ini | 6 +- frontend/messages/en.json | 18 +- frontend/messages/it.json | 18 +- frontend/src/app.html | 2 +- .../components/dashboard/MailViewer.svelte | 193 ++++++- frontend/src/lib/types.d.ts | 1 + frontend/src/lib/wailsjs/go/main/App.d.ts | 8 + frontend/src/lib/wailsjs/go/main/App.js | 16 + frontend/src/lib/wailsjs/go/models.ts | 4 + frontend/src/routes/(app)/+layout.svelte | 57 +- frontend/src/routes/(app)/+layout.ts | 12 + frontend/src/routes/(app)/+page.ts | 14 +- .../src/routes/(app)/settings/+page.svelte | 32 ++ go.mod | 3 +- go.sum | 6 +- installer/installer.iss | 11 +- main.go | 4 + 25 files changed, 1549 insertions(+), 66 deletions(-) create mode 100644 backend/utils/mail/eml_pec_reader.go create mode 100644 backend/utils/mail/msg_parser.go create mode 100644 backend/utils/mail/msg_reader.go create mode 100644 backend/utils/mailparser.go create mode 100644 frontend/src/routes/(app)/+layout.ts diff --git a/.gitignore b/.gitignore index 260d12d..c3dc776 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,8 @@ logs/ # SCS DDLs extra/ -extra/*.dll \ No newline at end of file +extra/*.dll + + +*.eml +*.msg \ No newline at end of file diff --git a/app.go b/app.go index bdb75c7..c525781 100644 --- a/app.go +++ b/app.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "sync" + "time" "golang.org/x/sys/windows/registry" @@ -26,6 +27,8 @@ type App struct { openImages map[string]bool openPDFsMux sync.Mutex openPDFs map[string]bool + openEMLsMux sync.Mutex + openEMLs map[string]bool } // NewApp creates a new App application struct @@ -33,6 +36,7 @@ func NewApp() *App { return &App{ openImages: make(map[string]bool), openPDFs: make(map[string]bool), + openEMLs: make(map[string]bool), } } @@ -87,11 +91,89 @@ func (a *App) ReadEML(filePath string) (*internal.EmailData, error) { return internal.ReadEmlFile(filePath) } +// ReadPEC reads a PEC .eml file and returns the inner email data +func (a *App) ReadPEC(filePath string) (*internal.EmailData, error) { + return internal.ReadPecInnerEml(filePath) +} + +// ReadMSG reads a .msg file and returns the email data +func (a *App) ReadMSG(filePath string, useExternalConverter bool) (*internal.EmailData, error) { + if useExternalConverter { + return internal.ReadMsgFile(filePath) + } + return internal.OSSReadMsgFile(filePath) +} + +// ReadMSGOSS reads a .msg file and returns the email data +func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) { + return internal.OSSReadMsgFile(filePath) +} + // ShowOpenFileDialog shows the file open dialog for EML files func (a *App) ShowOpenFileDialog() (string, error) { return internal.ShowFileDialog(a.ctx) } +// OpenEMLWindow saves EML to temp and opens a new instance of the app +func (a *App) OpenEMLWindow(base64Data string, filename string) error { + a.openEMLsMux.Lock() + if a.openEMLs[filename] { + a.openEMLsMux.Unlock() + return fmt.Errorf("eml '%s' is already open", filename) + } + a.openEMLs[filename] = true + a.openEMLsMux.Unlock() + + // 1. Decode base64 + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + return fmt.Errorf("failed to decode base64: %w", err) + } + + // 2. Save to temp file + tempDir := os.TempDir() + // Use timestamp or unique ID to avoid conflicts if multiple files have same name + timestamp := time.Now().Format("20060102_150405") + tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s_%s", "emly_attachment", timestamp, filename)) + if err := os.WriteFile(tempFile, data, 0644); err != nil { + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + return fmt.Errorf("failed to write temp file: %w", err) + } + + // 3. Launch new instance + exe, err := os.Executable() + if err != nil { + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + return fmt.Errorf("failed to get executable path: %w", err) + } + + // Run EMLy with the file path as argument + cmd := exec.Command(exe, tempFile) + if err := cmd.Start(); err != nil { + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + return fmt.Errorf("failed to start viewer: %w", err) + } + + // Monitor process in background to release lock when closed + go func() { + cmd.Wait() + a.openEMLsMux.Lock() + delete(a.openEMLs, filename) + a.openEMLsMux.Unlock() + }() + + return nil +} + // OpenImageWindow opens a new window instance to display the image func (a *App) OpenImageWindow(base64Data string, filename string) error { a.openImagesMux.Lock() @@ -114,7 +196,8 @@ func (a *App) OpenImageWindow(base64Data string, filename string) error { // 2. Save to temp file tempDir := os.TempDir() // Use timestamp to make unique - tempFile := filepath.Join(tempDir, fmt.Sprintf("%s", filename)) + timestamp := time.Now().Format("20060102_150405") + tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename)) if err := os.WriteFile(tempFile, data, 0644); err != nil { a.openImagesMux.Lock() delete(a.openImages, filename) @@ -221,7 +304,9 @@ func (a *App) OpenPDF(base64Data string, filename string) error { // 2. Save to temp file tempDir := os.TempDir() - tempFile := filepath.Join(tempDir, fmt.Sprintf("%s", filename)) + // Use timestamp to make unique + timestamp := time.Now().Format("20060102_150405") + tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename)) if err := os.WriteFile(tempFile, data, 0644); err != nil { return fmt.Errorf("failed to write temp file: %w", err) } @@ -247,7 +332,9 @@ func (a *App) OpenImage(base64Data string, filename string) error { // 2. Save to temp file tempDir := os.TempDir() - tempFile := filepath.Join(tempDir, fmt.Sprintf("%s", filename)) + // Use timestamp to make unique + timestamp := time.Now().Format("20060102_150405") + tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", timestamp, filename)) if err := os.WriteFile(tempFile, data, 0644); err != nil { return fmt.Errorf("failed to write temp file: %w", err) } diff --git a/backend/utils/mail/eml_pec_reader.go b/backend/utils/mail/eml_pec_reader.go new file mode 100644 index 0000000..bcf15a7 --- /dev/null +++ b/backend/utils/mail/eml_pec_reader.go @@ -0,0 +1,121 @@ +package internal + +import ( + "bytes" + "fmt" + "io" + "net/mail" + "os" + "strings" + + "emly/backend/utils" +) + +// ReadPecInnerEml reads the inner email (postacert.eml) from a PEC EML file. +// It opens the outer file, looks for the specific attachment, and parses it. +func ReadPecInnerEml(filePath string) (*EmailData, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // 1. Parse outer "Envelope" + outerEmail, err := utils.Parse(file) + if err != nil { + return nil, fmt.Errorf("failed to parse outer email: %w", err) + } + + // 2. Look for the real content inside postacert.eml + var innerEmailData []byte + foundPec := false + + for _, att := range outerEmail.Attachments { + // Standard PEC puts the real message in postacert.eml + // Using case-insensitive check and substring as per example + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + data, err := io.ReadAll(att.Data) + if err != nil { + return nil, fmt.Errorf("failed to read inner email content: %w", err) + } + innerEmailData = data + foundPec = true + break + } + } + + if !foundPec { + return nil, fmt.Errorf("not a signed PEC or 'postacert.eml' attachment is missing") + } + + // 3. Parse the inner EML content + innerEmail, err := utils.Parse(bytes.NewReader(innerEmailData)) + if err != nil { + return nil, fmt.Errorf("failed to parse inner email structure: %w", err) + } + + // Helper to format addresses (reused logic pattern from eml_reader.go) + formatAddress := func(addr []*mail.Address) []string { + var result []string + for _, a := range addr { + // convertToUTF8 is defined in eml_reader.go (same package) + result = append(result, convertToUTF8(a.String())) + } + return result + } + + // Determine body (prefer HTML) + body := innerEmail.HTMLBody + if body == "" { + body = innerEmail.TextBody + } + + // Process attachments of the inner email + var attachments []EmailAttachment + var hasDatiCert, hasSmime, hasInnerPecEmail bool + + for _, att := range innerEmail.Attachments { + data, err := io.ReadAll(att.Data) + if err != nil { + continue + } + + // Check internal flags for the inner email (recursive PEC check?) + filenameLower := strings.ToLower(att.Filename) + if filenameLower == "daticert.xml" { + hasDatiCert = true + } + if filenameLower == "smime.p7s" { + hasSmime = true + } + if strings.HasSuffix(filenameLower, ".eml") { + hasInnerPecEmail = true + } + + attachments = append(attachments, EmailAttachment{ + Filename: att.Filename, + ContentType: att.ContentType, + Data: data, + }) + } + + isPec := hasDatiCert && hasSmime + + // Format From + var from string + if len(innerEmail.From) > 0 { + from = innerEmail.From[0].String() + } + + return &EmailData{ + From: convertToUTF8(from), + To: formatAddress(innerEmail.To), + Cc: formatAddress(innerEmail.Cc), + Bcc: formatAddress(innerEmail.Bcc), + Subject: convertToUTF8(innerEmail.Subject), + Body: convertToUTF8(body), + Attachments: attachments, + IsPec: isPec, + HasInnerEmail: hasInnerPecEmail, + }, nil +} diff --git a/backend/utils/mail/eml_reader.go b/backend/utils/mail/eml_reader.go index 7804db4..009176b 100644 --- a/backend/utils/mail/eml_reader.go +++ b/backend/utils/mail/eml_reader.go @@ -5,9 +5,11 @@ import ( "io" "net/mail" "os" + "strings" "unicode/utf8" - "github.com/DusanKasan/parsemail" + "emly/backend/utils" + "golang.org/x/text/encoding/charmap" "golang.org/x/text/transform" ) @@ -19,13 +21,15 @@ type EmailAttachment struct { } type EmailData struct { - From string `json:"from"` - To []string `json:"to"` - Cc []string `json:"cc"` - Bcc []string `json:"bcc"` - Subject string `json:"subject"` - Body string `json:"body"` - Attachments []EmailAttachment `json:"attachments"` + From string `json:"from"` + To []string `json:"to"` + Cc []string `json:"cc"` + Bcc []string `json:"bcc"` + Subject string `json:"subject"` + Body string `json:"body"` + Attachments []EmailAttachment `json:"attachments"` + IsPec bool `json:"isPec"` + HasInnerEmail bool `json:"hasInnerEmail"` } func ReadEmlFile(filePath string) (*EmailData, error) { @@ -35,7 +39,7 @@ func ReadEmlFile(filePath string) (*EmailData, error) { } defer file.Close() - email, err := parsemail.Parse(file) + email, err := utils.Parse(file) if err != nil { return nil, fmt.Errorf("failed to parse email: %w", err) } @@ -55,13 +59,28 @@ func ReadEmlFile(filePath string) (*EmailData, error) { body = email.TextBody } - // Process attachments + // Process attachments and detect PEC var attachments []EmailAttachment + var hasDatiCert, hasSmime, hasInnerEmail bool + for _, att := range email.Attachments { data, err := io.ReadAll(att.Data) if err != nil { continue // Handle error or skip? Skipping for now. } + + // PEC Detection Logic + filenameLower := strings.ToLower(att.Filename) + if filenameLower == "daticert.xml" { + hasDatiCert = true + } + if filenameLower == "smime.p7s" { + hasSmime = true + } + if strings.HasSuffix(filenameLower, ".eml") { + hasInnerEmail = true + } + attachments = append(attachments, EmailAttachment{ Filename: att.Filename, ContentType: att.ContentType, @@ -69,6 +88,8 @@ func ReadEmlFile(filePath string) (*EmailData, error) { }) } + isPec := hasDatiCert && hasSmime + // Format From var from string if len(email.From) > 0 { @@ -76,13 +97,15 @@ func ReadEmlFile(filePath string) (*EmailData, error) { } return &EmailData{ - From: convertToUTF8(from), - To: formatAddress(email.To), - Cc: formatAddress(email.Cc), - Bcc: formatAddress(email.Bcc), - Subject: convertToUTF8(email.Subject), - Body: convertToUTF8(body), - Attachments: attachments, + From: convertToUTF8(from), + To: formatAddress(email.To), + Cc: formatAddress(email.Cc), + Bcc: formatAddress(email.Bcc), + Subject: convertToUTF8(email.Subject), + Body: convertToUTF8(body), + Attachments: attachments, + IsPec: isPec, + HasInnerEmail: hasInnerEmail, }, nil } diff --git a/backend/utils/mail/file_dialog.go b/backend/utils/mail/file_dialog.go index 7b6c0ca..12f1271 100644 --- a/backend/utils/mail/file_dialog.go +++ b/backend/utils/mail/file_dialog.go @@ -6,14 +6,18 @@ import ( "github.com/wailsapp/wails/v2/pkg/runtime" ) -var EMLDialogOptions = runtime.OpenDialogOptions{ - Title: "Select EML file", - Filters: []runtime.FileFilter{{DisplayName: "EML Files (*.eml)", Pattern: "*.eml"}}, +var EmailDialogOptions = runtime.OpenDialogOptions{ + Title: "Select Email file", + Filters: []runtime.FileFilter{ + {DisplayName: "Email Files (*.eml;*.msg)", Pattern: "*.eml;*.msg"}, + {DisplayName: "EML Files (*.eml)", Pattern: "*.eml"}, + {DisplayName: "MSG Files (*.msg)", Pattern: "*.msg"}, + }, ShowHiddenFiles: false, } func ShowFileDialog(ctx context.Context) (string, error) { - filePath, err := runtime.OpenFileDialog(ctx, EMLDialogOptions) + filePath, err := runtime.OpenFileDialog(ctx, EmailDialogOptions) if err != nil { return "", err } diff --git a/backend/utils/mail/msg_parser.go b/backend/utils/mail/msg_parser.go new file mode 100644 index 0000000..559f4fc --- /dev/null +++ b/backend/utils/mail/msg_parser.go @@ -0,0 +1,261 @@ +package internal + +import ( + "encoding/base64" + "fmt" + "io" + "os" + "strings" + + "github.com/richardlehane/mscfb" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +// MAPI Property Tags +const ( + prSubject = "0037" + prBody = "1000" + prBodyHTML = "1013" + prSenderName = "0C1A" + prSenderEmail = "0C1F" + prDisplayTo = "0E04" // Display list of To recipients + prDisplayCc = "0E03" + prDisplayBcc = "0E02" + prMessageHeaders = "007D" + prClientSubmitTime = "0039" // Date + prAttachLongFilename = "3707" + prAttachFilename = "3704" + prAttachData = "3701" + prAttachMimeTag = "370E" +) + +// MAPI Property Types +const ( + ptUnicode = "001F" + ptString8 = "001E" + ptBinary = "0102" +) + +type msgParser struct { + reader *mscfb.Reader + props map[string][]byte +} + +func parseMsgFile(filePath string) (*EmailData, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + doc, err := mscfb.New(f) + if err != nil { + return nil, err + } + + email := &EmailData{ + To: []string{}, + Cc: []string{}, + Bcc: []string{}, + } + + // We need to iterate through the entries to find properties and attachments + // Since mscfb is a sequential reader, we might need to be careful. + // However, usually properties are in streams. + + // Strategy: + // 1. Read all streams into a map keyed by their path/name for easier access? + // MSG files can be large (attachments), so maybe not all. + // 2. Identify properties from their stream names directly. + + // Simplified approach: scan for stream names matching our patterns. + + // Better approach: + // The Root Entry has "properties". + // We need to detect if we are in an attachment storage. + + // Since mscfb iterates flat (Post-Order?), we can track context? + // mscfb File struct provides Name and path. + + attachmentsMap := make(map[string]*EmailAttachment) + + for entry, err := doc.Next(); err == nil; entry, err = doc.Next() { + name := entry.Name + + // Check if it's a property stream + if strings.HasPrefix(name, "__substg1.0_") { + path := entry.Path // Path is array of directory names + + // Root properties + if len(path) == 0 { // In root + val, err := io.ReadAll(doc) + if err != nil { + continue + } + processRootProperty(name, val, email) + } else if strings.HasPrefix(path[len(path)-1], "__attach_version1.0_") { + // Attachment property + attachStorageName := path[len(path)-1] + if _, exists := attachmentsMap[attachStorageName]; !exists { + attachmentsMap[attachStorageName] = &EmailAttachment{} + } + + val, err := io.ReadAll(doc) + if err != nil { + continue + } + processAttachProperty(name, val, attachmentsMap[attachStorageName]) + } + } + } + + // Finalize attachments + for _, att := range attachmentsMap { + if strings.Contains(strings.ToLower(att.ContentType), "multipart/signed") { + dataStr := string(att.Data) + // Check if it already looks like a plain text EML (contains typical headers) + if strings.Contains(dataStr, "Content-Type:") || strings.Contains(dataStr, "MIME-Version:") || strings.Contains(dataStr, "From:") { + if !strings.HasSuffix(strings.ToLower(att.Filename), ".eml") { + att.Filename += ".eml" + } + } else { + // Try to decode as Base64 + // Clean up the base64 string: remove newlines and spaces + base64Str := strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' || r == ' ' || r == '\t' { + return -1 + } + return r + }, dataStr) + + // Try standard decoding + decoded, err := base64.StdEncoding.DecodeString(base64Str) + if err != nil { + // Try raw decoding (no padding) + decoded, err = base64.RawStdEncoding.DecodeString(base64Str) + } + + if err == nil { + att.Data = decoded + if !strings.HasSuffix(strings.ToLower(att.Filename), ".eml") { + att.Filename += ".eml" + } + } else { + fmt.Println("Failed to decode multipart/signed attachment:", err) + } + } + } + + if att.Filename == "" { + att.Filename = "attachment" + } + // Only add if we have data + if len(att.Data) > 0 { + email.Attachments = append(email.Attachments, *att) + } + } + + return email, nil +} + +func processRootProperty(name string, data []byte, email *EmailData) { + tag := name[12:16] + typ := name[16:20] + + strVal := "" + if typ == ptUnicode { + strVal = decodeUTF16(data) + } else if typ == ptString8 { + strVal = string(data) + } + + switch tag { + case prSubject: + email.Subject = strVal + case prBody: + if email.Body == "" { // Prefer body if not set + email.Body = strVal + } + case prBodyHTML: + email.Body = strVal // Prefer HTML + case prSenderName: + if email.From == "" { + email.From = strVal + } else { + email.From = fmt.Sprintf("%s <%s>", strVal, email.From) + } + case prSenderEmail: + if email.From == "" { + email.From = strVal + } else if !strings.Contains(email.From, "<") { + email.From = fmt.Sprintf("%s <%s>", email.From, strVal) + } + case prDisplayTo: + // Split by ; or similar if needed, but display string is usually one line + email.To = splitAndTrim(strVal) + case prDisplayCc: + email.Cc = splitAndTrim(strVal) + case prDisplayBcc: + email.Bcc = splitAndTrim(strVal) + case prClientSubmitTime: + // Date logic to be added if struct supports it + } + + /* + if tag == prClientSubmitTime && typ == "0040" { + if len(data) >= 8 { + ft := binary.LittleEndian.Uint64(data) + t := time.Date(1601, 1, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(ft) * 100 * time.Nanosecond) + email.Date = t.Format(time.RFC1123Z) + } + } + */ +} + +func processAttachProperty(name string, data []byte, att *EmailAttachment) { + tag := name[12:16] + typ := name[16:20] + + strVal := "" + if typ == ptUnicode { + strVal = decodeUTF16(data) + } else if typ == ptString8 { + strVal = string(data) + } + + switch tag { + case prAttachLongFilename: + att.Filename = strVal + case prAttachFilename: + if att.Filename == "" { + att.Filename = strVal + } + case prAttachMimeTag: + att.ContentType = strVal + case prAttachData: + att.Data = data + } +} + +func decodeUTF16(b []byte) string { + decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder() + decoded, _, _ := transform.Bytes(decoder, b) + // Remove null terminators if present + return strings.TrimRight(string(decoded), "\x00") +} + +func splitAndTrim(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ";") + var res []string + for _, p := range parts { + t := strings.TrimSpace(p) + if t != "" { + res = append(res, t) + } + } + return res +} diff --git a/backend/utils/mail/msg_reader.go b/backend/utils/mail/msg_reader.go new file mode 100644 index 0000000..76037c9 --- /dev/null +++ b/backend/utils/mail/msg_reader.go @@ -0,0 +1,148 @@ +package internal + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// ReadMsgFile reads a .msg file using the native Go parser. +func ReadMsgFile(filePath string) (*EmailData, error) { + return ReadMsgPecFile(filePath) +} + +func OSSReadMsgFile(filePath string) (*EmailData, error) { + return parseMsgFile(filePath) +} + +// parseSignedMsgExec executes 'signed_msg.exe' via cmd to convert a MSG to JSON, +// then processes the output to reconstruct the PEC email data. +func ReadMsgPecFile(filePath string) (*EmailData, error) { + fmt.Println("Called!") + // 1. Locate signed_msg.exe + exePath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("failed to get executable path: %w", err) + } + baseDir := filepath.Dir(exePath) + helperExe := filepath.Join(baseDir, "signed_msg.exe") + + fmt.Println(helperExe) + + // 2. Create temp file for JSON output + // Using generic temp file naming with timestamp + timestamp := time.Now().Format("20060102_150405") + tempFile, err := os.CreateTemp("", fmt.Sprintf("pec_output_%s_*.json", timestamp)) + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + tempPath := tempFile.Name() + tempFile.Close() // Close immediately, exe will write to it + // defer os.Remove(tempPath) // Cleanup + + // 3. Run signed_msg.exe + // Use exec.Command + // Note: Command might need to be "cmd", "/C", ... but usually direct execution works on Windows + fmt.Println(helperExe, filePath, tempPath) + cmd := exec.Command(helperExe, filePath, tempPath) + // Hide window? + // cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} // Requires syscall import + + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("signed_msg.exe failed: %s, output: %s", err, string(output)) + } + + // 4. Read JSON output + jsonData, err := os.ReadFile(tempPath) + if err != nil { + return nil, fmt.Errorf("failed to read json output: %w", err) + } + + // 5. Parse JSON + var pecJson struct { + Subject string `json:"subject"` + From string `json:"from"` + To []string `json:"to"` + Cc []string `json:"cc"` + Bcc []string `json:"bcc"` + Body string `json:"body"` + HtmlBody string `json:"htmlBody"` + Attachments []struct { + Filename string `json:"filename"` + ContentType string `json:"contentType"` + Data string `json:"data"` // Base64 + DataFormat string `json:"dataFormat"` // "base64" (optional) + } `json:"attachments"` + } + + if err := json.Unmarshal(jsonData, &pecJson); err != nil { + return nil, fmt.Errorf("failed to parse json output: %w", err) + } + + // 6. Check for postacert.eml to determine if it is a PEC + var foundPostacert bool + var hasDatiCert, hasSmime bool + + // We'll prepare attachments listing at the same time + var attachments []EmailAttachment + + for _, att := range pecJson.Attachments { + attData, err := base64.StdEncoding.DecodeString(att.Data) + if err != nil { + fmt.Printf("Failed to decode attachment %s: %v\n", att.Filename, err) + continue + } + + filenameLower := strings.ToLower(att.Filename) + if filenameLower == "postacert.eml" { + foundPostacert = true + } + if filenameLower == "daticert.xml" { + hasDatiCert = true + } + if filenameLower == "smime.p7s" { + hasSmime = true + } + + attachments = append(attachments, EmailAttachment{ + Filename: att.Filename, + ContentType: att.ContentType, + Data: attData, + }) + } + + if !foundPostacert { + // Maybe its a normal MSG, continue to try to parse it as a regular email + normalMsgEmail, err := parseMsgFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to find postacert.eml and also failed to parse as normal MSG: %w", err) + } + return normalMsgEmail, nil + } + + // 7. It is a PEC. Return the outer message (wrapper) + // so the user can see the PEC envelope and attachments (postacert.eml, etc.) + + body := pecJson.HtmlBody + if body == "" { + body = pecJson.Body + } + + return &EmailData{ + From: convertToUTF8(pecJson.From), + To: pecJson.To, // Assuming format is already correct or compatible + Cc: pecJson.Cc, + Bcc: pecJson.Bcc, + Subject: convertToUTF8(pecJson.Subject), + Body: convertToUTF8(body), + Attachments: attachments, + IsPec: hasDatiCert || hasSmime, // Typical PEC indicators + HasInnerEmail: foundPostacert, + }, nil +} diff --git a/backend/utils/mailparser.go b/backend/utils/mailparser.go new file mode 100644 index 0000000..a3c3bee --- /dev/null +++ b/backend/utils/mailparser.go @@ -0,0 +1,512 @@ +package utils + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "io/ioutil" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "strings" + "time" +) + +const contentTypeMultipartMixed = "multipart/mixed" +const contentTypeMultipartAlternative = "multipart/alternative" +const contentTypeMultipartRelated = "multipart/related" +const contentTypeTextHtml = "text/html" +const contentTypeTextPlain = "text/plain" + +// Parse an email message read from io.Reader into parsemail.Email struct +func Parse(r io.Reader) (email Email, err error) { + msg, err := mail.ReadMessage(r) + if err != nil { + return + } + + email, err = createEmailFromHeader(msg.Header) + if err != nil { + return + } + + email.ContentType = msg.Header.Get("Content-Type") + contentType, params, err := parseContentType(email.ContentType) + if err != nil { + return + } + + switch contentType { + case contentTypeMultipartMixed, "multipart/signed": + email.TextBody, email.HTMLBody, email.Attachments, email.EmbeddedFiles, err = parseMultipartMixed(msg.Body, params["boundary"]) + case contentTypeMultipartAlternative: + email.TextBody, email.HTMLBody, email.EmbeddedFiles, err = parseMultipartAlternative(msg.Body, params["boundary"]) + case contentTypeMultipartRelated: + email.TextBody, email.HTMLBody, email.EmbeddedFiles, err = parseMultipartRelated(msg.Body, params["boundary"]) + case contentTypeTextPlain: + message, _ := ioutil.ReadAll(msg.Body) + email.TextBody = strings.TrimSuffix(string(message[:]), "\n") + case contentTypeTextHtml: + message, _ := ioutil.ReadAll(msg.Body) + email.HTMLBody = strings.TrimSuffix(string(message[:]), "\n") + default: + email.Content, err = decodeContent(msg.Body, msg.Header.Get("Content-Transfer-Encoding")) + } + + return +} + +func createEmailFromHeader(header mail.Header) (email Email, err error) { + hp := headerParser{header: &header} + + email.Subject = decodeMimeSentence(header.Get("Subject")) + email.From = hp.parseAddressList(header.Get("From")) + email.Sender = hp.parseAddress(header.Get("Sender")) + email.ReplyTo = hp.parseAddressList(header.Get("Reply-To")) + email.To = hp.parseAddressList(header.Get("To")) + email.Cc = hp.parseAddressList(header.Get("Cc")) + email.Bcc = hp.parseAddressList(header.Get("Bcc")) + email.Date = hp.parseTime(header.Get("Date")) + email.ResentFrom = hp.parseAddressList(header.Get("Resent-From")) + email.ResentSender = hp.parseAddress(header.Get("Resent-Sender")) + email.ResentTo = hp.parseAddressList(header.Get("Resent-To")) + email.ResentCc = hp.parseAddressList(header.Get("Resent-Cc")) + email.ResentBcc = hp.parseAddressList(header.Get("Resent-Bcc")) + email.ResentMessageID = hp.parseMessageId(header.Get("Resent-Message-ID")) + email.MessageID = hp.parseMessageId(header.Get("Message-ID")) + email.InReplyTo = hp.parseMessageIdList(header.Get("In-Reply-To")) + email.References = hp.parseMessageIdList(header.Get("References")) + email.ResentDate = hp.parseTime(header.Get("Resent-Date")) + + if hp.err != nil { + err = hp.err + return + } + + //decode whole header for easier access to extra fields + //todo: should we decode? aren't only standard fields mime encoded? + email.Header, err = decodeHeaderMime(header) + if err != nil { + return + } + + return +} + +func parseContentType(contentTypeHeader string) (contentType string, params map[string]string, err error) { + if contentTypeHeader == "" { + contentType = contentTypeTextPlain + return + } + + return mime.ParseMediaType(contentTypeHeader) +} + +func parseMultipartRelated(msg io.Reader, boundary string) (textBody, htmlBody string, embeddedFiles []EmbeddedFile, err error) { + pmr := multipart.NewReader(msg, boundary) + for { + part, err := pmr.NextPart() + + if err == io.EOF { + break + } else if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + contentType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + switch contentType { + case contentTypeTextPlain: + ppContent, err := ioutil.ReadAll(part) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + textBody += strings.TrimSuffix(string(ppContent[:]), "\n") + case contentTypeTextHtml: + ppContent, err := ioutil.ReadAll(part) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + htmlBody += strings.TrimSuffix(string(ppContent[:]), "\n") + case contentTypeMultipartAlternative: + tb, hb, ef, err := parseMultipartAlternative(part, params["boundary"]) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + htmlBody += hb + textBody += tb + embeddedFiles = append(embeddedFiles, ef...) + default: + if isEmbeddedFile(part) { + ef, err := decodeEmbeddedFile(part) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + embeddedFiles = append(embeddedFiles, ef) + } else { + return textBody, htmlBody, embeddedFiles, fmt.Errorf("Can't process multipart/related inner mime type: %s", contentType) + } + } + } + + return textBody, htmlBody, embeddedFiles, err +} + +func parseMultipartAlternative(msg io.Reader, boundary string) (textBody, htmlBody string, embeddedFiles []EmbeddedFile, err error) { + pmr := multipart.NewReader(msg, boundary) + for { + part, err := pmr.NextPart() + + if err == io.EOF { + break + } else if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + contentType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + switch contentType { + case contentTypeTextPlain: + ppContent, err := ioutil.ReadAll(part) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + textBody += strings.TrimSuffix(string(ppContent[:]), "\n") + case contentTypeTextHtml: + ppContent, err := ioutil.ReadAll(part) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + htmlBody += strings.TrimSuffix(string(ppContent[:]), "\n") + case contentTypeMultipartRelated: + tb, hb, ef, err := parseMultipartRelated(part, params["boundary"]) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + htmlBody += hb + textBody += tb + embeddedFiles = append(embeddedFiles, ef...) + default: + if isEmbeddedFile(part) { + ef, err := decodeEmbeddedFile(part) + if err != nil { + return textBody, htmlBody, embeddedFiles, err + } + + embeddedFiles = append(embeddedFiles, ef) + } else { + return textBody, htmlBody, embeddedFiles, fmt.Errorf("Can't process multipart/alternative inner mime type: %s", contentType) + } + } + } + + return textBody, htmlBody, embeddedFiles, err +} + +func parseMultipartMixed(msg io.Reader, boundary string) (textBody, htmlBody string, attachments []Attachment, embeddedFiles []EmbeddedFile, err error) { + mr := multipart.NewReader(msg, boundary) + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + + contentType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + + if contentType == contentTypeMultipartMixed { + textBody, htmlBody, attachments, embeddedFiles, err = parseMultipartMixed(part, params["boundary"]) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + } else if contentType == contentTypeMultipartAlternative { + textBody, htmlBody, embeddedFiles, err = parseMultipartAlternative(part, params["boundary"]) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + } else if contentType == contentTypeMultipartRelated { + textBody, htmlBody, embeddedFiles, err = parseMultipartRelated(part, params["boundary"]) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + } else if contentType == contentTypeTextPlain { + ppContent, err := ioutil.ReadAll(part) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + + textBody += strings.TrimSuffix(string(ppContent[:]), "\n") + } else if contentType == contentTypeTextHtml { + ppContent, err := ioutil.ReadAll(part) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + + htmlBody += strings.TrimSuffix(string(ppContent[:]), "\n") + } else if isAttachment(part) { + at, err := decodeAttachment(part) + if err != nil { + return textBody, htmlBody, attachments, embeddedFiles, err + } + + attachments = append(attachments, at) + } else { + return textBody, htmlBody, attachments, embeddedFiles, fmt.Errorf("Unknown multipart/mixed nested mime type: %s", contentType) + } + } + + return textBody, htmlBody, attachments, embeddedFiles, err +} + +func decodeMimeSentence(s string) string { + result := []string{} + ss := strings.Split(s, " ") + + for _, word := range ss { + dec := new(mime.WordDecoder) + w, err := dec.Decode(word) + if err != nil { + if len(result) == 0 { + w = word + } else { + w = " " + word + } + } + + result = append(result, w) + } + + return strings.Join(result, "") +} + +func decodeHeaderMime(header mail.Header) (mail.Header, error) { + parsedHeader := map[string][]string{} + + for headerName, headerData := range header { + + parsedHeaderData := []string{} + for _, headerValue := range headerData { + parsedHeaderData = append(parsedHeaderData, decodeMimeSentence(headerValue)) + } + + parsedHeader[headerName] = parsedHeaderData + } + + return mail.Header(parsedHeader), nil +} + +func isEmbeddedFile(part *multipart.Part) bool { + return part.Header.Get("Content-Transfer-Encoding") != "" +} + +func decodeEmbeddedFile(part *multipart.Part) (ef EmbeddedFile, err error) { + cid := decodeMimeSentence(part.Header.Get("Content-Id")) + decoded, err := decodeContent(part, part.Header.Get("Content-Transfer-Encoding")) + if err != nil { + return + } + + ef.CID = strings.Trim(cid, "<>") + ef.Data = decoded + ef.ContentType = part.Header.Get("Content-Type") + + return +} + +func isAttachment(part *multipart.Part) bool { + return part.FileName() != "" +} + +func decodeAttachment(part *multipart.Part) (at Attachment, err error) { + filename := decodeMimeSentence(part.FileName()) + decoded, err := decodeContent(part, part.Header.Get("Content-Transfer-Encoding")) + if err != nil { + return + } + + at.Filename = filename + at.Data = decoded + at.ContentType = strings.Split(part.Header.Get("Content-Type"), ";")[0] + + return +} + +func decodeContent(content io.Reader, encoding string) (io.Reader, error) { + switch encoding { + case "base64": + decoded := base64.NewDecoder(base64.StdEncoding, content) + b, err := ioutil.ReadAll(decoded) + if err != nil { + return nil, err + } + + return bytes.NewReader(b), nil + case "7bit": + dd, err := ioutil.ReadAll(content) + if err != nil { + return nil, err + } + + return bytes.NewReader(dd), nil + case "8bit", "binary", "": + dd, err := ioutil.ReadAll(content) + if err != nil { + return nil, err + } + + return bytes.NewReader(dd), nil + case "quoted-printable": + decoded := quotedprintable.NewReader(content) + dd, err := ioutil.ReadAll(decoded) + if err != nil { + return nil, err + } + + return bytes.NewReader(dd), nil + default: + return nil, fmt.Errorf("unknown encoding: %s", encoding) + } +} + +type headerParser struct { + header *mail.Header + err error +} + +func (hp headerParser) parseAddress(s string) (ma *mail.Address) { + if hp.err != nil { + return nil + } + + if strings.Trim(s, " \n") != "" { + ma, hp.err = mail.ParseAddress(s) + + return ma + } + + return nil +} + +func (hp headerParser) parseAddressList(s string) (ma []*mail.Address) { + if hp.err != nil { + return + } + + if strings.Trim(s, " \n") != "" { + ma, hp.err = mail.ParseAddressList(s) + return + } + + return +} + +func (hp headerParser) parseTime(s string) (t time.Time) { + if hp.err != nil || s == "" { + return + } + + formats := []string{ + time.RFC1123Z, + "Mon, 2 Jan 2006 15:04:05 -0700", + time.RFC1123Z + " (MST)", + "Mon, 2 Jan 2006 15:04:05 -0700 (MST)", + } + + for _, format := range formats { + t, hp.err = time.Parse(format, s) + if hp.err == nil { + return + } + } + + return +} + +func (hp headerParser) parseMessageId(s string) string { + if hp.err != nil { + return "" + } + + return strings.Trim(s, "<> ") +} + +func (hp headerParser) parseMessageIdList(s string) (result []string) { + if hp.err != nil { + return + } + + for _, p := range strings.Split(s, " ") { + if strings.Trim(p, " \n") != "" { + result = append(result, hp.parseMessageId(p)) + } + } + + return +} + +// Attachment with filename, content type and data (as a io.Reader) +type Attachment struct { + Filename string + ContentType string + Data io.Reader +} + +// EmbeddedFile with content id, content type and data (as a io.Reader) +type EmbeddedFile struct { + CID string + ContentType string + Data io.Reader +} + +// Email with fields for all the headers defined in RFC5322 with it's attachments and +type Email struct { + Header mail.Header + + Subject string + Sender *mail.Address + From []*mail.Address + ReplyTo []*mail.Address + To []*mail.Address + Cc []*mail.Address + Bcc []*mail.Address + Date time.Time + MessageID string + InReplyTo []string + References []string + + ResentFrom []*mail.Address + ResentSender *mail.Address + ResentTo []*mail.Address + ResentDate time.Time + ResentCc []*mail.Address + ResentBcc []*mail.Address + ResentMessageID string + + ContentType string + Content io.Reader + + HTMLBody string + TextBody string + + Attachments []Attachment + EmbeddedFiles []EmbeddedFile +} diff --git a/config.ini b/config.ini index 146f8c1..c954f14 100644 --- a/config.ini +++ b/config.ini @@ -1,6 +1,6 @@ [EMLy] -SDK_DECODER_SEMVER="1.1.0" -SDK_DECODER_RELEASE_CHANNEL="beta" -GUI_SEMVER="1.1.4" +SDK_DECODER_SEMVER="1.2.1-hotfix_1" +SDK_DECODER_RELEASE_CHANNEL="alpha" +GUI_SEMVER="1.2.2" GUI_RELEASE_CHANNEL="beta" LANGUAGE="it" \ No newline at end of file diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 67688c0..b00f221 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,6 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "hello_world": "Hello, {name} from en!", + "layout_loading_text": "Loading...", "error_unexpected": "An unexpected error occurred", "sidebar_overview": "Mail Viewer", "sidebar_settings": "Settings", @@ -29,6 +30,10 @@ "settings_preview_pdf_builtin_label": "Use built-in viewer for PDFs", "settings_preview_pdf_builtin_hint": "Uses EMLy's built-in viewer for PDF files.", "settings_preview_pdf_builtin_info": "Info: If disabled, PDF files will be treated as downloads instead of being previewed within the app.", + "settings_msg_converter_title": "MSG Handling", + "settings_msg_converter_description": "Configure how MSG files are processed.", + "settings_msg_converter_label": "Use MSG to EML converter", + "settings_msg_converter_hint": "Uses an external app to convert .MSG files to .EML files. Disable it to use the OSS version (less accurate).", "settings_danger_zone_title": "Danger Zone", "settings_danger_zone_description": "Advanced actions. Proceed with caution.", "settings_danger_devtools_label": "Open DevTools", @@ -56,9 +61,9 @@ "settings_unsaved_toast_save": "Save changes", "settings_unsaved_toast_reset": "Reset", "mail_no_email_selected": "No email selected", - "mail_open_eml_btn": "Open EML File", + "mail_open_eml_btn": "Open EML/MSG File", "mail_subject_no_subject": "(No Subject)", - "mail_open_btn_label": "Open EML file", + "mail_open_btn_label": "Open EML/MSG file", "mail_open_btn_title": "Open another file", "mail_close_btn_label": "Close", "mail_close_btn_title": "Close", @@ -72,8 +77,13 @@ "mail_error_image": "Failed to open image file.", "settings_toast_language_changed": "Language changed successfully!", "settings_toast_language_change_failed": "Failed to change language.", - "mail_open_btn_text": "Open EML File", + "mail_open_btn_text": "Open EML/MSG File", "mail_close_btn_text": "Close", "settings_danger_reset_dialog_description_part1": "This action cannot be undone.", - "settings_danger_reset_dialog_description_part2": "This will permanently delete your current settings and return the app to its default state." + "settings_danger_reset_dialog_description_part2": "This will permanently delete your current settings and return the app to its default state.", + "mail_error_opening": "Failed to open EML file.", + "mail_pec_signed_badge": "Signed mail", + "mail_pec_feature_warning": "PEC detected: some features may be limited.", + "mail_sign_label": "Sign:", + "mail_loading_msg_conversion": "Converting MSG file... This might take a while." } diff --git a/frontend/messages/it.json b/frontend/messages/it.json index 562168c..b8f5b7b 100644 --- a/frontend/messages/it.json +++ b/frontend/messages/it.json @@ -1,6 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "hello_world": "Ciao, {name} in it!", + "layout_loading_text": "Caricamento...", "error_unexpected": "Si è verificato un errore imprevisto", "sidebar_overview": "Visualizza Mail", "sidebar_settings": "Impostazioni", @@ -29,6 +30,10 @@ "settings_preview_pdf_builtin_label": "Usa visualizzatore integrato per PDF", "settings_preview_pdf_builtin_hint": "Usa il visualizzatore integrato di EMLy per i file PDF.", "settings_preview_pdf_builtin_info": "Info: Se disabilitato, i file PDF verranno trattati come download invece di essere visualizzati nell'app.", + "settings_msg_converter_title": "Gestione MSG", + "settings_msg_converter_description": "Configura come vengono elaborati i file MSG.", + "settings_msg_converter_label": "Usa convertitore MSG in EML", + "settings_msg_converter_hint": "Usa un'applicazione esterna per convertire i file .MSG in .EML. Disabilitalo per usare la versione OSS (meno accurata).", "settings_danger_zone_title": "Zona Pericolo", "settings_danger_zone_description": "Azioni avanzate. Procedere con cautela.", "settings_danger_devtools_label": "Apri DevTools", @@ -56,9 +61,9 @@ "settings_unsaved_toast_save": "Salva", "settings_unsaved_toast_reset": "Ripristina", "mail_no_email_selected": "Nessuna email selezionata", - "mail_open_eml_btn": "Apri File EML", + "mail_open_eml_btn": "Apri File EML/MSG", "mail_subject_no_subject": "(Nessun Oggetto)", - "mail_open_btn_label": "Apri file EML", + "mail_open_btn_label": "Apri file EML/MSG", "mail_open_btn_title": "Apri un altro file", "mail_close_btn_label": "Chiudi", "mail_close_btn_title": "Chiudi", @@ -72,8 +77,13 @@ "mail_error_image": "Impossibile aprire il file immagine.", "settings_toast_language_changed": "Lingua cambiata con successo!", "settings_toast_language_change_failed": "Impossibile cambiare lingua.", - "mail_open_btn_text": "Apri File EML", + "mail_open_btn_text": "Apri file EML/MSG", "mail_close_btn_text": "Chiudi", "settings_danger_reset_dialog_description_part1": "Questa azione non può essere annullata.", - "settings_danger_reset_dialog_description_part2": "Questo eliminerà permanentemente le tue impostazioni attuali e riporterà l'app allo stato predefinito." + "settings_danger_reset_dialog_description_part2": "Questo eliminerà permanentemente le tue impostazioni attuali e riporterà l'app allo stato predefinito.", + "mail_error_opening": "Impossibile aprire il file EML.", + "mail_pec_signed_badge": "Mail firmata", + "mail_pec_feature_warning": "PEC rilevata: alcune funzionalità potrebbero essere limitate.", + "mail_sign_label": "Firma:", + "mail_loading_msg_conversion": "Conversione file MSG in corso... Potrebbe richiedere del tempo." } diff --git a/frontend/src/app.html b/frontend/src/app.html index 07c9204..a3b1624 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -67,7 +67,7 @@
-
Loading, please wait.
+
Loading, please wait...
%sveltekit.body%
diff --git a/frontend/src/lib/components/dashboard/MailViewer.svelte b/frontend/src/lib/components/dashboard/MailViewer.svelte index 19df2ea..1b1952c 100644 --- a/frontend/src/lib/components/dashboard/MailViewer.svelte +++ b/frontend/src/lib/components/dashboard/MailViewer.svelte @@ -1,6 +1,6 @@
+ {#if isLoading} +
+ +
{loadingText}
+
+ {/if}
{#if mailState.currentEmail === null}
@@ -187,6 +261,14 @@ {m.mail_bcc()} {mailState.currentEmail.bcc.join(", ")} {/if} + + {#if mailState.currentEmail.isPec} + {m.mail_sign_label()} + + + PEC + + {/if}
@@ -211,6 +293,32 @@ {att.filename} + {:else if att.filename.toLowerCase().endsWith(".eml")} + + {:else if mailState.currentEmail.isPec && att.filename.toLowerCase().endsWith(".p7s")} + + + {att.filename} + + {:else if mailState.currentEmail.isPec && att.filename.toLowerCase() === "daticert.xml"} + + + {att.filename} + {:else} diff --git a/frontend/src/lib/types.d.ts b/frontend/src/lib/types.d.ts index 31b460d..93cad07 100644 --- a/frontend/src/lib/types.d.ts +++ b/frontend/src/lib/types.d.ts @@ -6,6 +6,7 @@ interface EMLy_GUI_Settings { selectedLanguage: SupportedLanguages = "en" | "it"; useBuiltinPreview: boolean; useBuiltinPDFViewer?: boolean; + useMsgConverter?: boolean; previewFileSupportedTypes?: SupportedFileTypePreview[]; } diff --git a/frontend/src/lib/wailsjs/go/main/App.d.ts b/frontend/src/lib/wailsjs/go/main/App.d.ts index 43c212a..66a32d8 100644 --- a/frontend/src/lib/wailsjs/go/main/App.d.ts +++ b/frontend/src/lib/wailsjs/go/main/App.d.ts @@ -20,6 +20,8 @@ export function GetViewerData():Promise; export function OpenDefaultAppsSettings():Promise; +export function OpenEMLWindow(arg1:string,arg2:string):Promise; + export function OpenImage(arg1:string,arg2:string):Promise; export function OpenImageWindow(arg1:string,arg2:string):Promise; @@ -32,6 +34,12 @@ export function QuitApp():Promise; export function ReadEML(arg1:string):Promise; +export function ReadMSG(arg1:string,arg2:boolean):Promise; + +export function ReadMSGOSS(arg1:string):Promise; + +export function ReadPEC(arg1:string):Promise; + export function SaveConfig(arg1:utils.Config):Promise; export function ShowOpenFileDialog():Promise; diff --git a/frontend/src/lib/wailsjs/go/main/App.js b/frontend/src/lib/wailsjs/go/main/App.js index a4da375..9a176af 100644 --- a/frontend/src/lib/wailsjs/go/main/App.js +++ b/frontend/src/lib/wailsjs/go/main/App.js @@ -34,6 +34,10 @@ export function OpenDefaultAppsSettings() { return window['go']['main']['App']['OpenDefaultAppsSettings'](); } +export function OpenEMLWindow(arg1, arg2) { + return window['go']['main']['App']['OpenEMLWindow'](arg1, arg2); +} + export function OpenImage(arg1, arg2) { return window['go']['main']['App']['OpenImage'](arg1, arg2); } @@ -58,6 +62,18 @@ export function ReadEML(arg1) { return window['go']['main']['App']['ReadEML'](arg1); } +export function ReadMSG(arg1, arg2) { + return window['go']['main']['App']['ReadMSG'](arg1, arg2); +} + +export function ReadMSGOSS(arg1) { + return window['go']['main']['App']['ReadMSGOSS'](arg1); +} + +export function ReadPEC(arg1) { + return window['go']['main']['App']['ReadPEC'](arg1); +} + export function SaveConfig(arg1) { return window['go']['main']['App']['SaveConfig'](arg1); } diff --git a/frontend/src/lib/wailsjs/go/models.ts b/frontend/src/lib/wailsjs/go/models.ts index 4a78422..dfe35d7 100644 --- a/frontend/src/lib/wailsjs/go/models.ts +++ b/frontend/src/lib/wailsjs/go/models.ts @@ -199,6 +199,8 @@ export namespace internal { subject: string; body: string; attachments: EmailAttachment[]; + isPec: boolean; + hasInnerEmail: boolean; static createFrom(source: any = {}) { return new EmailData(source); @@ -213,6 +215,8 @@ export namespace internal { this.subject = source["subject"]; this.body = source["body"]; this.attachments = this.convertValues(source["attachments"], EmailAttachment); + this.isPec = source["isPec"]; + this.hasInnerEmail = source["hasInnerEmail"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index c551932..fe6baba 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -7,11 +7,11 @@ import "../layout.css"; import { onMount } from "svelte"; import * as m from "$lib/paraglide/messages.js"; - import { GetConfig } from "$lib/wailsjs/go/main/App"; import type { utils } from "$lib/wailsjs/go/models"; import { Toaster } from "$lib/components/ui/sonner/index.js"; import AppSidebar from "$lib/components/SidebarApp.svelte"; import * as Sidebar from "$lib/components/ui/sidebar/index.js"; + import { dev } from '$app/environment'; import { PanelRightClose, PanelRightOpen, @@ -20,6 +20,7 @@ } from "@lucide/svelte"; import { Separator } from "$lib/components/ui/separator/index.js"; import { toast } from "svelte-sonner"; + import { buttonVariants } from "$lib/components/ui/button/index.js"; import { WindowMinimise, @@ -28,6 +29,7 @@ WindowIsMaximised, Quit, } from "$lib/wailsjs/runtime/runtime"; + import { RefreshCcwDot } from "@lucide/svelte"; let versionInfo: utils.Config | null = $state(null); let isMaximized = $state(false); @@ -65,10 +67,10 @@ } onMount(async () => { - versionInfo = await GetConfig(); + versionInfo = data.data as utils.Config; }); - let { children } = $props(); + let { data, children } = $props(); const THEME_KEY = "emly_theme"; let theme = $state<"dark" | "light">("dark"); @@ -109,24 +111,24 @@
@@ -275,6 +294,12 @@ opacity: 0.4; } + .title version debug{ + color: #e11d48; + opacity: 1; + font-weight: 600; + } + .version-wrapper { position: relative; display: inline-block; diff --git a/frontend/src/routes/(app)/+layout.ts b/frontend/src/routes/(app)/+layout.ts new file mode 100644 index 0000000..44994df --- /dev/null +++ b/frontend/src/routes/(app)/+layout.ts @@ -0,0 +1,12 @@ +import type { LayoutLoad } from './$types'; +import { GetConfig } from "$lib/wailsjs/go/main/App"; + +export const load = (async () => { + try { + const config = await GetConfig(); + return { data: config, error: null }; + } catch (e) { + console.error("Failed to load config:", e); + return { data: null, error: 'Failed to load config' }; + } +}) satisfies LayoutLoad; \ No newline at end of file diff --git a/frontend/src/routes/(app)/+page.ts b/frontend/src/routes/(app)/+page.ts index c134a82..73ae303 100644 --- a/frontend/src/routes/(app)/+page.ts +++ b/frontend/src/routes/(app)/+page.ts @@ -1,7 +1,9 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -import { GetViewerData, GetStartupFile, ReadEML } from '$lib/wailsjs/go/main/App'; +import { GetViewerData, GetStartupFile, ReadEML, ReadMSG } from '$lib/wailsjs/go/main/App'; import DOMPurify from 'dompurify'; +import { settingsStore } from '$lib/stores/settings.svelte'; +import type { internal } from '$lib/wailsjs/go/models'; export const load: PageLoad = async () => { try { @@ -18,7 +20,15 @@ export const load: PageLoad = async () => { // Check if opened with a file const startupFile = await GetStartupFile(); if (startupFile) { - const emlContent = await ReadEML(startupFile); + let emlContent: internal.EmailData; + + if (startupFile.toLowerCase().endsWith(".msg")) { + const useExt = settingsStore.settings.useMsgConverter ?? true; + emlContent = await ReadMSG(startupFile, useExt); + } else { + emlContent = await ReadEML(startupFile); + } + if (emlContent) { emlContent.body = DOMPurify.sanitize(emlContent.body || ""); return { email: emlContent }; diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index b4d5e54..6b64e43 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -32,6 +32,7 @@ selectedLanguage: "it", useBuiltinPreview: true, useBuiltinPDFViewer: true, + useMsgConverter: true, previewFileSupportedTypes: ["jpg", "jpeg", "png"], }; @@ -59,6 +60,7 @@ useBuiltinPreview: !!s.useBuiltinPreview, useBuiltinPDFViewer: s.useBuiltinPDFViewer ?? defaults.useBuiltinPDFViewer ?? true, + useMsgConverter: s.useMsgConverter ?? defaults.useMsgConverter ?? true, previewFileSupportedTypes: s.previewFileSupportedTypes || defaults.previewFileSupportedTypes || [], }; @@ -69,6 +71,7 @@ (a.selectedLanguage ?? "") === (b.selectedLanguage ?? "") && !!a.useBuiltinPreview === !!b.useBuiltinPreview && !!a.useBuiltinPDFViewer === !!b.useBuiltinPDFViewer && + !!a.useMsgConverter === !!b.useMsgConverter && JSON.stringify(a.previewFileSupportedTypes?.sort()) === JSON.stringify(b.previewFileSupportedTypes?.sort()) ); @@ -363,6 +366,35 @@ + + + {m.settings_msg_converter_title()} + {m.settings_msg_converter_description()} + + +
+
+
+ +

+ {m.settings_msg_converter_hint()} +

+
+ +
+
+
+
+ {#if $dangerZoneEnabled} diff --git a/go.mod b/go.mod index cc13404..cb50373 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module emly go 1.24.4 require ( - github.com/DusanKasan/parsemail v1.2.0 github.com/jaypipes/ghw v0.21.2 + github.com/richardlehane/mscfb v1.0.6 github.com/wailsapp/wails/v2 v2.11.0 golang.org/x/sys v0.40.0 golang.org/x/text v0.22.0 @@ -29,6 +29,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/richardlehane/msoleps v1.0.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect diff --git a/go.sum b/go.sum index 61912cb..d17463a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/DusanKasan/parsemail v1.2.0 h1:CrzTL1nuPLxB41aO4zE/Tzc9GVD8jjifUftlbTKQQl4= -github.com/DusanKasan/parsemail v1.2.0/go.mod h1:B9lfMbpVe4DMqPImAOCGti7KEwasnRTrKKn66iQefVs= 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= @@ -53,6 +51,10 @@ 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= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM= +github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/installer/installer.iss b/installer/installer.iss index 7bb4266..9e73869 100644 --- a/installer/installer.iss +++ b/installer/installer.iss @@ -1,8 +1,8 @@ [Setup] AppName=EMLy -AppVersion=1.1.4 +AppVersion=1.2.2 DefaultDirName={autopf}\EMLy -OutputBaseFilename=EMLy_Installer +OutputBaseFilename=EMLy_Installer_1.2.2 ArchitecturesInstallIn64BitMode=x64compatible DisableProgramGroupPage=yes ; Request administrative privileges for HKA to write to HKLM if needed, @@ -15,21 +15,28 @@ UninstallDisplayIcon={app}\EMLy.exe ; Source path relative to this .iss file (assuming it is in the "installer" folder and build is in "../build") Source: "..\build\bin\EMLy.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\build\bin\config.ini"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\build\bin\signed_msg.exe"; DestDir: "{app}"; Flags: ignoreversion [Registry] ; 1. Register the .eml extension and point it to our internal ProgID "EMLy.EML" Root: HKA; Subkey: "Software\Classes\.eml"; ValueType: string; ValueName: ""; ValueData: "EMLy.EML"; Flags: uninsdeletevalue +Root: HKA; Subkey: "Software\Classes\.msg"; ValueType: string; ValueName: ""; ValueData: "EMLy.MSG"; Flags: uninsdeletevalue ; 2. Define the ProgID with a readable name and icon Root: HKA; Subkey: "Software\Classes\EMLy.EML"; ValueType: string; ValueName: ""; ValueData: "EMLy Email Message"; Flags: uninsdeletekey Root: HKA; Subkey: "Software\Classes\EMLy.EML\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\EMLy.exe,0" +Root: HKA; Subkey: "Software\Classes\EMLy.MSG"; ValueType: string; ValueName: ""; ValueData: "EMLy Outlook Message"; Flags: uninsdeletekey +Root: HKA; Subkey: "Software\Classes\EMLy.MSG\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\EMLy.exe,0" + ; 3. Define the open command ; "%1" passes the file path to the application Root: HKA; Subkey: "Software\Classes\EMLy.EML\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\EMLy.exe"" ""%1""" +Root: HKA; Subkey: "Software\Classes\EMLy.MSG\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\EMLy.exe"" ""%1""" ; Optional: Add "Open with EMLy" to context menu explicitly (though file association typically handles the double click) Root: HKA; Subkey: "Software\Classes\EMLy.EML\shell\open"; ValueType: string; ValueName: "FriendlyAppName"; ValueData: "EMLy" +Root: HKA; Subkey: "Software\Classes\EMLy.MSG\shell\open"; ValueType: string; ValueName: "FriendlyAppName"; ValueData: "EMLy" [Icons] Name: "{autoprograms}\EMLy"; Filename: "{app}\EMLy.exe" diff --git a/main.go b/main.go index 189b65e..c7fb12c 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ func (a *App) onSecondInstanceLaunch(secondInstanceData options.SecondInstanceDa log.Println("user opened second from", secondInstanceData.WorkingDirectory) runtime.WindowUnminimise(a.ctx) runtime.WindowShow(a.ctx) + log.Println("launchArgs", secondInstanceArgs) go runtime.EventsEmit(a.ctx, "launchArgs", secondInstanceArgs) } @@ -62,6 +63,9 @@ func main() { if strings.HasSuffix(strings.ToLower(arg), ".eml") { app.StartupFilePath = arg } + if strings.HasSuffix(strings.ToLower(arg), ".msg") { + app.StartupFilePath = arg + } } // Create application with options