From cdb31634a69ae0fb4a85b01367d5c14e5d649f60 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:16:59 +0200 Subject: [PATCH] fix(attachments): route all viewer blob fetches for cross-account messages Extend the cross-account blob routing beyond download/preview to every blob fetch in the message viewer, so a message opened from a different account in the unified / All-Mail view renders and exports correctly instead of 404ing against the active account: - inline cid: images, drag-to-desktop, attachment thumbnails, the "download all" zip bundle, and the S/MIME / TNEF / embedded-rfc822 blob reads now use a resolved blobClient (getClientForAccount(sourceClientAccountId)) and the owner blobAccountId (sourceAccountId), computed once from the open message's source; - fetchBlobAsObjectUrl / fetchBlobArrayBuffer / fetchBlob calls pass the accountId (the client methods gained the param in the previous commit); - non-cross-account behaviour is unchanged (blobClient === active client). Extends 10-attachments with an inline-image case (verified to fall back to the placeholder without the routing). The SMTP helper can now send multipart/related inline images. --- integration/README.md | 10 ++++--- integration/tests/10-attachments.spec.ts | 34 ++++++++++++++++++++++++ integration/tests/helpers/smtp.ts | 26 +++++++++++++++++- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/integration/README.md b/integration/README.md index d6e6c50f..303b9df1 100644 --- a/integration/README.md +++ b/integration/README.md @@ -112,10 +112,12 @@ 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`). +- **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 diff --git a/integration/tests/10-attachments.spec.ts b/integration/tests/10-attachments.spec.ts index 159e5fa8..42315cd2 100644 --- a/integration/tests/10-attachments.spec.ts +++ b/integration/tests/10-attachments.spec.ts @@ -74,4 +74,38 @@ test.describe('Cross-account attachments', () => { 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: '

see below

pic' }, + }); + + 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:/); + }); }); diff --git a/integration/tests/helpers/smtp.ts b/integration/tests/helpers/smtp.ts index cd994206..734c2e00 100644 --- a/integration/tests/helpers/smtp.ts +++ b/integration/tests/helpers/smtp.ts @@ -26,6 +26,11 @@ interface SendOptions { headers?: Record; /** 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:`. Sent as + * multipart/related; `base64` is the pre-encoded image payload. + */ + inlineImage?: { cid: string; contentType: string; base64: string; html: string }; } class SmtpError extends Error {} @@ -116,7 +121,26 @@ export async function sendMail(opts: SendOptions): Promise { }; let mime: string; - if (opts.attachment) { + 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}"`;