b918217497
Unlink: POST /user/link/unlink (telegram|vk) via account.RemoveIdentity, refusing
the last identity; email is never unlinked. New fbs LinkUnlinkRequest + gateway
link.unlink op, returning the refreshed profile.
Change-email: purposeChange confirm-codes (RequestChangeCode/ConfirmChange) that
atomically replace the account's confirmed email (account.replaceEmailIdentity);
a new address owned by another account is refused without disclosure, never merged.
The one-tap deeplink handles purposeChange too. Reuses the LinkEmail* fbs tables;
gateway link.email.change.{request,confirm} ops + backendclient methods; branded
ru/en change-email copy.
222 lines
8.6 KiB
Go
222 lines
8.6 KiB
Go
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: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
|
|
},
|
|
},
|
|
purposeChange: {
|
|
"en": {
|
|
Subject: "Confirm your new Erudit e-mail",
|
|
Preheader: "Confirm your new address",
|
|
Heading: "Confirm your new e-mail",
|
|
Intro: "Enter this code to switch your account to this address:",
|
|
CTALabel: "Confirm with one tap",
|
|
FooterIgnore: "If you didn't request this change, you can safely ignore it — your address stays the same.",
|
|
},
|
|
"ru": {
|
|
Subject: "Подтвердите новый e-mail в Эрудит",
|
|
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}}
|
|
`))
|