@bobfrankston/mailx-settings 0.1.13 → 0.1.15

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/docs/accounts.md CHANGED
@@ -11,6 +11,11 @@
11
11
  ```jsonc
12
12
  {
13
13
  "name": "Bob Frankston", // optional; default name applied to every account that doesn't override
14
+ "sig": { // optional; file-level signature applied to every account that doesn't override
15
+ "text": "—\nBob Frankston\nbob@example.com",
16
+ "html": false
17
+ },
18
+ "signature": "<p>—<br>Bob Frankston</p>", // optional; file-level legacy-form fallback (used if account has no `sig`/`signature`)
14
19
  "accounts": [
15
20
  {
16
21
  "id": "gmail", // short tag used in folder paths and the UI
@@ -19,7 +24,13 @@
19
24
  "imap": { "host": "imap.example.com", "port": 993, "tls": true, "auth": "password" },
20
25
  "smtp": { "host": "smtp.example.com", "port": 465, "tls": true, "auth": "password" },
21
26
  "enabled": true, // false skips this account at startup
22
- "identityDomains": ["alias.com"] // extra domains for Reply-From auto-detect
27
+ "identityDomains": ["alias.com"], // extra domains for Reply-From auto-detect
28
+ "sig": { // signature appended to NEW messages only (not replies/forwards)
29
+ "text": "—\nBob Frankston\nbob@example.com", // \n becomes <br>; HTML-escaped
30
+ "html": false // reserved; leave false (true would trust `text` as raw HTML)
31
+ },
32
+ "signature": "<p>—<br>Bob Frankston</p>" // alternative form: HTML string,
33
+ // applied to new + reply + forward
23
34
  }
24
35
  ],
25
36
  "keys": { // AI provider API keys (optional)
@@ -37,6 +48,8 @@
37
48
  - **auth** — `"password"` for traditional IMAP/SMTP, `"oauth2"` for Gmail/Google Workspace/Outlook.
38
49
  - **enabled** — set `false` to keep the account record but skip sync at startup.
39
50
  - **identityDomains** — addresses you receive at on alternative domains. When you Reply, rmfmail picks the matching identity address as From instead of the account's primary.
51
+ - **sig** — per-account signature for *new* messages (skipped on reply / forward / draft-resume). `text` is the plain-text body, HTML-escaped at insertion with `\n` → `<br>`. The optional `html` flag is reserved for "trust as raw HTML"; leave it `false` (or omit) for now. Specify either `sig` or `signature`, not both — `sig` wins if both are present and the message is new. May also be specified at the **file level** (alongside `name`) to apply across every account that doesn't define its own; per-account always wins over file-level.
52
+ - **signature** — legacy alternative: an HTML string applied to *new + reply + forward* (positioned before the quote on replies, at the end for new messages). Useful when you want the signature on every outgoing message regardless of context. Also supported at the **file level** as a global fallback.
40
53
  - **keys.anthropic / keys.openai** — API keys for the AI features (translate / proofread / summarize / autocomplete). Lives at the file's top level alongside `accounts:` so you set it once across all your devices. Generated at console.anthropic.com or platform.openai.com. Provider selection is in `preferences.jsonc`; the key here is read based on which provider is active. Empty string means "not configured" — the AI feature silently no-ops. Local Ollama provider needs no key.
41
54
 
42
55
  ## Notes
@@ -0,0 +1,90 @@
1
+ # npmglobalize — `--dist-tag` flag plumbing
2
+
3
+ Purpose: let callers pass `--tag <name>` through to `npm publish` so a release can land on a non-`latest` dist-tag without modifying `package.json`. Required by `mailx`'s prod/dev workflow (`docs/prod.md`); benign for every other consumer (default behavior unchanged).
4
+
5
+ ## File
6
+
7
+ `/c/users/bob/onedrive/xfer/bin/linuxbin/node_modules/@bobfrankston/npmglobalize/lib.js`
8
+
9
+ (Or wherever the maintained source lives — this file is the deployed copy. Edit the source repo instead and rebuild if there's one.)
10
+
11
+ ## Step 1 — argv parsing
12
+
13
+ Near the top of the script where other CLI flags are read, accept `--dist-tag` (and an alias `--tag`) followed by a tag name. Skip the value for the rest of argv processing.
14
+
15
+ ```js
16
+ let distTag = "";
17
+ {
18
+ const idx = process.argv.findIndex(a => a === "--dist-tag" || a === "--tag");
19
+ if (idx !== -1) {
20
+ distTag = process.argv[idx + 1] || "";
21
+ if (!distTag || distTag.startsWith("-")) {
22
+ console.error("npmglobalize: --dist-tag requires a tag name");
23
+ process.exit(1);
24
+ }
25
+ process.argv.splice(idx, 2);
26
+ }
27
+ }
28
+ ```
29
+
30
+ If npmglobalize already centralizes argv parsing (e.g. via minimist or a custom routine), insert the same logic in that path instead. Either way: extract once, store in a module-scope variable, strip from argv so downstream code doesn't see it.
31
+
32
+ ## Step 2 — forward to `npm publish`
33
+
34
+ Around line 4486:
35
+
36
+ ```js
37
+ const npmArgs = ['publish', tarballName];
38
+ ```
39
+
40
+ Change to:
41
+
42
+ ```js
43
+ const npmArgs = ['publish', tarballName];
44
+ if (distTag) npmArgs.push('--tag', distTag);
45
+ ```
46
+
47
+ That's the entire publish-side change. When `distTag` is empty (the default), `npmArgs` is identical to before.
48
+
49
+ ## Step 3 — log line
50
+
51
+ Where the script announces "Publishing X@Y to npm…", include the tag if present:
52
+
53
+ ```js
54
+ console.log(`Publishing ${name}@${version} to npm${distTag ? ` (tag ${distTag})` : ""}…`);
55
+ ```
56
+
57
+ Helps the rebuild-cmd output show clearly that the publish landed on `dev` and not `latest`.
58
+
59
+ ## Step 4 — verify
60
+
61
+ ```bash
62
+ # In a workspace whose package.json is publishable:
63
+ npmglobalize --dist-tag dev
64
+ npm view @bobfrankston/<package> dist-tags
65
+ # Expect: { latest: '<previous>', dev: '<just-published>' }
66
+ ```
67
+
68
+ Run again without the flag:
69
+
70
+ ```bash
71
+ npmglobalize
72
+ npm view @bobfrankston/<package> dist-tags
73
+ # Expect: { latest: '<just-published>', dev: '<earlier>' }
74
+ ```
75
+
76
+ If the second run accidentally bumped `dev` instead of `latest`, the argv strip in Step 1 didn't run.
77
+
78
+ ## Step 5 — release npmglobalize itself
79
+
80
+ If npmglobalize is installed via npm (it is — `@bobfrankston/npmglobalize`), bump its own version, publish, then `npm install -g @bobfrankston/npmglobalize` on every machine that uses it. Otherwise the new flag is only present on the machine you edited.
81
+
82
+ ## What this does NOT change
83
+
84
+ - Default publish target stays `@latest` — every existing caller of `npmglobalize` keeps working.
85
+ - No effect on version bumping, git-tag pushing, build orchestration, or the `package.json` `dependencies` rewrite. The flag only adds to the final `npm publish` invocation.
86
+ - Does not replace existing scripts that read npm dist-tags. dist-tags are managed in two ways now: (a) at publish time via `--tag`, (b) post-publish via `npm dist-tag add/rm`. Step 2 above only adds (a).
87
+
88
+ ## Effort estimate
89
+
90
+ 5 lines of code + a log-line tweak + a publish of npmglobalize. Maybe 15 minutes wall-clock including the verification steps.
@@ -0,0 +1,88 @@
1
+ # prod / dev release workflow — Android (MAUI APK)
2
+
3
+ Status: design only. Same intent as `prod.md` (npm side); the APK is a complete, self-contained binary so the unit of promotion is a *file*, not a dependency or dist-tag.
4
+
5
+ ## Simpler approach (Bob 2026-05-09)
6
+
7
+ No separate MAUI project. Same source tree produces both flavors; the difference is purely in the OUTPUT filename, and "what's currently prod" is decided by which file lives at the canonical prod URL.
8
+
9
+ ### Today
10
+
11
+ - One build target: `mailx-maui.apk` at `https://rmf39.aaz.lt/mailx/mailx-maui.apk`.
12
+ - `versions.json` carries the version string; updater compares to running.
13
+ - Every build overwrites the single APK. No history, no channels.
14
+
15
+ ### Phase 1 — start producing dev.apk too
16
+
17
+ While there's no formal "production release" yet, every build produces:
18
+
19
+ - `rmfmaildev.apk` (or `dev.apk`) — the just-built, latest dev tip. Updater on dev devices points here.
20
+ - `mailx-maui.apk` — unchanged for now (current production behavior). Updater on prod devices points here.
21
+
22
+ The build script writes both. Same APK contents initially — the dual output is just renamed copies. Devices on different channels read different filenames.
23
+
24
+ ### Phase 2 — once a release is declared
25
+
26
+ Build outputs:
27
+
28
+ - `rmfmaildev.apk` — every build. Always the freshest.
29
+ - `rmfmail-<version>.apk` — versioned filename, never overwritten. One file per build.
30
+
31
+ "Promoting" a known-good dev build to production = `cp rmfmail-1.0.NNN.apk rmfmail.apk` (or web-server-side: update a redirect / symlink / manifest pointer at `rmfmail.apk` to name the chosen versioned file).
32
+
33
+ The canonical URLs:
34
+
35
+ ```
36
+ https://rmf39.aaz.lt/mailx/rmfmail.apk ← prod pointer (chosen versioned APK)
37
+ https://rmf39.aaz.lt/mailx/rmfmaildev.apk ← latest dev build
38
+ https://rmf39.aaz.lt/mailx/rmfmail-1.0.NNN.apk ← every released version, immutable
39
+ ```
40
+
41
+ Roll back = re-point `rmfmail.apk` to a prior versioned APK.
42
+
43
+ ## What changes from the current build
44
+
45
+ `build-apk.cmd` / `build-apk.ts`:
46
+
47
+ - Read version from `package.json` (already does).
48
+ - Build APK once.
49
+ - Phase 1: write to `rmfmaildev.apk` AND `mailx-maui.apk` (mirror).
50
+ - Phase 2: write to `rmfmail-<version>.apk` (versioned, never overwritten) AND `rmfmaildev.apk` (latest dev mirror).
51
+ - Sync to web root.
52
+
53
+ Promotion script (`prod-android.ps1`?) — Phase 2 only:
54
+
55
+ - Take a version arg (or default to current dev build).
56
+ - Copy / point `rmfmail.apk` at that version's APK.
57
+ - Update `versions.json` so the updater on prod devices sees a newer version available.
58
+
59
+ `AppUpdater.cs` — channel awareness:
60
+
61
+ - Reads from configurable URL based on channel setting.
62
+ - Default: prod URL (`rmfmail.apk` / corresponding `versions.json`).
63
+ - Dev mode: dev URL.
64
+ - Channel toggle in Settings (or build-time constant in a separate flavor — but per Bob's preference, NO separate MAUI project; channel is a runtime setting).
65
+
66
+ ## What this does NOT need
67
+
68
+ - **No separate MAUI project**. The APK is the same binary regardless of which channel it serves; users with the dev URL configured get latest builds, users with the prod URL get whatever was last promoted. Source tree is single.
69
+ - **No dist-tag analog**. The "tag" is just the filename / URL the updater polls.
70
+ - **No flavor build**. Same package id, same signing key, same APK bytes — just two distribution endpoints (or, with Phase 2, two distinct files referenced from the manifests).
71
+
72
+ ## Coexistence on a single device
73
+
74
+ Single MAUI project means a single Android package id. Two builds CAN'T be installed side-by-side without diverging package ids (Android refuses). If side-by-side becomes a real need later, reintroduce the separate-MAUI-project idea — but that's the orthogonal "I want both versions on the same phone" design, not the prod/dev release flow.
75
+
76
+ ## Order
77
+
78
+ 1. **Phase 1 first**: build script outputs `rmfmaildev.apk` alongside today's `mailx-maui.apk`. Zero behavior change for prod users; dev users (on the dev channel) get latest. ~10 lines in `build-apk.ts`.
79
+ 2. **AppUpdater channel toggle**: Settings checkbox + persisted preference + URL switch. ~50 lines.
80
+ 3. **Phase 2 when ready**: switch to versioned filenames + `rmfmail.apk` pointer + promotion script. Done when there's a real first "production release" to designate.
81
+
82
+ ## Effort
83
+
84
+ - Phase 1: ~30 minutes (build script + manifest output).
85
+ - AppUpdater channel: ~1 hour (Settings UI + storage + URL plumbing).
86
+ - Phase 2: ~1 hour when activated (promotion script + versioned filenames + pointer mechanism on web root).
87
+
88
+ Total ~2.5 hours wall-clock when fully wired. Phase 1 alone gets you the "always-have-a-fresh-dev-build" benefit without any commitment to the formal release flow.
package/docs/prod.md ADDED
@@ -0,0 +1,224 @@
1
+ # prod / dev release workflow
2
+
3
+ Status: design. Two viable approaches; pick one. Both deliver the same shape:
4
+
5
+ - `rebuild.cmd` publishes builds without affecting what casual users get.
6
+ - A separate `prod.cmd` step promotes a known-good build to be what casual users install.
7
+ - Casual users keep typing `npm install -g @bobfrankston/rmfmail` (no flags).
8
+ - Dev users explicitly opt in.
9
+
10
+ ## Key invariant (answers the obvious confusion)
11
+
12
+ `@latest` is **what casual `npm install` gets**. It only moves when prod.cmd promotes. So between promotions:
13
+
14
+ ```
15
+ @latest = @prod = 1.0.609 ← last promoted, what users get
16
+ @dev = 1.0.620 ← every rebuild bumps this
17
+ ```
18
+
19
+ Casual users keep installing 1.0.609 until you say so. Dev tip never accidentally becomes the casual install.
20
+
21
+ ---
22
+
23
+ ## Approach A — npm dist-tags (one package)
24
+
25
+ ### Mechanics
26
+
27
+ npm's built-in [dist-tag](https://docs.npmjs.com/cli/v10/commands/npm-dist-tag) feature: one package, multiple movable pointers.
28
+
29
+ - `npm publish --tag dev` sets `@dev` to the new version. `@latest` untouched.
30
+ - `npm dist-tag add @bobfrankston/rmfmail@<v> prod` moves the `prod` pointer; `npm dist-tag add ... latest` moves `latest` too. Two HTTP calls, no republish.
31
+
32
+ ### Required changes
33
+
34
+ **1. npmglobalize** — accepts a `--dist-tag` flag and forwards to `npm publish`. ~5 lines. See `docs/npmglobalize-disttag.md` for the patch.
35
+
36
+ **2. rebuild.cmd** — invoke `npmglobalize --dist-tag dev`. One-line edit.
37
+
38
+ **3. prod.cmd** — new file. Explicit arg wins; otherwise query npm for the
39
+ current `@dev` and promote that. (`@dev` is the right default because you've
40
+ just published it via `rebuild.cmd` and want it to become user-facing.)
41
+
42
+ ```cmd
43
+ @echo off
44
+ setlocal
45
+ set V=%1
46
+ if "%V%"=="" (
47
+ for /f "delims=" %%v in ('npm view @bobfrankston/rmfmail dist-tags.dev') do set V=%%v
48
+ )
49
+ if "%V%"=="" (
50
+ echo No version supplied and 'npm view ... dist-tags.dev' returned empty.
51
+ echo Usage: prod.cmd [version] e.g. prod.cmd 1.0.611
52
+ exit /b 1
53
+ )
54
+ echo Promoting @bobfrankston/rmfmail@%V% to prod and latest...
55
+ call npm dist-tag add @bobfrankston/rmfmail@%V% prod
56
+ call npm dist-tag add @bobfrankston/rmfmail@%V% latest
57
+ echo Done.
58
+ endlocal
59
+ ```
60
+
61
+ Roll back to an older known-good: `prod.cmd 1.0.598` — explicit arg path.
62
+
63
+ The "default = local package.json" alternative (if you never want to query
64
+ the registry):
65
+
66
+ ```cmd
67
+ for /f "tokens=2 delims=:," %%v in ('findstr /b " \"version\":" "%~dp0app\package.json"') do set V=%%~v
68
+ set V=%V: =%
69
+ set V=%V:"=%
70
+ ```
71
+
72
+ Use whichever default matches your workflow — the explicit-arg path works
73
+ either way.
74
+
75
+ ### Workflow
76
+
77
+ 1. Edit code, run `rebuild.cmd` — version bumps, publishes as `@dev`. `@latest`/`@prod` unaffected.
78
+ 2. On the dev machine: `npm install -g @bobfrankston/rmfmail@dev` to grab the freshest build.
79
+ 3. When something feels stable: run `prod.cmd`. Promotes the current `package.json` version to `@prod` and `@latest`.
80
+ 4. On the prod machine (and any default install): `npm install -g @bobfrankston/rmfmail` (no tag) gets the newly-promoted version.
81
+
82
+ Roll back: `npm dist-tag add @bobfrankston/rmfmail@<earlier> prod` + `... latest`. No republish, no version bump.
83
+
84
+ ### Effort
85
+
86
+ - npmglobalize change: ~5 lines, ~5 minutes.
87
+ - `rebuild.cmd`: one-line edit.
88
+ - `prod.cmd`: 15 lines, fresh file.
89
+ - Test cycle: ~10 minutes.
90
+
91
+ ---
92
+
93
+ ## Approach B — wrapper packages
94
+
95
+ ### Mechanics
96
+
97
+ Split the current single package into three:
98
+
99
+ | Name | Role | Versioning |
100
+ |---|---|---|
101
+ | `@bobfrankston/rmfmailapp` | Today's actual code. | Bumps freely on every build. |
102
+ | `@bobfrankston/rmfmail` | Wrapper. `dependencies: { rmfmailapp: "1.0.609" }` pinned. | Bumps only on promotion. |
103
+ | `@bobfrankston/rmfmaildev` | Wrapper. `dependencies: { rmfmailapp: "*" }`. | Tracks rmfmailapp tip. |
104
+
105
+ Casual user: `npm install -g @bobfrankston/rmfmail` — installs the wrapper, which pulls in the pinned rmfmailapp via npm's normal dependency resolution. Wrapper's `bin` shim invokes rmfmailapp's actual entry point, so the CLI is identical.
106
+
107
+ Promotion = git commit on the wrapper bumping `dependencies.rmfmailapp` from one frozen version to another, plus `npm publish` of the wrapper.
108
+
109
+ ### Layout
110
+
111
+ ```
112
+ mailx/
113
+ rmfmailapp/ # was app/
114
+ package.json "name": "@bobfrankston/rmfmailapp"
115
+ bin/, packages/, client/ unchanged
116
+ rmfmail/ # NEW
117
+ package.json pins rmfmailapp version
118
+ bin/rmfmail.js shim that imports rmfmailapp's entry
119
+ rmfmaildev/ # NEW
120
+ package.json "*" range or always-repinned
121
+ bin/rmfmaildev.js shim
122
+ ```
123
+
124
+ ### `rmfmail/package.json`
125
+
126
+ ```json
127
+ {
128
+ "name": "@bobfrankston/rmfmail",
129
+ "version": "1.0.0",
130
+ "type": "module",
131
+ "bin": { "rmfmail": "bin/rmfmail.js" },
132
+ "dependencies": { "@bobfrankston/rmfmailapp": "1.0.609" }
133
+ }
134
+ ```
135
+
136
+ ### `rmfmail/bin/rmfmail.js`
137
+
138
+ ```js
139
+ #!/usr/bin/env node
140
+ import { createRequire } from "node:module";
141
+ const require = createRequire(import.meta.url);
142
+ await import(`file://${require.resolve("@bobfrankston/rmfmailapp/bin/mailx.js")}`);
143
+ ```
144
+
145
+ ### `rmfmaildev/package.json`
146
+
147
+ Same shape but `"@bobfrankston/rmfmailapp": "*"` so npm always resolves to the freshest published rmfmailapp.
148
+
149
+ ### Required changes
150
+
151
+ **1. Rename** `@bobfrankston/rmfmail` → `@bobfrankston/rmfmailapp` in `app/package.json` and any workspace deps that reference it. One-time.
152
+
153
+ **2. Create** `rmfmail/` and `rmfmaildev/` directories with the package.json + shim listed above. ~30 lines total.
154
+
155
+ **3. prod.cmd** — bumps the pin and publishes the wrapper:
156
+
157
+ ```cmd
158
+ @echo off
159
+ setlocal
160
+ set TARGET=%1
161
+ if "%TARGET%"=="" (
162
+ for /f "tokens=2 delims=:," %%v in ('findstr /b " \"version\":" "%~dp0rmfmailapp\package.json"') do set TARGET=%%~v
163
+ set TARGET=%TARGET: =%
164
+ set TARGET=%TARGET:"=%
165
+ )
166
+ node -e "const p=require('./rmfmail/package.json'); p.dependencies['@bobfrankston/rmfmailapp']='%TARGET%'; require('fs').writeFileSync('./rmfmail/package.json', JSON.stringify(p,null,2)+'\n');"
167
+ cd rmfmail
168
+ call npm version patch
169
+ call npm publish --access public
170
+ cd ..
171
+ git add rmfmail/package.json
172
+ git commit -m "rmfmail: pin rmfmailapp@%TARGET%"
173
+ endlocal
174
+ ```
175
+
176
+ **4. npmglobalize**: no changes required.
177
+
178
+ ### Workflow
179
+
180
+ 1. Build, run `rebuild.cmd` — publishes rmfmailapp.
181
+ 2. Dev install: `npm install -g @bobfrankston/rmfmaildev`. Pulls latest rmfmailapp through the `*` range.
182
+ 3. Promotion: `prod.cmd 1.0.611`. Edits rmfmail's pin, bumps wrapper patch version, publishes wrapper, commits.
183
+ 4. Casual install: `npm install -g @bobfrankston/rmfmail` resolves the new wrapper, npm fetches the pinned rmfmailapp, both land under the wrapper.
184
+
185
+ Roll back: edit rmfmail/package.json to a prior pin, `git revert`, republish wrapper.
186
+
187
+ ### Effort
188
+
189
+ - One-time rename: ~30 minutes.
190
+ - Wrapper skeletons: ~15 minutes.
191
+ - prod.cmd: ~15 minutes.
192
+ - Test: ~30 minutes.
193
+ - Total: ~1.5 hours up-front, then near-zero per promotion.
194
+
195
+ ---
196
+
197
+ ## Comparison
198
+
199
+ | | A: dist-tags | B: wrappers |
200
+ |--|--|--|
201
+ | Number of npm packages | 1 | 3 (app + 2 wrappers) |
202
+ | What "promotion" is | `npm dist-tag add` (HTTP call) | git commit + `npm publish` of wrapper |
203
+ | Promotion in git history | no (registry-only) | yes |
204
+ | Roll-back | retag prior version | `git revert` then republish wrapper |
205
+ | npmglobalize change needed | yes (`--tag` flag, ~5 lines) | no |
206
+ | Casual install command | `npm install -g @bobfrankston/rmfmail` | (same) |
207
+ | Dev install command | `... rmfmail@dev` | `... rmfmaildev` |
208
+ | One-time setup work | ~15 minutes | ~1.5 hours |
209
+ | Per-promotion work | seconds (registry call) | minutes (commit + publish) |
210
+ | Multi-channel future (beta, rc) | more dist-tags | more wrappers |
211
+
212
+ ## Recommendation
213
+
214
+ If you want it working today with minimum disturbance: **A (dist-tags)**.
215
+
216
+ If auditability of "what version was prod when" matters and you're willing to spend the one-time rename: **B (wrappers)**.
217
+
218
+ A → B migration later is straightforward (the rename and wrapper creation is the same work whenever you do it; dist-tags become a non-issue once wrappers exist).
219
+
220
+ ## What neither approach does
221
+
222
+ - Does not enable side-by-side coexistence on a single machine — that's the orthogonal `RMFMAIL_PROFILE` env-var design (separate config dir, AUMID, mailto handler, etc.).
223
+ - Does not change the in-app updater. mailx's existing version-check flow keeps working with either.
224
+ - Does not affect Android. APKs aren't on npm — `prod-android.md` is the parallel document with the equivalent channel-manifest design.
@@ -0,0 +1,141 @@
1
+ # Push Relay (C127, optional)
2
+
3
+ Status: design only — not yet built. Relevant when polling latency feels too slow OR when battery / quota matters more than now.
4
+
5
+ ## Problem
6
+
7
+ mailx polls Google Calendar / Gmail / Outlook for changes. Polling is cheap, simple, and works on every network — the right default. But it has two costs:
8
+
9
+ 1. **Latency floor.** Even at a 5 s syncToken cadence, "the user just rescheduled this on their phone" takes ~5 s to surface in mailx. For most workflows that's invisible; for "alarm fires for an event that was moved an hour ago" it isn't.
10
+ 2. **Battery / quota on mobile.** The Android shell polls the same way the desktop does; on a phone that's measurable wakeups.
11
+
12
+ Push notifications (Google's `events.watch`, Microsoft Graph webhook subscriptions, Gmail's `users.watch`) fix both, but only deliver to a public HTTPS URL. The desktop client doesn't have one. So we need a relay.
13
+
14
+ ## Shape
15
+
16
+ A standalone Node service in `MailApps/relayer/`, deployed wherever the operator runs other small services (alerter-style: tiny Express app, one config file, one long-running process). One relay process serves multiple users; per-user state is keyed by user-issued tokens.
17
+
18
+ ```
19
+ ┌──────────────┐ watch ┌─────────┐ webhook ┌───────────┐ WS/SSE ┌────────┐
20
+ │ Google / MS │ ──register── │ relayer │ ◀──────── │ relayer │ ◀────────│ rmfmail│
21
+ │ Calendar API │ │ /sub │ │ /hook/:id │ │ client │
22
+ └──────────────┘ └─────────┘ └───────────┘ └────────┘
23
+ ▲ │ ▲
24
+ │ events.watch( │ per-subscription state: │
25
+ │ address: relayer/hook │ upstream channel id, expiry, owner-token, │
26
+ │ token: per-sub secret │ fan-out endpoints │
27
+ │ ) │ │
28
+ └──── done by relayer on │ │
29
+ client request, with │ │
30
+ client-supplied OAuth │ │
31
+ access token (kept │ │
32
+ short-lived; client │ │
33
+ re-presents on │ │
34
+ renewal) │ │
35
+
36
+ ```
37
+
38
+ Three roles, separable:
39
+ - **Subscriber API** — clients POST to register what they want watched.
40
+ - **Hook receiver** — public endpoint(s) the upstream services post to.
41
+ - **Fan-out** — per-subscription WS or SSE that holds open client connections and pushes payloads.
42
+
43
+ ## API (relayer side)
44
+
45
+ Generic relay shape — caller specifies upstream service + the params that service needs to register a watch. The relay knows enough about each supported upstream to issue the watch-registration call and decode the inbound webhook envelope; it doesn't introspect payload content.
46
+
47
+ ```
48
+ POST /subscribe
49
+ body: {
50
+ upstream: "google-calendar" | "google-gmail" | "ms-graph-events" | …
51
+ params: { …upstream-specific watch args, e.g. calendarId, accessToken, expiry preference }
52
+ deliver: "sse" | "ws"
53
+ }
54
+ → 200 {
55
+ subscriptionId: "<uuid>",
56
+ deliverUrl: "/sub/<uuid>", // open SSE / WS here
57
+ authToken: "<random-secret>", // present on connect
58
+ expiresAt: <ms> // when relay's upstream channel needs renewal
59
+ }
60
+
61
+ POST /subscribe/<id>/renew
62
+ body: { params: { accessToken: "<fresh OAuth>" } }
63
+ → 200 { expiresAt: <ms> }
64
+
65
+ DELETE /subscribe/<id>
66
+ → 204 (also calls upstream stop-watch)
67
+
68
+ GET /subscribe/<id>/status
69
+ → 200 { upstream, expiresAt, lastEventAt, queueDepth }
70
+
71
+ GET /sub/<id> (SSE) — emits `data: {payload}\n\n` per upstream event
72
+ (or WS upgrade) — same payload shape as text frames
73
+ ```
74
+
75
+ Auth: `Authorization: Bearer <authToken>` on every call after `/subscribe`. The token issued at subscribe time scopes the subscription; one client manages one or more subscriptions, each with its own token.
76
+
77
+ ## API (client side)
78
+
79
+ Two settings the user fills in:
80
+ - `pushRelayUrl` — e.g. `https://mail1.example.com:NNNN`
81
+ - `pushRelayCredentials` — issued by the operator at first contact (one-time, opaque blob; relay verifies)
82
+
83
+ When `pushRelayUrl` is non-empty, the client at startup:
84
+ 1. Reads its previous subscription IDs from local config.
85
+ 2. For each, opens the SSE / WS connection.
86
+ 3. If the connection refuses with "unknown subscription" (relay restart, expiry), re-subscribes from scratch with the current OAuth token and stores the new id.
87
+ 4. Hooks the inbound event stream into the same `mailxapi.onEvent` channel the daemon already uses, so the alarm subsystem / message-list / folder-tree all consume push events the same way they consume the daemon's IDLE / sync events.
88
+
89
+ The client owns OAuth tokens; the relay receives short-lived access tokens at register / renew time, doesn't store refresh tokens.
90
+
91
+ ## Reliability
92
+
93
+ - **Replay queue per subscription** — last N events (in-memory ring, e.g. N=100) so a brief disconnect doesn't lose events. Client passes `Last-Event-ID` (SSE) or a sequence cursor (WS) on reconnect.
94
+ - **Channel renewal** — relay tracks each upstream channel's expiry, schedules renewal at 80% of TTL, calls back to the client (via the WS/SSE itself) for a fresh OAuth token if needed.
95
+ - **Fail-open** — if the relay is unreachable, the client's existing syncToken polling continues. Push is "freshness boost," never load-bearing for correctness.
96
+
97
+ ## Generic relay vs per-upstream code
98
+
99
+ The relay needs per-upstream code for:
100
+ - How to register a watch (the API call shape varies).
101
+ - How to decode the inbound webhook envelope (Google sends headers in `X-Goog-*`, Graph sends a JSON body, etc.).
102
+ - How to renew or stop a watch.
103
+
104
+ Per-upstream is a small adapter (~50–100 lines each). Adding "Outlook Tasks" or "iCloud Calendar" later is a new file in `relayer/upstreams/`. The fan-out, subscription store, WS/SSE plumbing, replay buffer — all generic, written once.
105
+
106
+ ## Operator footprint
107
+
108
+ - One Node process, one port, one HTTPS cert (or behind a reverse proxy that handles TLS).
109
+ - Storage: SQLite or JSON for the subscription map (small — one row per active subscription).
110
+ - Logs to a file the operator already tails.
111
+ - No queue infrastructure, no message broker, no DB cluster.
112
+
113
+ This is the same operational shape as alerter — drop into `MailApps/`, deploy alongside other small services, treat as one more daemon.
114
+
115
+ ## When to build this
116
+
117
+ Only when polling demonstrably isn't fast enough, OR when mobile battery becomes a real concern (push lets the Android shell wake on event instead of polling on a timer). Polling at 5–10 s syncToken cadence is functionally instant for the "user rescheduled an event" case and quota-cheap. Push is a freshness optimization, not a correctness fix.
118
+
119
+ ## Alternatives considered
120
+
121
+ - **Cloudflare Workers + Durable Objects** — cleaner per-user-isolation story but requires each user to deploy their own Worker (or one shared operator-run Worker, with a different trust shape). Higher onboarding friction; users without a CF account can't use it. Pro: free tier, edge latency. Con: vendor lock-in, two-click onboarding minimum (account + token).
122
+ - **Cloudflare Tunnel / ngrok to the local daemon** — no relay needed, the daemon exposes its own webhook receiver. Catch: only works while the daemon is running; events while offline are lost (recovered by the next syncToken poll, which already exists). Per-instance tunnel.
123
+ - **Status quo (poll only)** — what mailx does today. Right default; this doc is for when "default isn't enough."
124
+
125
+ ## Files (when built)
126
+
127
+ ```
128
+ MailApps/relayer/
129
+ package.json
130
+ tsconfig.json
131
+ index.ts — Express server + WS upgrade + SSE
132
+ upstreams/
133
+ google-calendar.ts
134
+ google-gmail.ts
135
+ ms-graph-events.ts
136
+ subs.ts — subscription store (SQLite)
137
+ fanout.ts — per-sub connection pool + replay buffer
138
+ readme.md — operator doc (deploy + config)
139
+ ```
140
+
141
+ Client side: extend `mailx-service` with a `pushRelayClient.ts` that opens / re-opens / re-subscribes; surface its status in the existing sync-status banner.
@@ -0,0 +1,156 @@
1
+ # `rmf-tiny` — optional TinyMCE editor adapter for rmfmail
2
+
3
+ Status: design only. Not implemented.
4
+
5
+ ## Goal
6
+
7
+ Let users who want Thunderbird-class paste fidelity opt into TinyMCE without baking it into rmfmail itself. Bob 2026-05-09 verified: pasted a Word document into Thunderbird, tiptap, Quill, and TinyMCE — only TinyMCE preserved the formatting (boxed monospace block with borders, paragraph spacing, font preservation). Nothing else came close.
8
+
9
+ The license catch — TinyMCE is GPLv2 — means rmfmail (MIT) can't bundle it. The arms-length pattern is the resolution: rmfmail ships **no** TinyMCE bytes; a separate `rmf-tiny` package the user installs themselves provides the adapter + pulls in TinyMCE via npm.
10
+
11
+ ## Shape
12
+
13
+ ```
14
+ @bobfrankston/rmfmail ← MIT, ships no TinyMCE
15
+ client/compose/editor.ts ← MailxEditor interface + factory
16
+ gains a "tinymce" branch that
17
+ dynamically imports rmf-tiny
18
+
19
+ @bobfrankston/rmf-tiny ← MIT (the adapter glue is original code)
20
+ package.json ← peerDependency: tinymce
21
+ src/adapter.ts ← implements MailxEditor against TinyMCE
22
+ README.md ← install instructions
23
+ ```
24
+
25
+ User opts in:
26
+
27
+ ```
28
+ npm install -g @bobfrankston/rmf-tiny tinymce
29
+ ```
30
+
31
+ (Two packages explicit — keeps the licensing posture clean. rmf-tiny is just the adapter; tinymce comes from Tiny's own npm publication, not bundled with anything rmfmail-related.)
32
+
33
+ In rmfmail Settings → Compose → Editor: the dropdown grows a "TinyMCE (requires rmf-tiny)" option. If selected and the import fails, surface a status-bar error pointing at the install command.
34
+
35
+ ## Why a separate package, not a flag inside rmfmail
36
+
37
+ - rmfmail's git history and npm tarball never contain TinyMCE → distribution layer is clean.
38
+ - rmf-tiny is small (~200 lines of adapter glue + a thin install README) and can be MIT — no GPL infection because it imports TinyMCE at runtime via the same kind of dynamic boundary npm packages always use. Combination at user's machine is fully GPL-permitted.
39
+ - Other editors can follow the same pattern (`rmf-ckeditor`, `rmf-prosemirror-pro`) without polluting rmfmail.
40
+
41
+ ## Why call it `rmf-tiny`
42
+
43
+ Short, distinguishable from the upstream package, namespaces under your scope (`@bobfrankston/rmf-tiny`), and "tiny" is the upstream's own brand so the connection is obvious. Other naming options:
44
+ - `@bobfrankston/mailx-tinymce-adapter` (verbose, descriptive)
45
+ - `@bobfrankston/rmftiny` (no hyphen, matches rmfmail style)
46
+
47
+ `rmf-tiny` is fine; pick the form that scans cleanest in the install line.
48
+
49
+ ## Files
50
+
51
+ ### `rmf-tiny/package.json`
52
+
53
+ ```json
54
+ {
55
+ "name": "@bobfrankston/rmf-tiny",
56
+ "version": "0.1.0",
57
+ "description": "TinyMCE editor adapter for rmfmail. Bring-your-own TinyMCE.",
58
+ "type": "module",
59
+ "main": "src/adapter.js",
60
+ "license": "MIT",
61
+ "peerDependencies": {
62
+ "tinymce": ">=6"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "tinymce": { "optional": false }
66
+ }
67
+ }
68
+ ```
69
+
70
+ `peerDependencies` puts the install responsibility on the user — `npm install @bobfrankston/rmf-tiny` warns that `tinymce` must also be installed; rmf-tiny does not vendor or bundle TinyMCE.
71
+
72
+ ### `rmf-tiny/src/adapter.ts`
73
+
74
+ ```ts
75
+ // MIT-licensed adapter glue. Imports the user-installed TinyMCE at
76
+ // runtime; doesn't redistribute any TinyMCE bytes. Implements the same
77
+ // `MailxEditor` interface rmfmail's createEditor factory expects, so
78
+ // the host code path is identical.
79
+ import type { Editor as TinyEditor } from "tinymce";
80
+
81
+ export interface MailxEditor {
82
+ setHtml(html: string): void;
83
+ getHtml(): string;
84
+ getText(): string;
85
+ focus(): void;
86
+ setCursor(pos: number): void;
87
+ root: HTMLElement;
88
+ // …other methods rmfmail's interface requires
89
+ }
90
+
91
+ export async function createTinyMceEditor(container: HTMLElement): Promise<MailxEditor> {
92
+ // Dynamic import — fails clearly if the user hasn't installed tinymce.
93
+ const tinymce = (await import("tinymce")).default;
94
+ // Plus the plugins TinyMCE's paste-from-Word handler needs:
95
+ await Promise.all([
96
+ import("tinymce/themes/silver"),
97
+ import("tinymce/icons/default"),
98
+ import("tinymce/plugins/paste"),
99
+ import("tinymce/plugins/lists"),
100
+ import("tinymce/plugins/link"),
101
+ import("tinymce/plugins/table"),
102
+ import("tinymce/plugins/code"),
103
+ ]);
104
+ // ... initialize tinymce against `container`, return MailxEditor shim.
105
+ }
106
+ ```
107
+
108
+ The actual init/wire-up is the work — wrapping TinyMCE's API in our `MailxEditor` shape, hooking paste events, setHtml/getHtml round-tripping, etc. Maybe ~200 lines.
109
+
110
+ ### rmfmail-side change to `editor.ts`
111
+
112
+ ```ts
113
+ export async function createEditor(
114
+ container: HTMLElement,
115
+ type: "quill" | "tiptap" | "tinymce"
116
+ ): Promise<MailxEditor> {
117
+ if (type === "tinymce") {
118
+ try {
119
+ const m = await import("@bobfrankston/rmf-tiny");
120
+ return m.createTinyMceEditor(container);
121
+ } catch (e: any) {
122
+ const status = document.getElementById("status-sync");
123
+ if (status) status.textContent = `TinyMCE editor not available — install: npm install -g @bobfrankston/rmf-tiny tinymce`;
124
+ return createQuillEditor(container); // fall back so compose still works
125
+ }
126
+ }
127
+ if (type === "tiptap") return createTiptapEditor(container);
128
+ return createQuillEditor(container);
129
+ }
130
+ ```
131
+
132
+ Settings panel adds the "TinyMCE" option in the editor dropdown.
133
+
134
+ ## Effort
135
+
136
+ - `rmf-tiny` package skeleton (package.json, README, basic adapter shell): ~30 minutes.
137
+ - Adapter wiring (init, setHtml/getHtml, paste handling, toolbar config): ~3 hours including testing the Word-paste case.
138
+ - rmfmail-side factory branch + Settings dropdown option: ~30 minutes.
139
+ - Documentation for users (README in rmf-tiny + Settings tooltip): ~30 minutes.
140
+
141
+ About half a day total.
142
+
143
+ ## Order
144
+
145
+ 1. Build `rmf-tiny` against your local TinyMCE install. Verify the Word-paste case actually works through your adapter.
146
+ 2. Add the factory branch and Settings option to rmfmail.
147
+ 3. Publish `rmf-tiny` to npm.
148
+ 4. Document the opt-in install command in rmfmail's Settings tooltip and `README.md`.
149
+
150
+ Don't publish the adapter until the Word-paste case actually works end-to-end through the adapter — TinyMCE direct is one thing, our adapter possibly mangles it differently.
151
+
152
+ ## Caveats
153
+
154
+ - The adapter must not re-implement parts of TinyMCE — it's a thin wrapper. Re-implementing parts of GPL code in MIT is the legal trap; staying purely in adapter / interface territory is safe.
155
+ - TinyMCE's bundle is large (~1MB minified). Users opting in pay that cost on the first compose-window load. Fine for opt-in; would be unacceptable as a default.
156
+ - `rmf-tiny` major version should track TinyMCE's major version (rmf-tiny@7.x for tinymce@7.x) so peerDependency mismatches are obvious.
package/index.d.ts CHANGED
@@ -46,7 +46,7 @@ export declare function getStorageInfo(): {
46
46
  /** Fill in provider defaults for an account based on email domain.
47
47
  * Exported so mailx-service's leanAccountsJsonc helper can reuse the same
48
48
  * canonicalization rules that loadAccounts uses. */
49
- export declare function normalizeAccount(acct: any, globalName?: string): AccountConfig;
49
+ export declare function normalizeAccount(acct: any, globalName?: string, globalSig?: any, globalSignature?: string): AccountConfig;
50
50
  declare const DEFAULT_PREFERENCES: {
51
51
  ui: {
52
52
  theme: "system" | "dark" | "light";
@@ -101,7 +101,7 @@ export declare function loadAccountsAsync(): Promise<AccountConfig[]>;
101
101
  * - Drop `imap.user` / `smtp.user` if they equal the email.
102
102
  * - Drop `sig.html: false` (default; only keep when user enabled HTML sigs).
103
103
  * - Keep field order: id → label → email → primary* → imap → smtp → defaultSend
104
- * → relayDomains → deliveredToPrefix → identityDomains → syncContacts → sig.
104
+ * → relayDomains → identityDomains → syncContacts → sig.
105
105
  * Curated for readability, not alphabetic. */
106
106
  export declare function denormalizeAccount(acct: AccountConfig, globalName?: string): any;
107
107
  /** Save account configs */
@@ -155,7 +155,6 @@ export declare function loadAllowlist(): typeof DEFAULT_ALLOWLIST;
155
155
  export declare function saveAllowlist(list: typeof DEFAULT_ALLOWLIST): Promise<void>;
156
156
  /** Load settings — unified view combining all files (backward compatible) */
157
157
  export declare function loadSettings(): MailxSettings;
158
- /** Save settings — writes to split files */
159
158
  export declare function saveSettings(settings: MailxSettings): Promise<void>;
160
159
  /** Get the local store base path */
161
160
  export declare function getStorePath(): string;
package/index.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}
package/index.js CHANGED
@@ -492,7 +492,7 @@ const PROVIDERS = {
492
492
  /** Fill in provider defaults for an account based on email domain.
493
493
  * Exported so mailx-service's leanAccountsJsonc helper can reuse the same
494
494
  * canonicalization rules that loadAccounts uses. */
495
- export function normalizeAccount(acct, globalName) {
495
+ export function normalizeAccount(acct, globalName, globalSig, globalSignature) {
496
496
  const email = acct.email || "";
497
497
  const localPart = email.split("@")[0]?.toLowerCase() || "";
498
498
  const domain = email.split("@")[1]?.toLowerCase() || "";
@@ -535,7 +535,6 @@ export function normalizeAccount(acct, globalName) {
535
535
  defaultSend: acct.defaultSend,
536
536
  syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
537
537
  relayDomains: acct.relayDomains,
538
- deliveredToPrefix: acct.deliveredToPrefix,
539
538
  identityDomains: acct.identityDomains,
540
539
  // `spam` passthrough retired 2026-04-22 — markAsSpamMessages now finds
541
540
  // the junk folder via `specialUse === "junk"` on the DB folder record
@@ -546,9 +545,11 @@ export function normalizeAccount(acct, globalName) {
546
545
  // `as any` is the minimum-blast-radius way to add the field without
547
546
  // blocking the build. Once mailx-types has rebuilt once (post-field)
548
547
  // this cast can be removed.
549
- ...(acct.signature ? { signature: acct.signature } : {}),
548
+ ...(acct.signature ? { signature: acct.signature } : (globalSignature ? { signature: globalSignature } : {})),
550
549
  ...(acct.sig && typeof acct.sig === "object" && typeof acct.sig.text === "string"
551
- ? { sig: { text: acct.sig.text, html: !!acct.sig.html } } : {}),
550
+ ? { sig: { text: acct.sig.text, html: !!acct.sig.html } }
551
+ : (globalSig && typeof globalSig === "object" && typeof globalSig.text === "string"
552
+ ? { sig: { text: globalSig.text, html: !!globalSig.html } } : {})),
552
553
  };
553
554
  }
554
555
  // ── Defaults ──
@@ -637,13 +638,15 @@ export function loadAccounts() {
637
638
  }
638
639
  const raw = accounts.accounts || accounts;
639
640
  const globalName = accounts.name || "";
640
- const result = deduplicateAccounts(raw.map((a) => normalizeAccount(a, globalName)));
641
+ const globalSig = accounts.sig;
642
+ const globalSignature = accounts.signature;
643
+ const result = deduplicateAccounts(raw.map((a) => normalizeAccount(a, globalName, globalSig, globalSignature)));
641
644
  return applyAccountOverrides(result);
642
645
  }
643
646
  // Legacy: read from settings.jsonc
644
647
  const legacy = loadLegacySettings();
645
648
  if (legacy?.accounts)
646
- return applyAccountOverrides(legacy.accounts.map((a) => normalizeAccount(a, legacy.name)));
649
+ return applyAccountOverrides(legacy.accounts.map((a) => normalizeAccount(a, legacy.name, legacy.sig, legacy.signature)));
647
650
  return DEFAULT_ACCOUNTS;
648
651
  }
649
652
  /** Normalize email for dedup — Gmail ignores dots before @ */
@@ -699,8 +702,10 @@ export async function loadAccountsAsync() {
699
702
  if (data?.accounts || Array.isArray(data)) {
700
703
  const raw = data.accounts || data;
701
704
  const globalName = data.name || "";
705
+ const globalSig = data.sig;
706
+ const globalSignature = data.signature;
702
707
  // cloudRead has already cached content to LOCAL_DIR.
703
- return applyAccountOverrides(raw.map((a) => normalizeAccount(a, globalName)));
708
+ return applyAccountOverrides(raw.map((a) => normalizeAccount(a, globalName, globalSig, globalSignature)));
704
709
  }
705
710
  }
706
711
  // Cloud unreachable / unparseable — fall through to local cache.
@@ -721,7 +726,7 @@ export async function loadAccountsAsync() {
721
726
  * - Drop `imap.user` / `smtp.user` if they equal the email.
722
727
  * - Drop `sig.html: false` (default; only keep when user enabled HTML sigs).
723
728
  * - Keep field order: id → label → email → primary* → imap → smtp → defaultSend
724
- * → relayDomains → deliveredToPrefix → identityDomains → syncContacts → sig.
729
+ * → relayDomains → identityDomains → syncContacts → sig.
725
730
  * Curated for readability, not alphabetic. */
726
731
  export function denormalizeAccount(acct, globalName) {
727
732
  const domain = (acct.email || "").split("@")[1]?.toLowerCase() || "";
@@ -779,8 +784,6 @@ export function denormalizeAccount(acct, globalName) {
779
784
  out.enabled = false; // default true → omit
780
785
  if (acct.relayDomains && acct.relayDomains.length > 0)
781
786
  out.relayDomains = acct.relayDomains;
782
- if (acct.deliveredToPrefix && acct.deliveredToPrefix.length > 0)
783
- out.deliveredToPrefix = acct.deliveredToPrefix;
784
787
  if (acct.identityDomains && acct.identityDomains.length > 0)
785
788
  out.identityDomains = acct.identityDomains;
786
789
  // syncContacts default: true for OAuth, false otherwise. Only emit when
@@ -982,10 +985,24 @@ export function loadPreferences() {
982
985
  if (localConfig.historyDays !== undefined) {
983
986
  shared.sync.historyDays = localConfig.historyDays;
984
987
  }
988
+ // Pass through any non-typed keys (calendar.showHolidays /
989
+ // showJewishHolidays, checkDomainReputation, externalEditor, …) so
990
+ // loadSettings's extras pickup sees them. Earlier this return shape
991
+ // was hard-restricted to {ui, sync, autocomplete} and silently
992
+ // dropped every other key the file contained — which is why
993
+ // toggling holidays looked persisted in the file but didn't take
994
+ // effect: loadSettings only saw ui/sync/autocomplete back.
995
+ const extras = {};
996
+ for (const k of Object.keys(shared)) {
997
+ if (k === "ui" || k === "sync" || k === "autocomplete")
998
+ continue;
999
+ extras[k] = shared[k];
1000
+ }
985
1001
  return {
986
1002
  ui: { ...DEFAULT_PREFERENCES.ui, ...shared.ui },
987
1003
  sync: { ...DEFAULT_PREFERENCES.sync, ...shared.sync },
988
1004
  autocomplete: { ...DEFAULT_AUTOCOMPLETE, ...shared.autocomplete },
1005
+ ...extras,
989
1006
  };
990
1007
  }
991
1008
  /** Save preferences */
@@ -1047,6 +1064,16 @@ export function loadSettings() {
1047
1064
  const accounts = loadAccounts();
1048
1065
  const prefs = loadPreferences();
1049
1066
  const localConfig = readLocalConfig();
1067
+ // Pull through every non-{ui,sync,autocomplete} key the preferences
1068
+ // file had — calendar.showHolidays, checkDomainReputation,
1069
+ // externalEditor, etc. saveSettings stores them; loadSettings has to
1070
+ // hand them back or the round-trip is lossy and toggles "don't stick."
1071
+ const extras = {};
1072
+ for (const k of Object.keys(prefs)) {
1073
+ if (k === "ui" || k === "sync" || k === "autocomplete")
1074
+ continue;
1075
+ extras[k] = prefs[k];
1076
+ }
1050
1077
  return {
1051
1078
  accounts,
1052
1079
  ui: prefs.ui,
@@ -1056,12 +1083,57 @@ export function loadSettings() {
1056
1083
  basePath: localConfig.storePath || DEFAULT_STORE_PATH,
1057
1084
  compressionBoundaryDays: 365,
1058
1085
  },
1086
+ ...extras,
1059
1087
  };
1060
1088
  }
1061
- /** Save settings — writes to split files */
1089
+ /** Save settings — writes to split files.
1090
+ * Preserves arbitrary extra keys (calendar.showHolidays, checkDomainReputation,
1091
+ * externalEditor, …) by round-tripping the prior preferences file and
1092
+ * *deep-merging* the new payload over it. A shallow merge would let one
1093
+ * partial save (e.g. `calendar.showJewishHolidays:true`) wholesale-replace
1094
+ * the prior `calendar` object and clobber sibling keys (showHolidays).
1095
+ *
1096
+ * Also: skip re-writing accounts.jsonc when accounts haven't actually
1097
+ * changed. Every saveSettings call previously paid the sync GDrive write
1098
+ * cost (~34 s observed in the daemon log) even for a single checkbox
1099
+ * toggle, because the client sends the full settings object back.
1100
+ * Identity comparison via JSON.stringify is good enough — JS object
1101
+ * identity is meaningless across an IPC round-trip. */
1102
+ function deepMerge(target, source) {
1103
+ if (source === null || source === undefined)
1104
+ return target;
1105
+ if (typeof source !== "object" || Array.isArray(source))
1106
+ return source;
1107
+ const out = (typeof target === "object" && target !== null && !Array.isArray(target)) ? { ...target } : {};
1108
+ for (const k of Object.keys(source)) {
1109
+ out[k] = deepMerge(out[k], source[k]);
1110
+ }
1111
+ return out;
1112
+ }
1113
+ let _lastAccountsJson = null;
1062
1114
  export async function saveSettings(settings) {
1063
- await saveAccounts(settings.accounts);
1064
- savePreferences({ ui: settings.ui, sync: settings.sync });
1115
+ // Skip the slow accounts write when nothing changed. Initialize the
1116
+ // baseline lazily from the current on-disk file on first call so a
1117
+ // restart picks up the right "did this change?" reference.
1118
+ if (_lastAccountsJson === null) {
1119
+ try {
1120
+ _lastAccountsJson = JSON.stringify(loadAccounts());
1121
+ }
1122
+ catch {
1123
+ _lastAccountsJson = "";
1124
+ }
1125
+ }
1126
+ const incomingJson = JSON.stringify(settings.accounts);
1127
+ if (incomingJson !== _lastAccountsJson) {
1128
+ await saveAccounts(settings.accounts);
1129
+ _lastAccountsJson = incomingJson;
1130
+ }
1131
+ const prior = loadPreferences();
1132
+ const incoming = { ...settings };
1133
+ delete incoming.accounts;
1134
+ delete incoming.store;
1135
+ const next = deepMerge(prior, incoming);
1136
+ savePreferences(next);
1065
1137
  }
1066
1138
  /** Get the local store base path */
1067
1139
  export function getStorePath() {
@@ -1182,10 +1254,17 @@ export async function deployDocs(appVersion) {
1182
1254
  if (deployedVersion === appVersion)
1183
1255
  return; // already up to date
1184
1256
  const mdFiles = fs.readdirSync(docsDir).filter(f => f.endsWith(".md"));
1185
- for (const f of mdFiles) {
1257
+ // Parallel cloud writes each provider.write is independent.
1258
+ // The previous serial loop took ~5-10 s for the 9 .md files
1259
+ // because every write was one round-trip to GDrive. The doc-
1260
+ // deploy is fire-and-forget at the outer caller, but holding
1261
+ // GDrive's API for 10 s starves the *other* fire-and-forget
1262
+ // tasks (account refresh, contact sync) that share the
1263
+ // provider. Parallel cuts that to ~1 s on a normal connection.
1264
+ await Promise.all(mdFiles.map(async (f) => {
1186
1265
  const content = fs.readFileSync(path.join(docsDir, f), "utf-8");
1187
1266
  await provider.write(f, content);
1188
- }
1267
+ }));
1189
1268
  await provider.write(".docs-version", appVersion);
1190
1269
  console.log(` [docs] deployed ${mdFiles.length} .md file(s) for app v${appVersion}`);
1191
1270
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-settings",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "license": "ISC",
19
19
  "dependencies": {
20
- "@bobfrankston/mailx-types": "^0.1.10",
20
+ "@bobfrankston/mailx-types": "^0.1.11",
21
21
  "jsonc-parser": "^3.3.1"
22
22
  },
23
23
  "repository": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  ".transformedSnapshot": {
35
35
  "dependencies": {
36
- "@bobfrankston/mailx-types": "^0.1.10",
36
+ "@bobfrankston/mailx-types": "^0.1.11",
37
37
  "jsonc-parser": "^3.3.1"
38
38
  }
39
39
  }