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 }