Skip to main content

mxr_store/
lib.rs

1mod account;
2mod body;
3mod draft;
4mod event_log;
5mod label;
6mod message;
7mod pool;
8mod rules;
9mod search;
10mod snooze;
11mod sync_cursor;
12mod sync_log;
13mod sync_runtime_status;
14mod thread;
15
16pub use event_log::EventLogEntry;
17pub use pool::Store;
18pub use rules::{row_to_rule_json, row_to_rule_log_json, RuleLogInput, RuleRecordInput};
19pub use sync_log::{SyncLogEntry, SyncStatus};
20pub use sync_runtime_status::{SyncRuntimeStatus, SyncRuntimeStatusUpdate};
21
22#[cfg(test)]
23mod tests {
24    use super::*;
25    use mxr_core::*;
26
27    fn test_account() -> Account {
28        Account {
29            id: AccountId::new(),
30            name: "Test".to_string(),
31            email: "test@example.com".to_string(),
32            sync_backend: Some(BackendRef {
33                provider_kind: ProviderKind::Fake,
34                config_key: "fake".to_string(),
35            }),
36            send_backend: None,
37            enabled: true,
38        }
39    }
40
41    fn test_envelope(account_id: &AccountId) -> Envelope {
42        Envelope {
43            id: MessageId::new(),
44            account_id: account_id.clone(),
45            provider_id: "fake-1".to_string(),
46            thread_id: ThreadId::new(),
47            message_id_header: Some("<test@example.com>".to_string()),
48            in_reply_to: None,
49            references: vec![],
50            from: Address {
51                name: Some("Alice".to_string()),
52                email: "alice@example.com".to_string(),
53            },
54            to: vec![Address {
55                name: None,
56                email: "bob@example.com".to_string(),
57            }],
58            cc: vec![],
59            bcc: vec![],
60            subject: "Test subject".to_string(),
61            date: chrono::Utc::now(),
62            flags: MessageFlags::READ | MessageFlags::STARRED,
63            snippet: "Preview text".to_string(),
64            has_attachments: false,
65            size_bytes: 1024,
66            unsubscribe: UnsubscribeMethod::None,
67            label_provider_ids: vec![],
68        }
69    }
70
71    #[tokio::test]
72    async fn account_roundtrip() {
73        let store = Store::in_memory().await.unwrap();
74        let account = test_account();
75        store.insert_account(&account).await.unwrap();
76        let fetched = store.get_account(&account.id).await.unwrap().unwrap();
77        assert_eq!(fetched.name, account.name);
78        assert_eq!(fetched.email, account.email);
79    }
80
81    #[tokio::test]
82    async fn account_insert_upserts_existing_runtime_record() {
83        let store = Store::in_memory().await.unwrap();
84        let mut account = test_account();
85        store.insert_account(&account).await.unwrap();
86
87        account.name = "Updated".to_string();
88        account.email = "updated@example.com".to_string();
89        store.insert_account(&account).await.unwrap();
90
91        let fetched = store.get_account(&account.id).await.unwrap().unwrap();
92        assert_eq!(fetched.name, "Updated");
93        assert_eq!(fetched.email, "updated@example.com");
94    }
95
96    #[tokio::test]
97    async fn envelope_upsert_and_query() {
98        let store = Store::in_memory().await.unwrap();
99        let account = test_account();
100        store.insert_account(&account).await.unwrap();
101
102        let env = test_envelope(&account.id);
103        store.upsert_envelope(&env).await.unwrap();
104
105        let fetched = store.get_envelope(&env.id).await.unwrap().unwrap();
106        assert_eq!(fetched.id, env.id);
107        assert_eq!(fetched.subject, env.subject);
108        assert_eq!(fetched.from.email, env.from.email);
109        assert_eq!(fetched.flags, env.flags);
110
111        let list = store
112            .list_envelopes_by_account(&account.id, 100, 0)
113            .await
114            .unwrap();
115        assert_eq!(list.len(), 1);
116    }
117
118    #[tokio::test]
119    async fn label_crud() {
120        let store = Store::in_memory().await.unwrap();
121        let account = test_account();
122        store.insert_account(&account).await.unwrap();
123
124        let label = Label {
125            id: LabelId::new(),
126            account_id: account.id.clone(),
127            name: "Inbox".to_string(),
128            kind: LabelKind::System,
129            color: None,
130            provider_id: "INBOX".to_string(),
131            unread_count: 5,
132            total_count: 20,
133        };
134        store.upsert_label(&label).await.unwrap();
135
136        let labels = store.list_labels_by_account(&account.id).await.unwrap();
137        assert_eq!(labels.len(), 1);
138        assert_eq!(labels[0].name, "Inbox");
139        assert_eq!(labels[0].unread_count, 5);
140
141        store.update_label_counts(&label.id, 3, 25).await.unwrap();
142        let labels = store.list_labels_by_account(&account.id).await.unwrap();
143        assert_eq!(labels[0].unread_count, 3);
144        assert_eq!(labels[0].total_count, 25);
145    }
146
147    #[tokio::test]
148    async fn body_cache() {
149        let store = Store::in_memory().await.unwrap();
150        let account = test_account();
151        store.insert_account(&account).await.unwrap();
152
153        let env = test_envelope(&account.id);
154        store.upsert_envelope(&env).await.unwrap();
155
156        let body = MessageBody {
157            message_id: env.id.clone(),
158            text_plain: Some("Hello world".to_string()),
159            text_html: Some("<p>Hello world</p>".to_string()),
160            attachments: vec![AttachmentMeta {
161                id: AttachmentId::new(),
162                message_id: env.id.clone(),
163                filename: "report.pdf".to_string(),
164                mime_type: "application/pdf".to_string(),
165                size_bytes: 50000,
166                local_path: None,
167                provider_id: "att-1".to_string(),
168            }],
169            fetched_at: chrono::Utc::now(),
170            metadata: MessageMetadata::default(),
171        };
172        store.insert_body(&body).await.unwrap();
173
174        let fetched = store.get_body(&env.id).await.unwrap().unwrap();
175        assert_eq!(fetched.text_plain, body.text_plain);
176        assert_eq!(fetched.attachments.len(), 1);
177        assert_eq!(fetched.attachments[0].filename, "report.pdf");
178    }
179
180    #[tokio::test]
181    async fn message_labels_junction() {
182        let store = Store::in_memory().await.unwrap();
183        let account = test_account();
184        store.insert_account(&account).await.unwrap();
185
186        let label = Label {
187            id: LabelId::new(),
188            account_id: account.id.clone(),
189            name: "Inbox".to_string(),
190            kind: LabelKind::System,
191            color: None,
192            provider_id: "INBOX".to_string(),
193            unread_count: 0,
194            total_count: 0,
195        };
196        store.upsert_label(&label).await.unwrap();
197
198        let env = test_envelope(&account.id);
199        store.upsert_envelope(&env).await.unwrap();
200        store
201            .set_message_labels(&env.id, std::slice::from_ref(&label.id))
202            .await
203            .unwrap();
204
205        let by_label = store
206            .list_envelopes_by_label(&label.id, 100, 0)
207            .await
208            .unwrap();
209        assert_eq!(by_label.len(), 1);
210        assert_eq!(by_label[0].id, env.id);
211        assert_eq!(by_label[0].label_provider_ids, vec!["INBOX".to_string()]);
212
213        let by_account = store
214            .list_envelopes_by_account(&account.id, 100, 0)
215            .await
216            .unwrap();
217        assert_eq!(by_account.len(), 1);
218        assert_eq!(by_account[0].label_provider_ids, vec!["INBOX".to_string()]);
219
220        let fetched = store.get_envelope(&env.id).await.unwrap().unwrap();
221        assert_eq!(fetched.label_provider_ids, vec!["INBOX".to_string()]);
222    }
223
224    #[tokio::test]
225    async fn thread_aggregation() {
226        let store = Store::in_memory().await.unwrap();
227        let account = test_account();
228        store.insert_account(&account).await.unwrap();
229
230        let thread_id = ThreadId::new();
231        for i in 0..3 {
232            let mut env = test_envelope(&account.id);
233            env.provider_id = format!("fake-thread-{}", i);
234            env.thread_id = thread_id.clone();
235            env.date = chrono::Utc::now() - chrono::Duration::hours(i);
236            if i == 0 {
237                env.flags = MessageFlags::empty(); // unread
238            }
239            store.upsert_envelope(&env).await.unwrap();
240        }
241
242        let thread = store.get_thread(&thread_id).await.unwrap().unwrap();
243        assert_eq!(thread.message_count, 3);
244        assert_eq!(thread.unread_count, 1);
245    }
246
247    #[tokio::test]
248    async fn list_subscriptions_groups_by_sender_and_skips_trash() {
249        let store = Store::in_memory().await.unwrap();
250        let account = test_account();
251        store.insert_account(&account).await.unwrap();
252
253        let mut latest = test_envelope(&account.id);
254        latest.from.name = Some("Readwise".into());
255        latest.from.email = "hello@readwise.io".into();
256        latest.subject = "Latest digest".into();
257        latest.unsubscribe = UnsubscribeMethod::HttpLink {
258            url: "https://example.com/unsub".into(),
259        };
260        latest.date = chrono::Utc::now();
261
262        let mut older = latest.clone();
263        older.id = MessageId::new();
264        older.provider_id = "fake-older".into();
265        older.subject = "Older digest".into();
266        older.date = latest.date - chrono::Duration::days(3);
267
268        let mut trashed = latest.clone();
269        trashed.id = MessageId::new();
270        trashed.provider_id = "fake-trash".into();
271        trashed.subject = "Trashed digest".into();
272        trashed.date = latest.date + chrono::Duration::hours(1);
273        trashed.flags.insert(MessageFlags::TRASH);
274
275        let mut no_unsub = test_envelope(&account.id);
276        no_unsub.from.email = "plain@example.com".into();
277        no_unsub.provider_id = "fake-none".into();
278        no_unsub.unsubscribe = UnsubscribeMethod::None;
279
280        store.upsert_envelope(&older).await.unwrap();
281        store.upsert_envelope(&latest).await.unwrap();
282        store.upsert_envelope(&trashed).await.unwrap();
283        store.upsert_envelope(&no_unsub).await.unwrap();
284
285        let subscriptions = store.list_subscriptions(10).await.unwrap();
286        assert_eq!(subscriptions.len(), 1);
287        assert_eq!(subscriptions[0].sender_email, "hello@readwise.io");
288        assert_eq!(subscriptions[0].message_count, 2);
289        assert_eq!(subscriptions[0].latest_subject, "Latest digest");
290    }
291
292    #[tokio::test]
293    async fn draft_crud() {
294        let store = Store::in_memory().await.unwrap();
295        let account = test_account();
296        store.insert_account(&account).await.unwrap();
297
298        let draft = Draft {
299            id: DraftId::new(),
300            account_id: account.id.clone(),
301            reply_headers: None,
302            to: vec![Address {
303                name: None,
304                email: "bob@example.com".to_string(),
305            }],
306            cc: vec![],
307            bcc: vec![],
308            subject: "Draft subject".to_string(),
309            body_markdown: "# Hello".to_string(),
310            attachments: vec![],
311            created_at: chrono::Utc::now(),
312            updated_at: chrono::Utc::now(),
313        };
314        store.insert_draft(&draft).await.unwrap();
315
316        let drafts = store.list_drafts(&account.id).await.unwrap();
317        assert_eq!(drafts.len(), 1);
318
319        store.delete_draft(&draft.id).await.unwrap();
320        let drafts = store.list_drafts(&account.id).await.unwrap();
321        assert_eq!(drafts.len(), 0);
322    }
323
324    #[tokio::test]
325    async fn snooze_lifecycle() {
326        let store = Store::in_memory().await.unwrap();
327        let account = test_account();
328        store.insert_account(&account).await.unwrap();
329
330        let env = test_envelope(&account.id);
331        store.upsert_envelope(&env).await.unwrap();
332
333        let snoozed = Snoozed {
334            message_id: env.id.clone(),
335            account_id: account.id.clone(),
336            snoozed_at: chrono::Utc::now(),
337            wake_at: chrono::Utc::now() - chrono::Duration::hours(1), // already due
338            original_labels: vec![],
339        };
340        store.insert_snooze(&snoozed).await.unwrap();
341
342        let due = store.get_due_snoozes(chrono::Utc::now()).await.unwrap();
343        assert_eq!(due.len(), 1);
344
345        store.remove_snooze(&env.id).await.unwrap();
346        let due = store.get_due_snoozes(chrono::Utc::now()).await.unwrap();
347        assert_eq!(due.len(), 0);
348    }
349
350    #[tokio::test]
351    async fn sync_log_lifecycle() {
352        let store = Store::in_memory().await.unwrap();
353        let account = test_account();
354        store.insert_account(&account).await.unwrap();
355
356        let log_id = store
357            .insert_sync_log(&account.id, &SyncStatus::Running)
358            .await
359            .unwrap();
360        assert!(log_id > 0);
361
362        store
363            .complete_sync_log(log_id, &SyncStatus::Success, 55, None)
364            .await
365            .unwrap();
366
367        let last = store.get_last_sync(&account.id).await.unwrap().unwrap();
368        assert_eq!(last.status, SyncStatus::Success);
369        assert_eq!(last.messages_synced, 55);
370    }
371
372    #[tokio::test]
373    async fn event_log_insert_and_query() {
374        let store = Store::in_memory().await.unwrap();
375        let account = test_account();
376        store.insert_account(&account).await.unwrap();
377        let env = test_envelope(&account.id);
378        let env_id = env.id.as_str();
379        store.upsert_envelope(&env).await.unwrap();
380
381        store
382            .insert_event("info", "sync", "Sync completed", Some(&account.id), None)
383            .await
384            .unwrap();
385        store
386            .insert_event(
387                "error",
388                "sync",
389                "Sync failed",
390                Some(&account.id),
391                Some("timeout"),
392            )
393            .await
394            .unwrap();
395        store
396            .insert_event("info", "rule", "Rule applied", None, None)
397            .await
398            .unwrap();
399        store
400            .insert_event_refs(
401                "info",
402                "mutation",
403                "Archived a message",
404                Some(&account.id),
405                Some(env_id.as_str()),
406                None,
407                Some("from=test@example.com"),
408            )
409            .await
410            .unwrap();
411
412        let all = store.list_events(10, None, None).await.unwrap();
413        assert_eq!(all.len(), 4);
414
415        let errors = store.list_events(10, Some("error"), None).await.unwrap();
416        assert_eq!(errors.len(), 1);
417        assert_eq!(errors[0].summary, "Sync failed");
418
419        let sync_events = store.list_events(10, None, Some("sync")).await.unwrap();
420        assert_eq!(sync_events.len(), 2);
421
422        let mutation_events = store.list_events(10, None, Some("mutation")).await.unwrap();
423        assert_eq!(mutation_events.len(), 1);
424        assert_eq!(
425            mutation_events[0].message_id.as_deref(),
426            Some(env_id.as_str())
427        );
428    }
429
430    #[tokio::test]
431    async fn prune_events_before_removes_old_rows() {
432        let store = Store::in_memory().await.unwrap();
433        let account = test_account();
434        store.insert_account(&account).await.unwrap();
435
436        store
437            .insert_event("info", "sync", "recent", Some(&account.id), None)
438            .await
439            .unwrap();
440        sqlx::query("UPDATE event_log SET timestamp = ? WHERE summary = 'recent'")
441            .bind(chrono::Utc::now().timestamp())
442            .execute(store.writer())
443            .await
444            .unwrap();
445
446        store
447            .insert_event("info", "sync", "old", Some(&account.id), None)
448            .await
449            .unwrap();
450        sqlx::query("UPDATE event_log SET timestamp = ? WHERE summary = 'old'")
451            .bind((chrono::Utc::now() - chrono::Duration::days(120)).timestamp())
452            .execute(store.writer())
453            .await
454            .unwrap();
455
456        let removed = store
457            .prune_events_before((chrono::Utc::now() - chrono::Duration::days(90)).timestamp())
458            .await
459            .unwrap();
460        assert_eq!(removed, 1);
461
462        let events = store.list_events(10, None, None).await.unwrap();
463        assert_eq!(events.len(), 1);
464        assert_eq!(events[0].summary, "recent");
465    }
466
467    #[tokio::test]
468    async fn get_message_id_by_provider_id() {
469        let store = Store::in_memory().await.unwrap();
470        let account = test_account();
471        store.insert_account(&account).await.unwrap();
472
473        let env = test_envelope(&account.id);
474        store.upsert_envelope(&env).await.unwrap();
475
476        let found = store
477            .get_message_id_by_provider_id(&account.id, &env.provider_id)
478            .await
479            .unwrap();
480        assert_eq!(found, Some(env.id.clone()));
481
482        let not_found = store
483            .get_message_id_by_provider_id(&account.id, "nonexistent")
484            .await
485            .unwrap();
486        assert!(not_found.is_none());
487    }
488
489    #[tokio::test]
490    async fn recalculate_label_counts() {
491        let store = Store::in_memory().await.unwrap();
492        let account = test_account();
493        store.insert_account(&account).await.unwrap();
494
495        let label = Label {
496            id: LabelId::new(),
497            account_id: account.id.clone(),
498            name: "Inbox".to_string(),
499            kind: LabelKind::System,
500            color: None,
501            provider_id: "INBOX".to_string(),
502            unread_count: 0,
503            total_count: 0,
504        };
505        store.upsert_label(&label).await.unwrap();
506
507        // Insert 3 messages: 2 read, 1 unread
508        for i in 0..3 {
509            let mut env = test_envelope(&account.id);
510            env.provider_id = format!("fake-label-{}", i);
511            if i < 2 {
512                env.flags = MessageFlags::READ;
513            } else {
514                env.flags = MessageFlags::empty();
515            }
516            store.upsert_envelope(&env).await.unwrap();
517            store
518                .set_message_labels(&env.id, std::slice::from_ref(&label.id))
519                .await
520                .unwrap();
521        }
522
523        store.recalculate_label_counts(&account.id).await.unwrap();
524
525        let labels = store.list_labels_by_account(&account.id).await.unwrap();
526        assert_eq!(labels[0].total_count, 3);
527        assert_eq!(labels[0].unread_count, 1);
528    }
529
530    #[tokio::test]
531    async fn replace_label_moves_message_associations_when_id_changes() {
532        let store = Store::in_memory().await.unwrap();
533        let account = test_account();
534        store.insert_account(&account).await.unwrap();
535
536        let original = Label {
537            id: LabelId::from_provider_id("imap", "Projects"),
538            account_id: account.id.clone(),
539            name: "Projects".to_string(),
540            kind: LabelKind::Folder,
541            color: None,
542            provider_id: "Projects".to_string(),
543            unread_count: 0,
544            total_count: 0,
545        };
546        store.upsert_label(&original).await.unwrap();
547
548        let env = test_envelope(&account.id);
549        store.upsert_envelope(&env).await.unwrap();
550        store
551            .set_message_labels(&env.id, std::slice::from_ref(&original.id))
552            .await
553            .unwrap();
554
555        let renamed = Label {
556            id: LabelId::from_provider_id("imap", "Client Work"),
557            account_id: account.id.clone(),
558            name: "Client Work".to_string(),
559            kind: LabelKind::Folder,
560            color: None,
561            provider_id: "Client Work".to_string(),
562            unread_count: 0,
563            total_count: 0,
564        };
565        store.replace_label(&original.id, &renamed).await.unwrap();
566
567        let labels = store.list_labels_by_account(&account.id).await.unwrap();
568        assert_eq!(labels.len(), 1);
569        assert_eq!(labels[0].name, "Client Work");
570        assert_eq!(labels[0].id, renamed.id);
571
572        let by_new_label = store
573            .list_envelopes_by_label(&renamed.id, 100, 0)
574            .await
575            .unwrap();
576        assert_eq!(by_new_label.len(), 1);
577        assert_eq!(by_new_label[0].id, env.id);
578        assert!(store
579            .list_envelopes_by_label(&original.id, 100, 0)
580            .await
581            .unwrap()
582            .is_empty());
583    }
584
585    #[tokio::test]
586    async fn rules_roundtrip_and_history() {
587        let store = Store::in_memory().await.unwrap();
588        let now = chrono::Utc::now();
589
590        store
591            .upsert_rule(crate::RuleRecordInput {
592                id: "rule-1",
593                name: "Archive newsletters",
594                enabled: true,
595                priority: 10,
596                conditions_json: r#"{"type":"field","field":"has_label","label":"newsletters"}"#,
597                actions_json: r#"[{"type":"archive"}]"#,
598                created_at: now,
599                updated_at: now,
600            })
601            .await
602            .unwrap();
603
604        let rules = store.list_rules().await.unwrap();
605        assert_eq!(rules.len(), 1);
606        let rule_json = crate::rules::row_to_rule_json(&rules[0]);
607        assert_eq!(rule_json["name"], "Archive newsletters");
608        assert_eq!(rule_json["priority"], 10);
609
610        store
611            .insert_rule_log(crate::RuleLogInput {
612                rule_id: "rule-1",
613                rule_name: "Archive newsletters",
614                message_id: "msg-1",
615                actions_applied_json: r#"["archive"]"#,
616                timestamp: now,
617                success: true,
618                error: None,
619            })
620            .await
621            .unwrap();
622
623        let logs = store.list_rule_logs(Some("rule-1"), 10).await.unwrap();
624        assert_eq!(logs.len(), 1);
625        let log_json = crate::rules::row_to_rule_log_json(&logs[0]);
626        assert_eq!(log_json["rule_name"], "Archive newsletters");
627        assert_eq!(log_json["message_id"], "msg-1");
628    }
629
630    #[tokio::test]
631    async fn get_saved_search_by_name() {
632        let store = Store::in_memory().await.unwrap();
633
634        let search = SavedSearch {
635            id: SavedSearchId::new(),
636            account_id: None,
637            name: "Unread".to_string(),
638            query: "is:unread".to_string(),
639            sort: SortOrder::DateDesc,
640            icon: None,
641            position: 0,
642            created_at: chrono::Utc::now(),
643        };
644        store.insert_saved_search(&search).await.unwrap();
645
646        let found = store.get_saved_search_by_name("Unread").await.unwrap();
647        assert!(found.is_some());
648        assert_eq!(found.unwrap().query, "is:unread");
649
650        let not_found = store.get_saved_search_by_name("Nonexistent").await.unwrap();
651        assert!(not_found.is_none());
652    }
653
654    #[tokio::test]
655    async fn saved_search_crud() {
656        let store = Store::in_memory().await.unwrap();
657
658        let s1 = SavedSearch {
659            id: SavedSearchId::new(),
660            account_id: None,
661            name: "Unread".to_string(),
662            query: "is:unread".to_string(),
663            sort: SortOrder::DateDesc,
664            icon: None,
665            position: 0,
666            created_at: chrono::Utc::now(),
667        };
668        let s2 = SavedSearch {
669            id: SavedSearchId::new(),
670            account_id: None,
671            name: "Starred".to_string(),
672            query: "is:starred".to_string(),
673            sort: SortOrder::DateDesc,
674            icon: Some("star".to_string()),
675            position: 1,
676            created_at: chrono::Utc::now(),
677        };
678
679        store.insert_saved_search(&s1).await.unwrap();
680        store.insert_saved_search(&s2).await.unwrap();
681
682        // List returns both, ordered by position
683        let all = store.list_saved_searches().await.unwrap();
684        assert_eq!(all.len(), 2);
685        assert_eq!(all[0].name, "Unread");
686        assert_eq!(all[1].name, "Starred");
687
688        // Get by name
689        let found = store.get_saved_search_by_name("Starred").await.unwrap();
690        assert!(found.is_some());
691        assert_eq!(found.unwrap().query, "is:starred");
692
693        // Delete
694        store.delete_saved_search(&s1.id).await.unwrap();
695        let remaining = store.list_saved_searches().await.unwrap();
696        assert_eq!(remaining.len(), 1);
697        assert_eq!(remaining[0].name, "Starred");
698
699        // Delete by name
700        let deleted = store.delete_saved_search_by_name("Starred").await.unwrap();
701        assert!(deleted);
702        let empty = store.list_saved_searches().await.unwrap();
703        assert!(empty.is_empty());
704    }
705
706    #[tokio::test]
707    async fn list_contacts_ordered_by_frequency() {
708        let store = Store::in_memory().await.unwrap();
709        let account = test_account();
710        store.insert_account(&account).await.unwrap();
711
712        // Insert 3 messages from alice, 2 from bob, 1 from carol
713        for i in 0..3 {
714            let mut env = test_envelope(&account.id);
715            env.provider_id = format!("fake-alice-{}", i);
716            env.from = Address {
717                name: Some("Alice".to_string()),
718                email: "alice@example.com".to_string(),
719            };
720            store.upsert_envelope(&env).await.unwrap();
721        }
722        for i in 0..2 {
723            let mut env = test_envelope(&account.id);
724            env.provider_id = format!("fake-bob-{}", i);
725            env.from = Address {
726                name: Some("Bob".to_string()),
727                email: "bob@example.com".to_string(),
728            };
729            store.upsert_envelope(&env).await.unwrap();
730        }
731        {
732            let mut env = test_envelope(&account.id);
733            env.provider_id = "fake-carol-0".to_string();
734            env.from = Address {
735                name: Some("Carol".to_string()),
736                email: "carol@example.com".to_string(),
737            };
738            store.upsert_envelope(&env).await.unwrap();
739        }
740
741        let contacts = store.list_contacts(10).await.unwrap();
742        assert_eq!(contacts.len(), 3);
743        // Ordered by frequency: alice (3), bob (2), carol (1)
744        assert_eq!(contacts[0].1, "alice@example.com");
745        assert_eq!(contacts[1].1, "bob@example.com");
746        assert_eq!(contacts[2].1, "carol@example.com");
747    }
748
749    #[tokio::test]
750    async fn sync_cursor_persistence() {
751        let store = Store::in_memory().await.unwrap();
752        let account = test_account();
753        store.insert_account(&account).await.unwrap();
754
755        let cursor = store.get_sync_cursor(&account.id).await.unwrap();
756        assert!(cursor.is_none());
757
758        let new_cursor = SyncCursor::Gmail { history_id: 12345 };
759        store
760            .set_sync_cursor(&account.id, &new_cursor)
761            .await
762            .unwrap();
763
764        let fetched = store.get_sync_cursor(&account.id).await.unwrap().unwrap();
765        let json = serde_json::to_string(&fetched).unwrap();
766        assert!(json.contains("12345"));
767    }
768}