From 57784385e7b671093bba65526c57c0dc4d67ab59 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 15:33:52 +0000 Subject: [PATCH 1/3] Add interactive wizard, --test menu, --doctor, single-exe build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns mailpress from a "edit config.local.json by hand + run consent.mjs + install scheduled task" series of manual steps into a single mailpress.exe that walks the operator through setup on first run. What's new: - cli.mjs single entry point routes to wizard/poll/test/doctor/consent - lib/wizard.mjs: 6-step interactive setup (printer picker, OAuth client walkthrough, browser consent, test print, scheduled task install). Every step in try/catch with retry/skip/quit; verifies token matches the configured Gmail account *before* persisting. - lib/test.mjs (--test): post-install menu — print test page, print any file you pick (verifies .docx/.xlsx/.pdf path), switch printer, blast all printers. Persists printer changes back to config. - lib/doctor.mjs (--doctor): independent health checks for config, token, Gmail API, configured printer presence, scheduled task status. - scripts/build.mjs: Node SEA pipeline (esbuild bundle -> SEA blob -> postject inject -> resedit icon + version metadata). - .github/workflows/release.yml: Windows CI build; uploads dist artifacts and publishes mailpress.exe to GitHub Releases on v* tags. - assets/icon.{svg,ico}: app icon (envelope on slant + speed lines) embedded into mailpress.exe. Robustness fixes against the original index.mjs/consent.mjs behavior (based on extra-high-effort code review): - Corrupt token file -> GmailAuthError(fatal=true), not silent retry loop - Fatal GmailAuthError mid-batch re-thrown (no notification spam) - Auth failure exit code preserved at 2 (matches original semantics) - PowerShell BOM stripped from Get-Printer JSON output - Print job timeout (default 2min/file) so hung spooler can't hang poll - Filenames passed via | delimiter (illegal in Win filenames) not , - Scheduled Task: Stop before Unregister; drop the spurious -Argument quoting - Wizard tolerates corrupt config.local.json and rewrites it - installRoot() heuristic tightened so SEA exe with .mjs argv[1] doesn't resolve install root to a foreign dir - OAuth port picker: real-bind-and-fallback (no TOCTOU probe race) - openBrowser uses spawn argv (no shell metachar interpretation of URL) - ASCII fallback for box-drawing chars on cmd.exe-style consoles Index.mjs and consent.mjs preserved as thin shims so the existing Scheduled Task and any muscle memory keep working. https://claude.ai/code/session_01CfsRS2PZ4t8RUnhPdVEWHF --- .github/workflows/release.yml | 54 ++++++ .gitignore | 2 + README.md | 120 ++++++++++++ assets/icon-preview-256.png | Bin 0 -> 7042 bytes assets/icon.ico | Bin 0 -> 372526 bytes assets/icon.svg | 22 +++ cli.mjs | 161 ++++++++++++++++ consent.mjs | 149 ++++----------- index.mjs | 342 ++++------------------------------ lib/config.mjs | 112 +++++++++++ lib/doctor.mjs | 125 +++++++++++++ lib/gmail.mjs | 226 ++++++++++++++++++++++ lib/log.mjs | 93 +++++++++ lib/oauth.mjs | 186 ++++++++++++++++++ lib/poll.mjs | 123 ++++++++++++ lib/printer.mjs | 188 +++++++++++++++++++ lib/prompt.mjs | 63 +++++++ lib/task.mjs | 115 ++++++++++++ lib/test.mjs | 137 ++++++++++++++ lib/wizard.mjs | 282 ++++++++++++++++++++++++++++ package.json | 19 +- print-files.ps1 | 13 +- scripts/build.mjs | 198 ++++++++++++++++++++ 23 files changed, 2303 insertions(+), 427 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 README.md create mode 100644 assets/icon-preview-256.png create mode 100644 assets/icon.ico create mode 100644 assets/icon.svg create mode 100644 cli.mjs create mode 100644 lib/config.mjs create mode 100644 lib/doctor.mjs create mode 100644 lib/gmail.mjs create mode 100644 lib/log.mjs create mode 100644 lib/oauth.mjs create mode 100644 lib/poll.mjs create mode 100644 lib/printer.mjs create mode 100644 lib/prompt.mjs create mode 100644 lib/task.mjs create mode 100644 lib/test.mjs create mode 100644 lib/wizard.mjs create mode 100644 scripts/build.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1804b90 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: build mailpress.exe + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build: + name: build on windows + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: install build deps + # No lockfile yet; install pinned devDependencies so the build uses + # the versions in package.json, not whatever stale npx cache exists. + run: npm install --no-save esbuild@^0.25.0 postject@^1.0.0-alpha.6 resedit@^3.0.0 + + - name: build mailpress.exe + run: node scripts/build.mjs + + - name: upload artifact + uses: actions/upload-artifact@v4 + with: + name: mailpress-windows-x64 + path: | + dist/mailpress.exe + dist/print-files.ps1 + dist/install-task.ps1 + dist/config.example.json + + - name: release (on tag) + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: | + dist/mailpress.exe + dist/print-files.ps1 + dist/install-task.ps1 + dist/config.example.json + draft: false + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 58904ec..63985be 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ config.local.json .local-token.json spool/ mailpress.log +mailpress-setup.log *.log +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd857d5 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# mailpress + +Polls a Gmail inbox and prints every new email's body + attachments on a +wired Windows printer. Runs as a Scheduled Task on the office PC. + +## Install (the easy way) + +1. Download `mailpress.exe`, `print-files.ps1`, `install-task.ps1`, and + `config.example.json` from the latest [GitHub Release][releases]. Put + them all in the same folder, e.g. `C:\mailpress\`. +2. Double-click `mailpress.exe`. The setup wizard runs the first time + there's no config: it lists installed printers, walks you through the + Google OAuth client creation, runs the consent flow in your browser, + does a test print, and installs itself as a Scheduled Task. +3. Done. Send a test email to the office inbox and confirm it prints. + +[releases]: https://github.com/turetsky/mailpress/releases + +The only step Google does NOT let an installer automate is creating the +OAuth client itself — the wizard prints the exact links and steps. + +## Install from source (no .exe) + +``` +git clone https://github.com/turetsky/mailpress.git +cd mailpress +node cli.mjs # launches the wizard if no config +``` + +## Commands + +``` +mailpress # poll forever (or run wizard if not configured) +mailpress --setup # re-run the interactive setup wizard +mailpress --test # interactive: switch printer, test print any file +mailpress --once # process current unread and exit +mailpress --doctor # run health checks and report what's broken +mailpress --consent # just re-do the OAuth consent (token died) +mailpress --uninstall # remove the Scheduled Task +mailpress --help +``` + +`--test` opens an interactive menu where you can: +- print the mailpress test page +- print any file you specify (drop in a path to a .docx / .xlsx / .pdf to + verify Word/Excel/PDF conversion works through the Print verb) +- switch the active printer (saves to config.local.json) +- send the test page to every installed printer at once + +Useful when the printer changes, a driver flakes, or you just want to +confirm the spool still works without sending real email. + +Exit codes: +- `0` — success (or `--once` completed) +- `1` — generic failure (see `mailpress.log`) +- `2` — fatal auth failure; re-run `mailpress --consent` or `--setup` + +Set `MAILPRESS_DEBUG=1` for verbose logs. Set `MAILPRESS_HOME=` to +override where config + token + logs live (defaults to the exe's folder). + +## Troubleshooting + +The wizard writes a detailed log to `mailpress-setup.log` next to the +exe. The polling loop writes `mailpress.log`. Both are gitignored. If +something breaks, run `mailpress --doctor` first — it identifies which +subsystem failed (config, token, Gmail API, printer presence, scheduled +task) and what to do about each. + +Common cases: + +- **"refresh token failed / invalid_grant"** — token expired or was + revoked. Run `mailpress --consent`. +- **"printer not found"** — the printer name changed in Windows. Run + `mailpress --setup` and pick it again. +- **"This app isn't verified"** in the browser — expected for an + un-published OAuth client. Click Advanced → Go to (unsafe). + Add your Gmail as a Test user on the OAuth consent screen. +- **Refresh token missing on consent** — Google only returns it on + first consent per client. Revoke at + and re-run setup. + +## Build + +``` +node scripts/build.mjs +``` + +Produces `dist/mailpress.exe` (or `dist/mailpress` on non-Windows) using +Node's [Single Executable Applications][sea]. Needs Node >= 20.12. +CI builds on every push and publishes the exe to GitHub Releases on +version tags (`v*`). + +[sea]: https://nodejs.org/api/single-executable-applications.html + +## Architecture + +``` +cli.mjs # entry point — arg parsing, routing, top-level catch +lib/ + config.mjs # load/save/validate config.local.json + log.mjs # leveled console + file logger + prompt.mjs # readline wrapper (ask/confirm/choose) + gmail.mjs # OAuth refresh + Gmail API client + MIME helpers + printer.mjs # Windows printer enumeration + printing + test print + oauth.mjs # interactive OAuth consent flow + task.mjs # Windows Scheduled Task install/uninstall + poll.mjs # main polling loop + doctor.mjs # diagnostic mode + wizard.mjs # first-run interactive setup + test.mjs # post-install test menu (--test) +index.mjs # back-compat shim → poll.mjs +consent.mjs # back-compat shim → oauth.mjs +print-files.ps1 # PowerShell helper: print one or more files +install-task.ps1 # legacy: install Scheduled Task from source +assets/ + icon.svg # source icon (envelope on slant + speed lines) + icon.ico # multi-size .ico embedded into mailpress.exe +scripts/build.mjs # Node SEA build pipeline (icon embed via resedit) +.github/workflows/release.yml # CI builds + tag releases +``` diff --git a/assets/icon-preview-256.png b/assets/icon-preview-256.png new file mode 100644 index 0000000000000000000000000000000000000000..2c0a5ccf8003e35e69f8c08c8cad7227d8db7830 GIT binary patch literal 7042 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 literal 0 HcmV?d00001 diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c7ee76150c861b39000b9fe9b8445da8f9536f29 GIT binary patch literal 372526 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 + + + + + + + + + + + + + + + + + + diff --git a/cli.mjs b/cli.mjs new file mode 100644 index 0000000..a8f2155 --- /dev/null +++ b/cli.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// mailpress entry point. Handles arg parsing and routes to the wizard, +// poll loop, or doctor. Single top-level try/catch so unexpected errors +// land in the log instead of crashing silently. + +import { createLogger } from "./lib/log.mjs"; +import { loadConfig, validateConfig, resolveInRoot, configPath } from "./lib/config.mjs"; + +const USAGE = `\ +mailpress — Gmail-to-printer relay + +Usage: + mailpress run setup wizard if not configured, otherwise poll + mailpress --setup (re)run the interactive setup wizard + mailpress --test interactive: test print, switch printer, etc. + mailpress --once process current unread messages and exit + mailpress --doctor run diagnostic checks and exit + mailpress --consent just re-do the OAuth consent flow + mailpress --uninstall remove the Windows scheduled task + mailpress --help this message + mailpress --version print version and exit + +Environment: + MAILPRESS_HOME override the install root (where config + logs live) +`; + +function parseArgs(argv) { + const flags = new Set(argv.slice(2)); + if (flags.has("--help") || flags.has("-h")) return { cmd: "help" }; + if (flags.has("--version") || flags.has("-v")) return { cmd: "version" }; + if (flags.has("--setup")) return { cmd: "setup" }; + if (flags.has("--doctor")) return { cmd: "doctor" }; + if (flags.has("--consent")) return { cmd: "consent" }; + if (flags.has("--uninstall")) return { cmd: "uninstall" }; + if (flags.has("--test")) return { cmd: "test" }; + if (flags.has("--once")) return { cmd: "once" }; + return { cmd: "auto" }; +} + +async function main() { + const args = parseArgs(process.argv); + + if (args.cmd === "help") { + process.stdout.write(USAGE); + return 0; + } + if (args.cmd === "version") { + process.stdout.write("mailpress 0.2.0\n"); + return 0; + } + + // Load config first so we know which log file to write to. Wizard mode + // gets its own setup log. Poll mode gets the main log. + let cfg = null; + try { cfg = loadConfig(); } catch (e) { + // Config corrupt — wizard can repair, others should fail loudly. + if (args.cmd !== "setup" && args.cmd !== "doctor" && args.cmd !== "auto") { + process.stderr.write(`config error: ${e.message}\n`); + return 2; + } + } + + const isWizard = args.cmd === "setup" || args.cmd === "consent" || (args.cmd === "auto" && !cfg); + const logPath = isWizard + ? resolveInRoot(cfg?.setupLogPath || "./mailpress-setup.log") + : resolveInRoot(cfg?.logPath || "./mailpress.log"); + const log = createLogger({ filePath: logPath, minLevel: process.env.MAILPRESS_DEBUG ? "DEBUG" : "INFO", pretty: true }); + + log.debug(`mailpress cli: cmd=${args.cmd} platform=${process.platform} node=${process.versions.node}`); + log.debug(`config path: ${configPath()}`); + log.debug(`log path: ${logPath}`); + + try { + switch (args.cmd) { + case "setup": { + const { runWizard } = await import("./lib/wizard.mjs"); + await runWizard({ log }); + return 0; + } + case "consent": { + const cfgNow = loadConfig(); + const errs = validateConfig(cfgNow); + if (errs.length) { + log.error("config invalid:", errs.join("; ")); + log.say("Run `mailpress --setup` first."); + return 2; + } + const { runConsent } = await import("./lib/oauth.mjs"); + const { GmailClient } = await import("./lib/gmail.mjs"); + log.heading("OAuth consent"); + const tok = await runConsent({ + clientId: cfgNow.googleClientId, + clientSecret: cfgNow.googleClientSecret, + gmailAddress: cfgNow.gmailAddress, + log, + }); + new GmailClient(cfgNow, { log }).saveToken(tok); + log.success("token saved"); + return 0; + } + case "doctor": { + const { runDoctor } = await import("./lib/doctor.mjs"); + const { failed } = await runDoctor({ log }); + return failed === 0 ? 0 : 1; + } + case "uninstall": { + const { uninstallTask } = await import("./lib/task.mjs"); + const out = await uninstallTask(); + log.success(`scheduled task: ${out}`); + return 0; + } + case "test": { + const { runTest } = await import("./lib/test.mjs"); + return await runTest({ log }); + } + case "once": + case "auto": { + const { GmailClient } = await import("./lib/gmail.mjs"); + const needsWizard = + !cfg || + validateConfig(cfg).length > 0 || + !new GmailClient(cfg, { log }).hasToken(); + if (needsWizard) { + const { runWizard } = await import("./lib/wizard.mjs"); + await runWizard({ log }); + try { cfg = loadConfig(); } + catch (e) { + log.error("post-wizard config still unreadable:", e.message); + return 2; + } + if (!cfg || validateConfig(cfg).length > 0) { + log.error("setup did not complete; aborting"); + return 2; + } + } + const { runPoll } = await import("./lib/poll.mjs"); + await runPoll(cfg, { log, runOnce: args.cmd === "once" }); + return 0; + } + default: + process.stdout.write(USAGE); + return 1; + } + } catch (e) { + log.fatal(e); + process.stderr.write(`\nFatal: ${e.message}\nSee log: ${logPath}\n`); + // Exit 2 means "re-run --setup or --consent"; preserved from the original + // index.mjs so Scheduled Task / monitoring scripts can distinguish. + const { GmailAuthError } = await import("./lib/gmail.mjs"); + if (e instanceof GmailAuthError && e.fatal) return 2; + return 1; + } +} + +main().then( + (code) => process.exit(code ?? 0), + (e) => { + process.stderr.write(`Unhandled: ${e.stack || e.message}\n`); + process.exit(1); + }, +); diff --git a/consent.mjs b/consent.mjs index 82a581b..53e5aac 100644 --- a/consent.mjs +++ b/consent.mjs @@ -1,120 +1,39 @@ #!/usr/bin/env node -// One-shot OAuth consent flow to mint a refresh token for the office Gmail. -// -// Usage: -// 1. cp config.example.json config.local.json -// 2. Fill in googleClientId + googleClientSecret + gmailAddress. -// (Reuse the existing client from /home/yaakov/code/gauth/.local-credentials.json -// — same client works for any Gmail account, you just sign in as the office one.) -// 3. node consent.mjs -// 4. Browser opens → sign in as the OFFICE Gmail account → grant gmail.modify. -// 5. Refresh token is written to ./.local-token.json (gitignored). -// -// Only needs to be done once. If the token ever dies (password change, manual -// revocation, 6-month dormancy) re-run this script. - -import { createServer } from "node:http"; -import { readFileSync, writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; -import { exec } from "node:child_process"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = resolve(HERE, "config.local.json"); - -let config; -try { - config = JSON.parse(readFileSync(CONFIG_PATH, "utf8")); -} catch (e) { - console.error(`Cannot read ${CONFIG_PATH}: ${e.message}`); - console.error("Copy config.example.json -> config.local.json and fill it in first."); - process.exit(1); +// Thin shim that runs the OAuth consent flow against config.local.json. +// Equivalent to `node cli.mjs --consent`. Kept so existing docs/muscle +// memory still work. + +import { createLogger } from "./lib/log.mjs"; +import { loadConfig, validateConfig, resolveInRoot } from "./lib/config.mjs"; +import { runConsent } from "./lib/oauth.mjs"; +import { GmailClient } from "./lib/gmail.mjs"; + +async function main() { + const cfg = loadConfig(); + const errors = cfg ? validateConfig(cfg) : ["config.local.json is missing"]; + if (errors.length) { + console.error("Config errors:"); + for (const e of errors) console.error(" -", e); + console.error("\nRun `node cli.mjs --setup` to launch the setup wizard."); + process.exit(2); + } + const log = createLogger({ + filePath: resolveInRoot(cfg.setupLogPath || "./mailpress-setup.log"), + minLevel: "INFO", + pretty: true, + }); + log.heading("OAuth consent"); + const tok = await runConsent({ + clientId: cfg.googleClientId, + clientSecret: cfg.googleClientSecret, + gmailAddress: cfg.gmailAddress, + log, + }); + new GmailClient(cfg, { log }).saveToken(tok); + log.success("token saved"); } -if (!config.googleClientId || config.googleClientId.startsWith("FILL_IN")) { - console.error("googleClientId is not set in config.local.json"); +main().catch((e) => { + console.error("FATAL", e.stack || e.message); process.exit(1); -} - -const TOKEN_PATH = resolve(HERE, config.tokenPath || "./.local-token.json"); - -// gmail.modify covers: read messages + remove UNREAD label. That's all we need. -const SCOPES = ["https://www.googleapis.com/auth/gmail.modify"].join(" "); - -const PORT = 8765; -const REDIRECT_URI = `http://localhost:${PORT}`; - -const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); -authUrl.searchParams.set("client_id", config.googleClientId); -authUrl.searchParams.set("redirect_uri", REDIRECT_URI); -authUrl.searchParams.set("response_type", "code"); -authUrl.searchParams.set("scope", SCOPES); -authUrl.searchParams.set("access_type", "offline"); -authUrl.searchParams.set("prompt", "consent"); -if (config.gmailAddress && !config.gmailAddress.startsWith("FILL_IN")) { - authUrl.searchParams.set("login_hint", config.gmailAddress); -} - -console.log("\n=== STEP 1: open this URL in your browser ===\n"); -console.log(authUrl.toString()); -console.log(`\n=== STEP 2: sign in as ${config.gmailAddress || "the OFFICE Gmail account"} ===`); -console.log(" - If 'This app isn't verified' → Advanced → Go to (unsafe)."); -console.log(" - Grant Gmail modify permission."); -console.log(`\nWaiting for redirect on http://localhost:${PORT} ...\n`); - -// Best-effort: try to auto-open the browser. Works on macOS/Linux/Windows. -const opener = process.platform === "darwin" ? "open" - : process.platform === "win32" ? "start \"\"" - : "xdg-open"; -exec(`${opener} "${authUrl.toString()}"`, () => { /* ignore failures */ }); - -const server = createServer(async (req, res) => { - const u = new URL(req.url, REDIRECT_URI); - if (!u.searchParams.has("code") && !u.searchParams.has("error")) { - res.writeHead(404).end("not the redirect"); - return; - } - if (u.searchParams.has("error")) { - const err = u.searchParams.get("error"); - res.writeHead(200, { "content-type": "text/plain" }) - .end(`oauth error: ${err}\nyou can close this tab.`); - console.error("oauth error:", err); - server.close(); - process.exit(1); - } - const code = u.searchParams.get("code"); - res.writeHead(200, { "content-type": "text/plain" }) - .end("success — you can close this tab and return to the terminal."); - - const tokenRes = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - code, - client_id: config.googleClientId, - client_secret: config.googleClientSecret, - redirect_uri: REDIRECT_URI, - grant_type: "authorization_code", - }), - }); - if (!tokenRes.ok) { - console.error("token exchange failed:", tokenRes.status, await tokenRes.text()); - server.close(); - process.exit(1); - } - const tok = await tokenRes.json(); - if (!tok.refresh_token) { - console.error("No refresh_token in response — Google only returns one on first consent."); - console.error("Revoke the app at https://myaccount.google.com/permissions then re-run."); - server.close(); - process.exit(1); - } - writeFileSync(TOKEN_PATH, JSON.stringify(tok, null, 2)); - console.log("\n=== SUCCESS ==="); - console.log("Refresh token saved to:", TOKEN_PATH); - console.log("Scopes granted:", tok.scope); - console.log("\nNext: run `npm start` (or `node index.mjs`) on the office PC."); - server.close(); }); - -server.listen(PORT); diff --git a/index.mjs b/index.mjs index a1226e0..74f4579 100644 --- a/index.mjs +++ b/index.mjs @@ -1,321 +1,49 @@ #!/usr/bin/env node -// mailpress — poll a Gmail inbox, print each new message body + attachments -// on a Windows-attached printer, mark printed messages as read. -// -// Usage: -// node index.mjs # run forever, poll on config.pollIntervalMs -// node index.mjs --once # process current unread, exit (useful for testing) -// -// Prereqs: -// - config.local.json filled in (see config.example.json) -// - .local-token.json present (run `node consent.mjs` once) -// - Office PC with the printer wired up + MS Office (or LibreOffice) installed -// - This script run on Windows (PowerShell is invoked for the print step) +// Thin shim that delegates to lib/poll. Keeps `node index.mjs` and the +// existing Scheduled Task working. Exit codes match the original: +// 0 = clean exit (--once only) +// 1 = generic failure +// 2 = auth failure (re-run setup/consent) -import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, appendFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve, join } from "node:path"; -import { spawn } from "node:child_process"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = resolve(HERE, "config.local.json"); - -const config = JSON.parse(readFileSync(CONFIG_PATH, "utf8")); -const TOKEN_PATH = resolve(HERE, config.tokenPath || "./.local-token.json"); -const SPOOL_DIR = resolve(HERE, config.spoolDir || "./spool"); -const LOG_PATH = resolve(HERE, config.logPath || "./mailpress.log"); -const POLL_MS = config.pollIntervalMs ?? 300_000; -const MAX_BYTES = config.maxAttachmentBytes ?? 25 * 1024 * 1024; -const PRINT_PS1 = resolve(HERE, "print-files.ps1"); - -if (!existsSync(SPOOL_DIR)) mkdirSync(SPOOL_DIR, { recursive: true }); +import { createLogger } from "./lib/log.mjs"; +import { loadConfig, validateConfig, resolveInRoot } from "./lib/config.mjs"; +import { runPoll } from "./lib/poll.mjs"; +import { GmailAuthError } from "./lib/gmail.mjs"; const runOnce = process.argv.includes("--once"); -function log(level, ...parts) { - const line = `${new Date().toISOString()} ${level} ${parts.join(" ")}`; - console.log(line); - try { appendFileSync(LOG_PATH, line + "\n"); } catch { /* best-effort */ } -} - -// ---------- OAuth: refresh access token ---------- - -let cachedAccessToken = null; -let cachedAccessExpiry = 0; - -async function getAccessToken() { - if (cachedAccessToken && Date.now() < cachedAccessExpiry - 30_000) { - return cachedAccessToken; - } - const tok = JSON.parse(readFileSync(TOKEN_PATH, "utf8")); - if (!tok.refresh_token) throw new Error("No refresh_token in token file. Re-run consent.mjs."); - const res = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: config.googleClientId, - client_secret: config.googleClientSecret, - refresh_token: tok.refresh_token, - grant_type: "refresh_token", - }), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`refresh token failed ${res.status}: ${text}`); - } - const j = await res.json(); - cachedAccessToken = j.access_token; - cachedAccessExpiry = Date.now() + (j.expires_in * 1000); - return cachedAccessToken; -} - -// ---------- Gmail helpers ---------- - -async function gmail(path, init = {}) { - const token = await getAccessToken(); - const url = `https://gmail.googleapis.com/gmail/v1/users/me${path}`; - const res = await fetch(url, { - ...init, - headers: { - ...(init.headers || {}), - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - if (!res.ok) throw new Error(`gmail ${path}: ${res.status} ${await res.text()}`); - return res.json(); -} - -function headerVal(headers, name) { - const h = headers?.find((x) => x.name.toLowerCase() === name.toLowerCase()); - return h ? h.value : ""; -} - -// Decode Gmail's url-safe base64 to a Buffer. -function b64urlToBuffer(s) { - if (!s) return Buffer.alloc(0); - const std = s.replace(/-/g, "+").replace(/_/g, "/"); - const pad = std.length % 4 ? "=".repeat(4 - (std.length % 4)) : ""; - return Buffer.from(std + pad, "base64"); -} - -// Walk the MIME tree, returning { bodyText, bodyHtml, attachments[] }. -// attachments: [{ filename, mimeType, attachmentId, size }] -function walkMime(payload) { - let bodyText = ""; - let bodyHtml = ""; - const attachments = []; - - function walk(part) { - if (!part) return; - const filename = part.filename || ""; - const mimeType = part.mimeType || ""; - const body = part.body || {}; - const disposition = (headerVal(part.headers, "content-disposition") || "").toLowerCase(); - const isAttachment = !!filename && (body.attachmentId || disposition.startsWith("attachment")); - - if (isAttachment) { - attachments.push({ - filename, - mimeType, - attachmentId: body.attachmentId, - size: body.size || 0, - }); - } else if (mimeType === "text/plain" && body.data && !bodyText) { - bodyText = b64urlToBuffer(body.data).toString("utf8"); - } else if (mimeType === "text/html" && body.data && !bodyHtml) { - bodyHtml = b64urlToBuffer(body.data).toString("utf8"); - } - - if (Array.isArray(part.parts)) { - for (const sub of part.parts) walk(sub); - } - } - walk(payload); - return { bodyText, bodyHtml, attachments }; -} - -// Very rough HTML -> text fallback when an email is HTML-only. -function htmlToText(html) { - return html - .replace(//gi, "") - .replace(//gi, "") - .replace(//gi, "\n") - .replace(/<\/p>/gi, "\n\n") - .replace(/<[^>]+>/g, "") - .replace(/ /gi, " ") - .replace(/&/gi, "&") - .replace(/</gi, "<") - .replace(/>/gi, ">") - .replace(/"/gi, '"') - .replace(/'/gi, "'") - .replace(/\n{3,}/g, "\n\n") - .trim(); -} - -// Sanitize a filename for the filesystem. -function safeName(name, fallback = "attachment") { - const cleaned = (name || "").replace(/[\\/:*?"<>|\r\n]+/g, "_").trim(); - return cleaned || fallback; -} - -// ---------- Print step (PowerShell shell-out) ---------- - -function printFiles(files) { - return new Promise((resolveP, rejectP) => { - const args = [ - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", PRINT_PS1, - "-PrinterName", config.printerName, - "-Files", files.join(","), - "-SleepMs", String(config.perPrintSleepMs ?? 3000), - ]; - const ps = spawn("powershell.exe", args, { stdio: ["ignore", "pipe", "pipe"] }); - let stdout = "", stderr = ""; - ps.stdout.on("data", (d) => { stdout += d.toString(); }); - ps.stderr.on("data", (d) => { stderr += d.toString(); }); - ps.on("close", (code) => { - if (stdout) log("INFO", "powershell:", stdout.trim().replace(/\n/g, " | ")); - if (stderr) log("WARN", "powershell stderr:", stderr.trim().replace(/\n/g, " | ")); - if (code === 0) resolveP(); - else rejectP(new Error(`print-files.ps1 exited ${code}`)); - }); - ps.on("error", rejectP); - }); -} - -// ---------- Notify on failure ---------- - -async function sendNotify(subject, body) { - if (!config.notifyEmail || !config.notifyFromAddress) return; +async function main() { + let cfg; try { - const raw = [ - `From: ${config.notifyFromAddress}`, - `To: ${config.notifyEmail}`, - `Subject: [mailpress] ${subject}`, - `MIME-Version: 1.0`, - `Content-Type: text/plain; charset=UTF-8`, - ``, - body, - ].join("\r\n"); - const b64 = Buffer.from(raw, "utf8").toString("base64") - .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); - await gmail("/messages/send", { - method: "POST", - body: JSON.stringify({ raw: b64 }), - }); - log("INFO", "notify sent:", subject); + cfg = loadConfig(); } catch (e) { - log("WARN", "notify send failed:", e.message); - } -} - -// ---------- Per-message processing ---------- - -async function processMessage(id) { - const msg = await gmail(`/messages/${id}?format=full`); - const headers = msg.payload?.headers || []; - const subject = headerVal(headers, "Subject") || "(no subject)"; - const from = headerVal(headers, "From") || "(unknown)"; - const date = headerVal(headers, "Date") || ""; - log("INFO", `msg ${id} | from=${from} | subj=${subject}`); - - const { bodyText, bodyHtml, attachments } = walkMime(msg.payload); - - const msgDir = join(SPOOL_DIR, id); - mkdirSync(msgDir, { recursive: true }); - const filesToPrint = []; - - // ---- Body: write as .txt and print via Notepad /p ---- - const bodyHeader = - `From: ${from}\r\n` + - `Date: ${date}\r\n` + - `Subject: ${subject}\r\n` + - `${"-".repeat(70)}\r\n\r\n`; - const bodyContent = (bodyText && bodyText.trim()) - ? bodyText - : (bodyHtml ? htmlToText(bodyHtml) : "(no body)"); - const bodyPath = join(msgDir, "body.txt"); - writeFileSync(bodyPath, bodyHeader + bodyContent, "utf8"); - filesToPrint.push(bodyPath); - - // ---- Attachments ---- - for (let i = 0; i < attachments.length; i++) { - const a = attachments[i]; - if (!a.attachmentId) { - log("WARN", `msg ${id}: attachment ${a.filename} has no attachmentId, skip`); - continue; - } - if (a.size > MAX_BYTES) { - log("WARN", `msg ${id}: attachment ${a.filename} too big (${a.size}b), skip`); - continue; - } - const data = await gmail(`/messages/${id}/attachments/${a.attachmentId}`); - const buf = b64urlToBuffer(data.data); - const filename = `${String(i + 1).padStart(2, "0")}_${safeName(a.filename)}`; - const filePath = join(msgDir, filename); - writeFileSync(filePath, buf); - filesToPrint.push(filePath); - log("INFO", `msg ${id}: staged ${filename} (${buf.length}b, ${a.mimeType})`); - } - - // ---- Send to printer ---- - await printFiles(filesToPrint); - - // ---- Mark as read (remove UNREAD label) ---- - await gmail(`/messages/${id}/modify`, { - method: "POST", - body: JSON.stringify({ removeLabelIds: ["UNREAD"] }), + console.error("Config error:", e.message); + console.error("Run `node cli.mjs --setup` to repair."); + process.exit(2); + } + const errors = cfg ? validateConfig(cfg) : ["config.local.json is missing"]; + if (errors.length) { + console.error("Config errors:"); + for (const e of errors) console.error(" -", e); + console.error("\nRun `node cli.mjs --setup` to launch the setup wizard."); + process.exit(2); + } + const log = createLogger({ + filePath: resolveInRoot(cfg.logPath || "./mailpress.log"), + minLevel: process.env.MAILPRESS_DEBUG ? "DEBUG" : "INFO", + pretty: true, }); - - // ---- Cleanup spool ---- - try { rmSync(msgDir, { recursive: true, force: true }); } - catch (e) { log("WARN", `cleanup ${msgDir}: ${e.message}`); } - - log("INFO", `msg ${id}: printed ${filesToPrint.length} file(s), marked read`); -} - -// ---------- Main poll loop ---------- - -async function tick() { - const q = encodeURIComponent(config.gmailQuery || "in:inbox category:primary is:unread"); - const list = await gmail(`/messages?q=${q}&maxResults=25`); - const ids = (list.messages || []).map((m) => m.id); - if (ids.length === 0) { - log("DEBUG", "no new messages"); - return; - } - log("INFO", `found ${ids.length} new message(s)`); - for (const id of ids) { - try { - await processMessage(id); - } catch (e) { - log("ERROR", `msg ${id} failed: ${e.message}`); - await sendNotify(`error processing message ${id}`, `${e.message}\n\n${e.stack || ""}`); - } - } -} - -async function main() { - log("INFO", `mailpress starting; query="${config.gmailQuery}"; poll=${POLL_MS}ms; printer="${config.printerName}"`); - while (true) { - try { - await tick(); - } catch (e) { - log("ERROR", `tick failed: ${e.message}`); - // Auth failures get a notification so user knows to re-run consent. - if (/refresh token failed|invalid_grant/i.test(e.message)) { - await sendNotify("auth failure — re-run consent.mjs", e.message); - process.exit(2); - } - } - if (runOnce) break; - await new Promise((r) => setTimeout(r, POLL_MS)); + try { + await runPoll(cfg, { log, runOnce }); + } catch (e) { + log.fatal(e); + if (e instanceof GmailAuthError && e.fatal) process.exit(2); + process.exit(1); } } main().catch((e) => { - log("FATAL", e.stack || e.message); + // Pre-logger failures only. + console.error("FATAL", e.stack || e.message); process.exit(1); }); diff --git a/lib/config.mjs b/lib/config.mjs new file mode 100644 index 0000000..6c78325 --- /dev/null +++ b/lib/config.mjs @@ -0,0 +1,112 @@ +// Config load/save/validate. Resolves paths against the install root, which +// is the directory containing the exe (or the project root when running +// from source). All other modules go through this so file locations stay +// consistent between dev runs and the bundled exe. + +import { readFileSync, writeFileSync, existsSync, renameSync, chmodSync } from "node:fs"; +import { dirname, resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const DEFAULTS = { + gmailQuery: "in:inbox category:primary is:unread", + pollIntervalMs: 300_000, + tokenPath: "./.local-token.json", + spoolDir: "./spool", + logPath: "./mailpress.log", + setupLogPath: "./mailpress-setup.log", + maxAttachmentBytes: 26_214_400, + perPrintSleepMs: 3000, +}; + +const REQUIRED_FIELDS = [ + "googleClientId", + "googleClientSecret", + "gmailAddress", + "printerName", +]; + +// Install root: where config.local.json, the spool, logs, and PowerShell +// helpers live. When packaged as a SEA exe, process.execPath points at the +// exe and there is no script arg. When running from source via +// `node /path/to/cli.mjs`, argv[1] is the script. +// +// We distinguish "running from source" by checking that the basename of the +// executable IS a node binary (node, node.exe), not by trusting argv[1] to +// end in .mjs — a SEA binary invoked with `mailpress.exe foo.mjs` would +// otherwise resolve install root to wherever foo.mjs lives. +export function installRoot() { + if (process.env.MAILPRESS_HOME) return resolve(process.env.MAILPRESS_HOME); + const execBase = (process.execPath.split(/[\\\/]/).pop() || "").toLowerCase(); + const isNode = execBase === "node" || execBase === "node.exe"; + const argv1 = process.argv[1]; + if (isNode && argv1 && (argv1.endsWith(".mjs") || argv1.endsWith(".js") || argv1.endsWith(".cjs"))) { + return dirname(resolve(argv1)); + } + return dirname(process.execPath); +} + +export function configPath() { + return join(installRoot(), "config.local.json"); +} + +export function examplePath() { + // The example ships beside the source files. When running from a SEA exe, + // it may not be on disk — callers should handle ENOENT. + return join(installRoot(), "config.example.json"); +} + +export function resolveInRoot(p) { + return resolve(installRoot(), p); +} + +export function loadConfig() { + const path = configPath(); + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8"); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error(`config.local.json is not valid JSON: ${e.message}`); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`config.local.json must be a JSON object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`); + } + return { ...DEFAULTS, ...parsed }; +} + +export function saveConfig(cfg) { + const path = configPath(); + const tmp = path + ".tmp"; + // Strip our private comment field if it's still there from the example. + const { _comment, ...clean } = cfg; + writeFileSync(tmp, JSON.stringify(clean, null, 2), "utf8"); + // 0600 — config has the OAuth client secret in it. + try { chmodSync(tmp, 0o600); } catch { /* Windows: ignore */ } + renameSync(tmp, path); +} + +export function validateConfig(cfg) { + const errors = []; + if (!cfg || typeof cfg !== "object") { + return ["config is missing or unreadable"]; + } + for (const field of REQUIRED_FIELDS) { + const v = cfg[field]; + if (!v || typeof v !== "string" || v.startsWith("FILL_IN")) { + errors.push(`${field} is not set`); + } + } + if (cfg.pollIntervalMs != null && (!Number.isFinite(cfg.pollIntervalMs) || cfg.pollIntervalMs < 10_000)) { + errors.push("pollIntervalMs must be a number >= 10000"); + } + if (cfg.gmailAddress && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cfg.gmailAddress)) { + errors.push(`gmailAddress doesn't look like an email: ${cfg.gmailAddress}`); + } + return errors; +} + +export function isConfigured() { + const cfg = loadConfig(); + return cfg && validateConfig(cfg).length === 0; +} diff --git a/lib/doctor.mjs b/lib/doctor.mjs new file mode 100644 index 0000000..41585b1 --- /dev/null +++ b/lib/doctor.mjs @@ -0,0 +1,125 @@ +// Diagnostic mode. Runs every subsystem check independently and prints a +// pass/fail report. Designed so you can run `mailpress.exe --doctor` and +// see exactly what's broken without re-running the whole wizard. + +import { existsSync } from "node:fs"; +import { loadConfig, validateConfig, configPath, resolveInRoot } from "./config.mjs"; +import { GmailClient } from "./gmail.mjs"; +import { isWindows, listPrinters, printerExists } from "./printer.mjs"; +import { taskExists, TASK_NAME } from "./task.mjs"; + +// A check returns { name, pass, message, fix? }. `fix` is a hint for the +// user about what to do — printed dimly under the failure line. +async function check(name, fn) { + try { + const result = await fn(); + if (result === true || result === undefined) return { name, pass: true }; + if (typeof result === "object") return { name, pass: !!result.pass, ...result }; + return { name, pass: false, message: String(result) }; + } catch (e) { + return { name, pass: false, message: e.message }; + } +} + +export async function runDoctor({ log } = {}) { + const results = []; + + results.push(await check("platform is Windows", () => { + if (isWindows()) return true; + return { pass: false, message: `running on ${process.platform}`, fix: "Run this on the Windows office PC. mailpress relies on PowerShell + Windows printers." }; + })); + + results.push(await check("node version >= 18", () => { + const major = parseInt(process.versions.node.split(".")[0], 10); + if (major >= 18) return true; + return { pass: false, message: `node ${process.versions.node}`, fix: "Install Node.js 18 or newer from https://nodejs.org" }; + })); + + results.push(await check("config.local.json exists", () => { + if (existsSync(configPath())) return true; + return { pass: false, message: `not found: ${configPath()}`, fix: "Run mailpress with no arguments to launch the setup wizard." }; + })); + + const config = (() => { try { return loadConfig(); } catch { return null; } })(); + + results.push(await check("config.local.json is valid", () => { + if (!config) return { pass: false, message: "could not read config" }; + const errors = validateConfig(config); + if (errors.length === 0) return true; + return { pass: false, message: errors.join("; "), fix: "Re-run setup or edit config.local.json by hand." }; + })); + + if (config) { + const tokenPath = resolveInRoot(config.tokenPath || "./.local-token.json"); + results.push(await check("OAuth refresh token exists", () => { + if (existsSync(tokenPath)) return true; + return { pass: false, message: `not found: ${tokenPath}`, fix: "Re-run setup to re-authorize Gmail access." }; + })); + + if (existsSync(tokenPath) && validateConfig(config).length === 0) { + const gmail = new GmailClient(config, { log }); + results.push(await check("Gmail API reachable + token works", async () => { + const prof = await gmail.profile(); + if (prof.emailAddress) { + return { pass: true, message: `as ${prof.emailAddress}` }; + } + return { pass: false, message: "no emailAddress in profile response" }; + })); + + results.push(await check("authenticated as configured gmailAddress", async () => { + const prof = await gmail.profile(); + if (prof.emailAddress?.toLowerCase() === config.gmailAddress?.toLowerCase()) return true; + return { + pass: false, + message: `token belongs to ${prof.emailAddress}, config says ${config.gmailAddress}`, + fix: "Re-run setup and sign in as the correct Gmail account.", + }; + })); + } + + if (isWindows()) { + results.push(await check(`printer "${config.printerName}" exists`, async () => { + const found = await printerExists(config.printerName); + if (found) return true; + const printers = await listPrinters().catch(() => []); + return { + pass: false, + message: `not found among ${printers.length} installed printer(s)`, + fix: `Available: ${printers.map((p) => p.Name).join(", ") || "(none)"}. Re-run setup or update printerName.`, + }; + })); + } + } + + if (isWindows()) { + results.push(await check(`scheduled task "${TASK_NAME}" installed`, async () => { + const exists = await taskExists(); + if (exists) return true; + return { + pass: false, + message: "not installed", + fix: "Re-run setup and accept the 'install scheduled task' step (so mailpress runs at login).", + }; + })); + } + + // Report + log.heading("Diagnostic report"); + let failed = 0; + for (const r of results) { + if (r.pass) { + log.success(`${r.name}${r.message ? " — " + r.message : ""}`); + } else { + failed++; + log.failure(`${r.name} — ${r.message || "failed"}`); + if (r.fix) log.say(" " + r.fix); + } + } + log.say(""); + if (failed === 0) { + log.success(`All ${results.length} checks passed.`); + } else { + log.failure(`${failed} of ${results.length} checks failed.`); + } + return { results, failed }; +} diff --git a/lib/gmail.mjs b/lib/gmail.mjs new file mode 100644 index 0000000..c068e53 --- /dev/null +++ b/lib/gmail.mjs @@ -0,0 +1,226 @@ +// Gmail API client. Owns the access-token refresh cache and the small +// surface of Gmail endpoints we use. MIME parsing helpers live here too so +// the wizard, doctor, and poll loop all share one implementation. + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { resolveInRoot } from "./config.mjs"; + +const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +const API_BASE = "https://gmail.googleapis.com/gmail/v1/users/me"; + +export class GmailAuthError extends Error { + constructor(message, { status, body, fatal = false } = {}) { + super(message); + this.name = "GmailAuthError"; + this.status = status; + this.body = body; + this.fatal = fatal; // true means "user has to re-consent" + } +} + +export class GmailClient { + constructor(config, { log } = {}) { + this.config = config; + this.log = log; + this.tokenPath = resolveInRoot(config.tokenPath || "./.local-token.json"); + this._cachedToken = null; + this._cachedExpiry = 0; + } + + hasToken() { + if (!existsSync(this.tokenPath)) return false; + try { + const t = JSON.parse(readFileSync(this.tokenPath, "utf8")); + return !!t.refresh_token; + } catch { + return false; + } + } + + saveToken(token) { + writeFileSync(this.tokenPath, JSON.stringify(token, null, 2), "utf8"); + } + + loadToken() { + return JSON.parse(readFileSync(this.tokenPath, "utf8")); + } + + async getAccessToken() { + if (this._cachedToken && Date.now() < this._cachedExpiry - 30_000) { + return this._cachedToken; + } + if (!existsSync(this.tokenPath)) { + throw new GmailAuthError(`token file missing: ${this.tokenPath}`, { fatal: true }); + } + let tok; + try { + tok = this.loadToken(); + } catch (e) { + throw new GmailAuthError(`token file is corrupt: ${e.message}`, { fatal: true }); + } + if (!tok || typeof tok !== "object" || !tok.refresh_token) { + throw new GmailAuthError("no refresh_token in token file", { fatal: true }); + } + const res = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: this.config.googleClientId, + client_secret: this.config.googleClientSecret, + refresh_token: tok.refresh_token, + grant_type: "refresh_token", + }), + }); + if (!res.ok) { + const text = await res.text(); + const fatal = res.status === 400 || res.status === 401 || /invalid_grant|invalid_client/i.test(text); + throw new GmailAuthError(`refresh token failed (${res.status}): ${text}`, { + status: res.status, body: text, fatal, + }); + } + const j = await res.json(); + if (typeof j.access_token !== "string" || !j.access_token) { + throw new GmailAuthError(`refresh response missing access_token: ${JSON.stringify(j)}`, { fatal: true }); + } + // Default to 1h if Google ever omits expires_in. A small positive number + // is safer than NaN (which would cause refresh-on-every-call). + const expiresInSec = Number.isFinite(j.expires_in) && j.expires_in > 0 ? j.expires_in : 3600; + this._cachedToken = j.access_token; + this._cachedExpiry = Date.now() + expiresInSec * 1000; + return this._cachedToken; + } + + async request(path, init = {}) { + const token = await this.getAccessToken(); + const res = await fetch(API_BASE + path, { + ...init, + headers: { + ...(init.headers || {}), + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + const body = await res.text(); + const err = new Error(`gmail ${path}: ${res.status} ${body}`); + err.status = res.status; + err.body = body; + throw err; + } + return res.json(); + } + + listMessages(query, maxResults = 25) { + const q = encodeURIComponent(query); + return this.request(`/messages?q=${q}&maxResults=${maxResults}`); + } + + getMessage(id) { + return this.request(`/messages/${id}?format=full`); + } + + getAttachment(messageId, attachmentId) { + return this.request(`/messages/${messageId}/attachments/${attachmentId}`); + } + + markRead(id) { + return this.request(`/messages/${id}/modify`, { + method: "POST", + body: JSON.stringify({ removeLabelIds: ["UNREAD"] }), + }); + } + + // Send a plain-text email via Gmail. Used for failure notifications. + async sendPlain(from, to, subject, body) { + const raw = [ + `From: ${from}`, + `To: ${to}`, + `Subject: ${subject}`, + `MIME-Version: 1.0`, + `Content-Type: text/plain; charset=UTF-8`, + ``, + body, + ].join("\r\n"); + const b64 = Buffer.from(raw, "utf8").toString("base64") + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return this.request("/messages/send", { + method: "POST", + body: JSON.stringify({ raw: b64 }), + }); + } + + // Sanity: get the profile. Cheapest endpoint to verify auth works. + profile() { + return this.request("/profile"); + } +} + +// ---------- MIME helpers (pure functions, no API access) ---------- + +export function headerVal(headers, name) { + const h = headers?.find((x) => x.name.toLowerCase() === name.toLowerCase()); + return h ? h.value : ""; +} + +export function b64urlToBuffer(s) { + if (!s) return Buffer.alloc(0); + const std = s.replace(/-/g, "+").replace(/_/g, "/"); + const pad = std.length % 4 ? "=".repeat(4 - (std.length % 4)) : ""; + return Buffer.from(std + pad, "base64"); +} + +export function walkMime(payload) { + let bodyText = ""; + let bodyHtml = ""; + const attachments = []; + + function walk(part) { + if (!part) return; + const filename = part.filename || ""; + const mimeType = part.mimeType || ""; + const body = part.body || {}; + const disposition = (headerVal(part.headers, "content-disposition") || "").toLowerCase(); + const isAttachment = !!filename && (body.attachmentId || disposition.startsWith("attachment")); + + if (isAttachment) { + attachments.push({ + filename, + mimeType, + attachmentId: body.attachmentId, + size: body.size || 0, + }); + } else if (mimeType === "text/plain" && body.data && !bodyText) { + bodyText = b64urlToBuffer(body.data).toString("utf8"); + } else if (mimeType === "text/html" && body.data && !bodyHtml) { + bodyHtml = b64urlToBuffer(body.data).toString("utf8"); + } + + if (Array.isArray(part.parts)) { + for (const sub of part.parts) walk(sub); + } + } + walk(payload); + return { bodyText, bodyHtml, attachments }; +} + +export function htmlToText(html) { + return html + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n\n") + .replace(/<[^>]+>/g, "") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function safeName(name, fallback = "attachment") { + const cleaned = (name || "").replace(/[\\/:*?"<>|\r\n]+/g, "_").trim(); + return cleaned || fallback; +} diff --git a/lib/log.mjs b/lib/log.mjs new file mode 100644 index 0000000..e30a7cc --- /dev/null +++ b/lib/log.mjs @@ -0,0 +1,93 @@ +// Leveled logger. Writes to console (pretty) and to a log file (structured). +// The setup log is the single source of truth when the wizard misbehaves — +// it captures every step, every command, every error. + +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +const LEVELS = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40, FATAL: 50 }; + +const COLORS = { + DEBUG: "\x1b[90m", + INFO: "\x1b[36m", + WARN: "\x1b[33m", + ERROR: "\x1b[31m", + FATAL: "\x1b[91m", + RESET: "\x1b[0m", + BOLD: "\x1b[1m", + DIM: "\x1b[2m", +}; + +function ts() { + return new Date().toISOString(); +} + +function safeAppend(path, line) { + try { + mkdirSync(dirname(path), { recursive: true }); + appendFileSync(path, line + "\n"); + } catch { + // best-effort: never let logging crash the caller + } +} + +export function createLogger({ filePath, minLevel = "INFO", pretty = true } = {}) { + const min = LEVELS[minLevel] ?? LEVELS.INFO; + const useColor = pretty && process.stdout.isTTY; + + function emit(level, parts) { + const text = parts.map((p) => (p instanceof Error ? (p.stack || p.message) : String(p))).join(" "); + const fileLine = `${ts()} ${level.padEnd(5)} ${text}`; + if (filePath) safeAppend(filePath, fileLine); + if (LEVELS[level] < min) return; + if (useColor) { + const c = COLORS[level] || ""; + console.log(`${c}${level.padEnd(5)}${COLORS.RESET} ${text}`); + } else { + console.log(fileLine); + } + } + + return { + debug: (...a) => emit("DEBUG", a), + info: (...a) => emit("INFO", a), + warn: (...a) => emit("WARN", a), + error: (...a) => emit("ERROR", a), + fatal: (...a) => emit("FATAL", a), + + // Pretty helpers for the wizard. These bypass the level filter and don't + // tag the line with a level — they're for user-facing prose. + say(text) { + if (filePath) safeAppend(filePath, `${ts()} UI ${text}`); + console.log(text); + }, + heading(text) { + // Only use Unicode box-drawing chars when the terminal is modern + // enough to render them. Windows cmd.exe in cp437/cp1252 mangles + // them into mojibake. + const lineChar = useColor ? "─" : "-"; + const line = lineChar.repeat(Math.min(text.length + 2, 60)); + const out = useColor + ? `\n${COLORS.BOLD}${text}${COLORS.RESET}\n${COLORS.DIM}${line}${COLORS.RESET}` + : `\n${text}\n${line}`; + if (filePath) safeAppend(filePath, `${ts()} UI === ${text} ===`); + console.log(out); + }, + success(text) { + const out = useColor ? `${COLORS.INFO}✓${COLORS.RESET} ${text}` : `OK ${text}`; + if (filePath) safeAppend(filePath, `${ts()} UI OK ${text}`); + console.log(out); + }, + failure(text) { + const out = useColor ? `${COLORS.ERROR}✗${COLORS.RESET} ${text}` : `FAIL ${text}`; + if (filePath) safeAppend(filePath, `${ts()} UI FAIL ${text}`); + console.log(out); + }, + setFile(p) { + filePath = p; + }, + file() { + return filePath; + }, + }; +} diff --git a/lib/oauth.mjs b/lib/oauth.mjs new file mode 100644 index 0000000..c664a10 --- /dev/null +++ b/lib/oauth.mjs @@ -0,0 +1,186 @@ +// Interactive OAuth consent flow. Spins up a localhost listener, opens the +// user's browser at Google's auth URL, exchanges the code for a refresh +// token, returns the token object. Caller is responsible for persisting it. + +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; + +const SCOPES = [ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.send", +].join(" "); + +const DEFAULT_PORT = 8765; +const PORT_FALLBACKS = [8766, 8767, 8768, 8769, 0]; // 0 = ephemeral +const TIMEOUT_MS = 10 * 60 * 1000; + +// Bind the OAuth callback server, trying preferred port first then +// fallbacks. Returns the live server bound to a free port. Avoids the +// probe-then-bind TOCTOU race: only one bind per port, and the returned +// server IS the bound one. +function bindServer(handler, preferred) { + const candidates = [preferred, ...PORT_FALLBACKS.filter((p) => p !== preferred)]; + return new Promise((resolveOuter, rejectOuter) => { + let i = 0; + const tryNext = () => { + if (i >= candidates.length) { + rejectOuter(new Error("could not bind any local port for OAuth callback")); + return; + } + const port = candidates[i++]; + const server = createServer(handler); + const onError = () => { + server.removeListener("listening", onListening); + try { server.close(); } catch { /* */ } + tryNext(); + }; + const onListening = () => { + server.removeListener("error", onError); + resolveOuter({ server, port: server.address().port }); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port); + }; + tryNext(); + }); +} + +// Open the user's default browser at `url`. Uses spawn with an argv array +// to avoid any shell-metachar interpretation of the URL (&, %, etc.). +function openBrowser(url) { + try { + if (process.platform === "darwin") { + spawn("open", [url], { detached: true, stdio: "ignore" }).unref(); + } else if (process.platform === "win32") { + // cmd /c start "" "" — the empty quoted string is the window title. + // Using cmd /c with an argv array avoids re-parsing the URL through shell. + spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); + } else { + spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref(); + } + } catch { /* best-effort; user can paste the URL by hand */ } +} + +// runConsent({ clientId, clientSecret, gmailAddress }) -> tokenJson +// Resolves to the raw Google token JSON (refresh_token, access_token, scope, +// expires_in, token_type). Rejects on error/timeout. +export async function runConsent({ clientId, clientSecret, gmailAddress, port = DEFAULT_PORT, log }) { + if (!clientId || !clientSecret) { + throw new Error("clientId and clientSecret are required"); + } + + let resolveOuter, rejectOuter; + const outer = new Promise((res, rej) => { resolveOuter = res; rejectOuter = rej; }); + + let finished = false; + let server, timer, redirectUri; + const finish = (err, value) => { + if (finished) return; + finished = true; + if (timer) clearTimeout(timer); + try { server && server.close(); } catch { /* */ } + err ? rejectOuter(err) : resolveOuter(value); + }; + + const safeWrite = (res, status, headers, body) => { + try { + if (res.headersSent) return; + res.writeHead(status, headers).end(body); + } catch { /* response may already be torn down */ } + }; + + const handler = async (req, res) => { + try { + const u = new URL(req.url, redirectUri); + if (u.pathname === "/favicon.ico") { + safeWrite(res, 404, {}, ""); + return; + } + if (!u.searchParams.has("code") && !u.searchParams.has("error")) { + // Not the redirect — could be a stale browser tab probing the port. + // Reply 404 so it's distinguishable from the real callback page. + safeWrite(res, 404, { "content-type": "text/plain" }, "not the redirect"); + return; + } + if (u.searchParams.has("error")) { + const err = u.searchParams.get("error"); + safeWrite(res, 200, { "content-type": "text/plain" }, + `OAuth error: ${err}\nYou can close this tab.`); + finish(new Error(`OAuth error from Google: ${err}`)); + return; + } + const code = u.searchParams.get("code"); + const tokenRes = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + if (!tokenRes.ok) { + const text = await tokenRes.text(); + safeWrite(res, 200, { "content-type": "text/plain" }, + "Token exchange failed. Return to the terminal for details."); + finish(new Error(`token exchange failed (${tokenRes.status}): ${text}`)); + return; + } + const tok = await tokenRes.json(); + if (!tok.refresh_token) { + safeWrite(res, 200, { "content-type": "text/plain" }, + "Got an access token, but Google didn't return a refresh token.\n" + + "Google only returns refresh_token on first consent. Revoke the app\n" + + "at https://myaccount.google.com/permissions and re-run setup."); + finish(new Error( + "no refresh_token in Google's response. Revoke the app at " + + "https://myaccount.google.com/permissions and re-run setup.", + )); + return; + } + safeWrite(res, 200, { "content-type": "text/html" }, + `` + + `

