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
915pub 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}