@volter/supercode-teams 0.3.159 → 0.3.161

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/mail/send.mjs CHANGED
@@ -9,6 +9,7 @@ import { AGENT_HARNESS, RESUME_WAIT_MS } from './agents.mjs';
9
9
  import { MAX_RELAYED_BYTES, RELAY_NAME_PREFIX } from './relay.mjs';
10
10
  import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, newMessageId, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
11
11
  import { nativeMailContinuation } from './continuation.mjs';
12
+ import { paneWaitReason, terminalReceipt } from './delivery-status.mjs';
12
13
 
13
14
  export const EXIT_REFUSED = 2;
14
15
  export const EXIT_UNKNOWN = 3;
@@ -29,7 +30,7 @@ const CODEX_KNOWN_WAIT_MS = 20_000;
29
30
  const RELAY_GRACE_MS = 30_000;
30
31
  /** The kinds of delivery that hand nothing over (#deliverWoken's `how`): the copy waits filed. An operator's mailbox,
31
32
  * which its operator reads, and a session's hook, which shows it, are deliveries. */
32
- const LEFT_FILED = new Set(['queued', 'stored', 'pane_held', 'pane_waits', 'not_running']);
33
+ const LEFT_FILED = new Set(['queued', 'stored', 'pane_held', 'pane_waits', 'native_waits', 'not_running']);
33
34
 
34
35
  /** Replies in one unbroken chain between two sessions beyond which another is refused. */
35
36
  const REPLY_CHAIN_LIMIT = 32;
@@ -41,7 +42,7 @@ const NAME_SEEN_EVERY_MS = 60 * 60 * 1000;
41
42
  const outcome = (code, text, receipt = null) => ({ code, text, receipt });
42
43
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
43
44
  /** The daemon pane a live session runs in, or null (mail_route.rs `daemon_pane`). */
44
- const paneOf = (session) => { const pane = typeof session.pane === 'string' ? session.pane.split(':')[0] : null; return pane && /^p_[A-Za-z0-9_-]+$/.test(pane) ? pane : null; };
45
+ const paneOf = (session) => { const selected = session.door === 'native' ? session.delivery_pane : session.pane; const pane = typeof selected === 'string' ? selected.split(':')[0] : null; return pane && /^p_[A-Za-z0-9_-]+$/.test(pane) ? pane : null; };
45
46
 
46
47
  /** A delivery a door refused before anything was sent. */
47
48
  class Refused extends Error { constructor(kind, bytes = 0) { super(kind); this.kind = kind; this.bytes = bytes; } }
