feat(account): brand and harden the email pipeline
Swap the net/smtp mailer for go-mail behind the existing Mailer seam: the Message struct now carries a text + HTML body, TLS mode is chosen from the port (implicit TLS on 465, else mandatory STARTTLS), a dial timeout bounds the synchronous send, and no client certificate is needed. Add a branded, image-free, mobile-friendly ru/en HTML template (with a plain-text alternative) rendering a large readable code and an ignore-notice footer with a landing link. Add BACKEND_PUBLIC_BASE_URL config (the canonical origin for the email footer link, never the request Host — anti-injection), required when a relay is configured. Fix the email-login address squat: ProvisionEmail creates the account flagged is_guest until the code is confirmed, so an abandoned login is reaped like any guest and its address freed; confirming (login or link) clears the flag. Seed the new account's language from the client, plumbed through the email-login request. The confirm deeplink, its transport surface and the send rate-limit land in follow-up work.
This commit is contained in:
@@ -174,7 +174,7 @@ 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)
|
||||||
// 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.
|
||||||
@@ -57,12 +66,45 @@ var (
|
|||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
store *Store
|
store *Store
|
||||||
mailer Mailer
|
mailer Mailer
|
||||||
|
baseURL string
|
||||||
now func() time.Time
|
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() }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
@@ -83,16 +125,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 +160,23 @@ 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)
|
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 +215,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,7 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
||||||
|
|||||||
@@ -3,23 +3,37 @@ package account
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"strconv"
|
||||||
"net/smtp"
|
"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. The Port selects the TLS
|
||||||
|
// mode: smtpImplicitTLSPort (465) uses implicit TLS (SSL); any other port uses
|
||||||
|
// mandatory STARTTLS.
|
||||||
type SMTPConfig struct {
|
type SMTPConfig struct {
|
||||||
Host string
|
Host string
|
||||||
Port string
|
Port string
|
||||||
@@ -28,8 +42,19 @@ type SMTPConfig struct {
|
|||||||
From string
|
From 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.
|
// smtpImplicitTLSPort is the port on which the relay is dialled with implicit
|
||||||
|
// TLS (SSL). Any other port negotiates mandatory STARTTLS instead.
|
||||||
|
smtpImplicitTLSPort = 465
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 +64,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.WithTimeout(smtpDialTimeout)}
|
||||||
return fmt.Errorf("account: send mail to %s: %w", to, err)
|
if port == smtpImplicitTLSPort {
|
||||||
|
opts = append(opts, mail.WithSSLPort(false))
|
||||||
|
} else {
|
||||||
|
opts = append(opts, mail.WithPort(port), 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 +119,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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").
|
||||||
@@ -154,6 +161,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 +211,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))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
Reference in New Issue
Block a user