Skip to main content

unifier/
postbox.rs

1//! Postbox operations: keys, mailbox messages, and cron drops.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use uuid::Uuid;
7
8use crate::cron::CronSchedule;
9use crate::envelope::{Envelope, DEFAULT_SENDER};
10use crate::error::{Error, Result};
11use crate::fs_text::{read_text, write_text, write_text_atomic};
12use crate::home::UnifierHome;
13use crate::paths::{cron_dir, key_path, mailbox_dir, message_path, parse_message_id};
14use crate::scope::{path_within_root, resolve_under_root};
15
16/// A message picked up from the filesystem postbox.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Message {
19    pub id: Uuid,
20    pub path: PathBuf,
21    pub body: String,
22}
23
24pub fn put_key(home: &UnifierHome, key: &str, value: &str) -> Result<()> {
25    validate_key(key)?;
26    write_text_atomic(&key_path(home, key), value)
27}
28
29pub fn get_key(home: &UnifierHome, key: &str) -> Result<Option<String>> {
30    validate_key(key)?;
31    let path = key_path(home, key);
32    if path.is_file() {
33        Ok(Some(read_text(&path)?))
34    } else {
35        Ok(None)
36    }
37}
38
39pub fn delete_key(home: &UnifierHome, key: &str) -> Result<bool> {
40    validate_key(key)?;
41    let path = key_path(home, key);
42    if path.is_file() {
43        fs::remove_file(path)?;
44        Ok(true)
45    } else {
46        Ok(false)
47    }
48}
49
50pub fn send(home: &UnifierHome, recipient: &str, body: &str) -> Result<Uuid> {
51    send_from(home, DEFAULT_SENDER, recipient, body)
52}
53
54pub fn send_from(home: &UnifierHome, from: &str, recipient: &str, body: &str) -> Result<Uuid> {
55    validate_segment(from, "from")?;
56    validate_segment(recipient, "recipient")?;
57    let env = Envelope::new(from, recipient, Envelope::parse_payload(body));
58    let dir = mailbox_dir(home, recipient);
59    write_text(&message_path(&dir, &env.id), &env.to_json()?)?;
60    Ok(env.id)
61}
62
63pub fn post_cron(home: &UnifierHome, schedule: &str, body: &str) -> Result<Uuid> {
64    CronSchedule::parse(schedule)?;
65    let dir = cron_dir(home, schedule);
66    drop_message(&dir, body)
67}
68
69fn drop_message(dir: &Path, body: &str) -> Result<Uuid> {
70    let id = Uuid::new_v4();
71    write_text(&message_path(dir, &id), body)?;
72    Ok(id)
73}
74
75pub fn poll_mailbox(home: &UnifierHome, recipient: &str) -> Result<Vec<Message>> {
76    validate_segment(recipient, "recipient")?;
77    collect_messages(&mailbox_dir(home, recipient))
78}
79
80pub fn poll_cron(home: &UnifierHome) -> Result<Vec<Message>> {
81    let cron_root = home.path().join(crate::constants::CRON);
82    if !cron_root.is_dir() {
83        return Ok(Vec::new());
84    }
85
86    let mut out = Vec::new();
87    for entry in fs::read_dir(&cron_root)? {
88        let entry = entry?;
89        if !entry.file_type()?.is_dir() {
90            continue;
91        }
92        let name = entry.file_name();
93        let name = name.to_string_lossy();
94        let schedule = match CronSchedule::parse(&name) {
95            Ok(s) => s,
96            Err(_) => continue,
97        };
98        if !schedule.matches_now() {
99            continue;
100        }
101        out.extend(collect_messages(&entry.path())?);
102    }
103    out.sort_by(|a, b| a.path.cmp(&b.path));
104    Ok(out)
105}
106
107pub fn list_dir(home: &UnifierHome, subpath: &str) -> Result<Vec<Message>> {
108    let dir = resolve_under_root(home.path(), subpath)?;
109    if !dir.is_dir() {
110        return Ok(Vec::new());
111    }
112    collect_messages(&dir)
113}
114
115pub fn ack(home: &UnifierHome, id_or_path: &str) -> Result<bool> {
116    let path = resolve_message_path(home, id_or_path)?;
117    if path.is_file() {
118        fs::remove_file(&path)?;
119        Ok(true)
120    } else {
121        Ok(false)
122    }
123}
124
125fn collect_messages(dir: &Path) -> Result<Vec<Message>> {
126    if !dir.is_dir() {
127        return Ok(Vec::new());
128    }
129    let mut messages = Vec::new();
130    for entry in fs::read_dir(dir)? {
131        let entry = entry?;
132        if !entry.file_type()?.is_file() {
133            continue;
134        }
135        let name = entry.file_name();
136        let name = name.to_string_lossy();
137        let Some(id) = parse_message_id(&name) else {
138            continue;
139        };
140        let path = entry.path();
141        messages.push(Message {
142            id,
143            path: path.clone(),
144            body: read_text(&path)?,
145        });
146    }
147    messages.sort_by_key(|m| m.id);
148    Ok(messages)
149}
150
151fn resolve_message_path(home: &UnifierHome, id_or_path: &str) -> Result<PathBuf> {
152    if id_or_path.contains('/') {
153        let candidate = resolve_under_root(home.path(), id_or_path)?;
154        if !path_within_root(home.path(), &candidate)? {
155            return Err(Error::msg("path escapes store root"));
156        }
157        return Ok(candidate);
158    }
159    if PathBuf::from(id_or_path).is_absolute() {
160        let candidate = PathBuf::from(id_or_path);
161        if !path_within_root(home.path(), &candidate)? {
162            return Err(Error::msg("path escapes store root"));
163        }
164        return Ok(candidate);
165    }
166    let id = Uuid::parse_str(id_or_path)
167        .map_err(|_| Error::msg(format!("invalid message id or path: {id_or_path}")))?;
168    find_message_by_id(home, &id)
169}
170
171fn find_message_by_id(home: &UnifierHome, id: &Uuid) -> Result<PathBuf> {
172    let filename = format!("{}.txt", id.hyphenated());
173    for sub in [crate::constants::CRON, crate::constants::MAILBOX] {
174        let base = home.path().join(sub);
175        if !base.is_dir() {
176            continue;
177        }
178        for entry in fs::read_dir(&base)? {
179            let entry = entry?;
180            if entry.file_type()?.is_dir() {
181                let candidate = entry.path().join(&filename);
182                if candidate.is_file() {
183                    return Ok(candidate);
184                }
185            }
186        }
187    }
188    Err(Error::msg(format!("message not found: {id}")))
189}
190
191fn validate_key(key: &str) -> Result<()> {
192    if key.is_empty() {
193        return Err(Error::msg("key must not be empty"));
194    }
195    if key.contains("..") {
196        return Err(Error::msg("key must not contain '..'"));
197    }
198    Ok(())
199}
200
201fn validate_segment(segment: &str, label: &str) -> Result<()> {
202    if segment.is_empty() || segment.contains('/') || segment.contains("..") {
203        return Err(Error::msg(format!("invalid {label}: {segment}")));
204    }
205    Ok(())
206}