package main import ( "context" "errors" "fmt" "io" "log/slog" "net/mail" "net/url" "os" "slices" "strings" "time" "git.nakama.town/fmartingr/gotoolkit/encoding" "git.nakama.town/fmartingr/gotoolkit/model" "git.nakama.town/fmartingr/gotoolkit/service" "github.com/containrrr/shoutrrr" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" ) type ReceivedEmail struct { Recipients []string Msg *mail.Message body string } func (re *ReceivedEmail) String() string { return fmt.Sprintf(`FROM: %s TO: %s, BODY: %s`, re.Msg.Header.Get("From"), re.Recipients, re.Body()) } func (re *ReceivedEmail) Body() string { if re.body == "" { body, err := io.ReadAll(re.Msg.Body) if err != nil { slog.Error("failed to read email body", slog.String("err", err.Error())) } re.body = string(body) } return re.body } type Config struct { Port int Recipients []ConfigRecipient } func (c *Config) SetDefaults() { if c.Port == 0 { c.Port = 11125 } } type ConfigRecipient struct { Addresses []string // email addresses Target string // shoutrrr address targetURL *url.URL } func (cr ConfigRecipient) GetTargetURL() *url.URL { if cr.targetURL == nil { var err error cr.targetURL, err = url.Parse(cr.Target) if err != nil { slog.Error("failed to parse shoutrrr target URL", slog.String("target", cr.Target), slog.String("err", err.Error())) } } return cr.targetURL } var _ model.Server = (*smtpServer)(nil) type smtpServer struct { backend *smtp.Server config Config } func (s *smtpServer) IsEnabled() bool { return true } func (s *smtpServer) Start(_ context.Context) error { slog.Info("Started SMTP server", slog.String("addr", s.backend.Addr)) return s.backend.ListenAndServe() } func (s *smtpServer) Stop(ctx context.Context) error { slog.Info("Stopping SMTP server") return s.backend.Shutdown(ctx) } func NewSMTPServer(config *Config) model.Server { be := &Backend{ config: config, } smtpBackend := smtp.NewServer(be) smtpBackend.Addr = fmt.Sprintf(":%d", config.Port) // smtpBackend.Domain = "localhost" smtpBackend.WriteTimeout = 10 * time.Second smtpBackend.ReadTimeout = 10 * time.Second smtpBackend.MaxMessageBytes = 1024 * 1024 smtpBackend.MaxRecipients = 50 smtpBackend.AllowInsecureAuth = true return &smtpServer{ backend: smtpBackend, } } // The Backend implements SMTP server methods. type Backend struct { config *Config } // NewSession is called after client greeting (EHLO, HELO). func (bkd *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) { return &Session{ forwarderFunc: bkd.forwardEmail, }, nil } func (bkd *Backend) forwardEmail(email ReceivedEmail) error { slog.Info("forwading message", slog.String("to", strings.Join(email.Recipients, ","))) for _, r := range bkd.config.Recipients { for _, a := range email.Recipients { if slices.Contains(r.Addresses, a) { urlParams := url.Values{ "title": {email.Msg.Header.Get("Subject")}, } destinationURL := r.GetTargetURL() destinationURL.RawQuery = urlParams.Encode() if err := shoutrrr.Send(destinationURL.String(), email.Body()); err != nil { slog.Error("Error sending message", slog.String("err", err.Error())) continue } // Already sent via this configuration, go to the next break } } } return nil } // A Session is returned after successful login. type Session struct { addresses []string body string forwarderFunc func(ReceivedEmail) error } // AuthMechanisms returns a slice of available auth mechanisms; only PLAIN is // supported in this example. func (s *Session) AuthMechanisms() []string { return []string{sasl.Plain} } // Auth is the handler for supported authenticators. func (s *Session) Auth(mech string) (sasl.Server, error) { return sasl.NewPlainServer(func(identity, username, password string) error { if username != "username" || password != "password" { return errors.New("Invalid username or password") } 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 } func main() { var config *Config configPath := "config.toml" f, err := os.Open(configPath) if err != nil { slog.Error("Error opening config file", slog.String("err", err.Error()), slog.String("path", configPath)) return } enc := encoding.NewTOMLEncoding() if err := enc.DecodeReader(f, &config); err != nil { slog.Error("Error decoding config file", slog.String("err", err.Error()), slog.String("path", configPath)) return } config.SetDefaults() slog.Info("config loaded", slog.Int("recipients", len(config.Recipients))) ctx := context.Background() smtpServer := NewSMTPServer(config) svc, err := service.NewService([]model.Server{ smtpServer, }) if err != nil { slog.Error("Error creating service:", slog.String("err", err.Error())) return } if err := svc.Start(ctx); err != nil { slog.Error("Error starting service:", slog.String("err", err.Error())) return } if err := svc.WaitStop(ctx); err != nil { slog.Error("Error waiting for service interruption:", slog.String("err", err.Error())) } }