Skip to main content

mxr_provider_gmail/
provider.rs

1use async_trait::async_trait;
2use mxr_core::{
3    AccountId, Address, Draft, Label, LabelChange, LabelId, LabelKind, MailSendProvider,
4    MailSyncProvider, MxrError, SendReceipt, SyncBatch, SyncCapabilities, SyncCursor,
5};
6use tracing::{debug, warn};
7
8use crate::client::{GmailApi, GmailClient, MessageFormat};
9use crate::error::GmailError;
10use crate::parse::{extract_message_body, gmail_message_to_envelope};
11use crate::send;
12use mxr_core::types::SyncedMessage;
13
14pub struct GmailProvider {
15    account_id: AccountId,
16    client: Box<dyn GmailApi>,
17}
18
19impl GmailProvider {
20    pub fn new(account_id: AccountId, client: GmailClient) -> Self {
21        Self {
22            account_id,
23            client: Box::new(client),
24        }
25    }
26
27    #[cfg(test)]
28    fn with_api(account_id: AccountId, client: Box<dyn GmailApi>) -> Self {
29        Self { account_id, client }
30    }
31
32    fn map_label(&self, gl: crate::types::GmailLabel) -> Label {
33        let kind = match gl.label_type.as_deref() {
34            Some("system") => LabelKind::System,
35            _ => LabelKind::User,
36        };
37
38        let color = gl.color.as_ref().and_then(|c| c.background_color.clone());
39
40        Label {
41            id: LabelId::from_provider_id("gmail", &gl.id),
42            account_id: self.account_id.clone(),
43            name: gl.name,
44            kind,
45            color,
46            provider_id: gl.id,
47            unread_count: gl.messages_unread.unwrap_or(0),
48            total_count: gl.messages_total.unwrap_or(0),
49        }
50    }
51
52    async fn initial_sync(&self) -> Result<SyncBatch, MxrError> {
53        debug!("Starting initial sync for account {}", self.account_id);
54
55        let mut all_messages = Vec::new();
56        let mut page_token: Option<String> = None;
57        let mut latest_history_id: Option<u64> = None;
58        // Fetch first 200 messages for fast time-to-first-content.
59        // The daemon stores a GmailBackfill cursor with the page_token,
60        // and the sync loop continues fetching remaining pages in the
61        // background every 2s until all messages are synced.
62        const MAX_INITIAL_MESSAGES: usize = 200;
63
64        loop {
65            let batch_size = (MAX_INITIAL_MESSAGES - all_messages.len()).min(100) as u32;
66            if batch_size == 0 {
67                tracing::info!(
68                    "Initial sync: fetched {MAX_INITIAL_MESSAGES} messages, \
69                     remaining pages will be backfilled in background"
70                );
71                break;
72            }
73
74            let resp = self
75                .client
76                .list_messages(None, page_token.as_deref(), batch_size)
77                .await
78                .map_err(MxrError::from)?;
79
80            let refs = resp.messages.unwrap_or_default();
81            if refs.is_empty() {
82                break;
83            }
84
85            let ids: Vec<String> = refs.iter().map(|r| r.id.clone()).collect();
86            let messages = self
87                .client
88                .batch_get_messages(&ids, MessageFormat::Full)
89                .await
90                .map_err(MxrError::from)?;
91
92            for msg in &messages {
93                if let Some(ref hid) = msg.history_id {
94                    if let Ok(h) = hid.parse::<u64>() {
95                        latest_history_id =
96                            Some(latest_history_id.map_or(h, |cur: u64| cur.max(h)));
97                    }
98                }
99                match gmail_message_to_envelope(msg, &self.account_id) {
100                    Ok(env) => {
101                        let body = extract_message_body(msg);
102                        all_messages.push(SyncedMessage {
103                            envelope: env,
104                            body,
105                        });
106                    }
107                    Err(e) => warn!(msg_id = %msg.id, error = %e, "Failed to parse message"),
108                }
109            }
110
111            match resp.next_page_token {
112                Some(token) => page_token = Some(token),
113                None => {
114                    page_token = None;
115                    break;
116                }
117            }
118        }
119
120        let next_cursor = match (latest_history_id, &page_token) {
121            (Some(hid), Some(token)) => {
122                tracing::info!(
123                    history_id = hid,
124                    "Initial sync producing GmailBackfill cursor for background sync"
125                );
126                SyncCursor::GmailBackfill {
127                    history_id: hid,
128                    page_token: token.clone(),
129                }
130            }
131            (Some(hid), None) => {
132                tracing::info!(
133                    history_id = hid,
134                    total = all_messages.len(),
135                    "Initial sync complete — all messages fetched, delta-ready"
136                );
137                SyncCursor::Gmail { history_id: hid }
138            }
139            _ => SyncCursor::Initial,
140        };
141
142        Ok(SyncBatch {
143            upserted: all_messages,
144            deleted_provider_ids: vec![],
145            label_changes: vec![],
146            next_cursor,
147        })
148    }
149
150    async fn backfill_sync(
151        &self,
152        history_id: u64,
153        page_token: &str,
154    ) -> Result<SyncBatch, MxrError> {
155        tracing::info!(
156            "Backfill sync: fetching next page for account {}",
157            self.account_id,
158        );
159
160        const BACKFILL_BATCH: u32 = 100;
161        let resp = self
162            .client
163            .list_messages(None, Some(page_token), BACKFILL_BATCH)
164            .await
165            .map_err(MxrError::from)?;
166
167        let refs = resp.messages.unwrap_or_default();
168        if refs.is_empty() {
169            return Ok(SyncBatch {
170                upserted: vec![],
171                deleted_provider_ids: vec![],
172                label_changes: vec![],
173                next_cursor: SyncCursor::Gmail { history_id },
174            });
175        }
176
177        let ids: Vec<String> = refs.iter().map(|r| r.id.clone()).collect();
178        debug!("Backfill: fetching {} messages (full)", ids.len());
179        let messages = self
180            .client
181            .batch_get_messages(&ids, MessageFormat::Full)
182            .await
183            .map_err(MxrError::from)?;
184
185        let mut synced = Vec::new();
186        for msg in &messages {
187            match gmail_message_to_envelope(msg, &self.account_id) {
188                Ok(env) => {
189                    let body = extract_message_body(msg);
190                    synced.push(SyncedMessage {
191                        envelope: env,
192                        body,
193                    });
194                }
195                Err(e) => {
196                    warn!(msg_id = %msg.id, error = %e, "Failed to parse message in backfill")
197                }
198            }
199        }
200
201        let has_more = resp.next_page_token.is_some();
202        let next_cursor = match resp.next_page_token {
203            Some(token) => SyncCursor::GmailBackfill {
204                history_id,
205                page_token: token,
206            },
207            None => SyncCursor::Gmail { history_id },
208        };
209
210        tracing::info!(fetched = synced.len(), has_more, "Backfill batch complete");
211
212        Ok(SyncBatch {
213            upserted: synced,
214            deleted_provider_ids: vec![],
215            label_changes: vec![],
216            next_cursor,
217        })
218    }
219
220    async fn delta_sync(&self, history_id: u64) -> Result<SyncBatch, MxrError> {
221        debug!(
222            history_id,
223            "Starting delta sync for account {}", self.account_id
224        );
225
226        let mut upserted_ids = std::collections::HashSet::new();
227        let mut deleted_ids = Vec::new();
228        let mut label_changes = Vec::new();
229        let mut latest_history_id = history_id;
230        let mut page_token: Option<String> = None;
231
232        loop {
233            let resp = match self
234                .client
235                .list_history(history_id, page_token.as_deref())
236                .await
237            {
238                Ok(resp) => resp,
239                Err(GmailError::NotFound(body)) => {
240                    warn!(
241                        history_id,
242                        account = %self.account_id,
243                        error = %body,
244                        "Gmail history cursor stale, falling back to initial sync"
245                    );
246                    return self.initial_sync().await;
247                }
248                Err(error) => return Err(MxrError::from(error)),
249            };
250
251            if let Some(ref hid) = resp.history_id {
252                if let Ok(h) = hid.parse::<u64>() {
253                    latest_history_id = latest_history_id.max(h);
254                }
255            }
256
257            let records = resp.history.unwrap_or_default();
258            for record in records {
259                // Messages added
260                if let Some(added) = record.messages_added {
261                    for a in added {
262                        upserted_ids.insert(a.message.id);
263                    }
264                }
265
266                // Messages deleted
267                if let Some(deleted) = record.messages_deleted {
268                    for d in deleted {
269                        deleted_ids.push(d.message.id);
270                    }
271                }
272
273                // Label additions
274                if let Some(label_added) = record.labels_added {
275                    for la in label_added {
276                        label_changes.push(LabelChange {
277                            provider_message_id: la.message.id,
278                            added_labels: la.label_ids.unwrap_or_default(),
279                            removed_labels: vec![],
280                        });
281                    }
282                }
283
284                // Label removals
285                if let Some(label_removed) = record.labels_removed {
286                    for lr in label_removed {
287                        label_changes.push(LabelChange {
288                            provider_message_id: lr.message.id,
289                            added_labels: vec![],
290                            removed_labels: lr.label_ids.unwrap_or_default(),
291                        });
292                    }
293                }
294            }
295
296            match resp.next_page_token {
297                Some(token) => page_token = Some(token),
298                None => break,
299            }
300        }
301
302        // Fetch full messages for new/changed messages
303        let ids_to_fetch: Vec<String> = upserted_ids.into_iter().collect();
304        let mut synced = Vec::new();
305
306        if !ids_to_fetch.is_empty() {
307            let messages = self
308                .client
309                .batch_get_messages(&ids_to_fetch, MessageFormat::Full)
310                .await
311                .map_err(MxrError::from)?;
312
313            for msg in &messages {
314                match gmail_message_to_envelope(msg, &self.account_id) {
315                    Ok(env) => {
316                        let body = extract_message_body(msg);
317                        synced.push(SyncedMessage {
318                            envelope: env,
319                            body,
320                        });
321                    }
322                    Err(e) => warn!(msg_id = %msg.id, error = %e, "Failed to parse message"),
323                }
324            }
325        }
326
327        Ok(SyncBatch {
328            upserted: synced,
329            deleted_provider_ids: deleted_ids,
330            label_changes,
331            next_cursor: SyncCursor::Gmail {
332                history_id: latest_history_id,
333            },
334        })
335    }
336}
337
338#[async_trait]
339impl MailSyncProvider for GmailProvider {
340    fn name(&self) -> &str {
341        "gmail"
342    }
343
344    fn account_id(&self) -> &AccountId {
345        &self.account_id
346    }
347
348    fn capabilities(&self) -> SyncCapabilities {
349        SyncCapabilities {
350            labels: true,
351            server_search: true,
352            delta_sync: true,
353            push: false, // push via pub/sub not yet implemented
354            batch_operations: true,
355            native_thread_ids: true,
356        }
357    }
358
359    async fn authenticate(&mut self) -> mxr_core::provider::Result<()> {
360        // Auth is managed by GmailAuth externally before constructing the provider
361        Ok(())
362    }
363
364    async fn refresh_auth(&mut self) -> mxr_core::provider::Result<()> {
365        // Token refresh is handled automatically by yup-oauth2
366        Ok(())
367    }
368
369    async fn sync_labels(&self) -> mxr_core::provider::Result<Vec<Label>> {
370        let resp = self.client.list_labels().await.map_err(MxrError::from)?;
371
372        let gmail_labels = resp.labels.unwrap_or_default();
373        let mut labels = Vec::with_capacity(gmail_labels.len());
374
375        for gl in gmail_labels {
376            labels.push(self.map_label(gl));
377        }
378
379        Ok(labels)
380    }
381
382    async fn sync_messages(&self, cursor: &SyncCursor) -> mxr_core::provider::Result<SyncBatch> {
383        match cursor {
384            SyncCursor::Initial => self.initial_sync().await,
385            SyncCursor::Gmail { history_id } => self.delta_sync(*history_id).await,
386            SyncCursor::GmailBackfill {
387                history_id,
388                page_token,
389            } => self.backfill_sync(*history_id, page_token).await,
390            other => Err(MxrError::Provider(format!(
391                "Gmail provider received incompatible cursor: {other:?}"
392            ))),
393        }
394    }
395
396    async fn fetch_attachment(
397        &self,
398        provider_message_id: &str,
399        provider_attachment_id: &str,
400    ) -> mxr_core::provider::Result<Vec<u8>> {
401        self.client
402            .get_attachment(provider_message_id, provider_attachment_id)
403            .await
404            .map_err(MxrError::from)
405    }
406
407    async fn modify_labels(
408        &self,
409        provider_message_id: &str,
410        add: &[String],
411        remove: &[String],
412    ) -> mxr_core::provider::Result<()> {
413        let add_refs: Vec<&str> = add.iter().map(|s| s.as_str()).collect();
414        let remove_refs: Vec<&str> = remove.iter().map(|s| s.as_str()).collect();
415        self.client
416            .modify_message(provider_message_id, &add_refs, &remove_refs)
417            .await
418            .map_err(MxrError::from)
419    }
420
421    async fn create_label(
422        &self,
423        name: &str,
424        color: Option<&str>,
425    ) -> mxr_core::provider::Result<Label> {
426        let label = self
427            .client
428            .create_label(name, color)
429            .await
430            .map_err(MxrError::from)?;
431        Ok(self.map_label(label))
432    }
433
434    async fn rename_label(
435        &self,
436        provider_label_id: &str,
437        new_name: &str,
438    ) -> mxr_core::provider::Result<Label> {
439        let label = self
440            .client
441            .rename_label(provider_label_id, new_name)
442            .await
443            .map_err(MxrError::from)?;
444        Ok(self.map_label(label))
445    }
446
447    async fn delete_label(&self, provider_label_id: &str) -> mxr_core::provider::Result<()> {
448        self.client
449            .delete_label(provider_label_id)
450            .await
451            .map_err(MxrError::from)
452    }
453
454    async fn trash(&self, provider_message_id: &str) -> mxr_core::provider::Result<()> {
455        self.client
456            .trash_message(provider_message_id)
457            .await
458            .map_err(MxrError::from)
459    }
460
461    async fn set_read(
462        &self,
463        provider_message_id: &str,
464        read: bool,
465    ) -> mxr_core::provider::Result<()> {
466        if read {
467            self.client
468                .modify_message(provider_message_id, &[], &["UNREAD"])
469                .await
470                .map_err(MxrError::from)
471        } else {
472            self.client
473                .modify_message(provider_message_id, &["UNREAD"], &[])
474                .await
475                .map_err(MxrError::from)
476        }
477    }
478
479    async fn set_starred(
480        &self,
481        provider_message_id: &str,
482        starred: bool,
483    ) -> mxr_core::provider::Result<()> {
484        if starred {
485            self.client
486                .modify_message(provider_message_id, &["STARRED"], &[])
487                .await
488                .map_err(MxrError::from)
489        } else {
490            self.client
491                .modify_message(provider_message_id, &[], &["STARRED"])
492                .await
493                .map_err(MxrError::from)
494        }
495    }
496
497    async fn search_remote(&self, query: &str) -> mxr_core::provider::Result<Vec<String>> {
498        let resp = self
499            .client
500            .list_messages(Some(query), None, 100)
501            .await
502            .map_err(MxrError::from)?;
503
504        let ids = resp
505            .messages
506            .unwrap_or_default()
507            .into_iter()
508            .map(|m| m.id)
509            .collect();
510
511        Ok(ids)
512    }
513}
514
515#[async_trait]
516impl MailSendProvider for GmailProvider {
517    fn name(&self) -> &str {
518        "gmail"
519    }
520
521    async fn send(&self, draft: &Draft, from: &Address) -> mxr_core::provider::Result<SendReceipt> {
522        let rfc2822 =
523            send::build_rfc2822(draft, from).map_err(|e| MxrError::Provider(e.to_string()))?;
524        let encoded = send::encode_for_gmail(&rfc2822);
525
526        let result = self
527            .client
528            .send_message(&encoded)
529            .await
530            .map_err(MxrError::from)?;
531
532        let message_id = result["id"].as_str().map(|s| s.to_string());
533
534        Ok(SendReceipt {
535            provider_message_id: message_id,
536            sent_at: chrono::Utc::now(),
537        })
538    }
539
540    async fn save_draft(
541        &self,
542        draft: &Draft,
543        from: &Address,
544    ) -> mxr_core::provider::Result<Option<String>> {
545        let rfc2822 =
546            send::build_rfc2822(draft, from).map_err(|e| MxrError::Provider(e.to_string()))?;
547        let encoded = send::encode_for_gmail(&rfc2822);
548
549        let draft_id = self
550            .client
551            .create_draft(&encoded)
552            .await
553            .map_err(MxrError::from)?;
554
555        Ok(Some(draft_id))
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::error::GmailError;
563    use crate::types::*;
564    use serde_json::json;
565    use std::collections::HashMap;
566    use std::sync::Mutex;
567    struct MockGmailApi {
568        messages: HashMap<String, GmailMessage>,
569        labels: Vec<GmailLabel>,
570        modified: Mutex<Vec<String>>,
571        stale_history: bool,
572    }
573
574    #[async_trait]
575    impl GmailApi for MockGmailApi {
576        async fn list_messages(
577            &self,
578            _query: Option<&str>,
579            page_token: Option<&str>,
580            _max_results: u32,
581        ) -> Result<GmailListResponse, GmailError> {
582            Ok(match page_token {
583                Some("page-2") => GmailListResponse {
584                    messages: Some(vec![GmailMessageRef {
585                        id: "msg-backfill".into(),
586                        thread_id: "thread-backfill".into(),
587                    }]),
588                    next_page_token: None,
589                    result_size_estimate: Some(3),
590                },
591                _ => GmailListResponse {
592                    messages: Some(vec![
593                        GmailMessageRef {
594                            id: "msg-1".into(),
595                            thread_id: "thread-1".into(),
596                        },
597                        GmailMessageRef {
598                            id: "msg-attach".into(),
599                            thread_id: "thread-attach".into(),
600                        },
601                    ]),
602                    next_page_token: Some("page-2".into()),
603                    result_size_estimate: Some(3),
604                },
605            })
606        }
607
608        async fn batch_get_messages(
609            &self,
610            message_ids: &[String],
611            _format: MessageFormat,
612        ) -> Result<Vec<GmailMessage>, GmailError> {
613            Ok(message_ids
614                .iter()
615                .filter_map(|id| self.messages.get(id).cloned())
616                .collect())
617        }
618
619        async fn list_history(
620            &self,
621            _start_history_id: u64,
622            _page_token: Option<&str>,
623        ) -> Result<GmailHistoryResponse, GmailError> {
624            if self.stale_history {
625                return Err(GmailError::NotFound(
626                    json!({
627                        "error": {
628                            "code": 404,
629                            "message": "Requested entity was not found.",
630                            "errors": [
631                                {
632                                    "message": "Requested entity was not found.",
633                                    "domain": "global",
634                                    "reason": "notFound"
635                                }
636                            ],
637                            "status": "NOT_FOUND"
638                        }
639                    })
640                    .to_string(),
641                ));
642            }
643
644            Ok(GmailHistoryResponse {
645                history: Some(vec![GmailHistoryRecord {
646                    id: "23".into(),
647                    messages: None,
648                    messages_added: Some(vec![GmailHistoryMessageAdded {
649                        message: GmailMessageRef {
650                            id: "msg-3".into(),
651                            thread_id: "thread-3".into(),
652                        },
653                    }]),
654                    messages_deleted: Some(vec![GmailHistoryMessageDeleted {
655                        message: GmailMessageRef {
656                            id: "msg-1".into(),
657                            thread_id: "thread-1".into(),
658                        },
659                    }]),
660                    labels_added: Some(vec![GmailHistoryLabelAdded {
661                        message: GmailMessageRef {
662                            id: "msg-attach".into(),
663                            thread_id: "thread-attach".into(),
664                        },
665                        label_ids: Some(vec!["STARRED".into()]),
666                    }]),
667                    labels_removed: None,
668                }]),
669                next_page_token: None,
670                history_id: Some("23".into()),
671            })
672        }
673
674        async fn modify_message(
675            &self,
676            message_id: &str,
677            _add_labels: &[&str],
678            _remove_labels: &[&str],
679        ) -> Result<(), GmailError> {
680            self.modified.lock().unwrap().push(message_id.to_string());
681            Ok(())
682        }
683
684        async fn trash_message(&self, message_id: &str) -> Result<(), GmailError> {
685            self.modified
686                .lock()
687                .unwrap()
688                .push(format!("trash:{message_id}"));
689            Ok(())
690        }
691
692        async fn send_message(
693            &self,
694            _raw_base64url: &str,
695        ) -> Result<serde_json::Value, GmailError> {
696            Ok(json!({"id": "sent-1"}))
697        }
698
699        async fn get_attachment(
700            &self,
701            _message_id: &str,
702            _attachment_id: &str,
703        ) -> Result<Vec<u8>, GmailError> {
704            Ok(b"Hello".to_vec())
705        }
706
707        async fn create_draft(&self, _raw_base64url: &str) -> Result<String, GmailError> {
708            Ok("draft-1".into())
709        }
710
711        async fn list_labels(&self) -> Result<GmailLabelsResponse, GmailError> {
712            Ok(GmailLabelsResponse {
713                labels: Some(self.labels.clone()),
714            })
715        }
716
717        async fn create_label(
718            &self,
719            name: &str,
720            color: Option<&str>,
721        ) -> Result<GmailLabel, GmailError> {
722            Ok(GmailLabel {
723                id: "Label_2".into(),
724                name: name.into(),
725                label_type: Some("user".into()),
726                messages_total: Some(0),
727                messages_unread: Some(0),
728                color: color.map(|color| GmailLabelColor {
729                    text_color: Some("#000000".into()),
730                    background_color: Some(color.into()),
731                }),
732            })
733        }
734
735        async fn rename_label(
736            &self,
737            label_id: &str,
738            new_name: &str,
739        ) -> Result<GmailLabel, GmailError> {
740            Ok(GmailLabel {
741                id: label_id.into(),
742                name: new_name.into(),
743                label_type: Some("user".into()),
744                messages_total: Some(0),
745                messages_unread: Some(0),
746                color: None,
747            })
748        }
749
750        async fn delete_label(&self, _label_id: &str) -> Result<(), GmailError> {
751            Ok(())
752        }
753    }
754
755    fn gmail_provider() -> GmailProvider {
756        gmail_provider_with_stale_history(false)
757    }
758
759    fn gmail_provider_with_stale_history(stale_history: bool) -> GmailProvider {
760        let mut messages = HashMap::new();
761        for message in [
762            serde_json::from_value::<GmailMessage>(gmail_message("msg-1", "thread-1", "Welcome"))
763                .unwrap(),
764            serde_json::from_value::<GmailMessage>(gmail_attachment_message()).unwrap(),
765            serde_json::from_value::<GmailMessage>(gmail_message(
766                "msg-3",
767                "thread-3",
768                "Delta message",
769            ))
770            .unwrap(),
771            serde_json::from_value::<GmailMessage>(gmail_message(
772                "msg-backfill",
773                "thread-backfill",
774                "Backfill message",
775            ))
776            .unwrap(),
777        ] {
778            messages.insert(message.id.clone(), message);
779        }
780
781        GmailProvider::with_api(
782            AccountId::new(),
783            Box::new(MockGmailApi {
784                messages,
785                labels: vec![
786                    GmailLabel {
787                        id: "INBOX".into(),
788                        name: "INBOX".into(),
789                        label_type: Some("system".into()),
790                        messages_total: Some(2),
791                        messages_unread: Some(1),
792                        color: None,
793                    },
794                    GmailLabel {
795                        id: "Label_1".into(),
796                        name: "Projects".into(),
797                        label_type: Some("user".into()),
798                        messages_total: Some(1),
799                        messages_unread: Some(0),
800                        color: None,
801                    },
802                ],
803                modified: Mutex::new(Vec::new()),
804                stale_history,
805            }),
806        )
807    }
808
809    fn gmail_message(id: &str, thread_id: &str, subject: &str) -> serde_json::Value {
810        json!({
811            "id": id,
812            "threadId": thread_id,
813            "labelIds": ["INBOX"],
814            "snippet": format!("Snippet for {subject}"),
815            "historyId": "22",
816            "internalDate": "1710495000000",
817            "sizeEstimate": 1024,
818            "payload": {
819                "mimeType": "multipart/mixed",
820                "headers": [
821                    {"name": "From", "value": "Alice Example <alice@example.com>"},
822                    {"name": "To", "value": "Bob Example <bob@example.com>"},
823                    {"name": "Subject", "value": subject},
824                    {"name": "Date", "value": "Fri, 15 Mar 2024 09:30:00 +0000"},
825                    {"name": "Message-ID", "value": format!("<{id}@example.com>")}
826                ],
827                "parts": [
828                    {
829                        "mimeType": "text/plain",
830                        "body": {"size": 12, "data": "SGVsbG8gd29ybGQ"}
831                    },
832                    {
833                        "mimeType": "text/html",
834                        "body": {"size": 33, "data": "PHA-SGVsbG8gd29ybGQ8L3A-"}
835                    }
836                ]
837            }
838        })
839    }
840
841    fn gmail_attachment_message() -> serde_json::Value {
842        json!({
843            "id": "msg-attach",
844            "threadId": "thread-attach",
845            "labelIds": ["INBOX", "UNREAD"],
846            "snippet": "Attachment snippet",
847            "historyId": "21",
848            "internalDate": "1710495000000",
849            "sizeEstimate": 2048,
850            "payload": {
851                "mimeType": "multipart/mixed",
852                "headers": [
853                    {"name": "From", "value": "Calendar Bot <calendar@example.com>"},
854                    {"name": "To", "value": "Bob Example <bob@example.com>"},
855                    {"name": "Subject", "value": "Calendar invite"},
856                    {"name": "Date", "value": "Fri, 15 Mar 2024 09:30:00 +0000"},
857                    {"name": "Message-ID", "value": "<msg-attach@example.com>"},
858                    {"name": "List-Unsubscribe", "value": "<https://example.com/unsubscribe>"},
859                    {"name": "Authentication-Results", "value": "mx.example.net; dkim=pass"},
860                    {"name": "Content-Language", "value": "en"}
861                ],
862                "parts": [
863                    {
864                        "mimeType": "text/plain",
865                        "body": {"size": 16, "data": "QXR0YWNobWVudCBib2R5"}
866                    },
867                    {
868                        "mimeType": "application/pdf",
869                        "filename": "report.pdf",
870                        "body": {"attachmentId": "att-1", "size": 5}
871                    }
872                ]
873            }
874        })
875    }
876
877    #[tokio::test]
878    async fn gmail_provider_passes_sync_and_send_conformance() {
879        let provider = gmail_provider();
880        mxr_provider_fake::conformance::run_sync_conformance(&provider).await;
881        mxr_provider_fake::conformance::run_send_conformance(&provider).await;
882    }
883
884    #[tokio::test]
885    async fn gmail_delta_sync_tracks_history_changes() {
886        let provider = gmail_provider();
887        let batch = provider
888            .sync_messages(&SyncCursor::Gmail { history_id: 22 })
889            .await
890            .unwrap();
891
892        assert_eq!(batch.deleted_provider_ids, vec!["msg-1"]);
893        assert_eq!(batch.label_changes.len(), 1);
894        assert_eq!(batch.upserted.len(), 1);
895        assert_eq!(batch.upserted[0].envelope.provider_id, "msg-3");
896        assert!(matches!(
897            batch.next_cursor,
898            SyncCursor::Gmail { history_id: 23 }
899        ));
900    }
901
902    #[tokio::test]
903    async fn gmail_delta_sync_recovers_from_stale_history_cursor() {
904        let provider = gmail_provider_with_stale_history(true);
905        let batch = provider
906            .sync_messages(&SyncCursor::Gmail {
907                history_id: 27_672_073,
908            })
909            .await
910            .unwrap();
911
912        assert_eq!(batch.upserted.len(), 3);
913        assert!(batch.deleted_provider_ids.is_empty());
914        assert!(batch.label_changes.is_empty());
915        assert!(matches!(
916            batch.next_cursor,
917            SyncCursor::Gmail { history_id: 22 }
918        ));
919    }
920}