From 5dde5e00e15891c6b3aedddac13e5fe8ecff173f Mon Sep 17 00:00:00 2001 From: daluchen Date: Fri, 18 Sep 2026 11:04:42 +0800 Subject: [PATCH 1/2] feat: add batch forward-as-attachment for selected emails Multi-select emails in the list, right-click, and pick "Forward as attachments" to open one composer carrying every selected message as its own message/rfc822 (.eml) attachment - the batch sibling of the existing single-message action. - lib/forward-as-attachment.ts: buildBatchForwardAsAttachmentPayload builds one payload from many emails. The subject is count-based (there is no single original subject), with the count localized via an injected label so the subject never claims more attachments than the composer carries. Emails without a blobId are skipped; null when none has one, matching the single flow's silent no-op. - mail-app.tsx: handleBatchForwardAsAttachment resolves the selection through useEmailStore.getState() (fresh state, list-ordered attachments, stale ids drop out), runs the plugin transform and veto hooks per email - one veto cancels the whole compose - and opens the composer with the attachments seeded via replyTo. Clears the selection only on success. - email-context-menu.tsx / email-list.tsx: new batch menu item shown when the right-clicked email belongs to a multi-selection; hidden in the scheduled view like the single item. - locales: context_menu.forward_as_attachments and email_composer.forward_batch_count in all 27 locales. Co-Authored-By: Claude Code --- .../__tests__/email-context-menu.test.tsx | 101 ++++++++++++++++++ components/email/email-context-menu.tsx | 18 ++++ components/email/email-list.tsx | 3 + components/mail/mail-app.tsx | 84 ++++++++++++++- lib/__tests__/forward-as-attachment.test.ts | 80 +++++++++++++- lib/forward-as-attachment.ts | 40 +++++++ locales/ar/common.json | 2 + locales/ca/common.json | 2 + locales/cs/common.json | 2 + locales/da/common.json | 2 + locales/de/common.json | 2 + locales/en/common.json | 2 + locales/es/common.json | 2 + locales/fa/common.json | 2 + locales/fr/common.json | 2 + locales/he/common.json | 2 + locales/hu/common.json | 2 + locales/it/common.json | 2 + locales/ja/common.json | 2 + locales/ko/common.json | 2 + locales/lv/common.json | 2 + locales/mn/common.json | 2 + locales/nb/common.json | 2 + locales/nl/common.json | 2 + locales/pl/common.json | 2 + locales/pt/common.json | 2 + locales/ro/common.json | 2 + locales/ru/common.json | 2 + locales/sk/common.json | 2 + locales/tr/common.json | 2 + locales/uk/common.json | 2 + locales/zh-TW/common.json | 2 + locales/zh/common.json | 2 + 33 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/email-context-menu.test.tsx diff --git a/components/email/__tests__/email-context-menu.test.tsx b/components/email/__tests__/email-context-menu.test.tsx new file mode 100644 index 000000000..fcf3555a4 --- /dev/null +++ b/components/email/__tests__/email-context-menu.test.tsx @@ -0,0 +1,101 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// The menu's only far-reaching child - the plugin host iframe slot. It is +// never what these tests are about, so stub it out. +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: () => null, +})); + +// vitest.setup.ts mocks next-intl's useTranslations to return the key itself, +// so labels render as raw keys ("forward_as_attachments" etc.). + +import { EmailContextMenu } from '../email-context-menu'; + +const email: Email = { + id: 'e1', + blobId: 'b1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + hasAttachment: false, + size: 100, +} as unknown as Email; + +const mailboxes: Mailbox[] = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +type MenuProps = React.ComponentProps; + +function renderMenu(props: Partial = {}) { + const onClose = vi.fn(); + render( + , + ); + return { onClose }; +} + +describe('EmailContextMenu batch forward as attachment', () => { + it('shows the batch header and batch forward item when the right-clicked email is in a multi-selection', () => { + renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() }); + expect(screen.getByTestId('ctx-batch-forward-as-attachment')).toBeInTheDocument(); + expect(screen.getByText('items_selected')).toBeInTheDocument(); + }); + + it('clicking the batch item invokes onBatchForwardAsAttachment and closes the menu', () => { + const onBatchForwardAsAttachment = vi.fn(); + const { onClose } = renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment }); + fireEvent.click(screen.getByTestId('ctx-batch-forward-as-attachment')); + expect(onBatchForwardAsAttachment).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('hides the single-message forward-as-attachment item in batch mode', () => { + renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() }); + // vitest's next-intl mock renders labels as raw keys; the singular key + // must be absent while the plural batch key is present. + expect(screen.queryByText('forward_as_attachment')).toBeNull(); + expect(screen.getByText('forward_as_attachments')).toBeInTheDocument(); + }); + + it('hides the batch item when only one email is selected', () => { + renderMenu({ isMultiSelect: true, selectedCount: 1, onBatchForwardAsAttachment: vi.fn() }); + expect(screen.queryByTestId('ctx-batch-forward-as-attachment')).toBeNull(); + }); + + it('hides the batch item when the right-clicked email is outside the selection', () => { + renderMenu({ isMultiSelect: false, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() }); + expect(screen.queryByTestId('ctx-batch-forward-as-attachment')).toBeNull(); + }); + + it('hides the batch item for scheduled emails', () => { + const scheduled = { ...email, isScheduled: true } as unknown as Email; + renderMenu({ + email: scheduled, + isMultiSelect: true, + selectedCount: 3, + onBatchForwardAsAttachment: vi.fn(), + }); + expect(screen.queryByTestId('ctx-batch-forward-as-attachment')).toBeNull(); + }); + + it('disables the batch item when no handler is passed', () => { + renderMenu({ isMultiSelect: true, selectedCount: 3 }); + expect((screen.getByTestId('ctx-batch-forward-as-attachment') as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 26ee9d45a..9ab7ed72c 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -117,6 +117,7 @@ interface EmailContextMenuProps { */ copyTargets?: CopyTargetAccount[]; onCopyToAccount?: (accountId: string, mailboxId: string) => void; + onBatchForwardAsAttachment?: () => void; } // Get mailbox icon based on role @@ -172,6 +173,7 @@ export function EmailContextMenu({ onBatchMoveToMailbox, onBatchMarkAsSpam, onBatchUndoSpam, + onBatchForwardAsAttachment, onEditDraft, onCancelScheduledForEdit, onRescheduleScheduled, @@ -552,6 +554,22 @@ export function EmailContextMenu({ {!isScheduled && ( <> + {/* Batch forward - multi-select sibling of the single-message item below. + First action in batch mode so compose-style actions stay above the + archive/delete group, mirroring the single menu's ordering. */} + {showBatchActions && ( + <> + handleAction(onBatchForwardAsAttachment!)} + disabled={!onBatchForwardAsAttachment} + /> + + + )} + {/* Edit Draft - only for single draft emails */} {!isScheduled && !showBatchActions && isDraft && onEditDraft && ( <> diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index e31e97500..522821478 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -56,6 +56,7 @@ interface EmailListProps { onReplyAll?: (email: Email) => void; onForward?: (email: Email) => void; onForwardAsAttachment?: (email: Email) => void; + onBatchForwardAsAttachment?: () => void; onMarkAsRead?: (email: Email, read: boolean) => void; onToggleStar?: (email: Email) => void; onTogglePinned?: (email: Email) => void; @@ -98,6 +99,7 @@ export function EmailList({ onReplyAll, onForward, onForwardAsAttachment, + onBatchForwardAsAttachment, onMarkAsRead, onToggleStar, onTogglePinned, @@ -960,6 +962,7 @@ export function EmailList({ onFindFromSender={onFindFromSender && contextMenuEmail.from?.[0]?.email ? () => onFindFromSender({ name: contextMenuEmail!.from![0].name || '', email: contextMenuEmail!.from![0].email }) : undefined} + onBatchForwardAsAttachment={onBatchForwardAsAttachment} onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)} onToggleStar={() => onToggleStar?.(contextMenuEmail!)} onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 51124c9de..b3601f742 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -133,7 +133,7 @@ import { composeOptionsFor, runBeforeCompose, type ComposePrefill, type Composer import { formatMailto } from "@/lib/protocol-handlers/mailto"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildComposeTabTitle, buildReplySubject } from "@/lib/subject-prefix"; -import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; +import { buildForwardAsAttachmentPayload, buildBatchForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; import { getEffectiveLocale } from '@/i18n/detect-locale'; import { SCHEDULED_MAILBOX_ID, @@ -2572,6 +2572,85 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (isMobile) setActiveView('viewer'); }; + // Batch sibling of handleForwardAsAttachment above: opens ONE composer with + // every selected message attached as message/rfc822. Resolution goes through + // useEmailStore.getState() - always fresh, immune to the closure-staleness + // noted above - and filters the current list so attachment order follows the + // list, not checkbox-click order. Stale selection ids (folder switched under + // the selection) simply drop out of the filter rather than forwarding + // something the user can no longer see. + const handleBatchForwardAsAttachment = async () => { + const state = useEmailStore.getState(); + const ids = state.selectedEmailIds; + if (ids.size < 2) return; // menu only offers this for >1; guards future wiring + // Unreachable from the menu in the scheduled view (the whole actions block + // is isScheduled-gated); resolve the right pool anyway so a future shortcut + // can't silently read the wrong list. + const pool = state.isScheduledView ? state.scheduledEmails : state.emails; + const selected = pool.filter((e) => ids.has(e.id)); + if (selected.length === 0) return; + + const { + emailDownloadTemplate, + filenameSpaceReplacement, + filenameLowercase, + filenameStripDiacritics, + filenameCollapseSeparators, + } = useSettingsStore.getState(); + + const transformed: Email[] = []; + for (const e of selected) { + transformed.push(await emailHooks.onBeforeComposeOpenToForwardAsAttachment.transform(e)); + } + const payload = buildBatchForwardAsAttachmentPayload( + transformed, + t('email_composer.prefix.forward'), + (count) => t('email_composer.forward_batch_count', { count }), + { + template: emailDownloadTemplate, + spaceReplacement: filenameSpaceReplacement, + lowercase: filenameLowercase, + stripDiacritics: filenameStripDiacritics, + collapseSeparators: filenameCollapseSeparators, + }, + ); + if (!payload) return; // nothing attachable - same silent no-op as the single flow + + // A veto on any one original cancels the whole batch compose: opening a + // composer missing a message a plugin refused would forward a partial set + // the user never chose. (ReplyContext is single-email; no batch variant.) + for (const e of transformed) { + const ok = await emailHooks.onBeforeForward.intercept({ + originalEmailId: e.id, + originalEmail: emailToReadView(e), + mode: 'forward' as const, + }); + if (!ok) return; + } + + startFreshComposerSession(); + setPendingDraft({ + to: "", + cc: "", + bcc: "", + subject: payload.subject, + body: "", + showCc: false, + showBcc: false, + selectedIdentityId: null, + subAddressTag: "", + mode: "forward", + draftId: null, + // No single original subject to quote - the count-based subject above + // is the whole story, and the composer seeds attachments from this array. + replyTo: { attachments: payload.attachments }, + }); + setComposerMode('forward'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + clearSelection(); // only on success - a vetoed/no-op attempt keeps the selection + }; + const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { if (!client || !emailToDelete) return; @@ -4922,6 +5001,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { selectEmail(email); handleForwardAsAttachment(email); }} + onBatchForwardAsAttachment={() => { + void handleBatchForwardAsAttachment(); + }} onMarkAsRead={async (email, read) => { if (client) { await markAsRead(client, email.id, read); diff --git a/lib/__tests__/forward-as-attachment.test.ts b/lib/__tests__/forward-as-attachment.test.ts index ceec2b7fa..ef8a48481 100644 --- a/lib/__tests__/forward-as-attachment.test.ts +++ b/lib/__tests__/forward-as-attachment.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { buildForwardAsAttachmentPayload } from '@/lib/forward-as-attachment'; +import { buildForwardAsAttachmentPayload, buildBatchForwardAsAttachmentPayload } from '@/lib/forward-as-attachment'; import type { Email } from '@/lib/jmap/types'; // Pin TZ so the local-time date rendering in the filename test is deterministic, @@ -93,3 +93,81 @@ describe('buildForwardAsAttachmentPayload', () => { expect(payload?.attachment.name).not.toContain('Bobby'); }); }); + +describe('buildBatchForwardAsAttachmentPayload', () => { + const countLabel = (n: number) => `${n} email${n === 1 ? '' : 's'}`; + + it('returns null when no email has a blobId', () => { + const emails = [makeEmail({ id: 'e1', blobId: undefined }), makeEmail({ id: 'e2', blobId: undefined })]; + expect(buildBatchForwardAsAttachmentPayload(emails, 'Fwd:', countLabel)).toBeNull(); + }); + + it('skips emails without a blobId and attaches the rest', () => { + const emails = [ + makeEmail({ id: 'e1', blobId: 'blob-1' }), + makeEmail({ id: 'e2', blobId: undefined }), + makeEmail({ id: 'e3', blobId: 'blob-3' }), + ]; + const payload = buildBatchForwardAsAttachmentPayload(emails, 'Fwd:', countLabel); + expect(payload?.attachments.map((a) => a.blobId)).toEqual(['blob-1', 'blob-3']); + }); + + it('builds the subject from the attachable count, not the input length', () => { + const emails = [ + makeEmail({ id: 'e1', blobId: 'blob-1' }), + makeEmail({ id: 'e2', blobId: 'blob-2' }), + makeEmail({ id: 'e3', blobId: undefined }), + ]; + const payload = buildBatchForwardAsAttachmentPayload(emails, 'Fwd:', countLabel); + expect(payload?.subject).toBe('Fwd: 2 emails'); + }); + + it('prefixes the count phrase using the given forward prefix', () => { + const emails = [makeEmail({ id: 'e1', blobId: 'b1' }), makeEmail({ id: 'e2', blobId: 'b2' })]; + const payload = buildBatchForwardAsAttachmentPayload(emails, 'WG:', countLabel); + expect(payload?.subject).toBe('WG: 2 emails'); + }); + + it('preserves the input list order of the attachments', () => { + const emails = [ + makeEmail({ id: 'e1', blobId: 'b1', subject: 'First' }), + makeEmail({ id: 'e2', blobId: 'b2', subject: 'Second' }), + makeEmail({ id: 'e3', blobId: 'b3', subject: 'Third' }), + ]; + const payload = buildBatchForwardAsAttachmentPayload(emails, 'Fwd:', countLabel); + expect(payload?.attachments.map((a) => a.name)).toEqual([ + expect.stringMatching(/first/i), + expect.stringMatching(/second/i), + expect.stringMatching(/third/i), + ]); + }); + + it('references each email\'s own blobId and size as message/rfc822 .eml entries', () => { + const emails = [ + makeEmail({ id: 'e1', blobId: 'b1', size: 100 }), + makeEmail({ id: 'e2', blobId: 'b2', size: 200 }), + ]; + const payload = buildBatchForwardAsAttachmentPayload(emails, 'Fwd:', countLabel); + expect(payload?.attachments).toEqual([ + { blobId: 'b1', name: expect.stringMatching(/\.eml$/), type: 'message/rfc822', size: 100 }, + { blobId: 'b2', name: expect.stringMatching(/\.eml$/), type: 'message/rfc822', size: 200 }, + ]); + }); + + it('applies user space/case transforms but ignores a custom filename template, unlike "Export as .eml"', () => { + const emails = [makeEmail({ id: 'e1', blobId: 'b1', subject: 'Missed spam example' })]; + const payload = buildBatchForwardAsAttachmentPayload(emails, 'Fwd:', countLabel, { + template: 'custom-{subject}', + lowercase: true, + spaceReplacement: 'dash', + }); + expect(payload?.attachments[0].name).toBe('2026-07-26-22.25.22-missed-spam-example.eml'); + }); + + it('supports a single email, producing a "one" count label', () => { + const emails = [makeEmail({ id: 'e1', blobId: 'b1' })]; + const payload = buildBatchForwardAsAttachmentPayload(emails, 'Fwd:', countLabel); + expect(payload?.subject).toBe('Fwd: 1 email'); + expect(payload?.attachments).toHaveLength(1); + }); +}); diff --git a/lib/forward-as-attachment.ts b/lib/forward-as-attachment.ts index d0feb48d1..bdb6d0e11 100644 --- a/lib/forward-as-attachment.ts +++ b/lib/forward-as-attachment.ts @@ -14,6 +14,11 @@ export interface ForwardAsAttachmentPayload { attachment: ForwardAsAttachmentEntry; } +export interface BatchForwardAsAttachmentPayload { + subject: string; + attachments: ForwardAsAttachmentEntry[]; +} + /** * Build the subject and synthetic attachment entry for forwarding a * message as a message/rfc822 attachment instead of inline-quoted text @@ -56,3 +61,38 @@ export function buildForwardAsAttachmentPayload( }, }; } + +/** + * Batch sibling of buildForwardAsAttachmentPayload: one composer carrying + * every selected message as its own message/rfc822 attachment. The subject is + * count-based (there is no single original subject): `countLabel(n)` returns + * the localized phrase for n messages (e.g. "3 emails") and is called with + * the number actually attached, so the subject never claims more attachments + * than the composer carries. Emails without a blobId are skipped; returns + * null when none of them has one (same silent no-op as the single flow). + * + * Attachments keep the input list order so the composer shows them in the + * same order the list did. Per-message "{date}-{subject}" filenames would + * collide only when two messages share subject AND receivedAt-to-the-second + * - rare enough to tolerate (JMAP accepts duplicate part names), so no + * dedupe suffix. + */ +export function buildBatchForwardAsAttachmentPayload( + emails: Email[], + forwardPrefix: string, + countLabel: (count: number) => string, + filenameOptions?: EmailFilenameOptions, +): BatchForwardAsAttachmentPayload | null { + const attachable = emails.filter((email) => email.blobId); + if (attachable.length === 0) return null; + + return { + subject: buildForwardSubject(countLabel(attachable.length), forwardPrefix), + attachments: attachable.map((email) => ({ + blobId: email.blobId!, + name: emailExportFilename(email, { ...filenameOptions, template: "{date}-{subject}" }), + type: "message/rfc822" as const, + size: email.size, + })), + }; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 968049efc..bd4c3afc1 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -675,6 +675,7 @@ "forward": "إعادة توجيه:", "reply": "رد:" }, + "forward_batch_count": "{count, plural, one {رسالة واحدة} other {# رسائل}}", "no_subject": "(بلا موضوع)", "unknown_sender": "غير معروف", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "ليس مزعجًا", "tag": "وسم", "items_selected": "{count} رسالة محددة", + "forward_as_attachments": "إعادة التوجيه كمرفقات", "edit_draft": "تعديل المسودة", "cancel_scheduled_send": "إلغاء الإرسال", "reschedule_send": "إعادة الجدولة", diff --git a/locales/ca/common.json b/locales/ca/common.json index eccc0aa81..fe6e13512 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -675,6 +675,7 @@ "forward": "Reenv.:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 correu} other {# correus}}", "no_subject": "(Sense assumpte)", "unknown_sender": "Desconegut", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "No és brossa", "tag": "Etiqueta", "items_selected": "{count} correus seleccionats", + "forward_as_attachments": "Reenvia com a fitxers adjunts", "edit_draft": "Edita l'esborrany", "cancel_scheduled_send": "Cancel·la l'enviament", "reschedule_send": "Reprograma", diff --git a/locales/cs/common.json b/locales/cs/common.json index f899dbddb..0216794aa 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -672,6 +672,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 zpráva} few {# zprávy} other {# zpráv}}", "no_subject": "(Bez předmětu)", "unknown_sender": "Neznámý", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Není spam", "tag": "Štítek", "items_selected": "{count} vybraných zpráv", + "forward_as_attachments": "Přeposlat jako přílohy", "edit_draft": "Upravit koncept", "cancel_scheduled_send": "Zrušit odeslání", "reschedule_send": "Přeplánovat", diff --git a/locales/da/common.json b/locales/da/common.json index fbbf0ce1d..1502db287 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -674,6 +674,7 @@ "forward": "Vs:", "reply": "Sv:" }, + "forward_batch_count": "{count, plural, one {1 e-mail} other {# e-mails}}", "no_subject": "(Intet emne)", "unknown_sender": "Ukendt", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Ikke spam", "tag": "Tag", "items_selected": "{count} e-mails valgt", + "forward_as_attachments": "Videresend som vedhæftede filer", "edit_draft": "Redigér kladde", "cancel_scheduled_send": "Annuller afsendelse", "reschedule_send": "Planlæg om", diff --git a/locales/de/common.json b/locales/de/common.json index 3a594ea72..4a1292044 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -672,6 +672,7 @@ "forward": "WG:", "reply": "AW:" }, + "forward_batch_count": "{count, plural, one {1 E-Mail} other {# E-Mails}}", "no_subject": "(Kein Betreff)", "unknown_sender": "Unbekannt", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Kein Spam", "tag": "Label", "items_selected": "{count} E-Mails ausgewählt", + "forward_as_attachments": "Als Anhänge weiterleiten", "edit_draft": "Entwurf bearbeiten", "cancel_scheduled_send": "Senden abbrechen", "reschedule_send": "Neu planen", diff --git a/locales/en/common.json b/locales/en/common.json index 4e9de0c46..3fdb96f0a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -676,6 +676,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 email} other {# emails}}", "no_subject": "(No Subject)", "unknown_sender": "Unknown", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Not spam", "tag": "Tag", "items_selected": "{count} emails selected", + "forward_as_attachments": "Forward as attachments", "edit_draft": "Edit Draft", "cancel_scheduled_send": "Cancel send", "reschedule_send": "Reschedule", diff --git a/locales/es/common.json b/locales/es/common.json index 3fa407970..66209e2ba 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -672,6 +672,7 @@ "forward": "Rvf:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 correo} other {# correos}}", "no_subject": "(Sin Asunto)", "unknown_sender": "Desconocido", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "No es spam", "tag": "Etiqueta", "items_selected": "{count} correos seleccionados", + "forward_as_attachments": "Reenviar como archivos adjuntos", "edit_draft": "Editar borrador", "cancel_scheduled_send": "Cancelar envío", "reschedule_send": "Reprogramar", diff --git a/locales/fa/common.json b/locales/fa/common.json index 7f48db283..09aab21a2 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -675,6 +675,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count} ایمیل", "no_subject": "(بدون موضوع)", "unknown_sender": "ناشناس", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "هرزنامه نیست", "tag": "برچسب", "items_selected": "{count} ایمیل انتخاب شده", + "forward_as_attachments": "ارسال به‌عنوان پیوست‌ها", "edit_draft": "ویرایش پیش‌نویس", "cancel_scheduled_send": "لغو ارسال", "reschedule_send": "باززمان‌بندی", diff --git a/locales/fr/common.json b/locales/fr/common.json index 8a455eee5..eff9fa1e6 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -672,6 +672,7 @@ "forward": "Tr:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 e-mail} other {# e-mails}}", "no_subject": "(Sans objet)", "unknown_sender": "Inconnu", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Pas un spam", "tag": "Étiquette", "items_selected": "{count} emails sélectionnés", + "forward_as_attachments": "Transférer en pièces jointes", "edit_draft": "Modifier le brouillon", "cancel_scheduled_send": "Annuler l’envoi", "reschedule_send": "Replanifier", diff --git a/locales/he/common.json b/locales/he/common.json index 989f495c9..4b5398591 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -629,6 +629,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {הודעת דוא״ל אחת} other {# הודעות דוא״ל}}", "no_subject": "(ללא נושא)", "unknown_sender": "לא ידוע", "quote": { @@ -2474,6 +2475,7 @@ "not_spam": "לא ספאם", "tag": "תווית", "items_selected": "נבחרו הודעות דוא\"ל מסוג{count}", + "forward_as_attachments": "העבר כקבצים מצורפים", "edit_draft": "ערוך טיוטה", "cancel_scheduled_send": "ביטול שליחה", "reschedule_send": "תזמן מחדש", diff --git a/locales/hu/common.json b/locales/hu/common.json index e194011f2..8dd50298f 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -675,6 +675,7 @@ "forward": "Továbbítás:", "reply": "Válasz:" }, + "forward_batch_count": "{count, plural, one {1 e-mail} other {# e-mail}}", "no_subject": "(Nincs tárgy)", "unknown_sender": "Ismeretlen", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Nem spam", "tag": "Címke", "items_selected": "{count} e-mail kijelölve", + "forward_as_attachments": "Továbbítás mellékletként", "edit_draft": "Piszkozat szerkesztése", "cancel_scheduled_send": "Küldés megszakítása", "reschedule_send": "Átütemezés", diff --git a/locales/it/common.json b/locales/it/common.json index b105b1967..35413b400 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -672,6 +672,7 @@ "forward": "Inol:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 email} other {# email}}", "no_subject": "(Nessun oggetto)", "unknown_sender": "Sconosciuto", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Non spam", "tag": "Etichetta", "items_selected": "{count} messaggi selezionati", + "forward_as_attachments": "Inoltra come allegati", "edit_draft": "Modifica bozza", "cancel_scheduled_send": "Annulla invio", "reschedule_send": "Riprogramma", diff --git a/locales/ja/common.json b/locales/ja/common.json index a6f5508ee..9b4585f7a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -672,6 +672,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count}件のメール", "no_subject": "(件名なし)", "unknown_sender": "不明", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "迷惑メールでない", "tag": "ラベル", "items_selected": "{count}件のメールを選択", + "forward_as_attachments": "添付ファイルとして転送", "edit_draft": "下書きを編集", "cancel_scheduled_send": "送信をキャンセル", "reschedule_send": "再予約", diff --git a/locales/ko/common.json b/locales/ko/common.json index eb0f59305..172aa879c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -672,6 +672,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count}개의 메일", "no_subject": "(제목 없음)", "unknown_sender": "알 수 없음", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "정상 메일", "tag": "태그", "items_selected": "{count}개의 메일 선택됨", + "forward_as_attachments": "첨부 파일로 전달", "edit_draft": "임시보관 메일 수정", "cancel_scheduled_send": "보내기 취소", "reschedule_send": "다시 예약", diff --git a/locales/lv/common.json b/locales/lv/common.json index 34ae3d488..51ab9194a 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -672,6 +672,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 vēstule} other {# vēstules}}", "no_subject": "(nav temata)", "unknown_sender": "Nezināms", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Nav mēstule", "tag": "Tags", "items_selected": "{count} vēstules atlasītas", + "forward_as_attachments": "Pārsūtīt kā pielikumiem", "edit_draft": "Rediģēt melnrakstu", "cancel_scheduled_send": "Atcelt sūtīšanu", "reschedule_send": "Pārplānot", diff --git a/locales/mn/common.json b/locales/mn/common.json index 439d91890..d3fa0df08 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -675,6 +675,7 @@ "forward": "Дахин:", "reply": "Дахин:" }, + "forward_batch_count": "{count, plural, one {1 имэйл} other {# имэйл}}", "no_subject": "(Сэдэв байхгүй)", "unknown_sender": "Үл мэдэгдэх", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Спам биш", "tag": "Таг", "items_selected": "{count} имэйл сонгосон", + "forward_as_attachments": "Хавсралт болгон дамжуулах", "edit_draft": "Ноорог засах", "cancel_scheduled_send": "Илгээхийг цуцлах", "reschedule_send": "Хуваарь солих", diff --git a/locales/nb/common.json b/locales/nb/common.json index 4b4258b96..8669f73e7 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -675,6 +675,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 e-post} other {# e-poster}}", "no_subject": "(Uten emne)", "unknown_sender": "Ukjent", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Ikke søppelpost", "tag": "Etikett", "items_selected": "Valgt: {count}", + "forward_as_attachments": "Videresend som vedlegg", "edit_draft": "Rediger utkast", "cancel_scheduled_send": "Avbryt sending", "reschedule_send": "Planlegg på nytt", diff --git a/locales/nl/common.json b/locales/nl/common.json index 6185127f7..950183f97 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -672,6 +672,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 e-mail} other {# e-mails}}", "no_subject": "(Geen onderwerp)", "unknown_sender": "Onbekend", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Geen spam", "tag": "Label", "items_selected": "{count} e-mails geselecteerd", + "forward_as_attachments": "Doorsturen als bijlagen", "edit_draft": "Concept bewerken", "cancel_scheduled_send": "Verzenden annuleren", "reschedule_send": "Opnieuw plannen", diff --git a/locales/pl/common.json b/locales/pl/common.json index e65a28bad..07e587b8e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -672,6 +672,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 e-mail} few {# e-maile} many {# e-maili} other {# e-maili}}", "no_subject": "(Bez tematu)", "unknown_sender": "Nieznany", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "To nie spam", "tag": "Etykieta", "items_selected": "{count} zaznaczonych wiadomości", + "forward_as_attachments": "Prześlij dalej jako załączniki", "edit_draft": "Edytuj szkic", "cancel_scheduled_send": "Anuluj wysyłkę", "reschedule_send": "Przełóż", diff --git a/locales/pt/common.json b/locales/pt/common.json index 29cf6f142..051dee68a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -672,6 +672,7 @@ "forward": "Enc:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 e-mail} other {# e-mails}}", "no_subject": "(Sem Assunto)", "unknown_sender": "Desconhecido", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Não é spam", "tag": "Etiqueta", "items_selected": "{count} e-mails selecionados", + "forward_as_attachments": "Encaminhar como anexos", "edit_draft": "Editar rascunho", "cancel_scheduled_send": "Cancelar envio", "reschedule_send": "Reagendar", diff --git a/locales/ro/common.json b/locales/ro/common.json index d0910b0ed..e00235792 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -675,6 +675,7 @@ "forward": "Redirecționare:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 e-mail} few {# e-mailuri} other {# de e-mailuri}}", "no_subject": "(Fără subiect)", "unknown_sender": "Necunoscut", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Nu este spam", "tag": "Etichetă", "items_selected": "{count} e-mailuri selectate", + "forward_as_attachments": "Redirecționează ca atașamente", "edit_draft": "Editează schița", "cancel_scheduled_send": "Anulează trimiterea", "reschedule_send": "Reprogramare", diff --git a/locales/ru/common.json b/locales/ru/common.json index 51fc6cb43..46441e775 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -672,6 +672,7 @@ "forward": "Пересл:", "reply": "Ответ:" }, + "forward_batch_count": "{count, plural, one {1 письмо} few {# письма} many {# писем} other {# писем}}", "no_subject": "(Без темы)", "unknown_sender": "Неизвестный", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Не спам", "tag": "Тег", "items_selected": "{count} писем выбрано", + "forward_as_attachments": "Переслать как вложения", "edit_draft": "Редактировать черновик", "cancel_scheduled_send": "Отменить отправку", "reschedule_send": "Перенести", diff --git a/locales/sk/common.json b/locales/sk/common.json index bf789b23d..f272a0b6a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -675,6 +675,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 e-mail} few {# e-maily} other {# e-mailov}}", "no_subject": "(Bez predmetu)", "unknown_sender": "Neznámy", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Nie je spam", "tag": "Štítok", "items_selected": "{count} vybraných e-mailov", + "forward_as_attachments": "Preposlať ako prílohy", "edit_draft": "Upraviť koncept", "cancel_scheduled_send": "Zrušiť odoslanie", "reschedule_send": "Prenaplánovať", diff --git a/locales/tr/common.json b/locales/tr/common.json index be75c33a0..c2d1aff3e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -675,6 +675,7 @@ "forward": "İlt:", "reply": "Yn:" }, + "forward_batch_count": "{count, plural, one {1 e-posta} other {# e-posta}}", "no_subject": "(Konu Yok)", "unknown_sender": "Bilinmeyen", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Spam değil", "tag": "Etiket", "items_selected": "{count} e-posta seçildi", + "forward_as_attachments": "Ek olarak ilet", "edit_draft": "Taslağı Düzenle", "cancel_scheduled_send": "Göndermeyi iptal et", "reschedule_send": "Yeniden zamanla", diff --git a/locales/uk/common.json b/locales/uk/common.json index aab213403..1f19b5a38 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -672,6 +672,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count, plural, one {1 лист} few {# листи} many {# листів} other {# листів}}", "no_subject": "(без теми)", "unknown_sender": "Невідомий", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "Не спам", "tag": "Мітка", "items_selected": "Вибрано електронних листів: {count}", + "forward_as_attachments": "Переслати як вкладення", "edit_draft": "Редагувати чернетку", "cancel_scheduled_send": "Скасувати надсилання", "reschedule_send": "Перенести", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 5dcdfd143..3031d2fee 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -675,6 +675,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count} 封郵件", "no_subject": "(無主題)", "unknown_sender": "未知", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "非垃圾郵件", "tag": "標籤", "items_selected": "已選取 {count} 封郵件", + "forward_as_attachments": "以附件形式轉寄", "edit_draft": "編輯草稿", "cancel_scheduled_send": "取消傳送", "reschedule_send": "重新排程", diff --git a/locales/zh/common.json b/locales/zh/common.json index 6aeb9a007..9c0f5c733 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -672,6 +672,7 @@ "forward": "Fwd:", "reply": "Re:" }, + "forward_batch_count": "{count} 封邮件", "no_subject": "(无主题)", "unknown_sender": "未知", "quote": { @@ -2508,6 +2509,7 @@ "not_spam": "不是垃圾邮件", "tag": "标签", "items_selected": "已选择 {count} 封邮件", + "forward_as_attachments": "作为附件转发", "edit_draft": "编辑草稿", "cancel_scheduled_send": "取消发送", "reschedule_send": "重新计划", From c7cac25b7bcfa37103200d2ae479a7bff0520a7d Mon Sep 17 00:00:00 2001 From: daluchen Date: Thu, 15 Oct 2026 15:03:43 +0800 Subject: [PATCH 2/2] fix: resolve the batch forward selection through account-scoped keys Upstream renamed the store's selection from bare email ids to account-scoped selectedEmailKeys, and a bare id can name a different message in another account. Route the batch forward-as-attachment handler through resolveSelectedEmails like the other batch actions: it matches keys against the loaded rows and expanded thread members, and the scheduled view it used to special-case offers no selection. Co-Authored-By: Claude Code --- components/mail/mail-app.tsx | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index b3601f742..c5e6d1b55 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -2575,20 +2575,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Batch sibling of handleForwardAsAttachment above: opens ONE composer with // every selected message attached as message/rfc822. Resolution goes through // useEmailStore.getState() - always fresh, immune to the closure-staleness - // noted above - and filters the current list so attachment order follows the - // list, not checkbox-click order. Stale selection ids (folder switched under - // the selection) simply drop out of the filter rather than forwarding - // something the user can no longer see. + // noted above - and matches the account-scoped selection keys against the + // loaded rows and expanded thread members, so attachment order follows the + // list, not checkbox-click order. Stale selection keys (folder switched under + // the selection) simply drop out rather than forwarding something the user + // can no longer see. Scheduled view offers no selection (its batch toolbar + // and menu are gated off), so resolving the regular lists is enough. const handleBatchForwardAsAttachment = async () => { - const state = useEmailStore.getState(); - const ids = state.selectedEmailIds; - if (ids.size < 2) return; // menu only offers this for >1; guards future wiring - // Unreachable from the menu in the scheduled view (the whole actions block - // is isScheduled-gated); resolve the right pool anyway so a future shortcut - // can't silently read the wrong list. - const pool = state.isScheduledView ? state.scheduledEmails : state.emails; - const selected = pool.filter((e) => ids.has(e.id)); - if (selected.length === 0) return; + const selected = resolveSelectedEmails(useEmailStore.getState()); + if (selected.length < 2) return; // menu only offers this for >1; guards future wiring const { emailDownloadTemplate,