Skip to main content

agent_first_mail/store/
archive.rs

1use super::*;
2
3pub(super) fn archive_index_field_value(
4    field: ArchiveMessageIndexField,
5    message: &MessageFile,
6    item: &ArchiveMessageItem,
7    offset: &FixedOffset,
8) -> Option<Value> {
9    let value = match field {
10        ArchiveMessageIndexField::Time => {
11            message_time_datetime(message, offset).unwrap_or_default()
12        }
13        ArchiveMessageIndexField::From => message.from.clone().unwrap_or_default(),
14        ArchiveMessageIndexField::To => message.to.join(", "),
15        ArchiveMessageIndexField::Subject => message.subject.clone().unwrap_or_default(),
16        ArchiveMessageIndexField::Summary => item.summary.clone().unwrap_or_default(),
17        ArchiveMessageIndexField::MessageId => item.message_id.clone(),
18        ArchiveMessageIndexField::ArchiveTime => item.archived_rfc3339.clone(),
19        ArchiveMessageIndexField::Link => String::new(),
20    };
21    let keep_empty = matches!(
22        field,
23        ArchiveMessageIndexField::Time
24            | ArchiveMessageIndexField::From
25            | ArchiveMessageIndexField::Link
26            | ArchiveMessageIndexField::MessageId
27    );
28    if value.is_empty() && !keep_empty {
29        None
30    } else {
31        Some(json!({
32            "kind": field.as_str(),
33            "value": value,
34            "href": format!("views/messages/{}.md", item.message_id),
35        }))
36    }
37}
38
39impl Workspace {
40    pub fn create_archive_message_category(
41        &self,
42        name: &str,
43        message_id: Option<&str>,
44        summary: Option<&str>,
45        reason: Option<&str>,
46    ) -> Result<Value> {
47        self.require_workspace()?;
48        validate_name("archive_name", name)?;
49        if let Some(message_id) = message_id {
50            validate_id("message_id", message_id)?;
51            if summary
52                .map(str::trim)
53                .filter(|value| !value.is_empty())
54                .is_none()
55            {
56                return Err(AppError::new(
57                    "invalid_request",
58                    "--summary is required when --message is supplied",
59                ));
60            }
61        }
62        let date = if let Some(message_id) = message_id {
63            self.message_date(message_id)?
64        } else {
65            workspace_local_date(&self.workspace_date_offset()?)
66        };
67        let archive_uid = self.next_archive_uid(&date)?;
68        let archive_dir = self.archive_message_dir_for_name(&archive_uid, name);
69        if archive_dir.exists() {
70            return Err(AppError::new(
71                "archive_exists",
72                format!(
73                    "archive message category already exists: {}",
74                    path_to_string(&archive_dir)
75                ),
76            ));
77        }
78        create_dir_all(&archive_dir.join("data"))?;
79        create_dir_all(&archive_dir.join("views/messages"))?;
80        write_string_new(
81            &archive_dir.join("notes.md"),
82            &new_notes_md(&self.root, self.template_language()?)?,
83        )?;
84        self.write_archive_messages_named(
85            &archive_uid,
86            name,
87            &ArchiveMessages::new(&archive_uid, name),
88        )?;
89
90        let mut result = json!({
91            "code": "archive_message_created",
92            "archive_uid": archive_uid,
93            "archive_name": name,
94            "message_count": 0,
95            "path": rel_path(&self.root, &archive_dir),
96        });
97        if let Some(message_id) = message_id {
98            self.ensure_no_related_conversation(message_id)?;
99            let archived_rfc3339 = self.set_direct_message_archive(message_id, &archive_uid)?;
100            self.upsert_archive_message_item(&archive_uid, message_id, summary, &archived_rfc3339)?;
101            self.refresh_archive_message_category(&archive_uid)?;
102            let queue =
103                self.queue_archive_for_archived_messages(&[message_id.to_string()], None)?;
104            result = json!({
105                "code": "archive_message_created",
106                "archive_uid": archive_uid,
107                "archive_name": name,
108                "message_id": message_id,
109                "summary": summary,
110                "message_count": 1,
111                "path": rel_path(&self.root, &archive_dir),
112                "eligible_message_ids": queue.eligible_message_ids,
113                "location_count": queue.location_count,
114                "queued_location_count": queue.queued_location_count,
115                "queued": !queue.items.is_empty(),
116                "push_ids": queue.items.iter().map(|item| item.push_id.clone()).collect::<Vec<_>>(),
117                "push_id": queue.items.first().map(|item| item.push_id.clone())
118            });
119        } else {
120            self.refresh_archive_message_category(&archive_uid)?;
121        }
122        self.append_audit_event(
123            "archive_message_created",
124            vec![audit_target("archive", &archive_uid)],
125            reason.map(str::trim).filter(|value| !value.is_empty()),
126            json!({
127                "archive_uid": archive_uid,
128                "archive_name": name,
129                "message_id": message_id,
130                "summary": summary,
131                "path": rel_path(&self.root, &archive_dir),
132            }),
133        )?;
134        Ok(result)
135    }
136
137    pub fn archive_list(&self) -> Result<Value> {
138        self.require_workspace()?;
139        let cases = self.archive_case_items()?;
140        let messages = self.archive_message_category_items()?;
141        Ok(json!({
142            "code": "archive_list",
143            "case_count": cases.len(),
144            "message_count": messages.len(),
145            "case_path_templates": {
146                "case_path": "archive/cases/{case_dir}",
147                "view_path": "archive/cases/{case_dir}/case.md",
148                "data_path": "archive/cases/{case_dir}/data/case.json",
149            },
150            "message_path_templates": {
151                "archive_path": "archive/notifications/{archive_dir}",
152                "view_path": "archive/notifications/{archive_dir}/archive.md",
153                "data_path": "archive/notifications/{archive_dir}/data/archive.json",
154            },
155            "cases": cases,
156            "messages": messages,
157        }))
158    }
159
160    pub fn archive_list_cases(&self) -> Result<Value> {
161        self.require_workspace()?;
162        let cases = self.archive_case_items()?;
163        Ok(json!({
164            "code": "archive_case_list",
165            "count": cases.len(),
166            "path_templates": {
167                "case_path": "archive/cases/{case_dir}",
168                "view_path": "archive/cases/{case_dir}/case.md",
169                "data_path": "archive/cases/{case_dir}/data/case.json",
170            },
171            "items": cases,
172        }))
173    }
174
175    pub fn archive_list_messages(&self) -> Result<Value> {
176        self.require_workspace()?;
177        let messages = self.archive_message_category_items()?;
178        Ok(json!({
179            "code": "archive_message_list",
180            "count": messages.len(),
181            "path_templates": {
182                "archive_path": "archive/notifications/{archive_dir}",
183                "view_path": "archive/notifications/{archive_dir}/archive.md",
184                "data_path": "archive/notifications/{archive_dir}/data/archive.json",
185            },
186            "items": messages,
187        }))
188    }
189
190    pub fn archive_message_show(&self, archive_ref: &str) -> Result<Value> {
191        self.require_workspace()?;
192        let (archive_uid, archive_dir) = self.resolve_archive_message_category(archive_ref)?;
193        self.refresh_archive_message_category(&archive_uid)?;
194        let data = self.read_archive_messages(&archive_uid)?;
195        let archive_path = self.archive_message_index_path(&archive_uid);
196        let text = read_to_string(&archive_path, "read archive message view")?;
197        Ok(json!({
198            "code": "archive_message",
199            "archive_uid": archive_uid,
200            "archive_name": data.archive_name,
201            "path": rel_path(&self.root, &archive_dir),
202            "view_path": rel_path(&self.root, &archive_path),
203            "notes_path": rel_path(&self.root, &archive_dir.join("notes.md")),
204            "message_count": data.items.len(),
205            "items": data.items,
206            "text": text,
207        }))
208    }
209
210    pub fn archive_message_restore(
211        &self,
212        archive_ref: &str,
213        message_id: &str,
214        reason: Option<&str>,
215    ) -> Result<Value> {
216        self.require_workspace()?;
217        let reason = self.checked_reason(reason)?;
218        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
219        self.restore_direct_archive_message(
220            &archive_uid,
221            message_id,
222            reason,
223            "message_restored",
224            "message_restored",
225        )
226    }
227
228    pub(super) fn restore_direct_archive_message(
229        &self,
230        archive_uid: &str,
231        message_id: &str,
232        reason: Option<&str>,
233        event_kind: &str,
234        result_code: &str,
235    ) -> Result<Value> {
236        validate_archive_uid(archive_uid)?;
237        validate_id("message_id", message_id)?;
238        let mut message = self.read_message_by_id(message_id)?;
239        if message.workspace.archive_uid.as_deref() != Some(archive_uid) {
240            return Err(AppError::new(
241                "archive_entry_not_found",
242                format!("message {message_id} is not in archive {archive_uid}"),
243            ));
244        }
245        let removed_push = crate::push_queue::remove_pending_message_pushes(
246            &self.root,
247            message_id,
248            "message.archive",
249        )?;
250        let push_ids = removed_push
251            .iter()
252            .map(|item| item.push_id.clone())
253            .collect::<Vec<_>>();
254        self.remove_archive_message_item(archive_uid, message_id)?;
255        let view_path = self.archive_message_view_path(archive_uid, message_id);
256        if view_path.exists() {
257            remove_file(&view_path)?;
258        }
259        message.workspace.status = "triage".to_string();
260        message.workspace.archive_uid = None;
261        message.workspace.archived_rfc3339 = None;
262        message.workspace.origin = None;
263        message.workspace.remote_sync = None;
264        self.write_message_cache(&message)?;
265        self.refresh_message_after_ref_change(message_id)?;
266        self.clear_message_pending_pushes(message_id, &push_ids, false)?;
267        self.refresh_archive_message_category(archive_uid)?;
268        self.append_audit_event(
269            event_kind,
270            vec![
271                audit_target("message", message_id),
272                audit_target("archive", archive_uid),
273            ],
274            reason,
275            json!({
276                "message_id": message_id,
277                "archive_uid": archive_uid,
278                "from_path": format!("archive/notifications/{archive_uid}/views/messages/{message_id}.md"),
279                "to_path": format!("triage/{message_id}.md"),
280                "removed_push_ids": push_ids.clone(),
281            }),
282        )?;
283        Ok(json!({
284            "code": result_code,
285            "message_id": message_id,
286            "archive_uid": archive_uid,
287            "triage_path": format!("triage/{message_id}.md"),
288            "removed_push_count": push_ids.len(),
289            "push_ids": push_ids,
290        }))
291    }
292
293    pub fn archive_message_move(
294        &self,
295        archive_ref: &str,
296        message_id: &str,
297        new_archive_ref: &str,
298        reason: Option<&str>,
299    ) -> Result<Value> {
300        self.require_workspace()?;
301        let reason = self.checked_reason(reason)?;
302        validate_id("message_id", message_id)?;
303        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
304        let (new_archive_uid, _) = self.resolve_archive_message_category(new_archive_ref)?;
305        if archive_uid == new_archive_uid {
306            return Ok(json!({
307                "code": "message_archive_moved",
308                "message_id": message_id,
309                "archive_uid": archive_uid,
310                "new_archive_uid": new_archive_uid,
311                "changed": false,
312            }));
313        }
314        let mut old_data = self.read_archive_messages(&archive_uid)?;
315        let pos = old_data
316            .items
317            .iter()
318            .position(|item| item.message_id == message_id)
319            .ok_or_else(|| {
320                AppError::new(
321                    "archive_entry_not_found",
322                    format!("message {message_id} is not in archive {archive_uid}"),
323                )
324            })?;
325        let item = old_data.items.remove(pos);
326        self.write_archive_messages(&archive_uid, &old_data)?;
327        let old_view = self.archive_message_view_path(&archive_uid, message_id);
328        if old_view.exists() {
329            remove_file(&old_view)?;
330        }
331        let mut message = self.read_message_by_id(message_id)?;
332        message.workspace.status = "archived".to_string();
333        message.workspace.archive_uid = Some(new_archive_uid.to_string());
334        message.workspace.archived_rfc3339 = Some(item.archived_rfc3339.clone());
335        message.workspace.remote_sync = None;
336        self.write_message_cache(&message)?;
337        self.upsert_archive_message_item(
338            &new_archive_uid,
339            message_id,
340            item.summary.as_deref(),
341            &item.archived_rfc3339,
342        )?;
343        self.refresh_archive_message_category(&archive_uid)?;
344        self.refresh_archive_message_category(&new_archive_uid)?;
345        self.append_audit_event(
346            "message_archive_moved",
347            vec![
348                audit_target("message", message_id),
349                audit_target("archive", &archive_uid),
350                audit_target("archive", &new_archive_uid),
351            ],
352            reason,
353            json!({
354                "message_id": message_id,
355                "from_archive_uid": archive_uid,
356                "archive_uid": new_archive_uid,
357            }),
358        )?;
359        Ok(json!({
360            "code": "message_archive_moved",
361            "message_id": message_id,
362            "from_archive_uid": archive_uid,
363            "archive_uid": new_archive_uid,
364            "changed": true,
365        }))
366    }
367
368    pub fn archive_message_rename(
369        &self,
370        archive_ref: &str,
371        name: &str,
372        reason: Option<&str>,
373    ) -> Result<Value> {
374        self.require_workspace()?;
375        let reason = self.checked_reason(reason)?;
376        validate_name("archive_name", name)?;
377        let (archive_uid, from) = self.resolve_archive_message_category(archive_ref)?;
378        let mut data = self.read_archive_messages(&archive_uid)?;
379        let old_name = data.archive_name.clone();
380        let to = self.archive_message_dir_for_name(&archive_uid, name);
381        let changed_path = to != from;
382        if changed_path && to.exists() {
383            return Err(AppError::new(
384                "archive_exists",
385                format!(
386                    "archive message category already exists: {}",
387                    path_to_string(&to)
388                ),
389            ));
390        }
391        if let Some(parent) = to.parent() {
392            create_dir_all(parent)?;
393        }
394        if changed_path {
395            fs::rename(&from, &to)
396                .map_err(|e| AppError::io("rename archive message category", &e))?;
397        }
398        data.archive_name = name.to_string();
399        self.write_archive_messages(&archive_uid, &data)?;
400        self.refresh_archive_message_category(&archive_uid)?;
401        self.append_audit_event(
402            "message_archive_category_renamed",
403            vec![audit_target("archive", &archive_uid)],
404            reason,
405            json!({
406                "archive_uid": archive_uid,
407                "old_archive_name": old_name,
408                "archive_name": name,
409                "message_count": data.items.len(),
410            }),
411        )?;
412        Ok(json!({
413            "code": "message_archive_category_renamed",
414            "archive_uid": archive_uid,
415            "old_archive_name": old_name,
416            "archive_name": name,
417            "path": rel_path(&self.root, &to),
418            "message_count": data.items.len(),
419            "changed": old_name != name || changed_path,
420        }))
421    }
422
423    pub fn archive_message_set_summary(
424        &self,
425        archive_ref: &str,
426        message_id: &str,
427        summary: &str,
428        reason: Option<&str>,
429    ) -> Result<Value> {
430        self.require_workspace()?;
431        let reason = self.checked_reason(reason)?;
432        validate_id("message_id", message_id)?;
433        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
434        self.update_archive_message_summary(&archive_uid, message_id, summary)?;
435        self.refresh_archive_message_category(&archive_uid)?;
436        self.append_audit_event(
437            "message_archive_summary_set",
438            vec![
439                audit_target("message", message_id),
440                audit_target("archive", &archive_uid),
441            ],
442            reason,
443            json!({
444                "message_id": message_id,
445                "archive_uid": archive_uid,
446                "summary": summary,
447            }),
448        )?;
449        Ok(json!({
450            "code": "message_archive_summary_set",
451            "message_id": message_id,
452            "archive_uid": archive_uid,
453            "summary": summary,
454        }))
455    }
456
457    pub fn archive_message_notes_show(&self, archive_ref: &str) -> Result<Value> {
458        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
459        let path = self.archive_message_notes_path(&archive_uid);
460        self.notes_show(
461            "archive_message_notes",
462            vec![audit_target("archive", &archive_uid)],
463            &path,
464        )
465    }
466
467    pub fn archive_message_notes_append(&self, archive_ref: &str, text: &str) -> Result<Value> {
468        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
469        let path = self.archive_message_notes_path(&archive_uid);
470        self.notes_append(
471            "archive_message_notes_appended",
472            vec![audit_target("archive", &archive_uid)],
473            &path,
474            text,
475        )
476    }
477
478    pub fn archive_message_notes_replace(&self, archive_ref: &str, text: &str) -> Result<Value> {
479        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
480        let path = self.archive_message_notes_path(&archive_uid);
481        self.notes_replace(
482            "archive_message_notes_replaced",
483            vec![audit_target("archive", &archive_uid)],
484            &path,
485            text,
486        )
487    }
488
489    pub(super) fn set_direct_message_archive(
490        &self,
491        message_id: &str,
492        archive_uid: &str,
493    ) -> Result<String> {
494        let now = now_rfc3339();
495        let mut msg = self.read_message_by_id(message_id)?;
496        if let Some(existing) = msg.workspace.archive_uid.as_deref() {
497            if existing != archive_uid {
498                return Err(AppError::new(
499                    "message_already_archived",
500                    format!(
501                        "message {message_id} is already archived in {existing}; use archive message {existing} move"
502                    ),
503                ));
504            }
505        }
506        msg.workspace.status = "archived".to_string();
507        msg.workspace.archive_uid = Some(archive_uid.to_string());
508        let archived_rfc3339 = msg
509            .workspace
510            .archived_rfc3339
511            .clone()
512            .unwrap_or_else(|| now.clone());
513        msg.workspace.archived_rfc3339 = Some(archived_rfc3339.clone());
514        msg.workspace.origin = None;
515        msg.workspace.remote_sync = None;
516        self.write_message_cache(&msg)?;
517        self.remove_triage_view_for_message(message_id)?;
518        Ok(archived_rfc3339)
519    }
520
521    pub(super) fn archive_case_path_for_name(&self, case_uid: &str, name: &str) -> PathBuf {
522        self.root
523            .join("archive")
524            .join("cases")
525            .join(case_dir_name(case_uid, name))
526    }
527
528    pub(super) fn archive_message_dir(&self, archive_uid: &str) -> PathBuf {
529        self.find_archive_message_dir_by_uid(archive_uid)
530            .ok()
531            .flatten()
532            .unwrap_or_else(|| {
533                self.root
534                    .join("archive")
535                    .join("notifications")
536                    .join(archive_uid)
537            })
538    }
539
540    pub(super) fn archive_message_dir_for_name(&self, archive_uid: &str, name: &str) -> PathBuf {
541        self.root
542            .join("archive")
543            .join("notifications")
544            .join(archive_dir_name(archive_uid, name))
545    }
546
547    pub(super) fn archive_message_index_path(&self, archive_uid: &str) -> PathBuf {
548        self.archive_message_dir(archive_uid).join("archive.md")
549    }
550
551    pub(super) fn archive_message_notes_path(&self, archive_uid: &str) -> PathBuf {
552        self.archive_message_dir(archive_uid).join("notes.md")
553    }
554
555    pub(super) fn archive_message_json_path(&self, archive_uid: &str) -> PathBuf {
556        self.archive_message_dir(archive_uid)
557            .join("data")
558            .join("archive.json")
559    }
560
561    pub(super) fn archive_message_view_path(&self, archive_uid: &str, message_id: &str) -> PathBuf {
562        self.archive_message_dir(archive_uid)
563            .join("views")
564            .join("messages")
565            .join(format!("{message_id}.md"))
566    }
567
568    pub(super) fn read_archive_messages(&self, archive_uid: &str) -> Result<ArchiveMessages> {
569        validate_archive_uid(archive_uid)?;
570        let path = self.archive_message_json_path(archive_uid);
571        if !path.exists() {
572            return Ok(ArchiveMessages::new(archive_uid, ""));
573        }
574        let data = read_to_string(&path, "read archive messages")?;
575        let mut messages: ArchiveMessages = serde_json::from_str(&data)
576            .map_err(|e| AppError::json("parse archive messages", &e))?;
577        if messages.schema_name != "archive_messages" || messages.schema_version != 1 {
578            return Err(AppError::new(
579                "archive_messages_invalid",
580                format!(
581                    "invalid archive messages schema: {}",
582                    rel_path(&self.root, &path)
583                ),
584            ));
585        }
586        messages.archive_uid = archive_uid.to_string();
587        Ok(messages)
588    }
589
590    pub(super) fn write_archive_messages(
591        &self,
592        archive_uid: &str,
593        data: &ArchiveMessages,
594    ) -> Result<()> {
595        self.write_archive_messages_named(archive_uid, &data.archive_name, data)
596    }
597
598    pub(super) fn write_archive_messages_named(
599        &self,
600        archive_uid: &str,
601        archive_name: &str,
602        data: &ArchiveMessages,
603    ) -> Result<()> {
604        let mut normalized = data.clone();
605        normalized.schema_name = "archive_messages".to_string();
606        normalized.schema_version = 1;
607        normalized.archive_uid = archive_uid.to_string();
608        normalized.archive_name = archive_name.to_string();
609        normalized
610            .items
611            .sort_by(|a, b| a.message_id.cmp(&b.message_id));
612        normalized
613            .items
614            .dedup_by(|a, b| a.message_id == b.message_id);
615        write_json_pretty(&self.archive_message_json_path(archive_uid), &normalized)
616    }
617
618    pub(super) fn upsert_archive_message_item(
619        &self,
620        archive_uid: &str,
621        message_id: &str,
622        summary: Option<&str>,
623        archived_rfc3339: &str,
624    ) -> Result<()> {
625        let mut data = self.read_archive_messages(archive_uid)?;
626        let summary = summary
627            .map(str::trim)
628            .filter(|value| !value.is_empty())
629            .map(ToString::to_string);
630        if let Some(item) = data
631            .items
632            .iter_mut()
633            .find(|item| item.message_id == message_id)
634        {
635            if summary.is_some() {
636                item.summary = summary;
637            }
638            item.archived_rfc3339 = archived_rfc3339.to_string();
639        } else {
640            data.items.push(ArchiveMessageItem {
641                message_id: message_id.to_string(),
642                summary,
643                archived_rfc3339: archived_rfc3339.to_string(),
644            });
645        }
646        self.write_archive_messages(archive_uid, &data)
647    }
648
649    pub(super) fn remove_archive_message_item(
650        &self,
651        archive_uid: &str,
652        message_id: &str,
653    ) -> Result<()> {
654        let mut data = self.read_archive_messages(archive_uid)?;
655        let before = data.items.len();
656        data.items.retain(|item| item.message_id != message_id);
657        if data.items.len() == before {
658            return Err(AppError::new(
659                "archive_entry_not_found",
660                format!("message {message_id} is not in archive {archive_uid}"),
661            ));
662        }
663        self.write_archive_messages(archive_uid, &data)
664    }
665
666    pub(super) fn update_archive_message_summary(
667        &self,
668        archive_uid: &str,
669        message_id: &str,
670        summary: &str,
671    ) -> Result<()> {
672        let mut data = self.read_archive_messages(archive_uid)?;
673        let item = data
674            .items
675            .iter_mut()
676            .find(|item| item.message_id == message_id)
677            .ok_or_else(|| {
678                AppError::new(
679                    "archive_entry_not_found",
680                    format!("message {message_id} is not in archive {archive_uid}"),
681                )
682            })?;
683        item.summary = Some(summary.trim().to_string()).filter(|value| !value.is_empty());
684        self.write_archive_messages(archive_uid, &data)
685    }
686
687    pub(super) fn refresh_archive_indexes(&self) -> Result<()> {
688        create_dir_all(&self.root.join("archive/cases"))?;
689        create_dir_all(&self.root.join("archive/notifications"))?;
690        let language = self.template_language()?;
691        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
692        for archive_uid in self.archive_message_category_ids()? {
693            self.refresh_archive_message_category_with_renderer(&archive_uid, &mut renderer, true)?;
694        }
695        Ok(())
696    }
697
698    pub(super) fn refresh_archive_message_category(&self, archive_uid: &str) -> Result<()> {
699        let language = self.template_language()?;
700        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
701        self.refresh_archive_message_category_with_renderer(archive_uid, &mut renderer, true)?;
702        Ok(())
703    }
704
705    pub(super) fn refresh_archive_message_category_with_renderer(
706        &self,
707        archive_uid: &str,
708        renderer: &mut MarkdownTemplateRenderer<'_>,
709        sync_state: bool,
710    ) -> Result<ArchiveMessageViewRefresh> {
711        validate_archive_uid(archive_uid)?;
712        let archive_dir = self.archive_message_dir(archive_uid);
713        let messages_dir = archive_dir.join("views").join("messages");
714        create_dir_all(&messages_dir)?;
715        let mut data = self.read_archive_messages(archive_uid)?;
716        if sync_state {
717            data.items.retain(|item| {
718                self.read_message_by_id(&item.message_id)
719                    .map(|message| message.workspace.archive_uid.as_deref() == Some(archive_uid))
720                    .unwrap_or(false)
721            });
722            self.write_archive_messages(archive_uid, &data)?;
723        }
724        let items = data
725            .items
726            .iter()
727            .filter(|item| {
728                self.read_message_by_id(&item.message_id)
729                    .map(|message| message.workspace.archive_uid.as_deref() == Some(archive_uid))
730                    .unwrap_or(false)
731            })
732            .cloned()
733            .collect::<Vec<_>>();
734        let desired = items
735            .iter()
736            .map(|item| item.message_id.clone())
737            .collect::<BTreeSet<_>>();
738        let mut message_count = 0usize;
739        for item in &items {
740            let message = self.read_message_by_id(&item.message_id)?;
741            let view_path = self.archive_message_view_path(archive_uid, &item.message_id);
742            write_string(
743                &view_path,
744                &self.render_archive_message_view(
745                    &message,
746                    archive_uid,
747                    &data.archive_name,
748                    item,
749                    renderer,
750                    view_path.parent(),
751                )?,
752            )?;
753            message_count += 1;
754        }
755        if messages_dir.exists() {
756            for entry in read_dir(&messages_dir, "read archive message views")? {
757                let path = entry.path();
758                if path.extension().and_then(|s| s.to_str()) != Some("md") {
759                    continue;
760                }
761                let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
762                    continue;
763                };
764                if !desired.contains(stem) {
765                    remove_file(&path)?;
766                }
767            }
768        }
769        let config = MailConfig::load(&self.root)?;
770        write_string(
771            &archive_dir.join("archive.md"),
772            &self.render_archive_message_index(
773                archive_uid,
774                &data.archive_name,
775                &items,
776                &config,
777                renderer,
778            )?,
779        )?;
780        Ok(ArchiveMessageViewRefresh {
781            archive_message_index_count: 1,
782            archive_message_count: message_count,
783        })
784    }
785
786    pub(super) fn render_archive_message_view(
787        &self,
788        message: &MessageFile,
789        archive_uid: &str,
790        archive_name: &str,
791        item: &ArchiveMessageItem,
792        renderer: &mut MarkdownTemplateRenderer<'_>,
793        output_dir: Option<&Path>,
794    ) -> Result<String> {
795        let config = MailConfig::load(&self.root)?;
796        let title = message.subject.as_deref().unwrap_or("");
797        let message_value = message_template_value(message)?;
798        let item_value = serde_json::to_value(item)
799            .map_err(|e| AppError::json("serialize archive message item", &e))?;
800        let generated_rfc3339 = now_rfc3339();
801        let conversation =
802            self.message_conversation_with_renderer(message, &config, renderer, output_dir)?;
803        let context = json!({
804            "frontmatter": {
805                "kind": "archive_message",
806                "message_id": message.message_id.as_str(),
807                "archive_uid": archive_uid,
808                "archive_name": archive_name,
809                "archived_rfc3339": item.archived_rfc3339.as_str(),
810                "generated_rfc3339": generated_rfc3339.as_str(),
811            },
812            "language": config.resolved_language_bcp47(),
813            "archive_uid": archive_uid,
814            "archive_name": archive_name,
815            "message_id": message.message_id.as_str(),
816            "title": title,
817            "summary": item.summary.as_deref().unwrap_or(""),
818            "archived_rfc3339": item.archived_rfc3339.as_str(),
819            "generated_rfc3339": generated_rfc3339.as_str(),
820            "conversation": conversation.trim(),
821            "message": message_value,
822            "item": item_value,
823        });
824        renderer.render(TemplateKey::ArchiveMessage, &context)
825    }
826
827    pub(super) fn render_archive_message_index(
828        &self,
829        archive_uid: &str,
830        archive_name: &str,
831        data_items: &[ArchiveMessageItem],
832        config: &MailConfig,
833        renderer: &mut MarkdownTemplateRenderer<'_>,
834    ) -> Result<String> {
835        let mut items = data_items.to_vec();
836        items.sort_by(|a, b| {
837            let a_time = self
838                .read_message_by_id(&a.message_id)
839                .ok()
840                .and_then(|message| message_time(&message))
841                .unwrap_or_else(|| a.archived_rfc3339.clone());
842            let b_time = self
843                .read_message_by_id(&b.message_id)
844                .ok()
845                .and_then(|message| message_time(&message))
846                .unwrap_or_else(|| b.archived_rfc3339.clone());
847            compare_rfc3339_asc(&b_time, &a_time).then_with(|| a.message_id.cmp(&b.message_id))
848        });
849        let mut rendered_items = Vec::new();
850        let offset = config.resolved_timezone_offset();
851        for item in &items {
852            let message = self.read_message_by_id(&item.message_id)?;
853            let fields = config
854                .archive
855                .message_index
856                .item_fields
857                .iter()
858                .filter_map(|field| archive_index_field_value(*field, &message, item, &offset))
859                .collect::<Vec<_>>();
860            let has_message_id = config
861                .archive
862                .message_index
863                .item_fields
864                .contains(&ArchiveMessageIndexField::MessageId);
865            let has_link = config
866                .archive
867                .message_index
868                .item_fields
869                .contains(&ArchiveMessageIndexField::Link);
870            let mut second = Vec::new();
871            if !has_message_id || !has_link {
872                if !has_message_id {
873                    second.push(json!({
874                        "kind": "message_id",
875                        "value": item.message_id.as_str(),
876                    }));
877                }
878                if !has_link {
879                    second.push(json!({
880                        "kind": "link",
881                        "href": format!("views/messages/{}.md", item.message_id),
882                    }));
883                }
884            }
885            let title = item
886                .summary
887                .as_deref()
888                .filter(|value| !value.trim().is_empty())
889                .or(message.subject.as_deref())
890                .unwrap_or(item.message_id.as_str())
891                .to_string();
892            let mut rendered_item = thread_item_common(
893                &message,
894                &offset,
895                config.template_language(),
896                format!("views/messages/{}.md", item.message_id),
897                title,
898            )?;
899            if let Value::Object(map) = &mut rendered_item {
900                map.insert(
901                    "summary".to_string(),
902                    json!(item.summary.as_deref().unwrap_or("")),
903                );
904                map.insert(
905                    "display_summary".to_string(),
906                    json!(item
907                        .summary
908                        .as_deref()
909                        .map(markdown_inline)
910                        .unwrap_or_default()),
911                );
912                map.insert(
913                    "archived_rfc3339".to_string(),
914                    json!(item.archived_rfc3339.as_str()),
915                );
916                map.insert(
917                    "archived_time".to_string(),
918                    time_context(&item.archived_rfc3339, &offset),
919                );
920                map.insert("fields".to_string(), json!(fields));
921                map.insert("secondary".to_string(), json!(second));
922                map.insert(
923                    "item".to_string(),
924                    serde_json::to_value(item)
925                        .map_err(|e| AppError::json("serialize archive message item", &e))?,
926                );
927            }
928            rendered_items.push(rendered_item);
929        }
930        let context = json!({
931            "archive_uid": archive_uid,
932            "archive_name": archive_name,
933            "message_count": rendered_items.len(),
934            "generated_rfc3339": now_rfc3339(),
935            "language": config.resolved_language_bcp47(),
936            "items": rendered_items,
937            "config": {
938                "archive": {
939                    "message_index": {
940                        "item_fields": config.archive.message_index.item_fields
941                            .iter()
942                            .map(|field| field.as_str())
943                            .collect::<Vec<_>>(),
944                    },
945                },
946            },
947        });
948        renderer.render(TemplateKey::ArchiveMessageIndex, &context)
949    }
950
951    pub(super) fn archive_message_category_ids(&self) -> Result<Vec<String>> {
952        let dir = self.root.join("archive/notifications");
953        if !dir.exists() {
954            return Ok(Vec::new());
955        }
956        let mut ids = Vec::new();
957        for entry in read_dir(&dir, "read archive messages directory")? {
958            let path = entry.path();
959            if path.is_dir() {
960                if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
961                    if path.join("data").join("archive.json").is_file() {
962                        if let Some(uid) = archive_uid_from_dir_name(name) {
963                            ids.push(uid);
964                        }
965                    }
966                }
967            }
968        }
969        ids.sort();
970        ids.dedup();
971        Ok(ids)
972    }
973
974    pub(super) fn archive_message_category_items(&self) -> Result<Vec<Value>> {
975        let mut out = Vec::new();
976        for archive_uid in self.archive_message_category_ids()? {
977            let data = self.read_archive_messages(&archive_uid)?;
978            let path = self.archive_message_dir(&archive_uid);
979            out.push(json!({
980                "archive_uid": archive_uid,
981                "archive_name": data.archive_name,
982                "archive_dir": path
983                    .file_name()
984                    .and_then(|s| s.to_str())
985                    .unwrap_or_default(),
986            }));
987        }
988        Ok(out)
989    }
990
991    pub(super) fn resolve_archive_message_category(
992        &self,
993        archive_ref: &str,
994    ) -> Result<(String, PathBuf)> {
995        let archive_uid = parse_archive_ref(archive_ref)?;
996        self.find_archive_message_dir_by_uid(&archive_uid)?
997            .map(|path| (archive_uid.clone(), path))
998            .ok_or_else(|| {
999                AppError::new(
1000                    "archive_not_found",
1001                    format!("archive message category not found: {archive_uid}"),
1002                )
1003            })
1004    }
1005
1006    pub(super) fn find_archive_message_dir_by_uid(
1007        &self,
1008        archive_uid: &str,
1009    ) -> Result<Option<PathBuf>> {
1010        validate_archive_uid(archive_uid)?;
1011        let dir = self.root.join("archive/notifications");
1012        if !dir.exists() {
1013            return Ok(None);
1014        }
1015        let mut matches = Vec::new();
1016        for entry in read_dir(&dir, "read archive messages directory")? {
1017            let path = entry.path();
1018            if !path.is_dir() {
1019                continue;
1020            }
1021            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1022                continue;
1023            };
1024            if archive_uid_from_dir_name(name).as_deref() == Some(archive_uid) {
1025                matches.push(path);
1026            }
1027        }
1028        match matches.len() {
1029            0 => Ok(None),
1030            1 => Ok(matches.into_iter().next()),
1031            _ => Err(AppError::new(
1032                "duplicate_archive_uid",
1033                format!("duplicate archive uid found: {archive_uid}"),
1034            )),
1035        }
1036    }
1037}