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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/6-preview-the-email-blast-as-the-client-wi → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/6-preview-the-email-blast-as-the-client-wi was merged into main as f234f2d.

Review

Sign in to review this change.
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
2929 read (`seen`, the asset's `updated_at`; the form also sends the box's text):
3030 if they changed, it answers 409 and approves nothing. Approval is a
3131 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,
3341 price, a strip of the facts, the description, your photo, name,
3442 brokerage and license number, and the Equal Housing Opportunity line.
3543 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
435435 const back = await api(alice, { kind: "mls", approved: false });
436436 expect(((await back.json()) as { assets: { approval: unknown }[] }).assets.filter((a) => a.approval === null).length).toBe(1);
437437 });
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("&lt;script&gt;");
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(

emailParts has no direct test; add cases for subject/no subject, CRLF, and signature removal.

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: {
146146 ${TABS.map(
147147 (t) => `<div class="tabpanel" role="tabpanel" id="panel-${t.id}" aria-labelledby="tab-${t.id}"${t.id === open ? "" : " hidden"}>
148148 ${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>` : ""}
149150 ${l.assets
150151 .filter((a) => t.kinds.includes(a.kind))
151152 .map((a) => editor(l, a, editable))
src/routes.ts +9
@@ -57,6 +57,7 @@ import { events } from "./live.ts";
5757 import { rpc } from "./mcp.ts";
5858 import { draftOf, home, type Draft } from "./pages/home.ts";
5959 import { layout } from "./pages/layout.ts";
60 import { emailPage } from "./pages/email.ts";
6061 import { fairHousing, flyerPage, listingPage, tabOf } from "./pages/listing.ts";
6162 import { notesPage } from "./pages/notes.ts";
6263 import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts";
@@ -418,6 +419,14 @@ export const routes: readonly Route[] = [
418419 flyerPage({ who: c.who, listing }),
419420 ),
420421 },
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 },
421430 {
422431 method: "GET",
423432 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
123123 theirs.press("a");
124124 expect(plain(theirs.screen())).toContain("0 of 6 approved");
125125 });
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 @@
77 * j/k move between the assets, e edits the one under the cursor (the
88 * words are checked again as they are saved), t writes them all again in
99 * 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.
1111 * - notes: a adds, d removes.
1212 *
1313 * j/k move, q leaves. Launching, with its photos, is the page's. Plain ANSI,
@@ -31,6 +31,7 @@ import {
3131 retoneListing,
3232 } from "./actions.ts";
3333 import { ssh, title, where } from "./env.ts";
34 import { emailParts } from "./pages/email.ts";
3435 import { money, TONES, type Kind } from "./generate.ts";
3536 import type { Who } from "./who.ts";
3637
@@ -47,6 +48,8 @@ export const fromLine = (line: string): string => line.replace(/\\n/g, "\n");
4748 export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }) {
4849 let view: "listings" | "notes" = "listings";
4950 let reading: number | undefined;
51 /** The email blast, laid out as the page's preview is, instead of the assets. */
52 let previewing = false;
5053 let cursor = 0;
5154 /** What the one-line input is for: a new note, or an asset's new words. */
5255 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 }
6871 if (found === undefined) return [];
6972 if (!found.ok) return [found.message];
7073 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 }
7185 const signed = l.assets.filter((a) => a.approval !== null).length;
7286 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`}`), ""];
7387 // 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 }
90104 if (reading !== undefined) {
91105 lines.push(...readLines());
92106 const found = open();
93 help =
107 if (previewing) help = "esc back to the assets · q quit";
108 else help =
94109 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";
97112 } else if (view === "listings") {
98113 const rows = listings();
99114 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 }
168183 if (reading !== undefined) {
169184 const found = open();
170185 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;
174189 }
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;
175199 if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1));
176200 if (up) cursor = Math.max(cursor - 1, 0);
177201 if (found?.ok === true && (key === "e" || key === "t" || key === "a")) {
static/app.css +67
@@ -734,6 +734,73 @@ h3 {
734734 }
735735 }
736736
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(--space-3);
744 font-weight: 600;
745 }
746 .email-card {
747 padding: var(--space-4);
748 color: var(--color-ink);
749 background: var(--color-paper);
750 border-radius: var(--radius-sm);
751 box-shadow: 0 10px 30px var(--color-shadow);
752 }
753 .email-card p {
754 margin: 0 0 var(--space-3);
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(--space-3);
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(--space-2) var(--space-3);
773 font-weight: 600;
774 }
775 .email-agent {
776 display: flex;
777 gap: var(--space-3);
778 align-items: center;
779 padding-top: var(--space-3);
780 border-top: 1px solid var(--color-shadow);
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(--space-3) 0 0;
796 font-size: 0.875rem;
797 }
798 @media print {
799 .email-card {
800 box-shadow: none;
801 }
802 }
803
737804 /* Paper: the flyer alone, edge to edge, its colours kept. */
738805 @page {
739806 size: letter;
static/listing.js +45
@@ -73,3 +73,48 @@ for (const button of document.querySelectorAll("[data-print]")) {
7373 button.hidden = false;
7474 button.addEventListener("click", () => window.print());
7575 }
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs