smtp2shoutrrr/backend.go
butterrobot c5942c3d8f
All checks were successful
CI / goreleaser-lint (pull_request) Successful in 3s
CI / format (pull_request) Successful in 2m5s
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 2m58s
CI / build (pull_request) Successful in 2m38s
fix: address review of the HTML conversion feature (FMG-9)
Blockers

- Cap the rendered output at 64 KiB. Block prefixes are re-emitted on every
  line, so nesting multiplied against line count: one message at the server's
  own 1 MB limit rendered to 125 MB and 894 MiB of allocation, which OOM-kills
  the process in any modest container. Now 65 KB and 30 MiB, with a "…" so a
  cut message says so.
- Stop turning an undecodable transfer encoding into a permanent 550. Real
  mailers emit unpadded base64, and a 550 tells the sender to stop retrying, so
  mail that main delivered was lost for good. base64 and quoted-printable now
  degrade to whatever decoded — and to the bytes as they arrived — exactly as
  the charset path already did. One unreadable part no longer fails a message
  that has a perfectly good alternative in hand.
- Keep the media type mime.ParseMediaType returns alongside its error. A part
  with "charset=" or an unquoted attachment file name was dropped whole, which
  delivered an empty notification with a 250 for mail main forwarded, and made
  the new attachment guard fail open on the malformed forms older MUAs emit.

Renderer

- Merge two emphasis spans that meet with nothing between them, and drop a
  marker nested inside itself. Splitting a bolded label across two <b> runs is
  what every Word and Outlook export does, and it put a literal "**" in front
  of the reader — the exact thing this feature exists to prevent.
- Render code spans and fenced blocks from their text, so escapes stay literal
  where Markdown makes them literal, and fence past the longest backtick run
  inside. A sender could otherwise close the fence and have the rest of the
  message render as live Markdown — a heading or a link inside what the reader
  trusts as a forwarded notification.
- Strip line breaks from href and src and collapse them in alt text. A newline
  in a URL is invisible in the document and a fabricated line in the message,
  and it destroyed the destination as well.
- Keep the blank lines inside <pre>, which a diff and a stack trace are shaped
  by; recognise visibility:hidden, mso-hide:all and font-size:0 preheaders;
  de-duplicate a bare URL label on a line that opens with a bullet.
- Raise the depth guard above the parser's own limit on open elements, so
  content is never dropped silently between the two.

Elsewhere

- Forward the raw HTML when a body renders to nothing, as the render-error path
  already did. An image-only newsletter handed shoutrrr "", which Mattermost
  and Discord reject, so every target failed and the sender retried forever.
- Scope body selection to its container per RFC 2046: the parts of a
  multipart/alternative are one content in several forms, the parts of any
  other multipart are cumulative. "Plain beats HTML" applied across a mixed
  container picked a list's unsubscribe footer over the newsletter itself.
- Pass the message by pointer. Its body is a single-use stream cached on the
  value, so a copy would forward an empty notification to every recipient after
  the first.
- Normalize in BodyFormat.valid(), so a Config assembled in Go rather than
  loaded from a file does not fail on a Format nobody set.

Tests and docs

- A realistic transactional message pinned end to end in both formats. Every
  fault above lived in a combination of features rather than in one of them,
  which is why statement coverage did not catch any of them.
- TestRenderHTMLSurvivesBrokenMarkup asserts output rather than only err == nil,
  and the nesting-limit test asserts the cap rather than NotContains, which
  passed on empty output.
- README: the conversion section is its own, the startup rejection and the
  case-insensitivity are documented, the copy-paste example no longer enables a
  non-default, and "exactly as it arrived" is corrected — raw is decoded first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 07:28:45 +00:00

331 lines
9.7 KiB
Go

