SMTP server to forward messages to shoutrrr endpoints
  • Go 96%
  • Makefile 2.7%
  • Dockerfile 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
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
.github/workflows ci: rename the FORGEJO_TOKEN secret to ACTIONS_TOKEN (FMG-6) (#12) 2026-09-08 20:09:53 +02:00
cmd deps: upgrade Go to 1.27.1, refresh dependencies and drop gotoolkit (#8) (FMG-2) 2026-09-07 21:27:03 +02:00
.gitignore dev: updated golangci-lint to latest and fixed issues 2026-02-04 12:59:15 +01:00
.goreleaser.yml ci: migrate from Woodpecker to Forgejo Actions and simplify dev tooling (#6) 2026-04-07 18:22:04 +02:00
backend.go fix: address review of the HTML conversion feature (FMG-9) 2026-09-10 07:28:45 +00:00
backend_test.go fix: address review of the HTML conversion feature (FMG-9) 2026-09-10 07:28:45 +00:00
config.go feat: add a per-recipient Format option to convert HTML bodies (FMG-9) 2026-09-09 20:58:52 +00:00
config_test.go refactor: convert HTML with html-to-markdown instead of by hand (FMG-9) 2026-09-10 09:09:46 +00:00
Containerfile deps: upgrade Go to 1.27.1, refresh dependencies and drop gotoolkit (#8) (FMG-2) 2026-09-07 21:27:03 +02:00
email.go refactor: convert HTML with html-to-markdown instead of by hand (FMG-9) 2026-09-10 09:09:46 +00:00
email_test.go refactor: convert HTML with html-to-markdown instead of by hand (FMG-9) 2026-09-10 09:09:46 +00:00
format.go refactor: convert HTML with html-to-markdown instead of by hand (FMG-9) 2026-09-10 09:09:46 +00:00
go.mod refactor: convert HTML with html-to-markdown instead of by hand (FMG-9) 2026-09-10 09:09:46 +00:00
go.sum refactor: convert HTML with html-to-markdown instead of by hand (FMG-9) 2026-09-10 09:09:46 +00:00
html.go refactor: convert HTML with html-to-markdown instead of by hand (FMG-9) 2026-09-10 09:09:46 +00:00
html_test.go refactor: convert HTML with html-to-markdown instead of by hand (FMG-9) 2026-09-10 09:09:46 +00:00
LICENSE Initial commit 2024-11-13 15:32:54 +01:00
Makefile deps: upgrade Go to 1.27.1, refresh dependencies and drop gotoolkit (#8) (FMG-2) 2026-09-07 21:27:03 +02:00
README.md refactor: convert HTML with html-to-markdown instead of by hand (FMG-9) 2026-09-10 09:09:46 +00:00
server.go deps: upgrade Go to 1.27.1, refresh dependencies and drop gotoolkit (#8) (FMG-2) 2026-09-07 21:27:03 +02:00
server_test.go deps: upgrade Go to 1.27.1, refresh dependencies and drop gotoolkit (#8) (FMG-2) 2026-09-07 21:27:03 +02:00

smtp2Shoutrrr

A simple SMTP server that forwards incoming emails to a Shoutrrr supported service.

Installing

First generate a new configuration file following this example:

# config.toml
# Port the SMTP server will listen on
Port = 11025

# Credentials for the SMTP server (required — the server refuses to start without them)
Username = "user"
Password = "nometokens"

# Configure recipients to forward emails to

# Multiple Targets (Recommended)
# Use the Targets array to send notifications to multiple services
[[Recipients]]
# Email addresses to forward emails from
Addresses = ["user@example.com"]
# Shoutrrr services to forward emails to
# See shoutrrr documentation: https://containrrr.dev/shoutrrr/
Targets = [
    "ntfy://ntfy.sh/my-ntfy-topic?tags=email",
    "discord://token@id",
    "slack://token@channel"
]
# Optional: how an HTML message body should reach these targets.
#   "raw"      forward the message unchanged (default)
#   "markdown" render an HTML body as Markdown
# See "Converting HTML messages" below.
# Format = "markdown"

# Single Target (Deprecated)
# The Target field is still supported for backward compatibility
[[Recipients]]
Addresses = ["legacy@example.com"]
Target = "ntfy://ntfy.sh/legacy-topic?tags=thing"  # Will show deprecation warning

# Note: Repeat [[Recipients]] as needed

# Optional: Configure a catch-all recipient for unmatched email addresses
[CatchAll]
# Shoutrrr services to forward unmatched emails to
Targets = ["ntfy://ntfy.sh/catch-all-topic?tags=unmatched"]
# Format applies to the catch-all as well
# Format = "markdown"

The server refuses to start if the configuration cannot forward anything — no recipients and no catch-all, a recipient without addresses or usable targets, or a mistyped table name such as [[Recipient]], which TOML would otherwise accept silently, or a Format that names none of the supported conversions. It also refuses to start without a Username and a Password: these used to fall back to username/password, which left the authentication gate open to the most obvious guess there is.

Clients must authenticate with the configured Username and Password before starting a mail transaction; an unauthenticated client is refused rather than relayed. A client that used to send without issuing AUTH has to be configured with the credentials above.

The reply the sending client gets reflects what happened to the notification: 250 once at least one target has accepted it, 451 when none did so the sender retries, and 550 for a message that cannot be parsed at all — a malformed Content-Type, for instance — which no retry could fix. A partial failure is logged but still accepted, since a retry would redeliver to every target and duplicate the notification on the ones that already have it.

From releases

  • Grab the latest release from the releases page
  • Put the configuration file in the same directory as the binary
  • Run the binary for your appropriate platform

From source (development)

  • Clone this repository
  • Put the configuration file in the repository folder
  • Run make quick-run

Using docker

  • Create a config.toml file as described above
  • Run the docker image mounting the Config.toml file as /config.toml and exposing the configured port:
docker run -v /path/to/config.toml:/config.toml \
    -p 11025:11025 \
    git.nakama.town/fmartingr/smtp2shoutrrr:latest

Converting HTML messages

Plenty of mail carries nothing but HTML, and plenty of notification services render none of it — a Mattermost direct message shows the markup verbatim. Setting Format on a recipient (or on [CatchAll]) rewrites an HTML body before it is forwarded:

Format Effect
raw Forward the body as the message wrote it. (default)
markdown Render an HTML body as Markdown.

There is deliberately no plain-text format beside it. Markdown reads as plain text wherever nothing renders it — that is rather the point of Markdown — so a second conversion would only be a worse copy of this one.

The value is not case sensitive, and an unrecognised one is refused when the server starts rather than silently forwarding raw HTML.

Only an HTML body is ever rewritten. A message that arrives as plain text is what the sender chose to write and is forwarded untouched whatever Format says. Which part of a multipart message that is follows RFC 2046: the parts of a multipart/alternative are the same content in several forms, so the text/plain one is preferred where it is not empty; the parts of any other multipart are cumulative, so the first one carrying a body is the message and the footers, signatures and attachments after it are not.

The Markdown itself is produced by html-to-markdown, which knows CommonMark — escaping, delimiter runs, fencing a code block past the backticks inside it. What it does not know is mail, because it is written for documents. Before the conversion runs, the parsed message is stripped down to what a notification should carry:

  • Hidden elements go. A template opens with a preheader written for the inbox list, which reads as noise anywhere else.
  • Images without alt text go, which removes tracking pixels, spacers and sliced-up banners. An inline cid: attachment leaves its alt text behind as ordinary words.
  • Destinations a reader cannot open — #anchors, javascript:, cid: — are dropped and the text of the link is kept. Tabs and line breaks are removed 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, since mail lays itself out in tables far more often than it tabulates anything.
  • Quoting and list nesting is flattened past six levels. A line prefix is re-emitted on every line of every level it nests, so depth multiplies against line count: a message at the 1 MB limit nested 250 quotes deep otherwise renders to 131 MB and takes minutes of CPU.

If a body is too deeply nested for the HTML parser, or renders to nothing at all because it was images and tracking pixels, it is forwarded unchanged and a warning is logged: reformatting is a courtesy to the target, not a condition of delivery. Output past 64 KiB is cut short with a , which no chat target would have displayed anyway.

Bodies are decoded before they are rendered, so quoted-printable and base64 transfer encodings and non-UTF-8 character sets (iso-8859-1, windows-1252, …) reach the target as readable text in both formats, raw included — which is the one way raw is not quite the bytes that arrived.

Development

Run the server with:

make quick-run

Send a test email with:

This will read the config.toml in the current directory to set the appropriate SMTP client configuration.

go run ./cmd/sendmail/.