Skip to main content

mxr_provider_gmail/
parse.rs

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    // Gmail: absence of UNREAD means the message is read
103    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 this part has a non-empty filename, it's an attachment
158    if let Some(ref filename) = payload.filename {
159        if !filename.is_empty() {
160            return true;
161        }
162    }
163
164    // If this part has an attachment_id in its body, it's an attachment
165    if let Some(ref body) = payload.body {
166        if body.attachment_id.is_some() {
167            return true;
168        }
169    }
170
171    // Recurse into child parts
172    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
191/// Extract text_plain and text_html from a GmailMessage payload.
192pub 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    // Check for attachment (has filename or attachment_id)
216    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    // Leaf text node
255    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    // Recurse into child parts
281    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        // UNREAD present → not read
455        assert!(!env.flags.contains(MessageFlags::READ));
456        // Deterministic IDs
457        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        // No UNREAD → READ
517        let flags = labels_to_flags(&["INBOX".to_string()]);
518        assert!(flags.contains(MessageFlags::READ));
519
520        // UNREAD present → not READ
521        let flags = labels_to_flags(&["UNREAD".to_string()]);
522        assert!(!flags.contains(MessageFlags::READ));
523
524        // All special labels
525        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)); // no UNREAD
533        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        // "Hello, World!" in URL-safe base64 no padding
543        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        // Multiple URIs: mailto + https with one-click header
551        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        // With one-click header, prefers the HTTPS URL for OneClick
560        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        // multipart/mixed containing multipart/alternative
599        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()), // "Hello"
624                                }),
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                            // "Hello" in URL-safe base64 no padding
746                            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                            // "<b>Hello</b>" in URL-safe base64 no padding
758                            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}