diff --git a/README.md b/README.md index bd857d5..b3a0f1f 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,23 @@ mailpress --help - 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) +- change how often mailpress checks for new mail (30s — 15min, custom) - 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. +### Polling vs push notifications + +mailpress polls Gmail every `pollIntervalMs` (default 5 min, configurable +in the wizard or via `--test`). Gmail also supports push notifications +via Pub/Sub (`users.watch`) and IMAP IDLE, both giving ~5-second +latency. They're not wired up because the extra setup (Cloud Pub/Sub +topic + 7-day watch renewal, or a separate IMAP client) isn't worth the +~30-second improvement over a 1-minute poll for an office printer. The +code that would change is `lib/poll.mjs` — see the comment at the top +of that file. + Exit codes: - `0` — success (or `--once` completed) - `1` — generic failure (see `mailpress.log`) diff --git a/lib/poll.mjs b/lib/poll.mjs index ce1fbcd..de32808 100644 --- a/lib/poll.mjs +++ b/lib/poll.mjs @@ -1,6 +1,15 @@ // 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. +// +// 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, +// at the cost of: +// - Pub/Sub: a Cloud Pub/Sub topic + grant + 7-day watch renewal +// - IMAP IDLE: a separate protocol client with reconnect logic +// For an office printer where "print sometime in the next minute" is +// fine, neither is worth the setup. The polling interval is configurable +// down to 10s (config.pollIntervalMs) — see the wizard / --test menu. import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; diff --git a/lib/prompt.mjs b/lib/prompt.mjs index 90ee9fb..7041ad6 100644 --- a/lib/prompt.mjs +++ b/lib/prompt.mjs @@ -61,3 +61,32 @@ export class Prompter { } } } + +// Shared polling-interval picker used by both wizard and test menu. +// Returns milliseconds. Validator floor is 10s; the wizard's default of +// 5min is rarely the right answer once people see how snappy 30s is. +export async function pickPollingInterval(prompter, log, currentMs) { + const presets = [ + { label: "30 seconds — near real-time, ~2,900 polls/day", value: 30_000 }, + { label: "1 minute — snappy, ~1,440 polls/day", value: 60_000 }, + { label: "5 minutes — relaxed default, ~290 polls/day", value: 300_000 }, + { label: "15 minutes — low-traffic inbox, ~96 polls/day", value: 900_000 }, + { label: "custom (enter seconds)", value: "custom" }, + ]; + // Mark whichever preset matches the current value as the suggested one. + for (const p of presets) { + if (p.value === currentMs) p.label = `${p.label} (current)`; + } + log.say(`Gmail's quota is huge — pick whatever feels right for the office:`); + const choice = await prompter.choose("How often should mailpress check for new mail?", presets); + if (choice !== "custom") return choice; + const secs = await prompter.ask("Polling interval in seconds (min 10):", { + default: currentMs ? Math.round(currentMs / 1000) : 60, + validate: (v) => { + const n = Number(v); + if (!Number.isFinite(n) || n < 10) return "must be a number >= 10"; + return null; + }, + }); + return Math.round(Number(secs) * 1000); +} diff --git a/lib/test.mjs b/lib/test.mjs index d11b73d..07f5ffb 100644 --- a/lib/test.mjs +++ b/lib/test.mjs @@ -12,13 +12,14 @@ 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"; +import { Prompter, dim, bold, pickPollingInterval } 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: "Switch the active printer (and save to config)", value: "switch" }, + { label: "Change how often mailpress checks for new mail", value: "interval" }, { label: "Quit", value: "quit" }, ]; @@ -54,6 +55,28 @@ async function actionFile(config, log, prompter) { log.success("send to printer complete (check the tray for the page)"); } +async function actionInterval(config, log, prompter) { + const chosen = await pickPollingInterval(prompter, log, config.pollIntervalMs); + if (chosen === config.pollIntervalMs) { + log.say("(same interval — no change)"); + return; + } + const ok = await prompter.confirm( + `Update config.local.json: pollIntervalMs = ${chosen} (${(chosen / 1000)}s) ?`, + { default: true }); + if (!ok) { + log.say(dim(" not saved")); + return; + } + const fresh = loadConfig() || {}; + fresh.pollIntervalMs = chosen; + saveConfig(fresh); + config.pollIntervalMs = chosen; + log.success(`polling interval set to ${chosen}ms 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 actionSwitch(config, log, prompter) { const chosen = await pickPrinter({ prompter, current: config.printerName, log, dim }); if (chosen === config.printerName) { @@ -124,6 +147,7 @@ export async function runTest({ log }) { 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 === "interval") await actionInterval(config, log, prompter); else if (choice === "all") await actionAll(config, log, prompter); } catch (e) { log.failure(e.message); diff --git a/lib/wizard.mjs b/lib/wizard.mjs index 6968453..4082fc4 100644 --- a/lib/wizard.mjs +++ b/lib/wizard.mjs @@ -14,7 +14,7 @@ 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"; +import { Prompter, bold, dim, pickPollingInterval } from "./prompt.mjs"; async function tryVerifyExistingToken(gmail, config, log) { try { @@ -152,6 +152,11 @@ export async function runWizard({ log }) { validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "doesn't look like an email", }); + // Polling interval. Future: Gmail Pub/Sub push subscription or IMAP + // IDLE for sub-second latency — not worth the extra Google Cloud + // setup for an office printer. + config.pollIntervalMs = await pickPollingInterval(prompter, log, config.pollIntervalMs); + // Persist before the consent flow so if it fails we don't lose creds. saveConfig(config); log.success("config saved");