Skip to main content

rust_web_server/mailer/
delivery.rs

1//! Attachments, transports, delivery later, templates and previews
2//! (18.35.0, #93).
3
4use std::sync::{Arc, Mutex};
5
6use super::{Email, Mailer, MailerError};
7#[cfg(feature = "tera")]
8use super::EmailBuilder;
9
10/// A file attached to an email.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct MailAttachment {
13    /// The name the recipient sees (one line, ASCII; others become `_`).
14    pub filename: String,
15    pub content_type: String,
16    pub data: Vec<u8>,
17}
18
19impl MailAttachment {
20    pub fn new(filename: &str, content_type: &str, data: impl Into<Vec<u8>>) -> Self {
21        MailAttachment { filename: filename.to_string(), content_type: content_type.to_string(), data: data.into() }
22    }
23}
24
25/// An [`Email`] with its attachments: what a [`Deliver`] sends.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Mail {
28    pub email: Email,
29    pub attachments: Vec<MailAttachment>,
30}
31
32impl PartialEq for Email {
33    fn eq(&self, other: &Self) -> bool {
34        (&self.to, &self.cc, &self.bcc, &self.subject, &self.text, &self.html, &self.reply_to)
35            == (&other.to, &other.cc, &other.bcc, &other.subject, &other.text, &other.html, &other.reply_to)
36    }
37}
38
39impl Eq for Email {}
40
41impl From<Email> for Mail {
42    fn from(email: Email) -> Self {
43        Mail { email, attachments: Vec::new() }
44    }
45}
46
47impl Mail {
48    pub fn new(email: Email) -> Self {
49        Mail::from(email)
50    }
51
52    /// Add an attachment.
53    pub fn attach(mut self, attachment: MailAttachment) -> Self {
54        self.attachments.push(attachment);
55        self
56    }
57
58    /// Send it on `queue`'s worker threads through `transport` (with the
59    /// queue's retries), returning at once. Lost if the process exits
60    /// first: see [`enqueue`](Self::enqueue) for a persistent queue.
61    #[cfg(feature = "jobs")]
62    pub fn deliver_later(self, queue: &crate::jobs::JobQueue, transport: Arc<dyn Deliver>) {
63        queue.submit(move || transport.deliver(&self).map_err(|e| e.to_string()));
64    }
65
66    /// Put it in `queue` as a [`MAIL_JOB_TYPE`] job; a process that called
67    /// [`register_delivery`] sends it. Returns the job id.
68    #[cfg(all(feature = "jobs", any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")))]
69    pub async fn enqueue(&self, queue: &crate::jobs::PersistentJobQueue, options: crate::jobs::EnqueueOptions) -> Result<String, crate::model::DbError> {
70        queue.enqueue_with(MAIL_JOB_TYPE, &self.to_payload(), options).await
71    }
72
73    /// The mail as job payload text: one `key=value` line per field,
74    /// values percent-encoded, attachments' data in base64.
75    pub fn to_payload(&self) -> String {
76        let e = &self.email;
77        let mut lines = Vec::new();
78        let mut push = |key: &str, value: &str| lines.push(format!("{key}={}", encode(value)));
79        e.to.iter().for_each(|v| push("to", v));
80        e.cc.iter().for_each(|v| push("cc", v));
81        e.bcc.iter().for_each(|v| push("bcc", v));
82        push("subject", &e.subject);
83        if let Some(v) = &e.text {
84            push("text", v);
85        }
86        if let Some(v) = &e.html {
87            push("html", v);
88        }
89        if let Some(v) = &e.reply_to {
90            push("reply_to", v);
91        }
92        for a in &self.attachments {
93            let data = crate::core::base64::Base64::encode(&a.data).unwrap_or_default();
94            lines.push(format!("attachment={};{};{}", encode(&a.filename), encode(&a.content_type), data));
95        }
96        lines.join("\n")
97    }
98
99    /// Parse [`to_payload`](Self::to_payload) text.
100    pub fn from_payload(text: &str) -> Result<Mail, MailerError> {
101        let bad = |what: &str| MailerError::Build(format!("not a mail payload: {what}"));
102        let mut email = Email { to: Vec::new(), cc: Vec::new(), bcc: Vec::new(), subject: String::new(), text: None, html: None, reply_to: None };
103        let mut attachments = Vec::new();
104        for line in text.lines().filter(|l| !l.is_empty()) {
105            let (key, value) = line.split_once('=').ok_or_else(|| bad(line))?;
106            if key == "attachment" {
107                let mut fields = value.splitn(3, ';');
108                let (Some(name), Some(content_type), Some(data)) = (fields.next(), fields.next(), fields.next()) else {
109                    return Err(bad("attachment"));
110                };
111                let data = crate::core::base64::Base64::decode(data.to_string()).map_err(|_| bad("attachment data"))?;
112                attachments.push(MailAttachment { filename: decode(name).ok_or_else(|| bad(name))?, content_type: decode(content_type).ok_or_else(|| bad(content_type))?, data });
113                continue;
114            }
115            let value = decode(value).ok_or_else(|| bad(value))?;
116            match key {
117                "to" => email.to.push(value),
118                "cc" => email.cc.push(value),
119                "bcc" => email.bcc.push(value),
120                "subject" => email.subject = value,
121                "text" => email.text = Some(value),
122                "html" => email.html = Some(value),
123                "reply_to" => email.reply_to = Some(value),
124                other => return Err(bad(other)),
125            }
126        }
127        if email.to.is_empty() {
128            return Err(bad("no recipient"));
129        }
130        Ok(Mail { email, attachments })
131    }
132}
133
134fn encode(value: &str) -> String {
135    let mut out = String::with_capacity(value.len());
136    for byte in value.bytes() {
137        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'@' | b' ') {
138            out.push(byte as char);
139        } else {
140            out.push_str(&format!("%{byte:02X}"));
141        }
142    }
143    out
144}
145
146fn decode(text: &str) -> Option<String> {
147    let bytes = text.as_bytes();
148    let mut out = Vec::with_capacity(bytes.len());
149    let mut i = 0;
150    while i < bytes.len() {
151        if bytes[i] == b'%' {
152            out.push(u8::from_str_radix(text.get(i + 1..i + 3)?, 16).ok()?);
153            i += 3;
154        } else {
155            out.push(bytes[i]);
156            i += 1;
157        }
158    }
159    String::from_utf8(out).ok()
160}
161
162/// Something that sends mail: [`Mailer`] (SMTP), [`TestMailbox`] (keeps
163/// it, for tests and development), or your own (an HTTP email API).
164pub trait Deliver: Send + Sync {
165    fn deliver(&self, mail: &Mail) -> Result<(), MailerError>;
166}
167
168impl Deliver for Mailer {
169    fn deliver(&self, mail: &Mail) -> Result<(), MailerError> {
170        self.send_mail(mail)
171    }
172}
173
174/// Keeps what it's given instead of sending it: the transport for tests
175/// and local development. Clones share the mailbox.
176///
177/// ```rust
178/// use rust_web_server::mailer::{Deliver, Email, Mail, TestMailbox};
179///
180/// let mailbox = TestMailbox::new();
181/// let email = Email::builder().to("a@example.com").subject("Hi").text("Hello").build().unwrap();
182/// mailbox.deliver(&Mail::new(email)).unwrap();
183/// assert_eq!("Hi", mailbox.last().unwrap().email.subject);
184/// ```
185#[derive(Clone, Default)]
186pub struct TestMailbox {
187    sent: Arc<Mutex<Vec<Mail>>>,
188}
189
190impl TestMailbox {
191    pub fn new() -> Self {
192        TestMailbox::default()
193    }
194
195    /// Every mail delivered so far, oldest first.
196    pub fn sent(&self) -> Vec<Mail> {
197        self.sent.lock().unwrap_or_else(|e| e.into_inner()).clone()
198    }
199
200    /// The latest.
201    pub fn last(&self) -> Option<Mail> {
202        self.sent.lock().unwrap_or_else(|e| e.into_inner()).last().cloned()
203    }
204
205    /// The latest sent to `address` (To, Cc or Bcc).
206    pub fn last_to(&self, address: &str) -> Option<Mail> {
207        let sent = self.sent.lock().unwrap_or_else(|e| e.into_inner());
208        sent.iter()
209            .rev()
210            .find(|m| m.email.to.iter().chain(&m.email.cc).chain(&m.email.bcc).any(|a| a.eq_ignore_ascii_case(address)))
211            .cloned()
212    }
213
214    pub fn count(&self) -> usize {
215        self.sent.lock().unwrap_or_else(|e| e.into_inner()).len()
216    }
217
218    pub fn clear(&self) {
219        self.sent.lock().unwrap_or_else(|e| e.into_inner()).clear();
220    }
221}
222
223impl Deliver for TestMailbox {
224    fn deliver(&self, mail: &Mail) -> Result<(), MailerError> {
225        self.sent.lock().unwrap_or_else(|e| e.into_inner()).push(mail.clone());
226        Ok(())
227    }
228}
229
230/// The job type of mail put in a [`PersistentJobQueue`](crate::jobs::PersistentJobQueue)
231/// by [`Mail::enqueue`].
232pub const MAIL_JOB_TYPE: &str = "rws.mail";
233
234/// Send the queue's [`MAIL_JOB_TYPE`] jobs through `transport` (a failed
235/// send is retried like any job).
236#[cfg(all(feature = "jobs", any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")))]
237pub fn register_delivery(queue: &crate::jobs::PersistentJobQueue, transport: Arc<dyn Deliver>) {
238    queue.register(MAIL_JOB_TYPE, move |payload| {
239        let mail = Mail::from_payload(payload).map_err(|e| e.to_string())?;
240        transport.deliver(&mail).map_err(|e| e.to_string())
241    });
242}
243
244// ── Templates ────────────────────────────────────────────────────────────────
245
246#[cfg(feature = "tera")]
247impl EmailBuilder {
248    /// Set the bodies from the templates `<name>.txt` (text) and
249    /// `<name>.html` (HTML) of the global engine
250    /// ([`template::init`](crate::template::init)), whichever exist,
251    /// rendered with `ctx`. An error when neither exists or one fails.
252    pub fn render(self, name: &str, ctx: &crate::template::Context) -> Result<Self, MailerError> {
253        let engine = crate::template::try_global().ok_or_else(|| MailerError::Build("the template engine isn't initialized: call template::init".into()))?;
254        self.render_with(&engine, name, ctx)
255    }
256
257    /// [`render`](Self::render) with a given engine.
258    pub fn render_with(mut self, engine: &crate::template::TeraEngine, name: &str, ctx: &crate::template::Context) -> Result<Self, MailerError> {
259        let (text, html) = (format!("{name}.txt"), format!("{name}.html"));
260        let mut found = false;
261        if engine.has_template(&text) {
262            self = self.text(&engine.render(&text, ctx).map_err(MailerError::Build)?);
263            found = true;
264        }
265        if engine.has_template(&html) {
266            self = self.html(&engine.render(&html, ctx).map_err(MailerError::Build)?);
267            found = true;
268        }
269        if !found {
270            return Err(MailerError::Build(format!("no template {text} or {html}")));
271        }
272        Ok(self)
273    }
274}
275
276// ── Previews ─────────────────────────────────────────────────────────────────
277
278type Preview = Arc<dyn Fn() -> Result<Mail, MailerError> + Send + Sync>;
279
280/// Mail previews: pages showing what each email looks like, built from
281/// sample data, without sending anything. A middleware: wrap the app in it
282/// **in development only** (it serves whatever the previews render).
283///
284/// | Path | Shows |
285/// |---|---|
286/// | `<prefix>` | the list of previews |
287/// | `<prefix>/<name>` | headers, attachments, and the HTML body in a frame (else the text) |
288/// | `<prefix>/<name>.html`, `<prefix>/<name>.txt` | the bodies alone |
289///
290/// ```rust
291/// use rust_web_server::mailer::{Email, MailPreviews};
292///
293/// let previews = MailPreviews::new("/_mail").add("welcome", || {
294///     Ok(Email::builder().to("ann@example.com").subject("Welcome").html("<h1>Hi Ann</h1>").build()?.into())
295/// });
296/// ```
297#[derive(Clone)]
298pub struct MailPreviews {
299    prefix: String,
300    previews: Arc<Vec<(String, Preview)>>,
301}
302
303impl MailPreviews {
304    pub fn new(prefix: &str) -> Self {
305        MailPreviews { prefix: format!("/{}", prefix.trim_matches('/')), previews: Arc::new(Vec::new()) }
306    }
307
308    /// Add the preview `name` (letters, digits, `-`, `_`), built by `build`
309    /// on every view.
310    pub fn add(mut self, name: &str, build: impl Fn() -> Result<Mail, MailerError> + Send + Sync + 'static) -> Self {
311        Arc::make_mut(&mut self.previews).push((name.to_string(), Arc::new(build)));
312        self
313    }
314
315    fn page(&self, path: &str) -> Option<crate::response::Response> {
316        use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
317        let rest = path.strip_prefix(&self.prefix)?;
318        if rest.is_empty() || rest == "/" {
319            let items: String = self
320                .previews
321                .iter()
322                .map(|(name, _)| format!("<li><a href=\"{0}/{1}\">{1}</a></li>", escape(&self.prefix), escape(name)))
323                .collect();
324            return Some(html(STATUS_CODE_REASON_PHRASE.n200_ok, &format!("<h1>Mail previews</h1><ul>{items}</ul>")));
325        }
326        let name = rest.strip_prefix('/')?;
327        let (name, part) = match name.rsplit_once('.') {
328            Some((n, p)) if p == "html" || p == "txt" => (n, Some(p)),
329            _ => (name, None),
330        };
331        let Some((_, build)) = self.previews.iter().find(|(n, _)| n == name) else {
332            return Some(Response::text(STATUS_CODE_REASON_PHRASE.n404_not_found, &format!("no mail preview {name:?}")));
333        };
334        let mail = match build() {
335            Ok(mail) => mail,
336            Err(e) => return Some(Response::text(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, &format!("the preview failed: {e}"))),
337        };
338        let e = &mail.email;
339        match part {
340            Some("html") => Some(match &e.html {
341                Some(body) => html(STATUS_CODE_REASON_PHRASE.n200_ok, body),
342                None => Response::text(STATUS_CODE_REASON_PHRASE.n404_not_found, "this email has no HTML body"),
343            }),
344            Some(_) => Some(match &e.text {
345                Some(body) => Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, body),
346                None => Response::text(STATUS_CODE_REASON_PHRASE.n404_not_found, "this email has no text body"),
347            }),
348            None => {
349                let mut page = format!("<p><a href=\"{}\">All previews</a></p><table>", escape(&self.prefix));
350                let mut row = |k: &str, v: &str| page.push_str(&format!("<tr><th>{k}</th><td>{}</td></tr>", escape(v)));
351                row("Subject", &e.subject);
352                row("To", &e.to.join(", "));
353                if !e.cc.is_empty() {
354                    row("Cc", &e.cc.join(", "));
355                }
356                if !e.bcc.is_empty() {
357                    row("Bcc", &e.bcc.join(", "));
358                }
359                if let Some(reply_to) = &e.reply_to {
360                    row("Reply-To", reply_to);
361                }
362                for a in &mail.attachments {
363                    row("Attachment", &format!("{} ({}, {} bytes)", a.filename, a.content_type, a.data.len()));
364                }
365                page.push_str("</table>");
366                if e.html.is_some() {
367                    // Sandboxed: the email's HTML can't script this page.
368                    page.push_str(&format!("<iframe sandbox src=\"{}/{}.html\" style=\"width:100%;height:70vh;border:1px solid #ccc\"></iframe>", escape(&self.prefix), escape(name)));
369                }
370                if let Some(text) = &e.text {
371                    page.push_str(&format!("<h2>Text</h2><pre>{}</pre>", escape(text)));
372                }
373                Some(html(STATUS_CODE_REASON_PHRASE.n200_ok, &page))
374            }
375        }
376    }
377}
378
379fn escape(text: &str) -> String {
380    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
381}
382
383fn html(status: &crate::response::StatusCodeReasonPhrase, body: &str) -> crate::response::Response {
384    let content = crate::range::Range::get_content_range(body.as_bytes().to_vec(), crate::mime_type::MimeType::TEXT_HTML.to_string());
385    crate::response::Response::get_response(status, None, Some(vec![content]))
386}
387
388impl crate::middleware::Middleware for MailPreviews {
389    fn handle(&self, request: &crate::request::Request, connection: &crate::server::ConnectionInfo, next: &dyn crate::application::Application) -> Result<crate::response::Response, String> {
390        if request.method == "GET" {
391            let path = request.request_uri.split('?').next().unwrap_or("");
392            if let Some(response) = self.page(path) {
393                return Ok(response);
394            }
395        }
396        next.execute(request, connection)
397    }
398
399    fn is_passthrough(&self, request: &crate::request::Request, _connection: &crate::server::ConnectionInfo) -> bool {
400        !request.request_uri.starts_with(&self.prefix)
401    }
402
403    fn accepts_streamed_body(&self, _request: &crate::request::Request, _connection: &crate::server::ConnectionInfo) -> bool {
404        true
405    }
406
407    fn layer_name(&self) -> &'static str {
408        "MailPreviews"
409    }
410}
411
412#[cfg(test)]
413mod tests;