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}
82
83#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
477pub 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
525fn ensure_mail(client: &packset_client::PacksetClient, verb: &str) -> Result<()> {
528 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
599pub 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 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
651pub 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
693pub 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
744pub 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
815pub 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
907pub const MAIL_UNCHECKED: &str = "mail could not be checked";
911
912#[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
929pub 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}