1use std::path::{Path, PathBuf};
17
18use chrono::Utc;
19use serde::{Deserialize, Serialize};
20
21use crate::error::SessionStoreError;
22
23pub const MAILBOX_SCHEMA_VERSION: u32 = 1;
25pub const MAILBOX_DIR: &str = "mailbox";
27const READ_SUBDIR: &str = "read";
29
30pub const MAX_MAIL_BYTES: usize = 32_768;
32pub const MAX_TEXT_CHARS: usize = 8_000;
34pub const MAX_QUESTIONS: usize = 3;
36pub const MAX_QUESTION_CHARS: usize = 2_000;
38pub const MAX_OPTIONS: usize = 3;
40pub const MAX_OPTION_LABEL_CHARS: usize = 80;
42pub const MAX_OPTION_DESCRIPTION_CHARS: usize = 240;
44pub const MAX_QUESTION_ID_CHARS: usize = 64;
46pub const MAX_INBOX_DEPTH: usize = 100;
48pub const DEFAULT_TTL_SECS: i64 = 3_600;
50pub const MIN_TTL_SECS: i64 = 60;
52pub const MAX_TTL_SECS: i64 = 86_400;
54pub const READ_RETENTION_SECS: i64 = 7 * 86_400;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum MailKind {
61 Question,
64 Answer,
66 Notice,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct MailOption {
73 pub label: String,
75 pub description: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct MailQuestion {
82 pub id: String,
84 pub question: String,
86 #[serde(default)]
88 pub options: Vec<MailOption>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct NewMail {
94 to_session: String,
95 kind: MailKind,
96 in_reply_to: Option<String>,
97 questions: Vec<MailQuestion>,
98 text: String,
99 ttl_secs: i64,
100}
101
102impl NewMail {
103 pub fn question(
106 to_session: &str,
107 questions: Vec<MailQuestion>,
108 body: &str,
109 ttl_secs: Option<i64>,
110 ) -> Result<Self, SessionStoreError> {
111 validate_session_id(to_session)?;
112 if questions.is_empty() || questions.len() > MAX_QUESTIONS {
113 return Err(SessionStoreError::invalid_mail(format!(
114 "question messages carry 1-{MAX_QUESTIONS} questions, got {}",
115 questions.len()
116 )));
117 }
118 for question in &questions {
119 validate_question(question)?;
120 }
121 validate_text_len("body", body, MAX_TEXT_CHARS)?;
122 Ok(Self {
123 to_session: to_session.to_owned(),
124 kind: MailKind::Question,
125 in_reply_to: None,
126 questions,
127 text: body.to_owned(),
128 ttl_secs: clamp_ttl(ttl_secs),
129 })
130 }
131
132 pub fn answer(
134 to_session: &str,
135 in_reply_to: &str,
136 text: &str,
137 ttl_secs: Option<i64>,
138 ) -> Result<Self, SessionStoreError> {
139 validate_session_id(to_session)?;
140 if in_reply_to.is_empty() || in_reply_to.len() > 128 {
141 return Err(SessionStoreError::invalid_mail("answer requires the question message id"));
142 }
143 validate_text("answer text", text)?;
144 Ok(Self {
145 to_session: to_session.to_owned(),
146 kind: MailKind::Answer,
147 in_reply_to: Some(in_reply_to.to_owned()),
148 questions: Vec::new(),
149 text: text.to_owned(),
150 ttl_secs: clamp_ttl(ttl_secs),
151 })
152 }
153
154 pub fn notice(to_session: &str, text: &str, ttl_secs: Option<i64>) -> Result<Self, SessionStoreError> {
156 validate_session_id(to_session)?;
157 validate_text("notice text", text)?;
158 Ok(Self {
159 to_session: to_session.to_owned(),
160 kind: MailKind::Notice,
161 in_reply_to: None,
162 questions: Vec::new(),
163 text: text.to_owned(),
164 ttl_secs: clamp_ttl(ttl_secs),
165 })
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct MailMessage {
172 pub schema_version: u32,
174 pub message_id: String,
176 pub from_session: String,
178 pub to_session: String,
180 pub kind: MailKind,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub in_reply_to: Option<String>,
185 #[serde(default)]
187 pub questions: Vec<MailQuestion>,
188 pub text: String,
190 pub created_at: String,
192 pub expires_at: String,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct MailReceipt {
199 pub message_id: String,
201}
202
203#[derive(Debug, Clone, Default, PartialEq, Eq)]
206pub struct InboxDrain {
207 pub live: Vec<MailMessage>,
209 pub expired_ids: Vec<String>,
211 pub quarantined: u64,
213}
214
215fn validate_session_id(session: &str) -> Result<(), SessionStoreError> {
216 let canonical = !session.is_empty()
219 && session.len() <= 80
220 && !session.starts_with('.')
221 && session
222 .bytes()
223 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.');
224 if canonical {
225 Ok(())
226 } else {
227 Err(SessionStoreError::invalid_mail(format!("invalid session id: {session:?}")))
228 }
229}
230
231fn validate_text_len(field: &str, text: &str, max_chars: usize) -> Result<(), SessionStoreError> {
232 if text.chars().count() > max_chars {
233 return Err(SessionStoreError::invalid_mail(format!("{field} exceeds {max_chars} characters")));
234 }
235 Ok(())
236}
237
238fn validate_text(field: &str, text: &str) -> Result<(), SessionStoreError> {
239 if text.trim().is_empty() {
240 return Err(SessionStoreError::invalid_mail(format!("{field} must not be empty")));
241 }
242 validate_text_len(field, text, MAX_TEXT_CHARS)
243}
244
245fn validate_question(question: &MailQuestion) -> Result<(), SessionStoreError> {
246 if question.id.is_empty() || question.id.len() > MAX_QUESTION_ID_CHARS {
247 return Err(SessionStoreError::invalid_mail("question id must be 1-64 characters"));
248 }
249 if question.question.trim().is_empty() {
250 return Err(SessionStoreError::invalid_mail("question text must not be empty"));
251 }
252 validate_text_len("question text", &question.question, MAX_QUESTION_CHARS)?;
253 if question.options.len() > MAX_OPTIONS {
254 return Err(SessionStoreError::invalid_mail(format!("questions carry at most {MAX_OPTIONS} options")));
255 }
256 for option in &question.options {
257 if option.label.trim().is_empty() {
258 return Err(SessionStoreError::invalid_mail("option label must not be empty"));
259 }
260 validate_text_len("option label", &option.label, MAX_OPTION_LABEL_CHARS)?;
261 if option.description.trim().is_empty() {
262 return Err(SessionStoreError::invalid_mail("option description must not be empty"));
263 }
264 validate_text_len("option description", &option.description, MAX_OPTION_DESCRIPTION_CHARS)?;
265 }
266 Ok(())
267}
268
269fn clamp_ttl(ttl_secs: Option<i64>) -> i64 {
270 ttl_secs.unwrap_or(DEFAULT_TTL_SECS).clamp(MIN_TTL_SECS, MAX_TTL_SECS)
271}
272
273fn inbox_dir(workspace: &Path, session: &str) -> PathBuf {
274 crate::session_dir(workspace, session).join(MAILBOX_DIR)
275}
276
277fn read_dir_of(inbox: &Path) -> PathBuf {
278 inbox.join(READ_SUBDIR)
279}
280
281fn unread_count(inbox: &Path) -> Result<usize, SessionStoreError> {
282 let entries = std::fs::read_dir(inbox).map_err(|error| SessionStoreError::io(inbox.to_path_buf(), error))?;
283 let mut count = 0;
284 for entry in entries.flatten() {
285 if entry.file_type().is_ok_and(|kind| kind.is_symlink()) {
288 continue;
289 }
290 let path = entry.path();
291 if !path.is_file() {
292 continue;
293 }
294 let is_message = path
295 .file_name()
296 .and_then(|name| name.to_str())
297 .is_some_and(|name| name.ends_with(".json"));
298 if is_message {
299 count += 1;
300 }
301 }
302 Ok(count)
303}
304
305pub fn send_mail(workspace: &Path, from_session: &str, mail: &NewMail) -> Result<MailReceipt, SessionStoreError> {
310 validate_session_id(from_session)?;
311 let target_dir = crate::session_dir(workspace, &mail.to_session);
312 if !target_dir.is_dir() {
313 return Err(SessionStoreError::session_not_live(&mail.to_session));
314 }
315 if !crate::retention::session_dir_is_live(&target_dir) {
316 return Err(SessionStoreError::session_not_live(&mail.to_session));
317 }
318 let inbox = inbox_dir(workspace, &mail.to_session);
319 crate::ensure_private_directory(&inbox)?;
320 if unread_count(&inbox)? >= MAX_INBOX_DEPTH {
321 return Err(SessionStoreError::mailbox_full(&mail.to_session));
322 }
323
324 let created = Utc::now();
325 let expires = created + chrono::Duration::seconds(mail.ttl_secs);
326 for _ in 0..3 {
328 let message_id = uuid::Uuid::new_v4().to_string();
329 let message = MailMessage {
330 schema_version: MAILBOX_SCHEMA_VERSION,
331 message_id: message_id.clone(),
332 from_session: from_session.to_owned(),
333 to_session: mail.to_session.clone(),
334 kind: mail.kind,
335 in_reply_to: mail.in_reply_to.clone(),
336 questions: mail.questions.clone(),
337 text: mail.text.clone(),
338 created_at: created.to_rfc3339(),
339 expires_at: expires.to_rfc3339(),
340 };
341 let bytes = serde_json::to_vec(&message)?;
342 if bytes.len() > MAX_MAIL_BYTES {
343 return Err(SessionStoreError::invalid_mail(format!("envelope exceeds {MAX_MAIL_BYTES} bytes")));
344 }
345 let path = inbox.join(format!("{message_id}.json"));
346 let claimed = vtcode_commons::VtCodePaths::write_private_file_atomic_if_absent(&path, &bytes)
347 .map_err(|error| SessionStoreError::io(path.clone(), std::io::Error::other(error)))?;
348 if claimed {
349 return Ok(MailReceipt { message_id });
350 }
351 }
352 Err(SessionStoreError::invalid_mail("message id collision; retry the send"))
353}
354
355fn read_message_file(path: &Path) -> Result<MailMessage, SessionStoreError> {
357 use std::io::Read as _;
360 let file = std::fs::File::open(path).map_err(|error| SessionStoreError::io(path.to_path_buf(), error))?;
361 let mut bytes = Vec::new();
362 file.take((MAX_MAIL_BYTES + 1) as u64)
363 .read_to_end(&mut bytes)
364 .map_err(|error| SessionStoreError::io(path.to_path_buf(), error))?;
365 if bytes.len() > MAX_MAIL_BYTES {
366 return Err(SessionStoreError::invalid_mail(format!("envelope exceeds {MAX_MAIL_BYTES} bytes")));
367 }
368 let message: MailMessage = serde_json::from_slice(&bytes).map_err(SessionStoreError::from)?;
369 if message.schema_version != MAILBOX_SCHEMA_VERSION {
370 return Err(SessionStoreError::invalid_mail(format!("unsupported mailbox schema {}", message.schema_version)));
371 }
372 let stem_matches = path
373 .file_stem()
374 .and_then(|stem| stem.to_str())
375 .is_some_and(|stem| stem == message.message_id);
376 if !stem_matches {
377 return Err(SessionStoreError::invalid_mail("message id does not match filename"));
378 }
379 Ok(message)
380}
381
382fn message_is_expired(message: &MailMessage, now: &chrono::DateTime<Utc>) -> bool {
383 let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&message.expires_at) else {
384 return true;
385 };
386 expires <= *now
387}
388
389fn move_to_read(inbox: &Path, file_name: &str) -> Result<(), SessionStoreError> {
390 let read_dir = read_dir_of(inbox);
391 crate::ensure_private_directory(&read_dir)?;
392 let from = inbox.join(file_name);
393 let to = read_dir.join(file_name);
394 if to.exists() {
399 std::fs::remove_file(&from).map_err(|error| SessionStoreError::io(from.clone(), error))?;
400 return Ok(());
401 }
402 std::fs::rename(&from, &to).map_err(|error| SessionStoreError::io(from, error))?;
403 Ok(())
404}
405
406fn reap_read_dir(inbox: &Path, now: &chrono::DateTime<Utc>) -> Result<(), SessionStoreError> {
409 let read_dir = read_dir_of(inbox);
410 let entries = match std::fs::read_dir(&read_dir) {
411 Ok(entries) => entries,
412 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
413 Err(error) => return Err(SessionStoreError::io(read_dir, error)),
414 };
415 for entry in entries.flatten() {
416 let path = entry.path();
417 if !path.is_file() {
418 continue;
419 }
420 let modified = path.metadata().and_then(|meta| meta.modified()).ok().and_then(|modified| {
421 chrono::DateTime::<Utc>::from_timestamp(
422 modified.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64,
423 0,
424 )
425 });
426 let stale = modified.is_some_and(|mtime| (*now - mtime).num_seconds() > READ_RETENTION_SECS);
427 if stale
428 && let Err(error) = std::fs::remove_file(&path)
429 && error.kind() != std::io::ErrorKind::NotFound
430 {
431 return Err(SessionStoreError::io(path, error));
432 }
433 }
434 Ok(())
435}
436
437pub fn drain_inbox(workspace: &Path, session: &str) -> Result<InboxDrain, SessionStoreError> {
443 validate_session_id(session)?;
444 let inbox = inbox_dir(workspace, session);
445 if !inbox.is_dir() {
446 return Ok(InboxDrain::default());
447 }
448 let now = Utc::now();
449 let mut drain = InboxDrain::default();
450 let mut names: Vec<String> = Vec::new();
451 for entry in std::fs::read_dir(&inbox).map_err(|error| SessionStoreError::io(inbox.clone(), error))? {
452 let entry = entry.map_err(|error| SessionStoreError::io(inbox.clone(), std::io::Error::other(error)))?;
453 let path = entry.path();
454 if entry.file_type().is_ok_and(|kind| kind.is_symlink()) {
458 if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
459 move_to_read(&inbox, name)?;
460 drain.quarantined += 1;
461 }
462 continue;
463 }
464 if !path.is_file() {
465 continue;
466 }
467 if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
468 if name.ends_with(".json") {
469 names.push(name.to_owned());
470 }
471 }
472 }
473 names.sort();
474 for name in names {
475 let path = inbox.join(&name);
476 let message = match read_message_file(&path) {
477 Ok(message) => message,
478 Err(_) => {
479 move_to_read(&inbox, &name)?;
480 drain.quarantined += 1;
481 continue;
482 }
483 };
484 if message_is_expired(&message, &now) {
485 move_to_read(&inbox, &name)?;
486 drain.expired_ids.push(message.message_id);
487 } else {
488 drain.live.push(message);
489 }
490 }
491 drain
492 .live
493 .sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.message_id.cmp(&b.message_id)));
494 reap_read_dir(&inbox, &now)?;
495 Ok(drain)
496}
497
498pub fn ack_mail(workspace: &Path, session: &str, message_id: &str) -> Result<bool, SessionStoreError> {
501 validate_session_id(session)?;
502 if uuid::Uuid::parse_str(message_id).is_err() {
503 return Err(SessionStoreError::invalid_mail("invalid message id"));
504 }
505 let inbox = inbox_dir(workspace, session);
506 let path = inbox.join(format!("{message_id}.json"));
507 if !path.is_file() {
508 return Ok(false);
509 }
510 let name = format!("{message_id}.json");
511 move_to_read(&inbox, &name)?;
512 Ok(true)
513}
514
515pub fn read_mail(workspace: &Path, session: &str, message_id: &str) -> Result<Option<MailMessage>, SessionStoreError> {
517 validate_session_id(session)?;
518 if uuid::Uuid::parse_str(message_id).is_err() {
519 return Err(SessionStoreError::invalid_mail("invalid message id"));
520 }
521 let path = inbox_dir(workspace, session).join(format!("{message_id}.json"));
522 if !path.is_file() {
523 return Ok(None);
524 }
525 read_message_file(&path).map(Some)
526}
527
528pub fn find_mail(workspace: &Path, session: &str, message_id: &str) -> Result<Option<MailMessage>, SessionStoreError> {
533 if let Some(message) = read_mail(workspace, session, message_id)? {
534 return Ok(Some(message));
535 }
536 let path = read_dir_of(&inbox_dir(workspace, session)).join(format!("{message_id}.json"));
537 if !path.is_file() {
538 return Ok(None);
539 }
540 read_message_file(&path).map(Some)
541}