Files
webmail/lib/store/mail/methods.ts
T

274 lines
12 KiB
TypeScript

/** Mail reads answered from the store. */
import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area';
import { listObjects } from '../engine/core-schema';
import type { SqlValue } from '../storage';
import { textCondition, type TextField } from './search';
type Filter = Record<string, unknown>;
interface Sql { sql: string; params: SqlValue[] }
const TEXT_FIELDS: readonly TextField[] = ['text', 'subject', 'from', 'to', 'body'];
function jmapOf(ctx: MethodContext, args: Record<string, unknown>): string {
return (args.accountId as string | undefined) ?? ctx.defaultJmap;
}
function filterSql(filter: Filter, jmap: string): Sql {
if (typeof filter.operator === 'string') {
const conditions = Array.isArray(filter.conditions) ? filter.conditions as Filter[] : [];
const parts = conditions.map((c) => filterSql(c, jmap));
const params = parts.flatMap((p) => p.params);
const joined = (joiner: string) => parts.map((p) => `(${p.sql})`).join(joiner);
if (filter.operator === 'AND') return { sql: parts.length ? joined(' AND ') : '1', params };
if (filter.operator === 'OR') return { sql: parts.length ? joined(' OR ') : '0', params };
if (filter.operator === 'NOT') return { sql: parts.length ? `NOT (${joined(' OR ')})` : '1', params };
throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`);
}
const conditions: string[] = [];
const params: SqlValue[] = [];
for (const [key, value] of Object.entries(filter)) {
switch (key) {
case 'inMailbox':
conditions.push('EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = e.rowid AND m.mailbox_id = ?)');
params.push(String(value));
break;
case 'inMailboxOtherThan': {
const ids = (value as string[]).map(String);
conditions.push(`EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = e.rowid AND m.mailbox_id NOT IN (${ids.map(() => '?').join(',') || "''"}))`);
params.push(...ids);
break;
}
case 'hasKeyword':
conditions.push('EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = e.rowid AND k.keyword = ?)');
params.push(String(value).toLowerCase());
break;
case 'notKeyword':
conditions.push('NOT EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = e.rowid AND k.keyword = ?)');
params.push(String(value).toLowerCase());
break;
case 'after':
conditions.push('e.received_at >= ?');
params.push(Date.parse(String(value)) || 0);
break;
case 'before':
conditions.push('e.received_at < ?');
params.push(Date.parse(String(value)) || 0);
break;
case 'minSize':
conditions.push('e.size >= ?');
params.push(Number(value));
break;
case 'maxSize':
conditions.push('e.size < ?');
params.push(Number(value));
break;
case 'hasAttachment':
conditions.push(value ? 'e.has_attachment = 1' : 'e.has_attachment = 0');
break;
case 'id':
conditions.push(`e.id IN (${(value as string[]).map(() => '?').join(',') || "''"})`);
params.push(...(value as string[]).map(String));
break;
default:
if ((TEXT_FIELDS as readonly string[]).includes(key)) {
const text = textCondition(key as TextField, String(value));
conditions.push(text.sql);
params.push(...text.params);
break;
}
// cc, bcc, header and the thread keyword filters: the server's job.
throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
}
}
return { sql: conditions.length ? conditions.join(' AND ') : '1', params };
}
interface Comparator { property?: string; isAscending?: boolean; keyword?: string }
function orderSql(ctx: MethodContext, sort: Comparator[]): Sql {
const levels = sort.length > 0 ? sort : [{ property: 'receivedAt', isAscending: false }];
const parts: string[] = [];
const params: SqlValue[] = [];
for (const level of levels) {
// RFC 8621 says isAscending defaults to true.
let ascending = level.isAscending !== false;
switch (level.property) {
case 'receivedAt': parts.push(`e.received_at ${ascending ? 'ASC' : 'DESC'}`); break;
case 'sentAt': parts.push(`COALESCE(e.sent_at, e.received_at) ${ascending ? 'ASC' : 'DESC'}`); break;
case 'size': parts.push(`e.size ${ascending ? 'ASC' : 'DESC'}`); break;
case 'subject': parts.push(`e.subject_sort COLLATE NOCASE ${ascending ? 'ASC' : 'DESC'}`); break;
case 'from': parts.push(`e.from_sort COLLATE NOCASE ${ascending ? 'ASC' : 'DESC'}`); break;
case 'to': parts.push(`e.to_sort COLLATE NOCASE ${ascending ? 'ASC' : 'DESC'}`); break;
case 'hasKeyword': {
// The client flips this on servers that read it the other way, so a
// stored answer has to read it the way that server would.
if (ctx.invertedKeywordSort) ascending = !ascending;
parts.push(`EXISTS (SELECT 1 FROM email_keyword s WHERE s.email_rowid = e.rowid AND s.keyword = ?) ${ascending ? 'ASC' : 'DESC'}`);
params.push(String(level.keyword ?? '').toLowerCase());
break;
}
default:
throw new MethodError('unsupportedSort', `${String(level.property)} isn't available offline`);
}
}
parts.push('e.rowid DESC');
return { sql: parts.join(', '), params };
}
/** The one folder a filter limits itself to, so the query can start from that folder's index. */
function drivingMailbox(filter: Filter | undefined): string | null {
if (!filter) return null;
if (typeof filter.inMailbox === 'string') return filter.inMailbox;
if (filter.operator === 'AND' && Array.isArray(filter.conditions)) {
for (const condition of filter.conditions as Filter[]) {
const found = drivingMailbox(condition);
if (found) return found;
}
}
return null;
}
const emailQuery: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const filter = (args.filter ?? undefined) as Filter | undefined;
if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server');
const where = filter ? filterSql(filter, jmap) : { sql: '1', params: [] };
const order = orderSql(ctx, (args.sort as Comparator[]) ?? []);
const box = drivingMailbox(filter);
// Starting from the folder's own index keeps small folders fast in a large account.
const from = box
? 'email_mailbox d INDEXED BY email_mailbox_list CROSS JOIN email e ON e.rowid = d.email_rowid'
: 'email e';
const base = box
? { sql: 'd.jmap = ? AND d.mailbox_id = ?', params: [jmap, box] as SqlValue[] }
: { sql: 'e.jmap = ?', params: [jmap] as SqlValue[] };
const position = Math.max(0, Number(args.position ?? 0));
const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : -1;
const collapse = Boolean(args.collapseThreads);
const group = collapse ? 'GROUP BY COALESCE(e.thread_id, e.id)' : '';
const ids = ctx.db.all<{ id: string }>(
`SELECT e.id FROM ${from} WHERE ${base.sql} AND (${where.sql}) ${group} ORDER BY ${order.sql} LIMIT ? OFFSET ?`,
[...base.params, ...where.params, ...order.params, limit, position],
).map((row) => String(row.id));
const result: MethodResult = {
accountId: jmap,
canCalculateChanges: false,
// Local answers aren't a position in the server's log, so this is a token only.
queryState: 'local',
position,
ids,
};
if (args.calculateTotal) {
const count = collapse ? 'COUNT(DISTINCT COALESCE(e.thread_id, e.id))' : 'COUNT(*)';
result.total = Number(ctx.db.get<{ n: number }>(
`SELECT ${count} AS n FROM ${from} WHERE ${base.sql} AND (${where.sql})`,
[...base.params, ...where.params],
)?.n ?? 0);
}
return result;
};
const BODY_PROPERTIES = new Set(['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments', 'bcc', 'replyTo', 'messageId', 'inReplyTo', 'references']);
function pick(email: Record<string, unknown>, properties: string[] | undefined): Record<string, unknown> {
if (!properties) return email;
const out: Record<string, unknown> = { id: email.id };
for (const property of properties) if (property in email) out[property] = email[property];
return out;
}
const emailGet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
if (!Array.isArray(args.ids)) throw new MethodError('requestTooLarge', 'ids: null needs the server');
const properties = args.properties as string[] | undefined;
const wantsValues = Boolean(args.fetchTextBodyValues || args.fetchHTMLBodyValues || args.fetchAllBodyValues) || Boolean(properties?.includes('bodyValues'));
const wantsBody = wantsValues || !properties || properties.some((p) => BODY_PROPERTIES.has(p));
const list: Record<string, unknown>[] = [];
const notFound: string[] = [];
for (const id of args.ids as string[]) {
const row = ctx.db.get<{ rowid: number; json: string; body_state: number }>(
'SELECT rowid, json, body_state FROM email WHERE jmap = ? AND id = ?',
[jmap, id],
);
if (!row) {
notFound.push(id);
continue;
}
const email = JSON.parse(row.json) as Record<string, unknown>;
if (!wantsBody) {
list.push(pick(email, properties));
continue;
}
const body = ctx.db.get<{ json: string }>('SELECT json FROM email_body WHERE email_rowid = ?', [row.rowid]);
// Asked for the text and it isn't here: as if the message weren't there, not an empty message.
if (!body && wantsValues) {
notFound.push(id);
continue;
}
list.push(pick({ ...(body ? JSON.parse(body.json) as object : {}), ...email }, properties));
}
const state = ctx.db.get<{ state: string | null }>("SELECT state FROM sync_state WHERE jmap = ? AND type = 'Email'", [jmap])?.state;
return { accountId: jmap, ...(state ? { state } : {}), list, notFound };
};
const threadGet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const ids = Array.isArray(args.ids) ? [...new Set(args.ids as string[])] : [];
const list: Array<{ id: string; emailIds: string[] }> = [];
const notFound: string[] = [];
for (const id of ids) {
const stored = ctx.db.get<{ email_ids: string }>('SELECT email_ids FROM thread WHERE jmap = ? AND id = ?', [jmap, id]);
if (stored) {
list.push({ id, emailIds: JSON.parse(stored.email_ids) as string[] });
continue;
}
const rows = ctx.db.all<{ id: string }>('SELECT id FROM email WHERE jmap = ? AND thread_id = ? ORDER BY received_at ASC', [jmap, id]);
if (rows.length > 0) list.push({ id, emailIds: rows.map((row) => String(row.id)) });
else notFound.push(id);
}
return { accountId: jmap, list, notFound };
};
const mailboxGet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const boxes = listObjects<Record<string, unknown>>(ctx.db, jmap, 'Mailbox');
if (boxes.length === 0) throw new MethodError('serverUnavailable', 'Folders not kept yet');
const ids = Array.isArray(args.ids) ? new Set(args.ids as string[]) : null;
const chosen = ids ? boxes.filter((box) => ids.has(box.id as string)) : boxes;
const state = ctx.db.get<{ state: string | null }>("SELECT state FROM sync_state WHERE jmap = ? AND type = 'Mailbox'", [jmap])?.state;
return {
accountId: jmap,
...(state ? { state } : {}),
list: chosen.map((box) => pick(box, args.properties as string[] | undefined)),
notFound: ids ? [...ids].filter((id) => !boxes.some((box) => box.id === id)) : [],
};
};
const mailboxQuery: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const boxes = listObjects<Record<string, unknown>>(ctx.db, jmap, 'Mailbox');
if (boxes.length === 0) throw new MethodError('serverUnavailable', 'Folders not kept yet');
const filter = (args.filter ?? {}) as { role?: string };
const chosen = filter.role ? boxes.filter((box) => box.role === filter.role) : boxes;
const position = typeof args.position === 'number' ? args.position : 0;
const limit = typeof args.limit === 'number' ? args.limit : chosen.length;
return {
accountId: jmap,
queryState: 'local',
canCalculateChanges: false,
position,
ids: chosen.slice(position, position + limit).map((box) => box.id),
...(args.calculateTotal ? { total: chosen.length } : {}),
};
};
export const MAIL_METHODS: Readonly<Record<string, MethodHandler>> = {
'Email/query': emailQuery,
'Email/get': emailGet,
'Thread/get': threadGet,
'Mailbox/get': mailboxGet,
'Mailbox/query': mailboxQuery,
};