0e20eb19c1
In-process go-smtp shim (Shape 1): apps → shim → hub → Resend, Resend key stays hub-side. From-header allowlist (reject 5xx pre-hub), single-shot raw-MIME forward, status→SMTP mapping. Global + per-app toggles gate compose-time env injection from .felhom.yml smtp_mapping. Hungarian UI on settings + app config pages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
353 lines
12 KiB
Go
353 lines
12 KiB
Go
// 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
|
|
}
|