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.added_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_notification(&archive_uid, name, &now_rfc3339()),
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/notification.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/notification.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.collection_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        old_data.updated_rfc3339 = Some(now_rfc3339());
327        self.write_archive_messages(&archive_uid, &old_data)?;
328        let old_view = self.archive_message_view_path(&archive_uid, message_id);
329        if old_view.exists() {
330            remove_file(&old_view)?;
331        }
332        let mut message = self.read_message_by_id(message_id)?;
333        message.workspace.status = "archived".to_string();
334        message.workspace.archive_uid = Some(new_archive_uid.to_string());
335        message.workspace.archived_rfc3339 = Some(item.added_rfc3339.clone());
336        message.workspace.remote_sync = None;
337        self.write_message_cache(&message)?;
338        self.upsert_archive_message_item(
339            &new_archive_uid,
340            message_id,
341            item.summary.as_deref(),
342            &item.added_rfc3339,
343        )?;
344        self.refresh_archive_message_category(&archive_uid)?;
345        self.refresh_archive_message_category(&new_archive_uid)?;
346        self.append_audit_event(
347            "message_archive_moved",
348            vec![
349                audit_target("message", message_id),
350                audit_target("archive", &archive_uid),
351                audit_target("archive", &new_archive_uid),
352            ],
353            reason,
354            json!({
355                "message_id": message_id,
356                "from_archive_uid": archive_uid,
357                "archive_uid": new_archive_uid,
358            }),
359        )?;
360        Ok(json!({
361            "code": "message_archive_moved",
362            "message_id": message_id,
363            "from_archive_uid": archive_uid,
364            "archive_uid": new_archive_uid,
365            "changed": true,
366        }))
367    }
368
369    pub fn archive_message_rename(
370        &self,
371        archive_ref: &str,
372        name: &str,
373        reason: Option<&str>,
374    ) -> Result<Value> {
375        self.require_workspace()?;
376        let reason = self.checked_reason(reason)?;
377        validate_name("archive_name", name)?;
378        let (archive_uid, from) = self.resolve_archive_message_category(archive_ref)?;
379        let mut data = self.read_archive_messages(&archive_uid)?;
380        let old_name = data.collection_name.clone();
381        let to = self.archive_message_dir_for_name(&archive_uid, name);
382        let changed_path = to != from;
383        if changed_path && to.exists() {
384            return Err(AppError::new(
385                "archive_exists",
386                format!(
387                    "archive message category already exists: {}",
388                    path_to_string(&to)
389                ),
390            ));
391        }
392        if let Some(parent) = to.parent() {
393            create_dir_all(parent)?;
394        }
395        if changed_path {
396            fs::rename(&from, &to)
397                .map_err(|e| AppError::io("rename archive message category", &e))?;
398        }
399        data.collection_name = name.to_string();
400        data.updated_rfc3339 = Some(now_rfc3339());
401        self.write_archive_messages(&archive_uid, &data)?;
402        self.refresh_archive_message_category(&archive_uid)?;
403        self.append_audit_event(
404            "message_archive_category_renamed",
405            vec![audit_target("archive", &archive_uid)],
406            reason,
407            json!({
408                "archive_uid": archive_uid,
409                "old_archive_name": old_name,
410                "archive_name": name,
411                "message_count": data.items.len(),
412            }),
413        )?;
414        Ok(json!({
415            "code": "message_archive_category_renamed",
416            "archive_uid": archive_uid,
417            "old_archive_name": old_name,
418            "archive_name": name,
419            "path": rel_path(&self.root, &to),
420            "message_count": data.items.len(),
421            "changed": old_name != name || changed_path,
422        }))
423    }
424
425    pub fn archive_message_set_summary(
426        &self,
427        archive_ref: &str,
428        message_id: &str,
429        summary: &str,
430        reason: Option<&str>,
431    ) -> Result<Value> {
432        self.require_workspace()?;
433        let reason = self.checked_reason(reason)?;
434        validate_id("message_id", message_id)?;
435        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
436        self.update_archive_message_summary(&archive_uid, message_id, summary)?;
437        self.refresh_archive_message_category(&archive_uid)?;
438        self.append_audit_event(
439            "message_archive_summary_set",
440            vec![
441                audit_target("message", message_id),
442                audit_target("archive", &archive_uid),
443            ],
444            reason,
445            json!({
446                "message_id": message_id,
447                "archive_uid": archive_uid,
448                "summary": summary,
449            }),
450        )?;
451        Ok(json!({
452            "code": "message_archive_summary_set",
453            "message_id": message_id,
454            "archive_uid": archive_uid,
455            "summary": summary,
456        }))
457    }
458
459    pub fn archive_message_notes_show(&self, archive_ref: &str) -> Result<Value> {
460        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
461        let path = self.archive_message_notes_path(&archive_uid);
462        self.notes_show(
463            "archive_message_notes",
464            vec![audit_target("archive", &archive_uid)],
465            &path,
466        )
467    }
468
469    pub fn archive_message_notes_append(&self, archive_ref: &str, text: &str) -> Result<Value> {
470        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
471        let path = self.archive_message_notes_path(&archive_uid);
472        self.notes_append(
473            "archive_message_notes_appended",
474            vec![audit_target("archive", &archive_uid)],
475            &path,
476            text,
477        )
478    }
479
480    pub fn archive_message_notes_replace(&self, archive_ref: &str, text: &str) -> Result<Value> {
481        let (archive_uid, _) = self.resolve_archive_message_category(archive_ref)?;
482        let path = self.archive_message_notes_path(&archive_uid);
483        self.notes_replace(
484            "archive_message_notes_replaced",
485            vec![audit_target("archive", &archive_uid)],
486            &path,
487            text,
488        )
489    }
490
491    pub(super) fn set_direct_message_archive(
492        &self,
493        message_id: &str,
494        archive_uid: &str,
495    ) -> Result<String> {
496        let now = now_rfc3339();
497        let mut msg = self.read_message_by_id(message_id)?;
498        if let Some(existing) = msg.workspace.archive_uid.as_deref() {
499            if existing != archive_uid {
500                return Err(AppError::new(
501                    "message_already_archived",
502                    format!(
503                        "message {message_id} is already archived in {existing}; use archive message {existing} move"
504                    ),
505                ));
506            }
507        }
508        msg.workspace.status = "archived".to_string();
509        msg.workspace.archive_uid = Some(archive_uid.to_string());
510        let archived_rfc3339 = msg
511            .workspace
512            .archived_rfc3339
513            .clone()
514            .unwrap_or_else(|| now.clone());
515        msg.workspace.archived_rfc3339 = Some(archived_rfc3339.clone());
516        msg.workspace.origin = None;
517        msg.workspace.remote_sync = None;
518        self.clear_message_from_all_dispositions(message_id)?;
519        self.write_message_materialized_cache(&msg)?;
520        self.remove_triage_view_for_message(message_id)?;
521        Ok(archived_rfc3339)
522    }
523
524    pub(super) fn archive_case_path_for_name(&self, case_uid: &str, name: &str) -> PathBuf {
525        self.root
526            .join("archive")
527            .join("cases")
528            .join(case_dir_name(case_uid, name))
529    }
530
531    pub(super) fn archive_message_dir(&self, archive_uid: &str) -> PathBuf {
532        self.find_archive_message_dir_by_uid(archive_uid)
533            .ok()
534            .flatten()
535            .unwrap_or_else(|| {
536                self.root
537                    .join("archive")
538                    .join("notifications")
539                    .join(archive_uid)
540            })
541    }
542
543    pub(super) fn archive_message_dir_for_name(&self, archive_uid: &str, name: &str) -> PathBuf {
544        self.root
545            .join("archive")
546            .join("notifications")
547            .join(archive_dir_name(archive_uid, name))
548    }
549
550    pub(super) fn archive_message_index_path(&self, archive_uid: &str) -> PathBuf {
551        self.archive_message_dir(archive_uid).join("archive.md")
552    }
553
554    pub(super) fn archive_message_notes_path(&self, archive_uid: &str) -> PathBuf {
555        self.archive_message_dir(archive_uid).join("notes.md")
556    }
557
558    pub(super) fn archive_message_json_path(&self, archive_uid: &str) -> PathBuf {
559        self.archive_message_dir(archive_uid)
560            .join("data")
561            .join("notification.json")
562    }
563
564    pub(super) fn archive_message_view_path(&self, archive_uid: &str, message_id: &str) -> PathBuf {
565        self.archive_message_dir(archive_uid)
566            .join("views")
567            .join("messages")
568            .join(format!("{message_id}.md"))
569    }
570
571    pub(super) fn read_archive_messages(&self, archive_uid: &str) -> Result<ArchiveMessages> {
572        validate_archive_uid(archive_uid)?;
573        let path = self.archive_message_json_path(archive_uid);
574        if !path.exists() {
575            return Ok(ArchiveMessages::new_notification(
576                archive_uid,
577                "",
578                &now_rfc3339(),
579            ));
580        }
581        let data = read_to_string(&path, "read archive messages")?;
582        let messages: ArchiveMessages = serde_json::from_str(&data)
583            .map_err(|e| AppError::json("parse archive messages", &e))?;
584        if messages.schema_name != ARCHIVE_NOTIFICATION_SCHEMA_NAME
585            || messages.schema_version != MESSAGE_COLLECTION_SCHEMA_VERSION
586            || messages.collection_uid != archive_uid
587        {
588            return Err(AppError::new(
589                "archive_messages_invalid",
590                format!(
591                    "invalid archive messages schema: {}",
592                    rel_path(&self.root, &path)
593                ),
594            ));
595        }
596        Ok(messages)
597    }
598
599    pub(super) fn write_archive_messages(
600        &self,
601        archive_uid: &str,
602        data: &ArchiveMessages,
603    ) -> Result<()> {
604        self.write_archive_messages_named(archive_uid, &data.collection_name, data)
605    }
606
607    pub(super) fn write_archive_messages_named(
608        &self,
609        archive_uid: &str,
610        archive_name: &str,
611        data: &ArchiveMessages,
612    ) -> Result<()> {
613        let mut normalized = data.clone();
614        normalized.normalize(ARCHIVE_NOTIFICATION_SCHEMA_NAME, archive_uid, archive_name);
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        added_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.added_rfc3339 = added_rfc3339.to_string();
639        } else {
640            data.items.push(ArchiveMessageItem {
641                message_id: message_id.to_string(),
642                summary,
643                added_rfc3339: added_rfc3339.to_string(),
644            });
645        }
646        data.updated_rfc3339 = Some(now_rfc3339());
647        self.write_archive_messages(archive_uid, &data)
648    }
649
650    pub(super) fn remove_archive_message_item(
651        &self,
652        archive_uid: &str,
653        message_id: &str,
654    ) -> Result<()> {
655        let mut data = self.read_archive_messages(archive_uid)?;
656        let before = data.items.len();
657        data.items.retain(|item| item.message_id != message_id);
658        if data.items.len() == before {
659            return Err(AppError::new(
660                "archive_entry_not_found",
661                format!("message {message_id} is not in archive {archive_uid}"),
662            ));
663        }
664        data.updated_rfc3339 = Some(now_rfc3339());
665        self.write_archive_messages(archive_uid, &data)
666    }
667
668    pub(super) fn update_archive_message_summary(
669        &self,
670        archive_uid: &str,
671        message_id: &str,
672        summary: &str,
673    ) -> Result<()> {
674        let mut data = self.read_archive_messages(archive_uid)?;
675        let item = data
676            .items
677            .iter_mut()
678            .find(|item| item.message_id == message_id)
679            .ok_or_else(|| {
680                AppError::new(
681                    "archive_entry_not_found",
682                    format!("message {message_id} is not in archive {archive_uid}"),
683                )
684            })?;
685        item.summary = Some(summary.trim().to_string()).filter(|value| !value.is_empty());
686        data.updated_rfc3339 = Some(now_rfc3339());
687        self.write_archive_messages(archive_uid, &data)
688    }
689
690    pub(super) fn refresh_archive_indexes(&self) -> Result<()> {
691        create_dir_all(&self.root.join("archive/cases"))?;
692        create_dir_all(&self.root.join("archive/notifications"))?;
693        let language = self.template_language()?;
694        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
695        for archive_uid in self.archive_message_category_ids()? {
696            self.refresh_archive_message_category_with_renderer(&archive_uid, &mut renderer, true)?;
697        }
698        Ok(())
699    }
700
701    pub(super) fn refresh_archive_message_category(&self, archive_uid: &str) -> Result<()> {
702        let language = self.template_language()?;
703        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
704        self.refresh_archive_message_category_with_renderer(archive_uid, &mut renderer, true)?;
705        Ok(())
706    }
707
708    pub(super) fn refresh_archive_message_category_with_renderer(
709        &self,
710        archive_uid: &str,
711        renderer: &mut MarkdownTemplateRenderer<'_>,
712        sync_state: bool,
713    ) -> Result<ArchiveMessageViewRefresh> {
714        validate_archive_uid(archive_uid)?;
715        let archive_dir = self.archive_message_dir(archive_uid);
716        let messages_dir = archive_dir.join("views").join("messages");
717        create_dir_all(&messages_dir)?;
718        let mut data = self.read_archive_messages(archive_uid)?;
719        if sync_state {
720            let before = data.items.len();
721            data.items.retain(|item| {
722                self.read_message_by_id(&item.message_id)
723                    .map(|message| message.workspace.archive_uid.as_deref() == Some(archive_uid))
724                    .unwrap_or(false)
725            });
726            if data.items.len() != before {
727                data.updated_rfc3339 = Some(now_rfc3339());
728                self.write_archive_messages(archive_uid, &data)?;
729            }
730        }
731        let items = data
732            .items
733            .iter()
734            .filter(|item| {
735                self.read_message_by_id(&item.message_id)
736                    .map(|message| message.workspace.archive_uid.as_deref() == Some(archive_uid))
737                    .unwrap_or(false)
738            })
739            .cloned()
740            .collect::<Vec<_>>();
741        let desired = items
742            .iter()
743            .map(|item| item.message_id.clone())
744            .collect::<BTreeSet<_>>();
745        let mut message_count = 0usize;
746        for item in &items {
747            let message = self.read_message_by_id(&item.message_id)?;
748            let view_path = self.archive_message_view_path(archive_uid, &item.message_id);
749            write_string(
750                &view_path,
751                &self.render_archive_message_view(
752                    &message,
753                    archive_uid,
754                    &data.collection_name,
755                    item,
756                    renderer,
757                    view_path.parent(),
758                )?,
759            )?;
760            message_count += 1;
761        }
762        if messages_dir.exists() {
763            for entry in read_dir(&messages_dir, "read archive message views")? {
764                let path = entry.path();
765                if path.extension().and_then(|s| s.to_str()) != Some("md") {
766                    continue;
767                }
768                let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
769                    continue;
770                };
771                if !desired.contains(stem) {
772                    remove_file(&path)?;
773                }
774            }
775        }
776        let config = MailConfig::load(&self.root)?;
777        write_string(
778            &archive_dir.join("archive.md"),
779            &self.render_archive_message_index(
780                archive_uid,
781                &data.collection_name,
782                &items,
783                &config,
784                renderer,
785            )?,
786        )?;
787        Ok(ArchiveMessageViewRefresh {
788            archive_message_index_count: 1,
789            archive_message_count: message_count,
790        })
791    }
792
793    pub(super) fn render_archive_message_view(
794        &self,
795        message: &MessageFile,
796        archive_uid: &str,
797        archive_name: &str,
798        item: &ArchiveMessageItem,
799        renderer: &mut MarkdownTemplateRenderer<'_>,
800        output_dir: Option<&Path>,
801    ) -> Result<String> {
802        let config = MailConfig::load(&self.root)?;
803        let title = message.subject.as_deref().unwrap_or("");
804        let message_value = message_template_value(message)?;
805        let item_value = serde_json::to_value(item)
806            .map_err(|e| AppError::json("serialize archive message item", &e))?;
807        let generated_rfc3339 = now_rfc3339();
808        let conversation =
809            self.message_conversation_with_renderer(message, &config, renderer, output_dir)?;
810        let context = json!({
811            "frontmatter": {
812                "kind": "archive_message",
813                "message_id": message.message_id.as_str(),
814                "archive_uid": archive_uid,
815                "archive_name": archive_name,
816                "added_rfc3339": item.added_rfc3339.as_str(),
817                "generated_rfc3339": generated_rfc3339.as_str(),
818            },
819            "archive": {
820                "archive_uid": archive_uid,
821                "archive_name": archive_name,
822            },
823            "message": message_value,
824            "item": item_value,
825            "view": {
826                "language": config.resolved_language_bcp47(),
827                "title": title,
828                "summary": item.summary.as_deref().unwrap_or(""),
829                "added_rfc3339": item.added_rfc3339.as_str(),
830                "generated_rfc3339": generated_rfc3339.as_str(),
831                "conversation": conversation.trim(),
832            },
833        });
834        renderer.render(TemplateKey::ArchiveMessage, &context)
835    }
836
837    pub(super) fn render_archive_message_index(
838        &self,
839        archive_uid: &str,
840        archive_name: &str,
841        data_items: &[ArchiveMessageItem],
842        config: &MailConfig,
843        renderer: &mut MarkdownTemplateRenderer<'_>,
844    ) -> Result<String> {
845        let mut items = data_items.to_vec();
846        items.sort_by(|a, b| {
847            let a_time = self
848                .read_message_by_id(&a.message_id)
849                .ok()
850                .and_then(|message| message_time(&message))
851                .unwrap_or_else(|| a.added_rfc3339.clone());
852            let b_time = self
853                .read_message_by_id(&b.message_id)
854                .ok()
855                .and_then(|message| message_time(&message))
856                .unwrap_or_else(|| b.added_rfc3339.clone());
857            compare_rfc3339_asc(&b_time, &a_time).then_with(|| a.message_id.cmp(&b.message_id))
858        });
859        let mut rendered_items = Vec::new();
860        let offset = config.resolved_timezone_offset();
861        for item in &items {
862            let message = self.read_message_by_id(&item.message_id)?;
863            let fields = config
864                .archive
865                .message_index
866                .item_fields
867                .iter()
868                .filter_map(|field| archive_index_field_value(*field, &message, item, &offset))
869                .collect::<Vec<_>>();
870            let has_message_id = config
871                .archive
872                .message_index
873                .item_fields
874                .contains(&ArchiveMessageIndexField::MessageId);
875            let has_link = config
876                .archive
877                .message_index
878                .item_fields
879                .contains(&ArchiveMessageIndexField::Link);
880            let mut second = Vec::new();
881            if !has_message_id || !has_link {
882                if !has_message_id {
883                    second.push(json!({
884                        "kind": "message_id",
885                        "value": item.message_id.as_str(),
886                    }));
887                }
888                if !has_link {
889                    second.push(json!({
890                        "kind": "link",
891                        "href": format!("views/messages/{}.md", item.message_id),
892                    }));
893                }
894            }
895            let title = item
896                .summary
897                .as_deref()
898                .filter(|value| !value.trim().is_empty())
899                .or(message.subject.as_deref())
900                .unwrap_or(item.message_id.as_str())
901                .to_string();
902            let mut rendered_item = thread_item_common(
903                &message,
904                &offset,
905                config.template_language(),
906                format!("views/messages/{}.md", item.message_id),
907                title,
908            )?;
909            if let Value::Object(map) = &mut rendered_item {
910                if let Some(Value::Object(view)) = map.get_mut("view") {
911                    view.insert(
912                        "summary".to_string(),
913                        json!(item.summary.as_deref().unwrap_or("")),
914                    );
915                    view.insert(
916                        "display_summary".to_string(),
917                        json!(item
918                            .summary
919                            .as_deref()
920                            .map(markdown_inline)
921                            .unwrap_or_default()),
922                    );
923                    view.insert(
924                        "added_rfc3339".to_string(),
925                        json!(item.added_rfc3339.as_str()),
926                    );
927                    view.insert(
928                        "added_time".to_string(),
929                        time_context(&item.added_rfc3339, &offset),
930                    );
931                    view.insert("fields".to_string(), json!(fields));
932                    view.insert("secondary".to_string(), json!(second));
933                }
934                map.insert(
935                    "item".to_string(),
936                    serde_json::to_value(item)
937                        .map_err(|e| AppError::json("serialize archive message item", &e))?,
938                );
939            }
940            rendered_items.push(rendered_item);
941        }
942        let generated_rfc3339 = now_rfc3339();
943        let context = json!({
944            "archive": {
945                "archive_uid": archive_uid,
946                "archive_name": archive_name,
947            },
948            "items": rendered_items,
949            "view": {
950                "language": config.resolved_language_bcp47(),
951                "message_count": rendered_items.len(),
952                "generated_rfc3339": generated_rfc3339,
953            },
954            "config": {
955                "archive": {
956                    "message_index": {
957                        "item_fields": config.archive.message_index.item_fields
958                            .iter()
959                            .map(|field| field.as_str())
960                            .collect::<Vec<_>>(),
961                    },
962                },
963            },
964        });
965        renderer.render(TemplateKey::ArchiveMessageIndex, &context)
966    }
967
968    pub(super) fn archive_message_category_ids(&self) -> Result<Vec<String>> {
969        let dir = self.root.join("archive/notifications");
970        if !dir.exists() {
971            return Ok(Vec::new());
972        }
973        let mut ids = Vec::new();
974        for entry in read_dir(&dir, "read archive messages directory")? {
975            let path = entry.path();
976            if path.is_dir() {
977                if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
978                    if path.join("data").join("notification.json").is_file() {
979                        if let Some(uid) = archive_uid_from_dir_name(name) {
980                            ids.push(uid);
981                        }
982                    }
983                }
984            }
985        }
986        ids.sort();
987        ids.dedup();
988        Ok(ids)
989    }
990
991    pub(super) fn archive_message_category_items(&self) -> Result<Vec<Value>> {
992        let mut out = Vec::new();
993        for archive_uid in self.archive_message_category_ids()? {
994            let data = self.read_archive_messages(&archive_uid)?;
995            let path = self.archive_message_dir(&archive_uid);
996            out.push(json!({
997                "archive_uid": archive_uid,
998                "archive_name": data.collection_name,
999                "archive_dir": path
1000                    .file_name()
1001                    .and_then(|s| s.to_str())
1002                    .unwrap_or_default(),
1003            }));
1004        }
1005        Ok(out)
1006    }
1007
1008    pub(super) fn resolve_archive_message_category(
1009        &self,
1010        archive_ref: &str,
1011    ) -> Result<(String, PathBuf)> {
1012        let archive_uid = parse_archive_ref(archive_ref)?;
1013        self.find_archive_message_dir_by_uid(&archive_uid)?
1014            .map(|path| (archive_uid.clone(), path))
1015            .ok_or_else(|| {
1016                AppError::new(
1017                    "archive_not_found",
1018                    format!("archive message category not found: {archive_uid}"),
1019                )
1020            })
1021    }
1022
1023    pub(super) fn find_archive_message_dir_by_uid(
1024        &self,
1025        archive_uid: &str,
1026    ) -> Result<Option<PathBuf>> {
1027        validate_archive_uid(archive_uid)?;
1028        let dir = self.root.join("archive/notifications");
1029        if !dir.exists() {
1030            return Ok(None);
1031        }
1032        let mut matches = Vec::new();
1033        for entry in read_dir(&dir, "read archive messages directory")? {
1034            let path = entry.path();
1035            if !path.is_dir() {
1036                continue;
1037            }
1038            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1039                continue;
1040            };
1041            if archive_uid_from_dir_name(name).as_deref() == Some(archive_uid) {
1042                matches.push(path);
1043            }
1044        }
1045        match matches.len() {
1046            0 => Ok(None),
1047            1 => Ok(matches.into_iter().next()),
1048            _ => Err(AppError::new(
1049                "duplicate_archive_uid",
1050                format!("duplicate archive uid found: {archive_uid}"),
1051            )),
1052        }
1053    }
1054}