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:
+38
-1
@@ -80,9 +80,46 @@ integration/
|
||||
│ └── app.ts # login, add/switch account, folder-counter reads
|
||||
├── 01-login.spec.ts
|
||||
├── 02-mail-sync.spec.ts # single-account: receive/read/move/delete/folder-create
|
||||
└── 03-multi-account.spec.ts # isolation + cross-account Unified Inbox aggregation
|
||||
├── 03-multi-account.spec.ts # isolation + cross-account Unified Inbox aggregation
|
||||
├── 04-all-mail.spec.ts # All Mail view: single-account merge + cross-account
|
||||
├── 04-shared-identity.spec.ts# composer From offers shared/group send-as identities (issue #569)
|
||||
├── 05-actions.spec.ts # context-menu read/unread, delete, spam (inbox)
|
||||
├── 06-shared-folders.spec.ts # delegated folder: appears + read/unread/delete/spam
|
||||
├── 07-drafts.spec.ts # multiple recipients, changed sender, continue-draft button
|
||||
├── 08-shared-moves.spec.ts # moving mail across own/shared and shared/shared
|
||||
├── 09-live-counters.spec.ts # live unified/All-Mail counters (login + shared)
|
||||
└── 10-attachments.spec.ts # cross-account attachment download from All Mail
|
||||
```
|
||||
|
||||
## Findings surfaced by the suite
|
||||
|
||||
Some tests assert server-side truth (or use `test.fail` to pin a known gap)
|
||||
because the UI behaviour is currently incomplete. Worth a look:
|
||||
|
||||
- **Shared-account counters now reconcile on focus/interval** (`09-live-counters`).
|
||||
Stalwart's SSE only pushes StateChange for the *primary* account, so a
|
||||
background change in a shared/delegated account is never pushed. The client
|
||||
now also polls the session's secondary accounts, so their folder badges and
|
||||
the unified/All-Mail counter refresh on the visibility reconcile and on a slow
|
||||
background poll. (A *login* account already updates live via its own SSE.)
|
||||
Note: these shared counters still don't update the instant a local action
|
||||
runs — they follow the reconcile, not the optimistic path.
|
||||
- **`mark-as-spam` doesn't optimistically decrement the source counter** the
|
||||
way `delete` does; it settles after a reconcile.
|
||||
- **Reopening a draft resets the From selector** to the default identity even
|
||||
though the draft was saved with (and the server retains) the chosen sender.
|
||||
Pinned with `test.fail` in `07-drafts`.
|
||||
- **Cross-account moves (own ⇆ shared folder) don't relocate the message.** The
|
||||
"Move to" submenu offers the shared folder, but clicking it is a no-op.
|
||||
Shared ⇆ shared (same owner) moves work. Pinned with `test.fail` in
|
||||
`08-shared-moves`.
|
||||
- **Cross-account attachments & inline images (fixed).** Blobs are account-
|
||||
scoped, so viewing/downloading/previewing an attachment, rendering an inline
|
||||
`cid:` image, dragging out, and the bundle/S-MIME/TNEF/embedded-message
|
||||
fetches on an All-Mail message from another account 404'd against the active
|
||||
account. Every viewer blob fetch now routes to the message's owning client +
|
||||
accountId (`10-attachments` covers download + inline image).
|
||||
|
||||
## How the tests work
|
||||
|
||||
- **Mutations** are made out-of-band — mail is injected over SMTP
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
folderCounts,
|
||||
expectFolderUnread,
|
||||
expectFolderTotal,
|
||||
expectFolderCountsSynced,
|
||||
emailItem,
|
||||
expectEmailVisible,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
@@ -82,11 +82,12 @@ test.describe('Single-account sync', () => {
|
||||
await jmap.request([
|
||||
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
|
||||
]);
|
||||
await forceSync(page);
|
||||
|
||||
// Source Inbox drains, destination gains the message.
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
||||
await expectFolderTotal(page, { name: 'Archive2' }, 1);
|
||||
// Source Inbox drains, destination gains the message. These follow a
|
||||
// reconcile (not live push), so nudge one before every poll to stay robust
|
||||
// against a single missed reconcile under load.
|
||||
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0 });
|
||||
await expectFolderCountsSynced(page, { name: 'Archive2' }, { total: 1 });
|
||||
expect(inbox).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -99,13 +100,12 @@ test.describe('Single-account sync', () => {
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||
|
||||
await jmap.request([['Email/set', { accountId: jmap.accountId, destroy: [email.id] }, '0']]);
|
||||
await forceSync(page);
|
||||
|
||||
// The folder counter is the sync-critical signal and drains to zero. (The
|
||||
// already-rendered list view is not re-queried on a background delete, so
|
||||
// we don't assert on the row disappearing here.)
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 0);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
||||
// The folder counter is the sync-critical signal and drains to zero (via a
|
||||
// reconcile, nudged before every poll). (The already-rendered list view is
|
||||
// not re-queried on a background delete, so we don't assert on the row
|
||||
// disappearing here.)
|
||||
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0, total: 0 });
|
||||
});
|
||||
|
||||
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
seedUnifiedSettings,
|
||||
folderRow,
|
||||
expectFolderUnread,
|
||||
expectFolderTotal,
|
||||
forceSync,
|
||||
expectFolderCountsSynced,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
@@ -49,18 +48,17 @@ test.describe('Multi-account sync', () => {
|
||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
|
||||
|
||||
await addAccount(page, bob);
|
||||
await forceSync(page);
|
||||
// Both accounts are now registered in the switcher.
|
||||
await accountSwitcher(page).click();
|
||||
await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Active = bob: his own Inbox shows 1 unread — alice's 2 don't leak in.
|
||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1);
|
||||
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
|
||||
|
||||
// Switch back to alice: her count is intact.
|
||||
await switchAccount(page, alice.email);
|
||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
|
||||
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 2 });
|
||||
});
|
||||
|
||||
test('the cross-account Unified Inbox aggregates unread across accounts', async ({ page }) => {
|
||||
@@ -70,35 +68,26 @@ test.describe('Multi-account sync', () => {
|
||||
await seedUnifiedSettings(page);
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob);
|
||||
await forceSync(page);
|
||||
|
||||
// Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox
|
||||
// (bob) still reports just its own 1.
|
||||
await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible();
|
||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 2);
|
||||
await expectFolderTotal(page, { name: 'unified-inbox' }, 2);
|
||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1);
|
||||
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 2, total: 2 });
|
||||
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
|
||||
});
|
||||
|
||||
// fixme: the unified counter for a *background* (non-active) account is not
|
||||
// updated live on this branch — the background-push counter fix lives in the
|
||||
// unified-mailbox feature commits (single-source unified counters / keep
|
||||
// unified counters current for shared accounts), which sit on
|
||||
// feat/unified-mailbox-account-scope, not on this harness-only base branch.
|
||||
test.fixme('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
|
||||
test('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
|
||||
await seedUnifiedSettings(page);
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob); // bob is now the active account
|
||||
await forceSync(page);
|
||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 0);
|
||||
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 0 });
|
||||
|
||||
// Mail lands in alice's inbox while bob is the active account.
|
||||
await send(alice, subj('bg'));
|
||||
await forceSync(page);
|
||||
|
||||
// The unified counter reflects the background account's new mail.
|
||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 1);
|
||||
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 1 });
|
||||
// bob (active) own Inbox is unaffected.
|
||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 0);
|
||||
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
addAccount,
|
||||
seedAllMailSettings,
|
||||
folderRow,
|
||||
openFolder,
|
||||
expectFolderCountsSynced,
|
||||
expectEmailVisible,
|
||||
emailItem,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* The "All Mail" view — a virtual folder that merges messages across an
|
||||
* account's folders (Inbox + custom, excluding junk/sent/trash/drafts/archive),
|
||||
* and across every logged-in account when the cross-account sub-option is on.
|
||||
*/
|
||||
const { alice, bob } = ACCOUNTS;
|
||||
const ALL_MAIL = '__cross_all__';
|
||||
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
const send = (to: typeof alice, subject: string) =>
|
||||
sendMail({ from: to.email, authPass: to.password, to: to.email, subject, body: 'x' });
|
||||
|
||||
test.describe('All Mail — single account', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('merges Inbox + custom folders and excludes Junk', async ({ page }) => {
|
||||
const inboxSubj = subj('am-inbox');
|
||||
const folderSubj = subj('am-folder');
|
||||
const junkSubj = subj('am-junk');
|
||||
await send(alice, inboxSubj);
|
||||
await send(alice, folderSubj);
|
||||
await send(alice, junkSubj);
|
||||
|
||||
// File one into a custom folder and one into Junk (excluded from All Mail).
|
||||
const folderMail = await jmap.waitForEmail(folderSubj);
|
||||
await jmap.moveEmailToFolder(folderMail.id, 'Projects');
|
||||
const junkMail = await jmap.waitForEmail(junkSubj);
|
||||
const junk = await jmap.mailboxByRole('junk');
|
||||
await jmap.moveEmail(junkMail.id, junk!.id);
|
||||
|
||||
await seedAllMailSettings(page, { crossAccount: false });
|
||||
await login(page, alice);
|
||||
|
||||
// The All Mail entry is present and shows the two included-folder unreads.
|
||||
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
||||
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
|
||||
|
||||
// Its list merges the Inbox and custom-folder messages, but not Junk.
|
||||
await openFolder(page, { name: ALL_MAIL });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, inboxSubj);
|
||||
await expectEmailVisible(page, folderSubj);
|
||||
await expect(emailItem(page, junkSubj)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('All Mail — cross account', () => {
|
||||
test.beforeEach(async () => {
|
||||
for (const a of [alice, bob]) {
|
||||
const j = await JmapClient.connect(a.email, a.password);
|
||||
await j.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test('merges mail from every logged-in account', async ({ page }) => {
|
||||
const aSubj = subj('am-a');
|
||||
const bSubj = subj('am-b');
|
||||
await send(alice, aSubj);
|
||||
await send(bob, bSubj);
|
||||
|
||||
await seedAllMailSettings(page, { crossAccount: true });
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob);
|
||||
|
||||
// All Mail aggregates unread across both accounts (alice 1 + bob 1).
|
||||
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
||||
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
|
||||
|
||||
await openFolder(page, { name: ALL_MAIL });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, aSubj);
|
||||
await expectEmailVisible(page, bSubj);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
expectFolderUnread,
|
||||
expectFolderTotal,
|
||||
expectFolderCountsSynced,
|
||||
expectEmailVisible,
|
||||
expectEmailUnread,
|
||||
emailContextAction,
|
||||
emailItem,
|
||||
openFolder,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Message actions from the list context menu — mark read/unread, delete, spam —
|
||||
* performed in the Inbox, with the outcome checked on both the UI (counters,
|
||||
* row state) and the server (which mailbox the message ended up in).
|
||||
*/
|
||||
const alice = ACCOUNTS.alice;
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
const send = (subject: string) =>
|
||||
sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
|
||||
|
||||
test.describe('Inbox message actions', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('mark read then unread toggles the row state and Inbox unread counter', async ({ page }) => {
|
||||
const s = subj('act-read');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 1);
|
||||
await expectEmailUnread(page, s, true);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-read');
|
||||
await expectEmailUnread(page, s, false);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-unread');
|
||||
await expectEmailUnread(page, s, true);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 1);
|
||||
});
|
||||
|
||||
test('delete moves the message to Trash and updates both counters', async ({ page }) => {
|
||||
const s = subj('act-del');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-delete');
|
||||
|
||||
// Leaves the Inbox, lands in Trash — on the UI...
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 0);
|
||||
await expectFolderTotal(page, { role: 'trash' }, 1);
|
||||
await expect(emailItem(page, s)).toHaveCount(0);
|
||||
|
||||
// ...and on the server.
|
||||
const trash = await jmap.mailboxByRole('trash');
|
||||
const found = await jmap.findEmailBySubject(s, trash!.id);
|
||||
expect(found, 'deleted message is in Trash on the server').toBeTruthy();
|
||||
});
|
||||
|
||||
test('mark as spam moves the message to Junk', async ({ page }) => {
|
||||
const s = subj('act-spam');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-spam');
|
||||
|
||||
// The destination (Junk) counter updates optimistically, but the source
|
||||
// (Inbox) counter isn't always decremented until the next reconcile when
|
||||
// the action fires moments after login — unlike delete, which decrements
|
||||
// the source immediately. The synced assertion nudges a reconcile per poll.
|
||||
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
|
||||
await expectFolderCountsSynced(page, { role: 'inbox' }, { total: 0 });
|
||||
|
||||
const junk = await jmap.mailboxByRole('junk');
|
||||
const found = await jmap.findEmailBySubject(s, junk!.id);
|
||||
expect(found, 'spammed message is in Junk on the server').toBeTruthy();
|
||||
});
|
||||
|
||||
test('spam then not-spam round-trips the message back out of Junk', async ({ page }) => {
|
||||
const s = subj('act-notspam');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await emailContextAction(page, s, 'ctx-spam');
|
||||
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
|
||||
|
||||
// Open Junk, then mark not-spam.
|
||||
await openFolder(page, { role: 'junk' });
|
||||
await expectEmailVisible(page, s);
|
||||
await emailContextAction(page, s, 'ctx-not-spam');
|
||||
|
||||
// The message leaves the open Junk list (optimistic) and round-trips on the
|
||||
// server: out of Junk, back in Inbox. (Asserted on the optimistic list +
|
||||
// authoritative server state rather than the Junk badge, whose reconcile
|
||||
// can stall under heavy concurrent load.)
|
||||
await expect(emailItem(page, s)).toHaveCount(0);
|
||||
const junk = await jmap.mailboxByRole('junk');
|
||||
const inbox = await jmap.mailboxByRole('inbox');
|
||||
expect(await jmap.findEmailBySubject(s, junk!.id), 'message no longer in Junk').toBeFalsy();
|
||||
expect(await jmap.findEmailBySubject(s, inbox!.id), 'message back in Inbox').toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
expandSharedFolders,
|
||||
folderRow,
|
||||
openFolder,
|
||||
expectFolderCountsSynced,
|
||||
expectEmailVisible,
|
||||
expectEmailUnread,
|
||||
emailContextAction,
|
||||
emailItem,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Shared (delegated) folders. Alice shares a custom folder — plus her Trash and
|
||||
* Junk so delete/spam can route to the owner's system folders — with carol, who
|
||||
* then acts on the mail from her own session and checks the shared counters.
|
||||
*
|
||||
* carol is the grantee (not asserted on by other specs), so the shared-account
|
||||
* visibility this leaves in Stalwart's session cache doesn't leak elsewhere.
|
||||
*/
|
||||
const { alice, carol } = ACCOUNTS;
|
||||
const SHARED = 'TeamShared';
|
||||
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
|
||||
test.describe('Shared folder actions', () => {
|
||||
let ja: JmapClient; // owner (alice)
|
||||
let sharedId: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
ja = await JmapClient.connect(alice.email, alice.password);
|
||||
const jc = await JmapClient.connect(carol.email, carol.password);
|
||||
await ja.reset();
|
||||
await jc.reset();
|
||||
// Delegate a custom folder + Trash + Junk to carol.
|
||||
sharedId = await ja.createSharedFolder(SHARED, carol.email);
|
||||
await ja.shareMailboxByRole('trash', carol.email);
|
||||
await ja.shareMailboxByRole('junk', carol.email);
|
||||
});
|
||||
|
||||
async function seedIntoShared(subject: string): Promise<void> {
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
|
||||
const m = await ja.waitForEmail(subject);
|
||||
await ja.moveEmail(m.id, sharedId);
|
||||
}
|
||||
|
||||
test('shared folder appears with its counter and message', async ({ page }) => {
|
||||
const s = subj('sh-show');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
|
||||
await expect(folderRow(page, { name: SHARED, shared: true }).first()).toBeVisible();
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
|
||||
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, s);
|
||||
});
|
||||
|
||||
test('mark read/unread in a shared folder updates its counter', async ({ page }) => {
|
||||
const s = subj('sh-read');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-read');
|
||||
await expectEmailUnread(page, s, false);
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 0 });
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-unread');
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
|
||||
|
||||
// Owner sees the same state on the server.
|
||||
const found = await ja.findEmailBySubject(s, sharedId);
|
||||
expect(found.keywords?.$seen).toBeFalsy();
|
||||
});
|
||||
|
||||
test('delete in a shared folder moves the message to the shared Trash', async ({ page }) => {
|
||||
const s = subj('sh-del');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
|
||||
|
||||
await emailContextAction(page, s, 'ctx-delete');
|
||||
|
||||
// Source shared folder drains, and the message really is in the owner's
|
||||
// Trash on the server. (We assert the destination server-side rather than
|
||||
// the shared Trash badge to keep the check independent of sidebar layout.)
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 0 });
|
||||
const trash = await ja.mailboxByRole('trash');
|
||||
expect(await ja.findEmailBySubject(s, trash!.id), 'message in owner Trash').toBeTruthy();
|
||||
});
|
||||
|
||||
test('mark as spam in a shared folder moves the message to the shared Junk', async ({ page }) => {
|
||||
const s = subj('sh-spam');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
|
||||
|
||||
await emailContextAction(page, s, 'ctx-spam');
|
||||
|
||||
// The message leaves the shared folder's list, and on the server it has
|
||||
// moved to the owner's Junk and out of the shared folder. (Unlike delete,
|
||||
// spam doesn't optimistically drain the source *counter*, and forceSync
|
||||
// can't reconcile a shared account — so we assert list + server state.)
|
||||
await expect(emailItem(page, s)).toHaveCount(0);
|
||||
const junk = await ja.mailboxByRole('junk');
|
||||
expect(await ja.findEmailBySubject(s, junk!.id), 'message in owner Junk').toBeTruthy();
|
||||
expect(await ja.findEmailBySubject(s, sharedId), 'message no longer in shared folder').toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
openComposer,
|
||||
addRecipient,
|
||||
setFrom,
|
||||
setSubject,
|
||||
waitDraftSaved,
|
||||
closeComposer,
|
||||
composerRecipients,
|
||||
openFolder,
|
||||
emailItem,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Draft handling. Focus areas reported as flaky by the user:
|
||||
* - the "continue draft" (edit-draft) button in the message view,
|
||||
* - multiple recipients being persisted to the draft,
|
||||
* - a changed sender identity being persisted to the draft.
|
||||
*
|
||||
* Each test drives the composer, lets it auto-save, then verifies the draft on
|
||||
* the server (JMAP) and by reopening it in the UI.
|
||||
*/
|
||||
const { alice, bob, carol } = ACCOUNTS;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||
|
||||
async function draftBody(page: import('@playwright/test').Page, text: string) {
|
||||
await page.locator('.ProseMirror').first().fill(text);
|
||||
}
|
||||
|
||||
test.describe('Drafts', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('multiple recipients save and reopen via the continue-draft button', async ({ page }) => {
|
||||
const subject = subj('draft-multi');
|
||||
await login(page, alice);
|
||||
|
||||
await openComposer(page);
|
||||
await addRecipient(page, bob.email);
|
||||
await addRecipient(page, carol.email);
|
||||
await setSubject(page, subject);
|
||||
await draftBody(page, 'draft body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
// Server: the draft carries BOTH recipients.
|
||||
const drafts = await jmap.mailboxByRole('drafts');
|
||||
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
|
||||
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
|
||||
expect(to).toEqual([bob.email, carol.email].sort());
|
||||
|
||||
// UI: opening the draft shows the continue-draft button, which reopens the
|
||||
// composer with both recipients intact.
|
||||
await openFolder(page, { role: 'drafts' });
|
||||
await emailItem(page, subject).first().click();
|
||||
await page.locator('[data-testid="edit-draft"]').click();
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
|
||||
const recips = await composerRecipients(page);
|
||||
expect(recips).toContain(bob.email);
|
||||
expect(recips).toContain(carol.email);
|
||||
});
|
||||
|
||||
test('a recipient typed but not committed to a chip is still saved', async ({ page }) => {
|
||||
const subject = subj('draft-uncommitted');
|
||||
await login(page, alice);
|
||||
|
||||
await openComposer(page);
|
||||
await addRecipient(page, bob.email); // committed chip
|
||||
// Type a second address but do NOT press Enter — leave it as raw input.
|
||||
const input = page.locator('[data-testid="composer-to"] input').first();
|
||||
await input.click();
|
||||
await input.fill(carol.email);
|
||||
await setSubject(page, subject); // blur the To field
|
||||
await draftBody(page, 'uncommitted body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
const drafts = await jmap.mailboxByRole('drafts');
|
||||
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
|
||||
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
|
||||
// Both the committed and the still-in-the-input recipient must survive.
|
||||
expect(to).toEqual([bob.email, carol.email].sort());
|
||||
});
|
||||
|
||||
test('a server-created draft shows the continue-draft button when viewed', async ({ page }) => {
|
||||
const subject = subj('draft-server');
|
||||
await jmap.createDraft(subject, bob.email);
|
||||
|
||||
await login(page, alice);
|
||||
await openFolder(page, { role: 'drafts' });
|
||||
await emailItem(page, subject).first().click();
|
||||
|
||||
// The edit-draft ("continue draft") button must be present for any message
|
||||
// carrying the $draft keyword, regardless of how the draft was created.
|
||||
await expect(page.locator('[data-testid="edit-draft"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a changed sender identity is saved to the draft (server)', async ({ page }) => {
|
||||
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
|
||||
const subject = subj('draft-from');
|
||||
|
||||
await login(page, alice);
|
||||
await openComposer(page);
|
||||
await setFrom(page, altId);
|
||||
await addRecipient(page, bob.email);
|
||||
await setSubject(page, subject);
|
||||
await draftBody(page, 'from-change body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
const drafts = await jmap.mailboxByRole('drafts');
|
||||
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
|
||||
expect((draft.from ?? [])[0]?.name, 'draft From carries the selected identity').toBe('Alice Team');
|
||||
});
|
||||
|
||||
// KNOWN BUG (documented via test.fail): a draft composed with a non-default
|
||||
// identity is saved with the right From on the server (see the test above),
|
||||
// but reopening the draft resets the composer's From selector to the default
|
||||
// identity instead of restoring the one the draft was written with. If this
|
||||
// starts passing, the reopen path was fixed — flip this back to a plain test.
|
||||
test.fail('reopening a draft restores the changed sender in the From selector', async ({ page }) => {
|
||||
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
|
||||
const subject = subj('draft-from-reopen');
|
||||
|
||||
await login(page, alice);
|
||||
await openComposer(page);
|
||||
await setFrom(page, altId);
|
||||
await addRecipient(page, bob.email);
|
||||
await setSubject(page, subject);
|
||||
await draftBody(page, 'reopen body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
await openFolder(page, { role: 'drafts' });
|
||||
await emailItem(page, subject).first().click();
|
||||
await page.locator('[data-testid="edit-draft"]').click();
|
||||
await expect(page.locator('[data-testid="composer-from"]')).toHaveValue(altId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
expandSharedFolders,
|
||||
openFolder,
|
||||
folderMailboxId,
|
||||
moveEmailTo,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Moving mail across the own-account / shared-folder boundary, in both
|
||||
* directions, and between two shared folders. The move is driven from the list
|
||||
* context menu's "Move to" submenu; the authoritative check is the server-side
|
||||
* mailbox the message ends up in, with the reliably-updating (own-account)
|
||||
* counters checked in the UI too.
|
||||
*/
|
||||
const { alice, carol } = ACCOUNTS;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||
|
||||
test.describe('Shared-folder moves', () => {
|
||||
let ja: JmapClient; // owner
|
||||
let jc: JmapClient; // grantee
|
||||
let teamA: string;
|
||||
let teamB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
ja = await JmapClient.connect(alice.email, alice.password);
|
||||
jc = await JmapClient.connect(carol.email, carol.password);
|
||||
await ja.reset();
|
||||
await jc.reset();
|
||||
teamA = await ja.createSharedFolder('TeamA', carol.email);
|
||||
teamB = await ja.createSharedFolder('TeamB', carol.email);
|
||||
});
|
||||
|
||||
async function seedInto(mailboxId: string, subject: string, owner = ja): Promise<void> {
|
||||
const acct = owner === ja ? alice : carol;
|
||||
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
|
||||
const m = await owner.waitForEmail(subject);
|
||||
await owner.moveEmail(m.id, mailboxId);
|
||||
}
|
||||
|
||||
test('shared folder A -> shared folder B', async ({ page }) => {
|
||||
const s = subj('mv-a2b');
|
||||
await seedInto(teamA, s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { name: 'TeamB', shared: true });
|
||||
await openFolder(page, { name: 'TeamA', shared: true });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
expect(await ja.findEmailBySubject(s, teamB), 'message in TeamB').toBeTruthy();
|
||||
expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy();
|
||||
});
|
||||
|
||||
test('shared folder B -> shared folder A', async ({ page }) => {
|
||||
const s = subj('mv-b2a');
|
||||
await seedInto(teamB, s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
|
||||
await openFolder(page, { name: 'TeamB', shared: true });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
expect(await ja.findEmailBySubject(s, teamA), 'message in TeamA').toBeTruthy();
|
||||
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
|
||||
});
|
||||
|
||||
// KNOWN LIMITATION (documented via test.fail): the "Move to" submenu offers a
|
||||
// shared folder as a destination for an own-account message, but clicking it
|
||||
// does NOT relocate the message across the account boundary — it stays put.
|
||||
// Same in reverse (shared -> own). If cross-account moves get implemented,
|
||||
// these will start passing; flip them back to plain tests then.
|
||||
test.fail('own account -> shared folder', async ({ page }) => {
|
||||
const s = subj('mv-own2sh');
|
||||
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
|
||||
await jc.waitForEmail(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
|
||||
await openFolder(page, { role: 'inbox', shared: false });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Expected (once supported): the message moves to the owner's shared TeamA.
|
||||
expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy();
|
||||
});
|
||||
|
||||
test.fail('shared folder -> own account', async ({ page }) => {
|
||||
const s = subj('mv-sh2own');
|
||||
await seedInto(teamA, s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { role: 'inbox', shared: false });
|
||||
await openFolder(page, { name: 'TeamA', shared: true });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Expected (once supported): the message arrives in carol's own Inbox.
|
||||
expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
addAccount,
|
||||
seedUnifiedSettings,
|
||||
seedAllMailSettings,
|
||||
expandSharedFolders,
|
||||
folderCounts,
|
||||
expectFolderUnread,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Live currency of the unified / All-Mail counters across every source folder.
|
||||
*
|
||||
* Stalwart's SSE only pushes StateChange for the *primary* account, so:
|
||||
* - a background *login* account updates the badge live (each login has its
|
||||
* own SSE) — asserted with no reconcile;
|
||||
* - a *shared/delegated* account gets no push at all, so the client polls the
|
||||
* session's secondary accounts too; the badge reconciles on focus/interval.
|
||||
* (Regression test for the shared-account state-poll.)
|
||||
*/
|
||||
const { alice, bob, carol } = ACCOUNTS;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||
|
||||
async function deliverIntoSharedFolder(owner: JmapClient, folderId: string, subject: string) {
|
||||
const acct = ACCOUNTS.alice;
|
||||
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
|
||||
const m = await owner.waitForEmail(subject);
|
||||
await owner.moveEmail(m.id, folderId);
|
||||
}
|
||||
|
||||
test.describe('Live unified/All-Mail counters', () => {
|
||||
test('a background login account updates the unified counter live (no reconcile)', async ({ page }) => {
|
||||
for (const a of [alice, bob]) {
|
||||
const j = await JmapClient.connect(a.email, a.password);
|
||||
await j.reset();
|
||||
}
|
||||
await seedUnifiedSettings(page);
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob); // bob active, alice in the background
|
||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 0);
|
||||
|
||||
// Mail lands in alice's inbox while bob is active — no focus/forceSync here.
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: subj('bg-live'), body: 'x' });
|
||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 1);
|
||||
});
|
||||
|
||||
test('a shared-folder change reconciles the All-Mail counter on focus', async ({ page }) => {
|
||||
const ja = await JmapClient.connect(alice.email, alice.password);
|
||||
const jc = await JmapClient.connect(carol.email, carol.password);
|
||||
await ja.reset();
|
||||
await jc.reset();
|
||||
const shared = await ja.createSharedFolder('TeamShared', carol.email);
|
||||
|
||||
await seedAllMailSettings(page, { crossAccount: false });
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
expect((await folderCounts(page, { name: '__cross_all__' })).unread).toBe(0);
|
||||
|
||||
// A background change in the shared (delegated) account gets no SSE push.
|
||||
await deliverIntoSharedFolder(ja, shared, subj('sh-live'));
|
||||
|
||||
// Focus reconcile now polls the shared account too, so the All-Mail badge
|
||||
// picks up the shared folder's new unread.
|
||||
await forceSync(page);
|
||||
await expect
|
||||
.poll(async () => (await folderCounts(page, { name: '__cross_all__' })).unread, { timeout: 15000 })
|
||||
.toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
addAccount,
|
||||
switchAccount,
|
||||
seedSettings,
|
||||
folderRow,
|
||||
openFolder,
|
||||
emailItem,
|
||||
expectEmailVisible,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Attachments on a message that belongs to a *different* account, opened from
|
||||
* the cross-account All-Mail view. Blobs are account-scoped, so downloading one
|
||||
* must route to the owning account's client + accountId — otherwise it 404s
|
||||
* against the active account (the reported bug).
|
||||
*/
|
||||
const { alice, bob } = ACCOUNTS;
|
||||
const ATT = { filename: 'report.bin', contentType: 'application/octet-stream', content: 'hello-attachment-content-12345' };
|
||||
|
||||
test.describe('Cross-account attachments', () => {
|
||||
test.beforeEach(async () => {
|
||||
for (const a of [alice, bob]) {
|
||||
const j = await JmapClient.connect(a.email, a.password);
|
||||
await j.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test('an attachment on another account\'s All-Mail message downloads correctly', async ({ page }) => {
|
||||
const subject = `IT attach ${Date.now()}`;
|
||||
// Deliver a message with an attachment to bob.
|
||||
await sendMail({ from: bob.email, authPass: bob.password, to: bob.email, subject, body: 'see attachment', attachment: ATT });
|
||||
|
||||
// Cross-account All Mail + always download attachments (don't preview).
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
enableCrossAllView: true,
|
||||
unifiedCrossAccount: true,
|
||||
includeGroupInUnified: true,
|
||||
mailAttachmentAction: 'download',
|
||||
});
|
||||
|
||||
// Make alice the active account, with bob added, so bob's message is
|
||||
// genuinely cross-account when opened.
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob);
|
||||
await switchAccount(page, alice.email);
|
||||
await forceSync(page);
|
||||
|
||||
// Open the All-Mail view and bob's message.
|
||||
await expect(folderRow(page, { name: '__cross_all__' }).first()).toBeVisible();
|
||||
await openFolder(page, { name: '__cross_all__' });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, subject);
|
||||
await emailItem(page, subject).first().click();
|
||||
|
||||
// The attachment chip is present; clicking it downloads the blob from bob's
|
||||
// account (pre-fix this 404s against alice and no download fires).
|
||||
const chip = page.locator(`[data-testid="attachment"][data-attachment-name="${ATT.filename}"]`).first();
|
||||
await chip.waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 15000 }),
|
||||
chip.click(),
|
||||
]);
|
||||
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of stream) chunks.push(c as Buffer);
|
||||
expect(Buffer.concat(chunks).toString()).toContain(ATT.content);
|
||||
});
|
||||
|
||||
test('an inline image on another account\'s All-Mail message renders', async ({ page }) => {
|
||||
const subject = `IT inline ${Date.now()}`;
|
||||
// 1x1 PNG referenced from the HTML body via cid.
|
||||
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
await sendMail({
|
||||
from: bob.email, authPass: bob.password, to: bob.email, subject, body: '',
|
||||
inlineImage: { cid: 'inlinepic', contentType: 'image/png', base64: png, html: '<p>see below</p><img src="cid:inlinepic" alt="pic" width="1" height="1" />' },
|
||||
});
|
||||
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
enableCrossAllView: true,
|
||||
unifiedCrossAccount: true,
|
||||
includeGroupInUnified: true,
|
||||
});
|
||||
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob);
|
||||
await switchAccount(page, alice.email);
|
||||
await forceSync(page);
|
||||
|
||||
await openFolder(page, { name: '__cross_all__' });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, subject);
|
||||
await emailItem(page, subject).first().click();
|
||||
|
||||
// The inline cid: image resolves to a blob URL fetched from bob's account.
|
||||
// Pre-fix the fetch 404s and it falls back to the data:image/gif placeholder.
|
||||
const img = page.frameLocator('iframe[title="Email content"]').locator('img').first();
|
||||
await expect
|
||||
.poll(async () => (await img.getAttribute('src').catch(() => '')) ?? '', { timeout: 15000 })
|
||||
.toMatch(/^blob:/);
|
||||
});
|
||||
});
|
||||
@@ -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