use std::error::Error as StdError;
use cot::config::EmailUrl;
use cot::email::{EmailMessage, EmailMessageError};
use lettre::message::header::ContentType;
use lettre::message::{Attachment, Body, Mailbox, MultiPart, SinglePart};
use lettre::transport::smtp;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::email::transport::{Transport, TransportError, TransportResult};
const ERROR_PREFIX: &str = "smtp transport error:";
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SMTPError {
#[error("{ERROR_PREFIX} IO error: {0}")]
Io(#[from] std::io::Error),
#[error("{ERROR_PREFIX} send error: {0}")]
SmtpSend(Box<dyn StdError + Send + Sync>),
#[error("{ERROR_PREFIX} transport creation error: {0}")]
TransportCreation(Box<dyn StdError + Send + Sync>),
#[error("{ERROR_PREFIX} message error: {0}")]
MessageBuild(#[from] EmailMessageError),
}
impl From<SMTPError> for TransportError {
fn from(err: SMTPError) -> Self {
match err {
SMTPError::MessageBuild(e) => TransportError::MessageBuildError(e),
other => TransportError::Backend(Box::new(other)),
}
}
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Mechanism {
#[default]
Plain,
Login,
Xoauth2,
}
impl From<Mechanism> for smtp::authentication::Mechanism {
fn from(mechanism: Mechanism) -> Self {
match mechanism {
Mechanism::Plain => smtp::authentication::Mechanism::Plain,
Mechanism::Login => smtp::authentication::Mechanism::Login,
Mechanism::Xoauth2 => smtp::authentication::Mechanism::Xoauth2,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Smtp {
transport: AsyncSmtpTransport<Tokio1Executor>,
}
impl Smtp {
pub fn new(url: &EmailUrl, mechanism: Mechanism) -> TransportResult<Self> {
let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(url.as_str())
.map_err(|err| SMTPError::TransportCreation(Box::new(err)))?
.authentication(vec![mechanism.into()])
.build();
Ok(Smtp { transport })
}
}
impl Transport for Smtp {
async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> {
for message in messages {
let m = convert_email_message_to_lettre_message(message.clone())?;
self.transport
.send(m)
.await
.map_err(|err| SMTPError::SmtpSend(Box::new(err)))?;
}
Ok(())
}
}
fn convert_email_message_to_lettre_message(
message: EmailMessage,
) -> Result<Message, EmailMessageError> {
let from_mailbox = message
.from
.email()
.as_str()
.parse::<Mailbox>()
.map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?;
let mut builder = Message::builder()
.from(from_mailbox)
.subject(message.subject);
for to in message.to {
let mb = to
.email()
.as_str()
.parse::<Mailbox>()
.map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?;
builder = builder.to(mb);
}
for cc in message.cc {
let mb = cc
.email()
.as_str()
.parse::<Mailbox>()
.map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?;
builder = builder.cc(mb);
}
for bcc in message.bcc {
let mb = bcc
.email()
.as_str()
.parse::<Mailbox>()
.map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?;
builder = builder.bcc(mb);
}
for r in message.reply_to {
let mb = r
.email()
.as_str()
.parse::<Mailbox>()
.map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?;
builder = builder.reply_to(mb);
}
let mut mixed = MultiPart::mixed().singlepart(SinglePart::plain(message.body));
for attach in message.attachments {
let mime: ContentType = attach.content_type.parse().unwrap_or_else(|_| {
"application/octet-stream"
.parse()
.expect("could not parse default mime type")
});
let part = Attachment::new(attach.filename).body(Body::new(attach.data), mime);
mixed = mixed.singlepart(part);
}
let email = builder
.multipart(mixed)
.map_err(|err| EmailMessageError::BuildError(Box::new(err)))?;
Ok(email)
}
#[cfg(test)]
mod tests {
use cot::email::AttachmentData;
use lettre::transport::smtp;
use super::*;
#[cot::test]
async fn test_smtp_creation() {
let url = EmailUrl::from("smtp://user:pass@smtp.gmail.com:587");
let smtp = Smtp::new(&url, Mechanism::Plain);
assert!(smtp.is_ok());
}
#[cot::test]
async fn test_smtp_error_to_transport_error() {
let smtp_error = SMTPError::SmtpSend(Box::new(std::io::Error::other("test")));
let transport_error: TransportError = smtp_error.into();
assert_eq!(
transport_error.to_string(),
"email transport error: transport error: smtp transport error: send error: test"
);
let smtp_error = SMTPError::TransportCreation(Box::new(std::io::Error::other("test")));
let transport_error: TransportError = smtp_error.into();
assert_eq!(
transport_error.to_string(),
"email transport error: transport error: smtp transport error: transport creation error: test"
);
let smtp_error = SMTPError::Io(std::io::Error::other("test"));
let transport_error: TransportError = smtp_error.into();
assert_eq!(
transport_error.to_string(),
"email transport error: transport error: smtp transport error: IO error: test"
);
}
#[cot::test]
async fn mechanism_from_maps_all_cases() {
let m_plain: smtp::authentication::Mechanism = Mechanism::Plain.into();
assert_eq!(m_plain, smtp::authentication::Mechanism::Plain);
let m_login: smtp::authentication::Mechanism = Mechanism::Login.into();
assert_eq!(m_login, smtp::authentication::Mechanism::Login);
let m_xoauth2: smtp::authentication::Mechanism = Mechanism::Xoauth2.into();
assert_eq!(m_xoauth2, smtp::authentication::Mechanism::Xoauth2);
}
#[cot::test]
async fn try_from_basic_converts_and_contains_headers() {
let msg = EmailMessage::builder()
.from(crate::common_types::Email::new("from@example.com").unwrap())
.to(vec![
crate::common_types::Email::new("to@example.com").unwrap(),
])
.subject("Hello World")
.body("This is the body.")
.build()
.unwrap();
let built: Message =
convert_email_message_to_lettre_message(msg).expect("conversion to lettre::Message");
let formatted = String::from_utf8_lossy(&built.formatted()).to_string();
assert!(formatted.contains("From: from@example.com"),);
assert!(formatted.contains("To: to@example.com"),);
assert!(formatted.contains("Subject: Hello World"),);
assert!(formatted.contains("Content-Type: multipart/mixed"),);
assert!(formatted.contains("This is the body."),);
}
#[cot::test]
async fn try_from_includes_cc_and_reply_to_headers() {
let msg = EmailMessage::builder()
.from(crate::common_types::Email::new("sender@example.com").unwrap())
.to(vec![
crate::common_types::Email::new("primary@example.com").unwrap(),
])
.cc(vec![
crate::common_types::Email::new("cc1@example.com").unwrap(),
crate::common_types::Email::new("cc2@example.com").unwrap(),
])
.bcc(vec![
crate::common_types::Email::new("hidden@example.com").unwrap(),
])
.reply_to(vec![
crate::common_types::Email::new("replyto@example.com").unwrap(),
])
.subject("Headers Test")
.body("Body")
.build()
.unwrap();
let built: Message =
convert_email_message_to_lettre_message(msg).expect("conversion to lettre::Message");
let formatted = String::from_utf8_lossy(&built.formatted()).to_string();
assert!(
formatted.contains("Cc: cc1@example.com, cc2@example.com")
|| (formatted.contains("Cc: cc1@example.com")
&& formatted.contains("cc2@example.com")),
);
assert!(formatted.contains("Reply-To: replyto@example.com"),);
}
#[cot::test]
async fn try_from_with_attachment_uses_default_mime_on_parse_failure() {
let attachment = AttachmentData {
filename: "report.bin".to_string(),
content_type: "this/is not a valid mime".to_string(),
data: vec![0xDE, 0xAD, 0xBE, 0xEF],
};
let msg = EmailMessage::builder()
.from(crate::common_types::Email::new("sender@example.com").unwrap())
.to(vec![
crate::common_types::Email::new("to@example.com").unwrap(),
])
.subject("Attachment Test")
.body("Please see attachment")
.attachments(vec![attachment])
.build()
.unwrap();
let built: Message =
convert_email_message_to_lettre_message(msg).expect("conversion to lettre::Message");
let formatted = String::from_utf8_lossy(&built.formatted()).to_string();
assert!(formatted.contains("Content-Disposition: attachment"),);
assert!(formatted.contains("report.bin"),);
assert!(formatted.contains("Content-Type: application/octet-stream"),);
assert!(formatted.contains("Please see attachment"));
}
}