From 93ddf1b42c1343664b177b738be2ed81bfefdfa0 Mon Sep 17 00:00:00 2001 From: Marijn Bent Date: Sun, 20 Sep 2026 20:33:14 +0200 Subject: [PATCH 1/3] feat(mailer): support a separate TCP relay address --- DEPLOY.md | 4 +++ internal/mailer/smtp.go | 15 ++++++++- internal/mailer/smtp_relay_test.go | 52 ++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 internal/mailer/smtp_relay_test.go diff --git a/DEPLOY.md b/DEPLOY.md index d16e3fba..f18a4746 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -354,3 +354,7 @@ first event type + availability. | Email `550 domain not verified` | From address domain isn't verified with your email provider. | | Logo broken in email when testing locally | Gmail's image proxy can't reach `localhost` — only loads from a public URL. | | Litestream `InvalidAccessKeyId` / 403, log shows `endpoint=""` | `LITESTREAM_ENDPOINT` unset (or the running build predates the endpoint/region config) → Litestream defaults to AWS. Set the **account** endpoint (no bucket) + `region=auto` for R2, and redeploy so the config is live. | + +### SMTP through a TCP relay + +Set `EMAIL_SMTP_CONNECT_HOST` and `EMAIL_SMTP_CONNECT_PORT` to dial a TCP relay instead of the configured SMTP address. Each unset value falls back to the SMTP host or port. The original SMTP host remains the TLS server name and authentication identity; certificate verification stays enabled. These overrides apply to both environment and database SMTP settings. The relay must forward the SMTP connection without terminating TLS. diff --git a/internal/mailer/smtp.go b/internal/mailer/smtp.go index 26a478b2..34350dc0 100644 --- a/internal/mailer/smtp.go +++ b/internal/mailer/smtp.go @@ -11,6 +11,7 @@ import ( "net" "net/mail" "net/smtp" + "os" "strings" "time" @@ -35,6 +36,8 @@ var defaultSMTPTimeout = 30 * time.Second // SMTP sends email via an SMTP server. type SMTP struct { + connectHost string + connectPort string host string port string username string @@ -49,7 +52,17 @@ type SMTP struct { // startTLS selects port-587 STARTTLS mode. Both false means plain SMTP // (suitable for a local relay on port 25). func NewSMTP(host, port, username, password string, implicitTLS, startTLS bool, from, fromName string) *SMTP { + connectHost := os.Getenv("EMAIL_SMTP_CONNECT_HOST") + if connectHost == "" { + connectHost = host + } + connectPort := os.Getenv("EMAIL_SMTP_CONNECT_PORT") + if connectPort == "" { + connectPort = port + } return &SMTP{ + connectHost: connectHost, + connectPort: connectPort, host: host, port: port, username: username, @@ -81,7 +94,7 @@ func newDialers(deadline time.Time, host string) (net.Dialer, tls.Dialer) { } func (s *SMTP) Send(ctx context.Context, msg Message) error { - addr := net.JoinHostPort(s.host, s.port) + addr := net.JoinHostPort(s.connectHost, s.connectPort) raw := s.buildRaw(msg) // Bounds the whole conversation, not just the dial (see defaultSMTPTimeout). diff --git a/internal/mailer/smtp_relay_test.go b/internal/mailer/smtp_relay_test.go new file mode 100644 index 00000000..99841a92 --- /dev/null +++ b/internal/mailer/smtp_relay_test.go @@ -0,0 +1,52 @@ +package mailer + +import ( + "context" + "crypto/tls" + "errors" + "net" + "testing" + "time" +) + +func TestSMTPRelayPreservesTLSIdentity(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + t.Setenv("EMAIL_SMTP_CONNECT_HOST", host) + t.Setenv("EMAIL_SMTP_CONNECT_PORT", port) + names := make(chan string, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(3 * time.Second)) + server := tls.Server(conn, &tls.Config{GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + names <- hello.ServerName + return nil, errors.New("test rejects TLS handshake") + }}) + _ = server.Handshake() + }() + sender := NewSMTP("smtp.example.com", "465", "", "", true, false, "from@example.com", "Test") + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err := sender.Send(ctx, Message{To: []string{"to@example.com"}, Subject: "test"}); err == nil { + t.Fatal("accepted a rejected TLS handshake") + } + select { + case name := <-names: + if name != "smtp.example.com" { + t.Fatalf("TLS server name = %q", name) + } + case <-ctx.Done(): + t.Fatal("did not connect to relay") + } +} From 1a9fce347074a2896b8d3da45fb3b48f91b23147 Mon Sep 17 00:00:00 2001 From: Marijn Bent Date: Mon, 21 Sep 2026 10:30:56 +0200 Subject: [PATCH 2/3] fix(mailer): pass relay configuration explicitly through mailer setup --- .env.example | 2 + DEPLOY.md | 3 +- docs/ARCHITECTURE.md | 1 + internal/config/config.go | 36 +++++---- internal/config/smtp_relay_test.go | 13 +++ internal/handler/email_settings.go | 45 ++++++++--- internal/handler/handler.go | 2 + internal/mailer/smtp.go | 5 +- internal/mailer/smtp_dial_test.go | 2 +- internal/mailer/smtp_relay_test.go | 123 ++++++++++++++++++++--------- internal/mailer/smtp_test.go | 6 +- internal/server/server.go | 12 +-- internal/server/smtp_relay_test.go | 107 +++++++++++++++++++++++++ 13 files changed, 281 insertions(+), 76 deletions(-) create mode 100644 internal/config/smtp_relay_test.go create mode 100644 internal/server/smtp_relay_test.go diff --git a/.env.example b/.env.example index 978db08e..90ff278b 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,8 @@ MICROSOFT_TENANT=common # SMTP (optional — emails are skipped if not set) EMAIL_SMTP_HOST= EMAIL_SMTP_PORT=587 +EMAIL_SMTP_CONNECT_HOST= +EMAIL_SMTP_CONNECT_PORT= EMAIL_SMTP_USER= EMAIL_SMTP_PASS= EMAIL_FROM_ADDRESS=bookings@localhost diff --git a/DEPLOY.md b/DEPLOY.md index f18a4746..9956f710 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -30,6 +30,7 @@ This guide covers a generic Docker deploy and a step-by-step **Railway** deploy | `DATABASE_URL` | no | `sqlite://./data/calnode.db` | Point at the persistent volume, e.g. `sqlite:///data/calnode.db`. | | `PORT` | no | `3000` | The app listens on `$PORT`. Many platforms inject their own (Railway injects `8080`) — let them. | | `EMAIL_SMTP_HOST` / `_PORT` / `_USER` / `_PASS` | no¹ | — / `587` | SMTP. Can also be set later in Settings → Email (DB-stored, encrypted). | +| `EMAIL_SMTP_CONNECT_HOST` / `EMAIL_SMTP_CONNECT_PORT` | no | SMTP host / port | TCP relay address, loaded at startup. Applies to environment and database SMTP settings; TLS and authentication still use the SMTP host. | | `EMAIL_SMTP_TLS` / `_STARTTLS` | no | `false` | `STARTTLS` for 587, implicit `TLS` for 465. | | `EMAIL_FROM_ADDRESS` / `EMAIL_FROM_NAME` | no | `bookings@localhost` / `Calnode` | The From identity. | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | no | — | Google sign-in + calendar. Can also be set in Settings → Google OAuth. | @@ -357,4 +358,4 @@ first event type + availability. ### SMTP through a TCP relay -Set `EMAIL_SMTP_CONNECT_HOST` and `EMAIL_SMTP_CONNECT_PORT` to dial a TCP relay instead of the configured SMTP address. Each unset value falls back to the SMTP host or port. The original SMTP host remains the TLS server name and authentication identity; certificate verification stays enabled. These overrides apply to both environment and database SMTP settings. The relay must forward the SMTP connection without terminating TLS. +Set `EMAIL_SMTP_CONNECT_HOST` and `EMAIL_SMTP_CONNECT_PORT` to dial a TCP relay instead of the configured SMTP address. Each unset value falls back to the SMTP host or port. The original SMTP host remains the TLS server name and authentication identity; certificate verification stays enabled. These overrides are loaded at startup and apply to both environment and database SMTP settings, including settings reloads and test emails. Restart after changing the relay environment variables. An active relay is logged when the SMTP mailer is configured. The relay must forward the SMTP connection without terminating TLS. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b870dfc6..5a51f6a8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -643,6 +643,7 @@ as the desired state: genuinely broken SMTP config. Explicit credentials state intent. `GET /v1/settings/email` returns the live `transport`, and the admin UI badges it, so filled-in SMTP fields are never mistaken for SMTP delivery. +- **TCP relay:** `EMAIL_SMTP_CONNECT_HOST` / `EMAIL_SMTP_CONNECT_PORT` are loaded by `internal/config` and passed through `SMTPConfig` / `BuildMailer` at boot and settings reload. They change only the dial address; TLS and authentication keep the original SMTP host. Active overrides are logged. - **The dial must stay bounded.** `defaultSMTPTimeout` once applied only via `conn.SetDeadline`, which runs *after* the dial returns, leaving the dial itself bounded by the OS SYN-retry limit (~2 min). Against a packet-dropping host that stalls the job diff --git a/internal/config/config.go b/internal/config/config.go index bf63157e..6cb178e3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,14 +20,16 @@ type Config struct { LogLevel slog.Level // Email / SMTP - SMTPHost string - SMTPPort string - SMTPUser string - SMTPPass string - SMTPTLS bool // implicit TLS (port 465) - SMTPStartTLS bool // STARTTLS (port 587) - EmailFrom string - EmailFromName string + SMTPConnectHost string + SMTPConnectPort string + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPass string + SMTPTLS bool // implicit TLS (port 465) + SMTPStartTLS bool // STARTTLS (port 587) + EmailFrom string + EmailFromName string // Google OAuth (calendar + sign-in) GoogleClientID string @@ -108,14 +110,16 @@ func Load() *Config { DatabaseURL: getEnv("DATABASE_URL", "sqlite://./data/calnode.db"), BaseURL: getEnv("BASE_URL", "http://localhost:3000"), - SMTPHost: getEnv("EMAIL_SMTP_HOST", ""), - SMTPPort: getEnv("EMAIL_SMTP_PORT", "587"), - SMTPUser: getEnv("EMAIL_SMTP_USER", ""), - SMTPPass: getEnv("EMAIL_SMTP_PASS", ""), - SMTPTLS: getBool("EMAIL_SMTP_TLS", false), - SMTPStartTLS: getBool("EMAIL_SMTP_STARTTLS", false), - EmailFrom: getEnv("EMAIL_FROM_ADDRESS", "bookings@localhost"), - EmailFromName: getEnv("EMAIL_FROM_NAME", "Calnode"), + SMTPConnectHost: getEnv("EMAIL_SMTP_CONNECT_HOST", ""), + SMTPConnectPort: getEnv("EMAIL_SMTP_CONNECT_PORT", ""), + SMTPHost: getEnv("EMAIL_SMTP_HOST", ""), + SMTPPort: getEnv("EMAIL_SMTP_PORT", "587"), + SMTPUser: getEnv("EMAIL_SMTP_USER", ""), + SMTPPass: getEnv("EMAIL_SMTP_PASS", ""), + SMTPTLS: getBool("EMAIL_SMTP_TLS", false), + SMTPStartTLS: getBool("EMAIL_SMTP_STARTTLS", false), + EmailFrom: getEnv("EMAIL_FROM_ADDRESS", "bookings@localhost"), + EmailFromName: getEnv("EMAIL_FROM_NAME", "Calnode"), GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""), GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""), diff --git a/internal/config/smtp_relay_test.go b/internal/config/smtp_relay_test.go new file mode 100644 index 00000000..ebb9d540 --- /dev/null +++ b/internal/config/smtp_relay_test.go @@ -0,0 +1,13 @@ +package config + +import "testing" + +func TestLoadSMTPConnectAddress(t *testing.T) { + t.Setenv("EMAIL_SMTP_HOST", "smtp.example.com") + t.Setenv("EMAIL_SMTP_CONNECT_HOST", "relay.example.com") + t.Setenv("EMAIL_SMTP_CONNECT_PORT", "2525") + cfg := Load() + if cfg.SMTPHost != "smtp.example.com" || cfg.SMTPConnectHost != "relay.example.com" || cfg.SMTPConnectPort != "2525" { + t.Fatal("SMTP identity and relay address were not loaded separately") + } +} diff --git a/internal/handler/email_settings.go b/internal/handler/email_settings.go index b8adc731..80a13bc7 100644 --- a/internal/handler/email_settings.go +++ b/internal/handler/email_settings.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "strconv" "time" @@ -17,14 +18,16 @@ import ( // SMTPConfig holds decrypted email settings loaded from the DB. // Used by server.go to build the initial mailer on startup. type SMTPConfig struct { - Host string - Port string - User string - Pass string - TLS bool - StartTLS bool - From string - FromName string + ConnectHost string + ConnectPort string + Host string + Port string + User string + Pass string + TLS bool + StartTLS bool + From string + FromName string // ResendAPIKey, when set, switches delivery to Resend's HTTPS API instead of SMTP. ResendAPIKey string @@ -40,6 +43,28 @@ const ( TransportSMTP EmailTransport = "smtp" ) +func (h *Handler) SetSMTPConnectAddress(host, port string) { + h.smtpConnectHost = host + h.smtpConnectPort = port +} + +func (h *Handler) BuildMailer(cfg SMTPConfig) (mailer.Mailer, EmailTransport) { + cfg.ConnectHost = h.smtpConnectHost + cfg.ConnectPort = h.smtpConnectPort + m, transport := BuildMailer(cfg) + if transport == TransportSMTP && (cfg.ConnectHost != "" || cfg.ConnectPort != "") { + host, port := cfg.ConnectHost, cfg.ConnectPort + if host == "" { + host = cfg.Host + } + if port == "" { + port = cfg.Port + } + h.logger.Info("mailer: SMTP relay active", "connect_address", net.JoinHostPort(host, port), "smtp_host", cfg.Host) + } + return m, transport +} + // BuildMailer picks the delivery transport for a config, and is the single place that // decision is made - both boot and the settings-save path call it, so they cannot drift. // @@ -57,7 +82,7 @@ func BuildMailer(cfg SMTPConfig) (mailer.Mailer, EmailTransport) { case cfg.ResendAPIKey != "": return mailer.NewResend(cfg.ResendAPIKey, cfg.From, cfg.FromName), TransportResend case cfg.Host != "": - return mailer.NewSMTP(cfg.Host, cfg.Port, cfg.User, cfg.Pass, + return mailer.NewSMTP(cfg.Host, cfg.Port, cfg.ConnectHost, cfg.ConnectPort, cfg.User, cfg.Pass, cfg.TLS, cfg.StartTLS, cfg.From, cfg.FromName), TransportSMTP default: return &mailer.Noop{}, TransportNone @@ -311,7 +336,7 @@ func (h *Handler) PatchEmailSettings(w http.ResponseWriter, r *http.Request) { if cfg == nil { h.live.Swap(&mailer.Noop{}) } else { - m, transport := BuildMailer(*cfg) + m, transport := h.BuildMailer(*cfg) h.live.Swap(m) h.logger.InfoContext(r.Context(), "mailer: reconfigured", "transport", string(transport)) } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 6adbb088..05898e14 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -25,6 +25,8 @@ type Handler struct { logger *slog.Logger bookingSvc *booking.Service mailer mailer.Mailer + smtpConnectHost string + smtpConnectPort string live *mailer.Live // non-nil in production; nil in tests using a direct stub encKey [32]byte // AES-256 key for encrypting secrets stored in the DB calMu sync.RWMutex diff --git a/internal/mailer/smtp.go b/internal/mailer/smtp.go index 3f9a4637..b15e5511 100644 --- a/internal/mailer/smtp.go +++ b/internal/mailer/smtp.go @@ -11,7 +11,6 @@ import ( "net" "net/mail" "net/smtp" - "os" "strings" "time" @@ -51,12 +50,10 @@ type SMTP struct { // NewSMTP constructs an SMTP sender. implicitTLS selects port-465 mode; // startTLS selects port-587 STARTTLS mode. Both false means plain SMTP // (suitable for a local relay on port 25). -func NewSMTP(host, port, username, password string, implicitTLS, startTLS bool, from, fromName string) *SMTP { - connectHost := os.Getenv("EMAIL_SMTP_CONNECT_HOST") +func NewSMTP(host, port, connectHost, connectPort, username, password string, implicitTLS, startTLS bool, from, fromName string) *SMTP { if connectHost == "" { connectHost = host } - connectPort := os.Getenv("EMAIL_SMTP_CONNECT_PORT") if connectPort == "" { connectPort = port } diff --git a/internal/mailer/smtp_dial_test.go b/internal/mailer/smtp_dial_test.go index 2c32bdd0..90bbbe6a 100644 --- a/internal/mailer/smtp_dial_test.go +++ b/internal/mailer/smtp_dial_test.go @@ -43,7 +43,7 @@ func TestSend_unreachableHostFailsPromptly(t *testing.T) { defaultSMTPTimeout = 500 * time.Millisecond t.Cleanup(func() { defaultSMTPTimeout = orig }) - s := NewSMTP("203.0.113.1", "587", "user", "pass", false, true, "from@example.com", "From") + s := NewSMTP("203.0.113.1", "587", "", "", "user", "pass", false, true, "from@example.com", "From") done := make(chan error, 1) start := time.Now() diff --git a/internal/mailer/smtp_relay_test.go b/internal/mailer/smtp_relay_test.go index 99841a92..333a06ed 100644 --- a/internal/mailer/smtp_relay_test.go +++ b/internal/mailer/smtp_relay_test.go @@ -1,52 +1,103 @@ package mailer import ( + "bufio" "context" "crypto/tls" "errors" + "fmt" "net" + "strings" "testing" "time" ) func TestSMTPRelayPreservesTLSIdentity(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) + for _, startTLS := range []bool{false, true} { + t.Run(fmt.Sprintf("startTLS=%v", startTLS), func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + names := make(chan string, 1) + serverResult := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverResult <- err + return + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { + serverResult <- err + return + } + if startTLS { + reader := bufio.NewReader(conn) + for _, step := range []struct{ reply, command string }{ + {"220 smtp.example.com ready\r\n", "EHLO "}, + {"250-smtp.example.com\r\n250 STARTTLS\r\n", "STARTTLS\r\n"}, + } { + if _, err := fmt.Fprint(conn, step.reply); err != nil { + serverResult <- err + return + } + line, err := reader.ReadString('\n') + if err != nil || !strings.HasPrefix(line, step.command) { + serverResult <- fmt.Errorf("command %q: %v", line, err) + return + } + } + if _, err := fmt.Fprint(conn, "220 begin TLS\r\n"); err != nil { + serverResult <- err + return + } + } + server := tls.Server(conn, &tls.Config{GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + names <- hello.ServerName + return nil, errors.New("test rejects TLS handshake") + }}) + if err := server.Handshake(); err == nil { + serverResult <- errors.New("handshake unexpectedly succeeded") + return + } + serverResult <- nil + }() + sender := NewSMTP("smtp.example.com", "465", host, port, "", "", !startTLS, startTLS, "from@example.com", "Test") + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err := sender.Send(ctx, Message{To: []string{"to@example.com"}, Subject: "test"}); err == nil { + t.Fatal("accepted a rejected TLS handshake") + } + select { + case name := <-names: + if name != "smtp.example.com" { + t.Fatalf("TLS server name = %q", name) + } + case <-ctx.Done(): + t.Fatal("did not connect to relay") + } + if err := <-serverResult; err != nil { + t.Fatal(err) + } + }) } - defer listener.Close() - host, port, err := net.SplitHostPort(listener.Addr().String()) - if err != nil { - t.Fatal(err) - } - t.Setenv("EMAIL_SMTP_CONNECT_HOST", host) - t.Setenv("EMAIL_SMTP_CONNECT_PORT", port) - names := make(chan string, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - return - } - defer conn.Close() - conn.SetDeadline(time.Now().Add(3 * time.Second)) - server := tls.Server(conn, &tls.Config{GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { - names <- hello.ServerName - return nil, errors.New("test rejects TLS handshake") - }}) - _ = server.Handshake() - }() - sender := NewSMTP("smtp.example.com", "465", "", "", true, false, "from@example.com", "Test") - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - if err := sender.Send(ctx, Message{To: []string{"to@example.com"}, Subject: "test"}); err == nil { - t.Fatal("accepted a rejected TLS handshake") - } - select { - case name := <-names: - if name != "smtp.example.com" { - t.Fatalf("TLS server name = %q", name) +} + +func TestSMTPConnectAddressDefaults(t *testing.T) { + for _, tc := range []struct{ host, port, wantHost, wantPort string }{ + {"", "", "smtp.example.com", "465"}, + {"relay.example.com", "", "relay.example.com", "465"}, + {"", "2525", "smtp.example.com", "2525"}, + } { + s := NewSMTP("smtp.example.com", "465", tc.host, tc.port, "", "", true, false, "from@example.com", "Test") + if s.connectHost != tc.wantHost || s.connectPort != tc.wantPort { + t.Fatalf("connect address = %s:%s, want %s:%s", s.connectHost, s.connectPort, tc.wantHost, tc.wantPort) } - case <-ctx.Done(): - t.Fatal("did not connect to relay") } } diff --git a/internal/mailer/smtp_test.go b/internal/mailer/smtp_test.go index 9b8fbf8b..1421172f 100644 --- a/internal/mailer/smtp_test.go +++ b/internal/mailer/smtp_test.go @@ -36,7 +36,7 @@ func TestSMTP_Send_timesOutOnUnresponsiveServer(t *testing.T) { t.Fatalf("split addr: %v", err) } - s := NewSMTP(host, port, "", "", false, false, "from@test.local", "Test") + s := NewSMTP(host, port, "", "", "", "", false, false, "from@test.local", "Test") // A short ctx deadline, not defaultSMTPTimeout's 30s, keeps this test fast — Send picks // whichever deadline is earlier, so this proves ctx is actually honoured end-to-end, not @@ -83,7 +83,7 @@ func TestSMTP_Send_appliesDefaultTimeoutWithNoCtxDeadline(t *testing.T) { t.Fatalf("split addr: %v", err) } - s := NewSMTP(host, port, "", "", false, false, "from@test.local", "Test") + s := NewSMTP(host, port, "", "", "", "", false, false, "from@test.local", "Test") start := time.Now() // No deadline on this ctx at all — must still fall back to defaultSMTPTimeout. @@ -129,7 +129,7 @@ func TestSMTP_Send_invalidRecipientNeverDials(t *testing.T) { if err != nil { t.Fatalf("split addr: %v", err) } - s := NewSMTP(host, port, "", "", false, false, "from@test.local", "Test") + s := NewSMTP(host, port, "", "", "", "", false, false, "from@test.local", "Test") err = s.Send(context.Background(), Message{ To: []string{"a@b.example\r\nBcc: attacker@example.com"}, diff --git a/internal/server/server.go b/internal/server/server.go index d8e46b73..882d6804 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -49,6 +49,7 @@ func BuildHandler(ctx context.Context, cfg *config.Config, db *sql.DB, logger *s } h.SetDataDir(dataDir) h.SetEncKey(cfg.EncryptionKey) + h.SetSMTPConnectAddress(cfg.SMTPConnectHost, cfg.SMTPConnectPort) h.SetDemoMode(cfg.DemoMode) h.SetDemoResetInterval(cfg.DemoResetInterval) @@ -96,16 +97,17 @@ func BuildHandler(ctx context.Context, cfg *config.Config, db *sql.DB, logger *s case dbSMTP != nil: // BuildMailer, not NewSMTP directly, so boot and the settings-save path pick the // transport by the same rule. A Resend API key here means HTTPS delivery. - m, transport := handler.BuildMailer(*dbSMTP) + m, transport := h.BuildMailer(*dbSMTP) live.Swap(m) logger.Info("mailer: configured from database", "transport", string(transport), "host", dbSMTP.Host, "port", dbSMTP.Port) case cfg.SMTPHost != "": - live.Swap(mailer.NewSMTP( - cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, - cfg.SMTPTLS, cfg.SMTPStartTLS, cfg.EmailFrom, cfg.EmailFromName, - )) + m, _ := h.BuildMailer(handler.SMTPConfig{ + Host: cfg.SMTPHost, Port: cfg.SMTPPort, User: cfg.SMTPUser, Pass: cfg.SMTPPass, + TLS: cfg.SMTPTLS, StartTLS: cfg.SMTPStartTLS, From: cfg.EmailFrom, FromName: cfg.EmailFromName, + }) + live.Swap(m) logger.Info("mailer: configured from environment", "host", cfg.SMTPHost, "port", cfg.SMTPPort) // Seed env-var settings into DB so they appear in the UI on first boot. seedSMTPToDB(db, cfg, encKey, logger) diff --git a/internal/server/smtp_relay_test.go b/internal/server/smtp_relay_test.go new file mode 100644 index 00000000..fcc6be09 --- /dev/null +++ b/internal/server/smtp_relay_test.go @@ -0,0 +1,107 @@ +package server + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/calnode/calnode/internal/config" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/handler" +) + +func TestSMTPRelayAtBootAndSettingsReload(t *testing.T) { + for _, stored := range []bool{false, true} { + t.Run(fmt.Sprintf("stored=%v", stored), func(t *testing.T) { + names := make(chan string, 2) + relay := httptest.NewUnstartedServer(http.NotFoundHandler()) + relay.TLS = &tls.Config{GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + names <- hello.ServerName + return nil, errors.New("test rejects handshake") + }} + relay.StartTLS() + defer relay.Close() + host, port, err := net.SplitHostPort(relay.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + database, err := db.Open("sqlite://:memory:") + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := db.Migrate(database); err != nil { + t.Fatal(err) + } + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, nil)) + seed := handler.New(database, logger) + rec := httptest.NewRecorder() + seed.Setup(rec, httptest.NewRequest(http.MethodPost, "/v1/setup", strings.NewReader(`{"name":"Host","email":"host@example.com","timezone":"UTC"}`))) + if rec.Code != http.StatusCreated { + t.Fatalf("setup: %d %s", rec.Code, rec.Body) + } + var identity struct { + APIKey string `json:"api_key"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &identity); err != nil { + t.Fatal(err) + } + patch := func(h *handler.Handler, smtpHost string) { + t.Helper() + req := httptest.NewRequest(http.MethodPatch, "/v1/settings/email", strings.NewReader(fmt.Sprintf(`{"smtp_host":%q,"smtp_port":"465","smtp_tls":true,"from_address":"from@example.com"}`, smtpHost))) + req.Header.Set("Authorization", "Bearer "+identity.APIKey) + rec := httptest.NewRecorder() + h.RequireAuth(h.PatchEmailSettings)(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("settings: %d %s", rec.Code, rec.Body) + } + } + if stored { + patch(seed, "smtp.example.com") + } + ctx, cancel := context.WithCancel(t.Context()) + h, drain := BuildHandler(ctx, &config.Config{ + BaseURL: "https://calnode.localhost", DataDir: t.TempDir(), + SMTPHost: "smtp.example.com", SMTPPort: "465", SMTPTLS: true, + EmailFrom: "from@example.com", SMTPConnectHost: host, SMTPConnectPort: port, + }, database, logger) + defer func() { cancel(); drain() }() + for _, smtpHost := range []string{"smtp.example.com", "updated.example.com"} { + if smtpHost == "updated.example.com" { + patch(h, smtpHost) + } + req := httptest.NewRequest(http.MethodPost, "/v1/settings/email/test", nil) + req.Header.Set("Authorization", "Bearer "+identity.APIKey) + rec := httptest.NewRecorder() + h.RequireAuth(h.TestEmailConnection)(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("test email: %d %s", rec.Code, rec.Body) + } + select { + case name := <-names: + if name != smtpHost { + t.Fatalf("TLS name = %q, want %q", name, smtpHost) + } + case <-time.After(time.Second): + t.Fatal("test email did not reach relay") + } + } + cancel() + drain() + if !strings.Contains(logs.String(), "SMTP relay active") || !strings.Contains(logs.String(), relay.Listener.Addr().String()) { + t.Fatal("relay configuration was not logged") + } + }) + } +} From 5e2cf0ac1478cdab783eacbb7e47e4c7c190b637 Mon Sep 17 00:00:00 2001 From: Marijn Bent Date: Mon, 21 Sep 2026 10:35:57 +0200 Subject: [PATCH 3/3] test(mailer): use the email settings From field --- internal/server/smtp_relay_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/smtp_relay_test.go b/internal/server/smtp_relay_test.go index fcc6be09..3d2a3825 100644 --- a/internal/server/smtp_relay_test.go +++ b/internal/server/smtp_relay_test.go @@ -59,7 +59,7 @@ func TestSMTPRelayAtBootAndSettingsReload(t *testing.T) { } patch := func(h *handler.Handler, smtpHost string) { t.Helper() - req := httptest.NewRequest(http.MethodPatch, "/v1/settings/email", strings.NewReader(fmt.Sprintf(`{"smtp_host":%q,"smtp_port":"465","smtp_tls":true,"from_address":"from@example.com"}`, smtpHost))) + req := httptest.NewRequest(http.MethodPatch, "/v1/settings/email", strings.NewReader(fmt.Sprintf(`{"smtp_host":%q,"smtp_port":"465","smtp_tls":true,"email_from":"from@example.com"}`, smtpHost))) req.Header.Set("Authorization", "Bearer "+identity.APIKey) rec := httptest.NewRecorder() h.RequireAuth(h.PatchEmailSettings)(rec, req)