Public: anyone can read this; the crew can change it.
Preview the email blast as the client will see it, in the agent's brand #13
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/6-preview-the-email-blast-as-the-client-wi → main 2 commits fork 3 threads · 3 unresolved
10 files
changed +305
−8
README.md +9 −1
| @@ -29,7 +29,15 @@ One address in, a listing launched: the MLS description, three social posts, a f | ||
| 29 | 29 | read (`seen`, the asset's `updated_at`; the form also sends the box's text): |
| 30 | 30 | if they changed, it answers 409 and approves nothing. Approval is a |
| 31 | 31 | record, not a gate: Copy and Print flyer do not look at it. |
| 32 | 4. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo, | |
| 32 | 4. **Preview the email.** The Email tab's "Preview email" opens | |
| 33 | `/listings/:id/email`: the subject, hero photo, price and facts, the body, | |
| 34 | your photo, name, brokerage, license and contact in your colour, and the | |
| 35 | Equal Housing Opportunity line. "Copy as email" puts the rendered HTML | |
| 36 | (with a plain-text twin) on the clipboard for your mail tool; without | |
| 37 | JavaScript the page still renders. "Copy subject" copies the subject line | |
| 38 | apart, for the mail tool's subject box; the message copied is the card only. | |
| 39 | In the terminal, `p` on an open listing previews the same message as text. Photos are this app's own, never remote. | |
| 40 | 5. **Print the flyer.** "Print flyer" opens a letter-size page — hero photo, | |
| 33 | 41 | price, a strip of the facts, the description, your photo, name, |
| 34 | 42 | brokerage and license number, and the Equal Housing Opportunity line. |
| 35 | 43 | Print it, or save it as a PDF. "Rewrite all" writes every asset again in |
src/listings.test.ts +29
| @@ -435,3 +435,32 @@ test("the page says how many are approved, names the flags, and the form and API | ||
| 435 | 435 | const back = await api(alice, { kind: "mls", approved: false }); |
| 436 | 436 | expect(((await back.json()) as { assets: { approval: unknown }[] }).assets.filter((a) => a.approval === null).length).toBe(1); |
| 437 | 437 | }); |
| 438 | ||
| 439 | test("the email blast is a page of its own, in the agent's brand", async () => { | |
| 440 | const { to } = await launch(); | |
| 441 | const r = await fetch(base + to + "/email"); | |
| 442 | expect(r.status).toBe(200); | |
| 443 | expect(r.headers.get("content-security-policy")).toContain("default-src"); | |
| 444 | const page = await r.text(); | |
| 445 | expect(page).toContain('class="email palette-forest"'); | |
| 446 | expect(page).toContain('class="email-subject"'); | |
| 447 | expect(page).toContain("License OR 201255501"); | |
| 448 | expect(page).toContain("Equal Housing Opportunity"); | |
| 449 | expect(page).toContain("data-copy-email"); | |
| 450 | expect(page).toContain("data-subject>A home you will want to see: 18 Quarry Road<"); | |
| 451 | expect(page.split(`>${SAMPLE.agent_name}<`).length - 1).toBe(1); | |
| 452 | expect(page).not.toContain("The facts:"); | |
| 453 | expect(page).not.toMatch(/(src|href)="https?:/); | |
| 454 | // The Email tab links to it. | |
| 455 | expect(await (await fetch(base + to + "?tab=email")).text()).toContain(`href="${to}/email"`); | |
| 456 | }); | |
| 457 | ||
| 458 | test("the email preview escapes a hostile address and 404s for an unknown id", async () => { | |
| 459 | const form = launchForm(); | |
| 460 | form.set("address", `<script>alert(1)</script> "Lane", Bend, OR 97701`); | |
| 461 | const r = await fetch(`${base}/listings`, { method: "POST", body: form, headers: alice, redirect: "manual" }); | |
| 462 | const page = await (await fetch(base + r.headers.get("location")! + "/email")).text(); | |
| 463 | expect(page).not.toContain("<script>alert(1)"); | |
| 464 | expect(page).toContain("<script>"); | |
| 465 | expect((await fetch(`${base}/listings/999999/email`)).status).toBe(404); | |
| 466 | }); |
src/pages/email.test.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | /** The email preview's text handling: subject, body, and the signature the page draws itself. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { emailParts } from "./email.ts"; | |
| 4 | ||
| 5 | const text = "Subject: Just listed\n\nHello.\n\nThe facts: 3 bedrooms.\n\nAnn Agent\nHarbor Realty · License 1\n555"; | |
| 6 | ||
| 7 | test("the subject is split off, the facts sentence and the signature dropped", () => { | |
| 8 | const { subject, paragraphs } = emailParts(text, "Ann Agent"); | |
| 9 | expect(subject).toBe("Just listed"); | |
| 10 | expect(paragraphs).toEqual(["Hello."]); | |
| 11 | }); | |
| 12 | ||
| 13 | test("a signature is kept when it is not the agent's", () => { | |
| 14 | expect(emailParts(text, "Someone Else").paragraphs).toHaveLength(2); | |
| 15 | }); | |
| 16 | ||
| 17 | test("CRLF text reads the same", () => { | |
| 18 | expect(emailParts(text.replace(/\n/g, "\r\n"), "Ann Agent")).toEqual(emailParts(text, "Ann Agent")); | |
| 19 | }); | |
| 20 | ||
| 21 | test("no Subject line leaves the subject empty and the body whole", () => { | |
| 22 | const { subject, paragraphs } = emailParts("Hello.\n\nBye.", "Ann"); | |
| 23 | expect(subject).toBe(""); | |
| 24 | expect(paragraphs).toEqual(["Hello.", "Bye."]); | |
| 25 | }); |
src/pages/email.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * The email blast as the client will see it, at `/listings/:id/email`: the | |
| 3 | * subject on top, then a message in the agent's colours. Plain classes, no | |
| 4 | * inline styles (the CSP allows none); `static/listing.js` inlines the | |
| 5 | * computed styles when the agent copies it into their mail tool. | |
| 6 | */ | |
| 7 | ||
| 8 | import { count, money } from "../generate.ts"; | |
| 9 | import { escape } from "../http.ts"; | |
| 10 | import type { Launched } from "../listing.ts"; | |
| 11 | import type { Who } from "../who.ts"; | |
| 12 | import { layout } from "./layout.ts"; | |
| 13 | import { photoUrl } from "./listing.ts"; | |
| 14 | ||
| 15 | /** The asset's text split into its subject and body paragraphs; the signature the page draws itself is dropped. */ | |
| 16 | export function emailParts( | |
| 17 | text: string, | |
| 18 | agentName: string, | |
| 19 | ): { subject: string; paragraphs: string[] } { | |
| 20 | const lines = text.replace(/\r\n?/g, "\n").trim(); | |
| 21 | const m = /^subject:[ \t]*(.*)(?:\n|$)/i.exec(lines); | |
| 22 | const subject = m?.[1]?.trim() ?? ""; | |
| 23 | const body = m === null ? lines : lines.slice(m[0].length); | |
| 24 | const paragraphs = body.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p !== ""); | |
| 25 | // The facts strip draws these; the sentence would say them twice. | |
| 26 | const kept = paragraphs.filter((p) => !/^the facts:/i.test(p)); | |
| 27 | paragraphs.splice(0, paragraphs.length, ...kept); | |
| 28 | const last = paragraphs.at(-1); | |
| 29 | if (last !== undefined && agentName !== "" && last.startsWith(agentName)) paragraphs.pop(); | |
| 30 | return { subject, paragraphs }; | |
| 31 | } | |
| 32 | ||
| 33 | /** The message itself, one article: what is copied is what is shown. */ | |
| 34 | export function email(l: Launched): string { | |
| 35 | const { subject, paragraphs } = emailParts( | |
| 36 | l.assets.find((a) => a.kind === "email")?.text ?? "", | |
| 37 | l.agent_name, | |
| 38 | ); | |
| 39 | const hero = l.photos[0]; | |
| 40 | return `<article class="email palette-${escape(l.palette)}" data-email> | |
| 41 | <p class="email-subject"><span class="muted">Subject</span> <span data-subject>${escape(subject)}</span></p> | |
| 42 | <div class="email-card"> | |
| 43 | ${hero === undefined ? "" : `<img class="email-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="600" height="400">`} | |
| 44 | <h2 class="email-address brand">${escape(l.address)}</h2> | |
| 45 | <p class="email-price brand">${money(l.price)}</p> | |
| 46 | <p class="email-facts brand-fill">${l.beds} beds · ${l.baths} baths · ${count(l.sqft)} sq ft · ${escape(l.lot)} lot · ${l.year} built</p> | |
| 47 | ${paragraphs.map((p) => `<p class="email-body">${escape(p).replace(/\n/g, "<br>")}</p>`).join("")} | |
outdated
Fine print repeats factsLine, already shown in the strip above and in the body text. Remove the duplication. |
||
| 48 | <div class="email-agent"> | |
| 49 | ${l.headshot === null ? "" : `<img class="email-face" src="${escape(photoUrl(l, l.headshot))}" alt="${escape(l.agent_name)}" width="72" height="72">`} | |
| 50 | <div> | |
| 51 | <p class="email-name brand">${escape(l.agent_name)}</p> | |
| 52 | <p>${escape(l.brokerage)} · License ${escape(l.license)}</p> | |
| 53 | <p>${escape(l.agent_phone)} · ${escape(l.agent_email)}</p> | |
| 54 | </div> | |
| 55 | </div> | |
| 56 | <p class="email-fine">Equal Housing Opportunity.</p> | |
| 57 | </div> | |
| 58 | </article>`; | |
| 59 | } | |
| 60 | ||
| 61 | export function emailPage(input: { who: Who; listing: Launched }): string { | |
| 62 | const { who, listing: l } = input; | |
| 63 | return layout( | |
| 64 | `<div class="print-bar row"> | |
| 65 | <a href="/listings/${l.id}?tab=email">← Back to the listing</a> | |
| 66 | <button type="button" class="button" data-copy-email hidden>Copy as email</button> | |
| 67 | <button type="button" class="button button-quiet" data-copy-subject hidden>Copy subject</button> | |
| 68 | <span class="muted" data-copy-note role="status">Paste it into your mail tool; the photos load from this page.</span> | |
| 69 | </div> | |
| 70 | ${email(l)}`, | |
| 71 | who, | |
| 72 | { path: `/listings/${l.id}/email`, heading: `Email: ${l.address}` }, | |
| 73 | ); | |
| 74 | } |
src/pages/listing.ts +1
| @@ -146,6 +146,7 @@ export function listingPage(input: { | ||
| 146 | 146 | ${TABS.map( |
| 147 | 147 | (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}> |
| 148 | 148 | ${t.id === "flyer" ? `<div class="flyer-preview"><a href="/listings/${l.id}/flyer" aria-label="Open the printable flyer">${flyer(l)}</a></div>` : ""} |
| 149 | ${t.id === "email" ? `<p><a class="button button-quiet" href="/listings/${l.id}/email">Preview email</a></p>` : ""} | |
| 149 | 150 | ${l.assets |
| 150 | 151 | .filter((a) => t.kinds.includes(a.kind)) |
| 151 | 152 | .map((a) => editor(l, a, editable)) |
src/routes.ts +9
| @@ -57,6 +57,7 @@ import { events } from "./live.ts"; | ||
| 57 | 57 | import { rpc } from "./mcp.ts"; |
| 58 | 58 | import { draftOf, home, type Draft } from "./pages/home.ts"; |
| 59 | 59 | import { layout } from "./pages/layout.ts"; |
| 60 | import { emailPage } from "./pages/email.ts"; | |
| 60 | 61 | import { fairHousing, flyerPage, listingPage, tabOf } from "./pages/listing.ts"; |
| 61 | 62 | import { notesPage } from "./pages/notes.ts"; |
| 62 | 63 | import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts"; |
| @@ -418,6 +419,14 @@ export const routes: readonly Route[] = [ | ||
| 418 | 419 | flyerPage({ who: c.who, listing }), |
| 419 | 420 | ), |
| 420 | 421 | }, |
| 422 | { | |
| 423 | method: "GET", | |
| 424 | path: "/listings/:id/email", | |
| 425 | handle: (c) => | |
| 426 | page(c.who, getListing(c.db, c.who, { id: Number(c.params.id) }), (listing) => | |
| 427 | emailPage({ who: c.who, listing }), | |
| 428 | ), | |
| 429 | }, | |
| 421 | 430 | { |
| 422 | 431 | method: "GET", |
| 423 | 432 | path: "/listings/:id/photos/:name", |
src/tty.test.ts +15
| @@ -123,3 +123,18 @@ test("a approves the asset under the cursor, and withdraws it; a stranger's a is | ||
| 123 | 123 | theirs.press("a"); |
| 124 | 124 | expect(plain(theirs.screen())).toContain("0 of 6 approved"); |
| 125 | 125 | }); |
| 126 | ||
| 127 | test("p previews the email as the client reads it; esc returns to the assets", () => { | |
| 128 | const { db } = fresh(); | |
| 129 | const tty = session(db, other); // reading is open to everyone | |
| 130 | tty.press("\r"); | |
| 131 | tty.press("p"); | |
| 132 | const shown = plain(tty.screen()); | |
| 133 | expect(shown).toContain("Subject: "); | |
| 134 | expect(shown).toContain("Equal Housing Opportunity."); | |
| 135 | expect(shown).not.toContain("The facts:"); | |
| 136 | tty.press("e"); // nothing is edited from the preview | |
| 137 | expect(plain(tty.screen())).not.toContain("Edit ("); | |
| 138 | tty.press("\x1b"); | |
| 139 | expect(plain(tty.screen())).toContain("▸ MLS description"); | |
| 140 | }); |
src/tty.ts +31 −7
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * j/k move between the assets, e edits the one under the cursor (the |
| 8 | 8 | * words are checked again as they are saved), t writes them all again in |
| 9 | 9 | * the next tone, a approves the asset or withdraws it, esc goes back; x |
| 10 | * removes a listing. | |
| 10 | * removes a listing; p previews the email as the client will read it. | |
| 11 | 11 | * - notes: a adds, d removes. |
| 12 | 12 | * |
| 13 | 13 | * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI, |
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | retoneListing, |
| 32 | 32 | } from "./actions.ts"; |
| 33 | 33 | import { ssh, title, where } from "./env.ts"; |
| 34 | import { emailParts } from "./pages/email.ts"; | |
| 34 | 35 | import { money, TONES, type Kind } from "./generate.ts"; |
| 35 | 36 | import type { Who } from "./who.ts"; |
| 36 | 37 | |
| @@ -47,6 +48,8 @@ export const fromLine = (line: string): string => line.replace(/\\n/g, "\n"); | ||
| 47 | 48 | export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }) { |
| 48 | 49 | let view: "listings" | "notes" = "listings"; |
| 49 | 50 | let reading: number | undefined; |
| 51 | /** The email blast, laid out as the page's preview is, instead of the assets. */ | |
| 52 | let previewing = false; | |
| 50 | 53 | let cursor = 0; |
| 51 | 54 | /** What the one-line input is for: a new note, or an asset's new words. */ |
| 52 | 55 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | undefined; |
| @@ -68,6 +71,17 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 68 | 71 | if (found === undefined) return []; |
| 69 | 72 | if (!found.ok) return [found.message]; |
| 70 | 73 | const l = found.value; |
| 74 | if (previewing) { | |
| 75 | const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === "email")?.text ?? "", l.agent_name); | |
| 76 | return [ | |
| 77 | bold(`Subject: ${subject}`), "", | |
| 78 | bold(`${l.address} · ${money(l.price)}`), | |
| 79 | dim(`${l.beds} beds · ${l.baths} baths · ${l.sqft.toLocaleString("en-US")} sq ft · ${l.lot} lot · ${l.year} built`), "", | |
| 80 | ...paragraphs.flatMap((p) => [...p.split("\n"), ""]), | |
| 81 | bold(l.agent_name), `${l.brokerage} · License ${l.license}`, `${l.agent_phone} · ${l.agent_email}`, "", | |
| 82 | dim("Equal Housing Opportunity."), | |
| 83 | ]; | |
| 84 | } | |
| 71 | 85 | const signed = l.assets.filter((a) => a.approval !== null).length; |
| 72 | 86 | const head = [bold(`${l.address} · ${money(l.price)}`), dim(`${l.tone} tone · ${signed === l.assets.length ? "Ready to send" : `${signed} of ${l.assets.length} approved`}`), ""]; |
| 73 | 87 | // Scroll so the asset under the cursor starts the page. |
| @@ -90,10 +104,11 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 90 | 104 | if (reading !== undefined) { |
| 91 | 105 | lines.push(...readLines()); |
| 92 | 106 | const found = open(); |
| 93 | help = | |
| 107 | if (previewing) help = "esc back to the assets · q quit"; | |
| 108 | else help = | |
| 94 | 109 | found?.ok === true && canEditListing(me, found.value) |
| 95 | ? "j/k asset · e edit · a approve/withdraw · t next tone · esc back · q quit" | |
| 96 | : "j/k asset · esc back · q quit"; | |
| 110 | ? "j/k asset · e edit · a approve/withdraw · t next tone · p preview email · esc back · q quit" | |
| 111 | : "j/k asset · p preview email · esc back · q quit"; | |
| 97 | 112 | } else if (view === "listings") { |
| 98 | 113 | const rows = listings(); |
| 99 | 114 | if (rows.length === 0) lines.push(dim("No listings yet. Launch one on the page.")); |
| @@ -168,10 +183,19 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 168 | 183 | if (reading !== undefined) { |
| 169 | 184 | const found = open(); |
| 170 | 185 | const assets = found?.ok === true ? found.value.assets : []; |
| 171 | if (key === "\x1b") { | |
| 172 | reading = undefined; | |
| 173 | cursor = 0; | |
| 186 | if (key === "p" && !previewing && found?.ok === true) { | |
| 187 | previewing = true; | |
| 188 | return undefined; | |
| 174 | 189 | } |
| 190 | if (key === "\x1b") { | |
| 191 | if (previewing) previewing = false; | |
| 192 | else { | |
| 193 | reading = undefined; | |
| 194 | cursor = 0; | |
| 195 | } | |
| 196 | return undefined; | |
| 197 | } | |
| 198 | if (previewing) return undefined; | |
| 175 | 199 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 176 | 200 | if (up) cursor = Math.max(cursor - 1, 0); |
| 177 | 201 | if (found?.ok === true && (key === "e" || key === "t" || key === "a")) { |
static/app.css +67
| @@ -734,6 +734,73 @@ h3 { | ||
| 734 | 734 | } |
| 735 | 735 | } |
| 736 | 736 | |
| 737 | /* The email preview: a message on paper, in the agent's colours. */ | |
| 738 | .email { | |
| 739 | max-width: 37.5rem; | |
| 740 | margin: 0 auto; | |
| 741 | } | |
| 742 | .email-subject { | |
| 743 | margin: 0 0 var(); | |
| 744 | font-weight: 600; | |
| 745 | } | |
| 746 | .email-card { | |
| 747 | padding: var(); | |
| 748 | color: var(); | |
| 749 | background: var(); | |
| 750 | border-radius: var(); | |
| 751 | box-shadow: 0 10px 30px var(); | |
| 752 | } | |
| 753 | .email-card p { | |
| 754 | margin: 0 0 var(); | |
| 755 | } | |
| 756 | .email-hero { | |
| 757 | display: block; | |
| 758 | width: 100%; | |
| 759 | height: auto; | |
| 760 | aspect-ratio: 3 / 2; | |
| 761 | object-fit: cover; | |
| 762 | margin-bottom: var(); | |
| 763 | } | |
| 764 | .email-address { | |
| 765 | margin: 0; | |
| 766 | } | |
| 767 | .email-price { | |
| 768 | font-size: 1.5rem; | |
| 769 | font-weight: 700; | |
| 770 | } | |
| 771 | .email-facts { | |
| 772 | padding: var() var(); | |
| 773 | font-weight: 600; | |
| 774 | } | |
| 775 | .email-agent { | |
| 776 | display: flex; | |
| 777 | gap: var(); | |
| 778 | align-items: center; | |
| 779 | padding-top: var(); | |
| 780 | border-top: 1px solid var(); | |
| 781 | } | |
| 782 | .email-agent p { | |
| 783 | margin: 0; | |
| 784 | } | |
| 785 | .email-face { | |
| 786 | width: 4.5rem; | |
| 787 | height: 4.5rem; | |
| 788 | border-radius: 50%; | |
| 789 | object-fit: cover; | |
| 790 | } | |
| 791 | .email-name { | |
| 792 | font-weight: 700; | |
| 793 | } | |
| 794 | .email-fine { | |
| 795 | margin: var() 0 0; | |
| 796 | font-size: 0.875rem; | |
| 797 | } | |
| 798 | @media print { | |
| 799 | .email-card { | |
| 800 | box-shadow: none; | |
| 801 | } | |
| 802 | } | |
| 803 | ||
| 737 | 804 | /* Paper: the flyer alone, edge to edge, its colours kept. */ |
| 738 | 805 | @page { |
| 739 | 806 | size: letter; |
static/listing.js +45
| @@ -73,3 +73,48 @@ for (const button of document.querySelectorAll("[data-print]")) { | ||
| 73 | 73 | button.hidden = false; |
| 74 | 74 | button.addEventListener("click", () => window.print()); |
| 75 | 75 | } |
| 76 | ||
| 77 | // Copy as email: the rendered message, styles inlined from what the browser | |
| 78 | // computed (a mail tool ignores our stylesheet), as text/html with the plain | |
| 79 | // words beside it. Photos are made absolute so they load where it is pasted. | |
| 80 | const INLINED = [ | |
| 81 | "color", "background-color", "font-family", "font-size", "font-weight", | |
| 82 | "line-height", "text-align", "margin", "padding", "display", "width", | |
| 83 | "max-width", "height", "border-radius", "object-fit", | |
| 84 | ]; | |
| 85 | for (const button of document.querySelectorAll("[data-copy-email]")) { | |
| 86 | const mail = document.querySelector(".email-card"); | |
| 87 | if (!navigator.clipboard || !window.ClipboardItem || !mail) continue; | |
| 88 | button.hidden = false; | |
| 89 | button.addEventListener("click", async () => { | |
| 90 | const copy = mail.cloneNode(true); | |
| 91 | const from = [mail, ...mail.querySelectorAll("*")]; | |
| 92 | [copy, ...copy.querySelectorAll("*")].forEach((el, i) => { | |
| 93 | const css = getComputedStyle(from[i]); | |
| 94 | el.setAttribute("style", INLINED.map((p) => `${p}:${css.getPropertyValue(p)}`).join(";")); | |
| 95 | el.removeAttribute("class"); | |
| 96 | if (el.tagName === "IMG") el.src = new URL(el.getAttribute("src"), location.href).href; | |
| 97 | }); | |
| 98 | const html = `<div>${copy.outerHTML}</div>`; | |
| 99 | await navigator.clipboard.write([ | |
mail.innerText includes the "Subject" label line in the plain-text twin; copy the card only. |
||
| 100 | new ClipboardItem({ | |
| 101 | "text/html": new Blob([html], { type: "text/html" }), | |
| 102 | "text/plain": new Blob([mail.innerText], { type: "text/plain" }), | |
| 103 | }), | |
| 104 | ]); | |
| 105 | button.textContent = "Copied"; | |
| 106 | setTimeout(() => (button.textContent = "Copy as email"), 1500); | |
| 107 | }); | |
| 108 | } | |
| 109 | ||
| 110 | // The subject goes in the mail tool's subject box, so it is copied apart from the message. | |
| 111 | for (const button of document.querySelectorAll("[data-copy-subject]")) { | |
| 112 | const subject = document.querySelector("[data-subject]"); | |
| 113 | if (!navigator.clipboard || !subject) continue; | |
| 114 | button.hidden = false; | |
| 115 | button.addEventListener("click", async () => { | |
| 116 | await navigator.clipboard.writeText(subject.textContent); | |
| 117 | button.textContent = "Copied"; | |
| 118 | setTimeout(() => (button.textContent = "Copy subject"), 1500); | |
| 119 | }); | |
| 120 | } |
new above
offline
emailParts has no direct test; add cases for subject/no subject, CRLF, and signature removal.