pub mod outbox;
use crate::errors::{CoreError, CoreResult};
use crate::time::SharedClock;
use mail_builder::MessageBuilder;
use sui_id_store::repos::smtp_config;
use sui_id_store::Database;
use tokio::sync::Mutex;
use wasm_smtp::SmtpClient;
use wasm_smtp_tokio::{TokioPlainTransport, TokioTlsTransport};
#[derive(Debug, Clone)]
pub struct OutgoingMail {
pub to: String,
pub subject: String,
pub text_body: String,
pub html_body: Option<String>,
pub locale: Option<sui_id_i18n::Locale>,
}
#[derive(Debug, Clone)]
pub struct MailSendOutcome {
pub from: String,
pub to: String,
pub subject: String,
}
pub trait MailSender: Send + Sync {
fn send<'a>(
&'a self,
mail: OutgoingMail,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = CoreResult<MailSendOutcome>> + Send + 'a>,
>;
}
pub struct SmtpMailSender {
db: Database,
ehlo_hostname: String,
}
impl SmtpMailSender {
pub fn new(db: Database, ehlo_hostname: impl Into<String>) -> Self {
Self {
db,
ehlo_hostname: ehlo_hostname.into(),
}
}
pub async fn test_connection(&self) -> CoreResult<()> {
let cfg = smtp_config::get(&self.db).await?
.ok_or_else(|| CoreError::BadRequest("SMTP is not configured".into()))?;
let password = smtp_config::decrypt_password(&cfg, self.db.key()).await?;
run_smtp_session(&cfg, password.as_deref(), &self.ehlo_hostname, None).await
.map_err(|e| CoreError::BadRequest(format!("SMTP test failed: {e}")))?;
Ok(())
}
}
impl MailSender for SmtpMailSender {
fn send<'a>(
&'a self,
mail: OutgoingMail,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = CoreResult<MailSendOutcome>> + Send + 'a>,
> {
Box::pin(async move {
let cfg = smtp_config::get(&self.db).await?
.ok_or_else(|| CoreError::BadRequest("SMTP is not configured".into()))?;
if !cfg.enabled {
return Err(CoreError::BadRequest("SMTP is disabled".into()));
}
let password = smtp_config::decrypt_password(&cfg, self.db.key()).await?;
let from = cfg.from_address.clone();
let subject = mail.subject.clone();
let to_addr = mail.to.clone();
run_smtp_session(&cfg, password.as_deref(), &self.ehlo_hostname, Some(&mail)).await
.map_err(|e| {
tracing::warn!(error = %e, "SMTP send failed");
CoreError::BadRequest(format!("SMTP send failed: {e}"))
})?;
Ok(MailSendOutcome {
from,
to: to_addr,
subject,
})
})
}
}
async fn run_smtp_session(
cfg: &sui_id_store::models::SmtpConfigRow,
password: Option<&str>,
ehlo_hostname: &str,
mail: Option<&OutgoingMail>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use sui_id_store::models::SmtpTlsMode;
match cfg.tls_mode {
SmtpTlsMode::Implicit => {
let transport =
TokioTlsTransport::connect_implicit_tls(&cfg.host, cfg.port, &cfg.host).await?;
let client = SmtpClient::connect(transport, ehlo_hostname).await?;
authenticate_and_dispatch(client, cfg, password, mail).await?;
}
SmtpTlsMode::StartTls => {
let transport =
TokioPlainTransport::connect(&cfg.host, cfg.port, &cfg.host).await?;
let client = SmtpClient::connect_starttls(transport, ehlo_hostname).await?;
authenticate_and_dispatch(client, cfg, password, mail).await?;
}
}
Ok(())
}
async fn authenticate_and_dispatch<T: wasm_smtp::Transport>(
mut client: SmtpClient<T>,
cfg: &sui_id_store::models::SmtpConfigRow,
password: Option<&str>,
mail: Option<&OutgoingMail>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let (Some(user), Some(pass)) = (cfg.username.as_deref(), password) {
client.login(user, pass).await?;
}
if let Some(mail) = mail {
let mut builder = MessageBuilder::new()
.from(build_from(&cfg.from_address, cfg.from_name.as_deref()))
.to(mail.to.as_str())
.subject(mail.subject.as_str())
.text_body(mail.text_body.as_str());
if let Some(html) = mail.html_body.as_deref() {
builder = builder.html_body(html);
}
client
.send_message(&cfg.from_address, &[mail.to.as_str()], builder)
.await?;
}
client.quit().await?;
Ok(())
}
fn build_from<'a>(addr: &'a str, name: Option<&'a str>) -> mail_builder::headers::address::Address<'a> {
match name {
Some(n) => (n, addr).into(),
None => addr.into(),
}
}
#[derive(Default)]
pub struct InMemoryMailSender {
sent: Mutex<Vec<OutgoingMail>>,
}
impl InMemoryMailSender {
pub fn new() -> Self {
Self::default()
}
pub async fn drain(&self) -> Vec<OutgoingMail> {
let mut g = self.sent.lock().await;
std::mem::take(&mut *g)
}
pub async fn last(&self) -> Option<OutgoingMail> {
self.sent.lock().await.last().cloned()
}
pub async fn count(&self) -> usize {
self.sent.lock().await.len()
}
}
impl MailSender for InMemoryMailSender {
fn send<'a>(
&'a self,
mail: OutgoingMail,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = CoreResult<MailSendOutcome>> + Send + 'a>,
> {
Box::pin(async move {
let outcome = MailSendOutcome {
from: "test@sui-id.test".into(),
to: mail.to.clone(),
subject: mail.subject.clone(),
};
self.sent.lock().await.push(mail);
Ok(outcome)
})
}
}
#[allow(unused)]
fn clock_anchor(_: &SharedClock) {}