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
18use cases::*;
19use drafts::*;
20use messages::*;
21use refs::CaseIndex;
22use remote_sync::*;
23use render::*;
24use util::*;
25
26pub use render::{
27    clean_body_text, render_message_section, render_message_section_with_config,
28    render_message_section_with_options,
29};
30pub(crate) use triage::render_triage_view;
31
32use crate::config::{
33    ArchiveMessageIndexField, MailConfig, ReasonMode, SpecialUseKind, TemplateLanguage,
34};
35use crate::error::{AppError, Result};
36use crate::frontmatter::{CaseFrontmatter, DraftFrontmatter, TriageFrontmatter};
37use crate::markdown::{read_doc, render_frontmatter};
38use crate::templates::{language_template_path, MarkdownTemplateRenderer, TemplateKey};
39use crate::types::RemoteSyncState;
40use crate::types::{
41    ArchiveMessageItem, ArchiveMessages, AttachmentRef, CaseMessages, MailDirection, MessageFile,
42    MessageStatus, PushItem, PushLocation, RemoteLocation, RemoteState, WorkspacePendingPush,
43    WorkspacePushState, WorkspaceState,
44};
45use crate::util::{canonical_flags, sha256_fingerprint, write_json_pretty, write_string_atomic};
46use chrono::{DateTime, Datelike, Duration, FixedOffset, SecondsFormat, Timelike, Utc};
47use sanitize_filename::{sanitize_with_options, Options as SanitizeFilenameOptions};
48use serde::{Deserialize, Serialize};
49use serde_json::Map;
50use serde_json::{json, Value};
51use std::collections::{BTreeMap, BTreeSet};
52use std::fs;
53use std::io::{BufRead, BufReader, Write as _};
54use std::path::{Path, PathBuf};
55use std::time::Instant;
56
57const AFMAIL_GITIGNORE_BEGIN: &str = "# BEGIN afmail managed";
58const AFMAIL_GITIGNORE_END: &str = "# END afmail managed";
59const AFMAIL_GITIGNORE_BODY: &str = r#"# Local mail evidence and runtime state.
60.afmail/logs/
61.afmail/transactions/
62.afmail/workspace.lock
63.afmail/workspace.progress.json
64
65# Generated caches and read views; rebuild with afmail render refresh.
66messages/*.json
67triage/*.md
68spam/*.md
69trash/*.md
70deleted/*.md
71cases/*/*/case.md
72cases/*/*/views/**/*.md
73archive/cases/*/case.md
74archive/cases/*/views/**/*.md
75archive/notifications/*/archive.md
76archive/notifications/*/views/**/*.md
77"#;
78const AFMAIL_AGENTS_BEGIN: &str = "<!-- BEGIN afmail managed -->";
79const AFMAIL_AGENTS_END: &str = "<!-- END afmail managed -->";
80
81pub fn now_rfc3339() -> String {
82    Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
83}
84
85#[derive(Clone, Debug)]
86pub struct Workspace {
87    root: PathBuf,
88}
89
90#[derive(Clone, Debug, Default)]
91struct CaseViewRefresh {
92    case_index_count: usize,
93    case_message_count: usize,
94}
95
96#[derive(Clone, Debug, Default)]
97struct ArchiveMessageViewRefresh {
98    archive_message_index_count: usize,
99    archive_message_count: usize,
100}
101
102#[derive(Clone, Debug, Default)]
103struct RenderRefreshTotals {
104    active_case_count: usize,
105    archived_case_count: usize,
106    archive_message_category_count: usize,
107    case_index_count: usize,
108    case_message_count: usize,
109    archive_message_index_count: usize,
110    archive_message_count: usize,
111}
112
113impl RenderRefreshTotals {
114    fn add_case(&mut self, refresh: CaseViewRefresh) {
115        self.case_index_count += refresh.case_index_count;
116        self.case_message_count += refresh.case_message_count;
117    }
118
119    fn add_archive_message(&mut self, refresh: ArchiveMessageViewRefresh) {
120        self.archive_message_index_count += refresh.archive_message_index_count;
121        self.archive_message_count += refresh.archive_message_count;
122    }
123}
124
125impl Workspace {
126    pub fn at(path: impl Into<PathBuf>) -> Self {
127        Self { root: path.into() }
128    }
129
130    pub fn discover(start: impl AsRef<Path>) -> Result<Self> {
131        let mut current = start.as_ref().to_path_buf();
132        loop {
133            if current.join(".afmail").is_dir() {
134                return Ok(Self::at(current));
135            }
136            if !current.pop() {
137                return Err(AppError::new(
138                    "workspace_not_found",
139                    "no .afmail directory found in current directory or parents",
140                ));
141            }
142        }
143    }
144
145    pub fn root(&self) -> &Path {
146        &self.root
147    }
148
149    pub fn init(&self) -> Result<Value> {
150        create_dir_all(&self.root.join(".afmail/messages"))?;
151        create_dir_all(&self.root.join(".afmail/push"))?;
152        create_dir_all(&self.root.join(".afmail/logs"))?;
153        create_dir_all(&self.root.join(".afmail/transactions"))?;
154        create_dir_all(&self.root.join("messages"))?;
155        create_dir_all(&self.root.join("triage"))?;
156        create_dir_all(&self.root.join("spam"))?;
157        create_dir_all(&self.root.join("trash"))?;
158        create_dir_all(&self.root.join("cases"))?;
159        create_dir_all(&self.root.join("archive/cases"))?;
160        create_dir_all(&self.root.join("archive/notifications"))?;
161        write_json_if_missing(
162            &self.root.join(".afmail/config.json"),
163            &serde_json::to_value(crate::config::MailConfig::default())
164                .map_err(|e| AppError::json("serialize config", &e))?,
165        )?;
166        write_string_if_missing(&self.root.join(".afmail/logs/events.jsonl"), "")?;
167        let config = MailConfig::load(&self.root)?;
168        let language = config.template_language();
169        let language_bcp47 = config.resolved_language_bcp47().to_string();
170        let timezone_utc_offset = config.resolved_timezone_utc_offset();
171        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
172        let template_context = json!({"language": language_bcp47});
173        let gitignore_change = ensure_managed_block_file(
174            &self.root.join(".gitignore"),
175            AFMAIL_GITIGNORE_BEGIN,
176            AFMAIL_GITIGNORE_END,
177            "",
178            AFMAIL_GITIGNORE_BODY,
179        )?;
180        let agent_skill_path = self.root.join("AGENTS.md");
181        let rendered_agents = renderer.render(TemplateKey::WorkspaceAgents, &template_context)?;
182        let (agent_skill_prefix, agent_skill_body) = managed_block_template_parts(
183            &rendered_agents,
184            AFMAIL_AGENTS_BEGIN,
185            AFMAIL_AGENTS_END,
186            &agent_skill_path,
187        )?;
188        let agent_skill_change = ensure_managed_block_file(
189            &agent_skill_path,
190            AFMAIL_AGENTS_BEGIN,
191            AFMAIL_AGENTS_END,
192            &agent_skill_prefix,
193            &agent_skill_body,
194        )?;
195        let do_not_edit_path = self.root.join(".afmail/DO_NOT_EDIT.txt");
196        let do_not_edit_created = if do_not_edit_path.exists() {
197            false
198        } else {
199            write_string(
200                &do_not_edit_path,
201                &renderer.render(TemplateKey::WorkspaceDoNotEdit, &template_context)?,
202            )?;
203            true
204        };
205        Ok(json!({
206            "code": "workspace_initialized",
207            "workspace_path": path_to_string(&self.root),
208            "created_rfc3339": now_rfc3339(),
209            "gitignore_path": ".gitignore",
210            "gitignore_created": gitignore_change.created,
211            "gitignore_updated": gitignore_change.updated,
212            "agent_skill_path": "AGENTS.md",
213            "agent_skill_created": agent_skill_change.created,
214            "agent_skill_updated": agent_skill_change.updated,
215            "do_not_edit_path": ".afmail/DO_NOT_EDIT.txt",
216            "do_not_edit_created": do_not_edit_created,
217            "language_bcp47": config.workspace.language_bcp47.clone(),
218            "resolved_language_bcp47": config.resolved_language_bcp47(),
219            "timezone_utc_offset": timezone_utc_offset,
220            "next_steps": [
221                "Adjust workspace.language_bcp47 or workspace.timezone_utc_offset with afmail config set if needed."
222            ]
223        }))
224    }
225
226    pub fn status(&self) -> Result<Value> {
227        self.require_workspace()?;
228        let cases = self.active_case_items()?;
229        let mut cases_by_group: BTreeMap<String, usize> = BTreeMap::new();
230        for case in &cases {
231            let group = case
232                .get("group")
233                .and_then(Value::as_str)
234                .unwrap_or_default()
235                .to_string();
236            *cases_by_group.entry(group).or_insert(0) += 1;
237        }
238        let mut message_status: BTreeMap<String, usize> = BTreeMap::new();
239        let message_paths = message_json_paths(&self.root)?;
240        let mut remote_missing_count = 0usize;
241        let mut remote_effect_pending_message_count = 0usize;
242        for path in &message_paths {
243            let message = read_message(path)?;
244            *message_status
245                .entry(message.workspace.status.clone())
246                .or_insert(0) += 1;
247            if message_remote_missing(&message) {
248                remote_missing_count += 1;
249            }
250            if message_remote_effect_pending(&message) {
251                remote_effect_pending_message_count += 1;
252            }
253        }
254        let push_status = serde_json::to_value(crate::push_queue::push_status(&self.root)?)
255            .map_err(|e| AppError::json("serialize push status", &e))?;
256        let archive_messages = self.archive_message_category_items()?;
257        let archived_cases = self.archive_case_items()?;
258        Ok(json!({
259            "code": "status",
260            "triage_count": count_files_with_ext(&self.root.join("triage"), "md")?,
261            "case_count": cases.len(),
262            "cases_by_group": cases_by_group,
263            "archive_message_category_count": archive_messages.len(),
264            "archived_case_count": archived_cases.len(),
265            "message_count": message_paths.len(),
266            "message_status": message_status,
267            "remote_missing_count": remote_missing_count,
268            "remote_effect_pending_message_count": remote_effect_pending_message_count,
269            "push_count": count_files_with_ext(&self.root.join(".afmail/push"), "json")?,
270            "push_status": push_status
271        }))
272    }
273
274    pub fn config_show(&self) -> Result<Value> {
275        self.require_workspace()?;
276        let config = crate::config::MailConfig::load(&self.root)?;
277        let value =
278            serde_json::to_value(config).map_err(|e| AppError::json("serialize config", &e))?;
279        Ok(json!({
280            "code": "config",
281            "config": value
282        }))
283    }
284
285    pub fn config_get(&self, key: &str) -> Result<Value> {
286        self.require_workspace()?;
287        let config = crate::config::MailConfig::load(&self.root)?;
288        let value = config_value_for_output(key, config.get_key(key)?);
289        Ok(json!({
290            "code": "config_value",
291            "key": key,
292            "value": value
293        }))
294    }
295
296    pub fn config_set(&self, key: &str, values: &[String]) -> Result<Value> {
297        self.require_workspace()?;
298        let mut config = crate::config::MailConfig::load(&self.root)?;
299        config.set_key(key, values)?;
300        config.write(&self.root)?;
301        let value = config_value_for_output(key, config.get_key(key)?);
302        Ok(json!({
303            "code": "config_updated",
304            "key": key,
305            "value": value
306        }))
307    }
308
309    pub fn remote_test(&self) -> Result<Value> {
310        self.require_workspace()?;
311        let config = crate::config::MailConfig::load(&self.root)?.require_imap()?;
312        crate::imap_pull::remote_test(&config)
313    }
314
315    pub fn remote_folders(&self) -> Result<Value> {
316        self.require_workspace()?;
317        let config = crate::config::MailConfig::load(&self.root)?;
318        let imap = config.require_imap()?;
319        crate::imap_pull::remote_folders(&config, &imap)
320    }
321
322    pub fn push(&self, mode: crate::push_queue::PushMode, dry_run: bool) -> Result<Value> {
323        self.push_with_progress(mode, dry_run, None)
324    }
325
326    pub fn push_with_progress(
327        &self,
328        mode: crate::push_queue::PushMode,
329        dry_run: bool,
330        progress: Option<&mut crate::progress::ProgressCallback<'_>>,
331    ) -> Result<Value> {
332        self.require_workspace()?;
333        crate::push_queue::push_with_progress(&self.root, mode, dry_run, progress)
334    }
335
336    pub fn push_list(&self) -> Result<Value> {
337        self.require_workspace()?;
338        crate::push_queue::list(&self.root)
339    }
340
341    pub fn render_refresh(&self) -> Result<Value> {
342        self.require_workspace()?;
343        create_dir_all(&self.root.join("archive/cases"))?;
344        create_dir_all(&self.root.join("archive/notifications"))?;
345        let cache = self.rebuild_message_cache_from_eml()?;
346        let triage = self.refresh_triage_views()?;
347        let dispositions = self.refresh_disposition_views()?;
348        let config = MailConfig::load(&self.root)?;
349        let mut renderer = MarkdownTemplateRenderer::new(&self.root, config.template_language());
350        let mut totals = RenderRefreshTotals::default();
351
352        for (_, case_path) in self.case_entries()? {
353            let refresh =
354                self.refresh_case_message_views_with_renderer(&case_path, &mut renderer)?;
355            totals.active_case_count += 1;
356            totals.add_case(refresh);
357        }
358        for entry in self.archived_case_entries()? {
359            let refresh =
360                self.refresh_case_message_views_with_renderer(&entry.path, &mut renderer)?;
361            totals.archived_case_count += 1;
362            totals.add_case(refresh);
363        }
364        for archive_uid in self.archive_message_category_ids()? {
365            let refresh = self.refresh_archive_message_category_with_renderer(
366                &archive_uid,
367                &mut renderer,
368                false,
369            )?;
370            totals.archive_message_category_count += 1;
371            totals.add_archive_message(refresh);
372        }
373
374        Ok(json!({
375            "code": "render_refreshed",
376            "active_case_count": totals.active_case_count,
377            "archived_case_count": totals.archived_case_count,
378            "archive_message_category_count": totals.archive_message_category_count,
379            "message_cache_rebuilt_count": cache.rebuilt_count,
380            "text_cache_removed_count": cache.removed_text_cache_count,
381            "triage_count": triage.get("triage_count").and_then(Value::as_u64).unwrap_or(0),
382            "triage_written_count": triage.get("triage_written_count").and_then(Value::as_u64).unwrap_or(0),
383            "stale_triage_removed_count": triage.get("stale_triage_removed_count").and_then(Value::as_u64).unwrap_or(0),
384            "spam_count": dispositions.get("spam_count").and_then(Value::as_u64).unwrap_or(0),
385            "spam_written_count": dispositions.get("spam_written_count").and_then(Value::as_u64).unwrap_or(0),
386            "stale_spam_removed_count": dispositions.get("stale_spam_removed_count").and_then(Value::as_u64).unwrap_or(0),
387            "trash_count": dispositions.get("trash_count").and_then(Value::as_u64).unwrap_or(0),
388            "trash_written_count": dispositions.get("trash_written_count").and_then(Value::as_u64).unwrap_or(0),
389            "stale_trash_removed_count": dispositions.get("stale_trash_removed_count").and_then(Value::as_u64).unwrap_or(0),
390            "deleted_count": dispositions.get("deleted_count").and_then(Value::as_u64).unwrap_or(0),
391            "deleted_written_count": dispositions.get("deleted_written_count").and_then(Value::as_u64).unwrap_or(0),
392            "stale_deleted_removed_count": dispositions.get("stale_deleted_removed_count").and_then(Value::as_u64).unwrap_or(0),
393            "generated": {
394                "triage/view.md.j2": triage.get("triage_written_count").and_then(Value::as_u64).unwrap_or(0),
395                "status/index.md.j2": dispositions.get("index_written_count").and_then(Value::as_u64).unwrap_or(0),
396                "status/message.md.j2": dispositions.get("message_written_count").and_then(Value::as_u64).unwrap_or(0),
397                "case/case.md.j2": totals.case_index_count,
398                "case/message.md.j2": totals.case_message_count,
399                "archive-message/archive.md.j2": totals.archive_message_index_count,
400                "archive-message/message.md.j2": totals.archive_message_count,
401            },
402            "template_sources": renderer.stats().to_value(),
403        }))
404    }
405
406    pub fn render_templates(&self, force: bool) -> Result<Value> {
407        self.require_workspace()?;
408        let templates_dir = self.root.join(".afmail/templates");
409        let existed_before = templates_dir.exists();
410        if existed_before && !templates_dir.is_dir() {
411            return Err(AppError::new(
412                "template_dir_invalid",
413                ".afmail/templates exists but is not a directory",
414            ));
415        }
416
417        create_dir_all(&templates_dir)?;
418
419        let mut items = Vec::new();
420        let mut exported_count = 0usize;
421        let mut overwritten_count = 0usize;
422        let mut kept_count = 0usize;
423        let builtin_count = 0usize;
424        let mut workspace_count = 0usize;
425
426        for language in TemplateLanguage::ALL {
427            for key in TemplateKey::ALL {
428                let path = templates_dir.join(language_template_path(language, key));
429                let existed = path.exists();
430                let (source, action) = if force || !existed {
431                    if let Some(parent) = path.parent() {
432                        create_dir_all(parent)?;
433                    }
434                    write_string(&path, key.builtin_text(language))?;
435                    workspace_count += 1;
436                    if existed {
437                        overwritten_count += 1;
438                        ("workspace", "overwritten")
439                    } else {
440                        exported_count += 1;
441                        ("workspace", "exported")
442                    }
443                } else {
444                    workspace_count += 1;
445                    kept_count += 1;
446                    ("workspace", "kept")
447                };
448                items.push(json!({
449                    "language": language.as_str(),
450                    "template_key": key.as_str(),
451                    "path": rel_path(&self.root, &path),
452                    "source": source,
453                    "action": action,
454                }));
455            }
456        }
457
458        Ok(json!({
459            "code": "render_templates",
460            "template_dir": ".afmail/templates",
461            "template_dir_created": !existed_before,
462            "force": force,
463            "exported_count": exported_count,
464            "overwritten_count": overwritten_count,
465            "kept_count": kept_count,
466            "builtin_count": builtin_count,
467            "workspace_count": workspace_count,
468            "items": items,
469        }))
470    }
471
472    pub fn log_list(&self, limit: usize) -> Result<Value> {
473        let events = self.read_audit_events()?;
474        Ok(json!({
475            "code": "log_list",
476            "count": events.len().min(limit),
477            "events": take_last(events, limit)
478        }))
479    }
480
481    pub fn log_tail(&self) -> Result<Value> {
482        self.log_list(20).map(|mut value| {
483            if let Some(obj) = value.as_object_mut() {
484                obj.insert("code".to_string(), json!("log_tail"));
485            }
486            value
487        })
488    }
489
490    pub fn log_message(&self, message_id: &str) -> Result<Value> {
491        validate_id("message_id", message_id)?;
492        self.log_filter("message", message_id)
493    }
494
495    pub fn log_case(&self, case_ref: &str) -> Result<Value> {
496        let case_uid = parse_case_ref(case_ref)?;
497        self.log_filter("case", &case_uid)
498    }
499
500    pub fn log_archive(&self, archive_ref: &str) -> Result<Value> {
501        let archive_uid = parse_archive_ref(archive_ref)?;
502        self.log_filter("archive", &archive_uid)
503    }
504
505    fn log_filter(&self, kind: &str, id: &str) -> Result<Value> {
506        let events = self
507            .read_audit_events()?
508            .into_iter()
509            .filter(|event| event_targets_id(event, kind, id))
510            .collect::<Vec<_>>();
511        Ok(json!({
512            "code": "log_filtered",
513            "target": {"kind": kind, "id": id},
514            "count": events.len(),
515            "events": events
516        }))
517    }
518}
519
520fn merge_reconciliation_into_pull(pull: &mut Value, reconciliation: &Value) {
521    let Some(pull_obj) = pull.as_object_mut() else {
522        return;
523    };
524    let Some(reconcile_obj) = reconciliation.as_object() else {
525        return;
526    };
527    for key in [
528        "checked_location_count",
529        "missing_location_count",
530        "deleted_remote_message_count",
531        "deleted_remote_message_ids",
532        "tombstoned_message_count",
533        "tombstoned_message_ids",
534        "kept_message_count",
535        "kept_message_ids",
536    ] {
537        if let Some(value) = reconcile_obj.get(key) {
538            pull_obj.insert(key.to_string(), value.clone());
539        }
540    }
541}
542
543fn merge_triage_refresh_into_pull(pull: &mut Value, triage: &Value) {
544    let Some(pull_obj) = pull.as_object_mut() else {
545        return;
546    };
547    let Some(triage_obj) = triage.as_object() else {
548        return;
549    };
550    for key in [
551        "triage_count",
552        "triage_written_count",
553        "stale_triage_removed_count",
554        "spam_count",
555        "spam_written_count",
556        "stale_spam_removed_count",
557        "trash_count",
558        "trash_written_count",
559        "stale_trash_removed_count",
560        "deleted_count",
561        "deleted_written_count",
562        "stale_deleted_removed_count",
563    ] {
564        if let Some(value) = triage_obj.get(key) {
565            pull_obj.insert(key.to_string(), value.clone());
566        }
567    }
568}