Merge pull request 'feat(email): make transactional email live — branded relay, rate-limit, squat fix (PR1a)' (#161) from feature/email-relay-pr1 into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m3s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m47s

This commit was merged in pull request #161.
This commit is contained in:
2026-07-03 01:56:55 +00:00
31 changed files with 804 additions and 116 deletions
+11
View File
@@ -332,6 +332,17 @@ jobs:
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }} GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
# Signs the finished-game export download URLs (backend + compose interpolation). # Signs the finished-game export download URLs (backend + compose interpolation).
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }} EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay. Empty host leaves the
# backend on the log mailer (email disabled) but the contour still boots.
SMTP_RELAY_USER: ${{ secrets.TEST_SMTP_RELAY_USER }}
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.
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }} GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }} CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
+15
View File
@@ -86,6 +86,14 @@ jobs:
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }} GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY). # Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay (confirm-codes).
SMTP_RELAY_USER: ${{ secrets.PROD_SMTP_RELAY_USER }}
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 }} PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
@@ -142,6 +150,13 @@ jobs:
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET' export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY' 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'
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true' export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
+7 -5
View File
@@ -214,11 +214,13 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. | | `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. | | `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_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). | | `BACKEND_SMTP_HOST` | — | Confirm-code relay host. **Empty selects the development log mailer** (the code is logged, not sent). |
| `BACKEND_SMTP_PORT` | `587` | Email relay port. | | `BACKEND_SMTP_PORT` | `587` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. | | `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_PASSWORD` | — | SMTP password. | | `BACKEND_SMTP_USERNAME` | — | SMTP AUTH user; empty relays without authentication. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. | | `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). |
| `BACKEND_PUBLIC_BASE_URL` | — | Canonical public origin (scheme + host) for links in the email. **Required when `BACKEND_SMTP_HOST` is set.** Never derived from a request Host header (anti-injection). |
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | | `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. | | `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. | | `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
+4 -1
View File
@@ -174,7 +174,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// Lobby & social domains. Their REST and stream surface lives in the gateway, // Lobby & social domains. Their REST and stream surface lives in the gateway,
// so they are handed to the server (like the route groups) for the handlers. // so they are handed to the server (like the route groups) for the handlers.
mailer := newMailer(cfg.SMTP, logger) mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer) emails := account.NewEmailService(accounts, mailer, cfg.PublicBaseURL)
// Throttle confirm-code sends per recipient: at most one per minute and five per
// rolling hour, guarding against email bombing and the relay's own quota.
emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5))
// Account linking & merge: the orchestrator over the account, merge and // Account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below. // session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
+2 -1
View File
@@ -13,6 +13,7 @@ require (
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
github.com/wneessen/go-mail v0.7.3
go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
@@ -109,7 +110,7 @@ require (
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.37.0 // indirect
google.golang.org/grpc v1.80.0 google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
+4
View File
@@ -279,6 +279,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -400,6 +402,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+31 -9
View File
@@ -121,13 +121,30 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
} }
// ProvisionEmail returns the account owning the email identity externalID, creating // ProvisionEmail returns the account owning the email identity externalID, creating
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM" // it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe // seeded into its time zone and language seeded from the client's UI language. Like
// and leaves an existing account untouched, so a returning user's saved zone is never // ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
// overwritten. The email account is created here (the code-request step), not at the // returning user's saved zone and language are never overwritten. The email account is
// later login, so this is where its zone is seeded. // created here (the code-request step), not at the later login, so this is where its
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) { // zone and language are seeded. It is created flagged is_guest with an unconfirmed
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)}) // email identity: an abandoned, never-confirmed login is then reaped like any guest,
// freeing the reserved address, and confirming the code clears the guest flag.
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) {
return s.provision(ctx, KindEmail, externalID, provisionSeed{
timeZone: seedZone(browserTZ),
preferredLanguage: supportedLanguage(language),
isGuest: true,
})
}
// supportedLanguage returns code normalised to a supported UI language ("en" or
// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It
// accepts region-tagged codes ("ru-RU").
func supportedLanguage(code string) string {
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(code)), "-"); lang == "en" || lang == "ru" {
return lang
}
return ""
} }
// ProvisionRobot provisions (or finds) the durable account backing a robot pool // ProvisionRobot provisions (or finds) the durable account backing a robot pool
@@ -239,6 +256,11 @@ type provisionSeed struct {
preferredLanguage string preferredLanguage string
displayName string displayName string
timeZone string timeZone string
// isGuest creates the account flagged is_guest. It is set for an email-login
// account, which stays a guest until the address is confirmed (so an abandoned,
// never-confirmed login is reaped and its address freed); confirming clears the
// flag. Platform identities (telegram/vk) are durable from creation.
isGuest bool
} }
// seedZone returns browserTZ when it is a well-formed zone to persist at account // seedZone returns browserTZ when it is a well-formed zone to persist at account
@@ -447,8 +469,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
tz = "UTC" tz = "UTC"
} }
insertAccount := table.Accounts. insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest).
VALUES(accountID, seed.displayName, lang, tz). VALUES(accountID, seed.displayName, lang, tz, seed.isGuest).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts var row model.Accounts
+82 -35
View File
@@ -28,6 +28,15 @@ const (
emailCodeMaxAttempts = 5 emailCodeMaxAttempts = 5
) )
// Confirmation purposes recorded on a pending confirm-code row. They select what
// verifying the code or the deeplink token does: sign in (login) or link/confirm the
// address on the current account (link). Email change and account deletion add
// further purposes in later stages.
const (
purposeLogin = "login"
purposeLink = "link"
)
// Errors returned by the email confirm-code flow. // Errors returned by the email confirm-code flow.
var ( var (
// ErrInvalidEmail is returned for an unparseable email address. // ErrInvalidEmail is returned for an unparseable email address.
@@ -46,6 +55,9 @@ var (
ErrTooManyAttempts = errors.New("account: too many confirmation attempts") ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
// ErrCodeMismatch is returned when the submitted code does not match. // ErrCodeMismatch is returned when the submitted code does not match.
ErrCodeMismatch = errors.New("account: confirmation code does not match") ErrCodeMismatch = errors.New("account: confirmation code does not match")
// ErrTooManyRequests is returned when confirm-code sends to an address are being
// requested too frequently (the resend cooldown or the rolling-hour cap).
ErrTooManyRequests = errors.New("account: too many code requests")
) )
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a // EmailService runs the email confirm-code flow: it issues a 6-digit code over a
@@ -55,14 +67,58 @@ var (
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow — // account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
// and using an email as a login reuses this mechanism. // and using an email as a login reuses this mechanism.
type EmailService struct { type EmailService struct {
store *Store store *Store
mailer Mailer mailer Mailer
now func() time.Time baseURL string
limiter *SendLimiter
now func() time.Time
} }
// NewEmailService constructs an EmailService over store, sending via mailer. // NewEmailService constructs an EmailService over store, sending via mailer. baseURL
func NewEmailService(store *Store, mailer Mailer) *EmailService { // is the canonical public origin (scheme + host) used to build the one-tap confirm
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }} // deeplink and the email footer landing link; an empty baseURL omits the deeplink
// (development / log mailer).
func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService {
return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }}
}
// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are
// not throttled — production wires a limiter; tests leave it off.
func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l }
// allowSend reports whether a confirm-code send to email is permitted now, recording
// it when so. A nil limiter permits every send.
func (s *EmailService) allowSend(email string) bool {
return s.limiter == nil || s.limiter.Allow(email)
}
// issueCode generates a fresh confirm-code for (accountID, email), replaces any prior
// pending confirmation, and mails the branded code in locale; purpose selects the
// email wording. Only the SHA-256 hash of the code is stored.
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
code, codeHash, err := generateCode()
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, s.now().Add(emailCodeTTL)); err != nil {
return err
}
msg, err := renderConfirmationEmail(purpose, code, "", s.baseURL, locale)
if err != nil {
return err
}
msg.To = email
return s.mailer.Send(ctx, msg)
}
// accountLocale returns the account's preferred UI language for localising email,
// defaulting to "en" when the account cannot be loaded.
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
acc, err := s.store.GetByID(ctx, accountID)
if err != nil {
return "en"
}
return acc.PreferredLanguage
} }
// RequestCode issues a fresh confirm-code for email to accountID and mails it, // RequestCode issues a fresh confirm-code for email to accountID and mails it,
@@ -73,6 +129,9 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
if err != nil { if err != nil {
return err return err
} }
if !s.allowSend(addr) {
return ErrTooManyRequests
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr) owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil { if err != nil {
return err return err
@@ -83,16 +142,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
} }
return ErrEmailTaken return ErrEmailTaken
} }
code, hash, err := generateCode() return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return err
}
subject := "Your Scrabble confirmation code"
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
return s.mailer.Send(ctx, addr, subject, body)
} }
// ConfirmCode verifies code for accountID and email. On success it attaches a // ConfirmCode verifies code for accountID and email. On success it attaches a
@@ -127,32 +177,26 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
} }
// RequestLoginCode issues a login confirm-code to the account that owns email, // RequestLoginCode issues a login confirm-code to the account that owns email,
// provisioning a fresh (unconfirmed) durable account when the email is new. It is // provisioning a fresh (unconfirmed) guest account when the email is new — it becomes
// the unauthenticated email-login entry point and, unlike RequestCode, // durable once the code is confirmed. It is the unauthenticated email-login entry
// does not refuse an already-confirmed email — that is the ordinary returning-user // point and, unlike RequestCode, does not refuse an already-confirmed email — that is
// login. The code is mailed to the address, so only its real owner can complete // the ordinary returning-user login. The code is mailed to the address, so only its
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset) // real owner can complete the login. On first contact browserTZ (the client's
// seeds the new account's time zone. It returns the target account id for the // detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
// subsequent LoginWithCode. // language. It returns the target account id for the subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) { func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
addr, err := normalizeEmail(email) addr, err := normalizeEmail(email)
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ) if !s.allowSend(addr) {
return uuid.UUID{}, ErrTooManyRequests
}
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language)
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
code, hash, err := generateCode() if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
if err != nil {
return uuid.UUID{}, err
}
if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return uuid.UUID{}, err
}
subject := "Your Scrabble login code"
body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
if err := s.mailer.Send(ctx, addr, subject, body); err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
return acc.ID, nil return acc.ID, nil
@@ -191,6 +235,9 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil { if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
return Account{}, err return Account{}, err
} }
if err := s.store.ClearGuest(ctx, acc.ID); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, acc.ID) return s.store.GetByID(ctx, acc.ID)
} }
+203
View File
@@ -0,0 +1,203 @@
package account
import (
"fmt"
"html/template"
"strings"
tmpltext "text/template"
"time"
)
// emailBrandColor is the single accent used in the confirmation email — a calm
// tile green, matching the "no riot of colours" brief.
const emailBrandColor = "#2f7d4f"
// confirmEmailView is the fully-localised data the confirmation email templates
// render. Every string is resolved before rendering, so the templates carry no
// localisation logic.
type confirmEmailView struct {
Brand string
Heading string
Intro string
Code string
Expiry string
CTALabel string
DeeplinkURL string
FooterIgnore string
LandingURL string
LandingLabel string
Preheader string
Locale string
Accent string
}
// emailCopy is the purpose- and locale-specific wording of a confirmation email.
type emailCopy struct {
Subject string
Preheader string
Heading string
Intro string
CTALabel string
FooterIgnore string
}
// confirmEmailCopy holds the wording per (purpose, locale). Unknown purposes fall
// back to the neutral link wording and unknown locales fall back to English.
var confirmEmailCopy = map[string]map[string]emailCopy{
purposeLogin: {
"en": {
Subject: "Your Erudit sign-in code",
Preheader: "Your sign-in code",
Heading: "Sign in to Erudit",
Intro: "Enter this code to sign in:",
CTALabel: "Sign in with one tap",
FooterIgnore: "If you didn't request this email, you can safely ignore it.",
},
"ru": {
Subject: "Код для входа в Эрудит",
Preheader: "Ваш код для входа",
Heading: "Вход в Эрудит",
Intro: "Введите этот код, чтобы войти в игру:",
CTALabel: "Войти одним нажатием",
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
},
},
purposeLink: {
"en": {
Subject: "Your Erudit confirmation code",
Preheader: "Your confirmation code",
Heading: "Confirm your e-mail",
Intro: "Enter this code to confirm your address:",
CTALabel: "Confirm with one tap",
FooterIgnore: "If you didn't request this email, you can safely ignore it.",
},
"ru": {
Subject: "Код подтверждения Эрудит",
Preheader: "Ваш код подтверждения",
Heading: "Подтверждение e-mail",
Intro: "Введите этот код, чтобы подтвердить адрес:",
CTALabel: "Подтвердить одним нажатием",
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
},
},
}
// emailBrand is the brand wordmark per locale.
var emailBrand = map[string]string{"en": "Erudit", "ru": "Эрудит"}
// emailExpiry formats the code-lifetime line per locale (abbreviated minutes to
// avoid plural agreement).
func emailExpiry(locale string, d time.Duration) string {
min := int(d / time.Minute)
if locale == "ru" {
return fmt.Sprintf("Код действует %d мин.", min)
}
return fmt.Sprintf("The code is valid for %d minutes.", min)
}
// normalizeLocale maps an account language to a supported email locale, defaulting
// to English.
func normalizeLocale(locale string) string {
if locale == "ru" {
return "ru"
}
return "en"
}
// renderConfirmationEmail builds the branded confirmation email for purpose in
// locale: a large readable code plus a one-tap deeplink button, with an
// ignore-notice footer and a landing link. Both a plain-text body and an HTML
// alternative are produced. deeplinkURL is the absolute /confirm link and
// landingURL the public landing origin.
func renderConfirmationEmail(purpose, code, deeplinkURL, landingURL, locale string) (Message, error) {
loc := normalizeLocale(locale)
byLocale, ok := confirmEmailCopy[purpose]
if !ok {
byLocale = confirmEmailCopy[purposeLink]
}
cp := byLocale[loc]
view := confirmEmailView{
Brand: emailBrand[loc],
Heading: cp.Heading,
Intro: cp.Intro,
Code: code,
Expiry: emailExpiry(loc, emailCodeTTL),
CTALabel: cp.CTALabel,
DeeplinkURL: deeplinkURL,
FooterIgnore: cp.FooterIgnore,
LandingURL: landingURL,
LandingLabel: emailBrand[loc],
Preheader: cp.Preheader,
Locale: loc,
Accent: emailBrandColor,
}
var html strings.Builder
if err := confirmEmailHTML.Execute(&html, view); err != nil {
return Message{}, fmt.Errorf("account: render confirmation email (html): %w", err)
}
var text strings.Builder
if err := confirmEmailText.Execute(&text, view); err != nil {
return Message{}, fmt.Errorf("account: render confirmation email (text): %w", err)
}
return Message{Subject: cp.Subject, Text: text.String(), HTML: html.String()}, nil
}
// confirmEmailHTML is a compact, image-free, mobile-friendly HTML email. Layout is
// table-based for broad mail-client compatibility and all styling is inlined
// because clients strip <style> blocks.
var confirmEmailHTML = template.Must(template.New("confirmEmailHTML").Parse(`<!DOCTYPE html>
<html lang="{{.Locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Brand}}</title>
</head>
<body style="margin:0;padding:0;background:#f4f5f7;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;">{{.Preheader}}</span>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:460px;background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="padding:28px 32px 4px;">
<div style="font-size:15px;font-weight:700;letter-spacing:.04em;color:{{.Accent}};">{{.Brand}}</div>
</td></tr>
<tr><td style="padding:8px 32px 0;">
<h1 style="margin:0;font-size:20px;line-height:1.3;color:#111827;font-weight:600;">{{.Heading}}</h1>
<p style="margin:12px 0 0;font-size:15px;line-height:1.5;color:#374151;">{{.Intro}}</p>
</td></tr>
<tr><td style="padding:18px 32px 0;">
<div style="font-size:34px;font-weight:700;letter-spacing:8px;text-align:center;color:#111827;background:#f3f4f6;border-radius:10px;padding:18px 0;font-family:'SFMono-Regular',Consolas,Menlo,monospace;">{{.Code}}</div>
<p style="margin:10px 0 0;font-size:13px;line-height:1.5;color:#6b7280;text-align:center;">{{.Expiry}}</p>
</td></tr>
{{if .DeeplinkURL}}<tr><td style="padding:22px 32px 0;" align="center">
<a href="{{.DeeplinkURL}}" style="display:inline-block;background:{{.Accent}};color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:12px 26px;border-radius:9px;">{{.CTALabel}}</a>
</td></tr>
{{end}}<tr><td style="padding:26px 32px 28px;">
<hr style="border:none;border-top:1px solid #eceef1;margin:0 0 16px;">
<p style="margin:0;font-size:12px;line-height:1.6;color:#9ca3af;">{{.FooterIgnore}}</p>
<p style="margin:10px 0 0;font-size:12px;color:#9ca3af;"><a href="{{.LandingURL}}" style="color:#6b7280;text-decoration:none;">{{.LandingLabel}}</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
`))
// confirmEmailText is the plain-text alternative (and multipart fallback).
var confirmEmailText = tmpltext.Must(tmpltext.New("confirmEmailText").Parse(`{{.Brand}}
{{.Heading}}
{{.Intro}}
{{.Code}}
{{.Expiry}}
{{if .DeeplinkURL}}{{.CTALabel}}:
{{.DeeplinkURL}}
{{end}}
{{.FooterIgnore}}
{{.LandingLabel}}{{.LandingURL}}
`))
@@ -0,0 +1,52 @@
package account
import (
"strings"
"testing"
)
// TestRenderConfirmationEmail checks that each (purpose, locale) renders a localised
// subject, embeds the code and the one-tap deeplink in both bodies, and produces HTML.
func TestRenderConfirmationEmail(t *testing.T) {
const deeplink = "https://erudit-game.ru/app/#/confirm/tok123"
cases := []struct {
name, purpose, locale, subjectSub string
}{
{"login ru", purposeLogin, "ru", "вход"},
{"login en", purposeLogin, "en", "sign-in"},
{"link ru", purposeLink, "ru", "подтвержд"},
{"link en", purposeLink, "en", "confirmation"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
msg, err := renderConfirmationEmail(c.purpose, "123456", deeplink, "https://erudit-game.ru", c.locale)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(strings.ToLower(msg.Subject), c.subjectSub) {
t.Errorf("subject %q does not contain %q", msg.Subject, c.subjectSub)
}
if !strings.Contains(msg.Text, "123456") || !strings.Contains(msg.HTML, "123456") {
t.Error("code missing from a body")
}
if !strings.Contains(msg.Text, deeplink) || !strings.Contains(msg.HTML, "confirm/tok123") {
t.Error("deeplink missing from a body")
}
if !strings.Contains(msg.HTML, "<html") {
t.Error("HTML body is not HTML")
}
})
}
}
// TestRenderConfirmationEmailUnknownLocaleDefaultsEnglish falls back to English for an
// unsupported locale rather than erroring or emitting an empty subject.
func TestRenderConfirmationEmailUnknownLocaleDefaultsEnglish(t *testing.T) {
msg, err := renderConfirmationEmail(purposeLogin, "000000", "", "https://erudit-game.ru", "de")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(strings.ToLower(msg.Subject), "sign-in") {
t.Errorf("unknown locale should default to English, got subject %q", msg.Subject)
}
}
+3 -9
View File
@@ -26,16 +26,10 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
if err != nil { if err != nil {
return err return err
} }
code, hash, err := generateCode() if !s.allowSend(addr) {
if err != nil { return ErrTooManyRequests
return err
} }
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
return err
}
subject := "Your Scrabble confirmation code"
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
return s.mailer.Send(ctx, addr, subject, body)
} }
// ConfirmLink verifies code for (accountID, email) and reports the address's // ConfirmLink verifies code for (accountID, email) and reports the address's
+95 -29
View File
@@ -3,33 +3,78 @@ package account
import ( import (
"context" "context"
"fmt" "fmt"
"net" "strconv"
"net/smtp" "strings"
"time"
"github.com/wneessen/go-mail"
"go.uber.org/zap" "go.uber.org/zap"
) )
// Message is a transactional email to send through a Mailer. Text is the
// required plain-text body and doubles as the multipart/alternative fallback;
// HTML, when non-empty, is the preferred body a capable client renders instead.
type Message struct {
To string
Subject string
Text string
HTML string
}
// Mailer delivers a transactional email. It is the seam behind which the email // Mailer delivers a transactional email. It is the seam behind which the email
// confirm-code flow sends codes, so the relay is swappable and unit tests use a // confirm-code flow sends codes, so the relay is swappable and unit tests use a
// fixture (see docs/TESTING.md: no real network in tests). The context is offered // fixture (see docs/TESTING.md: no real network in tests). The context bounds the
// for cancellation; the standard-library SMTP implementation sends synchronously // delivery and is honoured by the SMTP implementation.
// and ignores it.
type Mailer interface { type Mailer interface {
Send(ctx context.Context, to, subject, body string) error Send(ctx context.Context, msg Message) error
} }
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer // 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). // 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.
type SMTPConfig struct { type SMTPConfig struct {
Host string Host string
Port string Port string
Username string Username string
Password string Password string
From 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
} }
// SMTPMailer sends mail through an SMTP relay using the standard library. When a const (
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated. // 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.
type SMTPMailer struct { type SMTPMailer struct {
cfg SMTPConfig cfg SMTPConfig
} }
@@ -39,29 +84,49 @@ func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
return SMTPMailer{cfg: cfg} return SMTPMailer{cfg: cfg}
} }
// Send delivers a plain-text UTF-8 message to to via the configured relay. // Send delivers a UTF-8 message to msg.To via the configured relay. When msg.HTML
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error { // is set the message is multipart/alternative (plain text plus HTML); otherwise
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port) // it is plain text only.
var auth smtp.Auth func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
if m.cfg.Username != "" { port, err := strconv.Atoi(m.cfg.Port)
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) if err != nil {
return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err)
} }
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil { opts := []mail.Option{mail.WithPort(port), mail.WithTimeout(smtpDialTimeout)}
return fmt.Errorf("account: send mail to %s: %w", to, err) if m.cfg.tlsMode(port) == smtpTLSImplicit {
opts = append(opts, mail.WithSSL())
} else {
opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory))
}
if m.cfg.Username != "" {
opts = append(opts,
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
mail.WithUsername(m.cfg.Username),
mail.WithPassword(m.cfg.Password),
)
}
client, err := mail.NewClient(m.cfg.Host, opts...)
if err != nil {
return fmt.Errorf("account: build mail client: %w", err)
}
out := mail.NewMsg()
if err := out.From(m.cfg.From); err != nil {
return fmt.Errorf("account: set From %q: %w", m.cfg.From, err)
}
if err := out.To(msg.To); err != nil {
return fmt.Errorf("account: set To %q: %w", msg.To, err)
}
out.Subject(msg.Subject)
out.SetBodyString(mail.TypeTextPlain, msg.Text)
if msg.HTML != "" {
out.AddAlternativeString(mail.TypeTextHTML, msg.HTML)
}
if err := client.DialAndSendWithContext(ctx, out); err != nil {
return fmt.Errorf("account: send mail to %s: %w", msg.To, err)
} }
return nil return nil
} }
// message renders a minimal RFC 5322 plain-text email.
func message(from, to, subject, body string) []byte {
return []byte("From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" + body + "\r\n")
}
// LogMailer logs the message instead of sending it. It is the default when no // LogMailer logs the message instead of sending it. It is the default when no
// SMTP relay is configured and is intended for development only: it logs the body, // SMTP relay is configured and is intended for development only: it logs the body,
// which carries the confirm-code, so it must not be used in production. // which carries the confirm-code, so it must not be used in production.
@@ -74,11 +139,12 @@ func NewLogMailer(log *zap.Logger) LogMailer {
return LogMailer{log: log} return LogMailer{log: log}
} }
// Send logs the message at info level and reports success. // Send logs the message at info level and reports success. It logs the plain-text
func (m LogMailer) Send(_ context.Context, to, subject, body string) error { // body only (which carries the confirm-code); the HTML alternative is omitted.
func (m LogMailer) Send(_ context.Context, msg Message) error {
if m.log != nil { if m.log != nil {
m.log.Info("email not sent (log mailer)", m.log.Info("email not sent (log mailer)",
zap.String("to", to), zap.String("subject", subject), zap.String("body", body)) zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text))
} }
return nil return nil
} }
+29
View File
@@ -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)
}
})
}
}
+66
View File
@@ -0,0 +1,66 @@
package account
import (
"sync"
"time"
)
// SendLimiter throttles confirm-code sends per recipient address: it enforces a
// minimum cooldown between two sends and a cap over a rolling hour. It guards against
// email bombing and protects the relay's own quota. State is in-memory (per process,
// reset on restart) and keyed by the normalised recipient address, which is adequate
// for the single-instance backend. Safe for concurrent use.
type SendLimiter struct {
mu sync.Mutex
cooldown time.Duration
perHour int
now func() time.Time
sends map[string][]time.Time
}
// NewSendLimiter returns a SendLimiter allowing at most one send per cooldown and at
// most perHour sends over any rolling hour, to the same recipient.
func NewSendLimiter(cooldown time.Duration, perHour int) *SendLimiter {
return &SendLimiter{
cooldown: cooldown,
perHour: perHour,
now: func() time.Time { return time.Now() },
sends: make(map[string][]time.Time),
}
}
// Allow reports whether a send to key is permitted now, recording the send when it is.
// It is denied when the last send was within the cooldown or the rolling-hour cap is
// already reached.
func (l *SendLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
cutoff := now.Add(-time.Hour)
kept := l.sends[key][:0]
for _, t := range l.sends[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if n := len(kept); n > 0 && now.Sub(kept[n-1]) < l.cooldown {
l.set(key, kept)
return false
}
if len(kept) >= l.perHour {
l.set(key, kept)
return false
}
l.set(key, append(kept, now))
return true
}
// set stores the retained send times for key, dropping the entry entirely once empty
// so the map stays bounded to recipients active within the last hour.
func (l *SendLimiter) set(key string, times []time.Time) {
if len(times) == 0 {
delete(l.sends, key)
return
}
l.sends[key] = times
}
@@ -0,0 +1,43 @@
package account
import (
"testing"
"time"
)
// TestSendLimiter checks the per-recipient cooldown and the rolling-hour cap, and
// that recipients are throttled independently.
func TestSendLimiter(t *testing.T) {
base := time.Now()
now := base
l := NewSendLimiter(time.Minute, 3)
l.now = func() time.Time { return now }
if !l.Allow("a") {
t.Fatal("send 1 should be allowed")
}
if l.Allow("a") {
t.Fatal("immediate resend must be blocked by the cooldown")
}
if !l.Allow("b") {
t.Fatal("a different recipient is independent")
}
now = base.Add(time.Minute)
if !l.Allow("a") {
t.Fatal("send 2 after the cooldown should be allowed")
}
now = base.Add(2 * time.Minute)
if !l.Allow("a") {
t.Fatal("send 3 should be allowed")
}
now = base.Add(3 * time.Minute)
if l.Allow("a") {
t.Fatal("send 4 within the hour must be blocked by the cap")
}
now = base.Add(time.Hour + time.Minute)
if !l.Allow("a") {
t.Fatal("after the rolling hour the cap resets")
}
}
+17
View File
@@ -4,6 +4,7 @@ package config
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
"strconv" "strconv"
"time" "time"
@@ -42,6 +43,12 @@ type Config struct {
// SMTP configures the email relay used for confirm-codes. An empty Host // SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent). // selects the development log mailer (the code is logged, not sent).
SMTP account.SMTPConfig SMTP account.SMTPConfig
// PublicBaseURL is the canonical public origin (scheme + host, e.g.
// https://erudit-game.ru) used to build absolute links in outgoing email — the
// confirm deeplink and the footer landing link. It is deliberately not derived
// from a request Host header, which would let an attacker inject a phishing link
// into the email. Required whenever an SMTP relay is configured.
PublicBaseURL string
// ConnectorAddr is the gRPC address of the Telegram platform connector // ConnectorAddr is the gRPC address of the Telegram platform connector
// side-service, used by the admin console to send operator broadcasts. Empty // side-service, used by the admin console to send operator broadcasts. Empty
// disables broadcasts (the admin broadcast actions report "not configured"). // disables broadcasts (the admin broadcast actions report "not configured").
@@ -141,6 +148,7 @@ func Load() (Config, error) {
Username: os.Getenv("BACKEND_SMTP_USERNAME"), Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"), Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"), From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
TLS: os.Getenv("BACKEND_SMTP_TLS"),
} }
c := Config{ c := Config{
@@ -154,6 +162,7 @@ func Load() (Config, error) {
Robot: rb, Robot: rb,
RateWatch: rw, RateWatch: rw,
SMTP: smtp, SMTP: smtp,
PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"),
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"), ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
GuestReapInterval: guestReapInterval, GuestReapInterval: guestReapInterval,
GuestRetention: guestRetention, GuestRetention: guestRetention,
@@ -203,6 +212,14 @@ func (c Config) validate() error {
if c.GuestRetention <= 0 { if c.GuestRetention <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive") return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
} }
if c.SMTP.Host != "" {
if c.PublicBaseURL == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when BACKEND_SMTP_HOST is configured")
}
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
return nil return nil
} }
+43 -9
View File
@@ -19,8 +19,8 @@ import (
// recover the confirm-code from the body. // recover the confirm-code from the body.
type capturingMailer struct{ lastBody string } type capturingMailer struct{ lastBody string }
func (m *capturingMailer) Send(_ context.Context, _, _, body string) error { func (m *capturingMailer) Send(_ context.Context, msg account.Message) error {
m.lastBody = body m.lastBody = msg.Text
return nil return nil
} }
@@ -32,7 +32,7 @@ func TestEmailConfirmFlow(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer) svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc := provisionAccount(t) acc := provisionAccount(t)
email := "user-" + uuid.NewString() + "@example.com" email := "user-" + uuid.NewString() + "@example.com"
@@ -67,7 +67,7 @@ func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer) svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
owner := provisionAccount(t) owner := provisionAccount(t)
email := "taken-" + uuid.NewString() + "@example.com" email := "taken-" + uuid.NewString() + "@example.com"
@@ -89,7 +89,7 @@ func TestEmailCodeExpires(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer) svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc := provisionAccount(t) acc := provisionAccount(t)
email := "expire-" + uuid.NewString() + "@example.com" email := "expire-" + uuid.NewString() + "@example.com"
@@ -111,7 +111,7 @@ func TestEmailTooManyAttempts(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer) svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc := provisionAccount(t) acc := provisionAccount(t)
email := "lock-" + uuid.NewString() + "@example.com" email := "lock-" + uuid.NewString() + "@example.com"
@@ -203,10 +203,10 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
func TestEmailLoginFlow(t *testing.T) { func TestEmailLoginFlow(t *testing.T) {
ctx := context.Background() ctx := context.Background()
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(account.NewStore(testDB), mailer) svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
email := "login-" + uuid.NewString() + "@example.com" email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00") accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en")
if err != nil { if err != nil {
t.Fatalf("request login code: %v", err) t.Fatalf("request login code: %v", err)
} }
@@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) {
} }
// A second login for the same email is the returning user: same account. // A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil { if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil {
t.Fatalf("second request: %v", err) t.Fatalf("second request: %v", err)
} }
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
@@ -244,3 +244,37 @@ func TestEmailLoginFlow(t *testing.T) {
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID) t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
} }
} }
// TestEmailLoginProvisionsGuestUntilConfirmed covers the squat fix: an email-login
// account is a guest (reapable, so an abandoned never-confirmed login frees its
// address) until the code is confirmed, which promotes it to a durable account.
func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "squat-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en")
if err != nil {
t.Fatalf("request login code: %v", err)
}
before, err := store.GetByID(ctx, id)
if err != nil {
t.Fatalf("get before confirm: %v", err)
}
if !before.IsGuest {
t.Error("an unconfirmed email-login account must be a guest so it is reapable")
}
if _, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("login: %v", err)
}
after, err := store.GetByID(ctx, id)
if err != nil {
t.Fatalf("get after confirm: %v", err)
}
if after.IsGuest {
t.Error("confirming the login must clear the guest flag (promote to durable)")
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID
func newLinkService(mailer account.Mailer) *link.Service { func newLinkService(mailer account.Mailer) *link.Service {
store := account.NewStore(testDB) store := account.NewStore(testDB)
emails := account.NewEmailService(store, mailer) emails := account.NewEmailService(store, mailer, "https://erudit-game.ru")
sessions := session.NewService(session.NewStore(testDB), session.NewCache()) sessions := session.NewService(session.NewStore(testDB), session.NewCache())
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions) return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
} }
+1
View File
@@ -30,6 +30,7 @@ func TestStatusForError(t *testing.T) {
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"}, "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
"too many codes": {account.ErrTooManyRequests, http.StatusTooManyRequests, "too_many_requests"},
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"}, "session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"}, "chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"}, "no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
+2
View File
@@ -237,6 +237,8 @@ func statusForError(err error) (int, string) {
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts): errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
return http.StatusUnauthorized, "code_invalid" return http.StatusUnauthorized, "code_invalid"
case errors.Is(err, account.ErrTooManyRequests):
return http.StatusTooManyRequests, "too_many_requests"
case errors.Is(err, session.ErrNotFound): case errors.Is(err, session.ErrNotFound):
return http.StatusUnauthorized, "session_invalid" return http.StatusUnauthorized, "session_invalid"
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong), case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
+2 -1
View File
@@ -172,6 +172,7 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
type emailRequest struct { type emailRequest struct {
Email string `json:"email"` Email string `json:"email"`
BrowserTZ string `json:"browser_tz"` BrowserTZ string `json:"browser_tz"`
Language string `json:"language"`
} }
// handleEmailRequest issues a login confirm-code to the email. It always reports // handleEmailRequest issues a login confirm-code to the email. It always reports
@@ -183,7 +184,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
abortBadRequest(c, "email is required") abortBadRequest(c, "email is required")
return return
} }
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ); err != nil { if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
+19
View File
@@ -26,6 +26,25 @@ LOG_LEVEL=info
# real contour value with `openssl rand -base64 32` (Gitea TEST_/PROD_EXPORT_SIGN_KEY). # real contour value with `openssl rand -base64 32` (Gitea TEST_/PROD_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY=dev-export-sign-key EXPORT_SIGN_KEY=dev-export-sign-key
# --- Transactional email (Selectel relay) -----------------------------------
# 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.
# 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
PUBLIC_BASE_URL= # required when SMTP_RELAY_HOST is set (e.g. https://erudit-game.ru)
# --- Edge / caddy ----------------------------------------------------------- # --- Edge / caddy -----------------------------------------------------------
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the # Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
# external `edge` network). Prod: a domain so caddy does its own ACME. # external `edge` network). Prod: a domain so caddy does its own ACME.
+11 -2
View File
@@ -96,6 +96,13 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
| `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_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). | | `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. 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. |
| `PUBLIC_BASE_URL` | variable | _(empty)_ | Canonical public https origin for links in the email (the contour's own URL, e.g. `https://erudit-game.ru`). **Required by the backend whenever `SMTP_RELAY_HOST` is set** — it is never derived from a request Host header (anti-injection). |
The six `VITE_*` are **build-args** baked into the gateway and landing images at The six `VITE_*` are **build-args** baked into the gateway and landing images at
build time (both targets share one UI build stage — keep the args identical so it is build time (both targets share one UI build stage — keep the args identical so it is
@@ -198,11 +205,13 @@ players arrive.
**`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets: **`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN, `PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA, TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`; variables: BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY,
SMTP_RELAY_USER, SMTP_RELAY_PASS}`; variables:
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, `PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID, 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, TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK}`. VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK,
SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL}`.
## Host-side setup (outside this repo) ## Host-side setup (outside this repo)
+15
View File
@@ -136,6 +136,21 @@ services:
# GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the # GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the
# cgroup quota (the runtime otherwise sees all of the host's cores). # cgroup quota (the runtime otherwise sees all of the host's cores).
GOMAXPROCS: "2" 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). 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}
BACKEND_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
# The dictionary lives on a named volume seeded from the image on first boot # The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the # inherits). The admin console writes new version subdirectories here, and the
+12 -3
View File
@@ -232,9 +232,18 @@ arrive from a platform rather than completing a mandatory registration).
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled `confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
robot opponent (§7). The **email confirm-code flow** binds an email to the 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 authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a TTL, ≤ 5 attempts) is sent as a **branded HTML + plain-text email** through a
development log mailer when none is configured) and, once verified, attaches a `Mailer` seam (go-mail over the shared relay — TLS chosen by port, implicit on
confirmed email identity. Accounts and identities use application-generated 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
**email-login** account is created flagged `is_guest` and stays reapable until the
code is confirmed — so an abandoned, never-confirmed login frees its reserved
address — with confirming clearing the flag. Accounts and identities use
application-generated
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time **UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
payment; no purchase flow yet) is carried on the account and ORed on a merge. payment; no purchase flow yet) is carried on the account and ORed on a merge.
- **Linking** is initiated from an authenticated profile and proves - **Linking** is initiated from an authenticated profile and proves
+6
View File
@@ -61,6 +61,12 @@ A **VK Mini App** launch works the same way: it authenticates from VK's signed l
the name in the signed launch). While the theme preference is "auto" the app follows the VK the name in the signed launch). While the theme preference is "auto" the app follows the VK
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
"couldn't load" screen applies inside VK. "couldn't load" screen applies inside VK.
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
(en/ru), no images — to the address; entering it signs the player in. A new address
is provisioned on the first request but only becomes a durable account once the code
is confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address
freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap
per address), so a mistyped address or an impatient tap cannot flood an inbox.
Telegram runs a **single bot**: every player uses Telegram runs a **single bot**: every player uses
the same bot, and all of its chat and out-of-app notifications are written in the the same bot, and all of its chat and out-of-app notifications are written in the
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
+8 -1
View File
@@ -65,7 +65,14 @@ launch-параметрам VK (их проверяет gateway), и при пе
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
тихого повтора «не удалось тихого повтора «не удалось
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним загрузить» действует и внутри VK.
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
не завалили почтовый ящик.
Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой, **промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
+4 -1
View File
@@ -69,7 +69,10 @@ tests or touching CI.
content and block-visibility rules, the nudge turn/rate-limit rules, the content and block-visibility rules, the nudge turn/rate-limit rules, the
invitation flow (all-accept starts the game, decline cancels, lazy expiry, invitation flow (all-accept starts the game, decline cancels, lazy expiry,
inviter-only cancel), and the email confirm-code flow (request/confirm, taken inviter-only cancel), and the email confirm-code flow (request/confirm, taken
email, expiry and attempt-cap) with a fixture mailer. It also covers the email, expiry and attempt-cap, and the guest-until-confirmed login) with a fixture
mailer. The branded email template render (en/ru subject, code and body) and the
per-recipient send-rate limiter (cooldown + hourly cap) are pure account-package
unit tests, needing no database. It also covers the
**befriend-an-opponent** gate (a request needs a shared game), the **permanent **befriend-an-opponent** gate (a request needs a shared game), the **permanent
decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem, decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
+5
View File
@@ -146,6 +146,8 @@ github.com/volatiletech/randomize v0.0.1 h1:eE5yajattWqTB2/eN8df4dw+8jwAzBtbdo5s
github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY= github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY=
github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk= github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg= github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -176,15 +178,18 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
+4 -4
View File
@@ -292,14 +292,14 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
await expect(save).toBeEnabled(); await expect(save).toBeEnabled();
}); });
// Account linking is hidden in Profile.svelte while we target provider sign-in (the anonymous // The email upgrade box is now shown to guests (email bind + the merge dialog). These specs
// /app/ guest who upgrades by linking comes later). The flow is kept wired; re-enable these two // still assume a non-guest login and the visible Telegram control, so they stay skipped until
// specs together with the `.emailbox` section. // PR2 re-enables provider linking and adds a guest-login setup.
test.skip('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => { test.skip('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await openProfile(page); await openProfile(page);
// The linking section is shown to everyone (guests upgrade by linking). // The email box is shown to guests (this spec needs a guest login — re-enabled in PR2).
await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible();
// An address containing "merge" stands in (in the mock) for one already owned by // An address containing "merge" stands in (in the mock) for one already owned by
// another account, so the confirm step reveals a required merge. // another account, so the confirm step reveals a required merge.
+7 -5
View File
@@ -244,10 +244,11 @@
</form> </form>
{/if} {/if}
<!-- Linking & merge. Hidden for now: we target provider sign-in, and the anonymous <!-- The guest's email upgrade path: bind an email to register / sign in (a returning
/app/ guest (whose upgrade path this is) comes later. Kept wired — drop `hidden` address triggers the merge dialog below). Guest-only for now; provider linking
to re-enable, together with the skipped linking specs in e2e/social.spec.ts. --> (Add VK / Add Telegram / Unlink) and the non-guest matrix come next, together
<section class="emailbox" hidden> with the skipped linking specs in e2e/social.spec.ts. -->
<section class="emailbox" hidden={!p.isGuest}>
<h3>{t('profile.linkAccount')}</h3> <h3>{t('profile.linkAccount')}</h3>
{#if !emailSent} {#if !emailSent}
<div class="addrow"> <div class="addrow">
@@ -272,7 +273,8 @@
</div> </div>
{/if} {/if}
{#if telegramLinkable} {#if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button> <!-- Provider linking lands with the non-guest matrix (PR2); kept wired but hidden. -->
<button class="ghost tg" hidden onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
{/if} {/if}
</section> </section>