Public: anyone can read this; the crew can change it.
Download an approved email blast as a .eml file the agent opens in their own mail app to send #115
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/112-download-an-approved-email-blast-as-a-em → main 2 commits fork 2 threads · 2 unresolved
7 files
changed +379
−1
README.md +12
| @@ -298,6 +298,18 @@ list cannot drift from the app. | ||
| 298 | 298 | JavaScript the page still renders. "Copy subject" copies the subject line |
| 299 | 299 | apart, for the mail tool's subject box; the message copied is the card only. |
| 300 | 300 | In the terminal, `p` on an open listing previews the same message as text. Photos are this app's own, never remote. |
| 301 | **Send it from your own mail app:** approve the email, tap **Download as email | |
| 302 | draft (.eml)**, open the file, and it opens as an unsent draft in your mail | |
| 303 | app (`X-Unsent: 1`), with your name and address in From, the subject, a | |
| 304 | plain-text twin and the HTML with the photos as links to this app. Add the | |
| 305 | people to send it to and press send yourself; the app sends nothing. The | |
| 306 | file is `GET /listings/<id>/email.eml` (`?which=followup` or `reminder` for | |
| 307 | the other two; built in `src/eml.ts`), same read as the email page. An email | |
| 308 | not yet approved answers 409; the follow-up's To is left empty and the | |
| 309 | visitor list is never in the file. Header values are cleaned of line breaks. | |
| 310 | The follow-up and reminder download as `…-followup.eml` and `…-reminder.eml`. | |
| 311 | In the terminal, `E` in an email preview shows the file's headers once approved | |
| 312 | (a draft says it needs approving first). | |
| 301 | 313 | **Download launch kit** (`GET /listings/:id/kit.txt`, built in `src/kit.ts`) sends |
| 302 | 314 | the whole listing as one attachment named like `412-alder-lane-portland-or-97214-launch-kit.txt`. |
| 303 | 315 | Whoever may read the listing may download it; no permission changed. The `launch_kit` |
src/eml.test.ts added +160
| @@ -0,0 +1,160 @@ | ||
| 1 | /** | |
| 2 | * The approved email as a .eml draft: headers, both parts, CRLF, no header | |
| 3 | * injection, only once approved, and the visitor list never in the file. | |
| 4 | */ | |
| 5 | import { expect, test } from "bun:test"; | |
| 6 | import { mkdtempSync } from "node:fs"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { approveAsset, addVisitor, getListing, launchListing } from "./actions.ts"; | |
| 10 | import { openDb } from "./db.ts"; | |
| 11 | import { eml, emlFilename, header } from "./eml.ts"; | |
| 12 | import { SAMPLE } from "./sample.ts"; | |
| 13 | import type { Who } from "./who.ts"; | |
| 14 | ||
| 15 | const me: Who = { role: "user", id: "u1", name: "me" }; | |
| 16 | const sunday = { date: "2027-03-07", start: "13:00", end: "15:00" }; | |
| 17 | ||
| 18 | const setup = () => { | |
| 19 | const db = openDb(mkdtempSync(join(tmpdir(), "app-eml-"))); | |
| 20 | const made = launchListing(db, me, { fields: { ...SAMPLE, open_houses: [sunday] } }); | |
| 21 | if (!made.ok) throw new Error(made.message); | |
| 22 | return { db, id: made.value.id }; | |
| 23 | }; | |
| 24 | const read = (db: ReturnType<typeof openDb>, id: number) => { | |
| 25 | const f = getListing(db, me, { id }); | |
| 26 | if (!f.ok) throw new Error(f.message); | |
| 27 | return f.value; | |
| 28 | }; | |
| 29 | const approve = (db: ReturnType<typeof openDb>, id: number, kind: string) => { | |
| 30 | const a = read(db, id).assets.find((x) => x.kind === kind)!; | |
| 31 | const r = approveAsset(db, me, { id, kind, approved: true, seen: a.updated_at, door: "page" }); | |
| 32 | if (!r.ok) throw new Error(r.message); | |
| 33 | }; | |
| 34 | const decode = (file: string, type: string): string => { | |
| 35 | const part = file.split("--=_launch_").slice(1).find((p) => p.includes(`Content-Type: ${type}`))!; | |
| 36 | return Buffer.from(part.split("\r\n\r\n")[1]!.replace(/\r\n--.*$/s, "").replace(/\r\n/g, ""), "base64").toString("utf8"); | |
| 37 | }; | |
| 38 | ||
| 39 | test("a draft is not a file; an approved email is", () => { | |
| 40 | const { db, id } = setup(); | |
| 41 | expect(eml(read(db, id), "email", "https://x.test")).toBeNull(); | |
| 42 | approve(db, id, "email"); | |
| 43 | const file = eml({ ...read(db, id), photos: ["front.jpg"] }, "email", "https://x.test")!; | |
| 44 | expect(file).toContain(`From: "${SAMPLE.agent_name}" <${SAMPLE.agent_email}>\r\n`); | |
| 45 | expect(file).toMatch(/\r\nSubject: .+\r\n/); | |
| 46 | expect(file).toContain("\r\nX-Unsent: 1\r\n"); | |
| 47 | expect(file).toContain("Content-Type: multipart/alternative;"); | |
| 48 | expect(file.replace(/\r\n/g, "")).not.toMatch(/[\r\n]/); | |
| 49 | expect(file.split("\n").every((l, i, a) => i === a.length - 1 || l.endsWith("\r"))).toBe(true); | |
| 50 | expect(decode(file, "text/plain")).toContain(SAMPLE.agent_name); | |
| 51 | const html = decode(file, "text/html"); | |
| 52 | expect(html).toContain(`src="https://x.test/listings/${id}/photos/front.jpg"`); | |
| 53 | expect(html).toContain(SAMPLE.address); | |
| 54 | expect(file).not.toContain("Content-Disposition"); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a subject cannot start another header", () => { | |
| 58 | const { db, id } = setup(); | |
| 59 | approve(db, id, "email"); | |
| 60 | const l = read(db, id); | |
| 61 | // The subject line is parsed to one line; a header value built from anything else is cleaned too. | |
| 62 | const evil = { ...l, agent_name: 'Pat"\r\nBcc: [email protected]', assets: l.assets.map((a) => (a.kind === "email" ? { ...a, text: "Subject: Hi\r\nBcc: [email protected]\r\n\r\nBody" } : a)) }; | |
| 63 | const head = eml(evil, "email", "")!.split("\r\n\r\n")[0]!; | |
| 64 | const lines = head.split("\r\n"); | |
| 65 | expect(lines.some((x) => x.startsWith("Bcc:"))).toBe(false); | |
| 66 | expect(lines.find((x) => x.startsWith("Subject:"))).toBe("Subject: Hi"); | |
| 67 | expect(lines.some((x) => x.startsWith("Bcc"))).toBe(false); | |
| 68 | expect(lines.find((x) => x.startsWith("From:"))).toBe('From: "Pat Bcc: [email protected]" <' + SAMPLE.agent_email + ">"); | |
| 69 | expect(header("Subject", "a ".repeat(100).trim()).split("\r\n").every((x) => x.length <= 76)).toBe(true); | |
| 70 | expect(header("Subject", "a ".repeat(100).trim()).split("\r\n").slice(1).every((x) => x.startsWith(" "))).toBe(true); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a non-ASCII name is bare encoded words, never inside quotes; filenames differ by email", () => { | |
| 74 | const { db, id } = setup(); | |
| 75 | approve(db, id, "email"); | |
| 76 | const l = { ...read(db, id), agent_name: "José Núñez" }; | |
| 77 | const from = eml(l, "email", "")!.split("\r\n").find((x) => x.startsWith("From:"))!; | |
| 78 | expect(from).toMatch(/^From: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?= <.+>$/); | |
| 79 | expect(from).not.toContain('"'); | |
| 80 | expect(emlFilename(l, "email")).toBe("412-alder-lane-portland-or-97214.eml"); | |
| 81 | expect(emlFilename(l, "email_followup")).toBe("412-alder-lane-portland-or-97214-followup.eml"); | |
| 82 | expect(emlFilename(l, "email_reminder")).toBe("412-alder-lane-portland-or-97214-reminder.eml"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a non-ASCII subject is an encoded word", () => { | |
| 86 | const { db, id } = setup(); | |
| 87 | approve(db, id, "email"); | |
| 88 | const l = read(db, id); | |
| 89 | const f = eml({ ...l, assets: l.assets.map((a) => (a.kind === "email" ? { ...a, text: "Subject: Café — new\n\nBody" } : a)) }, "email", "")!; | |
| 90 | expect(f).toContain("Subject: =?UTF-8?B?"); | |
| 91 | }); | |
| 92 | ||
| 93 | test("the follow-up is addressed to no one and holds no visitor", () => { | |
| 94 | const { db, id } = setup(); | |
| 95 | const v = addVisitor(db, me, { id, name: "Vera Visitor", email: "[email protected]" }); | |
| 96 | expect(v.ok).toBe(true); | |
| 97 | expect(eml(read(db, id), "email_followup", "")).toBeNull(); | |
| 98 | approve(db, id, "email_followup"); | |
| 99 | const file = eml(read(db, id), "email_followup", "")!; | |
| 100 | expect(file).toContain("\r\nTo:\r\n"); | |
| 101 | expect(file).not.toContain("[email protected]"); | |
| 102 | expect(decode(file, "text/plain")).not.toContain("[email protected]"); | |
| 103 | expect(decode(file, "text/html")).not.toContain("Vera"); | |
| 104 | }); | |
| 105 | ||
| 106 | test("the route: 409 for a draft, the file once approved, link only when approved", async () => { | |
| 107 | const { serve } = await import("./server.ts"); | |
| 108 | const { sign } = await import("./who.ts"); | |
| 109 | const KEY = "a-key-only-the-forge-holds"; | |
| 110 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "eml-")), identityKey: KEY }); | |
| 111 | const base = `http://${server.hostname}:${server.port}`; | |
| 112 | const as = (id: string) => { | |
| 113 | const identity = `${id}|Pat|user|${Date.now() + 600_000}`; | |
| 114 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 115 | }; | |
| 116 | try { | |
| 117 | const form = new FormData(); | |
| 118 | form.set("address", "412 Alder Lane"); | |
| 119 | for (const [k, v] of Object.entries({ beds: 3, baths: 2, sqft: 1800, lot: "0.2 acre", year: 1999, price: 500000, | |
| 120 | tone: "classic", palette: "navy", agent_phone: "+1 (503) 555-0100", features: "a\nb\nc\nd\ne", | |
| 121 | open1_date: "2027-03-07", open1_start: "13:00", open1_end: "15:00", agent_name: "Pat", brokerage: "Bay Realty", license: "L1", agent_email: "[email protected]" })) | |
| 122 | form.set(k, String(v)); | |
| 123 | const made = await fetch(`${base}/listings`, { method: "POST", body: form, redirect: "manual", headers: as("author") }); | |
| 124 | const id = /\/listings\/(\d+)/.exec(made.headers.get("location") ?? "")![1]; | |
| 125 | const draft = await fetch(`${base}/listings/${id}/email.eml`, { headers: as("author") }); | |
| 126 | expect(draft.status).toBe(409); | |
| 127 | expect(await draft.text()).toContain("needs approving first"); | |
| 128 | expect(await (await fetch(`${base}/listings/${id}/email`, { headers: as("author") })).text()).not.toContain(".eml"); | |
| 129 | ||
| 130 | const approveOver = async (kind: string) => { | |
| 131 | const listing = await (await fetch(`${base}/listings/${id}?tab=email`, { headers: as("author") })).text(); | |
| 132 | const seen = new RegExp(`name="seen" value="([^"]*)">(?:\\s*<input[^>]*>)?\\s*<button[^>]*data-approve[^>]*formaction="/listings/${id}/assets/${kind}/approve"`).exec(listing)![1]!.replace(/&/g, "&"); | |
| 133 | const ok = await fetch(`${base}/listings/${id}/assets/${kind}/approve`, { method: "POST", body: new URLSearchParams({ seen }), redirect: "manual", headers: as("author") }); | |
| 134 | expect(ok.status).toBe(303); | |
| 135 | }; | |
| 136 | await approveOver("email"); | |
| 137 | ||
| 138 | const r = await fetch(`${base}/listings/${id}/email.eml`, { headers: as("author") }); | |
| 139 | expect(r.status).toBe(200); | |
| 140 | expect(r.headers.get("content-type")).toBe("message/rfc822"); | |
| 141 | expect(r.headers.get("content-disposition")).toBe('attachment; filename="412-alder-lane.eml"'); | |
| 142 | expect(await r.text()).toContain("X-Unsent: 1\r\n"); | |
| 143 | expect(await (await fetch(`${base}/listings/${id}/email`, { headers: as("author") })).text()).toContain(`href="/listings/${id}/email.eml"`); | |
| 144 | // The others are drafts until approved, then each has its own file name. | |
| 145 | for (const [which, kind] of [["followup", "email_followup"], ["reminder", "email_reminder"]] as const) { | |
| 146 | expect((await fetch(`${base}/listings/${id}/email.eml?which=${which}`, { headers: as("author") })).status).toBe(409); | |
| 147 | await approveOver(kind); | |
| 148 | const f = await fetch(`${base}/listings/${id}/email.eml?which=${which}`, { headers: as("author") }); | |
| 149 | expect(f.status).toBe(200); | |
| 150 | expect(f.headers.get("content-disposition")).toBe(`attachment; filename="412-alder-lane-${which}.eml"`); | |
| 151 | expect(await f.text()).toContain("\r\nTo:\r\n"); | |
| 152 | } | |
| 153 | // The door follows `permit`: listing read is open to anyone, and a listing that is not there is 404. | |
| 154 | expect((await fetch(`${base}/listings/${id}/email.eml`)).status).toBe(200); | |
| 155 | expect((await fetch(`${base}/listings/99999/email.eml`)).status).toBe(404); | |
| 156 | expect((await fetch(`${base}/listings/99999/email.eml`, { headers: as("author") })).status).toBe(404); | |
| 157 | } finally { | |
| 158 | server.stop(true); | |
| 159 | } | |
| 160 | }); |
src/eml.ts added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | /** | |
| 2 | * An approved email as a `.eml` draft: a listing and which email in, the file's | |
| 3 | * text out (CRLF line endings). The agent opens it in their own mail app, where | |
| 4 | * `X-Unsent: 1` makes it a draft; the app sends nothing. Pure: no database, no | |
| 5 | * permission; whoever may read the listing may read this once it is approved. | |
| 6 | * | |
| 7 | * Header values are one line each: control characters become spaces, text | |
| 8 | * beyond ASCII is an RFC 2047 encoded word, and long values are folded. | |
| 9 | */ | |
| 10 | ||
| 11 | import { factItems, money } from "./generate.ts"; | |
| 12 | import { escape } from "./http.ts"; | |
| 13 | import { addressSlug } from "./kit.ts"; | |
| 14 | import { propertyPath, type Launched } from "./listing.ts"; | |
| 15 | import { emailParts, type EmailKind } from "./pages/email.ts"; | |
| 16 | import { photoUrl } from "./pages/listing.ts"; | |
| 17 | import { token } from "./tokens.ts"; | |
| 18 | ||
| 19 | const CRLF = "\r\n"; | |
| 20 | ||
| 21 | /** One line, no control characters (CR, LF and the rest), trimmed. */ | |
| 22 | const oneLine = (v: string): string => | |
| 23 | v.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim(); | |
| 24 | ||
| 25 | const b64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64"); | |
| 26 | ||
| 27 | /** Text for a header: ASCII as it is, anything else as UTF-8 encoded words of at most 45 bytes. */ | |
| 28 | function words(v: string): string { | |
| 29 | const s = oneLine(v); | |
| 30 | if (/^[\x20-\x7e]*$/.test(s)) return s; | |
| 31 | const out: string[] = []; | |
| 32 | let chunk = ""; | |
| 33 | for (const ch of s) { | |
| 34 | if (new TextEncoder().encode(chunk + ch).length > 45) { | |
| 35 | out.push(chunk); | |
| 36 | chunk = ""; | |
| 37 | } | |
| 38 | chunk += ch; | |
| 39 | } | |
| 40 | if (chunk !== "") out.push(chunk); | |
| 41 | return out.map((c) => `=?UTF-8?B?${b64(new TextEncoder().encode(c))}?=`).join(" "); | |
| 42 | } | |
| 43 | ||
| 44 | /** "Name: value", folded at spaces to lines of at most 76 characters. */ | |
| 45 | export function header(name: string, value: string): string { | |
| 46 | const lines: string[] = []; | |
| 47 | let cur = `${name}:`; | |
| 48 | for (const w of value.split(" ").filter((x) => x !== "")) { | |
| 49 | if (cur.length + 1 + w.length > 76 && cur.length > name.length + 1) { | |
| 50 | lines.push(cur); | |
| 51 | cur = ` ${w}`; | |
| 52 | } else cur += ` ${w}`; | |
| 53 | } | |
| 54 | lines.push(cur); | |
| 55 | return lines.join(CRLF); | |
| 56 | } | |
| 57 | ||
| 58 | /** An address that cannot close its angle brackets or start a second one. */ | |
| 59 | const addr = (v: string): string => oneLine(v).replace(/[<>",;\s]/g, ""); | |
| 60 | ||
| 61 | /** A display name: plain ASCII in quotes, anything else as bare encoded words (RFC 2047 forbids them inside quotes). */ | |
| 62 | const display = (v: string): string => { | |
| 63 | const s = oneLine(v); | |
| 64 | return /^[\x20-\x7e]*$/.test(s) ? `"${s.replace(/["\\]/g, "")}"` : words(s); | |
| 65 | }; | |
| 66 | ||
| 67 | /** Base64 in lines of 76, the way a body part is carried whatever it holds. */ | |
| 68 | const body64 = (s: string): string => (b64(new TextEncoder().encode(s)).match(/.{1,76}/g) ?? []).join(CRLF); | |
| 69 | ||
| 70 | /** The emails that can be downloaded, by the `which` the email page takes. */ | |
| 71 | export const emlKind = (which: string | null): EmailKind => | |
| 72 | which === "followup" ? "email_followup" : which === "reminder" ? "email_reminder" : "email"; | |
| 73 | ||
| 74 | /** "412-alder-lane.eml", with -followup or -reminder so the three downloads do not collide. */ | |
| 75 | export const emlFilename = (l: Pick<Launched, "address" | "id">, kind: EmailKind): string => | |
| 76 | `${addressSlug(l)}${kind === "email_followup" ? "-followup" : kind === "email_reminder" ? "-reminder" : ""}.eml`; | |
| 77 | ||
| 78 | /** What the terminal shows for the file: its headers once approved, the same sentence the route gives otherwise. */ | |
| 79 | export const NEEDS_APPROVING = "This email needs approving first: approve it on the listing, then download it."; | |
| 80 | ||
| 81 | const abs = (origin: string, path: string): string => (/^https?:\/\//.test(path) ? path : `${origin}${path}`); | |
| 82 | ||
| 83 | function plainText(l: Launched, paragraphs: readonly string[], origin: string): string { | |
| 84 | return [ | |
| 85 | ...paragraphs, | |
| 86 | factItems(l).map((i) => `${i.value} ${i.label}`).join(" · "), | |
| 87 | `See the home: ${propertyPath(l, origin)}`, | |
| 88 | [l.agent_name, `${l.brokerage} · License ${l.license}`, `${l.agent_phone} · ${l.agent_email}`].join("\n"), | |
| 89 | "Equal Housing Opportunity.", | |
| 90 | ].join("\n\n").replace(/\r\n?|\n/g, CRLF); | |
| 91 | } | |
| 92 | ||
| 93 | /** The HTML part: inline styles, since a mail app reads no stylesheet; photos are absolute links. */ | |
| 94 | function htmlBody(l: Launched, subject: string, paragraphs: readonly string[], origin: string): string { | |
| 95 | const brand = token(`color-brand-${l.palette}`); | |
| 96 | const ink = token("color-fg"); | |
| 97 | const muted = token("color-ink-muted"); | |
| 98 | const on = token("color-brand-on"); | |
| 99 | const hero = l.photos[0]; | |
| 100 | const p = (s: string, style = ""): string => `<p style="margin:0 0 12px;${style}">${s}</p>`; | |
| 101 | return `<!doctype html><html><head><meta charset="utf-8"><title>${escape(subject)}</title></head> | |
| 102 | <body style="margin:0;padding:16px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:${ink}"> | |
| 103 | <div style="max-width:600px;margin:0 auto"> | |
| 104 | ${hero === undefined ? "" : `<img src="${escape(abs(origin, photoUrl(l, hero)))}" alt="${escape(l.address)}" width="600" style="display:block;width:100%;max-width:600px;height:auto">`} | |
| 105 | <h2 style="margin:16px 0 4px;color:${brand}">${escape(l.address)}</h2> | |
| 106 | ${p(escape(money(l.price)), `font-size:20px;font-weight:bold;color:${brand}`)} | |
| 107 | ${p(escape(factItems(l).map((i) => `${i.value} ${i.label}`).join(" · ")), `color:${muted}`)} | |
| 108 | ${paragraphs.map((x) => p(escape(x).replace(/\n/g, "<br>"))).join("\n")} | |
| 109 | ${p(`<a href="${escape(propertyPath(l, origin))}" style="display:inline-block;padding:12px 20px;background:${brand};color:${on};text-decoration:none;border-radius:4px">See the home</a>`)} | |
| 110 | ${p(`<b style="color:${brand}">${escape(l.agent_name)}</b><br>${escape(l.brokerage)} · License ${escape(l.license)}<br>${escape(l.agent_phone)} · ${escape(l.agent_email)}`)} | |
| 111 | ${p("Equal Housing Opportunity.", `font-size:13px;color:${muted}`)} | |
| 112 | </div></body></html>`.replace(/\r?\n/g, CRLF); | |
| 113 | } | |
| 114 | ||
| 115 | /** The file, or null while that email is not approved (or does not exist). */ | |
| 116 | export function eml(l: Launched, kind: EmailKind, origin: string, boundary = `=_launch_${l.id}_${kind}`): string | null { | |
| 117 | const asset = l.assets.find((a) => a.kind === kind); | |
| 118 | if (asset === undefined || asset.approval === null) return null; | |
| 119 | const { subject, paragraphs } = emailParts(asset.text, l.agent_name); | |
| 120 | const from = addr(l.agent_email); | |
| 121 | return [ | |
| 122 | header("From", `${display(l.agent_name)} <${from}>`), | |
| 123 | // The blast and the reminder are addressed by the agent in their mail app; the follow-up never carries the visitor list. | |
| 124 | "To:", | |
| 125 | header("Subject", words(subject)), | |
| 126 | "X-Unsent: 1", | |
| 127 | "MIME-Version: 1.0", | |
| 128 | `Content-Type: multipart/alternative; boundary="${boundary}"`, | |
| 129 | "", | |
| 130 | `--${boundary}`, | |
| 131 | "Content-Type: text/plain; charset=utf-8", | |
| 132 | "Content-Transfer-Encoding: base64", | |
| 133 | "", | |
| 134 | body64(plainText(l, paragraphs, origin)), | |
| 135 | `--${boundary}`, | |
| 136 | "Content-Type: text/html; charset=utf-8", | |
| 137 | "Content-Transfer-Encoding: base64", | |
| 138 | "", | |
| 139 | body64(htmlBody(l, subject, paragraphs, origin)), | |
| 140 | `--${boundary}--`, | |
| 141 | "", | |
| 142 | ].join(CRLF); | |
| 143 | } |
src/pages/email.ts +1
| @@ -111,6 +111,7 @@ export function emailPage(input: { | ||
| 111 | 111 | ${kind === "email" ? "" : `<a href="${path}">Email blast</a>`} |
| 112 | 112 | ${follow && kind !== "email_followup" ? `<a href="${path}?which=followup">Open house follow-up</a>` : ""} |
| 113 | 113 | ${remind && kind !== "email_reminder" ? `<a href="${path}?which=reminder">Open tomorrow reminder</a>` : ""} |
| 114 | ${l.assets.find((a) => a.kind === kind)?.approval == null ? "" : `<a href="${path}.eml${kind === "email" ? "" : `?which=${WHICH[kind]}`}" download>Download as email draft (.eml)</a>`} | |
| 114 | 115 | <button type="button" class="button" data-copy-email hidden>Copy as email</button> |
| 115 | 116 | <button type="button" class="button button-quiet" data-copy-subject hidden>Copy subject</button> |
| 116 | 117 | <span class="muted" data-copy-note role="status">Paste it into your mail tool; the photos load from this page.</span> |
src/routes.ts +20
| @@ -50,6 +50,7 @@ import { | ||
| 50 | 50 | type Result, |
| 51 | 51 | type Upload, |
| 52 | 52 | } from "./actions.ts"; |
| 53 | import { eml, emlFilename, emlKind, NEEDS_APPROVING } from "./eml.ts"; | |
| 53 | 54 | import { addressSlug, kit, kitFilename } from "./kit.ts"; |
| 54 | 55 | import { visitorsCsv } from "./visitors.ts"; |
| 55 | 56 | import { KINDS, STATUSES, TONES } from "./generate.ts"; |
| @@ -747,6 +748,25 @@ export const routes: readonly Route[] = [ | ||
| 747 | 748 | }); |
| 748 | 749 | }, |
| 749 | 750 | }, |
| 751 | { | |
| 752 | method: "GET", | |
| 753 | path: "/listings/:id/email.eml", | |
| 754 | handle: (c) => { | |
| 755 | const found = getListing(c.db, c.who, { id: Number(c.params.id) }); | |
| 756 | if (!found.ok) return text(found.message, STATUS[found.error]); | |
| 757 | const kind = emlKind(c.url.searchParams.get("which")); | |
| 758 | const file = eml(found.value, kind, publicOrigin); | |
| 759 | if (file === null) return text(NEEDS_APPROVING, 409); | |
| 760 | return new Response(file, { | |
| 761 | headers: { | |
| 762 | "content-type": "message/rfc822", | |
| 763 | "content-disposition": `attachment; filename="${emlFilename(found.value, kind)}"`, | |
| 764 | "cache-control": "no-store", | |
outdated
Same filename for all three emails; suffix the follow-up and reminder. |
||
| 765 | ...SECURITY, | |
| 766 | }, | |
| 767 | }); | |
| 768 | }, | |
| 769 | }, | |
| 750 | 770 | { |
| 751 | 771 | method: "GET", |
| 752 | 772 | path: "/listings/:id/email", |
src/tty.test.ts +23
| @@ -527,3 +527,26 @@ test("the week in the terminal asks permit per listing: a guest sees what a gues | ||
| 527 | 527 | expect(seen).toContain("THIS WEEK"); |
| 528 | 528 | expect(seen).toContain(SAMPLE.address); |
| 529 | 529 | }); |
| 530 | ||
| 531 | test("E in the email preview shows the .eml draft once approved, and says approve first before", () => { | |
| 532 | const { db } = fresh(); | |
| 533 | const tty = session(db, me); | |
| 534 | tty.press("\r"); | |
| 535 | tty.press("p"); | |
| 536 | expect(plain(tty.screen())).toContain("E the .eml draft"); | |
| 537 | tty.press("E"); | |
| 538 | expect(plain(tty.screen())).toContain("needs approving first"); | |
| 539 | tty.press("\x1b"); | |
| 540 | for (let i = 0; i < 7; i++) { | |
| 541 | tty.press("a"); | |
| 542 | tty.press("j"); | |
| 543 | } | |
| 544 | tty.press("p"); | |
| 545 | tty.press("E"); | |
| 546 | const shown = plain(tty.screen()); | |
| 547 | expect(shown).toContain("X-Unsent: 1"); | |
| 548 | expect(shown).toContain("Subject: "); | |
| 549 | expect(shown).toContain("To:"); | |
| 550 | tty.press("\x1b"); | |
| 551 | expect(plain(tty.screen())).toContain("j/↓↑ asset"); | |
| 552 | }); |
src/tty.ts +20 −1
| @@ -10,7 +10,8 @@ | ||
| 10 | 10 | * earlier words, esc goes back; x |
| 11 | 11 | * removes a listing; p previews the email as the client will read it; P previews the open house |
| 12 | 12 | * follow-up the same way, with the sign-in visitors it would go to; O previews |
| 13 | * the Open tomorrow reminder email; V lists | |
| 13 | * the Open tomorrow reminder email, and E there shows the .eml draft the page | |
| 14 | * downloads (its headers, once approved); V lists | |
| 14 | 15 | * those visitors (the listing's agent or an admin; a guest sees none): a adds |
| 15 | 16 | * one as `Name, email`, d removes the one under the cursor, esc goes back; k shows |
| 16 | 17 | * the launch kit, the file the page offers to download; R shows the seller |
| @@ -56,6 +57,7 @@ import { | ||
| 56 | 57 | useSuggestion, |
| 57 | 58 | } from "./actions.ts"; |
| 58 | 59 | import { publicOrigin, ssh, title, where } from "./env.ts"; |
| 60 | import { eml, emlFilename, NEEDS_APPROVING } from "./eml.ts"; | |
| 59 | 61 | import { kit } from "./kit.ts"; |
| 60 | 62 | import { reportText } from "./report.ts"; |
| 61 | 63 | import { scheduleText, weekText } from "./schedule.ts"; |
| @@ -113,6 +115,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 113 | 115 | let reading: number | undefined; |
| 114 | 116 | /** The email blast or the open house follow-up, laid out as the page's preview is, instead of the assets. */ |
| 115 | 117 | let previewing: "email" | "email_followup" | "email_reminder" | undefined; |
| 118 | /** In the preview, the .eml file's headers instead of the message (E). */ | |
| 119 | let filing = false; | |
| 116 | 120 | /** The sign-in visitors of the open house, the cursor on one. */ |
| 117 | 121 | let visiting: "visitors" | "showings" | false = false; |
| 118 | 122 | let visitorCursor = 0; |
| @@ -195,6 +199,15 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 195 | 199 | const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === kind)?.text ?? "", l.agent_name); |
| 196 | 200 | const approved = l.assets.find((a) => a.kind === kind)?.approval != null; |
| 197 | 201 | const sheet = kind === "email_followup" ? visitors(l.id) : undefined; |
| 202 | if (filing) { | |
| 203 | const file = eml(l, kind, publicOrigin); | |
| 204 | if (file === null) return [amber(NEEDS_APPROVING)]; | |
| 205 | return [ | |
| 206 | bold(`${emlFilename(l, kind)}: the draft the page downloads`), "", | |
| 207 | ...(file.split("\r\n\r\n")[0] ?? "").split("\r\n"), "", | |
| 208 | dim("A plain-text part and an HTML part follow; open the downloaded file in your mail app."), | |
| 209 | ]; | |
| 210 | } | |
| 198 | 211 | return [ |
| 199 | 212 | ...(approved ? [] : [amber("DRAFT — not approved"), ""]), |
| 200 | 213 | bold(`Subject: ${subject}`), "", |
| @@ -238,6 +251,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 238 | 251 | const found = open(); |
| 239 | 252 | if (visiting === "showings") help = "j/k move · a log one · d remove · esc back to the assets · q quit"; |
| 240 | 253 | else if (visiting) help = "j/k move · a add (Name, email) · d remove · esc back to the assets · q quit"; |
| 254 | else if (previewing && !filing) help = "E the .eml draft the page downloads · esc back to the assets · q quit"; | |
| 241 | 255 | else if (previewing || kitting || reporting || planning) help = "esc back to the assets · q quit"; |
| 242 | 256 | else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit"; |
| 243 | 257 | else help = |
| @@ -358,6 +372,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 358 | 372 | else previewing = which; |
| 359 | 373 | return undefined; |
| 360 | 374 | } |
| 375 | if (key === "E" && previewing && !filing) { | |
| 376 | filing = true; | |
| 377 | return undefined; | |
| 378 | } | |
| 361 | 379 | if (key === "V" && idle && found?.ok === true) { |
| 362 | 380 | visiting = "visitors"; |
| 363 | 381 | visitorCursor = 0; |
| @@ -446,6 +464,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 446 | 464 | if (key === "\x1b") { |
| 447 | 465 | if (previewing || kitting || reporting || planning || visiting) { |
| 448 | 466 | previewing = undefined; |
| 467 | filing = false; | |
| 449 | 468 | kitting = reporting = planning = false; |
| 450 | 469 | visiting = false; |
| 451 | 470 | } else { |
Encoded words inside quotes are not decoded by mail apps (RFC 2047 §5). Emit non-ASCII display names unquoted.