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:
Ilia Denisov
2026-07-03 02:25:28 +02:00
parent d5fbaa3034
commit 3877b23894
14 changed files with 464 additions and 96 deletions
+2 -1
View File
@@ -172,6 +172,7 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
type emailRequest struct {
Email string `json:"email"`
BrowserTZ string `json:"browser_tz"`
Language string `json:"language"`
}
// handleEmailRequest issues a login confirm-code to the email. It always reports
@@ -183,7 +184,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
abortBadRequest(c, "email is required")
return
}
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ); err != nil {
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil {
s.abortErr(c, err)
return
}