1use std::path::PathBuf;
8
9use crate::mail_route::{Caller, Door, LiveSessions, Unresolved};
10use crate::mailbox::{
11 local_machine_name, mail_root, Envelope, MailAddress, MailKind, Mailbox, ReplyVia,
12};
13use crate::HarnessHomes;
14
15pub const EXIT_REFUSED: i32 = 2;
17pub const EXIT_UNKNOWN: i32 = 3;
19pub const EXIT_STORED: i32 = 4;
21pub const EXIT_FAILED: i32 = 5;
23
24pub struct Outcome {
26 pub code: i32,
29 pub text: String,
31 pub receipt: Option<serde_json::Value>,
33}
34
35impl Outcome {
36 pub fn new(code: i32, text: impl Into<String>) -> Self {
38 Self {
39 code,
40 text: text.into(),
41 receipt: None,
42 }
43 }
44}
45
46#[derive(Debug, Clone, Default)]
48pub struct SendOptions {
49 pub subject: Option<String>,
51 pub in_reply_to: Option<String>,
53 pub notify_when_idle: bool,
55 pub queue: bool,
57 pub idempotency_key: Option<String>,
59 pub notice: bool,
62}
63
64pub async fn send(
68 homes: &HarnessHomes,
69 caller: &Caller,
70 to: &str,
71 body: &str,
72 options: SendOptions,
73) -> std::io::Result<Outcome> {
74 let SendOptions {
75 subject,
76 in_reply_to,
77 notify_when_idle,
78 queue,
79 idempotency_key,
80 notice,
81 } = options;
82 if let Some(machine) = remote_machine(to) {
83 let request = serde_json::json!({
84 "op": "send",
85 "from": caller.address.to_string(),
86 "from_name": caller.name,
87 "to": to,
88 "body": body,
89 "subject": subject,
90 "in_reply_to": in_reply_to,
91 "notify_when_idle": notify_when_idle,
92 "queue": queue,
93 "id": idempotency_key,
94 "notice": notice,
95 });
96 return Ok(remote_send(&machine, &request));
97 }
98 deliver(
99 homes,
100 caller,
101 to,
102 body,
103 subject,
104 in_reply_to,
105 notify_when_idle,
106 queue,
107 idempotency_key,
108 notice,
109 )
110 .await
111}
112
113fn remote_machine(to: &str) -> Option<String> {
115 let local = local_machine_name();
116 let machine = match MailAddress::parse(to) {
117 Ok(address) => address.machine,
118 Err(_) => to.rsplit_once('@')?.1.to_string(),
119 };
120 (machine != local).then_some(machine)
121}
122
123fn remote_send(machine: &str, request: &serde_json::Value) -> Outcome {
125 match crate::mailbox::teams_mail(machine, request) {
126 Ok(answer) => {
127 let mut outcome = Outcome::new(
128 answer["code"]
129 .as_i64()
130 .map_or(EXIT_FAILED, |code| code as i32),
131 answer["text"]
132 .as_str()
133 .or_else(|| answer["detail"].as_str())
134 .unwrap_or("the other machine answered nothing readable")
135 .to_string(),
136 );
137 outcome.receipt = answer.get("receipt").cloned();
138 outcome
139 }
140 Err(detail) if detail.contains("no mail grant") => Outcome::new(
141 EXIT_REFUSED,
142 format!(
143 "Not sent: machine {machine} does not accept mail from you (no mail grant). Only \
144 an operator can grant it; tell your user. Don't retry."
145 ),
146 ),
147 Err(detail) => Outcome::new(
148 EXIT_FAILED,
149 format!("Not sent to machine {machine}: {detail}"),
150 ),
151 }
152}
153
154#[allow(clippy::too_many_arguments)]
157async fn deliver(
158 homes: &HarnessHomes,
159 caller: &Caller,
160 to: &str,
161 body: &str,
162 subject: Option<String>,
163 in_reply_to: Option<String>,
164 notify_when_idle: bool,
165 queue: bool,
166 idempotency_key: Option<String>,
167 notice: bool,
168) -> std::io::Result<Outcome> {
169 use crate::mail_route::{deliver as route, door_for, Delivered, Refused};
170 let local_operator = to
175 .strip_prefix("operator:")
176 .and_then(|name| MailAddress::new(local_machine_name(), "operator", name).ok());
177 let (address, name) = match local_operator
178 .map(Ok)
179 .unwrap_or_else(|| MailAddress::parse(to))
180 {
181 Ok(address) if address.harness == "operator" || address.harness == "board" => {
183 let name = format!("{}@{}", address.session_id, address.machine);
184 (address, name)
185 }
186 Ok(address) if address.harness == crate::mail_agent::AGENT_HARNESS => {
188 if crate::mail_agent::load(&address.session_id).is_none() {
189 return Ok(Outcome::new(
190 EXIT_UNKNOWN,
191 format!(
192 "No agent named {} is declared on this machine. Nothing was sent. Run \
193 supercode agent show for the declared agents.",
194 address.session_id
195 ),
196 ));
197 }
198 let name = format!("agent {}@{}", address.session_id, address.machine);
199 (address, name)
200 }
201 _ => match LiveSessions::read(homes).resolve(to) {
202 Ok(session) => (session.address.clone(), session.name.clone()),
203 Err(Unresolved::Stale(message) | Unresolved::Unknown(message)) => {
204 return Ok(Outcome::new(EXIT_UNKNOWN, message));
205 }
206 },
207 };
208 if address == caller.address {
209 return Ok(Outcome::new(
210 EXIT_REFUSED,
211 "Not sent: that address is your own session.",
212 ));
213 }
214 let door = door_for(homes, &address).ok();
217 let message_id = crate::mail_file::message_id_for(&caller.address, idempotency_key.as_deref())?;
218 let sent_marker = sent_marker(&caller.address, &message_id);
219 let terminal = matches!(&door, Some(Door::Hook { pane: Some(_), .. }));
220 if let Ok(previous) = std::fs::read_to_string(&sent_marker) {
221 if terminal && !queue {
222 Mailbox::open(&mail_root(), &address)?.request_wake(&message_id)?;
223 }
224 let mut outcome = Outcome::new(
225 0,
226 format!(
227 "Already sent with --id {}: {previous} Nothing was sent again.",
228 idempotency_key.as_deref().unwrap_or_default()
229 ),
230 );
231 outcome.receipt = Some(delivery_receipt(&address, &message_id, terminal));
232 return Ok(outcome);
233 }
234 let in_reply_to = match in_reply_to {
239 Some(answered) if answered.len() < FULL_ID_LEN => {
240 let found = Mailbox::open(&mail_root(), &caller.address)
241 .and_then(|mailbox| mailbox.find_prefix(&answered))
242 .unwrap_or_default();
243 match found.as_slice() {
244 [only] => Some(only.envelope.id.clone()),
245 [] => {
246 return Ok(Outcome::new(
247 EXIT_REFUSED,
248 format!(
249 "Not sent: no message in your mailbox has an id starting {answered}. \
250 Name the message you answer as its envelope shows it."
251 ),
252 ))
253 }
254 many => {
255 let ids: Vec<&str> = many
256 .iter()
257 .map(|stored| stored.envelope.id.as_str())
258 .collect();
259 return Ok(Outcome::new(
260 EXIT_REFUSED,
261 format!(
262 "Not sent: {answered} names {} messages in your mailbox ({}). Give \
263 more of its id.",
264 ids.len(),
265 ids.join(", ")
266 ),
267 ));
268 }
269 }
270 }
271 other => other,
272 };
273 if let Some(answered) = in_reply_to.as_deref().filter(|id| id.starts_with("q-")) {
274 return crate::mail_question::reply(caller, &address, answered, body).await;
275 }
276 if let Some(answered) = in_reply_to.as_deref() {
277 if reply_chain_depth(&caller.address, &address, answered) >= REPLY_CHAIN_LIMIT {
278 return Ok(Outcome::new(
279 EXIT_REFUSED,
280 format!(
281 "Not sent: you and {name} have answered each other {REPLY_CHAIN_LIMIT} times \
282 in a row. If you are trading acknowledgements or status, stop; to go on, \
283 send a new message (without --re), or tell your user."
284 ),
285 ));
286 }
287 }
288 let (kind, reply_via) = if notice {
289 (MailKind::Notice, ReplyVia::None)
290 } else {
291 (MailKind::Peer, ReplyVia::Command)
292 };
293 let mut envelope = Envelope::new(
294 caller.address.clone(),
295 caller.name.clone(),
296 kind,
297 reply_via,
298 body,
299 )?;
300 envelope.subject = subject.as_deref().map(|value| {
301 value
302 .lines()
303 .next()
304 .unwrap_or("")
305 .chars()
306 .take(200)
307 .collect()
308 });
309 envelope.id = message_id.clone();
310 envelope.thread = crate::mailbox::thread_of_reply(in_reply_to.as_deref());
311 envelope.in_reply_to = in_reply_to;
312 if let Some(plan) = crate::mail_agent::plan(
313 &mut envelope,
314 &address,
315 &crate::mail_agent::Channel::default(),
316 )? {
317 return Ok(deliver_planned(homes, caller, &envelope, &plan, queue, notify_when_idle).await);
318 }
319 let Some(door) = door else {
320 return Ok(Outcome::new(
321 EXIT_UNKNOWN,
322 format!(
323 "{name} is no longer running. Nothing was sent. Run supercode message list for \
324 the live sessions."
325 ),
326 ));
327 };
328 let tier = door.name();
329 let idle_note = if notify_when_idle {
330 " Subscribed: one idle notice reaches you when its next turn ends."
331 } else {
332 ""
333 };
334 let (code, what) = match route(&envelope, &address, &door, !queue, notify_when_idle).await {
335 Err(detail) => {
336 return Ok(Outcome::new(
337 EXIT_FAILED,
338 format!("Not sent to {name}: {detail}"),
339 ))
340 }
341 Ok(Err(Refused::CannotQueueNative)) => {
342 return Ok(Outcome::new(
343 EXIT_REFUSED,
344 format!(
345 "Not sent: {name} is idle, and a Claude session always starts a turn when a \
346 message arrives, so --queue cannot hold it. Send without --queue to wake it."
347 ),
348 ))
349 }
350 Ok(Err(Refused::TooLong(bytes))) => {
351 return Ok(Outcome::new(
352 EXIT_REFUSED,
353 format!(
354 "Not sent: the message is {bytes} bytes; the limit is {}. Write it to a file \
355 and send its path instead.",
356 crate::mail_route::MAX_RELAYED_BYTES
357 ),
358 ))
359 }
360 Ok(Ok(Delivered::Steered)) => (0, "steered into its running turn"),
361 Ok(Ok(Delivered::Started)) => (0, "it was idle, so the message started a turn"),
362 Ok(Ok(Delivered::Native { busy: true })) => (0, "it will read it at its next tool call"),
363 Ok(Ok(Delivered::Native { busy: false })) => {
364 (0, "it was idle, so the message starts its next turn")
365 }
366 Ok(Ok(Delivered::Hooked)) => (
367 0,
368 "queued in its mailbox; terminal delivery waits for native transcript confirmation",
369 ),
370 Ok(Ok(Delivered::HookWoken)) => (
371 0,
372 "it was idle, so its pane was told to read its mailbox; its hook shows it the message in \
373 that turn",
374 ),
375 Ok(Ok(Delivered::Queued)) => (
376 0,
377 "it is idle and --queue leaves it so; the message waits in its mailbox",
378 ),
379 Ok(Ok(Delivered::Operator)) => (0, "filed in its mailbox"),
380 Ok(Ok(Delivered::Already)) => (0, "it already had this message; nothing was sent again"),
381 Ok(Ok(Delivered::Stored)) => (
382 EXIT_STORED,
383 "Stored (not failed): it has no delivery door, so it sees this only if it runs \
384 supercode message inbox (a Codex session gets one with: supercode message setup \
385 codex). Don't resend, and don't wait for a reply",
386 ),
387 };
388 record_send(&caller.address, &address, &message_id);
389 let shown_id = crate::mailbox::short_id(&message_id);
390 let text = if code == 0 {
391 format!(
392 "sent to {name} ({}, {tier}): {what}. Message id {shown_id}.{idle_note} Don't poll; \
393 carry on.",
394 address.harness
395 )
396 } else {
397 format!(
398 "{name} ({}): {what}. Message id {shown_id}.{idle_note}",
399 address.harness
400 )
401 };
402 if code == 0 && idempotency_key.is_some() {
403 if let Some(parent) = sent_marker.parent() {
404 std::fs::create_dir_all(parent).ok();
405 }
406 std::fs::write(&sent_marker, &text).ok();
407 }
408 let mut outcome = Outcome::new(code, text);
409 if code == 0 {
410 outcome.receipt = Some(delivery_receipt(&address, &message_id, terminal));
411 }
412 Ok(outcome)
413}
414
415pub async fn deliver_planned(
419 homes: &HarnessHomes,
420 caller: &Caller,
421 envelope: &Envelope,
422 plan: &crate::mail_agent::Plan,
423 queue: bool,
424 notify_when_idle: bool,
425) -> Outcome {
426 let mut reached: Vec<String> = Vec::new();
427 let mut copied: Vec<String> = Vec::new();
428 let mut failed: Vec<String> = Vec::new();
429 if let Some(agent) = &plan.agent {
430 if let Err(error) =
432 Mailbox::open(&mail_root(), agent).and_then(|mailbox| mailbox.deliver_read(envelope))
433 {
434 failed.push(format!("the agent's mailbox ({error})"));
435 }
436 }
437 for recipient in plan
438 .recipients
439 .iter()
440 .filter(|recipient| recipient.address != caller.address)
441 {
442 let label = recipient_label(homes, &recipient.address);
443 record_send(&caller.address, &recipient.address, &envelope.id);
444 if !recipient.wake {
445 match crate::mail_agent::file_unread(&recipient.address, envelope) {
446 Ok(()) => copied.push(label),
447 Err(error) => failed.push(format!("{label} ({error})")),
448 }
449 continue;
450 }
451 match deliver_woken(homes, envelope, &recipient.address, queue, notify_when_idle).await {
452 Ok(how) => reached.push(format!("{label} ({how})")),
453 Err(detail) => failed.push(format!("{label} ({detail})")),
454 }
455 }
456 let shown_id = crate::mailbox::short_id(&envelope.id);
457 let thread = plan
458 .thread
459 .as_ref()
460 .map(|thread| format!(" Thread {}.", crate::mailbox::short_id(&thread.id)))
461 .unwrap_or_default();
462 let mut text = if reached.is_empty() && copied.is_empty() {
463 format!("Not sent: nobody in its thread could be reached.{thread}")
464 } else {
465 let cc = if copied.is_empty() {
466 String::new()
467 } else {
468 format!(" CC (filed, not woken): {}.", copied.join(", "))
469 };
470 format!(
471 "sent to {}.{cc} Message id {shown_id}.{thread} Don't poll; carry on.",
472 if reached.is_empty() {
473 "no one woken".to_string()
474 } else {
475 reached.join(", ")
476 }
477 )
478 };
479 if !failed.is_empty() {
480 text.push_str(&format!(" Not delivered to: {}.", failed.join("; ")));
481 }
482 let code = if reached.is_empty() && copied.is_empty() {
483 EXIT_FAILED
484 } else {
485 0
486 };
487 let mut outcome = Outcome::new(code, text);
488 if code == 0 {
489 outcome.receipt = Some(
490 serde_json::json!({"delivered": true, "message_ids": [envelope.id], "receipt": "agent-mailbox"}),
491 );
492 }
493 outcome
494}
495
496fn recipient_label(homes: &HarnessHomes, address: &MailAddress) -> String {
498 if address.harness == "operator" || address.harness == "board" {
499 return format!("{}@{}", address.session_id, address.machine);
500 }
501 LiveSessions::read(homes)
502 .all()
503 .iter()
504 .find(|session| &session.address == address)
505 .map(|session| session.name.clone())
506 .unwrap_or_else(|| address.to_string())
507}
508
509async fn deliver_woken(
512 homes: &HarnessHomes,
513 envelope: &Envelope,
514 to: &MailAddress,
515 queue: bool,
516 notify_when_idle: bool,
517) -> Result<&'static str, String> {
518 use crate::mail_route::{deliver as route, door_for, Delivered, NoDoor};
519 let door = match door_for(homes, to) {
520 Ok(door) => door,
521 Err(NoDoor::OtherMachine(_)) => {
522 crate::mailbox::deliver_to(to, envelope).map_err(|error| error.to_string())?;
523 return Ok("filed on its machine");
524 }
525 Err(NoDoor::NotRunning) => {
526 crate::mail_agent::file_unread(to, envelope).map_err(|error| error.to_string())?;
529 if !crate::mail_agent::resumable(to) {
530 return Ok(
531 "stopped; not resumed, since the mailbox did not launch it: the \
532 message waits in its mailbox",
533 );
534 }
535 crate::mail_agent::resume(to).map_err(|error| format!("not resumed: {error}"))?;
536 let started = std::time::Instant::now();
537 loop {
538 if let Ok(door) = door_for(homes, to) {
539 break door;
540 }
541 if started.elapsed() > crate::mail_agent::RESUME_WAIT {
542 return Ok("resumed; the message waits in its mailbox");
543 }
544 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
545 }
546 }
547 };
548 match route(envelope, to, &door, !queue, notify_when_idle).await? {
549 Ok(Delivered::Steered) => Ok("steered into its running turn"),
550 Ok(Delivered::Started) => Ok("started a turn"),
551 Ok(Delivered::Native { busy: true }) => Ok("read at its next tool call"),
552 Ok(Delivered::Native { busy: false }) => Ok("starts its next turn"),
553 Ok(Delivered::Hooked | Delivered::HookWoken) => Ok("in its mailbox, its hook shows it"),
554 Ok(Delivered::Queued) => Ok("waits in its mailbox"),
555 Ok(Delivered::Operator) => Ok("filed"),
556 Ok(Delivered::Already) => Ok("it already had it"),
557 Ok(Delivered::Stored) => Ok("stored; it has no delivery door"),
558 Err(crate::mail_route::Refused::CannotQueueNative) => {
559 Err("idle, and --queue cannot hold a Claude session's mail".into())
560 }
561 Err(crate::mail_route::Refused::TooLong(bytes)) => Err(format!(
562 "{bytes} bytes; the limit is {}",
563 crate::mail_route::MAX_RELAYED_BYTES
564 )),
565 }
566}
567
568const REPLY_CHAIN_LIMIT: usize = 32;
573
574const FULL_ID_LEN: usize = 26;
576
577fn reply_chain_depth(a: &MailAddress, b: &MailAddress, answered: &str) -> usize {
581 let mut links = std::collections::HashMap::new();
582 for address in [a, b] {
583 let Ok(mailbox) = Mailbox::open(&mail_root(), address) else {
584 continue;
585 };
586 for stored in mailbox.list().unwrap_or_default() {
587 links.insert(
588 stored.envelope.id.clone(),
589 stored.envelope.in_reply_to.clone(),
590 );
591 }
592 }
593 let mut depth = 0;
594 let mut current = Some(answered.to_string());
595 while let Some(id) = current {
596 if depth > REPLY_CHAIN_LIMIT || !links.contains_key(&id) {
597 break;
598 }
599 depth += 1;
600 current = links.get(&id).cloned().flatten();
601 }
602 depth
603}
604
605fn send_log(sender: &MailAddress) -> PathBuf {
606 let hash = blake3::hash(sender.to_string().as_bytes()).to_hex();
607 mail_root().join("sent").join(&hash[..24]).join("log.jsonl")
608}
609
610fn record_send(sender: &MailAddress, to: &MailAddress, message_id: &str) {
611 let log = send_log(sender);
612 if let Some(parent) = log.parent() {
613 std::fs::create_dir_all(parent).ok();
614 }
615 if let Ok(mut file) = std::fs::OpenOptions::new()
616 .create(true)
617 .append(true)
618 .open(&log)
619 {
620 use std::io::Write as _;
621 let entry = serde_json::json!({"to": to.to_string(), "at_ms": now_ms(), "id": message_id});
622 writeln!(file, "{entry}").ok();
623 }
624}
625
626fn now_ms() -> u64 {
627 std::time::SystemTime::now()
628 .duration_since(std::time::UNIX_EPOCH)
629 .map(|elapsed| elapsed.as_millis() as u64)
630 .unwrap_or_default()
631}
632
633fn sent_marker(sender: &MailAddress, message_id: &str) -> PathBuf {
635 let hash = blake3::hash(sender.to_string().as_bytes()).to_hex();
636 mail_root().join("sent").join(&hash[..24]).join(message_id)
637}
638
639fn delivery_receipt(address: &MailAddress, id: &str, terminal: bool) -> serde_json::Value {
642 if !terminal {
643 return serde_json::json!({"delivered": true, "message_ids": [id], "receipt": "native-mail"});
644 }
645 let recipient = format!("{}:{}", address.harness, address.session_id);
646 if let Ok(entries) = std::fs::read_dir(mail_root().join("terminal-delivery")) {
647 for entry in entries.flatten() {
648 let Ok(bytes) = std::fs::read(entry.path().join("queue.json")) else {
649 continue;
650 };
651 let Ok(state) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
652 continue;
653 };
654 if state["recipient"].as_str() != Some(recipient.as_str()) {
655 continue;
656 }
657 if let Some(receipt) = state["receipts"].as_array().and_then(|receipts| {
658 receipts.iter().find(|r| {
659 r["message_ids"]
660 .as_array()
661 .is_some_and(|ids| ids.iter().any(|value| value.as_str() == Some(id)))
662 })
663 }) {
664 return receipt.clone();
665 }
666 return serde_json::json!({"delivered": false, "queued": true, "message_ids": [id], "reason": state["active"]["error"]});
667 }
668 }
669 serde_json::json!({"delivered": false, "queued": true, "message_ids": [id], "reason": "awaiting terminal mailbox"})
670}