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}