pi-session-mail 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ // What state each piece of a session's mail is in. Pure: it reads no files and
2
+ // no clock. The disk reader supplies the files and the command handler the
3
+ // in-memory unread set; `picker.ts` shows the statuses.
4
+ //
5
+ // Unread is what the footer counts: files still in `new/`, plus mail claimed
6
+ // into `cur/` that has not entered the conversation. Read is the rest of
7
+ // `cur/`. A sent request or ask is awaiting when no reply in `cur/` names it,
8
+ // and answered when one does. Sent messages expect no answer.
9
+ //
10
+ // The counting rules use the raw parsed JSON, not the validated envelope, so
11
+ // they stay the same as older copies that shared this mail root. A file that
12
+ // is not a valid envelope can still count; the picker shows only valid ones.
13
+
14
+ /** The boxes a session's mail lives in; `sent/` holds its outgoing copies. */
15
+ export type MailBox = "new" | "cur" | "sent";
16
+
17
+ /** What a status needs from an envelope. */
18
+ export type MailEnvelope = {
19
+ id: string;
20
+ kind: string;
21
+ in_reply_to: readonly string[];
22
+ };
23
+
24
+ /** One file in the mailbox; `envelope` is absent when the file is not a valid envelope. */
25
+ export type MailFile<E extends MailEnvelope = MailEnvelope> = {
26
+ box: MailBox;
27
+ /** The file's parsed JSON, absent when it cannot be read. */
28
+ parsed?: unknown;
29
+ envelope?: E;
30
+ };
31
+
32
+ /** What a piece of mail means to the person looking at it. */
33
+ export type Status = "unread" | "read" | "awaiting" | "answered" | "message";
34
+
35
+ /** One file with its status; a file that cannot be read has none. */
36
+ export type StatusFile<T extends MailFile> = T & { status?: Status };
37
+
38
+ type Facts = { id?: unknown; kind?: unknown; in_reply_to?: unknown };
39
+
40
+ const facts = (parsed: unknown): Facts => (typeof parsed === "object" && parsed !== null ? (parsed as Facts) : {});
41
+
42
+ /** The ids a `cur/` file answers, from its raw `in_reply_to` array when it has one. */
43
+ function answers(file: MailFile): readonly unknown[] {
44
+ if (file.box !== "cur") return [];
45
+ const list = facts(file.parsed ?? file.envelope).in_reply_to;
46
+ return Array.isArray(list) ? list : [];
47
+ }
48
+
49
+ function statusOf(file: MailFile, answered: ReadonlySet<unknown>, unread: ReadonlySet<string>): Status | undefined {
50
+ const source = file.parsed ?? file.envelope;
51
+ if (source === undefined) return undefined;
52
+ const { id, kind } = facts(source);
53
+ if (file.box === "new") return "unread";
54
+ if (file.box === "cur") return typeof id === "string" && unread.has(id) ? "unread" : "read";
55
+ if (kind === "message") return "message";
56
+ if (typeof id !== "string") return undefined;
57
+ return answered.has(id) ? "answered" : "awaiting";
58
+ }
59
+
60
+ /**
61
+ * One status per file, in the order given. `unread` holds the ids claimed into
62
+ * `cur/` that the conversation has not seen yet; a file still in `new/` is
63
+ * unread without being in it. A file that cannot be read gets no status.
64
+ */
65
+ export function mailStatus<T extends MailFile>(files: readonly T[], unread: ReadonlySet<string>): StatusFile<T>[] {
66
+ const answered = new Set(files.flatMap(answers));
67
+ return files.map((file) => ({ ...file, status: statusOf(file, answered, unread) }));
68
+ }
package/src/picker.ts ADDED
@@ -0,0 +1,285 @@
1
+ // The `/mailbox` picker: one list of this session's mail, the filter Tab
2
+ // (Shift+Tab backwards) cycles, and a detail screen for the selected item. It reads no files,
3
+ // running records or clock and imports none of them: the command handler
4
+ // passes the items, a lookup from address to label and running state, the
5
+ // current time, the title and the starting view. It resolves on Esc, or on `d`
6
+ // with the selected item for the handler to delete after a confirmation; the
7
+ // handler reopens it where the user was.
8
+
9
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ type Component,
12
+ type KeybindingsManager,
13
+ matchesKey,
14
+ type SelectItem,
15
+ SelectList,
16
+ type SelectListTheme,
17
+ stripTerminalSequences,
18
+ Text,
19
+ type TUI,
20
+ } from "@earendil-works/pi-tui";
21
+ import type { Status } from "./mail-status.ts";
22
+
23
+ /** The filters Tab cycles, in order. */
24
+ export type PickerFilter = "unread" | "awaiting" | "read" | "sent" | "all";
25
+ const FILTERS: readonly PickerFilter[] = ["unread", "awaiting", "read", "sent", "all"];
26
+
27
+ /** The most rows shown at once; `SelectList` scrolls the rest. */
28
+ const ROW_MAX = 10;
29
+
30
+ /** Room for a row's status prefix and its first line before the description column. */
31
+ const ROW_LAYOUT = { minPrimaryColumnWidth: 32, maxPrimaryColumnWidth: 50 };
32
+
33
+ const EMPTY: Record<PickerFilter, string> = {
34
+ unread: "No unread mail",
35
+ awaiting: "Nothing is waiting for a reply",
36
+ read: "No read mail",
37
+ sent: "Nothing sent",
38
+ all: "No mail",
39
+ };
40
+
41
+ /** What the picker shows for one item; plain values, no envelope or file path. */
42
+ export type PickerItem = {
43
+ id: string;
44
+ /** `sent/` is mail this session sent; `new/` and `cur/` are mail it received. */
45
+ box: "new" | "cur" | "sent";
46
+ from: string;
47
+ to: string;
48
+ kind: string;
49
+ inReplyTo: readonly string[];
50
+ ts: string;
51
+ body: string;
52
+ status: Status;
53
+ };
54
+
55
+ /** What the picker knows about an address: how to name it and whether it runs. */
56
+ export type Peer = { label: string; running: boolean };
57
+
58
+ /** Everything the picker needs, all known before it opens. */
59
+ export type PickerOptions = {
60
+ items: readonly PickerItem[];
61
+ /** The label and running state of each address, snapshotted before the picker opens. */
62
+ peers: ReadonlyMap<string, Peer>;
63
+ /** The session's name (or short id) and short address; the filter is shown after it. */
64
+ title: string;
65
+ /** The current time in ms, for relative times. */
66
+ now: number;
67
+ filter?: PickerFilter;
68
+ /** The item to select; when it is gone, `selectedIndex` decides instead. */
69
+ selectedId?: string;
70
+ selectedIndex?: number;
71
+ };
72
+
73
+ /** What the picker resolved: it closed, or the user chose to delete the selected item. */
74
+ export type PickResult =
75
+ | { action: "close" }
76
+ | {
77
+ action: "delete";
78
+ /** The selected item's id and box, so the handler finds its file. */
79
+ id: string;
80
+ box: PickerItem["box"];
81
+ /** The first line of its body, for the confirmation. */
82
+ firstLine: string;
83
+ /** Where the list was, so the handler reopens it where the user was. */
84
+ filter: PickerFilter;
85
+ index: number;
86
+ };
87
+
88
+ function listTheme(theme: Theme): SelectListTheme {
89
+ return {
90
+ selectedPrefix: (text: string): string => theme.fg("accent", text),
91
+ selectedText: (text: string): string => theme.bold(text),
92
+ description: (text: string): string => theme.fg("dim", text),
93
+ scrollInfo: (text: string): string => theme.fg("dim", text),
94
+ noMatch: (text: string): string => theme.fg("dim", text),
95
+ };
96
+ }
97
+
98
+ /** Untrusted text with terminal escape sequences gone and line endings made `\n`. */
99
+ const clean = (text: string): string => stripTerminalSequences(text).replace(/\r\n?/g, "\n");
100
+
101
+ /** One display line from untrusted text, without newlines or other control characters. */
102
+ const oneLine = (text: string): string =>
103
+ clean(text)
104
+ .split("\n")[0]
105
+ .replace(/[\p{Cc}\u2028\u2029]/gu, " ")
106
+ .trim();
107
+
108
+ /** A body for the detail screen: escape sequences and control characters gone, line breaks kept. */
109
+ const bodyText = (text: string): string => clean(text).replace(/[^\P{Cc}\n\t]/gu, " ");
110
+
111
+ const firstLine = (body: string): string => oneLine(body) || "(no text)";
112
+
113
+ /** How long ago `ts` was, as `now`, `5m ago`, `3h ago` or `2d ago`. */
114
+ function ago(ts: string, now: number): string {
115
+ const at = Date.parse(ts);
116
+ if (!Number.isFinite(at)) return "unknown time";
117
+ const minutes = Math.floor(Math.max(0, now - at) / 60_000);
118
+ if (minutes < 1) return "now";
119
+ if (minutes < 60) return `${minutes}m ago`;
120
+ const hours = Math.floor(minutes / 60);
121
+ if (hours < 24) return `${hours}h ago`;
122
+ const days = Math.floor(hours / 24);
123
+ if (days < 7) return `${days}d ago`;
124
+ if (days < 30) return `${Math.floor(days / 7)}w ago`;
125
+ if (days < 365) return `${Math.floor(days / 30)}mo ago`;
126
+ return `${Math.floor(days / 365)}y ago`;
127
+ }
128
+
129
+ function shows(filter: PickerFilter, status: Status): boolean {
130
+ if (filter === "all") return true;
131
+ if (filter === "sent") return status === "awaiting" || status === "answered" || status === "message";
132
+ return status === filter;
133
+ }
134
+
135
+ /** `d` deletes read and sent mail; unread mail is refused, since deleting its file would not take it back from the model. */
136
+ const deletable = (item: PickerItem): boolean => item.box !== "new" && item.status !== "unread";
137
+
138
+ const newestFirst = (a: PickerItem, b: PickerItem): number => b.ts.localeCompare(a.ts) || a.id.localeCompare(b.id);
139
+
140
+ /** A peer from the snapshot; an address the handler did not resolve is named by its short id. */
141
+ const peerOf = (peers: ReadonlyMap<string, Peer>, address: string): Peer =>
142
+ peers.get(address) ?? { label: address.slice(0, 8), running: false };
143
+
144
+ function row(item: PickerItem, peers: ReadonlyMap<string, Peer>, now: number, filter: PickerFilter): SelectItem {
145
+ const sent = item.box === "sent";
146
+ const other = peerOf(peers, sent ? item.to : item.from);
147
+ // SelectList drops descriptions at narrow widths, so required markers go in the label too.
148
+ const markers: string[] = [];
149
+ if (filter === "all" || filter === "sent") markers.push(item.status);
150
+ if (item.status === "awaiting") markers.push(other.running ? "running" : "closed");
151
+ const parts = [`${sent ? "to" : "from"} ${oneLine(other.label)}`, item.kind, ago(item.ts, now), ...markers];
152
+ const prefix = markers.length > 0 ? `[${markers.join(" ")}] ` : "";
153
+ return { value: item.id, label: `${prefix}${firstLine(item.body)}`, description: parts.join(" · ") };
154
+ }
155
+
156
+ /** The detail screen: the other session, the kind, id, absolute time, what it answers and the whole body. */
157
+ function detailText(item: PickerItem, peers: ReadonlyMap<string, Peer>): string {
158
+ const sent = item.box === "sent";
159
+ const address = sent ? item.to : item.from;
160
+ const lines = [
161
+ `${sent ? "To" : "From"} ${oneLine(peerOf(peers, address).label)} <${address}>`,
162
+ `Kind ${oneLine(item.kind)}`,
163
+ `Id ${oneLine(item.id)}`,
164
+ `Sent ${new Date(item.ts).toLocaleString()}`,
165
+ ];
166
+ if (item.inReplyTo.length > 0) lines.push(`In reply to ${item.inReplyTo.map(oneLine).join(", ")}`);
167
+ lines.push("", bodyText(item.body));
168
+ return lines.join("\n");
169
+ }
170
+
171
+ /** A `ctx.ui.custom` factory: the list and detail screens, till Esc closes the list. */
172
+ export function picker(options: PickerOptions) {
173
+ return (tui: TUI, theme: Theme, _keybindings: KeybindingsManager, done: (result: PickResult) => void): Component => {
174
+ const border = new DynamicBorder((text) => theme.fg("accent", text));
175
+ let filter = options.filter ?? "all";
176
+ let screen: "list" | "detail" = "list";
177
+ let shown: PickerItem[] = [];
178
+ let detailItem: PickerItem | undefined;
179
+ let list: SelectList;
180
+
181
+ const build = (selectedId: string | undefined, selectedIndex: number): void => {
182
+ shown = rowsFor(options.items, filter);
183
+ list = buildList(options, theme, filter, shown, selectedId, selectedIndex);
184
+ list.onSelect = (item: SelectItem): void => {
185
+ detailItem = shown.find((candidate) => candidate.id === item.value);
186
+ if (detailItem !== undefined) screen = "detail";
187
+ };
188
+ list.onCancel = (): void => done({ action: "close" });
189
+ };
190
+ build(options.selectedId, options.selectedIndex ?? 0);
191
+
192
+ const handleListInput = (data: string): void => {
193
+ const next = nextFilter(filter, data);
194
+ if (next !== undefined) {
195
+ filter = next;
196
+ build(undefined, 0);
197
+ return;
198
+ }
199
+ const result = data === "d" ? deletion(shown, list, filter) : undefined;
200
+ if (result !== undefined) done(result);
201
+ else if (data !== "d") list.handleInput(data);
202
+ };
203
+
204
+ return {
205
+ render(width: number): string[] {
206
+ const title = oneLine(`${options.title} · ${filter}`);
207
+ // The all and sent views mix statuses, so the hint follows the selected row, not the filter.
208
+ const selected = shown.find((item) => item.id === list.getSelectedItem()?.value);
209
+ const deletableNow = selected !== undefined && deletable(selected);
210
+ const hint =
211
+ screen === "detail" ? "esc back" : `tab filter · enter open${deletableNow ? " · d delete" : ""} · esc close`;
212
+ return [
213
+ ...border.render(width),
214
+ ...new Text(theme.fg("accent", theme.bold(title)), 0, 0).render(width),
215
+ ...(screen === "detail" && detailItem !== undefined
216
+ ? new Text(detailText(detailItem, options.peers), 0, 0).render(width)
217
+ : listLines(width, shown, list, filter, theme)),
218
+ ...new Text(theme.fg("dim", hint), 0, 0).render(width),
219
+ ...border.render(width),
220
+ ];
221
+ },
222
+ invalidate(): void {
223
+ list.invalidate();
224
+ },
225
+ handleInput(data: string): void {
226
+ if (screen === "detail") {
227
+ if (matchesKey(data, "escape")) screen = "list";
228
+ } else handleListInput(data);
229
+ tui.requestRender();
230
+ },
231
+ };
232
+ };
233
+ }
234
+
235
+ /** The filter Tab or Shift+Tab moves to from `filter`; undefined for any other key. */
236
+ function nextFilter(filter: PickerFilter, data: string): PickerFilter | undefined {
237
+ let step = 0;
238
+ if (matchesKey(data, "tab")) step = 1;
239
+ else if (matchesKey(data, "shift+tab")) step = -1;
240
+ else return undefined;
241
+ return FILTERS[(FILTERS.indexOf(filter) + step + FILTERS.length) % FILTERS.length];
242
+ }
243
+
244
+ /** The items `filter` shows, newest first. */
245
+ const rowsFor = (items: readonly PickerItem[], filter: PickerFilter): PickerItem[] =>
246
+ items.filter((item) => shows(filter, item.status)).sort(newestFirst);
247
+
248
+ /** A list of `items`, with the selection restored by id when it is there and by index otherwise. */
249
+ function buildList(
250
+ options: PickerOptions,
251
+ theme: Theme,
252
+ filter: PickerFilter,
253
+ items: PickerItem[],
254
+ selectedId: string | undefined,
255
+ selectedIndex: number,
256
+ ): SelectList {
257
+ const list = new SelectList(
258
+ items.map((item) => row(item, options.peers, options.now, filter)),
259
+ ROW_MAX,
260
+ listTheme(theme),
261
+ ROW_LAYOUT,
262
+ );
263
+ const found = selectedId === undefined ? -1 : items.findIndex((item) => item.id === selectedId);
264
+ list.setSelectedIndex(found >= 0 ? found : Math.max(0, selectedIndex));
265
+ return list;
266
+ }
267
+
268
+ /** The list, or the note that the filter is empty. */
269
+ function listLines(
270
+ width: number,
271
+ shown: readonly PickerItem[],
272
+ list: SelectList,
273
+ filter: PickerFilter,
274
+ theme: Theme,
275
+ ): string[] {
276
+ return shown.length > 0 ? list.render(width) : new Text(theme.fg("muted", ` ${EMPTY[filter]}`), 0, 0).render(width);
277
+ }
278
+
279
+ /** What `d` resolves with: the selected item when it may be deleted, otherwise undefined. */
280
+ function deletion(shown: readonly PickerItem[], list: SelectList, filter: PickerFilter): PickResult | undefined {
281
+ const index = shown.findIndex((item) => item.id === list.getSelectedItem()?.value);
282
+ const item = shown[index];
283
+ if (item === undefined || !deletable(item)) return undefined;
284
+ return { action: "delete", id: item.id, box: item.box, firstLine: firstLine(item.body), filter, index };
285
+ }
package/src/store.ts CHANGED
@@ -8,7 +8,9 @@
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
- import { isAbsolute, join } from "node:path";
11
+ import { dirname, isAbsolute, join } from "node:path";
12
+
13
+ import { type MailFile, mailStatus } from "./mail-status.ts";
12
14
 
