smtp2shoutrrr/email_test.go
butterrobot 06de333118
All checks were successful
CI / goreleaser-lint (pull_request) Successful in 3s
CI / format (pull_request) Successful in 33s
CI / test (pull_request) Successful in 2m21s
CI / lint (pull_request) Successful in 3m6s
CI / build (pull_request) Successful in 3m13s
refactor: convert HTML with html-to-markdown instead of by hand (FMG-9)
The hand-written renderer is replaced by
github.com/JohannesKaufmann/html-to-markdown/v2 plus the mail-specific policy
it has no opinion about. 986 lines of html.go become 372; the conversion
itself — CommonMark escaping, delimiter runs, fencing a code block past the
backticks inside it — is now a maintained library's problem rather than ours.

The original justification for writing it by hand was that the library would
drag in goquery and its dependencies. That was true of v1 and wrong for v2,
which dropped it: the measured cost is two modules, html-to-markdown/v2 and
JohannesKaufmann/dom, on top of the golang.org/x/net this already used.

What the library does not know is mail, because it is written for documents.
The parsed message is prepared before conversion:

- Hidden preheaders, written for the inbox list, are removed.
- Images without alt text go, which takes the tracking pixels, spacers and
  sliced-up banners with them. An inline cid: attachment leaves its alt text
  behind as ordinary words.
- Destinations a reader cannot open are dropped and the link text kept; tabs
  and line breaks are stripped from the rest, since a line break inside an
  href is invisible in the document and a fabricated line in the message.
- Table rows become lines and their cells stay apart, which the converter has
  no rule for: "Total4Failed0" otherwise.
- A link left holding nothing but a pixel falls back to its own destination
  rather than rendering as an invisible "[](url)".
- Quote and list nesting is flattened past six levels, and the output is
  capped at 64 KiB with a marker.

That last one is not something any of the candidates solved. html-to-markdown
amplifies exactly as the hand-written renderer did before it was capped, from
the same cause — a line prefix re-emitted per line and per level. Measured on
one message at the server's own 1 MB limit, nested 250 deep: 131 MB of output
over 2m16s, against 64 KiB in 1.5s and 85 MiB of peak heap with the flattening
in place.

Format = "text" is dropped, leaving raw and markdown. Markdown reads as plain
text wherever nothing renders it, so a second conversion would only have been
a worse copy of this one, and the plain-text libraries surveyed were the weak
half of the field. Nothing has shipped with "text", so no released
configuration names it; an unknown Format is still refused at startup.

The test suite carries over almost unchanged, because it asserts output rather
than internals — which is what made the swap safe to judge. Every mail-policy
and injection case still holds, and the pathological-input test is sized from
the constants now so the suite stays quick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:09:46 +00:00

508 lines
14 KiB
Go

