server.go raw

   1  package smtp
   2  
   3  import (
   4  	"context"
   5  	"fmt"
   6  	"net"
   7  	"time"
   8  
   9  	gosmtp "github.com/emersion/go-smtp"
  10  	"next.orly.dev/pkg/lol/log"
  11  )
  12  
  13  // InboundEmail represents a parsed inbound email ready for bridge processing.
  14  type InboundEmail struct {
  15  	From        string
  16  	To          []string
  17  	RawMessage  []byte
  18  	ReceivedAt  time.Time
  19  }
  20  
  21  // InboundHandler is called when a valid inbound email is received.
  22  type InboundHandler func(email *InboundEmail) error
  23  
  24  // ServerConfig holds SMTP server configuration.
  25  type ServerConfig struct {
  26  	// Domain is the bridge's email domain (e.g., "relay.example.com").
  27  	Domain string
  28  	// ListenAddr is the address to listen on (e.g., "0.0.0.0:2525").
  29  	ListenAddr string
  30  	// MaxMessageBytes is the maximum message size (default: 25MB).
  31  	MaxMessageBytes int64
  32  	// MaxRecipients is the maximum recipients per message (default: 10).
  33  	MaxRecipients int
  34  	// ReadTimeout is the per-command read timeout (default: 60s).
  35  	ReadTimeout time.Duration
  36  	// WriteTimeout is the per-command write timeout (default: 60s).
  37  	WriteTimeout time.Duration
  38  }
  39  
  40  // DefaultServerConfig returns sensible defaults for the SMTP server.
  41  func DefaultServerConfig(domain string) ServerConfig {
  42  	return ServerConfig{
  43  		Domain:          domain,
  44  		ListenAddr:      "0.0.0.0:2525",
  45  		MaxMessageBytes: 25 * 1024 * 1024, // 25MB
  46  		MaxRecipients:   10,
  47  		ReadTimeout:     60 * time.Second,
  48  		WriteTimeout:    60 * time.Second,
  49  	}
  50  }
  51  
  52  // Server wraps go-smtp to receive inbound emails for the bridge.
  53  type Server struct {
  54  	cfg     ServerConfig
  55  	handler InboundHandler
  56  	server  *gosmtp.Server
  57  	ln      net.Listener
  58  }
  59  
  60  // NewServer creates an SMTP server with the given configuration.
  61  // The handler is called for each valid inbound email.
  62  func NewServer(cfg ServerConfig, handler InboundHandler) *Server {
  63  	backend := &backend{
  64  		domain:  cfg.Domain,
  65  		handler: handler,
  66  	}
  67  
  68  	s := gosmtp.NewServer(backend)
  69  	s.Addr = cfg.ListenAddr
  70  	s.Domain = cfg.Domain
  71  	s.AllowInsecureAuth = true // We're behind a reverse proxy
  72  	s.MaxRecipients = cfg.MaxRecipients
  73  
  74  	if cfg.MaxMessageBytes > 0 {
  75  		s.MaxMessageBytes = cfg.MaxMessageBytes
  76  	}
  77  	if cfg.ReadTimeout > 0 {
  78  		s.ReadTimeout = cfg.ReadTimeout
  79  	}
  80  	if cfg.WriteTimeout > 0 {
  81  		s.WriteTimeout = cfg.WriteTimeout
  82  	}
  83  
  84  	return &Server{
  85  		cfg:     cfg,
  86  		handler: handler,
  87  		server:  s,
  88  	}
  89  }
  90  
  91  // Start begins listening for SMTP connections.
  92  func (s *Server) Start() error {
  93  	ln, err := net.Listen("tcp", s.cfg.ListenAddr)
  94  	if err != nil {
  95  		return fmt.Errorf("listen %s: %w", s.cfg.ListenAddr, err)
  96  	}
  97  	s.ln = ln
  98  
  99  	log.I.F("SMTP server listening on %s for domain %s", s.cfg.ListenAddr, s.cfg.Domain)
 100  
 101  	go func() {
 102  		if err := s.server.Serve(ln); err != nil {
 103  			log.E.F("SMTP server error: %v", err)
 104  		}
 105  	}()
 106  
 107  	return nil
 108  }
 109  
 110  // Stop gracefully shuts down the SMTP server.
 111  func (s *Server) Stop(ctx context.Context) error {
 112  	if s.server == nil {
 113  		return nil
 114  	}
 115  	log.I.F("SMTP server shutting down")
 116  	return s.server.Shutdown(ctx)
 117  }
 118  
 119  // Addr returns the listener's address, useful for tests.
 120  func (s *Server) Addr() net.Addr {
 121  	if s.ln == nil {
 122  		return nil
 123  	}
 124  	return s.ln.Addr()
 125  }
 126