@@ -274,7 +275,10 @@ export class MailSend {
274
275
  const session = sessions.find((row) => row.address === address);
275
276
  if (!session) return null;
276
277
  if (session.door === 'runtime') return { kind: 'runtime', session };
277
- if (session.door === 'native') return { kind: 'native', session };
278
+ if (session.door === 'native') {
279
+ const pane = paneOf(session);
280
+ return pane ? { kind: 'pane', session, pane } : { kind: 'native', session };
281
+ }
278
282
  if (session.door === 'hook') return { kind: 'hook', session, pane: session.pane ?? null, idle: session.status === 'idle' };
279
283
  // a session with no hook and no relay (Grok) in a daemon pane: its mail is typed there, into an empty composer
280
284
  if (session.door === 'pane' && session.pane) return { kind: 'pane', session, pane: session.pane };
@@ -288,11 +292,16 @@ export class MailSend {
288
292
  if (address === caller.address) return outcome(EXIT_REFUSED, 'Not sent: that address is your own session.');
289
293
  const door = parseAddress(address).harness === AGENT_HARNESS ? null : this.#door(address, sessions);
290
294
  const messageId = id == null && chosen ? chosen : messageIdFor(caller.address, id);
291
- const terminal = door?.kind === 'hook' && door.pane != null;
295
+ const terminal = ['hook', 'pane'].includes(door?.kind) && door.pane != null;
292
296
  const previous = this.store.sentMarker(caller.address, messageId);
293
297
  if (previous !== null) {
294
- if (terminal && !queue) this.store.requestWake(address, messageId);
295
- return outcome(0, `Already sent with --id ${id ?? ''}: ${previous} Nothing was sent again.`, this.#deliveryReceipt(address, messageId, terminal));
298
+ const stored = this.store.findIn(address, messageId);
299
+ const waiting = !['operator', 'board'].includes(parseAddress(address).harness) && stored?.state === 'unread'
300
+ && this.store.claimOf(address, messageId)?.recipient !== true;
301
+ const receipt = this.#deliveryReceipt(address, messageId, terminal || waiting);
302
+ if ((terminal || waiting) && !queue && !receipt.refused && !receipt.delivered) this.store.requestWake(address, messageId, { keep: true });
303
+ return outcome(!receipt.delivered ? EXIT_STORED : 0,
304
+ `Already filed with --id ${id ?? ''}: ${previous} Nothing was sent again.${receipt.refused ? ` Delivery stopped: ${receipt.reason}.` : ''}`, receipt);
296
305
  }
297
306
  const answered = this.#answeredId(caller.address, in_reply_to);
298
307
  if (answered.refused) return answered.refused;
@@ -334,7 +343,7 @@ export class MailSend {
334
343
  const [code, what] = {
335
344
  steered: [0, 'steered into its running turn'],
336
345
  started: [0, 'it was idle, so the message started a turn'],
337
- native_busy: [0, 'it will read it at its next tool call'],
346
+ native_waits: [EXIT_STORED, 'Stored: its native session is not known to be idle, so the message waits unread until it is idle. Do not resend'],
338
347
  native_idle: [0, 'it was idle, so the message starts its next turn'],
339
348
  hooked: [0, 'queued in its mailbox; terminal delivery waits for native transcript confirmation'],
340
349
  hook_woken: [0, 'it was idle, so its pane was told to read its mailbox; its hook shows it the message in that turn'],
@@ -342,8 +351,8 @@ export class MailSend {
342
351
  operator: [0, 'filed in its mailbox'],
343
352
  already: [0, 'it already had this message; nothing was sent again'],
344
353
  typed: [0, 'typed into its pane as its next turn'],
345
- pane_held: [0, 'held for its pane: typed once its composer is free'],
346
- pane_waits: [EXIT_STORED, 'Stored (not failed): its pane\'s composer was not empty, so the message waits in its mailbox; it sees it with supercode message inbox. Don\'t resend'],
354
+ pane_held: [EXIT_STORED, 'Stored: held by its terminal queue; native transcript acceptance is not confirmed. Do not resend'],
355
+ pane_waits: [EXIT_STORED, `Stored: its pane did not accept input (${this.store.wakeState(address, messageId).last_error ?? 'not at an empty composer'}); the message waits unread in its mailbox. Do not resend`],
347
356
  stored: [EXIT_STORED, `Stored (not failed): it has no delivery door, so it sees this only if it runs supercode message inbox${parseAddress(address).harness === 'codex' ? ' (a Codex session gets one with: supercode message setup codex)' : ''}. Don't resend, and don't wait for a reply`],
348
357
  }[delivered];
349
358
  this.#recordSend(caller.address, address, messageId, sentIn, receiver.continuation);
@@ -351,8 +360,8 @@ export class MailSend {
351
360
  const text = code === 0
352
361
  ? `sent to ${name} (${harness}, ${door.kind}): ${what}. Message id ${shortId(messageId)}.${receiver.alias ?? ''}${idleNote} Don't poll; carry on.`
353
362
  : `${name} (${harness}): ${what}. Message id ${shortId(messageId)}.${idleNote}`;
354
- if (code === 0 && id != null) { try { this.store.setSentMarker(caller.address, messageId, text); } catch { /* the marker is best kept */ } }
355
- return outcome(code, text, code === 0 ? this.#deliveryReceipt(address, messageId, terminal) : null);
363
+ if ((code === 0 || (terminal && code === EXIT_STORED)) && id != null) { try { this.store.setSentMarker(caller.address, messageId, text); } catch { /* the marker is best kept */ } }
364
+ return outcome(code, text, code === 0 || terminal ? this.#deliveryReceipt(address, messageId, terminal) : null);
356
365
  }
357
366
 
358
367
  /** An agent's plan for `sent` (its thread store decides), or an outcome when the plan refused it. */
@@ -386,8 +395,16 @@ export class MailSend {
386
395
  if (how === 'not_woken') { this.store.deliver(to, sent); delivered = 'queued'; }
387
396
  else { this.store.deliverRead(to, handed); finalReply = answers; delivered = how === 'steered' ? 'steered' : 'started'; }
388
397
  } else if (door.kind === 'native') {
389
- const busy = door.session.status !== 'idle';
398
+ const busy = door.session.status !== 'idle' || door.session.panes_current === false;
390
399
  if (!wake && !busy) throw new Refused('queue');
400
+ if (busy) {
401
+ this.store.deliver(to, sent);
402
+ this.store.requestWake(to, sent.id, { keep: true });
403
+ const state = this.store.wakeState(to, sent.id);
404
+ this.store.setWakeState(to, sent.id, { ...state, waiting_for_idle: true, next_at_ms: Date.now() + 300_000 });
405
+ if (notifyWhenIdle && !cameThroughLink(sent)) this.store.subscribeIdle(to, { message_id: sent.id, subscriber: sent.from, notice: true, final_reply: false });
406
+ return 'native_waits';
407
+ }
391
408
  // its reply line names the door this session really has: its tools when it runs supercode's MCP server
392
409
  const handed = sent.reply_via?.mode === 'command' && door.session.tools ? { ...sent, reply_via: { mode: 'tool' } } : sent;
393
410
  const text = render(handed);
@@ -421,7 +438,7 @@ export class MailSend {
421
438
  }
422
439
  this.relaying.add(key);
423
440
  let relayed;
424
- try { relayed = await this.relays.send(handed.from, handed.from_name, { sessionId: door.session.address.split(':').slice(3).join(':'), name: door.session.name.split('@')[0], socket: door.session.socket }, text, handed.id); }
441
+ try { relayed = await this.relays.send(handed.from, handed.from_name, { sessionId: door.session.address.split(':').slice(3).join(':'), name: door.session.name.split('@')[0], socket: door.session.socket }, text, handed.id, { expectsReply: handed.reply_via?.mode !== 'none' }); }
425
442
  finally { this.relaying.delete(key); }
426
443
  // a send the relay may have made keeps its copy and is unanswered, never taken back and said not sent; only one the
427
444
  // relay is known not to have made (refused, never handed in) takes its copy back. One its receiver read from its
@@ -441,7 +458,7 @@ export class MailSend {
441
458
  const now = this.store.findIn(to, handed.id);
442
459
  if (now?.state === 'unread') { try { this.store.markRead(to, now.path); } catch { /* moved meanwhile */ } }
443
460
  }
444
- delivered = busy ? 'native_busy' : 'native_idle';
461
+ delivered = 'native_idle';
445
462
  } else if (door.kind === 'hook') {
446
463
  this.store.deliver(to, sent);
447
464
  if (wake) this.store.requestWake(to, sent.id);
@@ -449,13 +466,21 @@ export class MailSend {
449
466
  } else if (door.kind === 'pane') {
450
467
  // typed as its next turn only into an empty composer (machine.mjs #paneTurn); otherwise it waits in its mailbox
451
468
  let typed = null;
452
- if (wake) { try { typed = await this.typeTurn(door.pane, parseAddress(to).harness, render(sent), [sent.id]); } catch { /* waits */ } }
469
+ if (wake) { try { typed = await this.typeTurn(door.pane, parseAddress(to).harness, render(sent), [sent.id], to); } catch (error) { typed = { reason: error.message }; } }
453
470
  if (typed?.delivered) { this.store.deliverRead(to, sent); delivered = 'typed'; }
454
471
  // held by the daemon's queue for the pane, typed once its composer frees: not typed yet, so kept unread
455
- else if (typed?.queued) { this.store.deliver(to, sent); delivered = 'pane_held'; }
472
+ else if (typed?.queued) { this.store.deliver(to, sent); if (wake) this.store.requestWake(to, sent.id, { keep: true }); delivered = 'pane_held'; }
456
473
  // not typed (its composer held a draft or a turn ran): it waits unread with a wake, so the carrier types it once the
457
474
  // composer is empty (mail/carrier.mjs), never left for a read a session with no hook never makes
458
- else { this.store.deliver(to, sent); if (wake) this.store.requestWake(to, sent.id); delivered = wake ? 'pane_waits' : 'queued'; }
475
+ else {
476
+ this.store.deliver(to, sent);
477
+ if (wake) {
478
+ this.store.requestWake(to, sent.id, { keep: true });
479
+ const state = this.store.wakeState(to, sent.id);
480
+ this.store.setWakeState(to, sent.id, { ...state, next_at_ms: Date.now() + 2_000, last_error: paneWaitReason(typed) });
481
+ }
482
+ delivered = wake ? 'pane_waits' : 'queued';
483
+ }
459
484
  } else if (door.kind === 'stored') { this.store.deliver(to, sent); delivered = 'stored'; }
460
485
  else { this.store.deliver(to, sent); delivered = 'operator'; }
461
486
  // a hand-over to the recipient's own door is its delivery, recorded as such
@@ -592,7 +617,7 @@ export class MailSend {
592
617
  if (error instanceof Refused) throw new Error(`${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
593
618
  throw error;
594
619
  }
595
- return { how, text: { steered: 'steered into its running turn', started: 'started a turn', native_busy: 'read at its next tool call', native_idle: 'starts its next turn', hooked: 'in its mailbox, its hook shows it', queued: 'waits in its mailbox', operator: 'filed', already: 'it already had it', stored: 'stored; it has no delivery door', typed: 'typed into its pane', pane_held: 'held for its pane until its composer is free', pane_waits: 'waits in its mailbox (its composer was not empty)' }[how] };
620
+ return { how, text: { steered: 'steered into its running turn', started: 'started a turn', native_waits: 'waits unread until its native turn is idle', native_idle: 'starts its next turn', hooked: 'in its mailbox, its hook shows it', queued: 'waits in its mailbox', operator: 'filed', already: 'it already had it', stored: 'stored; it has no delivery door', typed: 'typed into its pane', pane_held: 'held for its pane until its composer is free', pane_waits: 'waits in its mailbox (its composer was not empty)' }[how] };
596
621
  }
597
622
 
598
623
  // ---- a filing --------------------------------------------------------------------------------------------------
@@ -665,9 +690,11 @@ export class MailSend {
665
690
  id = `m-${blake3Hex(JSON.stringify([sender, receiver, key]))}`.slice(0, 26);
666
691
  const previous = this.store.findIn(receiver, id);
667
692
  const saved = previous ? this.store.body(previous) : null;
668
- if (saved && (saved.body !== text || (saved.subject ?? null) !== subject || saved.kind !== (params.channel ? 'channel' : 'peer') || saved.from_name !== (params.sender_name ?? `${fromName}@${this.machine}`) || (saved.in_reply_to ?? null) !== (params.in_reply_to ?? null) || (saved.voice_for ?? null) !== (params.voice_for ?? null))) {
693
+ const bodyMatches = previous ? this.store.bodyMatches(receiver, id, text) : null;
694
+ if (saved && (bodyMatches === false || (saved.subject ?? null) !== subject || saved.kind !== (params.channel ? 'channel' : 'peer') || saved.from_name !== (params.sender_name ?? `${fromName}@${this.machine}`) || (saved.in_reply_to ?? null) !== (params.in_reply_to ?? null) || (saved.voice_for ?? null) !== (params.voice_for ?? null))) {
669
695
  return refused('idempotency_conflict', 'this key already names different mail');
670
696
  }
697
+ if (saved && bodyMatches == null) return { ...refused('idempotency_payload_expired', 'this key already names retained mail, but an older release expired its payload before keeping a digest; the original payload cannot be compared. Nothing was filed again.'), message_id: id };
671
698
  }
672
699
  const inReplyTo = typeof params.in_reply_to === 'string' ? params.in_reply_to : null;
673
700
  const sent = makeEnvelope({
@@ -691,9 +718,9 @@ export class MailSend {
691
718
  return refused('delivery_failed', error.message);
692
719
  }
693
720
  return {
694
- delivered_to_bus: door.kind !== 'stored' && how !== 'pane_waits', message_id: sent.id, reply_to: sender,
721
+ delivered_to_bus: door.kind !== 'stored' && !['pane_waits', 'pane_held', 'native_waits'].includes(how), message_id: sent.id, reply_to: sender,
695
722
  target: { harness, session_id: sessionId, name: door.kind === 'native' ? door.session.name.split('@')[0] : null },
696
- delivery: { door: door.kind, how: { steered: 'steered', started: 'started', native_busy: 'next_tool_call', native_idle: 'started', hooked: 'hook', queued: 'queued', stored: 'stored', operator: 'filed', already: 'already', typed: 'typed', pane_held: 'held', pane_waits: 'stored' }[how] },
723
+ delivery: { door: door.kind, how: { steered: 'steered', started: 'started', native_waits: 'waiting_for_idle', native_idle: 'started', hooked: 'hook', queued: 'queued', stored: 'stored', operator: 'filed', already: 'already', typed: 'typed', pane_held: 'held', pane_waits: 'stored' }[how] },
697
724
  };
698
725
  }
699
726
 
@@ -807,7 +834,7 @@ export class MailSend {
807
834
  const claimed = this.store.claimUserTurn(address, waiting);
808
835
  if (!claimed) break;
809
836
  let result;
810
- try { result = await this.typeTurn(pane, harness, claimed.envelope.body, [claimed.envelope.id]); }
837
+ try { result = await this.typeTurn(pane, harness, claimed.envelope.body, [claimed.envelope.id], address); }
811
838
  catch (error) {
812
839
  try { this.store.release(address, claimed); } catch { /* recovered when this process ends */ }
813
840
  this.log(`mail: the user's turn ${claimed.envelope.id} for ${address} waits: ${error.message}`);
@@ -859,7 +886,7 @@ export class MailSend {
859
886
  const door = this.#door(address, this.live.sessions());
860
887
  if (!door) return { outcome: 'later', detail: 'its session is not running' };
861
888
  if (door.kind === 'other_machine') return { outcome: 'later', detail: `it is on ${door.machine}` };
862
- if (door.kind !== 'native' && door.kind !== 'runtime') {
889
+ if (door.kind !== 'native' && door.kind !== 'runtime' && door.kind !== 'pane') {
863
890
  // a hook's session is pointed at its mailbox by a wake (a native notice filed for it asks one)
864
891
  if (door.kind === 'hook' && wake) this.store.requestWake(address, id);
865
892
  return { outcome: 'filed', detail: `its door is ${door.kind}` };
@@ -874,6 +901,7 @@ export class MailSend {
874
901
  try {
875
902
  const how = await this.route(sent, address, door, true, false);
876
903
  // handed to the session (or already its): moved to cur/ (one only queued for a runtime not woken would stay unread)
904
+ if (LEFT_FILED.has(how)) return { outcome: how === 'native_waits' ? 'waiting' : how === 'pane_held' ? 'pending' : 'later', detail: how === 'pane_waits' ? this.store.wakeState(address, id).last_error : how };
877
905
  const now = this.store.findIn(address, id);
878
906
  if (how !== 'queued' && now?.state === 'unread') this.store.markRead(address, now.path);
879
907
  return { outcome: 'delivered', detail: null };
@@ -979,15 +1007,11 @@ export class MailSend {
979
1007
  * `delivery_receipt`). */
980
1008
  #deliveryReceipt(address, id, terminal) {
981
1009
  if (!terminal) return { delivered: true, message_ids: [id], receipt: 'native-mail' };
982
- const parsed = parseAddress(address);
983
- const recipient = `${parsed.harness}:${parsed.session}`;
984
- const state = this.store.terminalState(recipient);
985
- if (state) {
986
- const receipt = (Array.isArray(state.receipts) ? state.receipts : []).find((item) => Array.isArray(item?.message_ids) && item.message_ids.includes(id));
987
- if (receipt) return receipt;
988
- return { delivered: false, queued: true, message_ids: [id], reason: state.active?.error ?? null };
989
- }
990
- return { delivered: false, queued: true, message_ids: [id], reason: 'awaiting terminal mailbox' };
1010
+ const expired = this.store.record('expired', `${address}/${id}`);
1011
+ const receipt = terminalReceipt(this.store, address, id);
1012
+ if (receipt?.delivered) return receipt;
1013
+ if (expired) return { delivered: false, queued: false, refused: true, message_ids: [id], reason: expired.reason };
1014
+ return receipt ?? { delivered: false, queued: true, message_ids: [id], reason: this.store.wakeState(address, id).last_error ?? 'awaiting terminal mailbox' };
991
1015
  }
992
1016
 
993
1017
  // ---- the names ledger --------------------------------------------------------------------------------------------
@@ -27,6 +27,7 @@ const stored = (row) => ({
27
27
  });
28
28
  const COLUMNS = 'mailbox, id, header, state, claim_token';
29
29
  const validId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id);
30
+ const OFFLINE_CEILING_MS = 30 * 24 * 60 * 60_000;
30
31
 
31
32
  export class SqliteMailStore extends EventEmitter {
32
33
  constructor({ mailRoot, machine = null, log = () => {}, database = null, retention } = {}) {
@@ -51,6 +52,7 @@ export class SqliteMailStore extends EventEmitter {
51
52
  this.database ??= new MailDatabase(join(dirname(this.root), 'mail.sqlite'), { retention: this.retention });
52
53
  this.imported = await migrateMaildir(this.database, this.root, { log: this.log });
53
54
  this.database.retain();
55
+ this.reconcileDeliverySchedule();
54
56
  this.database.on('message', (address) => this.emit('changed', address));
55
57
  this.database.on('wake', ({ address, id }) => this.emit('wake', { address, id }));
56
58
  this.database.on('subscriptions', (address) => this.emit('subscriptions', address));
@@ -69,6 +71,7 @@ export class SqliteMailStore extends EventEmitter {
69
71
  this.owner?.close(); this.owner = null;
70
72
  }
71
73
  statement(sql) { return this.database.statement(sql); }
74
+ get changes() { return this.database.changes; }
72
75
  transaction(operation) { return this.database.transaction(operation); }
73
76
  record(collection, key) { return this.database.record(collection, key); }
74
77
  records(collection) { return this.database.records(collection); }
@@ -88,6 +91,29 @@ export class SqliteMailStore extends EventEmitter {
88
91
  messages: Object.fromEntries(states.map(({ state, count }) => [state, count])),
89
92
  mailbox_count: this.statement('SELECT count(*) AS count FROM mailboxes').get().count,
90
93
  pending_deliveries: this.statement('SELECT count(*) AS count FROM deliveries').get().count,
94
+ delivery_targets: this.statement('SELECT count(*) AS count FROM delivery_targets').get().count,
95
+ next_delivery: this.nextDeliveryTarget(),
96
+ change_cursor: this.changes.cursor(),
97
+ relay_pool: {
98
+ handles: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relays' AND key LIKE '%/relay.json' AND json_extract(record, '$.pool') = 1").get().count,
99
+ unanswered_starts: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relays' AND key LIKE '%/start.json'").get().count,
100
+ // These are committed owner handles, not liveness or PID authority.
101
+ // Let the local operator inspect and close a known runtime through its
102
+ // owner without reading SQLite or exposing a runtime bearer token.
103
+ recorded_owners: this.statement(`SELECT key, json_extract(record, '$.runtime_id') AS runtime_id,
104
+ json_extract(record, '$.connection') AS connection, json_extract(record, '$.pid') AS pid
105
+ FROM mail_records WHERE collection = 'relays' AND key LIKE '%/relay.json'
106
+ AND json_extract(record, '$.pool') = 1 ORDER BY key LIMIT 64`).all(),
107
+ recorded_starts: this.statement(`SELECT key, json_extract(record, '$.name') AS name,
108
+ json_extract(record, '$.at_ms') AS at_ms, json_extract(record, '$.directory') AS directory
109
+ FROM mail_records WHERE collection = 'relays' AND key LIKE '%/start.json' ORDER BY key LIMIT 64`).all(),
110
+ recorded_limit: 64,
111
+ reply_routes: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relay_routes'").get().count,
112
+ ambiguous_routes: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relay_routes' AND json_extract(record, '$.ambiguous') = 1").get().count,
113
+ deferred_replies: this.statement("SELECT count(*) AS count FROM mail_records WHERE collection = 'relay_pending'").get().count,
114
+ undelivered_replies: this.statement("SELECT count(*) AS count FROM messages WHERE json_type(header, '$.pool_reply_undelivered') IS NOT NULL").get().count,
115
+ pending_writer_notices: this.statement("SELECT count(*) AS count FROM messages WHERE state IN ('new', 'claimed') AND json_type(header, '$.pool_reply_failure_notice') IS NOT NULL").get().count,
116
+ },
91
117
  subscriptions: this.statement('SELECT count(*) AS count FROM subscriptions').get().count,
92
118
  carrier_rows: this.statement('SELECT count(*) AS count FROM carrier_log').get().count,
93
119
  terminal_log_rows: this.statement('SELECT count(*) AS count FROM terminal_log').get().count,
@@ -130,6 +156,17 @@ export class SqliteMailStore extends EventEmitter {
130
156
  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
157
  }
132
158
 
159
+ hookSummary(address) {
160
+ const source = `FROM messages m WHERE mailbox = ? AND state IN ('new', 'claimed')
161
+ AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.mailbox = m.mailbox AND d.message = m.id)`;
162
+ const count = this.statement(`SELECT count(*) AS count ${source}`).get(address).count;
163
+ const senders = this.statement(`SELECT kind, json_extract(header, '$.from_name') AS name, min(created_at_ms) AS first_at
164
+ ${source} GROUP BY kind, name ORDER BY first_at, kind, name LIMIT 65`).all(address);
165
+ const moreSenders = senders.length > 64;
166
+ if (moreSenders) senders.pop();
167
+ return { count, senders, moreSenders };
168
+ }
169
+
133
170
  typedSince(address, since, prefix) {
134
171
  return this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND kind = 'user'
135
172
  AND created_at_ms > ? AND id >= ? AND id < ? ORDER BY created_at_ms, id`)
@@ -249,6 +286,15 @@ export class SqliteMailStore extends EventEmitter {
249
286
  return { matches, messages: [...members.values()] };
250
287
  }
251
288
 
289
+ /** null means an older release expired the body before keeping a digest.
290
+ * Never compare its retention placeholder as though it were original text. */
291
+ bodyMatches(address, id, text) {
292
+ const row = this.statement('SELECT body, body_digest FROM messages WHERE mailbox = ? AND id = ?').get(address, id);
293
+ if (!row) return null;
294
+ if (row.body != null) return row.body === text;
295
+ return row.body_digest ? row.body_digest === createHash('sha256').update(text).digest('hex') : null;
296
+ }
297
+
252
298
  findIn(address, id) {
253
299
  const row = this.statement(`SELECT ${COLUMNS} FROM messages WHERE mailbox = ? AND id = ?`).get(address, id);
254
300
  return row ? stored(row) : null;
@@ -298,8 +344,9 @@ export class SqliteMailStore extends EventEmitter {
298
344
  }
299
345
 
300
346
  userTurns(address) {
347
+ this.#recoverAbandoned(address);
301
348
  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`)
349
+ AND COALESCE(json_extract(header, '$.in_reply_to'), '') NOT LIKE 'q-%' ORDER BY created_at_ms, id LIMIT 256`)
303
350
  .all(address).map((row) => { const item = stored(row); return { ...item, envelope: this.body(item) }; });
304
351
  }
305
352
 
@@ -343,11 +390,14 @@ export class SqliteMailStore extends EventEmitter {
343
390
  this.open(address);
344
391
  const { body, ...header } = envelope;
345
392
  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`)
393
+ const changed = this.statement(`INSERT INTO messages(mailbox, id, thread, sender, kind, subject, created_at_ms, state, header, body, body_digest, first_line, read_at_ms)
394
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(mailbox, id) DO NOTHING`)
348
395
  .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);
396
+ envelope.created_at_ms, state, JSON.stringify(header), body, createHash('sha256').update(body).digest('hex'), firstLine, state === 'cur' ? Date.now() : null).changes;
397
+ if (changed) {
398
+ if (state === 'new' && envelope.kind === 'user' && !String(envelope.in_reply_to ?? '').startsWith('q-')) this.requestWake(address, envelope.id, { keep: true });
399
+ this.database.changed('message', address);
400
+ }
351
401
  return rowReference(address, envelope.id);
352
402
  });
353
403
  }
@@ -367,31 +417,127 @@ export class SqliteMailStore extends EventEmitter {
367
417
  remove(address, path) {
368
418
  const key = reference(path);
369
419
  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;
420
+ return this.transaction(() => {
421
+ const changed = this.statement('DELETE FROM messages WHERE mailbox = ? AND id = ?').run(address, key[1]).changes > 0;
422
+ if (changed) {
423
+ this.refreshDeliveryTarget(...this.#deliveryTarget(address));
424
+ this.database.changed('message', address);
425
+ this.database.changed('wake', { address, id: key[1] });
426
+ }
427
+ return changed;
428
+ });
373
429
  }
374
430
 
375
431
  requestWake(address, id, { keep = false } = {}) {
376
432
  if (!validId(id)) throw new Error('invalid wake message id');
377
433
  this.open(address);
378
- const sql = `INSERT INTO deliveries(mailbox, message, due_at_ms, attempts, record) VALUES (?, ?, 0, 0, '{}')
434
+ const sql = `INSERT INTO deliveries(mailbox, message, machine, due_at_ms, attempts, record) VALUES (?, ?, ?, 0, 0, '{}')
379
435
  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 });
436
+ this.transaction(() => {
437
+ if (this.statement(sql).run(address, id, address.split(':')[1]).changes) {
438
+ this.refreshDeliveryTarget(...this.#deliveryTarget(address));
439
+ this.database.changed('wake', { address, id });
440
+ }
441
+ });
381
442
  }
382
443
  wakes(address) { return this.statement('SELECT message FROM deliveries WHERE mailbox = ? ORDER BY due_at_ms, message').all(address).map((row) => row.message); }
383
444
  allWakes() { return this.statement('SELECT mailbox AS address, message AS id FROM deliveries ORDER BY due_at_ms, mailbox, message').all(); }
384
445
  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; }
446
+ removeWake(address, id) {
447
+ return this.transaction(() => {
448
+ const changed = this.statement('DELETE FROM deliveries WHERE mailbox = ? AND message = ?').run(address, id).changes > 0;
449
+ if (changed) {
450
+ this.refreshDeliveryTarget(...this.#deliveryTarget(address));
451
+ this.database.changed('wake', { address, id });
452
+ }
453
+ return changed;
454
+ });
455
+ }
386
456
  wakeState(address, id) {
387
457
  const row = this.statement('SELECT record FROM deliveries WHERE mailbox = ? AND message = ?').get(address, id);
388
458
  return { attempts: 0, next_at_ms: 0, last_error: null, ...(row ? JSON.parse(row.record) : {}) };
389
459
  }
390
460
  setWakeState(address, id, state) {
391
461
  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 });
462
+ handover_at_ms: state.handover_at_ms ?? null, failing_since_ms: state.failing_since_ms ?? null, waiting_for_idle: state.waiting_for_idle === true };
463
+ this.transaction(() => {
464
+ if (this.statement('UPDATE deliveries SET record = ?, due_at_ms = ?, attempts = ? WHERE mailbox = ? AND message = ?')
465
+ .run(JSON.stringify(record), record.next_at_ms, record.attempts, address, id).changes) {
466
+ this.refreshDeliveryTarget(...this.#deliveryTarget(address));
467
+ this.database.changed('wake', { address, id });
468
+ }
469
+ });
470
+ }
471
+
472
+ #deliveryTarget(address) {
473
+ const machine = address.split(':')[1];
474
+ return machine === this.machine ? ['mailbox', address] : ['machine', machine];
475
+ }
476
+
477
+ /** One indexed row per local mailbox or remote machine. Offline probing is
478
+ * a machine deadline, independent of each message's retry deadline. */
479
+ refreshDeliveryTarget(kind, target) {
480
+ const row = kind === 'mailbox'
481
+ ? this.statement('SELECT min(due_at_ms) AS due FROM deliveries WHERE mailbox = ?').get(target)
482
+ : this.statement('SELECT min(due_at_ms) AS due FROM deliveries WHERE machine = ?').get(target);
483
+ if (row.due == null) {
484
+ this.statement('DELETE FROM delivery_targets WHERE kind = ? AND target = ?').run(kind, target);
485
+ return;
486
+ }
487
+ const wait = kind === 'machine' ? this.machineWait(target) : null;
488
+ const due = wait ? Math.min(wait.next_probe_ms ?? 0, (wait.offline_since_ms ?? Date.now()) + OFFLINE_CEILING_MS) : row.due;
489
+ this.statement(`INSERT INTO delivery_targets(kind, target, due_at_ms) VALUES (?, ?, ?)
490
+ ON CONFLICT(kind, target) DO UPDATE SET due_at_ms = excluded.due_at_ms`).run(kind, target, due);
491
+ }
492
+
493
+ /** Startup and the five-minute backstop rebuild derived schedule rows. Normal
494
+ * writes refresh only the target they changed, in the same transaction. */
495
+ reconcileDeliverySchedule() {
496
+ this.transaction(() => {
497
+ this.statement("UPDATE deliveries SET machine = substr(mailbox, 4, instr(substr(mailbox, 4), ':') - 1) WHERE machine = ''").run();
498
+ this.statement(`INSERT INTO deliveries(mailbox, message, machine, due_at_ms, attempts, record)
499
+ SELECT mailbox, id, ?, 0, 0, '{}' FROM messages WHERE kind = 'user' AND state IN ('new', 'claimed')
500
+ AND substr(mailbox, 4, instr(substr(mailbox, 4), ':') - 1) = ? AND COALESCE(json_extract(header, '$.in_reply_to'), '') NOT LIKE 'q-%'
501
+ ON CONFLICT(mailbox, message) DO NOTHING`).run(this.machine, this.machine);
502
+ this.statement('DELETE FROM delivery_targets').run();
503
+ const targets = this.statement(`SELECT DISTINCT 'mailbox' AS kind, mailbox AS target FROM deliveries WHERE machine = ?
504
+ UNION SELECT DISTINCT 'machine', machine FROM deliveries WHERE machine <> ?`).all(this.machine, this.machine);
505
+ for (const { kind, target } of targets) this.refreshDeliveryTarget(kind, target);
506
+ });
507
+ }
508
+
509
+ /** A live idle transition wakes only the native waits for those addresses. */
510
+ wakeIdleSessions(addresses) {
511
+ this.transaction(() => {
512
+ const changed = this.statement(`UPDATE deliveries SET due_at_ms = 0, record = json_set(record, '$.next_at_ms', 0)
513
+ WHERE mailbox IN (SELECT value FROM json_each(?)) AND json_extract(record, '$.waiting_for_idle') = 1
514
+ RETURNING mailbox`).all(JSON.stringify(addresses));
515
+ for (const address of new Set(changed.map((row) => row.mailbox))) this.refreshDeliveryTarget('mailbox', address);
516
+ if (changed.length) this.database.changed('wake', {});
517
+ });
518
+ }
519
+
520
+ nextDeliveryTarget(excluded = []) {
521
+ return this.statement(`SELECT t.kind, t.target, t.due_at_ms FROM delivery_targets t
522
+ WHERE NOT EXISTS (SELECT 1 FROM json_each(?) e WHERE json_extract(e.value, '$.kind') = t.kind AND json_extract(e.value, '$.target') = t.target)
523
+ ORDER BY t.due_at_ms, t.kind, t.target LIMIT 1`).get(JSON.stringify(excluded)) ?? null;
524
+ }
525
+
526
+ delayDeliveryTarget(kind, target, until) {
527
+ this.statement('UPDATE delivery_targets SET due_at_ms = max(due_at_ms, ?) WHERE kind = ? AND target = ?').run(until, kind, target);
528
+ }
529
+
530
+ dueWakes(address, now, limit = 256) {
531
+ return this.statement(`SELECT message FROM deliveries WHERE mailbox = ? AND due_at_ms <= ?
532
+ ORDER BY due_at_ms, message LIMIT ?`).all(address, now, limit).map(({ message }) => message);
533
+ }
534
+
535
+ remoteWakes(machine, now, { probing = false, limit = 256 } = {}) {
536
+ const sql = `SELECT d.mailbox AS address, d.message AS id, m.created_at_ms AS filed
537
+ FROM deliveries d JOIN messages m ON m.mailbox = d.mailbox AND m.id = d.message
538
+ WHERE d.machine = ? ${probing ? '' : 'AND d.due_at_ms <= ?'}
539
+ ORDER BY ${probing ? '' : 'd.due_at_ms, '}m.created_at_ms, d.mailbox, d.message LIMIT ?`;
540
+ return this.statement(sql).all(machine, ...(probing ? [] : [now]), limit);
395
541
  }
396
542
 
397
543
  expire(address, id, expiry) {
@@ -404,8 +550,12 @@ export class SqliteMailStore extends EventEmitter {
404
550
  #machineKey(machine) { return `${String(machine).replace(/[^A-Za-z0-9._-]/g, '_')}.json`; }
405
551
  machineWait(machine) { return this.record('machine_waits', this.#machineKey(machine)); }
406
552
  setMachineWait(machine, wait) {
407
- if (wait) this.putRecord('machine_waits', this.#machineKey(machine), wait);
408
- else this.removeRecord('machine_waits', this.#machineKey(machine));
553
+ this.transaction(() => {
554
+ if (wait) this.putRecord('machine_waits', this.#machineKey(machine), wait);
555
+ else this.removeRecord('machine_waits', this.#machineKey(machine));
556
+ this.refreshDeliveryTarget('machine', machine);
557
+ this.database.changed('wake', { machine });
558
+ });
409
559
  }
410
560
  carrierLine(line) { this.database.appendCarrier(line); }
411
561
  carrierLines(limit = 1000) {
package/mail/sqlite.mjs CHANGED
@@ -1,11 +1,14 @@
1
1
  // The machine daemon's mail database. Only its owner opens a writable handle;
2
2
  // clients use the machine socket. This module contains no harness or delivery IO.
3
3
  import { EventEmitter } from 'node:events';
4
+ import { createHash } from 'node:crypto';
4
5
  import { createRequire } from 'node:module';
5
6
  import { chmodSync, closeSync, mkdirSync, openSync } from 'node:fs';
6
7
  import { dirname } from 'node:path';
8
+ import { CHANGE_SCHEMA, ChangeStream } from './change-stream.mjs';
9
+ import { RECORD_SCHEMA } from '../record-state.mjs';
7
10
 
8
- export const MAIL_SCHEMA_VERSION = 2;
11
+ export const MAIL_SCHEMA_VERSION = 3;
9
12
  export const MAIL_RETENTION = Object.freeze({
10
13
  bodyDays: 30,
11
14
  maxReadHeaders: 1_000_000,
@@ -34,6 +37,7 @@ CREATE TABLE IF NOT EXISTS messages (
34
37
  state TEXT NOT NULL CHECK (state IN ('new', 'claimed', 'cur')),
35
38
  header TEXT NOT NULL CHECK (json_valid(header)),
36
39
  body TEXT,
40
+ body_digest TEXT,
37
41
  first_line TEXT NOT NULL,
38
42
  body_expired_at_ms INTEGER,
39
43
  read_at_ms INTEGER,
@@ -62,6 +66,7 @@ CREATE TABLE IF NOT EXISTS claims (
62
66
  CREATE TABLE IF NOT EXISTS deliveries (
63
67
  mailbox TEXT NOT NULL,
64
68
  message TEXT NOT NULL,
69
+ machine TEXT NOT NULL DEFAULT '',
65
70
  due_at_ms INTEGER NOT NULL,
66
71
  attempts INTEGER NOT NULL,
67
72
  record TEXT NOT NULL CHECK (json_valid(record)),
@@ -69,6 +74,15 @@ CREATE TABLE IF NOT EXISTS deliveries (
69
74
  FOREIGN KEY (mailbox, message) REFERENCES messages(mailbox, id) ON DELETE CASCADE
70
75
  ) STRICT;
71
76
  CREATE INDEX IF NOT EXISTS deliveries_due ON deliveries(due_at_ms, mailbox, message);
77
+ CREATE INDEX IF NOT EXISTS deliveries_machine_due ON deliveries(machine, due_at_ms, mailbox, message);
78
+ CREATE INDEX IF NOT EXISTS deliveries_mailbox_due ON deliveries(mailbox, due_at_ms, message);
79
+ CREATE TABLE IF NOT EXISTS delivery_targets (
80
+ kind TEXT NOT NULL CHECK (kind IN ('mailbox', 'machine')),
81
+ target TEXT NOT NULL,
82
+ due_at_ms INTEGER NOT NULL,
83
+ PRIMARY KEY (kind, target)
84
+ ) STRICT;
85
+ CREATE INDEX IF NOT EXISTS delivery_targets_due ON delivery_targets(due_at_ms, kind, target);
72
86
  CREATE TABLE IF NOT EXISTS subscriptions (
73
87
  mailbox TEXT NOT NULL,
74
88
  message TEXT NOT NULL,
@@ -183,20 +197,30 @@ export class MailDatabase extends EventEmitter {
183
197
  if (tables.some(({ name }) => name === 'mail_metadata')) {
184
198
  const stored = this.connection.prepare('SELECT value FROM mail_metadata WHERE key = ?').get('schema_version');
185
199
  storedVersion = stored ? JSON.parse(stored.value) : null;
186
- if (storedVersion !== 1 && storedVersion !== MAIL_SCHEMA_VERSION) throw new Error(`mail schema ${storedVersion} is not supported by this release`);
200
+ if (![1, 2, MAIL_SCHEMA_VERSION].includes(storedVersion)) throw new Error(`mail schema ${storedVersion} is not supported by this release`);
187
201
  } else if (tables.length) throw new Error(`the database at ${path} has no mail schema receipt; its schema has not been changed`);
188
202
  const mode = this.connection.prepare('PRAGMA journal_mode = WAL').get();
189
203
  if (String(mode.journal_mode).toLowerCase() !== 'wal') throw new Error(`mail database cannot use WAL at ${path}`);
190
204
  this.connection.exec('PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 2000;');
205
+ this.connection.function('supercode_sha256', { deterministic: true }, (body) => typeof body === 'string' ? createHash('sha256').update(body).digest('hex') : null);
191
206
  this.transaction(() => {
207
+ if (storedVersion != null && !this.connection.prepare('PRAGMA table_info(messages)').all().some(({ name }) => name === 'body_digest')) {
208
+ this.connection.exec('ALTER TABLE messages ADD COLUMN body_digest TEXT;');
209
+ }
210
+ if ([1, 2].includes(storedVersion) && !this.connection.prepare('PRAGMA table_info(deliveries)').all().some(({ name }) => name === 'machine')) {
211
+ this.connection.exec("ALTER TABLE deliveries ADD COLUMN machine TEXT NOT NULL DEFAULT '';");
212
+ }
192
213
  this.connection.exec(SCHEMA);
193
214
  // The private first candidate stored only the four legacy send fields.
194
215
  // Add the complete record before publishing the upgraded schema receipt.
195
216
  if (storedVersion === 1 && !this.connection.prepare('PRAGMA table_info(sent_messages)').all().some(({ name }) => name === 'record')) {
196
217
  this.connection.exec("ALTER TABLE sent_messages ADD COLUMN record TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(record) AND json_type(record) = 'object');");
197
218
  }
219
+ this.connection.exec(RECORD_SCHEMA);
220
+ this.connection.exec(CHANGE_SCHEMA);
198
221
  this.setMetadata('schema_version', MAIL_SCHEMA_VERSION);
199
222
  });
223
+ this.changes = new ChangeStream(this);
200
224
  } catch (error) {
201
225
  this.connection.close();
202
226
  throw error;
@@ -205,7 +229,18 @@ export class MailDatabase extends EventEmitter {
205
229
 
206
230
  statement(sql) {
207
231
  if (this.closed) throw new Error('the mail database is closed; no write or native action is resumed');
208
- if (!this.statements.has(sql)) this.statements.set(sql, this.connection.prepare(sql));
232
+ if (!this.statements.has(sql)) {
233
+ const statement = this.connection.prepare(sql);
234
+ this.statements.set(sql, new Proxy(statement, { get: (target, key) => {
235
+ if (key === 'run') return (...args) => {
236
+ const value = target.run(...args);
237
+ if (!this.depth && value.changes) this.#notify('commit', null);
238
+ return value;
239
+ };
240
+ const value = target[key];
241
+ return typeof value === 'function' ? value.bind(target) : value;
242
+ } }));
243
+ }
209
244
  return this.statements.get(sql);
210
245
  }
211
246
 
@@ -230,6 +265,7 @@ export class MailDatabase extends EventEmitter {
230
265
  if (!depth) {
231
266
  const events = this.pending.splice(0);
232
267
  for (const event of events) this.#notify(event.topic, event.key);
268
+ this.#notify('commit', null);
233
269
  }
234
270
  return value;
235
271
  }
@@ -241,6 +277,7 @@ export class MailDatabase extends EventEmitter {
241
277
  }
242
278
 
243
279
  #notify(topic, key) {
280
+ if (!this.listenerCount(topic)) return;
244
281
  // A caller may set the delivery's grace deadline immediately after creating
245
282
  // its wake. Notify after that write, as the former filesystem event did.
246
283
  setImmediate(() => { if (!this.closed) this.emit(topic, key); });
@@ -299,7 +336,7 @@ export class MailDatabase extends EventEmitter {
299
336
  retain(now = Date.now()) {
300
337
  const cutoff = now - this.retention.bodyDays * 86_400_000;
301
338
  return this.transaction(() => {
302
- const expired = this.statement(`UPDATE messages SET body = NULL, first_line = '', body_expired_at_ms = ?
339
+ const expired = this.statement(`UPDATE messages SET body_digest = coalesce(body_digest, supercode_sha256(body)), body = NULL, first_line = '', body_expired_at_ms = ?
303
340
  WHERE state = 'cur' AND created_at_ms < ? AND body IS NOT NULL
304
341
  AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.mailbox = messages.mailbox AND d.message = messages.id)
305
342
  AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.mailbox = messages.mailbox AND s.message = messages.id)`)
@@ -345,6 +382,7 @@ export class MailDatabase extends EventEmitter {
345
382
 
346
383
  close() {
347
384
  if (this.closed) return;
385
+ this.changes?.stop();
348
386
  this.closed = true;
349
387
  this.removeAllListeners();
350
388
  this.statements.clear();