Merge pull request #197 from Coffey-Labs/feat/forward-as-eml

Forward a message as an attachment
This commit is contained in:
Coffey Labs
2026-09-01 22:34:02 -07:00
committed by GitHub
9 changed files with 251 additions and 4 deletions
+13
View File
@@ -259,6 +259,19 @@ same query string — so what it builds can be read, edited and learned from.
- **Attachments** listed with type and size: download, open in a new tab, and an - **Attachments** listed with type and size: download, open in a new tab, and an
inline preview for images and PDFs. inline preview for images and PDFs.
- **Show original**, **Show headers**, **Download (.eml)** and **Print**. - **Show original**, **Show headers**, **Download (.eml)** and **Print**.
- **Forward as attachment** sends the message itself rather than a quotation of
it — headers, structure and every attachment intact, which is what a bounce
or a phishing report needs and what quoting destroys. It costs **no upload at
all**: a message's `blobId` is its own RFC822 blob and already lives in the
account, so a 40 MB message attaches by reference as fast as a small one. In
the message's ⋮ 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.
- Saved and attached `.eml` files are **named from the subject in whatever
script it is written in**. The rule keeps letters and drops only what a
filesystem cannot take — path separators, the names Windows reserves, control
characters — so a Russian or Japanese subject keeps its own name instead of
becoming a row of underscores.
- **Unsubscribe** where the message carries `List-Unsubscribe`. - **Unsubscribe** where the message carries `List-Unsubscribe`.
- **Sender details** expand to the full From/To/Cc/Reply-To with addresses. - **Sender details** expand to the full From/To/Cc/Reply-To with addresses.
- **What the spam filter said** sits in those details, read back off the - **What the spam filter said** sits in those details, read back off the
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { emlFilename, sanitizeFilename } from "@/lib/emlName";
describe("emlFilename", () => {
it("keeps an ordinary subject, with spaces as underscores", () => {
expect(emlFilename("Quarterly report")).toBe("Quarterly_report.eml");
});
it("keeps letters from any script, which the ASCII rule threw away", () => {
// The whole point: none of these may come out as a row of underscores.
expect(emlFilename("Квартальный отчёт")).toBe("Квартальный_отчёт.eml");
expect(emlFilename("四半期報告")).toBe("四半期報告.eml");
expect(emlFilename("Rapport trimestriel été")).toBe("Rapport_trimestriel_été.eml");
});
it("keeps the punctuation that is fine in a filename", () => {
expect(emlFilename("Re- budget (v3) [final]")).toBe("Re-_budget_(v3)_[final].eml");
});
it("drops path separators and the characters Windows reserves", () => {
expect(emlFilename("a/b\\c:d*e?f\"g<h>i|j")).toBe("abcdefghij.eml");
});
it("drops control characters", () => {
expect(emlFilename("a\u0007b\u0000c")).toBe("abc.eml");
expect(emlFilename("a\u007fb")).toBe("ab.eml");
});
it("falls back when there is no subject, or nothing survives", () => {
expect(emlFilename("")).toBe("message.eml");
expect(emlFilename(null)).toBe("message.eml");
expect(emlFilename(undefined)).toBe("message.eml");
expect(emlFilename("///")).toBe("message.eml");
expect(emlFilename(" ")).toBe("message.eml");
});
it("does not end in a dot or a space, which Windows refuses", () => {
expect(emlFilename("Report.")).toBe("Report.eml");
expect(emlFilename("Report ")).toBe("Report.eml");
expect(emlFilename("...Report...")).toBe("Report.eml");
});
it("does not start with a dot, which would hide the file on Unix", () => {
expect(emlFilename(".hidden")).toBe("hidden.eml");
});
it("caps the length so it survives a filesystem limit", () => {
const name = emlFilename("x".repeat(500));
expect(name).toBe(`${"x".repeat(80)}.eml`);
});
it("exposes the stem on its own", () => {
expect(sanitizeFilename("Quarterly report")).toBe("Quarterly_report");
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* A filename for a message saved or attached as `.eml`.
*
* The rule this replaces 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. ihasmail ships in nine languages besides English, so the
* subjects it handled worst were most of the world's.
*
* What is actually unsafe in a filename is a much shorter list than "not
* ASCII": the path separators, the characters Windows reserves, and the
* control range. Everything else is a letter to somebody.
*
* The test is written by code point rather than as a character class because
* the escaping in one of those is its own small trap, and this says plainly
* what it means.
*/
/** Reserved on Windows, or a path separator. */
const RESERVED = '<>:"/\\|?*';
function unsafe(ch: string): boolean {
const c = ch.codePointAt(0) ?? 0;
// C0 controls, and DEL.
if (c < 0x20 || c === 0x7f) return true;
return RESERVED.includes(ch);
}
/**
* Long enough to stay recognisable, short enough to survive a 255-*byte* limit
* once a CJK subject is three bytes a character.
*/
const MAX = 80;
/** The stem only, so a caller can put another extension on it. */
export function sanitizeFilename(subject: string | null | undefined): string {
const kept = [...(subject ?? "")].filter((ch) => !unsafe(ch)).join("");
return kept
// Whitespace becomes an underscore rather than being kept: it is what the
// previous rule did, and it saves a quoting question in a shell later.
.replace(/\s+/g, "_")
.slice(0, MAX)
// Windows refuses a name ending in a dot or a space, and a leading dot
// hides the file on Unix. Neither is worth inheriting from a subject.
.replace(/^[.\s_]+|[.\s_]+$/g, "");
}
export function emlFilename(subject: string | null | undefined): string {
return `${sanitizeFilename(subject) || "message"}.eml`;
}
@@ -0,0 +1,93 @@
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");
});
});
+26
View File
@@ -11,6 +11,7 @@ import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule"; import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n"; import { t as translate } from "@/lib/i18n";
import { settings } from "./settings"; import { settings } from "./settings";
import { emlFilename } from "@/lib/emlName";
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders"; import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
export interface ComposeAttachment { export interface ComposeAttachment {
@@ -85,6 +86,8 @@ interface ComposeState {
/** Open a message again as a mail that has not been sent yet. */ /** Open a message again as a mail that has not been sent yet. */
composeAsNew(email: Email): Promise<string>; composeAsNew(email: Email): Promise<string>;
reply(email: Email, mode: "reply" | "replyAll" | "forward", opts?: { all?: boolean }): Promise<string>; reply(email: Email, mode: "reply" | "replyAll" | "forward", opts?: { all?: boolean }): Promise<string>;
/** Forward the message whole, as an attachment, rather than quoted into a new one. */
forwardAsAttachment(email: Email): string;
update(key: string, patch: Partial<Draft>): void; update(key: string, patch: Partial<Draft>): void;
close(key: string, opts?: { discard?: boolean }): Promise<void>; close(key: string, opts?: { discard?: boolean }): Promise<void>;
focus(key: string): void; focus(key: string): void;
@@ -382,6 +385,29 @@ export const useCompose = create<ComposeState>((set, get) => ({
return d.key; return d.key;
}, },
forwardAsAttachment(email) {
const accountId = useMail.getState().accountId;
const key = get().open({
subject: replySubject(email.subject, "Fwd"),
relatedEmailId: email.id,
relatedKeyword: "$forwarded",
replyMode: "forward",
});
// A message's own blobId *is* its RFC822 blob, and it already lives in this
// account -- so this goes through the same path as attach-from-Files and
// uploads nothing at all, however large the message.
//
// It inherits that path's size check as well, which is measured against
// `maxSizeUpload` even though nothing is being uploaded. That is worth
// knowing rather than working around here: the check belongs to
// `addFromFiles` and applies to every by-reference attachment, so if it is
// wrong it is wrong in one place and should be fixed there.
if (accountId) {
void get().addFromFiles(key, [{ accountId, name: emlFilename(email.subject), type: "message/rfc822", size: email.size, blobId: email.blobId }]);
}
return key;
},
update(key, patch) { update(key, patch) {
set((s) => ({ drafts: s.drafts.map((d) => (d.key === key ? { ...d, ...patch, dirty: patch.dirty ?? (d.dirty || isContentPatch(patch)) } : d)) })); set((s) => ({ drafts: s.drafts.map((d) => (d.key === key ? { ...d, ...patch, dirty: patch.dirty ?? (d.dirty || isContentPatch(patch)) } : d)) }));
if (isContentPatch(patch)) scheduleAutosave(key, get); if (isContentPatch(patch)) scheduleAutosave(key, get);
+6 -2
View File
@@ -666,8 +666,12 @@ a.menu-item:hover { color: var(--fg); }
.attachment .att-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent-soft-fg); flex: 0 0 auto; overflow: hidden; } .attachment .att-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent-soft-fg); flex: 0 0 auto; overflow: hidden; }
.attachment .att-icon img { width: 100%; height: 100%; object-fit: cover; } .attachment .att-icon img { width: 100%; height: 100%; object-fit: cover; }
.attachment .att-text { flex: 1; min-width: 0; } .attachment .att-text { flex: 1; min-width: 0; }
.attachment .att-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .92em; } /* Both are spans, and `overflow`/`text-overflow` do nothing on an inline
.attachment .att-size { color: var(--fg-muted); font-size: .8em; } element -- 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. */
.attachment .att-name { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .92em; }
.attachment .att-size { display: block; color: var(--fg-muted); font-size: .8em; }
.attachment .att-actions { display: none; gap: 0; } .attachment .att-actions { display: none; gap: 0; }
.attachment:hover .att-actions { display: flex; } .attachment:hover .att-actions { display: flex; }
.attachment:hover .att-size { display: none; } .attachment:hover .att-size { display: none; }
+1
View File
@@ -492,6 +492,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}> <Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
<MenuItem icon={<Reply size={16} />} label={t("Reply")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} /> <MenuItem icon={<Reply size={16} />} label={t("Reply")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
<MenuItem icon={<Forward size={16} />} label={t("Forward")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} /> <MenuItem icon={<Forward size={16} />} label={t("Forward")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
<MenuItem icon={<Paperclip size={16} />} label={t("Forward as attachment")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) useCompose.getState().forwardAsAttachment(e); }} />
<MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().composeAsNew(e); }} /> <MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().composeAsNew(e); }} />
<MenuSep /> <MenuSep />
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} /> <MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
+5 -1
View File
@@ -10,6 +10,7 @@ import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar"; import { useCalendar } from "@/store/calendar";
import { startAppointment } from "@/lib/appointment"; import { startAppointment } from "@/lib/appointment";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { emlFilename } from "@/lib/emlName";
import { spamReport, type SpamReport } from "@/lib/spamScore"; import { spamReport, type SpamReport } from "@/lib/spamScore";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, formatAddress } from "@/lib/address"; import { displayName, formatAddress } from "@/lib/address";
@@ -128,7 +129,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const downloadEml = () => { const downloadEml = () => {
const a = document.createElement("a"); 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.download = "";
a.click(); a.click();
}; };
@@ -231,6 +232,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={<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={<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")} /> <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 {/* 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. */} 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)} /> <MenuItem icon={<MailPlus size={16} />} label={translate("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(e)} />
+2 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MailPlus, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download } from "lucide-react"; import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MailPlus, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download , Paperclip} from "lucide-react";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { useCompose } from "@/store/compose"; import { useCompose } from "@/store/compose";
@@ -302,6 +302,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
<span className="spacer" /> <span className="spacer" />
<button className="icon-btn" onClick={replyMore.open} aria-label={t("More ways to send this")}><MoreVertical size={18} /></button> <button className="icon-btn" onClick={replyMore.open} aria-label={t("More ways to send this")}><MoreVertical size={18} /></button>
<Popover anchor={replyMore.anchor} onClose={replyMore.close} align="end" width={220}> <Popover anchor={replyMore.anchor} onClose={replyMore.close} align="end" width={220}>
<MenuItem icon={<Paperclip size={16} />} label={t("Forward as attachment")} onClick={() => useCompose.getState().forwardAsAttachment(last)} />
<MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(last)} /> <MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(last)} />
</Popover> </Popover>
</div> </div>