1use std::sync::{Arc, Mutex};
5
6use super::{Email, Mailer, MailerError};
7#[cfg(feature = "tera")]
8use super::EmailBuilder;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct MailAttachment {
13 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#[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 pub fn attach(mut self, attachment: MailAttachment) -> Self {
54 self.attachments.push(attachment);
55 self
56 }
57
58 #[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 #[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 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 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
162pub 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#[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 pub fn sent(&self) -> Vec<Mail> {
197 self.sent.lock().unwrap_or_else(|e| e.into_inner()).clone()
198 }
199
200 pub fn last(&self) -> Option<Mail> {
202 self.sent.lock().unwrap_or_else(|e| e.into_inner()).last().cloned()
203 }
204
205 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
230pub const MAIL_JOB_TYPE: &str = "rws.mail";
233
234#[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#[cfg(feature = "tera")]
247impl EmailBuilder {
248 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 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
276type Preview = Arc<dyn Fn() -> Result<Mail, MailerError> + Send + Sync>;
279
280#[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 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 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
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;