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>
508 lines
14 KiB
Go
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)
|
|
})
|
|
}
|