fix(attachments): download/view attachments on cross-account All-Mail messages
Blobs are scoped per JMAP account, but the attachment download/preview path always used the active account's client and accountId. Opening a message from a different account in the unified / All-Mail view and downloading (or previewing) an attachment therefore 404'd against the active account. Route the blob fetch to the message's source instead: - resolveBlobSource() picks the owning login's client (getClientForAccount(sourceClientAccountId)) and the owner accountId (sourceAccountId) for delegated/shared blobs, in the unified view; - handleDownloadAttachment + the attachment-preview handlers use it; - downloadBlob / fetchBlobAsObjectUrl / fetchBlobArrayBuffer gain an accountId param (getBlobDownloadUrl/fetchBlob already had one). Adds 10-attachments: an attachment on another account's All-Mail message downloads with the correct bytes (verified to fail without the routing).
This commit is contained in:
@@ -85,7 +85,9 @@ integration/
|
||||
├── 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
|
||||
├── 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
|
||||
@@ -110,6 +112,10 @@ because the UI behaviour is currently incomplete. Worth a look:
|
||||
"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 (fixed).** Blobs are account-scoped, so viewing/
|
||||
downloading an attachment on an All-Mail message from another account 404'd
|
||||
against the active account. The download/preview path now routes to the
|
||||
message's owning client + accountId (`10-attachments`).
|
||||
|
||||
## How the tests work
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ 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 };
|
||||
}
|
||||
|
||||
class SmtpError extends Error {}
|
||||
@@ -110,14 +112,38 @@ 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.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