1use crate::events::{EventBus, ServerEvent};
2use crate::state::AppState;
3use notify::{RecommendedWatcher, RecursiveMode, Watcher};
4use std::path::Path;
5use std::time::Duration;
6use tokio::sync::mpsc;
7
8const DEBOUNCE: Duration = Duration::from_secs(2);
9
10pub fn spawn(state: AppState) -> anyhow::Result<RecommendedWatcher> {
11 let root = state.store.paths().maildir_root.clone();
12 let (tx, rx) = mpsc::unbounded_channel();
13
14 let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
15 if let Ok(event) = event {
16 if is_delivery(&event) {
17 let _ = tx.send(());
18 }
19 }
20 })?;
21
22 watcher.watch(Path::new(&root), RecursiveMode::Recursive)?;
23 tokio::spawn(debounce_loop(state, rx));
24
25 tracing::info!(root = %root.display(), "watching maildir for delivered mail");
26 Ok(watcher)
27}
28
29fn is_delivery(event: ¬ify::Event) -> bool {
30 use notify::EventKind;
31
32 if !matches!(
33 event.kind,
34 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
35 ) {
36 return false;
37 }
38
39 event.paths.iter().any(|p| {
40 let text = p.to_string_lossy();
41 !text.contains("/.notmuch/") && !text.ends_with(".mbsyncstate") && !text.ends_with(".lock")
42 })
43}
44
45async fn debounce_loop(state: AppState, mut rx: mpsc::UnboundedReceiver<()>) {
46 while rx.recv().await.is_some() {
47 while let Ok(Some(())) = tokio::time::timeout(DEBOUNCE, rx.recv()).await {}
48
49 if state.read_only {
50 tracing::debug!("maildir changed but the server is read-only; not indexing");
51 continue;
52 }
53
54 match state.store.notmuch().index_new().await {
55 Ok(revision) => {
56 if state.own_write(&revision).await {
65 tracing::debug!(%revision, "maildir changed to match our own tag write");
66 continue;
67 }
68
69 if let Err(err) = state.store.refresh_index().await {
74 tracing::warn!(%err, "could not bring the mail index up to the new mail");
75 }
76
77 tracing::info!(%revision, "indexed newly delivered mail");
78 state.events.publish(ServerEvent::MailChanged { revision });
79 }
80 Err(err) => {
81 tracing::warn!(%err, "could not index delivered mail");
82 publish_error(&state.events, &err.to_string());
83 }
84 }
85 }
86}
87
88fn publish_error(bus: &EventBus, detail: &str) {
89 bus.publish(ServerEvent::Error {
90 detail: detail.to_string(),
91 });
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use notify::event::{CreateKind, EventKind};
98 use std::path::PathBuf;
99
100 fn event(kind: EventKind, path: &str) -> notify::Event {
101 notify::Event {
102 kind,
103 paths: vec![PathBuf::from(path)],
104 attrs: Default::default(),
105 }
106 }
107
108 #[test]
109 fn a_new_maildir_file_counts_as_a_delivery() {
110 assert!(is_delivery(&event(
111 EventKind::Create(CreateKind::File),
112 "/Mail/main/Inbox/cur/123:2,"
113 )));
114 }
115
116 #[test]
117 fn notmuch_writing_its_own_database_does_not_count() {
118 assert!(!is_delivery(&event(
119 EventKind::Create(CreateKind::File),
120 "/Mail/.notmuch/xapian/postlist.glass"
121 )));
122 }
123
124 #[test]
125 fn mbsync_state_files_do_not_count() {
126 assert!(!is_delivery(&event(
127 EventKind::Create(CreateKind::File),
128 "/Mail/main/Inbox/.mbsyncstate"
129 )));
130 }
131
132 #[test]
133 fn access_events_do_not_count() {
134 assert!(!is_delivery(&event(
135 EventKind::Access(notify::event::AccessKind::Read),
136 "/Mail/main/Inbox/cur/123:2,"
137 )));
138 }
139}