diff --git a/FEATURES.md b/FEATURES.md index 8253e4c..01864f9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/web/src/store/__tests__/compose-as-new.test.ts b/web/src/store/__tests__/compose-as-new.test.ts new file mode 100644 index 0000000..f5444ae --- /dev/null +++ b/web/src/store/__tests__/compose-as-new.test.ts @@ -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: [""], + from: [{ name: "John", email: "john@example.org" }], + to: [{ name: "Ann", email: "ann@example.com" }], + cc: [{ name: null, email: "cc@example.com" }], + bcc: [{ name: null, email: "bcc@example.com" }], + replyTo: [{ name: null, email: "desk@example.org" }], + subject: "Quarterly numbers", + references: [""], + inReplyTo: [""], + keywords: {}, + htmlBody: [{ partId: "1", type: "text/html" }], + textBody: [{ partId: "1", type: "text/html" }], + bodyValues: { "1": { value: "

Here they are.

", 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: "ann@example.com" }] } as Email; + +const IDENTITIES = [ + { id: "i1", name: "John", email: "john@example.org", replyTo: null }, + { id: "i2", name: "John (other)", email: "other@example.org", 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: "ann@example.com" }]); + expect(d.cc).toEqual([{ name: null, email: "cc@example.com" }]); + expect(d.bcc).toEqual([{ name: null, email: "bcc@example.com" }]); + // 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: "desk@example.org" }]); + 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); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 2639e49..1df1ba0 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -81,6 +81,8 @@ interface ComposeState { pendingSends: Record; open(init?: Partial): string; openDraftEmail(email: Email): Promise; + /** Open a message again as a mail that has not been sent yet. */ + composeAsNew(email: Email): Promise; reply(email: Email, mode: "reply" | "replyAll" | "forward", opts?: { all?: boolean }): Promise; update(key: string, patch: Partial): void; close(key: string, opts?: { discard?: boolean }): Promise; @@ -220,6 +222,73 @@ export const useCompose = create((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 = {}; + 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, "
"), + 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; diff --git a/web/src/views/mail/MessageList.tsx b/web/src/views/mail/MessageList.tsx index 6725b89..c4328f0 100644 --- a/web/src/views/mail/MessageList.tsx +++ b/web/src/views/mail/MessageList.tsx @@ -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 } label={t("Reply")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} /> } label={t("Forward")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} /> + } label={t("Compose as new")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().composeAsNew(e); }} /> } label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} /> } label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} /> diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index a3bfccd..3817af7 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -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 } label={translate("Reply")} onClick={() => void reply(e, "reply")} /> } label={translate("Reply all")} onClick={() => void reply(e, "replyAll")} /> } 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. */} + } label={translate("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(e)} /> } label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} /> } label={translate("Delete this message")} onClick={() => void useMail.getState().trash([e.id])} />