use time::OffsetDateTime;
use crate::account::Email;
use crate::session::SignInMethod;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[non_exhaustive]
pub enum MailKind {
Setup,
SignInLink,
Invite,
NewDevice,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct SignInDetails {
pub at: OffsetDateTime,
pub method: SignInMethod,
pub user_agent: Option<String>,
}
impl SignInDetails {
#[must_use]
pub fn new(at: OffsetDateTime, method: SignInMethod, user_agent: Option<String>) -> Self {
Self {
at,
method,
user_agent,
}
}
}
#[derive(Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct Mail {
pub kind: MailKind,
pub to: Email,
pub link: String,
pub expires_at: Option<OffsetDateTime>,
pub sign_in: Option<SignInDetails>,
}
impl std::fmt::Debug for Mail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Mail")
.field("kind", &self.kind)
.field("to", &self.to)
.field("expires_at", &self.expires_at)
.field("sign_in", &self.sign_in)
.finish_non_exhaustive()
}
}
fn rfc3339(at: OffsetDateTime) -> String {
at.replace_nanosecond(0)
.unwrap_or(at)
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default()
}
fn printable(raw: &str) -> String {
raw.chars().filter(|c| !c.is_control()).collect()
}
impl Mail {
#[must_use]
pub fn new(
kind: MailKind,
to: Email,
link: impl Into<String>,
expires_at: Option<OffsetDateTime>,
sign_in: Option<SignInDetails>,
) -> Self {
Self {
kind,
to,
link: link.into(),
expires_at,
sign_in,
}
}
#[must_use]
pub fn subject(&self) -> &'static str {
match self.kind {
MailKind::Setup => "Set up your sign-in",
MailKind::SignInLink => "Your sign-in link",
MailKind::Invite => "You have been invited",
MailKind::NewDevice => "New sign-in to your account",
}
}
#[must_use]
pub fn text(&self) -> String {
let action = match self.kind {
MailKind::Setup => "set up your sign-in",
MailKind::SignInLink => "sign in",
MailKind::Invite => "accept the invitation and set up your sign-in",
MailKind::NewDevice => return self.new_device_text(),
};
format!(
"Open this link to {action}:\n\n{}\n\nIt works once and expires at {}. If you did not ask for it, ignore this email.\n",
self.link,
self.expires_at.map(rfc3339).unwrap_or_default(),
)
}
#[must_use]
pub fn link_token(&self) -> Option<String> {
url::Url::parse(&self.link)
.ok()?
.query_pairs()
.find(|(key, _)| key == "token")
.map(|(_, value)| value.into_owned())
}
fn new_device_text(&self) -> String {
let details = self
.sign_in
.as_ref()
.map(|details| {
format!(
"When: {}\nMethod: {}\nBrowser: {}\n\n",
rfc3339(details.at),
printable(details.method.as_str()),
details
.user_agent
.as_deref()
.map_or_else(|| "unknown".to_owned(), printable),
)
})
.unwrap_or_default();
format!(
"Your account was just signed in to from a browser it had not used before.\n\n{details}If this was you, there is nothing to do. If not, sign out that session and change your password:\n\n{}\n",
self.link,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_device_text_names_the_sign_in_and_strips_control_characters()
-> Result<(), Box<dyn std::error::Error>> {
let mail = Mail {
kind: MailKind::NewDevice,
to: Email::parse("o@example.com")?,
link: "https://home.example.com/settings/sessions".to_owned(),
expires_at: None,
sign_in: Some(SignInDetails {
at: OffsetDateTime::from_unix_timestamp(1_900_000_000)?,
method: SignInMethod::Passkey,
user_agent: Some("Evil\r\nBcc: x@y".into()),
}),
};
let text = mail.text();
assert!(text.contains("Method: passkey"));
assert!(text.contains("Browser: EvilBcc: x@y\n"));
assert!(text.contains("https://home.example.com/settings/sessions"));
assert!(text.contains("2030-03-17T17:46:40Z"));
assert_eq!(mail.subject(), "New sign-in to your account");
Ok(())
}
#[test]
fn link_mails_hide_the_token_from_debug() -> Result<(), Box<dyn std::error::Error>> {
let mail = Mail {
kind: MailKind::SignInLink,
to: Email::parse("o@example.com")?,
link: "https://home.example.com/sign-in/link?token=SECRET".to_owned(),
expires_at: Some(OffsetDateTime::from_unix_timestamp(1_900_000_000)?),
sign_in: None,
};
assert!(!format!("{mail:?}").contains("SECRET"));
assert_eq!(mail.link_token().as_deref(), Some("SECRET"));
assert!(mail.text().contains("expires at 2030-03-17T17:46:40Z"));
Ok(())
}
}