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 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 if let Some(added) = record.messages_added {
261 for a in added {
262 upserted_ids.insert(a.message.id);
263 }
264 }
265
266 if let Some(deleted) = record.messages_deleted {
268 for d in deleted {
269 deleted_ids.push(d.message.id);
270 }
271 }
272
273 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 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 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, batch_operations: true,
355 native_thread_ids: true,
356 }
357 }
358
359 async fn authenticate(&mut self) -> mxr_core::provider::Result<()> {
360 Ok(())
362 }
363
364 async fn refresh_auth(&mut self) -> mxr_core::provider::Result<()> {
365 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}