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>
251 lines
7 KiB
Go
251 lines
7 KiB
Go
package smtp2shoutrrr
|
|
|
|
import (
|
|
"bytes"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// credentials is prepended to fixtures that are not themselves about
|
|
// authentication, so each one still fails (or passes) for the reason it names
|
|
// rather than for the credentials it does not mention.
|
|
const credentials = `
|
|
Username = "user"
|
|
Password = "secret"
|
|
`
|
|
|
|
func writeConfig(t *testing.T, contents string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
|
|
|
|
return path
|
|
}
|
|
|
|
func TestLoadConfig(t *testing.T) {
|
|
path := writeConfig(t, `
|
|
Port = 2525
|
|
Username = "user"
|
|
Password = "secret"
|
|
|
|
[[Recipients]]
|
|
Addresses = ["user@example.com"]
|
|
Targets = ["ntfy://ntfy.sh/topic"]
|
|
|
|
[[Recipients]]
|
|
Addresses = ["legacy@example.com"]
|
|
Target = "ntfy://ntfy.sh/legacy"
|
|
|
|
[CatchAll]
|
|
Targets = ["ntfy://ntfy.sh/catch-all"]
|
|
`)
|
|
|
|
config, err := LoadConfig(path)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, 2525, config.Port)
|
|
require.Equal(t, "user", config.Username)
|
|
require.Equal(t, "secret", config.Password)
|
|
|
|
require.Len(t, config.Recipients, 2)
|
|
require.Equal(t, []string{"user@example.com"}, config.Recipients[0].Addresses)
|
|
require.Equal(t, []string{"ntfy://ntfy.sh/topic"}, config.Recipients[0].Targets)
|
|
require.Equal(t, "ntfy://ntfy.sh/legacy", config.Recipients[1].Target)
|
|
|
|
require.NotNil(t, config.CatchAll)
|
|
require.Equal(t, []string{"ntfy://ntfy.sh/catch-all"}, config.CatchAll.Targets)
|
|
}
|
|
|
|
func TestLoadConfigAppliesDefaults(t *testing.T) {
|
|
path := writeConfig(t, credentials+`
|
|
[[Recipients]]
|
|
Addresses = ["user@example.com"]
|
|
Targets = ["ntfy://ntfy.sh/topic"]
|
|
`)
|
|
|
|
config, err := LoadConfig(path)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, 11125, config.Port)
|
|
require.Nil(t, config.CatchAll)
|
|
|
|
// Credentials deliberately have no default; see
|
|
// TestLoadConfigRejectsUnsetCredentials.
|
|
require.Equal(t, "user", config.Username)
|
|
require.Equal(t, "secret", config.Password)
|
|
}
|
|
|
|
// Regression: unset credentials used to fall back to username/password with
|
|
// only a warning, so a config naming nothing but a port and its recipients
|
|
// left the AUTH gate open to the most obvious guess there is — and the README
|
|
// promised an authentication that had never been configured.
|
|
func TestLoadConfigRejectsUnsetCredentials(t *testing.T) {
|
|
recipient := `
|
|
[[Recipients]]
|
|
Addresses = ["user@example.com"]
|
|
Targets = ["ntfy://ntfy.sh/topic"]
|
|
`
|
|
|
|
for name, contents := range map[string]string{
|
|
"neither credential": recipient,
|
|
"no Username": `Password = "secret"` + recipient,
|
|
"no Password": `Username = "user"` + recipient,
|
|
"empty Username": `Username = ""` + "\n" + `Password = "secret"` + recipient,
|
|
"empty Password": `Username = "user"` + "\n" + `Password = ""` + recipient,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
config, err := LoadConfig(writeConfig(t, contents))
|
|
require.Error(t, err)
|
|
require.Nil(t, config)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigMissingFile(t *testing.T) {
|
|
_, err := LoadConfig(filepath.Join(t.TempDir(), "does-not-exist.toml"))
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestLoadConfigInvalidTOML(t *testing.T) {
|
|
_, err := LoadConfig(writeConfig(t, "Port = "))
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestLoadConfigRejectsUnusableConfigurations(t *testing.T) {
|
|
// A TOML decode ignores unknown keys, so each of these parses cleanly and
|
|
// would otherwise start a server that forwards nothing.
|
|
for name, contents := range map[string]string{
|
|
"mistyped recipients table": `
|
|
[[Recipient]]
|
|
Addresses = ["user@example.com"]
|
|
Targets = ["ntfy://ntfy.sh/topic"]
|
|
`,
|
|
"mistyped addresses key": `
|
|
[[Recipients]]
|
|
Adresses = ["user@example.com"]
|
|
Targets = ["ntfy://ntfy.sh/topic"]
|
|
`,
|
|
"recipient without targets": `
|
|
[[Recipients]]
|
|
Addresses = ["user@example.com"]
|
|
`,
|
|
"recipient with only unparseable targets": `
|
|
[[Recipients]]
|
|
Addresses = ["user@example.com"]
|
|
Targets = ["://invalid-url"]
|
|
`,
|
|
"recipient whose target is not a URL at all": `
|
|
[[Recipients]]
|
|
Addresses = ["user@example.com"]
|
|
Targets = ["this is not a url at all"]
|
|
`,
|
|
"catch-all without targets": `
|
|
[CatchAll]
|
|
Addresses = ["user@example.com"]
|
|
`,
|
|
"empty configuration": `
|
|
Port = 2525
|
|
`,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := LoadConfig(writeConfig(t, credentials+contents))
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigAcceptsCatchAllOnly(t *testing.T) {
|
|
config, err := LoadConfig(writeConfig(t, credentials+`
|
|
[CatchAll]
|
|
Targets = ["ntfy://ntfy.sh/catch-all"]
|
|
`))
|
|
require.NoError(t, err)
|
|
require.Empty(t, config.Recipients)
|
|
require.NotNil(t, config.CatchAll)
|
|
}
|
|
|
|
// A mistyped table name is silently dropped by the decoder. It is only fatal
|
|
// when nothing else is configured, so with a catch-all present the warning is
|
|
// the sole signal that the block was ignored.
|
|
func TestLoadConfigWarnsAboutIgnoredKeys(t *testing.T) {
|
|
var logged bytes.Buffer
|
|
restore := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
|
t.Cleanup(func() { slog.SetDefault(restore) })
|
|
|
|
config, err := LoadConfig(writeConfig(t, credentials+`
|
|
[[Recipient]]
|
|
Addresses = ["user@example.com"]
|
|
Targets = ["ntfy://ntfy.sh/topic"]
|
|
|
|
[CatchAll]
|
|
Targets = ["ntfy://ntfy.sh/catch-all"]
|
|
`))
|
|
require.NoError(t, err)
|
|
require.Empty(t, config.Recipients, "the mistyped table is still ignored")
|
|
require.Contains(t, logged.String(), "ignoring unknown key in config file")
|
|
require.Contains(t, logged.String(), "Recipient")
|
|
}
|
|
|
|
func TestLoadConfigReadsFormat(t *testing.T) {
|
|
config, err := LoadConfig(writeConfig(t, credentials+`
|
|
[[Recipients]]
|
|
Addresses = ["user@example.com"]
|
|
Targets = ["ntfy://ntfy.sh/topic"]
|
|
Format = "Markdown"
|
|
|
|
[[Recipients]]
|
|
Addresses = ["asis@example.com"]
|
|
Targets = ["ntfy://ntfy.sh/asis"]
|
|
|
|
[CatchAll]
|
|
Targets = ["ntfy://ntfy.sh/catch-all"]
|
|
Format = "markdown"
|
|
`))
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, FormatMarkdown, config.Recipients[0].Format, "the option is not case sensitive")
|
|
require.Equal(t, FormatRaw, config.Recipients[1].Format, "an unset Format forwards the message unchanged")
|
|
require.Equal(t, FormatMarkdown, config.CatchAll.Format)
|
|
}
|
|
|
|
// A typo here is silent otherwise: the server starts and forwards raw HTML to
|
|
// a target that cannot render it.
|
|
func TestLoadConfigRejectsUnknownFormat(t *testing.T) {
|
|
for name, contents := range map[string]string{
|
|
"on a recipient": `
|
|
[[Recipients]]
|
|
Addresses = ["user@example.com"]
|
|
Targets = ["ntfy://ntfy.sh/topic"]
|
|
Format = "text"
|
|
`,
|
|
"on the catch-all": `
|
|
[CatchAll]
|
|
Targets = ["ntfy://ntfy.sh/catch-all"]
|
|
Format = "md"
|
|
`,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := LoadConfig(writeConfig(t, credentials+contents))
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "raw, markdown")
|
|
})
|
|
}
|
|
}
|
|
|
|
// Validate used to depend on SetDefaults having run, so a Config assembled in
|
|
// Go failed on a Format nobody had set.
|
|
func TestValidateAcceptsAConfigBuiltWithoutDefaults(t *testing.T) {
|
|
config := &Config{
|
|
Username: "user",
|
|
Password: "secret",
|
|
Recipients: []ConfigRecipient{{Addresses: []string{"user@example.com"}, Targets: []string{"ntfy://ntfy.sh/topic"}}},
|
|
}
|
|
|
|
require.NoError(t, config.Validate())
|
|
}
|