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