1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
41use std::io::BufRead;
42use std::path::{Path, PathBuf};
43
44use serde::Serialize;
45
46use crate::mailbox::{Envelope, MailAddress, MailKind, Mailbox, ReplyVia, StoredEnvelope};
47use crate::HarnessHomes;
48
49pub const TYPED_ID_PREFIX: &str = "u-";
51
52pub const CHANNEL_ID_PREFIX: &str = "c-";
55
56#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ChannelLine {
60 pub header: String,
62 pub from: String,
64 pub sent_at_ms: u64,
66 pub delivered_at_ms: Option<u64>,
68 pub text: String,
70}
71
72pub fn channel_line_id(address: &MailAddress, header: &str) -> String {
74 let hash = blake3::hash(format!("{address}\n{header}").as_bytes()).to_hex();
75 format!("{CHANNEL_ID_PREFIX}{}", &hash[..24])
76}
77
78fn channel_lines(text: &str, fallback_ms: u64, delivered_at_ms: Option<u64>) -> Vec<ChannelLine> {
81 let starts: Vec<usize> = text
82 .match_indices("[channel: ")
83 .map(|(at, _)| at)
84 .filter(|at| *at == 0 || text[..*at].ends_with('\n'))
85 .collect();
86 starts
87 .iter()
88 .enumerate()
89 .filter_map(|(n, start)| {
90 let end = starts.get(n + 1).copied().unwrap_or(text.len());
91 let line = text[*start..end].trim_end();
92 let header = &line[..line.find(']')? + 1];
93 let field = |name: &str| {
94 header
95 .split(" · ")
96 .find_map(|part| part.strip_prefix(name))
97 .map(|value| value.trim_end_matches(']').trim().to_string())
98 };
99 Some(ChannelLine {
100 header: header.to_string(),
101 from: field("from: ").unwrap_or_else(|| "a channel".to_string()),
102 sent_at_ms: field("at: ")
103 .and_then(|at| supercode_interchange::sidecar::rfc3339_to_ms(&at))
104 .and_then(|at| u64::try_from(at).ok())
105 .unwrap_or(fallback_ms),
106 delivered_at_ms,
107 text: line.to_string(),
108 })
109 })
110 .collect()
111}
112
113pub const ANSWER_ID_PREFIX: &str = "a-";
115
116pub fn answer_id(address: &MailAddress, native_id: &str) -> String {
119 let hash = blake3::hash(format!("{address}\n{native_id}").as_bytes()).to_hex();
120 format!("{ANSWER_ID_PREFIX}{}", &hash[..24])
121}
122
123pub fn user_address(machine: &str) -> std::io::Result<MailAddress> {
126 MailAddress::new(machine.to_string(), "operator", "user")
127 .map_err(|error| std::io::Error::other(error.0))
128}
129
130pub fn typed_line_id(address: &MailAddress, sent_at_ms: u64, text: &str) -> String {
133 let hash = blake3::hash(format!("{address}\n{sent_at_ms}\n{text}").as_bytes()).to_hex();
134 format!("{TYPED_ID_PREFIX}{}", &hash[..24])
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct TypedLine {
140 pub sent_at_ms: u64,
142 pub delivered_at_ms: Option<u64>,
144 pub withdrawn: bool,
146 pub text: String,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Answer {
153 pub native_id: String,
155 pub at_ms: u64,
157 pub text: String,
159}
160
161#[derive(Debug, Clone, Default)]
163pub struct TranscriptMail {
164 pub typed: Vec<TypedLine>,
166 pub answers: Vec<Answer>,
168 pub channel: Vec<ChannelLine>,
170 pub delivered: HashMap<String, u64>,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[serde(tag = "state", rename_all = "snake_case")]
177pub enum Delivery {
178 Delivered {
180 at_ms: u64,
182 },
183 Sent,
185 Withdrawn,
187 Unknown,
190 NotRead,
193}
194
195impl Delivery {
196 pub fn describe(&self) -> String {
198 match self {
199 Self::Delivered { at_ms } => {
200 supercode_interchange::sidecar::ms_to_rfc3339(*at_ms as i64)
201 }
202 Self::Sent => "not yet".to_string(),
203 Self::Withdrawn => "never (taken back)".to_string(),
204 Self::Unknown => "unknown".to_string(),
205 Self::NotRead => "not read here (supercode message show reads it)".to_string(),
206 }
207 }
208}
209
210pub fn transcript(homes: &HarnessHomes, address: &MailAddress) -> Option<PathBuf> {
213 if address.harness != "claude-code" {
214 return None;
215 }
216 let file = format!("{}.jsonl", address.session_id);
217 std::fs::read_dir(&homes.claude_code)
218 .ok()?
219 .flatten()
220 .map(|project| project.path().join(&file))
221 .find(|path| path.is_file())
222}
223
224fn at_ms(value: &serde_json::Value) -> u64 {
225 value
226 .as_str()
227 .and_then(supercode_interchange::sidecar::rfc3339_to_ms)
228 .and_then(|at| u64::try_from(at).ok())
229 .unwrap_or_default()
230}
231
232fn prompt_text(content: &serde_json::Value) -> Option<String> {
233 let text = match content {
234 serde_json::Value::String(text) => text.clone(),
235 serde_json::Value::Array(parts) => parts
236 .iter()
237 .filter(|part| part["type"] == "text")
238 .filter_map(|part| part["text"].as_str())
239 .collect::<Vec<_>>()
240 .join("\n"),
241 _ => return None,
242 };
243 (!text.trim().is_empty()).then_some(text)
244}
245
246fn rendered_mail_ids(line: &str) -> Vec<&str> {
250 let mut ids = Vec::new();
251 for mark in ["cross-session-message id=\\\"", "(message "] {
252 for (at, _) in line.match_indices(mark) {
253 let rest = &line[at + mark.len()..];
254 let end = rest
255 .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
256 .unwrap_or(rest.len());
257 let id = &rest[..end];
258 if id.starts_with("m-") {
259 ids.push(id);
260 }
261 }
262 }
263 ids
264}
265
266fn machine_made(text: &str) -> bool {
269 text.contains("<cross-session-message")
270 || text.starts_with("[Cross-session")
271 || text.starts_with("[channel: ")
272 || text.starts_with("<task-notification>")
273}
274
275pub fn read_claude(path: &Path) -> std::io::Result<TranscriptMail> {
277 let reader = std::io::BufReader::new(std::fs::File::open(path)?);
278 let mut mail = TranscriptMail::default();
279 let mut queued: VecDeque<(String, Option<usize>)> = VecDeque::new();
285 let mut dequeued = 0_usize;
288 let mut absorbed: VecDeque<(String, Option<usize>)> = VecDeque::new();
291 let take = |waiting: &mut VecDeque<(String, Option<usize>)>, text: &str| {
292 let at = waiting.iter().position(|(words, _)| words == text)?;
293 waiting.remove(at).map(|(_, index)| index)
294 };
295 let mut not_typed: HashSet<usize> = HashSet::new();
297 let mut taken_in: HashMap<String, VecDeque<u64>> = HashMap::new();
300 let mut early: Vec<(String, Vec<String>, Option<usize>, u64)> = Vec::new();
304 for line in reader.lines() {
305 let line = line?;
306 let prompt = line.contains("\"promptSource\"") || line.contains("\"type\":\"user\"");
307 let queue = line.contains("\"queue-operation\"");
308 let attachment = line.contains("\"queued_command\"");
309 let answer = line.contains("\"stop_reason\":\"end_turn\"")
310 || line.contains("\"stop_reason\":\"stop_sequence\"");
311 let rendered = line.contains("cross-session-message id=") || line.contains("(message m-");
312 if !(prompt || queue || attachment || rendered || answer) {
313 continue;
314 }
315 let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
316 continue;
317 };
318 if record["isSidechain"] == true {
319 continue;
320 }
321 let kind = record["type"].as_str().unwrap_or_default();
322 let mut at = at_ms(&record["timestamp"]);
323 if kind == "queue-operation" && record["operation"] == "remove" {
324 if let Some(text) = record["content"].as_str() {
325 if let Some(found) = early.iter().position(|(words, ..)| words == text) {
328 let (_, ids, index, _) = early.remove(found);
329 for id in ids {
330 mail.delivered.entry(id).or_insert(at);
331 }
332 if let Some(index) = index {
333 mail.typed[index].delivered_at_ms = Some(at);
334 }
335 continue;
336 }
337 taken_in.entry(text.to_string()).or_default().push_back(at);
338 }
339 }
340 let mut waits_for_remove = false;
341 if kind == "attachment" && record["attachment"]["type"] == "queued_command" {
342 match record["attachment"]["prompt"]
343 .as_str()
344 .and_then(|text| taken_in.get_mut(text))
345 .and_then(VecDeque::pop_front)
346 {
347 Some(taken) => at = taken,
348 None => waits_for_remove = true,
349 }
350 }
351 if waits_for_remove {
352 let attached = &record["attachment"];
353 let text = attached["prompt"].as_str().unwrap_or_default().to_string();
354 let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
355 let ids = rendered_mail_ids(&line)
356 .into_iter()
357 .map(str::to_string)
358 .collect();
359 let index = match take(&mut queued, &text) {
361 Some(Some(index)) if !human => {
362 not_typed.insert(index);
363 None
364 }
365 Some(index) => index,
366 None if human && !text.trim().is_empty() => {
367 mail.typed.push(TypedLine {
368 sent_at_ms: at,
369 delivered_at_ms: None,
370 withdrawn: false,
371 text: text.clone(),
372 });
373 Some(mail.typed.len() - 1)
374 }
375 None => None,
376 };
377 early.push((text, ids, index, at));
378 continue;
379 }
380 if rendered && matches!(kind, "user" | "attachment") {
384 for id in rendered_mail_ids(&line) {
385 mail.delivered.entry(id.to_string()).or_insert(at);
386 }
387 }
388 match kind {
389 "assistant"
391 if answer
392 && matches!(
393 record["message"]["stop_reason"].as_str(),
394 Some("end_turn" | "stop_sequence")
395 ) =>
396 {
397 let (Some(native_id), Some(text)) = (
398 record["uuid"].as_str(),
399 prompt_text(&record["message"]["content"]),
400 ) else {
401 continue;
402 };
403 mail.answers.push(Answer {
404 native_id: native_id.to_string(),
405 at_ms: at,
406 text,
407 });
408 }
409 "queue-operation" if record["operation"] == "dequeue" => dequeued += 1,
411 "queue-operation" => {
412 let Some(text) = record["content"].as_str() else {
413 continue;
414 };
415 match record["operation"].as_str() {
416 Some("enqueue") => {
417 let index = (!machine_made(text) && !text.trim().is_empty()).then(|| {
418 mail.typed.push(TypedLine {
419 sent_at_ms: at,
420 delivered_at_ms: None,
421 withdrawn: false,
422 text: text.to_string(),
423 });
424 mail.typed.len() - 1
425 });
426 queued.push_back((text.to_string(), index));
427 }
428 Some("remove") => {
429 let Some(index) = take(&mut queued, text) else {
430 continue;
431 };
432 match record["reason"].as_str() {
433 Some("absorbed_mid_turn" | "delivered_to_agent") => {
435 if let Some(index) = index {
436 mail.typed[index].delivered_at_ms = Some(at);
437 }
438 absorbed.push_back((text.to_string(), index));
439 }
440 _ => {
442 if let Some(index) = index {
443 mail.typed[index].withdrawn = true;
444 }
445 }
446 }
447 }
448 _ => {}
449 }
450 }
451 "attachment" => {
454 let attached = &record["attachment"];
455 let Some(text) = attached["prompt"].as_str() else {
456 continue;
457 };
458 if attached["type"] != "queued_command" || text.trim().is_empty() {
459 continue;
460 }
461 let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
462 match take(&mut absorbed, text) {
463 Some(Some(index)) if !human => {
464 not_typed.insert(index);
465 }
466 Some(_) => {}
467 None if human => mail.typed.push(TypedLine {
468 sent_at_ms: at_ms(&attached["timestamp"]).min(at),
469 delivered_at_ms: Some(at),
470 withdrawn: false,
471 text: text.to_string(),
472 }),
473 None => {}
474 }
475 }
476 "user" => {
477 let Some(text) = prompt_text(&record["message"]["content"]) else {
478 continue;
479 };
480 let source = record["promptSource"].as_str();
481 let human = matches!(source, Some("typed" | "queued")) && record["isMeta"] != true;
482 if !human {
483 mail.channel.extend(channel_lines(&text, at, Some(at)));
484 }
485 let mut landing: Vec<Option<usize>> = Vec::new();
492 let let_go = std::mem::take(&mut dequeued);
493 if let_go == 0 {
494 landing.extend(take(&mut queued, &text));
495 }
496 let wanted = let_go;
497 while landing.len() < wanted {
498 let Some(at) = queued
499 .iter()
500 .position(|(words, _)| text.contains(words.as_str()))
501 else {
502 break;
503 };
504 landing.extend(queued.remove(at).map(|(_, index)| index));
505 }
506 if landing.is_empty() && wanted > 0 {
507 for _ in 0..wanted {
508 landing.extend(queued.pop_front().map(|(_, index)| index));
509 }
510 }
511 if !landing.is_empty() {
512 for index in landing.into_iter().flatten() {
513 if human {
514 mail.typed[index].delivered_at_ms = Some(at);
515 } else {
516 not_typed.insert(index);
517 }
518 }
519 continue;
520 }
521 if human {
522 mail.typed.push(TypedLine {
523 sent_at_ms: at,
524 delivered_at_ms: Some(at),
525 withdrawn: false,
526 text,
527 });
528 }
529 }
530 _ => {}
531 }
532 }
533 for (_, ids, index, at) in early {
536 for id in ids {
537 mail.delivered.entry(id).or_insert(at);
538 }
539 if let Some(index) = index {
540 mail.typed[index].delivered_at_ms.get_or_insert(at);
541 }
542 }
543 for (text, _) in &queued {
545 if text.starts_with("[channel: ") {
546 mail.channel.extend(channel_lines(text, 0, None));
547 }
548 }
549 let mut index = 0;
550 mail.typed.retain(|_| {
551 index += 1;
552 !not_typed.contains(&(index - 1))
553 });
554 Ok(mail)
555}
556
557pub fn read(homes: &HarnessHomes, address: &MailAddress) -> Option<TranscriptMail> {
559 if address.harness == "codex" {
560 return read_codex_answers(&crate::mail_question::transcript_for(address)?).ok();
561 }
562 read_claude(&transcript(homes, address)?).ok()
563}
564
565fn read_codex_answers(path: &Path) -> std::io::Result<TranscriptMail> {
569 let reader = std::io::BufReader::new(std::fs::File::open(path)?);
570 let mut mail = TranscriptMail::default();
571 for line in reader.lines().map_while(Result::ok) {
572 let rendered = line.contains("cross-session-message id=");
573 if !rendered && !line.contains("\"final_answer\"") {
574 continue;
575 }
576 let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
577 continue;
578 };
579 let item = &record["payload"];
580 if rendered
585 && record["type"] == "response_item"
586 && ((item["type"] == "message" && item["role"] == "user")
587 || matches!(
588 item["type"].as_str(),
589 Some("function_call_output" | "custom_tool_call_output")
590 ))
591 {
592 let at = at_ms(&record["timestamp"]);
593 for id in codex_rendered_mail_ids(&line) {
594 mail.delivered.entry(id.to_string()).or_insert(at);
595 }
596 }
597 if record["type"] != "response_item"
598 || item["type"] != "message"
599 || item["role"] != "assistant"
600 || item["phase"] != "final_answer"
601 {
602 continue;
603 }
604 let Some(id) = item["id"].as_str() else {
605 continue;
606 };
607 let text = item["content"]
608 .as_array()
609 .into_iter()
610 .flatten()
611 .filter(|part| part["type"] == "output_text")
612 .filter_map(|part| part["text"].as_str())
613 .collect::<Vec<_>>()
614 .join("\n");
615 if !text.is_empty() {
616 mail.answers.push(Answer {
617 native_id: id.to_string(),
618 at_ms: at_ms(&record["timestamp"]),
619 text,
620 });
621 }
622 }
623 Ok(mail)
624}
625
626fn codex_rendered_mail_ids(line: &str) -> Vec<&str> {
630 let mark = "cross-session-message id=";
631 line.match_indices(mark)
632 .filter_map(|(at, _)| {
633 let rest = line[at + mark.len()..].trim_start_matches(['\\', '"']);
634 let end = rest
635 .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
636 .unwrap_or(rest.len());
637 Some(&rest[..end]).filter(|id| id.starts_with("m-"))
638 })
639 .collect()
640}
641
642fn newest_before(items: &[(u64, String)], at: u64, inclusive: bool) -> Option<String> {
645 items
646 .iter()
647 .rev()
648 .find(|(when, _)| if inclusive { *when <= at } else { *when < at })
649 .map(|(_, id)| id.clone())
650}
651
652fn answer_times(address: &MailAddress, mail: &TranscriptMail) -> Vec<(u64, String)> {
654 let mut times: Vec<(u64, String)> = mail
655 .answers
656 .iter()
657 .map(|answer| (answer.at_ms, answer_id(address, &answer.native_id)))
658 .collect();
659 times.sort();
660 times
661}
662
663fn file_derived(
666 mailbox: &Mailbox,
667 filed: &HashMap<&str, &StoredEnvelope>,
668 envelope: Envelope,
669) -> std::io::Result<bool> {
670 if let Some(stored) = filed.get(envelope.id.as_str()) {
671 if stored.envelope.in_reply_to == envelope.in_reply_to {
672 return Ok(false);
673 }
674 std::fs::remove_file(&stored.path).ok();
675 }
676 mailbox.file_read(&envelope)?;
677 Ok(true)
678}
679
680pub fn file_typed_lines(mailbox: &Mailbox, mail: &TranscriptMail) -> std::io::Result<usize> {
683 let address = mailbox.address();
684 let filed = mailbox.list()?;
685 let by_id: HashMap<&str, &StoredEnvelope> = filed
686 .iter()
687 .map(|stored| (stored.envelope.id.as_str(), stored))
688 .collect();
689 let answers = answer_times(address, mail);
692 let mut typed_by_supercode: HashMap<&str, usize> = HashMap::new();
695 for stored in &filed {
696 if stored.envelope.kind == MailKind::User
697 && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
698 {
699 *typed_by_supercode
700 .entry(stored.envelope.body.trim())
701 .or_default() += 1;
702 }
703 }
704 let user = user_address(&address.machine)?;
705 let current: HashSet<String> = mail
709 .typed
710 .iter()
711 .map(|line| typed_line_id(address, line.sent_at_ms, &line.text))
712 .collect();
713 for stored in &filed {
714 if stored.envelope.id.starts_with(TYPED_ID_PREFIX)
715 && !stored
716 .envelope
717 .in_reply_to
718 .as_deref()
719 .is_some_and(|id| id.starts_with("q-"))
720 && !current.contains(&stored.envelope.id)
721 {
722 std::fs::remove_file(&stored.path).ok();
723 }
724 }
725 let mut count = 0;
726 for line in &mail.typed {
727 let id = typed_line_id(address, line.sent_at_ms, &line.text);
728 if !by_id.contains_key(id.as_str()) {
729 if let Some(left) = typed_by_supercode.get_mut(line.text.trim()) {
730 if *left > 0 {
731 *left -= 1;
732 continue;
733 }
734 }
735 }
736 let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
737 count += usize::from(file_derived(
738 mailbox,
739 &by_id,
740 Envelope {
741 id,
742 created_at_ms: line.sent_at_ms,
743 from: user.clone(),
744 from_name: format!("user@{}", address.machine),
745 kind: MailKind::User,
746 reply_via: ReplyVia::None,
747 in_reply_to_inferred: in_reply_to.is_some(),
748 in_reply_to,
749 thread: None,
750 native_from: None,
751 voice_for: None,
752 subject: None,
753 body: line.text.clone(),
754 },
755 )?);
756 }
757 for line in &mail.channel {
758 let id = channel_line_id(address, &line.header);
759 let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
760 count += usize::from(file_derived(
761 mailbox,
762 &by_id,
763 Envelope {
764 id,
765 created_at_ms: line.sent_at_ms,
766 from: MailAddress::new(address.machine.clone(), "operator", "channel")
767 .map_err(|error| std::io::Error::other(error.0))?,
768 from_name: line.from.clone(),
769 kind: MailKind::Channel,
770 reply_via: ReplyVia::None,
771 in_reply_to_inferred: in_reply_to.is_some(),
772 in_reply_to,
773 thread: None,
774 native_from: None,
775 voice_for: None,
776 subject: None,
777 body: line.text.clone(),
778 },
779 )?);
780 }
781 Ok(count)
782}
783
784pub fn file_answers(
788 root: &Path,
789 address: &MailAddress,
790 name: &str,
791 mail: &TranscriptMail,
792) -> std::io::Result<usize> {
793 let recipient =
794 crate::mail_question::creator_for(address).unwrap_or(user_address(&address.machine)?);
795 let mailbox = Mailbox::open(root, &recipient)?;
796 let listed = mailbox.list()?;
797 let mut filed: HashMap<&str, &StoredEnvelope> = HashMap::new();
798 for stored in &listed {
799 if stored.envelope.id.starts_with(ANSWER_ID_PREFIX)
801 && stored.envelope.kind != MailKind::Answer
802 {
803 std::fs::remove_file(&stored.path).ok();
804 continue;
805 }
806 filed.insert(stored.envelope.id.as_str(), stored);
807 }
808 let mut people: Vec<(u64, String)> = mail
811 .typed
812 .iter()
813 .map(|line| {
814 (
815 line.sent_at_ms,
816 typed_line_id(address, line.sent_at_ms, &line.text),
817 )
818 })
819 .chain(
820 mail.channel
821 .iter()
822 .map(|line| (line.sent_at_ms, channel_line_id(address, &line.header))),
823 )
824 .chain(
825 Mailbox::open(root, address)?
826 .list()?
827 .into_iter()
828 .filter(|stored| {
829 stored.envelope.kind == MailKind::User
830 && (!stored.envelope.id.starts_with(TYPED_ID_PREFIX)
831 || stored
832 .envelope
833 .in_reply_to
834 .as_deref()
835 .is_some_and(|id| id.starts_with("q-")))
836 })
837 .map(|stored| (stored.envelope.created_at_ms, stored.envelope.id)),
838 )
839 .collect();
840 people.sort();
841 let mut count = 0;
842 for answer in &mail.answers {
843 let id = answer_id(address, &answer.native_id);
844 let in_reply_to = newest_before(&people, answer.at_ms, true);
845 let envelope = Envelope {
846 id,
847 created_at_ms: answer.at_ms,
848 from: address.clone(),
849 from_name: name.to_string(),
850 kind: MailKind::Answer,
851 reply_via: ReplyVia::None,
852 in_reply_to_inferred: in_reply_to.is_some(),
853 in_reply_to,
854 thread: None,
855 native_from: None,
856 voice_for: None,
857 subject: None,
858 body: answer.text.clone(),
859 };
860 if recipient.machine != address.machine && !filed.contains_key(envelope.id.as_str()) {
861 crate::mailbox::deliver_to(&recipient, &envelope)?;
864 }
865 count += usize::from(file_derived(&mailbox, &filed, envelope)?);
866 }
867 Ok(count)
868}
869
870pub fn sent_by(root: &Path, address: &MailAddress) -> Vec<(MailAddress, StoredEnvelope)> {
874 let mut sent: Vec<(MailAddress, StoredEnvelope)> = crate::mailbox::all_mailboxes(root)
875 .into_iter()
876 .filter(|mailbox| mailbox.address() != address)
877 .flat_map(|mailbox| {
878 let to = mailbox.address().clone();
879 mailbox
880 .list()
881 .unwrap_or_default()
882 .into_iter()
883 .filter(|stored| &stored.envelope.from == address)
884 .map(move |stored| (to.clone(), stored))
885 })
886 .collect();
887 sent.sort_by_key(|(_, stored)| stored.envelope.created_at_ms);
888 sent
889}
890
891pub fn deliveries(
894 address: &MailAddress,
895 stored: &[StoredEnvelope],
896 mail: Option<&TranscriptMail>,
897) -> BTreeMap<String, Delivery> {
898 let Some(mail) = mail else {
899 return stored
900 .iter()
901 .map(|stored| {
902 (
903 stored.envelope.id.clone(),
904 crate::mail_question::delivered_at(&stored.envelope)
905 .map(|at_ms| Delivery::Delivered { at_ms })
906 .unwrap_or(Delivery::Unknown),
907 )
908 })
909 .collect();
910 };
911 let typed: HashMap<String, &TypedLine> = mail
912 .typed
913 .iter()
914 .map(|line| (typed_line_id(address, line.sent_at_ms, &line.text), line))
915 .collect();
916 let channel: HashMap<String, &ChannelLine> = mail
917 .channel
918 .iter()
919 .map(|line| (channel_line_id(address, &line.header), line))
920 .collect();
921 let mut used: HashSet<usize> = HashSet::new();
924 let mut result = BTreeMap::new();
925 for stored in stored {
926 let envelope = &stored.envelope;
927 if typed.get(&envelope.id).is_some_and(|line| line.withdrawn) {
928 result.insert(envelope.id.clone(), Delivery::Withdrawn);
929 continue;
930 }
931 let at = if let Some(line) = typed.get(&envelope.id) {
932 line.delivered_at_ms
933 } else if let Some(line) = channel.get(&envelope.id) {
934 line.delivered_at_ms
935 } else if let Some(at) = mail
936 .delivered
937 .get(&envelope.id)
938 .or_else(|| mail.delivered.get(crate::mailbox::short_id(&envelope.id)))
939 {
940 Some(*at)
941 } else if envelope.kind == MailKind::User
942 && envelope
943 .in_reply_to
944 .as_deref()
945 .is_some_and(|id| id.starts_with("q-"))
946 {
947 crate::mail_question::delivered_at(envelope)
948 } else if envelope.kind == MailKind::User {
949 mail.typed
950 .iter()
951 .enumerate()
952 .find(|(index, line)| {
953 !used.contains(index)
954 && line.sent_at_ms + 1_000 >= envelope.created_at_ms
955 && line.text.trim() == envelope.body.trim()
956 })
957 .and_then(|(index, line)| {
958 used.insert(index);
959 line.delivered_at_ms
960 })
961 } else {
962 None
963 };
964 result.insert(
965 envelope.id.clone(),
966 match at {
967 Some(at_ms) => Delivery::Delivered { at_ms },
968 None => Delivery::Sent,
969 },
970 );
971 }
972 result
973}
974
975pub const MIN_PREFIX_DIGITS: usize = 4;
977
978pub fn find_messages(root: &Path, id: &str) -> std::io::Result<Vec<(Mailbox, StoredEnvelope)>> {
983 Ok(find_messages_many(root, &[id])?.remove(0))
984}
985
986pub fn find_messages_many(
989 root: &Path,
990 ids: &[&str],
991) -> std::io::Result<Vec<Vec<(Mailbox, StoredEnvelope)>>> {
992 let mut found: Vec<Vec<(Mailbox, StoredEnvelope)>> = ids.iter().map(|_| Vec::new()).collect();
993 let usable: Vec<usize> = (0..ids.len())
995 .filter(|&index| {
996 ids[index]
997 .split_once('-')
998 .map_or(0, |(_, digits)| digits.len())
999 >= MIN_PREFIX_DIGITS
1000 })
1001 .collect();
1002 if usable.is_empty() {
1003 return Ok(found);
1004 }
1005 let prefixes: Vec<&str> = usable.iter().map(|&index| ids[index]).collect();
1006 for mailbox in crate::mailbox::all_mailboxes(root) {
1007 for (matched, stored) in mailbox.find_prefixes(&prefixes)? {
1008 let list = &mut found[usable[matched]];
1009 if !list
1010 .iter()
1011 .any(|(_, known)| known.envelope.id == stored.envelope.id)
1012 {
1013 list.push((mailbox.clone(), stored));
1014 }
1015 }
1016 }
1017 Ok(found)
1018}