@bobfrankston/mailx-settings 0.1.14 → 0.1.16
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 +14 -1
- package/docs/npmglobalize-disttag.md +90 -0
- package/docs/prod-android.md +88 -0
- package/docs/prod.md +224 -0
- package/docs/push-relay.md +141 -0
- package/docs/rmf-tiny.md +156 -0
- package/index.d.ts +2 -3
- package/index.d.ts.map +1 -1
- package/index.js +85 -13
- package/package.json +3 -3
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.
|
package/docs/rmf-tiny.md
ADDED
|
@@ -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 →
|
|
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":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAyG5G,QAAA,MAAM,SAAS,QAA4E,CAAC;AAiE5F,qFAAqF;AACrF,KAAK,kBAAkB,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE;IAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAAC;AAE/G,wBAAgB,YAAY,CAAC,EAAE,EAAE,kBAAkB,GAAG,MAAM,IAAI,CAM/D;AAOD,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAA2B;AAU7E,iBAAS,YAAY,IAAI,MAAM,CAgB9B;AAOD,sEAAsE;AACtE,wBAAsB,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAoCxE;AAED;;qCAEqC;AACrC,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BjF;AAyBD,2CAA2C;AAC3C,wBAAgB,WAAW,IAAI,OAAO,CAErC;AAED,4CAA4C;AAC5C,wBAAgB,cAAc,IAAI;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,CA+B3L;AAuID;;qDAEqD;AACrD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,GAAG,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,aAAa,
|
|
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
|
|
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 →
|
|
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
|
-
|
|
1064
|
-
|
|
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() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-settings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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.
|
|
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.
|
|
36
|
+
"@bobfrankston/mailx-types": "^0.1.11",
|
|
37
37
|
"jsonc-parser": "^3.3.1"
|
|
38
38
|
}
|
|
39
39
|
}
|