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 = [
155]155]
156156
157[[package]]157[[package]]
158name = "async-trait"
159version = "0.1.91"
160source = "registry+https://github.com/rust-lang/crates.io-index"
161checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
162dependencies = [
163 "proc-macro2",
164 "quote",
165 "syn 3.0.3",
166]
167
168[[package]]
158name = "atoi"169name = "atoi"
159version = "2.0.0"170version = "2.0.0"
160source = "registry+https://github.com/rust-lang/crates.io-index"171source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -467,6 +478,7 @@ dependencies = [
467 "clap",478 "clap",
468 "config",479 "config",
469 "git",480 "git",
481 "mail",
470 "model",482 "model",
471 "rpassword",483 "rpassword",
472 "serde",484 "serde",
@@ -546,6 +558,22 @@ dependencies = [
546]558]
547559
548[[package]]560[[package]]
561name = "core-foundation"
562version = "0.10.1"
563source = "registry+https://github.com/rust-lang/crates.io-index"
564checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
565dependencies = [
566 "core-foundation-sys",
567 "libc",
568]
569
570[[package]]
571name = "core-foundation-sys"
572version = "0.8.7"
573source = "registry+https://github.com/rust-lang/crates.io-index"
574checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
575
576[[package]]
549name = "cpufeatures"577name = "cpufeatures"
550version = "0.2.17"578version = "0.2.17"
551source = "registry+https://github.com/rust-lang/crates.io-index"579source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -972,6 +1000,22 @@ dependencies = [
972]1000]
9731001
974[[package]]1002[[package]]
1003name = "email-encoding"
1004version = "0.4.1"
1005source = "registry+https://github.com/rust-lang/crates.io-index"
1006checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
1007dependencies = [
1008 "base64",
1009 "memchr",
1010]
1011
1012[[package]]
1013name = "email_address"
1014version = "0.2.9"
1015source = "registry+https://github.com/rust-lang/crates.io-index"
1016checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
1017
1018[[package]]
975name = "encode_unicode"1019name = "encode_unicode"
976version = "1.0.0"1020version = "1.0.0"
977source = "registry+https://github.com/rust-lang/crates.io-index"1021source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1100,6 +1144,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1100checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"1144checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
11011145
1102[[package]]1146[[package]]
1147name = "foreign-types"
1148version = "0.3.2"
1149source = "registry+https://github.com/rust-lang/crates.io-index"
1150checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
1151dependencies = [
1152 "foreign-types-shared",
1153]
1154
1155[[package]]
1156name = "foreign-types-shared"
1157version = "0.1.1"
1158source = "registry+https://github.com/rust-lang/crates.io-index"
1159checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
1160
1161[[package]]
1103name = "form_urlencoded"1162name = "form_urlencoded"
1104version = "1.2.2"1163version = "1.2.2"
1105source = "registry+https://github.com/rust-lang/crates.io-index"1164source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1377,6 +1436,23 @@ dependencies = [
1377]1436]
13781437
1379[[package]]1438[[package]]
1439name = "hostname"
1440version = "0.4.2"
1441source = "registry+https://github.com/rust-lang/crates.io-index"
1442checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
1443dependencies = [
1444 "cfg-if",
1445 "libc",
1446 "windows-link",
1447]
1448
1449[[package]]
1450name = "httpdate"
1451version = "1.0.3"
1452source = "registry+https://github.com/rust-lang/crates.io-index"
1453checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
1454
1455[[package]]
1380name = "hybrid-array"1456name = "hybrid-array"
1381version = "0.4.13"1457version = "0.4.13"
1382source = "registry+https://github.com/rust-lang/crates.io-index"1458source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1622,6 +1698,33 @@ dependencies = [
1622]1698]
16231699
1624[[package]]1700[[package]]
1701name = "lettre"
1702version = "0.11.22"
1703source = "registry+https://github.com/rust-lang/crates.io-index"
1704checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
1705dependencies = [
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]]
1625name = "libbz2-rs-sys"1728name = "libbz2-rs-sys"
1626version = "0.2.5"1729version = "0.2.5"
1627source = "registry+https://github.com/rust-lang/crates.io-index"1730source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1704,6 +1807,12 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
1704[[package]]1807[[package]]
1705name = "mail"1808name = "mail"
1706version = "0.1.0"1809version = "0.1.0"
1810dependencies = [
1811 "config",
1812 "lettre",
1813 "thiserror 2.0.19",
1814 "tokio",
1815]
17071816
1708[[package]]1817[[package]]
1709name = "md-5"1818name = "md-5"
@@ -1732,6 +1841,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1732checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"1841checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
17331842
1734[[package]]1843[[package]]
1844name = "mime"
1845version = "0.3.17"
1846source = "registry+https://github.com/rust-lang/crates.io-index"
1847checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1848
1849[[package]]
1735name = "miniz_oxide"1850name = "miniz_oxide"
1736version = "0.8.9"1851version = "0.8.9"
1737source = "registry+https://github.com/rust-lang/crates.io-index"1852source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1777,6 +1892,23 @@ dependencies = [
1777]1892]
17781893
1779[[package]]1894[[package]]
1895name = "native-tls"
1896version = "0.2.18"
1897source = "registry+https://github.com/rust-lang/crates.io-index"
1898checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
1899dependencies = [
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]]
1780name = "nom"1912name = "nom"
1781version = "8.0.0"1913version = "8.0.0"
1782source = "registry+https://github.com/rust-lang/crates.io-index"1914source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1883,6 +2015,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1883checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"2015checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
18842016
1885[[package]]2017[[package]]
2018name = "openssl"
2019version = "0.10.81"
2020source = "registry+https://github.com/rust-lang/crates.io-index"
2021checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
2022dependencies = [
2023 "bitflags",
2024 "cfg-if",
2025 "foreign-types",
2026 "libc",
2027 "openssl-macros",
2028 "openssl-sys",
2029]
2030
2031[[package]]
2032name = "openssl-macros"
2033version = "0.1.1"
2034source = "registry+https://github.com/rust-lang/crates.io-index"
2035checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
2036dependencies = [
2037 "proc-macro2",
2038 "quote",
2039 "syn 2.0.119",
2040]
2041
2042[[package]]
2043name = "openssl-probe"
2044version = "0.2.1"
2045source = "registry+https://github.com/rust-lang/crates.io-index"
2046checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
2047
2048[[package]]
2049name = "openssl-sys"
2050version = "0.9.117"
2051source = "registry+https://github.com/rust-lang/crates.io-index"
2052checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
2053dependencies = [
2054 "cc",
2055 "libc",
2056 "pkg-config",
2057 "vcpkg",
2058]
2059
2060[[package]]
1886name = "p256"2061name = "p256"
1887version = "0.13.2"2062version = "0.13.2"
1888source = "registry+https://github.com/rust-lang/crates.io-index"2063source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2178,6 +2353,12 @@ dependencies = [
2178]2353]
21792354
2180[[package]]2355[[package]]
2356name = "quoted_printable"
2357version = "0.5.2"
2358source = "registry+https://github.com/rust-lang/crates.io-index"
2359checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
2360
2361[[package]]
2181name = "r-efi"2362name = "r-efi"
2182version = "5.3.0"2363version = "5.3.0"
2183source = "registry+https://github.com/rust-lang/crates.io-index"2364source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2405,6 +2586,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2405checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"2586checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
24062587
2407[[package]]2588[[package]]
2589name = "schannel"
2590version = "0.1.29"
2591source = "registry+https://github.com/rust-lang/crates.io-index"
2592checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
2593dependencies = [
2594 "windows-sys 0.61.2",
2595]
2596
2597[[package]]
2408name = "scopeguard"2598name = "scopeguard"
2409version = "1.2.0"2599version = "1.2.0"
2410source = "registry+https://github.com/rust-lang/crates.io-index"2600source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2426,6 +2616,29 @@ dependencies = [
2426]2616]
24272617
2428[[package]]2618[[package]]
2619name = "security-framework"
2620version = "3.7.0"
2621source = "registry+https://github.com/rust-lang/crates.io-index"
2622checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
2623dependencies = [
2624 "bitflags",
2625 "core-foundation",
2626 "core-foundation-sys",
2627 "libc",
2628 "security-framework-sys",
2629]
2630
2631[[package]]
2632name = "security-framework-sys"
2633version = "2.17.0"
2634source = "registry+https://github.com/rust-lang/crates.io-index"
2635checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
2636dependencies = [
2637 "core-foundation-sys",
2638 "libc",
2639]
2640
2641[[package]]
2429name = "semver"2642name = "semver"
2430version = "1.0.28"2643version = "1.0.28"
2431source = "registry+https://github.com/rust-lang/crates.io-index"2644source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3069,6 +3282,16 @@ dependencies = [
3069]3282]
30703283
3071[[package]]3284[[package]]
3285name = "tokio-native-tls"
3286version = "0.3.1"
3287source = "registry+https://github.com/rust-lang/crates.io-index"
3288checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
3289dependencies = [
3290 "native-tls",
3291 "tokio",
3292]
3293
3294[[package]]
3072name = "tokio-stream"3295name = "tokio-stream"
3073version = "0.1.19"3296version = "0.1.19"
3074source = "registry+https://github.com/rust-lang/crates.io-index"3297source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +10 −0
@@ -101,6 +101,16 @@ insta = "1"
101# is LGPL, which our license gate forbids (see docs/decisions.md).101# is LGPL, which our license gate forbids (see docs/decisions.md).
102ssh-key = { version = "0.6.7", features = ["ed25519", "rsa", "p256", "std"] }102ssh-key = { version = "0.6.7", features = ["ed25519", "rsa", "p256", "std"] }
103pgp = "0.20"103pgp = "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.
107lettre = { version = "0.11", default-features = false, features = [
108 "builder",
109 "smtp-transport",
110 "tokio1",
111 "tokio1-native-tls",
112 "hostname",
113] }
104114
105[workspace.lints.rust]115[workspace.lints.rust]
106unsafe_code = "forbid"116unsafe_code = "forbid"
crates/cli/Cargo.toml +1 −0
@@ -14,6 +14,7 @@ config = { workspace = true }
14store = { workspace = true }14store = { workspace = true }
15auth = { workspace = true }15auth = { workspace = true }
16git = { workspace = true }16git = { workspace = true }
17mail = { workspace = true }
17model = { workspace = true }18model = { workspace = true }
18anyhow = { workspace = true }19anyhow = { workspace = true }
19clap = { workspace = true }20clap = { workspace = true }
crates/cli/src/user_cmd.rs +56 −9
@@ -10,7 +10,7 @@ use std::process::ExitCode;
10use clap::Subcommand;10use clap::Subcommand;
11use config::Config;11use config::Config;
12use model::User;12use model::User;
13use store::{NewUser, Store};13use store::{NewInvite, NewUser, Store};
1414
15use crate::context::{block_on, open_store};15use crate::context::{block_on, open_store};
16use crate::prompt::{confirm, resolve_password};16use crate::prompt::{confirm, resolve_password};
@@ -160,18 +160,65 @@ async fn add(
160 if user.is_admin { " (admin)" } else { "" }160 if user.is_admin { " (admin)" } else { "" }
161 );161 );
162 if !has_password {162 if !has_password {
163 // Email invitations are not implemented yet (phase 9), so there is no163 // No password was set, so mint an activation invite, try to email it, and
164 // out-of-band way to activate the account — point the operator at the164 // always print the link so an operator can deliver it when SMTP is off.
165 // direct path. This is the SMTP-disabled fallback made explicit.165 send_activation(&config, &store, &user).await?;
166 eprintln!(
167 "note: no password set; this account cannot log in yet. \
168 Set one with: fabrica user passwd {}",
169 user.username
170 );
171 }166 }
172 Ok(ExitCode::SUCCESS)167 Ok(ExitCode::SUCCESS)
173}168}
174169
170/// Mint an activation invite for `user`, attempt to email it (best-effort), and
171/// print the activation URL to stdout regardless.
172async 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.
215fn 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
175async fn passwd(222async fn passwd(
176 config_path: Option<&Path>,223 config_path: Option<&Path>,
177 name: &str,224 name: &str,
crates/config/src/types.rs +3 −0
@@ -181,6 +181,8 @@ pub struct Auth {
181 pub session_ttl_days: u32,181 pub session_ttl_days: u32,
182 /// API token lifetime, in days.182 /// API token lifetime, in days.
183 pub token_ttl_days: u32,183 pub token_ttl_days: u32,
184 /// Activation/reset invite lifetime, in hours.
185 pub invite_ttl_hours: u32,
184 /// Name of the session cookie.186 /// Name of the session cookie.
185 pub cookie_name: String,187 pub cookie_name: String,
186 /// Set the `Secure` attribute on the session cookie.188 /// Set the `Secure` attribute on the session cookie.
@@ -200,6 +202,7 @@ impl Default for Auth {
200 secret_file: None,202 secret_file: None,
201 session_ttl_days: 30,203 session_ttl_days: 30,
202 token_ttl_days: 90,204 token_ttl_days: 90,
205 invite_ttl_hours: 72,
203 cookie_name: "fabrica_session".to_string(),206 cookie_name: "fabrica_session".to_string(),
204 cookie_secure: true,207 cookie_secure: true,
205 argon2_m_cost: 19456,208 argon2_m_cost: 19456,
crates/mail/Cargo.toml +6 −0
@@ -10,6 +10,12 @@ repository.workspace = true
10publish.workspace = true10publish.workspace = true
1111
12[dependencies]12[dependencies]
13config = { workspace = true }
14lettre = { workspace = true }
15thiserror = { workspace = true }
16
17[dev-dependencies]
18tokio = { workspace = true }
1319
14[lints]20[lints]
15workspace = true21workspace = true
crates/mail/src/lib.rs +223 −1
@@ -2,4 +2,226 @@
2// License, v. 2.0. If a copy of the MPL was not distributed with this2// 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/.3// 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
13mod templates;
14
15use std::time::Duration;
16
17use config::{Mail, MailBackend, MailEncryption};
18use lettre::message::{Mailbox, MultiPart};
19use lettre::transport::smtp::authentication::Credentials;
20use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
21
22pub use crate::templates::{Purpose, Rendered, render};
23
24/// An error from the mail layer.
25#[derive(Debug, thiserror::Error)]
26pub 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.
45pub struct Mailer {
46 from: Mailbox,
47 backend: Delivery,
48}
49
50/// The delivery mechanism behind a [`Mailer`].
51enum 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
60impl 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.
133fn 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.
141fn 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)]
169mod 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)]
10pub enum Purpose {
11 /// A new-account activation link.
12 Activate,
13 /// A password-reset link.
14 Reset,
15}
16
17impl 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)]
30pub 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]
42pub 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.
77fn escape(s: &str) -> String {
78 s.replace('&', "&amp;")
79 .replace('<', "&lt;")
80 .replace('>', "&gt;")
81 .replace('"', "&quot;")
82}
83
84#[cfg(test)]
85mod 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 {
79 pub updated_at: Timestamp,79 pub updated_at: Timestamp,
80}80}
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)]
85pub 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
82/// A nested, user-owned group. Groups provide the repository hierarchy; `path` is102/// A nested, user-owned group. Groups provide the repository hierarchy; `path` is
83/// the materialized `group/sub/leaf` under the owner.103/// the materialized `group/sub/leaf` under the owner.
84#[derive(Debug, Clone, PartialEq, Eq)]104#[derive(Debug, Clone, PartialEq, Eq)]
crates/model/src/lib.rs +1 −1
@@ -11,5 +11,5 @@
11pub mod entity;11pub mod entity;
12pub mod name;12pub mod name;
1313
14pub use entity::{ApiToken, Group, Key, KeyKind, Repo, Timestamp, User};14pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User};
15pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};15pub 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
11use model::Invite;
12use sqlx::any::AnyRow;
13use sqlx::{AssertSqlSafe, Row};
14
15use crate::{Store, StoreError, new_id, now_ms};
16
17/// The columns of `invites` in a fixed order, shared by every `SELECT`.
18const 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)]
23pub 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
34impl 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`].
103fn 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 @@
26//! set is embedded at build time and selected at runtime by [`Store::migrate`].26//! set is embedded at build time and selected at runtime by [`Store::migrate`].
2727
28mod groups;28mod groups;
29mod invites;
29mod keys;30mod keys;
30mod repos;31mod repos;
31mod tokens;32mod tokens;
@@ -39,6 +40,7 @@ use sqlx::Row;
39use sqlx::any::{AnyPoolOptions, AnyRow};40use sqlx::any::{AnyPoolOptions, AnyRow};
40use sqlx::migrate::Migrator;41use sqlx::migrate::Migrator;
4142
43pub use crate::invites::NewInvite;
42pub use crate::keys::NewKey;44pub use crate::keys::NewKey;
43pub use crate::repos::NewRepo;45pub use crate::repos::NewRepo;
44pub use crate::tokens::NewToken;46pub use crate::tokens::NewToken;
crates/store/src/tests.rs +53 −0
@@ -732,6 +732,59 @@ async fn rename_and_delete_repo() {
732 }732 }
733}733}
734734
735#[tokio::test]
736async 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
735/// Postgres round-trips, mirroring the SQLite ones. Ignored unless the788/// Postgres round-trips, mirroring the SQLite ones. Ignored unless the
736/// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a789/// `postgres-tests` feature is on and `FABRICA_TEST_POSTGRES_URL` points at a
737/// reachable database; the test cleans up the rows it creates so it can rerun.790/// 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
393happen there too. Centralizing it in one helper means `token add` today and `serve`393happen there too. Centralizing it in one helper means `token add` today and `serve`
394later sign with the same key. `auth::new_secret` provides the 32-byte base64url394later sign with the same key. `auth::new_secret` provides the 32-byte base64url
395value, keeping randomness in the auth crate.395value, 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,
400not 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
405is outside `deny.toml`'s allow-list — the same constraint that shaped the JWT and
406sqlx-TLS decisions. native-tls links the **system OpenSSL**, which is already a
407flake `buildInput` (and present in the Debian runtime image), so it adds no new C
408dependency 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
411here. The invite token reuses `auth::new_session_token`'s shape (32 bytes,
412base64url value emailed, SHA-256 stored).
fabrica.example.toml +1 −0
@@ -51,6 +51,7 @@ max_connections = 16
51secret_file = "/var/lib/fabrica/secret" # generated on first run, mode 060051secret_file = "/var/lib/fabrica/secret" # generated on first run, mode 0600
52session_ttl_days = 3052session_ttl_days = 30
53token_ttl_days = 9053token_ttl_days = 90
54invite_ttl_hours = 72 # activation/reset link lifetime
54cookie_name = "fabrica_session"55cookie_name = "fabrica_session"
55cookie_secure = true # set false only if instance.url is plain http (dev)56cookie_secure = true # set false only if instance.url is plain http (dev)
56argon2_m_cost = 19456 # KiB57argon2_m_cost = 19456 # KiB