diff --git a/FEATURES.md b/FEATURES.md index 28849ce..6191795 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -110,6 +110,21 @@ every drag. - **Hold a folder** in the drawer for the menu its ⋮ button opens. - **Pull the message list** down to refresh it. - **Drag in from the left edge** of a conversation to go back to the list. +- **Swipe the calendar sideways** in day or month view to step to the next + period or back — dragging left pulls the next one in from the right, the way + paper and every phone do it. Week and agenda scroll through a range rather + than turning to the next one, so a sideways flick would not obviously mean + anything there and does nothing. A drag that begins on an event is left + alone, which keeps dragging an event to move it available to be built later + without having to be untangled from this first. + + It asks for a longer drag than a row swipe does, and not because the + consequence is bigger — stepping back undoes it, while a swiped row has + already been archived. It is because this gesture has no way to change its + mind: a row slides open as it goes, so the strip underneath names what will + happen and letting go early calls it off, and a toast offers Undo afterwards. + Stepping the calendar shows nothing on the way and offers nothing after, so + the distance is the only chance to not mean it. The toolbar's refresh button and the thread's back arrow both stay. A gesture with no visible control is one only the people who already know about it can @@ -150,6 +165,21 @@ Archive, delete, spam, star, mark read/unread, move and label all offer **Undo** in the toast that follows, and the undo restores the previous state rather than guessing at an inverse. +**Archive by date** files into `Archive/` or `Archive//`, +creating the folders as needed and reusing them after that — including ones +made by hand or by another client. The names are numeric and zero-padded +(`2026`, `2026/09`) rather than month names, because these are real server-side +mailboxes: every other client sees them, a folder created as "September" by +someone reading in English stays "September" for the same account read in +Japanese, and `09` sorts between `08` and `10` where a name does not. The date +is read in the reader's own timezone, so it agrees with the date shown against +the message in the list. + +A selection spanning two months is two destinations, not one, and both are +written; the menu names the folder where there is a single answer and describes +the rule where there is not, and the toast afterwards says how many folders it +touched. One Undo puts the whole selection back wherever it came from. + `Delete` moves to the bin. **Empty** destroys, and is offered only on Deleted Items and Junk Mail — enforced where the action happens, not merely hidden in the menu. Emptying Junk destroys rather than moving to the bin, because routing @@ -229,8 +259,33 @@ 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 + message rather than scored here: the verdict, the score, the threshold it was + measured against, and the rules that moved it, largest mover first and signed + so which way each pushed is visible. Both the SpamAssassin-shaped `X-Spam-*` + set that Stalwart's own filter writes and Rspamd's `X-Spamd-Result` are read; + anything else is left alone rather than guessed at. Two things it will not + do: a score is always given the threshold it was measured against, because + 6.7 is damning against 5 and unremarkable against 15 and the number alone is + not something a reader can act on — where no threshold was stated, it says + so; and where the filter recorded no verdict, none is invented from the score, + since the filter applies policy ihasmail cannot see. Mail that arrived without + these headers shows nothing. - **Message body theming** is off by default — sender HTML is left exactly as it was designed, on a light card. One setting lets mail that brings no colours of its own follow the app's theme instead. @@ -309,7 +364,16 @@ minimisable and maximisable; full-screen on mobile. identity. Signature images live in Files too and are turned into inline `cid:` parts when the message is sent. - **Templates**: named subject + body, inserted into any draft, managed in - Settings. + Settings. Both carry **placeholders** — `{{recipientName}}`, + `{{recipientFirstName}}`, `{{recipientEmail}}`, `{{myName}}`, `{{myEmail}}`, + `{{subject}}`, `{{date}}` and `{{time}}` — filled at the moment the template + is inserted, so what they came to is visible and editable before anything is + sent rather than changing under the message afterwards. Dates and times + follow the same format settings as the rest of the app. A placeholder that + cannot be answered yet — a recipient's name on a draft nobody has addressed — + is **left in the body exactly as written**, because substituting an empty + string there produces "Hi ,", a greeting that is wrong rather than one that + is visibly unfinished. A name that is not a placeholder is left alone too. - **Attachments** by picking or dragging onto the composer, with progress per file and the size limit the server states (`MAX_UPLOAD_BYTES`, 50 MB by default). A pasted image is inserted inline instead, and pasted HTML is diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 9fe22dd..3ccd56c 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -128,6 +128,14 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, "header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? ", " : null, "header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null, + // Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so + // delivered mail carries it and mail this account wrote does not. + "header:X-Spam-Status:asText": + o.mailbox === "junk" + ? "Yes, score=14.2 required=5.0 tests=[BAYES_99=3.5, URIBL_BLOCKED=2.7, HTML_IMAGE_ONLY=1.4, SUBJ_ALL_CAPS=1.2, FROM_FREEMAIL=0.4] autolearn=no" + : o.mailbox === "inbox" + ? "No, score=-1.8 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7, SPF_PASS=-0.1, HTML_MESSAGE=0.9]" + : null, }; emails.push(e); return e; diff --git a/web/src/lib/__tests__/archiveDate.test.ts b/web/src/lib/__tests__/archiveDate.test.ts new file mode 100644 index 0000000..ee9304c --- /dev/null +++ b/web/src/lib/__tests__/archiveDate.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/archiveDate"; + +/** + * The dates below are written as local-time strings on purpose. The segments + * follow the reader's timezone, so a test pinned to UTC instants would pass or + * fail depending on where it ran. + */ +describe("archiveSegments", () => { + it("gives the year, and the zero-padded month", () => { + expect(archiveSegments("2026-09-04T10:00:00", "year")).toEqual(["2026"]); + expect(archiveSegments("2026-09-04T10:00:00", "month")).toEqual(["2026", "09"]); + }); + + it("zero-pads every month below October, so the folders sort", () => { + expect(archiveSegments("2026-01-15T10:00:00", "month")).toEqual(["2026", "01"]); + expect(archiveSegments("2026-10-15T10:00:00", "month")).toEqual(["2026", "10"]); + expect(archiveSegments("2026-12-15T10:00:00", "month")).toEqual(["2026", "12"]); + }); + + it("returns nothing to append when the date cannot be read", () => { + // Archive itself, rather than a folder named after a guess. + expect(archiveSegments(null, "month")).toEqual([]); + expect(archiveSegments(undefined, "month")).toEqual([]); + expect(archiveSegments("", "month")).toEqual([]); + expect(archiveSegments("not a date", "month")).toEqual([]); + }); + + it("joins to a path", () => { + expect(archivePath(["2026", "09"])).toBe("2026/09"); + expect(archivePath([])).toBe(""); + }); +}); + +describe("groupByArchivePath", () => { + it("keeps one destination for a selection from one month", () => { + const groups = groupByArchivePath( + [ + { id: "a", receivedAt: "2026-09-04T10:00:00" }, + { id: "b", receivedAt: "2026-09-28T10:00:00" }, + ], + "month", + ); + expect(groups).toHaveLength(1); + expect(groups[0]!.segments).toEqual(["2026", "09"]); + expect(groups[0]!.ids).toEqual(["a", "b"]); + }); + + it("splits a selection that spans months, which is the case that matters", () => { + const groups = groupByArchivePath( + [ + { id: "a", receivedAt: "2026-09-04T10:00:00" }, + { id: "b", receivedAt: "2026-08-30T10:00:00" }, + { id: "c", receivedAt: "2026-09-01T10:00:00" }, + ], + "month", + ); + expect(groups.map((g) => g.segments)).toEqual([ + ["2026", "09"], + ["2026", "08"], + ]); + expect(groups[0]!.ids).toEqual(["a", "c"]); + expect(groups[1]!.ids).toEqual(["b"]); + }); + + it("collapses the same span back to one group at year granularity", () => { + const entries = [ + { id: "a", receivedAt: "2026-09-04T10:00:00" }, + { id: "b", receivedAt: "2026-02-28T10:00:00" }, + ]; + expect(groupByArchivePath(entries, "month")).toHaveLength(2); + expect(groupByArchivePath(entries, "year")).toHaveLength(1); + }); + + it("orders groups by where their first message appeared", () => { + const groups = groupByArchivePath( + [ + { id: "a", receivedAt: "2024-01-04T10:00:00" }, + { id: "b", receivedAt: "2026-01-04T10:00:00" }, + ], + "year", + ); + expect(groups.map((g) => archivePath(g.segments))).toEqual(["2024", "2026"]); + }); + + it("gathers the undatable ones into their own group, bound for Archive itself", () => { + const groups = groupByArchivePath( + [ + { id: "a", receivedAt: "2026-09-04T10:00:00" }, + { id: "b", receivedAt: null }, + { id: "c", receivedAt: "bad" }, + ], + "month", + ); + expect(groups).toHaveLength(2); + expect(groups[1]!.segments).toEqual([]); + expect(groups[1]!.ids).toEqual(["b", "c"]); + }); + + it("has nothing to do with an empty selection", () => { + expect(groupByArchivePath([], "month")).toEqual([]); + }); +}); diff --git a/web/src/lib/__tests__/emlName.test.ts b/web/src/lib/__tests__/emlName.test.ts new file mode 100644 index 0000000..9d3bd7d --- /dev/null +++ b/web/src/lib/__tests__/emlName.test.ts @@ -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\"gi|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"); + }); +}); diff --git a/web/src/lib/__tests__/spamScore.test.ts b/web/src/lib/__tests__/spamScore.test.ts new file mode 100644 index 0000000..8aee66c --- /dev/null +++ b/web/src/lib/__tests__/spamScore.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { spamReport } from "@/lib/spamScore"; + +const sa = (v: string) => spamReport({ "header:X-Spam-Status:asText": v }); +const rs = (v: string) => spamReport({ "header:X-Spamd-Result:asText": v }); + +describe("spamReport, SpamAssassin-shaped headers", () => { + it("reads the verdict, score, threshold and tests", () => { + const r = sa("Yes, score=6.7 required=5.0 tests=[BAYES_99=3.5, HTML_MESSAGE=0.001, URIBL=2.2] autolearn=no"); + expect(r).not.toBeNull(); + expect(r!.verdict).toBe("spam"); + expect(r!.score).toBe(6.7); + expect(r!.threshold).toBe(5); + expect(r!.source).toBe("spamassassin"); + // Biggest mover first, so the reason it was scored reads off the top. + expect(r!.rules.map((x) => x.name)).toEqual(["BAYES_99", "URIBL", "HTML_MESSAGE"]); + }); + + it("reads a negative score and a clean verdict", () => { + const r = sa("No, score=-2.6 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7]"); + expect(r!.verdict).toBe("clean"); + expect(r!.score).toBe(-2.6); + expect(r!.rules[0]).toEqual({ name: "BAYES_00", score: -1.9 }); + }); + + it("survives a folded header, which is how they arrive", () => { + const r = sa("Yes, score=6.7\n\trequired=5.0 tests=[BAYES_99=3.5,\n\tURIBL=2.2]"); + expect(r!.score).toBe(6.7); + expect(r!.rules).toHaveLength(2); + }); + + it("keeps a verdict that states no score, and a score that states no verdict", () => { + expect(sa("Yes")!.verdict).toBe("spam"); + expect(sa("Yes")!.score).toBeNull(); + const scoreOnly = sa("score=1.2 required=5.0"); + expect(scoreOnly!.verdict).toBeNull(); + expect(scoreOnly!.score).toBe(1.2); + }); + + it("says nothing when there is nothing it understands", () => { + expect(sa("")).toBeNull(); + expect(sa("something else entirely")).toBeNull(); + expect(spamReport({})).toBeNull(); + }); + + it("drops a malformed test rather than scoring it as zero", () => { + const r = sa("Yes, score=3.0 tests=[GOOD=1.0, BROKEN=, =2.0, ALSO_GOOD=2.0]"); + expect(r!.rules.map((x) => x.name)).toEqual(["ALSO_GOOD", "GOOD"]); + }); +}); + +describe("spamReport, Rspamd", () => { + it("reads the action, score, threshold and rules with their notes", () => { + const r = rs("default: False [1.20 / 15.00]; MIME_GOOD(-0.10)[text/plain]; DKIM_ALLOW(-0.20)[example.com]; SUBJ_CAPS(2.00)[]"); + expect(r!.verdict).toBe("clean"); + expect(r!.score).toBe(1.2); + expect(r!.threshold).toBe(15); + expect(r!.source).toBe("rspamd"); + expect(r!.rules[0]).toEqual({ name: "SUBJ_CAPS", score: 2 }); + expect(r!.rules.find((x) => x.name === "DKIM_ALLOW")?.detail).toBe("example.com"); + // An empty bracket is not a note. + expect(r!.rules[0]!.detail).toBeUndefined(); + }); + + it("treats the acting verdicts as spam and False as clean", () => { + expect(rs("default: True [20.00 / 15.00];")!.verdict).toBe("spam"); + expect(rs("default: reject [20.00 / 15.00];")!.verdict).toBe("spam"); + expect(rs("default: add_header [16.00 / 15.00];")!.verdict).toBe("spam"); + expect(rs("default: False [1.00 / 15.00];")!.verdict).toBe("clean"); + }); + + it("declines to call greylisting a verdict about the message", () => { + const r = rs("default: greylist [8.00 / 15.00];"); + expect(r!.verdict).toBeNull(); + expect(r!.score).toBe(8); + }); + + it("says nothing for a header it cannot read", () => { + expect(rs("")).toBeNull(); + expect(rs("default: False")).toBeNull(); + }); +}); + +describe("spamReport, precedence and fallback", () => { + it("prefers the SpamAssassin set, which is what Stalwart's own filter writes", () => { + const r = spamReport({ + "header:X-Spam-Status:asText": "Yes, score=6.7 required=5.0", + "header:X-Spamd-Result:asText": "default: False [1.20 / 15.00];", + }); + expect(r!.source).toBe("spamassassin"); + expect(r!.verdict).toBe("spam"); + }); + + it("falls back to a bare score, with no threshold to read it against", () => { + const r = spamReport({ "header:X-Spam-Score:asText": "+4.1" }); + expect(r!.score).toBe(4.1); + expect(r!.threshold).toBeNull(); + expect(r!.verdict).toBeNull(); + expect(r!.rules).toEqual([]); + }); + + it("does not invent a verdict from score against threshold", () => { + // Above the threshold, but the filter did not say "Yes" -- so neither do we. + const r = sa("score=9.9 required=5.0 tests=[X=9.9]"); + expect(r!.verdict).toBeNull(); + }); +}); diff --git a/web/src/lib/__tests__/swipeNav.test.ts b/web/src/lib/__tests__/swipeNav.test.ts new file mode 100644 index 0000000..03041bc --- /dev/null +++ b/web/src/lib/__tests__/swipeNav.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch"; + +describe("navSwipeThreshold", () => { + it("asks for more travel than a row swipe does, at every width", () => { + // Not because the consequence is bigger -- stepping back undoes it -- but + // because this gesture reveals nothing on the way and offers no Undo + // after, so the distance is the only chance to not mean it. + for (const width of [320, 360, 414, 768, 1024]) { + expect(navSwipeThreshold(width)).toBeGreaterThan(swipeThreshold(width)); + } + }); + + it("is a share of the width, bounded at both ends", () => { + expect(navSwipeThreshold(360)).toBe(108); + expect(navSwipeThreshold(200)).toBe(80); // floor + expect(navSwipeThreshold(1000)).toBe(180); // ceiling + }); +}); + +describe("swipeNavDirection", () => { + const W = 400; // threshold is 120 at this width + + it("goes forward when the finger drags left, the way pages turn", () => { + expect(swipeNavDirection(-200, W)).toBe(1); + }); + + it("goes back when the finger drags right", () => { + expect(swipeNavDirection(200, W)).toBe(-1); + }); + + it("does nothing short of the threshold, in either direction", () => { + expect(swipeNavDirection(-60, W)).toBe(0); + expect(swipeNavDirection(60, W)).toBe(0); + expect(swipeNavDirection(0, W)).toBe(0); + }); + + it("fires exactly at the threshold and not a pixel before", () => { + const at = navSwipeThreshold(W); + expect(swipeNavDirection(-at, W)).toBe(1); + expect(swipeNavDirection(-(at - 1), W)).toBe(0); + expect(swipeNavDirection(at, W)).toBe(-1); + expect(swipeNavDirection(at - 1, W)).toBe(0); + }); + + it("scales with the width, so a tablet asks for more than a phone", () => { + // The same 120px drag commits on a narrow screen and does not on a wide one. + expect(swipeNavDirection(-120, 360)).toBe(1); + expect(swipeNavDirection(-120, 1024)).toBe(0); + }); +}); + +describe("the axis lock this shares with the row swipe", () => { + it("keeps a mostly-vertical drag as a scroll, which is what the day grid needs", () => { + // The day view scrolls through the hours; a scroll misread as a swipe + // throws the reader into another day. + expect(lockAxis(20, 30)).toBe("y"); + expect(lockAxis(30, 25)).toBe("y"); + }); + + it("commits to sideways only when it is clearly sideways", () => { + expect(lockAxis(40, 10)).toBe("x"); + }); + + it("is undecided until the drag has moved at all", () => { + expect(lockAxis(2, 2)).toBeNull(); + }); +}); diff --git a/web/src/lib/__tests__/templatePlaceholders.test.ts b/web/src/lib/__tests__/templatePlaceholders.test.ts new file mode 100644 index 0000000..93ffbfc --- /dev/null +++ b/web/src/lib/__tests__/templatePlaceholders.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { fillPlaceholders, PLACEHOLDER_NAMES, type PlaceholderContext } from "@/lib/templatePlaceholders"; + +const AT = new Date("2026-03-04T15:07:00Z"); + +function ctx(over: Partial = {}): PlaceholderContext { + return { + to: [{ name: "Ada Lovelace", email: "ada@example.com" }], + from: { name: "Grace Hopper", email: "grace@example.com" }, + subject: "Quarterly report", + now: AT, + ...over, + }; +} + +describe("fillPlaceholders", () => { + it("fills the names it knows", () => { + expect(fillPlaceholders("Hi {{recipientFirstName}},", ctx(), { html: true })).toBe("Hi Ada,"); + expect(fillPlaceholders("{{recipientName}} <{{recipientEmail}}>", ctx(), { html: false })).toBe("Ada Lovelace "); + expect(fillPlaceholders("-- {{myName}}", ctx(), { html: true })).toBe("-- Grace Hopper"); + expect(fillPlaceholders("Re: {{subject}}", ctx(), { html: false })).toBe("Re: Quarterly report"); + }); + + it("tolerates spaces inside the braces but not a different case", () => { + expect(fillPlaceholders("{{ myEmail }}", ctx(), { html: false })).toBe("grace@example.com"); + expect(fillPlaceholders("{{MyEmail}}", ctx(), { html: false })).toBe("{{MyEmail}}"); + }); + + it("leaves a placeholder it cannot answer exactly as written", () => { + // The case the design is about: a template inserted before the message is + // addressed. "Hi ," would be wrong; "Hi {{recipientFirstName}}," is unfinished. + const unaddressed = ctx({ to: [] }); + expect(fillPlaceholders("Hi {{recipientFirstName}},", unaddressed, { html: true })).toBe("Hi {{recipientFirstName}},"); + expect(fillPlaceholders("{{recipientEmail}}", unaddressed, { html: false })).toBe("{{recipientEmail}}"); + expect(fillPlaceholders("{{myName}}", ctx({ from: null }), { html: false })).toBe("{{myName}}"); + }); + + it("leaves a name it does not know alone rather than eating it", () => { + expect(fillPlaceholders("{{nonsense}} {{}} {{ }}", ctx(), { html: true })).toBe("{{nonsense}} {{}} {{ }}"); + }); + + it("falls back to the local part when a recipient has no name", () => { + const c = ctx({ to: [{ name: null, email: "ada.lovelace@example.com" }] }); + expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toBe("ada.lovelace"); + expect(fillPlaceholders("{{recipientFirstName}}", c, { html: false })).toBe("ada.lovelace"); + }); + + it("escapes a substituted value on the way into HTML, and not into a subject", () => { + const c = ctx({ to: [{ name: 'Ada ', email: "ada@example.com" }] }); + expect(fillPlaceholders("{{recipientName}}", c, { html: true })).not.toContain("