/*
* Copyright (C) 2025 Jonni Liljamo <jonni@liljamo.com>
*
* This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
* more information.
*/
use async_trait::async_trait;
use lettre::{
Message, SmtpTransport, Transport,
message::{Mailbox, header::ContentType},
transport::smtp::authentication::Credentials,
};
use serde::{Deserialize, Serialize};
use crate::{
config::{ServiceConfig, deserialize_token},
routes::message::MessageForm,
};
use super::Service;
#[derive(Clone, Deserialize, Serialize)]
pub struct EmailConfig {
from_name: Option<String>,
from: String,
to_name: Option<String>,
to: String,
username: String,
#[serde(deserialize_with = "deserialize_token")]
password: String,
smtp_host: String,
}
#[typetag::serde(name = "email")]
impl ServiceConfig for EmailConfig {
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
pub struct EmailService {
config: EmailConfig,
mailer: SmtpTransport,
}
impl EmailService {
pub fn new(
_client: reqwest::Client,
config: EmailConfig,
) -> Result<Self, Box<dyn std::error::Error>> {
let credentials = Credentials::new(config.username.clone(), config.password.clone());
let mailer = SmtpTransport::relay(&config.smtp_host)?
.credentials(credentials)
.build();
Ok(Self { config, mailer })
}
}
#[async_trait]
impl Service for EmailService {
async fn send(&self, form: &MessageForm) -> Result<(), Box<dyn std::error::Error>> {
let message = Message::builder()
.from(Mailbox::new(
self.config.from_name.clone(),
self.config.from.parse()?,
))
.to(Mailbox::new(
self.config.to_name.clone(),
self.config.to.parse()?,
))
.subject(form.title.clone())
.header(ContentType::TEXT_PLAIN)
.body(form.message.clone())?;
self.mailer.send(&message)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::config::Config;
use super::*;
#[tokio::test]
async fn config() {
Config::from_str(
r#"
[services.e]
type = "email"
from = "a@a.com"
to = "b@b.com"
username = "a"
password = "123"
smtp_host = "smtp.a.com"
"#,
)
.await
.unwrap()
.services["e"]
.as_any()
.downcast_ref::<EmailConfig>()
.unwrap();
}
}