use std::future::Future;
use std::pin::Pin;
use crate::error::AuthError;
#[derive(Clone)]
pub struct EmailMessage {
pub to: String,
pub subject: String,
pub template: EmailTemplate,
}
#[non_exhaustive]
#[derive(Clone)]
pub enum EmailTemplate {
EmailVerification {
url: String,
username: String,
},
PasswordReset {
url: String,
username: String,
},
MfaRecovery {
codes: Vec<String>,
username: String,
},
Invitation {
url: String,
invited_by: String,
},
}
pub trait EmailSender: Send + Sync {
fn send<'a>(
&'a self,
message: &'a EmailMessage,
) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>>;
}
pub struct LogEmailSender;
impl EmailSender for LogEmailSender {
fn send<'a>(
&'a self,
message: &'a EmailMessage,
) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
tracing::info!(
to = %message.to,
subject = %message.subject,
template = ?message.template,
"dev email (not delivered)"
);
Box::pin(std::future::ready(Ok(())))
}
}
pub struct NoopEmailSender;
impl EmailSender for NoopEmailSender {
fn send<'a>(
&'a self,
message: &'a EmailMessage,
) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
tracing::debug!(
to = %message.to,
subject = %message.subject,
"email dropped (NoopEmailSender)"
);
Box::pin(std::future::ready(Ok(())))
}
}
impl<T: EmailSender + ?Sized> EmailSender for std::sync::Arc<T> {
fn send<'a>(
&'a self,
message: &'a EmailMessage,
) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
(**self).send(message)
}
}
pub(crate) fn fallback_username(user: &crate::types::User) -> String {
if let Some(u) = &user.username {
return u.as_str().to_owned();
}
user.email
.as_str()
.split('@')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("there")
.to_owned()
}
impl std::fmt::Debug for EmailTemplate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EmailTemplate::EmailVerification { url, username } => f
.debug_struct("EmailVerification")
.field("url", url)
.field("username", username)
.finish(),
EmailTemplate::PasswordReset { url, username } => f
.debug_struct("PasswordReset")
.field("url", url)
.field("username", username)
.finish(),
EmailTemplate::MfaRecovery { codes, username } => f
.debug_struct("MfaRecovery")
.field("codes_count", &codes.len())
.field("username", username)
.finish(),
EmailTemplate::Invitation { url, invited_by } => f
.debug_struct("Invitation")
.field("url", url)
.field("invited_by", invited_by)
.finish(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn _assert_object_safe(_: &dyn EmailSender) {}
fn make_reset_message() -> EmailMessage {
EmailMessage {
to: "user@example.com".to_owned(),
subject: "Reset your password".to_owned(),
template: EmailTemplate::PasswordReset {
url: "https://example.com/reset?token=abc".to_owned(),
username: "user".to_owned(),
},
}
}
#[tokio::test]
async fn log_sender_succeeds() {
let sender = LogEmailSender;
let msg = make_reset_message();
let result = sender.send(&msg).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn log_sender_succeeds_with_invitation_template() {
let sender = LogEmailSender;
let msg = EmailMessage {
to: "invitee@example.com".to_owned(),
subject: "You've been invited".to_owned(),
template: EmailTemplate::Invitation {
url: "https://example.com/invite/tok123".to_owned(),
invited_by: "Acme Corp".to_owned(),
},
};
let result = sender.send(&msg).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn noop_sender_succeeds() {
let sender = NoopEmailSender;
let msg = make_reset_message();
let result = sender.send(&msg).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn trait_object_dispatch_works() {
let sender: Box<dyn EmailSender> = Box::new(LogEmailSender);
let msg = make_reset_message();
let result = sender.send(&msg).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn noop_trait_object_dispatch_works() {
let sender: Box<dyn EmailSender> = Box::new(NoopEmailSender);
let msg = make_reset_message();
let result = sender.send(&msg).await;
assert!(result.is_ok());
}
}