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}