smtp2shoutrrr/email.go
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

405 lines
11 KiB
Go

package smtp2shoutrrr
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"strings"
"unicode"
"golang.org/x/net/html/charset"
)
// errMalformedMessage marks a message this server can never turn into a
// notification. Redelivering the same bytes produces the same failure, so the
// sender is told to give up rather than retry forever.
var errMalformedMessage = errors.New("malformed message")
// maxMultipartDepth bounds how deep a message can make this server walk. Real
// mail nests three levels at most (mixed → related → alternative); anything
// past that is a generated or hostile message rather than one with a body
// worth finding.
const maxMultipartDepth = 8
type ReceivedEmail struct {
Recipients []string
Msg *mail.Message
bodyRead bool
body string
bodyIsHTML bool
}
// Body returns the message body as the message carried it: its text/plain part
// when there is one, its text/html part otherwise.
func (re *ReceivedEmail) Body() (string, error) {
if err := re.readBody(); err != nil {
return "", err
}
return re.body, nil
}
// FormattedBody returns the body in the requested format. Only an HTML body is
// ever rewritten: a message that already arrived as plain text is what the
// sender chose to write, and no target is better served by a round trip
// through a renderer.
func (re *ReceivedEmail) FormattedBody(format BodyFormat) (string, error) {
if err := re.readBody(); err != nil {
return "", err
}
if format.normalize() == FormatRaw || !re.bodyIsHTML {
return re.body, nil
}
// Reformatting is a courtesy to the target, not a condition of delivery.
// A body the renderer cannot take apart, or one that renders to nothing
// because it was all images and tracking pixels, is forwarded as it
// arrived: an empty notification is refused by most targets, which puts
// the message into a retry loop it can never leave.
rendered, err := renderMarkdown(re.body)
if err != nil {
slog.Warn("failed to render HTML body, forwarding it unchanged",
slog.String("format", string(format)),
slog.String("err", err.Error()))
return re.body, nil
}
if rendered == "" && strings.TrimSpace(re.body) != "" {
slog.Warn("HTML body rendered to nothing, forwarding it unchanged",
slog.String("format", string(format)),
slog.Int("html_length", len(re.body)))
return re.body, nil
}
return rendered, nil
}
func (re *ReceivedEmail) readBody() error {
if re.bodyRead {
return nil
}
body, isHTML, err := readEntity(textproto.MIMEHeader(re.Msg.Header), re.Msg.Body, 0)
if err != nil {
return err
}
re.body, re.bodyIsHTML, re.bodyRead = body, isHTML, true
return nil
}
// readEntity reads one MIME entity — the message itself or a part of it — and
// reports the body it contributes and whether that body is HTML.
func readEntity(header textproto.MIMEHeader, body io.Reader, depth int) (string, bool, error) {
contentType := header.Get("Content-Type")
if contentType == "" {
content, err := decodeContent(header, body, "")
return content, false, err
}
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return "", false, fmt.Errorf("%w: parsing Content-Type %q: %w", errMalformedMessage, contentType, err)
}
return readMediaType(header, body, mediaType, params, depth)
}
func readMediaType(
header textproto.MIMEHeader,
body io.Reader,
mediaType string,
params map[string]string,
depth int,
) (string, bool, error) {
switch {
case strings.HasPrefix(mediaType, "multipart/"):
return readMultipart(body, mediaType, params["boundary"], depth)
case strings.HasPrefix(mediaType, "text/"):
content, err := decodeContent(header, body, params["charset"])
return content, mediaType == "text/html", err
default:
// An image or an application/* payload on its own carries no text to
// notify with; the subject still reaches the target as the title.
return "", false, nil
}
}
func readMultipart(body io.Reader, mediaType, boundary string, depth int) (string, bool, error) {
if boundary == "" {
return "", false, fmt.Errorf("%w: multipart message without a boundary", errMalformedMessage)
}
if depth >= maxMultipartDepth {
slog.Warn("stopped descending into a deeply nested message", slog.Int("depth", depth))
return "", false, nil
}
// RFC 2046 §5.1.4: the parts of a multipart/alternative are one content in
// several forms, so exactly one of them is chosen. The parts of any other
// multipart are cumulative (§5.1.3): the first one carrying a body is the
// message, and what follows it is footers, signatures and enclosures.
alternatives := mediaType == "multipart/alternative"
var chosen bodySelector
reader := multipart.NewReader(body, boundary)
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
// A truncated container still hands over the parts that were
// readable. Only a message that yielded nothing at all is worth
// refusing, since there is nothing left to notify with.
slog.Warn("stopped reading a multipart message early", slog.String("err", err.Error()))
if chosen.empty() {
return "", false, fmt.Errorf("%w: reading multipart body: %w", errMalformedMessage, err)
}
break
}
content, isHTML, err := readPart(part, depth)
_ = part.Close()
if err != nil {
// One unreadable part is not the whole message; the rest of the
// tree may still hold a body.
slog.Warn("ignoring an unreadable email part", slog.String("err", err.Error()))
continue
}
if !alternatives {
if strings.TrimSpace(content) != "" {
return content, isHTML, nil
}
continue
}
chosen.offer(content, isHTML)
if chosen.done() {
break
}
}
content, isHTML := chosen.result()
return content, isHTML, nil
}
func readPart(part *multipart.Part, depth int) (string, bool, error) {
contentType := part.Header.Get("Content-Type")
slog.Debug("Processing email part", slog.String("content_type", contentType))
// mime.ParseMediaType hands back a usable media type alongside the error
// it reports for a malformed parameter — an empty charset, an unquoted
// file name — which older mailers emit often enough that dropping those
// parts would lose the message body itself.
if disposition, _, err := mime.ParseMediaType(part.Header.Get("Content-Disposition")); err == nil ||
errors.Is(err, mime.ErrInvalidMediaParameter) {
if disposition == "attachment" {
// An attached .txt or .html file is something the sender enclosed,
// not what they wrote.
return "", false, nil
}
}
// RFC 2045 §5.2 makes a part without a Content-Type plain US-ASCII text.
mediaType, params := "text/plain", map[string]string{}
if contentType != "" {
var err error
mediaType, params, err = mime.ParseMediaType(contentType)
if err != nil && !errors.Is(err, mime.ErrInvalidMediaParameter) {
slog.Warn("ignoring email part with an unparseable Content-Type",
slog.String("content_type", contentType),
slog.String("err", err.Error()))
return "", false, nil
}
if params == nil {
params = map[string]string{}
}
}
if !strings.HasPrefix(mediaType, "multipart/") &&
mediaType != "text/plain" && mediaType != "text/html" {
// text/calendar, an inline image, a signature: nothing to notify with.
return "", false, nil
}
return readMediaType(part.Header, part, mediaType, params, depth+1)
}
// decodeContent turns one entity's bytes into a UTF-8 string, undoing the
// transfer encoding and the character set the message declared. Without this a
// quoted-printable body reaches the target as "=E2=80=99" and a Latin-1 one as
// mojibake — and neither survives a trip through an HTML renderer.
func decodeContent(header textproto.MIMEHeader, body io.Reader, charsetLabel string) (string, error) {
raw, err := io.ReadAll(body)
if err != nil {
return "", fmt.Errorf("reading message body: %w", err)
}
return decodeCharset(decodeTransferEncoding(raw, header.Get("Content-Transfer-Encoding")), charsetLabel), nil
}
// decodeTransferEncoding is deliberately forgiving. A body that will not decode
// is still a body: forwarding the bytes as they arrived shows the reader
// something, where refusing the message tells the sender to stop retrying and
// loses the notification for good.
func decodeTransferEncoding(raw []byte, encoding string) []byte {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
return decodeBase64(raw)
case "quoted-printable":
// mime/multipart already unwraps this for the parts it hands out and
// drops the header with it, so only a single-part message arrives
// here still encoded.
decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(raw)))
if err != nil {
slog.Warn("failed to decode a quoted-printable body, forwarding what could be decoded",
slog.String("err", err.Error()))
}
if len(decoded) == 0 {
return raw
}
return decoded
default:
// 7bit, 8bit and binary are the identity encoding; anything else is
// something this server has no way to undo.
return raw
}
}
// decodeBase64 tolerates the two liberties real mailers take with a base64
// body: they wrap it across lines, and they leave the padding off the last
// group.
func decodeBase64(raw []byte) []byte {
compact := bytes.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, raw)
encoding := base64.StdEncoding
if len(compact)%4 != 0 {
encoding = base64.RawStdEncoding
}
decoded, err := encoding.DecodeString(string(compact))
if err != nil {
slog.Warn("failed to decode a base64 body, forwarding what could be decoded",
slog.String("err", err.Error()))
}
if len(decoded) == 0 {
return raw
}
return decoded
}
func decodeCharset(raw []byte, label string) string {
switch strings.ToLower(strings.TrimSpace(label)) {
case "", "utf-8", "utf8", "us-ascii", "ascii":
return string(raw)
}
encoding, _ := charset.Lookup(label)
if encoding == nil {
slog.Warn("unknown message charset, forwarding the body undecoded",
slog.String("charset", label))
return string(raw)
}
decoded, err := encoding.NewDecoder().Bytes(raw)
if err != nil {
slog.Warn("failed to decode message charset, forwarding the body undecoded",
slog.String("charset", label),
slog.String("err", err.Error()))
return string(raw)
}
return string(decoded)
}
// bodySelector picks between the forms of a multipart/alternative. Plain text
// beats HTML, and the first of each kind beats the rest, which is the order
// the message puts its own preference in. A blank part is held rather than
// chosen: senders that build the HTML from a template routinely emit an empty
// alternative beside it.
type bodySelector struct {
plain string
html string
hasPlain bool
hasHTML bool
}
func (s *bodySelector) offer(content string, isHTML bool) {
if isHTML {
if !s.hasHTML || strings.TrimSpace(s.html) == "" {
s.html, s.hasHTML = content, true
}
return
}
if !s.hasPlain || strings.TrimSpace(s.plain) == "" {
s.plain, s.hasPlain = content, true
}
}
func (s *bodySelector) empty() bool {
return !s.hasPlain && !s.hasHTML
}
// done reports that nothing later in the container can improve on what has
// been found.
func (s *bodySelector) done() bool {
return s.hasPlain && strings.TrimSpace(s.plain) != ""
}
func (s *bodySelector) result() (string, bool) {
if s.done() || !s.hasHTML {
return s.plain, false
}
return s.html, true
}