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