Merge pull request #509 from hildebrandttk/feat/unified-mailbox-account-scope
Feat/unified mailbox account scope Rework the sidebar "All accounts" into an account-bounded "Unified Mailbox" by default, with cross-account merging as an opt-in (admin-gated) sub-option. The standalone per-account "All Mail" virtual folder is folded into the unified All mail / Unread / Starred entries. Conflict resolution notes: - stores/settings-store.ts: both main and this branch independently added a per-account default-identity (#507) migration at different versions (main v6, branch v7). Merged migration is version 7 using the refactored migrateSettings function; the unified-mailbox rework is guarded at `version < 7` so users who stopped at main's interim v6 identity bump still receive it, while the #507 identity-map coercion stays at `version < 6` so their populated map is kept. - stores/auth-store.ts: kept main's applyPreferredIdentity (superset with the pre-#507 legacy migration). - stores/email-store.ts: removed the ALL_MAIL_MAILBOX_ID paths (folded into the unified views) while preserving main's plugin hooks (onSearchResults / onEmailsFetched); adopted advancedSearchCrossViewEmails for advanced cross-view search. - components/settings/layout-settings.tsx: kept main's faviconUnreadBadge setting alongside the new unifiedCrossAccount toggle. - integration/: union-merged the two independently-authored suites - branch suite is authoritative (matches new behavior) with main's shared-identity (#569) group infrastructure preserved. - components/email/email-composer.tsx: dropped a duplicate data-testid attribute introduced by the auto-merge.
This commit is contained in:
@@ -36,21 +36,41 @@ export async function neutralizeDevOverlay(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the cross-account Unified Mailbox before the app boots by seeding the
|
||||
* persisted settings store. Requires the `unifiedCrossAccountEnabled` admin
|
||||
* feature gate (provided by integration/webmail-config/policy.json). Must be
|
||||
* called before {@link login} so the init script is registered before the
|
||||
* first navigation.
|
||||
* Seed the persisted settings store before the app boots. Merges over the
|
||||
* store defaults on rehydrate. Must be called before {@link login} so the init
|
||||
* script is registered before the first navigation.
|
||||
*/
|
||||
export async function seedSettings(page: Page, settings: Record<string, unknown>): Promise<void> {
|
||||
await page.addInitScript((s) => {
|
||||
localStorage.setItem('settings-storage', JSON.stringify({ state: s, version: 7 }));
|
||||
}, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the cross-account Unified Mailbox. Requires the
|
||||
* `unifiedCrossAccountEnabled` admin feature gate (provided by
|
||||
* integration/webmail-config/policy.json).
|
||||
*/
|
||||
export async function seedUnifiedSettings(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'settings-storage',
|
||||
JSON.stringify({
|
||||
state: { enableUnifiedMailbox: true, unifiedCrossAccount: true, includeGroupInUnified: true },
|
||||
version: 7,
|
||||
}),
|
||||
);
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
unifiedCrossAccount: true,
|
||||
includeGroupInUnified: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the "All Mail" view. `crossAccount` spans every logged-in account
|
||||
* (requires the `unifiedCrossAccountEnabled` gate); otherwise it is account-
|
||||
* bounded (spans the active account's own + shared folders). The "All mail"
|
||||
* entry itself is gated by `crossAllViewEnabled` (also in policy.json).
|
||||
*/
|
||||
export async function seedAllMailSettings(page: Page, opts: { crossAccount?: boolean } = {}): Promise<void> {
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
enableCrossAllView: true,
|
||||
includeGroupInUnified: true,
|
||||
unifiedCrossAccount: !!opts.crossAccount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,10 +137,75 @@ export async function forceSync(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
|
||||
}
|
||||
|
||||
// ─── Composer / drafts ────────────────────────────────────────────────────
|
||||
|
||||
/** Open the composer via the keyboard shortcut and wait for it to render. */
|
||||
export async function openComposer(page: Page): Promise<void> {
|
||||
await page.keyboard.press('c');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
|
||||
}
|
||||
|
||||
/** Add a recipient to the To field (commits it as a chip with Enter). */
|
||||
export async function addRecipient(page: Page, email: string): Promise<void> {
|
||||
const input = page.locator('[data-testid="composer-to"] input').first();
|
||||
await input.click();
|
||||
await input.fill(email);
|
||||
await input.press('Enter');
|
||||
}
|
||||
|
||||
/** Select a sending identity in the From dropdown by its identity id. */
|
||||
export async function setFrom(page: Page, identityId: string): Promise<void> {
|
||||
await page.locator('[data-testid="composer-from"]').selectOption({ value: identityId });
|
||||
}
|
||||
|
||||
/** Fill the subject field. */
|
||||
export async function setSubject(page: Page, subject: string): Promise<void> {
|
||||
await page.locator('[data-testid="composer-subject"]').fill(subject);
|
||||
}
|
||||
|
||||
/** Wait until the composer reports the draft as saved. */
|
||||
export async function waitDraftSaved(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="composer-save-status"]')).toHaveAttribute('data-status', 'saved', {
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Close the composer (draft is auto-saved). */
|
||||
export async function closeComposer(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Recipient chips currently shown in the composer's To field. */
|
||||
export async function composerRecipients(page: Page): Promise<string[]> {
|
||||
const to = page.locator('[data-testid="composer-to"]');
|
||||
const text = (await to.innerText()).toLowerCase();
|
||||
return text.split(/\s+/).filter((t) => t.includes('@'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender addresses the composer's From control offers.
|
||||
*
|
||||
* With more than one identity the control is a <select> and each choice is an
|
||||
* <option>; with a single identity it collapses to a static <span> that shows
|
||||
* only that address. Returning the raw text of whichever is rendered lets a
|
||||
* test assert on the *set of senders* without caring which shape it took.
|
||||
*/
|
||||
export async function composerFromOptions(page: Page): Promise<string[]> {
|
||||
const from = page.locator('[data-testid="composer-from"]').first();
|
||||
await from.waitFor({ state: 'visible', timeout: 10000 });
|
||||
if ((await from.locator('option').count()) > 0) {
|
||||
return from.locator('option').allTextContents();
|
||||
}
|
||||
return [await from.innerText()];
|
||||
}
|
||||
|
||||
export interface FolderSelector {
|
||||
role?: string;
|
||||
name?: string;
|
||||
mailboxId?: string;
|
||||
/** true = only shared-account folders, false = only own folders. */
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
/** Locator for a sidebar folder row. */
|
||||
@@ -129,9 +214,24 @@ export function folderRow(page: Page, sel: FolderSelector): Locator {
|
||||
if (sel.role) s += `[data-folder-role="${sel.role}"]`;
|
||||
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
|
||||
if (sel.mailboxId) s += `[data-mailbox-id="${sel.mailboxId}"]`;
|
||||
if (sel.shared === true) s += '[data-shared="true"]';
|
||||
if (sel.shared === false) s += ':not([data-shared="true"])';
|
||||
return page.locator(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the sidebar "Shared" section and the given sharer's shared-account
|
||||
* group so its folders (data-shared="true") render. Idempotent.
|
||||
*/
|
||||
export async function expandSharedFolders(page: Page, sharerEmail: string): Promise<void> {
|
||||
const section = page.locator('[data-testid="section-shared"]');
|
||||
await section.waitFor({ state: 'visible', timeout: 30000 });
|
||||
if ((await section.getAttribute('data-expanded')) !== 'true') await section.click();
|
||||
const account = page.locator(`[data-testid="section-shared-account"][data-section-name="${sharerEmail}"]`);
|
||||
await account.waitFor({ state: 'visible', timeout: 30000 });
|
||||
if ((await account.getAttribute('data-expanded')) !== 'true') await account.click();
|
||||
}
|
||||
|
||||
export interface FolderCounts {
|
||||
unread: number;
|
||||
total: number;
|
||||
@@ -157,6 +257,31 @@ export async function expectFolderUnread(page: Page, sel: FolderSelector, expect
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
/** The JMAP (UI) mailbox id backing a folder row — namespaced for shared folders. */
|
||||
export async function folderMailboxId(page: Page, sel: FolderSelector): Promise<string> {
|
||||
const id = await folderRow(page, sel).first().getAttribute('data-mailbox-id');
|
||||
if (!id) throw new Error(`folder ${JSON.stringify(sel)} has no data-mailbox-id`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an email to `destMailboxId` (a UI mailbox id, e.g. from
|
||||
* {@link folderMailboxId}) via the list context menu's "Move to" submenu.
|
||||
*/
|
||||
export async function moveEmailTo(page: Page, subject: string, destMailboxId: string): Promise<void> {
|
||||
const row = emailItem(page, subject).first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const submenu = page.locator('[data-testid="ctx-move-to"]');
|
||||
await expect(async () => {
|
||||
await row.click({ button: 'right' });
|
||||
await submenu.waitFor({ state: 'visible', timeout: 2000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
await submenu.hover();
|
||||
const target = page.locator(`[data-testid="move-to:${destMailboxId}"]`);
|
||||
await target.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await target.click();
|
||||
}
|
||||
|
||||
/** Poll until a folder's total count reaches `expected`. */
|
||||
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
||||
await expect
|
||||
@@ -164,36 +289,39 @@ export async function expectFolderTotal(page: Page, sel: FolderSelector, expecte
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a folder's counts, nudging a reconcile (visibilitychange ->
|
||||
* checkForStateChanges) before *every* poll. Use for counters that update via
|
||||
* reconcile rather than live SSE push — after a server-side move/delete, a
|
||||
* mark-as-spam, or a shared-account change — where a single missed reconcile
|
||||
* would otherwise flake. Only the provided fields are compared.
|
||||
*/
|
||||
export async function expectFolderCountsSynced(
|
||||
page: Page,
|
||||
sel: FolderSelector,
|
||||
expected: { unread?: number; total?: number },
|
||||
timeout = 45000,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await forceSync(page);
|
||||
const c = await folderCounts(page, sel);
|
||||
return {
|
||||
...(expected.unread !== undefined ? { unread: c.unread } : {}),
|
||||
...(expected.total !== undefined ? { total: c.total } : {}),
|
||||
};
|
||||
},
|
||||
{ timeout, intervals: [500, 1000, 1500, 2000, 2000, 3000] },
|
||||
)
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
/** Click a folder row to select it. */
|
||||
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
|
||||
await folderRow(page, sel).first().click();
|
||||
}
|
||||
|
||||
/** Open the "New message" composer and wait for it to render. */
|
||||
export async function openComposer(page: Page): Promise<Locator> {
|
||||
await page.locator('[data-tour="compose-button"]').first().click();
|
||||
const composer = page.locator('[data-testid="email-composer"]');
|
||||
await composer.waitFor({ state: 'visible', timeout: 15000 });
|
||||
return composer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender addresses the composer's From control offers.
|
||||
*
|
||||
* With more than one identity the control is a <select> and each choice is an
|
||||
* <option>; with a single identity it collapses to a static <span> that shows
|
||||
* only that address. Returning the raw text of whichever is rendered lets a
|
||||
* test assert on the *set of senders* without caring which shape it took.
|
||||
*/
|
||||
export async function composerFromOptions(page: Page): Promise<string[]> {
|
||||
const from = page.locator('[data-testid="composer-from"]').first();
|
||||
await from.waitFor({ state: 'visible', timeout: 10000 });
|
||||
if ((await from.locator('option').count()) > 0) {
|
||||
return from.locator('option').allTextContents();
|
||||
}
|
||||
return [await from.innerText()];
|
||||
}
|
||||
|
||||
/** Locator for an email row by (exact) subject. */
|
||||
export function emailItem(page: Page, subject: string): Locator {
|
||||
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
|
||||
@@ -203,3 +331,27 @@ export function emailItem(page: Page, subject: string): Locator {
|
||||
export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise<void> {
|
||||
await expect(emailItem(page, subject).first()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert an email row's unread state (from its `data-unread` attribute). */
|
||||
export async function expectEmailUnread(page: Page, subject: string, unread: boolean, timeout = 20000): Promise<void> {
|
||||
await expect(emailItem(page, subject).first()).toHaveAttribute('data-unread', String(unread), { timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an email's right-click context menu and click one of its actions.
|
||||
* `testId` is one of: `ctx-delete`, `ctx-spam`, `ctx-not-spam`,
|
||||
* `ctx-mark-read`, `ctx-mark-unread`.
|
||||
*/
|
||||
export async function emailContextAction(page: Page, subject: string, testId: string): Promise<void> {
|
||||
const row = emailItem(page, subject).first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
await row.scrollIntoViewIfNeeded();
|
||||
const item = page.locator(`[data-testid="${testId}"]`);
|
||||
// Right-click can occasionally land before the list row is interactive;
|
||||
// retry opening the menu until the action item is actually present.
|
||||
await expect(async () => {
|
||||
await row.click({ button: 'right' });
|
||||
await item.waitFor({ state: 'visible', timeout: 2000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
@@ -12,9 +12,22 @@ import { JMAP_URL } from './config';
|
||||
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
// Identity/* lives under the submission capability, not mail.
|
||||
const PRINCIPALS = 'urn:ietf:params:jmap:principals';
|
||||
const SUBMISSION = 'urn:ietf:params:jmap:submission';
|
||||
|
||||
/** Rights granted on a shared mailbox (JMAP ACL). */
|
||||
export const FULL_MAILBOX_RIGHTS = {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
};
|
||||
|
||||
interface JmapMailbox {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -65,16 +78,91 @@ export class JmapClient {
|
||||
.map(([, name]) => name);
|
||||
}
|
||||
|
||||
async request(methodCalls: MethodCall[]): Promise<any> {
|
||||
async request(methodCalls: MethodCall[], using: string[] = [CORE, MAIL, SUBMISSION]): Promise<any> {
|
||||
const res = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ using: [CORE, MAIL, SUBMISSION], methodCalls }),
|
||||
body: JSON.stringify({ using, methodCalls }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** All sending identities of this account. */
|
||||
async identities(): Promise<Array<{ id: string; name: string; email: string }>> {
|
||||
const r = await this.request([['Identity/get', { accountId: this.accountId }, '0']], [CORE, SUBMISSION]);
|
||||
return r.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a second sending identity `name <email>` exists (idempotent by
|
||||
* name). Returns its id. Used to make the composer's From selector appear so
|
||||
* a changed sender can be exercised.
|
||||
*/
|
||||
async ensureIdentity(name: string, email: string): Promise<string> {
|
||||
const existing = (await this.identities()).find((i) => i.name === name);
|
||||
if (existing) return existing.id;
|
||||
const r = await this.request(
|
||||
[['Identity/set', { accountId: this.accountId, create: { alt: { name, email, replyTo: null } } }, '0']],
|
||||
[CORE, SUBMISSION],
|
||||
);
|
||||
const created = r.methodResponses[0][1].created?.alt;
|
||||
if (!created) throw new Error(`Identity/set failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Resolve another user's principal id (needed as the key in `shareWith`). */
|
||||
async principalIdByEmail(email: string): Promise<string> {
|
||||
const r = await this.request(
|
||||
[
|
||||
['Principal/query', { accountId: this.accountId, filter: { email } }, '0'],
|
||||
['Principal/get', { accountId: this.accountId, '#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' } }, '1'],
|
||||
],
|
||||
[CORE, PRINCIPALS],
|
||||
);
|
||||
const list = r.methodResponses[1][1].list as Array<{ id: string; email?: string }>;
|
||||
const match = list.find((p) => p.email === email) ?? list[0];
|
||||
if (!match) throw new Error(`No principal found for ${email}`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a folder in this account and share it with `granteeEmail`. Returns
|
||||
* the new mailbox id. The grantee then sees this account as a shared account
|
||||
* in their JMAP session.
|
||||
*/
|
||||
async createSharedFolder(name: string, granteeEmail: string): Promise<string> {
|
||||
const principalId = await this.principalIdByEmail(granteeEmail);
|
||||
const r = await this.request([
|
||||
['Mailbox/set', {
|
||||
accountId: this.accountId,
|
||||
create: { shared: { name, shareWith: { [principalId]: FULL_MAILBOX_RIGHTS } } },
|
||||
}, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.shared;
|
||||
if (!created) throw new Error(`createSharedFolder failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Grant `granteeEmail` access to an existing mailbox of this account. */
|
||||
async shareMailbox(mailboxId: string, granteeEmail: string): Promise<void> {
|
||||
const principalId = await this.principalIdByEmail(granteeEmail);
|
||||
await this.request([
|
||||
['Mailbox/set', {
|
||||
accountId: this.accountId,
|
||||
update: { [mailboxId]: { [`shareWith/${principalId}`]: FULL_MAILBOX_RIGHTS } },
|
||||
}, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** Grant `granteeEmail` access to a system folder (by role) of this account. */
|
||||
async shareMailboxByRole(role: string, granteeEmail: string): Promise<string> {
|
||||
const mb = await this.mailboxByRole(role);
|
||||
if (!mb) throw new Error(`No ${role} mailbox to share`);
|
||||
await this.shareMailbox(mb.id, granteeEmail);
|
||||
return mb.id;
|
||||
}
|
||||
|
||||
async mailboxes(): Promise<JmapMailbox[]> {
|
||||
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
|
||||
return r.methodResponses[0][1].list as JmapMailbox[];
|
||||
@@ -130,6 +218,52 @@ export class JmapClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Move an email so it lives solely in `toMailboxId`. */
|
||||
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||
await this.request([
|
||||
['Email/set', { accountId: this.accountId, update: { [emailId]: { mailboxIds: { [toMailboxId]: true } } } }, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** Deliver-and-file: create/find a custom folder and drop a message id into it. */
|
||||
async moveEmailToFolder(emailId: string, folderName: string): Promise<string> {
|
||||
const id = await this.createMailbox(folderName);
|
||||
await this.moveEmail(emailId, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Create a draft message (with the $draft keyword) in the Drafts folder. */
|
||||
async createDraft(subject: string, toEmail: string): Promise<string> {
|
||||
const drafts = await this.mailboxByRole('drafts');
|
||||
if (!drafts) throw new Error('No Drafts mailbox');
|
||||
const r = await this.request([
|
||||
['Email/set', {
|
||||
accountId: this.accountId,
|
||||
create: {
|
||||
d: {
|
||||
mailboxIds: { [drafts.id]: true },
|
||||
keywords: { $draft: true },
|
||||
from: [{ email: this.email }],
|
||||
to: [{ email: toEmail }],
|
||||
subject,
|
||||
bodyValues: { b: { value: 'server-created draft body' } },
|
||||
textBody: [{ partId: 'b', type: 'text/plain' }],
|
||||
},
|
||||
},
|
||||
}, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.d;
|
||||
if (!created) throw new Error(`createDraft failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Set or clear the $seen keyword on an email. */
|
||||
async setSeen(emailId: string, seen: boolean): Promise<void> {
|
||||
await this.request([
|
||||
['Email/set', { accountId: this.accountId, update: { [emailId]: { [`keywords/$seen`]: seen ? true : null } } }, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** Look up an email id by subject within an optional mailbox. */
|
||||
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
|
||||
const filter: Record<string, unknown> = { subject };
|
||||
@@ -139,7 +273,7 @@ export class JmapClient {
|
||||
['Email/get', {
|
||||
accountId: this.accountId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'preview'],
|
||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'to', 'preview'],
|
||||
}, '1'],
|
||||
]);
|
||||
return r.methodResponses[1][1].list[0];
|
||||
|
||||
@@ -24,6 +24,13 @@ interface SendOptions {
|
||||
body: string;
|
||||
/** Extra headers (e.g. custom Message-ID / In-Reply-To for threading). */
|
||||
headers?: Record<string, string>;
|
||||
/** Optional single attachment (sent as multipart/mixed, base64). */
|
||||
attachment?: { filename: string; contentType: string; content: string };
|
||||
/**
|
||||
* Optional inline image referenced by the HTML body via `cid:<cid>`. Sent as
|
||||
* multipart/related; `base64` is the pre-encoded image payload.
|
||||
*/
|
||||
inlineImage?: { cid: string; contentType: string; base64: string; html: string };
|
||||
}
|
||||
|
||||
class SmtpError extends Error {}
|
||||
@@ -110,14 +117,57 @@ export async function sendMail(opts: SendOptions): Promise<void> {
|
||||
From: opts.from,
|
||||
To: recipients.join(', '),
|
||||
Subject: opts.subject,
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
...opts.headers,
|
||||
};
|
||||
|
||||
let mime: string;
|
||||
if (opts.inlineImage) {
|
||||
const boundary = 'itrelated_boundary_0001';
|
||||
headers['MIME-Version'] = '1.0';
|
||||
headers['Content-Type'] = `multipart/related; boundary="${boundary}"`;
|
||||
const b64 = opts.inlineImage.base64.replace(/(.{76})/g, '$1\r\n');
|
||||
mime = [
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset=utf-8',
|
||||
'',
|
||||
crlf(opts.inlineImage.html),
|
||||
`--${boundary}`,
|
||||
`Content-Type: ${opts.inlineImage.contentType}`,
|
||||
`Content-ID: <${opts.inlineImage.cid}>`,
|
||||
'Content-Disposition: inline',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
b64,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
} else if (opts.attachment) {
|
||||
const boundary = 'itmixed_boundary_0001';
|
||||
headers['MIME-Version'] = '1.0';
|
||||
headers['Content-Type'] = `multipart/mixed; boundary="${boundary}"`;
|
||||
const b64 = Buffer.from(opts.attachment.content).toString('base64').replace(/(.{76})/g, '$1\r\n');
|
||||
mime = [
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
crlf(opts.body),
|
||||
`--${boundary}`,
|
||||
`Content-Type: ${opts.attachment.contentType}; name="${opts.attachment.filename}"`,
|
||||
`Content-Disposition: attachment; filename="${opts.attachment.filename}"`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
b64,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
} else {
|
||||
headers['Content-Type'] = 'text/plain; charset=utf-8';
|
||||
mime = crlf(opts.body);
|
||||
}
|
||||
|
||||
const headerBlock = Object.entries(headers)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\r\n');
|
||||
// Dot-stuff any line that begins with '.'
|
||||
const safeBody = crlf(opts.body).replace(/\r\n\./g, '\r\n..');
|
||||
const safeBody = mime.replace(/\r\n\./g, '\r\n..');
|
||||
send(`${headerBlock}\r\n\r\n${safeBody}\r\n.`);
|
||||
await waitReply('250');
|
||||
send('QUIT');
|
||||
|
||||
Reference in New Issue
Block a user