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

html body

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

html body

", "--b--", "", }, "\r\n"))} body, err := email.Body() require.NoError(t, err) require.Equal(t, "

html body

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

html body

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

html body

", "--b--", "", }, "\r\n"))} body, err := email.Body() require.NoError(t, err) require.Equal(t, "

html body

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

Build failed: run 42

", "", }, "\r\n") for format, want := range map[BodyFormat]string{ FormatMarkdown: "Build **failed**: [run 42](https://ci.example.com/42)", FormatRaw: "

Build failed: run 42

\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 \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 \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

html body

\r\n")} body, err := email.FormattedBody("") require.NoError(t, err) require.Equal(t, "

html body

\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("
", 600) + "deep" + strings.Repeat("
", 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": ``, } { 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

body

\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", "", "

the newsletter

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

the real one

", "--b--", "", }, "\r\n"))} body, err := email.Body() require.NoError(t, err) require.Equal(t, "

the real one

", 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) }) }