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}