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