Send a message again as a new one
A mail the far end rejected, or one that went to an address with a typo in it, is a mail you want to send again -- not forward, and not reply to. Doing it by hand meant a new message and copying five fields across. "Compose as new" sits with Reply and Forward, in the message menu and in the list's right-click menu. It opens a composer holding the recipients the message had, its Reply-To, its subject with nothing prefixed to it, its body with nothing wrapped around it, and its attachments. What makes it a new mail is what it leaves behind. `draftId` stays null, or sending would destroy the message it was made from. `inReplyTo`, `references`, `relatedEmailId` and `relatedKeyword` stay null, so it hangs off no thread and sending it marks the original neither answered nor forwarded. The Message-ID is the server's and the date is stamped at build time, so both are new without anything asking for them -- the send path needed no changes at all. A message you sent is composed as the identity you sent it as. One somebody else sent has no identity of yours to match, and guessing from whom it was addressed to would put the resend behind an alias that was only ever the receiving end, so that case takes the account's default. No signature is added. The body is the one that was sent, which already ends in whatever signature went with it, and appending the identity's would give it two. Closes #176
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