diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 19796d6..2f9a61b 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -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, // so they are handed to the server (like the route groups) for the handlers. mailer := newMailer(cfg.SMTP, logger) - emails := account.NewEmailService(accounts, mailer) + emails := account.NewEmailService(accounts, mailer, cfg.PublicBaseURL) // Account linking & merge: the orchestrator over the account, merge and // session layers. Wired to the /api/v1/user/link REST surface below. links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) diff --git a/backend/go.mod b/backend/go.mod index bf06733..eb56b0f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,6 +13,7 @@ require ( github.com/pressly/goose/v3 v3.27.1 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + github.com/wneessen/go-mail v0.7.3 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 @@ -109,7 +110,7 @@ require ( golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index cf9ae83..32f62c9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -279,6 +279,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0= +github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -400,6 +402,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index cd4b5a0..9da3bc5 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -121,13 +121,30 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string } // ProvisionEmail returns the account owning the email identity externalID, creating -// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM" -// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe -// and leaves an existing account untouched, so a returning user's saved zone is never -// overwritten. The email account is created here (the code-request step), not at the -// later login, so this is where its zone is seeded. -func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) { - return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)}) +// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset — +// seeded into its time zone and language seeded from the client's UI language. Like +// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a +// returning user's saved zone and language are never overwritten. The email account is +// created here (the code-request step), not at the later login, so this is where its +// zone and language are seeded. It is created flagged is_guest with an unconfirmed +// email identity: an abandoned, never-confirmed login is then reaped like any guest, +// freeing the reserved address, and confirming the code clears the guest flag. +func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) { + return s.provision(ctx, KindEmail, externalID, provisionSeed{ + timeZone: seedZone(browserTZ), + preferredLanguage: supportedLanguage(language), + isGuest: true, + }) +} + +// supportedLanguage returns code normalised to a supported UI language ("en" or +// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It +// accepts region-tagged codes ("ru-RU"). +func supportedLanguage(code string) string { + if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(code)), "-"); lang == "en" || lang == "ru" { + return lang + } + return "" } // ProvisionRobot provisions (or finds) the durable account backing a robot pool @@ -239,6 +256,11 @@ type provisionSeed struct { preferredLanguage string displayName string timeZone string + // isGuest creates the account flagged is_guest. It is set for an email-login + // account, which stays a guest until the address is confirmed (so an abandoned, + // never-confirmed login is reaped and its address freed); confirming clears the + // flag. Platform identities (telegram/vk) are durable from creation. + isGuest bool } // seedZone returns browserTZ when it is a well-formed zone to persist at account @@ -447,8 +469,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis tz = "UTC" } insertAccount := table.Accounts. - INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone). - VALUES(accountID, seed.displayName, lang, tz). + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest). + VALUES(accountID, seed.displayName, lang, tz, seed.isGuest). RETURNING(table.Accounts.AllColumns) var row model.Accounts diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index ae1fb7f..73886f9 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -28,6 +28,15 @@ const ( emailCodeMaxAttempts = 5 ) +// Confirmation purposes recorded on a pending confirm-code row. They select what +// verifying the code or the deeplink token does: sign in (login) or link/confirm the +// address on the current account (link). Email change and account deletion add +// further purposes in later stages. +const ( + purposeLogin = "login" + purposeLink = "link" +) + // Errors returned by the email confirm-code flow. var ( // ErrInvalidEmail is returned for an unparseable email address. @@ -55,14 +64,47 @@ var ( // account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow — // and using an email as a login reuses this mechanism. type EmailService struct { - store *Store - mailer Mailer - now func() time.Time + store *Store + mailer Mailer + baseURL string + now func() time.Time } -// NewEmailService constructs an EmailService over store, sending via mailer. -func NewEmailService(store *Store, mailer Mailer) *EmailService { - return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }} +// NewEmailService constructs an EmailService over store, sending via mailer. baseURL +// is the canonical public origin (scheme + host) used to build the one-tap confirm +// deeplink and the email footer landing link; an empty baseURL omits the deeplink +// (development / log mailer). +func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService { + return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }} +} + +// 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, @@ -83,16 +125,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema } return ErrEmailTaken } - code, hash, err := generateCode() - if err != nil { - return err - } - if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { - return err - } - subject := "Your Scrabble confirmation code" - body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute)) - return s.mailer.Send(ctx, addr, subject, body) + return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID)) } // ConfirmCode verifies code for accountID and email. On success it attaches a @@ -127,32 +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, -// provisioning a fresh (unconfirmed) durable account when the email is new. It is -// the unauthenticated email-login entry point and, unlike RequestCode, -// does not refuse an already-confirmed email — that is the ordinary returning-user -// login. The code is mailed to the address, so only its real owner can complete -// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset) -// seeds the new account's time zone. It returns the target account id for the -// subsequent LoginWithCode. -func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) { +// provisioning a fresh (unconfirmed) guest account when the email is new — it becomes +// durable once the code is confirmed. It is the unauthenticated email-login entry +// point and, unlike RequestCode, does not refuse an already-confirmed email — that is +// the ordinary returning-user login. The code is mailed to the address, so only its +// real owner can complete the login. On first contact browserTZ (the client's +// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI +// language. It returns the target account id for the subsequent LoginWithCode. +func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) { addr, err := normalizeEmail(email) if err != nil { return uuid.UUID{}, err } - acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ) + acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language) if err != nil { return uuid.UUID{}, err } - code, hash, err := generateCode() - if err != nil { - return uuid.UUID{}, err - } - if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { - return uuid.UUID{}, err - } - subject := "Your Scrabble login code" - body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute)) - if err := s.mailer.Send(ctx, addr, subject, body); err != nil { + if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil { return uuid.UUID{}, err } return acc.ID, nil @@ -191,6 +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 { return Account{}, err } + if err := s.store.ClearGuest(ctx, acc.ID); err != nil { + return Account{}, err + } return s.store.GetByID(ctx, acc.ID) } diff --git a/backend/internal/account/emailtemplate.go b/backend/internal/account/emailtemplate.go new file mode 100644 index 0000000..58a88ab --- /dev/null +++ b/backend/internal/account/emailtemplate.go @@ -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