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