This commit is contained in:
Flavio Fois
2026-02-04 19:57:31 +01:00
parent 0d6157b2ff
commit 0cda0a26fc
25 changed files with 1549 additions and 66 deletions

View File

@@ -0,0 +1,121 @@
package internal
import (
"bytes"
"fmt"
"io"
"net/mail"
"os"
"strings"
"emly/backend/utils"
)
// ReadPecInnerEml reads the inner email (postacert.eml) from a PEC EML file.
// It opens the outer file, looks for the specific attachment, and parses it.
func ReadPecInnerEml(filePath string) (*EmailData, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// 1. Parse outer "Envelope"
outerEmail, err := utils.Parse(file)
if err != nil {
return nil, fmt.Errorf("failed to parse outer email: %w", err)
}
// 2. Look for the real content inside postacert.eml
var innerEmailData []byte
foundPec := false
for _, att := range outerEmail.Attachments {
// Standard PEC puts the real message in postacert.eml
// Using case-insensitive check and substring as per example
if strings.Contains(strings.ToLower(att.Filename), "postacert.eml") {
data, err := io.ReadAll(att.Data)
if err != nil {
return nil, fmt.Errorf("failed to read inner email content: %w", err)
}
innerEmailData = data
foundPec = true
break
}
}
if !foundPec {
return nil, fmt.Errorf("not a signed PEC or 'postacert.eml' attachment is missing")
}
// 3. Parse the inner EML content
innerEmail, err := utils.Parse(bytes.NewReader(innerEmailData))
if err != nil {
return nil, fmt.Errorf("failed to parse inner email structure: %w", err)
}
// Helper to format addresses (reused logic pattern from eml_reader.go)
formatAddress := func(addr []*mail.Address) []string {
var result []string
for _, a := range addr {
// convertToUTF8 is defined in eml_reader.go (same package)
result = append(result, convertToUTF8(a.String()))
}
return result
}
// Determine body (prefer HTML)
body := innerEmail.HTMLBody
if body == "" {
body = innerEmail.TextBody
}
// Process attachments of the inner email
var attachments []EmailAttachment
var hasDatiCert, hasSmime, hasInnerPecEmail bool
for _, att := range innerEmail.Attachments {
data, err := io.ReadAll(att.Data)
if err != nil {
continue
}
// Check internal flags for the inner email (recursive PEC check?)
filenameLower := strings.ToLower(att.Filename)
if filenameLower == "daticert.xml" {
hasDatiCert = true
}
if filenameLower == "smime.p7s" {
hasSmime = true
}
if strings.HasSuffix(filenameLower, ".eml") {
hasInnerPecEmail = true
}
attachments = append(attachments, EmailAttachment{
Filename: att.Filename,
ContentType: att.ContentType,
Data: data,
})
}
isPec := hasDatiCert && hasSmime
// Format From
var from string
if len(innerEmail.From) > 0 {
from = innerEmail.From[0].String()
}
return &EmailData{
From: convertToUTF8(from),
To: formatAddress(innerEmail.To),
Cc: formatAddress(innerEmail.Cc),
Bcc: formatAddress(innerEmail.Bcc),
Subject: convertToUTF8(innerEmail.Subject),
Body: convertToUTF8(body),
Attachments: attachments,
IsPec: isPec,
HasInnerEmail: hasInnerPecEmail,
}, nil
}

View File

