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 }