diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f38372..0a7bfd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,8 +37,8 @@ jobs: path: | dist/mailpress.exe dist/print-files.ps1 - dist/install-task.ps1 dist/config.example.json + dist/mailpress.ico - name: release (on tag) if: startsWith(github.ref, 'refs/tags/v') @@ -47,7 +47,7 @@ jobs: files: | dist/mailpress.exe dist/print-files.ps1 - dist/install-task.ps1 dist/config.example.json + dist/mailpress.ico draft: false generate_release_notes: true diff --git a/FINDINGS.md b/FINDINGS.md deleted file mode 100644 index 3409f75..0000000 --- a/FINDINGS.md +++ /dev/null @@ -1,180 +0,0 @@ -# mailpress — research notes - -Goal: stand-alone successor to the printing extension that used to live -inside `gv-sms-worker` (now at `/home/yaakov/code/gv-worker/`). The -worker watches a Gmail inbox, pulls attachments off inbound messages, -converts non-PDFs to PDF, and prints them on a home printer via -PrintNode. - -This doc captures everything we know about how the original was built. -No code has been ported yet — the previous source has been deleted from -`gv-worker/src/index.ts` (RECAP said it was left as dead code, but the -current file has zero `print/pdf/attach` references). - - -## What the printing extension did (RECAP.txt, lines 114–143) - -Per inbound message: - -1. Walk the MIME tree, fetch each attachment by `attachmentId` via the - Gmail API. -2. **PDFs** → POST straight to PrintNode (`contentType: "pdf_base64"`). -3. **Everything else** (Word, Excel, PowerPoint, images, etc.) → - CloudConvert **sync** job (`POST /v2/jobs/{id}/wait`, - `output_format=pdf`) → PrintNode. -4. Scan the message body for cloud-storage links (OneDrive, SharePoint, - Drive, Dropbox, WeTransfer, `1drv.ms`). These are **listed**, not - fetched. -5. If any attachment **or** cloud link was found, send a notification - email to `NOTIFY_EMAIL` (`aturetsky@gmail.com`) summarising what - printed, what failed, and any links to handle manually. Sent from - the user's Gmail via the existing OAuth. -6. Remove the `UNREAD` label at the end (same as the SMS flow). - -Why CloudConvert: PrintNode only accepts `pdf_base64` or `raw_base64`. -Word/Excel/images don't work natively, so a conversion step is needed. -CloudConvert covers `.docx/.xlsx/.pptx` + images universally. -Free tier ~25 conversions/day; pay-as-you-go after. - - -## Functions that existed in the old `gv-worker/src/index.ts` - -Per RECAP.txt line 234–236, these were present (then deleted): - -- `generateReply` (Claude SMS reply path — not strictly print) -- `handleAttachment` (per-attachment dispatcher) -- `convertToPdfBase64` (CloudConvert wrapper) -- `printPdf` (PrintNode wrapper) -- `collectAttachments` (MIME-tree walker) -- `extractCloudLinks` (regex over message body) - -The current `gv-worker/src/index.ts` still has the Gmail OAuth glue -(`getAccessToken`, `gmail(path, token, ...)`) and the MIME helpers -worth borrowing as a starting point. - - -## Accounts / credentials (already provisioned, do NOT re-create) - -### PrintNode (https://app.printnode.com/) -- Account: `yjturetsky@gmail.com` -- Account ID: **520685** -- Agent: PrintNode v4.29.0 installed as a Windows service on - `DMDQU0RGH1GD0` (LAN IP `10.0.0.10`) -- Computer ID: **750025** -- Printers available: - - **Samsung M2020 Series** — `PRINTNODE_PRINTER_ID = 75458677` ← default - - EPSON ET-2800 — id `75458680` (color, switchable) -- API: HTTP Basic with `PRINTNODE_API_KEY:` (note the trailing colon - for an empty password — that's the PrintNode convention). -- List printers (sanity check): - `curl -u $PRINTNODE_API_KEY: https://api.printnode.com/printers` - -### CloudConvert (https://cloudconvert.com/) -- Account: `yjturetsky@gmail.com` -- API key scopes: `task.read`, `task.write` -- Free tier ~25 conversions/day. -- Endpoint shape used: `POST /v2/jobs` then poll `/v2/jobs/{id}/wait` - with `output_format=pdf`. - -### Gmail (read attachments + send notification) -- Reuse the existing `gauth-broker` Cloudflare Worker — same model as - `gv-worker`. Service binding `GAUTH` + `GAUTH_KEY` secret. -- Broker URL: https://gauth-broker.yjturetsky.workers.dev -- Existing client API keys in broker KV: `gv-worker`, `laptop-yaakov`, - `laptop-father`. **mailpress will need its own** — add to - `/home/yaakov/code/gauth/.local-clients.json` and re-upload via: - ``` - cd /home/yaakov/code/gauth/broker && \ - npx wrangler kv key put 'clients' --binding=STATE --path=../.local-clients.json - ``` - - -## Cloudflare Worker secrets that were on `gv-sms-worker` - -From the May-13 deployments (still present on the deployed worker as -of 2026-05-18 per RECAP "remain on the worker but unused"): - -- `PRINTNODE_API_KEY` -- `CLOUDCONVERT_API_KEY` - -Plus vars (in `wrangler.toml [vars]` on the old worker): - -- `PRINTNODE_PRINTER_ID = "75458677"` -- `NOTIFY_EMAIL = "aturetsky@gmail.com"` - -Confirmed present on Cloud version `ff582db9-9cee-47b9-b3df-bfb940e12f69` -(2026-05-13T22:56:24Z) via `npx wrangler versions view`. Secrets list -shows both `PRINTNODE_API_KEY` and `CLOUDCONVERT_API_KEY`. - - -## Recovering the old source (if we want the actual code, not a rewrite) - -`gv-worker` is **not** under git, and the source was deleted locally. -The pre-deletion code only lives on Cloudflare. Candidate version IDs -(all from 2026-05-13, when the printing extension was live): - -| Version ID | Created (UTC) | -|-----------------------------------------|----------------------| -| `a41c546a-6bce-4c70-be43-ef84fdb96470` | 2026-05-13 03:30:20 | -| `eb6d8741-55d8-43fd-92c7-7b9b64d50141` | 2026-05-13 03:31:13 | -| `c6c32877-e34e-4c68-a798-96313348912e` | 2026-05-13 03:49:35 | -| `10897254-df83-479e-8907-a90c68640af5` | 2026-05-13 22:07:54 | -| `556ea7f3-8024-4f36-afc5-439cea28992e` | 2026-05-13 22:10:14 | -| `8a8ba1b6-bb45-4e8b-b727-eea949d10ecc` | 2026-05-13 22:16:50 | -| `ff582db9-9cee-47b9-b3df-bfb940e12f69` | 2026-05-13 22:56:24 | ← last known-good - -How to get the source back: - -- **Cloudflare dashboard**: Workers & Pages → `gv-sms-worker` → - *Deployments* → pick a May-13 version → *View source*. Copy paste. -- **CLI**: `wrangler versions view ` shows metadata only — there - is no `wrangler versions download` in v3.x. Dashboard is the path. - -`.wrangler/dryrun/index.js` is from May 12 (pre-printing) — it has 0 -references to print/pdf/attach, so it's no help. - - -## Stack decision (proposed, not committed) - -The original was a Cloudflare Worker + cron polling Gmail. If we -rebuild as `mailpress`, options: - -1. **Same shape, separate worker.** Own `wrangler.toml`, own KV (if - needed for dedupe), own cron, consumes `gauth-broker` via service - binding. Cleanest separation from the SMS bot. -2. **Re-fold into `gv-worker`** behind a flag. RECAP already accounts - for this: the secrets are still on that worker. Saves an - invocation, but couples two unrelated flows. - -Recommendation: **#1** — `mailpress` as a self-contained worker. The -SMS flow has its own constraints (timing, REBBI/TUTOR_REPLYTO logic) -and the printing flow has its own (printer online, conversion quota). -Two workers = two cron cadences, two failure domains. - - -## Open questions before coding - -- Which Gmail label / query does mailpress watch? Same - `label:Google-Voice is:unread`, or its own label like `print:queue`? - Probably its own — printing every GV inbound was a side-effect, not - the point. -- Dedupe: store printed-message IDs in KV so we don't re-print on cron - overlap. (Old worker used UNREAD as the gate. If we use a dedicated - label we can remove the label after printing.) -- Notification target: keep `aturetsky@gmail.com`? Or send to - `yjturetsky@gmail.com` since dad is no longer the consumer of this - flow? -- Printer default: stay with the Samsung M2020 (mono) or switch to the - EPSON ET-2800 (color)? - - -## Reference files (read first when picking this up) - -- `/home/yaakov/code/gv-worker/RECAP.txt` — lines 39–52 (secrets/vars), - 114–143 (printing extension flow), 200–201 (status when removed), - 234–236 (deleted function names). -- `/home/yaakov/code/gv-worker/src/index.ts` — current (post-removal) - worker; has the Gmail OAuth + MIME helpers worth reusing. -- `/home/yaakov/code/gauth/` — the OAuth broker. -- `/home/yaakov/code/gv-worker/SITREP.txt` — paused-session notes, - unrelated to printing but explains current `gv-worker` state. diff --git a/README.md b/README.md index b3a0f1f..355e1af 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,15 @@ 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\`. +1. Download `mailpress.exe`, `print-files.ps1`, and `config.example.json` + from the latest [GitHub Release][releases] (or the artifact of a + `build on windows` workflow run). 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. + picks a polling interval, 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 @@ -21,12 +23,18 @@ OAuth client itself — the wizard prints the exact links and steps. ## Install from source (no .exe) +Useful if Node 18+ is already on the office PC and you want to skip the +download dance. + ``` -git clone https://github.com/turetsky/mailpress.git +git clone https://github.com/Turetsky/mailpress.git cd mailpress node cli.mjs # launches the wizard if no config ``` +The Scheduled Task installed by the wizard will point at `node cli.mjs` +in this case, so the source tree has to stay where you cloned it. + ## Commands ``` @@ -111,7 +119,7 @@ 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) + prompt.mjs # readline wrapper + polling-interval picker gmail.mjs # OAuth refresh + Gmail API client + MIME helpers printer.mjs # Windows printer enumeration + printing + test print oauth.mjs # interactive OAuth consent flow @@ -120,10 +128,8 @@ lib/ 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 +config.example.json # template — copy to config.local.json assets/ icon.svg # source icon (envelope on slant + speed lines) icon.ico # multi-size .ico embedded into mailpress.exe diff --git a/cli.mjs b/cli.mjs index a8f2155..eefc5ab 100644 --- a/cli.mjs +++ b/cli.mjs @@ -121,6 +121,15 @@ async function main() { validateConfig(cfg).length > 0 || !new GmailClient(cfg, { log }).hasToken(); if (needsWizard) { + // Scheduled Task / cron / piped invocations have no TTY. Refuse + // to launch the interactive wizard in that case — it would hang + // forever waiting for stdin. Exit 2 so the operator can spot + // the failure in the task history. + if (!process.stdin.isTTY) { + log.error("config is missing or invalid, and there is no terminal to run the wizard in."); + log.error("Run `mailpress --setup` from a console to configure."); + return 2; + } const { runWizard } = await import("./lib/wizard.mjs"); await runWizard({ log }); try { cfg = loadConfig(); } @@ -144,8 +153,8 @@ async function main() { } 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. + // Exit 2 means "re-run --setup or --consent" so Scheduled Task / + // monitoring scripts can distinguish auth failure from generic crashes. const { GmailAuthError } = await import("./lib/gmail.mjs"); if (e instanceof GmailAuthError && e.fatal) return 2; return 1; diff --git a/consent.mjs b/consent.mjs deleted file mode 100644 index 53e5aac..0000000 --- a/consent.mjs +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node -// 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"); -} - -main().catch((e) => { - console.error("FATAL", e.stack || e.message); - process.exit(1); -}); diff --git a/index.mjs b/index.mjs deleted file mode 100644 index 74f4579..0000000 --- a/index.mjs +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -// 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 { 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"); - -async function main() { - let cfg; - try { - cfg = loadConfig(); - } catch (e) { - 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, - }); - 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) => { - // Pre-logger failures only. - console.error("FATAL", e.stack || e.message); - process.exit(1); -}); diff --git a/install-task.ps1 b/install-task.ps1 deleted file mode 100644 index 3a76371..0000000 --- a/install-task.ps1 +++ /dev/null @@ -1,77 +0,0 @@ -#requires -version 5.1 -<# -.SYNOPSIS - Install mailpress as a Windows Scheduled Task that runs at login and stays - running in the background. - -.DESCRIPTION - Creates a scheduled task named "mailpress" that launches - `node index.mjs` from this directory at every user logon, restarts on - failure, and runs hidden. Run once on the office PC, as the user that - will own the printer. - - Re-run safely — the task is recreated. - -.EXAMPLE - cd C:\mailpress - powershell -ExecutionPolicy Bypass -File .\install-task.ps1 -#> - -$ErrorActionPreference = "Stop" - -$taskName = "mailpress" -$here = Split-Path -Parent $MyInvocation.MyCommand.Definition -$nodeExe = (Get-Command node.exe -ErrorAction SilentlyContinue).Source -if (-not $nodeExe) { - Write-Error "node.exe not found in PATH. Install Node.js >=18 first (https://nodejs.org)." - exit 1 -} -Write-Host "Using node: $nodeExe" -Write-Host "Working dir: $here" - -# Remove existing task if present. -$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue -if ($existing) { - Write-Host "Removing existing scheduled task '$taskName'..." - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -} - -$action = New-ScheduledTaskAction ` - -Execute $nodeExe ` - -Argument "index.mjs" ` - -WorkingDirectory $here - -$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. Polls inbox and prints new messages." | Out-Null - -Write-Host "Scheduled task '$taskName' installed." -Write-Host "Starting it now..." -Start-ScheduledTask -TaskName $taskName - -Write-Host "" -Write-Host "Done. Useful commands:" -Write-Host " Get-ScheduledTask -TaskName $taskName" -Write-Host " Stop-ScheduledTask -TaskName $taskName" -Write-Host " Start-ScheduledTask -TaskName $taskName" -Write-Host " Get-Content '$here\mailpress.log' -Wait -Tail 50" diff --git a/lib/poll.mjs b/lib/poll.mjs index de32808..4035364 100644 --- a/lib/poll.mjs +++ b/lib/poll.mjs @@ -1,6 +1,6 @@ -// 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. +// The main polling loop. Owns the per-tick "list unread → fetch → print → +// mark read" cycle. Pure consumer of lib/gmail + lib/printer; wizard, +// doctor, and test menu share the same underlying code. // // Why polling and not push: Gmail does support push via Pub/Sub // (users.watch) and via IMAP IDLE. Both buy ~5s latency vs ~30s polling, diff --git a/lib/task.mjs b/lib/task.mjs index 4936162..90a0a33 100644 --- a/lib/task.mjs +++ b/lib/task.mjs @@ -1,5 +1,5 @@ // Windows Scheduled Task helpers. Installs mailpress as a "run at login, -// restart on failure" task pointing at the current exe (or `node index.mjs` +// restart on failure" task pointing at the current exe (or `node cli.mjs` // when running from source). import { spawn } from "node:child_process"; @@ -51,12 +51,8 @@ export async function taskExists() { // 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. +// `node cli.mjs` — cli.mjs detects non-TTY (Scheduled Task) and exits +// cleanly if config is missing instead of trying to launch the wizard. function actionTarget() { const root = installRoot(); const execPath = process.execPath; @@ -65,10 +61,8 @@ function actionTarget() { 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 }; + // No manual surrounding quotes — Register-ScheduledTask quotes internally. + return { execute: execPath, argument: resolveInRoot("cli.mjs"), workingDir: root }; } export async function installTask() { diff --git a/print-files.ps1 b/print-files.ps1 index 0d16a49..f0e6c6a 100644 --- a/print-files.ps1 +++ b/print-files.ps1 @@ -4,12 +4,13 @@ Print a batch of files to a specific Windows printer. .DESCRIPTION - Called by index.mjs after it has staged an email's body + attachments in a - spool folder. Temporarily sets the default printer to -PrinterName so that - "Start-Process -Verb Print" routes there (the Print verb has no built-in - printer target across all file types), prints each file, restores the - original default. Adds a small sleep between files so the spooler doesn't - drop jobs and so apps (Word/Excel/Edge) have time to close. + Called by mailpress (lib/printer.mjs) after it has staged an email's body + + attachments in a spool folder. Temporarily sets the default printer to + -PrinterName so that "Start-Process -Verb Print" routes there (the Print + verb has no built-in printer target across all file types), prints each + file, restores the original default. Adds a small sleep between files so + the spooler doesn't drop jobs and so apps (Word/Excel/Edge) have time to + close. .PARAMETER PrinterName Exact Windows printer name (Get-Printer | Select Name). diff --git a/scripts/build.mjs b/scripts/build.mjs index c909ae3..518ecda 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -153,7 +153,7 @@ async function embedIcon() { } function copyAssets() { - const assets = ["print-files.ps1", "config.example.json", "install-task.ps1"]; + const assets = ["print-files.ps1", "config.example.json"]; for (const a of assets) { const src = join(ROOT, a); if (existsSync(src)) { @@ -191,7 +191,7 @@ async function main() { copyAssets(); smokeTest(); console.log(`\nDone. Artifacts in ${DIST}:`); - for (const f of [EXE_NAME, "print-files.ps1", "install-task.ps1", "config.example.json"]) { + for (const f of [EXE_NAME, "print-files.ps1", "config.example.json", "mailpress.ico"]) { if (existsSync(join(DIST, f))) console.log(` ${f}`); } }