Skip to main content

anvil_core/
mail.rs

1//! Mail. SMTP via lettre, with an in-memory fake driver for tests.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use lettre::message::header::ContentType;
7use lettre::transport::smtp::authentication::Credentials;
8use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
9use parking_lot::Mutex;
10
11use crate::config::MailConfig;
12use crate::Error;
13
14#[async_trait]
15pub trait MailDriver: Send + Sync {
16    async fn send(&self, message: OutgoingMessage) -> Result<(), Error>;
17}
18
19#[derive(Debug, Clone)]
20pub struct OutgoingMessage {
21    pub from: String,
22    pub to: Vec<String>,
23    pub cc: Vec<String>,
24    pub bcc: Vec<String>,
25    pub subject: String,
26    pub html_body: Option<String>,
27    pub text_body: Option<String>,
28}
29
30#[derive(Clone)]
31pub struct MailerHandle {
32    driver: Arc<dyn MailDriver>,
33}
34
35impl MailerHandle {
36    pub fn new(driver: Arc<dyn MailDriver>) -> Self {
37        Self { driver }
38    }
39
40    pub fn null() -> Self {
41        Self {
42            driver: Arc::new(NullMailDriver),
43        }
44    }
45
46    pub fn fake() -> (Self, Arc<Mutex<Vec<OutgoingMessage>>>) {
47        let outbox = Arc::new(Mutex::new(Vec::new()));
48        let driver = FakeDriver {
49            outbox: outbox.clone(),
50        };
51        (
52            Self {
53                driver: Arc::new(driver),
54            },
55            outbox,
56        )
57    }
58
59    pub fn smtp(config: &MailConfig) -> Result<Self, Error> {
60        let mut builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host)
61            .port(config.port);
62        if !config.username.is_empty() {
63            builder = builder.credentials(Credentials::new(
64                config.username.clone(),
65                config.password.clone(),
66            ));
67        }
68        let transport = builder.build();
69        Ok(Self {
70            driver: Arc::new(SmtpDriver {
71                transport,
72                default_from: format!("{} <{}>", config.from_name, config.from_address),
73            }),
74        })
75    }
76
77    pub async fn send(&self, message: OutgoingMessage) -> Result<(), Error> {
78        self.driver.send(message).await
79    }
80}
81
82/// Trait for app-defined mailables (Laravel's `Mailable`).
83#[async_trait]
84pub trait Mailable: Send + Sync {
85    async fn build(&self) -> Result<OutgoingMessage, Error>;
86}
87
88pub async fn to(addr: impl Into<String>, mailer: &MailerHandle, mailable: impl Mailable) -> Result<(), Error> {
89    let mut msg = mailable.build().await?;
90    msg.to.push(addr.into());
91    mailer.send(msg).await
92}
93
94struct NullMailDriver;
95
96#[async_trait]
97impl MailDriver for NullMailDriver {
98    async fn send(&self, message: OutgoingMessage) -> Result<(), Error> {
99        tracing::debug!(?message, "null mail driver dropped message");
100        Ok(())
101    }
102}
103
104struct FakeDriver {
105    outbox: Arc<Mutex<Vec<OutgoingMessage>>>,
106}
107
108#[async_trait]
109impl MailDriver for FakeDriver {
110    async fn send(&self, message: OutgoingMessage) -> Result<(), Error> {
111        self.outbox.lock().push(message);
112        Ok(())
113    }
114}
115
116struct SmtpDriver {
117    transport: AsyncSmtpTransport<Tokio1Executor>,
118    default_from: String,
119}
120
121#[async_trait]
122impl MailDriver for SmtpDriver {
123    async fn send(&self, message: OutgoingMessage) -> Result<(), Error> {
124        let from = if message.from.is_empty() {
125            self.default_from.clone()
126        } else {
127            message.from.clone()
128        };
129
130        let mut builder = Message::builder()
131            .from(from.parse().map_err(|e: lettre::address::AddressError| Error::Mail(e.to_string()))?);
132        for to in &message.to {
133            builder = builder.to(to.parse().map_err(|e: lettre::address::AddressError| Error::Mail(e.to_string()))?);
134        }
135        for cc in &message.cc {
136            builder = builder.cc(cc.parse().map_err(|e: lettre::address::AddressError| Error::Mail(e.to_string()))?);
137        }
138        for bcc in &message.bcc {
139            builder = builder.bcc(bcc.parse().map_err(|e: lettre::address::AddressError| Error::Mail(e.to_string()))?);
140        }
141        let builder = builder.subject(&message.subject);
142
143        let msg = if let Some(html) = &message.html_body {
144            builder
145                .header(ContentType::TEXT_HTML)
146                .body(html.clone())
147                .map_err(|e| Error::Mail(e.to_string()))?
148        } else if let Some(text) = &message.text_body {
149            builder
150                .header(ContentType::TEXT_PLAIN)
151                .body(text.clone())
152                .map_err(|e| Error::Mail(e.to_string()))?
153        } else {
154            return Err(Error::Mail("mail has no body".into()));
155        };
156
157        self.transport
158            .send(msg)
159            .await
160            .map_err(|e| Error::Mail(e.to_string()))?;
161        Ok(())
162    }
163}