Files
webmail/lib/forward-as-attachment.ts
T
daluchenandClaude Code 5dde5e00e1 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 <noreply@anthropic.com>
2026-10-15 14:50:31 +08:00

99 lines
3.9 KiB
TypeScript

import type { Email } from "@/lib/jmap/types";
import { buildForwardSubject } from "@/lib/subject-prefix";
import { emailExportFilename, type EmailFilenameOptions } from "@/lib/download-filename";
export interface ForwardAsAttachmentEntry {
blobId: string;
name: string;
type: "message/rfc822";
size: number;
}
export interface ForwardAsAttachmentPayload {
subject: string;
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
* (e.g. reporting spam to an upstream gateway that expects the raw
* original as an attachment, or preserving exact formatting/headers).
*
* Referenced by blobId, not re-uploaded - JMAP blobs are account-scoped,
* not per-email, so the same blobId a message already has can be attached
* to a brand new outgoing email directly.
*
* `filenameOptions`, when passed, carries the user's configured space/case/
* diacritics transforms (see useSettingsStore's filenameSpaceReplacement
* and friends) for consistency with "Export as .eml" / drag-out. Its
* `template`, if any, is ignored: this attachment goes out to a possibly
* external recipient (spam gateway, another person), so the filename is
* always just "{date}-{subject}.eml" - never the user's own from/to naming
* template, which could otherwise leak sender/recipient names into an
* attachment filename visible to that recipient.
*
* Returns null when the email has no blobId (nothing to reference).
*/
export function buildForwardAsAttachmentPayload(
email: Email,
forwardPrefix: string,
filenameOptions?: EmailFilenameOptions,
): ForwardAsAttachmentPayload | null {
if (!email.blobId) return null;
return {
// Match the normal Forward flow's getInitialSubject(), which leaves the
// subject blank rather than prefix-only when the original has none -
// buildForwardSubject("", prefix) would otherwise return just the bare
// prefix (e.g. "Fwd:") for a subject-less message.
subject: email.subject ? buildForwardSubject(email.subject, forwardPrefix) : "",
attachment: {
blobId: email.blobId,
name: emailExportFilename(email, { ...filenameOptions, template: "{date}-{subject}" }),
type: "message/rfc822",
size: email.size,
},
};
}
/**
* 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,
})),
};
}