Skip to main content

agent_first_mail/store/
mod.rs

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}