Merge pull request #179 from Coffey-Labs/feat/compose-as-new
Send a message again as a new one
This commit is contained in:
@@ -323,6 +323,11 @@ minimisable and maximisable; full-screen on mobile.
|
|||||||
- **Drafts** save as you type and on close, with the save state shown.
|
- **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
|
- **Quoting** on reply, with the signature placed above or below it, and
|
||||||
reply-all as an optional default.
|
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.
|
- **Send and archive**, and **archive on reply**, as options.
|
||||||
|
|
||||||
### Undo send, and scheduled send
|
### 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,8 @@ interface ComposeState {
|
|||||||
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft }>;
|
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft }>;
|
||||||
open(init?: Partial<Draft>): string;
|
open(init?: Partial<Draft>): string;
|
||||||
openDraftEmail(email: Email): Promise<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>;
|
reply(email: Email, mode: "reply" | "replyAll" | "forward", opts?: { all?: boolean }): Promise<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>;
|
||||||
@@ -220,6 +222,73 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
return d.key;
|
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) {
|
async reply(email, mode) {
|
||||||
const mail = useMail.getState();
|
const mail = useMail.getState();
|
||||||
const full = (await mail.getEmails([email.id], true))[0] ?? email;
|
const full = (await mail.getEmails([email.id], true))[0] ?? email;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
|
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
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 { useLocation } from "wouter";
|
||||||
import { useMail, type ListState } from "@/store/mail";
|
import { useMail, type ListState } from "@/store/mail";
|
||||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
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}>
|
<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={<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)} />
|
||||||
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
|
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { useLocation } from "wouter";
|
||||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
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={<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")} />
|
||||||
|
{/* 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 />
|
<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={<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])} />
|
<MenuItem icon={<Trash2 size={16} />} label={translate("Delete this message")} onClick={() => void useMail.getState().trash([e.id])} />
|
||||||
|
|||||||
Reference in New Issue
Block a user