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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
A draft that carried attachments and no message re-opened with the
contents of one of the attachments as its text. Attach a .csv to an
empty draft, save it, re-open it: the editor shows the rows of the csv
as what you had written, and the next save or send makes that the real
message.
Both edit-draft paths built the text body by joining every value in
bodyValues:
Object.values(draft.bodyValues).map(v => v.value).join('\n')
bodyValues is keyed by part id, and getEmail asks for
fetchAllBodyValues, which per RFC 8621 4.1.4 returns a value for every
text/* part of the message - attachments included. So any attached
.csv, .txt, .log or .md was joined in as body text, and with no html
body to prefer it became the whole message.
Take the body from the parts the server lists as the body instead, and
from nothing else. A draft whose textBody is empty now re-opens empty,
which is what an attachments-only draft is.
The selection lives in lib/email-body-selection.ts beside
getRenderableHtmlBody, which exists because the viewer and the mobile
thread view had already drifted apart once (#489). mail-app and the pro
email tab held character-for-character copies of this join, so the same
drift was already in place.