1use 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#[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}