pi-session-mail 0.2.2 → 0.4.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.
- package/CHANGELOG.md +61 -1
- package/README.md +44 -27
- package/package.json +3 -1
- package/src/index.ts +150 -53
- package/src/mail-status.ts +68 -0
- package/src/picker.ts +285 -0
- package/src/store.ts +150 -38
- package/src/tools.ts +92 -19
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
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
235
|
-
} catch {
|
|
236
|
-
|
|
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/`,
|
|
244
|
-
* requests (not messages) in `sent/` that no
|
|
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
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
(
|
|
252
|
-
|
|
253
|
-
|
|
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)) : [];
|