use std::sync::Arc;
use chrono::Utc;
use lettre::message::header::ContentType;
use lettre::message::MultiPart;
use lettre::message::SinglePart;
use lettre::transport::smtp::authentication::Credentials;
use lettre::AsyncTransport;
use lettre::Message;
use lettre::Tokio1Executor;
use serde::{Deserialize, Serialize};
use crate::config::EmailConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SmtpTls {
Tls,
StartTls,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SmtpProvider {
Gmail,
Icloud,
Fastmail,
Resend,
Custom,
}
impl SmtpProvider {
pub fn defaults(&self) -> (&'static str, u16, SmtpTls) {
match self {
SmtpProvider::Gmail => ("smtp.gmail.com", 465, SmtpTls::Tls),
SmtpProvider::Icloud => ("smtp.mail.me.com", 587, SmtpTls::StartTls),
SmtpProvider::Fastmail => ("smtp.fastmail.com", 465, SmtpTls::Tls),
SmtpProvider::Resend => ("smtp.resend.com", 587, SmtpTls::StartTls),
SmtpProvider::Custom => ("", 0, SmtpTls::Tls),
}
}
}
type SmtpTransport = lettre::AsyncSmtpTransport<Tokio1Executor>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendReceipt {
pub message_id: String,
pub sent_at: chrono::DateTime<Utc>,
}
pub struct SmtpClient {
transport: Arc<SmtpTransport>,
from: String,
default_to: String,
}
impl std::fmt::Debug for SmtpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SmtpClient")
.field("from", &self.from)
.field("default_to", &self.default_to)
.finish()
}
}
impl SmtpClient {
pub fn from_config(config: &EmailConfig, password: &str) -> anyhow::Result<Self> {
let (default_host, default_port, default_tls) = config.provider().defaults();
let host = if config.host.is_empty() {
default_host
} else {
&config.host
};
let port = if config.port == 0 {
default_port
} else {
config.port
};
let tls_mode = config.tls.unwrap_or(default_tls);
let user = match config.provider {
SmtpProvider::Resend => "resend".to_string(),
_ => {
if config.user.is_empty() {
config.my_email.clone()
} else {
config.user.clone()
}
}
};
anyhow::ensure!(!host.is_empty(), "SMTP host is required");
anyhow::ensure!(port > 0, "SMTP port is required");
let creds = Credentials::new(user.clone(), password.to_string());
let transport = match tls_mode {
SmtpTls::Tls => {
lettre::AsyncSmtpTransport::<Tokio1Executor>::relay(host)?
.port(port)
.credentials(creds)
.build()
}
SmtpTls::StartTls => {
lettre::AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)?
.port(port)
.credentials(creds)
.build()
}
};
Ok(Self {
transport: Arc::new(transport),
from: config.my_email.clone(),
default_to: config.my_email.clone(),
})
}
pub async fn send(
&self,
_to: &str,
subject: &str,
html: &str,
text: Option<&str>,
) -> anyhow::Result<SendReceipt> {
let text_body = text
.map(|s| s.to_string())
.unwrap_or_else(|| subject.to_string());
let email = Message::builder()
.from(self.from.parse()?)
.to(self.default_to.parse()?)
.subject(subject)
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(text_body),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html.to_string()),
),
)?;
let _response = self.transport.send(email).await?;
let message_id = format!("<{}>", uuid::Uuid::new_v4());
Ok(SendReceipt {
message_id,
sent_at: Utc::now(),
})
}
pub async fn test_connection(&self) -> anyhow::Result<()> {
let email = Message::builder()
.from(self.from.parse()?)
.to(self.default_to.parse()?)
.subject("Oxios Email Test")
.body("If you see this, Oxios email is working.".to_string())?;
self.transport.send(email).await?;
Ok(())
}
pub fn from_addr(&self) -> &str {
&self.from
}
pub fn default_to(&self) -> &str {
&self.default_to
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_defaults() {
let (host, port, tls) = SmtpProvider::Gmail.defaults();
assert_eq!(host, "smtp.gmail.com");
assert_eq!(port, 465);
assert_eq!(tls, SmtpTls::Tls);
let (host, port, tls) = SmtpProvider::Icloud.defaults();
assert_eq!(host, "smtp.mail.me.com");
assert_eq!(port, 587);
assert_eq!(tls, SmtpTls::StartTls);
let (host, port, tls) = SmtpProvider::Fastmail.defaults();
assert_eq!(host, "smtp.fastmail.com");
assert_eq!(port, 465);
assert_eq!(tls, SmtpTls::Tls);
let (host, port, tls) = SmtpProvider::Resend.defaults();
assert_eq!(host, "smtp.resend.com");
assert_eq!(port, 587);
assert_eq!(tls, SmtpTls::StartTls);
let (host, port, _) = SmtpProvider::Custom.defaults();
assert!(host.is_empty());
assert_eq!(port, 0);
}
}