smtp2shoutrrr/backend.go
Felipe M. ca2f426cee
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
fix: handle all email content types and preserve query parameters
The Body() method now correctly handles:
- Plain text/plain and text/html emails (not just multipart)
- All multipart types (mixed, related, etc., not just alternative)
- Falls back to HTML if text/plain is not available
- Adds debug logging for body length and content type

Additionally, when sending to multiple targets, query parameters are now
merged instead of replaced, preserving service-specific parameters like
Mattermost's username, icon, and channel configuration.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 16:31:13 +01:00

178 lines
4.4 KiB
Go

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
}