From b76bf29a73725d389e307399b51ea079b46ce588 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 15 Sep 2026 17:03:00 -0700 Subject: [PATCH] Add the AC Letters terminal client and separate mail sync command --- .devcontainer/config.fish | 14 ++- toolchain/mail/README.md | 52 +++++++++ toolchain/mail/ac-mail.mjs | 203 +++++++++++++++++++++++++++++++++ toolchain/mail/client.mjs | 102 +++++++++++++++++ toolchain/mail/client.test.mjs | 54 +++++++++ toolchain/mail/install.mjs | 18 +++ toolchain/mail/tui.test.py | 71 ++++++++++++ 7 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 toolchain/mail/README.md create mode 100755 toolchain/mail/ac-mail.mjs create mode 100644 toolchain/mail/client.mjs create mode 100644 toolchain/mail/client.test.mjs create mode 100644 toolchain/mail/install.mjs create mode 100644 toolchain/mail/tui.test.py diff --git a/.devcontainer/config.fish b/.devcontainer/config.fish index f4ca70d46b..680943ece7 100644 --- a/.devcontainer/config.fish +++ b/.devcontainer/config.fish @@ -2631,9 +2631,21 @@ alias ac-llm 'clear; ac; claude' alias ac-llm-continue 'clear; ac; claude --continue' alias ac-llm-resume 'clear; ac; claude --resume' +# AC letters TUI; bypass fish functions when invoking the installed command. +function ac-mail --description "Read and send AC letters" + if command -q ac-mail + command ac-mail $argv + else if set -q AC_ROOT; and test -f "$AC_ROOT/toolchain/mail/ac-mail.mjs" + command node "$AC_ROOT/toolchain/mail/ac-mail.mjs" $argv + else + echo "Install Letters: node toolchain/mail/install.mjs" >&2 + return 127 + end +end + # Mail sync (mbsync + mu index for mu4e) # Syncs both mail@aesthetic.computer and me@jas.life -function ac-mail +function ac-mail-sync clear set -l mbsyncrc ~/.mbsyncrc set -l msmtprc ~/.msmtprc diff --git a/toolchain/mail/README.md b/toolchain/mail/README.md new file mode 100644 index 0000000000..90e62d776d --- /dev/null +++ b/toolchain/mail/README.md @@ -0,0 +1,52 @@ +# ac-mail + +A terminal interface to the same letters as [web /mail](https://aesthetic.computer/mail). +Uses the shared `ac-login` session (`~/.ac-token`) and its existing refresh helper. +No additional dependencies; requires Node 24 and an interactive ANSI terminal. + +```sh +node toolchain/mail/install.mjs +ac-mail +``` + +If needed, run `ac-login` first. `ac-mail login` delegates to that same repository +login implementation; `ac-mail status` delegates to its status command. Nothing +launches a browser automatically. Ensure `~/.local/bin` is on your `PATH`. + +The AC fish configuration reserves `ac-mail` for this interface. The older +Gmail/mu4e sync loop is now `ac-mail-sync`. In a terminal that still has the old +function loaded, run `command ac-mail` to open Letters immediately, or start a +new shell to load the updated names. + +| View | Keys | +| --- | --- | +| Inbox / Sent | ↑↓ or j/k select, Enter open, Tab switch, c write, r refresh, q quit | +| Letter | ↑↓ or j/k scroll, c reply, Esc back | +| Compose | Tab / Shift+Tab change field, type to append, Backspace erase, Enter newline in body, Ctrl+S review, Esc keep draft | +| Review | y send, Esc edit, ↑↓ scroll | + +Opening an unread letter marks it read through `/api/mail`, visible after refreshing +the web client. Drafts stay in memory across views; they disappear on exit. Quitting +with `q` asks before discarding a draft; Ctrl+C exits immediately. Editing is append +and backspace in this first version, not a full text editor. + +The API currently returns the latest 50 inbox and 50 sent letters. The client +enforces its 500-character body and 80-character subject limits rather than letting +the server silently truncate a letter. Emoji can count as multiple characters under +these existing API limits. Outside email uses the same backend routing as web mail. + +Sending requires review and an explicit `y`. A timeout or server error can mean +delivery succeeded but confirmation was lost: refresh Sent before trying again. +The client never automatically retries a send. A changed shared login requires +reopening the client so a draft is not silently sent from another account. + +No message cache, draft files, analytics, or raw error logging. The interface uses +the terminal's alternate screen and restores normal terminal mode on exit. Terminal +recording tools and local screen access can still see displayed letters. Messages +remain readable on the server; this client does not implement encryption. + +```sh +ac-mail --demo # Synthetic mailbox; no login/network +node --test toolchain/mail/client.test.mjs +python3 toolchain/mail/tui.test.py # Real PTY interaction test (macOS/Linux) +``` diff --git a/toolchain/mail/ac-mail.mjs b/toolchain/mail/ac-mail.mjs new file mode 100755 index 0000000000..52eaad5b52 --- /dev/null +++ b/toolchain/mail/ac-mail.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +import { emitKeypressEvents } from "node:readline"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { createClient, MailError, safe, clip, wrap, graphemes, validateDraft, replyTo } from "./client.mjs"; + +const args = process.argv.slice(2); +if (args.includes("--help") || args.includes("-h")) { + console.log(`ac-mail — Letters, in your terminal + +ac-mail Open the shared AC mailbox +ac-mail login Run ac-login's browser sign-in +ac-mail status Run ac-login status +ac-mail --demo Explore with synthetic letters; no login or network + +Inbox: ↑/↓ or j/k · Enter open · Tab inbox/sent · c compose · r refresh · q quit +Letter: ↑/↓ scroll · c reply · Esc back +Compose: Tab field · Enter new body line · Ctrl+S review · Esc return +Review: y send · Esc edit + +Uses ~/.ac-token from ac-login. Drafts remain in memory, never temporary files. +The server currently returns the newest 50 letters in each mailbox.`); + process.exit(0); +} +if (["login", "status"].includes(args[0])) { + const child = spawn(process.execPath, [fileURLToPath(new URL("../../tezos/ac-login.mjs", import.meta.url)), ...(args[0] === "status" ? ["status"] : [])], { stdio: "inherit" }); + child.on("error", () => { console.error("Could not start ac-login."); process.exitCode = 1; }); + child.on("exit", (code) => { process.exitCode = code ?? 1; }); +} else if (args.some((arg) => arg !== "--demo")) { + console.error("Unknown option. Run ac-mail --help."); process.exitCode = 1; +} else if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error("ac-mail needs an interactive terminal. Run ac-mail --help for usage."); process.exitCode = 1; +} else { + await run(args.includes("--demo")); +} + +async function run(demo) { + const input = process.stdin, output = process.stdout; + const blank = () => ({ to: "", subject: "", body: "" }); + let mailbox = { inbox: [], sent: [], unread: 0, addresses: [] }; + let tab = "inbox", mode = "list", selected = 0, scroll = 0, field = 0; + let draft = blank(), notice = "", busy = false, closed = false, letter; + const demoData = { + addresses: ["demo@aesthetic.computer"], unread: 1, + inbox: [ + { id: "demo1", from: "@friend", subject: "Hello", text: "A letter in your terminal.\n\nThis is a synthetic preview. Nothing here sends real mail.", when: new Date().toISOString(), read: false }, + { id: "demo2", from: "@neighbor", subject: "An afternoon drawing", text: "I made a drawing today.\nWhat have you been making?", when: new Date().toISOString(), read: true }, + ], sent: [], + }; + const client = demo ? { + inbox: async () => structuredClone(demoData), + read: async (id) => { const m = demoData.inbox.find((m) => m.id === id); if (m && !m.read) { m.read = true; demoData.unread--; } return { read: 1 }; }, + send: async (d) => { demoData.sent.unshift({ id: `sent${demoData.sent.length}`, to: d.to, subject: d.subject, text: d.body, when: new Date().toISOString() }); return { status: "mailed" }; }, + } : createClient(); + + function finish() { + if (closed) return; + closed = true; + input.off("keypress", onKey); output.off("resize", paint); + process.off("SIGINT", finish); process.off("SIGTERM", finish); + input.setRawMode(false); input.pause(); + output.write("\x1b[0m\x1b[?25h\x1b[?1049l"); + draft = blank(); mailbox = {}; letter = null; + } + async function task(fn) { + busy = true; paint(); + try { await fn(); } catch (error) { + notice = error instanceof MailError ? error.message : "Could not complete mail request."; + } finally { busy = false; paint(); } + } + async function refresh() { + const next = await client.inbox(); + if (!Array.isArray(next.inbox) || !Array.isArray(next.sent)) throw new MailError("Invalid mailbox response."); + if (closed) return; + mailbox = next; + selected = Math.max(0, Math.min(selected, mailbox[tab].length - 1)); + } + function paint() { + if (closed) return; + const cols = Math.max(1, (output.columns || 80) - 1), rows = Math.max(1, output.rows || 24); + const contentRows = Math.max(1, rows - 5); + const lines = []; + const add = (text = "", style = "") => lines.push(style + clip(text, cols) + "\x1b[0m"); + if (cols < 28 || rows < 9) { + output.write("\x1b[H\x1b[2J" + clip("Resize terminal (28 × 9 minimum)", cols)); return; + } + const address = mailbox.addresses?.find((a) => !/^ac\d\d[a-z]{5}@/.test(a)) || mailbox.addresses?.[0]; + add(`Letters${demo ? " · DEMO" : ""} ${address || "ac-login"}`, "\x1b[1;36m"); + add(`${tab === "inbox" ? "[Inbox]" : " Inbox "} ${mailbox.unread || 0} unread ${tab === "sent" ? "[Sent]" : " Sent "}${mode === "compose" || mode === "review" ? " Write" : ""}`); + add("─".repeat(cols), "\x1b[90m"); + let help; + if (mode === "list") { + const entries = mailbox[tab] || []; + const offset = Math.max(0, selected - contentRows + 1); + if (!entries.length) add(busy ? "Loading letters…" : "No letters here yet."); + entries.slice(offset, offset + contentRows).forEach((m, i) => { + const day = new Date(m.when).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + const who = tab === "inbox" ? m.from : m.to; + add(`${m.read === false && tab === "inbox" ? "●" : " "} ${clip(who, 18).padEnd(18)} ${day} ${m.subject || "(no subject)"}`, i + offset === selected ? "\x1b[7m" : ""); + }); + help = "↑↓ move Enter open Tab inbox/sent c write r refresh q quit"; + } else if (mode === "letter") { + const text = [ + `${tab === "inbox" ? "From" : "To"}: ${tab === "inbox" ? letter.from : letter.to}`, + new Date(letter.when).toLocaleString(), letter.subject || "(no subject)", "", + letter.text, + ].flatMap((part) => wrap(part, cols)); + scroll = Math.max(0, Math.min(scroll, Math.max(0, text.length - contentRows))); + text.slice(scroll, scroll + contentRows).forEach((line) => add(line)); + help = "↑↓ scroll c reply Esc back q quit"; + } else if (mode === "compose") { + const labels = ["To", "Subject", "Body"], keys = ["to", "subject", "body"]; + labels.forEach((name, i) => { + if (i < 2) add(`${i === field ? "›" : " "} ${name}: ${draft[keys[i]]}${i === field ? "▏" : ""}`); + }); + add(`${field === 2 ? "›" : " "} Body (${draft.body.length}/500)`); + wrap(draft.body + (field === 2 ? "▏" : ""), cols).slice(-Math.max(1, contentRows - 3)).forEach((line) => add(line)); + help = "Tab field Enter newline Ctrl+S review Esc keep draft"; + } else if (mode === "review") { + const external = draft.to.includes("@") && !draft.to.startsWith("@") && !/@(?:mail\.)?aesthetic\.computer$/i.test(draft.to); + const text = [`Send to ${draft.to}?`, external ? "Outside email — providers can read this letter." : "AC letter — currently readable by administrators.", draft.subject || "(no subject)", "", draft.body].flatMap((part) => wrap(part, cols)); + scroll = Math.max(0, Math.min(scroll, Math.max(0, text.length - contentRows))); + text.slice(scroll, scroll + contentRows).forEach((line) => add(line)); + help = "y send Esc edit ↑↓ scroll"; + } else { + add("Discard your draft and quit?"); help = "y discard and quit Esc keep writing"; + } + while (lines.length < rows - 2) add(); + add(busy ? "Working…" : notice, "\x1b[33m"); + add(help, "\x1b[90m"); + output.write("\x1b[H\x1b[2J" + lines.slice(0, rows).join("\r\n")); + } + function compose(to = "", subject = "") { + // A pending draft is never overwritten by replying to another letter. + if (!draft.to && !draft.subject && !draft.body) draft = { to, subject, body: "" }; + field = draft.to ? 2 : 0; mode = "compose"; notice = ""; + } + function onKey(str, key = {}) { + if (closed) return; + if (key.ctrl && key.name === "c") { finish(); return; } + if (busy) return; + if (mode === "quit") { + if (str === "y") finish(); + else if (key.name === "escape") mode = "compose"; + paint(); return; + } + if (mode === "compose") { + if (key.name === "escape") { mode = "list"; notice = "Draft kept. Press c to resume."; } + else if (key.ctrl && key.name === "s") { + notice = validateDraft(draft) || ""; + if (!notice) { mode = "review"; scroll = 0; } + } else if (key.name === "tab") field = (field + (key.shift ? 2 : 1)) % 3; + else { + const name = ["to", "subject", "body"][field]; + if (key.name === "backspace") draft[name] = graphemes(draft[name]).slice(0, -1).join(""); + else if (key.name === "return") { if (field === 2 && draft.body.length < 500) draft.body += "\n"; else if (field < 2) field++; } + else if (!key.ctrl && !key.meta && str && !str.includes("\x1b")) { + const value = safe(str).replace(/\n/g, field === 2 ? "\n" : " "); + const limit = field === 2 ? 500 : field === 1 ? 80 : 254; + if (draft[name].length + value.length <= limit) draft[name] += value; + else notice = `Maximum ${limit} characters.`; + } + } + paint(); return; + } + if (mode === "review") { + if (key.name === "escape") mode = "compose"; + else if (key.name === "up") scroll--; + else if (key.name === "down") scroll++; + else if (str === "y") void task(async () => { + const result = await client.send(draft); + if (result.status !== "mailed") throw new MailError("Delivery unconfirmed. Refresh Sent before resending."); + draft = blank(); tab = "sent"; mode = "list"; selected = 0; + notice = demo ? "Demo letter sent. No real delivery." : "Letter sent."; + await refresh(); + }); + paint(); return; + } + if (str === "q") { if (draft.to || draft.subject || draft.body) mode = "quit"; else finish(); } + else if (str === "c") compose(mode === "letter" && tab === "inbox" ? replyTo(letter) : "", mode === "letter" && tab === "inbox" ? clip(`re: ${letter.subject || ""}`, 80) : ""); + else if (key.name === "escape") { mode = "list"; scroll = 0; } + else if (mode === "letter") { + if (key.name === "up" || str === "k") scroll--; + if (key.name === "down" || str === "j") scroll++; + } else if (key.name === "tab") { tab = tab === "inbox" ? "sent" : "inbox"; selected = 0; } + else if (key.name === "up" || str === "k") selected = Math.max(0, selected - 1); + else if (key.name === "down" || str === "j") selected = Math.max(0, Math.min(mailbox[tab].length - 1, selected + 1)); + else if (str === "r") void task(async () => { await refresh(); notice = "Refreshed."; }); + else if (key.name === "return" && mailbox[tab][selected]) { + letter = mailbox[tab][selected]; mode = "letter"; scroll = 0; + if (tab === "inbox" && !letter.read) void task(async () => { + await client.read(letter.id); + letter.read = true; mailbox.unread = Math.max(0, mailbox.unread - 1); + }); + } + paint(); + } + output.write("\x1b[?1049h\x1b[?25l"); + input.setRawMode(true); emitKeypressEvents(input); input.on("keypress", onKey); input.resume(); + output.on("resize", paint); process.on("SIGINT", finish); process.on("SIGTERM", finish); + process.once("exit", finish); + await task(refresh); +} diff --git a/toolchain/mail/client.mjs b/toolchain/mail/client.mjs new file mode 100644 index 0000000000..d905b776da --- /dev/null +++ b/toolchain/mail/client.mjs @@ -0,0 +1,102 @@ +import { loadTokens, UA } from "../mcp/ac-token.mjs"; + +export class MailError extends Error {} + +// Fixed first-party origin: never send the shared bearer token to a CLI URL. +export function createClient({ tokens = loadTokens, request = fetch } = {}) { + let identity; + async function call(method, body) { + let session; + try { session = await tokens(); } catch { + throw new MailError("Sign in with ac-login, then reopen ac-mail."); + } + if (!session.access_token || !session.user?.sub) { + throw new MailError("Sign in with ac-login, then reopen ac-mail."); + } + if (identity && identity !== session.user.sub) { + throw new MailError("AC account changed. Reopen ac-mail before continuing."); + } + identity = session.user.sub; + let res; + try { + res = await request("https://aesthetic.computer/api/mail", { + method, redirect: "error", signal: AbortSignal.timeout(15000), + headers: { Authorization: `Bearer ${session.access_token}`, "User-Agent": UA, "Content-Type": "application/json" }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + } catch { + throw new MailError(body?.text + ? "Delivery unconfirmed. Refresh Sent before resending." + : "Could not reach AC mail. Press r to retry."); + } + if (res.status === 401 || res.status === 403) throw new MailError("Access refused. Check ac-login status or sign in again."); + if (res.status === 404) throw new MailError("Recipient not found."); + if (!res.ok) throw new MailError(body?.text + ? "Delivery unconfirmed. Refresh Sent before resending." + : `Mail request failed (HTTP ${res.status}).`); + try { return await res.json(); } catch { + throw new MailError(body?.text ? "Delivery unconfirmed. Refresh Sent before resending." : "Invalid mail response. Press r to retry."); + } + } + return { + inbox: () => call("GET"), + read: (id) => call("POST", { action: "read", id }), + send: (draft) => { + const error = validateDraft(draft); + if (error) throw new MailError(error); + return call("POST", { to: draft.to.trim(), subject: draft.subject.trim(), text: draft.body.trim() }); + }, + }; +} + +export function validateDraft({ to, subject, body }) { + if (!to.trim()) return "Add a recipient."; + if (!body.trim()) return "Write a letter first."; + // The current /api/mail applies these limits even to outgoing SMTP letters. + if (body.trim().length > 500) return "Letters are limited to 500 characters."; + if (subject.trim().length > 80) return "Subjects are limited to 80 characters."; + if (/[\r\n\x00-\x1f\x7f]/.test(to + subject)) return "Recipient and subject must be single lines."; + return null; +} + +// All remote text is untrusted terminal input: discard controls and bidi marks. +export function safe(value) { + return String(value ?? "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, "").replace(/\t/g, " "); +} +const segments = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +export const graphemes = (text) => Array.from(segments.segment(text), (s) => s.segment); +export function width(text) { + return graphemes(text).reduce((sum, g) => { + const n = g.codePointAt(0); + const wide = /\p{Extended_Pictographic}/u.test(g) || (n >= 0x1100 && ( + n <= 0x115f || n === 0x2329 || n === 0x232a || (n >= 0x2e80 && n <= 0xa4cf) || + (n >= 0xac00 && n <= 0xd7a3) || (n >= 0xf900 && n <= 0xfaff) || + (n >= 0xfe10 && n <= 0xfe6f) || (n >= 0xff01 && n <= 0xff60) || n >= 0x20000)); + return sum + (wide ? 2 : 1); + }, 0); +} +export function clip(value, columns) { + let result = "", used = 0; + for (const g of graphemes(safe(value).replace(/\n/g, " "))) { + const n = width(g); + if (used + n > columns) break; + result += g; used += n; + } + return result; +} +export function wrap(value, columns) { + const lines = []; + for (const paragraph of safe(value).split("\n")) { + let line = ""; + for (const g of graphemes(paragraph)) { + if (width(line + g) > columns) { lines.push(line); line = ""; } + line += g; + } + lines.push(line); + } + return lines; +} + +export function replyTo(letter) { + return letter.fromEmail || letter.from || ""; +} diff --git a/toolchain/mail/client.test.mjs b/toolchain/mail/client.test.mjs new file mode 100644 index 0000000000..8b71472c93 --- /dev/null +++ b/toolchain/mail/client.test.mjs @@ -0,0 +1,54 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createClient, validateDraft, safe, clip, width, wrap, replyTo } from "./client.mjs"; + +const draft = { to: "@friend", subject: "Hello", body: "hi!" }; +const session = { access_token: "synthetic-token", user: { sub: "test-user" } }; +test("uses the shared session and mail API for reads, sending and read state", async () => { + const calls = []; + const client = createClient({ tokens: async () => session, request: async (url, options) => { + calls.push({ url, ...options }); return { ok: true, json: async () => ({ status: "mailed" }) }; + } }); + await client.inbox(); await client.send(draft); await client.read("letter-id"); + assert.equal(calls.length, 3); + assert.ok(calls.every((c) => c.url === "https://aesthetic.computer/api/mail" && c.headers.Authorization === "Bearer synthetic-token" && c.redirect === "error")); + assert.deepEqual(JSON.parse(calls[1].body), { to: "@friend", subject: "Hello", text: "hi!" }); + assert.deepEqual(JSON.parse(calls[2].body), { action: "read", id: "letter-id" }); +}); +test("account changes cannot silently send from a different identity", async () => { + let current = session; let calls = 0; + const client = createClient({ tokens: async () => current, request: async () => { calls++; return { ok: true, json: async () => ({}) }; } }); + await client.inbox(); current = { ...session, user: { sub: "another-user" } }; + await assert.rejects(() => client.send(draft), /account changed/); + assert.equal(calls, 1); +}); +test("send errors never leak provider contents or silently retry", async () => { + let calls = 0; + const client = createClient({ tokens: async () => session, request: async () => { calls++; throw new Error("PRIVATE_BODY_AND_TOKEN"); } }); + await assert.rejects(() => client.send(draft), { message: "Delivery unconfirmed. Refresh Sent before resending." }); + assert.equal(calls, 1); +}); +test("authentication and HTTP failures remain actionable without raw responses", async () => { + const unauth = createClient({ tokens: async () => { throw new Error("SECRET"); } }); + await assert.rejects(() => unauth.inbox(), /ac-login/); + for (const status of [401, 403, 404, 500]) { + const client = createClient({ tokens: async () => session, request: async () => ({ ok: false, status, text: () => { throw new Error("Never read raw error body"); } }) }); + await assert.rejects(() => client.send(draft), (e) => !e.message.includes("SECRET")); + } +}); +test("rejects API truncation and empty letters before sending", () => { + assert.equal(validateDraft(draft), null); + assert.match(validateDraft({ ...draft, body: "x".repeat(501) }), /500/); + assert.match(validateDraft({ ...draft, subject: "x".repeat(81) }), /80/); + assert.match(validateDraft({ ...draft, to: "" }), /recipient/); + assert.match(validateDraft({ ...draft, body: " " }), /letter/); + assert.match(validateDraft({ ...draft, subject: "a\nb" }), /single lines/); +}); +test("untrusted content cannot emit terminal controls", () => { + assert.ok(!/[\x1b\x07\x9b\u202e]/.test(safe("hi\x1b]52;c;clipboard\x07\x9b31m\u202e"))); + assert.equal(clip("hello\nworld", 7), "hello w"); + assert.equal(width("你好"), 4); + assert.equal(clip("你好x", 3), "你"); + assert.deepEqual(wrap("ab\ncd", 1), ["a", "b", "c", "d"]); + assert.equal(replyTo({ from: "Person", fromEmail: "person@example.invalid" }), "person@example.invalid"); +}); diff --git a/toolchain/mail/install.mjs b/toolchain/mail/install.mjs new file mode 100644 index 0000000000..5f1bd5e217 --- /dev/null +++ b/toolchain/mail/install.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { mkdir, symlink, realpath, chmod } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const bin = join(homedir(), ".local/bin"); +const target = fileURLToPath(new URL("./ac-mail.mjs", import.meta.url)); +await mkdir(bin, { recursive: true }); +await chmod(target, 0o755); +try { await symlink(target, join(bin, "ac-mail")); } +catch (error) { + if (error.code !== "EEXIST" || await realpath(join(bin, "ac-mail")) !== target) { + console.error("An ac-mail command already exists. Nothing was replaced."); + process.exit(1); + } +} +console.log("Installed ~/.local/bin/ac-mail. Run ac-mail (or ac-mail login to sign in)."); diff --git a/toolchain/mail/tui.test.py b/toolchain/mail/tui.test.py new file mode 100644 index 0000000000..2601e030d2 --- /dev/null +++ b/toolchain/mail/tui.test.py @@ -0,0 +1,71 @@ +"""Real-terminal smoke test using synthetic letters only (macOS/Linux).""" +import fcntl +import os +from pathlib import Path +import pty +import select +import signal +import struct +import subprocess +import termios +import time +import unittest + + +class TerminalTest(unittest.TestCase): + def test_read_reply_review_send_resize_and_exit(self): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + child = subprocess.Popen( + ["node", str(Path(__file__).with_name("ac-mail.mjs")), "--demo"], + stdin=slave, stdout=slave, stderr=slave, + ) + os.close(slave) + + def screen(needle): + data = b"" + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if select.select([master], [], [], 0.1)[0]: + try: + data += os.read(master, 65536) + except OSError: + break + if needle.encode() in data: + return data + self.fail(f"Terminal did not display {needle!r}") + + try: + screen("@neighbor") + os.write(master, b"\r") + screen("0 unread") + os.write(master, b"c") + screen("re: Hello") + os.write(master, b"terminal hello\x13") + screen("y send") + # Merely reviewing does not send. Resize while reviewing, then + # return to the draft and confirm its body survives. + fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 12, 36, 0, 0)) + child.send_signal(signal.SIGWINCH) + screen("Send to @friend?") + os.write(master, b"\x1b") + screen("terminal hello") + fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + child.send_signal(signal.SIGWINCH) + screen("Ctrl+S review") + os.write(master, b"\x13") + screen("y send") + os.write(master, b"y") + screen("Demo letter sent. No real delivery.") + os.write(master, b"q") + screen("\x1b[?1049l") + self.assertEqual(child.wait(timeout=3), 0) + finally: + if child.poll() is None: + child.terminate() + child.wait(timeout=3) + os.close(master) + + +if __name__ == "__main__": + unittest.main() -- 2.51.2