Files
native/src/api/email.ts
T

2523 lines
101 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { jmapClient } from './jmap-client';
import {
assertSetResult, batched, failedIdsOf, JMAPSetError, NoLongerScheduledError, parseHoldLimit, RecipientsRefusedError,
requireMethodResult, ScheduleTooLateError, type RefusedRecipient,
} from './jmap-result';
import { keywordPointer, mailboxPointer } from './patch-pointer';
import {
deliveryStatusCall, destroyEmailSubmissions, isStalwartAccount, makeRoomForSubmission, readSubmissionDestroy,
refusedRecipients, submissionAccountIds, submissionCreateError, submissionDestroyCall, submissionQuotaError,
SUBMISSION_ONLY_USING, SUBMISSION_SCAN_MAX_PAGES,
} from './submissions';
import { CAPABILITIES } from './types';
import type { Attachment, Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
import { textFilter } from '../lib/search-utils';
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
import { buildMdnMessage, type MdnOptions } from '../lib/mdn';
import { hasTruncatedDisplayedBody } from '../lib/email-body';
import { withInlineBodyParts } from '../lib/email-inline-parts';
import { stalwartStateWentBack } from './stalwart-state';
import { isTransientNetworkError } from '../lib/network-error';
export const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
];
// The viewer derives reply threading (In-Reply-To/References), Reply-To
// handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests
// from these; without `messageId`/`headers` none of that can work.
export const EMAIL_FULL_PROPERTIES = [
...EMAIL_LIST_PROPERTIES,
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
'messageId', 'inReplyTo', 'references', 'headers',
];
// The Mailbox properties the folder list reads (RFC 8621 §2), the default
// list on 0.16. Stalwart 1.0.0 adds JMAP Metadata's `metadata` and
// `privateMetadata` to the default (the pre-release's handlers panicked on
// them); the app reads neither, so an explicit list keeps them out.
export const MAILBOX_PROPERTIES = [
'id', 'name', 'parentId', 'role', 'sortOrder', 'totalEmails', 'unreadEmails',
'totalThreads', 'unreadThreads', 'myRights', 'isSubscribed',
];
const SUBMISSION_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION];
// Prefix a shared account's folder ids so they can't collide with the user's
// own. Matches the webmail's `${accountId}:${mailboxId}` scheme.
function sharedMailboxId(accountId: string, rawId: string): string {
return `${accountId}:${rawId}`;
}
/** Inverse of {@link sharedMailboxId}; a no-op for the user's own folders. */
export function unprefixMailboxId(id: string, accountId?: string): string {
if (!accountId) return id;
const prefix = `${accountId}:`;
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
}
// Stamp ownership onto a raw Mailbox/get result. Own folders keep their raw
// ids; a shared account's are prefixed and remember their `originalId` so
// server calls can still address them.
function tagMailbox(
mailbox: Mailbox,
accountId: string,
accountName: string | undefined,
isShared: boolean,
): Mailbox {
if (!isShared) {
return { ...mailbox, accountId, accountName, isShared: false };
}
return {
...mailbox,
id: sharedMailboxId(accountId, mailbox.id),
originalId: mailbox.id,
parentId: mailbox.parentId ? sharedMailboxId(accountId, mailbox.parentId) : mailbox.parentId,
accountId,
accountName,
isShared: true,
};
}
function maxInGet(): number {
return typeof jmapClient.getMaxObjectsInGet === 'function' ? jmapClient.getMaxObjectsInGet() : 500;
}
function maxInSet(): number {
return typeof jmapClient.getMaxObjectsInSet === 'function' ? jmapClient.getMaxObjectsInSet() : 500;
}
// A /changes or /queryChanges answer whose state went back below the one it
// was asked from (see stalwartStateWentBack). The server then skipped every
// change since, so the caller takes it like cannotCalculateChanges and reads
// in full. Only Stalwart's states are read, on accounts that list its
// extension.
function stateWentBack(accountId: string, sinceState: string, newState: unknown): boolean {
if (typeof jmapClient.hasAccountCapability !== 'function') return false;
if (!jmapClient.hasAccountCapability('urn:stalwart:jmap', accountId)) return false;
if (!stalwartStateWentBack(sinceState, newState)) return false;
console.warn(`[email] ${accountId}: state went back from ${sinceState} to ${String(newState)}, reading in full`);
return true;
}
/**
* Run one `Email/set` per `maxObjectsInSet`-sized slice of `update` /
* `destroy`, checking every slice for `notUpdated` / `notDestroyed`. Over-limit
* requests fail whole (RFC 8620 §3.6.1), so a long multi-select or a big
* offline replay must be split before it is sent. Every slice is sent before
* the first refusal is thrown: a refused message (Stalwart 1.0 refuses
* keywords over 128 bytes and more than 100 keywords on one message) must not
* leave the rest of a selection unchanged.
*/
async function emailSetBatched(
accountId: string,
args: { update?: Record<string, Record<string, unknown>>; destroy?: string[] },
what = 'email',
): Promise<{ updated: string[]; destroyed: string[] }> {
const out = { updated: [] as string[], destroyed: [] as string[] };
const size = maxInSet();
let refusal: unknown = null;
// Every batch's refusals, named on the error thrown: the rest was written.
const failed: string[] = [];
const check = (body: Record<string, any>, ids: string[]) => {
try {
assertSetResult(body, ids, what);
} catch (err) {
refusal ??= err;
failed.push(...(failedIdsOf(err) ?? []));
}
};
const updateEntries = Object.entries(args.update ?? {});
for (const slice of batched(updateEntries, size)) {
const res = await jmapClient.request([
['Email/set', { accountId, update: Object.fromEntries(slice) }, '0'],
]);
const body = requireMethodResult(res, '0', 'Email/set');
check(body, slice.map(([id]) => id));
out.updated.push(...Object.keys(body.updated ?? {}));
}
for (const slice of batched(args.destroy ?? [], size)) {
const res = await jmapClient.request([['Email/set', { accountId, destroy: slice }, '0']]);
const body = requireMethodResult(res, '0', 'Email/set');
check(body, slice);
out.destroyed.push(...((body.destroyed as string[] | undefined) ?? []));
}
if (refusal) {
if (refusal instanceof JMAPSetError) refusal.failedIds = failed;
throw refusal;
}
return out;
}
export async function getMailboxes(accountId?: string): Promise<Mailbox[]> {
return (await getMailboxesWithState(accountId)).list;
}
// Variant that also returns the JMAP `state` token so callers can later issue
// Mailbox/changes(sinceState=…) to fetch only what changed.
export async function getMailboxesWithState(
accountIdOverride?: string,
): Promise<{ list: Mailbox[]; state: string }> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const isShared = accountId !== jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId, properties: MAILBOX_PROPERTIES }, '0']],
);
const body = requireMethodResult(res, '0', 'Mailbox/get');
const list = ((body.list as Mailbox[]) ?? []).map((m) =>
tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared),
);
return { list, state: body.state as string };
}
/**
* Every folder of every shared/group account in this session, ready to merge
* into the sidebar next to the user's own. One request carrying a Mailbox/get
* per account, chunked to the server's maxCallsInRequest. An account that
* errors (revoked access, server hiccup) is skipped rather than sinking the
* whole list.
*/
export async function getSharedMailboxes(): Promise<Mailbox[]> {
const accounts = jmapClient.getSharedMailAccounts();
if (accounts.length === 0) return [];
const perRequest = Math.max(1, jmapClient.getMaxCallsInRequest());
const out: Mailbox[] = [];
for (let i = 0; i < accounts.length; i += perRequest) {
const chunk = accounts.slice(i, i + perRequest);
const res = await jmapClient.request(
chunk.map((acc, idx): JMAPMethodCall => [
'Mailbox/get',
{ accountId: acc.id, properties: MAILBOX_PROPERTIES },
String(idx),
]),
);
for (const [name, body, callId] of res.methodResponses) {
if (name !== 'Mailbox/get') continue;
const acc = chunk[Number(callId)];
if (!acc) continue;
for (const m of (body.list as Mailbox[]) ?? []) {
out.push(tagMailbox(m, acc.id, acc.name, true));
}
}
}
return out;
}
export async function getMailboxesByIds(
ids: string[],
accountIdOverride?: string,
): Promise<{ list: Mailbox[]; state: string }> {
if (ids.length === 0) return { list: [], state: '' };
const accountId = accountIdOverride ?? jmapClient.accountId;
const isShared = accountId !== jmapClient.accountId;
const list: Mailbox[] = [];
let state = '';
for (const slice of batched(ids, maxInGet())) {
const res = await jmapClient.request(
[['Mailbox/get', { accountId, ids: slice, properties: MAILBOX_PROPERTIES }, '0']],
);
const body = requireMethodResult(res, '0', 'Mailbox/get');
for (const m of (body.list as Mailbox[]) ?? []) {
list.push(tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared));
}
state = body.state as string;
}
return { list, state };
}
export interface MailboxChangesResult {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
}
// Returns null when the server can't compute the diff (typically
// `cannotCalculateChanges`, or a state that went back); callers should fall
// back to a full Mailbox/get.
export async function getMailboxChanges(
sinceState: string,
accountIdOverride?: string,
): Promise<MailboxChangesResult | null> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/changes', { accountId, sinceState }, '0']],
);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
if (stateWentBack(accountId, sinceState, body.newState)) return null;
return {
oldState: body.oldState as string,
newState: body.newState as string,
hasMoreChanges: Boolean(body.hasMoreChanges),
created: (body.created as string[]) ?? [],
updated: (body.updated as string[]) ?? [],
destroyed: (body.destroyed as string[]) ?? [],
};
}
export async function createMailbox(
data: { name: string; parentId?: string | null; role?: string | null },
accountIdOverride?: string,
): Promise<string> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const cid = 'new-mailbox';
// Subscribe explicitly: IMAP clients that list folders via LSUB
// (Thunderbird) hide unsubscribed mailboxes, and the server default is
// not guaranteed to be true. (#951)
const create: Record<string, unknown> = {
name: data.name,
parentId: data.parentId ?? null,
isSubscribed: true,
};
if (data.role !== undefined) create.role = data.role;
const res = await jmapClient.request([
['Mailbox/set', { accountId, create: { [cid]: create } }, '0'],
]);
const result = requireMethodResult(res, '0', 'Mailbox/set');
if (result.created?.[cid]?.id) return result.created[cid].id as string;
const failure = result.notCreated?.[cid] as
| { type?: string; description?: string; properties?: string[] }
| undefined;
throw new Error(
failure
? `${failure.type ?? 'create failed'}${failure.description ? `: ${failure.description}` : ''}`
: 'Mailbox create returned no id',
);
}
export async function updateMailbox(
id: string,
changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number },
accountIdOverride?: string,
): Promise<void> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, update: { [id]: changes } }, '0'],
]);
const body = requireMethodResult(res, '0', 'Mailbox/set');
const failure = body.notUpdated?.[id] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(
`${failure.type ?? 'update failed'}${failure.description ? `: ${failure.description}` : ''}`,
);
}
}
export async function deleteMailbox(
id: string,
accountIdOverride?: string,
opts?: { onDestroyRemoveEmails?: boolean },
): Promise<void> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const args: Record<string, unknown> = { accountId, destroy: [id] };
if (opts?.onDestroyRemoveEmails) args.onDestroyRemoveEmails = true;
const res = await jmapClient.request([['Mailbox/set', args, '0']]);
const body = requireMethodResult(res, '0', 'Mailbox/set');
const failure = body.notDestroyed?.[id] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(
`${failure.type ?? 'delete failed'}${failure.description ? `: ${failure.description}` : ''}`,
);
}
}
/**
* Destroy every message in a folder (Empty Trash / Empty Junk), in batches of
* at most `maxObjectsInSet`. Never gates on `Email/query.total`: it is only
* guaranteed with `calculateTotal`, and Stalwart omits it otherwise, which
* used to stop after the first batch (#711). Returns the number destroyed.
*/
export async function emptyMailbox(mailboxId: string, accountIdOverride?: string): Promise<number> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const batchSize = Math.min(500, maxInSet());
let totalDestroyed = 0;
for (;;) {
const res = await jmapClient.request([
['Email/query', { accountId, filter: { inMailbox: mailboxId }, limit: batchSize }, '0'],
['Email/set', { accountId, '#destroy': { resultOf: '0', name: 'Email/query', path: '/ids' } }, '1'],
]);
const query = requireMethodResult(res, '0', 'Email/query');
const set = requireMethodResult(res, '1', 'Email/set');
const found: string[] = (query.ids as string[]) ?? [];
const destroyed = ((set.destroyed as string[] | undefined) ?? []).length;
totalDestroyed += destroyed;
// Nothing left, or the server refused everything in this batch (missing
// permission, immutable mail) - stop instead of looping on the same ids.
if (found.length === 0 || destroyed === 0) break;
if (found.length < batchSize) break;
}
return totalDestroyed;
}
/**
* Set `$seen` on every unread message in a folder. Returns the count. Throws
* the first refusal once the rest are marked: a refused message stays unread
* (a shared folder without the right to set it, a message at Stalwart 1.0's
* 100-keyword limit), and the folder would otherwise look done.
*/
export async function markMailboxAsRead(mailboxId: string, accountIdOverride?: string): Promise<number> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const pageSize = Math.min(500, maxInSet());
let total = 0;
let refusal: unknown = null;
for (;;) {
const res = await jmapClient.request([
['Email/query', {
accountId,
filter: { operator: 'AND', conditions: [{ inMailbox: mailboxId }, { notKeyword: '$seen' }] },
limit: pageSize,
}, '0'],
]);
const ids = (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? [];
if (ids.length === 0) break;
const update = Object.fromEntries(ids.map((id) => [id, { [keywordPointer('$seen')]: true }]));
const setRes = await jmapClient.request([['Email/set', { accountId, update }, '0']]);
const body = requireMethodResult(setRes, '0', 'Email/set');
const marked = Object.keys(body.updated ?? {}).length;
total += marked;
try {
assertSetResult(body, ids, 'email');
} catch (err) {
refusal ??= err;
}
// Refused messages come back in every page; stop once only they are left.
if (marked === 0 || ids.length < pageSize) break;
}
if (refusal) throw refusal;
return total;
}
/**
* Mark every message in each folder read ("Mark folder as read" on a folder
* and its subfolders). A folder the server refuses - a read-only share, a
* message at the keyword limit - doesn't leave the folders after it unread:
* the first refusal is thrown once every folder was tried. A lost connection
* stops at once. Resolves with the number of messages marked.
*/
export async function markMailboxesAsRead(mailboxes: Array<{ id: string; accountId?: string }>): Promise<number> {
let total = 0;
let refusal: unknown = null;
for (const { id, accountId } of mailboxes) {
try {
total += await markMailboxAsRead(id, accountId);
} catch (err) {
if (isTransientNetworkError(err)) throw err;
refusal ??= err;
}
}
if (refusal) throw refusal;
return total;
}
function buildMailboxQueryFilter(
mailboxId: string | undefined,
userFilter: Record<string, unknown> | undefined,
): Record<string, unknown> {
// An undefined mailbox means "search every folder" (#788).
const inMailbox = mailboxId ? { inMailbox: mailboxId } : {};
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
// produces a hybrid object that servers reduce to the FilterCondition,
// silently dropping the operator's conditions (e.g. the "unread" toggle).
if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox;
if ('operator' in userFilter) {
return mailboxId ? { operator: 'AND', conditions: [inMailbox, userFilter] } : userFilter;
}
return { ...inMailbox, ...userFilter };
}
interface EmailQueryOptions {
position?: number;
limit?: number;
sort?: Array<{ property: string; isAscending: boolean; keyword?: string }>;
filter?: Record<string, unknown>;
/** Owning JMAP account when the mailbox belongs to a shared account. */
accountId?: string;
collapseThreads?: boolean;
}
function emailQueryArgs(mailboxId: string | undefined, options?: EmailQueryOptions): Record<string, unknown> {
const accountId = options?.accountId ?? jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const args: Record<string, unknown> = {
accountId,
filter,
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
position: options?.position ?? 0,
limit: options?.limit ?? 50,
calculateTotal: true,
};
if (options?.collapseThreads) args.collapseThreads = true;
return args;
}
export async function queryEmails(
mailboxId: string | undefined,
options?: EmailQueryOptions,
): Promise<{ ids: string[]; total: number; queryState?: string }> {
const res = await jmapClient.request([['Email/query', emailQueryArgs(mailboxId, options), '0']]);
const body = requireMethodResult(res, '0', 'Email/query');
return {
ids: (body.ids as string[]) ?? [],
total: (body.total as number) ?? 0,
queryState: body.queryState as string | undefined,
};
}
/**
* One page of a message list in a single request: Email/query, Email/get of
* the ids it returns and, with `threads`, Thread/get of those messages'
* threads (for the conversation-size badges), chained with result references
* instead of three round trips. `state` is the Email state the rows were
* read at.
*/
export async function queryEmailPage(
mailboxId: string | undefined,
options?: EmailQueryOptions & { threads?: boolean },
): Promise<{
ids: string[];
total: number;
queryState?: string;
list: Email[];
state?: string;
threads: Thread[];
}> {
// The chained Email/get takes every id the query returns, so a page can't
// be larger than the server lets one /get fetch.
const args = emailQueryArgs(mailboxId, {
...options,
limit: Math.min(options?.limit ?? 50, maxInGet()),
});
const accountId = args.accountId;
const calls: JMAPMethodCall[] = [
['Email/query', args, '0'],
['Email/get', {
accountId,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: EMAIL_LIST_PROPERTIES,
}, '1'],
];
if (options?.threads) {
calls.push(['Thread/get', {
accountId,
'#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' },
}, '2']);
}
const res = await jmapClient.request(calls);
const query = requireMethodResult(res, '0', 'Email/query');
const got = requireMethodResult(res, '1', 'Email/get');
const ids = (query.ids as string[]) ?? [];
let threads: Thread[] = [];
if (options?.threads) {
// Thread sizes only decorate the rows: a failed Thread/get keeps the page.
try {
threads = (requireMethodResult(res, '2', 'Thread/get').list as Thread[]) ?? [];
} catch { /* the list falls back to the in-page count */ }
}
// Email/get may answer in any order; keep the query's.
const byId = new Map(((got.list as Email[]) ?? []).map((e) => [e.id, e]));
return {
ids,
total: (query.total as number) ?? 0,
queryState: query.queryState as string | undefined,
list: ids.flatMap((id) => byId.get(id) ?? []),
state: got.state as string | undefined,
threads,
};
}
/** One account's part of a list that spans accounts. */
export interface AccountPageTarget {
/** Shared/group JMAP account; undefined = the user's own. */
accountId?: string;
position: number;
sort: Array<{ property: string; isAscending: boolean; keyword?: string }>;
/**
* This account's own filter, in place of the shared `options.filter`
* (the default search leaves each account's own Trash and Junk out).
*/
filter?: Record<string, unknown>;
}
export type AccountPage =
| { accountId?: string; ok: true; total: number; list: Email[]; threads: Thread[] }
| { accountId?: string; ok: false; error: Error };
/**
* A folder-less ("All folders") page from each of several accounts the
* session reaches, in as few requests as `maxCallsInRequest` allows: per
* account its Email/query, the Email/get of the ids and, with `threads`, the
* Thread/get of their threads, chained with result references. Results come
* back in `targets` order; an account whose calls fail carries its error
* while the others still return their page (webmail `fanOutAccountQuery`).
*/
export async function queryEmailPagesAcrossAccounts(
targets: AccountPageTarget[],
options: { limit: number; filter?: Record<string, unknown>; threads?: boolean },
): Promise<AccountPage[]> {
const callsPerTarget = options.threads ? 3 : 2;
const perRequest = Math.max(1, Math.floor(jmapClient.getMaxCallsInRequest() / callsPerTarget));
const limit = Math.min(options.limit, maxInGet());
const out: AccountPage[] = new Array(targets.length);
const indexed = targets.map((target, index) => ({ target, index }));
await Promise.all(batched(indexed, perRequest).map(async (chunk) => {
const calls: JMAPMethodCall[] = [];
for (const { target, index } of chunk) {
const args = emailQueryArgs(undefined, {
position: target.position,
limit,
sort: target.sort,
filter: target.filter ?? options.filter,
accountId: target.accountId,
});
calls.push(['Email/query', args, `${index}:q`]);
calls.push(['Email/get', {
accountId: args.accountId,
'#ids': { resultOf: `${index}:q`, name: 'Email/query', path: '/ids' },
properties: EMAIL_LIST_PROPERTIES,
}, `${index}:g`]);
if (options.threads) {
calls.push(['Thread/get', {
accountId: args.accountId,
'#ids': { resultOf: `${index}:g`, name: 'Email/get', path: '/list/*/threadId' },
}, `${index}:t`]);
}
}
let res: Awaited<ReturnType<typeof jmapClient.request>>;
try {
res = await jmapClient.request(calls);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
for (const { target, index } of chunk) out[index] = { accountId: target.accountId, ok: false, error };
return;
}
for (const { target, index } of chunk) {
try {
const query = requireMethodResult(res, `${index}:q`, 'Email/query');
const got = requireMethodResult(res, `${index}:g`, 'Email/get');
let threads: Thread[] = [];
if (options.threads) {
try {
threads = (requireMethodResult(res, `${index}:t`, 'Thread/get').list as Thread[]) ?? [];
} catch { /* thread sizes only decorate the rows */ }
}
const byId = new Map(((got.list as Email[]) ?? []).map((e) => [e.id, e]));
out[index] = {
accountId: target.accountId,
ok: true,
total: (query.total as number) ?? 0,
list: ((query.ids as string[]) ?? []).flatMap((id) => byId.get(id) ?? []),
threads,
};
} catch (err) {
out[index] = {
accountId: target.accountId,
ok: false,
error: err instanceof Error ? err : new Error(String(err)),
};
}
}
}));
return out;
}
export interface EmailQueryChangesResult {
oldQueryState: string;
newQueryState: string;
total: number;
removed: string[];
added: Array<{ id: string; index: number }>;
}
// Run Email/queryChanges for the standard "by receivedAt desc, in this mailbox"
// query. Returns null when the server replies with `cannotCalculateChanges`
// (or any other error, or a query state that went back) — caller should fall
// back to a fresh Email/query.
export async function getEmailQueryChanges(
mailboxId: string | undefined,
sinceQueryState: string,
options?: {
sort?: Array<{ property: string; isAscending: boolean; keyword?: string }>;
filter?: Record<string, unknown>;
upToId?: string;
maxChanges?: number;
/** Owning JMAP account when the mailbox belongs to a shared account. */
accountId?: string;
},
): Promise<EmailQueryChangesResult | null> {
const accountId = options?.accountId ?? jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const args: Record<string, unknown> = {
accountId,
filter,
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
sinceQueryState,
calculateTotal: true,
};
if (options?.upToId) args.upToId = options.upToId;
if (options?.maxChanges) args.maxChanges = options.maxChanges;
const res = await jmapClient.request([['Email/queryChanges', args, '0']]);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
// A query state is the Email change id the query was answered at.
if (stateWentBack(accountId, sinceQueryState, body.newQueryState)) return null;
return {
oldQueryState: body.oldQueryState as string,
newQueryState: body.newQueryState as string,
total: (body.total as number) ?? 0,
removed: (body.removed as string[]) ?? [],
added: (body.added as Array<{ id: string; index: number }>) ?? [],
};
}
export interface EmailListDelta {
/** Null when the server can't diff the query (the list is re-queried). */
queryChanges: EmailQueryChangesResult | null;
/** Null when the server can't compute Email changes since the list's state. */
changes: EmailChangesResult | null;
/** The messages the query added, with the list properties. */
added: Email[];
/** False when the chained Email/get failed (too many ids): fetch them apart. */
addedFetched: boolean;
/** Threads of the added messages (with `threads`). */
threads: Thread[];
}
function methodBody(
responses: Array<[string, Record<string, any>, string]>,
callId: string,
name: string,
): Record<string, any> | null {
const match = responses.find((r) => r[2] === callId);
return match && match[0] === name ? match[1] : null;
}
/**
* What changed in a folder list since it was read, in one request instead of
* three or four in a row: Email/queryChanges from the list's queryState,
* Email/changes from its Email state (rows that changed in place), and by
* result reference Email/get of the messages the query added and, with
* `threads`, Thread/get of their threads. Each part fails on its own: a query
* the server can't diff comes back as `queryChanges: null` (the caller
* re-queries), a state it can't diff as `changes: null`.
*/
export async function getEmailListDelta(
mailboxId: string | undefined,
sinceQueryState: string,
sinceState: string,
options?: {
sort?: Array<{ property: string; isAscending: boolean; keyword?: string }>;
/** Owning JMAP account when the mailbox belongs to a shared account. */
accountId?: string;
threads?: boolean;
},
): Promise<EmailListDelta> {
const accountId = options?.accountId ?? jmapClient.accountId;
const calls: JMAPMethodCall[] = [
['Email/queryChanges', {
accountId,
filter: buildMailboxQueryFilter(mailboxId, undefined),
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
sinceQueryState,
calculateTotal: true,
}, 'qc'],
['Email/changes', { accountId, sinceState }, 'ch'],
['Email/get', {
accountId,
'#ids': { resultOf: 'qc', name: 'Email/queryChanges', path: '/added/*/id' },
properties: EMAIL_LIST_PROPERTIES,
}, 'get'],
];
if (options?.threads) {
calls.push(['Thread/get', {
accountId,
'#ids': { resultOf: 'get', name: 'Email/get', path: '/list/*/threadId' },
}, 'th']);
}
const res = await jmapClient.request(calls);
const responses = res.methodResponses ?? [];
// A state that went back counts as one the server can't diff.
let qc = methodBody(responses, 'qc', 'Email/queryChanges');
if (qc && stateWentBack(accountId, sinceQueryState, qc.newQueryState)) qc = null;
let ch = methodBody(responses, 'ch', 'Email/changes');
if (ch && stateWentBack(accountId, sinceState, ch.newState)) ch = null;
const got = methodBody(responses, 'get', 'Email/get');
const th = methodBody(responses, 'th', 'Thread/get');
return {
queryChanges: qc
? {
oldQueryState: qc.oldQueryState as string,
newQueryState: qc.newQueryState as string,
total: (qc.total as number) ?? 0,
removed: (qc.removed as string[]) ?? [],
added: (qc.added as Array<{ id: string; index: number }>) ?? [],
}
: null,
changes: ch
? {
oldState: ch.oldState as string,
newState: ch.newState as string,
hasMoreChanges: Boolean(ch.hasMoreChanges),
created: (ch.created as string[]) ?? [],
updated: (ch.updated as string[]) ?? [],
destroyed: (ch.destroyed as string[]) ?? [],
}
: null,
added: (got?.list as Email[] | undefined) ?? [],
addedFetched: !!got,
threads: (th?.list as Thread[] | undefined) ?? [],
};
}
export async function getEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
return (await getEmailsWithState(ids, accountIdOverride)).list;
}
// Returns the Email/get response with the JMAP `state` token. Used by the
// store so we can later issue Email/changes(sinceState=…) for incremental
// updates instead of re-fetching the full list. Splits the id list to the
// server's maxObjectsInGet.
export async function getEmailsWithState(
ids: string[],
accountIdOverride?: string,
): Promise<{ list: Email[]; state: string }> {
const accountId = accountIdOverride ?? jmapClient.accountId;
if (ids.length === 0) {
// Email/get with an empty id list still returns a state token; useful for
// priming the store after an empty mailbox query.
const res = await jmapClient.request([
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = requireMethodResult(res, '0', 'Email/get');
return { list: [], state: body.state as string };
}
const list: Email[] = [];
let state = '';
for (const slice of batched(ids, maxInGet())) {
const res = await jmapClient.request([
['Email/get', { accountId, ids: slice, properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = requireMethodResult(res, '0', 'Email/get');
list.push(...((body.list as Email[]) ?? []));
state = body.state as string;
}
return { list, state };
}
// Body-part fields a list-row attachment chip needs (lib/list-attachments).
const LIST_ATTACHMENT_BODY_PROPERTIES = ['blobId', 'size', 'name', 'type', 'cid', 'disposition'];
/**
* Attachment parts of the given emails, keyed by email id, for list-row
* chips. Kept out of the list request on purpose: Stalwart reads and parses
* each message's whole raw blob to answer `attachments`, so asking for it on
* every list page made large folders slow (webmail #1089). Callers ask only
* for rows on screen that have `hasAttachment`. Ids the server did not
* return are absent from the map.
*/
export async function getEmailAttachments(
ids: string[],
accountIdOverride?: string,
): Promise<Map<string, Attachment[]>> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const found = new Map<string, Attachment[]>();
for (const slice of batched(ids, maxInGet())) {
const res = await jmapClient.request([
['Email/get', {
accountId,
ids: slice,
properties: ['id', 'attachments'],
bodyProperties: LIST_ATTACHMENT_BODY_PROPERTIES,
}, '0'],
]);
const body = requireMethodResult(res, '0', 'Email/get');
for (const email of (body.list as Pick<Email, 'id' | 'attachments'>[]) ?? []) {
found.set(email.id, email.attachments ?? []);
}
}
return found;
}
export interface EmailChangesResult {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
}
// Returns null when the server replies with `cannotCalculateChanges` or any
// other error response, or with a state that went back; caller should treat
// that as "rebuild from scratch".
export async function getEmailChanges(
sinceState: string,
maxChanges?: number,
accountIdOverride?: string,
): Promise<EmailChangesResult | null> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const args: Record<string, unknown> = { accountId, sinceState };
if (maxChanges) args.maxChanges = maxChanges;
const res = await jmapClient.request([['Email/changes', args, '0']]);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
if (stateWentBack(accountId, sinceState, body.newState)) return null;
return {
oldState: body.oldState as string,
newState: body.newState as string,
hasMoreChanges: Boolean(body.hasMoreChanges),
created: (body.created as string[]) ?? [],
updated: (body.updated as string[]) ?? [],
destroyed: (body.destroyed as string[]) ?? [],
};
}
const FULL_BODY_ARGS = {
properties: EMAIL_FULL_PROPERTIES,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 512000,
};
// A displayed part over that cap comes back cut off, often mid-tag, so a
// large HTML report rendered as a heading and a stub (#884). Those parts are
// fetched once more with this far larger, still bounded cap.
const TRUNCATED_BODY_REFETCH_MAX_BYTES = 8_000_000;
/**
* Refetch, in place and batched across the list, the HTML and text body
* values of messages whose displayed part came back truncated. Other parts'
* values are kept. A failure leaves the truncated values in place (the viewer
* says the message isn't shown in full) instead of failing the open.
*/
async function refetchTruncatedBodyValues(emails: Email[], accountId: string): Promise<void> {
const truncated = emails.filter(hasTruncatedDisplayedBody);
if (truncated.length === 0) return;
try {
const refetched = new Map<string, Email['bodyValues']>();
for (const slice of batched(truncated.map((e) => e.id), maxInGet())) {
const res = await jmapClient.request([
['Email/get', {
accountId,
ids: slice,
properties: ['id', 'bodyValues'],
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
maxBodyValueBytes: TRUNCATED_BODY_REFETCH_MAX_BYTES,
}, '0'],
]);
for (const e of (requireMethodResult(res, '0', 'Email/get').list as Email[] | undefined) ?? []) {
refetched.set(e.id, e.bodyValues);
}
}
for (const email of truncated) {
const values = refetched.get(email.id);
if (values) email.bodyValues = { ...email.bodyValues, ...values };
}
} catch (err) {
console.warn('[email] refetching truncated bodies failed', err);
}
}
export async function getFullEmail(id: string, accountIdOverride?: string): Promise<Email> {
// `accountIdOverride` lets the unified inbox open a message that lives under
// a group/shared account in the same session instead of the user's own.
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids: [id], ...FULL_BODY_ARGS }, '0'],
]);
const body = requireMethodResult(res, '0', 'Email/get');
const fetched = (body.list as Email[] | undefined)?.[0];
if (!fetched) throw new Error(`Email ${id} not found`);
// Stalwart 1.0 lists an inline image in mixed only in the body lists.
const email = withInlineBodyParts(fetched);
await refetchTruncatedBodyValues([email], accountId);
return email;
}
// Batch variant for offline sync and the thread view. Splits to the server's
// maxObjectsInGet ceiling itself.
export async function getFullEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
return (await getFullEmailsWithState(ids, accountIdOverride)).list;
}
/** An `Email/get` result: what came back, what the server does not know, and its Email state. */
export interface EmailGetResult<T> {
list: T[];
notFound: string[];
/** The account's Email state the result was read at (RFC 8620 §5.1). */
state?: string;
}
/**
* {@link getFullEmails} with the Email state of the response and the ids the
* server reported missing, for callers that cache what they read.
*/
export async function getFullEmailsWithState(
ids: string[],
accountIdOverride?: string,
): Promise<EmailGetResult<Email>> {
if (ids.length === 0) return { list: [], notFound: [] };
const accountId = accountIdOverride ?? jmapClient.accountId;
const out: EmailGetResult<Email> = { list: [], notFound: [] };
for (const slice of batched(ids, maxInGet())) {
const res = await jmapClient.request([
['Email/get', { accountId, ids: slice, ...FULL_BODY_ARGS }, '0'],
]);
const body = requireMethodResult(res, '0', 'Email/get');
out.list.push(...((body.list as Email[]) ?? []).map(withInlineBodyParts));
out.notFound.push(...((body.notFound as string[] | undefined) ?? []));
out.state = body.state as string | undefined;
}
await refetchTruncatedBodyValues(out.list, accountId);
return out;
}
/** The two mutable properties of an Email (RFC 8621 §4.1); nothing else ever changes. */
export type EmailFlags = Pick<Email, 'id' | 'keywords' | 'mailboxIds'>;
/**
* Only the keywords and folders of messages, to bring a copy read earlier up
* to date without downloading its body again.
*/
export async function getEmailFlags(
ids: string[],
accountIdOverride?: string,
): Promise<EmailGetResult<EmailFlags>> {
if (ids.length === 0) return { list: [], notFound: [] };
const accountId = accountIdOverride ?? jmapClient.accountId;
const out: EmailGetResult<EmailFlags> = { list: [], notFound: [] };
for (const slice of batched(ids, maxInGet())) {
const res = await jmapClient.request([
['Email/get', { accountId, ids: slice, properties: ['id', 'keywords', 'mailboxIds'] }, '0'],
]);
const body = requireMethodResult(res, '0', 'Email/get');
out.list.push(...((body.list as EmailFlags[]) ?? []));
out.notFound.push(...((body.notFound as string[] | undefined) ?? []));
out.state = body.state as string | undefined;
}
return out;
}
// What a collapsed conversation card shows; `sentAt` is the date it prints.
export const THREAD_HEADER_PROPERTIES = [...EMAIL_LIST_PROPERTIES, 'sentAt'];
/** A conversation's members, oldest first, without bodies. */
export interface ThreadHeaders {
emailIds: string[];
list: Email[];
}
/**
* The messages of several threads without their bodies, oldest first, in one
* back-referenced Thread/get → Email/get. The viewer lists conversations from
* these and downloads bodies only for the cards it opens. Threads the server
* does not know are in `notFound`.
*/
export async function getThreadsHeaders(
threadIds: string[],
accountIdOverride?: string,
): Promise<{ threads: Record<string, ThreadHeaders>; notFound: string[]; state?: string }> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const ids = Array.from(new Set(threadIds));
const res = await jmapClient.request([
['Thread/get', { accountId, ids }, '0'],
['Email/get', {
accountId,
'#ids': { resultOf: '0', name: 'Thread/get', path: '/list/*/emailIds' },
properties: THREAD_HEADER_PROPERTIES,
}, '1'],
]);
const found = (requireMethodResult(res, '0', 'Thread/get').list as Thread[] | undefined) ?? [];
const body = requireMethodResult(res, '1', 'Email/get');
const byId = new Map(((body.list as Email[]) ?? []).map((e) => [e.id, e]));
const threads: Record<string, ThreadHeaders> = {};
for (const thread of found) {
// Members the server did not return (no access to them) are left out.
const emailIds = thread.emailIds.filter((id) => byId.has(id));
threads[thread.id] = { emailIds, list: emailIds.map((id) => byId.get(id)!) };
}
return {
threads,
notFound: ids.filter((id) => !threads[id]),
state: body.state as string | undefined,
};
}
/**
* Import an already-uploaded raw MIME message (a `.eml` blob) into a mailbox
* via JMAP `Email/import`. Returns the new email id. Mirrors the webmail's
* `importRawEmail` import step.
*/
export async function importEmailBlob(
blobId: string,
mailboxId: string,
keywords: Record<string, boolean> = { $seen: true },
accountIdOverride?: string,
): Promise<string> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/import', {
accountId,
emails: {
'import-0': { blobId, mailboxIds: { [mailboxId]: true }, keywords },
},
}, '0'],
]);
const result = requireMethodResult(res, '0', 'Email/import');
const notCreated = result?.notCreated?.['import-0'];
if (notCreated) {
throw new Error(notCreated.description || notCreated.type || 'Failed to import email');
}
const id = result?.created?.['import-0']?.id;
if (!id) throw new Error('Email import succeeded but no id was returned');
return id;
}
export async function getThread(threadId: string, accountIdOverride?: string): Promise<Thread> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request(
[['Thread/get', { accountId, ids: [threadId] }, '0']],
);
const thread = (requireMethodResult(res, '0', 'Thread/get').list as Thread[] | undefined)?.[0];
if (!thread) throw new Error(`Thread ${threadId} not found`);
return thread;
}
/** Thread/get for many ids at once (chunked). Missing threads are skipped. */
export async function getThreads(threadIds: string[], accountIdOverride?: string): Promise<Thread[]> {
if (threadIds.length === 0) return [];
const accountId = accountIdOverride ?? jmapClient.accountId;
const out: Thread[] = [];
for (const slice of batched(Array.from(new Set(threadIds)), maxInGet())) {
const res = await jmapClient.request([['Thread/get', { accountId, ids: slice }, '0']]);
out.push(...((requireMethodResult(res, '0', 'Thread/get').list as Thread[]) ?? []));
}
return out;
}
// One `keywords/<name>` pointer per changed keyword. Never send the whole
// `keywords` map on update: the server replaces it, erasing every keyword the
// local copy didn't have (a star or tag set elsewhere, or all of them on a
// message that was never loaded).
function keywordPointers(patch: Record<string, boolean | null>): Record<string, unknown> {
const pointers: Record<string, unknown> = {};
for (const [keyword, value] of Object.entries(patch)) {
pointers[keywordPointer(keyword)] = value === false ? null : value;
}
return pointers;
}
/** Patch individual keywords (`{ $seen: true, $junk: null }`) on many messages. */
export async function patchKeywordsForEmails(
ids: string[],
patch: Record<string, boolean | null>,
accountIdOverride?: string,
): Promise<void> {
if (ids.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId;
const pointerPatch = keywordPointers(patch);
const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) update[id] = { ...pointerPatch };
await emailSetBatched(accountId, { update });
}
export async function moveEmail(
emailId: string,
fromMailboxId: string,
toMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
await moveEmails([emailId], fromMailboxId, toMailboxId, accountIdOverride);
}
// Move several emails from one mailbox to another. Pointer keys are JSON
// Pointer escaped so a mailbox id containing `/` or `~` cannot break the patch.
export async function moveEmails(
ids: string[],
fromMailboxId: string,
toMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
if (ids.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) {
update[id] = {
[mailboxPointer(fromMailboxId)]: null,
[mailboxPointer(toMailboxId)]: true,
};
}
await emailSetBatched(accountId, { update });
}
/**
* File messages into Junk and flip `$junk`/`$notjunk` so the server's
* classifier and other clients learn (#850). Optionally also marks them read
* (the "trash-and-read" delete action).
*/
export async function markAsSpam(
ids: string[],
junkMailboxId: string,
accountIdOverride?: string,
opts?: { markRead?: boolean },
): Promise<void> {
if (ids.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) {
update[id] = {
mailboxIds: { [junkMailboxId]: true },
[keywordPointer('$junk')]: true,
[keywordPointer('$notjunk')]: null,
...(opts?.markRead ? { [keywordPointer('$seen')]: true } : {}),
};
}
await emailSetBatched(accountId, { update });
}
/** Inverse of {@link markAsSpam}: restore into `targetMailboxId` with `$notjunk`. */
export async function undoSpam(
ids: string[],
targetMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
if (ids.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) {
update[id] = {
mailboxIds: { [targetMailboxId]: true },
[keywordPointer('$junk')]: null,
[keywordPointer('$notjunk')]: true,
};
}
await emailSetBatched(accountId, { update });
}
// Batch delete: destroy outright when already in trash, otherwise move to trash.
export async function deleteEmails(
ids: string[],
trashMailboxId: string,
currentMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
if (ids.length === 0) return;
if (currentMailboxId === trashMailboxId) {
await destroyEmails(ids, accountIdOverride);
} else {
await moveEmails(ids, currentMailboxId, trashMailboxId, accountIdOverride);
}
}
// Apply a different keyword patch to each of several emails in one
// round-trip (chunked). Undo uses it to put back each message's own keywords.
export async function patchKeywordsPerEmail(
updates: Array<{ id: string; patch: Record<string, boolean | null> }>,
accountIdOverride?: string,
): Promise<void> {
if (updates.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
for (const u of updates) update[u.id] = keywordPointers(u.patch);
await emailSetBatched(accountId, { update });
}
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
// the entire map, so we don't need to compute a diff against the current state.
export async function restoreEmailMailboxes(
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
accountIdOverride?: string,
): Promise<void> {
if (items.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const item of items) {
const onlyTrue: Record<string, true> = {};
for (const [id, present] of Object.entries(item.mailboxIds)) {
if (present) onlyTrue[id] = true;
}
update[item.id] = { mailboxIds: onlyTrue };
}
await emailSetBatched(accountId, { update });
}
// Replace one email's full mailboxIds map. JMAP "mailboxIds" assigns the whole
// set, so this is idempotent — replaying it produces the same result no matter
// the current server state. The offline outbox relies on that to coalesce and
// safely retry move/archive/trash operations.
export async function setEmailMailboxes(
emailId: string,
mailboxIds: Record<string, boolean>,
accountIdOverride?: string,
): Promise<void> {
await restoreEmailMailboxes([{ id: emailId, mailboxIds }], accountIdOverride);
}
// Permanently destroy emails (no move-to-trash). Idempotent: destroying an
// already-gone id is a no-op on replay (a `notFound` is tolerated).
export async function destroyEmails(ids: string[], accountIdOverride?: string): Promise<void> {
if (ids.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId;
// Every slice goes out before a refusal is thrown, and the error names the
// ids that are still there (failedIds): the server destroyed the rest, and
// the list must drop those rather than keep them until the next refresh
// (webmail ac99c68ef).
let refusal: JMAPSetError | null = null;
const failed: string[] = [];
for (const slice of batched(ids, maxInSet())) {
const res = await jmapClient.request([['Email/set', { accountId, destroy: slice }, '0']]);
try {
assertEmailsDestroyed(requireMethodResult(res, '0', 'Email/set'));
} catch (err) {
if (!(err instanceof JMAPSetError)) throw err;
refusal ??= err;
failed.push(...(err.failedIds ?? []));
}
}
if (refusal) {
refusal.failedIds = failed;
throw refusal;
}
}
/** Throw when an Email/set destroy left messages behind; `notFound` counts as gone. */
function assertEmailsDestroyed(body: Record<string, any>): void {
const notDestroyed = (body.notDestroyed ?? {}) as Record<string, { type?: string; description?: string }>;
const real = Object.entries(notDestroyed).filter(([, err]) => err?.type !== 'notFound');
if (real.length > 0) {
const [id, err] = real[0];
const error = new JMAPSetError(
err.type || 'setError',
`Failed to delete ${real.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
);
error.failedIds = real.map(([failedId]) => failedId);
throw error;
}
}
// Archive one or more emails into the archive mailbox, optionally auto-sorting
// into year or year/month subfolders. Mirrors the webmail implementation in
// lib/jmap/client.ts so behavior stays in sync between platforms.
export async function archiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountIdOverride?: string,
): Promise<void> {
if (emails.length === 0) return;
const accountId = accountIdOverride ?? jmapClient.accountId;
if (mode === 'single') {
const updates = Object.fromEntries(
emails.map((e) => [e.id, { mailboxIds: { [archiveMailboxId]: true } }]),
);
await emailSetBatched(accountId, { update: updates });
return;
}
type Dest = { year: string; month?: string };
const destFor = new Map<string, Dest>();
for (const e of emails) {
const d = new Date(e.receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
}
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
const yearIdFor = new Map<string, string>();
const monthIdFor = new Map<string, string>();
const createEntries: Record<string, Record<string, unknown>> = {};
const findExisting = (name: string, parentId: string) =>
existingMailboxes.find(
(m) => m.name === name && m.parentId === parentId,
);
for (const dest of destFor.values()) {
if (!yearIdFor.has(dest.year)) {
const existing = findExisting(dest.year, archiveMailboxId);
if (existing) {
yearIdFor.set(dest.year, existing.id);
} else {
const cid = `year-${dest.year}`;
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
yearIdFor.set(dest.year, `#${cid}`);
}
}
if (mode === 'month' && dest.month) {
const monthKey = `${dest.year}/${dest.month}`;
if (!monthIdFor.has(monthKey)) {
const yearRef = yearIdFor.get(dest.year)!;
const existingMonth = yearRef.startsWith('#')
? undefined
: findExisting(dest.month, yearRef);
if (existingMonth) {
monthIdFor.set(monthKey, existingMonth.id);
} else {
const cid = `month-${dest.year}-${dest.month}`;
createEntries[cid] = { name: dest.month, parentId: yearRef };
monthIdFor.set(monthKey, `#${cid}`);
}
}
}
}
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const [emailId, dest] of destFor.entries()) {
const destId = mode === 'month' && dest.month
? monthIdFor.get(`${dest.year}/${dest.month}`)!
: yearIdFor.get(dest.year)!;
updates[emailId] = { mailboxIds: { [destId]: true } };
}
// Creation ids are scoped to the request that introduced them (RFC 8620
// §3.3), so "#<cid>" only resolves in the request carrying the Mailbox/set:
// the folders are created alongside the first batch of messages, and the
// ids they were assigned are substituted into every later batch.
const updateBatches = batched(Object.entries(updates), maxInSet());
const hasCreates = Object.keys(createEntries).length > 0;
let createdIdFor: Record<string, string> = {};
for (let i = 0; i < updateBatches.length; i++) {
const batch: Array<[string, { mailboxIds: Record<string, true> }]> = i === 0
? updateBatches[i]
: updateBatches[i].map(([emailId, patch]) => {
const [destId] = Object.keys(patch.mailboxIds);
const resolved = createdIdFor[destId];
return [emailId, resolved ? { mailboxIds: { [resolved]: true } as Record<string, true> } : patch];
});
const methodCalls: JMAPMethodCall[] = [];
const withCreates = hasCreates && i === 0;
if (withCreates) {
methodCalls.push(['Mailbox/set', { accountId, create: createEntries }, '0']);
}
methodCalls.push(['Email/set', { accountId, update: Object.fromEntries(batch) }, String(methodCalls.length)]);
const response = await jmapClient.request(methodCalls);
if (withCreates) {
const mailboxResult = requireMethodResult(response, '0', 'Mailbox/set');
const notCreated = mailboxResult?.notCreated as
| Record<string, { type?: string; properties?: string[]; description?: string }>
| undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
}
const created = (mailboxResult?.created || {}) as Record<string, { id?: string }>;
createdIdFor = Object.fromEntries(
Object.entries(created)
.filter(([, mailbox]) => !!mailbox?.id)
.map(([cid, mailbox]) => [`#${cid}`, mailbox.id!]),
);
}
const emailResult = requireMethodResult(response, String(withCreates ? 1 : 0), 'Email/set');
const notUpdated = emailResult?.notUpdated as
| Record<string, { type?: string; description?: string }>
| undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(
`Failed to archive ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
);
}
}
}
export async function deleteEmail(
emailId: string,
trashMailboxId: string,
currentMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
await deleteEmails([emailId], trashMailboxId, currentMailboxId, accountIdOverride);
}
export async function searchEmails(
query: string,
mailboxId?: string,
limit = 30,
accountIdOverride?: string,
): Promise<string[]> {
const accountId = accountIdOverride ?? jmapClient.accountId;
// Quoted phrases and `word*` prefixes go out as conditions of their own (see
// splitSearchText), and an operator can't carry `inMailbox` next to them.
const filter = buildMailboxQueryFilter(mailboxId, textFilter(query));
const res = await jmapClient.request([
['Email/query', {
accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
]);
return (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? [];
}
// Cross-mailbox query with an arbitrary JMAP filter (used by contact activity
// to search "from OR to <addresses>" without picking a specific mailbox).
export async function queryEmailsByFilter(
filter: Record<string, unknown>,
limit = 5,
accountIdOverride?: string,
): Promise<string[]> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/query', {
accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
]);
return (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? [];
}
export interface OutgoingAttachment {
blobId: string;
type: string;
name: string;
size?: number;
disposition?: 'attachment' | 'inline';
cid?: string;
}
export interface OutgoingEmail {
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
replyTo?: EmailAddress[];
subject: string;
htmlBody?: string;
textBody?: string;
attachments?: OutgoingAttachment[];
// RFC 5322 threading: bare msg-ids (angle brackets are stripped). A legacy
// whitespace-separated string is accepted and split.
inReplyTo?: string[] | string;
references?: string[] | string;
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
messageId?: string;
requestReadReceipt?: boolean;
/**
* SMTP MAIL FROM when it must differ from the header From - a catch-all
* alias on an owned domain is sent through the identity's envelope while
* the visible From keeps the alias (webmail #246).
*/
envelopeMailFrom?: string;
}
export interface SendEmailResult {
/** True when the message was deferred (HOLDFOR / FUTURERELEASE). */
scheduled: boolean;
/** ISO timestamp the server resolved for a deferred send, when known. */
sendAt?: string;
emailId?: string;
emailSubmissionId?: string;
/** Post-send filing/cleanup problem (message did go out). */
filingWarning?: string;
/**
* Recipients the server refused while it took the submission; the others
* get the message. When it refused every one, the send throws
* RecipientsRefusedError instead.
*/
refusedRecipients?: RefusedRecipient[];
}
function toMessageIdList(value: string[] | string | undefined): string[] | undefined {
if (!value) return undefined;
const raw = Array.isArray(value) ? value : value.split(/[\s,]+/);
const ids = raw.map(stripMessageIdBrackets).filter(Boolean);
return ids.length ? ids : undefined;
}
function cleanAddresses(list: EmailAddress[] | undefined): EmailAddress[] | undefined {
if (!list) return undefined;
const out = list
.map((a) => {
const email = a.email?.trim();
if (!email) return null;
const name = sanitizeDisplayName(a.name);
return name ? { name, email } : { email };
})
.filter((a): a is EmailAddress => a !== null);
// RFC 5322 §3.6.3: an empty address-list header is malformed; omit instead.
return out.length ? out : undefined;
}
function dedupeAddresses(list: EmailAddress[] | undefined): EmailAddress[] | undefined {
if (!list) return undefined;
const seen = new Set<string>();
const out: EmailAddress[] = [];
for (const a of list) {
const key = a.email.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(a);
}
return out.length ? out : undefined;
}
/** The JMAP `Email/set` create object for an outgoing message or draft. */
function buildEmailCreate(email: OutgoingEmail): Record<string, unknown> {
const from = cleanAddresses(email.from) ?? email.from;
const emailCreate: Record<string, unknown> = {
from,
to: cleanAddresses(email.to) ?? [],
cc: cleanAddresses(email.cc),
bcc: dedupeAddresses(cleanAddresses(email.bcc)),
replyTo: cleanAddresses(email.replyTo),
subject: email.subject,
messageId: [email.messageId ? stripMessageIdBrackets(email.messageId) : generateMessageId(from[0]?.email ?? 'localhost')],
};
const bodyValues: Record<string, { value: string }> = {};
if (email.htmlBody) {
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
bodyValues.html = { value: email.htmlBody };
}
if (email.textBody || !email.htmlBody) {
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
bodyValues.text = { value: email.textBody ?? '' };
}
emailCreate.bodyValues = bodyValues;
if (email.attachments?.length) {
emailCreate.attachments = email.attachments.map((a) => {
const part: Record<string, unknown> = {
blobId: a.blobId,
type: a.type,
name: a.name,
disposition: a.disposition ?? 'attachment',
};
if (a.size != null) part.size = a.size;
if (a.cid) part.cid = a.cid;
return part;
});
}
// Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids.
const inReplyTo = toMessageIdList(email.inReplyTo);
const references = toMessageIdList(email.references);
if (inReplyTo) emailCreate.inReplyTo = inReplyTo;
if (references) emailCreate.references = references;
else if (inReplyTo) emailCreate.references = inReplyTo;
if (email.requestReadReceipt && from[0]?.email) {
// RFC 8098: ask the recipient's client to return a Message Disposition
// Notification to our address.
emailCreate['header:Disposition-Notification-To:asText'] = from[0].email;
}
return emailCreate;
}
/**
* The error for a submission the server refused. A hold beyond the MTA's
* limit becomes `ScheduleTooLateError`, and the limit it names is remembered
* so the pickers stop offering such times (webmail parity). A full
* submission quota (Stalwart 1.0, still full after making room) becomes
* `SubmissionQuotaError`, which the UI explains instead of showing the
* server's "please delete some".
*/
function submissionError(err: { description?: string; type?: string }, fallback: string, accountId: string): Error {
if (err.type === 'overQuota') return submissionQuotaError(err.description, accountId);
const limit = parseHoldLimit(err.description);
if (limit === null) return new Error(err.description ?? err.type ?? fallback);
jmapClient.learnHoldLimit(limit);
return new ScheduleTooLateError(limit);
}
/**
* The send request came back without an EmailSubmission and without an
* error: nothing confirms the message left, and it may have (webmail
* SendUnconfirmedError, #1019). The draft is kept.
*/
export class SendUnconfirmedError extends Error {
constructor() {
super('Send confirmation was not received. Check Sent before sending again. Your draft has been kept.');
this.name = 'SendUnconfirmedError';
}
}
export interface SendEmailOptions {
/**
* Drafts folder id. When given, the message is created there with `$draft`
* and moved to Sent by `onSuccessUpdateEmail` on the submission, so the
* SMTP send happens before it lands in Sent (#188). A refused submission
* removes that copy again rather than leaving a stray draft.
*/
draftsMailboxId?: string;
/** Previous draft version to destroy once the submission succeeded (#849). */
draftId?: string;
/**
* Account `draftId` lives in, when not the submitting one: a draft saved
* in the own Drafts and sent from a group identity, or the other way
* round. Email ids repeat across accounts.
*/
draftAccountId?: string;
/** Submitting account (shared/group account); defaults to the primary. */
accountId?: string;
}
export async function sendEmail(
email: OutgoingEmail,
identityId: string,
sentMailboxId: string,
// When > 0 the message is held for this many seconds before delivery via the
// SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later"
// scheduling and the global send-delay (undo-send) window.
holdForSeconds?: number,
opts?: SendEmailOptions,
): Promise<SendEmailResult> {
const accountId = opts?.accountId ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email);
const viaDrafts = !!opts?.draftsMailboxId;
if (viaDrafts) {
emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true };
emailCreate.keywords = { $seen: true, $draft: true };
} else {
emailCreate.mailboxIds = { [sentMailboxId]: true };
emailCreate.keywords = { $seen: true };
}
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
// For a deferred send the envelope must be set explicitly so the HOLDFOR
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
// server derive mailFrom from the Identity, dropping our parameter). An
// explicit envelope sender (catch-all From override) needs it as well.
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
if (holdFor > 0 || email.envelopeMailFrom) {
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
.map((r) => r.email.trim())
.filter(Boolean)
.map((address) => ({ email: address }));
const mailFrom: Record<string, unknown> = {
email: email.envelopeMailFrom || email.from[0]?.email,
};
if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) };
submissionCreate.envelope = { mailFrom, rcptTo };
}
const submissionArgs: Record<string, unknown> = {
accountId,
create: { 'sub-1': submissionCreate },
};
if (viaDrafts) {
submissionArgs.onSuccessUpdateEmail = {
'#sub-1': {
mailboxIds: { [sentMailboxId]: true },
[keywordPointer('$draft')]: null,
},
};
}
let res = await jmapClient.request(
[
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
['EmailSubmission/set', submissionArgs, '1'],
// Which recipients the server took. A refused one is lost without a
// bounce while the create succeeds and files the message in Sent.
deliveryStatusCall(accountId, 'sub-1', '2'),
],
SUBMISSION_USING,
);
// Stalwart 1.0 refuses a submission once the account holds `maxSubmissions`
// records (overQuota). The message exists by then, so make room and submit
// that copy once more instead of failing the send.
const copyCreated = res.methodResponses.find(([name, , callId]) => name === 'Email/set' && callId === '0');
const copyId = (copyCreated?.[1] as { created?: Record<string, { id?: string }> } | undefined)?.created?.draft?.id;
if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') {
await makeRoomForSubmission(accountId);
const retry = await jmapClient.request(
[
['EmailSubmission/set', {
...submissionArgs,
create: { 'sub-1': { ...submissionCreate, emailId: copyId } },
}, '1'],
deliveryStatusCall(accountId, 'sub-1', '2'),
],
SUBMISSION_USING,
);
res = { ...retry, methodResponses: [copyCreated, ...(retry.methodResponses ?? [])] };
}
// The get goes on its own: it fails whenever the create did, and must not
// count as a failed send or a filing problem either way.
const deliveryAnswer = res.methodResponses.find(([, , callId]) => callId === '2');
res = { ...res, methodResponses: res.methodResponses.filter(([, , callId]) => callId !== '2') };
let emailId: string | undefined;
let emailSubmissionId: string | undefined;
let sendAt: string | undefined;
let filingWarning: string | undefined;
let failure: Error | undefined;
for (const [methodName, result] of res.methodResponses) {
if (methodName === 'error' || methodName.endsWith('/error')) {
// Once the submission exists the message has left (or is held), so a
// later error is the implicit `onSuccessUpdateEmail` Email/set that
// Stalwart appends failing to file it. Failing the send here made the
// user retry and send the message twice.
if (emailSubmissionId) {
const err = result as { description?: string; type?: string };
filingWarning = filingWarning ?? (err.description || err.type || 'post-send filing failed');
continue;
}
failure = new Error((result as { description?: string }).description ?? 'Send failed');
break;
}
if (methodName === 'Email/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string; properties?: string[] }> }).notCreated?.draft;
if (notCreated) {
const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : '';
throw new Error(`${notCreated.description ?? notCreated.type ?? 'Failed to create message'}${props}`);
}
// Stalwart answers a submission carrying `onSuccessUpdateEmail` with a
// SECOND `Email/set` response (reusing the submission's call id) that
// reports the filing update and carries no `created`. Only take an id
// when one is actually there - assigning unconditionally overwrote the
// real id with undefined, which silently disabled the undo-send window.
const createdId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
if (createdId) emailId = createdId;
// Filing problems from onSuccessUpdateEmail come back on the implicit
// Email/set; the message already left, so warn rather than fail.
const notUpdated = (result as { notUpdated?: Record<string, { description?: string; type?: string }> }).notUpdated;
if (notUpdated && Object.keys(notUpdated).length) {
const first = Object.values(notUpdated)[0];
filingWarning = filingWarning ?? (first?.description || first?.type || 'post-send filing failed');
}
}
if (methodName === 'EmailSubmission/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.['sub-1'];
if (notCreated) {
failure = submissionError(notCreated, 'Failed to submit message', accountId);
break;
}
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['sub-1'];
emailSubmissionId = created?.id;
sendAt = created?.sendAt;
}
}
// No submission came back and nothing said why: an answer cut short, or a
// server that skipped the call. Nothing confirms the message left, and it
// may have. Reporting it as sent dropped the draft and closed the composer
// (#1019); this keeps both, and the copy made for it too, since it may be
// the one that went out.
if (!failure && !emailSubmissionId) failure = new SendUnconfirmedError();
if (failure instanceof SendUnconfirmedError) throw failure;
if (failure) {
// Nothing went out. A message created before the submission was refused
// must not stay behind: nobody tracked that copy, so a retry left a
// second one next to it. The caller still holds the message, and a
// previous draft version is untouched.
if (emailId) {
try {
await destroyEmails([emailId], accountId);
} catch (err) {
console.warn('[email] failed to remove the unsent copy:', err);
}
}
throw failure;
}
// Every recipient refused: nothing was queued, so nothing goes out, held or
// not, and no bounce will tell. That is a failed send. The copy made for it
// goes, as after any failed send - onSuccessUpdateEmail filed it in Sent,
// where it passes for a sent message - while the previous draft version
// stays and the composer keeps the text, so the address can be fixed and
// the message sent again. The record can neither go out nor be cancelled.
const delivery = emailSubmissionId ? refusedRecipients(deliveryAnswer) : { recipients: [], all: false };
if (delivery.all) {
if (emailId) {
try {
await destroyEmails([emailId], accountId);
} catch (err) {
console.warn('[email] failed to remove the unsent copy:', err);
}
}
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([emailSubmissionId!], accountId);
throw new RecipientsRefusedError(delivery.recipients);
}
// An immediate send's submission record has done its job once the request
// that created it returned: the message is queued and filed, and with no
// hold there is nothing to cancel. Stalwart 1.0 stops accepting submissions
// once an account holds `maxSubmissions` records, so the record goes now;
// Stalwart's destroy only removes the record, never the queued message.
// Other servers are left to the prune. Decided by the request, not by the
// undoStatus the create returned, which 0.16.10-0.16.16 report as final
// even for a held send.
const recordToDestroy = holdFor === 0 && emailSubmissionId && isStalwartAccount(accountId)
? emailSubmissionId
: undefined;
// The message is out (or scheduled) - now it is safe to drop the old draft.
// A failure here leaves an orphan in Drafts, which is a filing warning
// rather than a failed send (#849). The submission record rides along, and
// nothing about it may turn into a warning.
if (opts?.draftId && emailSubmissionId) {
const draftAccountId = opts.draftAccountId ?? accountId;
try {
if (recordToDestroy) {
const cleanup = await jmapClient.request(
[
['Email/set', { accountId: draftAccountId, destroy: [opts.draftId] }, '0'],
submissionDestroyCall([recordToDestroy], accountId, '1'),
],
SUBMISSION_USING,
);
readSubmissionDestroy(cleanup, '1');
assertEmailsDestroyed(requireMethodResult(cleanup, '0', 'Email/set'));
} else {
await destroyEmails([opts.draftId], draftAccountId);
}
} catch (err) {
filingWarning = filingWarning ?? `old draft cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
}
} else if (recordToDestroy) {
await destroyEmailSubmissions([recordToDestroy], accountId);
}
return {
scheduled: !!(holdForSeconds && holdForSeconds > 0),
sendAt,
emailId,
emailSubmissionId,
filingWarning,
// Some recipients refused: the others get the message, the caller says
// who doesn't.
...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}),
};
}
/** A saved draft version and the attachment parts the server built for it. */
export interface SavedDraftVersion {
id: string;
/**
* Attachment and inline parts of the new version, with their blobIds. A
* part's blobId only lives as long as this version: re-opened drafts must
* send these (not the previous version's) on their next save or send.
* Empty when the server did not answer the read-back.
*/
attachments: Attachment[];
}
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
/**
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
* is given it is destroyed only AFTER the replacement was created (#849): a
* combined create+destroy would delete the last good copy when the create
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
* version's attachment parts are read back in the same request, so the
* composer can re-point its draft-part blobIds before the old version (and
* its part blobs) is destroyed.
*/
export async function saveDraftVersion(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
/**
* Account the previous version lives in, when not this one: the draft
* moves between the own Drafts and a group's when its From does (#1090).
*/
previousDraftAccountId?: string,
): Promise<SavedDraftVersion> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email);
emailCreate.mailboxIds = { [draftsMailboxId]: true };
emailCreate.keywords = { $seen: true, $draft: true };
const res = await jmapClient.request([
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
// resolve a result reference into an Email/set `created` map
// (invalidResultReference), but it does resolve the creation id.
['Email/get', {
accountId,
ids: ['#draft'],
// The body lists too: Stalwart 1.0 lists the inline images there only.
properties: ['id', 'attachments', 'textBody', 'htmlBody'],
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
}, '1'],
]);
const body = requireMethodResult(res, '0', 'Email/set');
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
const id = body.created?.draft?.id as string | undefined;
if (!id) throw new Error('Draft save returned no id');
let attachments: Attachment[] = [];
try {
const got = requireMethodResult(res, '1', 'Email/get');
const saved = (got.list as Array<Pick<Email, 'id' | 'attachments' | 'textBody' | 'htmlBody'>> | undefined)
?.find((e) => e.id === id);
attachments = (saved ? withInlineBodyParts(saved).attachments ?? [] : []).filter((a) => !!a.blobId);
} catch (err) {
console.warn('[email] could not read back the saved draft parts:', err);
}
const previousAccountId = previousDraftAccountId ?? accountId;
if (previousDraftId && (previousDraftId !== id || previousAccountId !== accountId)) {
try {
await destroyEmails([previousDraftId], previousAccountId);
} catch (err) {
console.warn('[email] failed to destroy previous draft version:', err);
}
}
return { id, attachments };
}
/** {@link saveDraftVersion} for callers that only need the new draft id. */
export async function createDraft(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
}
export interface ScheduledEmail {
/** Account holding the submission: a shared account for a send from its identity. */
accountId: string;
emailSubmissionId: string;
emailId: string;
identityId: string;
threadId?: string;
sendAt: string;
undoStatus?: string;
subject?: string;
to?: EmailAddress[];
from?: EmailAddress[];
preview?: string;
}
// List pending (not-yet-delivered, not cancelled) scheduled submissions whose
// send time is still in the future, joined with a light Email/get so the UI
// can show subject/recipients. Covers every account that can hold mail: a
// send from a shared identity is held in the shared account, where a
// primary-only lookup never found it, so it could not be cancelled (webmail
// #874). Empty when no account supports FUTURERELEASE.
export async function listScheduledEmails(): Promise<ScheduledEmail[]> {
const accountIds = submissionAccountIds().filter((id) => jmapClient.hasDelayedSend(id));
const out: ScheduledEmail[] = [];
for (const accountId of accountIds) {
try {
out.push(...(await listScheduledEmailsIn(accountId)));
} catch (err) {
// A shared account that can't answer (revoked access, server error)
// must not hide the user's own scheduled mail.
if (accountId === jmapClient.accountId) throw err;
console.warn(`[email] scheduled sends of account ${accountId} unavailable:`, err);
}
}
return out.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
}
async function listScheduledEmailsIn(accountId: string): Promise<ScheduledEmail[]> {
const now = Date.now();
// Every page of the query. It lists records oldest sendAt first, so a
// single page of 200 lost the mail scheduled furthest ahead once the
// three-day window held more records than that. `total` only comes back
// with calculateTotal.
const pageSize = Math.min(500, maxInGet());
const found: string[] = [];
for (let page = 0; page < SUBMISSION_SCAN_MAX_PAGES; page++) {
const queryRes = await jmapClient.request(
[['EmailSubmission/query', { accountId, position: found.length, limit: pageSize, calculateTotal: true }, '0']],
SUBMISSION_ONLY_USING,
);
const [queryName, queryBody] = queryRes.methodResponses[0];
if (queryName === 'error' || queryName.endsWith('/error')) {
if (page === 0) return [];
break;
}
const pageIds = (queryBody.ids as string[]) ?? [];
found.push(...pageIds);
const done = typeof queryBody.total === 'number' ? found.length >= queryBody.total : pageIds.length < pageSize;
if (pageIds.length === 0 || done) break;
}
// A record created between two pages shifts the later ones by one.
const ids = Array.from(new Set(found));
if (ids.length === 0) return [];
const submissions: Array<{
id: string;
emailId: string;
identityId: string;
threadId?: string;
sendAt?: string;
undoStatus?: string;
}> = [];
for (const slice of batched(ids, maxInGet())) {
const subRes = await jmapClient.request(
[['EmailSubmission/get', {
accountId,
ids: slice,
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus'],
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
submissions.push(...((requireMethodResult(subRes, '0', 'EmailSubmission/get').list as typeof submissions) ?? []));
}
const pending = submissions.filter((s) => {
if (s.undoStatus !== 'pending' || !s.sendAt) return false;
const t = new Date(s.sendAt).getTime();
return Number.isFinite(t) && t > now;
});
if (pending.length === 0) return [];
const emailIds = Array.from(new Set(pending.map((s) => s.emailId)));
const emailById = new Map<string, Email>();
for (const slice of batched(emailIds, maxInGet())) {
const emailRes = await jmapClient.request([
['Email/get', {
accountId,
ids: slice,
properties: ['id', 'subject', 'to', 'from', 'preview', 'threadId'],
}, '0'],
]);
for (const e of (requireMethodResult(emailRes, '0', 'Email/get').list as Email[]) ?? []) {
emailById.set(e.id, e);
}
}
return pending
.map((s): ScheduledEmail | null => {
const e = emailById.get(s.emailId);
if (!s.sendAt) return null;
return {
accountId,
emailSubmissionId: s.id,
emailId: s.emailId,
identityId: s.identityId,
threadId: s.threadId ?? e?.threadId,
sendAt: s.sendAt,
undoStatus: s.undoStatus,
subject: e?.subject,
to: e?.to,
from: e?.from,
preview: e?.preview,
};
})
.filter((s): s is ScheduledEmail => s !== null);
}
// Cancel a pending scheduled send. The held message copy stays in Sent; only
// delivery is stopped (matches the webmail behaviour), and the canceled
// submission record is removed. `accountIdOverride` is the account holding
// the submission (see `ScheduledEmail.accountId`).
export async function cancelScheduledSend(emailSubmissionId: string, accountIdOverride?: string): Promise<void> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request(
[['EmailSubmission/set', {
accountId,
update: { [emailSubmissionId]: { undoStatus: 'canceled' } },
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
const failure = body.notUpdated?.[emailSubmissionId] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send');
}
// The canceled record is dead weight against Stalwart 1.0's submission
// limit. It goes in a request of its own, and only once the cancel is
// confirmed: had the two gone together and the cancel failed, the mail
// would still be queued with no record left to cancel it through.
if (body.updated && emailSubmissionId in body.updated) {
await destroyEmailSubmissions([emailSubmissionId], accountId);
}
}
/**
* A reschedule or "Send now" whose replacement the server refused, after the
* original had been cancelled: the original was put back at its old time
* under a new submission id, which the caller now holds instead (webmail
* RescheduleRestoredError). `reason` is why the replacement failed.
*/
export class RescheduleRestoredError extends Error {
constructor(
readonly reason: unknown,
readonly restoredSubmissionId: string,
readonly restoredSendAt?: string,
) {
super(reason instanceof Error ? reason.message : String(reason));
this.name = 'RescheduleRestoredError';
}
}
/**
* A reschedule or "Send now" that failed and could not put the original back
* either: the message is no longer scheduled. It was moved back to Drafts
* (`inDrafts`) so it does not sit in Sent looking sent. `reason` is why the
* replacement failed.
*/
export class RescheduleLostError extends Error {
constructor(readonly reason: unknown, readonly inDrafts: boolean) {
super(`Reschedule failed and the message is no longer scheduled: ${reason instanceof Error ? reason.message : String(reason)}`);
this.name = 'RescheduleLostError';
}
}
/**
* Change when a scheduled message goes out, or send it now with
* `holdForSeconds` = 0. The original is cancelled first and a replacement
* for the same Email, with the held envelope and a fresh HOLDFOR, created
* after it (#1308, webmail parity). The other order failed on servers that
* allow one active submission per Email and refuse the second create, and on
* servers that undo onSuccessUpdateEmail on cancel, which pulled the message
* back to Drafts once the original was cancelled after the replacement
* existed; the replacement now files it in Sent itself.
*
* A cancel that fails changes nothing: the original still goes out, or went
* out already (NoLongerScheduledError). A replacement the server refuses, or
* takes for no recipient, puts the original back at its old time under a new
* id (RescheduleRestoredError); when that fails too the message goes back to
* Drafts (RescheduleLostError). Returns the new submission id and send time,
* and the recipients the server refused for the replacement when it took
* the others.
*/
export async function rescheduleScheduledSend(
scheduled: {
emailSubmissionId: string;
emailId: string;
identityId: string;
from?: EmailAddress[];
to?: EmailAddress[];
/** Account holding the submission; the replacement is created there too. */
accountId?: string;
},
holdForSeconds: number,
recipients?: EmailAddress[],
): Promise<{ emailSubmissionId?: string; sendAt?: string; refusedRecipients?: RefusedRecipient[] }> {
const accountId = scheduled.accountId ?? jmapClient.accountId;
// "Send now" is a 1-second hold, as in the webmail.
const holdFor = Math.max(1, Math.ceil(holdForSeconds));
// Reuse the held submission's envelope: it names every recipient,
// Cc and Bcc included, and the envelope sender a catch-all From went
// out through. Rebuilding it from the Email's To dropped Cc and Bcc.
// The Email's To/Cc/Bcc are the fallback when the server has none. The
// account's Drafts and Sent come along for filing the replacement and for
// a message that ends up not scheduled at all.
const lookup = await jmapClient.request(
[
['EmailSubmission/get', {
accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'sendAt'],
}, '0'],
['Email/get', { accountId, ids: [scheduled.emailId], properties: ['to', 'cc', 'bcc'] }, '1'],
['Mailbox/get', { accountId, ids: null, properties: ['id', 'role'] }, '2'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null;
sendAt?: string;
}> | undefined)?.[0];
const envelope = original?.envelope;
const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined;
const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])];
const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients);
const rcptTo = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email);
const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email;
// Without an answer the replacement is made without filing, and a message
// that can't be put back stays where it is.
const [mailboxAnswer, mailboxBody] = lookup.methodResponses.find(([, , callId]) => callId === '2') ?? [];
const roles = mailboxAnswer === 'Mailbox/get'
? ((mailboxBody?.list ?? []) as Array<{ id: string; role?: string | null }>)
: [];
const draftsId = roles.find((m) => m.role === 'drafts')?.id;
const sentId = roles.find((m) => m.role === 'sent')?.id;
type Created = { id: string; sendAt?: string; delivery: { recipients: RefusedRecipient[]; all: boolean } };
// `until`: when the submission goes out (ms); undefined, at once.
const createSubmission = async (until: number | undefined): Promise<Created> => {
// HOLDFOR counts from when the server takes the request, so a retry
// after making room takes the time spent off it.
const holdSeconds = () => (until === undefined ? undefined : Math.max(1, Math.ceil((until - Date.now()) / 1000)));
const send = () => {
const seconds = holdSeconds();
return jmapClient.request(
[
['EmailSubmission/set', {
accountId,
create: {
replacement: {
emailId: scheduled.emailId,
identityId: scheduled.identityId,
envelope: {
mailFrom: seconds === undefined
? { email: mailFrom }
: { email: mailFrom, parameters: { HOLDFOR: String(seconds) } },
rcptTo,
},
},
},
...(draftsId && sentId ? {
onSuccessUpdateEmail: {
'#replacement': { mailboxIds: { [sentId]: true }, [keywordPointer('$draft')]: null },
},
} : {}),
}, '0'],
deliveryStatusCall(accountId, 'replacement', '1'),
],
SUBMISSION_USING,
);
};
let res = await send();
// A full submission quota (Stalwart 1.0): make room and try once more.
if (submissionCreateError(res, 'replacement')?.type === 'overQuota') {
await makeRoomForSubmission(accountId);
res = await send();
}
// Only the submission call counts: the implied Email/set of
// onSuccessUpdateEmail can fail while the submission was created.
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined;
if (notCreated) throw submissionError(notCreated, 'Failed to reschedule', accountId);
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
if (!created?.id) throw new Error('Failed to reschedule: the server returned no submission');
return {
id: created.id,
sendAt: created.sendAt,
delivery: refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1')),
};
};
// A submission that took no recipient goes nowhere; its record only counts
// against the limit. The recipients it refused, or null when it took some.
const withdrawIfDead = async (created: Created): Promise<RefusedRecipient[] | null> => {
if (!created.delivery.all) return null;
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([created.id], accountId);
return created.delivery.recipients;
};
// HOLDFOR counts from when the server takes the request.
const holdUntil = Date.now() + holdFor * 1000;
// Cancel first. Should it fail, nothing has changed and the original still
// goes out - unless it has gone out already ("Send now" of a message due
// any moment), or was cancelled on another device. Either cancel also
// removes the record it canceled.
try {
await cancelScheduledSend(scheduled.emailSubmissionId, accountId);
} catch (err) {
const status = await submissionUndoStatus(scheduled.emailSubmissionId, accountId);
if (status === 'final' || status === 'canceled') throw new NoLongerScheduledError(status === 'final');
throw err;
}
let replacement: Created;
try {
replacement = await createSubmission(holdUntil);
// The recipients are checked again for the replacement, and a throttle
// used up since the original was taken can refuse some or all of them.
// When it took nobody the reschedule failed; when it took some, the
// caller names the refused ones.
const refused = await withdrawIfDead(replacement);
if (refused) throw new RecipientsRefusedError(refused);
} catch (err) {
// Put the original back at its old time: HOLDFOR with the seconds left,
// at once when that time has passed meanwhile.
const originalTime = original?.sendAt ? Date.parse(original.sendAt) : NaN;
let restored: Created | undefined;
try {
restored = await createSubmission(Number.isFinite(originalTime) && originalTime > Date.now() ? originalTime : undefined);
} catch (restoreErr) {
console.warn('[email] could not put the original scheduled send back:', restoreErr);
}
if (restored && !(await withdrawIfDead(restored).catch(() => null))) {
throw new RescheduleRestoredError(err, restored.id, restored.sendAt ?? original?.sendAt);
}
// Neither went through: the message is not scheduled any more. It goes
// back to Drafts rather than sit in Sent looking sent.
let inDrafts = false;
if (draftsId) {
try {
await restoreEmailToDraft(scheduled.emailId, draftsId, sentId, accountId);
inDrafts = true;
} catch (draftErr) {
console.warn('[email] could not move the unscheduled message back to Drafts:', draftErr);
}
}
throw new RescheduleLostError(err, inDrafts);
}
return {
emailSubmissionId: replacement.id,
sendAt: replacement.sendAt,
...(replacement.delivery.recipients.length > 0 ? { refusedRecipients: replacement.delivery.recipients } : {}),
};
}
/**
* A submission's `undoStatus`; undefined when the server no longer has the
* record. Throws when it can't be read.
*/
async function readSubmissionUndoStatus(emailSubmissionId: string, accountId: string): Promise<string | undefined> {
const res = await jmapClient.request(
[['EmailSubmission/get', { accountId, ids: [emailSubmissionId], properties: ['id', 'undoStatus'] }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const list = requireMethodResult(res, '0', 'EmailSubmission/get').list as
Array<{ id?: string; undoStatus?: string }> | undefined;
return list?.find((s) => s.id === emailSubmissionId)?.undoStatus;
}
/** A submission's `undoStatus`, or undefined when it can't be read. */
async function submissionUndoStatus(emailSubmissionId: string, accountId: string): Promise<string | undefined> {
try {
return await readSubmissionUndoStatus(emailSubmissionId, accountId);
} catch {
return undefined;
}
}
/**
* Move a message back into Drafts (out of Sent) and re-flag it `$draft` so
* it can be edited and re-sent - used after cancelling a scheduled send for
* editing (webmail `restoreEmailToDraft`).
*/
export async function restoreEmailToDraft(
emailId: string,
draftsMailboxId: string,
sentMailboxId?: string,
accountIdOverride?: string,
): Promise<void> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const patch: Record<string, unknown> = {
[mailboxPointer(draftsMailboxId)]: true,
[keywordPointer('$draft')]: true,
[keywordPointer('$seen')]: true,
};
if (sentMailboxId && sentMailboxId !== draftsMailboxId) patch[mailboxPointer(sentMailboxId)] = null;
await emailSetBatched(accountId, { update: { [emailId]: patch } }, 'draft');
}
export interface SendReadReceiptOptions extends MdnOptions {
/** Identity the receipt is submitted through (its address is the MDN From). */
identityId: string;
/** Where the sent receipt is filed. */
sentMailboxId: string;
/** Submitting account (shared/group account); defaults to the primary. */
accountId?: string;
}
/**
* Send an RFC 8098 read receipt (MDN). JMAP has no MDN primitive, so the
* multipart/report is built client-side (`lib/mdn.ts`), uploaded as a blob,
* imported into Sent and submitted with an explicit envelope - mirrors the
* webmail's `client.sendReadReceipt`. When the submission fails the imported
* copy is destroyed again, so a refused receipt does not sit in Sent as if
* it had gone out. The caller flags the original `$mdnsent` afterwards.
*/
export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise<string> {
const accountId = opts.accountId ?? jmapClient.accountId;
const raw = buildMdnMessage(opts);
const bytes = new TextEncoder().encode(raw);
// Lazy: blob.ts pulls in expo-file-system, which this module otherwise
// never needs (and which the node test environment cannot load).
const { uploadBytes } = await import('./blob');
const upload = await uploadBytes(bytes, 'message/rfc822', accountId);
const emailId = await importEmailBlob(upload.blobId, opts.sentMailboxId, { $seen: true }, accountId);
let submissionId: string | undefined;
try {
const submit = () => jmapClient.request(
[
['EmailSubmission/set', {
accountId,
create: {
mdn: {
emailId,
identityId: opts.identityId,
envelope: {
mailFrom: { email: opts.fromEmail },
rcptTo: [{ email: opts.to }],
},
},
},
}, '0'],
],
SUBMISSION_USING,
);
let res = await submit();
// A full submission quota (Stalwart 1.0): make room and try once more.
if (submissionCreateError(res, 'mdn')?.type === 'overQuota') {
await makeRoomForSubmission(accountId, { automatic: opts.automatic });
res = await submit();
}
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
const refused = body.notCreated?.mdn as { type?: string; description?: string } | undefined;
if (refused?.type === 'overQuota') throw submissionQuotaError(refused.description, accountId);
assertSetResult(body, ['mdn'], 'read receipt');
submissionId = body.created?.mdn?.id as string | undefined;
} catch (err) {
await destroyEmails([emailId], accountId).catch((cleanupErr) => {
console.warn('[mdn] failed to remove the unsent receipt from Sent:', cleanupErr);
});
throw err;
}
// A receipt goes out without a hold, so its record can go at once, as after
// an immediate send (see sendEmail).
if (submissionId && isStalwartAccount(accountId)) {
await destroyEmailSubmissions([submissionId], accountId);
}
return emailId;
}
/**
* Copy blobs into another account of the login (RFC 8620 §6.3): the blob of
* an attachment in a shared or group mailbox, filed in the user's Files, or
* a file attached to a message of another account. Resolves with the new
* blobId for each. Stalwart answers Blob/copy only with the blob capability
* in `using`. Same account: the ids as they are, nothing is sent.
*/
export async function copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise<Record<string, string>> {
if (blobIds.length === 0 || fromAccountId === toAccountId) {
return Object.fromEntries(blobIds.map((id) => [id, id]));
}
const res = await jmapClient.request(
[['Blob/copy', { fromAccountId, accountId: toAccountId, blobIds }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.BLOB],
);
const body = requireMethodResult(res, '0', 'Blob/copy');
const copied = (body.copied ?? {}) as Record<string, string>;
const missing = blobIds.find((id) => !copied[id]);
if (missing) {
const error = (body.notCopied ?? {})[missing] as { type?: string; description?: string } | undefined;
throw new Error(error?.description || error?.type || 'Blob/copy did not copy the blob');
}
return copied;
}