use std::path::PathBuf;
use std::sync::Arc;
use lettre::{
AsyncTransport, Message, Tokio1Executor,
message::{Mailbox, MultiPart, SinglePart, header::ContentType},
transport::smtp::{AsyncSmtpTransport, authentication::Credentials},
};
const DEFAULT_SMTP_PORT: u16 = 587;
#[derive(Clone)]
pub struct Mailer {
inner: Arc<MailerInner>,
}
struct MailerInner {
backend: Backend,
from: Mailbox,
base_url: String,
}
enum Backend {
Smtp(AsyncSmtpTransport<Tokio1Executor>),
File(PathBuf),
}
impl Mailer {
pub fn from_env() -> anyhow::Result<Self> {
let from: Mailbox = std::env::var("FROM_EMAIL")
.unwrap_or_else(|_| "noreply@localhost".to_string())
.parse()?;
let base_url = std::env::var("PUBLIC_BASE_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
let backend = match std::env::var("SMTP_HOST") {
Ok(host) if !host.is_empty() => {
let port = std::env::var("SMTP_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_SMTP_PORT);
let user = std::env::var("SMTP_USER").unwrap_or_default();
let password = std::env::var("SMTP_PASSWORD").unwrap_or_default();
let mut builder =
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?.port(port);
if !user.is_empty() {
builder = builder.credentials(Credentials::new(user, password));
}
Backend::Smtp(builder.build())
}
_ => {
let dir = PathBuf::from("./emails");
std::fs::create_dir_all(&dir)?;
Backend::File(dir)
}
};
Ok(Self {
inner: Arc::new(MailerInner {
backend,
from,
base_url,
}),
})
}
pub fn base_url(&self) -> &str {
&self.inner.base_url
}
pub fn describe(&self) -> String {
match &self.inner.backend {
Backend::Smtp(_) => format!("smtp (from {})", self.inner.from),
Backend::File(dir) => format!("file ({} — no SMTP_HOST set)", dir.display()),
}
}
pub async fn send(
&self,
to: &str,
subject: &str,
text: &str,
html: Option<&str>,
) -> anyhow::Result<()> {
let to: Mailbox = to.parse()?;
let builder = Message::builder()
.from(self.inner.from.clone())
.to(to)
.subject(subject);
let message = match html {
Some(html_body) => builder.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(text.to_string()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html_body.to_string()),
),
)?,
None => builder
.header(ContentType::TEXT_PLAIN)
.body(text.to_string())?,
};
match &self.inner.backend {
Backend::Smtp(t) => {
t.send(message).await?;
}
Backend::File(dir) => {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let path = dir.join(format!("{ts}.eml"));
tokio::fs::write(&path, message.formatted()).await?;
println!("[mail] wrote {}", path.display());
}
}
Ok(())
}
}
pub mod templates {
pub fn password_reset(link: &str) -> (String, String, Option<String>) {
let subject = "Reset your password".to_string();
let text = format!(
"Hi,\n\
\n\
We received a request to reset your password.\n\
Click the link below to choose a new one:\n\
\n\
{link}\n\
\n\
This link expires in 1 hour.\n\
If you didn't request a reset, ignore this email.\n"
);
let html = format!(
"<p>Hi,</p>\
<p>We received a request to reset your password. \
<a href=\"{link}\">Click here to set a new one.</a></p>\
<p>This link expires in 1 hour. If you didn't request a reset, ignore this email.</p>"
);
(subject, text, Some(html))
}
pub fn verify_email(link: &str) -> (String, String, Option<String>) {
let subject = "Verify your email".to_string();
let text = format!(
"Welcome!\n\
\n\
Please verify your email address by clicking the link below:\n\
\n\
{link}\n\
\n\
The link expires in 24 hours.\n"
);
let html = format!(
"<p>Welcome!</p>\
<p>Please verify your email address by clicking the link below:</p>\
<p><a href=\"{link}\">{link}</a></p>\
<p>The link expires in 24 hours.</p>"
);
(subject, text, Some(html))
}
}