Skip to main content

ljos_cli/
mail.rs

1//! Mail from one seat to another.
2//!
3//! A message is a pack atom. Seats on one box share the pack and see it at
4//! once. `ljos sync` exports the machine's own atoms, so the same atom
5//! crosses machines in the sealed log. A receipt is its own atom: the pack
6//! and the log are append-only, and an import tells two atoms apart by the
7//! hash of their text, so the text of a message carries its id.
8
9use 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
16/// How many messages a prompt is shown. The rest stay unread.
17const PROMPT_CAP: usize = 8;
18
19/// The first packset that keeps kinds `message`, `receipt` and `group`.
20///
21/// 0.12.1 refuses them. A new kind has been a minor release: `outcome`
22/// arrived in 0.12.0, and `prediction` and `rule` in 0.6.0.
23pub(crate) const MAIL_PACKSET: &str = "0.13.0";
24
25/// What a probe post of an empty `message` means.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum Probe {
28    /// The writer refused the kind.
29    Refused,
30    /// The kind passed and a later check refused the empty probe.
31    Kept,
32    /// The writer did not answer.
33    Store,
34}
35
36/// Classify a probe error. Kind is checked before the text, so an empty
37/// message is refused as unknown when the pack cannot keep mail, and as
38/// missing text when it can.
39fn 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/// One message as it is written.
61#[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
74/// What `ljos send` was asked to write.
75pub 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}
82
83/// One message as a reader sees it.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Letter {
86    pub id: String,
87    pub from: String,
88    pub to: Vec<String>,
89    pub body: String,
90    pub interrupt: bool,
91    pub issue: Option<String>,
92    pub group: Option<String>,
93    pub reply: Option<String>,
94    pub scope: Option<String>,
95    pub read: bool,
96    ts: String,
97    seq: usize,
98}
99
100/// A read receipt addressed to the seat that sent the message.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Receipt {
103    pub msg: String,
104    pub by: String,
105    pub at: String,
106    pub key: String,
107}
108
109fn mint_id(kind: &str, parts: &[&str]) -> String {
110    static N: AtomicU64 = AtomicU64::new(1);
111    let n = N.fetch_add(1, Ordering::Relaxed);
112    let nanos = std::time::SystemTime::now()
113        .duration_since(std::time::UNIX_EPOCH)
114        .map(|d| d.as_nanos())
115        .unwrap_or(0);
116    let raw = format!(
117        "{kind}\n{n}\n{nanos}\n{}\n{}",
118        std::process::id(),
119        parts.join("\n")
120    );
121    super::work_id(&raw)
122}
123
124fn check_name(kind: &str, name: &str) -> Result<String> {
125    let name = name.trim();
126    if name.is_empty()
127        || name
128            .chars()
129            .any(|c| c.is_whitespace() || c == ',' || c == ':')
130    {
131        bail!("{kind}: {name:?} is not a name");
132    }
133    Ok(name.to_string())
134}
135
136fn tag(atom: &Value, prefix: &str) -> Option<String> {
137    atom["entities"]
138        .as_array()?
139        .iter()
140        .filter_map(Value::as_str)
141        .find_map(|e| e.strip_prefix(prefix).map(str::to_string))
142}
143
144fn tags(atom: &Value, prefix: &str) -> Vec<String> {
145    atom["entities"]
146        .as_array()
147        .into_iter()
148        .flatten()
149        .filter_map(Value::as_str)
150        .filter_map(|e| e.strip_prefix(prefix).map(str::to_string))
151        .collect()
152}
153
154fn has(atom: &Value, entity: &str) -> bool {
155    atom["entities"]
156        .as_array()
157        .into_iter()
158        .flatten()
159        .any(|e| e.as_str() == Some(entity))
160}
161
162/// The body a person wrote. The lines above the blank line are the id and
163/// the envelope, which keep two messages with the same words distinct.
164#[must_use]
165pub fn body_of(text: &str) -> String {
166    text.split_once("\n\n")
167        .map(|(_, body)| body.trim().to_string())
168        .unwrap_or_else(|| text.trim().to_string())
169}
170
171/// The text that travels. The id is in it, so an import that keys on the
172/// text keeps two copies of the same words.
173#[must_use]
174pub fn message_text(m: &Outgoing) -> String {
175    let mut head = format!("msg {}\nfrom {}\nto {}", m.id, m.from, m.to.join(", "));
176    if m.interrupt {
177        head.push_str("\ninterrupt");
178    }
179    if let Some(issue) = &m.issue {
180        head.push_str(&format!("\nissue {issue}"));
181    }
182    if let Some(group) = &m.group {
183        head.push_str(&format!("\ngroup {group}"));
184    }
185    if let Some(reply) = &m.reply {
186        head.push_str(&format!("\nreply {reply}"));
187    }
188    format!("{head}\n\n{}", m.body.trim())
189}
190
191/// The atom a message posts. The entities are what an inbox filters on.
192#[must_use]
193pub fn message_atom(m: &Outgoing, workspace: &str) -> Value {
194    let mut entities = vec![
195        format!("seat:{}", m.from),
196        format!("from:{}", m.from),
197        format!("msg:{}", m.id),
198    ];
199    for to in &m.to {
200        entities.push(format!("to:{to}"));
201    }
202    if m.interrupt {
203        entities.push("priority:interrupt".to_string());
204    }
205    if let Some(issue) = &m.issue {
206        entities.push(format!("issue:{issue}"));
207    }
208    if let Some(group) = &m.group {
209        entities.push(format!("group:{group}"));
210    }
211    if let Some(reply) = &m.reply {
212        entities.push(format!("reply:{reply}"));
213    }
214    if let Some(scope) = &m.scope {
215        entities.push(format!("scope:{scope}"));
216    }
217    json!({
218        "schema": "inside.atom/v1",
219        "kind": "message",
220        "level": "explicit",
221        "text": message_text(m),
222        "workspace": workspace,
223        "entities": entities,
224    })
225}
226
227fn letter_of(atom: &Value, seq: usize) -> Option<Letter> {
228    if atom["kind"].as_str() != Some("message") {
229        return None;
230    }
231    let id = tag(atom, "msg:")?;
232    let from = tag(atom, "from:").or_else(|| tag(atom, "seat:"))?;
233    let to = tags(atom, "to:");
234    if to.is_empty() {
235        return None;
236    }
237    Some(Letter {
238        id,
239        from,
240        to,
241        body: body_of(atom["text"].as_str().unwrap_or("")),
242        interrupt: has(atom, "priority:interrupt"),
243        issue: tag(atom, "issue:"),
244        group: tag(atom, "group:"),
245        reply: tag(atom, "reply:"),
246        scope: tag(atom, "scope:"),
247        read: false,
248        ts: atom["ts"].as_str().unwrap_or("").to_string(),
249        seq,
250    })
251}
252
253fn readers_of(atoms: &[Value]) -> BTreeSet<(String, String)> {
254    let mut out = BTreeSet::new();
255    for atom in atoms {
256        if atom["kind"].as_str() != Some("receipt") {
257            continue;
258        }
259        let Some(msg) = tag(atom, "receipt:") else {
260            continue;
261        };
262        let Some(by) = tag(atom, "from:").or_else(|| tag(atom, "seat:")) else {
263            continue;
264        };
265        out.insert((msg, by));
266    }
267    out
268}
269
270/// Messages addressed to `seat`, interrupts first, then oldest first.
271#[must_use]
272pub fn inbox_letters(atoms: &[Value], seat: &str, all: bool) -> Vec<Letter> {
273    let read = readers_of(atoms);
274    let mut letters: Vec<Letter> = atoms
275        .iter()
276        .enumerate()
277        .filter_map(|(seq, atom)| letter_of(atom, seq))
278        .filter(|letter| letter.to.iter().any(|to| to == seat))
279        .map(|mut letter| {
280            letter.read = read.contains(&(letter.id.clone(), seat.to_string()));
281            letter
282        })
283        .filter(|letter| all || !letter.read)
284        .collect();
285    letters.sort_by(|a, b| {
286        b.interrupt
287            .cmp(&a.interrupt)
288            .then(a.ts.cmp(&b.ts))
289            .then(a.seq.cmp(&b.seq))
290    });
291    letters
292}
293
294/// Receipts on messages this seat sent, newest stamp last.
295#[must_use]
296pub fn receipts_for(atoms: &[Value], seat: &str) -> Vec<Receipt> {
297    let mut out = Vec::new();
298    for atom in atoms {
299        if atom["kind"].as_str() != Some("receipt") {
300            continue;
301        }
302        if tag(atom, "to:").as_deref() != Some(seat) {
303            continue;
304        }
305        let Some(msg) = tag(atom, "receipt:") else {
306            continue;
307        };
308        let Some(by) = tag(atom, "from:") else {
309            continue;
310        };
311        let text = atom["text"].as_str().unwrap_or("");
312        let at = text
313            .lines()
314            .find_map(|l| l.strip_prefix("at "))
315            .unwrap_or("")
316            .to_string();
317        out.push(Receipt {
318            key: super::work_id(text),
319            msg,
320            by,
321            at,
322        });
323    }
324    out.sort_by(|a, b| a.at.cmp(&b.at).then(a.key.cmp(&b.key)));
325    out
326}
327
328/// Seats in a group, in the order they were added. A later drop removes one.
329#[must_use]
330pub fn members(atoms: &[Value], group: &str) -> Vec<String> {
331    let mut events: Vec<(String, usize, String, bool)> = Vec::new();
332    for (seq, atom) in atoms.iter().enumerate() {
333        if atom["kind"].as_str() != Some("group") {
334            continue;
335        }
336        if tag(atom, "group:").as_deref() != Some(group) {
337            continue;
338        }
339        let ts = atom["ts"].as_str().unwrap_or("").to_string();
340        if let Some(seat) = tag(atom, "member:") {
341            events.push((ts, seq, seat, false));
342        } else if let Some(seat) = tag(atom, "drop:") {
343            events.push((ts, seq, seat, true));
344        }
345    }
346    events.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
347    let mut out: Vec<String> = Vec::new();
348    for (_, _, seat, drop) in events {
349        if drop {
350            out.retain(|s| s != &seat);
351        } else if !out.iter().any(|s| s == &seat) {
352            out.push(seat);
353        }
354    }
355    out
356}
357
358fn one_line(body: &str, limit: Option<usize>) -> String {
359    let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
360    match limit {
361        Some(n) if flat.chars().count() > n => {
362            let t: String = flat.chars().take(n.saturating_sub(3)).collect();
363            format!("{t}...")
364        }
365        _ => flat,
366    }
367}
368
369fn flags(letter: &Letter) -> String {
370    let mut parts = Vec::new();
371    if letter.interrupt {
372        parts.push("interrupt".to_string());
373    }
374    if let Some(issue) = &letter.issue {
375        parts.push(format!("issue:{issue}"));
376    }
377    if let Some(group) = &letter.group {
378        parts.push(format!("group:{group}"));
379    }
380    if let Some(reply) = &letter.reply {
381        parts.push(format!("reply:{reply}"));
382    }
383    if letter.read {
384        parts.push("read".to_string());
385    }
386    parts.join(" ")
387}
388
389/// The inbox a seat prints. Receipts already shown are left out.
390#[must_use]
391pub fn render_inbox(
392    atoms: &[Value],
393    seat: &str,
394    all: bool,
395    seen_receipts: &BTreeSet<String>,
396) -> (String, Vec<String>) {
397    let letters = inbox_letters(atoms, seat, all);
398    let receipts: Vec<Receipt> = receipts_for(atoms, seat)
399        .into_iter()
400        .filter(|r| !seen_receipts.contains(&r.key))
401        .collect();
402    if letters.is_empty() && receipts.is_empty() {
403        let line = if all {
404            "inbox: nothing\n"
405        } else {
406            "inbox: nothing unread\n"
407        };
408        return (line.to_string(), Vec::new());
409    }
410    let mut out = String::new();
411    for letter in &letters {
412        out.push_str(&format!(
413            "{}\t{}\t{}\t{}\n",
414            letter.id,
415            letter.from,
416            flags(letter),
417            one_line(&letter.body, None)
418        ));
419    }
420    let mut keys = Vec::new();
421    for receipt in &receipts {
422        out.push_str(&format!(
423            "receipt\t{}\t{}\t{}\n",
424            receipt.msg, receipt.by, receipt.at
425        ));
426        keys.push(receipt.key.clone());
427    }
428    (out, keys)
429}
430
431/// The note a prompt hook prints, and the ids to receipt when it is delivered.
432///
433/// Message ids are prefixed `mail:`. Receipt ids the sender is being told
434/// about are prefixed `rcpt:` and are recorded locally, not written again.
435#[must_use]
436pub fn prompt_lines(letters: &[Letter], receipts: &[Receipt]) -> (String, Vec<String>) {
437    if letters.is_empty() && receipts.is_empty() {
438        return (String::new(), Vec::new());
439    }
440    let mut lines = vec!["mail:".to_string()];
441    let mut ids = Vec::new();
442    for letter in letters.iter().take(PROMPT_CAP) {
443        let mark = if letter.interrupt { "! " } else { "" };
444        let on = letter
445            .issue
446            .as_ref()
447            .map(|issue| format!(" on {issue}"))
448            .unwrap_or_default();
449        lines.push(format!(
450            "{mark}{} from {}{on}: {}",
451            letter.id,
452            letter.from,
453            one_line(&letter.body, Some(160))
454        ));
455        ids.push(format!("mail:{}", letter.id));
456    }
457    let rest = letters.len().saturating_sub(PROMPT_CAP);
458    if rest > 0 {
459        lines.push(format!("{rest} more in ljos inbox"));
460    }
461    for receipt in receipts.iter().take(PROMPT_CAP) {
462        lines.push(format!("read {} by {}", receipt.msg, receipt.by));
463        ids.push(format!("rcpt:{}", receipt.key));
464    }
465    (lines.join("\n"), ids)
466}
467
468fn find_letter(atoms: &[Value], id: &str) -> Result<Letter> {
469    atoms
470        .iter()
471        .enumerate()
472        .filter_map(|(seq, atom)| letter_of(atom, seq))
473        .find(|letter| letter.id == id)
474        .ok_or_else(|| anyhow::anyhow!("no message {id}"))
475}
476
477/// Who a send addresses. A group send leaves the sender off the list.
478pub fn recipients(
479    from: &str,
480    seat: Option<&str>,
481    group: Option<&str>,
482    members: &[String],
483) -> Result<Vec<String>> {
484    if let Some(group) = group {
485        if seat.is_some() {
486            bail!("send: name a seat or a group");
487        }
488        let to: Vec<String> = members
489            .iter()
490            .filter(|m| m.as_str() != from)
491            .cloned()
492            .collect();
493        if to.is_empty() {
494            bail!("send: {group} has no other member");
495        }
496        return Ok(to);
497    }
498    let Some(seat) = seat.map(str::trim).filter(|s| !s.is_empty()) else {
499        bail!("send: name a seat or a group");
500    };
501    let seat = check_name("send", seat)?;
502    if seat == from {
503        bail!("send: {seat} is this seat");
504    }
505    Ok(vec![seat])
506}
507
508fn is_mail_atom(atom: &Value) -> bool {
509    matches!(atom["kind"].as_str(), Some("message" | "receipt" | "group"))
510}
511
512fn writer_version(client: &packset_client::PacksetClient) -> Option<String> {
513    client
514        .status(Some(&client.workspace()))
515        .ok()
516        .and_then(|status| {
517            status
518                .get("version")
519                .and_then(Value::as_str)
520                .filter(|version| !version.is_empty())
521                .map(str::to_string)
522        })
523}
524
525/// Fail when this writer does not keep mail. An empty message is not
526/// stored: the kind is refused, or the text is.
527fn ensure_mail(client: &packset_client::PacksetClient, verb: &str) -> Result<()> {
528    // The writer's version decides when it gives one. packset 0.12.1 checks
529    // the text before the kind, so an empty probe reads the same from a
530    // writer that keeps mail and from one that refuses it.
531    if let Some(version) = writer_version(client) {
532        match super::cmp_semver(&version, MAIL_PACKSET) {
533            Some(std::cmp::Ordering::Less) => {
534                bail!("{verb}: {}", unsupported_mail(Some(&version)));
535            }
536            Some(_) => return Ok(()),
537            None => {}
538        }
539    }
540    let probe = json!({
541        "schema": "inside.atom/v1",
542        "kind": "message",
543        "level": "explicit",
544        "text": "",
545        "workspace": workspace_of(Some(&client.workspace())),
546    });
547    let posted = super::with_writer(|| client.post_atom(&probe).map_err(anyhow::Error::from));
548    match posted {
549        Ok(_) => Ok(()),
550        Err(err) => match probe_of(&format!("{err:#}")) {
551            Probe::Kept => Ok(()),
552            Probe::Refused => {
553                let version = writer_version(client);
554                bail!("{verb}: {}", unsupported_mail(version.as_deref()));
555            }
556            Probe::Store => Err(err).context("mail: the pack did not answer"),
557        },
558    }
559}
560
561fn listed() -> Result<Vec<Value>> {
562    let client = super::pack()?;
563    let workspace = client.workspace();
564    super::atoms_lean(&client, &workspace).context("mail: the pack did not answer")
565}
566
567fn load(verb: &str) -> Result<Vec<Value>> {
568    let client = super::pack()?;
569    let atoms = listed()?;
570    if !atoms.iter().any(is_mail_atom) {
571        ensure_mail(&client, verb)?;
572    }
573    Ok(atoms)
574}
575
576fn load_quiet() -> Option<Vec<Value>> {
577    let client = super::pack().ok()?;
578    let workspace = client.workspace();
579    super::atoms_lean(&client, &workspace).ok()
580}
581
582fn post(atom: &Value, verb: &str) -> Result<Value> {
583    let client = super::pack()?;
584    super::with_writer(|| {
585        client
586            .post_atom(atom)
587            .with_context(|| format!("{verb}: POST /v1/atoms failed"))
588    })
589}
590
591fn workspace_of(client_workspace: Option<&str>) -> String {
592    client_workspace.unwrap_or("seat").to_string()
593}
594
595fn stamp_source(atom: &mut Value) {
596    atom["source"] = super::atom_source();
597}
598
599/// Write one message and print its id.
600pub fn send(args: &Send<'_>) -> Result<String> {
601    let text = args.text.trim();
602    if text.is_empty() {
603        bail!("send: empty text");
604    }
605    let from = super::seat_name();
606    let group = args
607        .group
608        .map(|name| check_name("send", name))
609        .transpose()?;
610    let client = super::pack()?;
611    // A named seat is checked before the pack, so "this seat" stays that
612    // error. A group has no roster until the pack answers.
613    let atoms = if group.is_some() {
614        ensure_mail(&client, "send")?;
615        listed()?
616    } else {
617        Vec::new()
618    };
619    let roster = group
620        .as_deref()
621        .map(|name| members(&atoms, name))
622        .unwrap_or_default();
623    let to = recipients(from.as_str(), args.seat, group.as_deref(), &roster)?;
624    if group.is_none() {
625        ensure_mail(&client, "send")?;
626    }
627    let issue = args
628        .issue
629        .map(str::trim)
630        .filter(|s| !s.is_empty())
631        .map(|s| check_name("send", s))
632        .transpose()?;
633    let scope = issue.as_deref().and_then(super::sync::scope_for_issue);
634    let outgoing = Outgoing {
635        id: mint_id("msg", &[&from, text]),
636        from,
637        to: to.clone(),
638        body: text.to_string(),
639        interrupt: args.interrupt,
640        issue,
641        group,
642        reply: None,
643        scope,
644    };
645    let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
646    stamp_source(&mut atom);
647    post(&atom, "send")?;
648    Ok(format!("sent {} to {}\n", outgoing.id, to.join(", ")))
649}
650
651/// Unread mail for this seat, then receipts on what it sent that it has not
652/// been shown yet.
653///
654/// Listing does not write a receipt. `ljos hook --prompt` does, for each
655/// message it shows. A pack that does not answer, or that refuses mail, is
656/// an error. `ljos_inbox` calls this.
657pub fn inbox(all: bool) -> Result<String> {
658    let atoms = load("inbox")?;
659    let seat = super::seat_name();
660    let seen = receipt_seen();
661    let (text, keys) = render_inbox(&atoms, &seat, all, &seen);
662    remember_receipts(&keys);
663    Ok(text)
664}
665
666fn already_read(atoms: &[Value], msg: &str, seat: &str) -> bool {
667    readers_of(atoms).contains(&(msg.to_string(), seat.to_string()))
668}
669
670fn receipt_atom(msg: &Letter, reader: &str, at: &str, workspace: &str) -> Value {
671    let id = mint_id("receipt", &[msg.id.as_str(), reader, at]);
672    let text = format!("read {}\nby {reader}\nat {at}\nmsg {id}", msg.id);
673    let mut entities = vec![
674        format!("seat:{reader}"),
675        format!("from:{reader}"),
676        format!("to:{}", msg.from),
677        format!("msg:{id}"),
678        format!("receipt:{}", msg.id),
679    ];
680    if let Some(scope) = &msg.scope {
681        entities.push(format!("scope:{scope}"));
682    }
683    json!({
684        "schema": "inside.atom/v1",
685        "kind": "receipt",
686        "level": "explicit",
687        "text": text,
688        "workspace": workspace,
689        "entities": entities,
690    })
691}
692
693/// Write a receipt for one message. A second read of the same message
694/// writes nothing.
695pub fn read(id: &str) -> Result<String> {
696    let id = id.trim();
697    if id.is_empty() {
698        bail!("read: name a message");
699    }
700    let atoms = load("read")?;
701    let seat = super::seat_name();
702    let letter = find_letter(&atoms, id).context("read")?;
703    if !letter.to.iter().any(|to| to == &seat) {
704        bail!("read: {id} is not for {seat}");
705    }
706    if already_read(&atoms, id, &seat) {
707        return Ok(format!("read {id} already\n"));
708    }
709    let client = super::pack()?;
710    let mut atom = receipt_atom(
711        &letter,
712        &seat,
713        &super::now_utc(),
714        &workspace_of(Some(&client.workspace())),
715    );
716    stamp_source(&mut atom);
717    post(&atom, "read")?;
718    Ok(format!("read {id}\n"))
719}
720
721fn read_ids(ids: &[String]) {
722    let Some(atoms) = load_quiet() else {
723        return;
724    };
725    let seat = super::seat_name();
726    let Ok(client) = super::pack() else {
727        return;
728    };
729    let workspace = client.workspace();
730    let at = super::now_utc();
731    for id in ids {
732        let Ok(letter) = find_letter(&atoms, id) else {
733            continue;
734        };
735        if !letter.to.iter().any(|to| to == &seat) || already_read(&atoms, id, &seat) {
736            continue;
737        }
738        let mut atom = receipt_atom(&letter, &seat, &at, &workspace_of(Some(&workspace)));
739        stamp_source(&mut atom);
740        let _ = post(&atom, "read");
741    }
742}
743
744/// Answer a message. The reply goes to its sender and keeps its issue.
745pub fn reply(id: &str, text: &str, interrupt: bool) -> Result<String> {
746    let id = id.trim();
747    let text = text.trim();
748    if id.is_empty() {
749        bail!("reply: name a message");
750    }
751    if text.is_empty() {
752        bail!("reply: empty text");
753    }
754    let atoms = load("reply")?;
755    let parent = find_letter(&atoms, id).context("reply")?;
756    let from = super::seat_name();
757    if parent.from == from {
758        bail!("reply: {id} is from this seat");
759    }
760    let issue = parent.issue.clone();
761    let scope = issue
762        .as_deref()
763        .and_then(super::sync::scope_for_issue)
764        .or(parent.scope);
765    let outgoing = Outgoing {
766        id: mint_id("msg", &[&from, id, text]),
767        from,
768        to: vec![parent.from.clone()],
769        body: text.to_string(),
770        interrupt,
771        issue,
772        group: None,
773        reply: Some(parent.id.clone()),
774        scope,
775    };
776    let client = super::pack()?;
777    let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
778    stamp_source(&mut atom);
779    post(&atom, "reply")?;
780    let mut line = format!(
781        "sent {} to {}\nreply {}\n",
782        outgoing.id, parent.from, parent.id
783    );
784    if let Some(issue) = &outgoing.issue {
785        line.push_str(&format!("issue {issue}\n"));
786    }
787    Ok(line)
788}
789
790fn group_atom(name: &str, seat: &str, drop: bool, by: &str, workspace: &str) -> Value {
791    let at = super::now_utc();
792    let id = mint_id("group", &[name, seat, by, &at]);
793    let verb = if drop { "drop" } else { "member" };
794    let text = format!("group {id}\nname {name}\n{verb} {seat}\nby {by}\nat {at}");
795    let mut entities = vec![
796        format!("seat:{by}"),
797        format!("group:{name}"),
798        format!("msg:{id}"),
799    ];
800    if drop {
801        entities.push(format!("drop:{seat}"));
802    } else {
803        entities.push(format!("member:{seat}"));
804    }
805    json!({
806        "schema": "inside.atom/v1",
807        "kind": "group",
808        "level": "explicit",
809        "text": text,
810        "workspace": workspace,
811        "entities": entities,
812    })
813}
814
815/// List a group, or add and remove members. A drop is its own atom.
816pub fn group(name: &str, add: &[String], remove: &[String]) -> Result<String> {
817    let name = check_name("group", name)?;
818    if add.is_empty() && remove.is_empty() {
819        let atoms = load("group")?;
820        let seats = members(&atoms, &name);
821        if seats.is_empty() {
822            return Ok(format!("{name}\t(no members)\n"));
823        }
824        let mut out = String::new();
825        for seat in seats {
826            out.push_str(&format!("{name}\t{seat}\n"));
827        }
828        return Ok(out);
829    }
830    let atoms = load("group")?;
831    let by = super::seat_name();
832    let client = super::pack()?;
833    let workspace = client.workspace();
834    let mut roster = members(&atoms, &name);
835    let mut out = String::new();
836    for seat in add {
837        let seat = check_name("group", seat)?;
838        if roster.iter().any(|s| s == &seat) {
839            out.push_str(&format!("{seat} is already in {name}\n"));
840            continue;
841        }
842        let mut atom = group_atom(&name, &seat, false, &by, &workspace_of(Some(&workspace)));
843        stamp_source(&mut atom);
844        post(&atom, "group")?;
845        roster.push(seat.clone());
846        out.push_str(&format!("added {seat} to {name}\n"));
847    }
848    for seat in remove {
849        let seat = check_name("group", seat)?;
850        if !roster.iter().any(|s| s == &seat) {
851            out.push_str(&format!("{seat} is not in {name}\n"));
852            continue;
853        }
854        let mut atom = group_atom(&name, &seat, true, &by, &workspace_of(Some(&workspace)));
855        stamp_source(&mut atom);
856        post(&atom, "group")?;
857        roster.retain(|s| s != &seat);
858        out.push_str(&format!("removed {seat} from {name}\n"));
859    }
860    Ok(out)
861}
862
863fn state_dir() -> PathBuf {
864    std::env::var_os("XDG_STATE_HOME")
865        .map(PathBuf::from)
866        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
867        .unwrap_or_else(|| PathBuf::from(".local/state"))
868        .join("ljos")
869        .join("mail")
870}
871
872fn receipt_seen_path() -> PathBuf {
873    state_dir().join("receipts-seen")
874}
875
876fn receipt_seen() -> BTreeSet<String> {
877    std::fs::read_to_string(receipt_seen_path())
878        .map(|text| {
879            text.lines()
880                .map(str::trim)
881                .filter(|l| !l.is_empty())
882                .map(str::to_string)
883                .collect()
884        })
885        .unwrap_or_default()
886}
887
888fn remember_receipts(keys: &[String]) {
889    if keys.is_empty() {
890        return;
891    }
892    let path = receipt_seen_path();
893    if let Some(dir) = path.parent() {
894        let _ = std::fs::create_dir_all(dir);
895    }
896    let mut text = std::fs::read_to_string(&path).unwrap_or_default();
897    for key in keys {
898        if text.lines().any(|l| l == key) {
899            continue;
900        }
901        text.push_str(key);
902        text.push('\n');
903    }
904    let _ = std::fs::write(path, text);
905}
906
907/// What the prompt hook prints when the pack does not answer.
908///
909/// One line. The hook still exits 0, so the prompt is not blocked.
910pub const MAIL_UNCHECKED: &str = "mail could not be checked";
911
912/// Unread mail and unseen receipts for the prompt hook. A pack that does
913/// not answer leaves one line, so unread mail is not silent.
914#[must_use]
915pub fn prompt_note() -> (String, Vec<String>) {
916    let Some(atoms) = load_quiet() else {
917        return (MAIL_UNCHECKED.to_string(), Vec::new());
918    };
919    let seat = super::seat_name();
920    let letters = inbox_letters(&atoms, &seat, false);
921    let seen = receipt_seen();
922    let receipts: Vec<Receipt> = receipts_for(&atoms, &seat)
923        .into_iter()
924        .filter(|r| !seen.contains(&r.key))
925        .collect();
926    prompt_lines(&letters, &receipts)
927}
928
929/// Receipts for messages a runner was just shown, and a local mark for
930/// receipts this seat was told about. A pack that does not answer leaves
931/// the message unread, so the next prompt shows it again.
932pub fn ack_delivered(ids: &[String]) {
933    let mut reads = Vec::new();
934    let mut seen = Vec::new();
935    for id in ids {
936        if let Some(msg) = id.strip_prefix("mail:") {
937            reads.push(msg.to_string());
938        } else if let Some(key) = id.strip_prefix("rcpt:") {
939            seen.push(key.to_string());
940        }
941    }
942    if !reads.is_empty() {
943        read_ids(&reads);
944    }
945    remember_receipts(&seen);
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    fn outgoing(id: &str, from: &str, to: &[&str], body: &str) -> Outgoing {
953        Outgoing {
954            id: id.to_string(),
955            from: from.to_string(),
956            to: to.iter().map(|s| (*s).to_string()).collect(),
957            body: body.to_string(),
958            interrupt: false,
959            issue: None,
960            group: None,
961            reply: None,
962            scope: None,
963        }
964    }
965
966    fn atom(m: &Outgoing, ts: &str) -> Value {
967        let mut v = message_atom(m, "seat");
968        v["ts"] = json!(ts);
969        v
970    }
971
972    #[test]
973    fn the_same_words_stay_two_messages() {
974        let a = outgoing("a", "alice", &["bob"], "ok");
975        let b = outgoing("b", "carol", &["bob"], "ok");
976        let left = message_text(&a);
977        let right = message_text(&b);
978        assert_ne!(left, right);
979        assert_ne!(super::super::work_id(&left), super::super::work_id(&right));
980        assert_eq!(body_of(&left), "ok");
981        let atoms = [
982            atom(&a, "2026-10-09T00:00:00Z"),
983            atom(&b, "2026-10-09T00:00:01Z"),
984        ];
985        let plain = super::super::sync::log_plaintext(&atoms, "seat", "seat");
986        assert_eq!(plain.lines().count(), 2, "{plain}");
987        let got = super::super::sync::incoming(&plain, "other", &BTreeSet::new(), &BTreeSet::new());
988        assert_eq!(got.post.len(), 2);
989    }
990
991    #[test]
992    fn inbox_is_the_seat_named_and_an_interrupt_leads() {
993        let mut late = outgoing("late", "alice", &["bob"], "when you can");
994        late.issue = Some("acme-4".into());
995        let mut hot = outgoing("hot", "carol", &["bob"], "the build is red");
996        hot.interrupt = true;
997        hot.issue = Some("acme-4".into());
998        let other = outgoing("other", "alice", &["dave"], "not for bob");
999        let atoms = [
1000            atom(&late, "2026-10-09T00:00:00Z"),
1001            atom(&hot, "2026-10-09T00:00:02Z"),
1002            atom(&other, "2026-10-09T00:00:01Z"),
1003        ];
1004        let letters = inbox_letters(&atoms, "bob", false);
1005        assert_eq!(
1006            letters.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(),
1007            ["hot", "late"]
1008        );
1009        assert!(letters[0].interrupt);
1010        assert_eq!(letters[1].issue.as_deref(), Some("acme-4"));
1011        let (text, ids) = prompt_lines(&letters, &[]);
1012        assert!(
1013            text.starts_with("mail:\n! hot from carol on acme-4:"),
1014            "{text}"
1015        );
1016        assert!(ids.contains(&"mail:hot".to_string()), "{ids:?}");
1017        assert!(text.contains("late from alice on acme-4:"));
1018    }
1019
1020    #[test]
1021    fn a_receipt_takes_the_message_out_of_the_unread_list() {
1022        let msg = outgoing("m1", "alice", &["bob"], "look at the log");
1023        let message = atom(&msg, "2026-10-09T00:00:00Z");
1024        let receipt = json!({
1025            "kind": "receipt",
1026            "text": "read m1\nby bob\nat 2026-10-09T00:01:00Z\nmsg r1",
1027            "ts": "2026-10-09T00:01:00Z",
1028            "entities": ["seat:bob", "from:bob", "to:alice", "receipt:m1", "msg:r1"],
1029        });
1030        let held = [message, receipt];
1031        let unread = inbox_letters(&held, "bob", false);
1032        assert!(unread.is_empty());
1033        let all = inbox_letters(&held, "bob", true);
1034        assert!(all[0].read);
1035        let (text, keys) = render_inbox(&held, "alice", false, &BTreeSet::new());
1036        assert!(
1037            text.contains("receipt\tm1\tbob\t2026-10-09T00:01:00Z"),
1038            "{text}"
1039        );
1040        assert_eq!(keys.len(), 1);
1041        let (again, _) = render_inbox(&held, "alice", false, &keys.into_iter().collect());
1042        assert_eq!(again, "inbox: nothing unread\n");
1043    }
1044
1045    #[test]
1046    fn a_reply_keeps_the_issue_and_names_the_sender() {
1047        let mut parent = outgoing("p", "alice", &["bob"], "the build is red");
1048        parent.issue = Some("acme-4".into());
1049        parent.scope = Some("tools".into());
1050        let letter = letter_of(&atom(&parent, "2026-10-09T00:00:00Z"), 0).unwrap();
1051        let reply = Outgoing {
1052            id: "r".into(),
1053            from: "bob".into(),
1054            to: vec![letter.from.clone()],
1055            body: "looking".into(),
1056            interrupt: false,
1057            issue: letter.issue.clone(),
1058            group: None,
1059            reply: Some(letter.id.clone()),
1060            scope: letter.scope.clone(),
1061        };
1062        let atom = message_atom(&reply, "seat");
1063        let back = letter_of(&atom, 1).unwrap();
1064        assert_eq!(back.to, ["alice"]);
1065        assert_eq!(back.reply.as_deref(), Some("p"));
1066        assert_eq!(back.issue.as_deref(), Some("acme-4"));
1067        assert_eq!(back.scope.as_deref(), Some("tools"));
1068        assert!(super::super::sync::is_own(&atom));
1069    }
1070
1071    #[test]
1072    fn a_group_send_reaches_the_other_members() {
1073        let atoms = [
1074            json!({
1075                "kind": "group",
1076                "ts": "2026-10-09T00:00:00Z",
1077                "entities": ["group:ops", "member:alice"],
1078            }),
1079            json!({
1080                "kind": "group",
1081                "ts": "2026-10-09T00:00:01Z",
1082                "entities": ["group:ops", "member:bob"],
1083            }),
1084            json!({
1085                "kind": "group",
1086                "ts": "2026-10-09T00:00:02Z",
1087                "entities": ["group:ops", "member:carol"],
1088            }),
1089            json!({
1090                "kind": "group",
1091                "ts": "2026-10-09T00:00:03Z",
1092                "entities": ["group:ops", "drop:carol"],
1093            }),
1094        ];
1095        assert_eq!(members(&atoms, "ops"), ["alice", "bob"]);
1096        let to = recipients("alice", None, Some("ops"), &members(&atoms, "ops")).unwrap();
1097        assert_eq!(to, ["bob"]);
1098        let err = recipients("alice", None, Some("ops"), &["alice".into()]).unwrap_err();
1099        assert!(err.to_string().contains("no other member"), "{err}");
1100        let err = recipients("alice", Some("alice"), None, &[]).unwrap_err();
1101        assert!(err.to_string().contains("this seat"), "{err}");
1102    }
1103
1104    #[test]
1105    fn a_prompt_caps_the_note_and_keeps_the_rest_unread() {
1106        let letters: Vec<Letter> = (0..10)
1107            .map(|n| Letter {
1108                id: format!("m{n}"),
1109                from: "alice".into(),
1110                to: vec!["bob".into()],
1111                body: format!("line {n}"),
1112                interrupt: n == 9,
1113                issue: None,
1114                group: None,
1115                reply: None,
1116                scope: None,
1117                read: false,
1118                ts: format!("2026-10-09T00:00:{n:02}Z"),
1119                seq: n,
1120            })
1121            .collect();
1122        let mut ordered = letters.clone();
1123        ordered.sort_by(|a, b| b.interrupt.cmp(&a.interrupt).then(a.ts.cmp(&b.ts)));
1124        let (text, ids) = prompt_lines(&ordered, &[]);
1125        assert!(text.lines().next().unwrap() == "mail:");
1126        assert!(text.contains("! m9 from alice:"));
1127        assert!(text.contains("2 more in ljos inbox"), "{text}");
1128        assert_eq!(ids.iter().filter(|id| id.starts_with("mail:")).count(), 8);
1129        assert!(!ids.iter().any(|id| id == "mail:m7" || id == "mail:m8"));
1130    }
1131
1132    #[test]
1133    fn an_unknown_kind_is_a_pack_without_mail() {
1134        let refused =
1135            "bad response: http://127.0.0.1:8761/v1/atoms: 400: unknown atom kind: message";
1136        assert_eq!(probe_of(refused), Probe::Refused);
1137        let kept = "bad response: http://127.0.0.1:8761/v1/atoms: 400: atom text is required";
1138        assert_eq!(probe_of(kept), Probe::Kept);
1139        let down = "http://127.0.0.1:8761/v1/atoms: http: connection refused";
1140        assert_eq!(probe_of(down), Probe::Store);
1141        let named = unsupported_mail(Some("0.12.1"));
1142        assert!(named.contains("packset 0.12.1"), "{named}");
1143        assert!(named.contains(MAIL_PACKSET), "{named}");
1144        assert!(named.contains("kind message"), "{named}");
1145        let quiet = unsupported_mail(None);
1146        assert!(quiet.contains(MAIL_PACKSET), "{quiet}");
1147        assert!(quiet.contains("refused kind message"), "{quiet}");
1148    }
1149}