@@ -5,9 +5,11 @@ import (
"io"
"net/mail"
"os"
"strings"
"unicode/utf8"
"github.com/DusanKasan/parsemail"
"emly/backend/utils"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
@@ -19,13 +21,15 @@ type EmailAttachment struct {
}
type EmailData struct {
From string `json:"from"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subject"`
Body string `json:"body"`
Attachments []EmailAttachment `json:"attachments"`
From string `json:"from"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subject"`
Body string `json:"body"`
Attachments []EmailAttachment `json:"attachments"`
IsPec bool `json:"isPec"`
HasInnerEmail bool `json:"hasInnerEmail"`
}
func ReadEmlFile(filePath string) (*EmailData, error) {
@@ -35,7 +39,7 @@ func ReadEmlFile(filePath string) (*EmailData, error) {
}
defer file.Close()
email, err := parsemail.Parse(file)
email, err := utils.Parse(file)
if err != nil {
return nil, fmt.Errorf("failed to parse email: %w", err)
}
@@ -55,13 +59,28 @@ func ReadEmlFile(filePath string) (*EmailData, error) {
body = email.TextBody
}
// Process attachments
// Process attachments and detect PEC
var attachments []EmailAttachment
var hasDatiCert, hasSmime, hasInnerEmail bool
for _, att := range email.Attachments {
data, err := io.ReadAll(att.Data)
if err != nil {
continue // Handle error or skip? Skipping for now.
}
// PEC Detection Logic
filenameLower := strings.ToLower(att.Filename)
if filenameLower == "daticert.xml" {
hasDatiCert = true
}
if filenameLower == "smime.p7s" {
hasSmime = true
}
if strings.HasSuffix(filenameLower, ".eml") {
hasInnerEmail = true
}
attachments = append(attachments, EmailAttachment{
Filename: att.Filename,
ContentType: att.ContentType,
@@ -69,6 +88,8 @@ func ReadEmlFile(filePath string) (*EmailData, error) {
})
}
isPec := hasDatiCert && hasSmime
// Format From
var from string
if len(email.From) > 0 {
@@ -76,13 +97,15 @@ func ReadEmlFile(filePath string) (*EmailData, error) {
}
return &EmailData{
From: convertToUTF8(from),
To: formatAddress(email.To),
Cc: formatAddress(email.Cc),
Bcc: formatAddress(email.Bcc),
Subject: convertToUTF8(email.Subject),
Body: convertToUTF8(body),
Attachments: attachments,
From: convertToUTF8(from),
To: formatAddress(email.To),
Cc: formatAddress(email.Cc),
Bcc: formatAddress(email.Bcc),
Subject: convertToUTF8(email.Subject),
Body: convertToUTF8(body),
Attachments: attachments,
IsPec: isPec,
HasInnerEmail: hasInnerEmail,
}, nil
}

View File

@@ -6,14 +6,18 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
)
var EMLDialogOptions = runtime.OpenDialogOptions{
Title: "Select EML file",
Filters: []runtime.FileFilter{{DisplayName: "EML Files (*.eml)", Pattern: "*.eml"}},
var EmailDialogOptions = runtime.OpenDialogOptions{
Title: "Select Email file",
Filters: []runtime.FileFilter{
{DisplayName: "Email Files (*.eml;*.msg)", Pattern: "*.eml;*.msg"},
{DisplayName: "EML Files (*.eml)", Pattern: "*.eml"},
{DisplayName: "MSG Files (*.msg)", Pattern: "*.msg"},
},
ShowHiddenFiles: false,
}
func ShowFileDialog(ctx context.Context) (string, error) {
filePath, err := runtime.OpenFileDialog(ctx, EMLDialogOptions)
filePath, err := runtime.OpenFileDialog(ctx, EmailDialogOptions)
if err != nil {
return "", err
}

View File

@@ -0,0 +1,261 @@
package internal
import (
"encoding/base64"
"fmt"
"io"
"os"
"strings"
"github.com/richardlehane/mscfb"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
// MAPI Property Tags
const (
prSubject = "0037"
prBody = "1000"
prBodyHTML = "1013"
prSenderName = "0C1A"
prSenderEmail = "0C1F"
prDisplayTo = "0E04" // Display list of To recipients
prDisplayCc = "0E03"
prDisplayBcc = "0E02"
prMessageHeaders = "007D"
prClientSubmitTime = "0039" // Date
prAttachLongFilename = "3707"
prAttachFilename = "3704"
prAttachData = "3701"
prAttachMimeTag = "370E"
)
// MAPI Property Types
const (
ptUnicode = "001F"
ptString8 = "001E"
ptBinary = "0102"
)
type msgParser struct {
reader *mscfb.Reader
props map[string][]byte
}
func parseMsgFile(filePath string) (*EmailData, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
doc, err := mscfb.New(f)
if err != nil {
return nil, err
}
email := &EmailData{
To: []string{},
Cc: []string{},
Bcc: []string{},
}
// We need to iterate through the entries to find properties and attachments
// Since mscfb is a sequential reader, we might need to be careful.
// However, usually properties are in streams.
// Strategy:
// 1. Read all streams into a map keyed by their path/name for easier access?
// MSG files can be large (attachments), so maybe not all.
// 2. Identify properties from their stream names directly.
// Simplified approach: scan for stream names matching our patterns.
// Better approach:
// The Root Entry has "properties".
// We need to detect if we are in an attachment storage.
// Since mscfb iterates flat (Post-Order?), we can track context?
// mscfb File struct provides Name and path.
attachmentsMap := make(map[string]*EmailAttachment)
for entry, err := doc.Next(); err == nil; entry, err = doc.Next() {
name := entry.Name
// Check if it's a property stream
if strings.HasPrefix(name, "__substg1.0_") {
path := entry.Path // Path is array of directory names
// Root properties
if len(path) == 0 { // In root
val, err := io.ReadAll(doc)
if err != nil {
continue
}
processRootProperty(name, val, email)
} else if strings.HasPrefix(path[len(path)-1], "__attach_version1.0_") {
// Attachment property
attachStorageName := path[len(path)-1]
if _, exists := attachmentsMap[attachStorageName]; !exists {
attachmentsMap[attachStorageName] = &EmailAttachment{}
}
val, err := io.ReadAll(doc)
if err != nil {
continue
}
processAttachProperty(name, val, attachmentsMap[attachStorageName])
}
}
}
// Finalize attachments
for _, att := range attachmentsMap {
if strings.Contains(strings.ToLower(att.ContentType), "multipart/signed") {
dataStr := string(att.Data)
// Check if it already looks like a plain text EML (contains typical headers)
if strings.Contains(dataStr, "Content-Type:") || strings.Contains(dataStr, "MIME-Version:") || strings.Contains(dataStr, "From:") {
if !strings.HasSuffix(strings.ToLower(att.Filename), ".eml") {
att.Filename += ".eml"
}
} else {
// Try to decode as Base64
// Clean up the base64 string: remove newlines and spaces
base64Str := strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
return -1
}
return r
}, dataStr)
// Try standard decoding
decoded, err := base64.StdEncoding.DecodeString(base64Str)
if err != nil {
// Try raw decoding (no padding)
decoded, err = base64.RawStdEncoding.DecodeString(base64Str)
}
if err == nil {
att.Data = decoded
if !strings.HasSuffix(strings.ToLower(att.Filename), ".eml") {
att.Filename += ".eml"
}
} else {
fmt.Println("Failed to decode multipart/signed attachment:", err)
}
}
}
if att.Filename == "" {
att.Filename = "attachment"
}
// Only add if we have data
if len(att.Data) > 0 {
email.Attachments = append(email.Attachments, *att)
}
}
return email, nil
}
func processRootProperty(name string, data []byte, email *EmailData) {
tag := name[12:16]
typ := name[16:20]
strVal := ""
if typ == ptUnicode {
strVal = decodeUTF16(data)
} else if typ == ptString8 {
strVal = string(data)
}
switch tag {
case prSubject:
email.Subject = strVal
case prBody:
if email.Body == "" { // Prefer body if not set
email.Body = strVal
}
case prBodyHTML:
email.Body = strVal // Prefer HTML
case prSenderName:
if email.From == "" {
email.From = strVal
} else {
email.From = fmt.Sprintf("%s <%s>", strVal, email.From)
}
case prSenderEmail:
if email.From == "" {
email.From = strVal
} else if !strings.Contains(email.From, "<") {
email.From = fmt.Sprintf("%s <%s>", email.From, strVal)
}
case prDisplayTo:
// Split by ; or similar if needed, but display string is usually one line
email.To = splitAndTrim(strVal)
case prDisplayCc:
email.Cc = splitAndTrim(strVal)
case prDisplayBcc:
email.Bcc = splitAndTrim(strVal)
case prClientSubmitTime:
// Date logic to be added if struct supports it
}
/*
if tag == prClientSubmitTime && typ == "0040" {
if len(data) >= 8 {
ft := binary.LittleEndian.Uint64(data)
t := time.Date(1601, 1, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(ft) * 100 * time.Nanosecond)
email.Date = t.Format(time.RFC1123Z)
}
}
*/
}
func processAttachProperty(name string, data []byte, att *EmailAttachment) {
tag := name[12:16]
typ := name[16:20]
strVal := ""
if typ == ptUnicode {
strVal = decodeUTF16(data)
} else if typ == ptString8 {
strVal = string(data)
}
switch tag {
case prAttachLongFilename:
att.Filename = strVal
case prAttachFilename:
if att.Filename == "" {
att.Filename = strVal
}
case prAttachMimeTag:
att.ContentType = strVal
case prAttachData:
att.Data = data
}
}
func decodeUTF16(b []byte) string {
decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder()
decoded, _, _ := transform.Bytes(decoder, b)
// Remove null terminators if present
return strings.TrimRight(string(decoded), "\x00")
}
func splitAndTrim(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ";")
var res []string
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
res = append(res, t)
}
}
return res
}

