use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use lettre::message::{Mailbox, MultiPart, SinglePart, header::ContentType};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use crate::email::{EmailMessage, EmailSender};
use crate::email_render::{EmailBranding, render};
use crate::error::AuthError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SmtpTls {
None,
StartTls,
ImplicitTls,
}
#[derive(Debug, Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub from_address: String,
pub from_name: Option<String>,
pub tls: SmtpTls,
}
pub struct SmtpEmailSender<T = AsyncSmtpTransport<Tokio1Executor>> {
transport: T,
from: Mailbox,
branding: Arc<EmailBranding>,
}
impl SmtpEmailSender {
pub fn new(config: SmtpConfig, branding: EmailBranding) -> Result<Self, AuthError> {
let is_localhost = config.host == "localhost" || config.host == "127.0.0.1";
if config.tls == SmtpTls::None && !is_localhost {
return Err(AuthError::Email(
"SmtpTls::None is only allowed for localhost hosts".to_owned(),
));
}
if config.tls == SmtpTls::None {
tracing::warn!(
host = %config.host,
"SmtpEmailSender: using unencrypted SMTP (dev only)"
);
}
let mut builder = match config.tls {
SmtpTls::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
.map_err(|e| AuthError::Email(e.to_string()))?,
SmtpTls::ImplicitTls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
.map_err(|e| AuthError::Email(e.to_string()))?,
SmtpTls::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host),
};
builder = builder.port(config.port);
if let (Some(user), Some(pass)) = (config.username, config.password) {
builder = builder.credentials(Credentials::new(user, pass));
}
let transport = builder.build();
let from = build_mailbox(config.from_name, &config.from_address)?;
Ok(Self {
transport,
from,
branding: Arc::new(branding),
})
}
}
impl<T> SmtpEmailSender<T> {
#[cfg(test)]
pub fn new_with_transport(transport: T, branding: EmailBranding) -> Self {
Self {
transport,
from: "Test Sender <test@example.com>"
.parse()
.expect("hardcoded mailbox is valid"),
branding: Arc::new(branding),
}
}
}
impl<T> EmailSender for SmtpEmailSender<T>
where
T: AsyncTransport + Send + Sync,
T::Error: std::fmt::Display,
{
fn send<'a>(
&'a self,
message: &'a EmailMessage,
) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
Box::pin(async move {
let rendered = render(&message.template, &self.branding);
let to: Mailbox = message
.to
.parse()
.map_err(|e: lettre::address::AddressError| AuthError::Email(e.to_string()))?;
let email = Message::builder()
.from(self.from.clone())
.to(to)
.subject(&message.subject)
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(rendered.text),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(rendered.html),
),
)
.map_err(|e| AuthError::Email(e.to_string()))?;
self.transport
.send(email)
.await
.map(|_| ())
.map_err(|e| AuthError::Email(e.to_string()))
})
}
}
fn build_mailbox(name: Option<String>, address: &str) -> Result<Mailbox, AuthError> {
let addr: lettre::Address = address
.parse()
.map_err(|e: lettre::address::AddressError| AuthError::Email(e.to_string()))?;
Ok(Mailbox::new(name, addr))
}
#[cfg(test)]
mod tests {
use lettre::transport::stub::AsyncStubTransport;
use crate::email::EmailTemplate;
use super::*;
fn make_sender() -> SmtpEmailSender<AsyncStubTransport> {
let stub = AsyncStubTransport::new_ok();
SmtpEmailSender::new_with_transport(stub, EmailBranding::default())
}
fn make_sender_with_branding(branding: EmailBranding) -> SmtpEmailSender<AsyncStubTransport> {
let stub = AsyncStubTransport::new_ok();
SmtpEmailSender::new_with_transport(stub, branding)
}
fn reset_message() -> EmailMessage {
EmailMessage {
to: "alice@example.com".to_owned(),
subject: "Reset your password".to_owned(),
template: EmailTemplate::PasswordReset {
url: "https://example.com/reset?t=abc".to_owned(),
username: "alice".to_owned(),
},
}
}
#[tokio::test]
async fn send_captures_message_with_correct_headers() {
let sender = make_sender();
sender.send(&reset_message()).await.unwrap();
let msgs = sender.transport.messages().await;
assert_eq!(msgs.len(), 1);
let (envelope, raw) = &msgs[0];
let to_addrs: Vec<_> = envelope.to().iter().map(|a| a.as_ref()).collect();
assert!(to_addrs.contains(&"alice@example.com"));
assert!(raw.contains("Reset your password"));
assert!(raw.contains("text/plain"));
assert!(raw.contains("text/html"));
}
#[tokio::test]
async fn send_includes_subject_and_rendered_url_in_body() {
let sender = make_sender();
sender.send(&reset_message()).await.unwrap();
let msgs = sender.transport.messages().await;
let raw = &msgs[0].1;
assert!(raw.contains("Subject: Reset your password"));
assert!(
raw.contains("example.com/reset"),
"rendered URL must reach the SMTP transport"
);
assert!(
raw.contains("alice"),
"rendered username must reach the SMTP transport"
);
}
#[tokio::test]
async fn branding_app_name_propagates_to_smtp_body() {
let branding = EmailBranding {
app_name: "Acme Inc".to_owned(),
logo_url: None,
footer_line: None,
};
let sender = make_sender_with_branding(branding);
sender.send(&reset_message()).await.unwrap();
let msgs = sender.transport.messages().await;
let raw = &msgs[0].1;
assert!(
raw.contains("Acme Inc"),
"branding.app_name must appear in the rendered body sent over SMTP"
);
}
#[tokio::test]
async fn address_parse_failure_returns_email_error() {
let sender = make_sender();
let msg = EmailMessage {
to: "not-an-email".to_owned(),
subject: "Subject".to_owned(),
template: EmailTemplate::PasswordReset {
url: "https://example.com".to_owned(),
username: "x".to_owned(),
},
};
let err = sender.send(&msg).await.unwrap_err();
assert!(matches!(err, AuthError::Email(_)));
}
#[test]
fn tls_none_refused_for_non_localhost() {
let cfg = SmtpConfig {
host: "smtp.example.com".to_owned(),
port: 25,
username: None,
password: None,
from_address: "x@example.com".to_owned(),
from_name: None,
tls: SmtpTls::None,
};
let result = SmtpEmailSender::new(cfg, EmailBranding::default());
assert!(matches!(result, Err(AuthError::Email(_))));
}
#[test]
fn tls_none_allowed_for_localhost() {
let cfg = SmtpConfig {
host: "localhost".to_owned(),
port: 1025,
username: None,
password: None,
from_address: "x@example.com".to_owned(),
from_name: None,
tls: SmtpTls::None,
};
assert!(SmtpEmailSender::new(cfg, EmailBranding::default()).is_ok());
}
#[test]
fn no_auth_config_succeeds() {
let cfg = SmtpConfig {
host: "localhost".to_owned(),
port: 1025,
username: None,
password: None,
from_address: "x@example.com".to_owned(),
from_name: None,
tls: SmtpTls::None,
};
assert!(SmtpEmailSender::new(cfg, EmailBranding::default()).is_ok());
}
}