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:
parent
12ce6216b6
commit
c57f851a8b
48 changed files with 1810 additions and 120 deletions
177
backend/accounts-service/src/mail/mod.rs
Normal file
177
backend/accounts-service/src/mail/mod.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! Outgoing email: the `Mailer` transports, the SMTP/no-op implementations
|
||||
//! and the shared, non-reversible log tag for addresses.
|
||||
//!
|
||||
//! Secrets discipline: no caller ever passes a plaintext token into a log
|
||||
//! line; templates are the only place user-visible mail text exists, and
|
||||
//! their dynamic input is escaped there.
|
||||
|
||||
pub mod templates;
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use lettre::{
|
||||
AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
|
||||
message::{Mailbox, Message, MultiPart},
|
||||
transport::smtp::{
|
||||
authentication::Credentials,
|
||||
client::{Tls, TlsParameters},
|
||||
},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Language of the built-in email templates (`MAIL_LANG`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Lang {
|
||||
#[default]
|
||||
Ru,
|
||||
En,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
pub fn parse(s: &str) -> Option<Lang> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"ru" => Some(Lang::Ru),
|
||||
"en" => Some(Lang::En),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fully rendered email ready for any transport. Templates escape dynamic
|
||||
/// content, so a draft is safe to hand to a transport as-is.
|
||||
pub struct EmailDraft {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub text: String,
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
/// Transport abstraction. One method keeps fakes trivial. Desugared to an
|
||||
/// explicit `Send` future (rather than AFIT) so the trait stays usable from
|
||||
/// `tokio::spawn`ed background tasks without hidden auto-trait surprises.
|
||||
pub trait Mailer {
|
||||
fn deliver(&self, draft: EmailDraft) -> impl Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
/// Real SMTP via lettre. TLS only: 465 speaks implicit TLS, every other port
|
||||
/// uses required STARTTLS (no plaintext downgrade for password mail). rustls
|
||||
/// everywhere — no native-tls in the dependency tree.
|
||||
pub struct SmtpMailer {
|
||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||
from: Mailbox,
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
impl SmtpMailer {
|
||||
/// `from` is a full mailbox (`LoVisual <noreply@loki-code.dev>` or a bare
|
||||
/// address); `public_base_url` is the only link origin emails may carry.
|
||||
pub fn new(
|
||||
host: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
password: &str,
|
||||
from: &str,
|
||||
public_base_url: &str,
|
||||
) -> Result<Self> {
|
||||
let tls = TlsParameters::builder(host.to_owned())
|
||||
.build()
|
||||
.context("building SMTP TLS parameters")?;
|
||||
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.to_owned())
|
||||
.port(port)
|
||||
.tls(if port == 465 {
|
||||
Tls::Wrapper(tls)
|
||||
} else {
|
||||
Tls::Required(tls)
|
||||
});
|
||||
if !user.trim().is_empty() {
|
||||
builder = builder.credentials(Credentials::new(user.to_owned(), password.to_owned()));
|
||||
}
|
||||
Ok(SmtpMailer {
|
||||
transport: builder.build(),
|
||||
from: from.parse().context("MAIL_FROM is not a valid mailbox")?,
|
||||
public_base_url: public_base_url.trim().trim_end_matches('/').to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Link origin for email buttons, taken from config only — never from
|
||||
/// request headers (host header injection would forge phishing links).
|
||||
pub fn public_base_url(&self) -> &str {
|
||||
&self.public_base_url
|
||||
}
|
||||
}
|
||||
|
||||
impl Mailer for SmtpMailer {
|
||||
async fn deliver(&self, draft: EmailDraft) -> Result<()> {
|
||||
let mail = Message::builder()
|
||||
.from(self.from.clone())
|
||||
.to(draft
|
||||
.to
|
||||
.parse()
|
||||
.context("recipient is not a valid mailbox")?)
|
||||
.subject(draft.subject)
|
||||
.multipart(MultiPart::alternative_plain_html(draft.text, draft.html))?;
|
||||
self.transport.send(mail).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Stand-in transport for dev runs that deliberately skip SMTP: reports
|
||||
/// success and logs the fact, so callers need no disabled branch. Only
|
||||
/// non-confidential metadata is logged.
|
||||
pub struct NoopMailer;
|
||||
|
||||
impl Mailer for NoopMailer {
|
||||
async fn deliver(&self, draft: EmailDraft) -> Result<()> {
|
||||
tracing::info!(subject = %draft.subject, "no-op mailer: delivery suppressed (no SMTP configured)");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Short non-reversible log tag for an address: enough to correlate log
|
||||
/// lines for the same recipient across requests, useless for rebuilding the
|
||||
/// address or matching it against a candidate list.
|
||||
pub fn address_tag(email: &str) -> String {
|
||||
let digest = Sha256::digest(email.trim().to_lowercase().as_bytes());
|
||||
digest[..4].iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lang_parses_both_locales_and_nothing_else() {
|
||||
assert_eq!(Lang::parse("ru"), Some(Lang::Ru));
|
||||
assert_eq!(Lang::parse("EN"), Some(Lang::En));
|
||||
assert_eq!(Lang::parse("de"), None);
|
||||
assert_eq!(Lang::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_tag_is_short_stable_and_not_the_address() {
|
||||
let a = address_tag("User@Example.com ");
|
||||
let b = address_tag("user@example.com");
|
||||
assert_eq!(a, b, "tag must normalize case and whitespace");
|
||||
assert_eq!(a.len(), 8);
|
||||
assert!(!a.contains("user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_tag_differs_per_address() {
|
||||
assert_ne!(address_tag("a@example.com"), address_tag("b@example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_mailer_reports_success_without_sending() {
|
||||
NoopMailer
|
||||
.deliver(EmailDraft {
|
||||
to: "a@example.com".into(),
|
||||
subject: "s".into(),
|
||||
text: "t".into(),
|
||||
html: "<p>t</p>".into(),
|
||||
})
|
||||
.await
|
||||
.expect("no-op delivery must succeed");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue