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 let prunes = address.harness != "codex";
716 for stored in &filed {
717 if prunes
720 && stored.envelope.kind == MailKind::User
721 && stored.envelope.id.starts_with(TYPED_ID_PREFIX)
722 && !stored
723 .envelope
724 .in_reply_to
725 .as_deref()
726 .is_some_and(|id| id.starts_with("q-"))
727 && !current.contains(&stored.envelope.id)
728 {
729 std::fs::remove_file(&stored.path).ok();
730 }
731 }
732 let mut count = 0;
733 for line in &mail.typed {
734 let id = typed_line_id(address, line.sent_at_ms, &line.text);
735 if !by_id.contains_key(id.as_str()) {
736 if let Some(left) = typed_by_supercode.get_mut(line.text.trim()) {
737 if *left > 0 {
738 *left -= 1;
739 continue;
740 }
741 }
742 }
743 let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
744 count += usize::from(file_derived(
745 mailbox,
746 &by_id,
747 Envelope {
748 id,
749 created_at_ms: line.sent_at_ms,
750 from: user.clone(),
751 from_name: format!("user@{}", address.machine),
752 sender_identity: None,
753 kind: MailKind::User,
754 reply_via: ReplyVia::None,
755 in_reply_to_inferred: in_reply_to.is_some(),
756 in_reply_to,
757 thread: None,
758 native_from: None,
759 voice_for: None,
760 subject: None,
761 body: line.text.clone(),
762 },
763 )?);
764 }
765 for line in &mail.channel {
766 let id = channel_line_id(address, &line.header);
767 let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
768 count += usize::from(file_derived(
769 mailbox,
770 &by_id,
771 Envelope {
772 id,
773 created_at_ms: line.sent_at_ms,
774 from: MailAddress::new(address.machine.clone(), "operator", "channel")
775 .map_err(|error| std::io::Error::other(error.0))?,
776 from_name: line.from.clone(),
777 sender_identity: None,
778 kind: MailKind::Channel,
779 reply_via: ReplyVia::None,
780 in_reply_to_inferred: in_reply_to.is_some(),
781 in_reply_to,
782 thread: None,
783 native_from: None,
784 voice_for: None,
785 subject: None,
786 body: line.text.clone(),
787 },
788 )?);
789 }
790 Ok(count)
791}
792
793pub fn file_prompt(address: &MailAddress, text: &str, sent_at_ms: u64) -> std::io::Result<bool> {
799 if text.trim().is_empty() || machine_made(text) {
800 return Ok(false);
801 }
802 let mailbox = Mailbox::open(&crate::mailbox::mail_root(), address)?;
803 let filed = mailbox.list()?;
804 let id = typed_line_id(address, sent_at_ms, text);
805 let typed_by_supercode = filed.iter().any(|stored| {
806 stored.envelope.kind == MailKind::User
807 && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
808 && stored.envelope.body.trim() == text.trim()
809 && sent_at_ms.saturating_sub(stored.envelope.created_at_ms) < 15 * 60 * 1000
810 });
811 if typed_by_supercode || filed.iter().any(|stored| stored.envelope.id == id) {
812 return Ok(false);
813 }
814 mailbox.file_read(&Envelope {
815 id,
816 created_at_ms: sent_at_ms,
817 from: user_address(&address.machine)?,
818 from_name: format!("user@{}", address.machine),
819 sender_identity: None,
820 kind: MailKind::User,
821 reply_via: ReplyVia::None,
822 in_reply_to: None,
823 in_reply_to_inferred: false,
824 thread: None,
825 native_from: None,
826 voice_for: None,
827 subject: None,
828 body: text.to_string(),
829 })?;
830 Ok(true)
831}
832
833pub fn file_answers(
837 root: &Path,
838 address: &MailAddress,
839 name: &str,
840 mail: &TranscriptMail,
841) -> std::io::Result<usize> {
842 let recipient =
843 crate::mail_question::creator_for(address).unwrap_or(user_address(&address.machine)?);
844 let mailbox = Mailbox::open(root, &recipient)?;
845 let listed = mailbox.list()?;
846 let mut filed: HashMap<&str, &StoredEnvelope> = HashMap::new();
847 for stored in &listed {
848 if stored.envelope.id.starts_with(ANSWER_ID_PREFIX)
850 && stored.envelope.kind != MailKind::Answer
851 {
852 std::fs::remove_file(&stored.path).ok();
853 continue;
854 }
855 filed.insert(stored.envelope.id.as_str(), stored);
856 }
857 let mut people: Vec<(u64, String)> = mail
860 .typed
861 .iter()
862 .map(|line| {
863 (
864 line.sent_at_ms,
865 typed_line_id(address, line.sent_at_ms, &line.text),
866 )
867 })
868 .chain(
869 mail.channel
870 .iter()
871 .map(|line| (line.sent_at_ms, channel_line_id(address, &line.header))),
872 )
873 .chain(
874 Mailbox::open(root, address)?
875 .list()?
876 .into_iter()
877 .filter(|stored| {
878 stored.envelope.kind == MailKind::User
879 && (!stored.envelope.id.starts_with(TYPED_ID_PREFIX)
880 || stored
881 .envelope
882 .in_reply_to
883 .as_deref()
884 .is_some_and(|id| id.starts_with("q-")))
885 })
886 .map(|stored| (stored.envelope.created_at_ms, stored.envelope.id)),
887 )
888 .collect();
889 people.sort();
890 let mut count = 0;
891 for answer in &mail.answers {
892 let id = answer_id(address, &answer.native_id);
893 let in_reply_to = newest_before(&people, answer.at_ms, true);
894 let envelope = Envelope {
895 id,
896 created_at_ms: answer.at_ms,
897 from: address.clone(),
898 from_name: name.to_string(),
899 sender_identity: None,
900 kind: MailKind::Answer,
901 reply_via: ReplyVia::None,
902 in_reply_to_inferred: in_reply_to.is_some(),
903 in_reply_to,
904 thread: None,
905 native_from: None,
906 voice_for: None,
907 subject: None,
908 body: answer.text.clone(),
909 };
910 if recipient.machine != address.machine && !filed.contains_key(envelope.id.as_str()) {
911 crate::mailbox::deliver_to(&recipient, &envelope)?;
914 }
915 count += usize::from(file_derived(&mailbox, &filed, envelope)?);
916 }
917 Ok(count)
918}
919
920pub fn sent_by(root: &Path, address: &MailAddress) -> Vec<(MailAddress, StoredEnvelope)> {
924 let mut sent: Vec<(MailAddress, StoredEnvelope)> = crate::mailbox::all_mailboxes(root)
925 .into_iter()
926 .filter(|mailbox| mailbox.address() != address)
927 .flat_map(|mailbox| {
928 let to = mailbox.address().clone();
929 mailbox
930 .list()
931 .unwrap_or_default()
932 .into_iter()
933 .filter(|stored| &stored.envelope.from == address)
934 .map(move |stored| (to.clone(), stored))
935 })
936 .collect();
937 sent.sort_by_key(|(_, stored)| stored.envelope.created_at_ms);
938 sent
939}
940
941pub fn deliveries(
944 address: &MailAddress,
945 stored: &[StoredEnvelope],
946 mail: Option<&TranscriptMail>,
947) -> BTreeMap<String, Delivery> {
948 let Some(mail) = mail else {
949 return stored
950 .iter()
951 .map(|stored| {
952 (
953 stored.envelope.id.clone(),
954 crate::mail_question::delivered_at(&stored.envelope)
955 .map(|at_ms| Delivery::Delivered { at_ms })
956 .unwrap_or(Delivery::Unknown),
957 )
958 })
959 .collect();
960 };
961 let typed: HashMap<String, &TypedLine> = mail
962 .typed
963 .iter()
964 .map(|line| (typed_line_id(address, line.sent_at_ms, &line.text), line))
965 .collect();
966 let channel: HashMap<String, &ChannelLine> = mail
967 .channel
968 .iter()
969 .map(|line| (channel_line_id(address, &line.header), line))
970 .collect();
971 let mut used: HashSet<usize> = HashSet::new();
974 let mut result = BTreeMap::new();
975 for stored in stored {
976 let envelope = &stored.envelope;
977 if typed.get(&envelope.id).is_some_and(|line| line.withdrawn) {
978 result.insert(envelope.id.clone(), Delivery::Withdrawn);
979 continue;
980 }
981 let at = if let Some(line) = typed.get(&envelope.id) {
982 line.delivered_at_ms
983 } else if let Some(line) = channel.get(&envelope.id) {
984 line.delivered_at_ms
985 } else if let Some(at) = mail
986 .delivered
987 .get(&envelope.id)
988 .or_else(|| mail.delivered.get(crate::mailbox::short_id(&envelope.id)))
989 {
990 Some(*at)
991 } else if envelope.kind == MailKind::User
992 && envelope
993 .in_reply_to
994 .as_deref()
995 .is_some_and(|id| id.starts_with("q-"))
996 {
997 crate::mail_question::delivered_at(envelope)
998 } else if envelope.kind == MailKind::User {
999 mail.typed
1000 .iter()
1001 .enumerate()
1002 .find(|(index, line)| {
1003 !used.contains(index)
1004 && line.sent_at_ms + 1_000 >= envelope.created_at_ms
1005 && line.text.trim() == envelope.body.trim()
1006 })
1007 .and_then(|(index, line)| {
1008 used.insert(index);
1009 line.delivered_at_ms
1010 })
1011 } else {
1012 None
1013 };
1014 result.insert(
1015 envelope.id.clone(),
1016 match at {
1017 Some(at_ms) => Delivery::Delivered { at_ms },
1018 None => Delivery::Sent,
1019 },
1020 );
1021 }
1022 result
1023}
1024
1025pub const MIN_PREFIX_DIGITS: usize = 4;
1027
1028pub fn find_messages(root: &Path, id: &str) -> std::io::Result<Vec<(Mailbox, StoredEnvelope)>> {
1033 Ok(find_messages_many(root, &[id])?.remove(0))
1034}
1035
1036pub fn find_messages_many(
1039 root: &Path,
1040 ids: &[&str],
1041) -> std::io::Result<Vec<Vec<(Mailbox, StoredEnvelope)>>> {
1042 let mut found: Vec<Vec<(Mailbox, StoredEnvelope)>> = ids.iter().map(|_| Vec::new()).collect();
1043 let usable: Vec<usize> = (0..ids.len())
1045 .filter(|&index| {
1046 ids[index]
1047 .split_once('-')
1048 .map_or(0, |(_, digits)| digits.len())
1049 >= MIN_PREFIX_DIGITS
1050 })
1051 .collect();
1052 if usable.is_empty() {
1053 return Ok(found);
1054 }
1055 let prefixes: Vec<&str> = usable.iter().map(|&index| ids[index]).collect();
1056 for mailbox in crate::mailbox::all_mailboxes(root) {
1057 for (matched, stored) in mailbox.find_prefixes(&prefixes)? {
1058 let list = &mut found[usable[matched]];
1059 if !list
1060 .iter()
1061 .any(|(_, known)| known.envelope.id == stored.envelope.id)
1062 {
1063 list.push((mailbox.clone(), stored));
1064 }
1065 }
1066 }
1067 Ok(found)
1068}