Skip to main content

email_message/
mime_types.rs

1//! MIME content-type, content-disposition, and content-transfer-encoding
2//! types.
3//!
4//! Most of this module, `ContentType`, `MediaType`, `ContentDisposition`,
5//! `ContentTransferEncoding`, `ParameterValue`, is **always available**
6//! regardless of feature flags. The `mime` Cargo feature gates only
7//! [`MimePart`], the multipart/leaf MIME tree used by full-message
8//! rendering. A consumer that just wants typed content-type validation
9//! can use `email-message` with `default-features = false` and skip the
10//! `mime` feature.
11
12use std::fmt::Display;
13use std::str::FromStr;
14
15/// MIME content type.
16///
17/// # Equality and hashing
18///
19/// `PartialEq` / `Eq` / `Hash` are derived. To make derived equality
20/// match RFC 2045 §5.1 semantics (type, subtype, and parameter names
21/// are case-insensitive), construction lowercases those tokens. Parameter
22/// values are preserved as-is because their case sensitivity depends on
23/// the parameter (`boundary` is case-sensitive per RFC 2046 §5.1.1;
24/// `charset` is case-insensitive per RFC 2046 §4.1.2 but the kernel
25/// leaves the caller's bytes intact for round-trip fidelity).
26#[derive(Clone, Debug, PartialEq, Eq, Hash)]
27pub struct ContentType(String);
28
29impl ContentType {
30    /// Returns the normalized content-type field value.
31    #[must_use]
32    pub fn as_str(&self) -> &str {
33        self.0.as_str()
34    }
35
36    /// Borrowed type/subtype view, with no parameters.
37    ///
38    /// Cheap: it slices the stored string; no allocation. Validation guarantees
39    /// a well-formed `type/subtype` prefix exists.
40    #[must_use]
41    pub fn media_type(&self) -> MediaType<'_> {
42        let head = self.0.split(';').next().unwrap_or("").trim();
43        let (type_, subtype) = head.split_once('/').unwrap_or((head, ""));
44        MediaType { type_, subtype }
45    }
46
47    /// Iterate `(name, value)` parameter pairs in declaration order.
48    ///
49    /// Quoted values are returned with surrounding quotes stripped and
50    /// backslash escapes resolved.
51    pub fn parameters(&self) -> impl Iterator<Item = (&str, ParameterValue<'_>)> {
52        let mut segments = split_content_type_segments(self.0.as_str()).into_iter();
53        // Skip the type/subtype segment.
54        let _ = segments.next();
55        segments.filter_map(|segment| {
56            let (name, value) = segment.trim().split_once('=')?;
57            Some((name.trim(), ParameterValue::from_raw(value.trim())))
58        })
59    }
60
61    /// Look up a parameter by case-insensitive name.
62    #[must_use]
63    pub fn parameter(&self, name: &str) -> Option<ParameterValue<'_>> {
64        self.parameters()
65            .find(|(key, _)| key.eq_ignore_ascii_case(name))
66            .map(|(_, value)| value)
67    }
68
69    /// Convenience accessor for the `boundary` parameter (multipart only).
70    #[must_use]
71    pub fn boundary(&self) -> Option<ParameterValue<'_>> {
72        self.parameter("boundary")
73    }
74
75    /// Convenience accessor for the `charset` parameter.
76    #[must_use]
77    pub fn charset(&self) -> Option<ParameterValue<'_>> {
78        self.parameter("charset")
79    }
80}
81
82/// Borrowed view of a content-type's `type/subtype`.
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
84pub struct MediaType<'a> {
85    type_: &'a str,
86    subtype: &'a str,
87}
88
89impl<'a> MediaType<'a> {
90    /// Returns the top-level media type.
91    #[must_use]
92    pub const fn type_(&self) -> &'a str {
93        self.type_
94    }
95
96    /// Returns the media subtype.
97    #[must_use]
98    pub const fn subtype(&self) -> &'a str {
99        self.subtype
100    }
101
102    /// Returns `true` for a `text/*` media type.
103    #[must_use]
104    pub fn is_text(&self) -> bool {
105        self.type_.eq_ignore_ascii_case("text")
106    }
107
108    /// Returns `true` for a `multipart/*` media type.
109    #[must_use]
110    pub fn is_multipart(&self) -> bool {
111        self.type_.eq_ignore_ascii_case("multipart")
112    }
113
114    /// Returns `true` for an `image/*` media type.
115    #[must_use]
116    pub fn is_image(&self) -> bool {
117        self.type_.eq_ignore_ascii_case("image")
118    }
119
120    /// Case-insensitive compare against a `"type/subtype"` literal.
121    #[must_use]
122    pub fn matches(&self, expected: &str) -> bool {
123        let Some((ty, sub)) = expected.split_once('/') else {
124            return false;
125        };
126        self.type_.eq_ignore_ascii_case(ty) && self.subtype.eq_ignore_ascii_case(sub)
127    }
128}
129
130/// Borrowed parameter value, lazily resolving quoted-string escapes.
131#[derive(Clone, Debug)]
132pub struct ParameterValue<'a> {
133    raw: &'a str,
134}
135
136impl<'a> ParameterValue<'a> {
137    fn from_raw(raw: &'a str) -> Self {
138        Self { raw }
139    }
140
141    /// Raw textual form as it appears in the header (still quoted/escaped if it
142    /// was emitted that way).
143    #[must_use]
144    pub const fn as_raw(&self) -> &'a str {
145        self.raw
146    }
147
148    /// Returns the unquoted, unescaped string. For unquoted values this is a
149    /// borrow; for quoted values it allocates only to materialize the escapes.
150    #[must_use]
151    pub fn unquoted(&self) -> std::borrow::Cow<'a, str> {
152        let raw = self.raw;
153        if !raw.starts_with('"') || !raw.ends_with('"') || raw.len() < 2 {
154            return std::borrow::Cow::Borrowed(raw);
155        }
156
157        let inner = &raw[1..raw.len() - 1];
158        if !inner.contains('\\') {
159            return std::borrow::Cow::Borrowed(inner);
160        }
161
162        let mut out = String::with_capacity(inner.len());
163        let mut escaped = false;
164        for ch in inner.chars() {
165            if escaped {
166                out.push(ch);
167                escaped = false;
168            } else if ch == '\\' {
169                escaped = true;
170            } else {
171                out.push(ch);
172            }
173        }
174        std::borrow::Cow::Owned(out)
175    }
176}
177
178impl PartialEq<&str> for ParameterValue<'_> {
179    fn eq(&self, other: &&str) -> bool {
180        self.unquoted().as_ref() == *other
181    }
182}
183
184impl Display for ContentType {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(self.as_str())
187    }
188}
189
190/// Error returned when parsing an invalid MIME content type.
191#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
192#[error("content type must have a type/subtype form")]
193pub struct ContentTypeParseError;
194
195impl FromStr for ContentType {
196    type Err = ContentTypeParseError;
197
198    fn from_str(s: &str) -> Result<Self, Self::Err> {
199        normalize_parameterized_value(s, true)
200            .map(Self)
201            .ok_or(ContentTypeParseError)
202    }
203}
204
205fn is_mime_token(value: &str) -> bool {
206    value.bytes().all(is_mime_token_byte)
207}
208
209fn split_content_type_segments(value: &str) -> Vec<&str> {
210    let mut segments = Vec::new();
211    let mut start = 0;
212    let mut in_quotes = false;
213    let mut escaped = false;
214
215    for (index, ch) in value.char_indices() {
216        if escaped {
217            escaped = false;
218            continue;
219        }
220
221        if in_quotes && ch == '\\' {
222            escaped = true;
223            continue;
224        }
225
226        if ch == '"' {
227            in_quotes = !in_quotes;
228            continue;
229        }
230
231        if ch == ';' && !in_quotes {
232            segments.push(&value[start..index]);
233            start = index + ch.len_utf8();
234        }
235    }
236
237    segments.push(&value[start..]);
238    segments
239}
240
241const fn is_mime_token_byte(byte: u8) -> bool {
242    matches!(
243        byte,
244        b'!' | b'#'
245            | b'$'
246            | b'%'
247            | b'&'
248            | b'\''
249            | b'*'
250            | b'+'
251            | b'-'
252            | b'.'
253            | b'^'
254            | b'_'
255            | b'`'
256            | b'|'
257            | b'~'
258            | b'0'..=b'9'
259            | b'A'..=b'Z'
260            | b'a'..=b'z'
261    )
262}
263
264fn is_parameter_value(value: &str) -> bool {
265    if value.starts_with('"') {
266        return is_quoted_parameter_value(value);
267    }
268
269    is_mime_token(value)
270}
271
272fn is_quoted_parameter_value(value: &str) -> bool {
273    if !(value.ends_with('"') && value.len() >= 2) {
274        return false;
275    }
276
277    let mut escaped = false;
278    for byte in value[1..value.len() - 1].bytes() {
279        if escaped {
280            if is_forbidden_quoted_parameter_byte(byte) {
281                return false;
282            }
283            escaped = false;
284            continue;
285        }
286
287        if byte == b'\\' {
288            escaped = true;
289            continue;
290        }
291
292        if byte == b'"' || is_forbidden_quoted_parameter_byte(byte) {
293            return false;
294        }
295    }
296
297    !escaped
298}
299
300/// Reject NUL, CR, LF, and any non-tab ASCII control character inside a
301/// MIME quoted parameter. Matches the byte-discipline `validate_header`
302/// (in `crate::message`) and `push_header_line` (in
303/// `email_message_wire::rfc822`) apply to header values, so a parsed
304/// `ContentType` cannot carry bytes the wire renderer would later
305/// reject (META-001 R3 invariant).
306const fn is_forbidden_quoted_parameter_byte(byte: u8) -> bool {
307    byte != b'\t' && byte.is_ascii_control()
308}
309
310impl TryFrom<&str> for ContentType {
311    type Error = ContentTypeParseError;
312
313    fn try_from(value: &str) -> Result<Self, Self::Error> {
314        Self::from_str(value)
315    }
316}
317
318impl From<ContentType> for String {
319    fn from(value: ContentType) -> Self {
320        value.0
321    }
322}
323
324#[cfg(feature = "serde")]
325impl serde::Serialize for ContentType {
326    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
327    where
328        S: serde::Serializer,
329    {
330        serializer.serialize_str(self.as_str())
331    }
332}
333
334#[cfg(feature = "serde")]
335impl<'de> serde::Deserialize<'de> for ContentType {
336    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
337    where
338        D: serde::Deserializer<'de>,
339    {
340        let value = String::deserialize(deserializer)?;
341        value.parse().map_err(serde::de::Error::custom)
342    }
343}
344
345#[cfg(feature = "schemars")]
346impl schemars::JsonSchema for ContentType {
347    fn inline_schema() -> bool {
348        true
349    }
350
351    fn schema_name() -> std::borrow::Cow<'static, str> {
352        "ContentType".into()
353    }
354
355    fn schema_id() -> std::borrow::Cow<'static, str> {
356        concat!(module_path!(), "::ContentType").into()
357    }
358
359    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
360        schemars::json_schema!({
361            "type": "string",
362            "description": "MIME Content-Type field value"
363        })
364    }
365}
366
367#[cfg(feature = "arbitrary")]
368impl<'a> arbitrary::Arbitrary<'a> for ContentType {
369    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
370        let value = match u.int_in_range::<u8>(0..=4)? {
371            0 => "text/plain",
372            1 => "text/html; charset=utf-8",
373            2 => "application/octet-stream",
374            3 => "image/png",
375            _ => "multipart/mixed; boundary=boundary",
376        };
377        value.parse().map_err(|_| arbitrary::Error::IncorrectFormat)
378    }
379}
380
381/// MIME content-transfer-encoding (RFC 2045 §6).
382///
383/// The five RFC-defined values are explicit variants; any other syntactically
384/// valid mime-token (e.g. an `x-` extension) round-trips through `Other`.
385///
386/// # Casing
387///
388/// RFC 2045 §6.1 says encoding names are case-insensitive. Both the
389/// known-variant parser and the [`Other`] branch normalize to ASCII
390/// lowercase on construction, so equality and hashing through the
391/// derived impls are case-insensitive automatically: `Other("Base64")`
392/// is unreachable (parses to [`Base64`] instead) and `Other("X-MyEnc")`
393/// stores `"x-myenc"`.
394///
395/// [`Base64`]: Self::Base64
396/// [`Other`]: Self::Other
397#[derive(Clone, Debug, PartialEq, Eq, Hash)]
398#[non_exhaustive]
399pub enum ContentTransferEncoding {
400    /// The RFC 2045 `7bit` encoding.
401    SevenBit,
402    /// The RFC 2045 `8bit` encoding.
403    EightBit,
404    /// The RFC 2045 `binary` encoding.
405    Binary,
406    /// The RFC 2045 `quoted-printable` encoding.
407    QuotedPrintable,
408    /// The RFC 2045 `base64` encoding.
409    Base64,
410    /// Another syntactically valid, normalized encoding token.
411    Other(String),
412}
413
414impl ContentTransferEncoding {
415    /// Returns the normalized transfer-encoding token.
416    #[must_use]
417    pub fn as_str(&self) -> &str {
418        match self {
419            Self::SevenBit => "7bit",
420            Self::EightBit => "8bit",
421            Self::Binary => "binary",
422            Self::QuotedPrintable => "quoted-printable",
423            Self::Base64 => "base64",
424            Self::Other(value) => value.as_str(),
425        }
426    }
427}
428
429impl Display for ContentTransferEncoding {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        f.write_str(self.as_str())
432    }
433}
434
435/// Error returned when parsing an invalid transfer-encoding token.
436#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
437#[error("content-transfer-encoding cannot be empty")]
438pub struct ContentTransferEncodingParseError;
439
440impl FromStr for ContentTransferEncoding {
441    type Err = ContentTransferEncodingParseError;
442
443    fn from_str(s: &str) -> Result<Self, Self::Err> {
444        let value = s.trim();
445        if value.is_empty() || !is_mime_token(value) {
446            return Err(ContentTransferEncodingParseError);
447        }
448        Ok(if value.eq_ignore_ascii_case("7bit") {
449            Self::SevenBit
450        } else if value.eq_ignore_ascii_case("8bit") {
451            Self::EightBit
452        } else if value.eq_ignore_ascii_case("binary") {
453            Self::Binary
454        } else if value.eq_ignore_ascii_case("quoted-printable") {
455            Self::QuotedPrintable
456        } else if value.eq_ignore_ascii_case("base64") {
457            Self::Base64
458        } else {
459            Self::Other(value.to_ascii_lowercase())
460        })
461    }
462}
463
464impl TryFrom<&str> for ContentTransferEncoding {
465    type Error = ContentTransferEncodingParseError;
466
467    fn try_from(value: &str) -> Result<Self, Self::Error> {
468        Self::from_str(value)
469    }
470}
471
472#[cfg(feature = "serde")]
473impl serde::Serialize for ContentTransferEncoding {
474    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
475    where
476        S: serde::Serializer,
477    {
478        serializer.serialize_str(self.as_str())
479    }
480}
481
482#[cfg(feature = "serde")]
483impl<'de> serde::Deserialize<'de> for ContentTransferEncoding {
484    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
485    where
486        D: serde::Deserializer<'de>,
487    {
488        let value = String::deserialize(deserializer)?;
489        value.parse().map_err(serde::de::Error::custom)
490    }
491}
492
493#[cfg(feature = "schemars")]
494impl schemars::JsonSchema for ContentTransferEncoding {
495    fn inline_schema() -> bool {
496        true
497    }
498
499    fn schema_name() -> std::borrow::Cow<'static, str> {
500        "ContentTransferEncoding".into()
501    }
502
503    fn schema_id() -> std::borrow::Cow<'static, str> {
504        concat!(module_path!(), "::ContentTransferEncoding").into()
505    }
506
507    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
508        schemars::json_schema!({
509            "type": "string",
510            "description": "RFC 2045 Content-Transfer-Encoding token"
511        })
512    }
513}
514
515#[cfg(feature = "arbitrary")]
516impl<'a> arbitrary::Arbitrary<'a> for ContentTransferEncoding {
517    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
518        Ok(match u.int_in_range::<u8>(0..=5)? {
519            0 => Self::SevenBit,
520            1 => Self::EightBit,
521            2 => Self::Binary,
522            3 => Self::QuotedPrintable,
523            4 => Self::Base64,
524            _ => Self::Other("x-experimental".to_owned()),
525        })
526    }
527}
528
529/// MIME content-disposition token (RFC 2183).
530///
531/// # Equality and hashing
532///
533/// Same shape as [`ContentType`]: construction lowercases the disposition
534/// kind and parameter names, then `PartialEq` / `Eq` / `Hash` compare that
535/// normalized string. RFC 2183 §3 makes the disposition type and parameter
536/// names case-insensitive but leaves parameter value case sensitivity
537/// dependent on the parameter. The kernel preserves parameter values
538/// verbatim; for semantic comparison route through the disposition's
539/// accessors rather than comparing raw input strings.
540#[derive(Clone, Debug, PartialEq, Eq, Hash)]
541pub struct ContentDisposition(String);
542
543impl ContentDisposition {
544    /// Returns the normalized content-disposition field value.
545    #[must_use]
546    pub fn as_str(&self) -> &str {
547        self.0.as_str()
548    }
549
550    /// Borrowed disposition kind (`"inline"`, `"attachment"`, or an
551    /// `x-` extension), with no parameters.
552    ///
553    /// Cheap: it slices the stored string; no allocation. Validation
554    /// guarantees a well-formed disposition token prefix exists.
555    #[must_use]
556    pub fn kind(&self) -> &str {
557        self.0.split(';').next().unwrap_or("").trim()
558    }
559
560    /// Iterate `(name, value)` parameter pairs in declaration order.
561    ///
562    /// Quoted values are returned with surrounding quotes stripped and
563    /// backslash escapes resolved, mirroring [`ContentType::parameters`].
564    pub fn parameters(&self) -> impl Iterator<Item = (&str, ParameterValue<'_>)> {
565        let mut segments = split_content_type_segments(self.0.as_str()).into_iter();
566        // Skip the disposition-kind segment.
567        let _ = segments.next();
568        segments.filter_map(|segment| {
569            let (name, value) = segment.trim().split_once('=')?;
570            Some((name.trim(), ParameterValue::from_raw(value.trim())))
571        })
572    }
573
574    /// Look up a parameter by case-insensitive name.
575    #[must_use]
576    pub fn parameter(&self, name: &str) -> Option<ParameterValue<'_>> {
577        self.parameters()
578            .find(|(key, _)| key.eq_ignore_ascii_case(name))
579            .map(|(_, value)| value)
580    }
581
582    /// Convenience accessor for the `filename` parameter.
583    ///
584    /// RFC 2183 §2.3 defines this as the suggested filename a recipient's
585    /// mail client should use when saving the attachment to disk. For
586    /// non-ASCII filenames the kernel emits `filename*` (RFC 2231
587    /// charset/language extension); this accessor returns `filename` when
588    /// present and otherwise falls back to `filename*`.
589    #[must_use]
590    pub fn filename(&self) -> Option<ParameterValue<'_>> {
591        self.parameter("filename")
592            .or_else(|| self.parameter("filename*"))
593    }
594
595    /// Returns `true` if the disposition kind is `inline`
596    /// (case-insensitive).
597    #[must_use]
598    pub fn is_inline(&self) -> bool {
599        self.kind().eq_ignore_ascii_case("inline")
600    }
601
602    /// Returns `true` if the disposition kind is `attachment`
603    /// (case-insensitive).
604    #[must_use]
605    pub fn is_attachment(&self) -> bool {
606        self.kind().eq_ignore_ascii_case("attachment")
607    }
608}
609
610impl Display for ContentDisposition {
611    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
612        f.write_str(self.as_str())
613    }
614}
615
616/// Error returned when parsing an invalid MIME content disposition.
617#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
618#[error("content-disposition cannot be empty")]
619pub struct ContentDispositionParseError;
620
621impl FromStr for ContentDisposition {
622    type Err = ContentDispositionParseError;
623
624    fn from_str(s: &str) -> Result<Self, Self::Err> {
625        normalize_parameterized_value(s, false)
626            .map(Self)
627            .ok_or(ContentDispositionParseError)
628    }
629}
630
631impl TryFrom<&str> for ContentDisposition {
632    type Error = ContentDispositionParseError;
633
634    fn try_from(value: &str) -> Result<Self, Self::Error> {
635        Self::from_str(value)
636    }
637}
638
639/// Validate and normalize a parameterized header value (`Content-Type` shape
640/// or `Content-Disposition` shape). When `with_subtype` is true, the head
641/// must be `type/subtype`; otherwise it must be a single MIME token.
642///
643/// Lowercases the type/subtype tokens and parameter names so derived
644/// equality matches RFC 2045 §5.1 semantics. Parameter values are
645/// preserved verbatim. Returns `None` if the input fails any validation
646/// rule the previous bool-returning checks enforced.
647fn normalize_parameterized_value(value: &str, with_subtype: bool) -> Option<String> {
648    let value = value.trim();
649    if value.is_empty() {
650        return None;
651    }
652
653    let segments = split_content_type_segments(value);
654    let mut parts = segments.into_iter();
655    let head = parts.next()?.trim();
656
657    let canonical_head = if with_subtype {
658        let (ty, subtype) = head.split_once('/')?;
659        if ty.is_empty()
660            || subtype.is_empty()
661            || subtype.contains('/')
662            || !is_mime_token(ty)
663            || !is_mime_token(subtype)
664        {
665            return None;
666        }
667        format!(
668            "{}/{}",
669            ty.to_ascii_lowercase(),
670            subtype.to_ascii_lowercase()
671        )
672    } else {
673        if head.is_empty() || !is_mime_token(head) {
674            return None;
675        }
676        head.to_ascii_lowercase()
677    };
678
679    let mut canonical = canonical_head;
680    for parameter in parts {
681        let parameter = parameter.trim();
682        let (name, raw_value) = parameter.split_once('=')?;
683        let name = name.trim();
684        let raw_value = raw_value.trim();
685        if name.is_empty()
686            || raw_value.is_empty()
687            || !is_mime_token(name)
688            || !is_parameter_value(raw_value)
689        {
690            return None;
691        }
692        canonical.push_str("; ");
693        canonical.push_str(&name.to_ascii_lowercase());
694        canonical.push('=');
695        canonical.push_str(raw_value);
696    }
697
698    Some(canonical)
699}
700
701#[cfg(feature = "serde")]
702impl serde::Serialize for ContentDisposition {
703    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
704    where
705        S: serde::Serializer,
706    {
707        serializer.serialize_str(self.as_str())
708    }
709}
710
711#[cfg(feature = "serde")]
712impl<'de> serde::Deserialize<'de> for ContentDisposition {
713    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
714    where
715        D: serde::Deserializer<'de>,
716    {
717        let value = String::deserialize(deserializer)?;
718        value.parse().map_err(serde::de::Error::custom)
719    }
720}
721
722#[cfg(feature = "schemars")]
723impl schemars::JsonSchema for ContentDisposition {
724    fn inline_schema() -> bool {
725        true
726    }
727
728    fn schema_name() -> std::borrow::Cow<'static, str> {
729        "ContentDisposition".into()
730    }
731
732    fn schema_id() -> std::borrow::Cow<'static, str> {
733        concat!(module_path!(), "::ContentDisposition").into()
734    }
735
736    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
737        schemars::json_schema!({
738            "type": "string",
739            "description": "RFC 2183 Content-Disposition field value"
740        })
741    }
742}
743
744#[cfg(feature = "arbitrary")]
745impl<'a> arbitrary::Arbitrary<'a> for ContentDisposition {
746    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
747        let value = match u.int_in_range::<u8>(0..=2)? {
748            0 => "inline",
749            1 => "attachment",
750            _ => "attachment; filename=example.txt",
751        };
752        value.parse().map_err(|_| arbitrary::Error::IncorrectFormat)
753    }
754}
755
756/// Low-level MIME tree node, gated behind the `mime` Cargo feature.
757///
758/// `MimePart` is the kernel's escape hatch for callers building exotic
759/// MIME structures (custom multipart shapes, hand-rolled
760/// transfer-encoding choices, etc.). High-level paths through
761/// [`Body::Text`](crate::Body) / `Body::Html` / `Body::TextAndHtml` cover
762/// the common cases and apply byte-discipline (auto-promote non-ASCII
763/// text to base64, etc.) on the caller's behalf.
764///
765/// # Body byte-discipline is the caller's responsibility
766///
767/// Constructing `MimePart::Leaf` directly bypasses the kernel's
768/// auto-promotion path. The wire renderer enforces *header* invariants
769/// strictly (rejects raw CR / LF / NUL / non-tab control chars in any
770/// header value, regardless of `Content-Transfer-Encoding`), but it
771/// **trusts the caller's bytes** for body content under any transfer
772/// encoding other than `base64` / `quoted-printable`. That includes
773/// `7bit`, `8bit`, `binary`, and any `Other(...)` value: the renderer
774/// emits the body verbatim. RFC 2045 §6.2 forbids bytes > 127 under
775/// `7bit` and forbids bare CR / LF under both `7bit` and `8bit`;
776/// callers building `MimePart::Leaf` with a non-base64 / non-QP
777/// encoding must satisfy those invariants themselves, or downstream
778/// MTAs may reject the message.
779///
780/// # Variant set
781///
782/// Deliberately *not* `#[non_exhaustive]`. RFC 2046 closes MIME
783/// parts to exactly `discrete` (Leaf) and `composite` (Multipart);
784/// the kernel cannot honestly add a third variant without an RFC
785/// update. The exhaustive `match` shape lets downstream callers
786/// type-cover both arms without an `_ =>` clause.
787///
788/// # Untrusted-deserialize caveat
789///
790/// `MimePart::Multipart { parts: Vec<Self> }` is recursive: any
791/// caller deserializing a `MimePart` (or a `Body` containing one)
792/// from untrusted input must pre-bound the input length and the
793/// recursion depth. `serde_json` defaults to a 128-frame recursion
794/// limit which is safe; other formats (e.g. `serde_yaml`,
795/// `bincode`, `rmp-serde`, `serde_cbor`) may not, and a deeply
796/// nested attacker payload yields a `MimePart` value of arbitrary
797/// depth. The wire renderer (`email_message_wire::render_rfc822`)
798/// enforces a `MAX_MULTIPART_DEPTH` cap on outbound trees, including
799/// up to two frames of attachment-wrapping when inline and/or regular
800/// attachments are present, but other consumers of a deserialized
801/// `MimePart` (e.g. arbitrary caller code that walks the tree) must
802/// defend themselves.
803#[cfg(feature = "mime")]
804#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
805#[derive(Clone, Debug, PartialEq, Eq)]
806pub enum MimePart {
807    /// A discrete MIME part containing body bytes.
808    Leaf {
809        /// Media type and parameters for the body.
810        content_type: ContentType,
811        /// Transfer encoding to apply when rendering the body.
812        content_transfer_encoding: Option<ContentTransferEncoding>,
813        /// Optional presentation metadata for the part.
814        content_disposition: Option<ContentDisposition>,
815        /// Decoded body bytes.
816        body: Vec<u8>,
817    },
818    /// A multipart container holding nested MIME parts.
819    Multipart {
820        /// Multipart media type and parameters.
821        content_type: ContentType,
822        /// Explicit boundary, or `None` to generate one when rendering.
823        boundary: Option<String>,
824        /// Child parts in wire order.
825        parts: Vec<Self>,
826    },
827}
828
829#[cfg(all(feature = "mime", feature = "serde"))]
830impl serde::Serialize for MimePart {
831    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
832    where
833        S: serde::Serializer,
834    {
835        use base64::Engine as _;
836        use serde::ser::SerializeStruct as _;
837
838        match self {
839            Self::Leaf {
840                content_type,
841                content_transfer_encoding,
842                content_disposition,
843                body,
844            } => {
845                let mut len = 3; // type + content_type + body
846                if content_transfer_encoding.is_some() {
847                    len += 1;
848                }
849                if content_disposition.is_some() {
850                    len += 1;
851                }
852                let encoded = base64::engine::general_purpose::STANDARD.encode(body);
853                let mut value = serializer.serialize_struct("MimePart", len)?;
854                value.serialize_field("type", "leaf")?;
855                value.serialize_field("content_type", content_type)?;
856                if let Some(cte) = content_transfer_encoding {
857                    value.serialize_field("content_transfer_encoding", cte)?;
858                }
859                if let Some(cd) = content_disposition {
860                    value.serialize_field("content_disposition", cd)?;
861                }
862                value.serialize_field("body", &encoded)?;
863                value.end()
864            }
865            Self::Multipart {
866                content_type,
867                boundary,
868                parts,
869            } => {
870                let mut len = 3; // type + content_type + parts
871                if boundary.is_some() {
872                    len += 1;
873                }
874                let mut value = serializer.serialize_struct("MimePart", len)?;
875                value.serialize_field("type", "multipart")?;
876                value.serialize_field("content_type", content_type)?;
877                if let Some(b) = boundary {
878                    value.serialize_field("boundary", b)?;
879                }
880                value.serialize_field("parts", parts)?;
881                value.end()
882            }
883        }
884    }
885}
886
887#[cfg(all(feature = "mime", feature = "serde"))]
888impl<'de> serde::Deserialize<'de> for MimePart {
889    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
890    where
891        D: serde::Deserializer<'de>,
892    {
893        use base64::Engine as _;
894
895        #[derive(serde::Deserialize)]
896        #[serde(tag = "type", rename_all = "snake_case")]
897        enum RawMimePart {
898            Leaf {
899                content_type: ContentType,
900                #[serde(default)]
901                content_transfer_encoding: Option<ContentTransferEncoding>,
902                #[serde(default)]
903                content_disposition: Option<ContentDisposition>,
904                body: String,
905            },
906            Multipart {
907                content_type: ContentType,
908                #[serde(default)]
909                boundary: Option<String>,
910                #[serde(default)]
911                parts: Vec<MimePart>,
912            },
913        }
914
915        Ok(match RawMimePart::deserialize(deserializer)? {
916            RawMimePart::Leaf {
917                content_type,
918                content_transfer_encoding,
919                content_disposition,
920                body,
921            } => {
922                let decoded = base64::engine::general_purpose::STANDARD
923                    .decode(body.as_bytes())
924                    .map_err(|err| {
925                        serde::de::Error::custom(format!("invalid base64 MIME body: {err}"))
926                    })?;
927                Self::Leaf {
928                    content_type,
929                    content_transfer_encoding,
930                    content_disposition,
931                    body: decoded,
932                }
933            }
934            RawMimePart::Multipart {
935                content_type,
936                boundary,
937                parts,
938            } => Self::Multipart {
939                content_type,
940                boundary,
941                parts,
942            },
943        })
944    }
945}
946
947#[cfg(all(feature = "mime", feature = "schemars"))]
948impl schemars::JsonSchema for MimePart {
949    fn schema_name() -> std::borrow::Cow<'static, str> {
950        "MimePart".into()
951    }
952
953    fn schema_id() -> std::borrow::Cow<'static, str> {
954        concat!(module_path!(), "::MimePart").into()
955    }
956
957    /// MIME parts have no RFC 5322 string form, so this schema is *not*
958    /// wrapped in an `rfc5322-string-compat` `oneOf: [object, string]`
959    /// the way `Mailbox` / `Group` / `Address` are. The asymmetry is
960    /// deliberate: there is no producer-side wire shape for "MIME part
961    /// as a header-like string" to migrate from.
962    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
963        let recursive = generator.subschema_for::<MimePart>();
964        schemars::json_schema!({
965            "oneOf": [
966                {
967                    "type": "object",
968                    "properties": {
969                        "type": {"const": "leaf"},
970                        "content_type": {
971                            "type": "string",
972                            "description": "MIME Content-Type field value"
973                        },
974                        "content_transfer_encoding": {
975                            "type": "string",
976                            "description": "RFC 2045 Content-Transfer-Encoding token"
977                        },
978                        "content_disposition": {
979                            "type": "string",
980                            "description": "RFC 2183 Content-Disposition field value"
981                        },
982                        "body": {
983                            "type": "string",
984                            "contentEncoding": "base64",
985                            "description": "Base64-encoded MIME part body (RFC 4648, with padding)"
986                        }
987                    },
988                    "required": ["type", "content_type", "body"]
989                },
990                {
991                    "type": "object",
992                    "properties": {
993                        "type": {"const": "multipart"},
994                        "content_type": {
995                            "type": "string",
996                            "description": "MIME Content-Type field value"
997                        },
998                        "boundary": {"type": "string"},
999                        "parts": {"type": "array", "items": recursive}
1000                    },
1001                    "required": ["type", "content_type", "parts"]
1002                }
1003            ]
1004        })
1005    }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010    use std::collections::HashSet;
1011
1012    use super::{ContentTransferEncoding, ContentType};
1013
1014    #[test]
1015    fn content_type_accepts_valid_media_types_and_parameters() {
1016        for value in [
1017            "text/plain",
1018            "text/plain;charset=utf-8",
1019            "multipart/related; type=\"text/html\"",
1020            "application/octet-stream; name=\"a;b.txt\"",
1021        ] {
1022            assert!(
1023                ContentType::try_from(value).is_ok(),
1024                "expected valid content type: {value}"
1025            );
1026        }
1027    }
1028
1029    #[test]
1030    fn content_type_rejects_invalid_media_types() {
1031        for value in [
1032            "text/",
1033            "/plain",
1034            "text/plain/html",
1035            "text /plain",
1036            "text/plain; charset",
1037            "text/plain; charset=\"unterminated",
1038        ] {
1039            assert!(
1040                ContentType::try_from(value).is_err(),
1041                "expected invalid content type: {value}"
1042            );
1043        }
1044    }
1045
1046    #[test]
1047    fn content_type_rejects_quoted_parameter_with_control_chars() {
1048        // Direct bytes, NUL, BEL, VT, ESC must be rejected to match the
1049        // wire renderer's `push_header_line` byte discipline.
1050        for value in [
1051            "text/plain; name=\"x\u{0}y\"",
1052            "text/plain; name=\"x\u{07}y\"",
1053            "text/plain; name=\"x\u{0B}y\"",
1054            "text/plain; name=\"x\u{1B}y\"",
1055        ] {
1056            assert!(
1057                ContentType::try_from(value).is_err(),
1058                "expected control-char rejection: {value:?}"
1059            );
1060        }
1061    }
1062
1063    #[test]
1064    fn content_type_rejects_quoted_parameter_with_escaped_control_chars() {
1065        // Even after a `\` escape, control chars are still rejected.
1066        for value in [
1067            "text/plain; name=\"x\\\u{0}y\"",
1068            "text/plain; name=\"x\\\u{07}y\"",
1069        ] {
1070            assert!(
1071                ContentType::try_from(value).is_err(),
1072                "expected escaped-control-char rejection: {value:?}"
1073            );
1074        }
1075    }
1076
1077    #[test]
1078    fn content_type_accepts_tab_inside_quoted_parameter() {
1079        // Tab is the documented exception in the byte-discipline rule.
1080        assert!(ContentType::try_from("text/plain; name=\"a\tb\"").is_ok());
1081    }
1082
1083    #[test]
1084    fn content_type_media_type_view_splits_type_and_subtype() {
1085        let ct: ContentType = "text/plain; charset=utf-8".parse().unwrap();
1086        let media = ct.media_type();
1087        assert_eq!(media.type_(), "text");
1088        assert_eq!(media.subtype(), "plain");
1089        assert!(media.is_text());
1090        assert!(!media.is_multipart());
1091        assert!(media.matches("text/plain"));
1092        assert!(media.matches("TEXT/PLAIN"));
1093    }
1094
1095    #[test]
1096    fn content_type_parameter_lookup_is_case_insensitive_and_unquotes() {
1097        let ct: ContentType = "multipart/mixed; Boundary=\"abc\\\"def\"".parse().unwrap();
1098        let boundary = ct.boundary().expect("boundary present");
1099        assert_eq!(boundary.as_raw(), "\"abc\\\"def\"");
1100        assert_eq!(boundary.unquoted().as_ref(), "abc\"def");
1101    }
1102
1103    #[test]
1104    fn content_type_parameters_iterates_in_declaration_order() {
1105        let ct: ContentType = "text/html; charset=utf-8; boundary=x".parse().unwrap();
1106        let pairs: Vec<(String, String)> = ct
1107            .parameters()
1108            .map(|(k, v)| (k.to_owned(), v.unquoted().into_owned()))
1109            .collect();
1110        assert_eq!(
1111            pairs,
1112            vec![
1113                ("charset".to_owned(), "utf-8".to_owned()),
1114                ("boundary".to_owned(), "x".to_owned()),
1115            ]
1116        );
1117    }
1118
1119    #[test]
1120    fn content_transfer_encoding_canonicalizes_known_tokens() {
1121        assert_eq!(
1122            "Base64"
1123                .parse::<ContentTransferEncoding>()
1124                .unwrap()
1125                .as_str(),
1126            "base64"
1127        );
1128        assert_eq!(
1129            "7BIT".parse::<ContentTransferEncoding>().unwrap().as_str(),
1130            "7bit"
1131        );
1132        assert_eq!(
1133            "Quoted-Printable"
1134                .parse::<ContentTransferEncoding>()
1135                .unwrap(),
1136            ContentTransferEncoding::QuotedPrintable
1137        );
1138
1139        let other: ContentTransferEncoding = "x-my-encoding".parse().unwrap();
1140        assert_eq!(
1141            other,
1142            ContentTransferEncoding::Other("x-my-encoding".to_owned())
1143        );
1144        assert_eq!(other.as_str(), "x-my-encoding");
1145    }
1146
1147    #[test]
1148    fn content_disposition_kind_and_parameter_accessors() {
1149        use super::ContentDisposition;
1150        let cd: ContentDisposition = "attachment; filename=\"report.pdf\""
1151            .parse()
1152            .expect("disposition should parse");
1153        assert_eq!(cd.kind(), "attachment");
1154        assert!(cd.is_attachment());
1155        assert!(!cd.is_inline());
1156        let filename = cd.filename().expect("filename present");
1157        assert_eq!(filename.unquoted().as_ref(), "report.pdf");
1158    }
1159
1160    #[test]
1161    fn content_disposition_filename_falls_back_to_extended_parameter() {
1162        use super::ContentDisposition;
1163        let cd: ContentDisposition = "attachment; filename*=utf-8''f%C3%A1jl.txt"
1164            .parse()
1165            .expect("disposition should parse");
1166
1167        let filename = cd.filename().expect("filename* present");
1168        assert_eq!(filename.as_raw(), "utf-8''f%C3%A1jl.txt");
1169    }
1170
1171    #[test]
1172    fn content_disposition_inline_kind_is_case_insensitive() {
1173        use super::ContentDisposition;
1174        let cd: ContentDisposition = "INLINE".parse().expect("disposition should parse");
1175        assert!(cd.is_inline());
1176        assert!(!cd.is_attachment());
1177    }
1178
1179    #[test]
1180    fn content_disposition_parameters_iterates_in_declaration_order() {
1181        use super::ContentDisposition;
1182        let cd: ContentDisposition = "attachment; filename=report.pdf; size=42".parse().unwrap();
1183        let pairs: Vec<(String, String)> = cd
1184            .parameters()
1185            .map(|(k, v)| (k.to_owned(), v.unquoted().into_owned()))
1186            .collect();
1187        assert_eq!(
1188            pairs,
1189            vec![
1190                ("filename".to_owned(), "report.pdf".to_owned()),
1191                ("size".to_owned(), "42".to_owned()),
1192            ]
1193        );
1194    }
1195
1196    #[test]
1197    fn content_disposition_parameter_lookup_is_case_insensitive() {
1198        use super::ContentDisposition;
1199        let cd: ContentDisposition = "attachment; FileName=\"x.txt\"".parse().unwrap();
1200        assert_eq!(
1201            cd.parameter("filename").unwrap().unquoted().as_ref(),
1202            "x.txt"
1203        );
1204        assert_eq!(
1205            cd.parameter("FILENAME").unwrap().unquoted().as_ref(),
1206            "x.txt"
1207        );
1208    }
1209
1210    #[test]
1211    fn content_transfer_encoding_other_is_case_insensitive() {
1212        // RFC 2045 §6.1, encoding names are case-insensitive. Two
1213        // differently-cased spellings of the same x-* extension must
1214        // compare equal and hash to the same value.
1215        let a: ContentTransferEncoding = "X-MyEnc".parse().unwrap();
1216        let b: ContentTransferEncoding = "x-myenc".parse().unwrap();
1217        let c: ContentTransferEncoding = "X-MYENC".parse().unwrap();
1218        assert_eq!(a, b);
1219        assert_eq!(a, c);
1220        assert_eq!(a.as_str(), "x-myenc");
1221        assert_eq!(c.as_str(), "x-myenc");
1222
1223        // Same value can be safely used as a HashMap/HashSet key.
1224        let mut set: HashSet<ContentTransferEncoding> = HashSet::new();
1225        set.insert(a);
1226        assert!(set.contains(&b));
1227        assert!(set.contains(&c));
1228    }
1229
1230    #[test]
1231    fn content_type_eq_is_case_insensitive_after_normalize() {
1232        use std::collections::hash_map::DefaultHasher;
1233        use std::hash::{Hash, Hasher};
1234
1235        let upper = ContentType::try_from("TEXT/PLAIN; CHARSET=UTF-8").unwrap();
1236        let lower = ContentType::try_from("text/plain; charset=UTF-8").unwrap();
1237
1238        assert_eq!(upper, lower);
1239        assert_eq!(upper.as_str(), "text/plain; charset=UTF-8");
1240
1241        let mut h_u = DefaultHasher::new();
1242        upper.hash(&mut h_u);
1243        let mut h_l = DefaultHasher::new();
1244        lower.hash(&mut h_l);
1245        assert_eq!(h_u.finish(), h_l.finish());
1246
1247        // Parameter values are preserved as-is (case-sensitive per RFC 2046
1248        // §5.1.1 for `boundary`).
1249        let preserved = ContentType::try_from("multipart/mixed; BOUNDARY=\"AbC\"").unwrap();
1250        assert_eq!(preserved.as_str(), "multipart/mixed; boundary=\"AbC\"");
1251    }
1252
1253    #[test]
1254    fn content_disposition_eq_is_case_insensitive_after_normalize() {
1255        use super::ContentDisposition;
1256        let upper = ContentDisposition::try_from("ATTACHMENT; FILENAME=\"x.pdf\"").unwrap();
1257        let lower = ContentDisposition::try_from("attachment; filename=\"x.pdf\"").unwrap();
1258
1259        assert_eq!(upper, lower);
1260        assert_eq!(upper.as_str(), "attachment; filename=\"x.pdf\"");
1261    }
1262}