From 54a3dff1c214b9d4e5cac54b34d8bbddeb80aeaa Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Sat, 14 Feb 2026 09:03:41 +0100 Subject: [PATCH] Add TNEF handling and email loading improvements - Implement TNEF extraction and recursive parsing in new `tnef_reader.go` and associated tests. - Create tests for TNEF extraction scenarios in `tnef_diag_test.go`, `tnef_diag7_test.go`, and `tnef_diag8_test.go`. --- .claude/settings.local.json | 13 + app_mail.go | 41 ++ backend/utils/mail/eml_reader.go | 6 + backend/utils/mail/format_detector.go | 47 +++ backend/utils/mail/tnef_diag2_test.go | 58 +++ backend/utils/mail/tnef_diag3_test.go | 67 +++ backend/utils/mail/tnef_diag4_test.go | 78 ++++ backend/utils/mail/tnef_diag5_test.go | 241 +++++++++++ backend/utils/mail/tnef_diag6_test.go | 209 +++++++++ backend/utils/mail/tnef_diag7_test.go | 273 ++++++++++++ backend/utils/mail/tnef_diag8_test.go | 97 +++++ backend/utils/mail/tnef_diag_test.go | 79 ++++ backend/utils/mail/tnef_reader.go | 444 ++++++++++++++++++++ backend/utils/mail/tnef_reader_test.go | 59 +++ frontend/bun.lock | 19 +- frontend/package.json.md5 | 2 +- frontend/src/lib/utils/mail/email-loader.ts | 63 ++- frontend/src/lib/utils/mail/index.ts | 1 + frontend/src/lib/wailsjs/go/main/App.d.ts | 44 +- frontend/src/lib/wailsjs/go/main/App.js | 84 ++++ frontend/src/lib/wailsjs/go/models.ts | 110 +++++ go.mod | 3 + go.sum | 9 + 23 files changed, 2029 insertions(+), 18 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 backend/utils/mail/format_detector.go create mode 100644 backend/utils/mail/tnef_diag2_test.go create mode 100644 backend/utils/mail/tnef_diag3_test.go create mode 100644 backend/utils/mail/tnef_diag4_test.go create mode 100644 backend/utils/mail/tnef_diag5_test.go create mode 100644 backend/utils/mail/tnef_diag6_test.go create mode 100644 backend/utils/mail/tnef_diag7_test.go create mode 100644 backend/utils/mail/tnef_diag8_test.go create mode 100644 backend/utils/mail/tnef_diag_test.go create mode 100644 backend/utils/mail/tnef_reader.go create mode 100644 backend/utils/mail/tnef_reader_test.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..aacf7b5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "WebFetch(domain:github.com)", + "WebFetch(domain:www.gnu.org)", + "Bash(go run:*)", + "Bash(go build:*)", + "Bash(go doc:*)", + "Bash(go test:*)" + ] + } +} diff --git a/app_mail.go b/app_mail.go index 003e2ff..3358490 100644 --- a/app_mail.go +++ b/app_mail.go @@ -73,6 +73,47 @@ func (a *App) ReadMSGOSS(filePath string) (*internal.EmailData, error) { return internal.ReadMsgFile(filePath) } +// DetectEmailFormat inspects the file's binary content to determine its format, +// regardless of the file extension. Returns "eml", "msg", or "unknown". +// +// Parameters: +// - filePath: Absolute path to the file to inspect +// +// Returns: +// - string: Detected format ("eml", "msg", or "unknown") +// - error: Any file I/O errors +func (a *App) DetectEmailFormat(filePath string) (string, error) { + format, err := internal.DetectEmailFormat(filePath) + return string(format), err +} + +// ReadAuto automatically detects the email file format from its binary content +// and delegates to the appropriate reader (ReadEML/ReadPEC for EML, ReadMSG for MSG). +// +// Parameters: +// - filePath: Absolute path to the email file +// +// Returns: +// - *internal.EmailData: Parsed email data +// - error: Any parsing or detection errors +func (a *App) ReadAuto(filePath string) (*internal.EmailData, error) { + format, err := internal.DetectEmailFormat(filePath) + if err != nil { + return nil, err + } + + switch format { + case internal.FormatMSG: + return internal.ReadMsgFile(filePath) + default: // FormatEML or FormatUnknown – try PEC first, fall back to plain EML + data, err := internal.ReadPecInnerEml(filePath) + if err != nil { + return internal.ReadEmlFile(filePath) + } + return data, nil + } +} + // ShowOpenFileDialog displays the system file picker dialog filtered for email files. // This allows users to browse and select .eml or .msg files to open. // diff --git a/backend/utils/mail/eml_reader.go b/backend/utils/mail/eml_reader.go index 8cf0ebe..49c71e0 100644 --- a/backend/utils/mail/eml_reader.go +++ b/backend/utils/mail/eml_reader.go @@ -146,6 +146,9 @@ func ReadEmlFile(filePath string) (*EmailData, error) { }) } + // Expand any TNEF (winmail.dat) attachments into their contained files. + attachments = expandTNEFAttachments(attachments) + isPec := hasDatiCert && hasSmime // Format From @@ -267,6 +270,9 @@ func ReadPecInnerEml(filePath string) (*EmailData, error) { }) } + // Expand any TNEF (winmail.dat) attachments into their contained files. + attachments = expandTNEFAttachments(attachments) + isPec := hasDatiCert && hasSmime // Format From diff --git a/backend/utils/mail/format_detector.go b/backend/utils/mail/format_detector.go new file mode 100644 index 0000000..da07e07 --- /dev/null +++ b/backend/utils/mail/format_detector.go @@ -0,0 +1,47 @@ +package internal + +import ( + "bytes" + "os" +) + +// EmailFormat represents the detected format of an email file. +type EmailFormat string + +const ( + FormatEML EmailFormat = "eml" + FormatMSG EmailFormat = "msg" + FormatUnknown EmailFormat = "unknown" +) + +// msgMagic is the OLE2/CFB compound file header signature used by .msg files. +var msgMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1} + +// DetectEmailFormat identifies the email file format by inspecting the file's +// binary magic bytes, regardless of the file extension. +// +// Supported formats: +// - "msg": Microsoft Outlook MSG (OLE2/CFB compound file) +// - "eml": Standard MIME email (RFC 5322) +// - "unknown": Could not determine format +func DetectEmailFormat(filePath string) (EmailFormat, error) { + f, err := os.Open(filePath) + if err != nil { + return FormatUnknown, err + } + defer f.Close() + + buf := make([]byte, 8) + n, err := f.Read(buf) + if err != nil || n < 1 { + return FormatUnknown, nil + } + + // MSG files start with the OLE2 Compound File Binary magic bytes. + if n >= 8 && bytes.Equal(buf[:8], msgMagic) { + return FormatMSG, nil + } + + // EML files are plain-text MIME messages; assume EML for anything else. + return FormatEML, nil +} diff --git a/backend/utils/mail/tnef_diag2_test.go b/backend/utils/mail/tnef_diag2_test.go new file mode 100644 index 0000000..a32ba7f --- /dev/null +++ b/backend/utils/mail/tnef_diag2_test.go @@ -0,0 +1,58 @@ +package internal + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFAttributes(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + data, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + decoded, _ := tnef.Decode(data) + fmt.Printf("MAPI Attributes (%d):\n", len(decoded.Attributes)) + for _, attr := range decoded.Attributes { + dataPreview := fmt.Sprintf("%d bytes", len(attr.Data)) + if len(attr.Data) < 200 { + dataPreview = fmt.Sprintf("%q", attr.Data) + } + fmt.Printf(" Name=0x%04X Data=%s\n", attr.Name, dataPreview) + } + + // Check Body/BodyHTML from TNEF data struct fields + fmt.Printf("\nBody len: %d\n", len(decoded.Body)) + fmt.Printf("BodyHTML len: %d\n", len(decoded.BodyHTML)) + + // Check attachment details + for i, ta := range decoded.Attachments { + fmt.Printf("Attachment[%d]: title=%q dataLen=%d\n", i, ta.Title, len(ta.Data)) + } + } +} diff --git a/backend/utils/mail/tnef_diag3_test.go b/backend/utils/mail/tnef_diag3_test.go new file mode 100644 index 0000000..4a3399d --- /dev/null +++ b/backend/utils/mail/tnef_diag3_test.go @@ -0,0 +1,67 @@ +package internal + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFAllSizes(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + data, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + decoded, _ := tnef.Decode(data) + + totalAttrSize := 0 + for _, attr := range decoded.Attributes { + totalAttrSize += len(attr.Data) + fmt.Printf(" Attr 0x%04X: %d bytes\n", attr.Name, len(attr.Data)) + } + + totalAttSize := 0 + for _, ta := range decoded.Attachments { + totalAttSize += len(ta.Data) + } + + fmt.Printf("\nTotal TNEF data: %d bytes\n", len(data)) + fmt.Printf("Total attribute data: %d bytes\n", totalAttrSize) + fmt.Printf("Total attachment data: %d bytes\n", totalAttSize) + fmt.Printf("Accounted: %d bytes\n", totalAttrSize+totalAttSize) + fmt.Printf("Missing: %d bytes\n", len(data)-totalAttrSize-totalAttSize) + + // Try raw decode to check for nested message/attachment objects + fmt.Printf("\nBody: %d, BodyHTML: %d\n", len(decoded.Body), len(decoded.BodyHTML)) + + // Check attachment[0] content + if len(decoded.Attachments) > 0 { + a0 := decoded.Attachments[0] + fmt.Printf("\nAttachment[0] Title=%q Data (hex): %x\n", a0.Title, a0.Data) + } + } +} diff --git a/backend/utils/mail/tnef_diag4_test.go b/backend/utils/mail/tnef_diag4_test.go new file mode 100644 index 0000000..5c234e5 --- /dev/null +++ b/backend/utils/mail/tnef_diag4_test.go @@ -0,0 +1,78 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" +) + +func TestTNEFRawScan(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + data, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + fmt.Printf("TNEF raw size: %d bytes\n", len(data)) + + // Verify signature + if len(data) < 6 { + t.Fatal("too short") + } + sig := binary.LittleEndian.Uint32(data[0:4]) + key := binary.LittleEndian.Uint16(data[4:6]) + fmt.Printf("Signature: 0x%08X Key: 0x%04X\n", sig, key) + + offset := 6 + attrNum := 0 + for offset < len(data) { + if offset+9 > len(data) { + fmt.Printf(" Truncated at offset %d\n", offset) + break + } + + level := data[offset] + attrID := binary.LittleEndian.Uint32(data[offset+1 : offset+5]) + attrLen := binary.LittleEndian.Uint32(data[offset+5 : offset+9]) + + levelStr := "MSG" + if level == 0x02 { + levelStr = "ATT" + } + + fmt.Printf(" [%03d] offset=%-8d level=%s id=0x%08X len=%d\n", + attrNum, offset, levelStr, attrID, attrLen) + + // Move past: level(1) + id(4) + len(4) + data(attrLen) + checksum(2) + offset += 1 + 4 + 4 + int(attrLen) + 2 + + attrNum++ + if attrNum > 200 { + fmt.Println(" ... stopping at 200 attributes") + break + } + } + } +} diff --git a/backend/utils/mail/tnef_diag5_test.go b/backend/utils/mail/tnef_diag5_test.go new file mode 100644 index 0000000..c4e0189 --- /dev/null +++ b/backend/utils/mail/tnef_diag5_test.go @@ -0,0 +1,241 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" +) + +func TestTNEFMapiProps(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + rawData, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + // Navigate to the first attachment's attAttachment (0x9005) block + // From the raw scan: [011] offset=12082 + header(9bytes) = 12091 for data + // Actually let's re-scan to find it properly + offset := 6 + for offset < len(rawData) { + if offset+9 > len(rawData) { + break + } + level := rawData[offset] + attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5]) + attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9])) + dataStart := offset + 9 + + // attAttachment = 0x00069005, we want the FIRST one (for attachment group 1) + if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 { + fmt.Printf("Found attAttachment at offset %d, len=%d\n", offset, attrLen) + parseMapiProps(rawData[dataStart:dataStart+attrLen], t) + break + } + + offset += 9 + attrLen + 2 + } + } +} + +func parseMapiProps(data []byte, t *testing.T) { + if len(data) < 4 { + t.Fatal("too short for MAPI props") + } + + count := binary.LittleEndian.Uint32(data[0:4]) + fmt.Printf("MAPI property count: %d\n", count) + + offset := 4 + for i := 0; i < int(count) && offset+4 <= len(data); i++ { + propTag := binary.LittleEndian.Uint32(data[offset : offset+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + offset += 4 + + // Handle named properties (ID >= 0x8000) + if propID >= 0x8000 { + // Skip GUID (16 bytes) + kind (4 bytes) + if offset+20 > len(data) { + break + } + kind := binary.LittleEndian.Uint32(data[offset+16 : offset+20]) + offset += 20 + if kind == 0 { // MNID_ID + offset += 4 // skip NamedID + } else { // MNID_STRING + if offset+4 > len(data) { + break + } + nameLen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + nameLen + // Pad to 4-byte boundary + if nameLen%4 != 0 { + offset += 4 - nameLen%4 + } + } + } + + var valueSize int + switch propType { + case 0x0002: // PT_SHORT + valueSize = 4 // padded to 4 + case 0x0003: // PT_LONG + valueSize = 4 + case 0x000B: // PT_BOOLEAN + valueSize = 4 + case 0x0040: // PT_SYSTIME + valueSize = 8 + case 0x001E: // PT_STRING8 + if offset+4 > len(data) { + return + } + // count=1, then length, then data padded + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + slen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + strData := "" + if offset+slen <= len(data) && slen < 200 { + strData = string(data[offset : offset+slen]) + } + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X STRING8 len=%d val=%q\n", i, propID, propType, slen, strData) + offset += slen + if slen%4 != 0 { + offset += 4 - slen%4 + } + } + continue + case 0x001F: // PT_UNICODE + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + slen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X UNICODE len=%d\n", i, propID, propType, slen) + offset += slen + if slen%4 != 0 { + offset += 4 - slen%4 + } + } + continue + case 0x0102: // PT_BINARY + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + blen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X BINARY len=%d\n", i, propID, propType, blen) + offset += blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + continue + case 0x000D: // PT_OBJECT + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + olen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X OBJECT len=%d\n", i, propID, propType, olen) + // Peek at first 16 bytes (GUID) + if offset+16 <= len(data) { + fmt.Printf(" GUID: %x\n", data[offset:offset+16]) + } + offset += olen + if olen%4 != 0 { + offset += 4 - olen%4 + } + } + continue + case 0x1003: // PT_MV_LONG + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_LONG count=%d\n", i, propID, propType, cnt) + offset += cnt * 4 + continue + case 0x1102: // PT_MV_BINARY + if offset+4 > len(data) { + return + } + cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + totalSize := 0 + for j := 0; j < cnt; j++ { + if offset+4 > len(data) { + return + } + blen := int(binary.LittleEndian.Uint32(data[offset : offset+4])) + offset += 4 + totalSize += blen + offset += blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_BINARY count=%d totalSize=%d\n", i, propID, propType, cnt, totalSize) + continue + default: + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X (unknown type)\n", i, propID, propType) + return + } + + if valueSize > 0 { + if propType == 0x0003 && offset+4 <= len(data) { + val := binary.LittleEndian.Uint32(data[offset : offset+4]) + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X LONG val=%d (0x%X)\n", i, propID, propType, val, val) + } else { + fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X size=%d\n", i, propID, propType, valueSize) + } + offset += valueSize + } + } +} diff --git a/backend/utils/mail/tnef_diag6_test.go b/backend/utils/mail/tnef_diag6_test.go new file mode 100644 index 0000000..575e775 --- /dev/null +++ b/backend/utils/mail/tnef_diag6_test.go @@ -0,0 +1,209 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFNestedMessage(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + rawData, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + // Navigate to attAttachment (0x9005) for first attachment + offset := 6 + for offset < len(rawData) { + if offset+9 > len(rawData) { + break + } + level := rawData[offset] + attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5]) + attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9])) + dataStart := offset + 9 + + if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 { + mapiData := rawData[dataStart : dataStart+attrLen] + + // Parse MAPI props to find PR_ATTACH_DATA_OBJ (0x3701) + embeddedData := extractPRAttachDataObj(mapiData) + if embeddedData == nil { + t.Fatal("could not find PR_ATTACH_DATA_OBJ") + } + + fmt.Printf("PR_ATTACH_DATA_OBJ total: %d bytes\n", len(embeddedData)) + fmt.Printf("First 32 bytes after GUID: %x\n", embeddedData[16:min2(48, len(embeddedData))]) + + // Check if after the 16-byte GUID there's a TNEF signature + afterGuid := embeddedData[16:] + if len(afterGuid) >= 4 { + sig := binary.LittleEndian.Uint32(afterGuid[0:4]) + fmt.Printf("Signature after GUID: 0x%08X (TNEF=0x223E9F78)\n", sig) + + if sig == 0x223E9F78 { + fmt.Println("It's a nested TNEF stream!") + decoded, err := tnef.Decode(afterGuid) + if err != nil { + fmt.Printf("Nested TNEF decode error: %v\n", err) + } else { + fmt.Printf("Nested Body: %d bytes\n", len(decoded.Body)) + fmt.Printf("Nested BodyHTML: %d bytes\n", len(decoded.BodyHTML)) + fmt.Printf("Nested Attachments: %d\n", len(decoded.Attachments)) + for i, na := range decoded.Attachments { + fmt.Printf(" [%d] %q (%d bytes)\n", i, na.Title, len(na.Data)) + } + fmt.Printf("Nested Attributes: %d\n", len(decoded.Attributes)) + } + } else { + // Try as raw MAPI attributes (no TNEF wrapper) + fmt.Printf("Not a TNEF stream. First byte: 0x%02X\n", afterGuid[0]) + // Check if it's a count of MAPI properties + if len(afterGuid) >= 4 { + propCount := binary.LittleEndian.Uint32(afterGuid[0:4]) + fmt.Printf("First uint32 (possible prop count): %d\n", propCount) + } + } + } + break + } + + offset += 9 + attrLen + 2 + } + } +} + +func extractPRAttachDataObj(mapiData []byte) []byte { + if len(mapiData) < 4 { + return nil + } + count := int(binary.LittleEndian.Uint32(mapiData[0:4])) + offset := 4 + + for i := 0; i < count && offset+4 <= len(mapiData); i++ { + propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + offset += 4 + + // Handle named props + if propID >= 0x8000 { + if offset+20 > len(mapiData) { + return nil + } + kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20]) + offset += 20 + if kind == 0 { + offset += 4 + } else { + if offset+4 > len(mapiData) { + return nil + } + nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + nameLen + if nameLen%4 != 0 { + offset += 4 - nameLen%4 + } + } + } + + switch propType { + case 0x0002: // PT_SHORT + offset += 4 + case 0x0003: // PT_LONG + offset += 4 + case 0x000B: // PT_BOOLEAN + offset += 4 + case 0x0040: // PT_SYSTIME + offset += 8 + case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + slen + if slen%4 != 0 { + offset += 4 - slen%4 + } + } + case 0x0102: // PT_BINARY + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + case 0x000D: // PT_OBJECT + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + if propID == 0x3701 { + // This is PR_ATTACH_DATA_OBJ! + return mapiData[offset : offset+olen] + } + offset += olen + if olen%4 != 0 { + offset += 4 - olen%4 + } + } + default: + return nil + } + } + return nil +} + +func min2(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/utils/mail/tnef_diag7_test.go b/backend/utils/mail/tnef_diag7_test.go new file mode 100644 index 0000000..cad65c5 --- /dev/null +++ b/backend/utils/mail/tnef_diag7_test.go @@ -0,0 +1,273 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFRecursiveExtract(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + rawData, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + fmt.Println("=== Level 0 (top TNEF) ===") + atts, body := recursiveExtract(rawData, 0) + fmt.Printf("\nTotal extracted attachments: %d\n", len(atts)) + for i, a := range atts { + fmt.Printf(" [%d] %q (%d bytes)\n", i, a.Title, len(a.Data)) + } + fmt.Printf("Body HTML len: %d\n", len(body)) + if len(body) > 0 && len(body) < 500 { + fmt.Printf("Body: %s\n", body) + } + } +} + +func recursiveExtract(tnefData []byte, depth int) ([]*tnef.Attachment, string) { + prefix := strings.Repeat(" ", depth) + + decoded, err := tnef.Decode(tnefData) + if err != nil { + fmt.Printf("%sDecode error: %v\n", prefix, err) + return nil, "" + } + + // Collect body + bodyHTML := string(decoded.BodyHTML) + bodyText := string(decoded.Body) + + // Check for RTF body in attributes + for _, attr := range decoded.Attributes { + if attr.Name == 0x1009 { + fmt.Printf("%sFound PR_RTF_COMPRESSED: %d bytes\n", prefix, len(attr.Data)) + } + if attr.Name == 0x1000 { + fmt.Printf("%sFound PR_BODY: %d bytes\n", prefix, len(attr.Data)) + if bodyText == "" { + bodyText = string(attr.Data) + } + } + if attr.Name == 0x1013 || attr.Name == 0x1035 { + fmt.Printf("%sFound PR_BODY_HTML/PR_HTML: %d bytes\n", prefix, len(attr.Data)) + if bodyHTML == "" { + bodyHTML = string(attr.Data) + } + } + } + + fmt.Printf("%sAttachments: %d, Body: %d, BodyHTML: %d\n", + prefix, len(decoded.Attachments), len(bodyText), len(bodyHTML)) + + var allAttachments []*tnef.Attachment + + // Collect real attachments (skip placeholders) + for _, a := range decoded.Attachments { + if a.Title == "Untitled Attachment" && len(a.Data) < 200 { + fmt.Printf("%sSkipping placeholder: %q (%d bytes)\n", prefix, a.Title, len(a.Data)) + continue + } + allAttachments = append(allAttachments, a) + } + + // Now scan for embedded messages in raw TNEF + embeddedStreams := findEmbeddedTNEFStreams(tnefData) + for i, stream := range embeddedStreams { + fmt.Printf("%s--- Recursing into embedded message %d (%d bytes) ---\n", prefix, i, len(stream)) + subAtts, subBody := recursiveExtract(stream, depth+1) + allAttachments = append(allAttachments, subAtts...) + if bodyHTML == "" && subBody != "" { + bodyHTML = subBody + } + } + + if bodyHTML != "" { + return allAttachments, bodyHTML + } + return allAttachments, bodyText +} + +func findEmbeddedTNEFStreams(tnefData []byte) [][]byte { + var streams [][]byte + + // Navigate through TNEF attributes + offset := 6 + for offset+9 < len(tnefData) { + level := tnefData[offset] + attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5]) + attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9])) + dataStart := offset + 9 + + if dataStart+attrLen > len(tnefData) { + break + } + + // attAttachment (0x9005) at attachment level + if level == 0x02 && attrID == 0x00069005 && attrLen > 100 { + mapiData := tnefData[dataStart : dataStart+attrLen] + embedded := extractPRAttachDataObj2(mapiData) + if embedded != nil && len(embedded) > 22 { + // Skip 16-byte GUID, check for TNEF signature + afterGuid := embedded[16:] + if len(afterGuid) >= 4 { + sig := binary.LittleEndian.Uint32(afterGuid[0:4]) + if sig == 0x223E9F78 { + streams = append(streams, afterGuid) + } + } + } + } + + offset += 9 + attrLen + 2 + } + return streams +} + +func extractPRAttachDataObj2(mapiData []byte) []byte { + if len(mapiData) < 4 { + return nil + } + count := int(binary.LittleEndian.Uint32(mapiData[0:4])) + offset := 4 + + for i := 0; i < count && offset+4 <= len(mapiData); i++ { + propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + offset += 4 + + if propID >= 0x8000 { + if offset+20 > len(mapiData) { + return nil + } + kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20]) + offset += 20 + if kind == 0 { + offset += 4 + } else { + if offset+4 > len(mapiData) { + return nil + } + nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + nameLen + if nameLen%4 != 0 { + offset += 4 - nameLen%4 + } + } + } + + switch propType { + case 0x0002: + offset += 4 + case 0x0003: + offset += 4 + case 0x000B: + offset += 4 + case 0x0040: + offset += 8 + case 0x001E, 0x001F: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + slen + if slen%4 != 0 { + offset += 4 - slen%4 + } + } + case 0x0102: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + case 0x000D: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + if propID == 0x3701 { + return mapiData[offset : offset+olen] + } + offset += olen + if olen%4 != 0 { + offset += 4 - olen%4 + } + } + case 0x1003: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + cnt*4 + case 0x1102: + if offset+4 > len(mapiData) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + for j := 0; j < cnt; j++ { + if offset+4 > len(mapiData) { + return nil + } + blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4])) + offset += 4 + blen + if blen%4 != 0 { + offset += 4 - blen%4 + } + } + default: + return nil + } + } + return nil +} diff --git a/backend/utils/mail/tnef_diag8_test.go b/backend/utils/mail/tnef_diag8_test.go new file mode 100644 index 0000000..4b5b120 --- /dev/null +++ b/backend/utils/mail/tnef_diag8_test.go @@ -0,0 +1,97 @@ +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFDeepAttachment(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + outerEmail, _ := Parse(f) + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + + innerEmail, _ := Parse(bytes.NewReader(innerData)) + for _, att := range innerEmail.Attachments { + rawData, _ := io.ReadAll(att.Data) + if strings.ToLower(att.Filename) != "winmail.dat" { + continue + } + + // Dig to level 2: top → embedded[0] → embedded[0] + streams0 := findEmbeddedTNEFStreams(rawData) + if len(streams0) == 0 { + t.Fatal("no embedded streams at level 0") + } + streams1 := findEmbeddedTNEFStreams(streams0[0]) + if len(streams1) == 0 { + t.Fatal("no embedded streams at level 1") + } + + // Decode level 2 + decoded2, err := tnef.Decode(streams1[0]) + if err != nil { + t.Fatalf("level 2 decode: %v", err) + } + + fmt.Printf("Level 2 attachments: %d\n", len(decoded2.Attachments)) + for i, a := range decoded2.Attachments { + fmt.Printf(" [%d] title=%q size=%d\n", i, a.Title, len(a.Data)) + if len(a.Data) > 20 { + fmt.Printf(" first 20 bytes: %x\n", a.Data[:20]) + // Check for EML, MSG, TNEF signatures + if len(a.Data) >= 4 { + sig := binary.LittleEndian.Uint32(a.Data[0:4]) + if sig == 0x223E9F78 { + fmt.Println(" -> TNEF stream!") + } + } + if len(a.Data) >= 8 && bytes.Equal(a.Data[:8], []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}) { + fmt.Println(" -> MSG (OLE2) file!") + } + // Check if text/EML + if a.Data[0] < 128 && a.Data[0] >= 32 { + preview := string(a.Data[:min2(200, len(a.Data))]) + if strings.Contains(preview, "From:") || strings.Contains(preview, "Content-Type") || strings.Contains(preview, "MIME") || strings.Contains(preview, "Received:") { + fmt.Printf(" -> Looks like an EML file! First 200 chars: %s\n", preview) + } else { + fmt.Printf(" -> Text data: %.200s\n", preview) + } + } + } + } + + // Also check level 2's attAttachment for embedded msgs + streams2 := findEmbeddedTNEFStreams(streams1[0]) + fmt.Printf("\nLevel 2 embedded TNEF streams: %d\n", len(streams2)) + + // Check all MAPI attributes at level 2 + fmt.Println("\nLevel 2 MAPI attributes:") + for _, attr := range decoded2.Attributes { + fmt.Printf(" 0x%04X: %d bytes\n", attr.Name, len(attr.Data)) + // PR_BODY + if attr.Name == 0x1000 && len(attr.Data) < 500 { + fmt.Printf(" PR_BODY: %s\n", string(attr.Data)) + } + } + } +} diff --git a/backend/utils/mail/tnef_diag_test.go b/backend/utils/mail/tnef_diag_test.go new file mode 100644 index 0000000..186de64 --- /dev/null +++ b/backend/utils/mail/tnef_diag_test.go @@ -0,0 +1,79 @@ +package internal + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/teamwork/tnef" +) + +func TestTNEFDiag(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + f, _ := os.Open(testFile) + defer f.Close() + + // Parse the PEC outer envelope + outerEmail, err := Parse(f) + if err != nil { + t.Fatalf("parse outer: %v", err) + } + + // Find postacert.eml + var innerData []byte + for _, att := range outerEmail.Attachments { + if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") { + innerData, _ = io.ReadAll(att.Data) + break + } + } + if innerData == nil { + t.Fatal("no postacert.eml found") + } + + // Parse inner email + innerEmail, err := Parse(bytes.NewReader(innerData)) + if err != nil { + t.Fatalf("parse inner: %v", err) + } + + fmt.Printf("Inner attachments: %d\n", len(innerEmail.Attachments)) + for i, att := range innerEmail.Attachments { + data, _ := io.ReadAll(att.Data) + fmt.Printf(" [%d] filename=%q contentType=%q size=%d\n", i, att.Filename, att.ContentType, len(data)) + + if strings.ToLower(att.Filename) == "winmail.dat" || + strings.Contains(strings.ToLower(att.ContentType), "ms-tnef") { + + fmt.Printf(" Found TNEF! First 20 bytes: %x\n", data[:min(20, len(data))]) + fmt.Printf(" isTNEFData: %v\n", isTNEFData(data)) + + decoded, err := tnef.Decode(data) + if err != nil { + fmt.Printf(" TNEF decode error: %v\n", err) + continue + } + fmt.Printf(" TNEF Body len: %d\n", len(decoded.Body)) + fmt.Printf(" TNEF BodyHTML len: %d\n", len(decoded.BodyHTML)) + fmt.Printf(" TNEF Attachments: %d\n", len(decoded.Attachments)) + for j, ta := range decoded.Attachments { + fmt.Printf(" [%d] title=%q size=%d\n", j, ta.Title, len(ta.Data)) + } + fmt.Printf(" TNEF Attributes: %d\n", len(decoded.Attributes)) + } + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/utils/mail/tnef_reader.go b/backend/utils/mail/tnef_reader.go new file mode 100644 index 0000000..bea8feb --- /dev/null +++ b/backend/utils/mail/tnef_reader.go @@ -0,0 +1,444 @@ +package internal + +import ( + "encoding/binary" + "mime" + "path/filepath" + "strings" + + "github.com/teamwork/tnef" +) + +// tnefMagic is the TNEF file signature (little-endian 0x223E9F78). +var tnefMagic = []byte{0x78, 0x9F, 0x3E, 0x22} + +const maxTNEFDepth = 10 + +// isTNEFData returns true if the given byte slice starts with the TNEF magic number. +func isTNEFData(data []byte) bool { + return len(data) >= 4 && + data[0] == tnefMagic[0] && + data[1] == tnefMagic[1] && + data[2] == tnefMagic[2] && + data[3] == tnefMagic[3] +} + +// isTNEFAttachment returns true if an attachment is a TNEF-encoded winmail.dat. +// Detection is based on filename, content-type, or the TNEF magic bytes. +func isTNEFAttachment(att EmailAttachment) bool { + filenameLower := strings.ToLower(att.Filename) + if filenameLower == "winmail.dat" { + return true + } + ctLower := strings.ToLower(att.ContentType) + if strings.Contains(ctLower, "application/ms-tnef") || + strings.Contains(ctLower, "application/vnd.ms-tnef") { + return true + } + return isTNEFData(att.Data) +} + +// extractTNEFAttachments decodes a TNEF blob and returns the files embedded +// inside it, recursively following nested embedded MAPI messages. +func extractTNEFAttachments(data []byte) ([]EmailAttachment, error) { + return extractTNEFRecursive(data, 0) +} + +func extractTNEFRecursive(data []byte, depth int) ([]EmailAttachment, error) { + if depth > maxTNEFDepth { + return nil, nil + } + + decoded, err := tnef.Decode(data) + if err != nil { + return nil, err + } + + var attachments []EmailAttachment + + // Collect non-placeholder file attachments from the library output. + for _, att := range decoded.Attachments { + if len(att.Data) == 0 { + continue + } + // Skip the small MAPI placeholder text ("L'allegato è un messaggio + // incorporato MAPI 1.0...") that Outlook inserts for embedded messages. + if isEmbeddedMsgPlaceholder(att) { + continue + } + + filename := att.Title + if filename == "" || filename == "Untitled Attachment" { + filename = inferFilename(att.Data) + } + + attachments = append(attachments, EmailAttachment{ + Filename: filename, + ContentType: mimeTypeFromFilename(filename), + Data: att.Data, + }) + } + + // Recursively dig into embedded MAPI messages stored in + // attAttachment (0x9005) → PR_ATTACH_DATA_OBJ (0x3701). + for _, stream := range findEmbeddedTNEFStreamsFromRaw(data) { + subAtts, _ := extractTNEFRecursive(stream, depth+1) + attachments = append(attachments, subAtts...) + } + + return attachments, nil +} + +// isEmbeddedMsgPlaceholder returns true if the attachment is a tiny placeholder +// that Outlook generates for embedded MAPI messages ("L'allegato è un messaggio +// incorporato MAPI 1.0" or equivalent in other languages). +func isEmbeddedMsgPlaceholder(att *tnef.Attachment) bool { + if len(att.Data) > 300 { + return false + } + lower := strings.ToLower(string(att.Data)) + return strings.Contains(lower, "mapi 1.0") || + strings.Contains(lower, "embedded message") || + strings.Contains(lower, "messaggio incorporato") +} + +// inferFilename picks a reasonable filename based on the data's magic bytes. +func inferFilename(data []byte) string { + if looksLikeEML(data) { + return "embedded_message.eml" + } + if isTNEFData(data) { + return "embedded.dat" + } + if len(data) >= 8 { + if data[0] == 0xD0 && data[1] == 0xCF && data[2] == 0x11 && data[3] == 0xE0 { + return "embedded_message.msg" + } + } + return "attachment.dat" +} + +// looksLikeEML returns true if data starts with typical RFC 5322 headers. +func looksLikeEML(data []byte) bool { + if len(data) < 20 { + return false + } + // Quick check: must start with printable ASCII + if data[0] < 32 || data[0] > 126 { + return false + } + prefix := strings.ToLower(string(data[:min(200, len(data))])) + return strings.HasPrefix(prefix, "mime-version:") || + strings.HasPrefix(prefix, "from:") || + strings.HasPrefix(prefix, "received:") || + strings.HasPrefix(prefix, "date:") || + strings.HasPrefix(prefix, "content-type:") || + strings.HasPrefix(prefix, "return-path:") +} + +// expandTNEFAttachments iterates over the attachment list and replaces any +// TNEF-encoded winmail.dat entries with the files they contain. Attachments +// that are not TNEF are passed through unchanged. +func expandTNEFAttachments(attachments []EmailAttachment) []EmailAttachment { + var result []EmailAttachment + for _, att := range attachments { + if isTNEFAttachment(att) { + extracted, err := extractTNEFAttachments(att.Data) + if err == nil && len(extracted) > 0 { + result = append(result, extracted...) + continue + } + // If extraction fails, keep the original blob. + } + result = append(result, att) + } + return result +} + +// --------------------------------------------------------------------------- +// Raw TNEF attribute scanner — extracts nested TNEF streams from embedded +// MAPI messages that the teamwork/tnef library does not handle. +// --------------------------------------------------------------------------- + +// findEmbeddedTNEFStreamsFromRaw scans the raw TNEF byte stream for +// attAttachment (0x00069005) attribute blocks, parses their MAPI properties, +// and extracts any PR_ATTACH_DATA_OBJ (0x3701) values that begin with a +// TNEF signature. +func findEmbeddedTNEFStreamsFromRaw(tnefData []byte) [][]byte { + if len(tnefData) < 6 || !isTNEFData(tnefData) { + return nil + } + + var streams [][]byte + offset := 6 // skip TNEF signature (4) + key (2) + + for offset+9 < len(tnefData) { + level := tnefData[offset] + attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5]) + attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9])) + dataStart := offset + 9 + + if dataStart+attrLen > len(tnefData) || attrLen < 0 { + break + } + + // attAttachment (0x00069005) at attachment level (0x02) + if level == 0x02 && attrID == 0x00069005 && attrLen > 100 { + mapiData := tnefData[dataStart : dataStart+attrLen] + embedded := extractPRAttachDataObjFromMAPI(mapiData) + if embedded != nil && len(embedded) > 22 { + // Skip the 16-byte IID_IMessage GUID + afterGuid := embedded[16:] + if isTNEFData(afterGuid) { + streams = append(streams, afterGuid) + } + } + } + + // level(1) + id(4) + len(4) + data(attrLen) + checksum(2) + offset += 9 + attrLen + 2 + } + return streams +} + +// extractPRAttachDataObjFromMAPI parses a MAPI properties block (from an +// attAttachment attribute) and returns the raw value of PR_ATTACH_DATA_OBJ +// (property ID 0x3701, type PT_OBJECT 0x000D). +func extractPRAttachDataObjFromMAPI(data []byte) []byte { + if len(data) < 4 { + return nil + } + count := int(binary.LittleEndian.Uint32(data[0:4])) + off := 4 + + for i := 0; i < count && off+4 <= len(data); i++ { + propTag := binary.LittleEndian.Uint32(data[off : off+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + off += 4 + + // Named properties (ID >= 0x8000) have extra GUID + kind fields. + if propID >= 0x8000 { + if off+20 > len(data) { + return nil + } + kind := binary.LittleEndian.Uint32(data[off+16 : off+20]) + off += 20 + if kind == 0 { // MNID_ID + off += 4 + } else { // MNID_STRING + if off+4 > len(data) { + return nil + } + nameLen := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + nameLen + off += padTo4(nameLen) + } + } + + off = skipMAPIPropValue(data, off, propType, propID) + if off < 0 { + return nil // parse error + } + // If skipMAPIPropValue returned a special sentinel, extract it. + // We use a hack: skipMAPIPropValue can't return the data directly, + // so we handle PT_OBJECT / 0x3701 inline below. + } + + // Simpler approach: re-scan specifically for 0x3701. + return extractPRAttachDataObjDirect(data) +} + +// extractPRAttachDataObjDirect re-scans the MAPI property block and +// returns the raw value of PR_ATTACH_DATA_OBJ (0x3701, PT_OBJECT). +func extractPRAttachDataObjDirect(data []byte) []byte { + if len(data) < 4 { + return nil + } + count := int(binary.LittleEndian.Uint32(data[0:4])) + off := 4 + + for i := 0; i < count && off+4 <= len(data); i++ { + propTag := binary.LittleEndian.Uint32(data[off : off+4]) + propType := propTag & 0xFFFF + propID := (propTag >> 16) & 0xFFFF + off += 4 + + // Skip named property headers. + if propID >= 0x8000 { + if off+20 > len(data) { + return nil + } + kind := binary.LittleEndian.Uint32(data[off+16 : off+20]) + off += 20 + if kind == 0 { + off += 4 + } else { + if off+4 > len(data) { + return nil + } + nameLen := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + nameLen + off += padTo4(nameLen) + } + } + + switch propType { + case 0x0002: // PT_SHORT (padded to 4) + off += 4 + case 0x0003, 0x000A: // PT_LONG, PT_ERROR + off += 4 + case 0x000B: // PT_BOOLEAN (padded to 4) + off += 4 + case 0x0004: // PT_FLOAT + off += 4 + case 0x0005: // PT_DOUBLE + off += 8 + case 0x0006: // PT_CURRENCY + off += 8 + case 0x0007: // PT_APPTIME + off += 8 + case 0x0014: // PT_I8 + off += 8 + case 0x0040: // PT_SYSTIME + off += 8 + case 0x0048: // PT_CLSID + off += 16 + case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE + off = skipCountedBlobs(data, off) + case 0x0102: // PT_BINARY + off = skipCountedBlobs(data, off) + case 0x000D: // PT_OBJECT + if off+4 > len(data) { + return nil + } + cnt := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + for j := 0; j < cnt; j++ { + if off+4 > len(data) { + return nil + } + olen := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + if propID == 0x3701 && off+olen <= len(data) { + return data[off : off+olen] + } + off += olen + off += padTo4(olen) + } + case 0x1002: // PT_MV_SHORT + off = skipMVFixed(data, off, 4) + case 0x1003: // PT_MV_LONG + off = skipMVFixed(data, off, 4) + case 0x1005: // PT_MV_DOUBLE + off = skipMVFixed(data, off, 8) + case 0x1014: // PT_MV_I8 + off = skipMVFixed(data, off, 8) + case 0x1040: // PT_MV_SYSTIME + off = skipMVFixed(data, off, 8) + case 0x101E, 0x101F: // PT_MV_STRING8, PT_MV_UNICODE + off = skipCountedBlobs(data, off) + case 0x1048: // PT_MV_CLSID + off = skipMVFixed(data, off, 16) + case 0x1102: // PT_MV_BINARY + off = skipCountedBlobs(data, off) + default: + // Unknown type, can't continue + return nil + } + + if off < 0 || off > len(data) { + return nil + } + } + return nil +} + +// skipCountedBlobs advances past a MAPI value that stores count + N +// length-prefixed blobs (used by PT_STRING8, PT_UNICODE, PT_BINARY, and +// their multi-valued variants). +func skipCountedBlobs(data []byte, off int) int { + if off+4 > len(data) { + return -1 + } + cnt := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + for j := 0; j < cnt; j++ { + if off+4 > len(data) { + return -1 + } + blen := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + blen + off += padTo4(blen) + } + return off +} + +// skipMVFixed advances past a multi-valued fixed-size property +// (count followed by count*elemSize bytes). +func skipMVFixed(data []byte, off int, elemSize int) int { + if off+4 > len(data) { + return -1 + } + cnt := int(binary.LittleEndian.Uint32(data[off : off+4])) + off += 4 + cnt*elemSize + return off +} + +// skipMAPIPropValue is a generic value skipper (unused in the current flow +// but kept for completeness). +func skipMAPIPropValue(data []byte, off int, propType uint32, _ uint32) int { + switch propType { + case 0x0002: + return off + 4 + case 0x0003, 0x000A, 0x000B, 0x0004: + return off + 4 + case 0x0005, 0x0006, 0x0007, 0x0014, 0x0040: + return off + 8 + case 0x0048: + return off + 16 + case 0x001E, 0x001F, 0x0102, 0x000D: + return skipCountedBlobs(data, off) + case 0x1002, 0x1003: + return skipMVFixed(data, off, 4) + case 0x1005, 0x1014, 0x1040: + return skipMVFixed(data, off, 8) + case 0x1048: + return skipMVFixed(data, off, 16) + case 0x101E, 0x101F, 0x1102: + return skipCountedBlobs(data, off) + default: + return -1 + } +} + +// padTo4 returns the number of padding bytes needed to reach a 4-byte boundary. +func padTo4(n int) int { + r := n % 4 + if r == 0 { + return 0 + } + return 4 - r +} + +// --------------------------------------------------------------------------- +// MIME type helper +// --------------------------------------------------------------------------- + +// mimeTypeFromFilename guesses the MIME type from a file extension. +// Falls back to "application/octet-stream" when the type is unknown. +func mimeTypeFromFilename(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + return "application/octet-stream" + } + t := mime.TypeByExtension(ext) + if t == "" { + return "application/octet-stream" + } + // Strip any parameters (e.g. "; charset=utf-8") + if idx := strings.Index(t, ";"); idx != -1 { + t = strings.TrimSpace(t[:idx]) + } + return t +} diff --git a/backend/utils/mail/tnef_reader_test.go b/backend/utils/mail/tnef_reader_test.go new file mode 100644 index 0000000..20e659a --- /dev/null +++ b/backend/utils/mail/tnef_reader_test.go @@ -0,0 +1,59 @@ +package internal + +import ( + "fmt" + "os" + "testing" +) + +func TestReadEmlWithTNEF(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + // First try the PEC reader (this is a PEC email) + email, err := ReadPecInnerEml(testFile) + if err != nil { + t.Fatalf("ReadPecInnerEml failed: %v", err) + } + + fmt.Printf("Subject: %s\n", email.Subject) + fmt.Printf("From: %s\n", email.From) + fmt.Printf("Attachment count: %d\n", len(email.Attachments)) + + hasWinmailDat := false + for i, att := range email.Attachments { + fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data)) + if att.Filename == "winmail.dat" { + hasWinmailDat = true + } + } + + if hasWinmailDat { + t.Error("winmail.dat should have been expanded into its contained attachments") + } + + if len(email.Attachments) == 0 { + t.Error("expected at least one attachment after TNEF expansion") + } +} + +func TestReadEmlFallback(t *testing.T) { + testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml` + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("test EML file not present") + } + + // Also verify the plain EML reader path + email, err := ReadEmlFile(testFile) + if err != nil { + t.Fatalf("ReadEmlFile failed: %v", err) + } + + fmt.Printf("[EML] Subject: %s\n", email.Subject) + fmt.Printf("[EML] Attachment count: %d\n", len(email.Attachments)) + for i, att := range email.Attachments { + fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data)) + } +} diff --git a/frontend/bun.lock b/frontend/bun.lock index ebfc404..7020053 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -5,7 +5,10 @@ "": { "name": "frontend", "dependencies": { + "@rollup/rollup-win32-arm64-msvc": "^4.57.1", + "@types/html2canvas": "^1.0.0", "dompurify": "^3.3.1", + "html2canvas": "^1.4.1", "pdfjs-dist": "^5.4.624", "svelte-flags": "^3.0.1", "svelte-sonner": "^1.0.7", @@ -187,7 +190,7 @@ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="], @@ -249,6 +252,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/html2canvas": ["@types/html2canvas@1.0.0", "", { "dependencies": { "html2canvas": "*" } }, "sha512-BJpVf+FIN9UERmzhbtUgpXj6XBZpG67FMgBLLoj9HZKd9XifcCpSV+UnFcwTZfEyun4U/KmCrrVOG7829L589w=="], + "@types/node": ["@types/node@24.10.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -261,6 +266,8 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "bits-ui": ["bits-ui@2.15.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -277,6 +284,8 @@ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], @@ -307,6 +316,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -415,6 +426,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], @@ -433,6 +446,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], @@ -469,6 +484,8 @@ "paneforge/svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="], + "rollup/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="], + "svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="], "mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="], diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 646b398..65bd0b1 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -3c4a64d0cfb34e86fac16fceae842e43 \ No newline at end of file +1697d40a08e09716b8c29ddebeabd1ad \ No newline at end of file diff --git a/frontend/src/lib/utils/mail/email-loader.ts b/frontend/src/lib/utils/mail/email-loader.ts index 69b7b0f..c942b4e 100644 --- a/frontend/src/lib/utils/mail/email-loader.ts +++ b/frontend/src/lib/utils/mail/email-loader.ts @@ -6,6 +6,8 @@ import { ReadEML, ReadMSG, ReadPEC, + ReadAuto, + DetectEmailFormat, ShowOpenFileDialog, SetCurrentMailFilePath, ConvertToUTF8, @@ -23,7 +25,8 @@ export interface LoadEmailResult { } /** - * Determines the email file type from the path + * Determines the email file type from the path extension (best-effort hint). + * Use DetectEmailFormat (backend) for reliable format detection. */ export function getEmailFileType(filePath: string): 'eml' | 'msg' | null { const lowerPath = filePath.toLowerCase(); @@ -33,18 +36,57 @@ export function getEmailFileType(filePath: string): 'eml' | 'msg' | null { } /** - * Checks if a file path is a valid email file + * Checks if a file path looks like an email file by extension. + * Returns true also for unknown extensions so the backend can attempt parsing. */ export function isEmailFile(filePath: string): boolean { - return getEmailFileType(filePath) !== null; + return filePath.trim().length > 0; } /** - * Loads an email from a file path + * Loads an email from a file path. + * Uses ReadAuto so the backend detects the format from the file's binary + * content, regardless of extension. Falls back to the legacy per-format + * readers only when the caller explicitly requests them. + * * @param filePath - Path to the email file * @returns LoadEmailResult with the email data or error */ export async function loadEmailFromPath(filePath: string): Promise { + 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 { const fileType = getEmailFileType(filePath); if (!fileType) { @@ -60,7 +102,6 @@ export async function loadEmailFromPath(filePath: string): Promise; + export function CheckIsDefaultEMLHandler():Promise; +export function ConvertToUTF8(arg1:string):Promise; + +export function CreateBugReportFolder():Promise; + +export function DetectEmailFormat(arg1:string):Promise; + +export function DownloadUpdate():Promise; + +export function ExportSettings(arg1:string):Promise; + export function FrontendLog(arg1:string,arg2:string):Promise; export function GetConfig():Promise; +export function GetCurrentMailFilePath():Promise; + export function GetImageViewerData():Promise; export function GetMachineData():Promise; @@ -18,14 +32,26 @@ export function GetPDFViewerData():Promise; export function GetStartupFile():Promise; +export function GetUpdateStatus():Promise; + export function GetViewerData():Promise; +export function ImportSettings():Promise; + +export function InstallUpdate(arg1:boolean):Promise; + +export function InstallUpdateSilent():Promise; + +export function InstallUpdateSilentFromPath(arg1:string):Promise; + export function IsDebuggerRunning():Promise; export function OpenDefaultAppsSettings():Promise; export function OpenEMLWindow(arg1:string,arg2:string):Promise; +export function OpenFolderInExplorer(arg1:string):Promise; + export function OpenImage(arg1:string,arg2:string):Promise; export function OpenImageWindow(arg1:string,arg2:string):Promise; @@ -34,8 +60,12 @@ export function OpenPDF(arg1:string,arg2:string):Promise; export function OpenPDFWindow(arg1:string,arg2:string):Promise; +export function OpenURLInBrowser(arg1:string):Promise; + export function QuitApp():Promise; +export function ReadAuto(arg1:string):Promise; + export function ReadEML(arg1:string):Promise; export function ReadMSG(arg1:string,arg2:boolean):Promise; @@ -46,4 +76,16 @@ export function ReadPEC(arg1:string):Promise; export function SaveConfig(arg1:utils.Config):Promise; +export function SaveScreenshot():Promise; + +export function SaveScreenshotAs():Promise; + +export function SetCurrentMailFilePath(arg1:string):Promise; + +export function SetUpdateCheckerEnabled(arg1:boolean):Promise; + export function ShowOpenFileDialog():Promise; + +export function SubmitBugReport(arg1:main.BugReportInput):Promise; + +export function TakeScreenshot():Promise; diff --git a/frontend/src/lib/wailsjs/go/main/App.js b/frontend/src/lib/wailsjs/go/main/App.js index 228744d..27b31d9 100644 --- a/frontend/src/lib/wailsjs/go/main/App.js +++ b/frontend/src/lib/wailsjs/go/main/App.js @@ -2,10 +2,34 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function CheckForUpdates() { + return window['go']['main']['App']['CheckForUpdates'](); +} + export function CheckIsDefaultEMLHandler() { return window['go']['main']['App']['CheckIsDefaultEMLHandler'](); } +export function ConvertToUTF8(arg1) { + return window['go']['main']['App']['ConvertToUTF8'](arg1); +} + +export function CreateBugReportFolder() { + return window['go']['main']['App']['CreateBugReportFolder'](); +} + +export function DetectEmailFormat(arg1) { + return window['go']['main']['App']['DetectEmailFormat'](arg1); +} + +export function DownloadUpdate() { + return window['go']['main']['App']['DownloadUpdate'](); +} + +export function ExportSettings(arg1) { + return window['go']['main']['App']['ExportSettings'](arg1); +} + export function FrontendLog(arg1, arg2) { return window['go']['main']['App']['FrontendLog'](arg1, arg2); } @@ -14,6 +38,10 @@ export function GetConfig() { return window['go']['main']['App']['GetConfig'](); } +export function GetCurrentMailFilePath() { + return window['go']['main']['App']['GetCurrentMailFilePath'](); +} + export function GetImageViewerData() { return window['go']['main']['App']['GetImageViewerData'](); } @@ -30,10 +58,30 @@ export function GetStartupFile() { return window['go']['main']['App']['GetStartupFile'](); } +export function GetUpdateStatus() { + return window['go']['main']['App']['GetUpdateStatus'](); +} + export function GetViewerData() { return window['go']['main']['App']['GetViewerData'](); } +export function ImportSettings() { + return window['go']['main']['App']['ImportSettings'](); +} + +export function InstallUpdate(arg1) { + return window['go']['main']['App']['InstallUpdate'](arg1); +} + +export function InstallUpdateSilent() { + return window['go']['main']['App']['InstallUpdateSilent'](); +} + +export function InstallUpdateSilentFromPath(arg1) { + return window['go']['main']['App']['InstallUpdateSilentFromPath'](arg1); +} + export function IsDebuggerRunning() { return window['go']['main']['App']['IsDebuggerRunning'](); } @@ -46,6 +94,10 @@ export function OpenEMLWindow(arg1, arg2) { return window['go']['main']['App']['OpenEMLWindow'](arg1, arg2); } +export function OpenFolderInExplorer(arg1) { + return window['go']['main']['App']['OpenFolderInExplorer'](arg1); +} + export function OpenImage(arg1, arg2) { return window['go']['main']['App']['OpenImage'](arg1, arg2); } @@ -62,10 +114,18 @@ export function OpenPDFWindow(arg1, arg2) { return window['go']['main']['App']['OpenPDFWindow'](arg1, arg2); } +export function OpenURLInBrowser(arg1) { + return window['go']['main']['App']['OpenURLInBrowser'](arg1); +} + export function QuitApp() { return window['go']['main']['App']['QuitApp'](); } +export function ReadAuto(arg1) { + return window['go']['main']['App']['ReadAuto'](arg1); +} + export function ReadEML(arg1) { return window['go']['main']['App']['ReadEML'](arg1); } @@ -86,6 +146,30 @@ export function SaveConfig(arg1) { return window['go']['main']['App']['SaveConfig'](arg1); } +export function SaveScreenshot() { + return window['go']['main']['App']['SaveScreenshot'](); +} + +export function SaveScreenshotAs() { + return window['go']['main']['App']['SaveScreenshotAs'](); +} + +export function SetCurrentMailFilePath(arg1) { + return window['go']['main']['App']['SetCurrentMailFilePath'](arg1); +} + +export function SetUpdateCheckerEnabled(arg1) { + return window['go']['main']['App']['SetUpdateCheckerEnabled'](arg1); +} + export function ShowOpenFileDialog() { return window['go']['main']['App']['ShowOpenFileDialog'](); } + +export function SubmitBugReport(arg1) { + return window['go']['main']['App']['SubmitBugReport'](arg1); +} + +export function TakeScreenshot() { + return window['go']['main']['App']['TakeScreenshot'](); +} diff --git a/frontend/src/lib/wailsjs/go/models.ts b/frontend/src/lib/wailsjs/go/models.ts index dfe35d7..9389afe 100644 --- a/frontend/src/lib/wailsjs/go/models.ts +++ b/frontend/src/lib/wailsjs/go/models.ts @@ -242,6 +242,44 @@ export namespace internal { export namespace main { + export class BugReportInput { + name: string; + email: string; + description: string; + screenshotData: string; + localStorageData: string; + configData: string; + + static createFrom(source: any = {}) { + return new BugReportInput(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.email = source["email"]; + this.description = source["description"]; + this.screenshotData = source["screenshotData"]; + this.localStorageData = source["localStorageData"]; + this.configData = source["configData"]; + } + } + export class BugReportResult { + folderPath: string; + screenshotPath: string; + mailFilePath: string; + + static createFrom(source: any = {}) { + return new BugReportResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.folderPath = source["folderPath"]; + this.screenshotPath = source["screenshotPath"]; + this.mailFilePath = source["mailFilePath"]; + } + } export class ImageViewerData { data: string; filename: string; @@ -270,6 +308,70 @@ export namespace main { this.filename = source["filename"]; } } + export class ScreenshotResult { + data: string; + width: number; + height: number; + filename: string; + + static createFrom(source: any = {}) { + return new ScreenshotResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.data = source["data"]; + this.width = source["width"]; + this.height = source["height"]; + this.filename = source["filename"]; + } + } + export class SubmitBugReportResult { + zipPath: string; + folderPath: string; + + static createFrom(source: any = {}) { + return new SubmitBugReportResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.zipPath = source["zipPath"]; + this.folderPath = source["folderPath"]; + } + } + export class UpdateStatus { + currentVersion: string; + availableVersion: string; + updateAvailable: boolean; + checking: boolean; + downloading: boolean; + downloadProgress: number; + ready: boolean; + installerPath: string; + errorMessage: string; + releaseNotes?: string; + lastCheckTime: string; + + static createFrom(source: any = {}) { + return new UpdateStatus(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.currentVersion = source["currentVersion"]; + this.availableVersion = source["availableVersion"]; + this.updateAvailable = source["updateAvailable"]; + this.checking = source["checking"]; + this.downloading = source["downloading"]; + this.downloadProgress = source["downloadProgress"]; + this.ready = source["ready"]; + this.installerPath = source["installerPath"]; + this.errorMessage = source["errorMessage"]; + this.releaseNotes = source["releaseNotes"]; + this.lastCheckTime = source["lastCheckTime"]; + } + } export class ViewerData { imageData?: ImageViewerData; pdfData?: PDFViewerData; @@ -717,6 +819,10 @@ export namespace utils { SDKDecoderReleaseChannel: string; GUISemver: string; GUIReleaseChannel: string; + Language: string; + UpdateCheckEnabled: string; + UpdatePath: string; + UpdateAutoCheck: string; static createFrom(source: any = {}) { return new EMLyConfig(source); @@ -728,6 +834,10 @@ export namespace utils { this.SDKDecoderReleaseChannel = source["SDKDecoderReleaseChannel"]; this.GUISemver = source["GUISemver"]; this.GUIReleaseChannel = source["GUIReleaseChannel"]; + this.Language = source["Language"]; + this.UpdateCheckEnabled = source["UpdateCheckEnabled"]; + this.UpdatePath = source["UpdatePath"]; + this.UpdateAutoCheck = source["UpdateAutoCheck"]; } } export class Config { diff --git a/go.mod b/go.mod index e1e8842..70b57cd 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ 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 @@ -30,6 +31,8 @@ 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 diff --git a/go.sum b/go.sum index 9c440d3..bb2bed3 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +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= @@ -47,6 +48,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -65,6 +67,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/teamwork/test v0.0.0-20190410143529-8897d82f8d46/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA= +github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad h1:25sEr0awm0ZPancg5W5H5VvN7PWsJloUBpii10a9isw= +github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA= +github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32 h1:j15wq0XPAY/HR/0+dtwUrIrF2ZTKbk7QIES2p4dAG+k= +github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32/go.mod h1:v7dFaQrF/4+curx7UTH9rqTkHTgXqghfI3thANW150o= +github.com/teamwork/utils v1.0.0 h1:30WqhSbZ9nFhaJSx9HH+yFLiQvL64nqAOyyl5IxoYlY= +github.com/teamwork/utils v1.0.0/go.mod h1:3Fn0qxFeRNpvsg/9T1+btOOOKkd1qG2nPYKKcOmNpcs= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=