package smtp2shoutrrr import ( "strings" "testing" "unicode/utf8" "github.com/stretchr/testify/require" ) func render(t *testing.T, source string) string { t.Helper() out, err := renderMarkdown(source) require.NoError(t, err) return out } // The conversion itself belongs to html-to-markdown; what these pin is the // contract this package keeps on top of it, which is what a notification // should carry rather than what the document said. func TestRenderMarkdown(t *testing.T) { for name, tc := range map[string]struct{ source, want string }{ "paragraphs are separated by a blank line": { source: "

first

second

", want: "first\n\nsecond", }, "headings keep their level": { source: "

one

three

", want: "# one\n\n### three", }, "emphasis": { source: "

bold italic gone x=1

", want: "**bold** *italic* ~~gone~~ `x=1`", }, "two spans of the same emphasis meeting head on become one": { // What every Word and Outlook export does to a bolded label. source: "

Total: 49.99

", want: "**Total: 49.99**", }, "emphasis nested inside itself does not double its markers": { source: "

ab

", want: "**ab**", }, "links keep their destination": { source: `

see the docs

`, want: "see [the docs](https://example.com/x)", }, "a link with nothing to click on falls back to its destination": { // Otherwise a link wrapped around a tracking pixel renders as // "[](url)", which the reader cannot see at all. source: `

`, want: "[https://example.com/x](https://example.com/x)", }, "a link around nothing but a spacer falls back the same way": { source: `

`, want: "[https://example.com/x](https://example.com/x)", }, "unusable destinations are dropped but their text is kept": { source: `up click img`, want: "up click img", }, "line breaks inside a URL are removed rather than forwarded": { // A newline in an href is invisible in the document and a // fabricated line in the notification. source: "open report", want: "[open report](https://ok.example/rURGENT:%20wire%20funds)", }, "unordered lists": { source: "", want: "- one\n- two", }, "ordered lists count from their start attribute": { source: `
  1. three
  2. four
`, want: "3. three\n4. four", }, "blockquotes mark every line": { source: "

before

quoted

after

", want: "before\n\n> quoted\n\nafter", }, "a code block is fenced past the backticks it holds": { // Otherwise the sender closes the block and everything after it — // a heading, a link — renders as live Markdown in a message the // reader takes for a forwarded notification. source: "
ok\n```\n## Your account is locked\n
", want: "````\nok\n```\n## Your account is locked\n````", }, "table rows become lines and cells stay apart": { // Mail lays itself out in tables far more often than it tabulates // anything, and a converter with no table rules would otherwise // run a row together as "Total4Failed0". source: "
Total4
Failed0
", want: "Total 4 \nFailed 0", }, "images without alt text are dropped": { source: `

ab

`, want: "ab", }, "images with alt text are kept": { source: `Logo`, want: "![Logo](https://x.example/logo.png)", }, "an inline attachment keeps its alt text without a destination": { source: `Chart`, want: "Chart", }, "alt text is collapsed onto one line": { source: "\"a\n", want: "![a long alt](https://x.example/i.png)", }, "script and style content never reaches the reader": { source: "

body

", want: "body", }, "hidden preheaders are left out": { source: `
inbox preview

real body

`, want: "real body", }, "the other ways a preheader hides are recognised too": { source: `
a
b
` + `
c
d

body

`, want: "d\n\nbody", }, "entities and non-breaking spaces become ordinary text": { source: "

4m 12s & counting — done

", want: "4m 12s & counting — done", }, "zero width padding is dropped": { source: "

​a​b​

", want: "ab", }, "text that looks like markup is escaped": { source: "

2 * 3, a [b] c, `tick`

", want: "2 * 3, a \\[b] c, \\`tick\\`", }, "markers are only defused where they would take effect": { source: "

- not a bullet

# not a heading

1. not a list

a - b

", want: "\\- not a bullet\n\n\\# not a heading\n\n1\\. not a list\n\na - b", }, "a document with nothing to say renders to nothing": { source: ``, want: "", }, } { t.Run(name, func(t *testing.T) { require.Equal(t, tc.want, render(t, tc.source)) }) } } // The parser recovers from anything, so the conversion has to as well: a // notification is worth more than a report that the markup was invalid. func TestRenderMarkdownSurvivesBrokenMarkup(t *testing.T) { for name, tc := range map[string]struct{ source, want string }{ "unclosed tags": {"

boldboth

next", "**bold*both***\n\n***next***"}, "stray closing tags": {"

text", "text"}, "no markup at all": {"just some words", "just some words"}, "an empty document": {"", ""}, "an unterminated tag": {"

text<", "text<"}, } { t.Run(name, func(t *testing.T) { require.Equal(t, tc.want, render(t, tc.source)) }) } } // Regression: a line prefix is re-emitted on every line of every level it // nests, so depth multiplies against line count. One message at the server's // own 1 MB limit rendered to 131 MB over two and a quarter minutes — the same // class of fault as the remote DoS closed in 7565618. Neither the converter // nor any of the alternatives bounds this on its own. func TestRenderMarkdownBoundsPathologicalInput(t *testing.T) { // Sized to overrun the cap several times over once the nesting is // flattened, rather than to reproduce the original 1 MB message: the // assertion is the same and the suite stays quick. lines := strings.Repeat("

x

", maxRenderedBytes/maxNestingDepth) for name, source := range map[string]string{ "quotes nested far past anything real": strings.Repeat("
", 250) + lines, "lists nested far past anything real": strings.Repeat("