Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 20 additions & 16 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", ""),
Expand Down
13 changes: 13 additions & 0 deletions internal/config/smtp_relay_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
45 changes: 35 additions & 10 deletions internal/handler/email_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"time"
Expand All @@ -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
Expand All @@ -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.
//
Expand All @@ -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
Expand Down Expand Up @@ -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))
}
Expand Down
2 changes: 2 additions & 0 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions internal/mailer/smtp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/mailer/smtp_dial_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
103 changes: 103 additions & 0 deletions internal/mailer/smtp_relay_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
6 changes: 3 additions & 3 deletions internal/mailer/smtp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"},
Expand Down
12 changes: 7 additions & 5 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading