feat(email): make transactional email live — branded relay, rate-limit, squat fix (PR1a) #161
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
+2
-1
@@ -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). |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
+8
-4
@@ -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
|
||||
# (`"Игра Эрудит" <no-reply@erudit-game.ru>`). 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
|
||||
|
||||
+3
-2
@@ -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<id>`). |
|
||||
| `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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user