use crate::config::EmailConfig;
use anyhow::{Context, Result};
use lettre::{
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
message::{Mailbox, MessageBuilder, header::ContentType},
transport::smtp::authentication::Credentials,
};
pub(crate) trait MailSender {
async fn send_message(&self, email: Message) -> Result<String>;
}
pub(crate) struct LettreMailer {
inner: AsyncSmtpTransport<Tokio1Executor>,
to: String,
}
impl MailSender for LettreMailer {
async fn send_message(&self, email: Message) -> Result<String> {
let response = self
.inner
.send(email)
.await
.context("Failed to send email via SMTP")?;
Ok(format!(
"Email sent to {} (status: {})",
self.to,
response.code()
))
}
}
pub(crate) fn build_message(
from: &str,
to: &str,
subject: &str,
body: &str,
) -> Result<(Message, Mailbox)> {
let from_addr: Mailbox = from
.parse()
.context("Invalid sender email address in KODA_EMAIL_USERNAME")?;
let to_addr: Mailbox = to
.parse()
.context(format!("Invalid recipient email address: {to}"))?;
let email = MessageBuilder::new()
.from(from_addr)
.to(to_addr.clone())
.subject(subject)
.header(ContentType::TEXT_PLAIN)
.body(body.to_string())
.context("Failed to build email message")?;
Ok((email, to_addr))
}
pub async fn send_email(
config: &EmailConfig,
to: &str,
subject: &str,
body: &str,
) -> Result<String> {
let (email, to_addr) = build_message(&config.username, to, subject, body)?;
let creds = Credentials::new(config.username.clone(), config.password.clone());
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp_host)
.context("Failed to create SMTP transport")?
.port(config.smtp_port)
.credentials(creds)
.build();
let sender = LettreMailer {
inner: mailer,
to: to_addr.to_string(),
};
sender.send_message(email).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::EmailConfig;
fn fake_config() -> EmailConfig {
EmailConfig {
imap_host: "127.0.0.1".into(),
imap_port: 993,
smtp_host: "localhost".into(), smtp_port: 1, username: "sender@example.com".into(),
password: "pass".into(),
}
}
struct FakeMailer {
response: String,
}
impl MailSender for FakeMailer {
async fn send_message(&self, _email: Message) -> Result<String> {
Ok(self.response.clone())
}
}
#[test]
fn test_build_message_valid() {
let (msg, to) =
build_message("sender@example.com", "recip@example.com", "Hello", "World").unwrap();
assert!(to.to_string().contains("recip@example.com"));
let _ = msg;
}
#[test]
fn test_build_message_invalid_from() {
let err = build_message("not-an-email", "to@example.com", "s", "b").unwrap_err();
assert!(err.to_string().contains("Invalid sender"), "got: {err}");
}
#[test]
fn test_build_message_invalid_to() {
let err = build_message("from@example.com", "bad-addr", "s", "b").unwrap_err();
assert!(err.to_string().contains("Invalid recipient"), "got: {err}");
}
#[tokio::test]
async fn test_fake_mailer_returns_configured_response() {
let (email, _) = build_message("a@example.com", "b@example.com", "subj", "body").unwrap();
let mailer = FakeMailer {
response: "Email sent to b@example.com (status: 250)".into(),
};
let result = mailer.send_message(email).await.unwrap();
assert!(result.contains("250"));
assert!(result.contains("b@example.com"));
}
#[tokio::test]
async fn test_send_email_bad_from_address_fails_before_network() {
let mut cfg = fake_config();
cfg.username = "not-an-email".into();
let err = send_email(&cfg, "to@example.com", "s", "b")
.await
.unwrap_err();
assert!(err.to_string().contains("Invalid sender"));
}
#[tokio::test]
async fn test_send_email_smtp_transport_built_then_refused() {
let cfg = fake_config();
let err = send_email(&cfg, "to@example.com", "subject", "body")
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("SMTP"),
"Expected an SMTP-related error, got: {msg}"
);
}
}