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 project skill installed by afmail init.
65.agents/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 project_skills_dir = agent_root.join(".agents/skills");
409        let _project_skill = crate::skill_admin::install_codex_project_skill(&project_skills_dir)?;
410        let project_skill_path = project_skills_dir.join("agent-first-mail").join("SKILL.md");
411
412        let do_not_edit_path = self.root.join(".afmail/DO_NOT_EDIT.txt");
413        let do_not_edit_created = !do_not_edit_path.exists();
414        let do_not_edit_rendered =
415            renderer.render(TemplateKey::WorkspaceDoNotEdit, &template_context)?;
416        let do_not_edit_updated = fs::read_to_string(&do_not_edit_path)
417            .map(|existing| existing != do_not_edit_rendered)
418            .unwrap_or(true);
419        if do_not_edit_updated {
420            write_string(&do_not_edit_path, &do_not_edit_rendered)?;
421        }
422
423        Ok(json!({
424            "code": "workspace_initialized",
425            "workspace_path": path_to_string(&self.root),
426            "workspace_ref": workspace_ref,
427            "agent_root_path": path_to_string(agent_root),
428            "created_rfc3339": now_rfc3339(),
429            "gitignore_path": rel_path(&self.root, &workspace_gitignore_path),
430            "gitignore_created": workspace_gitignore_change.created,
431            "gitignore_updated": workspace_gitignore_change.updated,
432            "workspace_gitignore_path": rel_path(&self.root, &workspace_gitignore_path),
433            "workspace_gitignore_created": workspace_gitignore_change.created,
434            "workspace_gitignore_updated": workspace_gitignore_change.updated,
435            "agent_gitignore_path": rel_path(agent_root, &agent_gitignore_path),
436            "agent_gitignore_created": agent_gitignore_change.created,
437            "agent_gitignore_updated": agent_gitignore_change.updated,
438            "agent_skill_path": rel_path(agent_root, &agent_skill_path),
439            "agent_skill_created": agent_skill_change.created,
440            "agent_skill_updated": agent_skill_change.updated,
441            "project_skill_path": rel_path(agent_root, &project_skill_path),
442            "project_skill_installed": true,
443            "do_not_edit_path": ".afmail/DO_NOT_EDIT.txt",
444            "do_not_edit_created": do_not_edit_created,
445            "do_not_edit_updated": do_not_edit_updated,
446            "workspace_paths": workspace_paths,
447            "language_bcp47": config.workspace.language_bcp47.clone(),
448            "resolved_language_bcp47": config.resolved_language_bcp47(),
449            "timezone_utc_offset": timezone_utc_offset,
450            "next_steps": [
451                format!("Run afmail commands with workdir set to {}.", workspace_ref),
452                "Adjust workspace.language_bcp47 or workspace.timezone_utc_offset with afmail config set if needed.".to_string()
453            ]
454        }))
455    }
456
457    pub fn status(&self) -> Result<Value> {
458        self.require_workspace()?;
459        let cases = self.active_case_items()?;
460        let mut cases_by_group: BTreeMap<String, usize> = BTreeMap::new();
461        for case in &cases {
462            let group = case
463                .get("group")
464                .and_then(Value::as_str)
465                .unwrap_or_default()
466                .to_string();
467            *cases_by_group.entry(group).or_insert(0) += 1;
468        }
469        let mut message_status: BTreeMap<String, usize> = BTreeMap::new();
470        let message_paths = message_json_paths(&self.root)?;
471        let mut remote_missing_count = 0usize;
472        let mut remote_effect_pending_message_count = 0usize;
473        for path in &message_paths {
474            let message = read_message(path)?;
475            *message_status
476                .entry(message.workspace.status.clone())
477                .or_insert(0) += 1;
478            if message_remote_missing(&message) {
479                remote_missing_count += 1;
480            }
481            if message_remote_effect_pending(&message) {
482                remote_effect_pending_message_count += 1;
483            }
484        }
485        let push_status = serde_json::to_value(crate::push_queue::push_status(&self.root)?)
486            .map_err(|e| AppError::json("serialize push status", &e))?;
487        let archive_messages = self.archive_message_category_items()?;
488        let archived_cases = self.archive_case_items()?;
489        Ok(json!({
490            "code": "status",
491            "triage_count": count_files_with_ext(&self.root.join("triage"), "md")?,
492            "case_count": cases.len(),
493            "cases_by_group": cases_by_group,
494            "archive_message_category_count": archive_messages.len(),
495            "archived_case_count": archived_cases.len(),
496            "message_count": message_paths.len(),
497            "message_status": message_status,
498            "remote_missing_count": remote_missing_count,
499            "remote_effect_pending_message_count": remote_effect_pending_message_count,
500            "push_count": count_files_with_ext(&self.root.join(".afmail/push"), "json")?,
501            "push_status": push_status
502        }))
503    }
504
505    pub fn config_show(&self) -> Result<Value> {
506        self.require_workspace()?;
507        let config = crate::config::MailConfig::load(&self.root)?;
508        let value =
509            serde_json::to_value(config).map_err(|e| AppError::json("serialize config", &e))?;
510        Ok(json!({
511            "code": "config",
512            "config": value
513        }))
514    }
515
516    pub fn config_get(&self, key: &str) -> Result<Value> {
517        self.require_workspace()?;
518        let config = crate::config::MailConfig::load(&self.root)?;
519        let value = config_value_for_output(key, config.get_key(key)?);
520        Ok(json!({
521            "code": "config_value",
522            "key": key,
523            "value": value
524        }))
525    }
526
527    pub fn config_set(&self, key: &str, values: &[String]) -> Result<Value> {
528        self.require_workspace()?;
529        let mut config = crate::config::MailConfig::load(&self.root)?;
530        config.set_key(key, values)?;
531        config.write(&self.root)?;
532        let value = config_value_for_output(key, config.get_key(key)?);
533        Ok(json!({
534            "code": "config_updated",
535            "key": key,
536            "value": value
537        }))
538    }
539
540    pub fn remote_test(&self) -> Result<Value> {
541        self.require_workspace()?;
542        let config = crate::config::MailConfig::load(&self.root)?.require_imap()?;
543        crate::imap_pull::remote_test(&config)
544    }
545
546    pub fn remote_folders(&self) -> Result<Value> {
547        self.require_workspace()?;
548        let config = crate::config::MailConfig::load(&self.root)?;
549        let imap = config.require_imap()?;
550        crate::imap_pull::remote_folders(&config, &imap)
551    }
552
553    pub fn push_with_progress(
554        &self,
555        confirmed: bool,
556        progress: Option<&mut crate::progress::ProgressCallback<'_>>,
557    ) -> Result<Value> {
558        self.require_workspace()?;
559        crate::push_queue::push_with_progress(&self.root, confirmed, progress)
560    }
561
562    pub fn push_list(&self) -> Result<Value> {
563        self.require_workspace()?;
564        crate::push_queue::list(&self.root)
565    }
566
567    pub fn render_refresh(&self) -> Result<Value> {
568        self.require_workspace()?;
569        create_dir_all(&self.root.join("archive/cases"))?;
570        create_dir_all(&self.root.join("archive/notifications"))?;
571        let cache = self.rebuild_message_cache_from_eml()?;
572        let triage = self.refresh_triage_views()?;
573        let dispositions = self.refresh_disposition_views()?;
574        let config = MailConfig::load(&self.root)?;
575        let mut renderer = MarkdownTemplateRenderer::new(&self.root, config.template_language());
576        let mut totals = RenderRefreshTotals::default();
577
578        for (_, case_path) in self.case_entries()? {
579            let refresh =
580                self.refresh_case_message_views_with_renderer(&case_path, &mut renderer)?;
581            totals.active_case_count += 1;
582            totals.add_case(refresh);
583        }
584        for entry in self.archived_case_entries()? {
585            let refresh =
586                self.refresh_case_message_views_with_renderer(&entry.path, &mut renderer)?;
587            totals.archived_case_count += 1;
588            totals.add_case(refresh);
589        }
590        for archive_uid in self.archive_message_category_ids()? {
591            let refresh = self.refresh_archive_message_category_with_renderer(
592                &archive_uid,
593                &mut renderer,
594                false,
595            )?;
596            totals.archive_message_category_count += 1;
597            totals.add_archive_message(refresh);
598        }
599
600        Ok(json!({
601            "code": "render_refreshed",
602            "active_case_count": totals.active_case_count,
603            "archived_case_count": totals.archived_case_count,
604            "archive_message_category_count": totals.archive_message_category_count,
605            "message_cache_rebuilt_count": cache.rebuilt_count,
606            "text_cache_removed_count": cache.removed_text_cache_count,
607            "triage_count": triage.get("triage_count").and_then(Value::as_u64).unwrap_or(0),
608            "triage_written_count": triage.get("triage_written_count").and_then(Value::as_u64).unwrap_or(0),
609            "stale_triage_removed_count": triage.get("stale_triage_removed_count").and_then(Value::as_u64).unwrap_or(0),
610            "spam_count": dispositions.get("spam_count").and_then(Value::as_u64).unwrap_or(0),
611            "spam_written_count": dispositions.get("spam_written_count").and_then(Value::as_u64).unwrap_or(0),
612            "stale_spam_removed_count": dispositions.get("stale_spam_removed_count").and_then(Value::as_u64).unwrap_or(0),
613            "trash_count": dispositions.get("trash_count").and_then(Value::as_u64).unwrap_or(0),
614            "trash_written_count": dispositions.get("trash_written_count").and_then(Value::as_u64).unwrap_or(0),
615            "stale_trash_removed_count": dispositions.get("stale_trash_removed_count").and_then(Value::as_u64).unwrap_or(0),
616            "deleted_count": dispositions.get("deleted_count").and_then(Value::as_u64).unwrap_or(0),
617            "deleted_written_count": dispositions.get("deleted_written_count").and_then(Value::as_u64).unwrap_or(0),
618            "stale_deleted_removed_count": dispositions.get("stale_deleted_removed_count").and_then(Value::as_u64).unwrap_or(0),
619            "generated": {
620                "triage/view.md.j2": triage.get("triage_written_count").and_then(Value::as_u64).unwrap_or(0),
621                "status/index.md.j2": dispositions.get("index_written_count").and_then(Value::as_u64).unwrap_or(0),
622                "status/message.md.j2": dispositions.get("message_written_count").and_then(Value::as_u64).unwrap_or(0),
623                "case/case.md.j2": totals.case_index_count,
624                "case/message.md.j2": totals.case_message_count,
625                "archive-message/archive.md.j2": totals.archive_message_index_count,
626                "archive-message/message.md.j2": totals.archive_message_count,
627            },
628            "template_sources": renderer.stats().to_value(),
629        }))
630    }
631
632    pub fn render_templates(&self, force: bool) -> Result<Value> {
633        self.require_workspace()?;
634        let templates_dir = self.root.join("templates");
635        let existed_before = templates_dir.exists();
636        if existed_before && !templates_dir.is_dir() {
637            return Err(AppError::new(
638                "template_dir_invalid",
639                "templates exists but is not a directory",
640            ));
641        }
642
643        create_dir_all(&templates_dir)?;
644
645        let mut items = Vec::new();
646        let mut exported_count = 0usize;
647        let mut overwritten_count = 0usize;
648        let mut kept_count = 0usize;
649        let builtin_count = 0usize;
650        let mut workspace_count = 0usize;
651
652        for language in TemplateLanguage::ALL {
653            for key in TemplateKey::ALL {
654                let path = templates_dir.join(language_template_path(language, key));
655                let existed = path.exists();
656                let (source, action) = if force || !existed {
657                    if let Some(parent) = path.parent() {
658                        create_dir_all(parent)?;
659                    }
660                    write_string(&path, key.builtin_text(language))?;
661                    workspace_count += 1;
662                    if existed {
663                        overwritten_count += 1;
664                        ("workspace", "overwritten")
665                    } else {
666                        exported_count += 1;
667                        ("workspace", "exported")
668                    }
669                } else {
670                    workspace_count += 1;
671                    kept_count += 1;
672                    ("workspace", "kept")
673                };
674                items.push(json!({
675                    "language": language.as_str(),
676                    "template_key": key.as_str(),
677                    "path": rel_path(&self.root, &path),
678                    "source": source,
679                    "action": action,
680                }));
681            }
682        }
683
684        Ok(json!({
685            "code": "render_templates",
686            "template_dir": "templates",
687            "template_dir_created": !existed_before,
688            "force": force,
689            "exported_count": exported_count,
690            "overwritten_count": overwritten_count,
691            "kept_count": kept_count,
692            "builtin_count": builtin_count,
693            "workspace_count": workspace_count,
694            "items": items,
695        }))
696    }
697
698    pub fn log_list(&self, limit: usize) -> Result<Value> {
699        let events = self.read_audit_events()?;
700        Ok(json!({
701            "code": "log_list",
702            "count": events.len().min(limit),
703            "events": take_last(events, limit)
704        }))
705    }
706
707    pub fn log_tail(&self) -> Result<Value> {
708        self.log_list(20).map(|mut value| {
709            if let Some(obj) = value.as_object_mut() {
710                obj.insert("code".to_string(), json!("log_tail"));
711            }
712            value
713        })
714    }
715
716    pub fn log_message(&self, message_id: &str) -> Result<Value> {
717        validate_id("message_id", message_id)?;
718        self.log_filter("message", message_id)
719    }
720
721    pub fn log_case(&self, case_ref: &str) -> Result<Value> {
722        let case_uid = parse_case_ref(case_ref)?;
723        self.log_filter("case", &case_uid)
724    }
725
726    pub fn log_archive(&self, archive_ref: &str) -> Result<Value> {
727        let archive_uid = parse_archive_ref(archive_ref)?;
728        self.log_filter("archive", &archive_uid)
729    }
730
731    fn log_filter(&self, kind: &str, id: &str) -> Result<Value> {
732        let events = self
733            .read_audit_events()?
734            .into_iter()
735            .filter(|event| event_targets_id(event, kind, id))
736            .collect::<Vec<_>>();
737        Ok(json!({
738            "code": "log_filtered",
739            "target": {"kind": kind, "id": id},
740            "count": events.len(),
741            "events": events
742        }))
743    }
744}
745
746fn merge_reconciliation_into_pull(pull: &mut Value, reconciliation: &Value) {
747    let Some(pull_obj) = pull.as_object_mut() else {
748        return;
749    };
750    let Some(reconcile_obj) = reconciliation.as_object() else {
751        return;
752    };
753    for key in [
754        "checked_location_count",
755        "missing_location_count",
756        "deleted_remote_message_count",
757        "deleted_remote_message_ids",
758        "tombstoned_message_count",
759        "tombstoned_message_ids",
760        "kept_message_count",
761        "kept_message_ids",
762    ] {
763        if let Some(value) = reconcile_obj.get(key) {
764            pull_obj.insert(key.to_string(), value.clone());
765        }
766    }
767}
768
769fn merge_triage_refresh_into_pull(pull: &mut Value, triage: &Value) {
770    let Some(pull_obj) = pull.as_object_mut() else {
771        return;
772    };
773    let Some(triage_obj) = triage.as_object() else {
774        return;
775    };
776    for key in [
777        "triage_count",
778        "triage_written_count",
779        "stale_triage_removed_count",
780        "spam_count",
781        "spam_written_count",
782        "stale_spam_removed_count",
783        "trash_count",
784        "trash_written_count",
785        "stale_trash_removed_count",
786        "deleted_count",
787        "deleted_written_count",
788        "stale_deleted_removed_count",
789    ] {
790        if let Some(value) = triage_obj.get(key) {
791            pull_obj.insert(key.to_string(), value.clone());
792        }
793    }
794}