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