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