1use std::collections::BTreeSet;
10use std::path::PathBuf;
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use anyhow::{bail, Context, Result};
14use serde_json::{json, Value};
15
16const PROMPT_CAP: usize = 8;
18
19pub(crate) const MAIL_PACKSET: &str = "0.13.0";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum Probe {
28 Refused,
30 Kept,
32 Store,
34}
35
36fn probe_of(text: &str) -> Probe {
40 if text.contains("unknown atom kind") {
41 Probe::Refused
42 } else if text.contains(": 400:") {
43 Probe::Kept
44 } else {
45 Probe::Store
46 }
47}
48
49fn unsupported_mail(version: Option<&str>) -> String {
50 match version {
51 Some(version) => format!(
52 "this pack does not keep mail. packset {version} refused kind message. Mail needs packset {MAIL_PACKSET}."
53 ),
54 None => format!(
55 "this pack does not keep mail. The writer refused kind message. Mail needs packset {MAIL_PACKSET}."
56 ),
57 }
58}
59
60#[derive(Debug, Clone)]
62pub struct Outgoing {
63 pub id: String,
64 pub from: String,
65 pub to: Vec<String>,
66 pub body: String,
67 pub interrupt: bool,
68 pub issue: Option<String>,
69 pub group: Option<String>,
70 pub reply: Option<String>,
71 pub scope: Option<String>,
72}
73
74pub struct Send<'a> {
76 pub seat: Option<&'a str>,
77 pub group: Option<&'a str>,
78 pub text: &'a str,
79 pub interrupt: bool,
80 pub issue: Option<&'a str>,
81 pub new_seat: bool,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Letter {
88 pub id: String,
89 pub from: String,
90 pub to: Vec<String>,
91 pub body: String,
92 pub interrupt: bool,
93 pub issue: Option<String>,
94 pub group: Option<String>,
95 pub reply: Option<String>,
96 pub scope: Option<String>,
97 pub read: bool,
98 ts: String,
99 seq: usize,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Receipt {
105 pub msg: String,
106 pub by: String,
107 pub at: String,
108 pub key: String,
109}
110
111fn mint_id(kind: &str, parts: &[&str]) -> String {
112 static N: AtomicU64 = AtomicU64::new(1);
113 let n = N.fetch_add(1, Ordering::Relaxed);
114 let nanos = std::time::SystemTime::now()
115 .duration_since(std::time::UNIX_EPOCH)
116 .map(|d| d.as_nanos())
117 .unwrap_or(0);
118 let raw = format!(
119 "{kind}\n{n}\n{nanos}\n{}\n{}",
120 std::process::id(),
121 parts.join("\n")
122 );
123 super::work_id(&raw)
124}
125
126fn check_name(kind: &str, name: &str) -> Result<String> {
127 let name = name.trim();
128 if name.is_empty()
129 || name
130 .chars()
131 .any(|c| c.is_whitespace() || c == ',' || c == ':')
132 {
133 bail!("{kind}: {name:?} is not a name");
134 }
135 Ok(name.to_string())
136}
137
138fn tag(atom: &Value, prefix: &str) -> Option<String> {
139 atom["entities"]
140 .as_array()?
141 .iter()
142 .filter_map(Value::as_str)
143 .find_map(|e| e.strip_prefix(prefix).map(str::to_string))
144}
145
146fn tags(atom: &Value, prefix: &str) -> Vec<String> {
147 atom["entities"]
148 .as_array()
149 .into_iter()
150 .flatten()
151 .filter_map(Value::as_str)
152 .filter_map(|e| e.strip_prefix(prefix).map(str::to_string))
153 .collect()
154}
155
156fn has(atom: &Value, entity: &str) -> bool {
157 atom["entities"]
158 .as_array()
159 .into_iter()
160 .flatten()
161 .any(|e| e.as_str() == Some(entity))
162}
163
164#[must_use]
167pub fn body_of(text: &str) -> String {
168 text.split_once("\n\n")
169 .map(|(_, body)| body.trim().to_string())
170 .unwrap_or_else(|| text.trim().to_string())
171}
172
173pub const ATOM_TEXT_CAP: usize = 500;
175
176pub const MAIL_BODY_CAP: usize = 16_000;
179
180fn check_length(verb: &str, body: &str) -> Result<()> {
183 let n = body.chars().count();
184 if n > MAIL_BODY_CAP {
185 bail!(
186 "{verb}: the message is {n} characters, past {MAIL_BODY_CAP}; write it to a file, \
187 `deedar create file --path FILE --agent SEAT`, and send the accession it prints. Nothing was sent"
188 );
189 }
190 Ok(())
191}
192
193#[must_use]
198pub fn message_text(m: &Outgoing) -> String {
199 let mut head = format!("msg {}\nfrom {}\nto {}", m.id, m.from, m.to.join(", "));
200 if m.interrupt {
201 head.push_str("\ninterrupt");
202 }
203 if let Some(issue) = &m.issue {
204 head.push_str(&format!("\nissue {issue}"));
205 }
206 if let Some(group) = &m.group {
207 head.push_str(&format!("\ngroup {group}"));
208 }
209 if let Some(reply) = &m.reply {
210 head.push_str(&format!("\nreply {reply}"));
211 }
212 let body = m.body.trim();
213 let room = ATOM_TEXT_CAP.saturating_sub(head.chars().count() + 2);
214 if body.chars().count() <= room {
215 return format!("{head}\n\n{body}");
216 }
217 let cut: String = body.chars().take(room.saturating_sub(1)).collect();
218 format!("{head}\n\n{cut}\u{2026}")
219}
220
221#[must_use]
223pub fn message_atom(m: &Outgoing, workspace: &str) -> Value {
224 let mut entities = vec![
225 format!("seat:{}", m.from),
226 format!("from:{}", m.from),
227 format!("msg:{}", m.id),
228 ];
229 for to in &m.to {
230 entities.push(format!("to:{to}"));
231 }
232 if m.interrupt {
233 entities.push("priority:interrupt".to_string());
234 }
235 if let Some(issue) = &m.issue {
236 entities.push(format!("issue:{issue}"));
237 }
238 if let Some(group) = &m.group {
239 entities.push(format!("group:{group}"));
240 }
241 if let Some(reply) = &m.reply {
242 entities.push(format!("reply:{reply}"));
243 }
244 if let Some(scope) = &m.scope {
245 entities.push(format!("scope:{scope}"));
246 }
247 let text = message_text(m);
248 let mut atom = json!({
249 "schema": "inside.atom/v1",
250 "kind": "message",
251 "level": "explicit",
252 "text": text,
253 "workspace": workspace,
254 "entities": entities,
255 });
256 if body_of(&text) != m.body.trim() {
257 atom["body"] = Value::String(m.body.trim().to_string());
258 }
259 atom
260}
261
262fn letter_of(atom: &Value, seq: usize) -> Option<Letter> {
263 if atom["kind"].as_str() != Some("message") {
264 return None;
265 }
266 let id = tag(atom, "msg:")?;
267 let from = tag(atom, "from:").or_else(|| tag(atom, "seat:"))?;
268 let to = tags(atom, "to:");
269 if to.is_empty() {
270 return None;
271 }
272 Some(Letter {
273 id,
274 from,
275 to,
276 body: atom["body"]
278 .as_str()
279 .map(|b| b.trim().to_string())
280 .unwrap_or_else(|| body_of(atom["text"].as_str().unwrap_or(""))),
281 interrupt: has(atom, "priority:interrupt"),
282 issue: tag(atom, "issue:"),
283 group: tag(atom, "group:"),
284 reply: tag(atom, "reply:"),
285 scope: tag(atom, "scope:"),
286 read: false,
287 ts: atom["ts"].as_str().unwrap_or("").to_string(),
288 seq,
289 })
290}
291
292fn readers_of(atoms: &[Value]) -> BTreeSet<(String, String)> {
293 let mut out = BTreeSet::new();
294 for atom in atoms {
295 if atom["kind"].as_str() != Some("receipt") {
296 continue;
297 }
298 let Some(msg) = tag(atom, "receipt:") else {
299 continue;
300 };
301 let Some(by) = tag(atom, "from:").or_else(|| tag(atom, "seat:")) else {
302 continue;
303 };
304 out.insert((msg, by));
305 }
306 out
307}
308
309#[must_use]
311pub fn inbox_letters(atoms: &[Value], seat: &str, all: bool) -> Vec<Letter> {
312 let read = readers_of(atoms);
313 let mut letters: Vec<Letter> = atoms
314 .iter()
315 .enumerate()
316 .filter_map(|(seq, atom)| letter_of(atom, seq))
317 .filter(|letter| letter.to.iter().any(|to| to == seat))
318 .map(|mut letter| {
319 letter.read = read.contains(&(letter.id.clone(), seat.to_string()));
320 letter
321 })
322 .filter(|letter| all || !letter.read)
323 .collect();
324 letters.sort_by(|a, b| {
325 b.interrupt
326 .cmp(&a.interrupt)
327 .then(a.ts.cmp(&b.ts))
328 .then(a.seq.cmp(&b.seq))
329 });
330 letters
331}
332
333#[must_use]
335pub fn receipts_for(atoms: &[Value], seat: &str) -> Vec<Receipt> {
336 let mut out = Vec::new();
337 for atom in atoms {
338 if atom["kind"].as_str() != Some("receipt") {
339 continue;
340 }
341 if tag(atom, "to:").as_deref() != Some(seat) {
342 continue;
343 }
344 let Some(msg) = tag(atom, "receipt:") else {
345 continue;
346 };
347 let Some(by) = tag(atom, "from:") else {
348 continue;
349 };
350 let text = atom["text"].as_str().unwrap_or("");
351 let at = text
352 .lines()
353 .find_map(|l| l.strip_prefix("at "))
354 .unwrap_or("")
355 .to_string();
356 out.push(Receipt {
357 key: super::work_id(text),
358 msg,
359 by,
360 at,
361 });
362 }
363 out.sort_by(|a, b| a.at.cmp(&b.at).then(a.key.cmp(&b.key)));
364 out
365}
366
367#[must_use]
369pub fn members(atoms: &[Value], group: &str) -> Vec<String> {
370 let mut events: Vec<(String, usize, String, bool)> = Vec::new();
371 for (seq, atom) in atoms.iter().enumerate() {
372 if atom["kind"].as_str() != Some("group") {
373 continue;
374 }
375 if tag(atom, "group:").as_deref() != Some(group) {
376 continue;
377 }
378 let ts = atom["ts"].as_str().unwrap_or("").to_string();
379 if let Some(seat) = tag(atom, "member:") {
380 events.push((ts, seq, seat, false));
381 } else if let Some(seat) = tag(atom, "drop:") {
382 events.push((ts, seq, seat, true));
383 }
384 }
385 events.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
386 let mut out: Vec<String> = Vec::new();
387 for (_, _, seat, drop) in events {
388 if drop {
389 out.retain(|s| s != &seat);
390 } else if !out.iter().any(|s| s == &seat) {
391 out.push(seat);
392 }
393 }
394 out
395}
396
397fn prompt_body(body: &str) -> String {
401 let n = body.chars().count();
402 let line = one_line(body, Some(160));
403 if n > 160 {
404 format!("{line} (all {n} characters: `ljos inbox --all`)")
405 } else {
406 line
407 }
408}
409
410fn one_line(body: &str, limit: Option<usize>) -> String {
411 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
412 match limit {
413 Some(n) if flat.chars().count() > n => {
414 let t: String = flat.chars().take(n.saturating_sub(3)).collect();
415 format!("{t}...")
416 }
417 _ => flat,
418 }
419}
420
421fn flags(letter: &Letter) -> String {
422 let mut parts = Vec::new();
423 if letter.interrupt {
424 parts.push("interrupt".to_string());
425 }
426 if let Some(issue) = &letter.issue {
427 parts.push(format!("issue:{issue}"));
428 }
429 if let Some(group) = &letter.group {
430 parts.push(format!("group:{group}"));
431 }
432 if let Some(reply) = &letter.reply {
433 parts.push(format!("reply:{reply}"));
434 }
435 if letter.read {
436 parts.push("read".to_string());
437 }
438 parts.join(" ")
439}
440
441#[must_use]
443pub fn render_inbox(
444 atoms: &[Value],
445 seat: &str,
446 all: bool,
447 seen_receipts: &BTreeSet<String>,
448) -> (String, Vec<String>) {
449 let letters = inbox_letters(atoms, seat, all);
450 let receipts: Vec<Receipt> = receipts_for(atoms, seat)
451 .into_iter()
452 .filter(|r| !seen_receipts.contains(&r.key))
453 .collect();
454 if letters.is_empty() && receipts.is_empty() {
455 let line = if all {
456 "inbox: nothing\n"
457 } else {
458 "inbox: nothing unread\n"
459 };
460 return (line.to_string(), Vec::new());
461 }
462 let mut out = String::new();
463 for letter in &letters {
464 out.push_str(&format!(
465 "{}\t{}\t{}\t{}\n",
466 letter.id,
467 letter.from,
468 flags(letter),
469 one_line(&letter.body, None)
470 ));
471 }
472 let mut keys = Vec::new();
473 for receipt in &receipts {
474 out.push_str(&format!(
475 "receipt\t{}\t{}\t{}\n",
476 receipt.msg, receipt.by, receipt.at
477 ));
478 keys.push(receipt.key.clone());
479 }
480 (out, keys)
481}
482
483#[must_use]
488pub fn prompt_lines(letters: &[Letter], receipts: &[Receipt]) -> (String, Vec<String>) {
489 if letters.is_empty() && receipts.is_empty() {
490 return (String::new(), Vec::new());
491 }
492 let mut lines = vec!["mail:".to_string()];
493 let mut ids = Vec::new();
494 for letter in letters.iter().take(PROMPT_CAP) {
495 let mark = if letter.interrupt { "! " } else { "" };
496 let on = letter
497 .issue
498 .as_ref()
499 .map(|issue| format!(" on {issue}"))
500 .unwrap_or_default();
501 lines.push(format!(
502 "{mark}{} from {}{on}: {}",
503 letter.id,
504 letter.from,
505 prompt_body(&letter.body)
506 ));
507 ids.push(format!("mail:{}", letter.id));
508 }
509 let rest = letters.len().saturating_sub(PROMPT_CAP);
510 if rest > 0 {
511 lines.push(format!("{rest} more in ljos inbox"));
512 }
513 for receipt in receipts.iter().take(PROMPT_CAP) {
514 lines.push(format!("read {} by {}", receipt.msg, receipt.by));
515 ids.push(format!("rcpt:{}", receipt.key));
516 }
517 (lines.join("\n"), ids)
518}
519
520fn find_letter(atoms: &[Value], id: &str) -> Result<Letter> {
521 atoms
522 .iter()
523 .enumerate()
524 .filter_map(|(seq, atom)| letter_of(atom, seq))
525 .find(|letter| letter.id == id)
526 .ok_or_else(|| anyhow::anyhow!("no message {id}"))
527}
528
529#[must_use]
532pub fn known_seats(atoms: &[Value]) -> BTreeSet<String> {
533 let mut out = BTreeSet::new();
534 for atom in atoms {
535 for prefix in ["seat:", "from:", "member:"] {
536 out.extend(tags(atom, prefix));
537 }
538 if let Some(seat) = atom["source"]["harness"].as_str() {
539 out.insert(seat.to_string());
540 }
541 }
542 out.retain(|s| !s.trim().is_empty());
543 out
544}
545
546pub fn check_known(seat: &str, known: &BTreeSet<String>, new_seat: bool) -> Result<()> {
554 if new_seat || known.contains(seat) {
555 return Ok(());
556 }
557 bail!(
558 "send: no seat named {seat} has written to this pack, so the message would wait unread. \
559 {} Pass --new-seat to write to a seat that has not started yet",
560 seat_hint(seat, known)
561 )
562}
563
564const SEAT_HINT_MAX: usize = 4;
566
567#[must_use]
571pub fn named_seat(name: &str) -> bool {
572 let name = name.trim();
573 if name.is_empty() {
574 return false;
575 }
576 let rest = name.strip_prefix("sess-").unwrap_or(name);
577 let hex: Vec<char> = rest.chars().filter(|c| *c != '-').collect();
578 let conversation_id = hex.len() >= 16 && hex.iter().all(char::is_ascii_hexdigit);
579 !conversation_id
580}
581
582#[must_use]
586pub fn seat_hint(seat: &str, known: &BTreeSet<String>) -> String {
587 let named: BTreeSet<String> = known.iter().filter(|s| named_seat(s)).cloned().collect();
588 let guess = closest_seat(seat, &named).map(str::to_string);
589 let others: Vec<&String> = named
590 .iter()
591 .filter(|s| guess.as_deref() != Some(s.as_str()))
592 .collect();
593 let mut shown = others
594 .iter()
595 .take(SEAT_HINT_MAX)
596 .map(|s| s.as_str())
597 .collect::<Vec<_>>()
598 .join(", ");
599 if others.len() > SEAT_HINT_MAX {
600 shown.push_str(&format!(" and {} more", others.len() - SEAT_HINT_MAX));
601 }
602 match (guess, shown.is_empty()) {
603 (Some(g), true) => format!("Did you mean {g}?"),
604 (Some(g), false) => format!("Did you mean {g}? Other seats: {shown}."),
605 (None, false) => format!("Seats here: {shown}."),
606 (None, true) => "No named seat has started here yet.".to_string(),
607 }
608}
609
610#[must_use]
614pub fn closest_seat<'a>(seat: &str, known: &'a BTreeSet<String>) -> Option<&'a str> {
615 let lower = seat.to_lowercase();
616 if lower.is_empty() {
617 return None;
618 }
619 let holds = known
620 .iter()
621 .filter(|k| k.to_lowercase().contains(&lower) || lower.contains(&k.to_lowercase()))
622 .min_by_key(|k| (k.len().abs_diff(seat.len()), k.as_str()));
623 if let Some(k) = holds {
624 return Some(k.as_str());
625 }
626 known
627 .iter()
628 .map(|k| (edits(&lower, &k.to_lowercase()), k))
629 .filter(|(d, _)| *d <= 2)
630 .min()
631 .map(|(_, k)| k.as_str())
632}
633
634fn edits(a: &str, b: &str) -> usize {
636 let b: Vec<char> = b.chars().collect();
637 let mut row: Vec<usize> = (0..=b.len()).collect();
638 for (i, ca) in a.chars().enumerate() {
639 let mut prev = row[0];
640 row[0] = i + 1;
641 for (j, cb) in b.iter().enumerate() {
642 let cur = row[j + 1];
643 row[j + 1] = if ca == *cb {
644 prev
645 } else {
646 1 + prev.min(cur).min(row[j])
647 };
648 prev = cur;
649 }
650 }
651 row[b.len()]
652}
653
654pub fn recipients(
656 from: &str,
657 seat: Option<&str>,
658 group: Option<&str>,
659 members: &[String],
660) -> Result<Vec<String>> {
661 if let Some(group) = group {
662 if seat.is_some() {
663 bail!("send: name a seat or a group");
664 }
665 let to: Vec<String> = members
666 .iter()
667 .filter(|m| m.as_str() != from)
668 .cloned()
669 .collect();
670 if to.is_empty() {
671 bail!("send: {group} has no other member");
672 }
673 return Ok(to);
674 }
675 let Some(seat) = seat.map(str::trim).filter(|s| !s.is_empty()) else {
676 bail!("send: name a seat or a group");
677 };
678 let seat = check_name("send", seat)?;
679 if seat == from {
680 bail!("send: {seat} is this seat");
681 }
682 Ok(vec![seat])
683}
684
685fn is_mail_atom(atom: &Value) -> bool {
686 matches!(atom["kind"].as_str(), Some("message" | "receipt" | "group"))
687}
688
689fn writer_version(client: &packset_client::PacksetClient) -> Option<String> {
690 client
691 .status(Some(&client.workspace()))
692 .ok()
693 .and_then(|status| {
694 status
695 .get("version")
696 .and_then(Value::as_str)
697 .filter(|version| !version.is_empty())
698 .map(str::to_string)
699 })
700}
701
702fn ensure_mail(client: &packset_client::PacksetClient, verb: &str) -> Result<()> {
705 if let Some(version) = writer_version(client) {
709 match super::cmp_semver(&version, MAIL_PACKSET) {
710 Some(std::cmp::Ordering::Less) => {
711 bail!("{verb}: {}", unsupported_mail(Some(&version)));
712 }
713 Some(_) => return Ok(()),
714 None => {}
715 }
716 }
717 let probe = json!({
718 "schema": "inside.atom/v1",
719 "kind": "message",
720 "level": "explicit",
721 "text": "",
722 "workspace": workspace_of(Some(&client.workspace())),
723 });
724 let posted = super::with_writer(|| client.post_atom(&probe).map_err(anyhow::Error::from));
725 match posted {
726 Ok(_) => Ok(()),
727 Err(err) => match probe_of(&format!("{err:#}")) {
728 Probe::Kept => Ok(()),
729 Probe::Refused => {
730 let version = writer_version(client);
731 bail!("{verb}: {}", unsupported_mail(version.as_deref()));
732 }
733 Probe::Store => Err(err).context("mail: the pack did not answer"),
734 },
735 }
736}
737
738fn listed() -> Result<Vec<Value>> {
739 let client = super::pack()?;
740 let workspace = client.workspace();
741 super::atoms_lean(&client, &workspace).context("mail: the pack did not answer")
742}
743
744fn load(verb: &str) -> Result<Vec<Value>> {
745 let client = super::pack()?;
746 let atoms = listed()?;
747 if !atoms.iter().any(is_mail_atom) {
748 ensure_mail(&client, verb)?;
749 }
750 Ok(atoms)
751}
752
753fn load_quiet() -> Option<Vec<Value>> {
754 let client = super::pack().ok()?;
755 let workspace = client.workspace();
756 super::atoms_lean_shared(&client, &workspace).ok()
757}
758
759fn post(atom: &Value, verb: &str) -> Result<Value> {
760 let client = super::pack()?;
761 super::with_writer(|| {
762 client
763 .post_atom(atom)
764 .with_context(|| format!("{verb}: POST /v1/atoms failed"))
765 })
766}
767
768fn workspace_of(client_workspace: Option<&str>) -> String {
769 client_workspace.unwrap_or("seat").to_string()
770}
771
772fn stamp_source(atom: &mut Value) {
773 atom["source"] = super::atom_source();
774}
775
776pub fn send(args: &Send<'_>) -> Result<String> {
778 let text = args.text.trim();
779 if text.is_empty() {
780 bail!("send: empty text");
781 }
782 check_length("send", text)?;
783 let from = super::seat_name();
784 let group = args
785 .group
786 .map(|name| check_name("send", name))
787 .transpose()?;
788 let client = super::pack()?;
789 let atoms = if group.is_some() {
792 ensure_mail(&client, "send")?;
793 listed()?
794 } else {
795 Vec::new()
796 };
797 let roster = group
798 .as_deref()
799 .map(|name| members(&atoms, name))
800 .unwrap_or_default();
801 let to = recipients(from.as_str(), args.seat, group.as_deref(), &roster)?;
802 if group.is_none() {
803 ensure_mail(&client, "send")?;
804 if !args.new_seat {
805 let known = known_seats(&listed()?);
806 for seat in &to {
807 check_known(seat, &known, false)?;
808 }
809 }
810 }
811 let issue = args
812 .issue
813 .map(str::trim)
814 .filter(|s| !s.is_empty())
815 .map(|s| check_name("send", s))
816 .transpose()?;
817 let scope = issue.as_deref().and_then(super::sync::scope_for_issue);
818 let outgoing = Outgoing {
819 id: mint_id("msg", &[&from, text]),
820 from,
821 to: to.clone(),
822 body: text.to_string(),
823 interrupt: args.interrupt,
824 issue,
825 group,
826 reply: None,
827 scope,
828 };
829 let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
830 stamp_source(&mut atom);
831 post(&atom, "send")?;
832 let mut said = format!("sent {} to {}\n", outgoing.id, to.join(", "));
833 said.push_str(&wake_after(&outgoing));
834 Ok(said)
835}
836
837fn wake_after(m: &Outgoing) -> String {
840 let events: Vec<super::wake::Event> =
841 m.to.iter()
842 .map(|seat| super::wake::Event {
843 seat: seat.clone(),
844 from: m.from.clone(),
845 msg: m.id.clone(),
846 issue: m.issue.clone(),
847 interrupt: m.interrupt,
848 })
849 .collect();
850 let woken = super::wake::spawn(&events);
851 if woken.is_empty() {
852 String::new()
853 } else {
854 format!("waking {}\n", woken.join(", "))
855 }
856}
857
858pub fn inbox(all: bool) -> Result<String> {
865 let atoms = load("inbox")?;
866 let seat = super::seat_name();
867 let seen = receipt_seen();
868 let (text, keys) = render_inbox(&atoms, &seat, all, &seen);
869 remember_receipts(&keys);
870 Ok(text)
871}
872
873fn already_read(atoms: &[Value], msg: &str, seat: &str) -> bool {
874 readers_of(atoms).contains(&(msg.to_string(), seat.to_string()))
875}
876
877fn receipt_atom(msg: &Letter, reader: &str, at: &str, workspace: &str) -> Value {
878 let id = mint_id("receipt", &[msg.id.as_str(), reader, at]);
879 let text = format!("read {}\nby {reader}\nat {at}\nmsg {id}", msg.id);
880 let mut entities = vec![
881 format!("seat:{reader}"),
882 format!("from:{reader}"),
883 format!("to:{}", msg.from),
884 format!("msg:{id}"),
885 format!("receipt:{}", msg.id),
886 ];
887 if let Some(scope) = &msg.scope {
888 entities.push(format!("scope:{scope}"));
889 }
890 json!({
891 "schema": "inside.atom/v1",
892 "kind": "receipt",
893 "level": "explicit",
894 "text": text,
895 "workspace": workspace,
896 "entities": entities,
897 })
898}
899
900pub fn read(id: &str) -> Result<String> {
903 let id = id.trim();
904 if id.is_empty() {
905 bail!("read: name a message");
906 }
907 let atoms = load("read")?;
908 let seat = super::seat_name();
909 let letter = find_letter(&atoms, id).context("read")?;
910 if !letter.to.iter().any(|to| to == &seat) {
911 bail!("read: {id} is not for {seat}");
912 }
913 if already_read(&atoms, id, &seat) {
914 return Ok(format!("read {id} already\n"));
915 }
916 let client = super::pack()?;
917 let mut atom = receipt_atom(
918 &letter,
919 &seat,
920 &super::now_utc(),
921 &workspace_of(Some(&client.workspace())),
922 );
923 stamp_source(&mut atom);
924 post(&atom, "read")?;
925 Ok(format!("read {id}\n"))
926}
927
928fn read_ids(ids: &[String]) {
929 let Some(atoms) = load_quiet() else {
930 return;
931 };
932 let seat = super::seat_name();
933 let Ok(client) = super::pack() else {
934 return;
935 };
936 let workspace = client.workspace();
937 let at = super::now_utc();
938 for id in ids {
939 let Ok(letter) = find_letter(&atoms, id) else {
940 continue;
941 };
942 if !letter.to.iter().any(|to| to == &seat) || already_read(&atoms, id, &seat) {
943 continue;
944 }
945 let mut atom = receipt_atom(&letter, &seat, &at, &workspace_of(Some(&workspace)));
946 stamp_source(&mut atom);
947 let _ = post(&atom, "read");
948 }
949}
950
951pub fn reply(id: &str, text: &str, interrupt: bool) -> Result<String> {
953 let id = id.trim();
954 let text = text.trim();
955 if id.is_empty() {
956 bail!("reply: name a message");
957 }
958 if text.is_empty() {
959 bail!("reply: empty text");
960 }
961 check_length("reply", text)?;
962 let atoms = load("reply")?;
963 let parent = find_letter(&atoms, id).context("reply")?;
964 let from = super::seat_name();
965 if parent.from == from {
966 bail!("reply: {id} is from this seat");
967 }
968 let issue = parent.issue.clone();
969 let scope = issue
970 .as_deref()
971 .and_then(super::sync::scope_for_issue)
972 .or(parent.scope);
973 let outgoing = Outgoing {
974 id: mint_id("msg", &[&from, id, text]),
975 from,
976 to: vec![parent.from.clone()],
977 body: text.to_string(),
978 interrupt,
979 issue,
980 group: None,
981 reply: Some(parent.id.clone()),
982 scope,
983 };
984 let client = super::pack()?;
985 let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
986 stamp_source(&mut atom);
987 post(&atom, "reply")?;
988 let mut line = format!(
989 "sent {} to {}\nreply {}\n",
990 outgoing.id, parent.from, parent.id
991 );
992 if let Some(issue) = &outgoing.issue {
993 line.push_str(&format!("issue {issue}\n"));
994 }
995 line.push_str(&wake_after(&outgoing));
996 Ok(line)
997}
998
999fn group_atom(name: &str, seat: &str, drop: bool, by: &str, workspace: &str) -> Value {
1000 let at = super::now_utc();
1001 let id = mint_id("group", &[name, seat, by, &at]);
1002 let verb = if drop { "drop" } else { "member" };
1003 let text = format!("group {id}\nname {name}\n{verb} {seat}\nby {by}\nat {at}");
1004 let mut entities = vec![
1005 format!("seat:{by}"),
1006 format!("group:{name}"),
1007 format!("msg:{id}"),
1008 ];
1009 if drop {
1010 entities.push(format!("drop:{seat}"));
1011 } else {
1012 entities.push(format!("member:{seat}"));
1013 }
1014 json!({
1015 "schema": "inside.atom/v1",
1016 "kind": "group",
1017 "level": "explicit",
1018 "text": text,
1019 "workspace": workspace,
1020 "entities": entities,
1021 })
1022}
1023
1024pub fn group(name: &str, add: &[String], remove: &[String]) -> Result<String> {
1026 let name = check_name("group", name)?;
1027 if add.is_empty() && remove.is_empty() {
1028 let atoms = load("group")?;
1029 let seats = members(&atoms, &name);
1030 if seats.is_empty() {
1031 return Ok(format!("{name}\t(no members)\n"));
1032 }
1033 let mut out = String::new();
1034 for seat in seats {
1035 out.push_str(&format!("{name}\t{seat}\n"));
1036 }
1037 return Ok(out);
1038 }
1039 let atoms = load("group")?;
1040 let by = super::seat_name();
1041 let client = super::pack()?;
1042 let workspace = client.workspace();
1043 let mut roster = members(&atoms, &name);
1044 let mut out = String::new();
1045 for seat in add {
1046 let seat = check_name("group", seat)?;
1047 if roster.iter().any(|s| s == &seat) {
1048 out.push_str(&format!("{seat} is already in {name}\n"));
1049 continue;
1050 }
1051 let mut atom = group_atom(&name, &seat, false, &by, &workspace_of(Some(&workspace)));
1052 stamp_source(&mut atom);
1053 post(&atom, "group")?;
1054 roster.push(seat.clone());
1055 out.push_str(&format!("added {seat} to {name}\n"));
1056 }
1057 for seat in remove {
1058 let seat = check_name("group", seat)?;
1059 if !roster.iter().any(|s| s == &seat) {
1060 out.push_str(&format!("{seat} is not in {name}\n"));
1061 continue;
1062 }
1063 let mut atom = group_atom(&name, &seat, true, &by, &workspace_of(Some(&workspace)));
1064 stamp_source(&mut atom);
1065 post(&atom, "group")?;
1066 roster.retain(|s| s != &seat);
1067 out.push_str(&format!("removed {seat} from {name}\n"));
1068 }
1069 Ok(out)
1070}
1071
1072fn state_dir() -> PathBuf {
1073 std::env::var_os("XDG_STATE_HOME")
1074 .map(PathBuf::from)
1075 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
1076 .unwrap_or_else(|| PathBuf::from(".local/state"))
1077 .join("ljos")
1078 .join("mail")
1079}
1080
1081fn receipt_seen_path() -> PathBuf {
1082 state_dir().join("receipts-seen")
1083}
1084
1085fn receipt_seen() -> BTreeSet<String> {
1086 std::fs::read_to_string(receipt_seen_path())
1087 .map(|text| {
1088 text.lines()
1089 .map(str::trim)
1090 .filter(|l| !l.is_empty())
1091 .map(str::to_string)
1092 .collect()
1093 })
1094 .unwrap_or_default()
1095}
1096
1097fn remember_receipts(keys: &[String]) {
1098 if keys.is_empty() {
1099 return;
1100 }
1101 let path = receipt_seen_path();
1102 if let Some(dir) = path.parent() {
1103 let _ = std::fs::create_dir_all(dir);
1104 }
1105 let mut text = std::fs::read_to_string(&path).unwrap_or_default();
1106 for key in keys {
1107 if text.lines().any(|l| l == key) {
1108 continue;
1109 }
1110 text.push_str(key);
1111 text.push('\n');
1112 }
1113 let _ = std::fs::write(path, text);
1114}
1115
1116pub const MAIL_UNCHECKED: &str = "mail could not be checked";
1120
1121#[must_use]
1124pub fn prompt_note() -> (String, Vec<String>) {
1125 let Some(atoms) = load_quiet() else {
1126 return (MAIL_UNCHECKED.to_string(), Vec::new());
1127 };
1128 let seat = super::seat_name();
1129 let letters = inbox_letters(&atoms, &seat, false);
1130 let seen = receipt_seen();
1131 let receipts: Vec<Receipt> = receipts_for(&atoms, &seat)
1132 .into_iter()
1133 .filter(|r| !seen.contains(&r.key))
1134 .collect();
1135 prompt_lines(&letters, &receipts)
1136}
1137
1138pub const STOP_MAIL_HEAD: &str =
1140 "Mail marked interrupt reached this seat while you worked. Read it before you stop:";
1141
1142#[must_use]
1149pub fn stop_note() -> (String, Vec<String>) {
1150 let Some(atoms) = load_quiet() else {
1151 return (String::new(), Vec::new());
1152 };
1153 stop_lines(&atoms, &super::seat_name())
1154}
1155
1156#[must_use]
1158pub fn stop_lines(atoms: &[Value], seat: &str) -> (String, Vec<String>) {
1159 let hot: Vec<Letter> = inbox_letters(atoms, seat, false)
1160 .into_iter()
1161 .filter(|l| l.interrupt)
1162 .collect();
1163 let (lines, ids) = prompt_lines(&hot, &[]);
1164 if lines.is_empty() {
1165 return (String::new(), Vec::new());
1166 }
1167 let reply = "Answer with `ljos reply ID TEXT`.";
1168 (format!("{STOP_MAIL_HEAD}\n{lines}\n{reply}"), ids)
1169}
1170
1171pub fn ack_delivered(ids: &[String]) {
1175 let mut reads = Vec::new();
1176 let mut seen = Vec::new();
1177 for id in ids {
1178 if let Some(msg) = id.strip_prefix("mail:") {
1179 reads.push(msg.to_string());
1180 } else if let Some(key) = id.strip_prefix("rcpt:") {
1181 seen.push(key.to_string());
1182 }
1183 }
1184 if !reads.is_empty() {
1185 read_ids(&reads);
1186 }
1187 remember_receipts(&seen);
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192 use super::*;
1193
1194 fn outgoing(id: &str, from: &str, to: &[&str], body: &str) -> Outgoing {
1195 Outgoing {
1196 id: id.to_string(),
1197 from: from.to_string(),
1198 to: to.iter().map(|s| (*s).to_string()).collect(),
1199 body: body.to_string(),
1200 interrupt: false,
1201 issue: None,
1202 group: None,
1203 reply: None,
1204 scope: None,
1205 }
1206 }
1207
1208 #[test]
1209 fn a_long_body_fits_the_pack_and_reads_back_whole() {
1210 let long = "the aarch64 log says the Highway test times out. ".repeat(40);
1211 let m = outgoing("msg-1", "saddle", &["inky"], &long);
1212 let atom = message_atom(&m, "seat");
1213 let text = atom["text"].as_str().unwrap();
1214 assert!(
1215 text.chars().count() <= ATOM_TEXT_CAP,
1216 "{}",
1217 text.chars().count()
1218 );
1219 assert!(text.ends_with('\u{2026}'), "{text}");
1220 assert!(
1221 text.starts_with("msg msg-1\nfrom saddle\nto inky\n\n"),
1222 "{text}"
1223 );
1224 assert_eq!(atom["body"].as_str(), Some(long.trim()));
1225 let letter = letter_of(&atom, 0).expect("a letter");
1226 assert_eq!(letter.body, long.trim());
1227 let short = message_atom(&outgoing("msg-2", "saddle", &["inky"], "ok"), "seat");
1229 assert!(short.get("body").is_none());
1230 assert_eq!(letter_of(&short, 0).unwrap().body, "ok");
1231 let err = check_length("send", &"x".repeat(MAIL_BODY_CAP + 1))
1233 .unwrap_err()
1234 .to_string();
1235 assert!(err.contains("deedar create file"), "{err}");
1236 assert!(check_length("send", &"x".repeat(MAIL_BODY_CAP)).is_ok());
1237 let shown = prompt_body(&long);
1238 assert!(shown.contains("`ljos inbox --all`"), "{shown}");
1239 assert_eq!(prompt_body("ok"), "ok");
1240 }
1241
1242 fn atom(m: &Outgoing, ts: &str) -> Value {
1243 let mut v = message_atom(m, "seat");
1244 v["ts"] = json!(ts);
1245 v
1246 }
1247
1248 #[test]
1249 fn the_same_words_stay_two_messages() {
1250 let a = outgoing("a", "alice", &["bob"], "ok");
1251 let b = outgoing("b", "carol", &["bob"], "ok");
1252 let left = message_text(&a);
1253 let right = message_text(&b);
1254 assert_ne!(left, right);
1255 assert_ne!(super::super::work_id(&left), super::super::work_id(&right));
1256 assert_eq!(body_of(&left), "ok");
1257 let atoms = [
1258 atom(&a, "2026-10-09T00:00:00Z"),
1259 atom(&b, "2026-10-09T00:00:01Z"),
1260 ];
1261 let plain = super::super::sync::log_plaintext(&atoms, "seat", "seat");
1262 assert_eq!(plain.lines().count(), 2, "{plain}");
1263 let got = super::super::sync::incoming(&plain, "other", &BTreeSet::new(), &BTreeSet::new());
1264 assert_eq!(got.post.len(), 2);
1265 }
1266
1267 #[test]
1268 fn inbox_is_the_seat_named_and_an_interrupt_leads() {
1269 let mut late = outgoing("late", "alice", &["bob"], "when you can");
1270 late.issue = Some("acme-4".into());
1271 let mut hot = outgoing("hot", "carol", &["bob"], "the build is red");
1272 hot.interrupt = true;
1273 hot.issue = Some("acme-4".into());
1274 let other = outgoing("other", "alice", &["dave"], "not for bob");
1275 let atoms = [
1276 atom(&late, "2026-10-09T00:00:00Z"),
1277 atom(&hot, "2026-10-09T00:00:02Z"),
1278 atom(&other, "2026-10-09T00:00:01Z"),
1279 ];
1280 let letters = inbox_letters(&atoms, "bob", false);
1281 assert_eq!(
1282 letters.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(),
1283 ["hot", "late"]
1284 );
1285 assert!(letters[0].interrupt);
1286 assert_eq!(letters[1].issue.as_deref(), Some("acme-4"));
1287 let (text, ids) = prompt_lines(&letters, &[]);
1288 assert!(
1289 text.starts_with("mail:\n! hot from carol on acme-4:"),
1290 "{text}"
1291 );
1292 assert!(ids.contains(&"mail:hot".to_string()), "{ids:?}");
1293 assert!(text.contains("late from alice on acme-4:"));
1294 }
1295
1296 #[test]
1297 fn a_receipt_takes_the_message_out_of_the_unread_list() {
1298 let msg = outgoing("m1", "alice", &["bob"], "look at the log");
1299 let message = atom(&msg, "2026-10-09T00:00:00Z");
1300 let receipt = json!({
1301 "kind": "receipt",
1302 "text": "read m1\nby bob\nat 2026-10-09T00:01:00Z\nmsg r1",
1303 "ts": "2026-10-09T00:01:00Z",
1304 "entities": ["seat:bob", "from:bob", "to:alice", "receipt:m1", "msg:r1"],
1305 });
1306 let held = [message, receipt];
1307 let unread = inbox_letters(&held, "bob", false);
1308 assert!(unread.is_empty());
1309 let all = inbox_letters(&held, "bob", true);
1310 assert!(all[0].read);
1311 let (text, keys) = render_inbox(&held, "alice", false, &BTreeSet::new());
1312 assert!(
1313 text.contains("receipt\tm1\tbob\t2026-10-09T00:01:00Z"),
1314 "{text}"
1315 );
1316 assert_eq!(keys.len(), 1);
1317 let (again, _) = render_inbox(&held, "alice", false, &keys.into_iter().collect());
1318 assert_eq!(again, "inbox: nothing unread\n");
1319 }
1320
1321 #[test]
1322 fn a_reply_keeps_the_issue_and_names_the_sender() {
1323 let mut parent = outgoing("p", "alice", &["bob"], "the build is red");
1324 parent.issue = Some("acme-4".into());
1325 parent.scope = Some("tools".into());
1326 let letter = letter_of(&atom(&parent, "2026-10-09T00:00:00Z"), 0).unwrap();
1327 let reply = Outgoing {
1328 id: "r".into(),
1329 from: "bob".into(),
1330 to: vec![letter.from.clone()],
1331 body: "looking".into(),
1332 interrupt: false,
1333 issue: letter.issue.clone(),
1334 group: None,
1335 reply: Some(letter.id.clone()),
1336 scope: letter.scope.clone(),
1337 };
1338 let atom = message_atom(&reply, "seat");
1339 let back = letter_of(&atom, 1).unwrap();
1340 assert_eq!(back.to, ["alice"]);
1341 assert_eq!(back.reply.as_deref(), Some("p"));
1342 assert_eq!(back.issue.as_deref(), Some("acme-4"));
1343 assert_eq!(back.scope.as_deref(), Some("tools"));
1344 assert!(super::super::sync::is_own(&atom));
1345 }
1346
1347 #[test]
1348 fn a_group_send_reaches_the_other_members() {
1349 let atoms = [
1350 json!({
1351 "kind": "group",
1352 "ts": "2026-10-09T00:00:00Z",
1353 "entities": ["group:ops", "member:alice"],
1354 }),
1355 json!({
1356 "kind": "group",
1357 "ts": "2026-10-09T00:00:01Z",
1358 "entities": ["group:ops", "member:bob"],
1359 }),
1360 json!({
1361 "kind": "group",
1362 "ts": "2026-10-09T00:00:02Z",
1363 "entities": ["group:ops", "member:carol"],
1364 }),
1365 json!({
1366 "kind": "group",
1367 "ts": "2026-10-09T00:00:03Z",
1368 "entities": ["group:ops", "drop:carol"],
1369 }),
1370 ];
1371 assert_eq!(members(&atoms, "ops"), ["alice", "bob"]);
1372 let to = recipients("alice", None, Some("ops"), &members(&atoms, "ops")).unwrap();
1373 assert_eq!(to, ["bob"]);
1374 let err = recipients("alice", None, Some("ops"), &["alice".into()]).unwrap_err();
1375 assert!(err.to_string().contains("no other member"), "{err}");
1376 let err = recipients("alice", Some("alice"), None, &[]).unwrap_err();
1377 assert!(err.to_string().contains("this seat"), "{err}");
1378 }
1379
1380 #[test]
1381 fn a_prompt_caps_the_note_and_keeps_the_rest_unread() {
1382 let letters: Vec<Letter> = (0..10)
1383 .map(|n| Letter {
1384 id: format!("m{n}"),
1385 from: "alice".into(),
1386 to: vec!["bob".into()],
1387 body: format!("line {n}"),
1388 interrupt: n == 9,
1389 issue: None,
1390 group: None,
1391 reply: None,
1392 scope: None,
1393 read: false,
1394 ts: format!("2026-10-09T00:00:{n:02}Z"),
1395 seq: n,
1396 })
1397 .collect();
1398 let mut ordered = letters.clone();
1399 ordered.sort_by(|a, b| b.interrupt.cmp(&a.interrupt).then(a.ts.cmp(&b.ts)));
1400 let (text, ids) = prompt_lines(&ordered, &[]);
1401 assert!(text.lines().next().unwrap() == "mail:");
1402 assert!(text.contains("! m9 from alice:"));
1403 assert!(text.contains("2 more in ljos inbox"), "{text}");
1404 assert_eq!(ids.iter().filter(|id| id.starts_with("mail:")).count(), 8);
1405 assert!(!ids.iter().any(|id| id == "mail:m7" || id == "mail:m8"));
1406 }
1407
1408 #[test]
1409 fn a_seat_hint_names_a_few_seats_and_no_ids() {
1410 let known: BTreeSet<String> = [
1411 "alpha",
1412 "bravo",
1413 "charlie",
1414 "delta",
1415 "echo",
1416 "foxtrot",
1417 "saddle",
1418 "01a0dab3-895e-7533-93ca-c9807d5cb680",
1419 "sess-4f2a9c0d1e2b3a4f",
1420 ]
1421 .iter()
1422 .map(|s| (*s).to_string())
1423 .collect();
1424 assert!(named_seat("codex-vax0"));
1425 assert!(named_seat("disk-saver"));
1426 assert!(!named_seat("01a0dab3-895e-7533-93ca-c9807d5cb680"));
1427 assert!(!named_seat("sess-4f2a9c0d1e2b3a4f"));
1428 let hint = seat_hint("sadle", &known);
1429 assert_eq!(
1430 hint,
1431 "Did you mean saddle? Other seats: alpha, bravo, charlie, delta and 2 more."
1432 );
1433 let hint = seat_hint("zzz", &known);
1434 assert_eq!(hint, "Seats here: alpha, bravo, charlie, delta and 3 more.");
1435 let ids: BTreeSet<String> = ["01a0dab3-895e-7533-93ca-c9807d5cb680".to_string()].into();
1436 assert_eq!(
1437 seat_hint("codex", &ids),
1438 "No named seat has started here yet."
1439 );
1440 let err = check_known("sadle", &known, false).unwrap_err().to_string();
1441 assert!(!err.contains("01a0dab3"), "{err}");
1442 assert!(err.contains("Did you mean saddle?"), "{err}");
1443 }
1444
1445 #[test]
1446 fn a_misspelt_seat_is_answered_with_the_seat_it_meant() {
1447 let known: BTreeSet<String> = ["ljos-bot", "saddle", "inky"]
1448 .iter()
1449 .map(|s| (*s).to_string())
1450 .collect();
1451 assert_eq!(closest_seat("ljos", &known), Some("ljos-bot"));
1452 assert_eq!(closest_seat("sadle", &known), Some("saddle"));
1453 assert_eq!(closest_seat("eessi", &known), None);
1454 let err = check_known("ljos", &known, false).unwrap_err().to_string();
1455 assert!(err.contains("Did you mean ljos-bot?"), "{err}");
1456 let err = check_known("eessi", &known, false).unwrap_err().to_string();
1457 assert!(!err.contains("Did you mean"), "{err}");
1458 assert!(check_known("eessi", &known, true).is_ok());
1459 }
1460
1461 #[test]
1462 fn a_stop_carries_unread_interrupt_mail_only() {
1463 let plain = outgoing("plain", "grok", &["claude"], "when you can");
1464 let mut hot = outgoing("hot", "grok", &["claude"], "take ticket-d9n6 now");
1465 hot.interrupt = true;
1466 hot.issue = Some("ticket-d9n6".into());
1467 let atoms = [
1468 atom(&plain, "2026-10-11T00:00:00Z"),
1469 atom(&hot, "2026-10-11T00:00:01Z"),
1470 ];
1471 let (text, ids) = stop_lines(&atoms, "claude");
1472 assert!(text.starts_with(STOP_MAIL_HEAD), "{text}");
1473 assert!(
1474 text.contains("! hot from grok on ticket-d9n6: take ticket-d9n6 now"),
1475 "{text}"
1476 );
1477 assert!(!text.contains("when you can"), "{text}");
1478 assert_eq!(ids, ["mail:hot"]);
1479 let read = json!({
1480 "kind": "receipt",
1481 "text": "read hot\nby claude\nat 2026-10-11T00:00:02Z\nmsg r1",
1482 "entities": ["seat:claude", "from:claude", "to:grok", "receipt:hot", "msg:r1"],
1483 });
1484 let after = [atoms[0].clone(), atoms[1].clone(), read];
1485 assert_eq!(stop_lines(&after, "claude"), (String::new(), Vec::new()));
1486 assert_eq!(stop_lines(&atoms, "grok"), (String::new(), Vec::new()));
1487 }
1488
1489 #[test]
1490 fn an_unknown_kind_is_a_pack_without_mail() {
1491 let refused =
1492 "bad response: http://127.0.0.1:8761/v1/atoms: 400: unknown atom kind: message";
1493 assert_eq!(probe_of(refused), Probe::Refused);
1494 let kept = "bad response: http://127.0.0.1:8761/v1/atoms: 400: atom text is required";
1495 assert_eq!(probe_of(kept), Probe::Kept);
1496 let down = "http://127.0.0.1:8761/v1/atoms: http: connection refused";
1497 assert_eq!(probe_of(down), Probe::Store);
1498 let named = unsupported_mail(Some("0.12.1"));
1499 assert!(named.contains("packset 0.12.1"), "{named}");
1500 assert!(named.contains(MAIL_PACKSET), "{named}");
1501 assert!(named.contains("kind message"), "{named}");
1502 let quiet = unsupported_mail(None);
1503 assert!(quiet.contains(MAIL_PACKSET), "{quiet}");
1504 assert!(quiet.contains("refused kind message"), "{quiet}");
1505 }
1506}