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 d16e3fba..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. | @@ -354,3 +355,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 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 5fb2d946..b15e5511 100644 --- a/internal/mailer/smtp.go +++ b/internal/mailer/smtp.go @@ -35,6 +35,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 @@ -48,8 +50,16 @@ 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 { +func NewSMTP(host, port, connectHost, connectPort, username, password string, implicitTLS, startTLS bool, from, fromName string) *SMTP { + if connectHost == "" { + connectHost = host + } + if connectPort == "" { + connectPort = port + } return &SMTP{ + connectHost: connectHost, + connectPort: connectPort, host: host, port: port, username: username, @@ -81,7 +91,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) // Built before anything is dialed, so an unusable recipient costs no connection. raw, err := s.buildRaw(msg) if err != nil { 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 new file mode 100644 index 00000000..333a06ed --- /dev/null +++ b/internal/mailer/smtp_relay_test.go @@ -0,0 +1,103 @@ +package mailer + +import ( + "bufio" + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" +) + +func TestSMTPRelayPreservesTLSIdentity(t *testing.T) { + 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) + } + }) + } +} + +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) + } + } +} 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..3d2a3825 --- /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,"email_from":"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") + } + }) + } +}