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
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:
@@ -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 }}
|
||||||
|
|||||||
@@ -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
@@ -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. |
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user