use std::fmt;
use std::sync::Arc;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct Mail {
pub to: String,
pub subject: String,
pub text_body: String,
pub html_body: Option<String>,
pub headers: Vec<(String, String)>,
}
impl Mail {
pub fn framework_envelope(
to: impl Into<String>,
subject: impl Into<String>,
body: impl Into<String>,
system_name: &str,
request_ip: Option<&str>,
ua_summary: Option<&str>,
when: DateTime<Utc>,
) -> Self {
let mut text = body.into();
text.push_str("\n\n— — —\n");
text.push_str(&format!("System: {system_name}\n"));
text.push_str(&format!("When: {} UTC\n", when.format("%Y-%m-%d %H:%M")));
if let Some(ip) = request_ip {
text.push_str(&format!("From IP: {ip}\n"));
}
if let Some(ua) = ua_summary {
text.push_str(&format!("Device: {ua}\n"));
}
text.push_str(
"\nIf this was not you, sign in and visit /admin/account/sessions to revoke \
sessions, then ask your administrator to reset your account.\n",
);
Mail {
to: to.into(),
subject: subject.into(),
text_body: text,
html_body: None,
headers: Vec::new(),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum MailerError {
ConfigurationMissing(String),
Transient(String),
Permanent(String),
}
impl fmt::Display for MailerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ConfigurationMissing(m) => write!(f, "mailer configuration missing: {m}"),
Self::Transient(m) => write!(f, "mailer transient failure: {m}"),
Self::Permanent(m) => write!(f, "mailer permanent failure: {m}"),
}
}
}
impl std::error::Error for MailerError {}
pub trait Mailer: Send + Sync {
fn send<'a>(
&'a self,
msg: Mail,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = std::result::Result<(), MailerError>> + Send + 'a>,
>;
}
#[derive(Debug, Default, Clone)]
pub struct LogMailer;
impl LogMailer {
pub fn new() -> Self {
Self
}
}
impl Mailer for LogMailer {
fn send<'a>(
&'a self,
msg: Mail,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = std::result::Result<(), MailerError>> + Send + 'a>,
> {
Box::pin(async move {
let body_preview: String = msg.text_body.chars().take(200).collect();
let redacted = redact_likely_tokens(&body_preview);
log::info!(
target: "rustio_admin::mailer::log",
"[LogMailer] to={} subject={:?} body_preview={:?}",
msg.to,
msg.subject,
redacted,
);
Ok(())
})
}
}
fn redact_likely_tokens(s: &str) -> String {
s.split_whitespace()
.map(|w| {
if w.len() >= 32 && w.chars().all(is_token_url_char) {
"<redacted-link>"
} else {
w
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn is_token_url_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | ':' | '.' | '?' | '&' | '=' | '#')
}
pub type SharedMailer = Arc<dyn Mailer>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn framework_envelope_appends_security_footer() {
let m = Mail::framework_envelope(
"user@example.com",
"Test",
"Body line.",
"Bosphorus & Sham · Stockholm",
Some("198.51.100.42"),
Some("macOS · Safari 18"),
Utc::now(),
);
assert!(m.text_body.contains("Body line."));
assert!(m.text_body.contains("System: Bosphorus & Sham · Stockholm"));
assert!(m.text_body.contains("From IP: 198.51.100.42"));
assert!(m.text_body.contains("Device: macOS · Safari 18"));
assert!(m.text_body.contains("If this was not you"));
}
#[test]
fn framework_envelope_omits_missing_fields() {
let m = Mail::framework_envelope(
"user@example.com",
"Test",
"Body.",
"ACME",
None,
None,
Utc::now(),
);
assert!(!m.text_body.contains("From IP:"));
assert!(!m.text_body.contains("Device:"));
assert!(m.text_body.contains("If this was not you"));
}
#[tokio::test]
async fn log_mailer_send_is_ok() {
let m = LogMailer::new();
let mail = Mail {
to: "user@example.com".into(),
subject: "Hi".into(),
text_body: "test body".into(),
html_body: None,
headers: Vec::new(),
};
assert!(m.send(mail).await.is_ok());
}
#[test]
fn redact_likely_tokens_redacts_long_alnum_strings() {
let s = "Click http://example.com/admin/reset-password/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa to reset.";
let r = redact_likely_tokens(s);
assert!(r.contains("<redacted-link>"));
assert!(!r.contains("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
}
#[test]
fn redact_likely_tokens_keeps_short_words() {
let s = "Hello user, your account is fine.";
let r = redact_likely_tokens(s);
assert_eq!(r, s);
}
#[test]
fn mailer_error_display() {
let e = MailerError::ConfigurationMissing("no SMTP host".into());
assert!(format!("{e}").contains("configuration missing"));
let e = MailerError::Transient("timeout".into());
assert!(format!("{e}").contains("transient"));
let e = MailerError::Permanent("blocked".into());
assert!(format!("{e}").contains("permanent"));
}
}