package smtp2shoutrrr
import (
"crypto/sha256"
"crypto/subtle"
"errors"
"fmt"
"io"
"log/slog"
"net/mail"
"net/url"
"slices"
"strings"
"sync"
"github.com/containrrr/shoutrrr"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
)
// errNoUsableTargets marks a configuration fault rather than a delivery one:
// the recipient matched, but there is nothing to notify. No retry can add
// targets to a configuration.
var errNoUsableTargets = errors.New("recipient has no usable targets")
var (
// errUnreadableMessage rejects a message for good. Both causes — a
// message that is not parseable as mail, and a Content-Type this server
// cannot make sense of — fail identically on every redelivery.
errUnreadableMessage = &smtp.SMTPError{
Code: 550,
EnhancedCode: smtp.EnhancedCode{5, 6, 0},
Message: "Message could not be parsed",
}
// errMisconfiguredRecipient is permanent for the same reason: the server
// has nowhere to send this recipient's mail until its configuration
// changes, which no amount of redelivery brings about.
errMisconfiguredRecipient = &smtp.SMTPError{
Code: 550,
EnhancedCode: smtp.EnhancedCode{5, 3, 5},
Message: "Recipient has no notification targets configured",
}
// errForwardingFailed asks the sender to try again later. The 250 this
// replaces told the client a message had been delivered when no target
// had received it, so the client dropped a notification nobody ever saw.
errForwardingFailed = &smtp.SMTPError{
Code: 451,
EnhancedCode: smtp.EnhancedCode{4, 3, 0},
Message: "Failed to forward message to notification targets",
}
)
type Backend struct {
config *Config
}
func (bkd *Backend) sendNotifications(recipient ConfigRecipient, email *ReceivedEmail) error {
// Get all target URLs (handles merging and caching)
targetURLs := recipient.GetTargetURLs()
if len(targetURLs) == 0 {
return fmt.Errorf("%w: %s", errNoUsableTargets,
strings.Join(recipient.Addresses, ","))
}
// Prepare email body once (reuse for all targets)
body, err := email.FormattedBody(recipient.Format)
if err != nil {
slog.Error("Error getting email body", slog.String("err", err.Error()))
return fmt.Errorf("failed to get email body: %w", err)
}
slog.Debug("Prepared email body",
slog.Int("body_length", len(body)),
slog.String("format", string(recipient.Format)),
slog.String("subject", email.Msg.Header.Get("Subject")),
slog.String("content_type", email.Msg.Header.Get("Content-Type")))
urlParams := url.Values{
"title": {email.Msg.Header.Get("Subject")},
}
// Send to all targets, collect errors
var errs []error
for i, targetURL := range targetURLs {
// Clone URL to avoid mutation
destinationURL := *targetURL
// Merge new params with existing query params
existingParams := destinationURL.Query()
for key, values := range urlParams {
for _, value := range values {
existingParams.Add(key, value)
}
}
destinationURL.RawQuery = existingParams.Encode()
if err := shoutrrr.Send(destinationURL.String(), body); err != nil {
slog.Error("Error sending message to target",
slog.Int("target_index", i),
slog.String("target", targetURL.Host),
slog.String("err", err.Error()))
errs = append(errs, fmt.Errorf("target %d: %w", i, err))
// Continue to next target even if this one fails
} else {
slog.Info("Successfully sent notification",
slog.String("target", targetURL.Host),
slog.String("recipient", strings.Join(recipient.Addresses, ",")))
}
}
// Only a total failure is reported to the sender. A retry redelivers to
// every target, so pushing back on a partial one would duplicate the
// notification on the targets that already accepted it.
if len(errs) == len(targetURLs) {
return fmt.Errorf("no target accepted the notification: %w", errors.Join(errs...))
}
if len(errs) > 0 {
slog.Warn("notification reached only some targets",
slog.Int("failed", len(errs)),
slog.Int("targets", len(targetURLs)),
slog.String("recipient", strings.Join(recipient.Addresses, ",")))
}
return nil
}
func (bkd *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
return &Session{
forwarderFunc: bkd.forwardEmail,
config: bkd.config,
}, nil
}
func (bkd *Backend) forwardEmail(email *ReceivedEmail) error {
slog.Info("forwading message", slog.String("to", strings.Join(email.Recipients, ",")))
// Try to match configured recipients first
matched := false
for _, r := range bkd.config.Recipients {
for _, a := range email.Recipients {
if slices.Contains(r.Addresses, a) {
if err := bkd.sendNotifications(r, email); err != nil {
return err
}
matched = true
break
}
}
if matched {
break
}
}
// If no recipient matched and catch-all is configured, use it
if !matched && bkd.config.CatchAll != nil {
slog.Info("using catch-all recipient for unmatched email")
if err := bkd.sendNotifications(*bkd.config.CatchAll, email); err != nil {
return err
}
}
return nil
}
type Session struct {
// mu guards the transaction state below. go-smtp runs Data on its own
// goroutine for a BDAT transfer and keeps serving commands on the
// connection goroutine meanwhile, so an RSET can land in the middle of
// one and reset the session from under it.
mu sync.Mutex
addresses []string
// authenticated gates the mail transaction. go-smtp advertises AUTH but
// never enforces it — that is the backend's job — so without this the
// server relays for anyone who can open a connection.
authenticated bool
config *Config
// forwarderFunc takes the message by pointer: the body is read from a
// single-use stream and cached on it, so a copy would forward an empty
// notification to every recipient after the first.
forwarderFunc func(*ReceivedEmail) error
}
func (s *Session) isAuthenticated() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.authenticated
}
// recipients copies the transaction's recipients so a concurrent Reset cannot
// empty the slice a forward in progress is still reading.
func (s *Session) recipients() []string {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.addresses)
}
func (s *Session) AuthMechanisms() []string {
return []string{sasl.Plain}
}
func (s *Session) Auth(mech string) (sasl.Server, error) {
if mech != sasl.Plain {
return nil, smtp.ErrAuthUnknownMechanism
}
return sasl.NewPlainServer(func(identity, username, password string) error {
// PLAIN carries an authorization identity distinct from the
// authenticating one (RFC 4616). There is nobody to act on behalf of
// here, so only the empty and self-referential forms are accepted.
if identity != "" && identity != username {
return smtp.ErrAuthFailed
}
// Both comparisons run before either is acted on: short-circuiting on
// the username would time-distinguish a wrong username from a right
// one with a wrong password, handing an attacker the username.
usernameMatches := credentialMatches(username, s.config.Username)
passwordMatches := credentialMatches(password, s.config.Password)
if !usernameMatches || !passwordMatches {
// Logged without the attempted values: a misconfigured client
// sends its password in the username field often enough that
// recording them turns the log into a credential store.
slog.Warn("rejected authentication attempt")
return smtp.ErrAuthFailed
}
s.mu.Lock()
s.authenticated = true
s.mu.Unlock()
return nil
}), nil
}
// credentialMatches compares a supplied credential against the configured one
// in time independent of how much of it is right. Both sides are hashed first
// because subtle.ConstantTimeCompare returns early when the lengths differ,
// which would still leak the length of the configured credential.
func credentialMatches(supplied, configured string) bool {
suppliedSum := sha256.Sum256([]byte(supplied))
configuredSum := sha256.Sum256([]byte(configured))
return subtle.ConstantTimeCompare(suppliedSum[:], configuredSum[:]) == 1
}
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
if !s.isAuthenticated() {
return smtp.ErrAuthRequired
}
slog.Debug("Mail from", slog.String("from", from))
return nil
}
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
if !s.isAuthenticated() {
return smtp.ErrAuthRequired
}
slog.Debug("Rcpt to", slog.String("to", to))
s.mu.Lock()
defer s.mu.Unlock()
s.addresses = append(s.addresses, to)
return nil
}
func (s *Session) Data(r io.Reader) error {
if !s.isAuthenticated() {
return smtp.ErrAuthRequired
}
// Taken before the message is read: an RSET arriving mid-transfer aborts
// the read, and this is the transaction the recipients belong to either
// way.
recipients := s.recipients()
msg, err := mail.ReadMessage(r)
if err != nil {
slog.Error("Error reading data", slog.String("err", err.Error()))
return errUnreadableMessage
}
slog.Info("Received email", slog.String("destination", strings.Join(recipients, ",")))
if err := s.forwarderFunc(&ReceivedEmail{
Recipients: recipients,
Msg: msg,
}); err != nil {
slog.Error("Error forwarding email", slog.String("err", err.Error()))
switch {
case errors.Is(err, errMalformedMessage):
return errUnreadableMessage
case errors.Is(err, errNoUsableTargets):
return errMisconfiguredRecipient
default:
return errForwardingFailed
}
}
return nil
}
// Reset ends the current mail transaction. go-smtp calls it after every
// message, so the recipients have to go: leaving them would forward the next
// message on the connection to the previous one's targets as well.
// Authentication deliberately survives, as RFC 5321 scopes RSET to the
// transaction.
func (s *Session) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.addresses = nil
}
func (s *Session) Logout() error {
return nil
}