Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
9 changes: 9 additions & 0 deletions lib/poll.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
29 changes: 29 additions & 0 deletions lib/prompt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
28 changes: 26 additions & 2 deletions lib/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion lib/wizard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
Loading