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.
An approved email now downloads as a `.eml` draft the agent opens in their own mail app. A draft answers 409. The new tests, the speed, contract, openapi, design and followup suites, and the design lint pass. I did not run the full suite or `tsc`, because the repo has no `tsconfig.json`, and there is no `scripts/gate.ts` or `scripts/fast.ts` to run either.
shipped production is serving it deploy 957b843
An approved email now downloads as a .eml draft the agent opens in their own mail app. A draft answers 409. The new tests, the speed, contract, openapi, design and followup suites, and the design lint pass. I did not run the full suite or tsc, because the repo has no tsconfig.json, and there is no scripts/gate.ts or scripts/fast.ts to run either.
What changed
- Route:
GET /listings/:id/email.emltakes?which=followup|reminderlike the email page. It answersmessage/rfc822withattachment; filename="<address-slug>.eml"andno-store. - Who can read it: it goes through the same
getListingread as the email page, so there is no new contract cell. - Not approved: it answers 409 with "This email needs approving first: approve it on the listing, then download it."
- Email page: the "Download as email draft (.eml)" link shows only when that email is approved.
- Headers: the file carries
From:with the agent's name and address,To:empty, theSubject:,X-Unsent: 1andMIME-Version. Every line ends in CRLF. - Body:
multipart/alternativewith a plain-text part and an HTML part, both base64 UTF-8. The HTML has inline styles, brand colours read from the design tokens, and photos as absolute links usingpublicOrigin. There are no attachments. - Header safety:
- Header values lose CR/LF and other control characters.
- Long values fold at 76 characters.
- A non-ASCII subject becomes an RFC 2047 encoded word.
- The From: address and display name drop angle brackets, commas and quotes.
- New module:
src/eml.tsis pure, with the listing, which email and the origin in and a string out. - README: a paragraph in a realtor's words: approve it, download it, open it, it is a draft in your mail app.
Tests in src/eml.test.ts:
- A draft gives no file, and an approved email gives the headers above.
- A subject containing
\r\nBcc:comes out asSubject: Hiwith noBccheader. The subject parser (emailParts) already cuts at the first line, so the injection is stopped twice. I also tested theFrom:name, which is cleaned by the new code. - A non-ASCII subject is encoded.
- The follow-up has an empty
To:and the visitor's name and address appear nowhere in it. - Over HTTP: 409 for a draft, 200 with the right headers once approved, and the page link appears only after approval.
Not done
- No recipients are filled in: the agent adds them in their mail app.
- Photos are links, not attachments.
To try it, approve the email on a listing, then open Email → "Download as email draft (.eml)".
Works on the route and the headers are safe, but one miss on the list: no terminal door.
- (fix first) Both doors: src/tty.ts has nothing for the .eml. The page offers 'Download as email draft', and the terminal previews the same emails (p/P/O) and the launch kit (k, which shows the file as it would read). Add the equivalent: in the email preview, once approved, show the file text from eml() (or its headers), and say in the tty help line how to get it. A draft shows the same 'needs approving first' sentence. Add a tty test, and mention the terminal in the README paragraph.
- Display name: words() puts RFC 2047 encoded words inside a quoted string in From:, which RFC 2047 forbids; mail apps show =?UTF-8?B?...?= literally. Use the encoded word unquoted for a non-ASCII name (quote only plain ASCII).
- Filename is the same for blast, follow-up and reminder, so downloads collide in the Downloads folder (name-(1).eml). The issue's
<address-slug>.emlis satisfied, but suffix -followup / -reminder for the other two, with a test, or say why not. - Route test only covers 409 and 200 for the blast; add a 200 for ?which=reminder or followup over HTTP and a guest/unreadable listing case proving the door follows permit.
Ticks are held back until the pull is approved.
I've made all four changes on the same branch and pushed. The new tests and the speed, followup, contract, openapi, design and walkthrough suites pass, and scripts/lint-design.ts is clean. I did not run the full suite or tsc; the repo has no tsconfig.json.
- Terminal door:
- Key: E in an email preview (p, P or O) shows the .eml file's headers once the email is approved. A draft shows the same "needs approving first" sentence the route gives, kept as one constant in src/eml.ts that both use.
- Help and docs: the preview's help line names the key, the src/tty.ts header comment documents it, and the README paragraph mentions the terminal.
- What it shows: headers only, not the base64 body parts. esc leaves the whole preview and returns to the asset list, as it does from any preview.
- Test: a new tty test covers both the draft and approved cases.
- Display name: a non-ASCII
From:name is now bare encoded words with no quotes. A plain-ASCII name stays quoted. A new test checks both. - File names: the blast stays
<address-slug>.eml. The other two are<address-slug>-followup.emland<address-slug>-reminder.eml, through oneemlFilenamefunction that the route and the test both use. - Route tests: these now cover
?which=followupand?which=reminderover HTTP, each 409 before approval and 200 after, with the right file name and an emptyTo:. A guest with no identity gets 200 for an approved email, becausepermitlets anyone read a listing. A listing id that doesn't exist gets 404.
I read "guest/unreadable listing" as the guest case plus the unknown id. listing read is open to everyone in contract.json, so there is no listing a guest can't read. A guest getting 200 is therefore correct, and I did not add a contract cell.
All four earlier asks are met: the terminal door (E in the email preview, with a test), the non-ASCII From name as bare encoded words, distinct followup/reminder file names, and route tests for both variants including a guest and an unknown id. No contract cell is loosened. The preview serves an .eml for the sample listing. Nothing new to ask.
An approved email now downloads as a
.emldraft the agent opens in their own mail app. A draft answers 409. The new tests, the speed, contract, openapi, design and followup suites, and the design lint pass. I did not run the full suite ortsc, because the repo has notsconfig.json, and there is noscripts/gate.tsorscripts/fast.tsto run either.What changed
GET /listings/:id/email.emltakes?which=followup|reminderlike the email page. It answersmessage/rfc822withattachment; filename="<address-slug>.eml"andno-store.getListingread as the email page, so there is no new contract cell.From:with the agent's name and address,To:empty, theSubject:,X-Unsent: 1andMIME-Version. Every line ends in CRLF.multipart/alternativewith a plain-text part and an HTML part, both base64 UTF-8. The HTML has inline styles, brand colours read from the design tokens, and photos as absolute links usingpublicOrigin. There are no attachments.- Header values lose CR/LF and other control characters. - Long values fold at 76 characters. - A non-ASCII subject becomes an RFC 2047 encoded word. - The
From:address and display name drop angle brackets, commas and quotes.src/eml.tsis pure, with the listing, which email and the origin in and a string out.Tests in
src/eml.test.ts:\r\nBcc:comes out asSubject: Hiwith noBccheader. The subject parser (emailParts) already cuts at the first line, so the injection is stopped twice. I also tested theFrom:name, which is cleaned by the new code.To:and the visitor's name and address appear nowhere in it.Not done
To try it, approve the email on a listing, then open Email → "Download as email draft (.eml)".
Closes #112