Skip to main content

minco_plugin_notifications/
mail.rs

1use crate::{Notification, NotificationChannel, NotificationError, NotificationSink};
2use async_trait::async_trait;
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::{
8    collections::{BTreeMap, BTreeSet, VecDeque},
9    fmt::{self, Write as _},
10    sync::Arc,
11    time::{Duration, Instant},
12};
13use tokio::{sync::RwLock, task::JoinSet, time::timeout};
14use uuid::Uuid;
15
16const MAX_RECIPIENTS: usize = 50;
17const MAX_BODY_BYTES: usize = 1_000_000;
18const MAX_ATTACHMENT_COUNT: usize = 32;
19const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
20const MAX_RENDERED_MESSAGE_BYTES: usize = 39_000_000;
21const MAX_HEADERS: usize = 15;
22const MAX_USER_TAGS: usize = 48;
23const MAX_METADATA_BYTES: usize = 64 * 1024;
24const MAX_PROVIDER_MESSAGE_ID_BYTES: usize = 512;
25const MAX_TRACING_DELIVERY_DEDUPE_IDS: usize = 4_096;
26const OBSERVER_TIMEOUT: Duration = Duration::from_millis(100);
27const OBSERVER_CHILD_TIMEOUT: Duration = Duration::from_millis(75);
28const MAX_OBSERVERS: usize = 16;
29const HEADER_SOFT_LINE_BYTES: usize = 78;
30const HEADER_HARD_LINE_BYTES: usize = 998;
31const ENCODED_WORD_INPUT_BYTES: usize = 45;
32const RESERVED_TAGS: [&str; 2] = ["minco_message_id", "minco_topic"];
33const RESERVED_HEADERS: [&str; 17] = [
34    "bcc",
35    "cc",
36    "content-transfer-encoding",
37    "content-type",
38    "date",
39    "dkim-signature",
40    "from",
41    "message-id",
42    "mime-version",
43    "received",
44    "reply-to",
45    "return-path",
46    "sender",
47    "subject",
48    "to",
49    "x-minco-message-id",
50    "x-minco-topic",
51];
52
53#[derive(Clone, PartialEq, Eq)]
54pub struct MailAddress {
55    pub address: String,
56    pub name: Option<String>,
57}
58
59impl MailAddress {
60    pub fn new(address: impl Into<String>) -> Result<Self, MailError> {
61        let address = Self {
62            address: address.into(),
63            name: None,
64        };
65        address.validate()?;
66        Ok(address)
67    }
68
69    pub fn named(address: impl Into<String>, name: impl Into<String>) -> Result<Self, MailError> {
70        let address = Self {
71            address: address.into(),
72            name: Some(name.into()),
73        };
74        address.validate()?;
75        Ok(address)
76    }
77
78    pub fn validate(&self) -> Result<(), MailError> {
79        validate_email_address(&self.address)?;
80        if self.name.as_deref().is_some_and(|name| {
81            name.trim().is_empty() || name.len() > 256 || name.chars().any(char::is_control)
82        }) {
83            return Err(MailError::invalid("mail display name is invalid"));
84        }
85        Ok(())
86    }
87
88    pub fn formatted(&self) -> String {
89        match &self.name {
90            None => self.address.clone(),
91            Some(name) if name.is_ascii() && name.len() <= 60 => {
92                let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
93                format!("\"{escaped}\" <{}>", self.address)
94            }
95            Some(name) => format!("{} <{}>", encode_header_words(name), self.address),
96        }
97    }
98
99    pub fn domain(&self) -> &str {
100        self.address
101            .rsplit_once('@')
102            .map_or("localhost", |(_, domain)| domain)
103    }
104
105    fn normalized_key(&self) -> String {
106        let (local, domain) = self
107            .address
108            .rsplit_once('@')
109            .expect("validated mail address contains @");
110        format!("{local}@{}", domain.to_ascii_lowercase())
111    }
112}
113
114impl fmt::Debug for MailAddress {
115    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116        formatter
117            .debug_struct("MailAddress")
118            .field("address", &"[REDACTED]")
119            .field("name", &self.name.as_ref().map(|_| "[REDACTED]"))
120            .finish()
121    }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum MailAttachmentDisposition {
126    Attachment,
127    Inline,
128}
129
130#[derive(Clone, PartialEq, Eq)]
131pub struct MailAttachment {
132    pub file_name: String,
133    pub content_type: String,
134    pub content: Vec<u8>,
135    pub disposition: MailAttachmentDisposition,
136    pub content_id: Option<String>,
137}
138
139impl MailAttachment {
140    pub fn attachment(
141        file_name: impl Into<String>,
142        content_type: impl Into<String>,
143        content: impl Into<Vec<u8>>,
144    ) -> Result<Self, MailError> {
145        let attachment = Self {
146            file_name: file_name.into(),
147            content_type: content_type.into(),
148            content: content.into(),
149            disposition: MailAttachmentDisposition::Attachment,
150            content_id: None,
151        };
152        attachment.validate()?;
153        Ok(attachment)
154    }
155
156    pub fn inline(
157        file_name: impl Into<String>,
158        content_type: impl Into<String>,
159        content: impl Into<Vec<u8>>,
160        content_id: impl Into<String>,
161    ) -> Result<Self, MailError> {
162        let attachment = Self {
163            file_name: file_name.into(),
164            content_type: content_type.into(),
165            content: content.into(),
166            disposition: MailAttachmentDisposition::Inline,
167            content_id: Some(content_id.into()),
168        };
169        attachment.validate()?;
170        Ok(attachment)
171    }
172
173    fn validate(&self) -> Result<(), MailError> {
174        if self.file_name.trim().is_empty()
175            || self.file_name.len() > 255
176            || self
177                .file_name
178                .chars()
179                .any(|character| character.is_control() || matches!(character, '/' | '\\'))
180        {
181            return Err(MailError::invalid("mail attachment file name is invalid"));
182        }
183        if !valid_content_type(&self.content_type) {
184            return Err(MailError::invalid(
185                "mail attachment content type is invalid",
186            ));
187        }
188        if self.content.is_empty() {
189            return Err(MailError::invalid("mail attachment must not be empty"));
190        }
191        match (self.disposition, self.content_id.as_deref()) {
192            (MailAttachmentDisposition::Attachment, None) => {}
193            (MailAttachmentDisposition::Inline, Some(content_id))
194                if valid_content_id(content_id) => {}
195            _ => {
196                return Err(MailError::invalid(
197                    "inline mail attachments require a valid content ID",
198                ));
199            }
200        }
201        Ok(())
202    }
203}
204
205impl fmt::Debug for MailAttachment {
206    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207        formatter
208            .debug_struct("MailAttachment")
209            .field("file_name", &"[REDACTED]")
210            .field("content_type", &self.content_type)
211            .field("size_bytes", &self.content.len())
212            .field("disposition", &self.disposition)
213            .field(
214                "content_id",
215                &self.content_id.as_ref().map(|_| "[REDACTED]"),
216            )
217            .finish()
218    }
219}
220
221#[derive(Clone, PartialEq, Eq)]
222pub struct MailMessage {
223    pub id: Uuid,
224    pub topic: String,
225    pub to: Vec<MailAddress>,
226    pub cc: Vec<MailAddress>,
227    pub bcc: Vec<MailAddress>,
228    pub reply_to: Vec<MailAddress>,
229    pub subject: String,
230    pub text: Option<String>,
231    pub html: Option<String>,
232    pub attachments: Vec<MailAttachment>,
233    pub headers: BTreeMap<String, String>,
234    pub tags: BTreeMap<String, String>,
235    pub metadata: BTreeMap<String, serde_json::Value>,
236    pub created_at: DateTime<Utc>,
237}
238
239impl MailMessage {
240    pub fn builder(topic: impl Into<String>, subject: impl Into<String>) -> MailMessageBuilder {
241        MailMessageBuilder {
242            message: Self {
243                id: Uuid::now_v7(),
244                topic: topic.into(),
245                to: Vec::new(),
246                cc: Vec::new(),
247                bcc: Vec::new(),
248                reply_to: Vec::new(),
249                subject: subject.into(),
250                text: None,
251                html: None,
252                attachments: Vec::new(),
253                headers: BTreeMap::new(),
254                tags: BTreeMap::new(),
255                metadata: BTreeMap::new(),
256                created_at: Utc::now(),
257            },
258        }
259    }
260
261    pub fn recipients(&self) -> impl Iterator<Item = &MailAddress> {
262        self.to.iter().chain(&self.cc).chain(&self.bcc)
263    }
264
265    pub fn validate(&self) -> Result<(), MailError> {
266        if self.id.is_nil() || !valid_topic(&self.topic) {
267            return Err(MailError::invalid("mail identity or topic is invalid"));
268        }
269        if self.subject.trim().is_empty()
270            || self.subject.len() > 998
271            || self.subject.chars().any(char::is_control)
272        {
273            return Err(MailError::invalid("mail subject is invalid"));
274        }
275
276        let recipient_count = self.to.len() + self.cc.len() + self.bcc.len();
277        if recipient_count == 0 || recipient_count > MAX_RECIPIENTS {
278            return Err(MailError::invalid(
279                "mail message must contain between 1 and 50 recipients",
280            ));
281        }
282        let mut unique = BTreeSet::new();
283        for recipient in self.recipients() {
284            recipient.validate()?;
285            if !unique.insert(recipient.normalized_key()) {
286                return Err(MailError::invalid(
287                    "mail recipient lists must not contain duplicate mailboxes",
288                ));
289            }
290        }
291        if self.reply_to.len() > 10 {
292            return Err(MailError::invalid(
293                "mail message exceeds the reply-to address boundary",
294            ));
295        }
296        for reply_to in &self.reply_to {
297            reply_to.validate()?;
298        }
299
300        match (&self.text, &self.html) {
301            (None, None) => {
302                return Err(MailError::invalid(
303                    "mail message requires a text or HTML body",
304                ));
305            }
306            (Some(text), _) if !valid_body(text) => {
307                return Err(MailError::invalid("mail text body is invalid"));
308            }
309            (_, Some(html)) if !valid_body(html) => {
310                return Err(MailError::invalid("mail HTML body is invalid"));
311            }
312            _ => {}
313        }
314
315        if self.attachments.len() > MAX_ATTACHMENT_COUNT {
316            return Err(MailError::invalid(
317                "mail message exceeds the attachment count boundary",
318            ));
319        }
320        let mut attachment_bytes = 0_usize;
321        let mut content_ids = BTreeSet::new();
322        for attachment in &self.attachments {
323            attachment.validate()?;
324            attachment_bytes = attachment_bytes
325                .checked_add(attachment.content.len())
326                .ok_or_else(|| MailError::invalid("mail attachment size overflow"))?;
327            if let Some(content_id) = &attachment.content_id
328                && !content_ids.insert(content_id.to_ascii_lowercase())
329            {
330                return Err(MailError::invalid(
331                    "mail inline attachment content IDs must be unique",
332                ));
333            }
334        }
335        if attachment_bytes > MAX_ATTACHMENT_BYTES {
336            return Err(MailError::invalid(
337                "mail attachments exceed the 25 MiB raw-content boundary",
338            ));
339        }
340
341        if self.headers.len() > MAX_HEADERS
342            || self
343                .headers
344                .iter()
345                .any(|(name, value)| !valid_header(name, value))
346        {
347            return Err(MailError::invalid("mail custom headers are invalid"));
348        }
349        if self.tags.len() > MAX_USER_TAGS
350            || self.tags.iter().any(|(name, value)| {
351                RESERVED_TAGS
352                    .iter()
353                    .any(|reserved| name.eq_ignore_ascii_case(reserved))
354                    || !valid_tag_component(name)
355                    || !valid_tag_component(value)
356            })
357        {
358            return Err(MailError::invalid("mail delivery tags are invalid"));
359        }
360        let metadata_size = serde_json::to_vec(&self.metadata)
361            .map_err(|_| MailError::invalid("mail metadata cannot be serialized"))?
362            .len();
363        if metadata_size > MAX_METADATA_BYTES {
364            return Err(MailError::invalid(
365                "mail metadata exceeds the 64 KiB boundary",
366            ));
367        }
368        Ok(())
369    }
370}
371
372impl fmt::Debug for MailMessage {
373    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
374        formatter
375            .debug_struct("MailMessage")
376            .field("id", &self.id)
377            .field("topic", &self.topic)
378            .field("to_count", &self.to.len())
379            .field("cc_count", &self.cc.len())
380            .field("bcc_count", &self.bcc.len())
381            .field("reply_to_count", &self.reply_to.len())
382            .field("subject", &"[REDACTED]")
383            .field("text_bytes", &self.text.as_ref().map(String::len))
384            .field("html_bytes", &self.html.as_ref().map(String::len))
385            .field("attachment_count", &self.attachments.len())
386            .field("header_count", &self.headers.len())
387            .field("tag_count", &self.tags.len())
388            .field("metadata_count", &self.metadata.len())
389            .field("created_at", &self.created_at)
390            .finish()
391    }
392}
393
394#[must_use]
395#[derive(Debug, Clone)]
396pub struct MailMessageBuilder {
397    message: MailMessage,
398}
399
400impl MailMessageBuilder {
401    pub const fn id(mut self, id: Uuid) -> Self {
402        self.message.id = id;
403        self
404    }
405
406    pub const fn created_at(mut self, created_at: DateTime<Utc>) -> Self {
407        self.message.created_at = created_at;
408        self
409    }
410
411    pub fn to(mut self, address: MailAddress) -> Self {
412        self.message.to.push(address);
413        self
414    }
415
416    pub fn cc(mut self, address: MailAddress) -> Self {
417        self.message.cc.push(address);
418        self
419    }
420
421    pub fn bcc(mut self, address: MailAddress) -> Self {
422        self.message.bcc.push(address);
423        self
424    }
425
426    pub fn reply_to(mut self, address: MailAddress) -> Self {
427        self.message.reply_to.push(address);
428        self
429    }
430
431    pub fn text(mut self, body: impl Into<String>) -> Self {
432        self.message.text = Some(body.into());
433        self
434    }
435
436    pub fn html(mut self, body: impl Into<String>) -> Self {
437        self.message.html = Some(body.into());
438        self
439    }
440
441    pub fn attachment(mut self, attachment: MailAttachment) -> Self {
442        self.message.attachments.push(attachment);
443        self
444    }
445
446    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
447        self.message.headers.insert(name.into(), value.into());
448        self
449    }
450
451    pub fn tag(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
452        self.message.tags.insert(name.into(), value.into());
453        self
454    }
455
456    pub fn metadata(mut self, name: impl Into<String>, value: serde_json::Value) -> Self {
457        self.message.metadata.insert(name.into(), value);
458        self
459    }
460
461    pub fn build(self) -> Result<MailMessage, MailError> {
462        self.message.validate()?;
463        Ok(self.message)
464    }
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(rename_all = "snake_case")]
469pub enum MailErrorKind {
470    InvalidMessage,
471    Configuration,
472    Authentication,
473    Rejected,
474    Throttled,
475    Unavailable,
476    Ambiguous,
477    Protocol,
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
481#[serde(rename_all = "snake_case")]
482pub enum MailRetryAdvice {
483    Never,
484    SafeAfterBackoff,
485    ReconcileBeforeRetry,
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
489#[error("mail transport {transport} failed ({kind:?}): {message}")]
490pub struct MailError {
491    pub kind: MailErrorKind,
492    pub transport: String,
493    pub message: String,
494}
495
496impl MailError {
497    pub fn new(
498        kind: MailErrorKind,
499        transport: impl Into<String>,
500        message: impl Into<String>,
501    ) -> Self {
502        Self {
503            kind,
504            transport: sanitize_diagnostic(&transport.into(), 64),
505            message: sanitize_diagnostic(&message.into(), 2_048),
506        }
507    }
508
509    pub const fn retry_advice(&self) -> MailRetryAdvice {
510        match self.kind {
511            MailErrorKind::Throttled | MailErrorKind::Unavailable => {
512                MailRetryAdvice::SafeAfterBackoff
513            }
514            MailErrorKind::Ambiguous => MailRetryAdvice::ReconcileBeforeRetry,
515            MailErrorKind::InvalidMessage
516            | MailErrorKind::Configuration
517            | MailErrorKind::Authentication
518            | MailErrorKind::Rejected
519            | MailErrorKind::Protocol => MailRetryAdvice::Never,
520        }
521    }
522
523    pub fn can_failover(&self) -> bool {
524        self.retry_advice() == MailRetryAdvice::SafeAfterBackoff
525    }
526
527    pub fn is_ambiguous(&self) -> bool {
528        self.kind == MailErrorKind::Ambiguous
529    }
530
531    pub(crate) fn invalid(message: impl Into<String>) -> Self {
532        Self::new(MailErrorKind::InvalidMessage, "mail", message)
533    }
534}
535
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
537pub struct MailReceipt {
538    pub message_id: Uuid,
539    pub transport: String,
540    pub provider_message_id: String,
541    pub accepted_at: DateTime<Utc>,
542    pub attempt: u32,
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "snake_case")]
547pub enum MailSubmissionEventKind {
548    Prepared,
549    Attempting,
550    AttemptFailed,
551    Accepted,
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555pub struct MailSubmissionEvent {
556    pub event_id: Uuid,
557    pub message_id: Uuid,
558    pub topic: String,
559    pub transport: String,
560    pub kind: MailSubmissionEventKind,
561    pub occurred_at: DateTime<Utc>,
562    pub attempt: u32,
563    pub failure_kind: Option<MailErrorKind>,
564    pub duration_ms: Option<u64>,
565}
566
567impl MailSubmissionEvent {
568    fn new(
569        message: &MailMessage,
570        transport: impl Into<String>,
571        kind: MailSubmissionEventKind,
572        attempt: u32,
573        failure_kind: Option<MailErrorKind>,
574        duration: Option<Duration>,
575    ) -> Self {
576        Self {
577            event_id: Uuid::now_v7(),
578            message_id: message.id,
579            topic: message.topic.clone(),
580            transport: transport.into(),
581            kind,
582            occurred_at: Utc::now(),
583            attempt,
584            failure_kind,
585            duration_ms: duration.map(|value| u64::try_from(value.as_millis()).unwrap_or(u64::MAX)),
586        }
587    }
588}
589
590#[async_trait]
591pub trait MailObserver: Send + Sync + fmt::Debug {
592    async fn observe(&self, event: &MailSubmissionEvent);
593}
594
595#[derive(Debug, Default)]
596pub struct NoopMailObserver;
597
598#[async_trait]
599impl MailObserver for NoopMailObserver {
600    async fn observe(&self, _event: &MailSubmissionEvent) {}
601}
602
603#[derive(Debug, Default)]
604pub struct MemoryMailObserver {
605    events: RwLock<Vec<MailSubmissionEvent>>,
606}
607
608impl MemoryMailObserver {
609    pub async fn events(&self) -> Vec<MailSubmissionEvent> {
610        self.events.read().await.clone()
611    }
612
613    pub async fn clear(&self) {
614        self.events.write().await.clear();
615    }
616}
617
618#[async_trait]
619impl MailObserver for MemoryMailObserver {
620    async fn observe(&self, event: &MailSubmissionEvent) {
621        self.events.write().await.push(event.clone());
622    }
623}
624
625#[derive(Clone)]
626pub struct CompositeMailObserver {
627    observers: Vec<Arc<dyn MailObserver>>,
628}
629
630impl CompositeMailObserver {
631    pub fn new(observers: Vec<Arc<dyn MailObserver>>) -> Result<Self, MailError> {
632        if observers.is_empty() || observers.len() > MAX_OBSERVERS {
633            return Err(MailError::new(
634                MailErrorKind::Configuration,
635                "mail",
636                "mail observer composition must contain between 1 and 16 observers",
637            ));
638        }
639        Ok(Self { observers })
640    }
641}
642
643impl fmt::Debug for CompositeMailObserver {
644    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
645        formatter
646            .debug_struct("CompositeMailObserver")
647            .field("observer_count", &self.observers.len())
648            .finish()
649    }
650}
651
652#[async_trait]
653impl MailObserver for CompositeMailObserver {
654    async fn observe(&self, event: &MailSubmissionEvent) {
655        let mut tasks = JoinSet::new();
656        for (index, observer) in self.observers.iter().cloned().enumerate() {
657            let event = event.clone();
658            tasks.spawn(async move {
659                (
660                    index,
661                    timeout(OBSERVER_CHILD_TIMEOUT, observer.observe(&event))
662                        .await
663                        .is_ok(),
664                )
665            });
666        }
667        while let Some(result) = tasks.join_next().await {
668            match result {
669                Ok((_, true)) => {}
670                Ok((index, false)) => tracing::warn!(
671                    target: "minco.mail",
672                    mail_event_id = %event.event_id,
673                    mail_event = ?event.kind,
674                    mail_observer_index = index,
675                    "mail observer timed out"
676                ),
677                Err(_) => tracing::warn!(
678                    target: "minco.mail",
679                    mail_event_id = %event.event_id,
680                    mail_event = ?event.kind,
681                    "mail observer task failed"
682                ),
683            }
684        }
685    }
686}
687
688#[derive(Debug, Default)]
689pub struct TracingMailObserver;
690
691#[async_trait]
692impl MailObserver for TracingMailObserver {
693    async fn observe(&self, event: &MailSubmissionEvent) {
694        if event.kind == MailSubmissionEventKind::AttemptFailed {
695            tracing::warn!(
696                target: "minco.mail",
697                mail_event_id = %event.event_id,
698                mail_message_id = %event.message_id,
699                mail_topic = %event.topic,
700                mail_transport = %event.transport,
701                mail_event = ?event.kind,
702                mail_attempt = event.attempt,
703                mail_failure_kind = ?event.failure_kind,
704                mail_duration_ms = event.duration_ms,
705                "mail submission event"
706            );
707        } else {
708            tracing::info!(
709                target: "minco.mail",
710                mail_event_id = %event.event_id,
711                mail_message_id = %event.message_id,
712                mail_topic = %event.topic,
713                mail_transport = %event.transport,
714                mail_event = ?event.kind,
715                mail_attempt = event.attempt,
716                mail_failure_kind = ?event.failure_kind,
717                mail_duration_ms = event.duration_ms,
718                "mail submission event"
719            );
720        }
721    }
722}
723
724#[async_trait]
725pub trait MailTransport: Send + Sync + fmt::Debug {
726    fn name(&self) -> &str;
727
728    async fn send(&self, message: &MailMessage, attempt: u32) -> Result<MailReceipt, MailError>;
729}
730
731#[derive(Clone)]
732pub struct MailService {
733    transports: Vec<Arc<dyn MailTransport>>,
734    observer: Arc<dyn MailObserver>,
735}
736
737impl MailService {
738    pub fn new(
739        transports: Vec<Arc<dyn MailTransport>>,
740        observer: Arc<dyn MailObserver>,
741    ) -> Result<Self, MailError> {
742        if transports.is_empty() {
743            return Err(MailError::new(
744                MailErrorKind::Configuration,
745                "mail",
746                "mail service requires at least one transport",
747            ));
748        }
749        let mut names = BTreeSet::new();
750        for transport in &transports {
751            if !valid_transport_name(transport.name()) || !names.insert(transport.name().to_owned())
752            {
753                return Err(MailError::new(
754                    MailErrorKind::Configuration,
755                    "mail",
756                    "mail transport names must be unique stable identifiers",
757                ));
758            }
759        }
760        Ok(Self {
761            transports,
762            observer,
763        })
764    }
765
766    pub fn single(
767        transport: Arc<dyn MailTransport>,
768        observer: Arc<dyn MailObserver>,
769    ) -> Result<Self, MailError> {
770        Self::new(vec![transport], observer)
771    }
772
773    async fn observe(&self, event: MailSubmissionEvent) {
774        if timeout(OBSERVER_TIMEOUT, self.observer.observe(&event))
775            .await
776            .is_err()
777        {
778            tracing::warn!(
779                target: "minco.mail",
780                mail_event_id = %event.event_id,
781                mail_event = ?event.kind,
782                "mail observer timed out"
783            );
784        }
785    }
786
787    pub async fn send(&self, message: MailMessage) -> Result<MailReceipt, MailError> {
788        message.validate()?;
789        self.observe(MailSubmissionEvent::new(
790            &message,
791            "mail.service",
792            MailSubmissionEventKind::Prepared,
793            0,
794            None,
795            None,
796        ))
797        .await;
798
799        for (index, transport) in self.transports.iter().enumerate() {
800            let attempt = u32::try_from(index + 1).map_err(|_| {
801                MailError::new(
802                    MailErrorKind::Configuration,
803                    "mail",
804                    "mail transport attempt count overflow",
805                )
806            })?;
807            self.observe(MailSubmissionEvent::new(
808                &message,
809                transport.name(),
810                MailSubmissionEventKind::Attempting,
811                attempt,
812                None,
813                None,
814            ))
815            .await;
816
817            let started_at = Instant::now();
818            match transport.send(&message, attempt).await {
819                Ok(receipt) => {
820                    if let Err(error) =
821                        validate_receipt(&receipt, &message, transport.name(), attempt)
822                    {
823                        self.observe(MailSubmissionEvent::new(
824                            &message,
825                            transport.name(),
826                            MailSubmissionEventKind::AttemptFailed,
827                            attempt,
828                            Some(error.kind),
829                            Some(started_at.elapsed()),
830                        ))
831                        .await;
832                        return Err(error);
833                    }
834                    self.observe(MailSubmissionEvent::new(
835                        &message,
836                        transport.name(),
837                        MailSubmissionEventKind::Accepted,
838                        attempt,
839                        None,
840                        Some(started_at.elapsed()),
841                    ))
842                    .await;
843                    return Ok(receipt);
844                }
845                Err(error) => {
846                    let error = normalize_transport_error(error, transport.name());
847                    self.observe(MailSubmissionEvent::new(
848                        &message,
849                        transport.name(),
850                        MailSubmissionEventKind::AttemptFailed,
851                        attempt,
852                        Some(error.kind),
853                        Some(started_at.elapsed()),
854                    ))
855                    .await;
856                    let has_fallback = index + 1 < self.transports.len();
857                    if !error.can_failover() || !has_fallback {
858                        return Err(error);
859                    }
860                }
861            }
862        }
863
864        Err(MailError::new(
865            MailErrorKind::Unavailable,
866            "mail",
867            "all configured mail transports were unavailable",
868        ))
869    }
870}
871
872impl fmt::Debug for MailService {
873    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
874        formatter
875            .debug_struct("MailService")
876            .field(
877                "transports",
878                &self
879                    .transports
880                    .iter()
881                    .map(|transport| transport.name())
882                    .collect::<Vec<_>>(),
883            )
884            .finish_non_exhaustive()
885    }
886}
887
888pub struct MemoryMailTransport {
889    name: String,
890    messages: RwLock<Vec<MailMessage>>,
891}
892
893impl MemoryMailTransport {
894    pub fn named(name: impl Into<String>) -> Result<Self, MailError> {
895        let name = name.into();
896        if !valid_transport_name(&name) {
897            return Err(MailError::new(
898                MailErrorKind::Configuration,
899                "memory",
900                "memory mail transport name is invalid",
901            ));
902        }
903        Ok(Self {
904            name,
905            messages: RwLock::new(Vec::new()),
906        })
907    }
908
909    pub async fn messages(&self) -> Vec<MailMessage> {
910        self.messages.read().await.clone()
911    }
912
913    pub async fn count(&self) -> usize {
914        self.messages.read().await.len()
915    }
916
917    pub async fn clear(&self) {
918        self.messages.write().await.clear();
919    }
920
921    pub async fn sent_to(&self, address: &str) -> bool {
922        let Ok(expected) = MailAddress::new(address) else {
923            return false;
924        };
925        self.messages.read().await.iter().any(|message| {
926            message
927                .recipients()
928                .any(|recipient| same_mailbox(recipient, &expected))
929        })
930    }
931
932    pub async fn assert_sent_count(&self, expected: usize) {
933        assert_eq!(self.count().await, expected, "unexpected sent-mail count");
934    }
935
936    pub async fn assert_sent_to(&self, address: &str) {
937        assert!(
938            self.sent_to(address).await,
939            "no captured mail was sent to the expected address"
940        );
941    }
942}
943
944impl Default for MemoryMailTransport {
945    fn default() -> Self {
946        Self::named("memory").expect("static memory transport name")
947    }
948}
949
950impl fmt::Debug for MemoryMailTransport {
951    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
952        formatter
953            .debug_struct("MemoryMailTransport")
954            .field("name", &self.name)
955            .finish_non_exhaustive()
956    }
957}
958
959#[async_trait]
960impl MailTransport for MemoryMailTransport {
961    fn name(&self) -> &str {
962        &self.name
963    }
964
965    async fn send(&self, message: &MailMessage, attempt: u32) -> Result<MailReceipt, MailError> {
966        message.validate()?;
967        let sequence = {
968            let mut messages = self.messages.write().await;
969            messages.push(message.clone());
970            messages.len()
971        };
972        Ok(MailReceipt {
973            message_id: message.id,
974            transport: self.name.clone(),
975            provider_message_id: format!("memory:{}:{sequence}", message.id),
976            accepted_at: Utc::now(),
977            attempt,
978        })
979    }
980}
981
982#[derive(Clone)]
983pub struct LegacyNotificationMailTransport {
984    sink: Arc<dyn NotificationSink>,
985}
986
987impl LegacyNotificationMailTransport {
988    pub fn new(sink: Arc<dyn NotificationSink>) -> Self {
989        Self { sink }
990    }
991}
992
993impl fmt::Debug for LegacyNotificationMailTransport {
994    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
995        formatter
996            .debug_struct("LegacyNotificationMailTransport")
997            .finish_non_exhaustive()
998    }
999}
1000
1001#[async_trait]
1002impl MailTransport for LegacyNotificationMailTransport {
1003    fn name(&self) -> &'static str {
1004        "legacy-notification"
1005    }
1006
1007    async fn send(&self, message: &MailMessage, attempt: u32) -> Result<MailReceipt, MailError> {
1008        message.validate()?;
1009        if message.to.len() != 1
1010            || !message.cc.is_empty()
1011            || !message.bcc.is_empty()
1012            || !message.reply_to.is_empty()
1013            || message.html.is_some()
1014            || !message.attachments.is_empty()
1015            || !message.headers.is_empty()
1016            || !message.tags.is_empty()
1017        {
1018            return Err(MailError::new(
1019                MailErrorKind::Configuration,
1020                self.name(),
1021                "legacy notification transport accepts only one plain-text recipient",
1022            ));
1023        }
1024        let text = message.text.clone().ok_or_else(|| {
1025            MailError::new(
1026                MailErrorKind::Configuration,
1027                self.name(),
1028                "legacy notification transport requires a text body",
1029            )
1030        })?;
1031        let mut notification = Notification::new(
1032            message.topic.clone(),
1033            NotificationChannel::Email,
1034            message.to[0].address.clone(),
1035            message.subject.clone(),
1036            text,
1037        );
1038        notification.id = message.id;
1039        notification.created_at = message.created_at;
1040        notification.metadata = message.metadata.clone();
1041        self.sink
1042            .send(notification)
1043            .await
1044            .map_err(|error| match error {
1045                NotificationError::InvalidRecipient => MailError::new(
1046                    MailErrorKind::Rejected,
1047                    self.name(),
1048                    "legacy notification recipient was rejected",
1049                ),
1050                NotificationError::Delivery(_) => MailError::new(
1051                    MailErrorKind::Ambiguous,
1052                    self.name(),
1053                    "legacy notification delivery outcome is unknown",
1054                ),
1055            })?;
1056        Ok(MailReceipt {
1057            message_id: message.id,
1058            transport: self.name().into(),
1059            provider_message_id: format!("legacy:{}", message.id),
1060            accepted_at: Utc::now(),
1061            attempt,
1062        })
1063    }
1064}
1065
1066#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1067#[serde(rename_all = "snake_case")]
1068pub enum MailDeliveryEventKind {
1069    Submitted,
1070    Delivered,
1071    BouncedPermanent,
1072    BouncedTransient,
1073    BouncedUndetermined,
1074    Complaint,
1075    Rejected,
1076    DeliveryDelayed,
1077    RenderingFailed,
1078    Opened,
1079    Clicked,
1080    SubscriptionChanged,
1081}
1082
1083#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1084pub struct MailDeliveryEvent {
1085    pub source_event_id: String,
1086    pub message_id: Uuid,
1087    pub topic: String,
1088    pub transport: String,
1089    pub kind: MailDeliveryEventKind,
1090    pub occurred_at: DateTime<Utc>,
1091    pub provider_message_id: Option<String>,
1092}
1093
1094impl MailDeliveryEvent {
1095    pub fn validate(&self) -> Result<(), MailError> {
1096        if self.source_event_id.trim().is_empty()
1097            || self.source_event_id.len() > 512
1098            || self.source_event_id.chars().any(char::is_control)
1099            || self.message_id.is_nil()
1100            || !valid_topic(&self.topic)
1101            || !valid_transport_name(&self.transport)
1102            || self.provider_message_id.as_deref().is_some_and(|value| {
1103                value.trim().is_empty()
1104                    || value.len() > MAX_PROVIDER_MESSAGE_ID_BYTES
1105                    || value.chars().any(char::is_control)
1106            })
1107        {
1108            return Err(MailError::invalid("mail delivery event is invalid"));
1109        }
1110        Ok(())
1111    }
1112}
1113
1114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115pub enum MailDeliveryDisposition {
1116    Recorded,
1117    Duplicate,
1118}
1119
1120#[async_trait]
1121pub trait MailDeliveryEventSink: Send + Sync + fmt::Debug {
1122    async fn record(&self, event: MailDeliveryEvent) -> Result<MailDeliveryDisposition, MailError>;
1123}
1124
1125#[derive(Debug, Default)]
1126pub struct MemoryMailDeliveryEventSink {
1127    source_ids: RwLock<BTreeSet<String>>,
1128    events: RwLock<Vec<MailDeliveryEvent>>,
1129}
1130
1131impl MemoryMailDeliveryEventSink {
1132    pub async fn events(&self) -> Vec<MailDeliveryEvent> {
1133        self.events.read().await.clone()
1134    }
1135}
1136
1137#[async_trait]
1138impl MailDeliveryEventSink for MemoryMailDeliveryEventSink {
1139    async fn record(&self, event: MailDeliveryEvent) -> Result<MailDeliveryDisposition, MailError> {
1140        event.validate()?;
1141        {
1142            let mut source_ids = self.source_ids.write().await;
1143            if !source_ids.insert(event.source_event_id.clone()) {
1144                return Ok(MailDeliveryDisposition::Duplicate);
1145            }
1146        }
1147        self.events.write().await.push(event);
1148        Ok(MailDeliveryDisposition::Recorded)
1149    }
1150}
1151
1152#[derive(Debug)]
1153pub struct TracingMailDeliveryEventSink {
1154    source_ids: RwLock<DeliveryDedupeWindow>,
1155}
1156
1157impl Default for TracingMailDeliveryEventSink {
1158    fn default() -> Self {
1159        Self {
1160            source_ids: RwLock::new(DeliveryDedupeWindow::new(MAX_TRACING_DELIVERY_DEDUPE_IDS)),
1161        }
1162    }
1163}
1164
1165#[derive(Debug)]
1166struct DeliveryDedupeWindow {
1167    source_ids: BTreeSet<String>,
1168    insertion_order: VecDeque<String>,
1169    capacity: usize,
1170}
1171
1172impl DeliveryDedupeWindow {
1173    fn new(capacity: usize) -> Self {
1174        assert!(capacity > 0, "delivery dedupe capacity must be positive");
1175        Self {
1176            source_ids: BTreeSet::new(),
1177            insertion_order: VecDeque::new(),
1178            capacity,
1179        }
1180    }
1181
1182    fn record_if_new(&mut self, source_event_id: &str) -> bool {
1183        if self.source_ids.contains(source_event_id) {
1184            return false;
1185        }
1186        if self.source_ids.len() == self.capacity
1187            && let Some(oldest) = self.insertion_order.pop_front()
1188        {
1189            self.source_ids.remove(&oldest);
1190        }
1191        let source_event_id = source_event_id.to_owned();
1192        self.source_ids.insert(source_event_id.clone());
1193        self.insertion_order.push_back(source_event_id);
1194        true
1195    }
1196}
1197
1198#[async_trait]
1199impl MailDeliveryEventSink for TracingMailDeliveryEventSink {
1200    async fn record(&self, event: MailDeliveryEvent) -> Result<MailDeliveryDisposition, MailError> {
1201        event.validate()?;
1202        {
1203            let mut source_ids = self.source_ids.write().await;
1204            if !source_ids.record_if_new(&event.source_event_id) {
1205                return Ok(MailDeliveryDisposition::Duplicate);
1206            }
1207        }
1208        let source_event_digest = deterministic_mail_event_id(&[&event.source_event_id]);
1209        match event.kind {
1210            MailDeliveryEventKind::Submitted
1211            | MailDeliveryEventKind::Delivered
1212            | MailDeliveryEventKind::Opened
1213            | MailDeliveryEventKind::Clicked
1214            | MailDeliveryEventKind::SubscriptionChanged => tracing::info!(
1215                target: "minco.mail",
1216                mail_source_event_digest = %source_event_digest,
1217                mail_message_id = %event.message_id,
1218                mail_topic = %event.topic,
1219                mail_transport = %event.transport,
1220                mail_delivery_event = ?event.kind,
1221                "mail delivery event"
1222            ),
1223            _ => tracing::warn!(
1224                target: "minco.mail",
1225                mail_source_event_digest = %source_event_digest,
1226                mail_message_id = %event.message_id,
1227                mail_topic = %event.topic,
1228                mail_transport = %event.transport,
1229                mail_delivery_event = ?event.kind,
1230                "mail delivery event"
1231            ),
1232        }
1233        Ok(MailDeliveryDisposition::Recorded)
1234    }
1235}
1236
1237pub fn deterministic_mail_event_id(parts: &[&str]) -> String {
1238    let mut digest = Sha256::new();
1239    for part in parts {
1240        digest.update(u64::try_from(part.len()).unwrap_or(u64::MAX).to_be_bytes());
1241        digest.update(part.as_bytes());
1242    }
1243    format!("sha256:{}", lower_hex(&digest.finalize()))
1244}
1245
1246pub fn render_mime(message: &MailMessage, from: &MailAddress) -> Result<Vec<u8>, MailError> {
1247    message.validate()?;
1248    from.validate()?;
1249
1250    let mut rendered = String::new();
1251    write_address_header(&mut rendered, "From", std::slice::from_ref(from))?;
1252    if !message.to.is_empty() {
1253        write_address_header(&mut rendered, "To", &message.to)?;
1254    }
1255    if !message.cc.is_empty() {
1256        write_address_header(&mut rendered, "Cc", &message.cc)?;
1257    }
1258    if !message.reply_to.is_empty() {
1259        write_address_header(&mut rendered, "Reply-To", &message.reply_to)?;
1260    }
1261    write_unstructured_header(&mut rendered, "Subject", &message.subject)?;
1262    writeln_crlf(
1263        &mut rendered,
1264        &format!("Date: {}", message.created_at.to_rfc2822()),
1265    );
1266    writeln_crlf(
1267        &mut rendered,
1268        &format!("Message-ID: <{}@{}>", message.id, from.domain()),
1269    );
1270    writeln_crlf(&mut rendered, "MIME-Version: 1.0");
1271    writeln_crlf(
1272        &mut rendered,
1273        &format!("X-Minco-Message-ID: {}", message.id),
1274    );
1275    writeln_crlf(&mut rendered, &format!("X-Minco-Topic: {}", message.topic));
1276    for (name, value) in &message.headers {
1277        write_ascii_header(&mut rendered, name, value)?;
1278    }
1279    rendered.push_str(&render_body_entity(message));
1280
1281    if rendered
1282        .split("\r\n")
1283        .any(|line| line.len() > HEADER_HARD_LINE_BYTES)
1284    {
1285        return Err(MailError::invalid(
1286            "rendered mail contains a header line above the RFC hard boundary",
1287        ));
1288    }
1289
1290    let bytes = rendered.into_bytes();
1291    if bytes.len() > MAX_RENDERED_MESSAGE_BYTES {
1292        return Err(MailError::invalid(
1293            "rendered mail exceeds the 39 MB provider boundary",
1294        ));
1295    }
1296    Ok(bytes)
1297}
1298
1299fn render_body_entity(message: &MailMessage) -> String {
1300    let regular = message
1301        .attachments
1302        .iter()
1303        .filter(|attachment| attachment.disposition == MailAttachmentDisposition::Attachment)
1304        .collect::<Vec<_>>();
1305    let inline = message
1306        .attachments
1307        .iter()
1308        .filter(|attachment| attachment.disposition == MailAttachmentDisposition::Inline)
1309        .collect::<Vec<_>>();
1310
1311    let mut entity = render_alternative_entity(message);
1312    if !inline.is_empty() {
1313        let boundary = format!("minco-related-{}", message.id.simple());
1314        let mut related = multipart_header("related", &boundary);
1315        append_part(&mut related, &boundary, &entity);
1316        for attachment in inline {
1317            append_part(
1318                &mut related,
1319                &boundary,
1320                &render_attachment_entity(attachment),
1321            );
1322        }
1323        finish_multipart(&mut related, &boundary);
1324        entity = related;
1325    }
1326    if !regular.is_empty() {
1327        let boundary = format!("minco-mixed-{}", message.id.simple());
1328        let mut mixed = multipart_header("mixed", &boundary);
1329        append_part(&mut mixed, &boundary, &entity);
1330        for attachment in regular {
1331            append_part(&mut mixed, &boundary, &render_attachment_entity(attachment));
1332        }
1333        finish_multipart(&mut mixed, &boundary);
1334        entity = mixed;
1335    }
1336    entity
1337}
1338
1339fn render_alternative_entity(message: &MailMessage) -> String {
1340    match (&message.text, &message.html) {
1341        (Some(text), Some(html)) => {
1342            let boundary = format!("minco-alternative-{}", message.id.simple());
1343            let mut alternative = multipart_header("alternative", &boundary);
1344            append_part(
1345                &mut alternative,
1346                &boundary,
1347                &render_text_entity("text/plain", text),
1348            );
1349            append_part(
1350                &mut alternative,
1351                &boundary,
1352                &render_text_entity("text/html", html),
1353            );
1354            finish_multipart(&mut alternative, &boundary);
1355            alternative
1356        }
1357        (Some(text), None) => render_text_entity("text/plain", text),
1358        (None, Some(html)) => render_text_entity("text/html", html),
1359        (None, None) => unreachable!("validated mail has a body"),
1360    }
1361}
1362
1363fn render_text_entity(content_type: &str, body: &str) -> String {
1364    let mut rendered = String::new();
1365    writeln_crlf(
1366        &mut rendered,
1367        &format!("Content-Type: {content_type}; charset=UTF-8"),
1368    );
1369    writeln_crlf(&mut rendered, "Content-Transfer-Encoding: base64");
1370    rendered.push_str("\r\n");
1371    rendered.push_str(&base64_lines(body.as_bytes()));
1372    rendered
1373}
1374
1375fn render_attachment_entity(attachment: &MailAttachment) -> String {
1376    let mut rendered = String::new();
1377    writeln_crlf(
1378        &mut rendered,
1379        &format!("Content-Type: {}", attachment.content_type),
1380    );
1381    writeln_crlf(&mut rendered, "Content-Transfer-Encoding: base64");
1382    let disposition = match attachment.disposition {
1383        MailAttachmentDisposition::Attachment => "attachment",
1384        MailAttachmentDisposition::Inline => "inline",
1385    };
1386    writeln_crlf(
1387        &mut rendered,
1388        &format!(
1389            "Content-Disposition: {disposition}; filename*=UTF-8''{}",
1390            percent_encode(&attachment.file_name)
1391        ),
1392    );
1393    if let Some(content_id) = &attachment.content_id {
1394        writeln_crlf(&mut rendered, &format!("Content-ID: <{content_id}>"));
1395    }
1396    rendered.push_str("\r\n");
1397    rendered.push_str(&base64_lines(&attachment.content));
1398    rendered
1399}
1400
1401fn multipart_header(subtype: &str, boundary: &str) -> String {
1402    format!("Content-Type: multipart/{subtype}; boundary=\"{boundary}\"\r\n\r\n")
1403}
1404
1405fn append_part(target: &mut String, boundary: &str, part: &str) {
1406    let _ = write!(target, "--{boundary}\r\n{part}");
1407    if !target.ends_with("\r\n") {
1408        target.push_str("\r\n");
1409    }
1410}
1411
1412fn finish_multipart(target: &mut String, boundary: &str) {
1413    let _ = write!(target, "--{boundary}--\r\n");
1414}
1415
1416fn base64_lines(bytes: &[u8]) -> String {
1417    let encoded = STANDARD.encode(bytes);
1418    let mut rendered = String::with_capacity(encoded.len() + encoded.len() / 76 * 2 + 2);
1419    for chunk in encoded.as_bytes().chunks(76) {
1420        rendered.push_str(std::str::from_utf8(chunk).expect("base64 is ASCII"));
1421        rendered.push_str("\r\n");
1422    }
1423    rendered
1424}
1425
1426fn percent_encode(value: &str) -> String {
1427    let mut encoded = String::new();
1428    for byte in value.as_bytes() {
1429        if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'.' | b'_' | b'~') {
1430            encoded.push(char::from(*byte));
1431        } else {
1432            let _ = write!(encoded, "%{byte:02X}");
1433        }
1434    }
1435    encoded
1436}
1437
1438fn write_address_header(
1439    target: &mut String,
1440    name: &str,
1441    addresses: &[MailAddress],
1442) -> Result<(), MailError> {
1443    let prefix = format!("{name}: ");
1444    target.push_str(&prefix);
1445    let mut line_bytes = prefix.len();
1446    for (index, address) in addresses.iter().enumerate() {
1447        let formatted = address.formatted();
1448        if formatted.len() + 1 > HEADER_HARD_LINE_BYTES {
1449            return Err(MailError::invalid(
1450                "rendered mail address exceeds the RFC header boundary",
1451            ));
1452        }
1453        if index > 0 {
1454            if line_bytes + 2 + formatted.len() > HEADER_SOFT_LINE_BYTES {
1455                target.push_str(",\r\n ");
1456                line_bytes = 1;
1457            } else {
1458                target.push_str(", ");
1459                line_bytes += 2;
1460            }
1461        }
1462        if line_bytes + formatted.len() > HEADER_HARD_LINE_BYTES {
1463            return Err(MailError::invalid(
1464                "rendered mail address header exceeds the RFC hard boundary",
1465            ));
1466        }
1467        target.push_str(&formatted);
1468        line_bytes += formatted.len();
1469    }
1470    target.push_str("\r\n");
1471    Ok(())
1472}
1473
1474fn write_unstructured_header(
1475    target: &mut String,
1476    name: &str,
1477    value: &str,
1478) -> Result<(), MailError> {
1479    if value.is_ascii() && name.len() + value.len() + 2 <= HEADER_SOFT_LINE_BYTES {
1480        return write_ascii_header(target, name, value);
1481    }
1482    let encoded = encode_header_words(value);
1483    let prefix = format!("{name}: ");
1484    target.push_str(&prefix);
1485    let mut line_bytes = prefix.len();
1486    for (index, word) in encoded.split(' ').enumerate() {
1487        if index > 0 {
1488            if line_bytes + 1 + word.len() > HEADER_SOFT_LINE_BYTES {
1489                target.push_str("\r\n ");
1490                line_bytes = 1;
1491            } else {
1492                target.push(' ');
1493                line_bytes += 1;
1494            }
1495        }
1496        if line_bytes + word.len() > HEADER_HARD_LINE_BYTES {
1497            return Err(MailError::invalid(
1498                "rendered unstructured header exceeds the RFC hard boundary",
1499            ));
1500        }
1501        target.push_str(word);
1502        line_bytes += word.len();
1503    }
1504    target.push_str("\r\n");
1505    Ok(())
1506}
1507
1508fn write_ascii_header(target: &mut String, name: &str, value: &str) -> Result<(), MailError> {
1509    let prefix = format!("{name}: ");
1510    target.push_str(&prefix);
1511    let mut line_bytes = prefix.len();
1512    let mut remaining = value;
1513    while line_bytes + remaining.len() > HEADER_SOFT_LINE_BYTES {
1514        let available = HEADER_SOFT_LINE_BYTES.saturating_sub(line_bytes);
1515        let split = remaining
1516            .get(..available.min(remaining.len()))
1517            .and_then(|candidate| candidate.rfind(' '));
1518        let Some(split) = split.filter(|split| *split > 0) else {
1519            break;
1520        };
1521        target.push_str(&remaining[..split]);
1522        target.push_str("\r\n ");
1523        remaining = &remaining[split + 1..];
1524        line_bytes = 1;
1525    }
1526    if line_bytes + remaining.len() > HEADER_HARD_LINE_BYTES {
1527        return Err(MailError::invalid(
1528            "rendered custom header exceeds the RFC hard boundary",
1529        ));
1530    }
1531    target.push_str(remaining);
1532    target.push_str("\r\n");
1533    Ok(())
1534}
1535
1536fn encode_header_words(value: &str) -> String {
1537    let mut words = Vec::new();
1538    let mut remaining = value;
1539    while !remaining.is_empty() {
1540        let mut end = remaining.len().min(ENCODED_WORD_INPUT_BYTES);
1541        while !remaining.is_char_boundary(end) {
1542            end -= 1;
1543        }
1544        let (chunk, rest) = remaining.split_at(end);
1545        words.push(format!("=?UTF-8?B?{}?=", STANDARD.encode(chunk.as_bytes())));
1546        remaining = rest;
1547    }
1548    words.join(" ")
1549}
1550
1551fn writeln_crlf(target: &mut String, value: &str) {
1552    target.push_str(value);
1553    target.push_str("\r\n");
1554}
1555
1556fn validate_receipt(
1557    receipt: &MailReceipt,
1558    message: &MailMessage,
1559    transport: &str,
1560    attempt: u32,
1561) -> Result<(), MailError> {
1562    if receipt.message_id != message.id
1563        || receipt.transport != transport
1564        || receipt.attempt != attempt
1565        || receipt.provider_message_id.trim().is_empty()
1566        || receipt.provider_message_id.len() > MAX_PROVIDER_MESSAGE_ID_BYTES
1567        || receipt.provider_message_id.chars().any(char::is_control)
1568    {
1569        return Err(MailError::new(
1570            MailErrorKind::Ambiguous,
1571            transport,
1572            "mail transport returned an invalid acceptance receipt",
1573        ));
1574    }
1575    Ok(())
1576}
1577
1578fn normalize_transport_error(error: MailError, transport: &str) -> MailError {
1579    if error.transport == transport {
1580        error
1581    } else {
1582        MailError::new(
1583            MailErrorKind::Protocol,
1584            transport,
1585            "mail transport returned an error for a different transport",
1586        )
1587    }
1588}
1589
1590fn same_mailbox(left: &MailAddress, right: &MailAddress) -> bool {
1591    left.normalized_key() == right.normalized_key()
1592}
1593
1594fn validate_email_address(value: &str) -> Result<(), MailError> {
1595    if value.len() > 254
1596        || !value.is_ascii()
1597        || value
1598            .chars()
1599            .any(|character| character.is_control() || character.is_ascii_whitespace())
1600        || value.matches('@').count() != 1
1601    {
1602        return Err(MailError::invalid("mail address is invalid"));
1603    }
1604    let (local, domain) = value
1605        .rsplit_once('@')
1606        .ok_or_else(|| MailError::invalid("mail address is invalid"))?;
1607    if local.is_empty()
1608        || local.len() > 64
1609        || local.starts_with('.')
1610        || local.ends_with('.')
1611        || local.contains("..")
1612        || !local.bytes().all(valid_local_byte)
1613        || domain.is_empty()
1614        || domain.len() > 253
1615        || domain.split('.').any(|label| {
1616            label.is_empty()
1617                || label.len() > 63
1618                || label.starts_with('-')
1619                || label.ends_with('-')
1620                || !label
1621                    .bytes()
1622                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1623        })
1624    {
1625        return Err(MailError::invalid("mail address is invalid"));
1626    }
1627    Ok(())
1628}
1629
1630const fn valid_local_byte(byte: u8) -> bool {
1631    byte.is_ascii_alphanumeric()
1632        || matches!(
1633            byte,
1634            b'!' | b'#'
1635                | b'$'
1636                | b'%'
1637                | b'&'
1638                | b'\''
1639                | b'*'
1640                | b'+'
1641                | b'-'
1642                | b'.'
1643                | b'/'
1644                | b'='
1645                | b'?'
1646                | b'^'
1647                | b'_'
1648                | b'`'
1649                | b'{'
1650                | b'|'
1651                | b'}'
1652                | b'~'
1653        )
1654}
1655
1656fn valid_topic(value: &str) -> bool {
1657    !value.is_empty()
1658        && value.len() <= 128
1659        && value
1660            .bytes()
1661            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1662}
1663
1664fn valid_transport_name(value: &str) -> bool {
1665    !value.is_empty()
1666        && value.len() <= 64
1667        && value
1668            .bytes()
1669            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1670}
1671
1672fn valid_body(value: &str) -> bool {
1673    !value.is_empty() && value.len() <= MAX_BODY_BYTES && !value.contains('\0')
1674}
1675
1676fn valid_content_type(value: &str) -> bool {
1677    let Some((kind, subtype)) = value.split_once('/') else {
1678        return false;
1679    };
1680    !kind.is_empty()
1681        && !subtype.is_empty()
1682        && value.len() <= 127
1683        && value.bytes().all(|byte| {
1684            byte.is_ascii_alphanumeric()
1685                || matches!(
1686                    byte,
1687                    b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_'
1688                )
1689                || byte == b'/'
1690        })
1691}
1692
1693fn valid_content_id(value: &str) -> bool {
1694    !value.is_empty()
1695        && value.len() <= 255
1696        && value
1697            .bytes()
1698            .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'<' | b'>' | b'"' | b'\\'))
1699}
1700
1701fn valid_header(name: &str, value: &str) -> bool {
1702    !name.is_empty()
1703        && name.len() <= 78
1704        && name
1705            .bytes()
1706            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1707        && !RESERVED_HEADERS
1708            .iter()
1709            .any(|reserved| name.eq_ignore_ascii_case(reserved))
1710        && !name.to_ascii_lowercase().starts_with("x-ses-")
1711        && !value.is_empty()
1712        && value.len() <= 998
1713        && value.bytes().all(|byte| matches!(byte, 32..=126))
1714}
1715
1716fn valid_tag_component(value: &str) -> bool {
1717    !value.is_empty()
1718        && value.len() <= 256
1719        && value
1720            .bytes()
1721            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
1722}
1723
1724fn sanitize_diagnostic(value: &str, max_bytes: usize) -> String {
1725    let mut sanitized = value
1726        .chars()
1727        .map(|character| {
1728            if character.is_control() {
1729                ' '
1730            } else {
1731                character
1732            }
1733        })
1734        .collect::<String>();
1735    while sanitized.len() > max_bytes {
1736        sanitized.pop();
1737    }
1738    if sanitized.trim().is_empty() {
1739        "unspecified mail error".into()
1740    } else {
1741        sanitized
1742    }
1743}
1744
1745fn lower_hex(bytes: &[u8]) -> String {
1746    const HEX: &[u8; 16] = b"0123456789abcdef";
1747    let mut output = String::with_capacity(bytes.len() * 2);
1748    for byte in bytes {
1749        output.push(char::from(HEX[usize::from(byte >> 4)]));
1750        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1751    }
1752    output
1753}
1754
1755#[cfg(test)]
1756mod tests {
1757    use super::*;
1758    use mail_parser::MessageParser;
1759    use std::collections::VecDeque;
1760    use tokio::sync::Mutex;
1761
1762    fn message() -> MailMessage {
1763        MailMessage::builder("account.welcome", "Welcome")
1764            .to(MailAddress::new("person@example.com").unwrap())
1765            .text("Welcome")
1766            .build()
1767            .unwrap()
1768    }
1769
1770    #[test]
1771    fn address_deduplication_preserves_local_part_case() {
1772        let message = MailMessage::builder("topic", "Subject")
1773            .to(MailAddress::new("Person@Example.com").unwrap())
1774            .cc(MailAddress::new("person@example.com").unwrap())
1775            .text("Body")
1776            .build();
1777        assert!(message.is_ok());
1778
1779        let duplicate = MailMessage::builder("topic", "Subject")
1780            .to(MailAddress::new("person@Example.com").unwrap())
1781            .cc(MailAddress::new("person@example.com").unwrap())
1782            .text("Body")
1783            .build();
1784        assert!(duplicate.is_err());
1785    }
1786
1787    #[test]
1788    fn mime_omits_bcc_and_encodes_bodies_and_attachments() {
1789        let message = MailMessage::builder("invoice.ready", "Invoice ✓")
1790            .to(MailAddress::new("person@example.com").unwrap())
1791            .bcc(MailAddress::new("audit@example.com").unwrap())
1792            .text("Plain body")
1793            .html("<p>HTML body</p>")
1794            .attachment(
1795                MailAttachment::attachment("invoice.pdf", "application/pdf", b"PDF".to_vec())
1796                    .unwrap(),
1797            )
1798            .build()
1799            .unwrap();
1800        let rendered = String::from_utf8(
1801            render_mime(&message, &MailAddress::new("no-reply@example.com").unwrap()).unwrap(),
1802        )
1803        .unwrap();
1804        assert!(!rendered.contains("audit@example.com"));
1805        assert!(!rendered.contains("Plain body"));
1806        assert!(rendered.contains("multipart/mixed"));
1807        assert!(rendered.contains("application/pdf"));
1808        assert!(rendered.contains("=?UTF-8?B?"));
1809
1810        let parsed = MessageParser::default()
1811            .parse(rendered.as_bytes())
1812            .expect("rendered MIME must be independently parseable");
1813        assert_eq!(parsed.subject(), Some("Invoice ✓"));
1814        assert_eq!(parsed.body_text(0).as_deref(), Some("Plain body"));
1815        assert_eq!(parsed.body_html(0).as_deref(), Some("<p>HTML body</p>"));
1816        assert!(parsed.attachment(0).is_some());
1817        assert!(parsed.bcc().is_none());
1818    }
1819
1820    #[test]
1821    fn mime_folds_large_address_and_unstructured_headers_within_the_hard_limit() {
1822        let mut builder = MailMessage::builder(
1823            "invoice.ready",
1824            format!("Quarterly statement {}", "長".repeat(240)),
1825        )
1826        .text("Body")
1827        .header("X-Long-Audit-Token", "segment ".repeat(120));
1828        for index in 0..50 {
1829            let local = format!("recipient-{index:02}-{}", "x".repeat(48));
1830            let domain = format!("{}.{}.example", "a".repeat(63), "b".repeat(63));
1831            let address = MailAddress::named(
1832                format!("{local}@{domain}"),
1833                format!("Recipient {index:02} {}", "名".repeat(70)),
1834            )
1835            .unwrap();
1836            builder = builder.to(address);
1837        }
1838        for index in 0..10 {
1839            builder = builder.reply_to(
1840                MailAddress::named(
1841                    format!("reply-{index}@example.com"),
1842                    format!("Reply destination {index} {}", "係".repeat(50)),
1843                )
1844                .unwrap(),
1845            );
1846        }
1847        let rendered = render_mime(
1848            &builder.build().unwrap(),
1849            &MailAddress::named("no-reply@example.com", "送信者".repeat(25)).unwrap(),
1850        )
1851        .unwrap();
1852        let header_end = rendered
1853            .windows(4)
1854            .position(|window| window == b"\r\n\r\n")
1855            .expect("rendered header terminator");
1856        for line in rendered[..header_end].split(|byte| *byte == b'\n') {
1857            let line = line.strip_suffix(b"\r").unwrap_or(line);
1858            assert!(
1859                line.len() <= 998,
1860                "physical MIME header line is {} bytes",
1861                line.len()
1862            );
1863        }
1864    }
1865
1866    #[test]
1867    fn near_attachment_boundary_stays_inside_the_rendered_provider_limit() {
1868        let message = MailMessage::builder("attachment.boundary", "Boundary")
1869            .to(MailAddress::new("person@example.com").unwrap())
1870            .text("Body")
1871            .attachment(
1872                MailAttachment::attachment(
1873                    "boundary.bin",
1874                    "application/octet-stream",
1875                    vec![0_u8; MAX_ATTACHMENT_BYTES],
1876                )
1877                .unwrap(),
1878            )
1879            .build()
1880            .unwrap();
1881        let rendered =
1882            render_mime(&message, &MailAddress::new("no-reply@example.com").unwrap()).unwrap();
1883        assert!(rendered.len() > MAX_ATTACHMENT_BYTES);
1884        assert!(rendered.len() <= MAX_RENDERED_MESSAGE_BYTES);
1885    }
1886
1887    #[test]
1888    fn custom_headers_cannot_spoof_minco_or_ses_control_state() {
1889        for name in [
1890            "x-minco-message-id",
1891            "X-MiNcO-ToPiC",
1892            "X-SES-MESSAGE-TAGS",
1893            "x-ses-configuration-set",
1894            "X-SES-SOURCE-ARN",
1895        ] {
1896            let result = MailMessage::builder("topic", "Subject")
1897                .to(MailAddress::new("person@example.com").unwrap())
1898                .text("Body")
1899                .header(name, "spoof")
1900                .build();
1901            assert!(result.is_err(), "{name} must be reserved");
1902        }
1903        assert!(
1904            MailMessage::builder("topic", "Subject")
1905                .to(MailAddress::new("person@example.com").unwrap())
1906                .text("Body")
1907                .header("X-Application-Label", "not ASCII: ✓")
1908                .build()
1909                .is_err()
1910        );
1911    }
1912
1913    #[derive(Debug)]
1914    struct ScriptedTransport {
1915        name: &'static str,
1916        outcomes: Mutex<VecDeque<Result<(), MailErrorKind>>>,
1917    }
1918
1919    #[derive(Debug)]
1920    struct InvalidReceiptTransport;
1921
1922    #[async_trait]
1923    impl MailTransport for InvalidReceiptTransport {
1924        fn name(&self) -> &'static str {
1925            "invalid-receipt"
1926        }
1927
1928        async fn send(
1929            &self,
1930            message: &MailMessage,
1931            attempt: u32,
1932        ) -> Result<MailReceipt, MailError> {
1933            Ok(MailReceipt {
1934                message_id: message.id,
1935                transport: self.name().into(),
1936                provider_message_id: String::new(),
1937                accepted_at: Utc::now(),
1938                attempt,
1939            })
1940        }
1941    }
1942
1943    #[derive(Debug)]
1944    struct SlowObserver;
1945
1946    #[async_trait]
1947    impl MailObserver for SlowObserver {
1948        async fn observe(&self, _event: &MailSubmissionEvent) {
1949            tokio::time::sleep(Duration::from_millis(250)).await;
1950        }
1951    }
1952
1953    #[async_trait]
1954    impl MailTransport for ScriptedTransport {
1955        fn name(&self) -> &str {
1956            self.name
1957        }
1958
1959        async fn send(
1960            &self,
1961            message: &MailMessage,
1962            attempt: u32,
1963        ) -> Result<MailReceipt, MailError> {
1964            let outcome = {
1965                let mut outcomes = self.outcomes.lock().await;
1966                outcomes.pop_front().unwrap_or(Ok(()))
1967            };
1968            match outcome {
1969                Ok(()) => Ok(MailReceipt {
1970                    message_id: message.id,
1971                    transport: self.name.into(),
1972                    provider_message_id: format!("{}:{}", self.name, message.id),
1973                    accepted_at: Utc::now(),
1974                    attempt,
1975                }),
1976                Err(kind) => Err(MailError::new(kind, self.name, "scripted failure")),
1977            }
1978        }
1979    }
1980
1981    #[tokio::test]
1982    async fn ambiguous_outcome_never_fails_over() {
1983        let primary = Arc::new(ScriptedTransport {
1984            name: "primary",
1985            outcomes: Mutex::new(VecDeque::from([Err(MailErrorKind::Ambiguous)])),
1986        });
1987        let fallback = Arc::new(MemoryMailTransport::named("fallback").unwrap());
1988        let service =
1989            MailService::new(vec![primary, fallback.clone()], Arc::new(NoopMailObserver)).unwrap();
1990        let error = service.send(message()).await.unwrap_err();
1991        assert!(error.is_ambiguous());
1992        assert_eq!(fallback.count().await, 0);
1993    }
1994
1995    #[tokio::test]
1996    async fn explicit_unavailability_can_use_fallback() {
1997        let primary = Arc::new(ScriptedTransport {
1998            name: "primary",
1999            outcomes: Mutex::new(VecDeque::from([Err(MailErrorKind::Unavailable)])),
2000        });
2001        let fallback = Arc::new(MemoryMailTransport::named("fallback").unwrap());
2002        let observer = Arc::new(MemoryMailObserver::default());
2003        let service = MailService::new(vec![primary, fallback.clone()], observer.clone()).unwrap();
2004        let receipt = service.send(message()).await.unwrap();
2005        assert_eq!(receipt.transport, "fallback");
2006        assert_eq!(fallback.count().await, 1);
2007        assert_eq!(observer.events().await.len(), 5);
2008    }
2009
2010    #[tokio::test]
2011    async fn invalid_acceptance_receipt_emits_ambiguous_failure_observation() {
2012        let observer = Arc::new(MemoryMailObserver::default());
2013        let service =
2014            MailService::single(Arc::new(InvalidReceiptTransport), observer.clone()).unwrap();
2015        let error = service.send(message()).await.unwrap_err();
2016        assert_eq!(error.kind, MailErrorKind::Ambiguous);
2017        let events = observer.events().await;
2018        assert_eq!(events.len(), 3);
2019        assert_eq!(events[2].kind, MailSubmissionEventKind::AttemptFailed);
2020        assert_eq!(events[2].failure_kind, Some(MailErrorKind::Ambiguous));
2021    }
2022
2023    #[tokio::test]
2024    async fn slow_first_observer_does_not_suppress_later_observers() {
2025        let fast = Arc::new(MemoryMailObserver::default());
2026        let observer = Arc::new(
2027            CompositeMailObserver::new(vec![Arc::new(SlowObserver), fast.clone()]).unwrap(),
2028        );
2029        let service =
2030            MailService::single(Arc::new(MemoryMailTransport::default()), observer).unwrap();
2031        let started = Instant::now();
2032        service.send(message()).await.unwrap();
2033        assert_eq!(fast.events().await.len(), 3);
2034        assert!(started.elapsed() < Duration::from_millis(400));
2035    }
2036
2037    #[tokio::test]
2038    async fn delivery_sink_deduplicates_provider_events() {
2039        let sink = MemoryMailDeliveryEventSink::default();
2040        let event = MailDeliveryEvent {
2041            source_event_id: "provider-event-1".into(),
2042            message_id: Uuid::now_v7(),
2043            topic: "invoice.ready".into(),
2044            transport: "aws.ses".into(),
2045            kind: MailDeliveryEventKind::Delivered,
2046            occurred_at: Utc::now(),
2047            provider_message_id: Some("provider-message".into()),
2048        };
2049        assert_eq!(
2050            sink.record(event.clone()).await.unwrap(),
2051            MailDeliveryDisposition::Recorded
2052        );
2053        assert_eq!(
2054            sink.record(event).await.unwrap(),
2055            MailDeliveryDisposition::Duplicate
2056        );
2057        assert_eq!(sink.events().await.len(), 1);
2058    }
2059
2060    #[tokio::test]
2061    async fn tracing_delivery_sink_also_deduplicates_provider_events() {
2062        let sink = TracingMailDeliveryEventSink::default();
2063        let event = MailDeliveryEvent {
2064            source_event_id: "provider-event-1".into(),
2065            message_id: Uuid::now_v7(),
2066            topic: "invoice.ready".into(),
2067            transport: "aws.ses".into(),
2068            kind: MailDeliveryEventKind::Delivered,
2069            occurred_at: Utc::now(),
2070            provider_message_id: Some("provider-message".into()),
2071        };
2072        assert_eq!(
2073            sink.record(event.clone()).await.unwrap(),
2074            MailDeliveryDisposition::Recorded
2075        );
2076        assert_eq!(
2077            sink.record(event).await.unwrap(),
2078            MailDeliveryDisposition::Duplicate
2079        );
2080    }
2081
2082    #[test]
2083    fn tracing_delivery_dedupe_window_evicts_the_oldest_id() {
2084        let mut window = DeliveryDedupeWindow::new(2);
2085        assert!(window.record_if_new("one"));
2086        assert!(window.record_if_new("two"));
2087        assert!(!window.record_if_new("one"));
2088        assert!(window.record_if_new("three"));
2089        assert!(window.record_if_new("one"));
2090    }
2091}