Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions internal/mailparse/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,23 +147,26 @@ func trimContentID(raw string) string {
return strings.TrimSpace(s)
}

// decodeBytes reads a body applying its Content-Transfer-Encoding, returning raw
// bytes (binary-safe — unlike decode(), which is for text bodies).
// decodeBytes applies Content-Transfer-Encoding for both text bodies and
// attachments, preserving the original bytes if base64 decoding fails.
func decodeBytes(body io.Reader, cte string) []byte {
switch strings.ToLower(strings.TrimSpace(cte)) {
case "base64":
raw, _ := io.ReadAll(body)
// Be lenient with MIME whitespace and missing padding, as in piguard.
clean := strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
return -1
}
return r
}, string(raw))
dec, err := base64.StdEncoding.DecodeString(clean)
if err != nil {
return raw // not valid base64 — hand back what we have
if dec, err := base64.StdEncoding.DecodeString(clean); err == nil {
return dec
}
return dec
if dec, err := base64.RawStdEncoding.DecodeString(clean); err == nil {
return dec
}
return raw // not valid base64 — hand back what we have
case "quoted-printable":
b, _ := io.ReadAll(quotedprintable.NewReader(body))
return b
Expand Down
29 changes: 29 additions & 0 deletions internal/mailparse/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,35 @@ func TestAttachments_OrderAndDecode(t *testing.T) {
}
}

func TestAttachmentsBase64Padding(t *testing.T) {
for name, data := range map[string][]byte{
"two padding characters": {0x00, 0xff, 0x80, 0xfb},
"one padding character": {0x00, 0xff, 0x80, 0xfb, 0xff},
} {
for encodingName, encoding := range map[string]*base64.Encoding{
"padded": base64.StdEncoding,
"unpadded": base64.RawStdEncoding,
} {
t.Run(name+"/"+encodingName, func(t *testing.T) {
encoded := encoding.EncodeToString(data)
encoded = encoded[:4] + " \t\r\n" + encoded[4:]
raw := []byte("Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/plain\r\n\r\nBody\r\n" +
"--B\r\nContent-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"sample.bin\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" + encoded + "\r\n--B--\r\n")
atts := Attachments(raw)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if !bytes.Equal(atts[0].Data, data) {
t.Errorf("attachment bytes = %x, want %x", atts[0].Data, data)
}
})
}
}
}

// The inline image's Content-ID is captured (angle brackets stripped) so a
// renderer can resolve an HTML `cid:` reference to it; the ordinary PDF
// attachment carries none.
Expand Down
25 changes: 1 addition & 24 deletions internal/mailparse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@ package mailparse

import (
"bytes"
"encoding/base64"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"regexp"
"strings"
Expand Down Expand Up @@ -186,28 +184,7 @@ func walkParts(contentType, cte string, body io.Reader, depth int) (plain, htmlT
// decode reads a body applying its Content-Transfer-Encoding (base64 /
// quoted-printable / identity).
func decode(body io.Reader, cte string) string {
switch strings.ToLower(strings.TrimSpace(cte)) {
case "quoted-printable":
b, _ := io.ReadAll(quotedprintable.NewReader(body))
return string(b)
case "base64":
raw, _ := io.ReadAll(body)
// base64 bodies are line-wrapped; strip all whitespace then decode.
clean := strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
return -1
}
return r
}, string(raw))
dec, err := base64.StdEncoding.DecodeString(clean)
if err != nil {
return string(raw)
}
return string(dec)
default:
b, _ := io.ReadAll(body)
return string(b)
}
return string(decodeBytes(body, cte))
}

// HTMLToText renders an HTML body to readable plain text — the same rendition
Expand Down
56 changes: 56 additions & 0 deletions internal/mailparse/parse_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package mailparse

import (
"encoding/base64"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -118,6 +119,61 @@ func TestBase64Decoded(t *testing.T) {
}
}

func TestParseBase64Padding(t *testing.T) {
cases := []struct {
name, contentType, body, wantText string
}{
{"plain two padding characters", "text/plain", "test", "test"},
{"plain one padding character", "text/plain", "hello", "hello"},
{"html two padding characters", "text/html", "<p>ABC</p>", "ABC"},
{"html one padding character", "text/html", "<p>A</p>", "A"},
}
for _, tc := range cases {
for name, encoding := range map[string]*base64.Encoding{
"padded": base64.StdEncoding,
"unpadded": base64.RawStdEncoding,
} {
t.Run(tc.name+"/"+name, func(t *testing.T) {
encoded := encoding.EncodeToString([]byte(tc.body))
// MIME bodies can contain line breaks, spaces, and tabs.
encoded = encoded[:4] + " \t\r\n" + encoded[4:]
raw := "Content-Type: " + tc.contentType + "\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" + encoded
got := Parse([]byte(raw), 0)
if got.Text != tc.wantText || got.Truncated {
t.Errorf("Text = %q, Truncated = %v; want %q, false", got.Text, got.Truncated, tc.wantText)
}
wantHTML := ""
if tc.contentType == "text/html" {
wantHTML = tc.body
}
if got.HTML != wantHTML {
t.Errorf("HTML = %q, want %q", got.HTML, wantHTML)
}
})
}
}
}

func TestBase64InvalidPreservesRaw(t *testing.T) {
for name, raw := range map[string]string{
"invalid character after valid prefix": " \tZm9v$\r\n",
"impossible length": "Zm9vA",
"incomplete padding": "Zg=",
"extra padding": "Zg===",
"URL-safe alphabet": "-_8",
} {
t.Run(name, func(t *testing.T) {
if got := decode(strings.NewReader(raw), "base64"); got != raw {
t.Errorf("decode = %q, want original %q", got, raw)
}
if got := decodeBytes(strings.NewReader(raw), "base64"); string(got) != raw {
t.Errorf("decodeBytes = %q, want original %q", got, raw)
}
})
}
}

func TestParseHTML(t *testing.T) {
t.Run("text/html part surfaces decoded HTML, text flattened", func(t *testing.T) {
raw := "From: a@x.com\r\nContent-Type: text/html\r\n\r\n<div>Hello<br><b>World</b></div>"
Expand Down
Loading