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