// Package mailrelay is the in-process SMTP shim that gives deployed apps outbound // email through one managed path. It accepts SMTP from apps on the Docker network // (no Resend key on the box), validates the From-header domain, and forwards the raw // MIME to the hub, which holds the Resend key and re-emits the message unchanged. // // Architecture: Shape 1 — the shim runs IN-PROCESS inside felhom-controller (operator- // confirmed), reusing the controller's existing hub client. Listeners bind to the app // Docker network only; they are NEVER published to the host or internet. // // Validated end-to-end in SPIKE-smtp-app-relay-2026-06-28.md (verdict READY). package mailrelay import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "fmt" "io" "log" "math/big" "net" "strings" "time" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" ) // maxMessageBytes bounds a single message. Transactional app mail (password resets, // invites) is small; 10 MiB leaves generous headroom for inline images/attachments. const maxMessageBytes int64 = 10 << 20 // forwardTimeout bounds the synchronous hub forward (which itself dials Resend). const forwardTimeout = 30 * time.Second // Options configures the shim. type Options struct { PlainAddr string // plaintext + STARTTLS listener (default ":2525") TLSAddr string // implicit-TLS listener (default ":2465") ServiceName string // CN/SAN of the self-signed cert + SMTP greeting (e.g. "felhom-controller") Policy *Policy // From-domain allowlist (required) Forwarder Forwarder // hub forwarder (required) Logger *log.Logger } // Server runs the two SMTP listeners. type Server struct { opts Options tlsConf *tls.Config plainSrv *smtp.Server tlsSrv *smtp.Server plainLn net.Listener tlsLn net.Listener } // New builds the shim and its self-signed cert. It does not bind sockets — call Start. func New(opts Options) (*Server, error) { if opts.Policy == nil { return nil, fmt.Errorf("mailrelay: Policy is required") } if opts.Forwarder == nil { return nil, fmt.Errorf("mailrelay: Forwarder is required") } if opts.PlainAddr == "" { opts.PlainAddr = ":2525" } if opts.TLSAddr == "" { opts.TLSAddr = ":2465" } if opts.ServiceName == "" { opts.ServiceName = "felhom-controller" } if opts.Logger == nil { opts.Logger = log.Default() } cert, err := selfSignedCert(opts.ServiceName) if err != nil { return nil, fmt.Errorf("mailrelay: generating self-signed cert: %w", err) } tlsConf := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12} s := &Server{opts: opts, tlsConf: tlsConf} be := &backend{srv: s} // :2525 — plaintext with STARTTLS offered. AllowInsecureAuth lets an app present // AUTH before STARTTLS (apps send no creds, but some refuse to send without the offer). s.plainSrv = smtp.NewServer(be) s.plainSrv.Addr = opts.PlainAddr s.plainSrv.Domain = opts.ServiceName s.plainSrv.TLSConfig = tlsConf s.plainSrv.AllowInsecureAuth = true s.plainSrv.MaxMessageBytes = maxMessageBytes s.plainSrv.ReadTimeout = 60 * time.Second s.plainSrv.WriteTimeout = 60 * time.Second // :2465 — implicit TLS (the whole connection is TLS; for apps that force_tls). s.tlsSrv = smtp.NewServer(be) s.tlsSrv.Addr = opts.TLSAddr s.tlsSrv.Domain = opts.ServiceName s.tlsSrv.TLSConfig = tlsConf s.tlsSrv.MaxMessageBytes = maxMessageBytes s.tlsSrv.ReadTimeout = 60 * time.Second s.tlsSrv.WriteTimeout = 60 * time.Second return s, nil } // Start binds both listeners and serves them in background goroutines. A bind failure // is returned synchronously. The listeners must reach the app Docker network ONLY — // the caller is responsible for not publishing these ports to the host/internet. func (s *Server) Start() error { pl, err := net.Listen("tcp", s.opts.PlainAddr) if err != nil { return fmt.Errorf("mailrelay: listen %s: %w", s.opts.PlainAddr, err) } tl, err := tls.Listen("tcp", s.opts.TLSAddr, s.tlsConf) if err != nil { pl.Close() return fmt.Errorf("mailrelay: listen TLS %s: %w", s.opts.TLSAddr, err) } s.plainLn, s.tlsLn = pl, tl s.opts.Logger.Printf("[INFO] [mailrelay] plaintext+STARTTLS listener on %s", pl.Addr()) s.opts.Logger.Printf("[INFO] [mailrelay] implicit-TLS listener on %s", tl.Addr()) go func() { if err := s.plainSrv.Serve(pl); err != nil && !isClosedErr(err) { s.opts.Logger.Printf("[ERROR] [mailrelay] plaintext listener stopped: %v", err) } }() go func() { if err := s.tlsSrv.Serve(tl); err != nil && !isClosedErr(err) { s.opts.Logger.Printf("[ERROR] [mailrelay] implicit-TLS listener stopped: %v", err) } }() return nil } // PlainAddr / TLSAddr return the bound addresses (resolved ports — useful for tests // that listen on :0). func (s *Server) PlainAddr() string { if s.plainLn != nil { return s.plainLn.Addr().String() } return s.opts.PlainAddr } func (s *Server) TLSAddr() string { if s.tlsLn != nil { return s.tlsLn.Addr().String() } return s.opts.TLSAddr } // Close stops both listeners. func (s *Server) Close() error { var err error if s.plainSrv != nil { if e := s.plainSrv.Close(); e != nil { err = e } } if s.tlsSrv != nil { if e := s.tlsSrv.Close(); e != nil { err = e } } return err } func isClosedErr(err error) bool { return err != nil && strings.Contains(err.Error(), "use of closed network connection") } // ── go-smtp backend / session ──────────────────────────────────────────── type backend struct{ srv *Server } func (b *backend) NewSession(_ *smtp.Conn) (smtp.Session, error) { return &session{srv: b.srv}, nil } // session implements smtp.Session + smtp.AuthSession. type session struct { srv *Server from string to []string } func (s *session) Mail(from string, _ *smtp.MailOptions) error { s.from = from return nil } func (s *session) Rcpt(to string, _ *smtp.RcptOptions) error { s.to = append(s.to, to) return nil } // AuthMechanisms advertises PLAIN + LOGIN. We accept ANY credentials and ignore them // (apps send none, but some refuse to send without an AUTH offer; no credential is a // secret on the Docker network — the shim holds no Resend key). func (s *session) AuthMechanisms() []string { return []string{sasl.Plain, sasl.Login} } func (s *session) Auth(mech string) (sasl.Server, error) { switch mech { case sasl.Plain: return sasl.NewPlainServer(func(_, _, _ string) error { return nil }), nil case sasl.Login: return &loginServer{}, nil default: return nil, smtp.ErrAuthUnsupported } } // Data reads the raw message, enforces the From policy BEFORE any hub call, then // forwards it single-shot and maps the hub status to an SMTP reply. func (s *session) Data(r io.Reader) error { raw, err := io.ReadAll(io.LimitReader(r, maxMessageBytes+1)) if err != nil { s.srv.opts.Logger.Printf("[ERROR] [mailrelay] reading DATA: %v", err) return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 3, 0}, Message: "could not read message"} } if int64(len(raw)) > maxMessageBytes { return &smtp.SMTPError{Code: 552, EnhancedCode: smtp.EnhancedCode{5, 3, 4}, Message: "message too large"} } // From-policy gate (§7 B): reject with a clean 5xx BEFORE dialing the hub — never // spend a Resend call on a doomed message, and surface the misconfig loudly. if err := s.srv.opts.Policy.Check(raw); err != nil { s.srv.opts.Logger.Printf("[WARN] [mailrelay] rejected message (from=%q): %v", s.from, err) return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 7, 1}, Message: "sender address not permitted"} } ctx, cancel := context.WithTimeout(context.Background(), forwardTimeout) defer cancel() status, body, err := s.srv.opts.Forwarder.Forward(ctx, raw, s.from, s.to) if err != nil { // Transient: hub unreachable. Clean 4xx → the app shows the user an error; no // hang, no silent drop, no spool (§7 D / §9 rule 9). s.srv.opts.Logger.Printf("[ERROR] [mailrelay] forward to hub failed: %v", err) return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 4, 1}, Message: "upstream relay (hub) unavailable, try again later"} } if reply := mapStatusToSMTP(status, body); reply != nil { s.srv.opts.Logger.Printf("[WARN] [mailrelay] hub refused message (HTTP %d): %s", status, sanitizeReason(body)) return reply } s.srv.opts.Logger.Printf("[INFO] [mailrelay] message relayed (from=%q rcpts=%d bytes=%d hub=%d)", s.from, len(s.to), len(raw), status) return nil } func (s *session) Reset() { s.from = "" s.to = nil } func (s *session) Logout() error { return nil } // mapStatusToSMTP turns the hub's HTTP status into an SMTP reply so the app surfaces // the real outcome (spike §6): 2xx → nil (250 OK), 4xx → 451 transient (incl. 429 // rate-limit — retry later), 5xx → 554 permanent. The hub's reason is relayed where // present (sanitised to a single SMTP-safe line). func mapStatusToSMTP(status int, body string) error { switch { case status >= 200 && status < 300: return nil case status == 429: return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 7, 0}, Message: msgOr(sanitizeReason(body), "rate limit exceeded, try again later")} case status >= 400 && status < 500: return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 4, 1}, Message: msgOr(sanitizeReason(body), "upstream rejected (temporary)")} default: return &smtp.SMTPError{Code: 554, EnhancedCode: smtp.EnhancedCode{5, 0, 0}, Message: msgOr(sanitizeReason(body), "upstream rejected")} } } // sanitizeReason extracts a short, SMTP-safe single line from the hub's response body. // Never logs/relays a full body (which could echo addresses); caps at 200 chars. func sanitizeReason(body string) string { body = strings.TrimSpace(body) if body == "" { return "" } if i := strings.IndexAny(body, "\r\n"); i >= 0 { body = body[:i] } if len(body) > 200 { body = body[:200] } return body } func msgOr(s, def string) string { if s == "" { return def } return s } // loginServer implements the SMTP AUTH LOGIN exchange as a sasl.Server. go-sasl ships // a LOGIN *client* but no server (spike §2). It accepts ANY credentials and ignores // them. The exchange: challenge "Username:" → username → challenge "Password:" → // password → done. type loginServer struct{ step int } func (l *loginServer) Next(_ []byte) (challenge []byte, done bool, err error) { switch l.step { case 0: l.step++ return []byte("Username:"), false, nil case 1: l.step++ return []byte("Password:"), false, nil default: return nil, true, nil } } // selfSignedCert generates an in-process self-signed leaf for the shim (CN/SAN = the // service name). Apps on the Docker network are told to accept invalid certs; the cert // only terminates TLS for force_tls/starttls apps, it is not a trust anchor. func selfSignedCert(serviceName string) (tls.Certificate, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return tls.Certificate{}, err } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return tls.Certificate{}, err } tmpl := x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: serviceName}, DNSNames: []string{serviceName, "localhost"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, } der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) if err != nil { return tls.Certificate{}, err } return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil }