From 013a8cc08d0334e6abf0c6551a3949c4a2ac2573 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 9 Feb 2026 11:37:48 +0100 Subject: [PATCH 1/2] feat(utils): update email.go --- api/internal/client/mailer/mailer.go | 18 +- .../client/mailer/mailer_rfc2047_test.go | 355 ++++++++++++++++++ api/internal/utils/email.go | 92 +++++ 3 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 api/internal/client/mailer/mailer_rfc2047_test.go diff --git a/api/internal/client/mailer/mailer.go b/api/internal/client/mailer/mailer.go index a581509..ebd36a3 100644 --- a/api/internal/client/mailer/mailer.go +++ b/api/internal/client/mailer/mailer.go @@ -91,7 +91,14 @@ func (mailer Mailer) Send(to string, subject string, body string) error { } func (mailer Mailer) Reply(from string, name string, rcp model.Recipient, data []byte) error { - reader := bytes.NewReader(data) + // Preprocess email data to decode RFC 2047 encoded headers + processedData, err := utils.PreprocessEmailData(data) + if err != nil { + log.Printf("Warning: failed to preprocess email data: %v", err) + processedData = data // Fallback to original data + } + + reader := bytes.NewReader(processedData) email, err := letters.ParseEmail(reader) if err != nil { return err @@ -144,7 +151,14 @@ func (mailer Mailer) Reply(from string, name string, rcp model.Recipient, data [ } func (mailer Mailer) Forward(from string, name string, rcp model.Recipient, data []byte, templateFile string, templateData any, settings model.Settings) error { - reader := bytes.NewReader(data) + // Preprocess email data to decode RFC 2047 encoded headers + processedData, err := utils.PreprocessEmailData(data) + if err != nil { + log.Printf("Warning: failed to preprocess email data: %v", err) + processedData = data // Fallback to original data + } + + reader := bytes.NewReader(processedData) email, err := letters.ParseEmail(reader) if err != nil { return err diff --git a/api/internal/client/mailer/mailer_rfc2047_test.go b/api/internal/client/mailer/mailer_rfc2047_test.go new file mode 100644 index 0000000..746be7b --- /dev/null +++ b/api/internal/client/mailer/mailer_rfc2047_test.go @@ -0,0 +1,355 @@ +package mailer + +import ( + "bytes" + "net/mail" + "strings" + "testing" + + "github.com/mnako/letters" + "ivpn.net/email/api/internal/utils" +) + +func TestPreprocessEmailData_RFC2047Encoded(t *testing.T) { + tests := []struct { + name string + input string + expectError bool + checkFrom string + }{ + { + name: "RFC 2047 encoded display name with email", + input: `From: =?UTF-8?B?VGVzdCBVc2Vy?= +To: recipient@example.com +Subject: Test Subject + +Test body content`, + expectError: false, + checkFrom: "Test User ", + }, + { + name: "RFC 2047 encoded display name with special characters", + input: `From: =?UTF-8?B?aaabbbcccdddeee==?= +To: recipient@example.com +Subject: Test Subject + +Test body content`, + expectError: false, + }, + { + name: "Multiple RFC 2047 encoded headers", + input: `From: =?UTF-8?B?VGVzdCBVc2Vy?= +To: =?UTF-8?B?UmVjaXBpZW50?= +Subject: =?UTF-8?B?VGVzdCBTdWJqZWN0?= + +Test body content`, + expectError: false, + }, + { + name: "Plain text email without encoding", + input: `From: Test User +To: recipient@example.com +Subject: Test Subject + +Test body content`, + expectError: false, + checkFrom: "Test User ", + }, + { + name: "Mixed encoded and plain headers", + input: `From: =?UTF-8?Q?Test_User?= +To: plain@example.com +Subject: Normal Subject + +Test body content`, + expectError: false, + checkFrom: "Test User ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputData := []byte(strings.ReplaceAll(tt.input, "\n", "\r\n")) + + processedData, err := utils.PreprocessEmailData(inputData) + if (err != nil) != tt.expectError { + t.Errorf("preprocessEmailData() error = %v, expectError %v", err, tt.expectError) + return + } + + // Try to parse the processed data with standard mail.ReadMessage + msg, err := mail.ReadMessage(bytes.NewReader(processedData)) + if err != nil { + t.Errorf("Failed to parse processed email: %v", err) + return + } + + // Verify the From header can be parsed + fromHeader := msg.Header.Get("From") + if fromHeader == "" { + t.Error("From header is empty after preprocessing") + return + } + + // Try to parse the From address + fromAddr, err := mail.ParseAddress(fromHeader) + if err != nil { + t.Errorf("Failed to parse From address after preprocessing: %v", err) + t.Logf("From header value: %s", fromHeader) + return + } + + // If we have a specific expected From value, check it + if tt.checkFrom != "" && !strings.Contains(fromHeader, "=?") { + if fromHeader != tt.checkFrom { + // Allow for minor variations in formatting + if fromAddr.Address == "" { + t.Errorf("Expected From to be parseable, got error") + } + } + } + + t.Logf("Successfully parsed From: %s <%s>", fromAddr.Name, fromAddr.Address) + }) + } +} + +func TestPreprocessEmailData_PreservesBody(t *testing.T) { + input := `From: =?UTF-8?B?VGVzdCBVc2Vy?= +To: recipient@example.com +Subject: Test Subject +Content-Type: text/plain; charset=utf-8 + +This is a test body with multiple lines. +It should be preserved exactly as is. +Including special characters: ñ, ü, é` + + inputData := []byte(strings.ReplaceAll(input, "\n", "\r\n")) + + processedData, err := utils.PreprocessEmailData(inputData) + if err != nil { + t.Fatalf("utils.PreprocessEmailData() error = %v", err) + } + + msg, err := mail.ReadMessage(bytes.NewReader(processedData)) + if err != nil { + t.Fatalf("Failed to parse processed email: %v", err) + } + + // Read and verify body + bodyBuf := new(bytes.Buffer) + _, err = bodyBuf.ReadFrom(msg.Body) + if err != nil { + t.Fatalf("Failed to read body: %v", err) + } + + expectedBodyLines := []string{ + "This is a test body with multiple lines.", + "It should be preserved exactly as is.", + "Including special characters: ñ, ü, é", + } + + for _, line := range expectedBodyLines { + if !strings.Contains(bodyBuf.String(), line) { + t.Errorf("Body missing expected line: %s", line) + } + } +} + +func TestPreprocessEmailData_InvalidInput(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "Empty data", + input: "", + }, + { + name: "Invalid format", + input: "This is not a valid email", + }, + { + name: "Partial headers", + input: "From: test@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputData := []byte(tt.input) + + // Should not panic and should return the original data + processedData, err := utils.PreprocessEmailData(inputData) + if err != nil { + t.Logf("utils.PreprocessEmailData() returned error: %v", err) + } + + // Should return original data on error + if !bytes.Equal(processedData, inputData) { + t.Log("utils.PreprocessEmailData() returned different data, which is acceptable") + } + }) + } +} + +func TestCleanupMalformedEncodedAddress(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Malformed base64 with valid email", + input: "=?UTF-8?B?aaabbbcccdddeee==?= ", + expected: "user@example.com", + }, + { + name: "Valid encoded-word", + input: "Test User ", + expected: "Test User ", + }, + { + name: "Plain email without angle brackets", + input: "user@example.com", + expected: "user@example.com", + }, + { + name: "Email with display name", + input: "John Doe ", + expected: "John Doe ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := utils.CleanupMalformedEncodedAddress(tt.input) + + // The result should be parseable by mail.ParseAddress + _, err := mail.ParseAddress(result) + if err != nil { + t.Logf("Cleaned address: %s", result) + t.Logf("Parse error: %v", err) + // For malformed cases, we at least want the email part + if !strings.Contains(result, "@") { + t.Errorf("Result doesn't contain email address: %s", result) + } + } else { + t.Logf("Successfully parsed cleaned address: %s", result) + } + }) + } +} + +func TestPreprocessEmailData_MalformedEncoding(t *testing.T) { + // Test with the exact error case from the issue + input := `From: =?UTF-8?B?aaabbbcccdddeee==?= +To: recipient@example.com +Subject: Test Subject + +Test body content` + + inputData := []byte(strings.ReplaceAll(input, "\n", "\r\n")) + + processedData, err := utils.PreprocessEmailData(inputData) + if err != nil { + t.Fatalf("utils.PreprocessEmailData() error = %v", err) + } + + msg, err := mail.ReadMessage(bytes.NewReader(processedData)) + if err != nil { + t.Fatalf("Failed to parse processed email: %v", err) + } + + // Verify the From header can be parsed + fromHeader := msg.Header.Get("From") + if fromHeader == "" { + t.Fatal("From header is empty after preprocessing") + } + + t.Logf("From header after preprocessing: %s", fromHeader) + + // Try to parse the From address - this should not fail + fromAddr, err := mail.ParseAddress(fromHeader) + if err != nil { + t.Errorf("Failed to parse From address after preprocessing: %v", err) + t.Logf("From header value: %s", fromHeader) + } else { + t.Logf("Successfully parsed From: %s <%s>", fromAddr.Name, fromAddr.Address) + + // Verify we at least got the email address + if fromAddr.Address != "user@example.com" { + t.Errorf("Expected email address 'user@example.com', got '%s'", fromAddr.Address) + } + } +} + +func TestPreprocessEmailData_WithLettersParser(t *testing.T) { + // Test the full integration with letters.ParseEmail + tests := []struct { + name string + email string + expectError bool + }{ + { + name: "Malformed RFC 2047 encoding", + email: `From: =?UTF-8?B?aaabbbcccdddeee==?= +To: recipient@example.com +Subject: Test Subject +Content-Type: text/plain; charset=utf-8 + +This is a test email body.`, + expectError: false, + }, + { + name: "Valid RFC 2047 encoding", + email: `From: =?UTF-8?B?VGVzdCBVc2Vy?= +To: recipient@example.com +Subject: =?UTF-8?B?VGVzdCBTdWJqZWN0?= +Content-Type: text/plain; charset=utf-8 + +This is a test email body.`, + expectError: false, + }, + { + name: "Plain text headers", + email: `From: Test User +To: recipient@example.com +Subject: Test Subject +Content-Type: text/plain; charset=utf-8 + +This is a test email body.`, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + emailData := []byte(strings.ReplaceAll(tt.email, "\n", "\r\n")) + + // Preprocess the email + processedData, err := utils.PreprocessEmailData(emailData) + if err != nil { + t.Fatalf("utils.PreprocessEmailData() error = %v", err) + } + + // Try to parse with letters + reader := bytes.NewReader(processedData) + email, err := letters.ParseEmail(reader) + + if (err != nil) != tt.expectError { + t.Errorf("letters.ParseEmail() error = %v, expectError %v", err, tt.expectError) + return + } + + if err == nil { + t.Logf("Successfully parsed email with letters") + t.Logf(" Subject: %s", email.Headers.Subject) + if len(email.Headers.From) > 0 { + t.Logf(" From: %s <%s>", email.Headers.From[0].Name, email.Headers.From[0].Address) + } + t.Logf(" Text length: %d", len(email.Text)) + } + }) + } +} diff --git a/api/internal/utils/email.go b/api/internal/utils/email.go index b951fe7..4b38515 100644 --- a/api/internal/utils/email.go +++ b/api/internal/utils/email.go @@ -4,7 +4,10 @@ import ( "bytes" "crypto/rand" "fmt" + "io" "math/big" + "mime" + "net/mail" "regexp" "strings" "time" @@ -147,3 +150,92 @@ func cryptoRandInt(max int) (int, error) { } return int(nBig.Int64()), nil } + +// PreprocessEmailData decodes RFC 2047 encoded headers to fix parsing issues +// with email addresses containing encoded display names +func PreprocessEmailData(data []byte) ([]byte, error) { + msg, err := mail.ReadMessage(bytes.NewReader(data)) + if err != nil { + return data, nil // Return original data if it can't be parsed + } + + decoder := mime.WordDecoder{ + CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { + // Default charset handling + return input, nil + }, + } + + // Headers that commonly contain RFC 2047 encoded addresses + addressHeaders := []string{"From", "To", "Cc", "Bcc", "Reply-To", "Sender"} + + var buf bytes.Buffer + + // Write headers + for key := range msg.Header { + values := msg.Header[key] + for _, value := range values { + // Try to decode RFC 2047 encoded-words for address headers + needsDecoding := false + for _, addrHeader := range addressHeaders { + if strings.EqualFold(key, addrHeader) { + needsDecoding = true + break + } + } + + if needsDecoding && strings.Contains(value, "=?") { + // Decode the RFC 2047 encoded display name + decoded, err := decoder.DecodeHeader(value) + if err == nil { + value = decoded + } else { + // If decoding fails (e.g., malformed base64), try to clean it up + // Extract just the email address part if possible + value = CleanupMalformedEncodedAddress(value) + } + } + + buf.WriteString(key) + buf.WriteString(": ") + buf.WriteString(value) + buf.WriteString("\r\n") + } + } + + // Blank line between headers and body + buf.WriteString("\r\n") + + // Copy body + _, err = io.Copy(&buf, msg.Body) + if err != nil { + return data, nil // Return original data on error + } + + return buf.Bytes(), nil +} + +// CleanupMalformedEncodedAddress attempts to extract a valid email address +// from a malformed RFC 2047 encoded string +func CleanupMalformedEncodedAddress(addr string) string { + // Look for email address in angle brackets + if idx := strings.Index(addr, "<"); idx != -1 { + if endIdx := strings.Index(addr[idx:], ">"); endIdx != -1 { + email := addr[idx : idx+endIdx+1] + // Try to decode any remaining encoded-words before the email + before := addr[:idx] + + // Check if there's an encoded-word + if strings.Contains(before, "=?") { + // Remove the malformed encoded-word entirely + // and just use the email address + return strings.TrimSpace(email[1 : len(email)-1]) + } + + return strings.TrimSpace(before) + " " + email + } + } + + // If no angle brackets, return as-is + return addr +} From d2db6fe7398dd92971ac7aa24a2a383fe0282100 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 9 Feb 2026 14:20:55 +0100 Subject: [PATCH 2/2] feat(model): update msg.go --- api/internal/model/msg.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/api/internal/model/msg.go b/api/internal/model/msg.go index fc94cef..7d8a1dc 100644 --- a/api/internal/model/msg.go +++ b/api/internal/model/msg.go @@ -4,10 +4,13 @@ import ( "bytes" "fmt" "io" + "log" "mime" "mime/multipart" "net/mail" "strings" + + "ivpn.net/email/api/internal/utils" ) type Msg struct { @@ -20,7 +23,14 @@ type Msg struct { } func ParseMsg(data []byte) (Msg, error) { - msg, err := mail.ReadMessage(bytes.NewReader(data)) + // Preprocess email data to decode RFC 2047 encoded headers + processedData, err := utils.PreprocessEmailData(data) + if err != nil { + log.Printf("Warning: failed to preprocess email data: %v", err) + processedData = data // Fallback to original data + } + + msg, err := mail.ReadMessage(bytes.NewReader(processedData)) if err != nil { return Msg{}, err } @@ -57,7 +67,7 @@ func ParseMsg(data []byte) (Msg, error) { if isBounce(msg) { msgType = FailBounce - fromAddress, err = ExtractOriginalFrom(data) + fromAddress, err = ExtractOriginalFrom(processedData) if err != nil { return Msg{}, fmt.Errorf("extract original from bounce: %w", err) }