fabrica

hanna/fabrica

feat(mail): SMTP delivery, invite tokens, and user-add activation

7b0dd81 · hanna committed on 2026-07-25

Add the mail crate: three backends (smtp over native-TLS, stdout, none),
plain-text-primary/HTML-alternative activate and reset templates rendered
from the instance name/URL, and a best-effort Mailer::send_invite.

Invites are persisted hashed: the store gains create_invite /
invite_by_token_hash / single-use mark_invite_used over the invites
table, and model gains Invite. `fabrica user add` without --pass now
mints an activation invite (32-byte token, only its SHA-256 stored),
tries to email it, and always prints the {instance.url}/invite/{token}
link so an operator can deliver it when SMTP is off. Config gains
auth.invite_ttl_hours (default 72).

native-tls replaces the spec's rustls transport to keep ring out of the
license graph (see docs/decisions.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
15 files changed · +846 −11UnifiedSplit
Cargo.lock +223 −0
@@ -155,6 +155,17 @@ dependencies = [
155155 ]
156156
157157 [[package]]
158+name = "async-trait"
159+version = "0.1.91"
160+source = "registry+https://github.com/rust-lang/crates.io-index"
161+checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
162+dependencies = [
163+ "proc-macro2",
164+ "quote",
165+ "syn 3.0.3",
166+]
167+
168+[[package]]
158169 name = "atoi"
159170 version = "2.0.0"
160171 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -467,6 +478,7 @@ dependencies = [
467478 "clap",
468479 "config",
469480 "git",
481+ "mail",
470482 "model",
471483 "rpassword",
472484 "serde",
@@ -546,6 +558,22 @@ dependencies = [
546558 ]
547559
548560 [[package]]
561+name = "core-foundation"
562+version = "0.10.1"
563+source = "registry+https://github.com/rust-lang/crates.io-index"
564+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
565+dependencies = [
566+ "core-foundation-sys",
567+ "libc",
568+]
569+
570+[[package]]
571+name = "core-foundation-sys"
572+version = "0.8.7"
573+source = "registry+https://github.com/rust-lang/crates.io-index"
574+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
575+
576+[[package]]
549577 name = "cpufeatures"
550578 version = "0.2.17"
551579 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -972,6 +1000,22 @@ dependencies = [
9721000 ]
9731001
9741002 [[package]]
1003+name = "email-encoding"
1004+version = "0.4.1"
1005+source = "registry+https://github.com/rust-lang/crates.io-index"
1006+checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
1007+dependencies = [
1008+ "base64",
1009+ "memchr",
1010+]
1011+
1012+[[package]]
1013+name = "email_address"
1014+version = "0.2.9"
1015+source = "registry+https://github.com/rust-lang/crates.io-index"
1016+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
1017+
1018+[[package]]
9751019 name = "encode_unicode"
9761020 version = "1.0.0"
9771021 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1100,6 +1144,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
11001144 checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
11011145
11021146 [[package]]
1147+name = "foreign-types"
1148+version = "0.3.2"
1149+source = "registry+https://github.com/rust-lang/crates.io-index"
1150+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
1151+dependencies = [
1152+ "foreign-types-shared",
1153+]
1154+
1155+[[package]]
1156+name = "foreign-types-shared"
1157+version = "0.1.1"
1158+source = "registry+https://github.com/rust-lang/crates.io-index"
1159+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
1160+
1161+[[package]]
11031162 name = "form_urlencoded"
11041163 version = "1.2.2"
11051164 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1377,6 +1436,23 @@ dependencies = [
13771436 ]
13781437
13791438 [[package]]
1439+name = "hostname"
1440+version = "0.4.2"
1441+source = "registry+https://github.com/rust-lang/crates.io-index"
1442+checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
1443+dependencies = [
1444+ "cfg-if",
1445+ "libc",
1446+ "windows-link",
1447+]
1448+
1449+[[package]]
1450+name = "httpdate"
1451+version = "1.0.3"
1452+source = "registry+https://github.com/rust-lang/crates.io-index"
1453+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
1454+
1455+[[package]]
13801456 name = "hybrid-array"
13811457 version = "0.4.13"
13821458 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1622,6 +1698,33 @@ dependencies = [
16221698 ]
16231699
16241700 [[package]]
1701+name = "lettre"
1702+version = "0.11.22"
1703+source = "registry+https://github.com/rust-lang/crates.io-index"
1704+checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
1705+dependencies = [
1706+ "async-trait",
1707+ "base64",
1708+ "email-encoding",
1709+ "email_address",
1710+ "fastrand",
1711+ "futures-io",
1712+ "futures-util",
1713+ "hostname",
1714+ "httpdate",
1715+ "idna",
1716+ "mime",
1717+ "native-tls",
1718+ "nom",
1719+ "percent-encoding",
1720+ "quoted_printable",
1721+ "socket2",
1722+ "tokio",
1723+ "tokio-native-tls",
1724+ "url",
1725+]
1726+
1727+[[package]]
16251728 name = "libbz2-rs-sys"
16261729 version = "0.2.5"
16271730 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1704,6 +1807,12 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
17041807 [[package]]
17051808 name = "mail"
17061809 version = "0.1.0"
1810+dependencies = [
1811+ "config",
1812+ "lettre",
1813+ "thiserror 2.0.19",
1814+ "tokio",
1815+]
17071816
17081817 [[package]]
17091818 name = "md-5"
@@ -1732,6 +1841,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
17321841 checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
17331842
17341843 [[package]]
1844+name = "mime"
1845+version = "0.3.17"
1846+source = "registry+https://github.com/rust-lang/crates.io-index"
1847+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1848+
1849+[[package]]
17351850 name = "miniz_oxide"
17361851 version = "0.8.9"
17371852 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1777,6 +1892,23 @@ dependencies = [
17771892 ]
17781893
17791894 [[package]]
1895+name = "native-tls"
1896+version = "0.2.18"
1897+source = "registry+https://github.com/rust-lang/crates.io-index"
1898+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
1899+dependencies = [
1900+ "libc",
1901+ "log",
1902+ "openssl",
1903+ "openssl-probe",
1904+ "openssl-sys",
1905+ "schannel",
1906+ "security-framework",
1907+ "security-framework-sys",
1908+ "tempfile",
1909+]
1910+
1911+[[package]]
17801912 name = "nom"
17811913 version = "8.0.0"
17821914 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1883,6 +2015,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
18832015 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
18842016
18852017 [[package]]
2018+name = "openssl"
2019+version = "0.10.81"
2020+source = "registry+https://github.com/rust-lang/crates.io-index"
2021+checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
2022+dependencies = [
2023+ "bitflags",
2024+ "cfg-if",
2025+ "foreign-types",
2026+ "libc",
2027+ "openssl-macros",
2028+ "openssl-sys",
2029+]
2030+
2031+[[package]]
2032+name = "openssl-macros"
2033+version = "0.1.1"
2034+source = "registry+https://github.com/rust-lang/crates.io-index"
2035+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
2036+dependencies = [
2037+ "proc-macro2",
2038+ "quote",
2039+ "syn 2.0.119",
2040+]
2041+
2042+[[package]]
2043+name = "openssl-probe"
2044+version = "0.2.1"
2045+source = "registry+https://github.com/rust-lang/crates.io-index"
2046+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
2047+
2048+[[package]]
2049+name = "openssl-sys"
2050+version = "0.9.117"
2051+source = "registry+https://github.com/rust-lang/crates.io-index"
2052+checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
2053+dependencies = [
2054+ "cc",
2055+ "libc",
2056+ "pkg-config",
2057+ "vcpkg",
2058+]
2059+
2060+[[package]]
18862061 name = "p256"
18872062 version = "0.13.2"
18882063 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2178,6 +2353,12 @@ dependencies = [
21782353 ]
21792354
21802355 [[package]]
2356+name = "quoted_printable"
2357+version = "0.5.2"
2358+source = "registry+https://github.com/rust-lang/crates.io-index"
2359+checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
2360+
2361+[[package]]
21812362 name = "r-efi"
21822363 version = "5.3.0"
21832364 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2405,6 +2586,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
24052586 checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
24062587
24072588 [[package]]
2589+name = "schannel"
2590+version = "0.1.29"
2591+source = "registry+https://github.com/rust-lang/crates.io-index"
2592+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
2593+dependencies = [
2594+ "windows-sys 0.61.2",
2595+]
2596+
2597+[[package]]
24082598 name = "scopeguard"
24092599 version = "1.2.0"
24102600 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2426,6 +2616,29 @@ dependencies = [
24262616 ]
24272617
24282618 [[package]]
2619+name = "security-framework"
2620+version = "3.7.0"
2621+source = "registry+https://github.com/rust-lang/crates.io-index"
2622+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
2623+dependencies = [
2624+ "bitflags",
2625+ "core-foundation",
2626+ "core-foundation-sys",
2627+ "libc",
2628+ "security-framework-sys",
2629+]
2630+
2631+[[package]]
2632+name = "security-framework-sys"
2633+version = "2.17.0"
2634+source = "registry+https://github.com/rust-lang/crates.io-index"
2635+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
2636+dependencies = [
2637+ "core-foundation-sys",
2638+ "libc",
2639+]
2640+
2641+[[package]]
24292642 name = "semver"
24302643 version = "1.0.28"
24312644 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3069,6 +3282,16 @@ dependencies = [
30693282 ]
30703283
30713284 [[package]]
3285+name = "tokio-native-tls"
3286+version = "0.3.1"
3287+source = "registry+https://github.com/rust-lang/crates.io-index"
3288+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
3289+dependencies = [
3290+ "native-tls",
3291+ "tokio",
3292+]
3293+
3294+[[package]]
30723295 name = "tokio-stream"
30733296 version = "0.1.19"
30743297 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +10 −0
@@ -101,6 +101,16 @@ insta = "1"
101101 # is LGPL, which our license gate forbids (see docs/decisions.md).
102102 ssh-key = { version = "0.6.7", features = ["ed25519", "rsa", "p256", "std"] }
103103 pgp = "0.20"
104+# SMTP mail. native-tls (system OpenSSL, already a build input) rather than the
105+# spec's rustls transport, which pulls `ring` and would fail the license gate —
106+# see docs/decisions.md.
107+lettre = { version = "0.11", default-features = false, features = [
108+ "builder",
109+ "smtp-transport",
110+ "tokio1",
111+ "tokio1-native-tls",
112+ "hostname",
113+] }
104114
105115 [workspace.lints.rust]
106116 unsafe_code = "forbid"
crates/cli/Cargo.toml +1 −0
@@ -14,6 +14,7 @@ config = { workspace = true }
1414 store = { workspace = true }
1515 auth = { workspace = true }
1616 git = { workspace = true }
17+mail = { workspace = true }
1718 model = { workspace = true }
1819 anyhow = { workspace = true }
1920 clap = { workspace = true }
crates/cli/src/user_cmd.rs +56 −9
@@ -10,7 +10,7 @@ use std::process::ExitCode;
1010 use clap::Subcommand;
1111 use config::Config;
1212 use model::User;
13-use store::{NewUser, Store};
13+use store::{NewInvite, NewUser, Store};
1414
1515 use crate::context::{block_on, open_store};
1616 use crate::prompt::{confirm, resolve_password};
@@ -160,18 +160,65 @@ async fn add(
160160 if user.is_admin { " (admin)" } else { "" }
161161 );
162162 if !has_password {
163- // Email invitations are not implemented yet (phase 9), so there is no
164- // out-of-band way to activate the account — point the operator at the
165- // direct path. This is the SMTP-disabled fallback made explicit.
166- eprintln!(
167- "note: no password set; this account cannot log in yet. \
168- Set one with: fabrica user passwd {}",
169- user.username
170- );
163+ // No password was set, so mint an activation invite, try to email it, and
164+ // always print the link so an operator can deliver it when SMTP is off.
165+ send_activation(&config, &store, &user).await?;
171166 }
172167 Ok(ExitCode::SUCCESS)
173168 }
174169
170+/// Mint an activation invite for `user`, attempt to email it (best-effort), and
171+/// print the activation URL to stdout regardless.
172+async fn send_activation(config: &Config, store: &Store, user: &User) -> anyhow::Result<()> {
173+ // The emailed token is opaque 32-byte randomness; only its SHA-256 is stored.
174+ let token = auth::new_session_token();
175+ let ttl_ms = i64::from(config.auth.invite_ttl_hours).saturating_mul(3_600_000);
176+ store
177+ .create_invite(NewInvite {
178+ user_id: user.id.clone(),
179+ token_hash: token.id,
180+ purpose: mail::Purpose::Activate.as_str().to_string(),
181+ expires_at: now_ms().saturating_add(ttl_ms),
182+ })
183+ .await?;
184+
185+ let link = format!(
186+ "{}/invite/{}",
187+ config.instance.url.trim_end_matches('/'),
188+ token.cookie
189+ );
190+
191+ match mail::Mailer::build(&config.mail) {
192+ Ok(mailer) => match mailer
193+ .send_invite(
194+ &config.instance.name,
195+ &user.email,
196+ user.display_name.as_deref(),
197+ &link,
198+ mail::Purpose::Activate,
199+ )
200+ .await
201+ {
202+ Ok(()) => println!("sent activation email to {}", user.email),
203+ Err(err) => eprintln!("warning: could not send activation email: {err}"),
204+ },
205+ Err(err) => eprintln!("warning: mail not configured: {err}"),
206+ }
207+ println!(
208+ "activation link (valid {}h): {link}",
209+ config.auth.invite_ttl_hours
210+ );
211+ Ok(())
212+}
213+
214+/// The current time in Unix milliseconds.
215+fn now_ms() -> i64 {
216+ use std::time::{SystemTime, UNIX_EPOCH};
217+ SystemTime::now()
218+ .duration_since(UNIX_EPOCH)
219+ .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
220+}
221+
175222 async fn passwd(
176223 config_path: Option<&Path>,
177224 name: &str,
crates/config/src/types.rs +3 −0
@@ -181,6 +181,8 @@ pub struct Auth {
181181 pub session_ttl_days: u32,
182182 /// API token lifetime, in days.
183183 pub token_ttl_days: u32,
184+ /// Activation/reset invite lifetime, in hours.
185+ pub invite_ttl_hours: u32,
184186 /// Name of the session cookie.
185187 pub cookie_name: String,
186188 /// Set the `Secure` attribute on the session cookie.
@@ -200,6 +202,7 @@ impl Default for Auth {
200202 secret_file: None,
201203 session_ttl_days: 30,
202204 token_ttl_days: 90,
205+ invite_ttl_hours: 72,
203206 cookie_name: "fabrica_session".to_string(),
204207 cookie_secure: true,
205208 argon2_m_cost: 19456,
crates/mail/Cargo.toml +6 −0
@@ -10,6 +10,12 @@ repository.workspace = true
1010 publish.workspace = true
1111
1212 [dependencies]
13+config = { workspace = true }
14+lettre = { workspace = true }
15+thiserror = { workspace = true }
16+
17+[dev-dependencies]
18+tokio = { workspace = true }
1319
1420 [lints]
1521 workspace = true
crates/mail/src/lib.rs +223 −1
@@ -2,4 +2,226 @@
22 // License, v. 2.0. If a copy of the MPL was not distributed with this
33 // file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5-//! Outbound mail: templates rendered to text and HTML, sent best-effort.
5+//! Outbound mail: SMTP delivery plus the activation/reset templates.
6+//!
7+//! Three backends (`config::MailBackend`): `smtp` delivers over SMTP with
8+//! native-TLS (`starttls`/`tls`/plaintext), `stdout` prints the rendered message
9+//! (development), and `none` rejects any flow that needs mail. Sending is
10+//! best-effort — the caller always has the activation URL to deliver by hand — so
11+//! `send_invite` returns a `Result` the caller logs rather than fails on.
12+
13+mod templates;
14+
15+use std::time::Duration;
16+
17+use config::{Mail, MailBackend, MailEncryption};
18+use lettre::message::{Mailbox, MultiPart};
19+use lettre::transport::smtp::authentication::Credentials;
20+use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
21+
22+pub use crate::templates::{Purpose, Rendered, render};
23+
24+/// An error from the mail layer.
25+#[derive(Debug, thiserror::Error)]
26+pub enum MailError {
27+ /// The `none` backend was asked to send.
28+ #[error("mail is disabled (mail.backend = \"none\")")]
29+ Disabled,
30+ /// An address failed to parse.
31+ #[error("invalid email address: {0}")]
32+ Address(String),
33+ /// Building the SMTP transport failed.
34+ #[error("smtp transport setup failed: {0}")]
35+ Transport(String),
36+ /// Building the message failed.
37+ #[error("message construction failed: {0}")]
38+ Message(String),
39+ /// Delivery failed.
40+ #[error("mail delivery failed: {0}")]
41+ Send(String),
42+}
43+
44+/// A configured mailer. Cheap to build once and reuse.
45+pub struct Mailer {
46+ from: Mailbox,
47+ backend: Delivery,
48+}
49+
50+/// The delivery mechanism behind a [`Mailer`].
51+enum Delivery {
52+ /// Deliver over SMTP.
53+ Smtp(Box<AsyncSmtpTransport<Tokio1Executor>>),
54+ /// Print the rendered message to stdout.
55+ Stdout,
56+ /// Reject sends.
57+ None,
58+}
59+
60+impl Mailer {
61+ /// Build a mailer from configuration.
62+ ///
63+ /// # Errors
64+ ///
65+ /// Returns [`MailError::Address`] if `mail.from` is not a valid address, or
66+ /// [`MailError::Transport`] if the SMTP transport cannot be constructed.
67+ pub fn build(config: &Mail) -> Result<Self, MailError> {
68+ let from: Mailbox = config
69+ .from
70+ .parse()
71+ .map_err(|e| MailError::Address(format!("{}: {e}", config.from)))?;
72+
73+ let backend = match config.backend {
74+ MailBackend::Stdout => Delivery::Stdout,
75+ MailBackend::None => Delivery::None,
76+ MailBackend::Smtp => Delivery::Smtp(Box::new(build_transport(config)?)),
77+ };
78+ Ok(Self { from, backend })
79+ }
80+
81+ /// Send an invite email to `to_email` (optionally named `to_name`) with the
82+ /// activation `link`.
83+ ///
84+ /// # Errors
85+ ///
86+ /// Returns [`MailError`] on address/message/transport failure, or
87+ /// [`MailError::Disabled`] under the `none` backend. Callers treat this as
88+ /// best-effort and still surface the link out of band.
89+ pub async fn send_invite(
90+ &self,
91+ instance_name: &str,
92+ to_email: &str,
93+ to_name: Option<&str>,
94+ link: &str,
95+ purpose: Purpose,
96+ ) -> Result<(), MailError> {
97+ let rendered = render(instance_name, link, purpose);
98+ let to = mailbox(to_email, to_name)?;
99+
100+ match &self.backend {
101+ Delivery::None => Err(MailError::Disabled),
102+ Delivery::Stdout => {
103+ println!("--- mail ({}) ---", purpose.as_str());
104+ println!("From: {}", self.from);
105+ println!("To: {to}");
106+ println!("Subject: {}", rendered.subject);
107+ println!();
108+ println!("{}", rendered.plain);
109+ println!("--- end mail ---");
110+ Ok(())
111+ }
112+ Delivery::Smtp(transport) => {
113+ let email = Message::builder()
114+ .from(self.from.clone())
115+ .to(to)
116+ .subject(rendered.subject)
117+ .multipart(MultiPart::alternative_plain_html(
118+ rendered.plain,
119+ rendered.html,
120+ ))
121+ .map_err(|e| MailError::Message(e.to_string()))?;
122+ transport
123+ .send(email)
124+ .await
125+ .map_err(|e| MailError::Send(e.to_string()))?;
126+ Ok(())
127+ }
128+ }
129+ }
130+}
131+
132+/// Build a `to` mailbox from an address and optional display name.
133+fn mailbox(email: &str, name: Option<&str>) -> Result<Mailbox, MailError> {
134+ let address = email
135+ .parse()
136+ .map_err(|e| MailError::Address(format!("{email}: {e}")))?;
137+ Ok(Mailbox::new(name.map(str::to_string), address))
138+}
139+
140+/// Construct the async SMTP transport for the `smtp` backend.
141+fn build_transport(config: &Mail) -> Result<AsyncSmtpTransport<Tokio1Executor>, MailError> {
142+ let builder = match config.encryption {
143+ MailEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
144+ .map_err(|e| MailError::Transport(e.to_string()))?,
145+ MailEncryption::Starttls => {
146+ AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
147+ .map_err(|e| MailError::Transport(e.to_string()))?
148+ }
149+ MailEncryption::None => {
150+ AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host)
151+ }
152+ };
153+
154+ let mut builder = builder
155+ .port(config.port)
156+ .timeout(Some(Duration::from_secs(config.timeout_secs)));
157+ if !config.username.is_empty() {
158+ let password = config
159+ .password
160+ .as_ref()
161+ .map(|s| s.expose().to_string())
162+ .unwrap_or_default();
163+ builder = builder.credentials(Credentials::new(config.username.clone(), password));
164+ }
165+ Ok(builder.build())
166+}
167+
168+#[cfg(test)]
169+mod tests {
170+ #![allow(clippy::unwrap_used)]
171+
172+ use config::Mail;
173+
174+ use super::*;
175+
176+ fn stdout_config() -> Mail {
177+ Mail {
178+ backend: MailBackend::Stdout,
179+ from: "Fabrica <fabrica@example.com>".to_string(),
180+ ..Mail::default()
181+ }
182+ }
183+
184+ #[tokio::test]
185+ async fn stdout_backend_sends_without_error() {
186+ let mailer = Mailer::build(&stdout_config()).unwrap();
187+ mailer
188+ .send_invite(
189+ "Forge",
190+ "ada@example.com",
191+ Some("Ada"),
192+ "https://git.example.com/invite/tok",
193+ Purpose::Activate,
194+ )
195+ .await
196+ .unwrap();
197+ }
198+
199+ #[tokio::test]
200+ async fn none_backend_rejects() {
201+ let config = Mail {
202+ backend: MailBackend::None,
203+ ..stdout_config()
204+ };
205+ let mailer = Mailer::build(&config).unwrap();
206+ let err = mailer
207+ .send_invite(
208+ "Forge",
209+ "a@b.com",
210+ None,
211+ "https://x/invite/t",
212+ Purpose::Activate,
213+ )
214+ .await
215+ .unwrap_err();
216+ assert!(matches!(err, MailError::Disabled));
217+ }
218+
219+ #[test]
220+ fn invalid_from_is_rejected() {
221+ let config = Mail {
222+ from: "not an address".to_string(),
223+ ..stdout_config()
224+ };
225+ assert!(matches!(Mailer::build(&config), Err(MailError::Address(_))));
226+ }
227+}
crates/mail/src/templates.rs +117 −0
@@ -0,0 +1,117 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Message templates. A plain-text primary and a minimal HTML alternative are
6+//! rendered from the same data, with the instance name and URL interpolated.
7+
8+/// Why an invite was sent.
9+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10+pub enum Purpose {
11+ /// A new-account activation link.
12+ Activate,
13+ /// A password-reset link.
14+ Reset,
15+}
16+
17+impl Purpose {
18+ /// The `invites.purpose` token.
19+ #[must_use]
20+ pub fn as_str(self) -> &'static str {
21+ match self {
22+ Self::Activate => "activate",
23+ Self::Reset => "reset",
24+ }
25+ }
26+}
27+
28+/// A rendered message: subject plus the two body alternatives.
29+#[derive(Debug, Clone, PartialEq, Eq)]
30+pub struct Rendered {
31+ /// The `Subject:` header.
32+ pub subject: String,
33+ /// The plain-text body (primary).
34+ pub plain: String,
35+ /// The HTML body (alternative).
36+ pub html: String,
37+}
38+
39+/// Render the invite message for `purpose`, interpolating the instance name/URL
40+/// and the activation `link`.
41+#[must_use]
42+pub fn render(instance_name: &str, link: &str, purpose: Purpose) -> Rendered {
43+ let (subject, lead, action) = match purpose {
44+ Purpose::Activate => (
45+ format!("Activate your {instance_name} account"),
46+ format!("An account has been created for you on {instance_name}."),
47+ "set your password and activate your account",
48+ ),
49+ Purpose::Reset => (
50+ format!("Reset your {instance_name} password"),
51+ format!("A password reset was requested for your {instance_name} account."),
52+ "choose a new password",
53+ ),
54+ };
55+
56+ let plain = format!(
57+ "{lead}\n\nOpen this link to {action}:\n\n {link}\n\n\
58+ If you did not expect this email, you can safely ignore it.\n"
59+ );
60+ let html = format!(
61+ "<p>{}</p>\n<p>Open this link to {}:</p>\n<p><a href=\"{}\">{}</a></p>\n\
62+ <p>If you did not expect this email, you can safely ignore it.</p>\n",
63+ escape(&lead),
64+ escape(action),
65+ escape(link),
66+ escape(link),
67+ );
68+
69+ Rendered {
70+ subject,
71+ plain,
72+ html,
73+ }
74+}
75+
76+/// Minimal HTML escaping for the interpolated values.
77+fn escape(s: &str) -> String {
78+ s.replace('&', "&amp;")
79+ .replace('<', "&lt;")
80+ .replace('>', "&gt;")
81+ .replace('"', "&quot;")
82+}
83+
84+#[cfg(test)]
85+mod tests {
86+ use super::*;
87+
88+ #[test]
89+ fn activate_body_contains_link_and_name() {
90+ let r = render(
91+ "Forge",
92+ "https://git.example.com/invite/abc123",
93+ Purpose::Activate,
94+ );
95+ assert!(r.subject.contains("Forge"));
96+ assert!(r.plain.contains("https://git.example.com/invite/abc123"));
97+ assert!(
98+ r.html
99+ .contains("href=\"https://git.example.com/invite/abc123\"")
100+ );
101+ assert!(r.plain.contains("activate your account"));
102+ }
103+
104+ #[test]
105+ fn reset_uses_reset_wording() {
106+ let r = render("Forge", "https://x/invite/t", Purpose::Reset);
107+ assert!(r.subject.to_lowercase().contains("reset"));
108+ assert!(r.plain.contains("new password"));
109+ }
110+
111+ #[test]
112+ fn html_escapes_interpolated_values() {
113+ let r = render("A&B", "https://x/?a=1&b=2", Purpose::Activate);
114+ assert!(r.html.contains("&amp;"));
115+ assert!(!r.html.contains("a=1&b=2"), "raw ampersand must be escaped");
116+ }
117+}
crates/model/src/entity.rs +20 −0
@@ -79,6 +79,26 @@ pub struct User {
7979 pub updated_at: Timestamp,
8080 }
8181
82+/// A pending activation or password-reset invite. The emailed token is never
83+/// stored; `token_hash` is its SHA-256, matched on redemption.
84+#[derive(Debug, Clone, PartialEq, Eq)]
85+pub struct Invite {
86+ /// ULID primary key.
87+ pub id: String,
88+ /// The user this invite activates.
89+ pub user_id: String,
90+ /// SHA-256 (hex) of the emailed token.
91+ pub token_hash: String,
92+ /// `"activate"` or `"reset"`.
93+ pub purpose: String,
94+ /// When the invite expires.
95+ pub expires_at: Timestamp,
96+ /// When it was redeemed, if it has been (single-use).
97+ pub used_at: Option<Timestamp>,
98+ /// Creation time.
99+ pub created_at: Timestamp,
100+}
101+
82102 /// A nested, user-owned group. Groups provide the repository hierarchy; `path` is
83103 /// the materialized `group/sub/leaf` under the owner.
84104 #[derive(Debug, Clone, PartialEq, Eq)]
crates/model/src/lib.rs +1 −1
@@ -11,5 +11,5 @@
1111 pub mod entity;
1212 pub mod name;
1313
14-pub use entity::{ApiToken, Group, Key, KeyKind, Repo, Timestamp, User};
14+pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User};
1515 pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};
crates/store/src/invites.rs +113 −0
@@ -0,0 +1,113 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Activation and password-reset invites.
6+//!
7+//! The emailed token is never stored; only its SHA-256 (`token_hash`) is, and
8+//! redemption looks the invite up by that hash. Invites are single-use
9+//! (`used_at`) and time-limited (`expires_at`).
10+
11+use model::Invite;
12+use sqlx::any::AnyRow;
13+use sqlx::{AssertSqlSafe, Row};
14+
15+use crate::{Store, StoreError, new_id, now_ms};
16+
17+/// The columns of `invites` in a fixed order, shared by every `SELECT`.
18+const INVITE_COLUMNS: &str = "id, user_id, token_hash, purpose, expires_at, used_at, created_at";
19+
20+/// Fields needed to mint an invite. The caller generates the token and passes its
21+/// SHA-256 here; the store assigns the id and creation time.
22+#[derive(Debug, Clone)]
23+pub struct NewInvite {
24+ /// The user the invite activates.
25+ pub user_id: String,
26+ /// SHA-256 (hex) of the emailed token.
27+ pub token_hash: String,
28+ /// `"activate"` or `"reset"`.
29+ pub purpose: String,
30+ /// Absolute expiry, epoch ms.
31+ pub expires_at: i64,
32+}
33+
34+impl Store {
35+ /// Create an invite row.
36+ ///
37+ /// # Errors
38+ ///
39+ /// Returns [`StoreError::Query`] on a database failure.
40+ pub async fn create_invite(&self, new: NewInvite) -> Result<Invite, StoreError> {
41+ let invite = Invite {
42+ id: new_id(),
43+ user_id: new.user_id,
44+ token_hash: new.token_hash,
45+ purpose: new.purpose,
46+ expires_at: new.expires_at,
47+ used_at: None,
48+ created_at: now_ms(),
49+ };
50+ let sql = "INSERT INTO invites (id, user_id, token_hash, purpose, expires_at, used_at, created_at) \
51+ VALUES ($1, $2, $3, $4, $5, $6, $7)";
52+ sqlx::query(sql)
53+ .bind(invite.id.clone())
54+ .bind(invite.user_id.clone())
55+ .bind(invite.token_hash.clone())
56+ .bind(invite.purpose.clone())
57+ .bind(invite.expires_at)
58+ .bind(invite.used_at)
59+ .bind(invite.created_at)
60+ .execute(&self.pool)
61+ .await?;
62+ Ok(invite)
63+ }
64+
65+ /// Look up an invite by its token hash, regardless of state. The caller checks
66+ /// `used_at`/`expires_at` so it can distinguish "already used" from "expired"
67+ /// from "unknown" for the redemption page.
68+ ///
69+ /// # Errors
70+ ///
71+ /// Returns [`StoreError::Query`] on a database failure.
72+ pub async fn invite_by_token_hash(
73+ &self,
74+ token_hash: &str,
75+ ) -> Result<Option<Invite>, StoreError> {
76+ let sql = format!("SELECT {INVITE_COLUMNS} FROM invites WHERE token_hash = $1");
77+ let row = sqlx::query(AssertSqlSafe(sql))
78+ .bind(token_hash)
79+ .fetch_optional(&self.pool)
80+ .await?;
81+ Ok(row.as_ref().map(map_invite).transpose()?)
82+ }
83+
84+ /// Mark an invite redeemed. Guards against a double-redeem with a
85+ /// `used_at IS NULL` predicate, so `true` means "this call redeemed it".
86+ ///
87+ /// # Errors
88+ ///
89+ /// Returns [`StoreError::Query`] on a database failure.
90+ pub async fn mark_invite_used(&self, invite_id: &str) -> Result<bool, StoreError> {
91+ let updated =
92+ sqlx::query("UPDATE invites SET used_at = $1 WHERE id = $2 AND used_at IS NULL")
93+ .bind(now_ms())
94+ .bind(invite_id)
95+ .execute(&self.pool)
96+ .await?
97+ .rows_affected();
98+ Ok(updated > 0)
99+ }
100+}
101+
102+/// Map an `invites` row (selected via [`INVITE_COLUMNS`]) to an [`Invite`].
103+fn map_invite(row: &AnyRow) -> Result<Invite, sqlx::Error> {
104+ Ok(Invite {
105+ id: row.try_get("id")?,
106+ user_id: row.try_get("user_id")?,
107+ token_hash: row.try_get("token_hash")?,
108+ purpose: row.try_get("purpose")?,
109+ expires_at: row.try_get("expires_at")?,
110+ used_at: row.try_get("used_at")?,
111+ created_at: row.try_get("created_at")?,
112+ })
113+}
crates/store/src/lib.rs +2 −0
@@ -26,6 +26,7 @@
2626 //! set is embedded at build time and selected at runtime by [`Store::migrate`].
2727
2828 mod groups;
29+mod invites;
2930 mod keys;
3031 mod repos;
3132 mod tokens;
@@ -39,6 +40,7 @@ use sqlx::Row;
3940 use sqlx::any::{AnyPoolOptions, AnyRow};
4041 use sqlx::migrate::Migrator;
4142
43+pub use crate::invites::NewInvite;
4244 pub use crate::keys::NewKey;
4345 pub use crate::repos::NewRepo;
4446 pub use crate::tokens::NewToken;
crates/store/src/tests.rs +53 −0
@@ -732,6 +732,59 @@ async fn rename_and_delete_repo() {
732732 }
733733 }
734734
735+#[tokio::test]
736+async fn invites_round_trip_and_are_single_use() {
737+ use super::NewInvite;
738+
739+ for fx in sqlite_fixtures().await {
740+ let user = fx
741+ .store
742+ .create_user(sample_user("i", "i@example.com"))
743+ .await
744+ .unwrap();
745+
746+ let invite = fx
747+ .store
748+ .create_invite(NewInvite {
749+ user_id: user.id.clone(),
750+ token_hash: "hash-abc".to_string(),
751+ purpose: "activate".to_string(),
752+ expires_at: now_plus_a_day(),
753+ })
754+ .await
755+ .unwrap();
756+ assert!(invite.used_at.is_none());
757+
758+ let found = fx
759+ .store
760+ .invite_by_token_hash("hash-abc")
761+ .await
762+ .unwrap()
763+ .unwrap();
764+ assert_eq!(found.user_id, user.id);
765+ assert_eq!(found.purpose, "activate");
766+
767+ // Redemption is single-use: the first mark succeeds, the second does not.
768+ assert!(fx.store.mark_invite_used(&invite.id).await.unwrap());
769+ assert!(!fx.store.mark_invite_used(&invite.id).await.unwrap());
770+ let redeemed = fx
771+ .store
772+ .invite_by_token_hash("hash-abc")
773+ .await
774+ .unwrap()
775+ .unwrap();
776+ assert!(redeemed.used_at.is_some());
777+
778+ assert!(
779+ fx.store
780+ .invite_by_token_hash("nope")
781+ .await
782+ .unwrap()
783+ .is_none()
784+ );
785+ }
786+}
787+
735788 /// Postgres round-trips, mirroring the SQLite ones. Ignored unless the
736789 /// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a
737790 /// reachable database; the test cleans up the rows it creates so it can rerun.
docs/decisions.md +17 −0
@@ -393,3 +393,20 @@ directly on the data directory with no running server, so first-run generation m
393393 happen there too. Centralizing it in one helper means `token add` today and `serve`
394394 later sign with the same key. `auth::new_secret` provides the 32-byte base64url
395395 value, keeping randomness in the auth crate.
396+
397+## 2026-07-24 — Mail over native-TLS, not the spec's rustls transport
398+
399+**Decision:** `crates/mail` uses `lettre` with the `tokio1-native-tls` transport,
400+not the `tokio1-rustls` transport the spec names.
401+
402+**Alternatives:** `tokio1-rustls-tls` (the spec's choice).
403+
404+**Rationale:** lettre's rustls transport pulls `rustls` → `ring`, whose license set
405+is outside `deny.toml`'s allow-list — the same constraint that shaped the JWT and
406+sqlx-TLS decisions. native-tls links the **system OpenSSL**, which is already a
407+flake `buildInput` (and present in the Debian runtime image), so it adds no new C
408+dependency and keeps `ring` out of the graph. Backends are `smtp` (native-TLS
409+`starttls`/`tls`/plaintext), `stdout`, and `none`; tests exercise the `stdout` and
410+`none` paths so `cargo test` needs no SMTP server. A **SHOULD** deviation logged
411+here. The invite token reuses `auth::new_session_token`'s shape (32 bytes,
412+base64url value emailed, SHA-256 stored).
fabrica.example.toml +1 −0
@@ -51,6 +51,7 @@ max_connections = 16
5151 secret_file = "/var/lib/fabrica/secret" # generated on first run, mode 0600
5252 session_ttl_days = 30
5353 token_ttl_days = 90
54+invite_ttl_hours = 72 # activation/reset link lifetime
5455 cookie_name = "fabrica_session"
5556 cookie_secure = true # set false only if instance.url is plain http (dev)
5657 argon2_m_cost = 19456 # KiB