From c702f1bdac679fb68f1ef5944c8e85d567f93762 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 03:44:55 +0200 Subject: [PATCH] fix(email): explicit TLS mode for non-standard relay ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 465-only implicit-TLS heuristic mis-classified Selectel's SSL port (1127), which the mailer would have dialled with STARTTLS and failed. Add BACKEND_SMTP_TLS (ssl|starttls) — empty still derives the mode from the port (implicit on 465, else STARTTLS) — and dial implicit TLS with WithSSL()+WithPort so any port works, not just 465. Wire SMTP_RELAY_TLS through compose/ci/prod/.env.example and document it (Selectel: 1127 = SSL, 1126 = STARTTLS). Unit-tested. --- .gitea/workflows/ci.yaml | 1 + .gitea/workflows/prod-deploy.yaml | 2 ++ backend/README.md | 3 +- backend/internal/account/mailer.go | 40 ++++++++++++++++++------- backend/internal/account/mailer_test.go | 29 ++++++++++++++++++ backend/internal/config/config.go | 1 + deploy/.env.example | 12 +++++--- deploy/README.md | 5 ++-- deploy/docker-compose.yml | 13 ++++---- docs/ARCHITECTURE.md | 7 +++-- 10 files changed, 88 insertions(+), 25 deletions(-) create mode 100644 backend/internal/account/mailer_test.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index bbf9001..7527852 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -338,6 +338,7 @@ jobs: SMTP_RELAY_PASS: ${{ secrets.TEST_SMTP_RELAY_PASS }} SMTP_RELAY_HOST: ${{ vars.TEST_SMTP_RELAY_HOST }} SMTP_RELAY_PORT: ${{ vars.TEST_SMTP_RELAY_PORT }} + SMTP_RELAY_TLS: ${{ vars.TEST_SMTP_RELAY_TLS }} SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }} # Canonical public origin for links in the email (this contour's URL); # required by the backend whenever SMTP_RELAY_HOST is set. diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index 0228396..139ecde 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -91,6 +91,7 @@ jobs: SMTP_RELAY_PASS: ${{ secrets.PROD_SMTP_RELAY_PASS }} SMTP_RELAY_HOST: ${{ vars.PROD_SMTP_RELAY_HOST }} SMTP_RELAY_PORT: ${{ vars.PROD_SMTP_RELAY_PORT }} + SMTP_RELAY_TLS: ${{ vars.PROD_SMTP_RELAY_TLS }} SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }} PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }} @@ -151,6 +152,7 @@ jobs: export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY' export SMTP_RELAY_HOST='$SMTP_RELAY_HOST' export SMTP_RELAY_PORT='$SMTP_RELAY_PORT' + export SMTP_RELAY_TLS='$SMTP_RELAY_TLS' export SMTP_RELAY_USER='$SMTP_RELAY_USER' export SMTP_RELAY_PASS='$SMTP_RELAY_PASS' export SMTP_RELAY_FROM='$SMTP_RELAY_FROM' diff --git a/backend/README.md b/backend/README.md index bfaf5f2..3755184 100644 --- a/backend/README.md +++ b/backend/README.md @@ -215,7 +215,8 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p | `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. | | `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. | | `BACKEND_SMTP_HOST` | — | Confirm-code relay host. **Empty selects the development log mailer** (the code is logged, not sent). | -| `BACKEND_SMTP_PORT` | `587` | Relay port. `465` selects implicit TLS; any other port uses STARTTLS. No client certificate is needed (the server cert is validated against the system roots). | +| `BACKEND_SMTP_PORT` | `587` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | +| `BACKEND_SMTP_TLS` | — | Transport security: `ssl` (implicit TLS from connect) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); set it for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). | | `BACKEND_SMTP_USERNAME` | — | SMTP AUTH user; empty relays without authentication. | | `BACKEND_SMTP_PASSWORD` | — | SMTP AUTH password. | | `BACKEND_SMTP_FROM` | `no-reply@localhost` | From address. A deployed contour must use the prod domain (the relay only accepts its verified sender domain). | diff --git a/backend/internal/account/mailer.go b/backend/internal/account/mailer.go index a87454e..9a6a288 100644 --- a/backend/internal/account/mailer.go +++ b/backend/internal/account/mailer.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "time" "github.com/wneessen/go-mail" @@ -31,27 +32,46 @@ type Mailer interface { // SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer // instead, so a deployment without a relay still runs (the code lands in the log). // TLS is always used and no client certificate is required — only the server -// certificate is validated against the system roots. The Port selects the TLS -// mode: smtpImplicitTLSPort (465) uses implicit TLS (SSL); any other port uses -// mandatory STARTTLS. +// certificate is validated against the system roots. type SMTPConfig struct { Host string Port string Username string Password string From string + // TLS selects the transport security: "ssl" for implicit TLS from connect, or + // "starttls" to upgrade a plaintext connection. Empty derives the mode from the + // port (implicit TLS on 465, STARTTLS otherwise); set it explicitly for a relay on + // a non-standard port (e.g. Selectel's 1127 = SSL, 1126 = STARTTLS). + TLS string } const ( - // smtpImplicitTLSPort is the port on which the relay is dialled with implicit - // TLS (SSL). Any other port negotiates mandatory STARTTLS instead. - smtpImplicitTLSPort = 465 + // SMTP transport-security modes for SMTPConfig.TLS. + smtpTLSImplicit = "ssl" + smtpTLSSTARTTLS = "starttls" // smtpDialTimeout bounds a single relay connect-and-send. The confirm-code send // is synchronous on the request path, so an unreachable relay must fail fast // rather than hold the request open. smtpDialTimeout = 15 * time.Second ) +// tlsMode resolves the transport-security mode for the relay: the explicitly +// configured SMTPConfig.TLS, or — when unset — implicit TLS on the conventional SSL +// port 465 and STARTTLS on any other port. +func (cfg SMTPConfig) tlsMode(port int) string { + switch strings.ToLower(strings.TrimSpace(cfg.TLS)) { + case smtpTLSImplicit, "tls": + return smtpTLSImplicit + case smtpTLSSTARTTLS: + return smtpTLSSTARTTLS + } + if port == 465 { + return smtpTLSImplicit + } + return smtpTLSSTARTTLS +} + // SMTPMailer sends mail through an SMTP relay using go-mail. When a username is // set it authenticates, auto-discovering the strongest mechanism the relay // advertises; otherwise it relays unauthenticated. @@ -72,11 +92,11 @@ func (m SMTPMailer) Send(ctx context.Context, msg Message) error { if err != nil { return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err) } - opts := []mail.Option{mail.WithTimeout(smtpDialTimeout)} - if port == smtpImplicitTLSPort { - opts = append(opts, mail.WithSSLPort(false)) + opts := []mail.Option{mail.WithPort(port), mail.WithTimeout(smtpDialTimeout)} + if m.cfg.tlsMode(port) == smtpTLSImplicit { + opts = append(opts, mail.WithSSL()) } else { - opts = append(opts, mail.WithPort(port), mail.WithTLSPortPolicy(mail.TLSMandatory)) + opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory)) } if m.cfg.Username != "" { opts = append(opts, diff --git a/backend/internal/account/mailer_test.go b/backend/internal/account/mailer_test.go new file mode 100644 index 0000000..de204f0 --- /dev/null +++ b/backend/internal/account/mailer_test.go @@ -0,0 +1,29 @@ +package account + +import "testing" + +// TestSMTPTLSMode covers the explicit TLS mode and the port-based fallback, including +// the non-standard Selectel ports (1127 = SSL, 1126 = STARTTLS) that the 465 heuristic +// alone cannot classify. +func TestSMTPTLSMode(t *testing.T) { + cases := []struct { + name string + tls string + port int + want string + }{ + {"explicit ssl on a custom port (Selectel 1127)", "ssl", 1127, smtpTLSImplicit}, + {"explicit starttls on a custom port (Selectel 1126)", "starttls", 1126, smtpTLSSTARTTLS}, + {"tls is an alias for ssl", "TLS", 2525, smtpTLSImplicit}, + {"empty derives implicit TLS on 465", "", 465, smtpTLSImplicit}, + {"empty derives STARTTLS on 587", "", 587, smtpTLSSTARTTLS}, + {"an unknown value falls back to the port heuristic", "bogus", 465, smtpTLSImplicit}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := (SMTPConfig{TLS: c.tls}).tlsMode(c.port); got != c.want { + t.Errorf("tlsMode(TLS=%q, port=%d) = %q, want %q", c.tls, c.port, got, c.want) + } + }) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1ecd49a..b201cfd 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -148,6 +148,7 @@ func Load() (Config, error) { Username: os.Getenv("BACKEND_SMTP_USERNAME"), Password: os.Getenv("BACKEND_SMTP_PASSWORD"), From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"), + TLS: os.Getenv("BACKEND_SMTP_TLS"), } c := Config{ diff --git a/deploy/.env.example b/deploy/.env.example index b0c26ec..12d9fdc 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -30,12 +30,16 @@ EXPORT_SIGN_KEY=dev-export-sign-key # Confirm-code email via the shared Selectel relay (one relay for every contour; # limit 100 msgs / 5 min). Empty SMTP_RELAY_HOST makes the backend log codes instead # of sending (dev). Port 465 = implicit TLS, else STARTTLS; no client cert is needed. -# FROM must use the prod domain (Selectel only accepts the verified sender domain), so -# it is the same on every contour. PUBLIC_BASE_URL is the canonical https origin for -# links in the email — the contour's own public URL. Gitea TEST_/PROD_SMTP_RELAY_* + -# TEST_/PROD_PUBLIC_BASE_URL. +# TLS is implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it +# (ssl|starttls) — required for Selectel's non-standard ports (1127 = SSL, 1126 = +# STARTTLS). FROM must use the prod domain (Selectel only accepts the verified sender +# domain), so it is the same on every contour; a display name is fine +# (`"Игра Эрудит" `). PUBLIC_BASE_URL is the canonical https +# origin for links in the email — the contour's own public URL. Gitea +# TEST_/PROD_SMTP_RELAY_* + TEST_/PROD_PUBLIC_BASE_URL. SMTP_RELAY_HOST= SMTP_RELAY_PORT=465 +SMTP_RELAY_TLS= # empty = auto by port; ssl | starttls to force SMTP_RELAY_USER= # secret SMTP_RELAY_PASS= # secret SMTP_RELAY_FROM=no-reply@erudit-game.ru diff --git a/deploy/README.md b/deploy/README.md index b72ddad..47e981b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -97,7 +97,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org` | `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app`). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `SMTP_RELAY_HOST` | variable | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). | -| `SMTP_RELAY_PORT` | variable | `465` | Relay port. `465` selects implicit TLS; any other port uses STARTTLS. No client certificate is needed (the server cert is validated against the system roots). | +| `SMTP_RELAY_PORT` | variable | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | +| `SMTP_RELAY_TLS` | variable | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). | | `SMTP_RELAY_USER` | secret | _(empty)_ | Relay SMTP AUTH username. | | `SMTP_RELAY_PASS` | secret | _(empty)_ | Relay SMTP AUTH password. | | `SMTP_RELAY_FROM` | variable | `no-reply@localhost` | Sender address. **Must use the prod domain** (`no-reply@erudit-game.ru`) — Selectel only accepts the verified sender domain — so it is the same on every contour. | @@ -210,7 +211,7 @@ SMTP_RELAY_USER, SMTP_RELAY_PASS}`; variables: GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK, -SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_FROM, PUBLIC_BASE_URL}`. +SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL}`. ## Host-side setup (outside this repo) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0962fc9..d88221d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -137,13 +137,16 @@ services: # cgroup quota (the runtime otherwise sees all of the host's cores). GOMAXPROCS: "2" # Transactional email (confirm-codes) via the shared Selectel relay. An empty - # host makes the backend log codes instead of sending them (dev default). Port - # 465 selects implicit TLS; any other port uses STARTTLS. The From uses the prod - # domain (Selectel only accepts the verified sender domain), so it is the same on - # every contour. PUBLIC_BASE_URL is the canonical origin for links in the email - # (never a request Host header) — required whenever the relay host is set. + # host makes the backend log codes instead of sending them (dev default). TLS is + # implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it + # (ssl|starttls) — needed for Selectel's non-standard ports (1127 = SSL, 1126 = + # STARTTLS). The From uses the prod domain (Selectel only accepts the verified + # sender domain), so it is the same on every contour. PUBLIC_BASE_URL is the + # canonical origin for links in the email (never a request Host header) — + # required whenever the relay host is set. BACKEND_SMTP_HOST: ${SMTP_RELAY_HOST:-} BACKEND_SMTP_PORT: ${SMTP_RELAY_PORT:-465} + BACKEND_SMTP_TLS: ${SMTP_RELAY_TLS:-} BACKEND_SMTP_USERNAME: ${SMTP_RELAY_USER:-} BACKEND_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-} BACKEND_SMTP_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 70339a8..182212f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -233,9 +233,10 @@ arrive from a platform rather than completing a mandatory registration). robot opponent (§7). The **email confirm-code flow** binds an email to the authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute TTL, ≤ 5 attempts) is sent as a **branded HTML + plain-text email** through a - `Mailer` seam (go-mail over the shared relay — implicit TLS on port 465 or - STARTTLS, the server certificate validated against the system roots, no client - certificate; a development log mailer when no relay is configured) and, once + `Mailer` seam (go-mail over the shared relay — TLS chosen by port, implicit on + 465 and STARTTLS otherwise, or forced by config for a non-standard port; the + server certificate validated against the system roots, no client certificate; a + development log mailer when no relay is configured) and, once verified, attaches a confirmed email identity. Sends are throttled per recipient (a 60-second cooldown and a five-per-hour cap). Links in the email are built from a configured public base URL, never a request Host header (anti-injection). An