Skip to main content

monetize_estate_side/
sign_in.rs

1//! **A resend sign-in link, minted and mailed by the estate itself** (Gunnar's S10, owner
2//! 2026-10-11; Holger's A16; SHARED since P4c, A28 M11): a sold estate's buyer lost or let run
3//! out the one link the ready mail carried. monetize queues a signed `sign_in_again` order for
4//! the estate's main naming whose passkey, which address and which console host — never a
5//! secret. The main verifies it, mints a fresh passkey invite on that account (only while it
6//! holds no passkey) and mails the link itself. Pure (std only, no I/O): the product mints and
7//! mails; this is the order's checks, the lifetime and the words. Gunnar's gunnar-repair
8//! `sign_in` is the same code until T16 (H20).
9//!
10//! The link has the shape of the first one (monetize's `first_login_link`):
11//! `https://{host}/login?invite={secret}&user={account}`. Pure: the order's checks, the
12//! invite's lifetime, the link and the mail's words.
13
14/// The shortest invite a resend mints.
15pub const MIN_TTL_SECS: u64 = 3_600;
16/// The longest: an invite lives 30 days at most.
17pub const MAX_TTL_SECS: u64 = 30 * 86_400;
18/// An order that names no lifetime.
19pub const DEFAULT_TTL_SECS: u64 = 7 * 86_400;
20
21/// **Can this box mail this resend?** `account` goes into the link, `to` into a mail header,
22/// `host` into the link's authority: none may carry anything but itself (no second header,
23/// no other path, no query of its own).
24pub fn check(account: &str, to: &str, host: &str) -> Result<(), String> {
25    let name = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.');
26    if account.is_empty() || account.len() > 128 || !account.chars().all(name) {
27        return Err(format!("the account {account:?} is not a name a link can carry"));
28    }
29    let mut parts = to.split('@');
30    let (local, domain) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
31    let plain = |c: char| c.is_ascii_graphic() && !matches!(c, '<' | '>' | ',' | ';' | '"' | '\\' | '(' | ')' | '[' | ']' | ':');
32    if parts.next().is_some() || local.is_empty() || domain.is_empty() || !domain.contains('.') || to.len() > 254 || !to.chars().all(plain) {
33        return Err(format!("{to:?} is not one mail address"));
34    }
35    let label = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '.');
36    if host.is_empty() || host.len() > 253 || !host.chars().all(label) || host.starts_with('.') || host.ends_with('.') {
37        return Err(format!("{host:?} is not a host name"));
38    }
39    Ok(())
40}
41
42/// The invite's lifetime: what the order asked, within [`MIN_TTL_SECS`]..=[`MAX_TTL_SECS`];
43/// [`DEFAULT_TTL_SECS`] when it asked none.
44pub fn ttl(asked_secs: u64) -> u64 {
45    if asked_secs == 0 {
46        DEFAULT_TTL_SECS
47    } else {
48        asked_secs.clamp(MIN_TTL_SECS, MAX_TTL_SECS)
49    }
50}
51
52/// The link, the same shape as the first one.
53pub fn link(host: &str, secret: &str, account: &str) -> String {
54    format!("https://{host}/login?invite={secret}&user={account}")
55}
56
57/// A lifetime in words: whole days as days, else hours.
58pub fn ttl_words(secs: u64) -> String {
59    match secs {
60        s if s >= 86_400 && s % 86_400 == 0 => format!("{} day{}", s / 86_400, if s == 86_400 { "" } else { "s" }),
61        s => format!("{} hour{}", (s / 3_600).max(1), if s / 3_600 <= 1 { "" } else { "s" }),
62    }
63}
64
65/// **The mail the estate sends**: to the order's address, the link in its body and nowhere
66/// else. Its `Debug` withholds the body (it carries the secret).
67#[derive(Clone, PartialEq, Eq)]
68pub struct SignInMail {
69    pub to: String,
70    pub subject: String,
71    pub body: String,
72}
73
74impl std::fmt::Debug for SignInMail {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        write!(f, "SignInMail {{ to: {:?}, subject: {:?}, body: <withheld> }}", self.to, self.subject)
77    }
78}
79
80/// The mail for `account` on `host`, its invite `secret` living `ttl_secs`.
81pub fn mail(host: &str, account: &str, to: &str, secret: &str, ttl_secs: u64) -> SignInMail {
82    let ttl = ttl_words(ttl_secs);
83    let link = link(host, secret, account);
84    let body = format!(
85        "You asked for a new sign-in link for {host}. It registers your passkey on {account}, and it works once, for {ttl}:\n\n  {link}\n\n\
86         After that, sign in at https://{host} with your passkey. If you did not ask for this, you can ignore this mail: the link is only for {account}, who has no passkey yet.\n"
87    );
88    SignInMail { to: to.to_string(), subject: format!("Your new sign-in link for https://{host}"), body }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn the_link_has_the_first_links_shape_and_the_mail_carries_it_once() {
97        assert_eq!(link("acme.holger.mock.invalid", "s3cret", "acme"), "https://acme.holger.mock.invalid/login?invite=s3cret&user=acme");
98        let m = mail("acme.holger.mock.invalid", "acme", "buyer@mock.invalid", "s3cret", 7 * 86_400);
99        assert_eq!(m.to, "buyer@mock.invalid");
100        assert_eq!(m.body.matches("https://acme.holger.mock.invalid/login?invite=s3cret&user=acme").count(), 1, "{}", m.body);
101        assert!(m.body.contains("7 days") && !m.subject.contains("s3cret"));
102        assert!(!format!("{m:?}").contains("s3cret"), "Debug withholds the secret");
103    }
104
105    #[test]
106    fn the_lifetime_is_what_was_asked_within_an_hour_and_thirty_days() {
107        assert_eq!((ttl(0), ttl(60), ttl(86_400), ttl(365 * 86_400)), (DEFAULT_TTL_SECS, MIN_TTL_SECS, 86_400, MAX_TTL_SECS));
108        assert_eq!((ttl_words(86_400), ttl_words(3 * 86_400), ttl_words(3_600), ttl_words(7_200)), ("1 day".into(), "3 days".into(), "1 hour".into(), "2 hours".into()));
109    }
110
111    #[test]
112    fn nothing_but_a_name_an_address_and_a_host_passes() {
113        assert!(check("acme", "buyer@mock.invalid", "acme.holger.mock.invalid").is_ok());
114        assert!(check("first.admin-1", "a.b+c@sub.mock.invalid", "acme.mock.invalid").is_ok());
115        for (a, t, h) in [
116            ("", "b@e.mock.invalid", "h.mock.invalid"),
117            ("al ice", "b@e.mock.invalid", "h.mock.invalid"),
118            ("a&user=x", "b@e.mock.invalid", "h.mock.invalid"),
119            ("a", "b@e.mock.invalid\r\nBcc: x@evil.mock.invalid", "h.mock.invalid"),
120            ("a", "b@@e.mock.invalid", "h.mock.invalid"),
121            ("a", "Bob <b@e.mock.invalid>", "h.mock.invalid"),
122            ("a", "b@localhost", "h.mock.invalid"),
123            ("a", "b@e.mock.invalid", ""),
124            ("a", "b@e.mock.invalid", "evil.mock.invalid/x?"),
125            ("a", "b@e.mock.invalid", "user@evil.mock.invalid"),
126        ] {
127            assert!(check(a, t, h).is_err(), "{a:?} {t:?} {h:?}");
128        }
129    }
130}