Merge pull request #179 from Coffey-Labs/feat/compose-as-new

Send a message again as a new one
This commit is contained in:
Coffey Labs
2026-09-01 09:10:42 -07:00
committed by GitHub
5 changed files with 214 additions and 2 deletions
+5
View File
@@ -323,6 +323,11 @@ minimisable and maximisable; full-screen on mobile.
- **Drafts** save as you type and on close, with the save state shown.
- **Quoting** on reply, with the signature placed above or below it, and
reply-all as an optional default.
- **Compose as new** — the same mail again rather than passed on, for one that
bounced or went to a misspelled address. Recipients, Reply-To, subject, body
and attachments come across as they stand; the Message-ID, date and threading
headers do not, so it sends as a mail that has never been sent, and the
original is neither altered nor marked.
- **Send and archive**, and **archive on reply**, as options.
### Undo send, and scheduled send
@@ -0,0 +1,134 @@
import { beforeEach, afterEach, describe, expect, it } from "vitest";
import { useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import type { Email } from "@/jmap/types";
/**
* "Compose as new" is a mail sent again, not a mail passed on. What it keeps is
* easy to see on screen; what it must leave behind is not, and that is what
* these are for -- a draft that kept `draftId` would destroy the message it was
* made from on send, and one that kept `relatedEmailId` would mark it answered
* or forwarded by a mail that is neither.
*/
const SENT: Email = {
id: "m1",
messageId: ["<[email protected]>"],
from: [{ name: "John", email: "[email protected]" }],
to: [{ name: "Ann", email: "[email protected]" }],
cc: [{ name: null, email: "[email protected]" }],
bcc: [{ name: null, email: "[email protected]" }],
replyTo: [{ name: null, email: "[email protected]" }],
subject: "Quarterly numbers",
references: ["<[email protected]>"],
inReplyTo: ["<[email protected]>"],
keywords: {},
htmlBody: [{ partId: "1", type: "text/html" }],
textBody: [{ partId: "1", type: "text/html" }],
bodyValues: { "1": { value: "<p>Here they are.</p>", isEncodingProblem: false, isTruncated: false } },
attachments: [
{ blobId: "b1", name: "numbers.pdf", type: "application/pdf", size: 1024, cid: null, disposition: "attachment" },
{ blobId: "b2", name: "logo.png", type: "image/png", size: 64, cid: "logo@x", disposition: "inline" },
],
} as unknown as Email;
/** The same mail, but from somebody else. */
const RECEIVED: Email = { ...SENT, id: "m2", from: [{ name: "Ann", email: "[email protected]" }] } as Email;
const IDENTITIES = [
{ id: "i1", name: "John", email: "[email protected]", replyTo: null },
{ id: "i2", name: "John (other)", email: "[email protected]", replyTo: null },
];
function mailState(email: Email) {
useMail.setState({
accountId: "a1",
identities: IDENTITIES as never,
getEmails: (async () => [email]) as never,
defaultIdentity: (() => IDENTITIES[1]) as never,
});
}
const draftFor = async (email: Email) => {
mailState(email);
const key = await useCompose.getState().composeAsNew(email);
return useCompose.getState().drafts.find((d) => d.key === key)!;
};
beforeEach(() => useCompose.setState({ drafts: [], activeKey: null }));
afterEach(() => useCompose.setState({ drafts: [], activeKey: null }));
describe("compose as new", () => {
it("keeps every recipient the message had, bcc included", async () => {
const d = await draftFor(SENT);
expect(d.to).toEqual([{ name: "Ann", email: "[email protected]" }]);
expect(d.cc).toEqual([{ name: null, email: "[email protected]" }]);
expect(d.bcc).toEqual([{ name: null, email: "[email protected]" }]);
// Fields with something in them are shown, or the copy is invisible.
expect([d.showCc, d.showBcc]).toEqual([true, true]);
});
it("keeps the subject as it stands, with no Re: or Fwd: on it", async () => {
const d = await draftFor(SENT);
expect(d.subject).toBe("Quarterly numbers");
});
it("keeps the reply-to the message carried", async () => {
const d = await draftFor(SENT);
expect(d.replyTo).toEqual([{ name: null, email: "[email protected]" }]);
expect(d.showReplyTo).toBe(true);
});
it("keeps the body, unquoted and unwrapped", async () => {
const d = await draftFor(SENT);
expect(d.html).toContain("Here they are.");
expect(d.html).not.toContain("ihm-quote");
expect(d.html).not.toContain("blockquote");
expect(d.html).not.toContain("Forwarded message");
});
it("keeps the attachments, by the blobs they already have", async () => {
const d = await draftFor(SENT);
expect(d.attachments.map((a) => a.name)).toEqual(["numbers.pdf", "logo.png"]);
// A blobId and no error is what the send path needs to accept one.
expect(d.attachments.every((a) => a.blobId && !a.error && a.progress === 100)).toBe(true);
expect(d.attachments[1]!.inline).toBe(true);
expect(d.attachments[0]!.inline).toBe(false);
});
it("sends as the identity the message was sent from", async () => {
const d = await draftFor(SENT);
expect(d.identityId).toBe("i1");
});
it("falls back to the default identity for a message somebody else sent", async () => {
const d = await draftFor(RECEIVED);
expect(d.identityId).toBe("i2");
});
it("is not the message it came from, so sending cannot destroy it", async () => {
const d = await draftFor(SENT);
expect(d.draftId).toBeNull();
});
it("threads onto nothing and marks nothing", async () => {
const d = await draftFor(SENT);
expect(d.inReplyTo).toBeNull();
expect(d.references).toBeNull();
expect(d.relatedEmailId).toBeNull();
expect(d.relatedKeyword).toBeNull();
});
it("adds no second signature to a body that already has one", async () => {
const d = await draftFor(SENT);
expect(d.signatureHtml).toBe("");
expect(d.html).not.toContain("ihm-signature");
});
it("opens a separate draft each time, rather than reusing the last", async () => {
const first = await draftFor(SENT);
const second = await draftFor(SENT);
expect(second.key).not.toBe(first.key);
expect(useCompose.getState().drafts).toHaveLength(2);
});
});
+69
View File
@@ -81,6 +81,8 @@ interface ComposeState {
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft }>;
open(init?: Partial<Draft>): string;
openDraftEmail(email: Email): Promise<string>;
/** 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>;
update(key: string, patch: Partial<Draft>): void;
close(key: string, opts?: { discard?: boolean }): Promise<void>;
@@ -220,6 +222,73 @@ export const useCompose = create<ComposeState>((set, get) => ({
return d.key;
},
/*
* The same mail again, as a mail that has never been sent.
*
* Not a forward and not a reply: a mail that was rejected, or went to an
* address with a typo in it, is one you want to send *again* rather than pass
* on. So there is no Fwd: on the subject, no quote wrapper around the body,
* and the recipients it already had are the recipients it keeps.
*
* What makes it new is what is left out. `draftId` stays null, or sending
* would destroy the message this was made from; `inReplyTo`, `references`,
* `relatedEmailId` and `relatedKeyword` stay null, so nothing is threaded
* onto the old message and the old message is not marked answered or
* forwarded by sending this. The Message-ID and the date are the server's and
* `buildEmailObject`'s respectively, and neither is copied from anywhere, so
* both are new without anything here asking for it.
*/
async composeAsNew(email) {
const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email;
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
// Sent by you, so send it as you again -- the same rule that reopens a
// draft. A mail somebody else sent has no identity of yours to match, and
// guessing from who it was addressed to would put a resend behind an alias
// that was only ever the receiving end; the account's own default is the
// honest answer there.
const ident =
identities.find((i) => full.from?.some((f) => sameAddress(f.email, i.email))) ??
mail.defaultIdentity() ??
identities[0];
const htmlPart = full.htmlBody?.[0];
const textPart = full.textBody?.[0];
const html = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
const text = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
const accountId = mail.accountId!;
const cidMap: Record<string, string> = {};
const attachments: ComposeAttachment[] = [];
for (const a of full.attachments ?? []) {
const inline = Boolean(a.cid) && (a.disposition === "inline" || a.type.startsWith("image/"));
if (inline && a.cid && a.blobId) cidMap[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
}
const d = blankDraft({
identityId: ident?.id ?? null,
to: full.to ?? [],
cc: full.cc ?? [],
bcc: full.bcc ?? [],
// The message's own Reply-To if it carried one, which is the setting the
// report asks to keep; the identity's only when it did not.
replyTo: full.replyTo ?? ident?.replyTo ?? [],
showReplyTo: Boolean(full.replyTo?.length || ident?.replyTo?.length),
showCc: Boolean(full.cc?.length),
showBcc: Boolean(full.bcc?.length),
subject: full.subject ?? "",
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
text: text || (html ? htmlToText(html) : ""),
format: html ? "html" : settings().composeFormat,
attachments,
// No signature is added, and `signatureHtml` is left empty on purpose.
// The body is the sent one, which already ends in whatever signature it
// was sent with; appending the identity's would give it two.
requestReceipt: Boolean(full["header:Disposition-Notification-To:asAddresses"]?.length),
priority: /^[12]/.test(full["header:X-Priority:asText"] ?? "") ? "high" : /^[45]/.test(full["header:X-Priority:asText"] ?? "") ? "low" : "normal",
});
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
return d.key;
},
async reply(email, mode) {
const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email;
+2 -1
View File
@@ -1,6 +1,6 @@
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Archive, ArrowLeft, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
import { Archive, ArrowLeft, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
import { useLocation } from "wouter";
import { useMail, type ListState } from "@/store/mail";
import { dateTimeKey, useSettings } from "@/store/settings";
@@ -480,6 +480,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={<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)} />
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
+4 -1
View File
@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MailPlus, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
import { useLocation } from "wouter";
import { FilterFromMessageDialog } from "./FilterFromMessage";
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
@@ -190,6 +190,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")} />
{/* 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)} />
<MenuSep />
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
<MenuItem icon={<Trash2 size={16} />} label={translate("Delete this message")} onClick={() => void useMail.getState().trash([e.id])} />