Merge Bug Report and Update System into main #1
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
41
app_mail.go
41
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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
47
backend/utils/mail/format_detector.go
Normal file
47
backend/utils/mail/format_detector.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
)
|
||||
|
||||
// EmailFormat represents the detected format of an email file.
|
||||
type EmailFormat string
|
||||
|
||||
const (
|
||||
FormatEML EmailFormat = "eml"
|
||||
FormatMSG EmailFormat = "msg"
|
||||
FormatUnknown EmailFormat = "unknown"
|
||||
)
|
||||
|
||||
// msgMagic is the OLE2/CFB compound file header signature used by .msg files.
|
||||
var msgMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
|
||||
|
||||
// DetectEmailFormat identifies the email file format by inspecting the file's
|
||||
// binary magic bytes, regardless of the file extension.
|
||||
//
|
||||
// Supported formats:
|
||||
// - "msg": Microsoft Outlook MSG (OLE2/CFB compound file)
|
||||
// - "eml": Standard MIME email (RFC 5322)
|
||||
// - "unknown": Could not determine format
|
||||
func DetectEmailFormat(filePath string) (EmailFormat, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return FormatUnknown, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 8)
|
||||
n, err := f.Read(buf)
|
||||
if err != nil || n < 1 {
|
||||
return FormatUnknown, nil
|
||||
}
|
||||
|
||||
// MSG files start with the OLE2 Compound File Binary magic bytes.
|
||||
if n >= 8 && bytes.Equal(buf[:8], msgMagic) {
|
||||
return FormatMSG, nil
|
||||
}
|
||||
|
||||
// EML files are plain-text MIME messages; assume EML for anything else.
|
||||
return FormatEML, nil
|
||||
}
|
||||
58
backend/utils/mail/tnef_diag2_test.go
Normal file
58
backend/utils/mail/tnef_diag2_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFAttributes(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
data, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
decoded, _ := tnef.Decode(data)
|
||||
fmt.Printf("MAPI Attributes (%d):\n", len(decoded.Attributes))
|
||||
for _, attr := range decoded.Attributes {
|
||||
dataPreview := fmt.Sprintf("%d bytes", len(attr.Data))
|
||||
if len(attr.Data) < 200 {
|
||||
dataPreview = fmt.Sprintf("%q", attr.Data)
|
||||
}
|
||||
fmt.Printf(" Name=0x%04X Data=%s\n", attr.Name, dataPreview)
|
||||
}
|
||||
|
||||
// Check Body/BodyHTML from TNEF data struct fields
|
||||
fmt.Printf("\nBody len: %d\n", len(decoded.Body))
|
||||
fmt.Printf("BodyHTML len: %d\n", len(decoded.BodyHTML))
|
||||
|
||||
// Check attachment details
|
||||
for i, ta := range decoded.Attachments {
|
||||
fmt.Printf("Attachment[%d]: title=%q dataLen=%d\n", i, ta.Title, len(ta.Data))
|
||||
}
|
||||
}
|
||||
}
|
||||
67
backend/utils/mail/tnef_diag3_test.go
Normal file
67
backend/utils/mail/tnef_diag3_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFAllSizes(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
data, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
decoded, _ := tnef.Decode(data)
|
||||
|
||||
totalAttrSize := 0
|
||||
for _, attr := range decoded.Attributes {
|
||||
totalAttrSize += len(attr.Data)
|
||||
fmt.Printf(" Attr 0x%04X: %d bytes\n", attr.Name, len(attr.Data))
|
||||
}
|
||||
|
||||
totalAttSize := 0
|
||||
for _, ta := range decoded.Attachments {
|
||||
totalAttSize += len(ta.Data)
|
||||
}
|
||||
|
||||
fmt.Printf("\nTotal TNEF data: %d bytes\n", len(data))
|
||||
fmt.Printf("Total attribute data: %d bytes\n", totalAttrSize)
|
||||
fmt.Printf("Total attachment data: %d bytes\n", totalAttSize)
|
||||
fmt.Printf("Accounted: %d bytes\n", totalAttrSize+totalAttSize)
|
||||
fmt.Printf("Missing: %d bytes\n", len(data)-totalAttrSize-totalAttSize)
|
||||
|
||||
// Try raw decode to check for nested message/attachment objects
|
||||
fmt.Printf("\nBody: %d, BodyHTML: %d\n", len(decoded.Body), len(decoded.BodyHTML))
|
||||
|
||||
// Check attachment[0] content
|
||||
if len(decoded.Attachments) > 0 {
|
||||
a0 := decoded.Attachments[0]
|
||||
fmt.Printf("\nAttachment[0] Title=%q Data (hex): %x\n", a0.Title, a0.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
backend/utils/mail/tnef_diag4_test.go
Normal file
78
backend/utils/mail/tnef_diag4_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTNEFRawScan(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
data, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("TNEF raw size: %d bytes\n", len(data))
|
||||
|
||||
// Verify signature
|
||||
if len(data) < 6 {
|
||||
t.Fatal("too short")
|
||||
}
|
||||
sig := binary.LittleEndian.Uint32(data[0:4])
|
||||
key := binary.LittleEndian.Uint16(data[4:6])
|
||||
fmt.Printf("Signature: 0x%08X Key: 0x%04X\n", sig, key)
|
||||
|
||||
offset := 6
|
||||
attrNum := 0
|
||||
for offset < len(data) {
|
||||
if offset+9 > len(data) {
|
||||
fmt.Printf(" Truncated at offset %d\n", offset)
|
||||
break
|
||||
}
|
||||
|
||||
level := data[offset]
|
||||
attrID := binary.LittleEndian.Uint32(data[offset+1 : offset+5])
|
||||
attrLen := binary.LittleEndian.Uint32(data[offset+5 : offset+9])
|
||||
|
||||
levelStr := "MSG"
|
||||
if level == 0x02 {
|
||||
levelStr = "ATT"
|
||||
}
|
||||
|
||||
fmt.Printf(" [%03d] offset=%-8d level=%s id=0x%08X len=%d\n",
|
||||
attrNum, offset, levelStr, attrID, attrLen)
|
||||
|
||||
// Move past: level(1) + id(4) + len(4) + data(attrLen) + checksum(2)
|
||||
offset += 1 + 4 + 4 + int(attrLen) + 2
|
||||
|
||||
attrNum++
|
||||
if attrNum > 200 {
|
||||
fmt.Println(" ... stopping at 200 attributes")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
backend/utils/mail/tnef_diag5_test.go
Normal file
241
backend/utils/mail/tnef_diag5_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTNEFMapiProps(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
rawData, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Navigate to the first attachment's attAttachment (0x9005) block
|
||||
// From the raw scan: [011] offset=12082 + header(9bytes) = 12091 for data
|
||||
// Actually let's re-scan to find it properly
|
||||
offset := 6
|
||||
for offset < len(rawData) {
|
||||
if offset+9 > len(rawData) {
|
||||
break
|
||||
}
|
||||
level := rawData[offset]
|
||||
attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5])
|
||||
attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9]))
|
||||
dataStart := offset + 9
|
||||
|
||||
// attAttachment = 0x00069005, we want the FIRST one (for attachment group 1)
|
||||
if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 {
|
||||
fmt.Printf("Found attAttachment at offset %d, len=%d\n", offset, attrLen)
|
||||
parseMapiProps(rawData[dataStart:dataStart+attrLen], t)
|
||||
break
|
||||
}
|
||||
|
||||
offset += 9 + attrLen + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseMapiProps(data []byte, t *testing.T) {
|
||||
if len(data) < 4 {
|
||||
t.Fatal("too short for MAPI props")
|
||||
}
|
||||
|
||||
count := binary.LittleEndian.Uint32(data[0:4])
|
||||
fmt.Printf("MAPI property count: %d\n", count)
|
||||
|
||||
offset := 4
|
||||
for i := 0; i < int(count) && offset+4 <= len(data); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(data[offset : offset+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
offset += 4
|
||||
|
||||
// Handle named properties (ID >= 0x8000)
|
||||
if propID >= 0x8000 {
|
||||
// Skip GUID (16 bytes) + kind (4 bytes)
|
||||
if offset+20 > len(data) {
|
||||
break
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(data[offset+16 : offset+20])
|
||||
offset += 20
|
||||
if kind == 0 { // MNID_ID
|
||||
offset += 4 // skip NamedID
|
||||
} else { // MNID_STRING
|
||||
if offset+4 > len(data) {
|
||||
break
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4 + nameLen
|
||||
// Pad to 4-byte boundary
|
||||
if nameLen%4 != 0 {
|
||||
offset += 4 - nameLen%4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var valueSize int
|
||||
switch propType {
|
||||
case 0x0002: // PT_SHORT
|
||||
valueSize = 4 // padded to 4
|
||||
case 0x0003: // PT_LONG
|
||||
valueSize = 4
|
||||
case 0x000B: // PT_BOOLEAN
|
||||
valueSize = 4
|
||||
case 0x0040: // PT_SYSTIME
|
||||
valueSize = 8
|
||||
case 0x001E: // PT_STRING8
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
// count=1, then length, then data padded
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
slen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
strData := ""
|
||||
if offset+slen <= len(data) && slen < 200 {
|
||||
strData = string(data[offset : offset+slen])
|
||||
}
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X STRING8 len=%d val=%q\n", i, propID, propType, slen, strData)
|
||||
offset += slen
|
||||
if slen%4 != 0 {
|
||||
offset += 4 - slen%4
|
||||
}
|
||||
}
|
||||
continue
|
||||
case 0x001F: // PT_UNICODE
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
slen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X UNICODE len=%d\n", i, propID, propType, slen)
|
||||
offset += slen
|
||||
if slen%4 != 0 {
|
||||
offset += 4 - slen%4
|
||||
}
|
||||
}
|
||||
continue
|
||||
case 0x0102: // PT_BINARY
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X BINARY len=%d\n", i, propID, propType, blen)
|
||||
offset += blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
continue
|
||||
case 0x000D: // PT_OBJECT
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
olen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X OBJECT len=%d\n", i, propID, propType, olen)
|
||||
// Peek at first 16 bytes (GUID)
|
||||
if offset+16 <= len(data) {
|
||||
fmt.Printf(" GUID: %x\n", data[offset:offset+16])
|
||||
}
|
||||
offset += olen
|
||||
if olen%4 != 0 {
|
||||
offset += 4 - olen%4
|
||||
}
|
||||
}
|
||||
continue
|
||||
case 0x1003: // PT_MV_LONG
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_LONG count=%d\n", i, propID, propType, cnt)
|
||||
offset += cnt * 4
|
||||
continue
|
||||
case 0x1102: // PT_MV_BINARY
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
totalSize := 0
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(data) {
|
||||
return
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
offset += 4
|
||||
totalSize += blen
|
||||
offset += blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X MV_BINARY count=%d totalSize=%d\n", i, propID, propType, cnt, totalSize)
|
||||
continue
|
||||
default:
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X (unknown type)\n", i, propID, propType)
|
||||
return
|
||||
}
|
||||
|
||||
if valueSize > 0 {
|
||||
if propType == 0x0003 && offset+4 <= len(data) {
|
||||
val := binary.LittleEndian.Uint32(data[offset : offset+4])
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X LONG val=%d (0x%X)\n", i, propID, propType, val, val)
|
||||
} else {
|
||||
fmt.Printf(" [%03d] PropID=0x%04X Type=0x%04X size=%d\n", i, propID, propType, valueSize)
|
||||
}
|
||||
offset += valueSize
|
||||
}
|
||||
}
|
||||
}
|
||||
209
backend/utils/mail/tnef_diag6_test.go
Normal file
209
backend/utils/mail/tnef_diag6_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFNestedMessage(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
rawData, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Navigate to attAttachment (0x9005) for first attachment
|
||||
offset := 6
|
||||
for offset < len(rawData) {
|
||||
if offset+9 > len(rawData) {
|
||||
break
|
||||
}
|
||||
level := rawData[offset]
|
||||
attrID := binary.LittleEndian.Uint32(rawData[offset+1 : offset+5])
|
||||
attrLen := int(binary.LittleEndian.Uint32(rawData[offset+5 : offset+9]))
|
||||
dataStart := offset + 9
|
||||
|
||||
if level == 0x02 && attrID == 0x00069005 && attrLen > 1000 {
|
||||
mapiData := rawData[dataStart : dataStart+attrLen]
|
||||
|
||||
// Parse MAPI props to find PR_ATTACH_DATA_OBJ (0x3701)
|
||||
embeddedData := extractPRAttachDataObj(mapiData)
|
||||
if embeddedData == nil {
|
||||
t.Fatal("could not find PR_ATTACH_DATA_OBJ")
|
||||
}
|
||||
|
||||
fmt.Printf("PR_ATTACH_DATA_OBJ total: %d bytes\n", len(embeddedData))
|
||||
fmt.Printf("First 32 bytes after GUID: %x\n", embeddedData[16:min2(48, len(embeddedData))])
|
||||
|
||||
// Check if after the 16-byte GUID there's a TNEF signature
|
||||
afterGuid := embeddedData[16:]
|
||||
if len(afterGuid) >= 4 {
|
||||
sig := binary.LittleEndian.Uint32(afterGuid[0:4])
|
||||
fmt.Printf("Signature after GUID: 0x%08X (TNEF=0x223E9F78)\n", sig)
|
||||
|
||||
if sig == 0x223E9F78 {
|
||||
fmt.Println("It's a nested TNEF stream!")
|
||||
decoded, err := tnef.Decode(afterGuid)
|
||||
if err != nil {
|
||||
fmt.Printf("Nested TNEF decode error: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Nested Body: %d bytes\n", len(decoded.Body))
|
||||
fmt.Printf("Nested BodyHTML: %d bytes\n", len(decoded.BodyHTML))
|
||||
fmt.Printf("Nested Attachments: %d\n", len(decoded.Attachments))
|
||||
for i, na := range decoded.Attachments {
|
||||
fmt.Printf(" [%d] %q (%d bytes)\n", i, na.Title, len(na.Data))
|
||||
}
|
||||
fmt.Printf("Nested Attributes: %d\n", len(decoded.Attributes))
|
||||
}
|
||||
} else {
|
||||
// Try as raw MAPI attributes (no TNEF wrapper)
|
||||
fmt.Printf("Not a TNEF stream. First byte: 0x%02X\n", afterGuid[0])
|
||||
// Check if it's a count of MAPI properties
|
||||
if len(afterGuid) >= 4 {
|
||||
propCount := binary.LittleEndian.Uint32(afterGuid[0:4])
|
||||
fmt.Printf("First uint32 (possible prop count): %d\n", propCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
offset += 9 + attrLen + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractPRAttachDataObj(mapiData []byte) []byte {
|
||||
if len(mapiData) < 4 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(mapiData[0:4]))
|
||||
offset := 4
|
||||
|
||||
for i := 0; i < count && offset+4 <= len(mapiData); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
offset += 4
|
||||
|
||||
// Handle named props
|
||||
if propID >= 0x8000 {
|
||||
if offset+20 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20])
|
||||
offset += 20
|
||||
if kind == 0 {
|
||||
offset += 4
|
||||
} else {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + nameLen
|
||||
if nameLen%4 != 0 {
|
||||
offset += 4 - nameLen%4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch propType {
|
||||
case 0x0002: // PT_SHORT
|
||||
offset += 4
|
||||
case 0x0003: // PT_LONG
|
||||
offset += 4
|
||||
case 0x000B: // PT_BOOLEAN
|
||||
offset += 4
|
||||
case 0x0040: // PT_SYSTIME
|
||||
offset += 8
|
||||
case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + slen
|
||||
if slen%4 != 0 {
|
||||
offset += 4 - slen%4
|
||||
}
|
||||
}
|
||||
case 0x0102: // PT_BINARY
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
case 0x000D: // PT_OBJECT
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
if propID == 0x3701 {
|
||||
// This is PR_ATTACH_DATA_OBJ!
|
||||
return mapiData[offset : offset+olen]
|
||||
}
|
||||
offset += olen
|
||||
if olen%4 != 0 {
|
||||
offset += 4 - olen%4
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func min2(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
273
backend/utils/mail/tnef_diag7_test.go
Normal file
273
backend/utils/mail/tnef_diag7_test.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFRecursiveExtract(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
rawData, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Println("=== Level 0 (top TNEF) ===")
|
||||
atts, body := recursiveExtract(rawData, 0)
|
||||
fmt.Printf("\nTotal extracted attachments: %d\n", len(atts))
|
||||
for i, a := range atts {
|
||||
fmt.Printf(" [%d] %q (%d bytes)\n", i, a.Title, len(a.Data))
|
||||
}
|
||||
fmt.Printf("Body HTML len: %d\n", len(body))
|
||||
if len(body) > 0 && len(body) < 500 {
|
||||
fmt.Printf("Body: %s\n", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recursiveExtract(tnefData []byte, depth int) ([]*tnef.Attachment, string) {
|
||||
prefix := strings.Repeat(" ", depth)
|
||||
|
||||
decoded, err := tnef.Decode(tnefData)
|
||||
if err != nil {
|
||||
fmt.Printf("%sDecode error: %v\n", prefix, err)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Collect body
|
||||
bodyHTML := string(decoded.BodyHTML)
|
||||
bodyText := string(decoded.Body)
|
||||
|
||||
// Check for RTF body in attributes
|
||||
for _, attr := range decoded.Attributes {
|
||||
if attr.Name == 0x1009 {
|
||||
fmt.Printf("%sFound PR_RTF_COMPRESSED: %d bytes\n", prefix, len(attr.Data))
|
||||
}
|
||||
if attr.Name == 0x1000 {
|
||||
fmt.Printf("%sFound PR_BODY: %d bytes\n", prefix, len(attr.Data))
|
||||
if bodyText == "" {
|
||||
bodyText = string(attr.Data)
|
||||
}
|
||||
}
|
||||
if attr.Name == 0x1013 || attr.Name == 0x1035 {
|
||||
fmt.Printf("%sFound PR_BODY_HTML/PR_HTML: %d bytes\n", prefix, len(attr.Data))
|
||||
if bodyHTML == "" {
|
||||
bodyHTML = string(attr.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("%sAttachments: %d, Body: %d, BodyHTML: %d\n",
|
||||
prefix, len(decoded.Attachments), len(bodyText), len(bodyHTML))
|
||||
|
||||
var allAttachments []*tnef.Attachment
|
||||
|
||||
// Collect real attachments (skip placeholders)
|
||||
for _, a := range decoded.Attachments {
|
||||
if a.Title == "Untitled Attachment" && len(a.Data) < 200 {
|
||||
fmt.Printf("%sSkipping placeholder: %q (%d bytes)\n", prefix, a.Title, len(a.Data))
|
||||
continue
|
||||
}
|
||||
allAttachments = append(allAttachments, a)
|
||||
}
|
||||
|
||||
// Now scan for embedded messages in raw TNEF
|
||||
embeddedStreams := findEmbeddedTNEFStreams(tnefData)
|
||||
for i, stream := range embeddedStreams {
|
||||
fmt.Printf("%s--- Recursing into embedded message %d (%d bytes) ---\n", prefix, i, len(stream))
|
||||
subAtts, subBody := recursiveExtract(stream, depth+1)
|
||||
allAttachments = append(allAttachments, subAtts...)
|
||||
if bodyHTML == "" && subBody != "" {
|
||||
bodyHTML = subBody
|
||||
}
|
||||
}
|
||||
|
||||
if bodyHTML != "" {
|
||||
return allAttachments, bodyHTML
|
||||
}
|
||||
return allAttachments, bodyText
|
||||
}
|
||||
|
||||
func findEmbeddedTNEFStreams(tnefData []byte) [][]byte {
|
||||
var streams [][]byte
|
||||
|
||||
// Navigate through TNEF attributes
|
||||
offset := 6
|
||||
for offset+9 < len(tnefData) {
|
||||
level := tnefData[offset]
|
||||
attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5])
|
||||
attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9]))
|
||||
dataStart := offset + 9
|
||||
|
||||
if dataStart+attrLen > len(tnefData) {
|
||||
break
|
||||
}
|
||||
|
||||
// attAttachment (0x9005) at attachment level
|
||||
if level == 0x02 && attrID == 0x00069005 && attrLen > 100 {
|
||||
mapiData := tnefData[dataStart : dataStart+attrLen]
|
||||
embedded := extractPRAttachDataObj2(mapiData)
|
||||
if embedded != nil && len(embedded) > 22 {
|
||||
// Skip 16-byte GUID, check for TNEF signature
|
||||
afterGuid := embedded[16:]
|
||||
if len(afterGuid) >= 4 {
|
||||
sig := binary.LittleEndian.Uint32(afterGuid[0:4])
|
||||
if sig == 0x223E9F78 {
|
||||
streams = append(streams, afterGuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += 9 + attrLen + 2
|
||||
}
|
||||
return streams
|
||||
}
|
||||
|
||||
func extractPRAttachDataObj2(mapiData []byte) []byte {
|
||||
if len(mapiData) < 4 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(mapiData[0:4]))
|
||||
offset := 4
|
||||
|
||||
for i := 0; i < count && offset+4 <= len(mapiData); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(mapiData[offset : offset+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
offset += 4
|
||||
|
||||
if propID >= 0x8000 {
|
||||
if offset+20 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(mapiData[offset+16 : offset+20])
|
||||
offset += 20
|
||||
if kind == 0 {
|
||||
offset += 4
|
||||
} else {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + nameLen
|
||||
if nameLen%4 != 0 {
|
||||
offset += 4 - nameLen%4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch propType {
|
||||
case 0x0002:
|
||||
offset += 4
|
||||
case 0x0003:
|
||||
offset += 4
|
||||
case 0x000B:
|
||||
offset += 4
|
||||
case 0x0040:
|
||||
offset += 8
|
||||
case 0x001E, 0x001F:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
slen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + slen
|
||||
if slen%4 != 0 {
|
||||
offset += 4 - slen%4
|
||||
}
|
||||
}
|
||||
case 0x0102:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
case 0x000D:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
olen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
if propID == 0x3701 {
|
||||
return mapiData[offset : offset+olen]
|
||||
}
|
||||
offset += olen
|
||||
if olen%4 != 0 {
|
||||
offset += 4 - olen%4
|
||||
}
|
||||
}
|
||||
case 0x1003:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + cnt*4
|
||||
case 0x1102:
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if offset+4 > len(mapiData) {
|
||||
return nil
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(mapiData[offset : offset+4]))
|
||||
offset += 4 + blen
|
||||
if blen%4 != 0 {
|
||||
offset += 4 - blen%4
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
97
backend/utils/mail/tnef_diag8_test.go
Normal file
97
backend/utils/mail/tnef_diag8_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFDeepAttachment(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
outerEmail, _ := Parse(f)
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
innerEmail, _ := Parse(bytes.NewReader(innerData))
|
||||
for _, att := range innerEmail.Attachments {
|
||||
rawData, _ := io.ReadAll(att.Data)
|
||||
if strings.ToLower(att.Filename) != "winmail.dat" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Dig to level 2: top → embedded[0] → embedded[0]
|
||||
streams0 := findEmbeddedTNEFStreams(rawData)
|
||||
if len(streams0) == 0 {
|
||||
t.Fatal("no embedded streams at level 0")
|
||||
}
|
||||
streams1 := findEmbeddedTNEFStreams(streams0[0])
|
||||
if len(streams1) == 0 {
|
||||
t.Fatal("no embedded streams at level 1")
|
||||
}
|
||||
|
||||
// Decode level 2
|
||||
decoded2, err := tnef.Decode(streams1[0])
|
||||
if err != nil {
|
||||
t.Fatalf("level 2 decode: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Level 2 attachments: %d\n", len(decoded2.Attachments))
|
||||
for i, a := range decoded2.Attachments {
|
||||
fmt.Printf(" [%d] title=%q size=%d\n", i, a.Title, len(a.Data))
|
||||
if len(a.Data) > 20 {
|
||||
fmt.Printf(" first 20 bytes: %x\n", a.Data[:20])
|
||||
// Check for EML, MSG, TNEF signatures
|
||||
if len(a.Data) >= 4 {
|
||||
sig := binary.LittleEndian.Uint32(a.Data[0:4])
|
||||
if sig == 0x223E9F78 {
|
||||
fmt.Println(" -> TNEF stream!")
|
||||
}
|
||||
}
|
||||
if len(a.Data) >= 8 && bytes.Equal(a.Data[:8], []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}) {
|
||||
fmt.Println(" -> MSG (OLE2) file!")
|
||||
}
|
||||
// Check if text/EML
|
||||
if a.Data[0] < 128 && a.Data[0] >= 32 {
|
||||
preview := string(a.Data[:min2(200, len(a.Data))])
|
||||
if strings.Contains(preview, "From:") || strings.Contains(preview, "Content-Type") || strings.Contains(preview, "MIME") || strings.Contains(preview, "Received:") {
|
||||
fmt.Printf(" -> Looks like an EML file! First 200 chars: %s\n", preview)
|
||||
} else {
|
||||
fmt.Printf(" -> Text data: %.200s\n", preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check level 2's attAttachment for embedded msgs
|
||||
streams2 := findEmbeddedTNEFStreams(streams1[0])
|
||||
fmt.Printf("\nLevel 2 embedded TNEF streams: %d\n", len(streams2))
|
||||
|
||||
// Check all MAPI attributes at level 2
|
||||
fmt.Println("\nLevel 2 MAPI attributes:")
|
||||
for _, attr := range decoded2.Attributes {
|
||||
fmt.Printf(" 0x%04X: %d bytes\n", attr.Name, len(attr.Data))
|
||||
// PR_BODY
|
||||
if attr.Name == 0x1000 && len(attr.Data) < 500 {
|
||||
fmt.Printf(" PR_BODY: %s\n", string(attr.Data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
backend/utils/mail/tnef_diag_test.go
Normal file
79
backend/utils/mail/tnef_diag_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
func TestTNEFDiag(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
f, _ := os.Open(testFile)
|
||||
defer f.Close()
|
||||
|
||||
// Parse the PEC outer envelope
|
||||
outerEmail, err := Parse(f)
|
||||
if err != nil {
|
||||
t.Fatalf("parse outer: %v", err)
|
||||
}
|
||||
|
||||
// Find postacert.eml
|
||||
var innerData []byte
|
||||
for _, att := range outerEmail.Attachments {
|
||||
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
|
||||
innerData, _ = io.ReadAll(att.Data)
|
||||
break
|
||||
}
|
||||
}
|
||||
if innerData == nil {
|
||||
t.Fatal("no postacert.eml found")
|
||||
}
|
||||
|
||||
// Parse inner email
|
||||
innerEmail, err := Parse(bytes.NewReader(innerData))
|
||||
if err != nil {
|
||||
t.Fatalf("parse inner: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Inner attachments: %d\n", len(innerEmail.Attachments))
|
||||
for i, att := range innerEmail.Attachments {
|
||||
data, _ := io.ReadAll(att.Data)
|
||||
fmt.Printf(" [%d] filename=%q contentType=%q size=%d\n", i, att.Filename, att.ContentType, len(data))
|
||||
|
||||
if strings.ToLower(att.Filename) == "winmail.dat" ||
|
||||
strings.Contains(strings.ToLower(att.ContentType), "ms-tnef") {
|
||||
|
||||
fmt.Printf(" Found TNEF! First 20 bytes: %x\n", data[:min(20, len(data))])
|
||||
fmt.Printf(" isTNEFData: %v\n", isTNEFData(data))
|
||||
|
||||
decoded, err := tnef.Decode(data)
|
||||
if err != nil {
|
||||
fmt.Printf(" TNEF decode error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" TNEF Body len: %d\n", len(decoded.Body))
|
||||
fmt.Printf(" TNEF BodyHTML len: %d\n", len(decoded.BodyHTML))
|
||||
fmt.Printf(" TNEF Attachments: %d\n", len(decoded.Attachments))
|
||||
for j, ta := range decoded.Attachments {
|
||||
fmt.Printf(" [%d] title=%q size=%d\n", j, ta.Title, len(ta.Data))
|
||||
}
|
||||
fmt.Printf(" TNEF Attributes: %d\n", len(decoded.Attributes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
444
backend/utils/mail/tnef_reader.go
Normal file
444
backend/utils/mail/tnef_reader.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/teamwork/tnef"
|
||||
)
|
||||
|
||||
// tnefMagic is the TNEF file signature (little-endian 0x223E9F78).
|
||||
var tnefMagic = []byte{0x78, 0x9F, 0x3E, 0x22}
|
||||
|
||||
const maxTNEFDepth = 10
|
||||
|
||||
// isTNEFData returns true if the given byte slice starts with the TNEF magic number.
|
||||
func isTNEFData(data []byte) bool {
|
||||
return len(data) >= 4 &&
|
||||
data[0] == tnefMagic[0] &&
|
||||
data[1] == tnefMagic[1] &&
|
||||
data[2] == tnefMagic[2] &&
|
||||
data[3] == tnefMagic[3]
|
||||
}
|
||||
|
||||
// isTNEFAttachment returns true if an attachment is a TNEF-encoded winmail.dat.
|
||||
// Detection is based on filename, content-type, or the TNEF magic bytes.
|
||||
func isTNEFAttachment(att EmailAttachment) bool {
|
||||
filenameLower := strings.ToLower(att.Filename)
|
||||
if filenameLower == "winmail.dat" {
|
||||
return true
|
||||
}
|
||||
ctLower := strings.ToLower(att.ContentType)
|
||||
if strings.Contains(ctLower, "application/ms-tnef") ||
|
||||
strings.Contains(ctLower, "application/vnd.ms-tnef") {
|
||||
return true
|
||||
}
|
||||
return isTNEFData(att.Data)
|
||||
}
|
||||
|
||||
// extractTNEFAttachments decodes a TNEF blob and returns the files embedded
|
||||
// inside it, recursively following nested embedded MAPI messages.
|
||||
func extractTNEFAttachments(data []byte) ([]EmailAttachment, error) {
|
||||
return extractTNEFRecursive(data, 0)
|
||||
}
|
||||
|
||||
func extractTNEFRecursive(data []byte, depth int) ([]EmailAttachment, error) {
|
||||
if depth > maxTNEFDepth {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
decoded, err := tnef.Decode(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var attachments []EmailAttachment
|
||||
|
||||
// Collect non-placeholder file attachments from the library output.
|
||||
for _, att := range decoded.Attachments {
|
||||
if len(att.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
// Skip the small MAPI placeholder text ("L'allegato è un messaggio
|
||||
// incorporato MAPI 1.0...") that Outlook inserts for embedded messages.
|
||||
if isEmbeddedMsgPlaceholder(att) {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := att.Title
|
||||
if filename == "" || filename == "Untitled Attachment" {
|
||||
filename = inferFilename(att.Data)
|
||||
}
|
||||
|
||||
attachments = append(attachments, EmailAttachment{
|
||||
Filename: filename,
|
||||
ContentType: mimeTypeFromFilename(filename),
|
||||
Data: att.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// Recursively dig into embedded MAPI messages stored in
|
||||
// attAttachment (0x9005) → PR_ATTACH_DATA_OBJ (0x3701).
|
||||
for _, stream := range findEmbeddedTNEFStreamsFromRaw(data) {
|
||||
subAtts, _ := extractTNEFRecursive(stream, depth+1)
|
||||
attachments = append(attachments, subAtts...)
|
||||
}
|
||||
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
// isEmbeddedMsgPlaceholder returns true if the attachment is a tiny placeholder
|
||||
// that Outlook generates for embedded MAPI messages ("L'allegato è un messaggio
|
||||
// incorporato MAPI 1.0" or equivalent in other languages).
|
||||
func isEmbeddedMsgPlaceholder(att *tnef.Attachment) bool {
|
||||
if len(att.Data) > 300 {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(string(att.Data))
|
||||
return strings.Contains(lower, "mapi 1.0") ||
|
||||
strings.Contains(lower, "embedded message") ||
|
||||
strings.Contains(lower, "messaggio incorporato")
|
||||
}
|
||||
|
||||
// inferFilename picks a reasonable filename based on the data's magic bytes.
|
||||
func inferFilename(data []byte) string {
|
||||
if looksLikeEML(data) {
|
||||
return "embedded_message.eml"
|
||||
}
|
||||
if isTNEFData(data) {
|
||||
return "embedded.dat"
|
||||
}
|
||||
if len(data) >= 8 {
|
||||
if data[0] == 0xD0 && data[1] == 0xCF && data[2] == 0x11 && data[3] == 0xE0 {
|
||||
return "embedded_message.msg"
|
||||
}
|
||||
}
|
||||
return "attachment.dat"
|
||||
}
|
||||
|
||||
// looksLikeEML returns true if data starts with typical RFC 5322 headers.
|
||||
func looksLikeEML(data []byte) bool {
|
||||
if len(data) < 20 {
|
||||
return false
|
||||
}
|
||||
// Quick check: must start with printable ASCII
|
||||
if data[0] < 32 || data[0] > 126 {
|
||||
return false
|
||||
}
|
||||
prefix := strings.ToLower(string(data[:min(200, len(data))]))
|
||||
return strings.HasPrefix(prefix, "mime-version:") ||
|
||||
strings.HasPrefix(prefix, "from:") ||
|
||||
strings.HasPrefix(prefix, "received:") ||
|
||||
strings.HasPrefix(prefix, "date:") ||
|
||||
strings.HasPrefix(prefix, "content-type:") ||
|
||||
strings.HasPrefix(prefix, "return-path:")
|
||||
}
|
||||
|
||||
// expandTNEFAttachments iterates over the attachment list and replaces any
|
||||
// TNEF-encoded winmail.dat entries with the files they contain. Attachments
|
||||
// that are not TNEF are passed through unchanged.
|
||||
func expandTNEFAttachments(attachments []EmailAttachment) []EmailAttachment {
|
||||
var result []EmailAttachment
|
||||
for _, att := range attachments {
|
||||
if isTNEFAttachment(att) {
|
||||
extracted, err := extractTNEFAttachments(att.Data)
|
||||
if err == nil && len(extracted) > 0 {
|
||||
result = append(result, extracted...)
|
||||
continue
|
||||
}
|
||||
// If extraction fails, keep the original blob.
|
||||
}
|
||||
result = append(result, att)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw TNEF attribute scanner — extracts nested TNEF streams from embedded
|
||||
// MAPI messages that the teamwork/tnef library does not handle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// findEmbeddedTNEFStreamsFromRaw scans the raw TNEF byte stream for
|
||||
// attAttachment (0x00069005) attribute blocks, parses their MAPI properties,
|
||||
// and extracts any PR_ATTACH_DATA_OBJ (0x3701) values that begin with a
|
||||
// TNEF signature.
|
||||
func findEmbeddedTNEFStreamsFromRaw(tnefData []byte) [][]byte {
|
||||
if len(tnefData) < 6 || !isTNEFData(tnefData) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var streams [][]byte
|
||||
offset := 6 // skip TNEF signature (4) + key (2)
|
||||
|
||||
for offset+9 < len(tnefData) {
|
||||
level := tnefData[offset]
|
||||
attrID := binary.LittleEndian.Uint32(tnefData[offset+1 : offset+5])
|
||||
attrLen := int(binary.LittleEndian.Uint32(tnefData[offset+5 : offset+9]))
|
||||
dataStart := offset + 9
|
||||
|
||||
if dataStart+attrLen > len(tnefData) || attrLen < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// attAttachment (0x00069005) at attachment level (0x02)
|
||||
if level == 0x02 && attrID == 0x00069005 && attrLen > 100 {
|
||||
mapiData := tnefData[dataStart : dataStart+attrLen]
|
||||
embedded := extractPRAttachDataObjFromMAPI(mapiData)
|
||||
if embedded != nil && len(embedded) > 22 {
|
||||
// Skip the 16-byte IID_IMessage GUID
|
||||
afterGuid := embedded[16:]
|
||||
if isTNEFData(afterGuid) {
|
||||
streams = append(streams, afterGuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// level(1) + id(4) + len(4) + data(attrLen) + checksum(2)
|
||||
offset += 9 + attrLen + 2
|
||||
}
|
||||
return streams
|
||||
}
|
||||
|
||||
// extractPRAttachDataObjFromMAPI parses a MAPI properties block (from an
|
||||
// attAttachment attribute) and returns the raw value of PR_ATTACH_DATA_OBJ
|
||||
// (property ID 0x3701, type PT_OBJECT 0x000D).
|
||||
func extractPRAttachDataObjFromMAPI(data []byte) []byte {
|
||||
if len(data) < 4 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(data[0:4]))
|
||||
off := 4
|
||||
|
||||
for i := 0; i < count && off+4 <= len(data); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(data[off : off+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
off += 4
|
||||
|
||||
// Named properties (ID >= 0x8000) have extra GUID + kind fields.
|
||||
if propID >= 0x8000 {
|
||||
if off+20 > len(data) {
|
||||
return nil
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(data[off+16 : off+20])
|
||||
off += 20
|
||||
if kind == 0 { // MNID_ID
|
||||
off += 4
|
||||
} else { // MNID_STRING
|
||||
if off+4 > len(data) {
|
||||
return nil
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4 + nameLen
|
||||
off += padTo4(nameLen)
|
||||
}
|
||||
}
|
||||
|
||||
off = skipMAPIPropValue(data, off, propType, propID)
|
||||
if off < 0 {
|
||||
return nil // parse error
|
||||
}
|
||||
// If skipMAPIPropValue returned a special sentinel, extract it.
|
||||
// We use a hack: skipMAPIPropValue can't return the data directly,
|
||||
// so we handle PT_OBJECT / 0x3701 inline below.
|
||||
}
|
||||
|
||||
// Simpler approach: re-scan specifically for 0x3701.
|
||||
return extractPRAttachDataObjDirect(data)
|
||||
}
|
||||
|
||||
// extractPRAttachDataObjDirect re-scans the MAPI property block and
|
||||
// returns the raw value of PR_ATTACH_DATA_OBJ (0x3701, PT_OBJECT).
|
||||
func extractPRAttachDataObjDirect(data []byte) []byte {
|
||||
if len(data) < 4 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(data[0:4]))
|
||||
off := 4
|
||||
|
||||
for i := 0; i < count && off+4 <= len(data); i++ {
|
||||
propTag := binary.LittleEndian.Uint32(data[off : off+4])
|
||||
propType := propTag & 0xFFFF
|
||||
propID := (propTag >> 16) & 0xFFFF
|
||||
off += 4
|
||||
|
||||
// Skip named property headers.
|
||||
if propID >= 0x8000 {
|
||||
if off+20 > len(data) {
|
||||
return nil
|
||||
}
|
||||
kind := binary.LittleEndian.Uint32(data[off+16 : off+20])
|
||||
off += 20
|
||||
if kind == 0 {
|
||||
off += 4
|
||||
} else {
|
||||
if off+4 > len(data) {
|
||||
return nil
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4 + nameLen
|
||||
off += padTo4(nameLen)
|
||||
}
|
||||
}
|
||||
|
||||
switch propType {
|
||||
case 0x0002: // PT_SHORT (padded to 4)
|
||||
off += 4
|
||||
case 0x0003, 0x000A: // PT_LONG, PT_ERROR
|
||||
off += 4
|
||||
case 0x000B: // PT_BOOLEAN (padded to 4)
|
||||
off += 4
|
||||
case 0x0004: // PT_FLOAT
|
||||
off += 4
|
||||
case 0x0005: // PT_DOUBLE
|
||||
off += 8
|
||||
case 0x0006: // PT_CURRENCY
|
||||
off += 8
|
||||
case 0x0007: // PT_APPTIME
|
||||
off += 8
|
||||
case 0x0014: // PT_I8
|
||||
off += 8
|
||||
case 0x0040: // PT_SYSTIME
|
||||
off += 8
|
||||
case 0x0048: // PT_CLSID
|
||||
off += 16
|
||||
case 0x001E, 0x001F: // PT_STRING8, PT_UNICODE
|
||||
off = skipCountedBlobs(data, off)
|
||||
case 0x0102: // PT_BINARY
|
||||
off = skipCountedBlobs(data, off)
|
||||
case 0x000D: // PT_OBJECT
|
||||
if off+4 > len(data) {
|
||||
return nil
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if off+4 > len(data) {
|
||||
return nil
|
||||
}
|
||||
olen := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4
|
||||
if propID == 0x3701 && off+olen <= len(data) {
|
||||
return data[off : off+olen]
|
||||
}
|
||||
off += olen
|
||||
off += padTo4(olen)
|
||||
}
|
||||
case 0x1002: // PT_MV_SHORT
|
||||
off = skipMVFixed(data, off, 4)
|
||||
case 0x1003: // PT_MV_LONG
|
||||
off = skipMVFixed(data, off, 4)
|
||||
case 0x1005: // PT_MV_DOUBLE
|
||||
off = skipMVFixed(data, off, 8)
|
||||
case 0x1014: // PT_MV_I8
|
||||
off = skipMVFixed(data, off, 8)
|
||||
case 0x1040: // PT_MV_SYSTIME
|
||||
off = skipMVFixed(data, off, 8)
|
||||
case 0x101E, 0x101F: // PT_MV_STRING8, PT_MV_UNICODE
|
||||
off = skipCountedBlobs(data, off)
|
||||
case 0x1048: // PT_MV_CLSID
|
||||
off = skipMVFixed(data, off, 16)
|
||||
case 0x1102: // PT_MV_BINARY
|
||||
off = skipCountedBlobs(data, off)
|
||||
default:
|
||||
// Unknown type, can't continue
|
||||
return nil
|
||||
}
|
||||
|
||||
if off < 0 || off > len(data) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// skipCountedBlobs advances past a MAPI value that stores count + N
|
||||
// length-prefixed blobs (used by PT_STRING8, PT_UNICODE, PT_BINARY, and
|
||||
// their multi-valued variants).
|
||||
func skipCountedBlobs(data []byte, off int) int {
|
||||
if off+4 > len(data) {
|
||||
return -1
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4
|
||||
for j := 0; j < cnt; j++ {
|
||||
if off+4 > len(data) {
|
||||
return -1
|
||||
}
|
||||
blen := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4 + blen
|
||||
off += padTo4(blen)
|
||||
}
|
||||
return off
|
||||
}
|
||||
|
||||
// skipMVFixed advances past a multi-valued fixed-size property
|
||||
// (count followed by count*elemSize bytes).
|
||||
func skipMVFixed(data []byte, off int, elemSize int) int {
|
||||
if off+4 > len(data) {
|
||||
return -1
|
||||
}
|
||||
cnt := int(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||
off += 4 + cnt*elemSize
|
||||
return off
|
||||
}
|
||||
|
||||
// skipMAPIPropValue is a generic value skipper (unused in the current flow
|
||||
// but kept for completeness).
|
||||
func skipMAPIPropValue(data []byte, off int, propType uint32, _ uint32) int {
|
||||
switch propType {
|
||||
case 0x0002:
|
||||
return off + 4
|
||||
case 0x0003, 0x000A, 0x000B, 0x0004:
|
||||
return off + 4
|
||||
case 0x0005, 0x0006, 0x0007, 0x0014, 0x0040:
|
||||
return off + 8
|
||||
case 0x0048:
|
||||
return off + 16
|
||||
case 0x001E, 0x001F, 0x0102, 0x000D:
|
||||
return skipCountedBlobs(data, off)
|
||||
case 0x1002, 0x1003:
|
||||
return skipMVFixed(data, off, 4)
|
||||
case 0x1005, 0x1014, 0x1040:
|
||||
return skipMVFixed(data, off, 8)
|
||||
case 0x1048:
|
||||
return skipMVFixed(data, off, 16)
|
||||
case 0x101E, 0x101F, 0x1102:
|
||||
return skipCountedBlobs(data, off)
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// padTo4 returns the number of padding bytes needed to reach a 4-byte boundary.
|
||||
func padTo4(n int) int {
|
||||
r := n % 4
|
||||
if r == 0 {
|
||||
return 0
|
||||
}
|
||||
return 4 - r
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MIME type helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mimeTypeFromFilename guesses the MIME type from a file extension.
|
||||
// Falls back to "application/octet-stream" when the type is unknown.
|
||||
func mimeTypeFromFilename(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if ext == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
t := mime.TypeByExtension(ext)
|
||||
if t == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
// Strip any parameters (e.g. "; charset=utf-8")
|
||||
if idx := strings.Index(t, ";"); idx != -1 {
|
||||
t = strings.TrimSpace(t[:idx])
|
||||
}
|
||||
return t
|
||||
}
|
||||
59
backend/utils/mail/tnef_reader_test.go
Normal file
59
backend/utils/mail/tnef_reader_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadEmlWithTNEF(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
// First try the PEC reader (this is a PEC email)
|
||||
email, err := ReadPecInnerEml(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPecInnerEml failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Subject: %s\n", email.Subject)
|
||||
fmt.Printf("From: %s\n", email.From)
|
||||
fmt.Printf("Attachment count: %d\n", len(email.Attachments))
|
||||
|
||||
hasWinmailDat := false
|
||||
for i, att := range email.Attachments {
|
||||
fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data))
|
||||
if att.Filename == "winmail.dat" {
|
||||
hasWinmailDat = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasWinmailDat {
|
||||
t.Error("winmail.dat should have been expanded into its contained attachments")
|
||||
}
|
||||
|
||||
if len(email.Attachments) == 0 {
|
||||
t.Error("expected at least one attachment after TNEF expansion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEmlFallback(t *testing.T) {
|
||||
testFile := `H:\Dev\Gits\EMLy\EML_TNEF.eml`
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Skip("test EML file not present")
|
||||
}
|
||||
|
||||
// Also verify the plain EML reader path
|
||||
email, err := ReadEmlFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEmlFile failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[EML] Subject: %s\n", email.Subject)
|
||||
fmt.Printf("[EML] Attachment count: %d\n", len(email.Attachments))
|
||||
for i, att := range email.Attachments {
|
||||
fmt.Printf(" [%d] %s (%s, %d bytes)\n", i, att.Filename, att.ContentType, len(att.Data))
|
||||
}
|
||||
}
|
||||
@@ -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=="],
|
||||
|
||||
@@ -1 +1 @@
|
||||
3c4a64d0cfb34e86fac16fceae842e43
|
||||
1697d40a08e09716b8c29ddebeabd1ad
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ReadEML,
|
||||
ReadMSG,
|
||||
ReadPEC,
|
||||
ReadAuto,
|
||||
DetectEmailFormat,
|
||||
ShowOpenFileDialog,
|
||||
SetCurrentMailFilePath,
|
||||
ConvertToUTF8,
|
||||
@@ -23,7 +25,8 @@ export interface LoadEmailResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the email file type from the path
|
||||
* Determines the email file type from the path extension (best-effort hint).
|
||||
* Use DetectEmailFormat (backend) for reliable format detection.
|
||||
*/
|
||||
export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
@@ -33,18 +36,57 @@ export function getEmailFileType(filePath: string): 'eml' | 'msg' | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file path is a valid email file
|
||||
* Checks if a file path looks like an email file by extension.
|
||||
* Returns true also for unknown extensions so the backend can attempt parsing.
|
||||
*/
|
||||
export function isEmailFile(filePath: string): boolean {
|
||||
return getEmailFileType(filePath) !== null;
|
||||
return filePath.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an email from a file path
|
||||
* Loads an email from a file path.
|
||||
* Uses ReadAuto so the backend detects the format from the file's binary
|
||||
* content, regardless of extension. Falls back to the legacy per-format
|
||||
* readers only when the caller explicitly requests them.
|
||||
*
|
||||
* @param filePath - Path to the email file
|
||||
* @returns LoadEmailResult with the email data or error
|
||||
*/
|
||||
export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResult> {
|
||||
if (!filePath?.trim()) {
|
||||
return { success: false, error: 'No file path provided.' };
|
||||
}
|
||||
|
||||
try {
|
||||
// ReadAuto detects the format (EML/PEC/MSG) by magic bytes and dispatches
|
||||
// to the appropriate reader. This works for any extension, including
|
||||
// unconventional ones like winmail.dat or no extension at all.
|
||||
const email = await ReadAuto(filePath);
|
||||
|
||||
// Process body if needed (decode base64)
|
||||
if (email?.body) {
|
||||
const trimmed = email.body.trim();
|
||||
if (looksLikeBase64(trimmed)) {
|
||||
const decoded = tryDecodeBase64(trimmed);
|
||||
if (decoded) {
|
||||
email.body = decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, email, filePath };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('Failed to load email:', error);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an email using the explicit per-format readers (legacy path).
|
||||
* Prefer loadEmailFromPath for new code.
|
||||
*/
|
||||
export async function loadEmailFromPathLegacy(filePath: string): Promise<LoadEmailResult> {
|
||||
const fileType = getEmailFileType(filePath);
|
||||
|
||||
if (!fileType) {
|
||||
@@ -60,7 +102,6 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
|
||||
if (fileType === 'msg') {
|
||||
email = await ReadMSG(filePath, true);
|
||||
} else {
|
||||
// Try PEC first, fall back to regular EML
|
||||
try {
|
||||
email = await ReadPEC(filePath);
|
||||
} catch {
|
||||
@@ -68,7 +109,6 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
|
||||
}
|
||||
}
|
||||
|
||||
// Process body if needed (decode base64)
|
||||
if (email?.body) {
|
||||
const trimmed = email.body.trim();
|
||||
if (looksLikeBase64(trimmed)) {
|
||||
@@ -79,18 +119,11 @@ export async function loadEmailFromPath(filePath: string): Promise<LoadEmailResu
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
email,
|
||||
filePath,
|
||||
};
|
||||
return { success: true, email, filePath };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('Failed to load email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
getEmailFileType,
|
||||
isEmailFile,
|
||||
loadEmailFromPath,
|
||||
loadEmailFromPathLegacy,
|
||||
openAndLoadEmail,
|
||||
processEmailBody,
|
||||
type LoadEmailResult,
|
||||
|
||||
44
frontend/src/lib/wailsjs/go/main/App.d.ts
vendored
44
frontend/src/lib/wailsjs/go/main/App.d.ts
vendored
@@ -1,15 +1,29 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {utils} from '../models';
|
||||
import {main} from '../models';
|
||||
import {utils} from '../models';
|
||||
import {internal} from '../models';
|
||||
|
||||
export function CheckForUpdates():Promise<main.UpdateStatus>;
|
||||
|
||||
export function CheckIsDefaultEMLHandler():Promise<boolean>;
|
||||
|
||||
export function ConvertToUTF8(arg1:string):Promise<string>;
|
||||
|
||||
export function CreateBugReportFolder():Promise<main.BugReportResult>;
|
||||
|
||||
export function DetectEmailFormat(arg1:string):Promise<string>;
|
||||
|
||||
export function DownloadUpdate():Promise<string>;
|
||||
|
||||
export function ExportSettings(arg1:string):Promise<string>;
|
||||
|
||||
export function FrontendLog(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function GetConfig():Promise<utils.Config>;
|
||||
|
||||
export function GetCurrentMailFilePath():Promise<string>;
|
||||
|
||||
export function GetImageViewerData():Promise<main.ImageViewerData>;
|
||||
|
||||
export function GetMachineData():Promise<utils.MachineInfo>;
|
||||
@@ -18,14 +32,26 @@ export function GetPDFViewerData():Promise<main.PDFViewerData>;
|
||||
|
||||
export function GetStartupFile():Promise<string>;
|
||||
|
||||
export function GetUpdateStatus():Promise<main.UpdateStatus>;
|
||||
|
||||
export function GetViewerData():Promise<main.ViewerData>;
|
||||
|
||||
export function ImportSettings():Promise<string>;
|
||||
|
||||
export function InstallUpdate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function InstallUpdateSilent():Promise<void>;
|
||||
|
||||
export function InstallUpdateSilentFromPath(arg1:string):Promise<void>;
|
||||
|
||||
export function IsDebuggerRunning():Promise<boolean>;
|
||||
|
||||
export function OpenDefaultAppsSettings():Promise<void>;
|
||||
|
||||
export function OpenEMLWindow(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function OpenFolderInExplorer(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenImage(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function OpenImageWindow(arg1:string,arg2:string):Promise<void>;
|
||||
@@ -34,8 +60,12 @@ export function OpenPDF(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function OpenPDFWindow(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function OpenURLInBrowser(arg1:string):Promise<void>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function ReadAuto(arg1:string):Promise<internal.EmailData>;
|
||||
|
||||
export function ReadEML(arg1:string):Promise<internal.EmailData>;
|
||||
|
||||
export function ReadMSG(arg1:string,arg2:boolean):Promise<internal.EmailData>;
|
||||
@@ -46,4 +76,16 @@ export function ReadPEC(arg1:string):Promise<internal.EmailData>;
|
||||
|
||||
export function SaveConfig(arg1:utils.Config):Promise<void>;
|
||||
|
||||
export function SaveScreenshot():Promise<string>;
|
||||
|
||||
export function SaveScreenshotAs():Promise<string>;
|
||||
|
||||
export function SetCurrentMailFilePath(arg1:string):Promise<void>;
|
||||
|
||||
export function SetUpdateCheckerEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function ShowOpenFileDialog():Promise<string>;
|
||||
|
||||
export function SubmitBugReport(arg1:main.BugReportInput):Promise<main.SubmitBugReportResult>;
|
||||
|
||||
export function TakeScreenshot():Promise<main.ScreenshotResult>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
go.mod
3
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
|
||||
|
||||
9
go.sum
9
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=
|
||||
|
||||
Reference in New Issue
Block a user