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:
Stefan Hildebrandt
2026-07-11 21:15:43 +02:00
parent c3acb537d0
commit 26c3d07d56
7 changed files with 161 additions and 23 deletions
+28 -2
View File
@@ -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');