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