diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..0f38372
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -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
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 0000000..2c0a5cc
Binary files /dev/null and b/assets/icon-preview-256.png differ
diff --git a/assets/icon.ico b/assets/icon.ico
new file mode 100644
index 0000000..c7ee761
Binary files /dev/null and b/assets/icon.ico differ
diff --git a/assets/icon.svg b/assets/icon.svg
new file mode 100644
index 0000000..7fdd2d6
--- /dev/null
+++ b/assets/icon.svg
@@ -0,0 +1,22 @@
+
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(/