diff --git a/internal/mailparse/attachments.go b/internal/mailparse/attachments.go index 6fe19769e..9e03686b4 100644 --- a/internal/mailparse/attachments.go +++ b/internal/mailparse/attachments.go @@ -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 diff --git a/internal/mailparse/attachments_test.go b/internal/mailparse/attachments_test.go index 7a8d5ec35..2251b0231 100644 --- a/internal/mailparse/attachments_test.go +++ b/internal/mailparse/attachments_test.go @@ -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. diff --git a/internal/mailparse/parse.go b/internal/mailparse/parse.go index 13f1cf799..11b0eacc5 100644 --- a/internal/mailparse/parse.go +++ b/internal/mailparse/parse.go @@ -15,11 +15,9 @@ package mailparse import ( "bytes" - "encoding/base64" "io" "mime" "mime/multipart" - "mime/quotedprintable" "net/mail" "regexp" "strings" @@ -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 diff --git a/internal/mailparse/parse_test.go b/internal/mailparse/parse_test.go index cdbbb3df1..e4c6f96c9 100644 --- a/internal/mailparse/parse_test.go +++ b/internal/mailparse/parse_test.go @@ -1,6 +1,7 @@ package mailparse import ( + "encoding/base64" "strings" "testing" "time" @@ -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", "

ABC

", "ABC"}, + {"html one padding character", "text/html", "

A

", "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
Hello
World
"