@volter/supercode-teams 0.3.150 → 0.3.152
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/README.md +33 -1
- package/bin/teams.mjs +10 -2
- package/machine.mjs +143 -46
- package/mail/agents.mjs +40 -119
- package/mail/carrier.mjs +72 -111
- package/mail/continuation.mjs +101 -0
- package/mail/envelope.mjs +8 -10
- package/mail/hooks.mjs +43 -27
- package/mail/idle.mjs +14 -5
- package/mail/import-maildir.mjs +350 -0
- package/mail/questions.mjs +38 -46
- package/mail/reads.mjs +15 -26
- package/mail/relay-endpoint.mjs +14 -22
- package/mail/relay.mjs +100 -65
- package/mail/retire-maildir.mjs +122 -0
- package/mail/send.mjs +141 -104
- package/mail/sqlite-store.mjs +476 -0
- package/mail/sqlite.mjs +353 -0
- package/mail/transcript.mjs +3 -3
- package/mail/upkeep.mjs +46 -26
- package/mail/verbs.mjs +14 -36
- package/native-codex-pane.mjs +6 -6
- package/native-initial-input.mjs +6 -6
- package/native-launch-binding.mjs +35 -0
- package/package.json +2 -2
- package/session-people.mjs +7 -8
- package/terminal-mail.mjs +8 -19
- package/mail/store.mjs +0 -531
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// Indexed mail state behind the machine socket. The row reference in `path` is
|
|
2
|
+
// opaque compatibility data for owner-side callers; it is never a filesystem path.
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { blake3Hex } from './envelope.mjs';
|
|
7
|
+
import { MailDatabase, MailOwner, RETAINED_BODY } from './sqlite.mjs';
|
|
8
|
+
import { migrateMaildir } from './retire-maildir.mjs';
|
|
9
|
+
|
|
10
|
+
export const MIN_PREFIX_DIGITS = 4;
|
|
11
|
+
export { RETAINED_BODY };
|
|
12
|
+
const STATES = { new: 'unread', claimed: 'unread', cur: 'read' };
|
|
13
|
+
const alive = (pid) => {
|
|
14
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
15
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
16
|
+
};
|
|
17
|
+
const rowReference = (address, id) => `mail-row:${Buffer.from(JSON.stringify([address, id])).toString('base64url')}`;
|
|
18
|
+
const reference = (value) => {
|
|
19
|
+
if (value && typeof value === 'object') return [value.address, value.envelope?.id ?? value.messageId];
|
|
20
|
+
if (typeof value !== 'string' || !value.startsWith('mail-row:')) return null;
|
|
21
|
+
try { return JSON.parse(Buffer.from(value.slice(9), 'base64url').toString()); } catch { return null; }
|
|
22
|
+
};
|
|
23
|
+
const stored = (row) => ({
|
|
24
|
+
address: row.mailbox, messageId: row.id, envelope: JSON.parse(row.header),
|
|
25
|
+
state: STATES[row.state], part: row.state, path: rowReference(row.mailbox, row.id),
|
|
26
|
+
...(row.claim_token ? { claimToken: row.claim_token } : {}),
|
|
27
|
+
});
|
|
28
|
+
const COLUMNS = 'mailbox, id, header, state, claim_token';
|
|
29
|
+
const validId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id);
|
|
30
|
+
|
|
31
|
+
export class SqliteMailStore extends EventEmitter {
|
|
32
|
+
constructor({ mailRoot, machine = null, log = () => {}, database = null, retention } = {}) {
|
|
33
|
+
super();
|
|
34
|
+
this.root = resolve(mailRoot);
|
|
35
|
+
this.machine = machine;
|
|
36
|
+
this.log = log;
|
|
37
|
+
this.database = database;
|
|
38
|
+
this.retention = retention;
|
|
39
|
+
this.unread = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Import is completed by the owner before this handle becomes available to
|
|
43
|
+
* clients. A completed database never opens a Maildir to answer a query. */
|
|
44
|
+
acquire() {
|
|
45
|
+
this.owner ??= new MailOwner(join(dirname(this.root), 'mail-owner.sqlite'));
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async start() {
|
|
50
|
+
if (!this.owner) throw new Error('mail cannot open without this daemon\'s writer lease');
|
|
51
|
+
this.database ??= new MailDatabase(join(dirname(this.root), 'mail.sqlite'), { retention: this.retention });
|
|
52
|
+
this.imported = await migrateMaildir(this.database, this.root, { log: this.log });
|
|
53
|
+
this.database.retain();
|
|
54
|
+
this.database.on('message', (address) => this.emit('changed', address));
|
|
55
|
+
this.database.on('wake', ({ address, id }) => this.emit('wake', { address, id }));
|
|
56
|
+
this.database.on('subscriptions', (address) => this.emit('subscriptions', address));
|
|
57
|
+
this.retentionTimer = setInterval(() => {
|
|
58
|
+
try { this.database.retain(); }
|
|
59
|
+
catch (error) { this.log(`mail retention failed: ${error.message}`); }
|
|
60
|
+
}, 3_600_000);
|
|
61
|
+
this.retentionTimer.unref?.();
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
stop() {
|
|
66
|
+
clearInterval(this.retentionTimer);
|
|
67
|
+
this.removeAllListeners();
|
|
68
|
+
this.database?.close();
|
|
69
|
+
this.owner?.close(); this.owner = null;
|
|
70
|
+
}
|
|
71
|
+
statement(sql) { return this.database.statement(sql); }
|
|
72
|
+
transaction(operation) { return this.database.transaction(operation); }
|
|
73
|
+
record(collection, key) { return this.database.record(collection, key); }
|
|
74
|
+
records(collection) { return this.database.records(collection); }
|
|
75
|
+
putRecord(collection, key, value, options) { return this.database.putRecord(collection, key, value, options); }
|
|
76
|
+
removeRecord(collection, key) { return this.database.removeRecord(collection, key); }
|
|
77
|
+
|
|
78
|
+
statistics(now = Date.now()) {
|
|
79
|
+
const since = now - 86_400_000;
|
|
80
|
+
const states = this.statement('SELECT state, count(*) AS count FROM messages GROUP BY state ORDER BY state').all();
|
|
81
|
+
const writers = this.statement(`SELECT sender, kind, count(*) AS mailbox_copies, count(DISTINCT id) AS message_ids
|
|
82
|
+
FROM messages WHERE created_at_ms >= ? AND created_at_ms <= ?
|
|
83
|
+
GROUP BY sender, kind ORDER BY mailbox_copies DESC, sender, kind LIMIT 20`).all(since, now);
|
|
84
|
+
const pageSize = this.statement('PRAGMA page_size').get().page_size;
|
|
85
|
+
return {
|
|
86
|
+
at_ms: now,
|
|
87
|
+
schema: this.database.metadata('schema_version'),
|
|
88
|
+
messages: Object.fromEntries(states.map(({ state, count }) => [state, count])),
|
|
89
|
+
mailbox_count: this.statement('SELECT count(*) AS count FROM mailboxes').get().count,
|
|
90
|
+
pending_deliveries: this.statement('SELECT count(*) AS count FROM deliveries').get().count,
|
|
91
|
+
subscriptions: this.statement('SELECT count(*) AS count FROM subscriptions').get().count,
|
|
92
|
+
carrier_rows: this.statement('SELECT count(*) AS count FROM carrier_log').get().count,
|
|
93
|
+
terminal_log_rows: this.statement('SELECT count(*) AS count FROM terminal_log').get().count,
|
|
94
|
+
logical_bytes: this.statement('PRAGMA page_count').get().page_count * pageSize,
|
|
95
|
+
reusable_bytes: this.statement('PRAGMA freelist_count').get().freelist_count * pageSize,
|
|
96
|
+
recent_writers: { since_ms: since, through_ms: now, limit: 20, rows: writers, basis: 'stored envelope creation time; counts include recipient copies, not attempted calls' },
|
|
97
|
+
import: this.database.metadata('maildir_import'),
|
|
98
|
+
retirement: this.database.metadata('maildir_retirement'),
|
|
99
|
+
retention: this.database.metadata('retention'),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
addresses() { return this.statement('SELECT address FROM mailboxes ORDER BY address').all().map((row) => row.address); }
|
|
104
|
+
|
|
105
|
+
boxOf(address) {
|
|
106
|
+
const row = this.statement('SELECT address, folder FROM mailboxes WHERE address = ?').get(address);
|
|
107
|
+
return row ? { ...row, dir: join(this.root, row.folder) } : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
addressOfFolder(folder) { return this.statement('SELECT address FROM mailboxes WHERE folder = ?').get(folder)?.address ?? null; }
|
|
111
|
+
|
|
112
|
+
open(address) {
|
|
113
|
+
const known = this.boxOf(address);
|
|
114
|
+
if (known) return known;
|
|
115
|
+
const [, machine, harness, session] = String(address).split(':');
|
|
116
|
+
if (!machine || !harness || !session) throw new Error(`not a mail address: ${address}`);
|
|
117
|
+
const folder = `${harness.replace(/[^A-Za-z0-9-]/g, '_')}-${blake3Hex(String(address)).slice(0, 24)}`;
|
|
118
|
+
this.statement('INSERT INTO mailboxes(address, folder) VALUES (?, ?) ON CONFLICT(address) DO NOTHING').run(address, folder);
|
|
119
|
+
return this.boxOf(address);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
inventory(address) {
|
|
123
|
+
return {
|
|
124
|
+
stored: this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? ORDER BY created_at_ms, id`).all(address).map(stored),
|
|
125
|
+
skipped: this.statement('SELECT source FROM mail_import_errors WHERE mailbox = ? ORDER BY source').all(address).map((row) => row.source),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
unreadMessages(address) {
|
|
130
|
+
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND state IN ('new', 'claimed') ORDER BY created_at_ms, id`).all(address).map(stored);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
typedSince(address, since, prefix) {
|
|
134
|
+
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND kind = 'user'
|
|
135
|
+
AND created_at_ms > ? AND id >= ? AND id < ? ORDER BY created_at_ms, id`)
|
|
136
|
+
.all(address, since, prefix, `${prefix}\uffff`).map(stored);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
threadIn(address, thread) {
|
|
140
|
+
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND thread = ? ORDER BY created_at_ms, id`)
|
|
141
|
+
.all(address, thread).map(stored);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
headerWindow(address, recent) {
|
|
145
|
+
if (!Number.isSafeInteger(recent) || recent < 0) throw Object.assign(new Error('recent must be a nonnegative integer'), { code: 400 });
|
|
146
|
+
return {
|
|
147
|
+
unread: this.unreadMessages(address),
|
|
148
|
+
recent: this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? ORDER BY created_at_ms DESC, id DESC LIMIT ?`).all(address, recent).map(stored),
|
|
149
|
+
count: this.statement('SELECT count(*) AS count FROM messages WHERE mailbox = ?').get(address).count,
|
|
150
|
+
skipped: this.statement('SELECT source FROM mail_import_errors WHERE mailbox = ? ORDER BY source').all(address).map(({ source }) => source),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
history(address, last = null) {
|
|
155
|
+
if (last !== null && (!Number.isSafeInteger(last) || last < 0)) throw Object.assign(new Error('last must be a nonnegative integer'), { code: 400 });
|
|
156
|
+
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND (state = 'cur' OR (state = 'claimed' AND kind = 'user'))
|
|
157
|
+
ORDER BY created_at_ms DESC, id DESC LIMIT ?`).all(address, last ?? -1).reverse().map(stored);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
timeline(address, last = null) {
|
|
161
|
+
if (last !== null && (!Number.isSafeInteger(last) || last < 0)) throw Object.assign(new Error('last must be a nonnegative integer'), { code: 400 });
|
|
162
|
+
return this.statement(`SELECT ${COLUMNS}, created_at_ms, 'in' AS direction FROM messages
|
|
163
|
+
WHERE mailbox = ? AND NOT (kind = 'question' AND sender = ?)
|
|
164
|
+
UNION ALL SELECT ${COLUMNS}, created_at_ms, 'out' AS direction FROM messages WHERE sender = ? AND mailbox <> ?
|
|
165
|
+
ORDER BY created_at_ms DESC, direction DESC, mailbox DESC, id DESC LIMIT ?`)
|
|
166
|
+
.all(address, address, address, address, last ?? -1).reverse()
|
|
167
|
+
.map((row) => ({ address: row.mailbox, stored: stored(row), direction: row.direction }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
replies(address, id) {
|
|
171
|
+
// Older reply headers could carry a ten-character prefix. The parent index
|
|
172
|
+
// serves the finite set of compatible spellings, scoped to this mailbox.
|
|
173
|
+
const parents = new Set([id]);
|
|
174
|
+
for (let length = 10; length < id.length; length++) parents.add(id.slice(0, length));
|
|
175
|
+
const query = this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND json_extract(header, '$.in_reply_to') = ? ORDER BY created_at_ms, id`);
|
|
176
|
+
const rows = [...parents].flatMap((parent) => query.all(address, parent));
|
|
177
|
+
rows.sort((a, b) => JSON.parse(a.header).created_at_ms - JSON.parse(b.header).created_at_ms || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
178
|
+
return rows.map(stored);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
replyParent(a, b, id) {
|
|
182
|
+
const row = this.statement(`SELECT json_extract(header, '$.in_reply_to') AS parent FROM messages
|
|
183
|
+
WHERE mailbox IN (?, ?) AND id = ? ORDER BY CASE mailbox WHEN ? THEN 0 ELSE 1 END LIMIT 1`).get(a, b, id, b);
|
|
184
|
+
return row ? row.parent : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
body(item) {
|
|
188
|
+
const key = reference(item);
|
|
189
|
+
if (!Array.isArray(key) || key.length !== 2 || key.some((value) => typeof value !== 'string')) return null;
|
|
190
|
+
const row = this.statement('SELECT header, body, body_expired_at_ms FROM messages WHERE mailbox = ? AND id = ?').get(...key);
|
|
191
|
+
if (!row) return null;
|
|
192
|
+
const header = JSON.parse(row.header);
|
|
193
|
+
return row.body_expired_at_ms != null
|
|
194
|
+
? { ...header, body: RETAINED_BODY, body_expired_at_ms: row.body_expired_at_ms }
|
|
195
|
+
: { ...header, body: row.body ?? '' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
find(id) {
|
|
199
|
+
const digits = id.includes('-') ? id.slice(id.indexOf('-') + 1).length : 0;
|
|
200
|
+
if (digits < MIN_PREFIX_DIGITS) return [];
|
|
201
|
+
const rows = this.statement(`SELECT ${COLUMNS} FROM messages WHERE id >= ? AND id < ? ORDER BY id, mailbox`).all(id, `${id}\uffff`);
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
return rows.filter((row) => { if (seen.has(row.id)) return false; seen.add(row.id); return true; })
|
|
204
|
+
.map((row) => ({ address: row.mailbox, stored: stored(row) }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
firstLines(ids) {
|
|
208
|
+
const lines = new Map();
|
|
209
|
+
const query = this.statement('SELECT first_line FROM messages WHERE id = ? ORDER BY mailbox LIMIT 1');
|
|
210
|
+
for (const id of new Set(ids)) { const row = query.get(id); if (row) lines.set(id, row.first_line); }
|
|
211
|
+
return lines;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
threadMessages(thread) {
|
|
215
|
+
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE thread = ? ORDER BY created_at_ms, id, mailbox`).all(thread)
|
|
216
|
+
.map((row) => ({ address: row.mailbox, stored: stored(row) }));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Follow the same reply graph as the old thread verb, using the id, thread
|
|
220
|
+
* and parent indexes. A copy's canonical header is the first mailbox's, as
|
|
221
|
+
* the verb's former address-ordered map selected it. */
|
|
222
|
+
conversation(prefix) {
|
|
223
|
+
const matches = this.statement('SELECT count(DISTINCT id) AS count FROM messages WHERE id >= ? AND id < ?').get(prefix, `${prefix}\uffff`).count;
|
|
224
|
+
if (matches !== 1) return { matches, messages: [] };
|
|
225
|
+
const one = this.statement(`SELECT ${COLUMNS} FROM messages WHERE id >= ? AND id < ? ORDER BY mailbox LIMIT 1`).get(prefix, `${prefix}\uffff`);
|
|
226
|
+
const named = this.statement(`SELECT ${COLUMNS} FROM messages WHERE id = ? ORDER BY mailbox LIMIT 1`);
|
|
227
|
+
let root = one;
|
|
228
|
+
const ancestors = new Set();
|
|
229
|
+
for (;;) {
|
|
230
|
+
const parent = JSON.parse(root.header).in_reply_to;
|
|
231
|
+
if (typeof parent !== 'string' || ancestors.has(parent)) break;
|
|
232
|
+
const found = named.get(parent);
|
|
233
|
+
if (!found) break;
|
|
234
|
+
ancestors.add(parent); root = found;
|
|
235
|
+
}
|
|
236
|
+
const header = JSON.parse(root.header);
|
|
237
|
+
const canonical = 'NOT EXISTS (SELECT 1 FROM messages other WHERE other.id = m.id AND other.mailbox < m.mailbox)';
|
|
238
|
+
const columns = COLUMNS.split(', ').map((column) => `m.${column}`).join(', ');
|
|
239
|
+
const roots = this.statement(`SELECT ${columns} FROM messages m WHERE thread = ? AND ${canonical} ORDER BY created_at_ms, id, mailbox`)
|
|
240
|
+
.all(header.thread ?? header.id);
|
|
241
|
+
const children = this.statement(`SELECT ${columns} FROM messages m WHERE json_extract(header, '$.in_reply_to') = ? AND ${canonical} ORDER BY created_at_ms, id, mailbox`);
|
|
242
|
+
const queue = [root, ...roots], members = new Map();
|
|
243
|
+
while (queue.length) {
|
|
244
|
+
const row = queue.pop();
|
|
245
|
+
if (members.has(row.id)) continue;
|
|
246
|
+
members.set(row.id, { address: row.mailbox, stored: stored(row) });
|
|
247
|
+
for (const child of children.all(row.id)) queue.push(child);
|
|
248
|
+
}
|
|
249
|
+
return { matches, messages: [...members.values()] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
findIn(address, id) {
|
|
253
|
+
const row = this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND id = ?`).get(address, id);
|
|
254
|
+
return row ? stored(row) : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
findPrefixIn(address, prefix) {
|
|
258
|
+
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND id >= ? AND id < ? ORDER BY created_at_ms, id`)
|
|
259
|
+
.all(address, prefix, `${prefix}\uffff`).map((row) => { const item = stored(row); return { ...item, envelope: this.body(item) }; });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
links(address) {
|
|
263
|
+
return new Map(this.statement('SELECT id, json_extract(header, \'$.in_reply_to\') AS parent FROM messages WHERE mailbox = ?').all(address)
|
|
264
|
+
.map((row) => [row.id, row.parent ?? null]));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#recoverAbandoned(address) {
|
|
268
|
+
const pids = this.statement("SELECT DISTINCT claim_pid FROM messages WHERE mailbox = ? AND state = 'claimed'").all(address);
|
|
269
|
+
const release = this.statement("UPDATE messages SET state = 'new', claim_pid = NULL, claim_token = NULL WHERE mailbox = ? AND state = 'claimed' AND claim_pid = ?");
|
|
270
|
+
for (const { claim_pid: pid } of pids) {
|
|
271
|
+
if (!alive(pid) && release.run(address, pid).changes) this.database.changed('message', address);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
hasClaimable(address, wanted = null) {
|
|
276
|
+
this.#recoverAbandoned(address);
|
|
277
|
+
if (!wanted) return Boolean(this.statement("SELECT 1 FROM messages WHERE mailbox = ? AND state = 'new' AND kind != 'user' LIMIT 1").get(address));
|
|
278
|
+
return this.statement("SELECT header FROM messages WHERE mailbox = ? AND state = 'new' AND kind != 'user'").all(address)
|
|
279
|
+
.some((row) => wanted(JSON.parse(row.header)));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
claimUnread(address, via, caller, pid = process.pid) {
|
|
283
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('mail reader PID must be a positive integer');
|
|
284
|
+
return this.transaction(() => {
|
|
285
|
+
this.#recoverAbandoned(address);
|
|
286
|
+
const rows = this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND state = 'new' AND kind != 'user' ORDER BY created_at_ms, id`).all(address);
|
|
287
|
+
const take = this.statement("UPDATE messages SET state = 'claimed', claim_pid = ?, claim_token = ? WHERE mailbox = ? AND id = ? AND state = 'new'");
|
|
288
|
+
const claimed = [];
|
|
289
|
+
for (const row of rows) {
|
|
290
|
+
const token = randomBytes(16).toString('hex');
|
|
291
|
+
if (!take.run(pid, token, address, row.id).changes) continue;
|
|
292
|
+
this.claimFor(address, row.id, { pid, via, caller: caller ?? null, recipient: true, at_ms: Date.now() });
|
|
293
|
+
claimed.push(stored({ ...row, state: 'claimed', claim_token: token }));
|
|
294
|
+
}
|
|
295
|
+
if (claimed.length) this.database.changed('message', address);
|
|
296
|
+
return claimed;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
userTurns(address) {
|
|
301
|
+
return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND state = 'new' AND kind = 'user'
|
|
302
|
+
AND COALESCE(json_extract(header, '$.in_reply_to'), '') NOT LIKE 'q-%' ORDER BY created_at_ms, id`)
|
|
303
|
+
.all(address).map((row) => { const item = stored(row); return { ...item, envelope: this.body(item) }; });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
claimUserTurn(address, waiting) {
|
|
307
|
+
return this.transaction(() => {
|
|
308
|
+
this.#recoverAbandoned(address);
|
|
309
|
+
const token = randomBytes(16).toString('hex');
|
|
310
|
+
const changed = this.statement("UPDATE messages SET state = 'claimed', claim_pid = ?, claim_token = ? WHERE mailbox = ? AND id = ? AND state = 'new' AND kind = 'user'")
|
|
311
|
+
.run(process.pid, token, address, waiting.envelope.id).changes;
|
|
312
|
+
if (!changed) return null;
|
|
313
|
+
this.database.changed('message', address);
|
|
314
|
+
return { ...waiting, part: 'claimed', claimToken: token };
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
release(address, claimed) { return this.#settle(address, claimed, 'new'); }
|
|
319
|
+
acknowledge(address, claimed) { return this.#settle(address, claimed, 'cur'); }
|
|
320
|
+
|
|
321
|
+
#settle(address, claimed, state) {
|
|
322
|
+
if (!claimed.claimToken) return false;
|
|
323
|
+
const changed = this.statement(`UPDATE messages SET state = ?, claim_pid = NULL, claim_token = NULL, read_at_ms = ?
|
|
324
|
+
WHERE mailbox = ? AND id = ? AND state = 'claimed' AND claim_token = ?`)
|
|
325
|
+
.run(state, state === 'cur' ? Date.now() : null, address, claimed.envelope.id, claimed.claimToken).changes > 0;
|
|
326
|
+
if (changed) this.database.changed('message', address);
|
|
327
|
+
return changed;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
claimFor(address, id, claim) {
|
|
331
|
+
this.statement('INSERT INTO claims(mailbox, message, record) VALUES (?, ?, ?) ON CONFLICT(mailbox, message) DO UPDATE SET record = excluded.record')
|
|
332
|
+
.run(address, id, JSON.stringify(claim));
|
|
333
|
+
}
|
|
334
|
+
recordClaim(box, id, claim) { return this.claimFor(box.address, id, claim); }
|
|
335
|
+
claimOf(address, id) { const row = this.statement('SELECT record FROM claims WHERE mailbox = ? AND message = ?').get(address, id); return row ? JSON.parse(row.record) : null; }
|
|
336
|
+
|
|
337
|
+
#deliver(address, envelope, state) {
|
|
338
|
+
if (this.machine && String(address).split(':')[1] !== this.machine && String(envelope?.sender_identity?.observation ?? '').startsWith('link:')) {
|
|
339
|
+
throw new Error(`not carried on to ${address}: a message that came to this machine through a link is for its own sessions only`);
|
|
340
|
+
}
|
|
341
|
+
if (!validId(envelope.id) || !Number.isSafeInteger(envelope.created_at_ms) || typeof envelope.body !== 'string') throw new Error('invalid mail envelope');
|
|
342
|
+
return this.transaction(() => {
|
|
343
|
+
this.open(address);
|
|
344
|
+
const { body, ...header } = envelope;
|
|
345
|
+
const firstLine = Array.from(body.split('\n', 1)[0]).slice(0, 400).join('');
|
|
346
|
+
const changed = this.statement(`INSERT INTO messages(mailbox, id, thread, sender, kind, subject, created_at_ms, state, header, body, first_line, read_at_ms)
|
|
347
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(mailbox, id) DO NOTHING`)
|
|
348
|
+
.run(address, envelope.id, envelope.thread ?? envelope.id, envelope.from ?? '', envelope.kind ?? 'peer', envelope.subject ?? null,
|
|
349
|
+
envelope.created_at_ms, state, JSON.stringify(header), body, firstLine, state === 'cur' ? Date.now() : null).changes;
|
|
350
|
+
if (changed) this.database.changed('message', address);
|
|
351
|
+
return rowReference(address, envelope.id);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
deliver(address, envelope) { return this.#deliver(address, envelope, 'new'); }
|
|
355
|
+
deliverRead(address, envelope) { return this.#deliver(address, envelope, 'cur'); }
|
|
356
|
+
|
|
357
|
+
#mark(address, path, state) {
|
|
358
|
+
const key = reference(path);
|
|
359
|
+
if (!key || key[0] !== address) throw new Error('mail row reference belongs to a different mailbox');
|
|
360
|
+
const changed = this.statement('UPDATE messages SET state = ?, claim_pid = NULL, claim_token = NULL, read_at_ms = ? WHERE mailbox = ? AND id = ?')
|
|
361
|
+
.run(state, state === 'cur' ? Date.now() : null, address, key[1]).changes;
|
|
362
|
+
if (changed) this.database.changed('message', address);
|
|
363
|
+
}
|
|
364
|
+
markRead(address, path) { return this.#mark(address, path, 'cur'); }
|
|
365
|
+
markUnread(address, path) { return this.#mark(address, path, 'new'); }
|
|
366
|
+
|
|
367
|
+
remove(address, path) {
|
|
368
|
+
const key = reference(path);
|
|
369
|
+
if (!key || key[0] !== address) throw new Error('mail row reference belongs to a different mailbox');
|
|
370
|
+
const changed = this.statement('DELETE FROM messages WHERE mailbox = ? AND id = ?').run(address, key[1]).changes > 0;
|
|
371
|
+
if (changed) this.database.changed('message', address);
|
|
372
|
+
return changed;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
requestWake(address, id, { keep = false } = {}) {
|
|
376
|
+
if (!validId(id)) throw new Error('invalid wake message id');
|
|
377
|
+
this.open(address);
|
|
378
|
+
const sql = `INSERT INTO deliveries(mailbox, message, due_at_ms, attempts, record) VALUES (?, ?, 0, 0, '{}')
|
|
379
|
+
ON CONFLICT(mailbox, message) ${keep ? 'DO NOTHING' : "DO UPDATE SET due_at_ms = 0, attempts = 0, record = '{}'"}`;
|
|
380
|
+
if (this.statement(sql).run(address, id).changes) this.database.changed('wake', { address, id });
|
|
381
|
+
}
|
|
382
|
+
wakes(address) { return this.statement('SELECT message FROM deliveries WHERE mailbox = ? ORDER BY due_at_ms, message').all(address).map((row) => row.message); }
|
|
383
|
+
allWakes() { return this.statement('SELECT mailbox AS address, message AS id FROM deliveries ORDER BY due_at_ms, mailbox, message').all(); }
|
|
384
|
+
hasWake(address, id) { return validId(id) && Boolean(this.statement('SELECT 1 FROM deliveries WHERE mailbox = ? AND message = ?').get(address, id)); }
|
|
385
|
+
removeWake(address, id) { return this.statement('DELETE FROM deliveries WHERE mailbox = ? AND message = ?').run(address, id).changes > 0; }
|
|
386
|
+
wakeState(address, id) {
|
|
387
|
+
const row = this.statement('SELECT record FROM deliveries WHERE mailbox = ? AND message = ?').get(address, id);
|
|
388
|
+
return { attempts: 0, next_at_ms: 0, last_error: null, ...(row ? JSON.parse(row.record) : {}) };
|
|
389
|
+
}
|
|
390
|
+
setWakeState(address, id, state) {
|
|
391
|
+
const record = { attempts: state.attempts ?? 0, next_at_ms: state.next_at_ms ?? 0, last_error: state.last_error ?? null,
|
|
392
|
+
handover_at_ms: state.handover_at_ms ?? null, failing_since_ms: state.failing_since_ms ?? null };
|
|
393
|
+
if (this.statement('UPDATE deliveries SET record = ?, due_at_ms = ?, attempts = ? WHERE mailbox = ? AND message = ?')
|
|
394
|
+
.run(JSON.stringify(record), record.next_at_ms, record.attempts, address, id).changes) this.database.changed('wake', { address, id });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
expire(address, id, expiry) {
|
|
398
|
+
return this.transaction(() => {
|
|
399
|
+
this.putRecord('expired', `${address}/${id}`, expiry);
|
|
400
|
+
this.removeWake(address, id);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
expired() { return this.records('expired').map(({ value }) => value).filter((value) => value?.id).sort((a, b) => (a.expired_at_ms ?? 0) - (b.expired_at_ms ?? 0)); }
|
|
404
|
+
#machineKey(machine) { return `${String(machine).replace(/[^A-Za-z0-9._-]/g, '_')}.json`; }
|
|
405
|
+
machineWait(machine) { return this.record('machine_waits', this.#machineKey(machine)); }
|
|
406
|
+
setMachineWait(machine, wait) {
|
|
407
|
+
if (wait) this.putRecord('machine_waits', this.#machineKey(machine), wait);
|
|
408
|
+
else this.removeRecord('machine_waits', this.#machineKey(machine));
|
|
409
|
+
}
|
|
410
|
+
carrierLine(line) { this.database.appendCarrier(line); }
|
|
411
|
+
carrierLines(limit = 1000) {
|
|
412
|
+
return this.statement('SELECT record FROM (SELECT sequence, record FROM carrier_log ORDER BY sequence DESC LIMIT ?) ORDER BY sequence')
|
|
413
|
+
.all(limit).map(({ record }) => JSON.parse(record));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
subscriptions(address) { return this.statement('SELECT record FROM subscriptions WHERE mailbox = ? ORDER BY message').all(address).map((row) => JSON.parse(row.record)); }
|
|
417
|
+
subscribed() { return this.statement('SELECT DISTINCT mailbox FROM subscriptions ORDER BY mailbox').all().map(({ mailbox }) => mailbox); }
|
|
418
|
+
removeSubscription(address, id) {
|
|
419
|
+
const changed = this.statement('DELETE FROM subscriptions WHERE mailbox = ? AND message = ?').run(address, id).changes > 0;
|
|
420
|
+
if (changed) this.database.changed('subscriptions', address);
|
|
421
|
+
return changed;
|
|
422
|
+
}
|
|
423
|
+
updateSubscription(address, subscription) {
|
|
424
|
+
if (this.statement('UPDATE subscriptions SET record = ? WHERE mailbox = ? AND message = ?')
|
|
425
|
+
.run(JSON.stringify(subscription), address, subscription.message_id).changes) this.database.changed('subscriptions', address);
|
|
426
|
+
}
|
|
427
|
+
subscribeIdle(address, subscription) {
|
|
428
|
+
this.open(address);
|
|
429
|
+
const record = { message_id: subscription.message_id, subscriber: subscription.subscriber, created_at_ms: subscription.created_at_ms ?? Date.now(),
|
|
430
|
+
seen_working: false, notice: subscription.notice !== false, final_reply: subscription.final_reply === true };
|
|
431
|
+
this.statement('INSERT INTO subscriptions(mailbox, message, record) VALUES (?, ?, ?) ON CONFLICT(mailbox, message) DO UPDATE SET record = excluded.record')
|
|
432
|
+
.run(address, record.message_id, JSON.stringify(record));
|
|
433
|
+
this.database.changed('subscriptions', address);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#senderKey(sender) { return blake3Hex(String(sender)).slice(0, 24); }
|
|
437
|
+
recordSend(sender, to, id, thread, continuation = null) {
|
|
438
|
+
const entry = { to, id, thread: thread ?? null, at_ms: Date.now(), ...(continuation ? { continuation } : {}) };
|
|
439
|
+
this.statement(`INSERT INTO sent_messages(sender_key, id, recipient, thread, at_ms, record) VALUES (?, ?, ?, ?, ?, ?)
|
|
440
|
+
ON CONFLICT(sender_key, id, recipient) DO UPDATE SET thread = excluded.thread, at_ms = excluded.at_ms,
|
|
441
|
+
record = json_patch(sent_messages.record, excluded.record)`)
|
|
442
|
+
.run(this.#senderKey(sender), id, to, entry.thread, entry.at_ms, JSON.stringify(entry));
|
|
443
|
+
}
|
|
444
|
+
sendLog(sender) {
|
|
445
|
+
return this.statement('SELECT recipient AS "to", at_ms, id, thread, record FROM sent_messages WHERE sender_key = ? ORDER BY at_ms, id, recipient')
|
|
446
|
+
.all(this.#senderKey(sender)).map(({ record, ...legacy }) => ({ ...JSON.parse(record), ...legacy }));
|
|
447
|
+
}
|
|
448
|
+
sentIds(sender, prefix) {
|
|
449
|
+
return this.statement('SELECT DISTINCT id FROM sent_messages WHERE sender_key = ? AND id >= ? AND id < ? ORDER BY id')
|
|
450
|
+
.all(this.#senderKey(sender), prefix, `${prefix}\uffff`).map(({ id }) => id);
|
|
451
|
+
}
|
|
452
|
+
sentThread(sender, id) {
|
|
453
|
+
return this.statement('SELECT thread FROM sent_messages WHERE sender_key = ? AND id = ? AND thread IS NOT NULL ORDER BY at_ms, recipient LIMIT 1')
|
|
454
|
+
.get(this.#senderKey(sender), id)?.thread ?? null;
|
|
455
|
+
}
|
|
456
|
+
sentThreadHere(id) { return this.statement('SELECT thread FROM sent_messages WHERE id = ? AND thread IS NOT NULL ORDER BY at_ms, sender_key LIMIT 1').get(id)?.thread ?? null; }
|
|
457
|
+
sentMarker(sender, id) { return this.record('sent_receipts', `${this.#senderKey(sender)}/${id}`); }
|
|
458
|
+
setSentMarker(sender, id, text) { this.putRecord('sent_receipts', `${this.#senderKey(sender)}/${id}`, text); }
|
|
459
|
+
relayKnown(key) { return Boolean(this.statement("SELECT 1 FROM mail_records WHERE collection = 'relays' AND key >= ? AND key < ? LIMIT 1").get(`${key}/`, `${key}/\uffff`)); }
|
|
460
|
+
|
|
461
|
+
#terminalKey(recipient) { return `${createHash('sha256').update(recipient).digest('hex')}/queue.json`; }
|
|
462
|
+
terminalState(recipient) { return this.record('terminal_delivery', this.#terminalKey(recipient)); }
|
|
463
|
+
saveTerminalState(recipient, state) { return this.putRecord('terminal_delivery', this.#terminalKey(recipient), state); }
|
|
464
|
+
retainedTerminalStates() {
|
|
465
|
+
return this.statement(`SELECT record FROM mail_records WHERE collection = 'terminal_delivery'
|
|
466
|
+
AND (json_extract(record, '$.active') IS NOT NULL OR json_array_length(record, '$.pending') > 0) ORDER BY key`)
|
|
467
|
+
.all().map(({ record }) => JSON.parse(record));
|
|
468
|
+
}
|
|
469
|
+
terminalEvent(recipient, kind, record) {
|
|
470
|
+
return this.transaction(() => {
|
|
471
|
+
this.statement('INSERT INTO terminal_log(recipient, kind, record) VALUES (?, ?, ?)').run(recipient, kind, JSON.stringify(record));
|
|
472
|
+
this.statement('DELETE FROM terminal_log WHERE sequence <= (SELECT sequence FROM terminal_log ORDER BY sequence DESC LIMIT 1 OFFSET ?)')
|
|
473
|
+
.run(this.database.retention.maxTerminalLogRows);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|