1pub const MIN_TTL_SECS: u64 = 3_600;
16pub const MAX_TTL_SECS: u64 = 30 * 86_400;
18pub const DEFAULT_TTL_SECS: u64 = 7 * 86_400;
20
21pub 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
42pub 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
52pub fn link(host: &str, secret: &str, account: &str) -> String {
54 format!("https://{host}/login?invite={secret}&user={account}")
55}
56
57pub 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#[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
80pub 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}