diff --git a/components/email/__tests__/composer-inline-image-cid.test.tsx b/components/email/__tests__/composer-inline-image-cid.test.tsx
index 4bc7c97ae..52d4b62ee 100644
--- a/components/email/__tests__/composer-inline-image-cid.test.tsx
+++ b/components/email/__tests__/composer-inline-image-cid.test.tsx
@@ -1,4 +1,4 @@
-import { render, waitFor } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
@@ -174,10 +174,10 @@ describe('reply/forward inline image cid normalization', () => {
vi.clearAllMocks();
});
- it('hydrates an inline image whose attachment cid still carries angle brackets', async () => {
- const fetchBlobArrayBuffer = vi.fn().mockResolvedValue(
- new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).buffer, // PNG magic
- );
+ const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).buffer;
+
+ function mockClient() {
+ const fetchBlobArrayBuffer = vi.fn().mockResolvedValue(PNG_MAGIC);
useAuthStore.setState({
client: {
fetchBlobArrayBuffer,
@@ -185,10 +185,17 @@ describe('reply/forward inline image cid normalization', () => {
getMaxDelayedSend: () => 0,
} as never,
});
+ return { fetchBlobArrayBuffer };
+ }
+
+ it('sends a reply with the image inlined by cid, not duplicated as a regular attachment', async () => {
+ const { fetchBlobArrayBuffer } = mockClient();
+ const onSend = vi.fn();
render(
{
/>
);
- await waitFor(() => expect(fetchBlobArrayBuffer).toHaveBeenCalledTimes(1));
- expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'logo.png', 'image/png');
+ // Hydration: the placeholder gets swapped for the fetched blob's data URL.
+ await waitFor(() => expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'logo.png', 'image/png'));
+
+ // Reply auto-fills "to" from replyTo.from, so Send is reachable immediately.
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+
+ const sent = onSend.mock.calls[0][0] as { htmlBody?: string; attachments?: Array<{ cid?: string; blobId: string; disposition?: string }> };
+ // The data URL must have been rewritten back to a cid: reference for sending.
+ expect(sent.htmlBody).toContain('cid:logo123@sender');
+ expect(sent.htmlBody).not.toContain('data:image');
+ // Exactly one attachment for the one image - a bracket-mismatch used to
+ // mean it was never matched at all; matching on both regular AND inline
+ // paths would instead send the same blob twice (see forward test below).
+ expect(sent.attachments).toEqual([
+ expect.objectContaining({ blobId: 'blob-1', cid: 'logo123@sender', disposition: 'inline' }),
+ ]);
+ });
+
+ it('does not duplicate a forwarded inline image (declared octet-stream, no disposition) as a regular attachment', async () => {
+ const { fetchBlobArrayBuffer } = mockClient();
+ const onSend = vi.fn();
+
+ render(
+ Hi
',
+ quoteHeaderHtml: 'Forwarded message',
+ // Foxmail-style part (#543): no inline disposition, generic type -
+ // only the cid: body reference marks it as embedded.
+ attachments: [
+ { blobId: 'blob-1', name: 'logo.png', type: 'application/octet-stream', size: 100, cid: '' },
+ ],
+ }}
+ onClose={vi.fn()}
+ />
+ );
+
+ await waitFor(() => expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'logo.png', 'application/octet-stream'));
+
+ // The forward attachment-list filter must have recognized blob-1 as
+ // embedded and kept it out of the regular attachment chips.
+ expect(screen.queryByText('logo.png')).not.toBeInTheDocument();
+
+ // Forward doesn't auto-fill "to" - add a recipient so Send is reachable.
+ // A trailing delimiter is needed: a bare single-address paste is left as
+ // uncommitted input text rather than immediately chipped.
+ fireEvent.paste(screen.getByPlaceholderText('to_placeholder'), {
+ clipboardData: { getData: () => 'bob@example.com;' },
+ });
+
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+
+ const sent = onSend.mock.calls[0][0] as { attachments?: Array<{ cid?: string; blobId: string }> };
+ // Same blobId must appear exactly once, as the re-typed inline part -
+ // never also as a plain attachment (that would send it twice).
+ expect(sent.attachments).toEqual([
+ expect.objectContaining({ blobId: 'blob-1', cid: 'logo123@sender' }),
+ ]);
});
});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index c0875ca40..a9c781fb3 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -610,9 +610,10 @@ export function EmailComposer({
const embeddedCids = collectInlineImageCids(body);
return replyTo.attachments
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
- // (matches the viewer's hideInlineImageAttachments logic).
+ // (matches the viewer's hideInlineImageAttachments logic). att.cid may still
+ // carry angle brackets while embeddedCids (parsed from the body) never does.
.filter(att => !(att.cid && (
- embeddedCids.has(att.cid) ||
+ embeddedCids.has(normalizeCid(att.cid)) ||
(att.disposition === 'inline' && (att.type || '').startsWith('image/'))
)))
.map(att => ({