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