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
53 changes: 53 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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"
# No cache: requires a committed lockfile, which we don't ship.

- name: install build deps
# 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
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ config.local.json
.local-token.json
spool/
mailpress.log
mailpress-setup.log
*.log
dist/
120 changes: 120 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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=<dir>` 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 <app> (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
<https://myaccount.google.com/permissions> 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
```
Binary file added assets/icon-preview-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icon.ico
Binary file not shown.
22 changes: 22 additions & 0 deletions assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
161 changes: 161 additions & 0 deletions cli.mjs
Original file line number Diff line number Diff line change
@@ -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);
},
);
Loading
Loading