Merge pull request #197 from Coffey-Labs/feat/forward-as-eml
Forward a message as an attachment
This commit is contained in:
+13
@@ -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
|
||||
inline preview for images and PDFs.
|
||||
- **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`.
|
||||
- **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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { ensureScheduledMailbox, useScheduled } from "./scheduled";
|
||||
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { settings } from "./settings";
|
||||
import { emlFilename } from "@/lib/emlName";
|
||||
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||
|
||||
export interface ComposeAttachment {
|
||||
@@ -85,6 +86,8 @@ interface ComposeState {
|
||||
/** Open a message again as a mail that has not been sent yet. */
|
||||
composeAsNew(email: Email): 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;
|
||||
close(key: string, opts?: { discard?: boolean }): Promise<void>;
|
||||
focus(key: string): void;
|
||||
@@ -382,6 +385,29 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
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) {
|
||||
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);
|
||||
|
||||
@@ -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 img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.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; }
|
||||
.attachment .att-size { color: var(--fg-muted); font-size: .8em; }
|
||||
/* Both are spans, and `overflow`/`text-overflow` do nothing on an inline
|
||||
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:hover .att-actions { display: flex; }
|
||||
.attachment:hover .att-size { display: none; }
|
||||
|
||||
@@ -492,6 +492,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<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={<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); }} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
||||
|
||||
@@ -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 { spamReport, type SpamReport } from "@/lib/spamScore";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
import { displayName, formatAddress } from "@/lib/address";
|
||||
@@ -128,7 +129,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();
|
||||
};
|
||||
@@ -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={<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)} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useSettings } from "@/store/settings";
|
||||
import { useCompose } from "@/store/compose";
|
||||
@@ -302,6 +302,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
||||
<span className="spacer" />
|
||||
<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}>
|
||||
<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)} />
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user