1mod archive;
2mod cases;
3pub mod contacts;
4mod disposition_views;
5mod doctor;
6mod drafts;
7mod messages;
8mod purge;
9mod push_state;
10mod refs;
11mod remote_sync;
12mod render;
13#[cfg(test)]
14mod tests;
15mod transactions;
16mod triage;
17mod util;
18#[cfg(feature = "ui")]
19mod view_model;
20
21use cases::*;
22pub use contacts::ContactExtractSource;
23use disposition_views::*;
24use drafts::*;
25use messages::*;
26use refs::CaseIndex;
27use remote_sync::*;
28use render::*;
29use util::*;
30
31pub use render::{
32 clean_body_text, render_message_section, render_message_section_with_config,
33 render_message_section_with_options,
34};
35pub(crate) use triage::render_triage_view;
36
37use crate::config::{
38 ArchiveMessageIndexField, MailConfig, ReasonMode, SpecialUseKind, TemplateLanguage,
39};
40use crate::error::{AppError, Result};
41use crate::frontmatter::{CaseFrontmatter, DraftFrontmatter, TriageFrontmatter};
42use crate::markdown::{read_doc, render_frontmatter};
43use crate::templates::{language_template_path, MarkdownTemplateRenderer, TemplateKey};
44use crate::types::RemoteSyncState;
45use crate::types::{
46 ArchiveMessageItem, ArchiveMessages, AttachmentRef, CaseMessages, MailDirection,
47 MessageCollection, MessageCollectionItem, MessageContact, MessageFile, MessageStatus,
48 OutboundAction, PushItem, PushLocation, RemoteLocation, RemoteState, WorkspacePendingPush,
49 WorkspacePushState, WorkspaceState, ARCHIVE_NOTIFICATION_SCHEMA_NAME, CASE_SCHEMA_NAME,
50 MESSAGE_COLLECTION_SCHEMA_VERSION,
51};
52use crate::util::{canonical_flags, sha256_fingerprint, write_json_pretty, write_string_atomic};
53use chrono::{DateTime, Datelike, Duration, FixedOffset, SecondsFormat, Timelike, Utc};
54use sanitize_filename::{sanitize_with_options, Options as SanitizeFilenameOptions};
55use serde::{Deserialize, Serialize};
56use serde_json::Map;
57use serde_json::{json, Value};
58use std::collections::{BTreeMap, BTreeSet};
59use std::fs;
60use std::io::{BufRead, BufReader, Write as _};
61use std::path::{Path, PathBuf};
62use std::time::Instant;
63
64const AFMAIL_GITIGNORE_BEGIN: &str = "# BEGIN afmail managed";
65const AFMAIL_GITIGNORE_END: &str = "# END afmail managed";
66const AFMAIL_AGENT_GITIGNORE_HEADER: &str =
67 "# Agent-First Mail workspace skill installed by afmail init.";
68const AFMAIL_WORKSPACE_GITIGNORE_BODY: &str = r#"# Local mail evidence and runtime state.
69.afmail/push/
70.afmail/logs/
71.afmail/transactions/
72.afmail/workspace.lock
73.afmail/workspace.progress.json
74
75# Generated caches and read views; rebuild with afmail render refresh.
76messages/*.json
77triage/*.md
78spam/*.md
79trash/*.md
80deleted/*.md
81cases/*/*/case.md
82cases/*/*/views/**/*.md
83archive/cases/*/case.md
84archive/cases/*/views/**/*.md
85archive/notifications/*/archive.md
86archive/notifications/*/views/**/*.md
87"#;
88const AFMAIL_RESERVED_INIT_PATHS: &[&str] = &[
89 "triage",
90 "cases",
91 "archive",
92 "messages",
93 "templates",
94 "spam",
95 "trash",
96 "deleted",
97];
98const AFMAIL_AGENTS_BEGIN: &str = "<!-- BEGIN afmail managed -->";
99const AFMAIL_AGENTS_END: &str = "<!-- END afmail managed -->";
100
101pub(crate) struct DraftChange<'a> {
102 pub(crate) subject: Option<&'a str>,
103 pub(crate) to: &'a [String],
104 pub(crate) cc: &'a [String],
105 pub(crate) clear_cc: bool,
106 pub(crate) identity: Option<&'a str>,
107 pub(crate) body: Option<&'a str>,
108 pub(crate) body_file: Option<&'a str>,
109}
110
111pub(crate) struct NewDraft<'a> {
112 pub(crate) to: &'a [String],
113 pub(crate) cc: &'a [String],
114 pub(crate) subject: &'a str,
115 pub(crate) identity: Option<&'a str>,
116 pub(crate) body: Option<&'a str>,
117 pub(crate) body_file: Option<&'a str>,
118}
119
120pub fn now_rfc3339() -> String {
121 Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
122}
123
124fn resolve_init_target(agent_root: &Path, path: Option<&str>) -> Result<(PathBuf, String)> {
125 let raw = path.unwrap_or(".");
126 let rel = Path::new(raw);
127 if rel.is_absolute() {
128 return Err(AppError::new(
129 "invalid_request",
130 "afmail init PATH must be relative to the current directory",
131 ));
132 }
133 let mut normalized = PathBuf::new();
134 for component in rel.components() {
135 match component {
136 std::path::Component::CurDir => {}
137 std::path::Component::Normal(part) => normalized.push(part),
138 std::path::Component::ParentDir => {
139 return Err(AppError::new(
140 "invalid_request",
141 "afmail init PATH must not contain ..",
142 ))
143 }
144 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
145 return Err(AppError::new(
146 "invalid_request",
147 "afmail init PATH must stay under the current directory",
148 ))
149 }
150 }
151 }
152 let workspace_ref = if normalized.as_os_str().is_empty() {
153 ".".to_string()
154 } else {
155 let mut value = path_to_string(&normalized);
156 if !value.ends_with('/') {
157 value.push('/');
158 }
159 value
160 };
161 let target = if normalized.as_os_str().is_empty() {
162 agent_root.to_path_buf()
163 } else {
164 agent_root.join(normalized)
165 };
166 Ok((target, workspace_ref))
167}
168
169pub(crate) fn ensure_agent_gitignore_ignores_skill_dirs(
170 agent_root: &Path,
171 skill_dirs: &[PathBuf],
172) -> Result<()> {
173 let include_workspace_body = agent_gitignore_should_include_workspace_body(agent_root)?;
174 let _ = ensure_agent_gitignore_ignores_skill_dirs_with_workspace_body(
175 agent_root,
176 skill_dirs,
177 include_workspace_body,
178 )?;
179 Ok(())
180}
181
182fn ensure_agent_gitignore_ignores_skill_dirs_with_workspace_body(
183 agent_root: &Path,
184 skill_dirs: &[PathBuf],
185 include_workspace_body: bool,
186) -> Result<ManagedBlockChange> {
187 let gitignore_path = agent_root.join(".gitignore");
188 let existing_body = existing_gitignore_managed_body(&gitignore_path)?;
189 let mut patterns = BTreeSet::new();
190 if let Some(body) = existing_body.as_deref() {
191 patterns.extend(existing_agent_skill_gitignore_patterns(body));
192 }
193 patterns.extend(
194 skill_dirs
195 .iter()
196 .filter_map(|dir| agent_skill_dir_gitignore_pattern(agent_root, dir)),
197 );
198
199 if patterns.is_empty() && !include_workspace_body {
200 return Ok(ManagedBlockChange {
201 created: false,
202 updated: false,
203 });
204 }
205
206 let mut block_body = if patterns.is_empty() {
207 String::new()
208 } else {
209 render_agent_gitignore_body(&patterns)
210 };
211 if include_workspace_body {
212 if !block_body.is_empty() {
213 block_body.push('\n');
214 }
215 block_body.push_str(trim_surrounding_line_endings(
216 AFMAIL_WORKSPACE_GITIGNORE_BODY,
217 ));
218 }
219
220 ensure_managed_block_file(
221 &gitignore_path,
222 AFMAIL_GITIGNORE_BEGIN,
223 AFMAIL_GITIGNORE_END,
224 "",
225 &block_body,
226 )
227}
228
229fn agent_gitignore_should_include_workspace_body(agent_root: &Path) -> Result<bool> {
230 if agent_root.join(".afmail").is_dir() {
231 return Ok(true);
232 }
233 Ok(
234 existing_gitignore_managed_body(&agent_root.join(".gitignore"))?
235 .is_some_and(|body| body.contains(".afmail/push/") || body.contains("messages/*.json")),
236 )
237}
238
239fn existing_gitignore_managed_body(path: &Path) -> Result<Option<String>> {
240 if !path.exists() {
241 return Ok(None);
242 }
243 let existing = read_to_string(path, "read managed block file")?;
244 let Some(begin_pos) = existing.find(AFMAIL_GITIGNORE_BEGIN) else {
245 return Ok(None);
246 };
247 let after_begin = begin_pos + AFMAIL_GITIGNORE_BEGIN.len();
248 let end_rel = existing[after_begin..]
249 .find(AFMAIL_GITIGNORE_END)
250 .ok_or_else(|| {
251 AppError::new(
252 "managed_block_invalid",
253 format!(
254 "{} has an afmail managed BEGIN marker without a matching END marker",
255 path_to_string(path)
256 ),
257 )
258 })?;
259 let end_pos = after_begin + end_rel;
260 Ok(Some(
261 trim_surrounding_line_endings(&existing[after_begin..end_pos]).to_string(),
262 ))
263}
264
265fn existing_agent_skill_gitignore_patterns(block_body: &str) -> BTreeSet<String> {
266 block_body
267 .lines()
268 .filter_map(|line| {
269 let line = line.trim();
270 if line.is_empty()
271 || line.starts_with('#')
272 || line.starts_with(".agents/")
273 || !line.ends_with("/agent-first-mail/")
274 || !line.contains("/skills/agent-first-mail/")
275 {
276 return None;
277 }
278 Some(line.to_string())
279 })
280 .collect()
281}
282
283fn agent_skill_dir_gitignore_pattern(agent_root: &Path, skill_dir: &Path) -> Option<String> {
284 let absolute = if skill_dir.is_absolute() {
285 skill_dir.to_path_buf()
286 } else {
287 agent_root.join(skill_dir)
288 };
289 let rel = absolute.strip_prefix(agent_root).ok()?;
290 if rel.components().any(|component| {
291 matches!(
292 component,
293 std::path::Component::ParentDir
294 | std::path::Component::RootDir
295 | std::path::Component::Prefix(_)
296 )
297 }) {
298 return None;
299 }
300 let mut pattern = path_to_string(rel);
301 if pattern.is_empty() {
302 return None;
303 }
304 if !pattern.ends_with('/') {
305 pattern.push('/');
306 }
307 Some(pattern)
308}
309
310fn render_agent_gitignore_body(patterns: &BTreeSet<String>) -> String {
311 let mut out = String::new();
312 out.push_str(AFMAIL_AGENT_GITIGNORE_HEADER);
313 out.push('\n');
314 for pattern in patterns {
315 out.push_str(pattern);
316 out.push('\n');
317 }
318 out
319}
320
321fn prepare_init_target(root: &Path) -> Result<()> {
322 if root.exists() && !root.is_dir() {
323 return Err(AppError::new(
324 "invalid_request",
325 format!(
326 "afmail init target is not a directory: {}",
327 path_to_string(root)
328 ),
329 ));
330 }
331 if !root.exists() {
332 create_dir_all(root)?;
333 }
334 let afmail_dir = root.join(".afmail");
335 if afmail_dir.exists() && !afmail_dir.is_dir() {
336 return Err(AppError::new(
337 "invalid_request",
338 format!(
339 "afmail init target has a non-directory .afmail path: {}",
340 path_to_string(&afmail_dir)
341 ),
342 ));
343 }
344 if afmail_dir.is_dir() {
345 return Ok(());
346 }
347 let conflicts = reserved_init_conflicts(root);
348 if conflicts.is_empty() {
349 return Ok(());
350 }
351 Err(AppError::new(
352 "invalid_request",
353 format!(
354 "afmail init target already contains afmail-reserved paths: {}",
355 conflicts.join(", ")
356 ),
357 )
358 .with_hint("Choose a different empty mail workspace directory, or run init in an existing directory that already contains .afmail.")
359 .with_details(json!({ "reserved_paths": conflicts })))
360}
361
362fn reserved_init_conflicts(root: &Path) -> Vec<String> {
363 AFMAIL_RESERVED_INIT_PATHS
364 .iter()
365 .filter_map(|name| {
366 if root.join(name).exists() {
367 Some(format!("{name}/"))
368 } else {
369 None
370 }
371 })
372 .collect()
373}
374
375fn agent_workspace_paths(agent_root: &Path, workspace_ref: &str) -> Result<Vec<String>> {
376 let mut paths = read_agent_workspace_paths(agent_root)?;
377 if !paths.iter().any(|path| path == workspace_ref) {
378 paths.push(workspace_ref.to_string());
379 }
380 paths.sort();
381 Ok(paths)
382}
383
384fn read_agent_workspace_paths(agent_root: &Path) -> Result<Vec<String>> {
385 let path = agent_root.join("AGENTS.md");
386 if !path.exists() {
387 return Ok(Vec::new());
388 }
389 let text = read_to_string(&path, "read agent instructions")?;
390 let Some(begin_pos) = text.find(AFMAIL_AGENTS_BEGIN) else {
391 return Ok(Vec::new());
392 };
393 let after_begin = begin_pos + AFMAIL_AGENTS_BEGIN.len();
394 let Some(end_rel) = text[after_begin..].find(AFMAIL_AGENTS_END) else {
395 return Ok(Vec::new());
396 };
397 let body = &text[after_begin..after_begin + end_rel];
398 let mut out = Vec::new();
399 for line in body.lines() {
400 let trimmed = line.trim();
401 let Some(rest) = trimmed.strip_prefix("- `") else {
402 continue;
403 };
404 let Some(end) = rest.find('`') else {
405 continue;
406 };
407 let value = &rest[..end];
408 if !value.is_empty() && !out.iter().any(|existing| existing == value) {
409 out.push(value.to_string());
410 }
411 }
412 Ok(out)
413}
414
415#[derive(Clone, Debug)]
416pub struct Workspace {
417 root: PathBuf,
418}
419
420#[derive(Clone, Debug, Default)]
421struct CaseViewRefresh {
422 case_index_count: usize,
423 case_message_count: usize,
424}
425
426#[derive(Clone, Debug, Default)]
427struct ArchiveMessageViewRefresh {
428 archive_message_index_count: usize,
429 archive_message_count: usize,
430}
431
432#[derive(Clone, Debug, Default)]
433struct RenderRefreshTotals {
434 active_case_count: usize,
435 archived_case_count: usize,
436 archive_message_category_count: usize,
437 case_index_count: usize,
438 case_message_count: usize,
439 archive_message_index_count: usize,
440 archive_message_count: usize,
441}
442
443impl RenderRefreshTotals {
444 fn add_case(&mut self, refresh: CaseViewRefresh) {
445 self.case_index_count += refresh.case_index_count;
446 self.case_message_count += refresh.case_message_count;
447 }
448
449 fn add_archive_message(&mut self, refresh: ArchiveMessageViewRefresh) {
450 self.archive_message_index_count += refresh.archive_message_index_count;
451 self.archive_message_count += refresh.archive_message_count;
452 }
453}
454
455impl Workspace {
456 pub fn at(path: impl Into<PathBuf>) -> Self {
457 Self { root: path.into() }
458 }
459
460 pub fn discover(start: impl AsRef<Path>) -> Result<Self> {
461 let mut current = start.as_ref().to_path_buf();
462 loop {
463 if current.join(".afmail").is_dir() {
464 return Ok(Self::at(current));
465 }
466 if !current.pop() {
467 return Err(AppError::new(
468 "workspace_not_found",
469 "no .afmail directory found in current directory or parents",
470 ));
471 }
472 }
473 }
474
475 pub fn root(&self) -> &Path {
476 &self.root
477 }
478
479 pub fn init_command(agent_root: &Path, path: Option<&str>) -> Result<Value> {
480 let (workspace_root, workspace_ref) = resolve_init_target(agent_root, path)?;
481 Workspace::at(workspace_root).init_with_agent_root(agent_root, &workspace_ref)
482 }
483
484 pub fn init(&self) -> Result<Value> {
485 self.init_with_agent_root(&self.root, ".")
486 }
487
488 fn init_with_agent_root(&self, agent_root: &Path, workspace_ref: &str) -> Result<Value> {
489 prepare_init_target(&self.root)?;
490 create_dir_all(&self.root.join(".afmail/messages"))?;
491 create_dir_all(&self.root.join(".afmail/push"))?;
492 create_dir_all(&self.root.join(".afmail/logs"))?;
493 create_dir_all(&self.root.join(".afmail/transactions"))?;
494 create_dir_all(&self.root.join("templates"))?;
495 create_dir_all(&self.root.join("messages"))?;
496 create_dir_all(&self.root.join("triage"))?;
497 create_dir_all(&self.root.join("cases"))?;
498 create_dir_all(&self.root.join("archive/cases"))?;
499 create_dir_all(&self.root.join("archive/notifications"))?;
500 write_json_if_missing(
501 &self.root.join(".afmail/config.json"),
502 &serde_json::to_value(crate::config::MailConfig::default())
503 .map_err(|e| AppError::json("serialize config", &e))?,
504 )?;
505 write_string_if_missing(&self.root.join(".afmail/logs/events.jsonl"), "")?;
506 let config = MailConfig::load(&self.root)?;
507 let language = config.template_language();
508 let language_bcp47 = config.resolved_language_bcp47().to_string();
509 let timezone_utc_offset = config.resolved_timezone_utc_offset();
510 let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
511 let workspace_paths = agent_workspace_paths(agent_root, workspace_ref)?;
512 let template_context = json!({
513 "language": language_bcp47,
514 "workspaces": workspace_paths.clone(),
515 });
516
517 let workspace_skills_dir = agent_root.join(".codex/skills");
518 let workspace_skill =
519 crate::skill_admin::install_codex_workspace_skill(&workspace_skills_dir)?;
520 let workspace_skill_path = workspace_skill.skill_path.clone();
521
522 let workspace_gitignore_path = self.root.join(".gitignore");
523 let agent_gitignore_path = agent_root.join(".gitignore");
524 let same_root = self.root == agent_root;
525 let workspace_gitignore_change = if same_root {
526 ensure_agent_gitignore_ignores_skill_dirs_with_workspace_body(
527 agent_root,
528 std::slice::from_ref(&workspace_skill.skill_dir),
529 true,
530 )?
531 } else {
532 ensure_managed_block_file(
533 &workspace_gitignore_path,
534 AFMAIL_GITIGNORE_BEGIN,
535 AFMAIL_GITIGNORE_END,
536 "",
537 AFMAIL_WORKSPACE_GITIGNORE_BODY,
538 )?
539 };
540 let agent_gitignore_change = if same_root {
541 workspace_gitignore_change
542 } else {
543 ensure_agent_gitignore_ignores_skill_dirs_with_workspace_body(
544 agent_root,
545 std::slice::from_ref(&workspace_skill.skill_dir),
546 false,
547 )?
548 };
549
550 let agent_skill_path = agent_root.join("AGENTS.md");
551 let rendered_agents = renderer.render(TemplateKey::WorkspaceAgents, &template_context)?;
552 let (agent_skill_prefix, agent_skill_body) = managed_block_template_parts(
553 &rendered_agents,
554 AFMAIL_AGENTS_BEGIN,
555 AFMAIL_AGENTS_END,
556 &agent_skill_path,
557 )?;
558 let agent_skill_change = ensure_managed_block_file(
559 &agent_skill_path,
560 AFMAIL_AGENTS_BEGIN,
561 AFMAIL_AGENTS_END,
562 &agent_skill_prefix,
563 &agent_skill_body,
564 )?;
565
566 let do_not_edit_path = self.root.join(".afmail/DO_NOT_EDIT.txt");
567 let do_not_edit_created = !do_not_edit_path.exists();
568 let do_not_edit_rendered =
569 renderer.render(TemplateKey::WorkspaceDoNotEdit, &template_context)?;
570 let do_not_edit_updated = fs::read_to_string(&do_not_edit_path)
571 .map(|existing| existing != do_not_edit_rendered)
572 .unwrap_or(true);
573 if do_not_edit_updated {
574 write_string(&do_not_edit_path, &do_not_edit_rendered)?;
575 }
576
577 Ok(json!({
578 "code": "workspace_initialized",
579 "workspace_path": path_to_string(&self.root),
580 "workspace_ref": workspace_ref,
581 "agent_root_path": path_to_string(agent_root),
582 "created_rfc3339": now_rfc3339(),
583 "gitignore_path": rel_path(&self.root, &workspace_gitignore_path),
584 "gitignore_created": workspace_gitignore_change.created,
585 "gitignore_updated": workspace_gitignore_change.updated,
586 "workspace_gitignore_path": rel_path(&self.root, &workspace_gitignore_path),
587 "workspace_gitignore_created": workspace_gitignore_change.created,
588 "workspace_gitignore_updated": workspace_gitignore_change.updated,
589 "agent_gitignore_path": rel_path(agent_root, &agent_gitignore_path),
590 "agent_gitignore_created": agent_gitignore_change.created,
591 "agent_gitignore_updated": agent_gitignore_change.updated,
592 "agent_skill_path": rel_path(agent_root, &agent_skill_path),
593 "agent_skill_created": agent_skill_change.created,
594 "agent_skill_updated": agent_skill_change.updated,
595 "workspace_skill_path": rel_path(agent_root, &workspace_skill_path),
596 "workspace_skill_installed": true,
597 "do_not_edit_path": ".afmail/DO_NOT_EDIT.txt",
598 "do_not_edit_created": do_not_edit_created,
599 "do_not_edit_updated": do_not_edit_updated,
600 "workspace_paths": workspace_paths,
601 "language_bcp47": config.workspace.language_bcp47.clone(),
602 "resolved_language_bcp47": config.resolved_language_bcp47(),
603 "timezone_utc_offset": timezone_utc_offset,
604 "next_steps": [
605 format!("Run afmail commands with workdir set to {}.", workspace_ref),
606 "Adjust workspace.language_bcp47 or workspace.timezone_utc_offset with afmail config set if needed.".to_string()
607 ]
608 }))
609 }
610
611 pub fn status(&self) -> Result<Value> {
612 self.require_workspace()?;
613 let cases = self.active_case_items()?;
614 let mut cases_by_group: BTreeMap<String, usize> = BTreeMap::new();
615 for case in &cases {
616 let group = case
617 .get("group")
618 .and_then(Value::as_str)
619 .unwrap_or_default()
620 .to_string();
621 *cases_by_group.entry(group).or_insert(0) += 1;
622 }
623 let mut message_status: BTreeMap<String, usize> = BTreeMap::new();
624 let message_paths = message_json_paths(&self.root)?;
625 let mut remote_missing_count = 0usize;
626 let mut remote_effect_pending_message_count = 0usize;
627 for path in &message_paths {
628 let message = read_message(path)?;
629 *message_status
630 .entry(message.workspace.status.clone())
631 .or_insert(0) += 1;
632 if message_remote_missing(&message) {
633 remote_missing_count += 1;
634 }
635 if message_remote_effect_pending(&message) {
636 remote_effect_pending_message_count += 1;
637 }
638 }
639 let push_status = serde_json::to_value(crate::push_queue::push_status(&self.root)?)
640 .map_err(|e| AppError::json("serialize push status", &e))?;
641 let archive_messages = self.archive_message_category_items()?;
642 let archived_cases = self.archive_case_items()?;
643 Ok(json!({
644 "code": "status",
645 "triage_count": count_files_with_ext(&self.root.join("triage"), "md")?,
646 "case_count": cases.len(),
647 "cases_by_group": cases_by_group,
648 "archive_message_category_count": archive_messages.len(),
649 "archived_case_count": archived_cases.len(),
650 "message_count": message_paths.len(),
651 "message_status": message_status,
652 "remote_missing_count": remote_missing_count,
653 "remote_effect_pending_message_count": remote_effect_pending_message_count,
654 "push_count": count_files_with_ext(&self.root.join(".afmail/push"), "json")?,
655 "push_status": push_status
656 }))
657 }
658
659 pub fn config_show(&self) -> Result<Value> {
660 self.require_workspace()?;
661 let config = crate::config::MailConfig::load(&self.root)?;
662 let value =
663 serde_json::to_value(config).map_err(|e| AppError::json("serialize config", &e))?;
664 Ok(json!({
665 "code": "config",
666 "config": value
667 }))
668 }
669
670 pub fn config_get(&self, key: &str) -> Result<Value> {
671 self.require_workspace()?;
672 let config = crate::config::MailConfig::load(&self.root)?;
673 let value = config_value_for_output(key, config.get_key(key)?);
674 Ok(json!({
675 "code": "config_value",
676 "key": key,
677 "value": value
678 }))
679 }
680
681 pub fn config_set(&self, key: &str, values: &[String]) -> Result<Value> {
682 self.require_workspace()?;
683 let mut config = crate::config::MailConfig::load(&self.root)?;
684 config.set_key(key, values)?;
685 config.write(&self.root)?;
686 let value = config_value_for_output(key, config.get_key(key)?);
687 Ok(json!({
688 "code": "config_updated",
689 "key": key,
690 "value": value
691 }))
692 }
693
694 pub fn remote_test(&self) -> Result<Value> {
695 self.require_workspace()?;
696 let config = crate::config::MailConfig::load(&self.root)?.require_imap()?;
697 crate::imap_pull::remote_test(&config)
698 }
699
700 pub fn remote_folders(&self) -> Result<Value> {
701 self.require_workspace()?;
702 let config = crate::config::MailConfig::load(&self.root)?;
703 let imap = config.require_imap()?;
704 crate::imap_pull::remote_folders(&config, &imap)
705 }
706
707 pub fn push_with_progress(
708 &self,
709 confirmed: bool,
710 progress: Option<&mut crate::progress::ProgressCallback<'_>>,
711 ) -> Result<Value> {
712 self.require_workspace()?;
713 crate::push_queue::push_with_progress(&self.root, confirmed, progress)
714 }
715
716 pub fn push_list(&self) -> Result<Value> {
717 self.require_workspace()?;
718 crate::push_queue::list(&self.root)
719 }
720
721 pub fn render_refresh(&self) -> Result<Value> {
722 self.require_workspace()?;
723 create_dir_all(&self.root.join("archive/cases"))?;
724 create_dir_all(&self.root.join("archive/notifications"))?;
725 let cache = self.rebuild_message_cache_from_eml()?;
726 let triage = self.refresh_triage_views()?;
727 let dispositions = self.refresh_disposition_views()?;
728 let config = MailConfig::load(&self.root)?;
729 let mut renderer = MarkdownTemplateRenderer::new(&self.root, config.template_language());
730 let mut totals = RenderRefreshTotals::default();
731
732 for (_, case_path) in self.case_entries()? {
733 let refresh =
734 self.refresh_case_message_views_with_renderer(&case_path, &mut renderer)?;
735 totals.active_case_count += 1;
736 totals.add_case(refresh);
737 }
738 for entry in self.archived_case_entries()? {
739 let refresh =
740 self.refresh_case_message_views_with_renderer(&entry.path, &mut renderer)?;
741 totals.archived_case_count += 1;
742 totals.add_case(refresh);
743 }
744 for archive_uid in self.archive_message_category_ids()? {
745 let refresh = self.refresh_archive_message_category_with_renderer(
746 &archive_uid,
747 &mut renderer,
748 false,
749 )?;
750 totals.archive_message_category_count += 1;
751 totals.add_archive_message(refresh);
752 }
753
754 Ok(json!({
755 "code": "render_refreshed",
756 "active_case_count": totals.active_case_count,
757 "archived_case_count": totals.archived_case_count,
758 "archive_message_category_count": totals.archive_message_category_count,
759 "message_cache_rebuilt_count": cache.rebuilt_count,
760 "text_cache_removed_count": cache.removed_text_cache_count,
761 "triage_count": triage.get("triage_count").and_then(Value::as_u64).unwrap_or(0),
762 "triage_written_count": triage.get("triage_written_count").and_then(Value::as_u64).unwrap_or(0),
763 "stale_triage_removed_count": triage.get("stale_triage_removed_count").and_then(Value::as_u64).unwrap_or(0),
764 "spam_count": dispositions.get("spam_count").and_then(Value::as_u64).unwrap_or(0),
765 "spam_written_count": dispositions.get("spam_written_count").and_then(Value::as_u64).unwrap_or(0),
766 "stale_spam_removed_count": dispositions.get("stale_spam_removed_count").and_then(Value::as_u64).unwrap_or(0),
767 "trash_count": dispositions.get("trash_count").and_then(Value::as_u64).unwrap_or(0),
768 "trash_written_count": dispositions.get("trash_written_count").and_then(Value::as_u64).unwrap_or(0),
769 "stale_trash_removed_count": dispositions.get("stale_trash_removed_count").and_then(Value::as_u64).unwrap_or(0),
770 "deleted_count": dispositions.get("deleted_count").and_then(Value::as_u64).unwrap_or(0),
771 "deleted_written_count": dispositions.get("deleted_written_count").and_then(Value::as_u64).unwrap_or(0),
772 "stale_deleted_removed_count": dispositions.get("stale_deleted_removed_count").and_then(Value::as_u64).unwrap_or(0),
773 "generated": {
774 "triage/view.md.j2": triage.get("triage_written_count").and_then(Value::as_u64).unwrap_or(0),
775 "status/index.md.j2": dispositions.get("index_written_count").and_then(Value::as_u64).unwrap_or(0),
776 "status/message.md.j2": dispositions.get("message_written_count").and_then(Value::as_u64).unwrap_or(0),
777 "case/case.md.j2": totals.case_index_count,
778 "case/message.md.j2": totals.case_message_count,
779 "archive-message/archive.md.j2": totals.archive_message_index_count,
780 "archive-message/message.md.j2": totals.archive_message_count,
781 },
782 "template_sources": renderer.stats().to_value(),
783 }))
784 }
785
786 pub fn render_templates(&self, force: bool) -> Result<Value> {
787 self.require_workspace()?;
788 let templates_dir = self.root.join("templates");
789 let existed_before = templates_dir.exists();
790 if existed_before && !templates_dir.is_dir() {
791 return Err(AppError::new(
792 "template_dir_invalid",
793 "templates exists but is not a directory",
794 ));
795 }
796
797 create_dir_all(&templates_dir)?;
798
799 let mut items = Vec::new();
800 let mut exported_count = 0usize;
801 let mut overwritten_count = 0usize;
802 let mut kept_count = 0usize;
803 let builtin_count = 0usize;
804 let mut workspace_count = 0usize;
805
806 for language in TemplateLanguage::ALL {
807 for key in TemplateKey::ALL {
808 let path = templates_dir.join(language_template_path(language, key));
809 let existed = path.exists();
810 let (source, action) = if force || !existed {
811 if let Some(parent) = path.parent() {
812 create_dir_all(parent)?;
813 }
814 write_string(&path, key.builtin_text(language))?;
815 workspace_count += 1;
816 if existed {
817 overwritten_count += 1;
818 ("workspace", "overwritten")
819 } else {
820 exported_count += 1;
821 ("workspace", "exported")
822 }
823 } else {
824 workspace_count += 1;
825 kept_count += 1;
826 ("workspace", "kept")
827 };
828 items.push(json!({
829 "language": language.as_str(),
830 "template_key": key.as_str(),
831 "path": rel_path(&self.root, &path),
832 "source": source,
833 "action": action,
834 }));
835 }
836 }
837
838 Ok(json!({
839 "code": "render_templates",
840 "template_dir": "templates",
841 "template_dir_created": !existed_before,
842 "force": force,
843 "exported_count": exported_count,
844 "overwritten_count": overwritten_count,
845 "kept_count": kept_count,
846 "builtin_count": builtin_count,
847 "workspace_count": workspace_count,
848 "items": items,
849 }))
850 }
851
852 pub fn log_list(&self, limit: usize) -> Result<Value> {
853 let events = self.read_audit_events()?;
854 Ok(json!({
855 "code": "log_list",
856 "count": events.len().min(limit),
857 "events": take_last(events, limit)
858 }))
859 }
860
861 pub fn log_tail(&self) -> Result<Value> {
862 self.log_list(20).map(|mut value| {
863 if let Some(obj) = value.as_object_mut() {
864 obj.insert("code".to_string(), json!("log_tail"));
865 }
866 value
867 })
868 }
869
870 pub fn log_message(&self, message_id: &str) -> Result<Value> {
871 validate_id("message_id", message_id)?;
872 self.log_filter("message", message_id)
873 }
874
875 pub fn log_case(&self, case_ref: &str) -> Result<Value> {
876 let case_uid = parse_case_ref(case_ref)?;
877 self.log_filter("case", &case_uid)
878 }
879
880 pub fn log_archive(&self, archive_ref: &str) -> Result<Value> {
881 let archive_uid = parse_archive_ref(archive_ref)?;
882 self.log_filter("archive", &archive_uid)
883 }
884
885 fn log_filter(&self, kind: &str, id: &str) -> Result<Value> {
886 let events = self
887 .read_audit_events()?
888 .into_iter()
889 .filter(|event| event_targets_id(event, kind, id))
890 .collect::<Vec<_>>();
891 Ok(json!({
892 "code": "log_filtered",
893 "target": {"kind": kind, "id": id},
894 "count": events.len(),
895 "events": events
896 }))
897 }
898}
899
900fn merge_reconciliation_into_pull(pull: &mut Value, reconciliation: &Value) {
901 let Some(pull_obj) = pull.as_object_mut() else {
902 return;
903 };
904 let Some(reconcile_obj) = reconciliation.as_object() else {
905 return;
906 };
907 for key in [
908 "checked_location_count",
909 "missing_location_count",
910 "deleted_remote_message_count",
911 "deleted_remote_message_ids",
912 "tombstoned_message_count",
913 "tombstoned_message_ids",
914 "kept_message_count",
915 "kept_message_ids",
916 ] {
917 if let Some(value) = reconcile_obj.get(key) {
918 pull_obj.insert(key.to_string(), value.clone());
919 }
920 }
921}
922
923fn merge_triage_refresh_into_pull(pull: &mut Value, triage: &Value) {
924 let Some(pull_obj) = pull.as_object_mut() else {
925 return;
926 };
927 let Some(triage_obj) = triage.as_object() else {
928 return;
929 };
930 for key in [
931 "triage_count",
932 "triage_written_count",
933 "stale_triage_removed_count",
934 "spam_count",
935 "spam_written_count",
936 "stale_spam_removed_count",
937 "trash_count",
938 "trash_written_count",
939 "stale_trash_removed_count",
940 "deleted_count",
941 "deleted_written_count",
942 "stale_deleted_removed_count",
943 ] {
944 if let Some(value) = triage_obj.get(key) {
945 pull_obj.insert(key.to_string(), value.clone());
946 }
947 }
948}