the contact inbox is a mailbox, and it is copied the moment it changes

Messages are the one thing that happens between publishes. A backup triggered by
publishing would never contain them, and a RESTORE would destroy every message
received since the dump being restored — turning a routine recovery into losing
the only copy of something a stranger sent, with nothing about the operation
looking like it had done that.

So they live in their own SQLite file, in the shape Officer's email plugin
already uses. Not a schema that fits a contact form: this IS mail — a from, a
subject, a date and a body wearing a form — and storing it as a mailbox means
the mail client can open it, and anybody's agent already knows how to read it
without being told. The form's own fields become labelled lines in text_body,
where a reader and a grep both find them; inventing columns would be the moment
it stopped being a mailbox.

This side watches the file and copies it within seconds of a write, to the local
backup directory and to the machine off this one. The alternative is a window: a
message at ten past nine and a disk that fails at twenty past is a message
nobody sees, while the sender believes they contacted you.

Two details are the whole reason this is not a `cp`. The WAL is checkpointed
first — the site writes in WAL mode, so a new message lives in messages.db-wal
and not yet in messages.db, and copying the main file alone yields a database
that is intact, readable, and missing exactly the message that triggered the
copy. And the DIRECTORY is watched rather than the file, because SQLite replaces
files during a checkpoint and a watch on a replaced inode goes quiet for good —
a watcher that silently stops is worse than none, since the panel would go on
implying the inbox is protected.

The copy is current, not history; the ordinary backup runs take a timestamped
one alongside the dumps, so a restored site can have the inbox that matches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:27:36 +01:00
co-authored by Claude Opus 5
parent 9ff25256c1
commit fb234ede5d
3 changed files with 171 additions and 1 deletions
+10
View File
@@ -10,6 +10,7 @@ import { readRuns } from './ledger';
import { reconcile } from './derive';
import { createDatabases, pushSchema, readDatabases } from './databases';
import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups';
import { watchMailbox } from './messages';
import { join } from 'node:path';
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
@@ -509,3 +510,12 @@ router.all('/*', async (ctx) => {
where somebody would go to fix it.
*/
void schedule().catch(() => {});
/*
Watch the contact inbox and copy it whenever it changes.
Separate from the backup schedule on purpose: messages arrive between publishes, so they are the one
thing a publish-triggered backup would never contain. Not awaited, and failures swallowed, for the
same reason as above.
*/
void watchMailbox().catch(() => {});