From 26dd0be0f4e2be5a37e0d0a0204170b4cf9fbf12 Mon Sep 17 00:00:00 2001 From: Raul Ernesto Guillen Hernandez <37725305+RauPro@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:55:51 -0600 Subject: [PATCH] fix(mailparse): decode base64 bodies and attachments without padding Share transfer decoding between text bodies and attachments, and fall back to RawStdEncoding after StdEncoding fails, matching piguard's tolerance. Keep the original bytes when both decoders reject the input. Cover missing one/two padding characters in plain text, HTML and binary attachments, MIME whitespace, and malformed-input fallback. Fixes #1063 Co-authored-by: Codex Signed-off-by: Raul Ernesto Guillen Hernandez <37725305+RauPro@users.noreply.github.com> --- internal/mailparse/attachments.go | 15 ++++--- internal/mailparse/attachments_test.go | 29 +++++++++++++ internal/mailparse/parse.go | 25 +----------- internal/mailparse/parse_test.go | 56 ++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 30 deletions(-) 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
"