13
15
  /** What an envelope is: answered at settle, waited on, plain, or an answer. */
14
16
  export type Kind = "request" | "ask" | "message" | "reply";
@@ -137,6 +139,15 @@ function isFile(path: string): boolean {
137
139
  }
138
140
  }
139
141
 
142
+ /** A real directory: `lstat`, so a symlink to one is not. False when there is nothing there. */
143
+ function isDirectory(path: string): boolean {
144
+ try {
145
+ return lstatSync(path).isDirectory();
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
140
151
  /** `.json` names in `box` that are regular files, in readdir order. */
141
152
  function jsonFiles(address: string, box: Box): string[] {
142
153
  const dir = boxPath(address, box);
@@ -170,28 +181,39 @@ export type SentCopy = { envelope: Envelope; path: string };
170
181
 
171
182
  const isHops = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0;
172
183
 
184
+ /** The request ids an envelope answers; throws when the list is not a short list of strings. */
185
+ function replyIds(value: unknown): string[] {
186
+ if (!Array.isArray(value) || !value.every((id) => typeof id === "string"))
187
+ throw new Error("in_reply_to is not a list of ids");
188
+ if (value.length > MAX_REPLY_IDS) throw new Error(`in_reply_to names more than ${MAX_REPLY_IDS} requests`);
189
+ return value;
190
+ }
191
+
173
192
  /**
174
- * Parse an envelope file; throws when it is not a well-formed envelope from a
193
+ * Parse an envelope value; throws when it is not a well-formed envelope from a
175
194
  * valid address. An envelope written before `kind` and `hops` existed is read
176
195
  * as a reply when it answers something and as a request otherwise, with hops 0.
177
196
  * A `kind` this version does not know is read as `message`, so mail from a
178
197
  * newer copy is delivered rather than stranded; the original value is dropped.
179
198
  */
180
- export function readEnvelope(path: string): Envelope {
181
- const value = JSON.parse(readFileSync(path, "utf8")) as Partial<Envelope>;
199
+ function parseEnvelope(value: unknown): Envelope {
182
200
  if (typeof value !== "object" || value === null) throw new Error("not an object");
183
- if (typeof value.id !== "string" || value.id === "") throw new Error("missing id");
184
- if (!isAddress(value.from)) throw new Error(`invalid from address ${JSON.stringify(value.from)}`);
185
- if (!Array.isArray(value.in_reply_to) || !value.in_reply_to.every((id) => typeof id === "string"))
186
- throw new Error("in_reply_to is not a list of ids");
187
- if (value.in_reply_to.length > MAX_REPLY_IDS)
188
- throw new Error(`in_reply_to names more than ${MAX_REPLY_IDS} requests`);
189
- if (typeof value.body !== "string") throw new Error("missing body");
190
- if (value.kind === undefined) value.kind = value.in_reply_to.length > 0 ? "reply" : "request";
191
- else if (!KINDS.includes(value.kind)) value.kind = "message";
192
- if (value.hops === undefined) value.hops = 0;
193
- else if (!isHops(value.hops)) throw new Error(`invalid hops ${JSON.stringify(value.hops)}`);
194
- return value as Envelope;
201
+ // Copy so the raw parsed fields stay as written for `diskCounts`.
202
+ const envelope = { ...value } as Partial<Envelope>;
203
+ if (typeof envelope.id !== "string" || envelope.id === "") throw new Error("missing id");
204
+ if (!isAddress(envelope.from)) throw new Error(`invalid from address ${JSON.stringify(envelope.from)}`);
205
+ envelope.in_reply_to = replyIds(envelope.in_reply_to);
206
+ if (typeof envelope.body !== "string") throw new Error("missing body");
207
+ if (envelope.kind === undefined) envelope.kind = envelope.in_reply_to.length > 0 ? "reply" : "request";
208
+ else if (!KINDS.includes(envelope.kind)) envelope.kind = "message";
209
+ if (envelope.hops === undefined) envelope.hops = 0;
210
+ else if (!isHops(envelope.hops)) throw new Error(`invalid hops ${JSON.stringify(envelope.hops)}`);
211
+ return envelope as Envelope;
212
+ }
213
+
214
+ /** Parse an envelope file; throws when it is not a well-formed envelope. */
215
+ export function readEnvelope(path: string): Envelope {
216
+ return parseEnvelope(JSON.parse(readFileSync(path, "utf8")));
195
217
  }
196
218
 
197
219
  /**
@@ -226,31 +248,129 @@ export function findSent(address: string, ids: readonly string[]): Map<string, S
226
248
  return found;
227
249
  }
228
250
 
229
- function envelopesIn(address: string, box: Box): Partial<Envelope>[] {
251
+ /**
252
+ * The same lookup as `findSent`, for deletion: an unreadable `sent/` or a
253
+ * matching copy that cannot be read throws, because deleting a reply whose
254
+ * requests stayed behind would make them reappear as awaiting. A missing
255
+ * `sent/` holds no copies.
256
+ */
257
+ function sentCopiesForDeletion(address: string, ids: readonly string[]): Map<string, SentCopy> {
258
+ const found = new Map<string, SentCopy>();
259
+ if (ids.length === 0) return found;
260
+ const dir = boxPath(address, "sent");
261
+ let names: string[];
262
+ try {
263
+ names = readdirSync(dir);
264
+ } catch (err) {
265
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return found;
266
+ throw err;
267
+ }
268
+ if (!isDirectory(dir))
269
+ throw new Error(`refusing to delete sent copies through ${JSON.stringify(dir)}: not a real directory`);
270
+ for (const name of names) {
271
+ const id = ids.find((candidate) => name.endsWith(`-${candidate}.json`));
272
+ if (id === undefined || found.has(id)) continue;
273
+ const path = join(dir, name);
274
+ if (!isFile(path)) continue;
275
+ const envelope = readEnvelope(path);
276
+ if (envelope.id === id) found.set(id, { envelope, path });
277
+ }
278
+ return found;
279
+ }
280
+
281
+ /**
282
+ * Delete one mail file from this session's `cur/` or `sent/`, refusing any path
283
+ * outside those two boxes so a file still in `new/` cannot be taken from the
284
+ * model. The session folder and the box must be real directories, not symlinks,
285
+ * so the delete stays inside this mailbox however the path is spelled, and the
286
+ * file itself must be a regular file or a symlink, which is unlinked without
287
+ * its target. A `cur/` envelope that answers requests (`in_reply_to`) also
288
+ * loses this session's `sent/` copies of them, so they do not reappear as
289
+ * awaiting; a `sent/` that cannot be read throws before anything is removed.
290
+ */
291
+ export function deleteMail(address: string, path: string): void {
292
+ const parent = dirname(path);
293
+ const cur = boxPath(address, "cur");
294
+ if (parent !== cur && parent !== boxPath(address, "sent"))
295
+ throw new Error(`refusing to delete mail outside cur/ or sent/: ${JSON.stringify(path)}`);
296
+ for (const dir of [join(mailRoot(), address), parent])
297
+ if (!isDirectory(dir)) throw new Error(`refusing to delete through ${JSON.stringify(dir)}: not a real directory`);
298
+ let stats: ReturnType<typeof lstatSync>;
299
+ try {
300
+ stats = lstatSync(path);
301
+ } catch (err) {
302
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return; // already gone
303
+ throw err;
304
+ }
305
+ if (!stats.isFile() && !stats.isSymbolicLink())
306
+ throw new Error(`refusing to delete ${JSON.stringify(path)}: not a file`);
307
+ // Copies first, so an unlink that throws keeps the reply the picker was on.
308
+ const copies =
309
+ parent === cur && stats.isFile()
310
+ ? [...sentCopiesForDeletion(address, readEnvelope(path).in_reply_to).values()]
311
+ : [];
312
+ for (const copy of copies) rmSync(copy.path, { force: true });
313
+ rmSync(path, { force: true });
314
+ }
315
+
316
+ /** Every regular `.json` file in `box`, with its path, its parsed JSON and the envelope when valid. */
317
+ function envelopesIn(
318
+ address: string,
319
+ box: Box,
320
+ ): { path: string; parsed?: unknown; envelope?: Envelope; error?: string }[] {
230
321
  const dir = boxPath(address, box);
231
- const out: Partial<Envelope>[] = [];
322
+ const out: { path: string; parsed?: unknown; envelope?: Envelope; error?: string }[] = [];
232
323
  for (const name of jsonFiles(address, box)) {
324
+ const path = join(dir, name);
325
+ let parsed: unknown;
326
+ let envelope: Envelope | undefined;
327
+ let error: string | undefined;
233
328
  try {
234
- out.push(JSON.parse(readFileSync(join(dir, name), "utf8")));
235
- } catch {
236
- out.push({});
329
+ parsed = JSON.parse(readFileSync(path, "utf8"));
330
+ } catch (err) {
331
+ error = (err as Error).message;
332
+ }
333
+ if (error === undefined) {
334
+ try {
335
+ const valid = parseEnvelope(parsed);
336
+ // The picker shows `to` and sorts by `ts`; delivery does not need them, so only this reader requires them.
337
+ if (!isAddress(valid.to)) throw new Error(`invalid to address ${JSON.stringify(valid.to)}`);
338
+ if (typeof valid.ts !== "string") throw new Error("missing ts");
339
+ envelope = valid;
340
+ } catch (err) {
341
+ error = (err as Error).message;
342
+ }
237
343
  }
344
+ out.push({ path, parsed, envelope, error });
238
345
  }
239
346
  return out;
240
347
  }
241
348
 
349
+ /** One file in a session's mailbox: its box, its path, and the envelope it holds when valid. */
350
+ export type InboxFile = MailFile<Envelope> & { path: string; error?: string };
351
+
352
+ /** Every regular file in this session's `new/`, `cur/` and `sent/`, each with its box and path. */
353
+ export function readMailbox(address: string): InboxFile[] {
354
+ const out: InboxFile[] = [];
355
+ for (const box of ["new", "cur", "sent"] as const)
356
+ for (const file of envelopesIn(address, box)) out.push({ box, ...file });
357
+ return out;
358
+ }
359
+
242
360
  /**
243
- * Counts derived from disk: files still in `new/`, envelopes in `cur/`, and
244
- * requests (not messages) in `sent/` that no envelope in `cur/` answers.
361
+ * Counts derived from disk: files still in `new/`, files in `cur/`, and
362
+ * requests (not messages) in `sent/` with a string id that no `cur/` file names
363
+ * in its `in_reply_to`. It counts the raw parsed JSON, as older copies did, so
364
+ * sessions running either version agree.
245
365
  */
246
366
  export function diskCounts(address: string): { unclaimed: number; read: number; awaiting: number } {
247
- const cur = envelopesIn(address, "cur");
248
- const answered = new Set(cur.flatMap((e) => (Array.isArray(e.in_reply_to) ? e.in_reply_to : [])));
249
- // A message expects no answer, so it never counts as awaiting one.
250
- const awaiting = envelopesIn(address, "sent").filter(
251
- (e) => typeof e.id === "string" && e.kind !== "message" && !answered.has(e.id),
252
- ).length;
253
- return { unclaimed: listNew(address).length, read: cur.length, awaiting };
367
+ const files = readMailbox(address);
368
+ const statuses = mailStatus(files, new Set());
369
+ return {
370
+ unclaimed: files.filter((file) => file.box === "new").length,
371
+ read: files.filter((file) => file.box === "cur").length,
372
+ awaiting: statuses.filter((file) => file.status === "awaiting").length,
373
+ };
254
374
  }
255
375
 
256
376
  // ---------------------------------------------------------------------------
@@ -282,14 +402,6 @@ const RUNNING = "running";
282
402
  /** A folder being pruned is first renamed to this, which is no address, so a send makes a fresh folder. */
283
403
  const ASIDE = /^\.pruning\.([A-Za-z0-9_-]+)\.[0-9a-f-]{36}$/;
284
404
 
285
- const isDirectory = (path: string): boolean => {
286
- try {
287
- return lstatSync(path).isDirectory();
288
- } catch {
289
- return false;
290
- }
291
- };
292
-
293
405
  /** Names in `aside/<box>` when it is a real directory; a symlink or file there lists nothing. Undefined when it cannot be listed. */
294
406
  const boxNames = (aside: string, box: Box): string[] | undefined =>
295
407
  isDirectory(join(aside, box)) ? namesIn(join(aside, box)) : [];