e3cad77b2b 2026-10-28 1: //! Exercise the production mail handler with a recording transport. Including
e3cad77b2b 2026-10-28 2: //! the module here provides a test seam for its private Telegram dependency
e3cad77b2b 2026-10-28 3: //! without changing production visibility or making HTTP requests.
e3cad77b2b 2026-10-28 4: #[path = "../src/mail.rs"]
e3cad77b2b 2026-10-28 5: mod mail;
e3cad77b2b 2026-10-28 6: use smtp2tg::utils;
e3cad77b2b 2026-10-28 7: use std::io::Cursor;
e3cad77b2b 2026-10-28 8: use mailin_embedded::{Handler, response::{INTERNAL_ERROR, OK}};
e3cad77b2b 2026-10-28 9: use stacked_errors::{Result, StackableErr};
e3cad77b2b 2026-10-28 10: #[derive(Debug, PartialEq)]
e3cad77b2b 2026-10-28 11: enum Delivery {
e3cad77b2b 2026-10-28 12: Debug(String),
e3cad77b2b 2026-10-28 13: Text(String),
e3cad77b2b 2026-10-28 14: Files(Vec<(String, Vec<u8>)>, String),
e3cad77b2b 2026-10-28 15: }
e3cad77b2b 2026-10-28 16: mod telegram {
e3cad77b2b 2026-10-28 17: use super::{Delivery, utils::Attachment};
e3cad77b2b 2026-10-28 18: use std::{cell::RefCell, collections::HashMap};
e3cad77b2b 2026-10-28 19: use stacked_errors::{Result, StackableErr};
e3cad77b2b 2026-10-28 20: use tgbot::types::ChatPeerId;
e3cad77b2b 2026-10-28 21: thread_local! {
e3cad77b2b 2026-10-28 22: // Handler::data_end drives its future on the calling test thread.
e3cad77b2b 2026-10-28 23: static DELIVERIES: RefCell<Vec<Delivery>> = const { RefCell::new(Vec::new()) };
e3cad77b2b 2026-10-28 24: }
e3cad77b2b 2026-10-28 25: pub fn take_deliveries () -> Vec<Delivery> {
e3cad77b2b 2026-10-28 26: DELIVERIES.with(|items| std::mem::take(&mut *items.borrow_mut()))
e3cad77b2b 2026-10-28 27: }
e3cad77b2b 2026-10-28 28: #[derive(Debug)]
e3cad77b2b 2026-10-28 29: pub struct TelegramTransport {
e3cad77b2b 2026-10-28 30: pub default: ChatPeerId,
e3cad77b2b 2026-10-28 31: recipients: HashMap<String, ChatPeerId>,
e3cad77b2b 2026-10-28 32: }
e3cad77b2b 2026-10-28 33: impl TelegramTransport {
e3cad77b2b 2026-10-28 34: pub fn new (_api_key: String, recipients: HashMap<String, i64>, settings: &config::Config) -> Result<Self> {
e3cad77b2b 2026-10-28 35: Ok(Self {
e3cad77b2b 2026-10-28 36: default: settings.get_int("default").stack()?.into(),
e3cad77b2b 2026-10-28 37: recipients: recipients.into_iter().map(|(name, id)| (name, id.into())).collect(),
e3cad77b2b 2026-10-28 38: })
e3cad77b2b 2026-10-28 39: }
e3cad77b2b 2026-10-28 40: pub fn get (&self, name: &str) -> Result<&ChatPeerId> {
e3cad77b2b 2026-10-28 41: self.recipients.get(&name.to_lowercase()).context("Unknown test recipient")
e3cad77b2b 2026-10-28 42: }
e3cad77b2b 2026-10-28 43: pub async fn debug (&self, msg: &str) -> Result<()> {
e3cad77b2b 2026-10-28 44: DELIVERIES.with(|items| items.borrow_mut().push(Delivery::Debug(msg.into())));
e3cad77b2b 2026-10-28 45: Ok(())
e3cad77b2b 2026-10-28 46: }
e3cad77b2b 2026-10-28 47: pub async fn send (&self, _to: &ChatPeerId, msg: &str) -> Result<()> {
e3cad77b2b 2026-10-28 48: DELIVERIES.with(|items| items.borrow_mut().push(Delivery::Text(msg.into())));
e3cad77b2b 2026-10-28 49: Ok(())
e3cad77b2b 2026-10-28 50: }
e3cad77b2b 2026-10-28 51: pub async fn sendgroup (&self, _to: &ChatPeerId, media: Vec<Attachment>, msg: &str) -> Result<()> {
e3cad77b2b 2026-10-28 52: let files = media.into_iter().map(|file| (file.name, file.data.into_inner())).collect();
e3cad77b2b 2026-10-28 53: DELIVERIES.with(|items| items.borrow_mut().push(Delivery::Files(files, msg.into())));
e3cad77b2b 2026-10-28 54: Ok(())
e3cad77b2b 2026-10-28 55: }
e3cad77b2b 2026-10-28 56: }
e3cad77b2b 2026-10-28 57: }
e3cad77b2b 2026-10-28 58: fn server () -> Result<mail::MailServer> {
e3cad77b2b 2026-10-28 59: telegram::take_deliveries();
e3cad77b2b 2026-10-28 60: let settings = config::Config::builder()
e3cad77b2b 2026-10-28 61: .add_source(config::File::from_str(r#"
e3cad77b2b 2026-10-28 62: api_key = "test-api-key"
e3cad77b2b 2026-10-28 63: api_gateway = "https://api.telegram.org"
e3cad77b2b 2026-10-28 64: default = 0
e3cad77b2b 2026-10-28 65: fields = []
e3cad77b2b 2026-10-28 66: domains = ["example.com"]
e3cad77b2b 2026-10-28 67: [recipients]
e3cad77b2b 2026-10-28 68: "recipient@example.com" = 1
e3cad77b2b 2026-10-28 69: "#, config::FileFormat::Toml))
e3cad77b2b 2026-10-28 70: .build().stack()?;
e3cad77b2b 2026-10-28 71: mail::MailServer::new(settings)
e3cad77b2b 2026-10-28 72: }
e3cad77b2b 2026-10-28 73: fn relay (message: &str) -> Result<Vec<Delivery>> {
e3cad77b2b 2026-10-28 74: let mut server = server()?;
e3cad77b2b 2026-10-28 75: assert_eq!(server.data_start("example.com", "sender@example.com", true,
e3cad77b2b 2026-10-28 76: &["recipient@example.com".into()]), OK);
e3cad77b2b 2026-10-28 77: server.data(message.as_bytes()).stack()?;
e3cad77b2b 2026-10-28 78: assert_eq!(server.data_end(), OK);
e3cad77b2b 2026-10-28 79: Ok(telegram::take_deliveries())
e3cad77b2b 2026-10-28 80: }
e3cad77b2b 2026-10-28 81: fn caption (body: &str) -> String {
e3cad77b2b 2026-10-28 82: format!("<blockquote expandable>\n</blockquote><pre>{body}</pre>")
e3cad77b2b 2026-10-28 83: }
e3cad77b2b 2026-10-28 84: fn multipart (kind: &str, parts: &[&str]) -> String {
e3cad77b2b 2026-10-28 85: format!("MIME-Version: 1.0\r\nContent-Type: multipart/{kind}; boundary=parts\r\n\r\n--parts\r\n{}\r\n--parts--\r\n",
e3cad77b2b 2026-10-28 86: parts.join("\r\n--parts\r\n"))
e3cad77b2b 2026-10-28 87: }
e3cad77b2b 2026-10-28 88: const TEXT: &str = "Content-Type: text/plain; charset=utf-8\r\n\r\nPlain body";
e3cad77b2b 2026-10-28 89: const HTML: &str = "Content-Type: text/html; charset=utf-8\r\n\r\n<p>HTML body</p>";
e3cad77b2b 2026-10-28 90: #[test]
e3cad77b2b 2026-10-28 91: fn short_text_is_inlined_and_normalized () -> Result<()> {
e3cad77b2b 2026-10-28 92: let deliveries = relay("Content-Type: text/plain\r\n\r\nFirst & <second>\r\nThird")?;
e3cad77b2b 2026-10-28 93: assert_eq!(deliveries, vec![
e3cad77b2b 2026-10-28 94: Delivery::Text(caption("First & <second>\nThird")),
e3cad77b2b 2026-10-28 95: ]);
e3cad77b2b 2026-10-28 96: Ok(())
e3cad77b2b 2026-10-28 97: }
e3cad77b2b 2026-10-28 98: #[test]
e3cad77b2b 2026-10-28 99: fn alternative_html_is_not_sent_as_a_duplicate_attachment () -> Result<()> {
e3cad77b2b 2026-10-28 100: let deliveries = relay(&multipart("alternative", &[TEXT, HTML]))?;
e3cad77b2b 2026-10-28 101: assert_eq!(deliveries, vec![Delivery::Text(caption("Plain body"))]);
e3cad77b2b 2026-10-28 102: Ok(())
e3cad77b2b 2026-10-28 103: }
e3cad77b2b 2026-10-28 104: #[test]
e3cad77b2b 2026-10-28 105: fn html_only_message_uses_the_parsers_text_conversion () -> Result<()> {
e3cad77b2b 2026-10-28 106: assert_eq!(relay(HTML)?, vec![
e3cad77b2b 2026-10-28 107: Delivery::Text(caption("HTML body\n")),
e3cad77b2b 2026-10-28 108: ]);
e3cad77b2b 2026-10-28 109: Ok(())
e3cad77b2b 2026-10-28 110: }
e3cad77b2b 2026-10-28 111: #[test]
e3cad77b2b 2026-10-28 112: fn remaining_text_parts_are_sent_once_in_order () -> Result<()> {
e3cad77b2b 2026-10-28 113: let second = "Content-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nSecond=20body";
e3cad77b2b 2026-10-28 114: let third = "Content-Type: text/plain\r\n\r\nThird body";
e3cad77b2b 2026-10-28 115: assert_eq!(relay(&multipart("mixed", &[TEXT, second, third]))?, vec![
e3cad77b2b 2026-10-28 116: Delivery::Files(vec![
e3cad77b2b 2026-10-28 117: ("Attachment.txt".into(), b"Second body".to_vec()),
e3cad77b2b 2026-10-28 118: ("Attachment.txt".into(), b"Third body".to_vec()),
e3cad77b2b 2026-10-28 119: ], caption("Plain body")),
e3cad77b2b 2026-10-28 120: ]);
e3cad77b2b 2026-10-28 121: Ok(())
e3cad77b2b 2026-10-28 122: }
e3cad77b2b 2026-10-28 123: #[test]
e3cad77b2b 2026-10-28 124: fn text_at_the_message_limit_is_sent_as_a_decoded_attachment () -> Result<()> {
e3cad77b2b 2026-10-28 125: // The existing contract requires the complete escaped message to be <4096
e3cad77b2b 2026-10-28 126: // bytes. Exercise both sides so the first text iterator item cannot be lost.
e3cad77b2b 2026-10-28 127: let capacity = 4096 - caption("").len();
e3cad77b2b 2026-10-28 128: for size in [capacity - 1, capacity] {
e3cad77b2b 2026-10-28 129: let body = "x".repeat(size);
e3cad77b2b 2026-10-28 130: let message = format!("Content-Type: text/plain\r\n\r\n{body}");
e3cad77b2b 2026-10-28 131: let deliveries = relay(&message)?;
e3cad77b2b 2026-10-28 132: assert_eq!(deliveries.len(), 1);
e3cad77b2b 2026-10-28 133: let expected = if size < capacity {
e3cad77b2b 2026-10-28 134: Delivery::Text(caption(&body))
e3cad77b2b 2026-10-28 135: } else {
e3cad77b2b 2026-10-28 136: Delivery::Files(vec![("Attachment.txt".into(), body.into_bytes())], caption(""))
e3cad77b2b 2026-10-28 137: };
e3cad77b2b 2026-10-28 138: assert_eq!(deliveries[0], expected, "body size: {size}");
e3cad77b2b 2026-10-28 139: }
e3cad77b2b 2026-10-28 140: Ok(())
e3cad77b2b 2026-10-28 141: }
e3cad77b2b 2026-10-28 142: #[test]
e3cad77b2b 2026-10-28 143: fn attachment_payloads_are_transfer_decoded_and_keep_content_type_names () -> Result<()> {
e3cad77b2b 2026-10-28 144: let binary = "Content-Type: application/octet-stream; name=\"payload.bin\"\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: base64\r\n\r\nAAEC/w==";
e3cad77b2b 2026-10-28 145: let quoted = "Content-Type: application/octet-stream; name=\"notes.txt\"\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\ncaf=C3=A9=0Asecond=3Dline";
e3cad77b2b 2026-10-28 146: assert_eq!(relay(&multipart("mixed", &[TEXT, binary, quoted]))?, vec![
e3cad77b2b 2026-10-28 147: Delivery::Files(vec![
e3cad77b2b 2026-10-28 148: ("payload.bin".into(), vec![0, 1, 2, 255]),
e3cad77b2b 2026-10-28 149: ("notes.txt".into(), "café\nsecond=line".as_bytes().to_vec()),
e3cad77b2b 2026-10-28 150: ], caption("Plain body")),
e3cad77b2b 2026-10-28 151: ]);
e3cad77b2b 2026-10-28 152: Ok(())
e3cad77b2b 2026-10-28 153: }
e3cad77b2b 2026-10-28 154: #[test]
e3cad77b2b 2026-10-28 155: fn unnamed_attachment_uses_the_fallback_filename () -> Result<()> {
e3cad77b2b 2026-10-28 156: let attachment = "Content-Type: application/octet-stream\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: base64\r\n\r\naGVsbG8=";
e3cad77b2b 2026-10-28 157: assert_eq!(relay(&multipart("mixed", &[attachment]))?, vec![
e3cad77b2b 2026-10-28 158: Delivery::Files(vec![("Attachment.txt".into(), b"hello".to_vec())], caption("")),
e3cad77b2b 2026-10-28 159: ]);
e3cad77b2b 2026-10-28 160: Ok(())
e3cad77b2b 2026-10-28 161: }
e3cad77b2b 2026-10-28 162: #[test]
e3cad77b2b 2026-10-28 163: fn attachment_filename_is_preserved_for_case_insensitive_mime_headers () -> Result<()> {
e3cad77b2b 2026-10-28 164: // MIME field names are case insensitive. The old parser's Header::name()
e3cad77b2b 2026-10-28 165: // returned canonical names; raw_name() now preserves their original case.
e3cad77b2b 2026-10-28 166: for header in ["Content-Type", "content-type", "CONTENT-TYPE", "cOnTeNt-TyPe"] {
e3cad77b2b 2026-10-28 167: let attachment = format!("{header}: application/octet-stream; name=\"report.bin\"\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: base64\r\n\r\nAAEC/w==");
e3cad77b2b 2026-10-28 168: assert_eq!(relay(&multipart("mixed", &[&attachment]))?, vec![
e3cad77b2b 2026-10-28 169: Delivery::Files(vec![("report.bin".into(), vec![0, 1, 2, 255])], caption("")),
e3cad77b2b 2026-10-28 170: ], "MIME header: {header}");
e3cad77b2b 2026-10-28 171: }
e3cad77b2b 2026-10-28 172: Ok(())
e3cad77b2b 2026-10-28 173: }
e3cad77b2b 2026-10-28 174: #[test]
e3cad77b2b 2026-10-28 175: fn nested_alternatives_and_files_use_separate_iterators () -> Result<()> {
e3cad77b2b 2026-10-28 176: let alternative = multipart("alternative", &[TEXT, HTML]).replace("parts", "inner");
e3cad77b2b 2026-10-28 177: let extra = "Content-Type: text/plain\r\n\r\nExtra body";
e3cad77b2b 2026-10-28 178: let attachment = "Content-Type: application/octet-stream; name=\"file.bin\"\r\nContent-Disposition: attachment\r\nContent-Transfer-Encoding: base64\r\n\r\nAAEC/w==";
e3cad77b2b 2026-10-28 179: assert_eq!(relay(&multipart("mixed", &[&alternative, extra, attachment]))?, vec![
e3cad77b2b 2026-10-28 180: Delivery::Files(vec![
e3cad77b2b 2026-10-28 181: ("Attachment.txt".into(), b"Extra body".to_vec()),
e3cad77b2b 2026-10-28 182: ("file.bin".into(), vec![0, 1, 2, 255]),
e3cad77b2b 2026-10-28 183: ], caption("Plain body")),
e3cad77b2b 2026-10-28 184: ]);
e3cad77b2b 2026-10-28 185: Ok(())
e3cad77b2b 2026-10-28 186: }
e3cad77b2b 2026-10-28 187: #[test]
e3cad77b2b 2026-10-28 188: fn empty_text_body_does_not_create_a_spurious_attachment () -> Result<()> {
e3cad77b2b 2026-10-28 189: assert_eq!(relay("Content-Type: text/plain\r\n\r\n")?, vec![
e3cad77b2b 2026-10-28 190: Delivery::Text(caption("")),
e3cad77b2b 2026-10-28 191: ]);
e3cad77b2b 2026-10-28 192: Ok(())
e3cad77b2b 2026-10-28 193: }
e3cad77b2b 2026-10-28 194: #[test]
e3cad77b2b 2026-10-28 195: fn forbidden_closing_tag_stops_delivery () -> Result<()> {
e3cad77b2b 2026-10-28 196: let mut server = server()?;
e3cad77b2b 2026-10-28 197: server.data_start("example.com", "sender@example.com", false,
e3cad77b2b 2026-10-28 198: &["recipient@example.com".into()]);
e3cad77b2b 2026-10-28 199: server.data(b"Content-Type: text/plain\r\n\r\nUnsafe </pre> body").stack()?;
e3cad77b2b 2026-10-28 200: assert_eq!(server.data_end(), INTERNAL_ERROR);
e3cad77b2b 2026-10-28 201: let deliveries = telegram::take_deliveries();
e3cad77b2b 2026-10-28 202: assert_eq!(deliveries.len(), 1);
e3cad77b2b 2026-10-28 203: assert!(matches!(&deliveries[0], Delivery::Debug(message)
e3cad77b2b 2026-10-28 204: if message.starts_with("Sending emails failed:")));
e3cad77b2b 2026-10-28 205: Ok(())
e3cad77b2b 2026-10-28 206: }