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