daaki_message/types.rs
1//! Public API types for parsed and outgoing email messages.
2//!
3//! All types are fully owned (`String` / `Vec<u8>`) with no lifetime parameters,
4//! per workspace conventions.
5//!
6//! # References
7//! - RFC 5322 (Internet Message Format — address, date-time)
8//! - RFC 2045 (MIME — Content-Transfer-Encoding)
9//! - RFC 2183 (Content-Disposition)
10//! - RFC 3501 Section 6.4.5 (IMAP MIME section numbers)
11
12// ---------------------------------------------------------------------------
13// Validated newtypes
14// ---------------------------------------------------------------------------
15
16/// Error returned when constructing a validated protocol type from an invalid string.
17///
18/// Shared across `daaki-imap` and `daaki-smtp` for validated newtypes
19/// such as `SequenceSet`, `MailboxName`, `Domain`, `ForwardPath`, etc.
20/// Each crate's [`Error`] type provides a blanket `From<ValidationError>`
21/// conversion so these can be used with `?` in application code.
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[error("{0}")]
25pub struct ValidationError(String);
26
27impl ValidationError {
28 /// Create a new validation error with the given message.
29 pub fn new(message: impl Into<String>) -> Self {
30 Self(message.into())
31 }
32
33 /// Return the error message as a string slice.
34 pub fn message(&self) -> &str {
35 &self.0
36 }
37}
38
39/// A validated RFC 5322 header field name.
40///
41/// RFC 5322 Section 2.2 defines a field name as `1*ftext`, where
42/// `ftext = %d33-57 / %d59-126` — printable US-ASCII excluding the colon
43/// (`:`). This newtype enforces that constraint at construction time so
44/// that downstream code can rely on the name being well-formed.
45///
46/// # Case Insensitivity
47///
48/// Comparison and hashing are case-insensitive per RFC 5322 Section 2.2,
49/// so `HeaderName::new("Subject")` and `HeaderName::new("subject")` are equal.
50///
51/// # References
52/// - RFC 5322 Section 2.2 (header field syntax)
53#[non_exhaustive]
54#[derive(Debug, Clone)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
57pub struct HeaderName(String);
58
59impl HeaderName {
60 /// Creates a new `HeaderName` after validating ftext syntax
61 /// (RFC 5322 Section 2.2).
62 ///
63 /// The name must be non-empty and consist solely of printable US-ASCII
64 /// characters (33..=126) excluding colon (58).
65 ///
66 /// # Errors
67 ///
68 /// Returns [`crate::error::Error::InvalidHeaderName`] if the name is
69 /// empty or contains invalid characters.
70 ///
71 /// # References
72 /// - RFC 5322 Section 2.2
73 pub fn new(s: impl Into<String>) -> crate::Result<Self> {
74 let s = s.into();
75 validate_ftext(&s)?;
76 Ok(Self(s))
77 }
78
79 /// Creates a `HeaderName` without validation — test-only.
80 #[cfg(test)]
81 pub(crate) fn new_unchecked(s: impl Into<String>) -> Self {
82 Self(s.into())
83 }
84
85 /// Returns the header name as a string slice.
86 pub fn as_str(&self) -> &str {
87 &self.0
88 }
89
90 /// Consumes self and returns the inner `String`.
91 pub fn into_inner(self) -> String {
92 self.0
93 }
94}
95
96impl AsRef<str> for HeaderName {
97 fn as_ref(&self) -> &str {
98 &self.0
99 }
100}
101
102/// Internet message field names are case-insensitive during comparison.
103///
104/// RFC 822 Section 3.4.7 states this rule explicitly, and RFC 5322 retains
105/// the same `field-name` model for message headers.
106impl PartialEq for HeaderName {
107 fn eq(&self, other: &Self) -> bool {
108 self.0.eq_ignore_ascii_case(&other.0)
109 }
110}
111
112impl Eq for HeaderName {}
113
114/// Hash header names case-insensitively so hashing matches equality.
115///
116/// RFC 822 Section 3.4.7: field-name case is not distinguished. RFC 5322
117/// continues the same header field model.
118impl std::hash::Hash for HeaderName {
119 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
120 for byte in self.0.as_bytes() {
121 byte.to_ascii_lowercase().hash(state);
122 }
123 }
124}
125
126/// Order header names case-insensitively (RFC 5322 Section 2.2).
127impl PartialOrd for HeaderName {
128 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
129 Some(self.cmp(other))
130 }
131}
132
133/// Total ordering on header names, case-insensitive (RFC 5322 Section 2.2).
134impl Ord for HeaderName {
135 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
136 let a = self.0.as_bytes().iter().map(u8::to_ascii_lowercase);
137 let b = other.0.as_bytes().iter().map(u8::to_ascii_lowercase);
138 a.cmp(b)
139 }
140}
141
142impl std::fmt::Display for HeaderName {
143 /// Displays the header name verbatim (RFC 5322 Section 2.2).
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.write_str(&self.0)
146 }
147}
148
149impl TryFrom<String> for HeaderName {
150 type Error = crate::error::Error;
151
152 /// Converts a `String` into a validated `HeaderName` (RFC 5322 Section 2.2).
153 fn try_from(s: String) -> Result<Self, Self::Error> {
154 Self::new(s)
155 }
156}
157
158impl TryFrom<&str> for HeaderName {
159 type Error = crate::error::Error;
160
161 /// Converts a `&str` into a validated `HeaderName` (RFC 5322 Section 2.2).
162 fn try_from(s: &str) -> Result<Self, Self::Error> {
163 Self::new(s)
164 }
165}
166
167impl From<HeaderName> for String {
168 fn from(h: HeaderName) -> Self {
169 h.into_inner()
170 }
171}
172
173/// Validates that `s` is a valid `ftext` sequence per RFC 5322 Section 2.2.
174///
175/// `field-name = 1*ftext` where `ftext = %d33-57 / %d59-126` — printable
176/// US-ASCII characters excluding colon (`:`).
177///
178/// # References
179/// - RFC 5322 Section 2.2
180fn validate_ftext(s: &str) -> crate::Result<()> {
181 if s.is_empty() {
182 return Err(crate::error::Error::InvalidHeaderName(
183 "header name must not be empty".into(),
184 ));
185 }
186 if let Some(bad) = s.chars().find(|&c| {
187 // ftext: printable ASCII (33..=126) excluding ':' (58)
188 let b = c as u32;
189 !(33..=126).contains(&b) || c == ':'
190 }) {
191 return Err(crate::error::Error::InvalidHeaderName(format!(
192 "header name contains invalid character {bad:?} (RFC 5322 Section 2.2)"
193 )));
194 }
195 Ok(())
196}
197
198/// A validated RFC 5322 message-ID (bare, without angle brackets).
199///
200/// RFC 5322 Section 3.6.4 defines `msg-id = "<" id-left "@" id-right ">"`.
201/// This type stores only the bare `id-left "@" id-right` content. Angle
202/// brackets are added by the builder when emitting wire format.
203///
204/// # Validation rules
205/// - Non-empty
206/// - Exactly one `@` separator
207/// - `id-left` and `id-right` must each be non-empty
208/// - No whitespace, angle brackets, or control characters
209///
210/// # References
211/// - RFC 5322 Section 3.6.4 (message identification)
212#[non_exhaustive]
213#[derive(Debug, Clone, PartialEq, Eq, Hash)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
216pub struct MessageId(String);
217
218impl MessageId {
219 /// Creates a new `MessageId` after validation (RFC 5322 Section 3.6.4).
220 ///
221 /// The value must be a bare message-ID (no angle brackets) containing
222 /// exactly one `@` with non-empty parts on each side, and no
223 /// whitespace, angle brackets, or control characters.
224 ///
225 /// # Errors
226 ///
227 /// Returns [`crate::error::Error::InvalidMessageId`] if validation fails.
228 ///
229 /// # References
230 /// - RFC 5322 Section 3.6.4
231 pub fn new(s: impl Into<String>) -> crate::Result<Self> {
232 let s = s.into();
233 validate_message_id(&s)?;
234 Ok(Self(s))
235 }
236
237 /// Creates a `MessageId` without validation — test-only.
238 #[cfg(test)]
239 pub(crate) fn new_unchecked(s: impl Into<String>) -> Self {
240 Self(s.into())
241 }
242
243 /// Returns the message-ID as a string slice.
244 pub fn as_str(&self) -> &str {
245 &self.0
246 }
247
248 /// Consumes self and returns the inner `String`.
249 pub fn into_inner(self) -> String {
250 self.0
251 }
252}
253
254impl AsRef<str> for MessageId {
255 fn as_ref(&self) -> &str {
256 &self.0
257 }
258}
259
260impl std::fmt::Display for MessageId {
261 /// Displays the bare message-ID (RFC 5322 Section 3.6.4).
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 f.write_str(&self.0)
264 }
265}
266
267impl TryFrom<String> for MessageId {
268 type Error = crate::error::Error;
269
270 /// Converts a `String` into a validated `MessageId` (RFC 5322 Section 3.6.4).
271 fn try_from(s: String) -> Result<Self, Self::Error> {
272 Self::new(s)
273 }
274}
275
276impl TryFrom<&str> for MessageId {
277 type Error = crate::error::Error;
278
279 /// Converts a `&str` into a validated `MessageId` (RFC 5322 Section 3.6.4).
280 fn try_from(s: &str) -> Result<Self, Self::Error> {
281 Self::new(s)
282 }
283}
284
285impl From<MessageId> for String {
286 fn from(m: MessageId) -> Self {
287 m.into_inner()
288 }
289}
290
291/// Validates a bare message-ID per RFC 5322 Section 3.6.4.
292///
293/// The value must contain exactly one `@`. For outbound/public API values,
294/// RFC 5322 Section 3.6.4 permits only modern generated forms:
295/// `id-left = dot-atom-text` and `id-right = dot-atom-text / no-fold-literal`.
296/// Obsolete parser-only forms remain accepted by the inbound parser via
297/// separate recovery helpers.
298///
299/// RFC 6532 Section 3.2 extends the relevant `atext`, `qtext`, and `dtext`
300/// productions to permit UTF-8 non-ASCII.
301///
302/// # References
303/// - RFC 5322 Section 3.6.4
304/// - RFC 5322 Section 3.2.3
305/// - RFC 5322 Section 3.2.4
306/// - RFC 6532 Section 3.2
307fn validate_message_id(s: &str) -> crate::Result<()> {
308 if s.is_empty() {
309 return Err(crate::error::Error::InvalidMessageId(
310 "message-ID must not be empty".into(),
311 ));
312 }
313
314 let Some((id_left, id_right)) = split_strict_message_id_parts(s) else {
315 return Err(crate::error::Error::InvalidMessageId(
316 "message-ID must contain exactly one '@' \
317 (RFC 5322 Section 3.6.4)"
318 .into(),
319 ));
320 };
321
322 if id_left.is_empty() {
323 return Err(crate::error::Error::InvalidMessageId(
324 "message-ID id-left (before '@') must not be empty \
325 (RFC 5322 Section 3.6.4)"
326 .into(),
327 ));
328 }
329 if id_right.is_empty() {
330 return Err(crate::error::Error::InvalidMessageId(
331 "message-ID id-right (after '@') must not be empty \
332 (RFC 5322 Section 3.6.4)"
333 .into(),
334 ));
335 }
336
337 // RFC 5322 Section 3.6.4 prose says generated msg-id syntax "only permits
338 // the dot-atom-text form on the left-hand side of the @". Appendix A notes
339 // that no-fold-quote was removed from the modern msg-id syntax, even
340 // though obsolete parser forms still exist in Section 4.5.4.
341 if !is_message_id_dot_atom_text(id_left) {
342 return Err(crate::error::Error::InvalidMessageId(
343 "message-ID id-left must be dot-atom-text \
344 (RFC 5322 Section 3.6.4)"
345 .into(),
346 ));
347 }
348 if !(is_message_id_dot_atom_text(id_right) || is_message_id_no_fold_literal(id_right)) {
349 return Err(crate::error::Error::InvalidMessageId(
350 "message-ID id-right must be dot-atom-text or no-fold-literal \
351 (RFC 5322 Sections 3.6.4 and 3.2.3)"
352 .into(),
353 ));
354 }
355
356 Ok(())
357}
358
359/// Returns `true` when `s` is a syntactically valid bare message-ID body for
360/// liberal inbound parsing.
361///
362/// RFC 5322 Section 4.5.4 still defines obsolete `obs-id-left` /
363/// `obs-id-right` forms that parsers may recover from inbound messages, even
364/// though generators and strict public validators should not emit them.
365///
366/// # References
367/// - RFC 5322 Section 3.6.4
368/// - RFC 5322 Section 4.5.4
369/// - RFC 5322 Section 3.2.3
370/// - RFC 5322 Section 3.2.4
371/// - RFC 6532 Section 3.2
372pub(crate) fn is_valid_bare_message_id_body(s: &str) -> bool {
373 split_liberal_message_id_parts(s).is_some_and(|(id_left, id_right)| {
374 !id_left.is_empty()
375 && !id_right.is_empty()
376 && (is_message_id_dot_atom_text(id_left) || is_message_id_no_fold_quote(id_left))
377 && (is_message_id_dot_atom_text(id_right) || is_message_id_no_fold_literal(id_right))
378 })
379}
380
381/// Returns `true` when `s` is a syntactically valid bare message-ID body for
382/// generated output and strict public API validation.
383///
384/// Unlike liberal parser recovery, this follows the modern generation rule
385/// from RFC 5322 Section 3.6.4: `id-left` is limited to `dot-atom-text`.
386///
387/// # References
388/// - RFC 5322 Section 3.6.4
389/// - RFC 5322 Section 3.2.3
390/// - RFC 6532 Section 3.2
391pub(crate) fn is_strict_bare_message_id_body(s: &str) -> bool {
392 split_strict_message_id_parts(s).is_some_and(|(id_left, id_right)| {
393 !id_left.is_empty()
394 && !id_right.is_empty()
395 && is_message_id_dot_atom_text(id_left)
396 && (is_message_id_dot_atom_text(id_right) || is_message_id_no_fold_literal(id_right))
397 })
398}
399
400/// Split a bare message-ID body into `id-left` and `id-right` at the single
401/// `@` separator for strict/generated validation.
402///
403/// RFC 5322 Section 3.6.4 limits generated `msg-id` values to dot-atom
404/// `id-left`, so any additional `@` character makes the identifier invalid.
405///
406/// # References
407/// - RFC 5322 Section 3.6.4
408fn split_strict_message_id_parts(s: &str) -> Option<(&str, &str)> {
409 let (id_left, id_right) = s.split_once('@')?;
410 if id_right.contains('@') {
411 return None;
412 }
413 Some((id_left, id_right))
414}
415
416/// Split a bare message-ID body into `id-left` and `id-right` at the single
417/// unquoted `@` separator for liberal inbound parsing.
418///
419/// RFC 5322 Section 4.5.4 allows obsolete local-part syntax on the left-hand
420/// side, so `@` characters inside quoted strings are not treated as
421/// separators during parser recovery.
422///
423/// # References
424/// - RFC 5322 Section 3.6.4
425/// - RFC 5322 Section 4.5.4
426/// - RFC 5322 Section 3.2.4
427fn split_liberal_message_id_parts(s: &str) -> Option<(&str, &str)> {
428 let bytes = s.as_bytes();
429 let mut in_quotes = false;
430 let mut escaped = false;
431 let mut at_pos = None;
432
433 for (index, &byte) in bytes.iter().enumerate() {
434 if in_quotes {
435 if escaped {
436 escaped = false;
437 continue;
438 }
439
440 match byte {
441 b'\\' => escaped = true,
442 b'"' => in_quotes = false,
443 _ => {}
444 }
445 continue;
446 }
447
448 match byte {
449 b'"' => in_quotes = true,
450 b'@' => {
451 if at_pos.is_some() {
452 return None;
453 }
454 at_pos = Some(index);
455 }
456 _ => {}
457 }
458 }
459
460 if in_quotes || escaped {
461 return None;
462 }
463
464 at_pos.map(|index| (&s[..index], &s[index + 1..]))
465}
466
467/// Returns `true` when `s` matches RFC 5322 Section 3.2.3 `dot-atom-text`.
468///
469/// RFC 6532 Section 3.2 extends `atext` to include UTF-8 non-ASCII octets.
470///
471/// # References
472/// - RFC 5322 Section 3.2.3
473/// - RFC 6532 Section 3.2
474fn is_message_id_dot_atom_text(s: &str) -> bool {
475 if s.is_empty() || s.starts_with('.') || s.ends_with('.') || s.contains("..") {
476 return false;
477 }
478
479 s.bytes()
480 .all(|byte| is_message_id_atext(byte) || byte == b'.' || byte >= 0x80)
481}
482
483/// Returns `true` when `s` matches the quoted-string form allowed by obsolete
484/// `obs-id-left` parser recovery.
485///
486/// RFC 6532 Section 3.2 extends `qtext` to include UTF-8 non-ASCII octets.
487///
488/// # References
489/// - RFC 5322 Section 4.5.4
490/// - RFC 5322 Section 3.4.1
491/// - RFC 5322 Section 3.2.4
492/// - RFC 6532 Section 3.2
493fn is_message_id_no_fold_quote(s: &str) -> bool {
494 let Some(inner) = s
495 .strip_prefix('"')
496 .and_then(|value| value.strip_suffix('"'))
497 else {
498 return false;
499 };
500
501 let bytes = inner.as_bytes();
502 let mut index = 0;
503 while index < bytes.len() {
504 let byte = bytes[index];
505 if byte == b'\\' {
506 index += 1;
507 if index >= bytes.len() {
508 return false;
509 }
510
511 let escaped = bytes[index];
512 if !(escaped == b'\t' || (0x20..=0x7E).contains(&escaped)) {
513 return false;
514 }
515 } else if !(matches!(byte, 33 | 35..=91 | 93..=126) || byte >= 0x80) {
516 return false;
517 }
518 index += 1;
519 }
520
521 true
522}
523
524/// Returns `true` when `s` matches RFC 5322 Section 3.6.4 `no-fold-literal`.
525///
526/// RFC 6532 Section 3.2 extends `dtext` to include UTF-8 non-ASCII octets.
527///
528/// # References
529/// - RFC 5322 Section 3.6.4
530/// - RFC 6532 Section 3.2
531fn is_message_id_no_fold_literal(s: &str) -> bool {
532 let Some(inner) = s
533 .strip_prefix('[')
534 .and_then(|value| value.strip_suffix(']'))
535 else {
536 return false;
537 };
538
539 inner
540 .bytes()
541 .all(|byte| matches!(byte, 33..=90 | 94..=126) || byte >= 0x80)
542}
543
544/// Returns `true` when `byte` is RFC 5322 `atext`.
545///
546/// # References
547/// - RFC 5322 Section 3.2.3
548fn is_message_id_atext(byte: u8) -> bool {
549 byte.is_ascii_alphanumeric()
550 || matches!(
551 byte,
552 b'!' | b'#'
553 | b'$'
554 | b'%'
555 | b'&'
556 | b'\''
557 | b'*'
558 | b'+'
559 | b'-'
560 | b'/'
561 | b'='
562 | b'?'
563 | b'^'
564 | b'_'
565 | b'`'
566 | b'{'
567 | b'|'
568 | b'}'
569 | b'~'
570 )
571}
572
573// ---------------------------------------------------------------------------
574
575/// A parsed email message.
576///
577/// Produced by [`crate::parse_email`]. All fields are owned.
578/// Missing or unparseable optional fields are `None`.
579///
580/// # References
581/// - RFC 5322 (message structure)
582/// - RFC 2045–2046 (MIME body parts)
583#[non_exhaustive]
584#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
585#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
586pub struct ParsedEmail {
587 /// Message-ID with angle brackets stripped (RFC 5322 Section 3.6.4).
588 ///
589 /// This field uses `String` rather than [`MessageId`] because parsed
590 /// messages may originate from non-conformant sources whose message IDs
591 /// do not pass strict RFC 5322 validation. The parser follows Postel's
592 /// law: accept what you can, even if the value is technically malformed.
593 /// [`OutgoingEmail::in_reply_to`] and [`OutgoingEmail::references`] use
594 /// the validated [`MessageId`] type to ensure outgoing messages are
595 /// strictly conformant.
596 pub message_id: Option<String>,
597 /// All `In-Reply-To` message-ids, angle brackets stripped (RFC 5322 Section 3.6.4).
598 ///
599 /// RFC 5322: `in-reply-to = "In-Reply-To:" 1*msg-id CRLF` — multiple
600 /// message-IDs are valid. Each element is a single message-ID without
601 /// angle brackets.
602 ///
603 /// Uses `String` rather than [`MessageId`] for the same reason as
604 /// [`message_id`](Self::message_id): parsed messages may contain
605 /// non-conformant IDs that would fail strict validation.
606 pub in_reply_to: Vec<String>,
607 /// All `References` message-ids, angle brackets stripped (RFC 5322 Section 3.6.4).
608 ///
609 /// RFC 5322: `references = "References:" 1*msg-id CRLF`. Each element is
610 /// a single message-ID without angle brackets.
611 ///
612 /// Uses `String` rather than [`MessageId`] for the same reason as
613 /// [`message_id`](Self::message_id): parsed messages may contain
614 /// non-conformant IDs that would fail strict validation.
615 pub references: Vec<String>,
616 /// Decoded subject (RFC 5322 Section 3.6.5, RFC 2047 encoded words decoded).
617 pub subject: Option<String>,
618 /// Originator mailboxes (RFC 5322 Section 3.6.2: `from = "From:" mailbox-list`).
619 pub from: Vec<Address>,
620 /// The agent responsible for actual transmission of the message
621 /// (RFC 5322 Section 3.6.2: `sender = "Sender:" mailbox`).
622 ///
623 /// Contains exactly one mailbox. Required when `From` contains multiple
624 /// addresses; otherwise optional. `None` when the header is absent.
625 pub sender: Option<Address>,
626 /// To recipients (RFC 5322 Section 3.6.3).
627 pub to: Vec<Address>,
628 /// Cc recipients (RFC 5322 Section 3.6.3).
629 pub cc: Vec<Address>,
630 /// Bcc recipients (RFC 5322 Section 3.6.3).
631 pub bcc: Vec<Address>,
632 /// Reply-To addresses (RFC 5322 Section 3.6.2).
633 pub reply_to: Vec<Address>,
634 /// Parsed date with original timezone offset preserved (RFC 5322 Section 3.3).
635 pub date: Option<DateTime>,
636 /// First `text/plain` body part, decoded to UTF-8.
637 pub body_text: Option<String>,
638 /// First `text/html` body part, decoded to UTF-8.
639 pub body_html: Option<String>,
640 /// Detected attachments with IMAP section numbers.
641 pub attachments: Vec<ParsedAttachment>,
642 /// Raw header bytes as a `String` (everything before the header/body separator).
643 pub raw_headers: String,
644 /// Optional fields not defined by RFC 5322 (RFC 5322 Section 3.6.8).
645 ///
646 /// Contains all headers that are not one of the well-known structured
647 /// headers (From, To, Cc, Bcc, Reply-To, Subject, Date, Message-ID,
648 /// In-Reply-To, References, Sender, Content-Type,
649 /// Content-Transfer-Encoding, MIME-Version), plus any top-level
650 /// `Content-Disposition` or `Content-ID` field preserved for RFC 2183
651 /// Section 2.10 and RFC 2045 Section 7 consumers. Each entry is a
652 /// `(lowercase-name, decoded-value)` pair, preserving the order they
653 /// appeared in the message.
654 pub extra_headers: Vec<(String, String)>,
655 /// Total byte count of the input.
656 pub size: u64,
657}
658
659impl ParsedEmail {
660 /// Convert extra headers to validated `(HeaderName, String)` pairs
661 /// suitable for use in [`OutgoingEmail::extra_headers`].
662 ///
663 /// Headers whose names fail RFC 5322 Section 2.2 validation are
664 /// silently dropped — such names originate from non-conformant
665 /// messages and cannot be reproduced in well-formed output.
666 pub fn validated_extra_headers(&self) -> Vec<(HeaderName, String)> {
667 self.extra_headers
668 .iter()
669 .filter_map(|(name, value)| {
670 HeaderName::new(name.clone())
671 .ok()
672 .map(|hn| (hn, value.clone()))
673 })
674 .collect()
675 }
676}
677
678/// An email address with optional display name.
679///
680/// # References
681/// - RFC 5322 Section 3.4 (address specification)
682#[non_exhaustive]
683#[derive(Debug, Clone, PartialEq, Eq, Hash)]
684#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
685pub struct Address {
686 /// Display name, decoded from RFC 2047 encoded words if present.
687 pub name: Option<String>,
688 /// Email address (`addr-spec` form).
689 pub email: String,
690}
691
692impl Address {
693 /// Creates an address with only an email, validating the syntax.
694 ///
695 /// # Errors
696 ///
697 /// Returns [`crate::Error::InvalidAddress`] if the email is not valid
698 /// per RFC 5322 Section 3.4.
699 pub fn new(email: impl Into<String>) -> crate::Result<Self> {
700 let addr = Self {
701 name: None,
702 email: email.into(),
703 };
704 crate::builder::validate_address(&addr)?;
705 Ok(addr)
706 }
707
708 /// Creates an address with a display name and email, validating the syntax.
709 ///
710 /// # Errors
711 ///
712 /// Returns [`crate::Error::InvalidAddress`] if the email is not valid
713 /// per RFC 5322 Section 3.4.
714 pub fn with_name(name: impl Into<String>, email: impl Into<String>) -> crate::Result<Self> {
715 let addr = Self {
716 name: Some(name.into()),
717 email: email.into(),
718 };
719 crate::builder::validate_address(&addr)?;
720 Ok(addr)
721 }
722
723 /// Creates an `Address` **without validating** the email syntax.
724 ///
725 /// # You almost certainly want [`Address::new`] or [`Address::with_name`] instead
726 ///
727 /// Those constructors validate the email address against RFC 5322
728 /// Section 3.4 and reject malformed input. An `Address` built with
729 /// `new_unchecked` may contain syntax that is invalid for outgoing
730 /// mail, which can cause:
731 ///
732 /// - Rejected `RCPT TO` commands at the SMTP layer.
733 /// - Malformed `From`/`To`/`Cc` headers that downstream MTAs or MUAs
734 /// cannot parse.
735 /// - Messages that silently disappear into spam filters or bounce
736 /// queues.
737 ///
738 /// # When this method is appropriate
739 ///
740 /// This exists for **parser and protocol-conversion code** that must
741 /// accept addresses already received from the network, where Postel's
742 /// law applies (RFC 5322 Section 3.4): be liberal in what you accept.
743 /// For example, an IMAP ENVELOPE address or a `From:` header parsed
744 /// from an incoming message may contain syntax that is technically
745 /// non-conformant but still meaningful. Rejecting it would discard
746 /// data the user needs to see.
747 ///
748 /// If you are **building a new outgoing message**, use [`Address::new`]
749 /// or [`Address::with_name`] — they enforce the same validation the
750 /// message builder applies and will catch mistakes at construction
751 /// time rather than at send time.
752 pub fn new_unchecked(name: Option<String>, email: String) -> Self {
753 Self { name, email }
754 }
755}
756
757/// RFC 5322 specials that require a display name to be quoted.
758const SPECIALS: &[char] = &[
759 '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"',
760];
761
762impl std::fmt::Display for Address {
763 /// Formats the address per RFC 5322 Section 3.4.
764 ///
765 /// - Non-ASCII display names are RFC 2047 encoded (RFC 5322 Section 2.2
766 /// requires header field bodies to be US-ASCII; RFC 2047 Section 5
767 /// defines the encoded-word mechanism for non-ASCII text).
768 /// - ASCII display names containing specials are quoted per RFC 5322 Section 3.2.4.
769 /// - Without display name: bare `email`
770 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771 match &self.name {
772 // RFC 5322 Section 3.4: display-name is a `phrase` (`1*word`).
773 // Whitespace alone does not form a valid word, so treat it as absent.
774 Some(name) if !name.trim().is_empty() => {
775 if !name.is_ascii() || name.bytes().any(|b| (b < 0x20 && b != b'\t') || b == 0x7F) {
776 // RFC 5322 Section 2.2: field bodies must be US-ASCII printable.
777 // RFC 5322 Section 3.2.3: atext excludes control characters.
778 // HTAB (0x09) is valid WSP per RFC 5322 Section 2.2.
779 // RFC 2047 Section 5: use encoded-words for non-ASCII/non-printable text.
780 let encoded = crate::builder::encode_rfc2047_if_needed(name);
781 write!(f, "{encoded} <{}>", self.email)
782 } else if name.contains("=?") {
783 // RFC 2047 Section 5: an unquoted phrase may contain
784 // encoded-words. A display name that literally contains
785 // `=?` would be mis-decoded by the parser. Quoting
786 // prevents this: RFC 2047 Section 5 says encoded-words
787 // MUST NOT appear inside a quoted-string.
788 let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
789 write!(f, "\"{escaped}\" <{}>", self.email)
790 } else if name.chars().any(|c| SPECIALS.contains(&c)) {
791 let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
792 write!(f, "\"{escaped}\" <{}>", self.email)
793 } else {
794 write!(f, "{name} <{}>", self.email)
795 }
796 }
797 _ => write!(f, "{}", self.email),
798 }
799 }
800}
801
802impl std::str::FromStr for Address {
803 type Err = crate::error::Error;
804
805 /// Parses an address from a string (RFC 5322 Section 3.4).
806 ///
807 /// Accepts both `Display Name <email>` and bare `email` forms.
808 /// Decodes RFC 2047 encoded words in the display name (RFC 2047 Section 5).
809 #[allow(clippy::too_many_lines)]
810 fn from_str(s: &str) -> Result<Self, Self::Err> {
811 let s = s.trim();
812 if s.is_empty() {
813 return Err(crate::error::Error::InvalidAddress(
814 "empty address string".into(),
815 ));
816 }
817
818 // Try "Display Name <email>" form
819 if let Some(angle_start) = s.rfind('<') {
820 if let Some(angle_end) = s.rfind('>') {
821 if angle_end > angle_start {
822 let trailing = &s[angle_end + 1..];
823 validate_trailing_cfws(
824 trailing,
825 s,
826 "angle-addr",
827 "RFC 5322 Section 3.4 / Section 3.2.2",
828 )?;
829 let mut email = s[angle_start + 1..angle_end].trim().to_string();
830 // RFC 5322 Section 4.4: strip obsolete source route
831 // (obs-route = obs-domain-list ":"). Example:
832 // `<@hop1,@hop2:user@domain>` → `user@domain`.
833 if email.starts_with('@') {
834 if let Some(colon) = email.find(':') {
835 email = email[colon + 1..].trim().to_string();
836 }
837 }
838 let name_part = s[..angle_start].trim();
839 let name = crate::parser::normalize_display_name_phrase(name_part);
840 if email.is_empty() {
841 return Err(crate::error::Error::InvalidAddress(
842 "empty email in angle brackets".into(),
843 ));
844 }
845 let addr = Self { name, email };
846 // RFC 5322 Section 3.4.1 / RFC 6531 Section 3.3: parse
847 // the display name leniently, but validate the addr-spec
848 // with the same rules the builder enforces for emission.
849 crate::builder::validate_address(&addr)?;
850 return Ok(addr);
851 }
852 }
853 }
854
855 // Bare email — RFC 5322 Section 3.4.1: `addr-spec = local-part "@" domain`.
856 // Both local-part and domain must be non-empty.
857 //
858 // Also handles comments (RFC 5322 Section 3.2.2) before or after the
859 // addr-spec: `addr-spec (comment)` or `(comment) addr-spec`. The
860 // comment text is extracted as the display name, matching the behavior
861 // of `parse_single_address` in the parser.
862 if s.find('@').is_some() {
863 // Check for a parenthesized comment: `user@host (Name)` (trailing)
864 // or `(Name) user@host` (leading).
865 // RFC 5322 Section 3.2.2: `comment = "(" *([FWS] ccontent) [FWS] ")"`.
866 let (addr_part, comment_name) = if let Some(paren_start) =
867 crate::parser::find_paren_outside_quotes(s)
868 {
869 let before_paren = s[..paren_start].trim();
870 let comment_and_rest = &s[paren_start..];
871 // Extract comment content, handling nested parentheses
872 // (RFC 5322 Section 3.2.2).
873 let after_paren = &s[paren_start + 1..];
874 let name = strip_comment_delimiters(after_paren);
875 let comment_name = if name.is_empty() { None } else { Some(name) };
876
877 if !before_paren.is_empty() && before_paren.contains('@') {
878 // Trailing comment: `user@host (Name)`
879 validate_trailing_cfws(
880 comment_and_rest,
881 s,
882 "addr-spec comment",
883 "RFC 5322 Section 3.4.1 / Section 3.2.2",
884 )?;
885 (before_paren, comment_name)
886 } else {
887 // Leading comment: `(Name) user@host`
888 // RFC 5322 Section 3.2.2: CFWS (including comments) may
889 // appear before the addr-spec.
890 // Find the text after the closing paren for the addr-spec.
891 let close_paren = find_matching_close_paren(&s[paren_start..]);
892 let after_comment = s.get(paren_start + close_paren + 1..).unwrap_or("").trim();
893 if !after_comment.is_empty() && after_comment.contains('@') {
894 (after_comment, comment_name)
895 } else {
896 // `@` was inside the comment, not in the address
897 (before_paren, comment_name)
898 }
899 }
900 } else {
901 (s, None)
902 };
903
904 // Find `@` in the addr_part (not in the original string `s`),
905 // since `addr_part` may be a shorter substring when a comment
906 // was stripped. Fall back to the full addr_part length if there
907 // is no `@` in addr_part (which means the `@` was inside the
908 // comment, not in the address).
909 let at_in_addr = addr_part.find('@').unwrap_or(addr_part.len());
910 let local = &addr_part[..at_in_addr];
911 let domain = addr_part.get(at_in_addr + 1..).unwrap_or("");
912 if local.is_empty() || domain.is_empty() {
913 return Err(crate::error::Error::InvalidAddress(format!(
914 "invalid bare email (empty local-part or domain): {s} \
915 (RFC 5322 Section 3.4.1)"
916 )));
917 }
918 let addr = Self {
919 name: comment_name,
920 email: addr_part.to_string(),
921 };
922 // RFC 5322 Section 3.4.1 / RFC 6531 Section 3.3: bare addr-spec
923 // syntax must satisfy the same validation rules used by the
924 // message builder so public parsing and emission stay aligned.
925 crate::builder::validate_address(&addr)?;
926 return Ok(addr);
927 }
928
929 Err(crate::error::Error::InvalidAddress(format!(
930 "cannot parse address: {s}"
931 )))
932 }
933}
934
935/// Finds the offset (from the start of `s`) of the closing `)` that matches
936/// the opening `(` at position 0, respecting nesting and backslash escapes.
937///
938/// Returns `s.len() - 1` if no matching close paren is found (Postel's law:
939/// treat the rest of the string as the comment body).
940///
941/// # References
942/// - RFC 5322 Section 3.2.2 (nested comments, quoted-pair in comments)
943fn find_matching_close_paren(s: &str) -> usize {
944 let bytes = s.as_bytes();
945 if bytes.is_empty() || bytes[0] != b'(' {
946 return 0;
947 }
948 let mut depth: u32 = 0;
949 let mut i = 0;
950 while i < bytes.len() {
951 match bytes[i] {
952 b'\\' => {
953 // Skip escaped character (RFC 5322 Section 3.2.4 quoted-pair).
954 i += 2;
955 continue;
956 }
957 b'(' => {
958 depth = depth.saturating_add(1);
959 }
960 b')' => {
961 depth = depth.saturating_sub(1);
962 if depth == 0 {
963 return i;
964 }
965 }
966 _ => {}
967 }
968 i += 1;
969 }
970 // No matching close paren found — use end of string.
971 s.len().saturating_sub(1)
972}
973
974/// Extracts the text content from inside a parenthesized comment, handling
975/// nested parentheses and backslash escaping per RFC 5322 Section 3.2.2.
976///
977/// `input` should be the text AFTER the opening `(`. Returns the trimmed
978/// content with the trailing `)` stripped. Nested parentheses are preserved
979/// in the output.
980///
981/// # References
982/// - RFC 5322 Section 3.2.2 (comment = "(" *([FWS] ccontent) [FWS] ")")
983/// - RFC 5322 Section 3.2.4 (quoted-pair = "\" (VCHAR / WSP))
984fn strip_comment_delimiters(input: &str) -> String {
985 let bytes = input.as_bytes();
986 let mut depth: u32 = 1;
987 let mut end = input.len();
988 let mut i = 0;
989 while i < bytes.len() {
990 match bytes[i] {
991 b'\\' => {
992 // Skip the next character (quoted-pair per RFC 5322 Section 3.2.4).
993 i += 2;
994 continue;
995 }
996 b'(' => {
997 depth = depth.saturating_add(1);
998 }
999 b')' => {
1000 depth = depth.saturating_sub(1);
1001 if depth == 0 {
1002 end = i;
1003 break;
1004 }
1005 }
1006 _ => {}
1007 }
1008 i += 1;
1009 }
1010 // RFC 5322 Section 3.2.2: resolve quoted-pair escapes (`\X` → `X`)
1011 // within the extracted comment content.
1012 unescape_quoted_string(input[..end].trim())
1013}
1014
1015/// Returns `true` if the input contains only CFWS after trimming leading FWS.
1016///
1017/// Used to validate text after an `angle-addr` where RFC 5322 Section 3.4
1018/// permits only trailing CFWS, not arbitrary atoms or phrases.
1019///
1020/// # References
1021/// - RFC 5322 Section 3.2.2 (comments / CFWS)
1022/// - RFC 5322 Section 3.4 (angle-addr)
1023fn is_cfws_only(mut input: &str) -> bool {
1024 loop {
1025 input = input.trim_start();
1026 if input.is_empty() {
1027 return true;
1028 }
1029 if !input.starts_with('(') {
1030 return false;
1031 }
1032
1033 let close = find_matching_close_paren(input);
1034 if input.as_bytes().get(close) != Some(&b')') {
1035 return false;
1036 }
1037 input = &input[close + 1..];
1038 }
1039}
1040
1041/// Reject trailing non-CFWS text after a parsed address component.
1042///
1043/// # References
1044/// - RFC 5322 Section 3.2.2 (comments / CFWS)
1045/// - RFC 5322 Sections 3.4-3.4.1 (address forms)
1046fn validate_trailing_cfws(
1047 trailing: &str,
1048 original: &str,
1049 context: &str,
1050 rfc_sections: &str,
1051) -> Result<(), crate::error::Error> {
1052 if trailing.is_empty() || is_cfws_only(trailing) {
1053 return Ok(());
1054 }
1055 Err(crate::error::Error::InvalidAddress(format!(
1056 "invalid trailing text after {context}: {original} ({rfc_sections})"
1057 )))
1058}
1059
1060/// Unescapes a quoted-string: removes backslash from `\\` → `\` and `\"` → `"`.
1061///
1062/// Per RFC 5322 Section 3.2.4, a `quoted-pair` is `"\" (VCHAR / WSP)`.
1063fn unescape_quoted_string(input: &str) -> String {
1064 let mut result = String::with_capacity(input.len());
1065 let mut chars = input.chars();
1066 while let Some(c) = chars.next() {
1067 if c == '\\' {
1068 if let Some(next) = chars.next() {
1069 result.push(next);
1070 } else {
1071 result.push(c);
1072 }
1073 } else {
1074 result.push(c);
1075 }
1076 }
1077 result
1078}
1079
1080/// Metadata for an attachment found during parsing.
1081///
1082/// Does not contain the attachment binary data — use the `section` field
1083/// to fetch the content on-demand via IMAP `BODY.PEEK[section]`.
1084///
1085/// # References
1086/// - RFC 2183 (Content-Disposition)
1087/// - RFC 3501 Section 6.4.5 (MIME section numbers)
1088#[non_exhaustive]
1089#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1090#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1091pub struct ParsedAttachment {
1092 /// Filename from `Content-Disposition` or `Content-Type` name parameter.
1093 pub filename: Option<String>,
1094 /// MIME content type (e.g., `"application/pdf"`).
1095 pub content_type: String,
1096 /// Content-ID header value, stripped of angle brackets (RFC 2392).
1097 pub content_id: Option<String>,
1098 /// `true` if `Content-Disposition: inline` or has a `Content-ID`.
1099 pub is_inline: bool,
1100 /// Size of the MIME part body in bytes (if available).
1101 pub size: Option<u64>,
1102 /// IMAP MIME section number (e.g., `"2"`, `"1.2"`) for on-demand fetch.
1103 pub section: Option<String>,
1104}
1105
1106/// Day-of-week abbreviations per RFC 5322 Section 3.3.
1107const DOW_NAMES: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
1108
1109/// Month abbreviations per RFC 5322 Section 3.3.
1110const MONTH_NAMES: [&str; 12] = [
1111 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1112];
1113
1114/// Date-time with timezone offset preserved.
1115///
1116/// # Equality and Ordering
1117///
1118/// `PartialEq`, `Eq`, `Hash`, `PartialOrd`, and `Ord` are implemented manually
1119/// rather than derived. All comparisons normalize to UTC first, so two values
1120/// representing the same instant with different timezone offsets are considered
1121/// equal (e.g., `2025-01-01T00:00:00+0000` == `2024-12-31T19:00:00-0500`).
1122///
1123/// # References
1124/// - RFC 5322 Section 3.3 (date-time specification)
1125#[non_exhaustive]
1126#[derive(Debug, Clone)]
1127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1128pub struct DateTime {
1129 /// Year (e.g., 2025).
1130 pub(crate) year: u16,
1131 /// Month (1–12).
1132 pub(crate) month: u8,
1133 /// Day of month (1–31).
1134 pub(crate) day: u8,
1135 /// Hour (0–23).
1136 pub(crate) hour: u8,
1137 /// Minute (0–59).
1138 pub(crate) minute: u8,
1139 /// Second (0–59).
1140 pub(crate) second: u8,
1141 /// Timezone offset from UTC in minutes (e.g., +0530 → 330, −0800 → −480).
1142 pub(crate) tz_offset_minutes: i16,
1143}
1144
1145impl DateTime {
1146 /// Creates a new `DateTime` from individual components.
1147 ///
1148 /// Fields are stored as-is; clamping to valid ranges happens at
1149 /// formatting / comparison time (see [`Self::to_rfc5322_string`],
1150 /// [`Self::to_unix_timestamp`]).
1151 ///
1152 /// # References
1153 /// - RFC 5322 Section 3.3 (date-time specification)
1154 pub fn new(
1155 year: u16,
1156 month: u8,
1157 day: u8,
1158 hour: u8,
1159 minute: u8,
1160 second: u8,
1161 tz_offset_minutes: i16,
1162 ) -> Self {
1163 Self {
1164 year,
1165 month,
1166 day,
1167 hour,
1168 minute,
1169 second,
1170 tz_offset_minutes,
1171 }
1172 }
1173
1174 /// Returns the year (e.g., 2025).
1175 pub fn year(&self) -> u16 {
1176 self.year
1177 }
1178
1179 /// Returns the month, clamped to 1–12 (RFC 5322 Section 3.3).
1180 pub fn month(&self) -> u8 {
1181 self.month.clamp(1, 12)
1182 }
1183
1184 /// Returns the day of month, clamped to a valid range for the
1185 /// current month and year (RFC 5322 Section 3.3).
1186 pub fn day(&self) -> u8 {
1187 let month = self.month.clamp(1, 12);
1188 let max_day = Self::days_in_month(month, self.year);
1189 self.day.clamp(1, max_day)
1190 }
1191
1192 /// Returns the hour, clamped to 0–23 (RFC 5322 Section 3.3).
1193 pub fn hour(&self) -> u8 {
1194 self.hour.clamp(0, 23)
1195 }
1196
1197 /// Returns the minute, clamped to 0–59 (RFC 5322 Section 3.3).
1198 pub fn minute(&self) -> u8 {
1199 self.minute.clamp(0, 59)
1200 }
1201
1202 /// Returns the second, clamped to 0–60 (60 is valid for leap
1203 /// seconds per RFC 5322 Section 3.3).
1204 pub fn second(&self) -> u8 {
1205 self.second.clamp(0, 60)
1206 }
1207
1208 /// Returns the timezone offset from UTC in minutes (e.g., +0530 → 330,
1209 /// −0800 → −480), clamped to ±1439 (RFC 5322 Section 3.3).
1210 pub fn tz_offset_minutes(&self) -> i16 {
1211 self.tz_offset_minutes.clamp(-1439, 1439)
1212 }
1213
1214 /// Converts this date-time to a Unix timestamp (seconds since 1970-01-01T00:00:00Z).
1215 ///
1216 /// The timezone offset is applied so the result is always UTC-based.
1217 /// Uses the same civil-to-days algorithm as the builder.
1218 ///
1219 /// # References
1220 /// - RFC 5322 Section 3.3
1221 pub fn to_unix_timestamp(&self) -> i64 {
1222 // Clamp all fields to valid ranges, consistent with the string
1223 // formatters (RFC 5322 §3.3). This ensures the timestamp matches
1224 // the date-time that would be printed by to_rfc5322_string().
1225 let (month, day, hour, minute, second) = self.clamped_fields();
1226 let days = Self::civil_to_days(i32::from(self.year), u32::from(month), u32::from(day));
1227 // RFC 5322 Section 3.3 allows second=60 (leap second), but Unix
1228 // timestamps have no leap-second representation. Clamp to 59 so
1229 // the timestamp stays within the correct minute (RFC 5322 §3.3).
1230 let second = if second >= 60 { 59 } else { second };
1231 let secs =
1232 days * 86400 + i64::from(hour) * 3600 + i64::from(minute) * 60 + i64::from(second);
1233 // Clamp timezone offset to the valid RFC 5322 Section 3.3 range
1234 // (±23:59 = ±1439 minutes), consistent with tz_parts() used by
1235 // to_rfc5322_string(). Without this clamp, an out-of-range offset
1236 // produces a timestamp that disagrees with the formatted string.
1237 let tz = i64::from(self.tz_offset_minutes.clamp(-1439, 1439));
1238 secs - tz * 60
1239 }
1240
1241 /// Creates a `DateTime` from a Unix timestamp (seconds since epoch) and a
1242 /// timezone offset in minutes.
1243 ///
1244 /// # References
1245 /// - RFC 5322 Section 3.3
1246 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1247 pub fn from_unix_timestamp(timestamp: i64, tz_offset_minutes: i16) -> Self {
1248 // Clamp timezone offset to the valid RFC 5322 Section 3.3 range
1249 // (±23:59 = ±1439 minutes) before computing local time. This
1250 // ensures the stored offset matches what to_rfc5322_string() and
1251 // to_unix_timestamp() will use, preserving round-trip consistency.
1252 let tz_offset_minutes = tz_offset_minutes.clamp(-1439, 1439);
1253 // Apply timezone offset to get local time
1254 let local_secs = timestamp + i64::from(tz_offset_minutes) * 60;
1255 let days = local_secs.div_euclid(86400);
1256 let time_secs = local_secs.rem_euclid(86400) as u64;
1257
1258 let (year, month, day) = Self::civil_from_days(days);
1259
1260 // Clamp year to 0–9999 to prevent silent truncation when casting
1261 // i32 → u16. Negative years (BCE dates) and years above 9999 are
1262 // outside the RFC 5322 Section 3.3 representable range (year =
1263 // 4*DIGIT). Without this clamp, negative years wrap to large u16
1264 // values, corrupting the date and breaking round-trips.
1265 let year = year.clamp(0, 9999) as u16;
1266
1267 Self {
1268 year,
1269 month: month as u8,
1270 day: day as u8,
1271 hour: (time_secs / 3600) as u8,
1272 minute: ((time_secs % 3600) / 60) as u8,
1273 second: (time_secs % 60) as u8,
1274 tz_offset_minutes,
1275 }
1276 }
1277
1278 /// Returns the current UTC time as a `DateTime`.
1279 ///
1280 /// # References
1281 /// - RFC 5322 Section 3.3
1282 #[allow(
1283 clippy::cast_possible_truncation,
1284 clippy::cast_sign_loss,
1285 clippy::cast_possible_wrap
1286 )]
1287 pub fn now() -> Self {
1288 use std::time::{SystemTime, UNIX_EPOCH};
1289
1290 let secs = SystemTime::now()
1291 .duration_since(UNIX_EPOCH)
1292 .unwrap_or_default()
1293 .as_secs();
1294
1295 Self::from_unix_timestamp(secs as i64, 0)
1296 }
1297
1298 /// Returns the number of days in the given month, accounting for leap years.
1299 ///
1300 /// The RFC 5322 Section 3.3 grammar allows day values 1-31 for all months,
1301 /// but calendar-valid dates require per-month limits (e.g., Feb has 28 or 29).
1302 ///
1303 /// # References
1304 /// - RFC 5322 Section 3.3 (date-time specification)
1305 fn days_in_month(month: u8, year: u16) -> u8 {
1306 match month {
1307 2 => {
1308 // Leap year: divisible by 4, except centuries unless divisible by 400
1309 let y = u32::from(year);
1310 if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) {
1311 29
1312 } else {
1313 28
1314 }
1315 }
1316 4 | 6 | 9 | 11 => 30,
1317 // Months 1, 3, 5, 7, 8, 10, 12 all have 31 days.
1318 // month is clamped to 1-12 before calling; wildcard covers 31-day months.
1319 _ => 31,
1320 }
1321 }
1322
1323 /// Returns clamped date-time fields conforming to RFC 5322 Section 3.3.
1324 ///
1325 /// Clamps each field to its valid range:
1326 /// - month: 1–12
1327 /// - day: 1–`days_in_month(month, year)` (accounts for per-month limits and
1328 /// leap years, so invalid dates like Feb 31 are clamped to Feb 28/29)
1329 /// - hour: 0–23
1330 /// - minute: 0–59
1331 /// - second: 0–60 (60 is valid for leap seconds per RFC 5322 Section 3.3)
1332 ///
1333 /// # References
1334 /// - RFC 5322 Section 3.3 (date-time specification)
1335 fn clamped_fields(&self) -> (u8, u8, u8, u8, u8) {
1336 let month = self.month.clamp(1, 12);
1337 // Clamp day against the actual number of days in the clamped month
1338 // and year, not a flat 31 (RFC 5322 Section 3.3).
1339 let max_day = Self::days_in_month(month, self.year);
1340 let day = self.day.clamp(1, max_day);
1341 (
1342 month,
1343 day,
1344 self.hour.clamp(0, 23),
1345 self.minute.clamp(0, 59),
1346 self.second.clamp(0, 60),
1347 )
1348 }
1349
1350 /// Returns the day of week for this date.
1351 ///
1352 /// Returns 0 for Sunday, 1 for Monday, ..., 6 for Saturday.
1353 /// Uses clamped month/day values for consistency with `to_rfc5322_string()`
1354 /// (RFC 5322 Section 3.3).
1355 ///
1356 /// # References
1357 /// - RFC 5322 Section 3.3
1358 #[allow(clippy::cast_sign_loss)]
1359 pub fn weekday(&self) -> u8 {
1360 let (month, day, _, _, _) = self.clamped_fields();
1361 let days = Self::civil_to_days(i32::from(self.year), u32::from(month), u32::from(day));
1362 // 1970-01-01 was Thursday (4); result is always in [0, 6]
1363 (((days % 7) + 4 + 7) % 7) as u8
1364 }
1365
1366 /// Formats this date-time as an RFC 5322 date-time string.
1367 ///
1368 /// Produces the format: `day-of-week, DD Mon YYYY HH:MM:SS ±HHMM`
1369 /// (e.g., `"Thu, 13 Feb 2025 15:47:33 +0000"`).
1370 ///
1371 /// All fields are clamped to their valid ranges per RFC 5322 Section 3.3
1372 /// to ensure well-formed output (Postel's law: be conservative in what
1373 /// you send).
1374 ///
1375 /// # References
1376 /// - RFC 5322 Section 3.3
1377 pub fn to_rfc5322_string(&self) -> String {
1378 let (month, day, hour, minute, second) = self.clamped_fields();
1379 let dow = self.weekday();
1380 let dow_name = DOW_NAMES[dow as usize];
1381 // month is already clamped to [1, 12] by clamped_fields()
1382 let month_name = MONTH_NAMES[(month - 1) as usize];
1383 let (sign, tz_h, tz_m) = self.tz_parts();
1384 format!(
1385 "{dow_name}, {:02} {month_name} {:04} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
1386 day, self.year, hour, minute, second,
1387 )
1388 }
1389
1390 /// Formats this date-time as an ISO 8601 / RFC 3339 string.
1391 ///
1392 /// Produces the format: `YYYY-MM-DDTHH:MM:SS±HH:MM`
1393 /// (e.g., `"2025-02-13T15:47:33+00:00"`).
1394 ///
1395 /// All fields are clamped to their valid ranges for consistency with
1396 /// `to_rfc5322_string()`.
1397 ///
1398 /// This format is widely used in JSON APIs and structured data exchange.
1399 ///
1400 /// # References
1401 /// - ISO 8601 (date-time representation)
1402 /// - RFC 3339 (date-time on the Internet)
1403 pub fn to_iso8601_string(&self) -> String {
1404 let (month, day, hour, minute, second) = self.clamped_fields();
1405 let (sign, tz_h, tz_m) = self.tz_parts();
1406 format!(
1407 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{sign}{tz_h:02}:{tz_m:02}",
1408 self.year, month, day, hour, minute, second,
1409 )
1410 }
1411
1412 /// Parses an RFC 5322 date-time string into a `DateTime`.
1413 ///
1414 /// Accepts: `[day-of-week ","] day month year hour ":" minute [":" second] zone`
1415 ///
1416 /// Strips CFWS (comments and folding white space) before parsing, as
1417 /// allowed by the obsolete date syntax (RFC 5322 Section 4.3).
1418 ///
1419 /// Returns `None` if the input is not a valid RFC 5322 date-time.
1420 ///
1421 /// # References
1422 /// - RFC 5322 Section 3.3
1423 /// - RFC 5322 Section 4.3 (obsolete syntax)
1424 pub fn parse_rfc5322(input: &str) -> Option<Self> {
1425 crate::parser::parse_rfc5322_date(input)
1426 }
1427
1428 /// Returns the timezone offset decomposed as `(sign, hours, minutes)`.
1429 ///
1430 /// # References
1431 /// - RFC 5322 Section 3.3 (zone = ("+" / "-") 4DIGIT)
1432 fn tz_parts(&self) -> (char, u16, u16) {
1433 // Clamp to the valid RFC 5322 range: zone hours 0-23, minutes 0-59,
1434 // so the maximum magnitude is 23*60+59 = 1439 (RFC 5322 Section 3.3).
1435 let clamped = self.tz_offset_minutes.clamp(-1439, 1439);
1436 let sign = if clamped >= 0 { '+' } else { '-' };
1437 let abs = clamped.unsigned_abs();
1438 (sign, abs / 60, abs % 60)
1439 }
1440
1441 /// Converts days since Unix epoch to `(year, month, day)`.
1442 ///
1443 /// Algorithm from Howard Hinnant's date algorithms.
1444 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1445 fn civil_from_days(z: i64) -> (i32, u32, u32) {
1446 let z = z + 719_468;
1447 let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
1448 let doe = (z - era * 146_097) as u32;
1449 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1450 let y = i64::from(yoe) + era * 400;
1451 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1452 let mp = (5 * doy + 2) / 153;
1453 let d = doy - (153 * mp + 2) / 5 + 1;
1454 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1455 let y = if m <= 2 { y + 1 } else { y };
1456 (y as i32, m, d)
1457 }
1458
1459 /// Converts `(year, month, day)` to days since Unix epoch.
1460 ///
1461 /// Algorithm from Howard Hinnant's date algorithms.
1462 #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
1463 fn civil_to_days(year: i32, month: u32, day: u32) -> i64 {
1464 let y = if month <= 2 {
1465 i64::from(year) - 1
1466 } else {
1467 i64::from(year)
1468 };
1469 let m = if month <= 2 { month + 9 } else { month - 3 };
1470 let era = (if y >= 0 { y } else { y - 399 }) / 400;
1471 let yoe = (y - era * 400) as u64;
1472 let doy = (153 * u64::from(m) + 2) / 5 + u64::from(day) - 1;
1473 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1474 era * 146_097 + doe as i64 - 719_468
1475 }
1476}
1477
1478impl PartialEq for DateTime {
1479 /// Compares two `DateTime` values by their UTC-normalized timestamps,
1480 /// consistent with the `Ord` implementation.
1481 ///
1482 /// # References
1483 /// - RFC 5322 Section 3.3 (date-time specification)
1484 fn eq(&self, other: &Self) -> bool {
1485 self.to_unix_timestamp() == other.to_unix_timestamp()
1486 }
1487}
1488
1489impl Eq for DateTime {}
1490
1491impl std::hash::Hash for DateTime {
1492 /// Hashes by UTC-normalized timestamp, consistent with `Eq` and `Ord`.
1493 ///
1494 /// Two `DateTime` values representing the same UTC instant (but with
1495 /// different timezone offsets) will produce the same hash, matching the
1496 /// `Eq` behavior.
1497 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1498 self.to_unix_timestamp().hash(state);
1499 }
1500}
1501
1502impl PartialOrd for DateTime {
1503 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1504 Some(self.cmp(other))
1505 }
1506}
1507
1508impl Ord for DateTime {
1509 /// Compares two `DateTime` values by their UTC-normalized timestamps.
1510 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1511 self.to_unix_timestamp().cmp(&other.to_unix_timestamp())
1512 }
1513}
1514
1515impl std::fmt::Display for DateTime {
1516 /// Formats this date-time for human-readable display.
1517 ///
1518 /// All fields are clamped to their valid ranges, consistent with
1519 /// `to_rfc5322_string()` and `to_iso8601_string()` (RFC 5322 §3.3).
1520 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1521 let (month, day, hour, minute, second) = self.clamped_fields();
1522 let (sign, tz_h, tz_m) = self.tz_parts();
1523 write!(
1524 f,
1525 "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
1526 self.year, month, day, hour, minute, second,
1527 )
1528 }
1529}
1530
1531impl std::str::FromStr for DateTime {
1532 type Err = crate::error::Error;
1533
1534 /// Parses an RFC 5322 date-time string into a `DateTime`.
1535 ///
1536 /// Accepts: `[day-of-week ","] day month year hour ":" minute [":" second] zone`
1537 ///
1538 /// This enables the ergonomic `"Thu, 13 Feb 2025 15:47:33 +0000".parse::<DateTime>()`
1539 /// pattern via the standard `FromStr` trait.
1540 ///
1541 /// # References
1542 /// - RFC 5322 Section 3.3
1543 /// - RFC 5322 Section 4.3 (obsolete syntax)
1544 fn from_str(s: &str) -> Result<Self, Self::Err> {
1545 Self::parse_rfc5322(s).ok_or_else(|| {
1546 crate::error::Error::InvalidDate(format!("cannot parse RFC 5322 date: {s}"))
1547 })
1548 }
1549}
1550
1551/// An outgoing email to be built into raw RFC 5322 bytes.
1552///
1553/// Used as input to [`crate::build_message`].
1554///
1555/// # References
1556/// - RFC 5322 (message format)
1557/// - RFC 2046 (MIME multipart)
1558#[non_exhaustive]
1559#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
1560#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1561pub struct OutgoingEmail {
1562 /// Originator mailboxes (RFC 5322 Section 3.6.2: `from = "From:" mailbox-list`).
1563 ///
1564 /// Must contain at least one address. When multiple addresses are present,
1565 /// `sender` **must** be set per RFC 5322 Section 3.6.2:
1566 /// "If the from field contains more than one mailbox specification in the
1567 /// mailbox-list, then the sender field, containing the field value
1568 /// corresponding to the responsible agent, MUST appear in the message."
1569 pub from: Vec<Address>,
1570 /// The agent responsible for actual transmission of the message
1571 /// (RFC 5322 Section 3.6.2: `sender = "Sender:" mailbox`).
1572 ///
1573 /// Required when `from` contains more than one address. Optional when
1574 /// `from` contains exactly one address.
1575 pub sender: Option<Address>,
1576 /// To recipients (RFC 5322 Section 3.6.3).
1577 pub to: Vec<Address>,
1578 /// Cc recipients (RFC 5322 Section 3.6.3).
1579 pub cc: Vec<Address>,
1580 /// Bcc recipients — included in SMTP envelope (`RCPT TO`) but **must not**
1581 /// appear in message headers (RFC 5322 Section 3.6.3).
1582 pub bcc: Vec<Address>,
1583 /// Reply-To addresses (RFC 5322 Section 3.6.2: `address-list`).
1584 pub reply_to: Vec<Address>,
1585 /// Explicit origination date (RFC 5322 Section 3.6.1).
1586 ///
1587 /// When `None`, [`crate::build_message`] uses the current UTC time.
1588 /// Set this when building drafts, re-sending, or importing messages
1589 /// that require a specific date.
1590 pub date: Option<DateTime>,
1591 /// Subject line (RFC 5322 Section 3.6.5).
1592 pub subject: String,
1593 /// Plain text body.
1594 pub body_text: Option<String>,
1595 /// HTML body.
1596 pub body_html: Option<String>,
1597 /// In-Reply-To message-IDs — bare `addr-spec` values (no angle brackets),
1598 /// builder wraps each in angle brackets for the wire format and silently
1599 /// drops malformed IDs (RFC 5322 Section 3.6.4: `in-reply-to = 1*msg-id`).
1600 ///
1601 /// Accepts plain strings so that parsed message-IDs (which may be
1602 /// non-conformant) can flow directly from [`ParsedEmail`] into a reply
1603 /// without fallible conversion.
1604 pub in_reply_to: Vec<String>,
1605 /// References message-IDs — bare `addr-spec` values (no angle brackets),
1606 /// builder wraps each in angle brackets for the wire format and silently
1607 /// drops malformed IDs (RFC 5322 Section 3.6.4: `references = 1*msg-id`).
1608 ///
1609 /// Accepts plain strings for the same reason as [`in_reply_to`](Self::in_reply_to).
1610 pub references: Vec<String>,
1611 /// File attachments.
1612 pub attachments: Vec<OutgoingAttachment>,
1613 /// Additional headers to include verbatim (RFC 5322 Section 3.6.8).
1614 ///
1615 /// Each entry is a `(field-name, unstructured-value)` pair. The
1616 /// [`HeaderName`] newtype enforces RFC 5322 Section 2.2 ftext syntax at
1617 /// construction time. Values are sanitized (CR/LF stripped) and long
1618 /// lines are folded per RFC 5322 Section 2.2.3. Headers are emitted
1619 /// after the standard headers (From, To, Subject, etc.) in the order
1620 /// they appear here.
1621 pub extra_headers: Vec<(HeaderName, String)>,
1622}
1623
1624/// An attachment to include in an outgoing email.
1625///
1626/// # References
1627/// - RFC 2183 (Content-Disposition: attachment / inline)
1628/// - RFC 2392 (Content-ID)
1629/// - RFC 2045 (Content-Transfer-Encoding: base64)
1630#[non_exhaustive]
1631#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1632#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1633pub struct OutgoingAttachment {
1634 /// Filename for the `Content-Disposition` header.
1635 pub filename: String,
1636 /// MIME content type (e.g., `"application/pdf"`).
1637 pub content_type: String,
1638 /// Raw file data — will be base64-encoded in the message.
1639 pub data: Vec<u8>,
1640 /// When `true`, the part uses `Content-Disposition: inline` instead of
1641 /// `attachment` (RFC 2183 Section 2). Typically used for images
1642 /// embedded in HTML via `cid:` URLs.
1643 pub is_inline: bool,
1644 /// Optional `Content-ID` header value (RFC 2392). Should be a bare
1645 /// `addr-spec` (e.g., `"image001@example.com"`); the builder wraps it
1646 /// in angle brackets. Required for HTML-embedded inline images that
1647 /// are referenced as `<img src="cid:image001@example.com">`.
1648 pub content_id: Option<String>,
1649}
1650
1651impl OutgoingAttachment {
1652 /// Creates a regular (non-inline) attachment.
1653 ///
1654 /// # References
1655 /// - RFC 2183 (Content-Disposition: attachment)
1656 /// - RFC 2045 (Content-Transfer-Encoding: base64)
1657 pub fn new(
1658 filename: impl Into<String>,
1659 content_type: impl Into<String>,
1660 data: impl Into<Vec<u8>>,
1661 ) -> Self {
1662 Self {
1663 filename: filename.into(),
1664 content_type: content_type.into(),
1665 data: data.into(),
1666 is_inline: false,
1667 content_id: None,
1668 }
1669 }
1670
1671 /// Creates an inline attachment with a Content-ID for embedding in HTML.
1672 ///
1673 /// Inline attachments use `Content-Disposition: inline` (RFC 2183
1674 /// Section 2) and are referenced by the HTML body via `cid:` URLs
1675 /// (RFC 2392).
1676 ///
1677 /// # References
1678 /// - RFC 2183 Section 2 (Content-Disposition: inline)
1679 /// - RFC 2392 (Content-ID)
1680 pub fn inline(
1681 filename: impl Into<String>,
1682 content_type: impl Into<String>,
1683 data: impl Into<Vec<u8>>,
1684 content_id: impl Into<String>,
1685 ) -> Self {
1686 Self {
1687 filename: filename.into(),
1688 content_type: content_type.into(),
1689 data: data.into(),
1690 is_inline: true,
1691 content_id: Some(content_id.into()),
1692 }
1693 }
1694}
1695
1696/// The result of building an email message.
1697///
1698/// # References
1699/// - RFC 5322 (message format)
1700/// - RFC 5321 (SMTP envelope)
1701#[non_exhaustive]
1702#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1703#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1704pub struct BuiltMessage {
1705 /// Raw RFC 5322 message bytes (headers + body). BCC excluded from headers.
1706 pub raw: Vec<u8>,
1707 /// All envelope recipients (to + cc + bcc) for SMTP `RCPT TO`.
1708 pub envelope_recipients: Vec<String>,
1709 /// The generated Message-ID (bare `addr-spec`, no angle brackets).
1710 pub message_id: String,
1711}
1712
1713/// TLS mode for the initial connection (RFC 8314).
1714///
1715/// This type lives in `daaki-message` rather than in the protocol crates
1716/// because both `daaki-imap` and `daaki-smtp` need it, and `daaki-message`
1717/// is already the shared dependency in the workspace. Both protocol crates
1718/// re-export it at their crate root.
1719#[non_exhaustive]
1720#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1721#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1722pub enum TlsMode {
1723 /// Connect directly over TLS (RFC 8314 Section 3.3).
1724 Implicit,
1725 /// Connect in cleartext, then upgrade via STARTTLS.
1726 StartTls,
1727 /// No TLS (testing only — should not be used in production).
1728 None,
1729}
1730
1731#[cfg(test)]
1732#[path = "types_tests.rs"]
1733mod tests;