View File

@@ -0,0 +1,148 @@
package internal
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// ReadMsgFile reads a .msg file using the native Go parser.
func ReadMsgFile(filePath string) (*EmailData, error) {
return ReadMsgPecFile(filePath)
}
func OSSReadMsgFile(filePath string) (*EmailData, error) {
return parseMsgFile(filePath)
}
// parseSignedMsgExec executes 'signed_msg.exe' via cmd to convert a MSG to JSON,
// then processes the output to reconstruct the PEC email data.
func ReadMsgPecFile(filePath string) (*EmailData, error) {
fmt.Println("Called!")
// 1. Locate signed_msg.exe
exePath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
baseDir := filepath.Dir(exePath)
helperExe := filepath.Join(baseDir, "signed_msg.exe")
fmt.Println(helperExe)
// 2. Create temp file for JSON output
// Using generic temp file naming with timestamp
timestamp := time.Now().Format("20060102_150405")
tempFile, err := os.CreateTemp("", fmt.Sprintf("pec_output_%s_*.json", timestamp))
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
tempPath := tempFile.Name()
tempFile.Close() // Close immediately, exe will write to it
// defer os.Remove(tempPath) // Cleanup
// 3. Run signed_msg.exe <msgPath> <jsonPath>
// Use exec.Command
// Note: Command might need to be "cmd", "/C", ... but usually direct execution works on Windows
fmt.Println(helperExe, filePath, tempPath)
cmd := exec.Command(helperExe, filePath, tempPath)
// Hide window?
// cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} // Requires syscall import
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("signed_msg.exe failed: %s, output: %s", err, string(output))
}
// 4. Read JSON output
jsonData, err := os.ReadFile(tempPath)
if err != nil {
return nil, fmt.Errorf("failed to read json output: %w", err)
}
// 5. Parse JSON
var pecJson struct {
Subject string `json:"subject"`
From string `json:"from"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Body string `json:"body"`
HtmlBody string `json:"htmlBody"`
Attachments []struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
Data string `json:"data"` // Base64
DataFormat string `json:"dataFormat"` // "base64" (optional)
} `json:"attachments"`
}
if err := json.Unmarshal(jsonData, &pecJson); err != nil {
return nil, fmt.Errorf("failed to parse json output: %w", err)
}
// 6. Check for postacert.eml to determine if it is a PEC
var foundPostacert bool
var hasDatiCert, hasSmime bool
// We'll prepare attachments listing at the same time
var attachments []EmailAttachment
for _, att := range pecJson.Attachments {
attData, err := base64.StdEncoding.DecodeString(att.Data)
if err != nil {
fmt.Printf("Failed to decode attachment %s: %v\n", att.Filename, err)
continue
}
filenameLower := strings.ToLower(att.Filename)
if filenameLower == "postacert.eml" {
foundPostacert = true
}
if filenameLower == "daticert.xml" {
hasDatiCert = true
}
if filenameLower == "smime.p7s" {
hasSmime = true
}
attachments = append(attachments, EmailAttachment{
Filename: att.Filename,
ContentType: att.ContentType,
Data: attData,
})
}
if !foundPostacert {
// Maybe its a normal MSG, continue to try to parse it as a regular email
normalMsgEmail, err := parseMsgFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to find postacert.eml and also failed to parse as normal MSG: %w", err)
}
return normalMsgEmail, nil
}
// 7. It is a PEC. Return the outer message (wrapper)
// so the user can see the PEC envelope and attachments (postacert.eml, etc.)
body := pecJson.HtmlBody
if body == "" {
body = pecJson.Body
}
return &EmailData{
From: convertToUTF8(pecJson.From),
To: pecJson.To, // Assuming format is already correct or compatible
Cc: pecJson.Cc,
Bcc: pecJson.Bcc,
Subject: convertToUTF8(pecJson.Subject),
Body: convertToUTF8(body),
Attachments: attachments,
IsPec: hasDatiCert || hasSmime, // Typical PEC indicators
HasInnerEmail: foundPostacert,
}, nil
}