Skip to main content

agent_first_mail/store/
doctor.rs

1use super::*;
2
3#[derive(Clone, Debug, Serialize)]
4struct DoctorIssue {
5    code: String,
6    severity: &'static str,
7    message: String,
8    #[serde(skip_serializing_if = "Option::is_none")]
9    path: Option<String>,
10    #[serde(default, skip_serializing_if = "Vec::is_empty")]
11    refs: Vec<String>,
12    repairable: bool,
13}
14
15impl DoctorIssue {
16    fn error(code: &str, message: impl Into<String>, path: Option<String>) -> Self {
17        Self {
18            code: code.to_string(),
19            severity: "error",
20            message: message.into(),
21            path,
22            refs: Vec::new(),
23            repairable: false,
24        }
25    }
26
27    fn warning(
28        code: &str,
29        message: impl Into<String>,
30        path: Option<String>,
31        repairable: bool,
32    ) -> Self {
33        Self {
34            code: code.to_string(),
35            severity: "warning",
36            message: message.into(),
37            path,
38            refs: Vec::new(),
39            repairable,
40        }
41    }
42}
43
44impl Workspace {
45    pub fn doctor(&self) -> Result<Value> {
46        self.require_workspace()?;
47        let issues = self.doctor_issues()?;
48        let repairable_count = issues.iter().filter(|issue| issue.repairable).count();
49        let error_count = issues
50            .iter()
51            .filter(|issue| issue.severity == "error")
52            .count();
53        Ok(json!({
54            "code": "doctor",
55            "ok": issues.is_empty(),
56            "issue_count": issues.len(),
57            "error_count": error_count,
58            "repairable_count": repairable_count,
59            "checks": {
60                "git_checked": false,
61                "messages": true,
62                "cases": true,
63                "archives": true,
64                "push_queue": true,
65                "templates": true,
66                "transactions": true,
67            },
68            "issues": issues,
69        }))
70    }
71
72    pub fn doctor_repair(&self, confirm: bool) -> Result<Value> {
73        self.require_workspace()?;
74        if !confirm {
75            return Err(AppError::new(
76                "confirm_required",
77                "doctor repair requires --confirm",
78            )
79            .with_hint("Inspect with `afmail doctor`; apply repairs with `afmail doctor repair --confirm`.")
80            .with_details(json!({
81                "suggested_commands": [
82                    "afmail doctor",
83                    "afmail doctor repair --confirm"
84                ]
85            })));
86        }
87        self.ensure_no_incomplete_transactions()?;
88        let before = self.doctor_issues()?;
89        let cache = self.rebuild_message_cache_from_eml()?;
90        for path in message_json_paths(&self.root)? {
91            if let Ok(message) = read_message(&path) {
92                self.persist_message_state(&message)?;
93                self.persist_message_remote(&message)?;
94            }
95        }
96        let rendered = self.render_refresh()?;
97        let after = self.doctor_issues()?;
98        Ok(json!({
99            "code": "doctor_repair",
100            "confirmed": true,
101            "before_issue_count": before.len(),
102            "after_issue_count": after.len(),
103            "message_cache_rebuilt_count": cache.rebuilt_count,
104            "text_cache_removed_count": cache.removed_text_cache_count,
105            "render": rendered,
106            "remaining_issues": after,
107        }))
108    }
109
110    fn doctor_issues(&self) -> Result<Vec<DoctorIssue>> {
111        let mut issues = Vec::new();
112        self.check_transactions(&mut issues)?;
113        self.check_messages(&mut issues)?;
114        self.check_case_refs(&mut issues)?;
115        self.check_archive_refs(&mut issues)?;
116        self.check_push_overlay(&mut issues)?;
117        self.check_templates(&mut issues)?;
118        Ok(issues)
119    }
120
121    fn check_transactions(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
122        for transaction in self.incomplete_transactions()? {
123            issues.push(DoctorIssue::error(
124                "transaction_incomplete",
125                format!(
126                    "incomplete local transaction {} ({})",
127                    transaction.transaction_id, transaction.kind
128                ),
129                Some(format!(
130                    ".afmail/transactions/{}.json",
131                    transaction.transaction_id
132                )),
133            ));
134        }
135        Ok(())
136    }
137
138    fn check_messages(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
139        let mut ids = BTreeSet::new();
140        for path in message_json_paths(&self.root)? {
141            let rel = rel_path(&self.root, &path);
142            let message = match read_message(&path) {
143                Ok(message) => message,
144                Err(err) => {
145                    issues.push(DoctorIssue::error(
146                        "message_cache_invalid",
147                        err.message,
148                        Some(rel),
149                    ));
150                    continue;
151                }
152            };
153            ids.insert(message.message_id.clone());
154            let eml = self
155                .root
156                .join(format!(".afmail/messages/{}.eml", message.message_id));
157            if !eml.is_file() {
158                issues.push(DoctorIssue::error(
159                    "message_eml_missing",
160                    format!("missing raw .eml for {}", message.message_id),
161                    Some(rel_path(&self.root, &eml)),
162                ));
163            }
164            let state = self.root.join(format!(
165                ".afmail/messages/{}.state.json",
166                message.message_id
167            ));
168            if !state.is_file() {
169                issues.push(DoctorIssue::warning(
170                    "message_state_missing",
171                    format!("missing state sidecar for {}", message.message_id),
172                    Some(rel_path(&self.root, &state)),
173                    true,
174                ));
175            }
176            if message
177                .remote
178                .as_ref()
179                .is_some_and(|remote| !remote.locations.is_empty())
180            {
181                let remote = self.root.join(format!(
182                    ".afmail/messages/{}.remote.json",
183                    message.message_id
184                ));
185                if !remote.is_file() {
186                    issues.push(DoctorIssue::warning(
187                        "message_remote_missing",
188                        format!("missing remote sidecar for {}", message.message_id),
189                        Some(rel_path(&self.root, &remote)),
190                        true,
191                    ));
192                }
193            }
194        }
195        for entry in read_optional_dir(&self.root.join(".afmail/messages"), "read message state")? {
196            let path = entry.path();
197            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
198                continue;
199            };
200            let message_id = name
201                .strip_suffix(".state.json")
202                .or_else(|| name.strip_suffix(".remote.json"));
203            if let Some(message_id) = message_id {
204                if !ids.contains(message_id) && !self.message_path(message_id).is_file() {
205                    issues.push(DoctorIssue::warning(
206                        "message_sidecar_orphaned",
207                        format!("sidecar has no materialized message cache: {message_id}"),
208                        Some(rel_path(&self.root, &path)),
209                        true,
210                    ));
211                }
212            }
213        }
214        Ok(())
215    }
216
217    fn check_case_refs(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
218        for (case_uid, case_path) in self.all_case_entries()? {
219            let messages = read_case_messages(&case_messages_json_path(&case_path), &case_uid)?;
220            for message_id in messages.message_ids {
221                if !self.message_path(&message_id).is_file() {
222                    let mut issue = DoctorIssue::error(
223                        "case_message_ref_broken",
224                        format!("case {case_uid} references missing message {message_id}"),
225                        Some(rel_path(&self.root, &case_messages_json_path(&case_path))),
226                    );
227                    issue.refs = vec![case_uid.clone(), message_id];
228                    issues.push(issue);
229                }
230            }
231        }
232        Ok(())
233    }
234
235    fn check_archive_refs(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
236        for archive_uid in self.archive_message_category_ids()? {
237            let archive = self.read_archive_messages(&archive_uid)?;
238            for item in archive.items {
239                if !self.message_path(&item.message_id).is_file() {
240                    let mut issue = DoctorIssue::error(
241                        "archive_message_ref_broken",
242                        format!(
243                            "archive {archive_uid} references missing message {}",
244                            item.message_id
245                        ),
246                        Some(rel_path(
247                            &self.root,
248                            &self.archive_message_json_path(&archive_uid),
249                        )),
250                    );
251                    issue.refs = vec![archive_uid.clone(), item.message_id];
252                    issues.push(issue);
253                }
254            }
255        }
256        Ok(())
257    }
258
259    fn check_push_overlay(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
260        let items = crate::push_queue::pending_items(&self.root)?;
261        let mut pending_by_message: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
262        for item in items {
263            for message_id in item.message_ids() {
264                pending_by_message
265                    .entry(message_id.clone())
266                    .or_default()
267                    .insert(item.push_id.clone());
268                if !self.message_path(message_id).is_file() {
269                    issues.push(DoctorIssue::error(
270                        "push_message_ref_broken",
271                        format!(
272                            "push {} references missing message {message_id}",
273                            item.push_id
274                        ),
275                        Some(format!(".afmail/push/{}.json", item.push_id)),
276                    ));
277                }
278            }
279        }
280        for path in message_json_paths(&self.root)? {
281            let Ok(message) = read_message(&path) else {
282                continue;
283            };
284            let expected = pending_by_message
285                .remove(&message.message_id)
286                .unwrap_or_default();
287            let actual = message
288                .workspace
289                .push
290                .as_ref()
291                .map(|push| {
292                    push.pending
293                        .iter()
294                        .map(|pending| pending.push_id.clone())
295                        .collect::<BTreeSet<_>>()
296                })
297                .unwrap_or_default();
298            if expected != actual && (!expected.is_empty() || !actual.is_empty()) {
299                issues.push(DoctorIssue::warning(
300                    "push_overlay_drift",
301                    format!(
302                        "message {} push overlay differs from queue",
303                        message.message_id
304                    ),
305                    Some(rel_path(&self.root, &path)),
306                    true,
307                ));
308            }
309        }
310        Ok(())
311    }
312
313    fn check_templates(&self, issues: &mut Vec<DoctorIssue>) -> Result<()> {
314        let language = self.template_language()?;
315        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
316        for key in TemplateKey::ALL {
317            if let Err(err) = renderer.render(key, &minimal_template_context(language)) {
318                issues.push(DoctorIssue::error(
319                    "template_render_failed",
320                    err.message,
321                    Some(key.as_str().to_string()),
322                ));
323            }
324        }
325        Ok(())
326    }
327}
328
329fn read_optional_dir(path: &Path, context: &str) -> Result<Vec<fs::DirEntry>> {
330    if !path.exists() {
331        return Ok(Vec::new());
332    }
333    read_dir(path, context)
334}
335
336fn minimal_template_context(language: TemplateLanguage) -> Value {
337    json!({
338        "language": language.as_str(),
339        "frontmatter": {},
340        "message_id": "message_doctor",
341        "case_uid": "c20260609001",
342        "case_name": "doctor",
343        "archive_uid": "a20260609001",
344        "archive_name": "doctor",
345        "title": "doctor",
346        "status": "active",
347        "message_count": 0,
348        "attachment_count": 0,
349        "generated_rfc3339": "2026-06-09T00:00:00Z",
350        "archived_rfc3339": "2026-06-09T00:00:00Z",
351        "summary": "doctor",
352        "conversation": "",
353        "items": [],
354        "messages": [],
355        "related_messages": [],
356        "suggested_case_uids": [],
357        "suggested_reason": "",
358        "suggested_reason_yaml": "",
359        "body_text_visible_block": "\n",
360        "body_text_fence": "```",
361        "display_heading": "doctor",
362        "from": "",
363        "subject": "",
364        "to": [],
365        "cc": [],
366        "bcc": [],
367        "security": {
368            "authentication": {
369                "check": false,
370                "has_results": false,
371                "spf": "missing",
372                "dkim": "missing",
373                "dmarc": "missing",
374                "dmarc_policy": null,
375                "authenticated_domain": null,
376                "from_domain": null,
377                "alignment": "unknown",
378            },
379            "possible_bcc": false,
380            "reply_to_differs": false,
381            "reply_to_recipients": "",
382            "sender_differs": false,
383            "sender": "",
384            "mailing_list": "",
385            "mailing_list_headers": "",
386        },
387        "hints": [],
388        "attachments": [],
389        "sender": "",
390        "quoted": "",
391        "config": {
392            "archive": {
393                "message_index": {
394                    "item_fields": [],
395                },
396            },
397        },
398        "message": {
399            "schema_name": "message",
400            "schema_version": 1,
401            "message_id": "message_doctor",
402            "workspace": {"status": "triage"},
403        },
404        "case": {},
405    })
406}