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
+25 -1
View File
@@ -1,4 +1,4 @@
import { mkdir, readdir, stat, unlink } from 'node:fs/promises';
import { copyFile, mkdir, readdir, stat, unlink } from 'node:fs/promises';
import { isAbsolute, join } from 'node:path';
import { CronJob, CronTime, validateCronExpression } from 'cron';
import { db } from 'officerdb/db';
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
import { OFFICER_ROOT } from '@@/data-path';
import { fofosAdminSettings } from '../db/schema';
import { readDatabases } from './databases';
import { messagesFile } from './messages';
/**
* Backups of the site's databases: when, where, where else, and how many to keep.
@@ -304,6 +305,29 @@ export async function runBackup(): Promise<{ ok: boolean; files: string[]; outpu
}
}
/*
The mailbox, timestamped, alongside the dumps.
The watcher keeps a CURRENT copy at each destination and that is what protects a message minutes
after it arrives. This is the history: without it, restoring last week's site would come with this
morning's inbox and no way back to the one that matches.
*/
if (row?.rootPath) {
const mailbox = messagesFile(row.rootPath);
if (await stat(mailbox).catch(() => null)) {
const copy = join(dir, `messages-${stamp}.db`);
await copyFile(mailbox, copy).catch(() => {});
lines.push(`messages-${stamp}.db`);
if (row.backupSshHost && row.backupSshPath) {
const sent = await run(['scp', ...SSH_OPTIONS, copy, `${row.backupSshHost}:${row.backupSshPath}/`]);
if (!sent.ok) {
ok = false;
lines.push(` -> COPY FAILED: ${sent.output}`);
}
}
}
}
if (row?.backupKeep) lines.push(...(await prune(row.backupKeep, dir, row.backupSshHost, row.backupSshPath)));
/*