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