feat(email): make transactional email live — branded relay, rate-limit, squat fix (PR1a) #161
@@ -332,6 +332,17 @@ jobs:
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
|
||||
# Signs the finished-game export download URLs (backend + compose interpolation).
|
||||
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 }}
|
||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||
|
||||
@@ -86,6 +86,14 @@ jobs:
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
||||
# Signs the finished-game export download URLs (backend BACKEND_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_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||
@@ -142,6 +150,13 @@ jobs:
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
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'
|
||||
EOF
|
||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||
|
||||
+7
-5
@@ -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_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` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). |
|
||||
| `BACKEND_SMTP_PORT` | `587` | Email relay port. |
|
||||
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
||||
| `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. 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). |
|
||||
| `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_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. |
|
||||
|
||||
@@ -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,
|
||||
// so they are handed to the server (like the route groups) for the handlers.
|
||||
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
|
||||
// session layers. Wired to the /api/v1/user/link REST surface below.
|
||||
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ require (
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/testcontainers/testcontainers-go 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/exporters/stdout/stdoutmetric 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/sync v0.20.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/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -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/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
|
||||
@@ -121,13 +121,30 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
||||
}
|
||||
|
||||
// ProvisionEmail returns the account owning the email identity externalID, creating
|
||||
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
|
||||
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
|
||||
// and leaves an existing account untouched, so a returning user's saved zone is never
|
||||
// overwritten. The email account is created here (the code-request step), not at the
|
||||
// later login, so this is where its zone is seeded.
|
||||
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
|
||||
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
|
||||
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
|
||||
// seeded into its time zone and language seeded from the client's UI language. Like
|
||||
// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
|
||||
// returning user's saved zone and language are never overwritten. The email account is
|
||||
// created here (the code-request step), not at the later login, so this is where its
|
||||
// zone and language are seeded. It is created flagged is_guest with an unconfirmed
|
||||
// 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
|
||||
@@ -239,6 +256,11 @@ type provisionSeed struct {
|
||||
preferredLanguage string
|
||||
displayName 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
|
||||
@@ -447,8 +469,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
||||
tz = "UTC"
|
||||
}
|
||||
insertAccount := table.Accounts.
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
|
||||
VALUES(accountID, seed.displayName, lang, tz).
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest).
|
||||
VALUES(accountID, seed.displayName, lang, tz, seed.isGuest).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
|
||||
@@ -28,6 +28,15 @@ const (
|
||||
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.
|
||||
var (
|
||||
// ErrInvalidEmail is returned for an unparseable email address.
|
||||
@@ -46,6 +55,9 @@ var (
|
||||
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
|
||||
// ErrCodeMismatch is returned when the submitted 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
|
||||
@@ -57,12 +69,56 @@ var (
|
||||
type EmailService struct {
|
||||
store *Store
|
||||
mailer Mailer
|
||||
baseURL string
|
||||
limiter *SendLimiter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewEmailService constructs an EmailService over store, sending via mailer.
|
||||
func NewEmailService(store *Store, mailer Mailer) *EmailService {
|
||||
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }}
|
||||
// NewEmailService constructs an EmailService over store, sending via mailer. baseURL
|
||||
// is the canonical public origin (scheme + host) used to build the one-tap confirm
|
||||
// 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,
|
||||
@@ -73,6 +129,9 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.allowSend(addr) {
|
||||
return ErrTooManyRequests
|
||||
}
|
||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -83,16 +142,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
|
||||
}
|
||||
return ErrEmailTaken
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
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)
|
||||
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
|
||||
}
|
||||
|
||||
// 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,
|
||||
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
||||
// the unauthenticated email-login entry point and, unlike RequestCode,
|
||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
||||
// login. The code is mailed to the address, so only its real owner can complete
|
||||
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
|
||||
// seeds the new account's time zone. It returns the target account id for the
|
||||
// subsequent LoginWithCode.
|
||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) {
|
||||
// provisioning a fresh (unconfirmed) guest account when the email is new — it becomes
|
||||
// durable once the code is confirmed. It is the unauthenticated email-login entry
|
||||
// point and, unlike RequestCode, does not refuse an already-confirmed email — that is
|
||||
// the ordinary returning-user login. The code is mailed to the address, so only its
|
||||
// real owner can complete the login. On first contact browserTZ (the client's
|
||||
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
|
||||
// language. It returns the target account id for the subsequent LoginWithCode.
|
||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
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 {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
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 {
|
||||
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
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 {
|
||||
return Account{}, err
|
||||
}
|
||||
if err := s.store.ClearGuest(ctx, acc.ID); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return s.store.GetByID(ctx, acc.ID)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -26,16 +26,10 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
return err
|
||||
if !s.allowSend(addr) {
|
||||
return ErrTooManyRequests
|
||||
}
|
||||
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)
|
||||
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
|
||||
}
|
||||
|
||||
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
||||
|
||||
@@ -3,33 +3,78 @@ package account
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wneessen/go-mail"
|
||||
"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
|
||||
// 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
|
||||
// for cancellation; the standard-library SMTP implementation sends synchronously
|
||||
// and ignores it.
|
||||
// fixture (see docs/TESTING.md: no real network in tests). The context bounds the
|
||||
// delivery and is honoured by the SMTP implementation.
|
||||
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
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// SMTPMailer sends mail through an SMTP relay using the standard library. When a
|
||||
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated.
|
||||
const (
|
||||
// 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 {
|
||||
cfg SMTPConfig
|
||||
}
|
||||
@@ -39,29 +84,49 @@ func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
|
||||
return SMTPMailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Send delivers a plain-text UTF-8 message to to via the configured relay.
|
||||
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
|
||||
var auth smtp.Auth
|
||||
if m.cfg.Username != "" {
|
||||
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
// Send delivers a UTF-8 message to msg.To via the configured relay. When msg.HTML
|
||||
// is set the message is multipart/alternative (plain text plus HTML); otherwise
|
||||
// it is plain text only.
|
||||
func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
|
||||
port, err := strconv.Atoi(m.cfg.Port)
|
||||
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 {
|
||||
return fmt.Errorf("account: send mail to %s: %w", to, err)
|
||||
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.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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
@@ -74,11 +139,12 @@ func NewLogMailer(log *zap.Logger) LogMailer {
|
||||
return LogMailer{log: log}
|
||||
}
|
||||
|
||||
// Send logs the message at info level and reports success.
|
||||
func (m LogMailer) Send(_ context.Context, to, subject, body string) error {
|
||||
// Send logs the message at info level and reports success. It logs the plain-text
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -42,6 +43,12 @@ type Config struct {
|
||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||
// selects the development log mailer (the code is logged, not sent).
|
||||
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
|
||||
// side-service, used by the admin console to send operator broadcasts. Empty
|
||||
// disables broadcasts (the admin broadcast actions report "not configured").
|
||||
@@ -141,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{
|
||||
@@ -154,6 +162,7 @@ func Load() (Config, error) {
|
||||
Robot: rb,
|
||||
RateWatch: rw,
|
||||
SMTP: smtp,
|
||||
PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"),
|
||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||
GuestReapInterval: guestReapInterval,
|
||||
GuestRetention: guestRetention,
|
||||
@@ -203,6 +212,14 @@ func (c Config) validate() error {
|
||||
if c.GuestRetention <= 0 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
// recover the confirm-code from the body.
|
||||
type capturingMailer struct{ lastBody string }
|
||||
|
||||
func (m *capturingMailer) Send(_ context.Context, _, _, body string) error {
|
||||
m.lastBody = body
|
||||
func (m *capturingMailer) Send(_ context.Context, msg account.Message) error {
|
||||
m.lastBody = msg.Text
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestEmailConfirmFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer)
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
|
||||
acc := provisionAccount(t)
|
||||
email := "user-" + uuid.NewString() + "@example.com"
|
||||
@@ -67,7 +67,7 @@ func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer)
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
|
||||
owner := provisionAccount(t)
|
||||
email := "taken-" + uuid.NewString() + "@example.com"
|
||||
@@ -89,7 +89,7 @@ func TestEmailCodeExpires(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer)
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
|
||||
acc := provisionAccount(t)
|
||||
email := "expire-" + uuid.NewString() + "@example.com"
|
||||
@@ -111,7 +111,7 @@ func TestEmailTooManyAttempts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer)
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
|
||||
acc := provisionAccount(t)
|
||||
email := "lock-" + uuid.NewString() + "@example.com"
|
||||
@@ -203,10 +203,10 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
||||
func TestEmailLoginFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
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"
|
||||
|
||||
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00")
|
||||
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en")
|
||||
if err != nil {
|
||||
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.
|
||||
if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil {
|
||||
if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID
|
||||
|
||||
func newLinkService(mailer account.Mailer) *link.Service {
|
||||
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())
|
||||
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestStatusForError(t *testing.T) {
|
||||
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
||||
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
||||
"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"},
|
||||
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
||||
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
|
||||
|
||||
@@ -237,6 +237,8 @@ func statusForError(err error) (int, string) {
|
||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
|
||||
return http.StatusUnauthorized, "code_invalid"
|
||||
case errors.Is(err, account.ErrTooManyRequests):
|
||||
return http.StatusTooManyRequests, "too_many_requests"
|
||||
case errors.Is(err, session.ErrNotFound):
|
||||
return http.StatusUnauthorized, "session_invalid"
|
||||
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
|
||||
|
||||
@@ -172,6 +172,7 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
|
||||
type emailRequest struct {
|
||||
Email string `json:"email"`
|
||||
BrowserTZ string `json:"browser_tz"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// 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")
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,6 +26,25 @@ LOG_LEVEL=info
|
||||
# real contour value with `openssl rand -base64 32` (Gitea TEST_/PROD_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 -----------------------------------------------------------
|
||||
# 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.
|
||||
|
||||
+11
-2
@@ -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_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. 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
|
||||
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_{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,
|
||||
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,
|
||||
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}`.
|
||||
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)
|
||||
|
||||
|
||||
@@ -136,6 +136,21 @@ services:
|
||||
# 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).
|
||||
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 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
|
||||
|
||||
+12
-3
@@ -232,9 +232,18 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
|
||||
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 through a `Mailer` seam (an SMTP relay, or a
|
||||
development log mailer when none is configured) and, once verified, attaches a
|
||||
confirmed email identity. Accounts and identities use application-generated
|
||||
TTL, ≤ 5 attempts) is sent as a **branded HTML + plain-text email** through a
|
||||
`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
|
||||
**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
|
||||
payment; no purchase flow yet) is carried on the account and ORed on a merge.
|
||||
- **Linking** is initiated from an authenticated profile and proves
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
**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
|
||||
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
|
||||
|
||||
@@ -65,7 +65,14 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||
тихого повтора «не удалось
|
||||
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним
|
||||
загрузить» действует и внутри VK.
|
||||
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
|
||||
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
||||
не завалили почтовый ящик.
|
||||
Telegram держит **единого бота**: все игроки пользуются одним
|
||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
||||
|
||||
+4
-1
@@ -69,7 +69,10 @@ tests or touching CI.
|
||||
content and block-visibility rules, the nudge turn/rate-limit rules, the
|
||||
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
|
||||
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/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
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/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/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/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=
|
||||
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=
|
||||
|
||||
@@ -292,14 +292,14 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
|
||||
await expect(save).toBeEnabled();
|
||||
});
|
||||
|
||||
// Account linking is hidden in Profile.svelte while we target provider sign-in (the anonymous
|
||||
// /app/ guest who upgrades by linking comes later). The flow is kept wired; re-enable these two
|
||||
// specs together with the `.emailbox` section.
|
||||
// The email upgrade box is now shown to guests (email bind + the merge dialog). These specs
|
||||
// still assume a non-guest login and the visible Telegram control, so they stay skipped until
|
||||
// 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 }) => {
|
||||
await loginLobby(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();
|
||||
// An address containing "merge" stands in (in the mock) for one already owned by
|
||||
// another account, so the confirm step reveals a required merge.
|
||||
|
||||
@@ -244,10 +244,11 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Linking & merge. Hidden for now: we target provider sign-in, and the anonymous
|
||||
/app/ guest (whose upgrade path this is) comes later. Kept wired — drop `hidden`
|
||||
to re-enable, together with the skipped linking specs in e2e/social.spec.ts. -->
|
||||
<section class="emailbox" hidden>
|
||||
<!-- The guest's email upgrade path: bind an email to register / sign in (a returning
|
||||
address triggers the merge dialog below). Guest-only for now; provider linking
|
||||
(Add VK / Add Telegram / Unlink) and the non-guest matrix come next, together
|
||||
with the skipped linking specs in e2e/social.spec.ts. -->
|
||||
<section class="emailbox" hidden={!p.isGuest}>
|
||||
<h3>{t('profile.linkAccount')}</h3>
|
||||
{#if !emailSent}
|
||||
<div class="addrow">
|
||||
@@ -272,7 +273,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#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}
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user