package smtp2shoutrrr
import (
"encoding/base64"
"net/mail"
"strings"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
)
func readMessage(t *testing.T, raw string) *mail.Message {
t.Helper()
msg, err := mail.ReadMessage(strings.NewReader(raw))
require.NoError(t, err)
return msg
}
// Regression: this header reached log.Fatalf, which exits the process. The
// value is attacker-controlled and needs nothing but the ability to deliver a
// message.
func TestBodyRejectsMalformedContentType(t *testing.T) {
for name, contentType := range map[string]string{
"parameter without a value": "text/plain; charset",
"duplicate parameter": "text/plain; charset=utf-8; charset=ascii",
"no media type": ";",
"unterminated quoted value": `multipart/mixed; boundary="unterminated`,
} {
t.Run(name, func(t *testing.T) {
email := ReceivedEmail{
Msg: readMessage(t, "Subject: Test\r\nContent-Type: "+contentType+"\r\n\r\nbody\r\n"),
}
body, err := email.Body()
require.Error(t, err)
require.ErrorIs(t, err, errMalformedMessage,
"an unparseable header is permanent, so the sender must not be told to retry")
require.Empty(t, body)
})
}
}
func TestBodyReadsMessageWithoutContentType(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, "Subject: Test\r\n\r\nplain body\r\n")}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "plain body\r\n", body)
}
func TestBodyPrefersPlainTextPartOverHTML(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/alternative; boundary="b"`,
"",
"--b",
"Content-Type: text/html; charset=utf-8",
"",
"<p>html body</p>",
"--b",
"Content-Type: text/plain; charset=utf-8",
"",
"plain body",
"--b--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "plain body", body)
}
func TestBodyFallsBackToHTMLPart(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/alternative; boundary="b"`,
"",
"--b",
"Content-Type: text/html; charset=utf-8",
"",
"<p>html body</p>",
"--b--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "<p>html body</p>", body)
}
func TestBodyDecodesQuotedPrintable(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: quoted-printable",
"",
"caf=C3=A9 =E2=80=94 a very long line that the sender wrapped =",
"here",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "café — a very long line that the sender wrapped here\r\n", body)
}
func TestBodyDecodesBase64(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: base64",
"",
base64.StdEncoding.EncodeToString([]byte("café — encoded")),
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "café — encoded", body)
}
// Windows-1252 and Latin-1 are still what a good deal of automated mail is
// written in, and their bytes are not valid UTF-8.
func TestBodyDecodesNonUTF8Charset(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t,
"Subject: Test\r\nContent-Type: text/plain; charset=iso-8859-1\r\n\r\ncaf\xe9\r\n")}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "café\r\n", body)
require.True(t, utf8.ValidString(body))
}
func TestBodyUnknownCharsetIsForwardedUndecoded(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t,
"Subject: Test\r\nContent-Type: text/plain; charset=not-a-charset\r\n\r\nbody\r\n")}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "body\r\n", body)
}
// A forwarded message is a multipart/mixed wrapping the multipart/alternative
// that holds what the sender actually wrote. Stopping at the outer container
// left the notification empty.
func TestBodyDescendsIntoNestedMultipart(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/mixed; boundary="outer"`,
"",
"--outer",
`Content-Type: multipart/alternative; boundary="inner"`,
"",
"--inner",
"Content-Type: text/html; charset=utf-8",
"",
"<p>html body</p>",
"--inner",
"Content-Type: text/plain; charset=utf-8",
"",
"plain body",
"--inner--",
"--outer--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "plain body", body)
}
func TestBodySkipsAttachments(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/mixed; boundary="b"`,
"",
"--b",
"Content-Type: text/plain; charset=utf-8",
`Content-Disposition: attachment; filename="notes.txt"`,
"",
"an enclosed file, not the message",
"--b",
"Content-Type: text/plain; charset=utf-8",
"",
"the message itself",
"--b--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "the message itself", body)
}
// Senders that build the HTML alternative from a template routinely emit an
// empty text/plain beside it.
func TestBodyPrefersHTMLOverAnEmptyPlainTextPart(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/alternative; boundary="b"`,
"",
"--b",
"Content-Type: text/plain; charset=utf-8",
"",
" ",
"--b",
"Content-Type: text/html; charset=utf-8",
"",
"<p>html body</p>",
"--b--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "<p>html body</p>", body)
}
func TestBodyRejectsMultipartWithoutBoundary(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t,
"Subject: Test\r\nContent-Type: multipart/alternative\r\n\r\nbody\r\n")}
_, err := email.Body()
require.ErrorIs(t, err, errMalformedMessage)
}
func TestFormattedBodyConvertsHTML(t *testing.T) {
raw := strings.Join([]string{
"Subject: Test",
"Content-Type: text/html; charset=utf-8",
"Content-Transfer-Encoding: quoted-printable",
"",
"<p>Build <b>failed</b>: <a href=3D\"https://ci.example.com/42\">run 42</a></p>",
"",
}, "\r\n")
for format, want := range map[BodyFormat]string{
FormatMarkdown: "Build **failed**: [run 42](https://ci.example.com/42)",
FormatRaw: "<p>Build <b>failed</b>: <a href=\"https://ci.example.com/42\">run 42</a></p>\r\n",
} {
t.Run(string(format), func(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, raw)}
body, err := email.FormattedBody(format)
require.NoError(t, err)
require.Equal(t, want, body)
})
}
}
// A recipient asking for Markdown is asking for HTML to stop reaching it, not
// for the plain text a sender wrote to be run through a renderer.
func TestFormattedBodyLeavesPlainTextAlone(t *testing.T) {
raw := "Subject: Test\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n2 * 3 = 6 <see notes>\r\n"
for _, format := range []BodyFormat{FormatRaw, FormatMarkdown} {
t.Run(string(format), func(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, raw)}
body, err := email.FormattedBody(format)
require.NoError(t, err)
require.Equal(t, "2 * 3 = 6 <see notes>\r\n", body)
})
}
}
// The zero value of the option means the same as raw, so a recipient built in
// code rather than loaded from a file still forwards what it received.
func TestFormattedBodyTreatsUnsetFormatAsRaw(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t,
"Subject: Test\r\nContent-Type: text/html\r\n\r\n<p>html body</p>\r\n")}
body, err := email.FormattedBody("")
require.NoError(t, err)
require.Equal(t, "<p>html body</p>\r\n", body)
}
// Reformatting is a courtesy, so a body the renderer cannot use is still
// forwarded rather than costing the reader the notification.
func TestFormattedBodyFallsBackToTheRawHTML(t *testing.T) {
// The parser refuses a document with more than 512 elements open at once.
tooDeep := strings.Repeat("<div>", 600) + "deep" + strings.Repeat("</div>", 600)
for name, source := range map[string]string{
"a body the parser refuses": tooDeep,
// Otherwise shoutrrr is handed "", which Mattermost and Discord
// reject, so every target fails and the sender retries the same
// message forever.
"a body that renders to nothing": `<img src="https://x.example/pixel.gif">`,
} {
t.Run(name, func(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t,
"Subject: Test\r\nContent-Type: text/html\r\n\r\n"+source)}
body, err := email.FormattedBody(FormatMarkdown)
require.NoError(t, err)
require.Equal(t, source, body)
})
}
}
func TestBodyIsReadOnlyOnce(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t,
"Subject: Test\r\nContent-Type: text/html\r\n\r\n<p>body</p>\r\n")}
first, err := email.FormattedBody(FormatMarkdown)
require.NoError(t, err)
second, err := email.FormattedBody(FormatMarkdown)
require.NoError(t, err)
require.Equal(t, "body", first)
require.Equal(t, first, second, "the message body is consumed as it is read")
}
// Regression: an unreadable transfer encoding used to be reported as a
// permanent 550, which tells the sender to stop retrying and loses a
// notification that main delivered. Real mailers emit unpadded base64.
func TestBodyToleratesAnUndecodableTransferEncoding(t *testing.T) {
for name, tc := range map[string]struct{ raw, want string }{
"unpadded base64": {
raw: base64.RawStdEncoding.EncodeToString([]byte("hello world")),
want: "hello world",
},
"base64 that is not base64 at all": {
raw: "!!!! not base64 !!!!",
want: "!!!! not base64 !!!!",
},
} {
t.Run(name, func(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t,
"Subject: Test\r\nContent-Type: text/plain\r\n"+
"Content-Transfer-Encoding: base64\r\n\r\n"+tc.raw+"\r\n")}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, tc.want, strings.TrimSpace(body))
})
}
}
// Regression: one part that would not decode used to fail the whole message,
// even with a perfectly good alternative already in hand.
func TestBodyKeepsAGoodPartBesideAnUnreadableOne(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/alternative; boundary="b"`,
"",
"--b",
"Content-Type: text/html",
"Content-Transfer-Encoding: base64",
"",
"!!!!not base64!!!!",
"--b",
"Content-Type: text/plain",
"",
"the real body",
"--b--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "the real body", body)
}
// Regression: mime.ParseMediaType returns a usable media type alongside the
// error it reports for a malformed parameter. Discarding both dropped the part
// that held the message and delivered an empty notification with a 250.
func TestBodyReadsPartsWithAMalformedContentType(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/alternative; boundary="b"`,
"",
"--b",
"Content-Type: text/plain; charset=",
"",
"still text",
"--b--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "still text", body)
}
// The same fault the other way round: the attachment guard used to fail open
// on the unquoted file names older mailers emit, so the enclosure won.
func TestBodySkipsAttachmentsWithAMalformedDisposition(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/mixed; boundary="b"`,
"",
"--b",
"Content-Type: text/plain",
"Content-Disposition: attachment; filename=log file.txt",
"",
"an enclosed file, not the message",
"--b",
"Content-Type: text/plain",
"",
"the message itself",
"--b--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "the message itself", body)
}
// RFC 2046 §5.1.3: the parts of a multipart/mixed are cumulative, so the first
// one carrying a body is the message and the rest are footers and enclosures.
// Applying "plain beats HTML" across them handed list mail its unsubscribe
// footer as the notification.
func TestBodyPrefersTheFirstPartOfACumulativeMultipart(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/mixed; boundary="b"`,
"",
"--b",
"Content-Type: text/html",
"",
"<p>the newsletter</p>",
"--b",
"Content-Type: text/plain",
"",
"You are receiving this because you subscribed.",
"--b--",
"",
}, "\r\n"))}
body, err := email.FormattedBody(FormatMarkdown)
require.NoError(t, err)
require.Equal(t, "the newsletter", body,
"the HTML body is the message, and asking for Markdown must reach it")
}
func TestBodyPassesOverAnEmptyHTMLAlternative(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/alternative; boundary="b"`,
"",
"--b",
"Content-Type: text/html",
"",
" ",
"--b",
"Content-Type: text/html",
"",
"<p>the real one</p>",
"--b--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "<p>the real one</p>", body)
}
// A container this server cannot finish reading still hands over the parts it
// read. Only a message that yielded nothing at all is refused, since there is
// then nothing left to notify with and the same bytes fail the same way on
// every redelivery.
func TestBodyKeepsWhatItReadFromABrokenContainer(t *testing.T) {
t.Run("a good part before the break is delivered", func(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/mixed; boundary="b"`,
"",
"--b",
"Content-Type: text/plain",
"",
"good body",
"--b",
"this is not a header",
"",
"x",
"--b--",
"",
}, "\r\n"))}
body, err := email.Body()
require.NoError(t, err)
require.Equal(t, "good body", body)
})
t.Run("a break before anything readable is permanent", func(t *testing.T) {
email := ReceivedEmail{Msg: readMessage(t, strings.Join([]string{
"Subject: Test",
`Content-Type: multipart/alternative; boundary="b"`,
"",
"--b",
"this is not a header",
"",
"body",
"--b--",
"",
}, "\r\n"))}
_, err := email.Body()
require.ErrorIs(t, err, errMalformedMessage)
})
}