Files
webmail/lib/store/mail/schema.ts
T
Linus Rath 5c5d982f9d feat: save drafts and keep attachments while offline
A draft saved offline now stays on the device under a stand-in id and
goes to the server by itself when it's back. Until now every save was
refused and the composer showed "not saved".

- `lib/store/drafts`: one pending create per draft, however often it's
  saved. A draft from the server edited offline is replaced by create,
  then destroy, so a failure leaves a copy. Each create has a fixed
  Message-ID, looked up in Drafts after a lost response. A refused
  draft stays on the device with its error and can be opened. Changed
  on another device: both are kept and the user is told.
- `JMAPClient`: stand-in ids in Email/get and Email/set resolve like
  calendar's, also when answering from the device. A file attached
  offline stays readable, and is uploaded before a save that names it.
- Held files and kept mail files are stored in 1 MB chunks. Orphans are
  dropped at the end of a flush, counting what drafts need.
- `lib/store/mail/attachments.ts`: attachments, inline images and raw
  messages per scope, newest first, after everything else in a pass,
  with eviction that spares pinned mail and never sees held files.
- `mail.rawBlob` and `mail.rawStatus` for an offline export.

Changed test assertions: `outbox.heldFile` and `mail.getBlob` return
`blob` instead of `data`, so a file isn't held whole in memory. The
attachment tests ignore raw downloads, and mail-sync runs with
attachments off, because raw messages add a commit per message.
2026-11-03 08:49:19 +01:00

114 lines
3.9 KiB
TypeScript

import type { Migration } from '../storage';
export const MAIL_MIGRATIONS: readonly Migration[] = [
{
id: 100,
name: 'mail',
sql: `
CREATE TABLE email (
rowid INTEGER PRIMARY KEY,
jmap TEXT NOT NULL,
id TEXT NOT NULL,
thread_id TEXT,
received_at INTEGER NOT NULL,
sent_at INTEGER,
size INTEGER NOT NULL DEFAULT 0,
has_attachment INTEGER NOT NULL DEFAULT 0,
subject_sort TEXT,
from_sort TEXT,
to_sort TEXT,
json TEXT NOT NULL,
-- 0 no body yet, 1 kept, 2 the server had none to give
body_state INTEGER NOT NULL DEFAULT 0,
gen INTEGER NOT NULL DEFAULT 0,
UNIQUE (jmap, id)
);
CREATE INDEX email_received ON email (jmap, received_at DESC, rowid DESC);
CREATE INDEX email_thread ON email (jmap, thread_id);
CREATE INDEX email_backfill ON email (jmap, body_state, received_at DESC);
CREATE TABLE email_mailbox (
email_rowid INTEGER NOT NULL REFERENCES email (rowid) ON DELETE CASCADE,
jmap TEXT NOT NULL,
mailbox_id TEXT NOT NULL,
received_at INTEGER NOT NULL,
PRIMARY KEY (email_rowid, mailbox_id)
) WITHOUT ROWID;
CREATE INDEX email_mailbox_list ON email_mailbox (jmap, mailbox_id, received_at DESC, email_rowid DESC);
CREATE TABLE email_keyword (
email_rowid INTEGER NOT NULL REFERENCES email (rowid) ON DELETE CASCADE,
keyword TEXT NOT NULL,
PRIMARY KEY (email_rowid, keyword)
) WITHOUT ROWID;
CREATE INDEX email_keyword_lookup ON email_keyword (keyword, email_rowid);
CREATE TABLE email_body (
email_rowid INTEGER PRIMARY KEY REFERENCES email (rowid) ON DELETE CASCADE,
json TEXT NOT NULL
);
CREATE TABLE thread (
jmap TEXT NOT NULL,
id TEXT NOT NULL,
email_ids TEXT NOT NULL,
PRIMARY KEY (jmap, id)
) WITHOUT ROWID;
CREATE VIRTUAL TABLE email_fts_word USING fts5(
subject, from_addr, to_addr, body,
content = '', contentless_delete = 1,
tokenize = 'unicode61 remove_diacritics 2'
);
CREATE VIRTUAL TABLE email_fts_tri USING fts5(
subject, from_addr, to_addr, body,
content = '', contentless_delete = 1,
tokenize = 'trigram remove_diacritics 1'
);
`,
},
{
id: 101,
name: 'mail attachments',
sql: `
-- The attachments and inline images of kept messages.
CREATE TABLE email_part (
jmap TEXT NOT NULL,
blob_id TEXT NOT NULL,
email_rowid INTEGER NOT NULL,
received_at INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
type TEXT,
name TEXT,
-- 1 when the server wouldn't give it: not asked for again
refused INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (jmap, blob_id, email_rowid)
) WITHOUT ROWID;
CREATE INDEX email_part_message ON email_part (email_rowid);
CREATE INDEX email_part_newest ON email_part (jmap, received_at DESC);
CREATE TABLE blob (
jmap TEXT NOT NULL,
blob_id TEXT NOT NULL,
type TEXT,
size INTEGER NOT NULL,
at INTEGER NOT NULL,
data BLOB NOT NULL,
PRIMARY KEY (jmap, blob_id)
) WITHOUT ROWID;
`,
},
// In the drafts range: this came with drafts and attachments.
{
id: 410,
name: 'kept files in chunks, raw messages',
sql: `
-- Binding a whole file grows the WASM heap by its size, and that never shrinks.
-- A file counts as kept once its row in blob exists.
CREATE TABLE blob_chunk (
jmap TEXT NOT NULL,
blob_id TEXT NOT NULL,
seq INTEGER NOT NULL,
data BLOB NOT NULL,
PRIMARY KEY (jmap, blob_id, seq)
);
-- 1 for the whole message (Email.blobId), for an export.
ALTER TABLE email_part ADD COLUMN raw INTEGER NOT NULL DEFAULT 0;
`,
},
];