package smtp2shoutrrr import ( "fmt" "io" "log/slog" "net/mail" "net/url" "slices" "strings" "github.com/containrrr/shoutrrr" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" ) 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 { slog.Warn("no valid targets provided for recipient", slog.String("recipient", strings.Join(recipient.Addresses, ","))) return nil } // Prepare email body once (reuse for all targets) body, err := email.Body() 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("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, ","))) } } // Return aggregated error if any failed if len(errs) > 0 { return fmt.Errorf("failed to send to %d/%d targets: %v", len(errs), len(targetURLs), errs) } 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 { addresses []string config *Config forwarderFunc func(ReceivedEmail) error } func (s *Session) AuthMechanisms() []string { return []string{sasl.Plain} } func (s *Session) Auth(mech string) (sasl.Server, error) { return sasl.NewPlainServer(func(identity, username, password string) error { if username != s.config.Username && password != s.config.Password { return fmt.Errorf("invalid credentials") } return nil }), nil } func (s *Session) Mail(from string, opts *smtp.MailOptions) error { slog.Debug("Mail from", slog.String("from", from)) return nil } func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { slog.Debug("Rcpt to", slog.String("to", to)) s.addresses = append(s.addresses, to) return nil } func (s *Session) Data(r io.Reader) error { msg, err := mail.ReadMessage(r) if err != nil { slog.Error("Error reading data", slog.String("err", err.Error())) return fmt.Errorf("error reading data: %w", err) } slog.Info("Received email", slog.String("destination", strings.Join(s.addresses, ","))) if err := s.forwarderFunc(ReceivedEmail{ Recipients: s.addresses, Msg: msg, }); err != nil { slog.Error("Error forwarding email", slog.String("err", err.Error())) } return nil } func (s *Session) Reset() {} func (s *Session) Logout() error { return nil }