Files
ihasmail-inbuxa/web/src/store/__tests__/forward-as-attachment.test.ts
T
jcoffey-dev 0db795371e 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.
2026-09-01 21:57:11 -07:00

94 lines
3.7 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import type { Email } from "@/jmap/types";
/**
* Forwarding a message whole rather than quoted. The point of the
* implementation is that it costs no upload: a message's own `blobId` is its
* RFC822 blob and already lives in this account, so the attachment references
* it directly.
*/
function email(over: Partial<Email> = {}): Email {
return {
id: "e1",
blobId: "b-raw-1",
threadId: "t1",
mailboxIds: { mb1: true },
keywords: {},
size: 40 * 1024 * 1024,
receivedAt: "2026-03-04T10:00:00Z",
sentAt: "2026-03-04T10:00:00Z",
subject: "Quarterly report",
from: [{ name: "Ada Lovelace", email: "[email protected]" }],
to: [{ name: null, email: "[email protected]" }],
...over,
} as Email;
}
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
useMail.setState({
accountId: "a1",
identities: [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as never,
});
});
const draftFor = (key: string) => useCompose.getState().drafts.find((d) => d.key === key)!;
describe("forwardAsAttachment", () => {
it("attaches the message itself, by reference, with no upload", () => {
const key = useCompose.getState().forwardAsAttachment(email());
const d = draftFor(key);
expect(d.attachments).toHaveLength(1);
const a = d.attachments[0]!;
expect(a.type).toBe("message/rfc822");
// The message's own blob, carried straight across: nothing was uploaded,
// and the attachment is complete the moment the composer opens.
expect(a.blobId).toBe("b-raw-1");
expect(a.progress).toBe(100);
expect(a.error).toBeNull();
});
it("names the attachment from the subject", () => {
expect(draftFor(useCompose.getState().forwardAsAttachment(email())).attachments[0]!.name).toBe("Quarterly_report.eml");
});
it("names it from a subject in any script, not a row of underscores", () => {
const key = useCompose.getState().forwardAsAttachment(email({ subject: "四半期報告" }));
expect(draftFor(key).attachments[0]!.name).toBe("四半期報告.eml");
});
it("falls back to a name when there is no subject", () => {
const key = useCompose.getState().forwardAsAttachment(email({ subject: null }));
expect(draftFor(key).attachments[0]!.name).toBe("message.eml");
});
it("prefixes the subject once, and does not double it on a forward of a forward", () => {
expect(draftFor(useCompose.getState().forwardAsAttachment(email())).subject).toBe("Fwd: Quarterly report");
const again = useCompose.getState().forwardAsAttachment(email({ subject: "Fwd: Quarterly report" }));
expect(draftFor(again).subject).toBe("Fwd: Quarterly report");
});
it("marks the original forwarded, and starts no reply thread", () => {
const d = draftFor(useCompose.getState().forwardAsAttachment(email()));
expect(d.relatedEmailId).toBe("e1");
expect(d.relatedKeyword).toBe("$forwarded");
// A forward is not a reply: it must not join the original's thread.
expect(d.inReplyTo).toBeNull();
expect(d.references).toBeNull();
});
it("addresses nobody, since a forward chooses its own recipient", () => {
const d = draftFor(useCompose.getState().forwardAsAttachment(email()));
expect(d.to).toEqual([]);
expect(d.cc).toEqual([]);
});
it("does not quote the message into the body as well as attaching it", () => {
const d = draftFor(useCompose.getState().forwardAsAttachment(email()));
expect(d.html).not.toContain("Forwarded message");
expect(d.text).not.toContain("Forwarded message");
});
});