Skip to main content

agent_first_mail/store/
mod.rs

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}