Forward a message as an attachment

Forwarding quoted the original into a new message, which is the right
thing for passing on something to be read and the wrong thing for passing
on something to be looked at. Quoting rewrites the body, drops the
headers, and re-parents the attachments, so a bounce, a phishing report or
anything else where the message itself is the evidence arrived altered.

Forward as attachment sends the message whole, as a message/rfc822 part.
It costs no upload at all: a message's own blobId is its RFC822 blob and
already lives in the account, so this goes through the same by-reference
path as attach-from-Files and a 40 MB message attaches as fast as a small
one.

It is in the message's own menu, the list's right-click menu, and the
overflow on the reply strip at the foot of a thread, which is the one a
thumb finds on a phone.

Two things fixed on the way, both exposed rather than introduced by this.

The filename rule was subject.replace(/[^\w.-]+/g, "_"), and \w without
the u flag is ASCII: every character of a Russian, Japanese or Chinese
subject failed the class, so those messages downloaded as a row of
underscores. What is actually unsafe in a filename is much shorter than
"not ASCII" -- path separators, the names Windows reserves, the control
range -- so the rule now keeps letters from any script and drops only
those. It lives in one place and the .eml download uses it too.

And the composer's attachment chip set overflow/text-overflow on a span,
where neither does anything, so the name never truncated and the size ran
on after it on the same line. Only long names showed it, which is every
.eml named from a subject.
This commit is contained in:
2026-09-01 21:57:11 -07:00
parent 34578ba426
commit 0db795371e
9 changed files with 251 additions and 4 deletions
+5 -1
View File
@@ -10,6 +10,7 @@ import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
import { startAppointment } from "@/lib/appointment";
import { client } from "@/jmap/client";
import { emlFilename } from "@/lib/emlName";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html";
@@ -126,7 +127,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const downloadEml = () => {
const a = document.createElement("a");
a.href = client.downloadUrl(accountId, e.blobId, `${(e.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822");
a.href = client.downloadUrl(accountId, e.blobId, emlFilename(e.subject), "message/rfc822");
a.download = "";
a.click();
};
@@ -229,6 +230,9 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<MenuItem icon={<Reply size={16} />} label={translate("Reply")} onClick={() => void reply(e, "reply")} />
<MenuItem icon={<ReplyAll size={16} />} label={translate("Reply all")} onClick={() => void reply(e, "replyAll")} />
<MenuItem icon={<Forward size={16} />} label={translate("Forward")} onClick={() => void reply(e, "forward")} />
{/* The same message rather than a quotation of it: headers, attachments
and all, for passing one on to be looked at rather than read. */}
<MenuItem icon={<Paperclip size={16} />} label={translate("Forward as attachment")} onClick={() => useCompose.getState().forwardAsAttachment(e)} />
{/* Sends the same mail again rather than passing it on, so it sits with
the other three rather than down among the read-only actions. */}
<MenuItem icon={<MailPlus size={16} />} label={translate("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(e)} />