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(); }
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), 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 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 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 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 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 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 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 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}