mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
1487 lines
56 KiB
TypeScript
1487 lines
56 KiB
TypeScript
import { jmapClient } from './jmap-client';
|
||
import { assertSetResult, batched, requireMethodResult } from './jmap-result';
|
||
import { keywordPointer, mailboxPointer } from './patch-pointer';
|
||
import { CAPABILITIES } from './types';
|
||
import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
|
||
import { toWildcardQuery } from '../lib/search-utils';
|
||
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||
import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
|
||
import { buildMdnMessage, type MdnOptions } from '../lib/mdn';
|
||
|
||
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',
|
||
];
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
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();
|
||
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');
|
||
assertSetResult(body, slice.map(([id]) => id), what);
|
||
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');
|
||
assertSetResult(body, slice, what);
|
||
out.destroyed.push(...((body.destroyed as string[] | undefined) ?? []));
|
||
}
|
||
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 }, '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 },
|
||
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 }, '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`); 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;
|
||
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';
|
||
const create: Record<string, unknown> = {
|
||
name: data.name,
|
||
parentId: data.parentId ?? null,
|
||
};
|
||
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. */
|
||
export async function markMailboxAsRead(mailboxId: string, accountIdOverride?: string): Promise<number> {
|
||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||
const pageSize = Math.min(500, maxInSet());
|
||
let total = 0;
|
||
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;
|
||
if (marked === 0 || ids.length < pageSize) break;
|
||
}
|
||
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 };
|
||
}
|
||
|
||
export async function queryEmails(
|
||
mailboxId: string | undefined,
|
||
options?: {
|
||
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;
|
||
},
|
||
): Promise<{ ids: string[]; total: number; queryState?: string }> {
|
||
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;
|
||
const res = await jmapClient.request([['Email/query', args, '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,
|
||
};
|
||
}
|
||
|
||
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) — 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;
|
||
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 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 };
|
||
}
|
||
|
||
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; 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;
|
||
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,
|
||
};
|
||
|
||
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 email = (body.list as Email[] | undefined)?.[0];
|
||
if (!email) throw new Error(`Email ${id} not found`);
|
||
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[]> {
|
||
if (ids.length === 0) return [];
|
||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||
const out: Email[] = [];
|
||
for (const slice of batched(ids, maxInGet())) {
|
||
const res = await jmapClient.request([
|
||
['Email/get', { accountId, ids: slice, ...FULL_BODY_ARGS }, '0'],
|
||
]);
|
||
out.push(...((requireMethodResult(res, '0', 'Email/get').list as Email[]) ?? []));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* Every message of a thread with full bodies, oldest first, via a
|
||
* back-referenced Thread/get → Email/get. Powers the conversation view.
|
||
*/
|
||
export async function getThreadEmails(threadId: string, accountIdOverride?: string): Promise<Email[]> {
|
||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||
const res = await jmapClient.request([
|
||
['Thread/get', { accountId, ids: [threadId] }, '0'],
|
||
['Email/get', {
|
||
accountId,
|
||
'#ids': { resultOf: '0', name: 'Thread/get', path: '/list/*/emailIds' },
|
||
...FULL_BODY_ARGS,
|
||
}, '1'],
|
||
]);
|
||
const thread = (requireMethodResult(res, '0', 'Thread/get').list as Thread[] | undefined)?.[0];
|
||
const emails = (requireMethodResult(res, '1', 'Email/get').list as Email[]) ?? [];
|
||
if (!thread) return emails;
|
||
const order = new Map(thread.emailIds.map((id, i) => [id, i]));
|
||
return emails.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||
}
|
||
|
||
export async function setEmailKeywords(
|
||
emailId: string,
|
||
keywords: Record<string, boolean>,
|
||
accountIdOverride?: string,
|
||
): Promise<void> {
|
||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||
await emailSetBatched(accountId, { update: { [emailId]: { keywords } } });
|
||
}
|
||
|
||
/** 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: Record<string, unknown> = {};
|
||
for (const [keyword, value] of Object.entries(patch)) {
|
||
pointerPatch[keywordPointer(keyword)] = value === false ? null : value;
|
||
}
|
||
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 keyword maps to several emails in one round-trip (chunked).
|
||
export async function setKeywordsForEmails(
|
||
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
|
||
accountIdOverride?: string,
|
||
): Promise<void> {
|
||
if (updates.length === 0) return;
|
||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||
const update: Record<string, { keywords: Record<string, boolean> }> = {};
|
||
for (const u of updates) update[u.id] = { keywords: u.keywords };
|
||
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;
|
||
for (const slice of batched(ids, maxInSet())) {
|
||
const res = await jmapClient.request([['Email/set', { accountId, destroy: slice }, '0']]);
|
||
const body = requireMethodResult(res, '0', 'Email/set');
|
||
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];
|
||
throw new Error(
|
||
`Failed to delete ${real.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
const filter: Record<string, unknown> = { text: toWildcardQuery(query) };
|
||
if (mailboxId) filter.inMailbox = mailboxId;
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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) and a failed submission
|
||
* leaves it in Drafts instead of faking a sent copy.
|
||
*/
|
||
draftsMailboxId?: string;
|
||
/** Previous draft version to destroy once the submission succeeded (#849). */
|
||
draftId?: 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,
|
||
},
|
||
};
|
||
}
|
||
|
||
const res = await jmapClient.request(
|
||
[
|
||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||
['EmailSubmission/set', submissionArgs, '1'],
|
||
],
|
||
SUBMISSION_USING,
|
||
);
|
||
|
||
let emailId: string | undefined;
|
||
let emailSubmissionId: string | undefined;
|
||
let sendAt: string | undefined;
|
||
let filingWarning: string | undefined;
|
||
for (const [methodName, result] of res.methodResponses) {
|
||
if (methodName === 'error' || methodName.endsWith('/error')) {
|
||
throw new Error((result as { description?: string }).description ?? 'Send failed');
|
||
}
|
||
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) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to submit message');
|
||
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['sub-1'];
|
||
emailSubmissionId = created?.id;
|
||
sendAt = created?.sendAt;
|
||
}
|
||
}
|
||
|
||
// 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).
|
||
if (opts?.draftId && emailSubmissionId) {
|
||
try {
|
||
await destroyEmails([opts.draftId], accountId);
|
||
} catch (err) {
|
||
filingWarning = filingWarning ?? `old draft cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
|
||
}
|
||
}
|
||
|
||
return {
|
||
scheduled: !!(holdForSeconds && holdForSeconds > 0),
|
||
sendAt,
|
||
emailId,
|
||
emailSubmissionId,
|
||
filingWarning,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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). Returns the
|
||
* new draft id.
|
||
*/
|
||
export async function createDraft(
|
||
email: OutgoingEmail,
|
||
draftsMailboxId: string,
|
||
previousDraftId?: string,
|
||
accountIdOverride?: string,
|
||
): Promise<string> {
|
||
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'],
|
||
]);
|
||
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');
|
||
if (previousDraftId && previousDraftId !== id) {
|
||
try {
|
||
await destroyEmails([previousDraftId], accountId);
|
||
} catch (err) {
|
||
console.warn('[email] failed to destroy previous draft version:', err);
|
||
}
|
||
}
|
||
return id;
|
||
}
|
||
|
||
export interface ScheduledEmail {
|
||
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. Empty when the server lacks FUTURERELEASE.
|
||
export async function listScheduledEmails(): Promise<ScheduledEmail[]> {
|
||
if (!jmapClient.hasDelayedSend()) return [];
|
||
const accountId = jmapClient.accountId;
|
||
const now = Date.now();
|
||
|
||
const queryRes = await jmapClient.request(
|
||
[['EmailSubmission/query', { accountId, limit: 200 }, '0']],
|
||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||
);
|
||
const [queryName, queryBody] = queryRes.methodResponses[0];
|
||
if (queryName === 'error' || queryName.endsWith('/error')) return [];
|
||
const ids = (queryBody.ids as string[]) ?? [];
|
||
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 {
|
||
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)
|
||
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||
}
|
||
|
||
// Cancel a pending scheduled send. The held message copy stays in Sent; only
|
||
// delivery is stopped (matches the webmail behaviour).
|
||
export async function cancelScheduledSend(emailSubmissionId: string): Promise<void> {
|
||
const accountId = 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');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Change when a scheduled message goes out (or send it now with `holdForSeconds`
|
||
* = 0): cancel the pending submission and create a replacement for the same
|
||
* Email with a fresh HOLDFOR envelope, in one request. Returns the new
|
||
* submission id and resolved send time.
|
||
*/
|
||
export async function rescheduleScheduledSend(
|
||
scheduled: { emailSubmissionId: string; emailId: string; identityId: string; from?: EmailAddress[]; to?: EmailAddress[] },
|
||
holdForSeconds: number,
|
||
recipients?: EmailAddress[],
|
||
): Promise<{ emailSubmissionId?: string; sendAt?: string }> {
|
||
const accountId = jmapClient.accountId;
|
||
const create: Record<string, unknown> = { emailId: scheduled.emailId, identityId: scheduled.identityId };
|
||
if (holdForSeconds > 0) {
|
||
const rcpt = (recipients ?? scheduled.to ?? []).map((r) => ({ email: r.email.trim() })).filter((r) => r.email);
|
||
create.envelope = {
|
||
mailFrom: {
|
||
email: scheduled.from?.[0]?.email,
|
||
parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) },
|
||
},
|
||
rcptTo: rcpt,
|
||
};
|
||
}
|
||
const res = await jmapClient.request(
|
||
[['EmailSubmission/set', {
|
||
accountId,
|
||
update: { [scheduled.emailSubmissionId]: { undoStatus: 'canceled' } },
|
||
create: { replacement: create },
|
||
}, '0']],
|
||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||
);
|
||
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
|
||
const notUpdated = body.notUpdated?.[scheduled.emailSubmissionId] as { description?: string; type?: string } | undefined;
|
||
if (notUpdated) throw new Error(notUpdated.description ?? notUpdated.type ?? 'Failed to cancel the previous schedule');
|
||
const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined;
|
||
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to reschedule');
|
||
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
|
||
return { emailSubmissionId: created?.id, sendAt: created?.sendAt };
|
||
}
|
||
|
||
/**
|
||
* 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`. 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);
|
||
const res = await jmapClient.request(
|
||
[
|
||
['EmailSubmission/set', {
|
||
accountId,
|
||
create: {
|
||
mdn: {
|
||
emailId,
|
||
identityId: opts.identityId,
|
||
envelope: {
|
||
mailFrom: { email: opts.fromEmail },
|
||
rcptTo: [{ email: opts.to }],
|
||
},
|
||
},
|
||
},
|
||
}, '0'],
|
||
],
|
||
SUBMISSION_USING,
|
||
);
|
||
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
|
||
assertSetResult(body, ['mdn'], 'read receipt');
|
||
return emailId;
|
||
}
|