v1.2.2
This commit is contained in:
121
backend/utils/mail/eml_pec_reader.go
Normal file
121
backend/utils/mail/eml_pec_reader.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
261
backend/utils/mail/msg_parser.go
Normal file
261
backend/utils/mail/msg_parser.go
Normal 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
|
||||
}
|
||||
148
backend/utils/mail/msg_reader.go
Normal file
148
backend/utils/mail/msg_reader.go
Normal 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
|
||||
}
|
||||
512
backend/utils/mailparser.go
Normal file
512
backend/utils/mailparser.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const contentTypeMultipartMixed = "multipart/mixed"
|
||||
const contentTypeMultipartAlternative = "multipart/alternative"
|
||||
const contentTypeMultipartRelated = "multipart/related"
|
||||
const contentTypeTextHtml = "text/html"
|
||||
const contentTypeTextPlain = "text/plain"
|
||||
|
||||
// Parse an email message read from io.Reader into parsemail.Email struct
|
||||
func Parse(r io.Reader) (email Email, err error) {
|
||||
msg, err := mail.ReadMessage(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
email, err = createEmailFromHeader(msg.Header)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
email.ContentType = msg.Header.Get("Content-Type")
|
||||
contentType, params, err := parseContentType(email.ContentType)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch contentType {
|
||||
case contentTypeMultipartMixed, "multipart/signed":
|
||||
email.TextBody, email.HTMLBody, email.Attachments, email.EmbeddedFiles, err = parseMultipartMixed(msg.Body, params["boundary"])
|
||||
case contentTypeMultipartAlternative:
|
||||
email.TextBody, email.HTMLBody, email.EmbeddedFiles, err = parseMultipartAlternative(msg.Body, params["boundary"])
|
||||
case contentTypeMultipartRelated:
|
||||
email.TextBody, email.HTMLBody, email.EmbeddedFiles, err = parseMultipartRelated(msg.Body, params["boundary"])
|
||||
case contentTypeTextPlain:
|
||||
message, _ := ioutil.ReadAll(msg.Body)
|
||||
email.TextBody = strings.TrimSuffix(string(message[:]), "\n")
|
||||
case contentTypeTextHtml:
|
||||
message, _ := ioutil.ReadAll(msg.Body)
|
||||
email.HTMLBody = strings.TrimSuffix(string(message[:]), "\n")
|
||||
default:
|
||||
email.Content, err = decodeContent(msg.Body, msg.Header.Get("Content-Transfer-Encoding"))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func createEmailFromHeader(header mail.Header) (email Email, err error) {
|
||||
hp := headerParser{header: &header}
|
||||
|
||||
email.Subject = decodeMimeSentence(header.Get("Subject"))
|
||||
email.From = hp.parseAddressList(header.Get("From"))
|
||||
email.Sender = hp.parseAddress(header.Get("Sender"))
|
||||
email.ReplyTo = hp.parseAddressList(header.Get("Reply-To"))
|
||||
email.To = hp.parseAddressList(header.Get("To"))
|
||||
email.Cc = hp.parseAddressList(header.Get("Cc"))
|
||||
email.Bcc = hp.parseAddressList(header.Get("Bcc"))
|
||||
email.Date = hp.parseTime(header.Get("Date"))
|
||||
email.ResentFrom = hp.parseAddressList(header.Get("Resent-From"))
|
||||
email.ResentSender = hp.parseAddress(header.Get("Resent-Sender"))
|
||||
email.ResentTo = hp.parseAddressList(header.Get("Resent-To"))
|
||||
email.ResentCc = hp.parseAddressList(header.Get("Resent-Cc"))
|
||||
email.ResentBcc = hp.parseAddressList(header.Get("Resent-Bcc"))
|
||||
email.ResentMessageID = hp.parseMessageId(header.Get("Resent-Message-ID"))
|
||||
email.MessageID = hp.parseMessageId(header.Get("Message-ID"))
|
||||
email.InReplyTo = hp.parseMessageIdList(header.Get("In-Reply-To"))
|
||||
email.References = hp.parseMessageIdList(header.Get("References"))
|
||||
email.ResentDate = hp.parseTime(header.Get("Resent-Date"))
|
||||
|
||||
if hp.err != nil {
|
||||
err = hp.err
|
||||
return
|
||||
}
|
||||
|
||||
//decode whole header for easier access to extra fields
|
||||
//todo: should we decode? aren't only standard fields mime encoded?
|
||||
email.Header, err = decodeHeaderMime(header)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func parseContentType(contentTypeHeader string) (contentType string, params map[string]string, err error) {
|
||||
if contentTypeHeader == "" {
|
||||
contentType = contentTypeTextPlain
|
||||
return
|
||||
}
|
||||
|
||||
return mime.ParseMediaType(contentTypeHeader)
|
||||
}
|
||||
|
||||
func parseMultipartRelated(msg io.Reader, boundary string) (textBody, htmlBody string, embeddedFiles []EmbeddedFile, err error) {
|
||||
pmr := multipart.NewReader(msg, boundary)
|
||||
for {
|
||||
part, err := pmr.NextPart()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
contentType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
switch contentType {
|
||||
case contentTypeTextPlain:
|
||||
ppContent, err := ioutil.ReadAll(part)
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
textBody += strings.TrimSuffix(string(ppContent[:]), "\n")
|
||||
case contentTypeTextHtml:
|
||||
ppContent, err := ioutil.ReadAll(part)
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
htmlBody += strings.TrimSuffix(string(ppContent[:]), "\n")
|
||||
case contentTypeMultipartAlternative:
|
||||
tb, hb, ef, err := parseMultipartAlternative(part, params["boundary"])
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
htmlBody += hb
|
||||
textBody += tb
|
||||
embeddedFiles = append(embeddedFiles, ef...)
|
||||
default:
|
||||
if isEmbeddedFile(part) {
|
||||
ef, err := decodeEmbeddedFile(part)
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
embeddedFiles = append(embeddedFiles, ef)
|
||||
} else {
|
||||
return textBody, htmlBody, embeddedFiles, fmt.Errorf("Can't process multipart/related inner mime type: %s", contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
func parseMultipartAlternative(msg io.Reader, boundary string) (textBody, htmlBody string, embeddedFiles []EmbeddedFile, err error) {
|
||||
pmr := multipart.NewReader(msg, boundary)
|
||||
for {
|
||||
part, err := pmr.NextPart()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
contentType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
switch contentType {
|
||||
case contentTypeTextPlain:
|
||||
ppContent, err := ioutil.ReadAll(part)
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
textBody += strings.TrimSuffix(string(ppContent[:]), "\n")
|
||||
case contentTypeTextHtml:
|
||||
ppContent, err := ioutil.ReadAll(part)
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
htmlBody += strings.TrimSuffix(string(ppContent[:]), "\n")
|
||||
case contentTypeMultipartRelated:
|
||||
tb, hb, ef, err := parseMultipartRelated(part, params["boundary"])
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
htmlBody += hb
|
||||
textBody += tb
|
||||
embeddedFiles = append(embeddedFiles, ef...)
|
||||
default:
|
||||
if isEmbeddedFile(part) {
|
||||
ef, err := decodeEmbeddedFile(part)
|
||||
if err != nil {
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
embeddedFiles = append(embeddedFiles, ef)
|
||||
} else {
|
||||
return textBody, htmlBody, embeddedFiles, fmt.Errorf("Can't process multipart/alternative inner mime type: %s", contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textBody, htmlBody, embeddedFiles, err
|
||||
}
|
||||
|
||||
func parseMultipartMixed(msg io.Reader, boundary string) (textBody, htmlBody string, attachments []Attachment, embeddedFiles []EmbeddedFile, err error) {
|
||||
mr := multipart.NewReader(msg, boundary)
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return textBody, htmlBody, attachments, embeddedFiles, err
|
||||
}
|
||||
|
||||
contentType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return textBody, htmlBody, attachments, embeddedFiles, err
|
||||
}
|
||||
|
||||
if contentType == contentTypeMultipartMixed {
|
||||
textBody, htmlBody, attachments, embeddedFiles, err = parseMultipartMixed(part, params["boundary"])
|
||||
if err != nil {
|
||||
return textBody, htmlBody, attachments, embeddedFiles, err
|
||||
}
|
||||
} else if contentType == contentTypeMultipartAlternative {
|
||||
textBody, htmlBody, embeddedFiles, err = parseMultipartAlternative(part, params["boundary"])
|
||||
if err != nil {
|
||||
return textBody, htmlBody, attachments, embeddedFiles, err
|
||||
}
|
||||
} else if contentType == contentTypeMultipartRelated {
|
||||
textBody, htmlBody, embeddedFiles, err = parseMultipartRelated(part, params["boundary"])
|
||||
if err != nil {
|
||||
return textBody, htmlBody, attachments, embeddedFiles, err
|
||||
}
|
||||
} else if contentType == contentTypeTextPlain {
|
||||
ppContent, err := ioutil.ReadAll(part)
|
||||
if err != nil {
|
||||
return textBody, htmlBody, attachments, embeddedFiles, err
|
||||
}
|
||||
|
||||
textBody += strings.TrimSuffix(string(ppContent[:]), "\n")
|
||||
} else if contentType == contentTypeTextHtml {
|
||||
ppContent, err := ioutil.ReadAll(part)
|
||||
if err != nil {
|
||||
return textBody, htmlBody, attachments, embeddedFiles, err
|
||||
}
|
||||
|
||||
htmlBody += strings.TrimSuffix(string(ppContent[:]), "\n")
|
||||
} else if isAttachment(part) {
|
||||
at, err := decodeAttachment(part)
|
||||
if err != nil {
|
||||
return textBody, htmlBody, attachments, embeddedFiles, err
|
||||
}
|
||||
|
||||
attachments = append(attachments, at)
|
||||
} else {
|
||||
return textBody, htmlBody, attachments, embeddedFiles, fmt.Errorf("Unknown multipart/mixed nested mime type: %s", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
return textBody, htmlBody, attachments, embeddedFiles, err
|
||||
}
|
||||
|
||||
func decodeMimeSentence(s string) string {
|
||||
result := []string{}
|
||||
ss := strings.Split(s, " ")
|
||||
|
||||
for _, word := range ss {
|
||||
dec := new(mime.WordDecoder)
|
||||
w, err := dec.Decode(word)
|
||||
if err != nil {
|
||||
if len(result) == 0 {
|
||||
w = word
|
||||
} else {
|
||||
w = " " + word
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, w)
|
||||
}
|
||||
|
||||
return strings.Join(result, "")
|
||||
}
|
||||
|
||||
func decodeHeaderMime(header mail.Header) (mail.Header, error) {
|
||||
parsedHeader := map[string][]string{}
|
||||
|
||||
for headerName, headerData := range header {
|
||||
|
||||
parsedHeaderData := []string{}
|
||||
for _, headerValue := range headerData {
|
||||
parsedHeaderData = append(parsedHeaderData, decodeMimeSentence(headerValue))
|
||||
}
|
||||
|
||||
parsedHeader[headerName] = parsedHeaderData
|
||||
}
|
||||
|
||||
return mail.Header(parsedHeader), nil
|
||||
}
|
||||
|
||||
func isEmbeddedFile(part *multipart.Part) bool {
|
||||
return part.Header.Get("Content-Transfer-Encoding") != ""
|
||||
}
|
||||
|
||||
func decodeEmbeddedFile(part *multipart.Part) (ef EmbeddedFile, err error) {
|
||||
cid := decodeMimeSentence(part.Header.Get("Content-Id"))
|
||||
decoded, err := decodeContent(part, part.Header.Get("Content-Transfer-Encoding"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ef.CID = strings.Trim(cid, "<>")
|
||||
ef.Data = decoded
|
||||
ef.ContentType = part.Header.Get("Content-Type")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func isAttachment(part *multipart.Part) bool {
|
||||
return part.FileName() != ""
|
||||
}
|
||||
|
||||
func decodeAttachment(part *multipart.Part) (at Attachment, err error) {
|
||||
filename := decodeMimeSentence(part.FileName())
|
||||
decoded, err := decodeContent(part, part.Header.Get("Content-Transfer-Encoding"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
at.Filename = filename
|
||||
at.Data = decoded
|
||||
at.ContentType = strings.Split(part.Header.Get("Content-Type"), ";")[0]
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func decodeContent(content io.Reader, encoding string) (io.Reader, error) {
|
||||
switch encoding {
|
||||
case "base64":
|
||||
decoded := base64.NewDecoder(base64.StdEncoding, content)
|
||||
b, err := ioutil.ReadAll(decoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewReader(b), nil
|
||||
case "7bit":
|
||||
dd, err := ioutil.ReadAll(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewReader(dd), nil
|
||||
case "8bit", "binary", "":
|
||||
dd, err := ioutil.ReadAll(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewReader(dd), nil
|
||||
case "quoted-printable":
|
||||
decoded := quotedprintable.NewReader(content)
|
||||
dd, err := ioutil.ReadAll(decoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewReader(dd), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown encoding: %s", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
type headerParser struct {
|
||||
header *mail.Header
|
||||
err error
|
||||
}
|
||||
|
||||
func (hp headerParser) parseAddress(s string) (ma *mail.Address) {
|
||||
if hp.err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.Trim(s, " \n") != "" {
|
||||
ma, hp.err = mail.ParseAddress(s)
|
||||
|
||||
return ma
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hp headerParser) parseAddressList(s string) (ma []*mail.Address) {
|
||||
if hp.err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Trim(s, " \n") != "" {
|
||||
ma, hp.err = mail.ParseAddressList(s)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (hp headerParser) parseTime(s string) (t time.Time) {
|
||||
if hp.err != nil || s == "" {
|
||||
return
|
||||
}
|
||||
|
||||
formats := []string{
|
||||
time.RFC1123Z,
|
||||
"Mon, 2 Jan 2006 15:04:05 -0700",
|
||||
time.RFC1123Z + " (MST)",
|
||||
"Mon, 2 Jan 2006 15:04:05 -0700 (MST)",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
t, hp.err = time.Parse(format, s)
|
||||
if hp.err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (hp headerParser) parseMessageId(s string) string {
|
||||
if hp.err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.Trim(s, "<> ")
|
||||
}
|
||||
|
||||
func (hp headerParser) parseMessageIdList(s string) (result []string) {
|
||||
if hp.err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, p := range strings.Split(s, " ") {
|
||||
if strings.Trim(p, " \n") != "" {
|
||||
result = append(result, hp.parseMessageId(p))
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Attachment with filename, content type and data (as a io.Reader)
|
||||
type Attachment struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
Data io.Reader
|
||||
}
|
||||
|
||||
// EmbeddedFile with content id, content type and data (as a io.Reader)
|
||||
type EmbeddedFile struct {
|
||||
CID string
|
||||
ContentType string
|
||||
Data io.Reader
|
||||
}
|
||||
|
||||
// Email with fields for all the headers defined in RFC5322 with it's attachments and
|
||||
type Email struct {
|
||||
Header mail.Header
|
||||
|
||||
Subject string
|
||||
Sender *mail.Address
|
||||
From []*mail.Address
|
||||
ReplyTo []*mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
Date time.Time
|
||||
MessageID string
|
||||
InReplyTo []string
|
||||
References []string
|
||||
|
||||
ResentFrom []*mail.Address
|
||||
ResentSender *mail.Address
|
||||
ResentTo []*mail.Address
|
||||
ResentDate time.Time
|
||||
ResentCc []*mail.Address
|
||||
ResentBcc []*mail.Address
|
||||
ResentMessageID string
|
||||
|
||||
ContentType string
|
||||
Content io.Reader
|
||||
|
||||
HTMLBody string
|
||||
TextBody string
|
||||
|
||||
Attachments []Attachment
|
||||
EmbeddedFiles []EmbeddedFile
|
||||
}
|
||||
Reference in New Issue
Block a user