package smtp2shoutrrr import ( "context" "errors" "fmt" "log/slog" "net" "sync" "time" "github.com/emersion/go-smtp" ) const ( readTimeout = 10 * time.Second writeTimeout = 10 * time.Second // defaultShutdownTimeout bounds the drain of in-flight sessions. It must // stay well clear of readTimeout: an idle client is only dropped once its // read deadline expires, so a budget near readTimeout would report an // ordinary drain as a failed shutdown. defaultShutdownTimeout = readTimeout + 20*time.Second ) type Server struct { backend *smtp.Server // shutdownTimeout is a field rather than the constant so tests can drive a // real budget overrun without waiting out readTimeout. shutdownTimeout time.Duration mu sync.Mutex listener net.Listener addr string } // Addr reports the address the server is bound to, which is only known after // Start has bound it — relevant when the configured port is 0. func (s *Server) Addr() string { s.mu.Lock() defer s.mu.Unlock() return s.addr } func (s *Server) setListener(l net.Listener) { s.mu.Lock() defer s.mu.Unlock() s.listener = l s.addr = l.Addr().String() } func (s *Server) closeListener() { s.mu.Lock() l := s.listener s.listener = nil s.mu.Unlock() if l != nil { _ = l.Close() } } // Start binds the configured address and serves connections until ctx is // cancelled or Stop is called, then drains in-flight sessions. It returns nil // on a clean shutdown. func (s *Server) Start(ctx context.Context) error { // Binding here rather than inside ListenAndServe keeps the listener under // our control: go-smtp's Shutdown only closes listeners Serve has already // registered, so a stop that arrives first would otherwise leave Accept // running forever. l, err := net.Listen("tcp", s.backend.Addr) if err != nil { return fmt.Errorf("listen on %q: %w", s.backend.Addr, err) } s.setListener(l) slog.Info("Started SMTP server", slog.String("addr", l.Addr().String())) served := make(chan error, 1) go func() { served <- s.backend.Serve(l) }() select { case err := <-served: // Serve gave up on its own; still release the listener and drain any // sessions it left running before reporting why. s.closeListener() s.drain(context.WithoutCancel(ctx)) return err case <-ctx.Done(): } // Stop accepting and let Serve return before draining. go-smtp registers // each accepted session on a WaitGroup that Shutdown waits on from another // goroutine, so an Accept still in flight would Add to that WaitGroup // concurrently with the Wait — a data race, and a session the drain then // fails to wait for. s.closeListener() if err := <-served; err != nil && !errors.Is(err, net.ErrClosed) { return err } s.drain(context.WithoutCancel(ctx)) return nil } // drain stops the backend and waits for in-flight sessions to finish. Running // past the budget is still an intentional stop, so it is logged rather than // reported: turning it into an error exits the process non-zero on a routine // signal. func (s *Server) drain(ctx context.Context) { shutdownCtx, cancel := context.WithTimeout(ctx, s.shutdownTimeout) defer cancel() // Shutdown also closes the listener, which we have already closed, so its // close error is expected rather than a sign of a session still running. if err := s.Stop(shutdownCtx); err != nil && !errors.Is(err, smtp.ErrServerClosed) && !errors.Is(err, net.ErrClosed) { slog.Warn("SMTP server stopped with sessions still in flight", slog.String("err", err.Error())) } } // Stop shuts the server down and releases its listener. A Server is single // use: the backend cannot be restarted once stopped, so a later Start binds // the address and then immediately gives it up. func (s *Server) Stop(ctx context.Context) error { slog.Info("Stopping SMTP server") err := s.backend.Shutdown(ctx) s.closeListener() return err } func NewSMTPServer(config *Config) *Server { be := &Backend{ config: config, } smtpBackend := smtp.NewServer(be) smtpBackend.Addr = fmt.Sprintf(":%d", config.Port) smtpBackend.WriteTimeout = writeTimeout smtpBackend.ReadTimeout = readTimeout smtpBackend.MaxMessageBytes = 1024 * 1024 smtpBackend.MaxRecipients = 50 smtpBackend.AllowInsecureAuth = true return &Server{ backend: smtpBackend, shutdownTimeout: defaultShutdownTimeout, } }