Skip to main content

agent_first_mail/store/
contacts.rs

1use super::*;
2use crate::types::{ContactFrontmatter, MessageContact};
3
4pub enum ContactExtractSource {
5    FromTriage,
6    FromCase(String),
7    All,
8}
9
10/// Fields for `contact_create`, grouped so the entry point stays a single
11/// argument instead of a long positional list.
12pub struct NewContact<'a> {
13    pub name: &'a str,
14    pub group: Option<&'a str>,
15    pub emails: &'a [String],
16    pub phones: &'a [String],
17    pub org: Option<&'a str>,
18    pub role: Option<&'a str>,
19    pub tags: &'a [String],
20}
21
22impl Workspace {
23    pub fn contact_create(&self, params: NewContact<'_>) -> Result<Value> {
24        let NewContact {
25            name,
26            group,
27            emails,
28            phones,
29            org,
30            role,
31            tags,
32        } = params;
33        self.require_workspace()?;
34        validate_name("contact_name", name)?;
35        let config = MailConfig::load(&self.root)?;
36        let group = group.unwrap_or(config.contact.default_group.as_str());
37        validate_id("group", group)?;
38
39        let normalized_emails: Vec<String> = emails
40            .iter()
41            .map(|e| e.trim().to_ascii_lowercase())
42            .filter(|e| !e.is_empty())
43            .collect();
44
45        for email in &normalized_emails {
46            self.assert_email_unique(email, None)?;
47        }
48
49        let date = workspace_local_date(&config.resolved_timezone_offset());
50        let uid = self.next_contact_uid_for_date(&date)?;
51        let contact_dir = self.root.join("contacts").join(group);
52        create_dir_all(&contact_dir)?;
53
54        let file_name = format!("{}.md", contact_file_name(&uid, name));
55        let path = contact_dir.join(&file_name);
56
57        let now = now_rfc3339();
58        let mut fm = ContactFrontmatter::new(&uid, name, &now);
59        fm.emails = normalized_emails.clone();
60        fm.phones = phones.to_vec();
61        fm.organization = org.unwrap_or("").to_string();
62        fm.role = role.unwrap_or("").to_string();
63        fm.tags = tags.to_vec();
64
65        let default_notes = format!("## {name}\n\n");
66        write_contact_md(&path, &fm, &default_notes)?;
67
68        let contact_path = rel_path(&self.root, &path);
69        self.append_audit_event(
70            "contact_created",
71            vec![audit_target("contact", &uid)],
72            None,
73            json!({
74                "contact_uid": uid,
75                "display_name": name,
76                "group": group,
77                "emails": normalized_emails,
78            }),
79        )?;
80        if !normalized_emails.is_empty() {
81            self.refresh_views_after_contact_change()?;
82        }
83        Ok(json!({
84            "code": "contact_created",
85            "contact_uid": uid,
86            "display_name": name,
87            "group": group,
88            "emails": normalized_emails,
89            "contact_path": contact_path,
90        }))
91    }
92
93    pub fn contact_list(
94        &self,
95        group: Option<&str>,
96        tag: Option<&str>,
97        org: Option<&str>,
98        role: Option<&str>,
99    ) -> Result<Value> {
100        self.require_workspace()?;
101        let entries = self.all_active_contact_entries()?;
102        let mut contacts = Vec::new();
103        for (entry_group, uid, path) in entries {
104            if let Some(g) = group {
105                if entry_group != g {
106                    continue;
107                }
108            }
109            let content = read_to_string(&path, "read contact file")?;
110            let (fm, _) = read_contact_md_content(&content)?;
111            if let Some(t) = tag {
112                if !fm.tags.iter().any(|x| x == t) {
113                    continue;
114                }
115            }
116            if let Some(o) = org {
117                if fm.organization != o {
118                    continue;
119                }
120            }
121            if let Some(r) = role {
122                if fm.role != r {
123                    continue;
124                }
125            }
126            contacts.push(json!({
127                "contact_uid": uid,
128                "display_name": fm.display_name,
129                "group": entry_group,
130                "emails": fm.emails,
131                "phones": fm.phones,
132                "organization": fm.organization,
133                "role": fm.role,
134                "tags": fm.tags,
135                "contact_path": rel_path(&self.root, &path),
136            }));
137        }
138        let count = contacts.len();
139        Ok(json!({
140            "code": "contact_list",
141            "contacts": contacts,
142            "count": count,
143        }))
144    }
145
146    pub fn contact_show(&self, contact_ref: &str) -> Result<Value> {
147        self.require_workspace()?;
148        let uid = parse_contact_ref(contact_ref)?;
149        let (is_archived, path) = self.find_contact_path(&uid)?;
150        let content = read_to_string(&path, "read contact file")?;
151        let group = if is_archived {
152            None
153        } else {
154            Some(contact_group_from_path(&self.root, &path))
155        };
156        Ok(json!({
157            "code": "contact_show",
158            "contact_uid": uid,
159            "is_archived": is_archived,
160            "group": group,
161            "contact_path": rel_path(&self.root, &path),
162            "content": content,
163        }))
164    }
165
166    pub fn contact_move(&self, contact_ref: &str, new_group: &str) -> Result<Value> {
167        self.require_workspace()?;
168        let uid = parse_contact_ref(contact_ref)?;
169        validate_id("group", new_group)?;
170        let (group, src_path) = self.find_active_contact_path(&uid)?;
171        if group == new_group {
172            return Ok(json!({
173                "code": "contact_moved",
174                "contact_uid": uid,
175                "group": new_group,
176                "contact_path": rel_path(&self.root, &src_path),
177                "changed": false,
178            }));
179        }
180        let dest_dir = self.root.join("contacts").join(new_group);
181        create_dir_all(&dest_dir)?;
182        let file_name = path_file_name(&src_path);
183        let dest_path = dest_dir.join(&file_name);
184        fs::rename(&src_path, &dest_path).map_err(|e| AppError::io("move contact", &e))?;
185        // Remove the now-empty source group dir if nothing is left.
186        let src_group_dir = self.root.join("contacts").join(&group);
187        let _ = fs::remove_dir(&src_group_dir);
188        self.append_audit_event(
189            "contact_moved",
190            vec![audit_target("contact", &uid)],
191            None,
192            json!({"contact_uid": uid, "from_group": group, "to_group": new_group}),
193        )?;
194        Ok(json!({
195            "code": "contact_moved",
196            "contact_uid": uid,
197            "group": new_group,
198            "contact_path": rel_path(&self.root, &dest_path),
199            "changed": true,
200        }))
201    }
202
203    pub fn contact_rename(&self, contact_ref: &str, new_name: &str) -> Result<Value> {
204        self.require_workspace()?;
205        let uid = parse_contact_ref(contact_ref)?;
206        validate_name("contact_name", new_name)?;
207        let (group, src_path) = self.find_active_contact_path(&uid)?;
208        let content = read_to_string(&src_path, "read contact")?;
209        let (mut fm, body) = read_contact_md_content(&content)?;
210        let old_name = fm.display_name.clone();
211        fm.display_name = new_name.to_string();
212        fm.updated_rfc3339 = now_rfc3339();
213
214        let new_file_name = format!("{}.md", contact_file_name(&uid, new_name));
215        let dest_path = src_path.parent().unwrap_or(&self.root).join(&new_file_name);
216        write_contact_md(&dest_path, &fm, &body)?;
217        if src_path != dest_path {
218            remove_file(&src_path)?;
219        }
220        self.append_audit_event(
221            "contact_renamed",
222            vec![audit_target("contact", &uid)],
223            None,
224            json!({"contact_uid": uid, "old_name": old_name, "new_name": new_name}),
225        )?;
226        if !fm.emails.is_empty() {
227            self.refresh_views_after_contact_change()?;
228        }
229        Ok(json!({
230            "code": "contact_renamed",
231            "contact_uid": uid,
232            "display_name": new_name,
233            "group": group,
234            "contact_path": rel_path(&self.root, &dest_path),
235        }))
236    }
237
238    pub fn contact_email_add(&self, contact_ref: &str, email: &str) -> Result<Value> {
239        self.require_workspace()?;
240        let uid = parse_contact_ref(contact_ref)?;
241        let normalized = email.trim().to_ascii_lowercase();
242        if normalized.is_empty() {
243            return Err(AppError::new("invalid_request", "email must not be empty"));
244        }
245        self.assert_email_unique(&normalized, Some(&uid))?;
246        let (_, path) = self.find_active_contact_path(&uid)?;
247        let content = read_to_string(&path, "read contact")?;
248        let (mut fm, body) = read_contact_md_content(&content)?;
249        if !fm
250            .emails
251            .iter()
252            .any(|e| e.to_ascii_lowercase() == normalized)
253        {
254            fm.emails.push(normalized.clone());
255            fm.updated_rfc3339 = now_rfc3339();
256            write_contact_md(&path, &fm, &body)?;
257            self.refresh_views_after_contact_change()?;
258        }
259        Ok(json!({
260            "code": "contact_email_added",
261            "contact_uid": uid,
262            "email": normalized,
263            "emails": fm.emails,
264        }))
265    }
266
267    pub fn contact_email_remove(&self, contact_ref: &str, email: &str) -> Result<Value> {
268        self.require_workspace()?;
269        let uid = parse_contact_ref(contact_ref)?;
270        let normalized = email.trim().to_ascii_lowercase();
271        let (_, path) = self.find_active_contact_path(&uid)?;
272        let content = read_to_string(&path, "read contact")?;
273        let (mut fm, body) = read_contact_md_content(&content)?;
274        let before = fm.emails.len();
275        fm.emails.retain(|e| e.to_ascii_lowercase() != normalized);
276        if fm.emails.len() != before {
277            fm.updated_rfc3339 = now_rfc3339();
278            write_contact_md(&path, &fm, &body)?;
279            self.refresh_views_after_contact_change()?;
280        }
281        Ok(json!({
282            "code": "contact_email_removed",
283            "contact_uid": uid,
284            "email": normalized,
285            "emails": fm.emails,
286        }))
287    }
288
289    pub fn contact_phone_add(&self, contact_ref: &str, phone: &str) -> Result<Value> {
290        self.require_workspace()?;
291        let uid = parse_contact_ref(contact_ref)?;
292        let phone = phone.trim().to_string();
293        let (_, path) = self.find_active_contact_path(&uid)?;
294        let content = read_to_string(&path, "read contact")?;
295        let (mut fm, body) = read_contact_md_content(&content)?;
296        if !fm.phones.iter().any(|p| p == &phone) {
297            fm.phones.push(phone.clone());
298            fm.updated_rfc3339 = now_rfc3339();
299            write_contact_md(&path, &fm, &body)?;
300        }
301        Ok(json!({
302            "code": "contact_phone_added",
303            "contact_uid": uid,
304            "phone": phone,
305            "phones": fm.phones,
306        }))
307    }
308
309    pub fn contact_phone_remove(&self, contact_ref: &str, phone: &str) -> Result<Value> {
310        self.require_workspace()?;
311        let uid = parse_contact_ref(contact_ref)?;
312        let phone = phone.trim().to_string();
313        let (_, path) = self.find_active_contact_path(&uid)?;
314        let content = read_to_string(&path, "read contact")?;
315        let (mut fm, body) = read_contact_md_content(&content)?;
316        let before = fm.phones.len();
317        fm.phones.retain(|p| p != &phone);
318        if fm.phones.len() != before {
319            fm.updated_rfc3339 = now_rfc3339();
320            write_contact_md(&path, &fm, &body)?;
321        }
322        Ok(json!({
323            "code": "contact_phone_removed",
324            "contact_uid": uid,
325            "phone": phone,
326            "phones": fm.phones,
327        }))
328    }
329
330    pub fn contact_tag(&self, contact_ref: &str, tag: &str) -> Result<Value> {
331        self.require_workspace()?;
332        let uid = parse_contact_ref(contact_ref)?;
333        let tag = tag.trim().to_string();
334        let (_, path) = self.find_active_contact_path(&uid)?;
335        let content = read_to_string(&path, "read contact")?;
336        let (mut fm, body) = read_contact_md_content(&content)?;
337        merge_string(&mut fm.tags, &tag);
338        fm.updated_rfc3339 = now_rfc3339();
339        write_contact_md(&path, &fm, &body)?;
340        Ok(json!({
341            "code": "contact_tagged",
342            "contact_uid": uid,
343            "tag": tag,
344            "tags": fm.tags,
345        }))
346    }
347
348    pub fn contact_untag(&self, contact_ref: &str, tag: &str) -> Result<Value> {
349        self.require_workspace()?;
350        let uid = parse_contact_ref(contact_ref)?;
351        let tag = tag.trim().to_string();
352        let (_, path) = self.find_active_contact_path(&uid)?;
353        let content = read_to_string(&path, "read contact")?;
354        let (mut fm, body) = read_contact_md_content(&content)?;
355        fm.tags.retain(|t| t != &tag);
356        fm.updated_rfc3339 = now_rfc3339();
357        write_contact_md(&path, &fm, &body)?;
358        Ok(json!({
359            "code": "contact_untagged",
360            "contact_uid": uid,
361            "tag": tag,
362            "tags": fm.tags,
363        }))
364    }
365
366    pub fn contact_notes_show(&self, contact_ref: &str) -> Result<Value> {
367        self.require_workspace()?;
368        let uid = parse_contact_ref(contact_ref)?;
369        let (_, path) = self.find_contact_path(&uid)?;
370        let content = read_to_string(&path, "read contact")?;
371        let (_, body) = read_contact_md_content(&content)?;
372        Ok(json!({
373            "code": "contact_notes_show",
374            "contact_uid": uid,
375            "notes": body,
376        }))
377    }
378
379    pub fn contact_notes_append(&self, contact_ref: &str, text: &str) -> Result<Value> {
380        self.require_workspace()?;
381        let uid = parse_contact_ref(contact_ref)?;
382        let (_, path) = self.find_active_contact_path(&uid)?;
383        let content = read_to_string(&path, "read contact")?;
384        let (mut fm, body) = read_contact_md_content(&content)?;
385        let new_body = if body.trim().is_empty() {
386            format!("{}\n", text.trim_end())
387        } else {
388            format!("{}\n\n{}\n", body.trim_end(), text.trim_end())
389        };
390        fm.updated_rfc3339 = now_rfc3339();
391        write_contact_md(&path, &fm, &new_body)?;
392        Ok(json!({
393            "code": "contact_notes_appended",
394            "contact_uid": uid,
395        }))
396    }
397
398    pub fn contact_notes_replace(&self, contact_ref: &str, text: &str) -> Result<Value> {
399        self.require_workspace()?;
400        let uid = parse_contact_ref(contact_ref)?;
401        let (_, path) = self.find_active_contact_path(&uid)?;
402        let content = read_to_string(&path, "read contact")?;
403        let (mut fm, _) = read_contact_md_content(&content)?;
404        let new_body = format!("{}\n", text.trim_end());
405        fm.updated_rfc3339 = now_rfc3339();
406        write_contact_md(&path, &fm, &new_body)?;
407        Ok(json!({
408            "code": "contact_notes_replaced",
409            "contact_uid": uid,
410        }))
411    }
412
413    pub fn contact_archive_contact(
414        &self,
415        contact_ref: &str,
416        reason: Option<&str>,
417    ) -> Result<Value> {
418        self.require_workspace()?;
419        let uid = parse_contact_ref(contact_ref)?;
420        let reason = self.checked_reason(reason)?;
421        let (group, src_path) = self.find_active_contact_path(&uid)?;
422        let archive_dir = self.root.join("archive").join("contacts");
423        create_dir_all(&archive_dir)?;
424        let file_name = path_file_name(&src_path);
425        let dest_path = unique_dest_path(&archive_dir, &file_name);
426        fs::rename(&src_path, &dest_path).map_err(|e| AppError::io("archive contact", &e))?;
427        // Remove empty group dir if nothing left
428        let group_dir = self.root.join("contacts").join(&group);
429        let _ = fs::remove_dir(&group_dir);
430        self.append_audit_event(
431            "contact_archived",
432            vec![audit_target("contact", &uid)],
433            reason,
434            json!({"contact_uid": uid, "from_group": group}),
435        )?;
436        Ok(json!({
437            "code": "contact_archived",
438            "contact_uid": uid,
439            "contact_path": rel_path(&self.root, &dest_path),
440        }))
441    }
442
443    pub fn contact_reopen_contact(
444        &self,
445        contact_ref: &str,
446        group: Option<&str>,
447        reason: Option<&str>,
448    ) -> Result<Value> {
449        self.require_workspace()?;
450        let uid = parse_contact_ref(contact_ref)?;
451        let reason = self.checked_reason(reason)?;
452        let src_path = self.find_archived_contact_path(&uid)?;
453        let config = MailConfig::load(&self.root)?;
454        let target_group = group.unwrap_or(config.contact.default_group.as_str());
455        validate_id("group", target_group)?;
456        let dest_dir = self.root.join("contacts").join(target_group);
457        create_dir_all(&dest_dir)?;
458        let file_name = path_file_name(&src_path);
459        let dest_path = unique_dest_path(&dest_dir, &file_name);
460        fs::rename(&src_path, &dest_path).map_err(|e| AppError::io("reopen contact", &e))?;
461        // Remove empty archive/contacts dir (ok to fail)
462        let archive_dir = self.root.join("archive").join("contacts");
463        let _ = fs::remove_dir(&archive_dir);
464        self.append_audit_event(
465            "contact_reopened",
466            vec![audit_target("contact", &uid)],
467            reason,
468            json!({"contact_uid": uid, "group": target_group}),
469        )?;
470        Ok(json!({
471            "code": "contact_reopened",
472            "contact_uid": uid,
473            "group": target_group,
474            "contact_path": rel_path(&self.root, &dest_path),
475        }))
476    }
477
478    pub fn contact_extract(
479        &self,
480        source: ContactExtractSource,
481        group: Option<&str>,
482    ) -> Result<Value> {
483        self.require_workspace()?;
484        let config = MailConfig::load(&self.root)?;
485        let offset = config.resolved_timezone_offset();
486        let target_group = group.unwrap_or(config.contact.default_group.as_str());
487        validate_id("group", target_group)?;
488
489        let from_headers = match source {
490            ContactExtractSource::FromTriage => self.collect_from_headers_triage()?,
491            ContactExtractSource::FromCase(case_ref) => {
492                let uid = parse_case_ref(&case_ref)?;
493                self.collect_from_headers_case(&uid)?
494            }
495            ContactExtractSource::All => {
496                let mut headers = self.collect_from_headers_triage()?;
497                for (case_uid, _) in self.all_case_entries()? {
498                    headers.extend(self.collect_from_headers_case(&case_uid)?);
499                }
500                headers
501            }
502        };
503
504        let mut created_uids: Vec<String> = Vec::new();
505        let mut skipped_count = 0usize;
506
507        for (display_name, email) in from_headers {
508            let email_lc = email.to_ascii_lowercase();
509            if self.contact_email_conflict(&email_lc)? {
510                skipped_count += 1;
511                continue;
512            }
513            let date = workspace_local_date(&offset);
514            let uid = self.next_contact_uid_for_date(&date)?;
515            let contact_dir = self.root.join("contacts").join(target_group);
516            create_dir_all(&contact_dir)?;
517            let file_name = format!(
518                "{}.md",
519                contact_file_name(
520                    &uid,
521                    if display_name.is_empty() {
522                        &email_lc
523                    } else {
524                        &display_name
525                    }
526                )
527            );
528            let path = contact_dir.join(&file_name);
529            let now = now_rfc3339();
530            let actual_name = if display_name.is_empty() {
531                email_lc.clone()
532            } else {
533                display_name.clone()
534            };
535            let mut fm = ContactFrontmatter::new(&uid, &actual_name, &now);
536            fm.emails = vec![email_lc.clone()];
537            let default_notes = format!("## {actual_name}\n\n");
538            write_contact_md(&path, &fm, &default_notes)?;
539            self.append_audit_event(
540                "contact_created",
541                vec![audit_target("contact", &uid)],
542                None,
543                json!({"contact_uid": uid, "display_name": actual_name, "group": target_group, "emails": [&email_lc], "via": "extract"}),
544            )?;
545            created_uids.push(uid);
546        }
547
548        let created_count = created_uids.len();
549        if created_count > 0 {
550            self.refresh_views_after_contact_change()?;
551        }
552        Ok(json!({
553            "code": "contact_extract",
554            "created_count": created_count,
555            "skipped_count": skipped_count,
556            "created_uids": created_uids,
557        }))
558    }
559
560    /// Build an ephemeral email→contact map from contact files. Active contacts
561    /// take priority; archived contacts fill in only addresses not already held
562    /// by an active contact. Replaces the former persisted email index.
563    pub(super) fn contact_email_map(&self) -> Result<BTreeMap<String, MessageContact>> {
564        let mut map: BTreeMap<String, MessageContact> = BTreeMap::new();
565        for (_, _, path) in self.all_active_contact_entries()? {
566            let content = read_to_string(&path, "read contact for index")?;
567            if let Ok((fm, _)) = read_contact_md_content(&content) {
568                for email in &fm.emails {
569                    let normalized = email.trim().to_ascii_lowercase();
570                    if !normalized.is_empty() {
571                        map.insert(normalized, message_contact_from(&fm));
572                    }
573                }
574            }
575        }
576        for (_, path) in self.all_archived_contact_entries()? {
577            let content = read_to_string(&path, "read archived contact for index")?;
578            if let Ok((fm, _)) = read_contact_md_content(&content) {
579                for email in &fm.emails {
580                    let normalized = email.trim().to_ascii_lowercase();
581                    if !normalized.is_empty() {
582                        map.entry(normalized)
583                            .or_insert_with(|| message_contact_from(&fm));
584                    }
585                }
586            }
587        }
588        Ok(map)
589    }
590
591    /// Regenerate every read view that embeds the message section so newly
592    /// materialized contact links propagate. `refresh_triage_views` recomputes
593    /// `message.contact` for every message JSON first; the case/disposition/
594    /// archive refreshers then read the updated JSON.
595    pub(super) fn refresh_views_after_contact_change(&self) -> Result<()> {
596        self.refresh_triage_views()?;
597        self.refresh_disposition_views()?;
598        self.refresh_all_case_message_views()?;
599        self.refresh_archive_indexes()?;
600        Ok(())
601    }
602
603    fn assert_email_unique(&self, email: &str, excluding_uid: Option<&str>) -> Result<()> {
604        let normalized = email.trim().to_ascii_lowercase();
605        if normalized.is_empty() {
606            return Ok(());
607        }
608        if let Some(entry) = self.contact_email_map()?.get(&normalized) {
609            if excluding_uid != Some(entry.contact_uid.as_str()) {
610                return Err(AppError::new(
611                    "email_conflict",
612                    format!(
613                        "email {email} is already assigned to contact {}",
614                        entry.contact_uid
615                    ),
616                )
617                .with_details(json!({"email": email, "existing_contact_uid": entry.contact_uid})));
618            }
619        }
620        Ok(())
621    }
622
623    /// True if any active or archived contact already holds this email. Unlike
624    /// `assert_email_unique`, IO errors propagate instead of reading as "taken".
625    fn contact_email_conflict(&self, email: &str) -> Result<bool> {
626        let normalized = email.trim().to_ascii_lowercase();
627        if normalized.is_empty() {
628            return Ok(false);
629        }
630        Ok(self.contact_email_map()?.contains_key(&normalized))
631    }
632
633    fn next_contact_uid_for_date(&self, date: &str) -> Result<String> {
634        next_uid_for_date(
635            'p',
636            date,
637            self.all_active_contact_entries()?
638                .into_iter()
639                .map(|(_, uid, _)| uid)
640                .chain(
641                    self.all_archived_contact_entries()?
642                        .into_iter()
643                        .map(|(uid, _)| uid),
644                ),
645        )
646    }
647
648    fn all_active_contact_entries(&self) -> Result<Vec<(String, String, PathBuf)>> {
649        let contacts_dir = self.root.join("contacts");
650        if !contacts_dir.exists() {
651            return Ok(Vec::new());
652        }
653        let mut entries = Vec::new();
654        for group_entry in read_dir(&contacts_dir, "list contact groups")? {
655            if !group_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
656                continue;
657            }
658            let group = group_entry.file_name().to_string_lossy().to_string();
659            for file_entry in read_dir(&group_entry.path(), "list contact files")? {
660                let path = file_entry.path();
661                if path.extension().and_then(|s| s.to_str()) != Some("md") {
662                    continue;
663                }
664                let stem = path
665                    .file_stem()
666                    .and_then(|s| s.to_str())
667                    .unwrap_or("")
668                    .to_string();
669                if let Some(uid) = contact_uid_from_file_name(&stem) {
670                    entries.push((group.clone(), uid, path));
671                }
672            }
673        }
674        entries.sort_by(|a, b| a.1.cmp(&b.1));
675        Ok(entries)
676    }
677
678    fn all_archived_contact_entries(&self) -> Result<Vec<(String, PathBuf)>> {
679        let archive_dir = self.root.join("archive").join("contacts");
680        if !archive_dir.exists() {
681            return Ok(Vec::new());
682        }
683        let mut entries = Vec::new();
684        for entry in read_dir(&archive_dir, "list archived contacts")? {
685            let path = entry.path();
686            if path.extension().and_then(|s| s.to_str()) != Some("md") {
687                continue;
688            }
689            let stem = path
690                .file_stem()
691                .and_then(|s| s.to_str())
692                .unwrap_or("")
693                .to_string();
694            if let Some(uid) = contact_uid_from_file_name(&stem) {
695                entries.push((uid, path));
696            }
697        }
698        entries.sort_by(|a, b| a.0.cmp(&b.0));
699        Ok(entries)
700    }
701
702    fn find_active_contact_path(&self, uid: &str) -> Result<(String, PathBuf)> {
703        for (group, entry_uid, path) in self.all_active_contact_entries()? {
704            if entry_uid == uid {
705                return Ok((group, path));
706            }
707        }
708        Err(AppError::new(
709            "not_found",
710            format!("active contact {uid} not found"),
711        )
712        .with_hint(format!(
713            "Use `afmail contact list` to see available contacts, or `afmail contact reopen {uid}` if it was archived."
714        ))
715        .with_details(json!({"contact_uid": uid})))
716    }
717
718    fn find_archived_contact_path(&self, uid: &str) -> Result<PathBuf> {
719        for (entry_uid, path) in self.all_archived_contact_entries()? {
720            if entry_uid == uid {
721                return Ok(path);
722            }
723        }
724        Err(
725            AppError::new("not_found", format!("archived contact {uid} not found"))
726                .with_details(json!({"contact_uid": uid})),
727        )
728    }
729
730    fn find_contact_path(&self, uid: &str) -> Result<(bool, PathBuf)> {
731        for (_, entry_uid, path) in self.all_active_contact_entries()? {
732            if entry_uid == uid {
733                return Ok((false, path));
734            }
735        }
736        for (entry_uid, path) in self.all_archived_contact_entries()? {
737            if entry_uid == uid {
738                return Ok((true, path));
739            }
740        }
741        Err(AppError::new(
742            "not_found",
743            format!("contact {uid} not found (active or archived)"),
744        )
745        .with_details(json!({"contact_uid": uid})))
746    }
747
748    fn collect_from_headers_triage(&self) -> Result<Vec<(String, String)>> {
749        let triage_dir = self.root.join("triage");
750        if !triage_dir.exists() {
751            return Ok(Vec::new());
752        }
753        let mut results = Vec::new();
754        for entry in read_dir(&triage_dir, "list triage")? {
755            let path = entry.path();
756            if path.extension().and_then(|s| s.to_str()) != Some("md") {
757                continue;
758            }
759            let content = match fs::read_to_string(&path) {
760                Ok(c) => c,
761                Err(_) => continue,
762            };
763            let Ok((fm, _)) =
764                crate::markdown::read_doc::<crate::frontmatter::TriageFrontmatter>(&content)
765            else {
766                continue;
767            };
768            let primary_id = if fm.message_id.is_empty() {
769                fm.message_ids.first().cloned().unwrap_or_default()
770            } else {
771                fm.message_id.clone()
772            };
773            if primary_id.is_empty() {
774                continue;
775            }
776            if let Some(pair) = self.message_sender_pair(&primary_id) {
777                results.push(pair);
778            }
779        }
780        Ok(results)
781    }
782
783    fn collect_from_headers_case(&self, case_uid: &str) -> Result<Vec<(String, String)>> {
784        let (_, case_path) = self.find_active_case_entry(case_uid)?;
785        let case_data = read_case_file(&case_path)?;
786        let mut results = Vec::new();
787        for item in &case_data.items {
788            if let Some(pair) = self.message_sender_pair(&item.message_id) {
789                results.push(pair);
790            }
791        }
792        Ok(results)
793    }
794
795    fn message_sender_pair(&self, message_id: &str) -> Option<(String, String)> {
796        let msg_path = self
797            .root
798            .join("messages")
799            .join(format!("{message_id}.json"));
800        let content = fs::read_to_string(&msg_path).ok()?;
801        let msg: Value = serde_json::from_str(&content).ok()?;
802        let from = msg["from"].as_str()?;
803        let email = email_address(from);
804        if email.is_empty() {
805            return None;
806        }
807        let display_name = extract_from_display_name(from);
808        Some((display_name, email))
809    }
810
811    fn find_active_case_entry(&self, case_uid: &str) -> Result<(String, PathBuf)> {
812        for (uid, path) in self.all_case_entries()? {
813            if uid == case_uid {
814                return Ok((uid, path));
815            }
816        }
817        Err(AppError::new(
818            "not_found",
819            format!("case {case_uid} not found"),
820        ))
821    }
822}
823
824fn message_contact_from(fm: &ContactFrontmatter) -> MessageContact {
825    MessageContact {
826        contact_uid: fm.contact_uid.clone(),
827        display_name: fm.display_name.clone(),
828    }
829}
830
831/// Resolve the contact whose email set includes a message's `From` header,
832/// using a prebuilt email→contact map (see `contact_email_map`).
833pub(super) fn contact_for_from(
834    map: &BTreeMap<String, MessageContact>,
835    from_header: &str,
836) -> Option<MessageContact> {
837    if from_header.is_empty() {
838        return None;
839    }
840    let email = email_address(from_header);
841    if email.is_empty() {
842        return None;
843    }
844    map.get(&email).cloned()
845}
846
847fn write_contact_md(path: &Path, fm: &ContactFrontmatter, body: &str) -> Result<()> {
848    let rendered = crate::markdown::render_frontmatter(fm, body)?;
849    write_string(path, &rendered)
850}
851
852fn read_contact_md_content(content: &str) -> Result<(ContactFrontmatter, String)> {
853    crate::markdown::read_doc::<ContactFrontmatter>(content)
854        .map_err(|e| AppError::new("invalid_contact", format!("parse contact file: {e}")))
855}
856
857fn contact_group_from_path(_root: &Path, path: &Path) -> String {
858    path.parent()
859        .and_then(|p| p.file_name())
860        .and_then(|n| n.to_str())
861        .map(|s| {
862            // Skip if parent is "contacts" (shouldn't happen but guard)
863            if s == "contacts" {
864                "people"
865            } else {
866                s
867            }
868        })
869        .unwrap_or("people")
870        .to_string()
871}