@bobfrankston/rmfmail 1.0.570 → 1.0.589
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/TODO.md +18 -3
- package/bin/build-rmfmailto-exe.js +83 -0
- package/bin/mailto.js +130 -0
- package/bin/mailto.js.map +1 -0
- package/bin/mailto.ts +122 -0
- package/bin/mailx.js +199 -3
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +188 -3
- package/bin/rmfmailto-src/Cargo.lock +92 -0
- package/bin/rmfmailto-src/Cargo.toml +20 -0
- package/bin/rmfmailto-src/build.rs +27 -0
- package/bin/rmfmailto-src/src/main.rs +143 -0
- package/bin/rmfmailto.exe +0 -0
- package/client/app.js +157 -0
- package/client/app.js.map +1 -1
- package/client/app.ts +157 -0
- package/client/components/alarms.js +5 -2
- package/client/components/alarms.js.map +1 -1
- package/client/components/alarms.ts +4 -2
- package/client/components/folder-tree.js +43 -2
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +40 -2
- package/client/lib/api-client.js +7 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +11 -0
- package/client/lib/mailxapi.js +3 -0
- package/client/styles/components.css +40 -0
- package/package.json +12 -12
- package/packages/mailx-imap/index.d.ts +46 -14
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +527 -187
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +518 -172
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-server/index.js +4 -4
- package/packages/mailx-server/index.js.map +1 -1
- package/packages/mailx-server/index.ts +4 -4
- package/packages/mailx-service/index.d.ts +14 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +61 -8
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +59 -8
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +2 -0
- package/packages/mailx-service/reconciler.d.ts.map +1 -1
- package/packages/mailx-service/reconciler.js +16 -0
- package/packages/mailx-service/reconciler.js.map +1 -1
- package/packages/mailx-service/reconciler.ts +15 -0
- package/packages/mailx-store/db.js +2 -2
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +2 -2
- package/packages/mailx-store/package.json +1 -1
package/TODO.md
CHANGED
|
@@ -14,12 +14,15 @@ These items don't need user input — source-only changes, compile-verified befo
|
|
|
14
14
|
|---|---|---|---|
|
|
15
15
|
| **1** | **C118 — STATUS-before-SELECT in syncFolder** | S | Issue `STATUS folder (UIDNEXT)` before SELECT. If `UIDNEXT - 1 <= local highestUid` skip SELECT and FETCH entirely — folder is up to date. Cuts SELECT calls by ~99% on idle folders. Avoids the bobma/Sent SELECT-wedge case Bob hit (server is fine, but SELECT on idle huge folder + dead-socket-not-detected = 300s timeout). `mailx-imap/index.ts:909`. |
|
|
16
16
|
| **2** | **C119 — Lazy folder sync** | M | Don't sync all 96 bobma folders on every periodic pass. Only sync (a) folders the user has expanded in the tree, (b) special-use folders (INBOX/Sent/Drafts/Trash), (c) folders touched by recent local actions. Other folders sync on-demand when user clicks them. Closes most of the Thunderbird-vs-mailx speed gap. Touches `mailx-imap/index.ts` syncAll loop. |
|
|
17
|
-
| **3** | **
|
|
17
|
+
| **3** | **C121 — Counting timer for server connect** | S | Visible elapsed-time counter while the connection is being established, so Bob can tell "DNS slow", "TCP handshake stuck", "TLS hung", "IMAP greeting missing", or "LOGIN slow" apart at a glance. Phases: `dns → tcp connect → tls handshake → imap greeting → LOGIN ack`. Each phase emits a start event with `phase` + `accountId`; client renders `Connecting to <host> (<phase>, 3.2s)` in the status bar with a 100 ms ticking elapsed counter. On success or failure, drop the indicator and log per-phase durations. Touches `node-tcp-transport.connect()` + `iflow-direct.imap-native.ts:connect`/`login` + a new `imapManager.emit("connectProgress", ...)` chain. Pairs naturally with C120 — same `connId` ID space so the timer indicator can name the connection that later times out. |
|
|
18
|
+
| **4** | **C122 — Recent-unread count overlay on taskbar icon** | S | Show recent-unread count as a numeric overlay on the running mailx taskbar icon (Win11 style: small badge in the lower-right corner of the icon). `setOverlayIcon` is already wired (used as a placeholder); needs (a) a number-rendered overlay bitmap (1 / 2 / … / 9+ glyphs at 16×16 or via `DrawText` to a memory DC) and (b) hook into the unread-counter event chain so the badge updates when `folderCountsChanged` or `idle` events arrive. Distinct from S65 — that one is "badge persists when mailx isn't running" and is blocked on tray-process / Win11 Notification Center integration; this one is the *while-running* number. |
|
|
19
|
+
| **6** | **C125 — Unify desktop + Android IMAP code paths** | M | Today's split (`mailx-imap` Node-only + `mailx-store-web` browser-only) duplicates orchestration that's protocol-identical. Differences boil down to (a) transport factory choice (b) whether clients are persistent — both parameters, not architectural divides. Plan: extract `mailx-imap-core` with ImapManager + ops queue + fast-lane + sync orchestration over a `Transport` and a `Storage` interface, no Node-specific imports. `mailx-imap` and `mailx-store-web` become thin shims that wire engine-specific Storage + Transport. `{persistent: true\|false}` flag on ImapManager covers the lifetime split (no fast-lane queue when ephemeral). Pre-req for Android non-Gmail IMAP without forking sync code. |
|
|
20
|
+
| **5** | **C124 — mailto handler on Linux + macOS** | S | Counterpart to P115 (Win). Linux: write a `~/.local/share/applications/rmfmail.desktop` with `MimeType=x-scheme-handler/mailto;` + `Exec=node /path/to/mailx.js --mailto %u`, `xdg-mime default rmfmail.desktop x-scheme-handler/mailto`, `update-desktop-database`. No exe-launcher needed — Linux pickers read `Name=` from the `.desktop` directly. macOS: real `.app` bundle (LaunchServices won't register a bare script) with `Info.plist` `CFBundleURLTypes` declaring `mailto` + a tiny launcher binary; `LSSetDefaultHandlerForURLScheme` programmatically sets the default. Rust `rmfmailto-src/` crate already portable — drop the Win32 imports and `cargo build --target x86_64-apple-darwin` produces the macOS launcher. |
|
|
21
|
+
~~done — C120 read transport diagnostics~~ shipped v1.0.583 (per-folder timeout error now includes `[conn#X r=YB w=ZB writes=N sinceLastRead=Tms]` snapshot for the doomed socket).
|
|
18
22
|
| 11 | **[P116](#ext116) — Signature edit in Settings menu** | M | Deferred v1.0.401 — turns out to be M (multi-account modal + save path + JSONC round-trip), not S. Real work when Settings UI gets its proper pass. |
|
|
19
23
|
| 13 | **S56 full AbortController plumbing** | L | Thread `AbortSignal` through `getMessage → fetchMessageBody → provider.fetchOne`. Touches IMAP + Gmail + Outlook providers. |
|
|
20
|
-
| **18** | **[P115](#ext115) — register mailx as Windows default `mailto:` handler** | M | User-priority. Click any `mailto:` link in browser/Word/Outlook → opens mailx compose. Registry-only (no MSIX). Owed: (a) HKCU\\SOFTWARE\\Classes registry write at install/setup, (b) `--mailto <url>` CLI in `bin/mailx.ts` that parses the URL and forwards to the running daemon's compose IPC, (c) `bin/mailx.ts -register-mailto` / `-unregister-mailto` admin commands, (d) "Default mail" toggle in Settings UI that calls the same. Split out of [C46](#ext46) — only the mailto half; Share-target + MSIX stay blocked. |
|
|
21
24
|
|
|
22
|
-
*Items 1–10, 12, 14–
|
|
25
|
+
*Items 1–10, 12, 14–18 shipped — see [Done section](#done-recent). P115 mailto handler shipped v1.0.583 (CLI + registry + IPC + client wiring). The Settings-UI "Default mail" toggle (item d in the original brief) is still owed; lands with the wider Settings UI rework alongside [P116](#ext116).*
|
|
23
26
|
|
|
24
27
|
### Deferred (not autonomous — blocked on user input or external system)
|
|
25
28
|
|
|
@@ -782,6 +785,18 @@ Was part of S1 "local-first reconciliation refactor" until 2026-04-23 when S1's
|
|
|
782
785
|
|
|
783
786
|
| Item | Version |
|
|
784
787
|
|---|---|
|
|
788
|
+
| **C123 — Dynamic fast-lane connection (per-account)** — second persistent IMAP client per account (`fastClients` map) lazy-allocated alongside the existing slow-lane `opsClients`. `withConnection` fast tasks queue and drain on the fast client; slow tasks continue on the original ops client. Both lanes drain CONCURRENTLY now (`runningFast` / `runningSlow` independent flags) — click-time body fetch and flag toggle never wait behind a multi-minute prefetch / backfill again. Costs +1 IMAP socket per active account (Bob's bobma: 3 sockets per account = ops + fast + IDLE, well under Dovecot's 20-conn cap). Stale-detect, error-discard, shutdown, disconnectOps all updated to handle both lanes. Ops verbose-IMAP wire trace propagated to the fast lane so click-time wedges show in the log. Replaces the chunking-as-yield-point hack from v1.0.586 (which is still useful but no longer load-bearing). | v1.0.587 |
|
|
789
|
+
| **Pull-to-refresh on Android (and any touch context)** — Thunderbird-style drag-down at the top of the message list fires a sync, same path as F5 / btn-sync. Indicator slides in with the pull, chevron rotates at 80px to signal "Release to refresh", spinner replaces it during the actual sync. Touch-only — desktop keeps F5 / sync button. Lives in `client/app.ts` so it ships to Android MAUI through the shared WebView build automatically. | v1.0.587 |
|
|
790
|
+
| **Click-time body fetch unblocked by long-running sync/prefetch** — `mailx-imap.syncFolder` set-diff backfill loop and `prefetchBodies` IMAP path both held the persistent ops client for entire-folder durations. A 33-UID prefetch that grew to 222 mid-flight held it for 100 minutes (Bob log 2026-05-08 08:30→10:11). During the hold, every interactive click went into iflow's commandChain BEHIND the slow ops, so the user's preview pane stayed blank. Fix: chunk both paths — prefetch through `withConnection({slow:true})` per 25-UID slice, syncFolder backfill ditto per 100 UIDs (was 500). Each chunk releases the queue between fetches so fast-lane body clicks interleave roughly every 1-30s instead of waiting for the whole folder. The chunk sizes balance throughput (one FETCH per chunk, iflow's `fetchChunkSize:10` makes that 1-3 sub-FETCHes) against latency (worst-case click wait). | v1.0.586 |
|
|
791
|
+
| **Body fetch silent-null no longer hangs the viewer** — `Reconciler.pumpBodyFetches` only emitted `bodyAvailable` when `fetchMessageBody` returned a buffer; on null (Gmail format=raw cap, UID hash collision, IMAP source-extraction empty) it neither emitted `bodyAvailable` NOR `bodyFetchError`, so the viewer's placeholder + listener-armed loop stayed waiting forever. Now: null result emits `bodyFetchError` with `transient:false` and a concrete reason, so the existing `mv-body-fetch-error` banner shows instead of a permanent blank. | v1.0.586 |
|
|
792
|
+
| **Prefetch data-loss fix — guard against "0 of N returned"** — `mailx-imap.prefetchBodies` was pruning the entire requested batch when `fetchBodiesBatch` returned successfully but with no bodies (FETCH parser miss, wrong-folder, transient that didn't throw). User-reported on 2026-05-08: `[prefetch] bobma: 0 bodies cached, 66 stale rows pruned (done)` — 66 messages silently deleted from local DB. Earlier fix only covered "thrown batch ⇒ don't prune" (the 296-message loss); this one closes the parallel "successful but empty batch" hole on both IMAP and Gmail-API paths. Now: prune only when at least one body returned. Zero-return logs `batch returned 0/N bodies — NOT pruning (set-diff reconcile owns deletion)` and defers to syncFolder's grace-window reconcile. Per Bob's 2026-05-07 set-diff insight: deletion is exclusively the set-diff path's responsibility. | v1.0.585 |
|
|
793
|
+
| **mailx → rmfmail (CLI strings + log filename + post-register hint)** — daily log file is now `rmfmail-YYYY-MM-DD.log`; the 30-day retention scanner also matches the legacy `mailx-` prefix so old files age out cleanly. `mailx-store/db.ts` schema-mismatch error: "Run 'rmfmail -rebuild'". `mailx-server/index.ts` startup banner: "rmfmail server starting…" / "rmfmail server running on …". `--register-mailto` post-output rewritten to point at the right Win11 Settings path, name the Store-app warning, and explain the hash-protected `UrlAssociations\mailto\UserChoice` so the user understands why a registry write alone can't flip the active default. `openLocalPath("log")` opens `rmfmail-…` first, falls back to legacy filename if the daemon hasn't restarted since the upgrade. | v1.0.584 |
|
|
794
|
+
| **Trailing-comma email validation** — `mailx-service.send()` `validateAndPrune` (was `validateList`) silently drops empty / whitespace-only address fragments instead of throwing "empty address". Pruned list replaces `msg.to/cc/bcc` so the artifact also doesn't reach SMTP envelope assembly. Bare-name fragments (no `@`) still throw — that's a different bug. | v1.0.584 |
|
|
795
|
+
| **C120 — TCP transport diagnostics in per-folder timeout** — `mailx-imap`'s `syncOne` per-folder Promise.race timeout used to throw a bare `per-folder timeout (360s): <path>` error with no transport context. Now reads `client.transport.diagnostics` at the timeout moment and appends `[conn#N r=YB w=ZB writes=W sinceLastRead=Tms]` — same format iflow-direct uses for its own command-level timeouts. Lets the `[sync] folder error` log distinguish "server stopped sending data" (sinceLastRead climbing) from "we never finished writing" (writes climbing, no reads) without crossing into iflow's own log timeline. | v1.0.583 |
|
|
796
|
+
| **P115 — Default mailto: handler (Windows registry)** — `mailx --mailto <url>` parses RFC 6068 fields and atomic-renames `~/.rmfmail/pending-mailto.json`; the running daemon's `fs.watch` on the dir picks the file up and emits an `openMailto` event, the client opens compose pre-populated. New `bin/mailto.ts` helper isolates URL parsing + write/consume. `mailx --register-mailto` / `--unregister-mailto` write/remove the `HKCU\Software\Classes\rmfmail` + `HKCU\Software\Clients\Mail\rmfmail` registry keys via `reg.exe`, so rmfmail shows up in Settings → Default apps → Email. Startup race avoided via service-side `consumePendingMailto` IPC: client polls on app init for a file that existed before the daemon's watcher armed (case: `--mailto` with no daemon running spawns one). Watch + poll both atomic-read+delete; whichever fires first wins, the other gets null. | v1.0.583 |
|
|
797
|
+
| **Spring-loaded folders polish (Q117)** — drag-hover-expand was already partially in but persisted to localStorage on every transient expand; reworked to keep the expand transient (auto-collapse on `dragend`) and named the `DRAG_HOVER_EXPAND_MS = 600` constant so it isn't a magic number. Tracked auto-expanded folders in `dragAutoExpanded` set; global dragstart/dragend listeners scoped to mailx-message MIME types so the watcher doesn't fire on unrelated drags (image drag, text selection, etc.). | v1.0.583 |
|
|
798
|
+
| **Calendar alarm popup actually fires** — `client/components/alarms.ts` `collectDueAlarms` was reading `ev.start` (undefined) instead of `ev.startMs` from the DB row; `alarm = -10 min epoch` was always less than `now - 1 hr` so the LOOKBACK filter rejected every event silently. After the fix, calendar reminders 10 min before start fire as designed; the OS popup + in-WebView fallback paths inherited from earlier implementation. Tasks were already correct (`t.dueMs`). | v1.0.583 |
|
|
799
|
+
| **Mailx-server: NodeTransport → NodeTcpTransport** — `packages/mailx-server/index.ts` was importing the now-removed `@bobfrankston/iflow-node` package. Switched to the canonical `@bobfrankston/node-tcp-transport` import (same path the IPC daemon uses in `bin/mailx.ts`) so the legacy `--server` Express mode at least compiles. Runtime still needs the C34 wiring before it's usable end-to-end, but the package now builds clean. | v1.0.583 |
|
|
785
800
|
| **Migrated from active tables — autonomous queue** — items 1–10, 12, 14–17 from the autonomous-work queue: C24 batch body prefetch (IMAP half via iflow-direct `fetchBodiesBatch`), Android keyboard input lag (rAF yield), Send leaves second compose open (parent postMessage close), prefetch priority (2-way + INBOX-first), custom From history (localStorage), multi-email contacts, duplicate Message-ID indicator (⇆ badge), per-field context menus (search-selected + search-from-sender), attachment drag-out (Chromium DownloadURL), elevated-run warning (`isElevated` + `--allow-elevated`), Send-pending virtual folder (pink-accented top-of-tree row), Copy-as-quoted in viewer menu, `MAILX_SERVER_TOKEN` for non-loopback `--server`, Gmail HTTP `/batch` for body prefetch (100 sub-requests), Android open native Contacts/Calendar via `mailxapi-intent://`. | v1.0.400–407 |
|
|
786
801
|
| **Migrated from active tables — priority** — S52 per-feature primary flag (`primaryCalendar` / `primaryTasks` / `primaryContacts`); S64 pinned-taskbar icon + relaunch (msger PKEY_AppUserModel_RelaunchCommand/Icon/DisplayName via VT_LPWSTR PROPVARIANT + per-window AUMID); S54 narrow half (compose full-screen via `isSmall`); compose full-screen on narrow; word-paste sanitizer (tracked separately in Near-term). | 2026-04-26 / v1.0.376 |
|
|
787
802
|
| **Migrated from active tables — categorized** — C27 unified outbox PARKED 2026-04-23 (stays on filesystem queue with atomic-rename claim); C47 Android send wired + persistent queue (`sync_actions` rows with raw RFC 2822, drained on startup + 2-min poll); C48 multi-instance audit (atomic-rename `.ltr` claim, `instance.json` PID lock, stale-claim sweeper); S4 dally retired 2026-04-23 (Ctrl+Z + compose Save/Discard/Cancel cover loss-recovery). | v1.0.376 |
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build the rmfmailto.exe Win11 mailto-handler launcher.
|
|
4
|
+
*
|
|
5
|
+
* Hooked into `npm run build` after tsc + the icon build. Skips work when:
|
|
6
|
+
* - We're not on Windows (the binary is Win-only; non-Win builds skip).
|
|
7
|
+
* - The pre-built `bin/rmfmailto.exe` is newer than every source file in
|
|
8
|
+
* `bin/rmfmailto-src/` (mtime gate — cargo's own incremental cache is
|
|
9
|
+
* a separate optimization that runs anyway).
|
|
10
|
+
*
|
|
11
|
+
* On a real rebuild, runs `cargo build --release` inside `bin/rmfmailto-src/`
|
|
12
|
+
* and copies `target/release/rmfmailto.exe` to `bin/rmfmailto.exe`. The
|
|
13
|
+
* pre-built binary is checked into git so `npm install -g
|
|
14
|
+
* @bobfrankston/rmfmail` ships it without needing Rust on the install
|
|
15
|
+
* machine — Rust is only required on the BUILD machine.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const SRC_DIR = path.join(__dirname, "rmfmailto-src");
|
|
25
|
+
const BUILT_EXE = path.join(SRC_DIR, "target", "release", "rmfmailto.exe");
|
|
26
|
+
const SHIPPED_EXE = path.join(__dirname, "rmfmailto.exe");
|
|
27
|
+
|
|
28
|
+
if (process.platform !== "win32") {
|
|
29
|
+
console.log("[rmfmailto] non-Windows host — skipping (binary is Win-only)");
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check cargo availability up-front so a missing toolchain produces a
|
|
34
|
+
// clearer message than the spawn error.
|
|
35
|
+
try {
|
|
36
|
+
execSync("cargo --version", { stdio: "pipe" });
|
|
37
|
+
} catch {
|
|
38
|
+
console.log("[rmfmailto] cargo not found — skipping build (using checked-in bin/rmfmailto.exe).");
|
|
39
|
+
console.log(" Install rustup-init.exe to enable rebuilds when the Rust source changes.");
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// mtime gate: if the shipped exe is newer than the latest mtime in the src
|
|
44
|
+
// tree, skip. Avoids re-invoking cargo on every build when nothing's changed.
|
|
45
|
+
function newestMtime(dir) {
|
|
46
|
+
let newest = 0;
|
|
47
|
+
const stack = [dir];
|
|
48
|
+
while (stack.length) {
|
|
49
|
+
const d = stack.pop();
|
|
50
|
+
for (const name of fs.readdirSync(d)) {
|
|
51
|
+
// Skip cargo's own output dir — `target/` mtimes are noise.
|
|
52
|
+
if (name === "target") continue;
|
|
53
|
+
const full = path.join(d, name);
|
|
54
|
+
const st = fs.statSync(full);
|
|
55
|
+
if (st.isDirectory()) stack.push(full);
|
|
56
|
+
else if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return newest;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const srcMtime = newestMtime(SRC_DIR);
|
|
63
|
+
let shippedMtime = 0;
|
|
64
|
+
try { shippedMtime = fs.statSync(SHIPPED_EXE).mtimeMs; } catch { /* missing */ }
|
|
65
|
+
if (shippedMtime > srcMtime) {
|
|
66
|
+
console.log("[rmfmailto] up-to-date — skipping cargo build");
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log("[rmfmailto] building (cargo --release)...");
|
|
71
|
+
const t0 = Date.now();
|
|
72
|
+
const r = spawnSync("cargo", ["build", "--release"], {
|
|
73
|
+
cwd: SRC_DIR,
|
|
74
|
+
stdio: "inherit",
|
|
75
|
+
shell: true,
|
|
76
|
+
});
|
|
77
|
+
if (r.status !== 0) {
|
|
78
|
+
console.error(`[rmfmailto] cargo build failed (exit ${r.status})`);
|
|
79
|
+
process.exit(r.status || 1);
|
|
80
|
+
}
|
|
81
|
+
fs.copyFileSync(BUILT_EXE, SHIPPED_EXE);
|
|
82
|
+
const sec = ((Date.now() - t0) / 1000).toFixed(1);
|
|
83
|
+
console.log(`[rmfmailto] built and copied to bin/rmfmailto.exe (${sec}s)`);
|
package/bin/mailto.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mailto: URL handling per RFC 6068.
|
|
3
|
+
*
|
|
4
|
+
* Click flow when mailx is registered as the OS-level mailto handler:
|
|
5
|
+
*
|
|
6
|
+
* 1. User clicks mailto link in browser/Word/Outlook.
|
|
7
|
+
* 2. Windows runs the registered command — `node mailx.js --mailto "%1"`.
|
|
8
|
+
* 3. CLI calls writePendingMailto() with the parsed fields.
|
|
9
|
+
* 4. If a daemon is running, it picks up the file via fs.watch and emits
|
|
10
|
+
* `openMailto` to the WebView; the client populates compose.
|
|
11
|
+
* 5. If no daemon is running, the CLI spawns one; the daemon scans the
|
|
12
|
+
* pending file at startup before reaching the event loop.
|
|
13
|
+
*
|
|
14
|
+
* The file-drop indirection avoids having to hold an IPC socket open in
|
|
15
|
+
* the daemon — file-watch is enough for the click rate (one click = one
|
|
16
|
+
* file write) and works the same way the outbox queue does.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
/** Parse a mailto: URL per RFC 6068. Returns the parsed fields with empty
|
|
21
|
+
* defaults for anything missing. Throws if the input doesn't start with
|
|
22
|
+
* `mailto:` so the CLI can surface a clear error. */
|
|
23
|
+
export function parseMailto(url) {
|
|
24
|
+
const trimmed = (url || "").trim();
|
|
25
|
+
if (!/^mailto:/i.test(trimmed)) {
|
|
26
|
+
throw new Error(`Not a mailto: URL — got ${trimmed.substring(0, 40)}`);
|
|
27
|
+
}
|
|
28
|
+
// Split path and query. RFC 6068 allows commas in the path (multiple
|
|
29
|
+
// recipients) and the standard URL encoding for both halves.
|
|
30
|
+
const noScheme = trimmed.replace(/^mailto:/i, "");
|
|
31
|
+
const qIdx = noScheme.indexOf("?");
|
|
32
|
+
const pathPart = qIdx === -1 ? noScheme : noScheme.substring(0, qIdx);
|
|
33
|
+
const queryPart = qIdx === -1 ? "" : noScheme.substring(qIdx + 1);
|
|
34
|
+
const decode = (s) => {
|
|
35
|
+
try {
|
|
36
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return s;
|
|
40
|
+
} // tolerate malformed %XX — better than crashing
|
|
41
|
+
};
|
|
42
|
+
const splitAddrs = (s) => s.split(",").map(a => decode(a).trim()).filter(Boolean);
|
|
43
|
+
const out = {
|
|
44
|
+
to: pathPart ? splitAddrs(pathPart) : [],
|
|
45
|
+
cc: [], bcc: [],
|
|
46
|
+
subject: "", body: "", inReplyTo: "",
|
|
47
|
+
writtenAt: Date.now(),
|
|
48
|
+
};
|
|
49
|
+
if (!queryPart)
|
|
50
|
+
return out;
|
|
51
|
+
for (const pair of queryPart.split("&")) {
|
|
52
|
+
const eq = pair.indexOf("=");
|
|
53
|
+
if (eq === -1)
|
|
54
|
+
continue;
|
|
55
|
+
const key = pair.substring(0, eq).toLowerCase();
|
|
56
|
+
const val = decode(pair.substring(eq + 1));
|
|
57
|
+
switch (key) {
|
|
58
|
+
case "to":
|
|
59
|
+
out.to.push(...splitAddrs(pair.substring(eq + 1)));
|
|
60
|
+
break;
|
|
61
|
+
case "cc":
|
|
62
|
+
out.cc.push(...splitAddrs(pair.substring(eq + 1)));
|
|
63
|
+
break;
|
|
64
|
+
case "bcc":
|
|
65
|
+
out.bcc.push(...splitAddrs(pair.substring(eq + 1)));
|
|
66
|
+
break;
|
|
67
|
+
case "subject":
|
|
68
|
+
out.subject = val;
|
|
69
|
+
break;
|
|
70
|
+
case "body":
|
|
71
|
+
out.body = val;
|
|
72
|
+
break;
|
|
73
|
+
case "in-reply-to":
|
|
74
|
+
out.inReplyTo = val;
|
|
75
|
+
break;
|
|
76
|
+
// RFC 6068 also lists `references` and arbitrary headers; mailx
|
|
77
|
+
// doesn't propagate those today (compose UI doesn't surface
|
|
78
|
+
// refs from a fresh-link click).
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
export function getPendingMailtoFile() {
|
|
84
|
+
const home = process.env.USERPROFILE || process.env.HOME || ".";
|
|
85
|
+
return path.join(home, ".rmfmail", "pending-mailto.json");
|
|
86
|
+
}
|
|
87
|
+
/** Write the pending-mailto file atomically. Atomic-rename so the daemon's
|
|
88
|
+
* fs.watch never sees a partial write. */
|
|
89
|
+
export function writePendingMailto(data) {
|
|
90
|
+
const target = getPendingMailtoFile();
|
|
91
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
92
|
+
const tmp = target + ".tmp";
|
|
93
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
94
|
+
fs.renameSync(tmp, target);
|
|
95
|
+
}
|
|
96
|
+
/** Read + delete the pending-mailto file. One-shot — caller acts on the
|
|
97
|
+
* result, file is gone whether or not the action succeeded. Returns null
|
|
98
|
+
* if no file present (or unparseable / older than 5 min). */
|
|
99
|
+
export function consumePendingMailto() {
|
|
100
|
+
const target = getPendingMailtoFile();
|
|
101
|
+
let raw;
|
|
102
|
+
try {
|
|
103
|
+
raw = fs.readFileSync(target, "utf-8");
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
// Always unlink, even on parse failure — a corrupt file shouldn't wedge
|
|
109
|
+
// the next click.
|
|
110
|
+
try {
|
|
111
|
+
fs.unlinkSync(target);
|
|
112
|
+
}
|
|
113
|
+
catch { /* */ }
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(raw);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
// Tolerate older client writes that skipped writtenAt; treat as fresh.
|
|
122
|
+
const age = Date.now() - (parsed.writtenAt || Date.now());
|
|
123
|
+
if (age > 5 * 60_000) {
|
|
124
|
+
// Stale file from a crashed CLI invocation that wrote it but never
|
|
125
|
+
// got the daemon to start. Drop it; user can click again.
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return parsed;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=mailto.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mailto.js","sourceRoot":"","sources":["mailto.ts"],"names":[],"mappings":"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"}
|
package/bin/mailto.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mailto: URL handling per RFC 6068.
|
|
3
|
+
*
|
|
4
|
+
* Click flow when mailx is registered as the OS-level mailto handler:
|
|
5
|
+
*
|
|
6
|
+
* 1. User clicks mailto link in browser/Word/Outlook.
|
|
7
|
+
* 2. Windows runs the registered command — `node mailx.js --mailto "%1"`.
|
|
8
|
+
* 3. CLI calls writePendingMailto() with the parsed fields.
|
|
9
|
+
* 4. If a daemon is running, it picks up the file via fs.watch and emits
|
|
10
|
+
* `openMailto` to the WebView; the client populates compose.
|
|
11
|
+
* 5. If no daemon is running, the CLI spawns one; the daemon scans the
|
|
12
|
+
* pending file at startup before reaching the event loop.
|
|
13
|
+
*
|
|
14
|
+
* The file-drop indirection avoids having to hold an IPC socket open in
|
|
15
|
+
* the daemon — file-watch is enough for the click rate (one click = one
|
|
16
|
+
* file write) and works the same way the outbox queue does.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
export interface MailtoData {
|
|
23
|
+
to: string[];
|
|
24
|
+
cc: string[];
|
|
25
|
+
bcc: string[];
|
|
26
|
+
subject: string;
|
|
27
|
+
body: string;
|
|
28
|
+
inReplyTo: string;
|
|
29
|
+
/** Wall-clock when the file was written; lets the daemon ignore stale
|
|
30
|
+
* files left behind by a crashed CLI invocation. */
|
|
31
|
+
writtenAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse a mailto: URL per RFC 6068. Returns the parsed fields with empty
|
|
35
|
+
* defaults for anything missing. Throws if the input doesn't start with
|
|
36
|
+
* `mailto:` so the CLI can surface a clear error. */
|
|
37
|
+
export function parseMailto(url: string): MailtoData {
|
|
38
|
+
const trimmed = (url || "").trim();
|
|
39
|
+
if (!/^mailto:/i.test(trimmed)) {
|
|
40
|
+
throw new Error(`Not a mailto: URL — got ${trimmed.substring(0, 40)}`);
|
|
41
|
+
}
|
|
42
|
+
// Split path and query. RFC 6068 allows commas in the path (multiple
|
|
43
|
+
// recipients) and the standard URL encoding for both halves.
|
|
44
|
+
const noScheme = trimmed.replace(/^mailto:/i, "");
|
|
45
|
+
const qIdx = noScheme.indexOf("?");
|
|
46
|
+
const pathPart = qIdx === -1 ? noScheme : noScheme.substring(0, qIdx);
|
|
47
|
+
const queryPart = qIdx === -1 ? "" : noScheme.substring(qIdx + 1);
|
|
48
|
+
|
|
49
|
+
const decode = (s: string): string => {
|
|
50
|
+
try { return decodeURIComponent(s.replace(/\+/g, " ")); }
|
|
51
|
+
catch { return s; } // tolerate malformed %XX — better than crashing
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const splitAddrs = (s: string): string[] =>
|
|
55
|
+
s.split(",").map(a => decode(a).trim()).filter(Boolean);
|
|
56
|
+
|
|
57
|
+
const out: MailtoData = {
|
|
58
|
+
to: pathPart ? splitAddrs(pathPart) : [],
|
|
59
|
+
cc: [], bcc: [],
|
|
60
|
+
subject: "", body: "", inReplyTo: "",
|
|
61
|
+
writtenAt: Date.now(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (!queryPart) return out;
|
|
65
|
+
for (const pair of queryPart.split("&")) {
|
|
66
|
+
const eq = pair.indexOf("=");
|
|
67
|
+
if (eq === -1) continue;
|
|
68
|
+
const key = pair.substring(0, eq).toLowerCase();
|
|
69
|
+
const val = decode(pair.substring(eq + 1));
|
|
70
|
+
switch (key) {
|
|
71
|
+
case "to": out.to.push(...splitAddrs(pair.substring(eq + 1))); break;
|
|
72
|
+
case "cc": out.cc.push(...splitAddrs(pair.substring(eq + 1))); break;
|
|
73
|
+
case "bcc": out.bcc.push(...splitAddrs(pair.substring(eq + 1))); break;
|
|
74
|
+
case "subject": out.subject = val; break;
|
|
75
|
+
case "body": out.body = val; break;
|
|
76
|
+
case "in-reply-to": out.inReplyTo = val; break;
|
|
77
|
+
// RFC 6068 also lists `references` and arbitrary headers; mailx
|
|
78
|
+
// doesn't propagate those today (compose UI doesn't surface
|
|
79
|
+
// refs from a fresh-link click).
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getPendingMailtoFile(): string {
|
|
86
|
+
const home = process.env.USERPROFILE || process.env.HOME || ".";
|
|
87
|
+
return path.join(home, ".rmfmail", "pending-mailto.json");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Write the pending-mailto file atomically. Atomic-rename so the daemon's
|
|
91
|
+
* fs.watch never sees a partial write. */
|
|
92
|
+
export function writePendingMailto(data: MailtoData): void {
|
|
93
|
+
const target = getPendingMailtoFile();
|
|
94
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
95
|
+
const tmp = target + ".tmp";
|
|
96
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
97
|
+
fs.renameSync(tmp, target);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read + delete the pending-mailto file. One-shot — caller acts on the
|
|
101
|
+
* result, file is gone whether or not the action succeeded. Returns null
|
|
102
|
+
* if no file present (or unparseable / older than 5 min). */
|
|
103
|
+
export function consumePendingMailto(): MailtoData | null {
|
|
104
|
+
const target = getPendingMailtoFile();
|
|
105
|
+
let raw: string;
|
|
106
|
+
try { raw = fs.readFileSync(target, "utf-8"); }
|
|
107
|
+
catch { return null; }
|
|
108
|
+
// Always unlink, even on parse failure — a corrupt file shouldn't wedge
|
|
109
|
+
// the next click.
|
|
110
|
+
try { fs.unlinkSync(target); } catch { /* */ }
|
|
111
|
+
let parsed: MailtoData;
|
|
112
|
+
try { parsed = JSON.parse(raw) as MailtoData; }
|
|
113
|
+
catch { return null; }
|
|
114
|
+
// Tolerate older client writes that skipped writtenAt; treat as fresh.
|
|
115
|
+
const age = Date.now() - (parsed.writtenAt || Date.now());
|
|
116
|
+
if (age > 5 * 60_000) {
|
|
117
|
+
// Stale file from a crashed CLI invocation that wrote it but never
|
|
118
|
+
// got the daemon to start. Drop it; user can click again.
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
package/bin/mailx.js
CHANGED
|
@@ -40,6 +40,155 @@ const args = process.argv.slice(2);
|
|
|
40
40
|
function hasFlag(name) { return args.includes(`-${name}`) || args.includes(`--${name}`); }
|
|
41
41
|
const verbose = hasFlag("verbose");
|
|
42
42
|
const isDaemon = hasFlag("daemon"); // internal: re-spawned detached process
|
|
43
|
+
// ── mailto: handler hooks (P115) ──
|
|
44
|
+
//
|
|
45
|
+
// Three CLI entry points feed the OS-level mailto integration:
|
|
46
|
+
// --mailto <url> Decode the URL, drop pending-mailto.json, then
|
|
47
|
+
// continue to the existing instance check —
|
|
48
|
+
// which exits if a daemon is already running
|
|
49
|
+
// (the running daemon picks the file up via
|
|
50
|
+
// fs.watch) or falls through to spawn one.
|
|
51
|
+
// --register-mailto Write Windows registry keys (HKCU\SOFTWARE\Classes)
|
|
52
|
+
// so click-to-mail flows in browsers / Word /
|
|
53
|
+
// Outlook resolve to mailx. No-op on non-Windows.
|
|
54
|
+
// --unregister-mailto Remove those registry keys.
|
|
55
|
+
//
|
|
56
|
+
// The decode step is done FIRST (before the version-mismatch instance check
|
|
57
|
+
// near line 104) so the file is on disk regardless of which startup branch
|
|
58
|
+
// is about to run.
|
|
59
|
+
{
|
|
60
|
+
const mailtoIdx = args.findIndex(a => a === "--mailto" || a === "-mailto");
|
|
61
|
+
if (mailtoIdx !== -1) {
|
|
62
|
+
const url = args[mailtoIdx + 1];
|
|
63
|
+
if (!url) {
|
|
64
|
+
console.error("Usage: rmfmail --mailto <mailto:url>");
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const { parseMailto, writePendingMailto } = await import("./mailto.js");
|
|
69
|
+
writePendingMailto(parseMailto(url));
|
|
70
|
+
// Strip --mailto and its argument from argv so the rest of the
|
|
71
|
+
// file (instance check, auto-detach, daemon startup) sees a
|
|
72
|
+
// clean command line equivalent to a bare `mailx`.
|
|
73
|
+
args.splice(mailtoIdx, 2);
|
|
74
|
+
process.argv.splice(mailtoIdx + 2, 2);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
console.error(`rmfmail --mailto: ${e.message}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (hasFlag("register-mailto") || hasFlag("unregister-mailto")) {
|
|
83
|
+
const unregister = hasFlag("unregister-mailto");
|
|
84
|
+
if (process.platform !== "win32") {
|
|
85
|
+
console.error("--register-mailto / --unregister-mailto are Windows-only.");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const { execSync } = await import("node:child_process");
|
|
89
|
+
// Registered command points at the rmfmailto.exe launcher (per-app
|
|
90
|
+
// binary at %LOCALAPPDATA%\rmfmail\bin\rmfmailto.exe). The exe has its
|
|
91
|
+
// own VERSIONINFO with FileDescription="rmfmail" so the Win11 "Select
|
|
92
|
+
// an app" picker shows "rmfmail" instead of falling through to the
|
|
93
|
+
// host EXE's name (which made the picker show "Node.js JavaScript
|
|
94
|
+
// Runtime" when we registered `node.exe ... mailx.js --mailto "%1"`
|
|
95
|
+
// directly). rmfmailto.exe just shells out to node + mailx.js with the
|
|
96
|
+
// same argv. Postinstall copies the binary from `bin/rmfmailto.exe`
|
|
97
|
+
// (shipped in the npm package) to the per-app location below.
|
|
98
|
+
const __perAppBin = path.join(process.env.LOCALAPPDATA || "", "rmfmail", "bin");
|
|
99
|
+
const __rmfmailtoExe = path.join(__perAppBin, "rmfmailto.exe");
|
|
100
|
+
const __pkgRmfmailtoExe = path.join(import.meta.dirname, "rmfmailto.exe");
|
|
101
|
+
if (fs.existsSync(__pkgRmfmailtoExe) && (!fs.existsSync(__rmfmailtoExe) ||
|
|
102
|
+
fs.statSync(__pkgRmfmailtoExe).mtimeMs > fs.statSync(__rmfmailtoExe).mtimeMs)) {
|
|
103
|
+
try {
|
|
104
|
+
fs.mkdirSync(__perAppBin, { recursive: true });
|
|
105
|
+
fs.copyFileSync(__pkgRmfmailtoExe, __rmfmailtoExe);
|
|
106
|
+
console.log(`Installed rmfmailto.exe to ${__rmfmailtoExe}`);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
console.error(`Failed to copy rmfmailto.exe to per-app dir: ${e.message}`);
|
|
110
|
+
// Fall through and try registering the in-package path so
|
|
111
|
+
// mailto still works, just from inside node_modules.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const __launcherExe = fs.existsSync(__rmfmailtoExe) ? __rmfmailtoExe : __pkgRmfmailtoExe;
|
|
115
|
+
if (!fs.existsSync(__launcherExe)) {
|
|
116
|
+
console.error(`rmfmailto.exe not found at ${__launcherExe} — package may have been built without it. Re-run 'npm run build' on the build machine.`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
// reg.exe value for `\"path\" \"%1\"` — double-backslash escapes for
|
|
120
|
+
// cmd.exe's shell layer, which strips one level before the value reaches
|
|
121
|
+
// reg.exe. Quoting %1 keeps `?` and `&` from being eaten by cmd before
|
|
122
|
+
// the EXE's argv layer.
|
|
123
|
+
const cmd = `\\"${__launcherExe}\\" \\"%1\\"`;
|
|
124
|
+
const keys = [
|
|
125
|
+
// [hive\\path, value-name, value-data]
|
|
126
|
+
[`HKCU\\Software\\Classes\\rmfmail\\shell\\open\\command`, "", cmd],
|
|
127
|
+
[`HKCU\\Software\\Classes\\rmfmail\\DefaultIcon`, "", `\\"${path.join(import.meta.dirname, "..", "client", "icon.ico")}\\",0`],
|
|
128
|
+
[`HKCU\\Software\\Classes\\rmfmail`, "URL Protocol", ""],
|
|
129
|
+
[`HKCU\\Software\\Classes\\rmfmail`, "", "URL:rmfmail Protocol"],
|
|
130
|
+
// Make rmfmail visible in Settings → Default apps → Email so the
|
|
131
|
+
// user can flip it to default with one click.
|
|
132
|
+
[`HKCU\\Software\\Clients\\Mail\\rmfmail`, "", "rmfmail"],
|
|
133
|
+
[`HKCU\\Software\\Clients\\Mail\\rmfmail\\Capabilities`, "ApplicationName", "rmfmail"],
|
|
134
|
+
[`HKCU\\Software\\Clients\\Mail\\rmfmail\\Capabilities`, "ApplicationDescription", "Local-first email client"],
|
|
135
|
+
[`HKCU\\Software\\Clients\\Mail\\rmfmail\\Capabilities\\URLAssociations`, "mailto", "rmfmail"],
|
|
136
|
+
[`HKCU\\Software\\RegisteredApplications`, "rmfmail", "Software\\Clients\\Mail\\rmfmail\\Capabilities"],
|
|
137
|
+
// Belt-and-suspenders for the Win11 picker's display-name lookup:
|
|
138
|
+
// some code paths read `HKCU\Software\Classes\Applications\<exe>`
|
|
139
|
+
// for the FriendlyAppName even when the registered ProgId already
|
|
140
|
+
// has Capabilities\ApplicationName set. Setting both makes the
|
|
141
|
+
// picker show "rmfmail" regardless of which path it follows.
|
|
142
|
+
[`HKCU\\Software\\Classes\\Applications\\rmfmailto.exe`, "FriendlyAppName", "rmfmail"],
|
|
143
|
+
[`HKCU\\Software\\Classes\\Applications\\rmfmailto.exe`, "ApplicationName", "rmfmail"],
|
|
144
|
+
[`HKCU\\Software\\Classes\\Applications\\rmfmailto.exe\\shell\\open\\command`, "", cmd],
|
|
145
|
+
];
|
|
146
|
+
if (unregister) {
|
|
147
|
+
// Best-effort recursive deletes. /f suppresses the y/n prompt.
|
|
148
|
+
for (const root of [
|
|
149
|
+
"HKCU\\Software\\Classes\\rmfmail",
|
|
150
|
+
"HKCU\\Software\\Clients\\Mail\\rmfmail",
|
|
151
|
+
"HKCU\\Software\\Classes\\Applications\\rmfmailto.exe",
|
|
152
|
+
]) {
|
|
153
|
+
try {
|
|
154
|
+
execSync(`reg delete "${root}" /f`, { stdio: "pipe" });
|
|
155
|
+
}
|
|
156
|
+
catch { /* not present — fine */ }
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
execSync(`reg delete "HKCU\\Software\\RegisteredApplications" /v rmfmail /f`, { stdio: "pipe" });
|
|
160
|
+
}
|
|
161
|
+
catch { /* */ }
|
|
162
|
+
console.log("rmfmail unregistered as a mailto: handler.");
|
|
163
|
+
console.log("Note: if rmfmail was the active default in Settings → Default apps → Email,");
|
|
164
|
+
console.log("Windows may keep the prior selection in UserChoice until you pick another app.");
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
for (const [keyPath, name, data] of keys) {
|
|
168
|
+
const valueArg = name ? `/v "${name}"` : "/ve";
|
|
169
|
+
const dataArg = data ? `/d "${data}"` : "";
|
|
170
|
+
const type = data ? "/t REG_SZ" : "/t REG_SZ /d \"\"";
|
|
171
|
+
const cmdLine = `reg add "${keyPath}" ${valueArg} ${type} ${dataArg} /f`;
|
|
172
|
+
try {
|
|
173
|
+
execSync(cmdLine, { stdio: "pipe" });
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
console.error(` failed: ${cmdLine}\n ${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
console.log("rmfmail registered as a mailto: handler.");
|
|
180
|
+
console.log("");
|
|
181
|
+
console.log("To make it the default in Windows 11:");
|
|
182
|
+
console.log(" Settings → Apps → Default apps → search \"rmfmail\" → click rmfmail");
|
|
183
|
+
console.log(" → \"Set default\" for the mailto link type.");
|
|
184
|
+
console.log("");
|
|
185
|
+
console.log("Win11 enforces a hash on `HKCU\\...\\UrlAssociations\\mailto\\UserChoice`,");
|
|
186
|
+
console.log("so the only programmatic ways to flip the active default are MSIX packaging");
|
|
187
|
+
console.log("(parked under C46) or a hash-aware tool like SetUserFTA. The picker may");
|
|
188
|
+
console.log("warn \"app must be from the Store\" — click \"More options\" / \"Choose another");
|
|
189
|
+
console.log("app on this PC\" to reach rmfmail.");
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
43
192
|
// Read our own version once — used for the instance file + upgrade check below.
|
|
44
193
|
const __selfRoot = path.join(import.meta.dirname, "..");
|
|
45
194
|
const __selfVersion = (() => {
|
|
@@ -169,7 +318,7 @@ const rebuildMode = hasFlag("rebuild");
|
|
|
169
318
|
const repairMode = hasFlag("repair");
|
|
170
319
|
const importMode = hasFlag("import");
|
|
171
320
|
// Validate arguments
|
|
172
|
-
const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "email", "mail", "daemon"];
|
|
321
|
+
const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "email", "mail", "daemon", "reauth", "mailto", "register-mailto", "unregister-mailto", "allow-elevated"];
|
|
173
322
|
for (const arg of args) {
|
|
174
323
|
const flag = arg.replace(/^--?/, "");
|
|
175
324
|
if (arg.startsWith("-") && !knownFlags.includes(flag)) {
|
|
@@ -950,7 +1099,10 @@ async function main() {
|
|
|
950
1099
|
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
|
951
1100
|
try {
|
|
952
1101
|
for (const name of fs.readdirSync(logDir)) {
|
|
953
|
-
|
|
1102
|
+
// Match both legacy `mailx-` and current `rmfmail-` filenames
|
|
1103
|
+
// so the 30-day prune cleans up history from before the
|
|
1104
|
+
// rebrand without leaving the old files orphaned.
|
|
1105
|
+
if (!/^(?:mailx|rmfmail)-\d{4}-\d{2}-\d{2}\.log$/.test(name))
|
|
954
1106
|
continue;
|
|
955
1107
|
const full = path.join(logDir, name);
|
|
956
1108
|
try {
|
|
@@ -977,7 +1129,7 @@ async function main() {
|
|
|
977
1129
|
const d = new Date();
|
|
978
1130
|
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`;
|
|
979
1131
|
};
|
|
980
|
-
const logPath = path.join(logDir, `
|
|
1132
|
+
const logPath = path.join(logDir, `rmfmail-${localDate()}.log`);
|
|
981
1133
|
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
|
982
1134
|
console.log = (...a) => { logStream.write(`${ts()} ${a.join(" ")}\n`); };
|
|
983
1135
|
console.error = (...a) => { logStream.write(`${ts()} ERROR ${a.join(" ")}\n`); };
|
|
@@ -1416,6 +1568,50 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
1416
1568
|
imapManager.on("messageRemoved", (payload) => {
|
|
1417
1569
|
handle.send({ _event: "messageRemoved", type: "messageRemoved", ...payload });
|
|
1418
1570
|
});
|
|
1571
|
+
// Pending mailto: handler (P115).
|
|
1572
|
+
//
|
|
1573
|
+
// Two pickup paths, both safe to fire concurrently because the
|
|
1574
|
+
// consume helpers atomically read+delete:
|
|
1575
|
+
// 1. Startup — client calls service.consumePendingMailto via IPC once
|
|
1576
|
+
// app.ts is ready. Handles the case where `mailx --mailto <url>`
|
|
1577
|
+
// spawned us; the file existed before fs.watch armed.
|
|
1578
|
+
// 2. Live click — fs.watch fires when a `mailx --mailto <url>` write
|
|
1579
|
+
// lands while we're already running. We push an `openMailto`
|
|
1580
|
+
// event; the client's onEvent listener opens compose.
|
|
1581
|
+
//
|
|
1582
|
+
// We do NOT pre-fire from the daemon at startup — that would race the
|
|
1583
|
+
// client poll and one of them would lose the file (whichever read it
|
|
1584
|
+
// first wins; the other gets null and never opens compose).
|
|
1585
|
+
{
|
|
1586
|
+
const { consumePendingMailto, getPendingMailtoFile } = await import("./mailto.js");
|
|
1587
|
+
try {
|
|
1588
|
+
const pendingPath = getPendingMailtoFile();
|
|
1589
|
+
const dir = path.dirname(pendingPath);
|
|
1590
|
+
const baseName = path.basename(pendingPath);
|
|
1591
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1592
|
+
fs.watch(dir, (_event, filename) => {
|
|
1593
|
+
if (filename !== baseName)
|
|
1594
|
+
return;
|
|
1595
|
+
if (!fs.existsSync(pendingPath))
|
|
1596
|
+
return;
|
|
1597
|
+
// Brief debounce — atomic rename fires twice on Windows
|
|
1598
|
+
// (rename + change). The second read returns null because
|
|
1599
|
+
// consume deleted the file on the first; the early-return
|
|
1600
|
+
// above just skips a wasted IPC frame.
|
|
1601
|
+
setTimeout(() => {
|
|
1602
|
+
const data = consumePendingMailto();
|
|
1603
|
+
if (!data)
|
|
1604
|
+
return;
|
|
1605
|
+
handle.send({ _event: "openMailto", type: "openMailto",
|
|
1606
|
+
to: data.to, cc: data.cc, bcc: data.bcc,
|
|
1607
|
+
subject: data.subject, body: data.body, inReplyTo: data.inReplyTo });
|
|
1608
|
+
}, 50);
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
catch (e) {
|
|
1612
|
+
console.error(` [mailto] watch setup failed: ${e.message}`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1419
1615
|
// Brief pause for WebView2 to initialize before starting IMAP (avoids stdin writes during init)
|
|
1420
1616
|
await new Promise(r => setTimeout(r, 500));
|
|
1421
1617
|
// Register all accounts (OAuth may open browser for Gmail — event loop stays free for IPC)
|