feat(accounts): outgoing mail over SMTP (Resend), email verification, fail-closed password reset

- mail/ module (lettre, ru/en templates), links built only from PUBLIC_BASE_URL
- migration 0006: accounts.email_verified_at, shared email_tokens table (verify + reset), existing accounts marked verified
- reset mailer no longer logs tokens; RESET_MAIL_MODE=log is dev-only and refused with COOKIE_SECURE=true; Disabled by default answers 503
- forgot-password and resend-verification do their work in a background task (no timing oracle)
- device linking requires a verified email; email_verified exposed via /me and gRPC
- gateway rate limits, SMTP_* in compose and .env.example
- frontend: verify, forgot-password, reset-password pages, verify banner, ru/en strings
This commit is contained in:
loki5512344 2026-10-09 21:08:33 +02:00
parent 12ce6216b6
commit c57f851a8b
Signed by: boba
GPG key ID: 253067914055423B
48 changed files with 1810 additions and 120 deletions

View file

@ -38,6 +38,14 @@ pub fn test_config() -> Config {
s3_secret_key: "minioadmin".into(),
cookie_secure: false,
avatar_public_base_url: String::new(),
smtp_host: String::new(),
smtp_port: 587,
smtp_user: String::new(),
smtp_password: String::new(),
mail_from: String::new(),
public_base_url: String::new(),
mail_lang: accounts_service::mail::Lang::default(),
reset_mail_log: false,
}
}
@ -50,7 +58,9 @@ pub fn test_server(app: axum::Router) -> TestServer {
server
}
/// Registers a fresh random account; returns its id and email.
/// Registers a fresh random account; returns its id and email. The account
/// starts unverified, exactly like a real sign-up (device linking therefore
/// needs `mark_verified` first).
pub async fn register_account(server: &TestServer) -> (Uuid, String) {
let email = format!("t-{}@example.com", Uuid::new_v4());
let res = server
@ -65,6 +75,17 @@ pub async fn register_account(server: &TestServer) -> (Uuid, String) {
)
}
/// Marks the account's email as verified in place. Stand-in for the real
/// `/auth/verify-email` round trip in flows that only need a verified
/// account (device linking, avatars).
pub async fn mark_verified(pool: &sqlx::PgPool, account_id: Uuid) {
sqlx::query("UPDATE accounts SET email_verified_at = now() WHERE id = $1")
.bind(account_id)
.execute(pool)
.await
.expect("mark account verified");
}
/// Creates the test avatar bucket if it does not exist yet, so the avatar
/// tests are self-contained: any S3-compatible backend (MinIO, SeaweedFS)
/// works without external `mc mb` bootstrap. Mirrors `S3Storage::from_config`.