1use std::collections::VecDeque;
20use std::sync::{Arc, Mutex};
21
22use anyhow::Context;
23use axum::Router;
24use axum::extract::{Path, State};
25use axum::response::Html;
26use axum::routing::get;
27use lettre::message::header::ContentType;
28use lettre::message::{Mailbox, MultiPart};
29use lettre::transport::smtp::authentication::Credentials;
30use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
31use serde::{Deserialize, Serialize};
32
33use crate::db::{DateTime, now};
34use crate::queue::{Job, JobContext};
35use crate::{AppState, Config, Error, Result, context};
36
37const OUTBOX: usize = 50;
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57#[non_exhaustive]
58pub struct Mail {
59 pub to: Vec<String>,
61 pub subject: String,
63 pub text: String,
65 #[serde(default)]
67 pub html: Option<String>,
68 #[serde(default)]
70 pub cc: Vec<String>,
71 #[serde(default)]
73 pub bcc: Vec<String>,
74 #[serde(default)]
76 pub reply_to: Option<String>,
77 #[serde(default)]
79 pub from: Option<String>,
80 #[serde(default)]
82 pub attachments: Vec<Attachment>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
87#[non_exhaustive]
88pub struct Attachment {
89 pub filename: String,
91 pub content_type: String,
93 #[serde(with = "base64_bytes")]
95 pub data: Vec<u8>,
96}
97
98mod base64_bytes {
99 use base64::Engine;
100 use base64::engine::general_purpose::STANDARD;
101 use serde::{Deserialize, Deserializer, Serializer};
102
103 pub fn serialize<S: Serializer>(data: &[u8], s: S) -> Result<S::Ok, S::Error> {
104 s.serialize_str(&STANDARD.encode(data))
105 }
106
107 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
108 let text = String::deserialize(d)?;
109 STANDARD.decode(text).map_err(serde::de::Error::custom)
110 }
111}
112
113impl Mail {
114 pub fn new(to: impl Into<String>, subject: impl Into<String>, text: impl Into<String>) -> Self {
116 Self {
117 to: vec![to.into()],
118 subject: subject.into(),
119 text: text.into(),
120 html: None,
121 cc: Vec::new(),
122 bcc: Vec::new(),
123 reply_to: None,
124 from: None,
125 attachments: Vec::new(),
126 }
127 }
128
129 pub fn html(mut self, html: impl Into<String>) -> Self {
131 self.html = Some(html.into());
132 self
133 }
134
135 pub fn also_to(mut self, address: impl Into<String>) -> Self {
137 self.to.push(address.into());
138 self
139 }
140
141 pub fn cc(mut self, address: impl Into<String>) -> Self {
143 self.cc.push(address.into());
144 self
145 }
146
147 pub fn bcc(mut self, address: impl Into<String>) -> Self {
149 self.bcc.push(address.into());
150 self
151 }
152
153 pub fn reply_to(mut self, address: impl Into<String>) -> Self {
155 self.reply_to = Some(address.into());
156 self
157 }
158
159 pub fn from(mut self, address: impl Into<String>) -> Self {
161 self.from = Some(address.into());
162 self
163 }
164
165 pub fn attach(
167 mut self,
168 filename: impl Into<String>,
169 content_type: impl Into<String>,
170 data: impl Into<Vec<u8>>,
171 ) -> Self {
172 self.attachments.push(Attachment {
173 filename: filename.into(),
174 content_type: content_type.into(),
175 data: data.into(),
176 });
177 self
178 }
179
180 pub fn is_for(&self, address: &str) -> bool {
182 self.to
183 .iter()
184 .chain(&self.cc)
185 .chain(&self.bcc)
186 .any(|a| a == address || a.ends_with(&format!("<{address}>")))
187 }
188}
189
190setting_enum! {
191 pub enum MailDriver ("MAIL_MAILER") {
193 Smtp = "smtp",
195 Log = "log",
197 Memory = "memory",
199 }
200}
201
202setting_enum! {
203 pub enum MailEncryption ("MAIL_ENCRYPTION") {
205 Tls = "tls",
207 StartTls = "starttls",
209 None = "none",
211 }
212}
213
214#[derive(Clone)]
216#[non_exhaustive]
217pub struct MailConfig {
218 pub mailer: MailDriver,
220 pub host: String,
222 pub port: Option<u16>,
224 pub username: Option<String>,
226 pub password: Option<String>,
228 pub encryption: MailEncryption,
230 pub from_address: String,
232 pub from_name: Option<String>,
234 pub timeout: std::time::Duration,
237 pub failover: Vec<String>,
241}
242
243impl std::fmt::Debug for MailConfig {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 f.debug_struct("MailConfig")
247 .field("mailer", &self.mailer)
248 .field("host", &self.host)
249 .field("port", &self.port)
250 .field("username", &self.username)
251 .field("password", &self.password.as_ref().map(|_| "[hidden]"))
252 .field("encryption", &self.encryption)
253 .field("from_address", &self.from_address)
254 .field("from_name", &self.from_name)
255 .field("timeout", &self.timeout)
256 .field("failover", &self.failover)
257 .finish()
258 }
259}
260
261impl Default for MailConfig {
262 fn default() -> Self {
263 Self {
264 mailer: MailDriver::Log,
265 host: "localhost".into(),
266 port: None,
267 username: None,
268 password: None,
269 encryption: MailEncryption::StartTls,
270 from_address: "hello@example.com".into(),
271 timeout: std::time::Duration::from_secs(10),
272 from_name: None,
273 failover: Vec::new(),
274 }
275 }
276}
277
278impl MailConfig {
279 pub fn from_env(config: &Config, prefix: &str) -> crate::Result<Self> {
289 let prefix = prefix.trim_end_matches('_').to_ascii_uppercase();
290 let name = |name: &str| format!("{prefix}_{name}");
291 let var = |key: &str| config.var(&name(key));
292 let number = |key: &str| -> anyhow::Result<Option<u64>> {
293 var(key)
294 .map(|value| {
295 value.trim().parse::<u64>().map_err(|_| {
296 anyhow::anyhow!("{} must be a number, got `{value}`", name(key))
297 })
298 })
299 .transpose()
300 };
301 let defaults = Self::default();
302 let port = number("PORT")?
303 .map(|p| {
304 u16::try_from(p).map_err(|_| anyhow::anyhow!("{} is not a port", name("PORT")))
305 })
306 .transpose()?;
307 Ok(Self {
308 mailer: var("MAILER")
309 .map(|v| MailDriver::parse_as(&v, &name("MAILER")))
310 .transpose()?
311 .unwrap_or(config.mail.mailer),
312 host: var("HOST").unwrap_or(defaults.host),
313 port,
314 username: var("USERNAME"),
315 password: var("PASSWORD"),
316 encryption: var("ENCRYPTION")
317 .map(|v| MailEncryption::parse_as(&v, &name("ENCRYPTION")))
318 .transpose()?
319 .unwrap_or(defaults.encryption),
320 from_address: var("FROM_ADDRESS").unwrap_or_else(|| config.mail.from_address.clone()),
321 from_name: var("FROM_NAME").or_else(|| config.mail.from_name.clone()),
322 timeout: number("TIMEOUT")?.map_or(defaults.timeout, std::time::Duration::from_secs),
323 failover: Vec::new(),
324 })
325 }
326}
327
328#[derive(Clone)]
329enum Driver {
330 Log,
331 Memory,
332 Smtp {
333 transport: AsyncSmtpTransport<Tokio1Executor>,
334 from: Mailbox,
335 timeout: std::time::Duration,
336 },
337}
338
339#[derive(Debug, Clone, Serialize)]
340struct Sent {
341 id: u64,
342 at: DateTime,
343 mail: Mail,
344}
345
346#[derive(Clone)]
349pub struct Mailer {
350 driver: Driver,
351 failover: Vec<(String, Driver)>,
353 outbox: Arc<Mutex<(u64, VecDeque<Sent>)>>,
354 keep: Option<usize>,
356}
357
358impl Mailer {
359 pub(crate) fn from_config(config: &Config) -> anyhow::Result<Self> {
360 Self::open(&config.mail, config)
361 }
362
363 pub(crate) fn open(mail: &MailConfig, config: &Config) -> anyhow::Result<Self> {
365 let (driver, keep) = match mail.mailer {
366 MailDriver::Log => (Driver::Log, config.debug.then_some(OUTBOX)),
367 MailDriver::Memory => (Driver::Memory, Some(usize::MAX)),
368 MailDriver::Smtp => (smtp(mail, &config.name)?, config.debug.then_some(OUTBOX)),
369 };
370 Ok(Self {
371 driver,
372 failover: Vec::new(),
373 outbox: Arc::default(),
374 keep,
375 })
376 }
377
378 pub(crate) fn with_failover(mut self, others: Vec<(String, Mailer)>) -> Self {
380 self.failover = others
381 .into_iter()
382 .map(|(name, mailer)| (name, mailer.driver))
383 .collect();
384 self
385 }
386
387 pub async fn send(&self, mail: Mail) -> Result {
392 if let Err(mut last) = deliver(&self.driver, &mail).await {
393 if last.is_permanent() || self.failover.is_empty() {
394 return Err(last);
395 }
396 let mut sent = false;
397 for (name, driver) in &self.failover {
398 tracing::warn!(error = ?last, mailer = %name, "sending mail failed; trying the next mailer");
399 match deliver(driver, &mail).await {
400 Ok(()) => {
401 sent = true;
402 break;
403 }
404 Err(err) => last = err,
405 }
406 }
407 if !sent {
408 return Err(last);
409 }
410 }
411 self.remember(mail);
412 Ok(())
413 }
414
415 fn remember(&self, mail: Mail) {
416 let Some(keep) = self.keep else { return };
417 let mut outbox = self.outbox.lock().unwrap_or_else(|e| e.into_inner());
418 outbox.0 += 1;
419 let id = outbox.0;
420 outbox.1.push_back(Sent {
421 id,
422 at: now(),
423 mail,
424 });
425 while outbox.1.len() > keep {
426 outbox.1.pop_front();
427 }
428 }
429
430 pub fn sent(&self) -> Vec<Mail> {
433 let outbox = self.outbox.lock().unwrap_or_else(|e| e.into_inner());
434 outbox.1.iter().map(|s| s.mail.clone()).collect()
435 }
436
437 fn kept(&self) -> Vec<Sent> {
438 self.outbox
439 .lock()
440 .unwrap_or_else(|e| e.into_inner())
441 .1
442 .iter()
443 .cloned()
444 .collect()
445 }
446}
447
448async fn deliver(driver: &Driver, mail: &Mail) -> Result {
450 match driver {
451 Driver::Log => tracing::info!(
452 "mail (log driver)\nTo: {}\nSubject: {}\n\n{}\n",
453 mail.to.join(", "),
454 mail.subject,
455 mail.text
456 ),
457 Driver::Memory => {}
458 Driver::Smtp {
459 transport,
460 from,
461 timeout,
462 } => {
463 let message = message(from, mail)?;
464 tokio::time::timeout(*timeout, transport.send(message))
467 .await
468 .map_err(|_| anyhow::anyhow!("no answer from the SMTP server in {timeout:?}"))
469 .and_then(|sent| sent.map_err(anyhow::Error::from))
470 .with_context(|| format!("sending mail to {}", mail.to.join(", ")))?;
471 }
472 }
473 Ok(())
474}
475
476fn smtp(mail: &MailConfig, app_name: &str) -> anyhow::Result<Driver> {
477 let mut builder = match mail.encryption {
478 MailEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&mail.host)?,
479 MailEncryption::StartTls => {
480 AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&mail.host)?
481 }
482 MailEncryption::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&mail.host),
483 };
484 if let Some(port) = mail.port {
485 builder = builder.port(port);
486 }
487 builder = builder.timeout(Some(mail.timeout));
488 if let (Some(user), Some(password)) = (&mail.username, &mail.password) {
489 builder = builder.credentials(Credentials::new(user.clone(), password.clone()));
490 }
491 let name = mail
492 .from_name
493 .clone()
494 .unwrap_or_else(|| app_name.to_owned());
495 let address = mail.from_address.parse().with_context(|| {
496 format!(
497 "MAIL_FROM_ADDRESS `{}` is not an email address",
498 mail.from_address
499 )
500 })?;
501 Ok(Driver::Smtp {
502 transport: builder.build(),
503 from: Mailbox::new(Some(name), address),
504 timeout: mail.timeout,
505 })
506}
507
508fn mailbox(address: &str) -> Result<Mailbox> {
509 address.trim().parse().map_err(|err| {
510 Error::permanent(
511 anyhow::Error::new(err).context(format!("`{address}` is not an email address")),
512 )
513 })
514}
515
516fn message(from: &Mailbox, mail: &Mail) -> Result<Message> {
517 use lettre::message::{Attachment as Part, SinglePart};
518
519 if mail.to.is_empty() {
520 return Err(Error::permanent(anyhow::anyhow!(
521 "the mail has no recipient"
522 )));
523 }
524 let from = match &mail.from {
525 Some(address) => mailbox(address)?,
526 None => from.clone(),
527 };
528 let mut builder = Message::builder().from(from).subject(mail.subject.clone());
529 for address in &mail.to {
530 builder = builder.to(mailbox(address)?);
531 }
532 for address in &mail.cc {
533 builder = builder.cc(mailbox(address)?);
534 }
535 for address in &mail.bcc {
536 builder = builder.bcc(mailbox(address)?);
537 }
538 if let Some(address) = &mail.reply_to {
539 builder = builder.reply_to(mailbox(address)?);
540 }
541 let body = match &mail.html {
542 Some(html) => MultiPart::alternative_plain_html(mail.text.clone(), html.clone()),
543 None => MultiPart::mixed().singlepart(
544 SinglePart::builder()
545 .header(ContentType::TEXT_PLAIN)
546 .body(mail.text.clone()),
547 ),
548 };
549 let message = if mail.attachments.is_empty() {
550 match &mail.html {
551 Some(_) => builder.multipart(body),
552 None => builder
553 .header(ContentType::TEXT_PLAIN)
554 .body(mail.text.clone()),
555 }
556 } else {
557 let mut mixed = MultiPart::mixed().multipart(body);
558 for file in &mail.attachments {
559 let content_type = ContentType::parse(&file.content_type).map_err(|err| {
560 Error::permanent(anyhow::anyhow!(
561 "attachment `{}`: `{}` is not a content type ({err})",
562 file.filename,
563 file.content_type
564 ))
565 })?;
566 mixed = mixed
567 .singlepart(Part::new(file.filename.clone()).body(file.data.clone(), content_type));
568 }
569 builder.multipart(mixed)
570 };
571 Ok(message.map_err(anyhow::Error::from)?)
572}
573
574#[derive(Serialize, Deserialize)]
576pub(crate) struct SendMail(pub Mail);
577
578impl Job for SendMail {
579 const NAME: &'static str = "renox.send-mail";
580 const MAX_ATTEMPTS: u32 = 5;
581
582 async fn handle(self, ctx: JobContext) -> Result {
583 ctx.state.mailer.send(self.0).await
584 }
585}
586
587#[derive(Serialize, Deserialize)]
590pub(crate) struct SendMailVia {
591 pub mailer: String,
592 pub mail: Mail,
593}
594
595impl Job for SendMailVia {
596 const NAME: &'static str = "renox.send-mail-via";
597 const MAX_ATTEMPTS: u32 = 5;
598
599 async fn handle(self, ctx: JobContext) -> Result {
600 let mailer = ctx
602 .state
603 .mailer_named(&self.mailer)
604 .map_err(|err| Error::permanent(anyhow::anyhow!("{err:?}")))?;
605 mailer.send(self.mail).await
606 }
607}
608
609impl AppState {
610 pub fn mail_view(
617 &self,
618 to: impl Into<String>,
619 subject: impl Into<String>,
620 view: &str,
621 ctx: impl Serialize,
622 ) -> Result<Mail> {
623 let locale = crate::i18n::current_locale(self);
624 let ctx = minijinja::value::merge_maps([
625 minijinja::Value::from_serialize(&ctx),
626 context! {
627 app => context! { name => self.config.name, url => self.config.url, locale => locale },
628 t => crate::view::translate_function(self, &locale),
629 },
630 ]);
631 let html = self.views.render(&format!("{view}.html"), &ctx)?;
632 let text = match self.views.render(&format!("{view}.txt"), &ctx) {
633 Ok(text) => text,
634 Err(err) if is_not_found(&err) => html_to_text(&html),
635 Err(err) => return Err(err.into()),
636 };
637 Ok(Mail::new(to, subject, text.trim().to_owned()).html(html))
638 }
639
640 pub fn mail_view_in(
642 &self,
643 locale: &str,
644 to: impl Into<String>,
645 subject: impl Into<String>,
646 view: &str,
647 ctx: impl Serialize,
648 ) -> Result<Mail> {
649 crate::i18n::with_locale(Some(locale), || self.mail_view(to, subject, view, ctx))
650 }
651
652 pub async fn queue_mail(&self, mail: Mail) -> Result<i64> {
654 self.dispatch(SendMail(mail)).await
655 }
656
657 pub fn mailer_named(&self, name: &str) -> Result<&Mailer> {
661 self.mailers.get(name).ok_or_else(|| {
662 anyhow::anyhow!("no mailer named `{name}`: add it with `App::mailer(\"{name}\", …)`")
663 .into()
664 })
665 }
666
667 pub async fn queue_mail_via(&self, mailer: &str, mail: Mail) -> Result<i64> {
669 self.mailer_named(mailer)?;
670 self.dispatch(SendMailVia {
671 mailer: mailer.to_owned(),
672 mail,
673 })
674 .await
675 }
676}
677
678fn is_not_found(err: &anyhow::Error) -> bool {
679 err.downcast_ref::<minijinja::Error>()
680 .is_some_and(|e| e.kind() == minijinja::ErrorKind::TemplateNotFound)
681}
682
683pub(crate) fn html_to_text(html: &str) -> String {
686 let body = html
687 .find("<body")
688 .and_then(|start| html[start..].find('>').map(|end| start + end + 1))
689 .map_or(html, |start| &html[start..]);
690 let mut out = String::new();
691 let mut href: Option<String> = None;
692 let mut rest = body;
693 while let Some(open) = rest.find('<') {
694 out.push_str(&rest[..open]);
695 let Some(close) = rest[open..].find('>') else {
696 rest = &rest[open..];
698 break;
699 };
700 let tag = &rest[open + 1..open + close];
701 let name = tag
702 .trim_start_matches('/')
703 .split(|c: char| c.is_whitespace() || c == '/')
704 .next()
705 .unwrap_or_default()
706 .to_ascii_lowercase();
707 match name.as_str() {
708 "a" if !tag.starts_with('/') => {
709 href = tag
710 .split("href=\"")
711 .nth(1)
712 .and_then(|v| v.split('"').next())
713 .map(str::to_owned);
714 }
715 "a" => {
716 if let Some(url) = href.take() {
717 out.push_str(&format!(" ({url})"));
718 }
719 }
720 "style" | "head" | "title" if !tag.starts_with('/') => {
721 let end = format!("</{name}");
722 if let Some(skip) = rest[open..].to_ascii_lowercase().find(&end) {
723 rest = &rest[open + skip..];
724 continue;
725 }
726 }
727 "br" | "p" | "div" | "tr" | "li" | "h1" | "h2" | "h3" | "table" => out.push('\n'),
728 "td" | "th" => out.push(' '),
729 _ => {}
730 }
731 rest = &rest[open + close + 1..];
732 }
733 out.push_str(rest);
734 let decoded = out
735 .replace(" ", " ")
736 .replace("<", "<")
737 .replace(">", ">")
738 .replace(""", "\"")
739 .replace("'", "'")
740 .replace("'", "'")
741 .replace("/", "/")
742 .replace("&", "&");
743 let mut text = String::new();
744 let mut blank = false;
745 for line in decoded
746 .lines()
747 .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
748 {
749 if line.is_empty() {
750 blank = !text.is_empty();
751 continue;
752 }
753 if blank {
754 text.push('\n');
755 blank = false;
756 }
757 text.push_str(&line);
758 text.push('\n');
759 }
760 text
761}
762
763pub(crate) fn preview_router() -> Router<AppState> {
765 Router::new()
766 .route("/_renox/mail", get(list))
767 .route("/_renox/mail/{id}", get(show))
768}
769
770fn escape(s: &str) -> String {
771 s.replace('&', "&")
772 .replace('<', "<")
773 .replace('>', ">")
774 .replace('"', """)
775}
776
777const STYLE: &str = "<style>body{font-family:system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem;color:#222}\
778table{width:100%;border-collapse:collapse}td{padding:.5rem;border-bottom:1px solid #eee}\
779iframe{width:100%;height:32rem;border:1px solid #ddd;border-radius:.5rem}pre{white-space:pre-wrap;background:#f6f6f6;padding:1rem}</style>";
780
781async fn list(State(state): State<AppState>) -> Html<String> {
782 let rows: String = state
783 .mailer
784 .kept()
785 .iter()
786 .rev()
787 .map(|s| {
788 format!(
789 "<tr><td>{}</td><td><a href=\"/_renox/mail/{}\">{}</a></td><td>{}</td></tr>",
790 s.at.format("%H:%M:%S"),
791 s.id,
792 escape(&s.mail.subject),
793 escape(&s.mail.to.join(", "))
794 )
795 })
796 .collect();
797 let rows = if rows.is_empty() {
798 "<tr><td>No mail sent yet.</td></tr>".to_owned()
799 } else {
800 rows
801 };
802 Html(format!(
803 "<!doctype html><meta charset=utf-8><title>Mail · Renox</title>{STYLE}<h1>Sent mail</h1><table>{rows}</table>"
804 ))
805}
806
807async fn show(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Html<String>> {
808 let sent = state
809 .mailer
810 .kept()
811 .into_iter()
812 .find(|s| s.id == id)
813 .ok_or(Error::NotFound)?;
814 let html = sent.mail.html.as_deref().map_or(String::new(), |html| {
815 format!(
816 "<h2>HTML</h2><iframe sandbox srcdoc=\"{}\"></iframe>",
817 escape(html)
818 )
819 });
820 let mut details = format!("<p>To: {}</p>", escape(&sent.mail.to.join(", ")));
821 for (label, list) in [("Cc", &sent.mail.cc), ("Bcc", &sent.mail.bcc)] {
822 if !list.is_empty() {
823 details.push_str(&format!("<p>{label}: {}</p>", escape(&list.join(", "))));
824 }
825 }
826 for (label, value) in [("Reply-To", &sent.mail.reply_to), ("From", &sent.mail.from)] {
827 if let Some(value) = value {
828 details.push_str(&format!("<p>{label}: {}</p>", escape(value)));
829 }
830 }
831 if !sent.mail.attachments.is_empty() {
832 let files: Vec<String> = sent
833 .mail
834 .attachments
835 .iter()
836 .map(|a| {
837 format!(
838 "{} ({}, {} bytes)",
839 escape(&a.filename),
840 escape(&a.content_type),
841 a.data.len()
842 )
843 })
844 .collect();
845 details.push_str(&format!("<p>Attachments: {}</p>", files.join(", ")));
846 }
847 Ok(Html(format!(
848 "<!doctype html><meta charset=utf-8><title>{subject} · Renox</title>{STYLE}\
849 <p><a href=\"/_renox/mail\">← All mail</a></p><h1>{subject}</h1>{details}{html}\
850 <h2>Text</h2><pre>{text}</pre>",
851 subject = escape(&sent.mail.subject),
852 text = escape(&sent.mail.text),
853 )))
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 #[test]
861 fn html_becomes_readable_text() {
862 let html = r#"<html><head><style>p{color:red}</style></head><body>
863 <h1>Hello & welcome</h1><p>Click <a href="https://x.id/a?b=1&c=2">here</a>.</p>
864 <table><tr><td>Total</td><td>$10.00</td></tr></table></body></html>"#;
865 assert_eq!(
866 html_to_text(html),
867 "Hello & welcome\n\nClick here (https://x.id/a?b=1&c=2).\n\nTotal $10.00\n"
868 );
869 assert_eq!(html_to_text("<style>p{}</style>Hi<p>there"), "Hi\nthere\n");
872 assert_eq!(html_to_text("<title>No end"), "No end\n");
873 assert_eq!(html_to_text("Price < 5"), "Price < 5\n");
875 assert_eq!(html_to_text("<p>a</p>b <c"), "a\nb <c\n");
876 }
877
878 #[tokio::test]
881 async fn the_log_driver_keeps_the_last_fifty_while_debugging() {
882 let mut config = Config {
883 debug: true,
884 ..Config::default()
885 };
886 let mail = MailConfig::default();
887 let mailer = Mailer::open(&mail, &config).unwrap();
888 for i in 0..OUTBOX + 1 {
889 mailer
890 .send(Mail::new("ann@example.com", format!("Mail {i}"), "Hi"))
891 .await
892 .unwrap();
893 }
894 let sent = mailer.sent();
895 assert_eq!(sent.len(), OUTBOX);
896 assert_eq!(sent[0].subject, "Mail 1", "the oldest is gone");
897
898 config.debug = false;
899 let quiet = Mailer::open(&mail, &config).unwrap();
900 quiet
901 .send(Mail::new("ann@example.com", "Hi", "Hi"))
902 .await
903 .unwrap();
904 assert!(quiet.sent().is_empty());
905 }
906
907 #[test]
910 fn mails_smtp_cant_send_are_permanent_errors() {
911 let from: Mailbox = "shop@example.com".parse().unwrap();
912 let mut nobody = Mail::new("ann@example.com", "Hi", "Hi");
913 nobody.to.clear();
914 let err = message(&from, &nobody).unwrap_err();
915 assert!(err.is_permanent());
916 assert!(format!("{err:?}").contains("the mail has no recipient"));
917
918 let odd = Mail::new("ann@example.com", "Hi", "Hi").attach("a.bin", "not a type", vec![1]);
919 let err = message(&from, &odd).unwrap_err();
920 assert!(err.is_permanent());
921 assert!(
922 format!("{err:?}").contains("is not a content type"),
923 "{err:?}"
924 );
925 }
926
927 #[tokio::test]
928 async fn the_outbox_page_says_when_nothing_was_sent() {
929 let app = crate::testing::TestApp::new(crate::App::new()).await;
930 app.get("/_renox/mail")
931 .await
932 .assert_ok()
933 .assert_see("No mail sent yet.");
934 }
935}