1use super::*;
2use crate::types::{ContactFrontmatter, MessageContact};
3
4pub enum ContactExtractSource {
5 FromTriage,
6 FromCase(String),
7 All,
8}
9
10pub 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 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 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 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 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 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 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
831pub(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 if s == "contacts" {
864 "people"
865 } else {
866 s
867 }
868 })
869 .unwrap_or("people")
870 .to_string()
871}