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

Merged opened this

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

Merged

mason/listing-launch:mason/112-download-an-approved-email-blast-as-a-em → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/112-download-an-approved-email-blast-as-a-em was merged into main as 99f171a.

Review

Sign in to review this change.
7 files changed +379 −1
README.md +12
@@ -298,6 +298,18 @@ list cannot drift from the app.
298298 JavaScript the page still renders. "Copy subject" copies the subject line
299299 apart, for the mail tool's subject box; the message copied is the card only.
300300 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).
301313 **Download launch kit** (`GET /listings/:id/kit.txt`, built in `src/kit.ts`) sends
302314 the whole listing as one attachment named like `412-alder-lane-portland-or-97214-launch-kit.txt`.
303315 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(/&amp;/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. */
outdated

Encoded words inside quotes are not decoded by mail apps (RFC 2047 §5). Emit non-ASCII display names unquoted.

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: {
111111 ${kind === "email" ? "" : `<a href="${path}">Email blast</a>`}
112112 ${follow && kind !== "email_followup" ? `<a href="${path}?which=followup">Open house follow-up</a>` : ""}
113113 ${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>`}
114115 <button type="button" class="button" data-copy-email hidden>Copy as email</button>
115116 <button type="button" class="button button-quiet" data-copy-subject hidden>Copy subject</button>
116117 <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 {
5050 type Result,
5151 type Upload,
5252 } from "./actions.ts";
53 import { eml, emlFilename, emlKind, NEEDS_APPROVING } from "./eml.ts";
5354 import { addressSlug, kit, kitFilename } from "./kit.ts";
5455 import { visitorsCsv } from "./visitors.ts";
5556 import { KINDS, STATUSES, TONES } from "./generate.ts";
@@ -747,6 +748,25 @@ export const routes: readonly Route[] = [
747748 });
748749 },
749750 },
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 },
750770 {
751771 method: "GET",
752772 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
527527 expect(seen).toContain("THIS WEEK");
528528 expect(seen).toContain(SAMPLE.address);
529529 });
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 @@
1010 * earlier words, esc goes back; x
1111 * removes a listing; p previews the email as the client will read it; P previews the open house
1212 * 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
1415 * those visitors (the listing's agent or an admin; a guest sees none): a adds
1516 * one as `Name, email`, d removes the one under the cursor, esc goes back; k shows
1617 * the launch kit, the file the page offers to download; R shows the seller
@@ -56,6 +57,7 @@ import {
5657 useSuggestion,
5758 } from "./actions.ts";
5859 import { publicOrigin, ssh, title, where } from "./env.ts";
60 import { eml, emlFilename, NEEDS_APPROVING } from "./eml.ts";
5961 import { kit } from "./kit.ts";
6062 import { reportText } from "./report.ts";
6163 import { scheduleText, weekText } from "./schedule.ts";
@@ -113,6 +115,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
113115 let reading: number | undefined;
114116 /** The email blast or the open house follow-up, laid out as the page's preview is, instead of the assets. */
115117 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;
116120 /** The sign-in visitors of the open house, the cursor on one. */
117121 let visiting: "visitors" | "showings" | false = false;
118122 let visitorCursor = 0;
@@ -195,6 +199,15 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
195199 const { subject, paragraphs } = emailParts(l.assets.find((a) => a.kind === kind)?.text ?? "", l.agent_name);
196200 const approved = l.assets.find((a) => a.kind === kind)?.approval != null;
197201 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 }
198211 return [
199212 ...(approved ? [] : [amber("DRAFT — not approved"), ""]),
200213 bold(`Subject: ${subject}`), "",
@@ -238,6 +251,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
238251 const found = open();
239252 if (visiting === "showings") help = "j/k move · a log one · d remove · esc back to the assets · q quit";
240253 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";
241255 else if (previewing || kitting || reporting || planning) help = "esc back to the assets · q quit";
242256 else if (versioning !== undefined) help = "1-5 put that version back · esc back to the assets · q quit";
243257 else help =
@@ -358,6 +372,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
358372 else previewing = which;
359373 return undefined;
360374 }
375 if (key === "E" && previewing && !filing) {
376 filing = true;
377 return undefined;
378 }
361379 if (key === "V" && idle && found?.ok === true) {
362380 visiting = "visitors";
363381 visitorCursor = 0;
@@ -446,6 +464,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
446464 if (key === "\x1b") {
447465 if (previewing || kitting || reporting || planning || visiting) {
448466 previewing = undefined;
467 filing = false;
449468 kitting = reporting = planning = false;
450469 visiting = false;
451470 } else {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs