Skip to main content

postvak_core/
actions.rs

1//! Direct actions on chosen messages: mark, move, archive and delete, through the same `apply` as rules, which the
2//! daemon's sync loop runs; plus the dry-run and rule preview helpers the CLI and the MCP server share.
3use crate::config::{AccountConfig, Identity};
4use crate::mail_ops::{MailError, MailOps};
5use crate::message::clean;
6use crate::rules::apply::{ApplyError, apply, ensure_raw, move_target};
7use crate::rules::engine::{Context, Mode, Plan, PlannedAction, evaluate};
8use crate::rules::{Action, CompiledRule};
9use crate::store::{Message, Store, StoreError};
10use crate::trash::Trash;
11
12/// The rule name the CLI logs direct actions under.
13pub const RULE_NAME: &str = "cli";
14
15#[derive(Debug, thiserror::Error)]
16pub enum ActionError {
17    #[error(transparent)]
18    Apply(#[from] ApplyError),
19    #[error("{0} changed on the server since the last sync; run `postvak sync` first")]
20    FolderChanged(String),
21    #[error(transparent)]
22    Mail(#[from] MailError),
23    #[error("no message {folder}/{uid} in the local store; run `postvak sync` first")]
24    NotFound { folder: String, uid: u32 },
25    #[error(transparent)]
26    Store(#[from] StoreError),
27    #[error("{0} has not been synced yet; run `postvak sync` first")]
28    UnknownFolder(String),
29}
30
31/// The outcome for one uid: how many actions ran, or why none did.
32pub type UidResult = (u32, Result<usize, ActionError>);
33
34/// Selects `folder`, refusing when the stored uids belong to another UIDVALIDITY and would hit unrelated messages.
35fn select_synced(ops: &mut dyn MailOps, store: &Store, folder: &str) -> Result<(), ActionError> {
36    let stored = store
37        .folder(folder)?
38        .ok_or_else(|| ActionError::UnknownFolder(folder.to_string()))?;
39    if ops.select(folder)?.uidvalidity != stored.uidvalidity {
40        return Err(ActionError::FolderChanged(folder.to_string()));
41    }
42    Ok(())
43}
44
45/// Runs `action` on each uid in `folder`, logged under `rule_name`. One result per uid, so a missing message does not
46/// stop the others.
47#[allow(clippy::too_many_arguments)]
48pub fn run(
49    ops: &mut dyn MailOps,
50    store: &Store,
51    trash: &Trash,
52    folder: &str,
53    uids: &[u32],
54    action: &Action,
55    rule_name: &str,
56    now: i64,
57) -> Result<Vec<UidResult>, ActionError> {
58    select_synced(ops, store, folder)?;
59    let plan = Plan {
60        actions: vec![PlannedAction {
61            rule: rule_name.to_string(),
62            action: action.clone(),
63        }],
64        notify: false,
65    };
66    let mut results = Vec::new();
67    for &uid in uids {
68        let result = match store.message(folder, uid) {
69            Ok(Some(msg)) => apply(&plan, &msg, ops, store, trash, now).map_err(ActionError::from),
70            Ok(None) => Err(ActionError::NotFound {
71                folder: folder.to_string(),
72                uid,
73            }),
74            Err(e) => Err(e.into()),
75        };
76        results.push((uid, result));
77    }
78    Ok(results)
79}
80
81/// Downloads and indexes every body `folder` lacks. Returns how many arrived; a message that cannot be fetched is
82/// logged and skipped.
83pub fn fetch_bodies(
84    ops: &mut dyn MailOps,
85    store: &Store,
86    folder: &str,
87) -> Result<usize, ActionError> {
88    let missing = store.messages_without_body(folder)?;
89    if missing.is_empty() {
90        return Ok(0);
91    }
92    select_synced(ops, store, folder)?;
93    let mut fetched = 0;
94    for msg in &missing {
95        match ensure_raw(msg, ops, store) {
96            Ok(_) => fetched += 1,
97            Err(e) => log::warn!("{}/{}: body fetch failed: {e}", msg.folder, msg.uid),
98        }
99    }
100    Ok(fetched)
101}
102
103/// Downloads and indexes one message body, refusing when the folder changed on the server since the last sync.
104pub fn fetch_body(
105    ops: &mut dyn MailOps,
106    store: &Store,
107    folder: &str,
108    uid: u32,
109) -> Result<(), ActionError> {
110    select_synced(ops, store, folder)?;
111    let msg = store
112        .message(folder, uid)?
113        .ok_or_else(|| ActionError::NotFound {
114            folder: folder.to_string(),
115            uid,
116        })?;
117    ensure_raw(&msg, ops, store)?;
118    Ok(())
119}
120
121/// The most hits one folder adds to a server search, the highest uids first.
122const SERVER_HITS_PER_FOLDER: usize = 500;
123
124/// The words of a search query that a server search needs all of; control characters cannot go into IMAP.
125pub fn search_words(query: &str) -> Vec<String> {
126    query
127        .split_whitespace()
128        .map(|word| clean(word, false))
129        .filter(|word| !word.is_empty())
130        .collect()
131}
132
133/// Searches `folder` on the server for messages whose headers or body contain every word. Returns the uids the store
134/// knows; mail not synced yet waits for the next sync.
135pub fn search_server(
136    ops: &mut dyn MailOps,
137    store: &Store,
138    folder: &str,
139    words: &[String],
140) -> Result<Vec<u32>, ActionError> {
141    if words.is_empty() {
142        return Ok(Vec::new());
143    }
144    select_synced(ops, store, folder)?;
145    let mut hits = Vec::new();
146    for uid in ops
147        .search_text(words)?
148        .into_iter()
149        .rev()
150        .take(SERVER_HITS_PER_FOLDER)
151    {
152        if store.message(folder, uid)?.is_some() {
153            hits.push(uid);
154        }
155    }
156    Ok(hits)
157}
158
159/// Local search results plus the server's hits the store knows, each once, newest first, at most `limit`.
160pub fn with_server_hits(
161    store: &Store,
162    mut found: Vec<Message>,
163    hits: &[(String, u32)],
164    limit: u32,
165) -> Result<Vec<Message>, StoreError> {
166    for (folder, uid) in hits {
167        if found.iter().any(|m| m.folder == *folder && m.uid == *uid) {
168            continue;
169        }
170        if let Some(message) = store.message(folder, *uid)? {
171            found.push(message);
172        }
173    }
174    found.sort_by_key(|m| std::cmp::Reverse((m.internaldate, m.uid)));
175    found.truncate(limit as usize);
176    Ok(found)
177}
178
179/// What a dry run would do to `msg`; a user delete names its outcome, because an expunge cannot be undone on the server.
180pub fn planned_effect(store: &Store, msg: &Message, action: &Action) -> Result<String, StoreError> {
181    if *action != Action::Trash {
182        return Ok(format!("would {}", clean(&action.label(), false)));
183    }
184    let effect = match move_target(store, &msg.folder, action)? {
185        Some(trash) => format!("would move to {}", clean(&trash, false)),
186        None => "would delete (expunge, .eml backup kept)".to_string(),
187    };
188    Ok(effect)
189}
190
191/// One action a rule would take on a cached message.
192#[derive(Debug, Clone)]
193pub struct Planned {
194    pub rule: String,
195    pub message: Message,
196    pub action: Action,
197}
198
199/// What `rules` would do to every message cached in `store`, evaluated as `rules apply-existing` would.
200pub fn planned(
201    rules: &[CompiledRule],
202    store: &Store,
203    account: &AccountConfig,
204    identity: &Identity,
205    now: i64,
206) -> Result<Vec<Planned>, StoreError> {
207    let ctx = Context {
208        account: &account.name,
209        identity,
210        now,
211        mode: Mode::ApplyExisting,
212        notify_default: false,
213    };
214    let mut planned = Vec::new();
215    for folder in store.folders()? {
216        for message in store.messages_in_folder(&folder.name)? {
217            for PlannedAction { rule, action } in evaluate(rules, &message, &ctx).actions {
218                planned.push(Planned {
219                    rule,
220                    message: message.clone(),
221                    action,
222                });
223            }
224        }
225    }
226    Ok(planned)
227}
228
229/// Whether `planned` would remove the message from the server for good rather than move or flag it.
230pub fn expunges(store: &Store, planned: &Planned) -> Result<bool, StoreError> {
231    Ok(match planned.action {
232        Action::Delete => true,
233        Action::Trash => move_target(store, &planned.message.folder, &Action::Trash)?.is_none(),
234        _ => false,
235    })
236}
237
238/// What every surface says before `rules apply-existing` expunges mail of `account`; None when the rules expunge none.
239pub fn expunge_warning(
240    rules: &[CompiledRule],
241    store: &Store,
242    account: &AccountConfig,
243    identity: &Identity,
244    now: i64,
245) -> Result<Option<String>, StoreError> {
246    let mut count = 0;
247    for p in planned(rules, store, account, identity, now)? {
248        count += usize::from(expunges(store, &p)?);
249    }
250    Ok((count > 0).then(|| {
251        format!(
252            "{}: would permanently delete {count} message{} from the server; .eml backups kept locally for {} days",
253            account.name,
254            if count == 1 { "" } else { "s" },
255            account.trash_retention_days
256        )
257    }))
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::mail_ops::RecordingOps;
264    use crate::store::{Folder, Message};
265
266    fn setup() -> (RecordingOps, Store, Trash, tempfile::TempDir) {
267        let dir = tempfile::tempdir().unwrap();
268        let mut ops = RecordingOps::default().with_folder("INBOX", None);
269        ops.add_mail(
270            "INBOX",
271            5,
272            100,
273            "Subject: hi\r\n\r\n",
274            Some("Subject: hi\r\n\r\nbody"),
275        );
276        let store = Store::open_in_memory().unwrap();
277        store
278            .upsert_folder(&Folder {
279                name: "INBOX".into(),
280                uidvalidity: 1,
281                last_uid: 5,
282                special_use: None,
283                delimiter: None,
284            })
285            .unwrap();
286        store
287            .insert_message(&Message {
288                folder: "INBOX".into(),
289                uid: 5,
290                thread_id: "t".into(),
291                subject: Some("hi".into()),
292                internaldate: 100,
293                headers: b"Subject: hi\r\n\r\n".to_vec(),
294                ..Default::default()
295            })
296            .unwrap();
297        (ops, store, Trash::new(dir.path().join("trash")), dir)
298    }
299
300    #[test]
301    fn planned_effect_names_a_delete_without_trash() {
302        let (_ops, store, _trash, _dir) = setup();
303        let msg = store.message("INBOX", 5).unwrap().unwrap();
304        assert_eq!(
305            planned_effect(&store, &msg, &Action::Trash).unwrap(),
306            "would delete (expunge, .eml backup kept)"
307        );
308        assert_eq!(
309            planned_effect(&store, &msg, &Action::Archive).unwrap(),
310            "would archive"
311        );
312    }
313
314    #[test]
315    fn expunges_only_deletes_and_trash_without_a_trash_folder() {
316        let (_ops, store, _trash, _dir) = setup();
317        let message = store.message("INBOX", 5).unwrap().unwrap();
318        let expunges_with = |action: Action| {
319            let planned = Planned {
320                rule: "r".into(),
321                message: message.clone(),
322                action,
323            };
324            expunges(&store, &planned).unwrap()
325        };
326        assert!(expunges_with(Action::Delete));
327        assert!(expunges_with(Action::Trash));
328        assert!(!expunges_with(Action::Archive));
329        assert!(!expunges_with(Action::Flag));
330        store
331            .upsert_folder(&Folder {
332                name: "Trash".into(),
333                uidvalidity: 1,
334                last_uid: 0,
335                special_use: Some("Trash".into()),
336                delimiter: None,
337            })
338            .unwrap();
339        assert!(!expunges_with(Action::Trash));
340        assert!(expunges_with(Action::Delete));
341    }
342
343    #[test]
344    fn expunge_warning_counts_what_the_rules_would_expunge() {
345        let (_ops, store, _trash, _dir) = setup();
346        let password = crate::config::PasswordSource::Keyring { keyring: true };
347        let account = AccountConfig::new("work", "h", "me@example.com", password);
348        let identity = account.identity().unwrap();
349        let warning = |actions: &str| {
350            let toml = format!(
351                "[[rules]]\nname = \"r\"\nmatch.subject = {{ contains = \"hi\" }}\nactions = [{actions}]\n"
352            );
353            let rules = crate::rules::compile(&crate::rules::parse(&toml).unwrap()).unwrap();
354            expunge_warning(&rules, &store, &account, &identity, 0).unwrap()
355        };
356        assert_eq!(
357            warning("\"delete\"").as_deref(),
358            Some(
359                "work: would permanently delete 1 message from the server; .eml backups kept locally for 30 days"
360            )
361        );
362        assert_eq!(warning("\"flag\""), None);
363    }
364
365    #[test]
366    fn run_applies_to_each_uid_and_reports_missing_ones() {
367        let (mut ops, store, trash, _dir) = setup();
368        let results = run(
369            &mut ops,
370            &store,
371            &trash,
372            "INBOX",
373            &[5, 99],
374            &Action::Flag,
375            "mcp:test-host",
376            500,
377        )
378        .unwrap();
379        assert!(matches!(results[0], (5, Ok(1))));
380        assert!(matches!(
381            results[1],
382            (99, Err(ActionError::NotFound { .. }))
383        ));
384        assert_eq!(ops.mail["INBOX"][0].flags, ["\\Flagged"]);
385        assert_eq!(store.log(10).unwrap()[0].rule_name, "mcp:test-host");
386    }
387
388    #[test]
389    fn run_refuses_a_folder_whose_uidvalidity_changed() {
390        let (mut ops, store, trash, _dir) = setup();
391        ops.uidvalidity.insert("INBOX".into(), 2);
392        let err = run(
393            &mut ops,
394            &store,
395            &trash,
396            "INBOX",
397            &[5],
398            &Action::Trash,
399            RULE_NAME,
400            500,
401        )
402        .unwrap_err();
403        assert!(matches!(err, ActionError::FolderChanged(_)));
404        assert_eq!(ops.mail["INBOX"].len(), 1);
405        assert_eq!(ops.calls, ["select INBOX"]);
406    }
407
408    #[test]
409    fn run_refuses_a_folder_that_was_never_synced() {
410        let (mut ops, store, trash, _dir) = setup();
411        let err = run(
412            &mut ops,
413            &store,
414            &trash,
415            "Receipts",
416            &[5],
417            &Action::Flag,
418            RULE_NAME,
419            500,
420        )
421        .unwrap_err();
422        assert!(matches!(err, ActionError::UnknownFolder(_)));
423        assert!(ops.calls.is_empty());
424    }
425
426    #[test]
427    fn fetch_bodies_indexes_only_missing_bodies() {
428        let (mut ops, store, _trash, _dir) = setup();
429        assert_eq!(fetch_bodies(&mut ops, &store, "INBOX").unwrap(), 1);
430        assert_eq!(store.search("body", None, 10).unwrap().len(), 1);
431        let calls = ops.calls.len();
432        assert_eq!(fetch_bodies(&mut ops, &store, "INBOX").unwrap(), 0);
433        assert_eq!(ops.calls.len(), calls, "nothing left to fetch");
434    }
435
436    #[test]
437    fn search_server_returns_the_hits_the_store_knows() {
438        let (mut ops, store, _trash, _dir) = setup();
439        let mut ops = {
440            ops.add_mail(
441                "INBOX",
442                9,
443                300,
444                "Subject: unsynced\r\n\r\n",
445                Some("Subject: unsynced\r\n\r\nbody"),
446            );
447            ops.with_folder("Archive", None)
448        };
449        ops.add_mail(
450            "Archive",
451            7,
452            200,
453            "Subject: old\r\n\r\n",
454            Some("Subject: old\r\n\r\nsome BODY text"),
455        );
456        ops.add_mail(
457            "Archive",
458            8,
459            250,
460            "Subject: other\r\n\r\n",
461            Some("Subject: other\r\n\r\nnothing"),
462        );
463        store
464            .upsert_folder(&Folder {
465                name: "Archive".into(),
466                uidvalidity: 1,
467                last_uid: 8,
468                special_use: None,
469                delimiter: None,
470            })
471            .unwrap();
472        for uid in [7, 8] {
473            store
474                .insert_message(&Message {
475                    folder: "Archive".into(),
476                    uid,
477                    thread_id: format!("t{uid}"),
478                    ..Default::default()
479                })
480                .unwrap();
481        }
482        let words = search_words("body \u{7}");
483        assert_eq!(words, ["body"]);
484        assert_eq!(
485            search_server(&mut ops, &store, "Archive", &words).unwrap(),
486            [7]
487        );
488        assert_eq!(
489            search_server(&mut ops, &store, "INBOX", &words).unwrap(),
490            [5],
491            "uid 9 is not synced yet"
492        );
493        ops.calls.clear();
494        assert!(
495            search_server(&mut ops, &store, "INBOX", &search_words("  "))
496                .unwrap()
497                .is_empty()
498        );
499        assert!(ops.calls.is_empty());
500    }
501
502    #[test]
503    fn with_server_hits_adds_the_known_ones_newest_first_once() {
504        let (_ops, store, _trash, _dir) = setup();
505        let older = Message {
506            folder: "INBOX".into(),
507            uid: 2,
508            thread_id: "t2".into(),
509            internaldate: 50,
510            ..Default::default()
511        };
512        store.insert_message(&older).unwrap();
513        let local = store.message("INBOX", 5).unwrap().into_iter().collect();
514        let hits = [
515            ("INBOX".to_string(), 2),
516            ("INBOX".to_string(), 5),
517            ("INBOX".to_string(), 77),
518        ];
519        let merged = with_server_hits(&store, local, &hits, 10).unwrap();
520        let uids: Vec<u32> = merged.iter().map(|m| m.uid).collect();
521        assert_eq!(uids, [5, 2]);
522        assert_eq!(
523            with_server_hits(&store, Vec::new(), &hits, 1)
524                .unwrap()
525                .len(),
526            1
527        );
528    }
529}