Skip to main content

mxr_store/
lib.rs

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