Done.

You can close this tab and return to the terminal.

` + + ``); + finish(null, tok); + } catch (e) { + safeWrite(res, 500, {}, "internal error"); + finish(e); + } + }; + + const bound = await bindServer(handler, port); + server = bound.server; + redirectUri = `http://localhost:${bound.port}`; + if (log && bound.port !== port) log.debug(`port ${port} busy, using ${bound.port}`); + + // Surface any later server errors as a finish so callers see them. + server.on("error", (e) => finish(e)); + + timer = setTimeout(() => { + finish(new Error(`OAuth timed out after ${TIMEOUT_MS / 60000} minutes`)); + }, TIMEOUT_MS); + + const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + authUrl.searchParams.set("client_id", clientId); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", SCOPES); + authUrl.searchParams.set("access_type", "offline"); + authUrl.searchParams.set("prompt", "consent"); + if (gmailAddress) authUrl.searchParams.set("login_hint", gmailAddress); + + if (log) { + log.say(`Opening browser. If it doesn't open automatically, paste this URL:`); + log.say(" " + authUrl.toString()); + log.say(`Waiting for Google to redirect to ${redirectUri} ...`); + } + openBrowser(authUrl.toString()); + + return outer; +} + +export { SCOPES }; diff --git a/lib/poll.mjs b/lib/poll.mjs new file mode 100644 index 0000000..ce1fbcd --- /dev/null +++ b/lib/poll.mjs @@ -0,0 +1,123 @@ +// The main polling loop. Mirrors the original index.mjs flow but built on +// the shared lib/* modules so it shares OAuth, Gmail, MIME, and printer +// code with the wizard and doctor. + +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { GmailClient, GmailAuthError, walkMime, htmlToText, headerVal, safeName, b64urlToBuffer } from "./gmail.mjs"; + +export { GmailAuthError }; +import { printFiles } from "./printer.mjs"; +import { resolveInRoot } from "./config.mjs"; + +async function sendNotify(gmail, config, subject, body, log) { + if (!config.notifyEmail || !config.notifyFromAddress) return; + try { + await gmail.sendPlain(config.notifyFromAddress, config.notifyEmail, `[mailpress] ${subject}`, body); + log.info("notify sent:", subject); + } catch (e) { + log.warn("notify send failed:", e.message); + } +} + +async function processMessage(gmail, config, log, id) { + const msg = await gmail.getMessage(id); + const headers = msg.payload?.headers || []; + const subject = headerVal(headers, "Subject") || "(no subject)"; + const from = headerVal(headers, "From") || "(unknown)"; + const date = headerVal(headers, "Date") || ""; + log.info(`msg ${id} | from=${from} | subj=${subject}`); + + const { bodyText, bodyHtml, attachments } = walkMime(msg.payload); + + const spoolDir = resolveInRoot(config.spoolDir || "./spool"); + if (!existsSync(spoolDir)) mkdirSync(spoolDir, { recursive: true }); + const msgDir = join(spoolDir, id); + mkdirSync(msgDir, { recursive: true }); + const filesToPrint = []; + + // Body + const bodyHeader = + `From: ${from}\r\n` + + `Date: ${date}\r\n` + + `Subject: ${subject}\r\n` + + `${"-".repeat(70)}\r\n\r\n`; + const bodyContent = (bodyText && bodyText.trim()) + ? bodyText + : (bodyHtml ? htmlToText(bodyHtml) : "(no body)"); + const bodyPath = join(msgDir, "body.txt"); + writeFileSync(bodyPath, bodyHeader + bodyContent, "utf8"); + filesToPrint.push(bodyPath); + + // Attachments + const maxBytes = config.maxAttachmentBytes ?? 25 * 1024 * 1024; + for (let i = 0; i < attachments.length; i++) { + const a = attachments[i]; + if (!a.attachmentId) { + log.warn(`msg ${id}: attachment ${a.filename} has no attachmentId, skip`); + continue; + } + if (a.size > maxBytes) { + log.warn(`msg ${id}: attachment ${a.filename} too big (${a.size}b), skip`); + continue; + } + const data = await gmail.getAttachment(id, a.attachmentId); + const buf = b64urlToBuffer(data.data); + const filename = `${String(i + 1).padStart(2, "0")}_${safeName(a.filename)}`; + const filePath = join(msgDir, filename); + writeFileSync(filePath, buf); + filesToPrint.push(filePath); + log.info(`msg ${id}: staged ${filename} (${buf.length}b, ${a.mimeType})`); + } + + await printFiles(filesToPrint, config, { log }); + await gmail.markRead(id); + + try { rmSync(msgDir, { recursive: true, force: true }); } + catch (e) { log.warn(`cleanup ${msgDir}: ${e.message}`); } + + log.info(`msg ${id}: printed ${filesToPrint.length} file(s), marked read`); +} + +async function tick(gmail, config, log) { + const list = await gmail.listMessages(config.gmailQuery || "in:inbox category:primary is:unread", 25); + const ids = (list.messages || []).map((m) => m.id); + if (ids.length === 0) { + log.debug("no new messages"); + return; + } + log.info(`found ${ids.length} new message(s)`); + for (const id of ids) { + try { + await processMessage(gmail, config, log, id); + } catch (e) { + // Fatal auth errors mid-batch must bubble up: spamming notifications + // for every remaining message won't help, and the loop's outer catch + // is what triggers the "re-run setup" exit code. + if (e instanceof GmailAuthError && e.fatal) throw e; + log.error(`msg ${id} failed:`, e); + await sendNotify(gmail, config, `error processing message ${id}`, `${e.message}\n\n${e.stack || ""}`, log); + } + } +} + +// Run the poll loop until process is killed. Returns only if runOnce is true. +export async function runPoll(config, { log, runOnce = false } = {}) { + const gmail = new GmailClient(config, { log }); + const pollMs = config.pollIntervalMs ?? 300_000; + log.info(`mailpress starting; query="${config.gmailQuery}"; poll=${pollMs}ms; printer="${config.printerName}"`); + + while (true) { + try { + await tick(gmail, config, log); + } catch (e) { + log.error("tick failed:", e); + if (e instanceof GmailAuthError && e.fatal) { + await sendNotify(gmail, config, "auth failure — re-run setup", e.message, log); + throw e; // bubble up so caller can exit with non-zero + } + } + if (runOnce) break; + await new Promise((r) => setTimeout(r, pollMs)); + } +} diff --git a/lib/printer.mjs b/lib/printer.mjs new file mode 100644 index 0000000..71127ce --- /dev/null +++ b/lib/printer.mjs @@ -0,0 +1,188 @@ +// Printer helpers. Windows-only at runtime — on other platforms these throw +// with a clear message. List, validate, print a batch, print a test page. + +import { spawn } from "node:child_process"; +import { writeFileSync, mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveInRoot } from "./config.mjs"; + +const PRINT_PS1_NAME = "print-files.ps1"; + +export function isWindows() { + return process.platform === "win32"; +} + +function powershellPath() { + // Prefer Windows PowerShell over pwsh — print-files.ps1 has `#requires -version 5.1`. + return "powershell.exe"; +} + +// Run a PowerShell snippet and return { code, stdout, stderr }. +// Times out so a hung PowerShell doesn't deadlock the wizard. +function runPs(args, { input, timeoutMs = 30_000 } = {}) { + return new Promise((resolve, reject) => { + if (!isWindows()) { + reject(new Error("PowerShell is only available on Windows")); + return; + } + const ps = spawn(powershellPath(), args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = "", stderr = ""; + const killer = setTimeout(() => { + try { ps.kill("SIGKILL"); } catch { /* */ } + reject(new Error(`PowerShell timed out after ${timeoutMs}ms`)); + }, timeoutMs); + ps.stdout.on("data", (d) => { stdout += d.toString(); }); + ps.stderr.on("data", (d) => { stderr += d.toString(); }); + ps.on("error", (e) => { clearTimeout(killer); reject(e); }); + ps.on("close", (code) => { + clearTimeout(killer); + resolve({ code, stdout, stderr }); + }); + if (input) { + ps.stdin.write(input); + ps.stdin.end(); + } else { + ps.stdin.end(); + } + }); +} + +export async function listPrinters() { + if (!isWindows()) return []; + const { code, stdout, stderr } = await runPs([ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-Command", "Get-Printer | Select-Object Name,DriverName,PortName,PrinterStatus | ConvertTo-Json -Compress", + ]); + if (code !== 0) { + throw new Error(`Get-Printer failed (${code}): ${stderr.trim() || stdout.trim()}`); + } + // Windows PowerShell 5.1 sometimes prepends a UTF-8 BOM on pipe-captured + // output; JSON.parse chokes on it. + const clean = stdout.replace(/^/, "").trim(); + if (!clean) return []; + let parsed; + try { + parsed = JSON.parse(clean); + } catch (e) { + throw new Error(`Could not parse Get-Printer output: ${e.message}`); + } + // ConvertTo-Json returns an object (single printer) or array (many). Normalize. + return Array.isArray(parsed) ? parsed : [parsed]; +} + +export async function printerExists(name) { + if (!name) return false; + try { + const printers = await listPrinters(); + return printers.some((p) => p.Name === name); + } catch { + return false; + } +} + +// Time budget per file (rough): launch app + spool + sleep gap. Generous +// because Word/Excel cold start is slow. Adjust via config.perPrintTimeoutMs. +const DEFAULT_PER_FILE_TIMEOUT_MS = 120_000; + +export function printFiles(files, config, { log } = {}) { + return new Promise((resolveP, rejectP) => { + if (!isWindows()) { + rejectP(new Error("printing requires Windows")); + return; + } + if (!files.length) { + resolveP(); + return; + } + const ps1 = resolveInRoot(PRINT_PS1_NAME); + if (!existsSync(ps1)) { + rejectP(new Error(`print helper missing: ${ps1}`)); + return; + } + // Filenames with commas would split incorrectly with -Files "a,b,c". Pass + // them as one PowerShell array argument by quoting each as a separate + // -Files token (PowerShell collects repeated values for [string[]] params + // when given comma-separated, so we feed via a delimiter unlikely to + // appear in paths). Simpler: use the pipe character which is illegal in + // Windows filenames, and split on it inside the PS1. + const args = [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", ps1, + "-PrinterName", config.printerName, + "-FilesDelimited", files.join("|"), + "-SleepMs", String(config.perPrintSleepMs ?? 3000), + ]; + const timeoutMs = Math.max( + 30_000, + (config.perPrintTimeoutMs ?? DEFAULT_PER_FILE_TIMEOUT_MS) * files.length, + ); + const ps = spawn(powershellPath(), args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = "", stderr = "", done = false; + const finishOk = () => { if (!done) { done = true; clearTimeout(timer); resolveP(); } }; + const finishErr = (e) => { if (!done) { done = true; clearTimeout(timer); rejectP(e); } }; + const timer = setTimeout(() => { + try { ps.kill("SIGKILL"); } catch { /* */ } + finishErr(new Error(`print-files.ps1 timed out after ${timeoutMs}ms (printer offline? spooler hung? app dialog waiting?)`)); + }, timeoutMs); + ps.stdout.on("data", (d) => { stdout += d.toString(); }); + ps.stderr.on("data", (d) => { stderr += d.toString(); }); + ps.on("close", (code) => { + if (stdout && log) log.info("powershell:", stdout.trim().replace(/\n/g, " | ")); + if (stderr && log) log.warn("powershell stderr:", stderr.trim().replace(/\n/g, " | ")); + if (code === 0) finishOk(); + else finishErr(new Error(`print-files.ps1 exited ${code}`)); + }); + ps.on("error", finishErr); + }); +} + +// Interactive printer picker. Lists installed printers and asks the user to +// choose one. Returns the chosen name. Shared by the wizard and the +// --test menu so both flows behave identically. +export async function pickPrinter({ prompter, current, log, dim }) { + if (!isWindows()) { + return prompter.ask("Printer name (free text — can't enumerate on non-Windows):", { + default: current, + validate: (v) => v ? null : "required", + }); + } + log.say("Enumerating installed printers..."); + const printers = await listPrinters(); + if (printers.length === 0) { + throw new Error("Get-Printer returned no printers. Plug in / install the printer first, then retry."); + } + const options = printers.map((p) => ({ + label: `${p.Name}${p.PortName && dim ? dim(` (${p.PortName})`) : (p.PortName ? ` (${p.PortName})` : "")}`, + value: p.Name, + })); + if (current && printers.some((p) => p.Name === current)) { + options.unshift({ label: `${current} ${dim ? dim("(current)") : "(current)"}`, value: current }); + } + return prompter.choose("Which printer should mailpress send to?", options); +} + +// Write a tiny test page to a temp file and print it. Returns the temp path +// so the caller can clean up after confirming the print landed. +export async function testPrint(config, { log } = {}) { + const dir = mkdtempSync(join(tmpdir(), "mailpress-test-")); + const file = join(dir, "mailpress-test-page.txt"); + const stamp = new Date().toISOString(); + const content = + `MAILPRESS TEST PAGE\r\n` + + `${"=".repeat(40)}\r\n` + + `\r\n` + + `If you're reading this on paper, your printer is wired up correctly.\r\n` + + `\r\n` + + `Printer : ${config.printerName}\r\n` + + `Time : ${stamp}\r\n` + + `\r\n` + + `You can throw this page away.\r\n`; + writeFileSync(file, content, "utf8"); + try { + await printFiles([file], config, { log }); + } finally { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* */ } + } +} diff --git a/lib/prompt.mjs b/lib/prompt.mjs new file mode 100644 index 0000000..90ee9fb --- /dev/null +++ b/lib/prompt.mjs @@ -0,0 +1,63 @@ +// Small wrapper around node:readline used by wizard, test mode, and any +// future interactive command. Logs every prompt + answer to the file +// logger so debugging a confused user session is just "show me the log". + +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; + +const BOLD = "\x1b[1m", DIM = "\x1b[2m", RESET = "\x1b[0m"; + +export function bold(s) { return process.stdout.isTTY ? `${BOLD}${s}${RESET}` : s; } +export function dim(s) { return process.stdout.isTTY ? `${DIM}${s}${RESET}` : s; } + +export class Prompter { + constructor(log) { + this.log = log; + this.rl = createInterface({ input, output }); + } + close() { + try { this.rl.close(); } catch { /* already closed */ } + } + + async ask(question, { default: def, secret = false, validate } = {}) { + while (true) { + const hasDefault = def !== undefined && def !== null && def !== ""; + const suffix = hasDefault ? dim(` [${secret ? "(unchanged)" : def}]`) : ""; + const raw = (await this.rl.question(`${question}${suffix} `)).trim(); + const value = raw !== "" ? raw : (hasDefault ? def : ""); + this.log.debug(`prompt: ${question} -> ${secret ? "***" : value}`); + if (validate) { + const err = validate(value); + if (err) { + this.log.say(dim(` ${err}`)); + continue; + } + } + return value; + } + } + + async confirm(question, { default: def = true } = {}) { + const hint = def ? "[Y/n]" : "[y/N]"; + const a = (await this.rl.question(`${question} ${hint} `)).trim().toLowerCase(); + this.log.debug(`confirm: ${question} -> ${a}`); + if (!a) return def; + return a.startsWith("y"); + } + + async choose(question, options) { + this.log.say(question); + for (let i = 0; i < options.length; i++) { + this.log.say(` ${i + 1}. ${options[i].label}`); + } + while (true) { + const a = (await this.rl.question(dim(`Pick 1-${options.length}: `))).trim(); + const n = parseInt(a, 10); + if (Number.isInteger(n) && n >= 1 && n <= options.length) { + this.log.debug(`choose: ${question} -> ${options[n - 1].label}`); + return options[n - 1].value; + } + this.log.say(dim(` enter a number 1-${options.length}`)); + } + } +} diff --git a/lib/task.mjs b/lib/task.mjs new file mode 100644 index 0000000..4936162 --- /dev/null +++ b/lib/task.mjs @@ -0,0 +1,115 @@ +// Windows Scheduled Task helpers. Installs mailpress as a "run at login, +// restart on failure" task pointing at the current exe (or `node index.mjs` +// when running from source). + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolveInRoot, installRoot } from "./config.mjs"; +import { isWindows } from "./printer.mjs"; + +const TASK_NAME = "mailpress"; + +function runPs(script, { timeoutMs = 60_000 } = {}) { + return new Promise((resolve, reject) => { + if (!isWindows()) { + reject(new Error("Scheduled Tasks are Windows-only")); + return; + } + const ps = spawn("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-Command", script, + ], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = "", stderr = ""; + const killer = setTimeout(() => { + try { ps.kill("SIGKILL"); } catch { /* */ } + reject(new Error(`PowerShell timed out after ${timeoutMs}ms`)); + }, timeoutMs); + ps.stdout.on("data", (d) => { stdout += d.toString(); }); + ps.stderr.on("data", (d) => { stderr += d.toString(); }); + ps.on("error", (e) => { clearTimeout(killer); reject(e); }); + ps.on("close", (code) => { + clearTimeout(killer); + if (code !== 0) { + reject(new Error(`PowerShell exited ${code}: ${stderr.trim() || stdout.trim()}`)); + } else { + resolve(stdout.trim()); + } + }); + }); +} + +export async function taskExists() { + try { + const out = await runPs( + `if (Get-ScheduledTask -TaskName '${TASK_NAME}' -ErrorAction SilentlyContinue) { 'yes' } else { 'no' }`, + ); + return out.trim() === "yes"; + } catch { + return false; + } +} + +// What command should the Scheduled Task run? When packaged as a SEA exe, +// argv[0] points at mailpress.exe. When running from source, we register +// `node index.mjs` (NOT cli.mjs — see note below). +// +// Note: from source the task runs index.mjs, not cli.mjs. index.mjs is a +// pure poll loop; if config goes bad it exits non-zero and the task's +// RestartCount handles backoff. cli.mjs would auto-launch the wizard which +// needs interactive stdin — useless from a scheduled-task context. +function actionTarget() { + const root = installRoot(); + const execPath = process.execPath; + const baseName = execPath.split(/[\\\/]/).pop().toLowerCase(); + const isSea = baseName === "mailpress.exe" || baseName === "mailpress"; + if (isSea) { + return { execute: execPath, argument: null, workingDir: root }; + } + // Pass the script path as a single arg WITHOUT manual surrounding quotes — + // Register-ScheduledTask handles quoting internally; embedded quotes end + // up as literal characters in argv[1] which then doesn't resolve as a file. + return { execute: execPath, argument: resolveInRoot("index.mjs"), workingDir: root }; +} + +export async function installTask() { + const { execute, argument, workingDir } = actionTarget(); + if (!existsSync(execute)) { + throw new Error(`scheduled task target does not exist: ${execute}`); + } + // PowerShell single-quoted strings only need '' to escape a single quote. + // Path-safe: backslashes, $ , `, " all pass through as literal. + const esc = (s) => String(s).replace(/'/g, "''"); + const script = ` +$ErrorActionPreference = 'Stop' +$taskName = '${TASK_NAME}' +$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue +if ($existing) { + # Stop first; Unregister of a running task can fail or leave the process orphaned. + try { Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue } catch {} + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false +} +$action = New-ScheduledTaskAction -Execute '${esc(execute)}' ${argument != null ? `-Argument '${esc(argument)}'` : ""} -WorkingDirectory '${esc(workingDir)}' +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 0) -MultipleInstances IgnoreNew +$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited +Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description 'mailpress - Gmail to printer relay' | Out-Null +Start-ScheduledTask -TaskName $taskName +Write-Output 'installed' +`.trim(); + return runPs(script); +} + +export async function uninstallTask() { + return runPs(` +$ErrorActionPreference = 'Stop' +if (Get-ScheduledTask -TaskName '${TASK_NAME}' -ErrorAction SilentlyContinue) { + Stop-ScheduledTask -TaskName '${TASK_NAME}' -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName '${TASK_NAME}' -Confirm:$false + Write-Output 'removed' +} else { + Write-Output 'absent' +} +`.trim()); +} + +export { TASK_NAME }; diff --git a/lib/test.mjs b/lib/test.mjs new file mode 100644 index 0000000..d11b73d --- /dev/null +++ b/lib/test.mjs @@ -0,0 +1,137 @@ +// Interactive test menu. Run with `mailpress --test`. Lets the operator: +// - print the mailpress-generated test page +// - print any file they pick (so they can verify .docx, .xlsx, .pdf etc.) +// - switch the configured printer (persists to config.local.json) +// - quit +// +// Designed to be safe to run on a configured production install — the only +// mutation is saving a new printer choice, which it confirms first. + +import { existsSync, statSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, basename, extname, resolve } from "node:path"; +import { loadConfig, saveConfig, validateConfig } from "./config.mjs"; +import { isWindows, pickPrinter, printFiles, testPrint } from "./printer.mjs"; +import { Prompter, dim, bold } from "./prompt.mjs"; + +const MENU = [ + { label: "Print the mailpress test page (auto-generated)", value: "testpage" }, + { label: "Print a file I pick (.txt, .pdf, .docx, .xlsx, image)", value: "file" }, + { label: "Switch the active printer (and save to config)", value: "switch" }, + { label: "Print the test page on EACH installed printer", value: "all" }, + { label: "Quit", value: "quit" }, +]; + +async function actionTestPage(config, log) { + log.say(`Printing test page to "${config.printerName}"...`); + await testPrint(config, { log }); + log.success("test page sent to the spooler"); +} + +async function actionFile(config, log, prompter) { + log.say("Drop in a full path to any file you want to test-print."); + log.say(dim(" Examples: C:\\Users\\you\\Desktop\\sample.docx")); + log.say(dim(" C:\\Users\\you\\Downloads\\invoice.pdf")); + const path = await prompter.ask("File path:", { + validate: (v) => { + if (!v) return "required"; + if (!existsSync(v)) return `not found: ${v}`; + try { + if (!statSync(v).isFile()) return "not a file"; + } catch (e) { return e.message; } + return null; + }, + }); + const ext = extname(path).toLowerCase(); + log.say(`File: ${basename(path)} (${ext || "(no ext)"})`); + log.say(`Printer: ${config.printerName}`); + log.say(""); + if (ext && ![".txt", ".pdf", ".docx", ".doc", ".xlsx", ".xls", ".pptx", ".ppt", ".png", ".jpg", ".jpeg", ".gif", ".html", ".htm"].includes(ext)) { + log.say(dim(` Note: print-files.ps1 will try the Print verb. If no app is registered`)); + log.say(dim(` to print ${ext}, Windows will fall back to Open and you'll see a dialog.`)); + } + await printFiles([resolve(path)], config, { log }); + log.success("send to printer complete (check the tray for the page)"); +} + +async function actionSwitch(config, log, prompter) { + const chosen = await pickPrinter({ prompter, current: config.printerName, log, dim }); + if (chosen === config.printerName) { + log.say("(same printer — no change)"); + return; + } + const ok = await prompter.confirm( + `Update config.local.json: printerName = "${chosen}" ?`, { default: true }); + if (!ok) { + log.say(dim(" not saved")); + return; + } + const fresh = loadConfig() || {}; + fresh.printerName = chosen; + saveConfig(fresh); + config.printerName = chosen; + log.success(`printer switched to "${chosen}" and saved`); + log.say(dim(" Restart the mailpress scheduled task to pick up the change:")); + log.say(dim(` powershell -c "Stop-ScheduledTask mailpress; Start-ScheduledTask mailpress"`)); +} + +async function actionAll(config, log, prompter) { + if (!isWindows()) { + log.say("Can't enumerate printers off Windows."); + return; + } + const { listPrinters } = await import("./printer.mjs"); + const printers = await listPrinters(); + if (printers.length === 0) { + log.say("No printers found."); + return; + } + log.say(`Found ${printers.length} printer(s). Will send a test page to each in turn.`); + const ok = await prompter.confirm(`Proceed?`, { default: false }); + if (!ok) return; + for (const p of printers) { + log.say(""); + log.say(`-> ${p.Name}`); + try { + await testPrint({ ...config, printerName: p.Name }, { log }); + log.success(`sent to ${p.Name}`); + } catch (e) { + log.failure(`${p.Name}: ${e.message}`); + } + } +} + +export async function runTest({ log }) { + const cfg = loadConfig(); + const errors = cfg ? validateConfig(cfg) : ["config.local.json is missing"]; + if (errors.length) { + log.failure("Config not ready: " + errors.join("; ")); + log.say("Run `mailpress --setup` first."); + return 2; + } + const config = { ...cfg }; + const prompter = new Prompter(log); + log.heading("mailpress test menu"); + log.say(`Current printer: ${bold(config.printerName)}`); + log.say(dim(`(safe to run while the scheduled task is polling — no Gmail traffic)`)); + + try { + while (true) { + log.say(""); + const choice = await prompter.choose("What do you want to do?", MENU); + try { + if (choice === "quit") break; + if (choice === "testpage") await actionTestPage(config, log); + else if (choice === "file") await actionFile(config, log, prompter); + else if (choice === "switch") await actionSwitch(config, log, prompter); + else if (choice === "all") await actionAll(config, log, prompter); + } catch (e) { + log.failure(e.message); + log.debug(e.stack || e.message); + } + } + } finally { + prompter.close(); + } + return 0; +} diff --git a/lib/wizard.mjs b/lib/wizard.mjs new file mode 100644 index 0000000..6968453 --- /dev/null +++ b/lib/wizard.mjs @@ -0,0 +1,282 @@ +// First-run wizard. Walks the user through every setup step interactively. +// +// Design rules: +// - Every step is wrapped so a failure offers retry/skip/quit instead of +// dumping a stack trace and exiting. The user wants this to be robust. +// - Every prompt, every external call, every error is written to the +// setup log so we can diagnose problems after the fact. +// - We never lose progress: completed steps update config.local.json / +// .local-token.json immediately so re-running picks up where we left off. + +import { existsSync, rmSync } from "node:fs"; +import { loadConfig, saveConfig, validateConfig, resolveInRoot, DEFAULTS } from "./config.mjs"; +import { GmailClient } from "./gmail.mjs"; +import { runConsent } from "./oauth.mjs"; +import { isWindows, listPrinters, testPrint, pickPrinter } from "./printer.mjs"; +import { installTask, taskExists } from "./task.mjs"; +import { Prompter, bold, dim } from "./prompt.mjs"; + +async function tryVerifyExistingToken(gmail, config, log) { + try { + const prof = await gmail.profile(); + if (prof.emailAddress?.toLowerCase() === config.gmailAddress.toLowerCase()) return true; + log.debug(`existing token belongs to ${prof.emailAddress}, want ${config.gmailAddress}`); + return false; + } catch (e) { + log.debug(`existing token verify failed: ${e.message}`); + return false; + } +} + +function deleteToken(path, log) { + try { rmSync(path, { force: true }); log.debug(`deleted token: ${path}`); } + catch (e) { log.warn(`could not delete token ${path}: ${e.message}`); } +} + +// Run a wizard step. On failure, offer the user retry/skip/quit so a bad +// step doesn't sink the whole wizard. Returns the step's value or null +// if skipped/aborted. +async function step(prompter, log, name, fn, { allowSkip = false } = {}) { + log.heading(name); + while (true) { + try { + return await fn(); + } catch (e) { + log.failure(`${name}: ${e.message}`); + log.debug(e.stack || e.message); + const choices = allowSkip + ? [{ label: "retry", value: "retry" }, { label: "skip this step", value: "skip" }, { label: "quit setup", value: "quit" }] + : [{ label: "retry", value: "retry" }, { label: "quit setup", value: "quit" }]; + const choice = await prompter.choose("How do you want to handle this?", choices); + if (choice === "skip") return null; + if (choice === "quit") throw new Error("setup aborted by user"); + } + } +} + +// ---------- the wizard ---------- + +export async function runWizard({ log }) { + const prompter = new Prompter(log); + + log.heading("mailpress setup"); + log.say("This wizard will set up Gmail-to-printer relaying on this PC."); + log.say(`Detailed log: ${dim(log.file() || "(no file)")}`); + log.say(""); + + // Pull anything we've already configured so re-running is idempotent. + // Tolerate a corrupt config file — the whole point of running the wizard + // is to repair broken state, so we treat parse errors as "start fresh" + // and warn the user that the existing file will be overwritten. + let existing = {}; + try { + existing = loadConfig() || {}; + } catch (e) { + log.warn(`existing config.local.json is unreadable (${e.message}); starting fresh`); + } + const config = { ...DEFAULTS, ...existing }; + const startingClientId = config.googleClientId; + const startingGmailAddress = config.gmailAddress; + + try { + // ---- Step 1: system check ---- + await step(prompter, log, "Step 1 of 6 — system check", async () => { + if (!isWindows()) { + log.failure(`platform is ${process.platform}, not win32`); + log.say("mailpress prints through Windows PowerShell. It can be configured"); + log.say("here but won't actually print until run on the Windows office PC."); + const proceed = await prompter.confirm("Continue setup anyway?", { default: false }); + if (!proceed) throw new Error("aborted: not running on Windows"); + } else { + log.success(`platform: ${process.platform}`); + } + const major = parseInt(process.versions.node.split(".")[0], 10); + if (major < 18) throw new Error(`Node ${process.versions.node} is too old; need >= 18`); + log.success(`Node: ${process.versions.node}`); + }); + + // ---- Step 2: pick a printer ---- + await step(prompter, log, "Step 2 of 6 — pick a printer", async () => { + const chosen = await pickPrinter({ prompter, current: config.printerName, log, dim }); + config.printerName = chosen; + log.success(`printer: ${chosen}`); + }); + + // ---- Step 3: Gmail OAuth client + Gmail address ---- + await step(prompter, log, "Step 3 of 6 — Google OAuth client", async () => { + log.say("mailpress needs a Google Cloud OAuth client to access Gmail."); + log.say("This is the one step Google doesn't let an installer automate."); + log.say(""); + log.say(bold(" Manual steps in your browser:")); + log.say(" 1. Go to https://console.cloud.google.com/projectcreate"); + log.say(" (create a new project — name it whatever, e.g. \"mailpress\")"); + log.say(" 2. Enable the Gmail API:"); + log.say(" https://console.cloud.google.com/apis/library/gmail.googleapis.com"); + log.say(" → click \"Enable\""); + log.say(" 3. Configure OAuth consent screen (left sidebar → APIs & Services"); + log.say(" → OAuth consent screen):"); + log.say(" - User type: External"); + log.say(" - Add your Gmail as a Test user"); + log.say(" - Add scopes: gmail.modify and gmail.send"); + log.say(" 4. Create credentials → OAuth client ID → Application type:"); + log.say(" \"Desktop app\". Click Create."); + log.say(" 5. Copy the Client ID and Client secret from the dialog."); + log.say(""); + log.say("Documentation: https://developers.google.com/workspace/guides/create-credentials"); + log.say(""); + const ready = await prompter.confirm("Done? Have the client ID and secret ready?", { default: true }); + if (!ready) throw new Error("come back when the OAuth client is created"); + + config.googleClientId = await prompter.ask("Google Client ID:", { + default: !config.googleClientId?.startsWith("FILL_IN") ? config.googleClientId : undefined, + validate: (v) => v ? null : "required", + }); + config.googleClientSecret = await prompter.ask("Google Client Secret:", { + default: !config.googleClientSecret?.startsWith("FILL_IN") ? config.googleClientSecret : undefined, + secret: true, + validate: (v) => v ? null : "required", + }); + config.gmailAddress = await prompter.ask("Office Gmail address (the inbox to monitor):", { + default: !config.gmailAddress?.startsWith("FILL_IN") ? config.gmailAddress : undefined, + validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "doesn't look like an email", + }); + const sameNotify = await prompter.confirm( + `Send error notifications from ${config.gmailAddress}?`, { default: true }); + config.notifyFromAddress = sameNotify ? config.gmailAddress : await prompter.ask( + "From address for error notifications:", { + default: config.notifyFromAddress, + validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "doesn't look like an email", + }); + config.notifyEmail = await prompter.ask("Email to send error notifications TO:", { + default: config.notifyEmail || config.gmailAddress, + validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "doesn't look like an email", + }); + + // Persist before the consent flow so if it fails we don't lose creds. + saveConfig(config); + log.success("config saved"); + }); + + // ---- Step 4: OAuth consent flow ---- + await step(prompter, log, "Step 4 of 6 — authorize Gmail access", async () => { + const gmail = new GmailClient(config, { log }); + const credsChanged = + config.googleClientId !== startingClientId || + config.gmailAddress !== startingGmailAddress; + if (gmail.hasToken() && !credsChanged) { + // Only offer to reuse if the existing token was actually verifiable + // against the configured account. Otherwise force re-consent. + const ok = await tryVerifyExistingToken(gmail, config, log); + if (ok) { + const reuse = await prompter.confirm( + "An existing valid token was found. Skip re-authorization?", { default: true }); + if (reuse) { + log.success("kept existing token"); + return; + } + } else { + log.say(dim(" existing token is invalid or mismatched — re-authorizing")); + } + } else if (gmail.hasToken() && credsChanged) { + log.say(dim(" credentials changed since last run — re-authorizing")); + deleteToken(gmail.tokenPath, log); + } + log.say("A browser window will open. Sign in as " + bold(config.gmailAddress) + "."); + log.say("If you see \"This app isn't verified\" → Advanced → Go to (unsafe)."); + log.say(""); + const tok = await runConsent({ + clientId: config.googleClientId, + clientSecret: config.googleClientSecret, + gmailAddress: config.gmailAddress, + log, + }); + + // Verify the token belongs to the right account BEFORE persisting it. + // We have to construct a temporary client that reads from memory rather + // than from disk; simplest is to write to a temp path, verify, then + // promote on success. + const verifyClient = new GmailClient(config, { log }); + verifyClient._cachedToken = tok.access_token; + verifyClient._cachedExpiry = Date.now() + (tok.expires_in || 3600) * 1000; + const prof = await verifyClient.profile(); + if (prof.emailAddress?.toLowerCase() !== config.gmailAddress.toLowerCase()) { + throw new Error( + `signed in as ${prof.emailAddress} but config says ${config.gmailAddress}. ` + + `Revoke at https://myaccount.google.com/permissions and retry, or run --setup again ` + + `and update the configured Gmail address.`, + ); + } + // Verified — now it's safe to write. + gmail.saveToken(tok); + log.success(`token saved (scopes: ${tok.scope})`); + log.success(`authenticated as ${prof.emailAddress}`); + }); + + // ---- Step 5: test print ---- + await step(prompter, log, "Step 5 of 6 — test print", async () => { + if (!isWindows()) { + log.say("Skipping test print: not on Windows."); + return; + } + const doIt = await prompter.confirm( + `Print a one-page test to "${config.printerName}" now?`, { default: true }); + if (!doIt) { + log.say(dim(" skipped")); + return; + } + log.say("Sending test page... (this can take a few seconds)"); + await testPrint(config, { log }); + const ok = await prompter.confirm("Did the page come out of the printer?", { default: true }); + if (!ok) { + throw new Error("test page didn't print. Check the printer is on, has paper, and isn't in an error state."); + } + log.success("printer works"); + }, { allowSkip: true }); + + // ---- Step 6: install scheduled task ---- + await step(prompter, log, "Step 6 of 6 — install scheduled task", async () => { + if (!isWindows()) { + log.say("Skipping: scheduled tasks are Windows-only."); + return; + } + if (await taskExists()) { + const replace = await prompter.confirm( + "A 'mailpress' scheduled task already exists. Re-install it?", { default: true }); + if (!replace) { + log.success("kept existing scheduled task"); + return; + } + } else { + const install = await prompter.confirm( + "Install mailpress as a Windows Scheduled Task (runs at login, restarts on failure)?", + { default: true }); + if (!install) { + log.say(dim(" skipped. You can run mailpress manually with `mailpress.exe` or install later.")); + return; + } + } + const out = await installTask(); + log.debug("installTask output:", out); + log.success("scheduled task installed and started"); + }, { allowSkip: true }); + + // ---- Done ---- + log.heading("Setup complete"); + const errors = validateConfig(config); + if (errors.length) { + log.failure("config still has issues: " + errors.join("; ")); + } else { + log.success("config saved to " + dim("config.local.json")); + log.success("token saved (gitignored)"); + log.say(""); + log.say("mailpress is now polling your inbox in the background."); + log.say("Useful commands:"); + log.say(dim(" mailpress --doctor ") + "— re-run all health checks"); + log.say(dim(" mailpress --test ") + "— switch printer, test print any file"); + log.say(dim(" mailpress --once ") + "— process current unread and exit"); + log.say(dim(" mailpress --setup ") + "— re-run this wizard"); + } + } finally { + prompter.close(); + } +} diff --git a/package.json b/package.json index 9ba414c..4f5252d 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,26 @@ { "name": "mailpress", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "description": "Polls a Gmail inbox, prints every email body + attachments on a wired office printer.", + "bin": { + "mailpress": "./cli.mjs" + }, "scripts": { - "consent": "node consent.mjs", - "start": "node index.mjs", - "once": "node index.mjs --once" + "start": "node cli.mjs", + "setup": "node cli.mjs --setup", + "consent": "node cli.mjs --consent", + "doctor": "node cli.mjs --doctor", + "once": "node cli.mjs --once", + "build": "node scripts/build.mjs" }, "engines": { "node": ">=18" + }, + "devDependencies": { + "esbuild": "^0.25.0", + "postject": "^1.0.0-alpha.6", + "resedit": "^3.0.0" } } diff --git a/print-files.ps1 b/print-files.ps1 index cc6de80..0d16a49 100644 --- a/print-files.ps1 +++ b/print-files.ps1 @@ -26,10 +26,21 @@ param( [Parameter(Mandatory=$true)] [string] $PrinterName, - [Parameter(Mandatory=$true)] [string[]] $Files, + [string[]] $Files, + # Pipe-delimited list ("a|b|c"). Used by mailpress because '|' is illegal + # in Windows filenames, so it survives any reasonable attachment name. + [string] $FilesDelimited, [int] $SleepMs = 3000 ) +if (-not $Files -and $FilesDelimited) { + $Files = $FilesDelimited -split '\|' +} +if (-not $Files -or $Files.Count -eq 0) { + Write-Error "Either -Files or -FilesDelimited is required." + exit 2 +} + $ErrorActionPreference = "Stop" function Write-Log($msg) { diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..5faff01 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +// Build mailpress.exe using Node's Single Executable Applications feature. +// +// Steps: +// 1. Bundle cli.mjs + lib/*.mjs into a single CommonJS bundle (esbuild). +// 2. Generate the SEA preparation blob. +// 3. Copy the platform's node binary to dist/mailpress(.exe). +// 4. Inject the blob via postject. +// 5. Copy runtime assets (print-files.ps1, config.example.json) next to it. +// +// Run on Windows for a real .exe. On Linux/macOS this still produces a +// working binary for that platform (useful for testing the CLI plumbing +// even though the printer code is Windows-only). +// +// Prereqs: Node >= 20.12, `npx esbuild`, `npx postject`. The script will +// `npm install --no-save` them on demand if missing. + +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, rmSync, chmodSync } from "node:fs"; +// readFileSync/writeFileSync used by embedIcon below. +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { platform } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +const DIST = join(ROOT, "dist"); +const BUNDLE = join(DIST, "mailpress.cjs"); +const SEA_CONFIG = join(DIST, "sea-config.json"); +const BLOB = join(DIST, "sea-prep.blob"); +const IS_WIN = platform() === "win32"; +const EXE_NAME = IS_WIN ? "mailpress.exe" : "mailpress"; +const EXE = join(DIST, EXE_NAME); + +function run(cmd, opts = {}) { + console.log(`$ ${cmd}`); + execSync(cmd, { stdio: "inherit", cwd: ROOT, ...opts }); +} + +function ensureTool(pkg, binFlag = "--version") { + try { + execSync(`npx --no-install ${pkg} ${binFlag}`, { stdio: "ignore" }); + return; + } catch { /* not installed */ } + console.log(`Installing ${pkg}...`); + run(`npm install --no-save ${pkg}`); +} + +function checkNodeVersion() { + const [maj, min] = process.versions.node.split(".").map(Number); + if (maj < 20 || (maj === 20 && min < 12)) { + throw new Error(`Node ${process.versions.node} is too old for stable SEA. Need >= 20.12.`); + } +} + +function clean() { + if (existsSync(DIST)) rmSync(DIST, { recursive: true, force: true }); + mkdirSync(DIST, { recursive: true }); +} + +function bundle() { + ensureTool("esbuild"); + // SEA requires CommonJS. Bundle the CLI entry into a single CJS file. + // External: none — we want everything inlined. + run( + `npx esbuild cli.mjs ` + + `--bundle --platform=node --format=cjs --target=node20 ` + + `--outfile="${BUNDLE}" ` + + `--legal-comments=none --log-level=warning`, + ); +} + +function writeSeaConfig() { + const conf = { + main: BUNDLE, + output: BLOB, + disableExperimentalSEAWarning: true, + useSnapshot: false, + useCodeCache: true, + assets: {}, + }; + writeFileSync(SEA_CONFIG, JSON.stringify(conf, null, 2)); +} + +function generateBlob() { + run(`node --experimental-sea-config "${SEA_CONFIG}"`); +} + +function copyNode() { + copyFileSync(process.execPath, EXE); + try { chmodSync(EXE, 0o755); } catch { /* */ } +} + +function inject() { + ensureTool("postject"); + const sentinel = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; + // --macho-segment-name is only needed on macOS. + const extra = platform() === "darwin" ? "--macho-segment-name NODE_SEA" : ""; + run( + `npx postject "${EXE}" NODE_SEA_BLOB "${BLOB}" ` + + `--sentinel-fuse ${sentinel} ${extra}`, + ); +} + +// Embed an icon + version metadata into the Windows .exe via resedit. +// Skipped on non-Windows because non-PE binaries have no icon resource. +async function embedIcon() { + if (!IS_WIN) { + console.log("Skipping icon embed (not building a Windows .exe)"); + return; + } + const iconPath = join(ROOT, "assets", "icon.ico"); + if (!existsSync(iconPath)) { + console.warn(`assets/icon.ico missing — exe will keep node's default icon`); + return; + } + ensureTool("resedit"); + const { NtExecutable, NtExecutableResource, Resource, Data } = await import("resedit"); + const exeBuf = readFileSync(EXE); + const exe = NtExecutable.from(exeBuf); + const res = NtExecutableResource.from(exe); + + const iconFile = Data.IconFile.from(readFileSync(iconPath)); + Resource.IconGroupEntry.replaceIconsForResource( + res.entries, + 1, // icon group resource id + 1033, // en-US language id + iconFile.icons.map((i) => i.data), + ); + + const versionInfo = Resource.VersionInfo.createEmpty(); + versionInfo.setFileVersion(0, 2, 0, 0); + versionInfo.setProductVersion(0, 2, 0, 0); + versionInfo.setStringValues( + { lang: 1033, codepage: 1200 }, + { + ProductName: "mailpress", + FileDescription: "Gmail-to-printer relay", + CompanyName: "mailpress", + LegalCopyright: "", + OriginalFilename: "mailpress.exe", + }, + ); + versionInfo.outputToResourceEntries(res.entries); + + res.outputResource(exe); + writeFileSync(EXE, Buffer.from(exe.generate())); + console.log("Embedded icon + version metadata"); +} + +function copyAssets() { + const assets = ["print-files.ps1", "config.example.json", "install-task.ps1"]; + for (const a of assets) { + const src = join(ROOT, a); + if (existsSync(src)) { + copyFileSync(src, join(DIST, a)); + } else { + console.warn(`asset missing, skipping: ${a}`); + } + } + // The icon is embedded in the exe; also drop a copy beside it for users + // who want a desktop shortcut with a matching icon. + const ico = join(ROOT, "assets", "icon.ico"); + if (existsSync(ico)) copyFileSync(ico, join(DIST, "mailpress.ico")); +} + +function smokeTest() { + console.log("\nSmoke testing --help..."); + try { + execSync(`"${EXE}" --help`, { stdio: "inherit", cwd: DIST }); + execSync(`"${EXE}" --version`, { stdio: "inherit", cwd: DIST }); + } catch (e) { + throw new Error(`smoke test failed: ${e.message}`); + } +} + +async function main() { + checkNodeVersion(); + console.log(`Building mailpress for ${platform()} (node ${process.versions.node})`); + clean(); + bundle(); + writeSeaConfig(); + generateBlob(); + copyNode(); + inject(); + await embedIcon(); + copyAssets(); + smokeTest(); + console.log(`\nDone. Artifacts in ${DIST}:`); + for (const f of [EXE_NAME, "print-files.ps1", "install-task.ps1", "config.example.json"]) { + if (existsSync(join(DIST, f))) console.log(` ${f}`); + } +} + +main().catch((e) => { + console.error("Build failed:", e.message); + process.exit(1); +}); From 1381a9af7ed1e31213f1e099d8f8a29bd23cbdd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 16:16:04 +0000 Subject: [PATCH 2/3] ci: drop setup-node npm cache (no lockfile committed) The first CI run failed at 28s because actions/setup-node@v4 with cache: npm refuses to start when there's no package-lock.json or npm-shrinkwrap.json in the repo. We deliberately don't ship a lockfile (the project has no runtime deps and only three pinned devDeps), so dropping the cache option fixes the build. https://claude.ai/code/session_01CfsRS2PZ4t8RUnhPdVEWHF --- .github/workflows/release.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1804b90..0f38372 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,11 +21,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: "20" - cache: npm + # No cache: requires a committed lockfile, which we don't ship. - name: install build deps - # No lockfile yet; install pinned devDependencies so the build uses - # the versions in package.json, not whatever stale npx cache exists. + # Pin versions inline since there's no package-lock.json in the repo. run: npm install --no-save esbuild@^0.25.0 postject@^1.0.0-alpha.6 resedit@^3.0.0 - name: build mailpress.exe From 20a072d1f76c86485c276659a2f3a9ba4e044daf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 16:24:25 +0000 Subject: [PATCH 3/3] ci: pass ignoreCert to resedit (Node's win-x64 binary is signed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified end-to-end locally against the real node v20 win-x64.zip. resedit's NtExecutable.from refuses to parse signed PE files unless explicitly opted in; the official Node Windows distribution is signed. postject + resedit both invalidate any prior signature anyway, so we're already shipping an unsigned exe — ignoreCert just lets resedit get past the cert table on read. https://claude.ai/code/session_01CfsRS2PZ4t8RUnhPdVEWHF --- scripts/build.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index 5faff01..c909ae3 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -117,7 +117,11 @@ async function embedIcon() { ensureTool("resedit"); const { NtExecutable, NtExecutableResource, Resource, Data } = await import("resedit"); const exeBuf = readFileSync(EXE); - const exe = NtExecutable.from(exeBuf); + // Node's official Windows binary is code-signed. resedit refuses to parse + // signed PE files by default; we have to opt in. Embedding an icon + // invalidates the signature anyway, and postject has already done the + // same to inject the SEA blob, so the binary is unsigned end-to-end. + const exe = NtExecutable.from(exeBuf, { ignoreCert: true }); const res = NtExecutableResource.from(exe); const iconFile = Data.IconFile.from(readFileSync(iconPath));