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 ByThisRead {
196 at_ms: u64,
198 },
199}
200
201impl Delivery {
202 pub fn describe(&self) -> String {
204 match self {
205 Self::Delivered { at_ms } => {
206 supercode_interchange::sidecar::ms_to_rfc3339(*at_ms as i64)
207 }
208 Self::Sent => "not yet".to_string(),
209 Self::Withdrawn => "never (taken back)".to_string(),
210 Self::Unknown => "unknown".to_string(),
211 Self::NotRead => "not read here (supercode message show reads it)".to_string(),
212 Self::ByThisRead { at_ms } => format!(
213 "now, by this read ({})",
214 supercode_interchange::sidecar::ms_to_rfc3339(*at_ms as i64)
215 ),
216 }
217 }
218}
219
220pub fn transcript(homes: &HarnessHomes, address: &MailAddress) -> Option<PathBuf> {
223 if address.harness != "claude-code" {
224 return None;
225 }
226 let file = format!("{}.jsonl", address.session_id);
227 std::fs::read_dir(&homes.claude_code)
228 .ok()?
229 .flatten()
230 .map(|project| project.path().join(&file))
231 .find(|path| path.is_file())
232}
233
234fn at_ms(value: &serde_json::Value) -> u64 {
235 value
236 .as_str()
237 .and_then(supercode_interchange::sidecar::rfc3339_to_ms)
238 .and_then(|at| u64::try_from(at).ok())
239 .unwrap_or_default()
240}
241
242fn prompt_text(content: &serde_json::Value) -> Option<String> {
243 let text = match content {
244 serde_json::Value::String(text) => text.clone(),
245 serde_json::Value::Array(parts) => parts
246 .iter()
247 .filter(|part| part["type"] == "text")
248 .filter_map(|part| part["text"].as_str())
249 .collect::<Vec<_>>()
250 .join("\n"),
251 _ => return None,
252 };
253 (!text.trim().is_empty()).then_some(text)
254}
255
256fn rendered_mail_ids(line: &str) -> Vec<&str> {
260 let mut ids = Vec::new();
261 for mark in ["cross-session-message id=\\\"", "(message "] {
262 for (at, _) in line.match_indices(mark) {
263 let rest = &line[at + mark.len()..];
264 let end = rest
265 .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
266 .unwrap_or(rest.len());
267 let id = &rest[..end];
268 if id.starts_with("m-") {
269 ids.push(id);
270 }
271 }
272 }
273 ids
274}
275
276fn machine_made(text: &str) -> bool {
279 text.contains("<cross-session-message")
280 || text.starts_with("[Cross-session")
281 || text.starts_with("[channel: ")
282 || text.starts_with("<task-notification>")
283}
284
285pub fn read_claude(path: &Path) -> std::io::Result<TranscriptMail> {
287 let reader = std::io::BufReader::new(std::fs::File::open(path)?);
288 let mut mail = TranscriptMail::default();
289 let mut queued: VecDeque<(String, Option<usize>)> = VecDeque::new();
295 let mut dequeued = 0_usize;
298 let mut absorbed: VecDeque<(String, Option<usize>)> = VecDeque::new();
301 let take = |waiting: &mut VecDeque<(String, Option<usize>)>, text: &str| {
302 let at = waiting.iter().position(|(words, _)| words == text)?;
303 waiting.remove(at).map(|(_, index)| index)
304 };
305 let mut not_typed: HashSet<usize> = HashSet::new();
307 let mut taken_in: HashMap<String, VecDeque<u64>> = HashMap::new();
310 let mut early: Vec<(String, Vec<String>, Option<usize>, u64)> = Vec::new();
314 for line in reader.lines() {
315 let line = line?;
316 let prompt = line.contains("\"promptSource\"") || line.contains("\"type\":\"user\"");
317 let queue = line.contains("\"queue-operation\"");
318 let attachment = line.contains("\"queued_command\"");
319 let answer = line.contains("\"stop_reason\":\"end_turn\"")
320 || line.contains("\"stop_reason\":\"stop_sequence\"");
321 let rendered = line.contains("cross-session-message id=") || line.contains("(message m-");
322 if !(prompt || queue || attachment || rendered || answer) {
323 continue;
324 }
325 let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
326 continue;
327 };
328 if record["isSidechain"] == true {
329 continue;
330 }
331 let kind = record["type"].as_str().unwrap_or_default();
332 let mut at = at_ms(&record["timestamp"]);
333 if kind == "queue-operation" && record["operation"] == "remove" {
334 if let Some(text) = record["content"].as_str() {
335 if let Some(found) = early.iter().position(|(words, ..)| words == text) {
338 let (_, ids, index, _) = early.remove(found);
339 for id in ids {
340 mail.delivered.entry(id).or_insert(at);
341 }
342 if let Some(index) = index {
343 mail.typed[index].delivered_at_ms = Some(at);
344 }
345 continue;
346 }
347 taken_in.entry(text.to_string()).or_default().push_back(at);
348 }
349 }
350 let mut waits_for_remove = false;
351 if kind == "attachment" && record["attachment"]["type"] == "queued_command" {
352 match record["attachment"]["prompt"]
353 .as_str()
354 .and_then(|text| taken_in.get_mut(text))
355 .and_then(VecDeque::pop_front)
356 {
357 Some(taken) => at = taken,
358 None => waits_for_remove = true,
359 }
360 }
361 if waits_for_remove {
362 let attached = &record["attachment"];
363 let text = attached["prompt"].as_str().unwrap_or_default().to_string();
364 let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
365 let ids = rendered_mail_ids(&line)
366 .into_iter()
367 .map(str::to_string)
368 .collect();
369 let index = match take(&mut queued, &text) {
371 Some(Some(index)) if !human => {
372 not_typed.insert(index);
373 None
374 }
375 Some(index) => index,
376 None if human && !text.trim().is_empty() => {
377 mail.typed.push(TypedLine {
378 sent_at_ms: at,
379 delivered_at_ms: None,
380 withdrawn: false,
381 text: text.clone(),
382 });
383 Some(mail.typed.len() - 1)
384 }
385 None => None,
386 };
387 early.push((text, ids, index, at));
388 continue;
389 }
390 if rendered && matches!(kind, "user" | "attachment") {
394 for id in rendered_mail_ids(&line) {
395 mail.delivered.entry(id.to_string()).or_insert(at);
396 }
397 }
398 match kind {
399 "assistant"
401 if answer
402 && matches!(
403 record["message"]["stop_reason"].as_str(),
404 Some("end_turn" | "stop_sequence")
405 ) =>
406 {
407 let (Some(native_id), Some(text)) = (
408 record["uuid"].as_str(),
409 prompt_text(&record["message"]["content"]),
410 ) else {
411 continue;
412 };
413 mail.answers.push(Answer {
414 native_id: native_id.to_string(),
415 at_ms: at,
416 text,
417 });
418 }
419 "queue-operation" if record["operation"] == "dequeue" => dequeued += 1,
421 "queue-operation" => {
422 let Some(text) = record["content"].as_str() else {
423 continue;
424 };
425 match record["operation"].as_str() {
426 Some("enqueue") => {
427 let index = (!machine_made(text) && !text.trim().is_empty()).then(|| {
428 mail.typed.push(TypedLine {
429 sent_at_ms: at,
430 delivered_at_ms: None,
431 withdrawn: false,
432 text: text.to_string(),
433 });
434 mail.typed.len() - 1
435 });
436 queued.push_back((text.to_string(), index));
437 }
438 Some("remove") => {
439 let Some(index) = take(&mut queued, text) else {
440 continue;
441 };
442 match record["reason"].as_str() {
443 Some("absorbed_mid_turn" | "delivered_to_agent") => {
445 if let Some(index) = index {
446 mail.typed[index].delivered_at_ms = Some(at);
447 }
448 absorbed.push_back((text.to_string(), index));
449 }
450 _ => {
452 if let Some(index) = index {
453 mail.typed[index].withdrawn = true;
454 }
455 }
456 }
457 }
458 _ => {}
459 }
460 }
461 "attachment" => {
464 let attached = &record["attachment"];
465 let Some(text) = attached["prompt"].as_str() else {
466 continue;
467 };
468 if attached["type"] != "queued_command" || text.trim().is_empty() {
469 continue;
470 }
471 let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
472 match take(&mut absorbed, text) {
473 Some(Some(index)) if !human => {
474 not_typed.insert(index);
475 }
476 Some(_) => {}
477 None if human => mail.typed.push(TypedLine {
478 sent_at_ms: at_ms(&attached["timestamp"]).min(at),
479 delivered_at_ms: Some(at),
480 withdrawn: false,
481 text: text.to_string(),
482 }),
483 None => {}
484 }
485 }
486 "user" => {
487 let Some(text) = prompt_text(&record["message"]["content"]) else {
488 continue;
489 };
490 let source = record["promptSource"].as_str();
491 let human = matches!(source, Some("typed" | "queued")) && record["isMeta"] != true;
492 if !human {
493 mail.channel.extend(channel_lines(&text, at, Some(at)));
494 }
495 let mut landing: Vec<Option<usize>> = Vec::new();
502 let let_go = std::mem::take(&mut dequeued);
503 if let_go == 0 {
504 landing.extend(take(&mut queued, &text));
505 }
506 let wanted = let_go;
507 while landing.len() < wanted {
508 let Some(at) = queued
509 .iter()
510 .position(|(words, _)| text.contains(words.as_str()))
511 else {
512 break;
513 };
514 landing.extend(queued.remove(at).map(|(_, index)| index));
515 }
516 if landing.is_empty() && wanted > 0 {
517 for _ in 0..wanted {
518 landing.extend(queued.pop_front().map(|(_, index)| index));
519 }
520 }
521 if !landing.is_empty() {
522 for index in landing.into_iter().flatten() {
523 if human {
524 mail.typed[index].delivered_at_ms = Some(at);
525 } else {
526 not_typed.insert(index);
527 }
528 }
529 continue;
530 }
531 if human {
532 mail.typed.push(TypedLine {
533 sent_at_ms: at,
534 delivered_at_ms: Some(at),
535 withdrawn: false,
536 text,
537 });
538 }
539 }
540 _ => {}
541 }
542 }
543 for (_, ids, index, at) in early {
546 for id in ids {
547 mail.delivered.entry(id).or_insert(at);
548 }
549 if let Some(index) = index {
550 mail.typed[index].delivered_at_ms.get_or_insert(at);
551 }
552 }
553 for (text, _) in &queued {
555 if text.starts_with("[channel: ") {
556 mail.channel.extend(channel_lines(text, 0, None));
557 }
558 }
559 let mut index = 0;
560 mail.typed.retain(|_| {
561 index += 1;
562 !not_typed.contains(&(index - 1))
563 });
564 Ok(mail)
565}
566
567pub fn read(homes: &HarnessHomes, address: &MailAddress) -> Option<TranscriptMail> {
569 if address.harness == "codex" {
570 return read_codex_answers(&crate::mail_question::transcript_for(address)?).ok();
571 }
572 read_claude(&transcript(homes, address)?).ok()
573}
574
575fn read_codex_answers(path: &Path) -> std::io::Result<TranscriptMail> {
579 let reader = std::io::BufReader::new(std::fs::File::open(path)?);
580 let mut mail = TranscriptMail::default();
581 for line in reader.lines().map_while(Result::ok) {
582 let rendered = line.contains("cross-session-message id=");
583 if !rendered && !line.contains("\"final_answer\"") {
584 continue;
585 }
586 let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
587 continue;
588 };
589 let item = &record["payload"];
590 if rendered
595 && record["type"] == "response_item"
596 && ((item["type"] == "message" && item["role"] == "user")
597 || matches!(
598 item["type"].as_str(),
599 Some("function_call_output" | "custom_tool_call_output")
600 ))
601 {
602 let at = at_ms(&record["timestamp"]);
603 for id in codex_rendered_mail_ids(&line) {
604 mail.delivered.entry(id.to_string()).or_insert(at);
605 }
606 }
607 if record["type"] != "response_item"
608 || item["type"] != "message"
609 || item["role"] != "assistant"
610 || item["phase"] != "final_answer"
611 {
612 continue;
613 }
614 let Some(id) = item["id"].as_str() else {
615 continue;
616 };
617 let text = item["content"]
618 .as_array()
619 .into_iter()
620 .flatten()
621 .filter(|part| part["type"] == "output_text")
622 .filter_map(|part| part["text"].as_str())
623 .collect::<Vec<_>>()
624 .join("\n");
625 if !text.is_empty() {
626 mail.answers.push(Answer {
627 native_id: id.to_string(),
628 at_ms: at_ms(&record["timestamp"]),
629 text,
630 });
631 }
632 }
633 Ok(mail)
634}
635
636fn codex_rendered_mail_ids(line: &str) -> Vec<&str> {
640 let mark = "cross-session-message id=";
641 line.match_indices(mark)
642 .filter_map(|(at, _)| {
643 let rest = line[at + mark.len()..].trim_start_matches(['\\', '"']);
644 let end = rest
645 .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
646 .unwrap_or(rest.len());
647 Some(&rest[..end]).filter(|id| id.starts_with("m-"))
648 })
649 .collect()
650}
651
652fn newest_before(items: &[(u64, String)], at: u64, inclusive: bool) -> Option<String> {
655 items
656 .iter()
657 .rev()
658 .find(|(when, _)| if inclusive { *when <= at } else { *when < at })
659 .map(|(_, id)| id.clone())
660}
661
662fn answer_times(address: &MailAddress, mail: &TranscriptMail) -> Vec<(u64, String)> {
664 let mut times: Vec<(u64, String)> = mail
665 .answers
666 .iter()
667 .map(|answer| (answer.at_ms, answer_id(address, &answer.native_id)))
668 .collect();
669 times.sort();
670 times
671}
672
673fn file_derived(
676 mailbox: &Mailbox,
677 filed: &HashMap<&str, &StoredEnvelope>,
678 envelope: Envelope,
679) -> std::io::Result<bool> {
680 if let Some(stored) = filed.get(envelope.id.as_str()) {
681 if stored.envelope.in_reply_to == envelope.in_reply_to {
682 return Ok(false);
683 }
684 std::fs::remove_file(&stored.path).ok();
685 }
686 mailbox.file_read(&envelope)?;
687 Ok(true)
688}
689
690pub fn file_typed_lines(mailbox: &Mailbox, mail: &TranscriptMail) -> std::io::Result<usize> {
693 let address = mailbox.address();
694 let filed = mailbox.list()?;
695 let by_id: HashMap<&str, &StoredEnvelope> = filed
696 .iter()
697 .map(|stored| (stored.envelope.id.as_str(), stored))
698 .collect();
699 let answers = answer_times(address, mail);
702 let mut typed_by_supercode: HashMap<&str, usize> = HashMap::new();
705 for stored in &filed {
706 if stored.envelope.kind == MailKind::User
707 && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
708 {
709 *typed_by_supercode
710 .entry(stored.envelope.body.trim())
711 .or_default() += 1;
712 }
713 }
714 let user = user_address(&address.machine)?;
715 let current: HashSet<String> = mail
719 .typed
720 .iter()
721 .map(|line| typed_line_id(address, line.sent_at_ms, &line.text))
722 .collect();
723 let prunes = address.harness != "codex";
726 for stored in &filed {
727 if prunes
730 && stored.envelope.kind == MailKind::User
731 && stored.envelope.id.starts_with(TYPED_ID_PREFIX)
732 && !stored
733 .envelope
734 .in_reply_to
735 .as_deref()
736 .is_some_and(|id| id.starts_with("q-"))
737 && !current.contains(&stored.envelope.id)
738 {
739 std::fs::remove_file(&stored.path).ok();
740 }
741 }
742 let mut count = 0;
743 for line in &mail.typed {
744 let id = typed_line_id(address, line.sent_at_ms, &line.text);
745 if !by_id.contains_key(id.as_str()) {
746 if let Some(left) = typed_by_supercode.get_mut(line.text.trim()) {
747 if *left > 0 {
748 *left -= 1;
749 continue;
750 }
751 }
752 }
753 let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
754 count += usize::from(file_derived(
755 mailbox,
756 &by_id,
757 Envelope {
758 id,
759 created_at_ms: line.sent_at_ms,
760 from: user.clone(),
761 from_name: format!("user@{}", address.machine),
762 sender_identity: None,
763 kind: MailKind::User,
764 reply_via: ReplyVia::None,
765 in_reply_to_inferred: in_reply_to.is_some(),
766 in_reply_to,
767 thread: None,
768 native_from: None,
769 voice_for: None,
770 subject: None,
771 body: line.text.clone(),
772 },
773 )?);
774 }
775 for line in &mail.channel {
776 let id = channel_line_id(address, &line.header);
777 let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
778 count += usize::from(file_derived(
779 mailbox,
780 &by_id,
781 Envelope {
782 id,
783 created_at_ms: line.sent_at_ms,
784 from: MailAddress::new(address.machine.clone(), "operator", "channel")
785 .map_err(|error| std::io::Error::other(error.0))?,
786 from_name: line.from.clone(),
787 sender_identity: None,
788 kind: MailKind::Channel,
789 reply_via: ReplyVia::None,
790 in_reply_to_inferred: in_reply_to.is_some(),
791 in_reply_to,
792 thread: None,
793 native_from: None,
794 voice_for: None,
795 subject: None,
796 body: line.text.clone(),
797 },
798 )?);
799 }
800 Ok(count)
801}
802
803pub fn file_prompt(address: &MailAddress, text: &str, sent_at_ms: u64) -> std::io::Result<bool> {
809 if text.trim().is_empty() || machine_made(text) {
810 return Ok(false);
811 }
812 let mailbox = Mailbox::open(&crate::mailbox::mail_root(), address)?;
813 let filed = mailbox.list()?;
814 let id = typed_line_id(address, sent_at_ms, text);
815 let typed_by_supercode = filed.iter().any(|stored| {
816 stored.envelope.kind == MailKind::User
817 && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
818 && stored.envelope.body.trim() == text.trim()
819 && sent_at_ms.saturating_sub(stored.envelope.created_at_ms) < 15 * 60 * 1000
820 });
821 if typed_by_supercode || filed.iter().any(|stored| stored.envelope.id == id) {
822 return Ok(false);
823 }
824 mailbox.file_read(&Envelope {
825 id,
826 created_at_ms: sent_at_ms,
827 from: user_address(&address.machine)?,
828 from_name: format!("user@{}", address.machine),
829 sender_identity: None,
830 kind: MailKind::User,
831 reply_via: ReplyVia::None,
832 in_reply_to: None,
833 in_reply_to_inferred: false,
834 thread: None,
835 native_from: None,
836 voice_for: None,
837 subject: None,
838 body: text.to_string(),
839 })?;
840 Ok(true)
841}
842
843pub fn file_answers(
847 root: &Path,
848 address: &MailAddress,
849 name: &str,
850 mail: &TranscriptMail,
851) -> std::io::Result<usize> {
852 let recipient =
853 crate::mail_question::creator_for(address).unwrap_or(user_address(&address.machine)?);
854 let mailbox = Mailbox::open(root, &recipient)?;
855 let listed = mailbox.list()?;
856 let mut filed: HashMap<&str, &StoredEnvelope> = HashMap::new();
857 for stored in &listed {
858 if stored.envelope.id.starts_with(ANSWER_ID_PREFIX)
860 && stored.envelope.kind != MailKind::Answer
861 {
862 std::fs::remove_file(&stored.path).ok();
863 continue;
864 }
865 filed.insert(stored.envelope.id.as_str(), stored);
866 }
867 let mut people: Vec<(u64, String)> = mail
870 .typed
871 .iter()
872 .map(|line| {
873 (
874 line.sent_at_ms,
875 typed_line_id(address, line.sent_at_ms, &line.text),
876 )
877 })
878 .chain(
879 mail.channel
880 .iter()
881 .map(|line| (line.sent_at_ms, channel_line_id(address, &line.header))),
882 )
883 .chain(
884 Mailbox::open(root, address)?
885 .list()?
886 .into_iter()
887 .filter(|stored| {
888 stored.envelope.kind == MailKind::User
889 && (!stored.envelope.id.starts_with(TYPED_ID_PREFIX)
890 || stored
891 .envelope
892 .in_reply_to
893 .as_deref()
894 .is_some_and(|id| id.starts_with("q-")))
895 })
896 .map(|stored| (stored.envelope.created_at_ms, stored.envelope.id)),
897 )
898 .collect();
899 people.sort();
900 let mut count = 0;
901 for answer in &mail.answers {
902 let id = answer_id(address, &answer.native_id);
903 let in_reply_to = newest_before(&people, answer.at_ms, true);
904 let envelope = Envelope {
905 id,
906 created_at_ms: answer.at_ms,
907 from: address.clone(),
908 from_name: name.to_string(),
909 sender_identity: None,
910 kind: MailKind::Answer,
911 reply_via: ReplyVia::None,
912 in_reply_to_inferred: in_reply_to.is_some(),
913 in_reply_to,
914 thread: None,
915 native_from: None,
916 voice_for: None,
917 subject: None,
918 body: answer.text.clone(),
919 };
920 if recipient.machine != address.machine && !filed.contains_key(envelope.id.as_str()) {
921 crate::mailbox::deliver_to(&recipient, &envelope)?;
924 }
925 count += usize::from(file_derived(&mailbox, &filed, envelope)?);
926 }
927 Ok(count)
928}
929
930pub fn sent_by(root: &Path, address: &MailAddress) -> Vec<(MailAddress, StoredEnvelope)> {
934 let mut sent: Vec<(MailAddress, StoredEnvelope)> = crate::mailbox::all_mailboxes(root)
935 .into_iter()
936 .filter(|mailbox| mailbox.address() != address)
937 .flat_map(|mailbox| {
938 let to = mailbox.address().clone();
939 mailbox
940 .list()
941 .unwrap_or_default()
942 .into_iter()
943 .filter(|stored| &stored.envelope.from == address)
944 .map(move |stored| (to.clone(), stored))
945 })
946 .collect();
947 sent.sort_by_key(|(_, stored)| stored.envelope.created_at_ms);
948 sent
949}
950
951pub fn deliveries(
954 address: &MailAddress,
955 stored: &[StoredEnvelope],
956 mail: Option<&TranscriptMail>,
957) -> BTreeMap<String, Delivery> {
958 let Some(mail) = mail else {
959 return stored
960 .iter()
961 .map(|stored| {
962 (
963 stored.envelope.id.clone(),
964 crate::mail_question::delivered_at(&stored.envelope)
965 .map(|at_ms| Delivery::Delivered { at_ms })
966 .unwrap_or(Delivery::Unknown),
967 )
968 })
969 .collect();
970 };
971 let typed: HashMap<String, &TypedLine> = mail
972 .typed
973 .iter()
974 .map(|line| (typed_line_id(address, line.sent_at_ms, &line.text), line))
975 .collect();
976 let channel: HashMap<String, &ChannelLine> = mail
977 .channel
978 .iter()
979 .map(|line| (channel_line_id(address, &line.header), line))
980 .collect();
981 let mut used: HashSet<usize> = HashSet::new();
984 let mut result = BTreeMap::new();
985 for stored in stored {
986 let envelope = &stored.envelope;
987 if typed.get(&envelope.id).is_some_and(|line| line.withdrawn) {
988 result.insert(envelope.id.clone(), Delivery::Withdrawn);
989 continue;
990 }
991 let at = if let Some(line) = typed.get(&envelope.id) {
992 line.delivered_at_ms
993 } else if let Some(line) = channel.get(&envelope.id) {
994 line.delivered_at_ms
995 } else if let Some(at) = mail
996 .delivered
997 .get(&envelope.id)
998 .or_else(|| mail.delivered.get(crate::mailbox::short_id(&envelope.id)))
999 {
1000 Some(*at)
1001 } else if envelope.kind == MailKind::User
1002 && envelope
1003 .in_reply_to
1004 .as_deref()
1005 .is_some_and(|id| id.starts_with("q-"))
1006 {
1007 crate::mail_question::delivered_at(envelope)
1008 } else if envelope.kind == MailKind::User {
1009 mail.typed
1010 .iter()
1011 .enumerate()
1012 .find(|(index, line)| {
1013 !used.contains(index)
1014 && line.sent_at_ms + 1_000 >= envelope.created_at_ms
1015 && line.text.trim() == envelope.body.trim()
1016 })
1017 .and_then(|(index, line)| {
1018 used.insert(index);
1019 line.delivered_at_ms
1020 })
1021 } else {
1022 None
1023 };
1024 result.insert(
1025 envelope.id.clone(),
1026 match at {
1027 Some(at_ms) => Delivery::Delivered { at_ms },
1028 None => Delivery::Sent,
1029 },
1030 );
1031 }
1032 result
1033}
1034
1035pub const MIN_PREFIX_DIGITS: usize = 4;
1037
1038pub fn find_messages(root: &Path, id: &str) -> std::io::Result<Vec<(Mailbox, StoredEnvelope)>> {
1043 Ok(find_messages_many(root, &[id])?.remove(0))
1044}
1045
1046pub fn find_messages_many(
1049 root: &Path,
1050 ids: &[&str],
1051) -> std::io::Result<Vec<Vec<(Mailbox, StoredEnvelope)>>> {
1052 let mut found: Vec<Vec<(Mailbox, StoredEnvelope)>> = ids.iter().map(|_| Vec::new()).collect();
1053 let usable: Vec<usize> = (0..ids.len())
1055 .filter(|&index| {
1056 ids[index]
1057 .split_once('-')
1058 .map_or(0, |(_, digits)| digits.len())
1059 >= MIN_PREFIX_DIGITS
1060 })
1061 .collect();
1062 if usable.is_empty() {
1063 return Ok(found);
1064 }
1065 let prefixes: Vec<&str> = usable.iter().map(|&index| ids[index]).collect();
1066 for mailbox in crate::mailbox::all_mailboxes(root) {
1067 for (matched, stored) in mailbox.find_prefixes(&prefixes)? {
1068 let list = &mut found[usable[matched]];
1069 if !list
1070 .iter()
1071 .any(|(_, known)| known.envelope.id == stored.envelope.id)
1072 {
1073 list.push((mailbox.clone(), stored));
1074 }
1075 }
1076 }
1077 Ok(found)
1078}