Skip to main content

jmap_mime/
lib.rs

1//! Thin adapter: [`mime_tree`] types → [`jmap_mail_types`] types.
2//!
3//! All MIME parsing lives in `mime-tree`. This crate only maps field names.
4//!
5//! # Usage
6//!
7//! A real `MailBackend::parse_email` implementation MUST surface
8//! `mime_tree::parse` and `decode_body_value` errors as JMAP method
9//! errors per RFC 8620 §3.6.2 — never panic. This example uses `?` and
10//! returns `Result` so the pattern reads correctly when copied.
11//!
12//! ```rust
13//! use jmap_mime::{message_to_jmap_body, body_value_to_jmap};
14//! use jmap_types::Id;
15//! use mime_tree::{parse, decode_body_value};
16//!
17//! # fn demo() -> Result<(), Box<dyn std::error::Error>> {
18//! let raw = b"From: alice@example.com\r\n\
19//!             Content-Type: text/plain; charset=utf-8\r\n\
20//!             \r\n\
21//!             Hello, world!\r\n";
22//!
23//! let msg = parse(raw)?;
24//!
25//! // Assign blob IDs for each leaf part (storage layer decides how).
26//! let fields = message_to_jmap_body(&msg, |part| {
27//!     Id::from(format!("blob-{}", part.part_id))
28//! });
29//!
30//! assert_eq!(fields.text_body.len(), 1);
31//! assert_eq!(fields.text_body[0].type_.as_deref(), Some("text/plain"));
32//!
33//! // Decode body values on demand and map them into EmailBodyValue.
34//! for part_id in &fields.body_value_part_ids {
35//!     if let Some(part) = msg.part_index.find_by_id(part_id) {
36//!         let decoded = decode_body_value(raw, part, Some(8192))?;
37//!         let _jmap_val = body_value_to_jmap(decoded);
38//!     }
39//! }
40//! # Ok(())
41//! # }
42//! # demo().unwrap();
43//! ```
44
45#![forbid(unsafe_code)]
46
47use jmap_mail_types::email::{EmailBodyPart, EmailBodyValue};
48use jmap_types::Id;
49use mime_tree::{DecodedBodyValue, ParsedMessage, ParsedPart};
50
51/// Maximum multipart nesting depth this adapter will recurse into.
52///
53/// A multipart part whose depth in the tree (root = 0) exceeds this bound
54/// is converted as an opaque leaf: its [`EmailBodyPart`] is emitted with
55/// the same type / disposition / cid / charset / name as the multipart,
56/// but `sub_parts` is set to `None` instead of recursing further. The
57/// resulting JMAP wire response is a structurally well-formed truncation
58/// rather than a stack-overflow crash.
59///
60/// The bound exists as defense-in-depth against deeply-nested
61/// `multipart/*` framing supplied by a hostile SMTP sender. Mainstream
62/// MIME parsers cap recursion at roughly the same depth (64 is the
63/// commonly-cited industry value). The bound is intentionally far below
64/// the system thread stack frame limit so that a few thousand bytes of
65/// per-frame state stays well clear of overflow.
66///
67/// Note: this constant bounds only `jmap-mime`'s own walk. The upstream
68/// [`mime_tree::parse`] / [`mime_tree::ParsedPart::find_by_id`] paths
69/// have their own (currently unbounded — see crate `Gotchas`) recursion
70/// posture. Consumers MUST also bound raw message size upstream of
71/// `mime_tree::parse` to obtain total-message safety.
72pub const MAX_PART_DEPTH: usize = 64;
73
74/// The JMAP body fields derived from a parsed MIME message.
75///
76/// Returned by [`message_to_jmap_body`]. Each list mirrors the RFC 8621
77/// §4.1.4 definitions. `body_value_part_ids` lists the part IDs the caller
78/// should decode via [`mime_tree::decode_body_value`] and insert into the
79/// `bodyValues` map of the JMAP `Email` response.
80#[derive(Debug, Clone, PartialEq)]
81pub struct JmapBodyFields {
82    /// Full MIME tree (RFC 8621 §4.1.4 `bodyStructure`).
83    pub body_structure: EmailBodyPart,
84    /// Text/plain display parts (RFC 8621 §4.1.4 `textBody`).
85    pub text_body: Vec<EmailBodyPart>,
86    /// Text/html display parts (RFC 8621 §4.1.4 `htmlBody`).
87    pub html_body: Vec<EmailBodyPart>,
88    /// Attachment and non-inline parts (RFC 8621 §4.1.4 `attachments`).
89    pub attachments: Vec<EmailBodyPart>,
90    /// Short preview of the message body (RFC 8621 §4.1.4 `preview`).
91    pub preview: Option<String>,
92    /// Part IDs whose body content should be decoded and surfaced in
93    /// `bodyValues`. Typically the union of `textBody` and `htmlBody` part IDs.
94    pub body_value_part_ids: Vec<String>,
95}
96
97/// Convert a [`ParsedPart`] (and its children) into an [`EmailBodyPart`] tree.
98///
99/// `blob_id_for` is called once per non-multipart leaf to assign a `blobId`.
100/// Multipart parts receive `None` for `partId`, `blobId`, and `size`.
101///
102/// The `headers` and `language`/`location` fields are not populated here
103/// because they require access to the raw message bytes. Callers that need
104/// per-part raw headers can extract them from `part.header_range` and the
105/// original `&[u8]`.
106///
107/// # `blob_id_for` contract
108///
109/// The closure MUST be idempotent on `part.part_id`: repeated calls for
110/// parts with the same `part_id` MUST return the same `Id`. A counter-
111/// based or remote-allocate scheme that returns a fresh `Id` on every
112/// invocation will produce a JMAP `Email` response in which the same
113/// logical leaf appears under multiple distinct `blobId`s, which is a
114/// silent wire-format defect. Pure functions of `part.part_id` (or of
115/// the part bytes) satisfy the contract trivially.
116///
117/// `part_to_jmap` invokes the closure at most once per leaf in the
118/// subtree rooted at `part`. The frequency contract is only relevant
119/// when the same closure is also passed to [`message_to_jmap_body`],
120/// which calls the closure once per appearance in `body_structure` /
121/// `text_body` / `html_body` / `attachments` (so up to three times per
122/// plain-only leaf — see [`message_to_jmap_body`]).
123///
124/// # Depth bound
125///
126/// The recursion is bounded by [`MAX_PART_DEPTH`]. A multipart subtree
127/// nested deeper than the bound is converted as an opaque leaf with
128/// `sub_parts = None`. See the [`MAX_PART_DEPTH`] doc for rationale.
129pub fn part_to_jmap(part: &ParsedPart, blob_id_for: impl Fn(&ParsedPart) -> Id) -> EmailBodyPart {
130    part_to_jmap_inner(part, &blob_id_for, 0)
131}
132
133fn part_to_jmap_inner(
134    part: &ParsedPart,
135    blob_id_for: &dyn Fn(&ParsedPart) -> Id,
136    depth: usize,
137) -> EmailBodyPart {
138    let is_multipart = !part.children.is_empty();
139
140    // Defense-in-depth: stop recursing once the multipart nesting exceeds
141    // MAX_PART_DEPTH. The over-deep subtree is emitted as a structurally
142    // typed-but-leaf EmailBodyPart so the JMAP wire response stays valid.
143    let truncate_here = is_multipart && depth >= MAX_PART_DEPTH;
144
145    let sub_parts = if is_multipart && !truncate_here {
146        Some(
147            part.children
148                .iter()
149                .map(|c| part_to_jmap_inner(c, blob_id_for, depth + 1))
150                .collect(),
151        )
152    } else {
153        None
154    };
155
156    // EmailBodyPart is #[non_exhaustive]; use Default + field mutation.
157    let mut out = EmailBodyPart::default();
158    out.part_id = (!is_multipart).then(|| part.part_id.clone());
159    out.blob_id = (!is_multipart).then(|| blob_id_for(part));
160    // size: pre-decoded byte length of the body (encoded size; exact for
161    // identity/7bit/8bit, approximate for base64/QP).
162    out.size = (!is_multipart).then_some(part.body_range.1 as u64);
163    out.name = part.filename.clone();
164    out.type_ = Some(part.content_type.clone());
165    out.charset = part.charset.clone();
166    out.disposition = part.disposition.clone();
167    out.cid = part.cid.clone();
168    out.sub_parts = sub_parts;
169    // headers, language, location: require raw bytes; left as None/empty.
170    out
171}
172
173/// Convert a [`DecodedBodyValue`] into an [`EmailBodyValue`].
174///
175/// This is a direct field rename; no logic.
176pub fn body_value_to_jmap(val: DecodedBodyValue) -> EmailBodyValue {
177    // EmailBodyValue is #[non_exhaustive]; use the provided constructor.
178    let mut out = EmailBodyValue::new(val.value);
179    out.is_encoding_problem = val.is_encoding_problem;
180    out.is_truncated = val.is_truncated;
181    out
182}
183
184/// Build the full JMAP body fields from a [`ParsedMessage`].
185///
186/// Converts the entire part tree and all RFC 8621 §4.1.4 body lists.
187///
188/// # `blob_id_for` contract
189///
190/// The closure is invoked once per appearance of a non-multipart leaf
191/// in the produced fields, not once per unique leaf. In a plain-text-only
192/// message — by far the most common shape — RFC 8621 §4.1.4 mandates
193/// that `html_body` mirrors `text_body`, so the single text leaf is
194/// emitted from `body_structure`, `text_body`, and `html_body` and the
195/// closure is invoked three times for it. The closure MUST therefore be
196/// idempotent on `part.part_id`; see [`part_to_jmap`] for the full
197/// contract.
198///
199/// # `body_value_part_ids` shape
200///
201/// The returned [`JmapBodyFields::body_value_part_ids`] is the
202/// **concatenation** of [`mime_tree::ParsedMessage::text_body`] and
203/// [`mime_tree::ParsedMessage::html_body`] — in that order, with no
204/// dedup. For plain-only messages where `html_body` mirrors `text_body`,
205/// a leaf's `part_id` therefore appears twice in this list. Callers that
206/// build a `bodyValues` map keyed by `part_id` will silently dedup; a
207/// caller that builds an order-preserving `Vec` or that emits each entry
208/// directly to a JSON sink will produce duplicates. Dedup at the call
209/// site if either matters.
210///
211/// The caller must decode each `part_id` in `body_value_part_ids` via
212/// [`mime_tree::decode_body_value`] to populate `bodyValues` in the
213/// JMAP response.
214///
215/// # Depth bound
216///
217/// The recursion is bounded by [`MAX_PART_DEPTH`]. The bound applies to
218/// `body_structure` and to each entry in `text_body` / `html_body` /
219/// `attachments`. See the [`MAX_PART_DEPTH`] doc for rationale.
220pub fn message_to_jmap_body(
221    msg: &ParsedMessage,
222    blob_id_for: impl Fn(&ParsedPart) -> Id,
223) -> JmapBodyFields {
224    let blob_id_for = &blob_id_for;
225
226    let body_structure = part_to_jmap_inner(&msg.part_index, blob_id_for, 0);
227
228    let text_body = msg
229        .text_body
230        .iter()
231        .filter_map(|id| msg.part_index.find_by_id(id))
232        .map(|p| part_to_jmap_inner(p, blob_id_for, 0))
233        .collect();
234
235    let html_body = msg
236        .html_body
237        .iter()
238        .filter_map(|id| msg.part_index.find_by_id(id))
239        .map(|p| part_to_jmap_inner(p, blob_id_for, 0))
240        .collect();
241
242    let attachments = msg
243        .attachments
244        .iter()
245        .filter_map(|id| msg.part_index.find_by_id(id))
246        .map(|p| part_to_jmap_inner(p, blob_id_for, 0))
247        .collect();
248
249    let mut body_value_part_ids = Vec::with_capacity(msg.text_body.len() + msg.html_body.len());
250    body_value_part_ids.extend(msg.text_body.iter().cloned());
251    body_value_part_ids.extend(msg.html_body.iter().cloned());
252
253    JmapBodyFields {
254        body_structure,
255        text_body,
256        html_body,
257        attachments,
258        preview: msg.preview.clone(),
259        body_value_part_ids,
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use mime_tree::{decode_body_value, parse};
267
268    // Oracle: mime_tree::parse() on known-good RFC 5322 bytes.
269
270    // --- body_value_to_jmap ---
271
272    #[test]
273    fn body_value_plain_maps_fields() {
274        let dv = DecodedBodyValue {
275            value: "hello".into(),
276            is_truncated: false,
277            is_encoding_problem: false,
278        };
279        let jv = body_value_to_jmap(dv);
280        assert_eq!(jv.value, "hello");
281        assert!(!jv.is_truncated);
282        assert!(!jv.is_encoding_problem);
283    }
284
285    #[test]
286    fn body_value_flags_preserved() {
287        let dv = DecodedBodyValue {
288            value: "x".into(),
289            is_truncated: true,
290            is_encoding_problem: true,
291        };
292        let jv = body_value_to_jmap(dv);
293        assert!(jv.is_truncated);
294        assert!(jv.is_encoding_problem);
295    }
296
297    // --- part_to_jmap: single text/plain message ---
298
299    // Oracle: RFC 5322 minimal message with one text/plain part.
300    const PLAIN_MSG: &[u8] = b"From: alice@example.com\r\n\
301        Content-Type: text/plain; charset=utf-8\r\n\
302        \r\n\
303        Hello, world!\r\n";
304
305    #[test]
306    fn part_to_jmap_plain_part_id_and_type() {
307        let msg = parse(PLAIN_MSG).unwrap();
308        let root = &msg.part_index;
309        let jpart = part_to_jmap(root, |p| Id::from(format!("b-{}", p.part_id)));
310
311        assert_eq!(jpart.part_id.as_deref(), Some("1"));
312        assert_eq!(jpart.type_.as_deref(), Some("text/plain"));
313        assert_eq!(jpart.charset.as_deref(), Some("utf-8"));
314        assert!(jpart.sub_parts.is_none());
315    }
316
317    #[test]
318    fn part_to_jmap_plain_blob_id_assigned() {
319        let msg = parse(PLAIN_MSG).unwrap();
320        let jpart = part_to_jmap(&msg.part_index, |p| Id::from(format!("b-{}", p.part_id)));
321        assert_eq!(jpart.blob_id.as_ref().map(|id| id.as_ref()), Some("b-1"));
322    }
323
324    #[test]
325    fn part_to_jmap_plain_size_nonzero() {
326        let msg = parse(PLAIN_MSG).unwrap();
327        let jpart = part_to_jmap(&msg.part_index, |p| Id::from(p.part_id.clone()));
328        assert!(
329            jpart.size.unwrap_or(0) > 0,
330            "size must be nonzero for non-empty body"
331        );
332    }
333
334    // --- part_to_jmap: multipart message ---
335
336    // Oracle: RFC 5322 multipart/alternative with text + html parts.
337    const ALT_MSG: &[u8] = b"From: alice@example.com\r\n\
338        Content-Type: multipart/alternative; boundary=\"b\"\r\n\
339        \r\n\
340        --b\r\n\
341        Content-Type: text/plain; charset=utf-8\r\n\
342        \r\n\
343        Plain text\r\n\
344        --b\r\n\
345        Content-Type: text/html; charset=utf-8\r\n\
346        \r\n\
347        <p>HTML</p>\r\n\
348        --b--\r\n";
349
350    #[test]
351    fn part_to_jmap_multipart_has_no_part_id() {
352        let msg = parse(ALT_MSG).unwrap();
353        let jpart = part_to_jmap(&msg.part_index, |p| Id::from(p.part_id.clone()));
354
355        // Root is multipart — no partId, no blobId, no size.
356        assert!(
357            jpart.part_id.is_none(),
358            "multipart root must have no partId"
359        );
360        assert!(
361            jpart.blob_id.is_none(),
362            "multipart root must have no blobId"
363        );
364        assert!(jpart.size.is_none(), "multipart root must have no size");
365    }
366
367    #[test]
368    fn part_to_jmap_multipart_sub_parts_present() {
369        let msg = parse(ALT_MSG).unwrap();
370        let jpart = part_to_jmap(&msg.part_index, |p| Id::from(p.part_id.clone()));
371
372        let subs = jpart
373            .sub_parts
374            .as_ref()
375            .expect("multipart must have sub_parts");
376        assert_eq!(subs.len(), 2, "two child parts expected");
377        assert_eq!(subs[0].type_.as_deref(), Some("text/plain"));
378        assert_eq!(subs[1].type_.as_deref(), Some("text/html"));
379        // Children are leaves: they have partIds and no sub_parts.
380        assert!(subs[0].part_id.is_some());
381        assert!(subs[1].part_id.is_some());
382        assert!(subs[0].sub_parts.is_none());
383        assert!(subs[1].sub_parts.is_none());
384    }
385
386    // --- message_to_jmap_body: plain text ---
387
388    #[test]
389    fn message_to_jmap_body_plain_text_body() {
390        let msg = parse(PLAIN_MSG).unwrap();
391        let fields = message_to_jmap_body(&msg, |p| Id::from(p.part_id.clone()));
392
393        assert_eq!(fields.text_body.len(), 1);
394        assert_eq!(fields.text_body[0].type_.as_deref(), Some("text/plain"));
395        // RFC 8621 §4.1.4: when no HTML part exists, html_body mirrors text_body.
396        assert_eq!(
397            fields.html_body.len(),
398            1,
399            "html_body must mirror text_body when no HTML part present"
400        );
401        assert!(fields.attachments.is_empty());
402    }
403
404    #[test]
405    fn message_to_jmap_body_plain_preview() {
406        let msg = parse(PLAIN_MSG).unwrap();
407        let fields = message_to_jmap_body(&msg, |p| Id::from(p.part_id.clone()));
408
409        // mime-tree computes the preview; we just pass it through.
410        assert!(
411            fields.preview.is_some(),
412            "plain text message should have a preview"
413        );
414        let preview = fields.preview.unwrap();
415        assert!(
416            preview.contains("Hello"),
417            "preview should contain body text"
418        );
419    }
420
421    #[test]
422    fn message_to_jmap_body_plain_body_value_part_ids() {
423        let msg = parse(PLAIN_MSG).unwrap();
424        let fields = message_to_jmap_body(&msg, |p| Id::from(p.part_id.clone()));
425
426        // text_body part IDs should be in body_value_part_ids.
427        assert!(
428            !fields.body_value_part_ids.is_empty(),
429            "plain text must have at least one body value part ID"
430        );
431        for id in &fields.text_body {
432            let pid = id
433                .part_id
434                .as_deref()
435                .expect("text_body part must have partId");
436            assert!(
437                fields.body_value_part_ids.contains(&pid.to_owned()),
438                "text_body partId {pid} must appear in body_value_part_ids"
439            );
440        }
441    }
442
443    // --- message_to_jmap_body: multipart/alternative ---
444
445    #[test]
446    fn message_to_jmap_body_alt_text_and_html() {
447        let msg = parse(ALT_MSG).unwrap();
448        let fields = message_to_jmap_body(&msg, |p| Id::from(p.part_id.clone()));
449
450        assert_eq!(fields.text_body.len(), 1);
451        assert_eq!(fields.html_body.len(), 1);
452        assert!(fields.attachments.is_empty());
453        assert_eq!(fields.text_body[0].type_.as_deref(), Some("text/plain"));
454        assert_eq!(fields.html_body[0].type_.as_deref(), Some("text/html"));
455    }
456
457    #[test]
458    fn message_to_jmap_body_alt_body_value_part_ids_both() {
459        let msg = parse(ALT_MSG).unwrap();
460        let fields = message_to_jmap_body(&msg, |p| Id::from(p.part_id.clone()));
461
462        // Both text and html part IDs must be present.
463        for part in fields.text_body.iter().chain(fields.html_body.iter()) {
464            let pid = part.part_id.as_deref().expect("must have partId");
465            assert!(
466                fields.body_value_part_ids.contains(&pid.to_owned()),
467                "partId {pid} must be in body_value_part_ids"
468            );
469        }
470    }
471
472    // --- decode round-trip via body_value_to_jmap ---
473
474    #[test]
475    fn decode_roundtrip_plain() {
476        let msg = parse(PLAIN_MSG).unwrap();
477        let part = msg.part_index.find_by_id("1").unwrap();
478        let decoded = decode_body_value(PLAIN_MSG, part, None).unwrap();
479        let jval = body_value_to_jmap(decoded);
480
481        assert!(jval.value.contains("Hello, world!"));
482        assert!(!jval.is_truncated);
483        assert!(!jval.is_encoding_problem);
484    }
485
486    #[test]
487    fn decode_roundtrip_with_truncation() {
488        let msg = parse(PLAIN_MSG).unwrap();
489        let part = msg.part_index.find_by_id("1").unwrap();
490        // Truncate at 5 bytes — body is longer, so is_truncated must be true.
491        let decoded = decode_body_value(PLAIN_MSG, part, Some(5)).unwrap();
492        let jval = body_value_to_jmap(decoded);
493
494        assert!(
495            jval.is_truncated,
496            "value must be marked truncated when max_bytes hit"
497        );
498        assert!(jval.value.len() <= 5);
499    }
500
501    // --- attachment message ---
502
503    // Oracle: RFC 5322 message with text/plain body and an attachment.
504    const ATTACH_MSG: &[u8] = b"From: alice@example.com\r\n\
505        Content-Type: multipart/mixed; boundary=\"m\"\r\n\
506        \r\n\
507        --m\r\n\
508        Content-Type: text/plain; charset=utf-8\r\n\
509        \r\n\
510        See attached\r\n\
511        --m\r\n\
512        Content-Type: application/octet-stream\r\n\
513        Content-Disposition: attachment; filename=\"file.bin\"\r\n\
514        \r\n\
515        BINARYDATA\r\n\
516        --m--\r\n";
517
518    #[test]
519    fn message_to_jmap_body_attachment_classified() {
520        let msg = parse(ATTACH_MSG).unwrap();
521        let fields = message_to_jmap_body(&msg, |p| Id::from(p.part_id.clone()));
522
523        assert_eq!(fields.text_body.len(), 1);
524        assert_eq!(fields.attachments.len(), 1);
525        assert_eq!(
526            fields.attachments[0].type_.as_deref(),
527            Some("application/octet-stream")
528        );
529        assert_eq!(
530            fields.attachments[0].disposition.as_deref(),
531            Some("attachment")
532        );
533        assert_eq!(fields.attachments[0].name.as_deref(), Some("file.bin"));
534    }
535
536    #[test]
537    fn message_to_jmap_body_attachment_not_in_body_values() {
538        let msg = parse(ATTACH_MSG).unwrap();
539        let fields = message_to_jmap_body(&msg, |p| Id::from(p.part_id.clone()));
540
541        // Attachments should NOT be in body_value_part_ids.
542        for attach in &fields.attachments {
543            if let Some(pid) = &attach.part_id {
544                assert!(
545                    !fields.body_value_part_ids.contains(pid),
546                    "attachment partId {pid} must not be in body_value_part_ids"
547                );
548            }
549        }
550    }
551
552    // --- depth-bound (DoS defense) tests ---
553
554    use mime_tree::TransferEncoding;
555
556    // Build a deeply-nested multipart `ParsedPart` chain of length `depth`,
557    // terminated by a single text/plain leaf at the bottom. This bypasses
558    // `mime_tree::parse` so the test can exercise depths that the upstream
559    // parser cannot itself handle (its recursion is currently unbounded).
560    fn make_deep_multipart_chain(depth: usize) -> ParsedPart {
561        // Innermost leaf.
562        let mut node = ParsedPart {
563            part_id: "1".to_owned(),
564            content_type: "text/plain".to_owned(),
565            charset: Some("utf-8".to_owned()),
566            transfer_encoding: TransferEncoding::Identity,
567            disposition: None,
568            filename: None,
569            cid: None,
570            header_range: (0, 0),
571            body_range: (0, 0),
572            children: Vec::new(),
573            is_encoding_problem: false,
574        };
575        for level in (0..depth).rev() {
576            node = ParsedPart {
577                part_id: format!("L{level}"),
578                content_type: "multipart/mixed".to_owned(),
579                charset: None,
580                transfer_encoding: TransferEncoding::Identity,
581                disposition: None,
582                filename: None,
583                cid: None,
584                header_range: (0, 0),
585                body_range: (0, 0),
586                children: vec![node],
587                is_encoding_problem: false,
588            };
589        }
590        node
591    }
592
593    // Walk an EmailBodyPart sub_parts chain and return its depth, counting
594    // multipart wrappers. Stops when sub_parts is None or empty.
595    fn email_body_chain_depth(root: &EmailBodyPart) -> usize {
596        let mut depth = 0usize;
597        let mut cur = root;
598        while let Some(subs) = cur.sub_parts.as_ref().filter(|s| !s.is_empty()) {
599            depth += 1;
600            cur = &subs[0];
601        }
602        depth
603    }
604
605    #[test]
606    fn part_to_jmap_truncates_over_deep_multipart() {
607        // Build a chain MAX_PART_DEPTH + 4 multiparts deep. The adapter
608        // must convert it without recursing past MAX_PART_DEPTH and must
609        // not stack-overflow.
610        let part = make_deep_multipart_chain(MAX_PART_DEPTH + 4);
611        let jpart = part_to_jmap(&part, |p| Id::from(p.part_id.clone()));
612        // The emitted EmailBodyPart chain reflects the multipart wrappers
613        // walked before the bound kicks in. The wrapper at depth
614        // MAX_PART_DEPTH is emitted as an opaque leaf (sub_parts = None),
615        // so the visible chain length is exactly MAX_PART_DEPTH.
616        let observed = email_body_chain_depth(&jpart);
617        assert_eq!(
618            observed, MAX_PART_DEPTH,
619            "adapter should stop recursing at MAX_PART_DEPTH",
620        );
621    }
622
623    #[test]
624    fn part_to_jmap_preserves_full_tree_below_bound() {
625        // A chain shallower than the bound must round-trip without
626        // truncation.
627        let depth = 5usize;
628        assert!(depth < MAX_PART_DEPTH);
629        let part = make_deep_multipart_chain(depth);
630        let jpart = part_to_jmap(&part, |p| Id::from(p.part_id.clone()));
631        let observed = email_body_chain_depth(&jpart);
632        assert_eq!(
633            observed, depth,
634            "shallow trees must be preserved without truncation",
635        );
636    }
637
638    #[test]
639    fn part_to_jmap_handles_one_thousand_levels_without_panic() {
640        // Worst-case adversary: 1000-level-deep tree. Adapter must return
641        // in bounded stack regardless. The visible-chain length is
642        // MAX_PART_DEPTH; depths beyond that are collapsed to an opaque
643        // leaf.
644        let part = make_deep_multipart_chain(1000);
645        let jpart = part_to_jmap(&part, |p| Id::from(p.part_id.clone()));
646        assert_eq!(email_body_chain_depth(&jpart), MAX_PART_DEPTH);
647    }
648
649    #[test]
650    fn truncated_multipart_emits_opaque_leaf() {
651        // The first part whose depth equals MAX_PART_DEPTH must come back
652        // as a multipart-typed EmailBodyPart with sub_parts == None — the
653        // structural signal that the tree was truncated.
654        let part = make_deep_multipart_chain(MAX_PART_DEPTH + 2);
655        let jpart = part_to_jmap(&part, |p| Id::from(p.part_id.clone()));
656
657        // Walk down to the deepest emitted level.
658        let mut cur = &jpart;
659        for _ in 0..MAX_PART_DEPTH - 1 {
660            cur = &cur
661                .sub_parts
662                .as_ref()
663                .expect("interior nodes must have sub_parts")[0];
664        }
665        // `cur` is the multipart at depth MAX_PART_DEPTH - 1. Its single
666        // child is the one at depth MAX_PART_DEPTH, which should be the
667        // truncation marker: multipart type, sub_parts None.
668        let truncated = &cur
669            .sub_parts
670            .as_ref()
671            .expect("level MAX_PART_DEPTH - 1 still has sub_parts")[0];
672        assert_eq!(truncated.type_.as_deref(), Some("multipart/mixed"));
673        assert!(
674            truncated.sub_parts.is_none(),
675            "over-depth multipart must be emitted as opaque leaf (sub_parts = None)",
676        );
677        // Multipart parts carry no partId / blobId / size, even when
678        // they are the truncation marker.
679        assert!(truncated.part_id.is_none());
680        assert!(truncated.blob_id.is_none());
681        assert!(truncated.size.is_none());
682    }
683}