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