mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
2523 lines
101 KiB
TypeScript
2523 lines
101 KiB
TypeScript
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;
|
||
}
|