1use crate::types::{GmailHeader, GmailMessage, GmailPayload};
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use base64::Engine;
4use chrono::{TimeZone, Utc};
5use mxr_compose::parse::{
6 body_unsubscribe_from_html, calendar_metadata_from_text, decode_format_flowed,
7 parse_address_list as parse_rfc_address_list, parse_headers_from_pairs,
8};
9use mxr_core::{
10 AccountId, Address, AttachmentId, AttachmentMeta, Envelope, MessageBody, MessageFlags,
11 MessageId, TextPlainFormat, ThreadId, UnsubscribeMethod,
12};
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16pub enum ParseError {
17 #[error("Missing required header: {0}")]
18 MissingHeader(String),
19
20 #[error("Invalid date: {0}")]
21 InvalidDate(String),
22
23 #[error("Decode error: {0}")]
24 Decode(String),
25
26 #[error("Invalid headers: {0}")]
27 Headers(String),
28}
29
30pub fn gmail_message_to_envelope(
31 msg: &GmailMessage,
32 account_id: &AccountId,
33) -> Result<Envelope, ParseError> {
34 let headers = msg
35 .payload
36 .as_ref()
37 .and_then(|p| p.headers.as_ref())
38 .map(|h| h.as_slice())
39 .unwrap_or(&[]);
40
41 let fallback_date = if let Some(ref internal_date) = msg.internal_date {
42 let millis: i64 = internal_date
43 .parse()
44 .map_err(|_| ParseError::InvalidDate(internal_date.clone()))?;
45 Some(
46 Utc.timestamp_millis_opt(millis)
47 .single()
48 .unwrap_or_else(Utc::now),
49 )
50 } else {
51 None
52 };
53 let header_pairs: Vec<(String, String)> = headers
54 .iter()
55 .map(|header| (header.name.clone(), header.value.clone()))
56 .collect();
57 let parsed_headers = parse_headers_from_pairs(&header_pairs, fallback_date)
58 .map_err(|err| ParseError::Headers(err.to_string()))?;
59 let body_data = extract_body_data(msg);
60
61 let label_ids = msg.label_ids.as_deref().unwrap_or(&[]);
62 let flags = labels_to_flags(label_ids);
63 let has_attachments = check_has_attachments(msg.payload.as_ref());
64 let unsubscribe = match parsed_headers.unsubscribe {
65 UnsubscribeMethod::None => body_data
66 .text_html
67 .as_deref()
68 .and_then(body_unsubscribe_from_html)
69 .unwrap_or(UnsubscribeMethod::None),
70 unsubscribe => unsubscribe,
71 };
72
73 Ok(Envelope {
74 id: MessageId::from_provider_id("gmail", &msg.id),
75 account_id: account_id.clone(),
76 provider_id: msg.id.clone(),
77 thread_id: ThreadId::from_provider_id("gmail", &msg.thread_id),
78 message_id_header: parsed_headers.message_id_header,
79 in_reply_to: parsed_headers.in_reply_to,
80 references: parsed_headers.references,
81 from: parsed_headers.from.unwrap_or_else(|| Address {
82 name: None,
83 email: "unknown@unknown".to_string(),
84 }),
85 to: parsed_headers.to,
86 cc: parsed_headers.cc,
87 bcc: parsed_headers.bcc,
88 subject: parsed_headers.subject,
89 date: parsed_headers.date,
90 flags,
91 snippet: msg.snippet.clone().unwrap_or_default(),
92 has_attachments,
93 size_bytes: msg.size_estimate.unwrap_or(0),
94 unsubscribe,
95 label_provider_ids: msg.label_ids.clone().unwrap_or_default(),
96 })
97}
98
99pub fn labels_to_flags(label_ids: &[String]) -> MessageFlags {
100 let mut flags = MessageFlags::empty();
101
102 let has_unread = label_ids.iter().any(|l| l == "UNREAD");
104 if !has_unread {
105 flags |= MessageFlags::READ;
106 }
107
108 for label in label_ids {
109 match label.as_str() {
110 "STARRED" => flags |= MessageFlags::STARRED,
111 "DRAFT" => flags |= MessageFlags::DRAFT,
112 "SENT" => flags |= MessageFlags::SENT,
113 "TRASH" => flags |= MessageFlags::TRASH,
114 "SPAM" => flags |= MessageFlags::SPAM,
115 _ => {}
116 }
117 }
118
119 flags
120}
121
122pub fn parse_list_unsubscribe(headers: &[GmailHeader]) -> UnsubscribeMethod {
123 let header_pairs: Vec<(String, String)> = headers
124 .iter()
125 .map(|header| (header.name.clone(), header.value.clone()))
126 .collect();
127 parse_headers_from_pairs(&header_pairs, Some(Utc::now()))
128 .map(|parsed| parsed.unsubscribe)
129 .unwrap_or(UnsubscribeMethod::None)
130}
131
132pub fn parse_address(raw: &str) -> Address {
133 parse_rfc_address_list(raw)
134 .into_iter()
135 .next()
136 .unwrap_or(Address {
137 name: None,
138 email: raw.trim().to_string(),
139 })
140}
141
142pub fn parse_address_list(raw: &str) -> Vec<Address> {
143 parse_rfc_address_list(raw)
144}
145
146pub fn base64_decode_url(data: &str) -> Result<String, anyhow::Error> {
147 let bytes = URL_SAFE_NO_PAD.decode(data)?;
148 Ok(String::from_utf8(bytes)?)
149}
150
151fn check_has_attachments(payload: Option<&GmailPayload>) -> bool {
152 let payload = match payload {
153 Some(p) => p,
154 None => return false,
155 };
156
157 if let Some(ref filename) = payload.filename {
159 if !filename.is_empty() {
160 return true;
161 }
162 }
163
164 if let Some(ref body) = payload.body {
166 if body.attachment_id.is_some() {
167 return true;
168 }
169 }
170
171 if let Some(ref parts) = payload.parts {
173 for part in parts {
174 if check_has_attachments(Some(part)) {
175 return true;
176 }
177 }
178 }
179
180 false
181}
182
183#[derive(Debug, Default)]
184struct ExtractedBodyData {
185 text_plain: Option<String>,
186 text_html: Option<String>,
187 attachments: Vec<AttachmentMeta>,
188 calendar: Option<mxr_core::types::CalendarMetadata>,
189}
190
191pub fn extract_body(msg: &GmailMessage) -> (Option<String>, Option<String>, Vec<AttachmentMeta>) {
193 let body_data = extract_body_data(msg);
194 (
195 body_data.text_plain,
196 body_data.text_html,
197 body_data.attachments,
198 )
199}
200
201fn extract_body_data(msg: &GmailMessage) -> ExtractedBodyData {
202 let mut data = ExtractedBodyData::default();
203 if let Some(ref payload) = msg.payload {
204 walk_parts(payload, &msg.id, &mut data);
205 }
206 data
207}
208
209fn walk_parts(payload: &GmailPayload, provider_msg_id: &str, body_data: &mut ExtractedBodyData) {
210 let mime = payload
211 .mime_type
212 .as_deref()
213 .unwrap_or("application/octet-stream");
214
215 let is_attachment = payload
217 .filename
218 .as_ref()
219 .map(|f| !f.is_empty())
220 .unwrap_or(false)
221 || payload
222 .body
223 .as_ref()
224 .and_then(|b| b.attachment_id.as_ref())
225 .is_some();
226
227 if is_attachment && !mime.starts_with("multipart/") {
228 let filename = payload
229 .filename
230 .clone()
231 .unwrap_or_else(|| "unnamed".to_string());
232 let size = payload.body.as_ref().and_then(|b| b.size).unwrap_or(0);
233 let provider_id = payload
234 .body
235 .as_ref()
236 .and_then(|b| b.attachment_id.clone())
237 .unwrap_or_default();
238
239 body_data.attachments.push(AttachmentMeta {
240 id: AttachmentId::from_provider_id(
241 "gmail",
242 &format!("{provider_msg_id}:{provider_id}"),
243 ),
244 message_id: MessageId::from_provider_id("gmail", provider_msg_id),
245 filename,
246 mime_type: mime.to_string(),
247 size_bytes: size,
248 local_path: None,
249 provider_id,
250 });
251 return;
252 }
253
254 match mime {
256 "text/plain" if body_data.text_plain.is_none() => {
257 if let Some(data) = payload.body.as_ref().and_then(|b| b.data.as_ref()) {
258 if let Ok(decoded) = base64_decode_url(data) {
259 body_data.text_plain = Some(decoded);
260 }
261 }
262 }
263 "text/html" if body_data.text_html.is_none() => {
264 if let Some(data) = payload.body.as_ref().and_then(|b| b.data.as_ref()) {
265 if let Ok(decoded) = base64_decode_url(data) {
266 body_data.text_html = Some(decoded);
267 }
268 }
269 }
270 "text/calendar" if body_data.calendar.is_none() => {
271 if let Some(data) = payload.body.as_ref().and_then(|b| b.data.as_ref()) {
272 if let Ok(decoded) = base64_decode_url(data) {
273 body_data.calendar = calendar_metadata_from_text(&decoded);
274 }
275 }
276 }
277 _ => {}
278 }
279
280 if let Some(ref parts) = payload.parts {
282 for part in parts {
283 walk_parts(part, provider_msg_id, body_data);
284 }
285 }
286}
287
288pub fn extract_message_body(msg: &GmailMessage) -> MessageBody {
289 let header_pairs: Vec<(String, String)> = msg
290 .payload
291 .as_ref()
292 .and_then(|payload| payload.headers.as_ref())
293 .map(|headers| {
294 headers
295 .iter()
296 .map(|header| (header.name.clone(), header.value.clone()))
297 .collect()
298 })
299 .unwrap_or_default();
300 let parsed_headers = parse_headers_from_pairs(&header_pairs, Some(Utc::now())).ok();
301 let body_data = extract_body_data(msg);
302 let mut metadata = parsed_headers
303 .map(|parsed| parsed.metadata)
304 .unwrap_or_default();
305 metadata.calendar = body_data.calendar.clone();
306 let text_plain = match (&body_data.text_plain, &metadata.text_plain_format) {
307 (Some(text_plain), Some(TextPlainFormat::Flowed { delsp })) => {
308 Some(decode_format_flowed(text_plain, *delsp))
309 }
310 (Some(text_plain), _) => Some(text_plain.clone()),
311 (None, _) => None,
312 };
313 MessageBody {
314 message_id: MessageId::from_provider_id("gmail", &msg.id),
315 text_plain,
316 text_html: body_data.text_html,
317 attachments: body_data.attachments,
318 fetched_at: Utc::now(),
319 metadata,
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::types::GmailBody;
327 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
328 use mail_parser::MessageParser;
329 use mxr_compose::parse::extract_raw_header_block;
330 use mxr_test_support::{fixture_stem, standards_fixture_bytes, standards_fixture_names};
331 use serde_json::json;
332
333 fn make_headers(pairs: &[(&str, &str)]) -> Vec<GmailHeader> {
334 pairs
335 .iter()
336 .map(|(n, v)| GmailHeader {
337 name: n.to_string(),
338 value: v.to_string(),
339 })
340 .collect()
341 }
342
343 fn make_test_message() -> GmailMessage {
344 GmailMessage {
345 id: "msg-001".to_string(),
346 thread_id: "thread-001".to_string(),
347 label_ids: Some(vec!["INBOX".to_string(), "UNREAD".to_string()]),
348 snippet: Some("Hello world preview".to_string()),
349 history_id: Some("12345".to_string()),
350 internal_date: Some("1700000000000".to_string()),
351 size_estimate: Some(2048),
352 payload: Some(GmailPayload {
353 mime_type: Some("text/plain".to_string()),
354 headers: Some(make_headers(&[
355 ("From", "Alice <alice@example.com>"),
356 ("To", "Bob <bob@example.com>"),
357 ("Subject", "Test email"),
358 ("Message-ID", "<test123@example.com>"),
359 ("In-Reply-To", "<prev@example.com>"),
360 ("References", "<first@example.com> <prev@example.com>"),
361 ])),
362 body: Some(GmailBody {
363 attachment_id: None,
364 size: Some(100),
365 data: None,
366 }),
367 parts: None,
368 filename: None,
369 }),
370 }
371 }
372
373 fn gmail_message_from_fixture(name: &str) -> GmailMessage {
374 let raw = standards_fixture_bytes(name);
375 let parsed = MessageParser::default().parse(&raw).unwrap();
376 let mut headers = Vec::new();
377 let mut current_name = String::new();
378 let mut current_value = String::new();
379 for line in extract_raw_header_block(&raw).unwrap().lines() {
380 if line.starts_with(' ') || line.starts_with('\t') {
381 current_value.push(' ');
382 current_value.push_str(line.trim());
383 continue;
384 }
385
386 if !current_name.is_empty() {
387 headers.push(GmailHeader {
388 name: current_name.clone(),
389 value: current_value.trim().to_string(),
390 });
391 }
392
393 if let Some((name, value)) = line.split_once(':') {
394 current_name = name.to_string();
395 current_value = value.trim().to_string();
396 } else {
397 current_name.clear();
398 current_value.clear();
399 }
400 }
401 if !current_name.is_empty() {
402 headers.push(GmailHeader {
403 name: current_name,
404 value: current_value.trim().to_string(),
405 });
406 }
407 let body = parsed
408 .body_text(0)
409 .or_else(|| parsed.body_html(0))
410 .unwrap_or_default();
411
412 GmailMessage {
413 id: format!("fixture-{}", fixture_stem(name)),
414 thread_id: format!("fixture-thread-{}", fixture_stem(name)),
415 label_ids: Some(vec!["INBOX".to_string(), "UNREAD".to_string()]),
416 snippet: Some(body.lines().next().unwrap_or_default().to_string()),
417 history_id: Some("500".to_string()),
418 internal_date: Some("1710495000000".to_string()),
419 size_estimate: Some(raw.len() as u64),
420 payload: Some(GmailPayload {
421 mime_type: Some("text/plain".to_string()),
422 headers: Some(headers),
423 body: Some(GmailBody {
424 attachment_id: None,
425 size: Some(body.len() as u64),
426 data: Some(URL_SAFE_NO_PAD.encode(body.as_bytes())),
427 }),
428 parts: None,
429 filename: None,
430 }),
431 }
432 }
433
434 #[test]
435 fn parse_gmail_message_to_envelope() {
436 let msg = make_test_message();
437 let account_id = AccountId::from_provider_id("gmail", "test-account");
438 let env = gmail_message_to_envelope(&msg, &account_id).unwrap();
439
440 assert_eq!(env.provider_id, "msg-001");
441 assert_eq!(env.from.email, "alice@example.com");
442 assert_eq!(env.from.name, Some("Alice".to_string()));
443 assert_eq!(env.to.len(), 1);
444 assert_eq!(env.to[0].email, "bob@example.com");
445 assert_eq!(env.subject, "Test email");
446 assert_eq!(
447 env.message_id_header,
448 Some("<test123@example.com>".to_string())
449 );
450 assert_eq!(env.in_reply_to, Some("<prev@example.com>".to_string()));
451 assert_eq!(env.references.len(), 2);
452 assert_eq!(env.snippet, "Hello world preview");
453 assert_eq!(env.size_bytes, 2048);
454 assert!(!env.flags.contains(MessageFlags::READ));
456 assert_eq!(env.id, MessageId::from_provider_id("gmail", "msg-001"));
458 assert_eq!(
459 env.thread_id,
460 ThreadId::from_provider_id("gmail", "thread-001")
461 );
462 }
463
464 #[test]
465 fn parse_list_unsubscribe_one_click() {
466 let headers = make_headers(&[
467 (
468 "List-Unsubscribe",
469 "<https://unsub.example.com/oneclick>, <mailto:unsub@example.com>",
470 ),
471 ("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
472 ]);
473 let result = parse_list_unsubscribe(&headers);
474 assert!(matches!(
475 result,
476 UnsubscribeMethod::OneClick { ref url } if url == "https://unsub.example.com/oneclick"
477 ));
478 }
479
480 #[test]
481 fn parse_list_unsubscribe_mailto() {
482 let headers = make_headers(&[("List-Unsubscribe", "<mailto:unsub@example.com>")]);
483 let result = parse_list_unsubscribe(&headers);
484 assert!(matches!(
485 result,
486 UnsubscribeMethod::Mailto { ref address, .. } if address == "unsub@example.com"
487 ));
488 }
489
490 #[test]
491 fn parse_list_unsubscribe_http() {
492 let headers = make_headers(&[("List-Unsubscribe", "<https://unsub.example.com/link>")]);
493 let result = parse_list_unsubscribe(&headers);
494 assert!(matches!(
495 result,
496 UnsubscribeMethod::HttpLink { ref url } if url == "https://unsub.example.com/link"
497 ));
498 }
499
500 #[test]
501 fn parse_address_name_angle() {
502 let addr = parse_address("Alice <alice@example.com>");
503 assert_eq!(addr.name, Some("Alice".to_string()));
504 assert_eq!(addr.email, "alice@example.com");
505 }
506
507 #[test]
508 fn parse_address_bare() {
509 let addr = parse_address("alice@example.com");
510 assert_eq!(addr.name, None);
511 assert_eq!(addr.email, "alice@example.com");
512 }
513
514 #[test]
515 fn labels_to_flags_all_combinations() {
516 let flags = labels_to_flags(&["INBOX".to_string()]);
518 assert!(flags.contains(MessageFlags::READ));
519
520 let flags = labels_to_flags(&["UNREAD".to_string()]);
522 assert!(!flags.contains(MessageFlags::READ));
523
524 let flags = labels_to_flags(&[
526 "STARRED".to_string(),
527 "DRAFT".to_string(),
528 "SENT".to_string(),
529 "TRASH".to_string(),
530 "SPAM".to_string(),
531 ]);
532 assert!(flags.contains(MessageFlags::READ)); assert!(flags.contains(MessageFlags::STARRED));
534 assert!(flags.contains(MessageFlags::DRAFT));
535 assert!(flags.contains(MessageFlags::SENT));
536 assert!(flags.contains(MessageFlags::TRASH));
537 assert!(flags.contains(MessageFlags::SPAM));
538 }
539
540 #[test]
541 fn base64url_decode() {
542 let encoded = "SGVsbG8sIFdvcmxkIQ";
544 let decoded = base64_decode_url(encoded).unwrap();
545 assert_eq!(decoded, "Hello, World!");
546 }
547
548 #[test]
549 fn parse_list_unsubscribe_multi_uri_prefers_one_click() {
550 let headers = make_headers(&[
552 (
553 "List-Unsubscribe",
554 "<mailto:unsub@example.com>, <https://unsub.example.com/oneclick>",
555 ),
556 ("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
557 ]);
558 let result = parse_list_unsubscribe(&headers);
559 assert!(matches!(
561 result,
562 UnsubscribeMethod::OneClick { ref url } if url == "https://unsub.example.com/oneclick"
563 ));
564 }
565
566 #[test]
567 fn parse_list_unsubscribe_missing() {
568 let headers = make_headers(&[("Subject", "No unsubscribe here")]);
569 let result = parse_list_unsubscribe(&headers);
570 assert!(matches!(result, UnsubscribeMethod::None));
571 }
572
573 #[test]
574 fn parse_address_quoted_name() {
575 let addr = parse_address("\"Last, First\" <first.last@example.com>");
576 assert_eq!(addr.name, Some("Last, First".to_string()));
577 assert_eq!(addr.email, "first.last@example.com");
578 }
579
580 #[test]
581 fn parse_address_empty_string() {
582 let addr = parse_address("");
583 assert!(addr.name.is_none());
584 assert!(addr.email.is_empty());
585 }
586
587 #[test]
588 fn parse_address_list_with_quoted_commas() {
589 let addrs = parse_address_list("\"Last, First\" <a@example.com>, Bob <b@example.com>");
590 assert_eq!(addrs.len(), 2);
591 assert_eq!(addrs[0].name, Some("Last, First".to_string()));
592 assert_eq!(addrs[0].email, "a@example.com");
593 assert_eq!(addrs[1].email, "b@example.com");
594 }
595
596 #[test]
597 fn parse_deeply_nested_mime() {
598 let msg = GmailMessage {
600 id: "msg-nested".to_string(),
601 thread_id: "thread-nested".to_string(),
602 label_ids: None,
603 snippet: None,
604 history_id: None,
605 internal_date: None,
606 size_estimate: None,
607 payload: Some(GmailPayload {
608 mime_type: Some("multipart/mixed".to_string()),
609 headers: None,
610 body: None,
611 parts: Some(vec![
612 GmailPayload {
613 mime_type: Some("multipart/alternative".to_string()),
614 headers: None,
615 body: None,
616 parts: Some(vec![
617 GmailPayload {
618 mime_type: Some("text/plain".to_string()),
619 headers: None,
620 body: Some(GmailBody {
621 attachment_id: None,
622 size: Some(5),
623 data: Some("SGVsbG8".to_string()), }),
625 parts: None,
626 filename: None,
627 },
628 GmailPayload {
629 mime_type: Some("text/html".to_string()),
630 headers: None,
631 body: Some(GmailBody {
632 attachment_id: None,
633 size: Some(12),
634 data: Some("PGI-SGVsbG88L2I-".to_string()),
635 }),
636 parts: None,
637 filename: None,
638 },
639 ]),
640 filename: None,
641 },
642 GmailPayload {
643 mime_type: Some("application/pdf".to_string()),
644 headers: None,
645 body: Some(GmailBody {
646 attachment_id: Some("att-001".to_string()),
647 size: Some(50000),
648 data: None,
649 }),
650 parts: None,
651 filename: Some("report.pdf".to_string()),
652 },
653 ]),
654 filename: None,
655 }),
656 };
657
658 let (text_plain, text_html, attachments) = extract_body(&msg);
659 assert_eq!(text_plain, Some("Hello".to_string()));
660 assert!(text_html.is_some());
661 assert_eq!(attachments.len(), 1);
662 assert_eq!(attachments[0].filename, "report.pdf");
663 assert_eq!(attachments[0].mime_type, "application/pdf");
664 assert_eq!(attachments[0].size_bytes, 50000);
665 }
666
667 #[test]
668 fn parse_message_with_attachments_metadata() {
669 let msg = GmailMessage {
670 id: "msg-att".to_string(),
671 thread_id: "thread-att".to_string(),
672 label_ids: Some(vec!["INBOX".to_string()]),
673 snippet: Some("See attached".to_string()),
674 history_id: None,
675 internal_date: Some("1700000000000".to_string()),
676 size_estimate: Some(100000),
677 payload: Some(GmailPayload {
678 mime_type: Some("multipart/mixed".to_string()),
679 headers: Some(make_headers(&[
680 ("From", "alice@example.com"),
681 ("To", "bob@example.com"),
682 ("Subject", "Files attached"),
683 ])),
684 body: None,
685 parts: Some(vec![
686 GmailPayload {
687 mime_type: Some("text/plain".to_string()),
688 headers: None,
689 body: Some(GmailBody {
690 attachment_id: None,
691 size: Some(5),
692 data: Some("SGVsbG8".to_string()),
693 }),
694 parts: None,
695 filename: None,
696 },
697 GmailPayload {
698 mime_type: Some("image/png".to_string()),
699 headers: None,
700 body: Some(GmailBody {
701 attachment_id: Some("att-img".to_string()),
702 size: Some(25000),
703 data: None,
704 }),
705 parts: None,
706 filename: Some("screenshot.png".to_string()),
707 },
708 ]),
709 filename: None,
710 }),
711 };
712
713 let account_id = AccountId::from_provider_id("gmail", "test-account");
714 let env = gmail_message_to_envelope(&msg, &account_id).unwrap();
715 assert!(env.has_attachments);
716 assert_eq!(env.subject, "Files attached");
717
718 let (_, _, attachments) = extract_body(&msg);
719 assert_eq!(attachments.len(), 1);
720 assert_eq!(attachments[0].filename, "screenshot.png");
721 assert_eq!(attachments[0].mime_type, "image/png");
722 }
723
724 #[test]
725 fn body_extraction_multipart() {
726 let msg = GmailMessage {
727 id: "msg-mp".to_string(),
728 thread_id: "thread-mp".to_string(),
729 label_ids: None,
730 snippet: None,
731 history_id: None,
732 internal_date: None,
733 size_estimate: None,
734 payload: Some(GmailPayload {
735 mime_type: Some("multipart/alternative".to_string()),
736 headers: None,
737 body: None,
738 parts: Some(vec![
739 GmailPayload {
740 mime_type: Some("text/plain".to_string()),
741 headers: None,
742 body: Some(GmailBody {
743 attachment_id: None,
744 size: Some(5),
745 data: Some("SGVsbG8".to_string()),
747 }),
748 parts: None,
749 filename: None,
750 },
751 GmailPayload {
752 mime_type: Some("text/html".to_string()),
753 headers: None,
754 body: Some(GmailBody {
755 attachment_id: None,
756 size: Some(12),
757 data: Some("PGI-SGVsbG88L2I-".to_string()),
759 }),
760 parts: None,
761 filename: None,
762 },
763 ]),
764 filename: None,
765 }),
766 };
767
768 let (text_plain, text_html, _) = extract_body(&msg);
769 assert_eq!(text_plain, Some("Hello".to_string()));
770 assert!(text_html.is_some());
771 }
772
773 #[test]
774 fn standards_fixture_like_gmail_message_snapshot() {
775 let msg: GmailMessage = serde_json::from_value(json!({
776 "id": "fixture-1",
777 "threadId": "fixture-thread",
778 "labelIds": ["INBOX", "UNREAD"],
779 "snippet": "Fixture snippet",
780 "historyId": "500",
781 "internalDate": "1710495000000",
782 "sizeEstimate": 4096,
783 "payload": {
784 "mimeType": "multipart/mixed",
785 "headers": [
786 {"name": "From", "value": "Alice Smith <alice@example.com>"},
787 {"name": "To", "value": "Bob Example <bob@example.com>"},
788 {"name": "Subject", "value": "Planning meeting"},
789 {"name": "Date", "value": "Tue, 19 Mar 2024 14:15:00 +0000"},
790 {"name": "Message-ID", "value": "<calendar@example.com>"},
791 {"name": "Authentication-Results", "value": "mx.example.net; dkim=pass"},
792 {"name": "Content-Language", "value": "en"},
793 {"name": "List-Unsubscribe", "value": "<https://example.com/unsubscribe>"}
794 ],
795 "parts": [
796 {
797 "mimeType": "text/plain",
798 "body": {"size": 33, "data": "UGxlYXNlIGpvaW4gdGhlIHBsYW5uaW5nIG1lZXRpbmcu"}
799 },
800 {
801 "mimeType": "text/html",
802 "body": {"size": 76, "data": "PHA-PlBsZWFzZSBqb2luIHRoZSA8YSBocmVmPSJodHRwczovL2V4YW1wbGUuY29tL3Vuc3Vic2NyaWJlIj5tYWlsIHByZWZlcmVuY2VzPC9hPi48L3A-"}
803 },
804 {
805 "mimeType": "application/pdf",
806 "filename": "report.pdf",
807 "body": {"attachmentId": "att-1", "size": 5}
808 },
809 {
810 "mimeType": "text/calendar",
811 "body": {"size": 82, "data": "QkVHSU46VkNBTEVOREFSDQpNRVRIT0Q6UkVRVUVTVA0KQkVHSU46VkVWRU5UDQpTVU1NQVJZOlBsYW5uaW5nIG1lZXRpbmcNCkVORDpWRVZFTlQNCkVORDpWQ0FMRU5EQVI"}
812 }
813 ]
814 }
815 }))
816 .unwrap();
817
818 let account_id = AccountId::from_provider_id("gmail", "test-account");
819 let envelope = gmail_message_to_envelope(&msg, &account_id).unwrap();
820 let body = extract_message_body(&msg);
821 insta::assert_yaml_snapshot!(
822 "gmail_fixture_message",
823 json!({
824 "subject": envelope.subject,
825 "unsubscribe": format!("{:?}", envelope.unsubscribe),
826 "flags": envelope.flags.bits(),
827 "attachment_filenames": body.attachments.iter().map(|attachment| attachment.filename.clone()).collect::<Vec<_>>(),
828 "calendar": body.metadata.calendar,
829 "auth_results": body.metadata.auth_results,
830 "content_language": body.metadata.content_language,
831 "plain_text": body.text_plain,
832 })
833 );
834 }
835
836 #[test]
837 fn standards_fixture_gmail_header_matrix_snapshots() {
838 let account_id = AccountId::from_provider_id("gmail", "matrix-account");
839
840 for fixture in standards_fixture_names() {
841 let msg = gmail_message_from_fixture(fixture);
842 let envelope = gmail_message_to_envelope(&msg, &account_id).unwrap();
843 let body = extract_message_body(&msg);
844
845 insta::assert_yaml_snapshot!(
846 format!("gmail_fixture__{}", fixture_stem(fixture)),
847 json!({
848 "subject": envelope.subject,
849 "from": envelope.from,
850 "to": envelope.to,
851 "cc": envelope.cc,
852 "message_id": envelope.message_id_header,
853 "in_reply_to": envelope.in_reply_to,
854 "references": envelope.references,
855 "unsubscribe": format!("{:?}", envelope.unsubscribe),
856 "list_id": body.metadata.list_id,
857 "auth_results": body.metadata.auth_results,
858 "content_language": body.metadata.content_language,
859 "text_plain_format": format!("{:?}", body.metadata.text_plain_format),
860 "plain_excerpt": body.text_plain.as_deref().map(|text| text.lines().take(2).collect::<Vec<_>>().join("\n")),
861 })
862 );
863 }
864 }
865}