@bobfrankston/mailx-settings 0.1.30 → 0.1.31
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/azure.md +58 -0
- package/docs/config-help.md +131 -0
- package/docs/edit-in-word-docx.md +98 -0
- package/docs/host-abstraction-plan.md +169 -0
- package/docs/local-first-plan.md +303 -0
- package/docs/npmglobalize-transitive-workspace-deps.md +107 -0
- package/docs/outlook.md +80 -18
- package/docs/rules-design.md +172 -0
- package/index.d.ts.map +1 -1
- package/index.js +9 -8
- package/package.json +3 -3
package/docs/azure.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Azure app registration — Outlook / Microsoft Graph Mail
|
|
2
|
+
|
|
3
|
+
One-time setup for mailx itself (not per-user). Public-client OAuth means the **app** (mailx) registers once; end-users just sign in with their own Microsoft accounts at runtime and consent at first use. The client ID is public, baked into the source tree.
|
|
4
|
+
|
|
5
|
+
This is the same model mailx already uses for Google: one Google OAuth client committed with the code, every user signs in with their own Google account.
|
|
6
|
+
|
|
7
|
+
## 1. Register the app (one time, by the mailx author)
|
|
8
|
+
|
|
9
|
+
1. Go to https://portal.azure.com → search **App registrations** → **New registration**.
|
|
10
|
+
2. Fill in:
|
|
11
|
+
- **Name:** `mailx` (just a label; users won't usually see it)
|
|
12
|
+
- **Supported account types:** *Accounts in any organizational directory … **and** personal Microsoft accounts*. This lets both consumer (`@outlook.com`) and work/school (`@company.com`) users sign in to their own accounts.
|
|
13
|
+
- **Redirect URI:** pick **Public client/native** and enter `http://localhost`. oauthsupport uses the OAuth2 loopback flow; no fixed port is needed because the redirect URI is treated as a prefix.
|
|
14
|
+
3. **Register**. From the Overview page, copy the **Application (client) ID** — a GUID like `12345678-abcd-…`. Public clients have no client secret.
|
|
15
|
+
|
|
16
|
+
## 2. Grant Graph scopes
|
|
17
|
+
|
|
18
|
+
4. Left nav → **API permissions** → **Add a permission** → **Microsoft Graph** → **Delegated permissions**. Check:
|
|
19
|
+
- `Mail.ReadWrite` — read + modify messages (flags, move, delete)
|
|
20
|
+
- `Mail.Send` — send mail
|
|
21
|
+
- `offline_access` — **required** for refresh tokens; without it the session dies after an hour
|
|
22
|
+
- `User.Read` — fetch display name + email for account labeling
|
|
23
|
+
5. Click **Add permissions**. Admin consent isn't needed for personal accounts — each end-user grants it at first sign-in on their own machine.
|
|
24
|
+
|
|
25
|
+
## 3. Allow public-client flows
|
|
26
|
+
|
|
27
|
+
6. Left nav → **Authentication** → scroll to *Advanced settings* → **Allow public client flows** = **Yes** → **Save**. This lets the loopback redirect work without a client secret.
|
|
28
|
+
|
|
29
|
+
## 4. Ship the client ID with mailx
|
|
30
|
+
|
|
31
|
+
7. Commit the client ID into the mailx source tree the same way the Google client ID is committed. Candidate locations (pick whichever matches existing convention for Google):
|
|
32
|
+
- `oauthsupport` has a provider-keyed constants file — add a `microsoft` entry next to the existing `google` one, with:
|
|
33
|
+
- `client_id`: the GUID from step 1
|
|
34
|
+
- `tenant`: `"common"` (accepts both personal and work/school accounts)
|
|
35
|
+
- `auth_uri`: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize`
|
|
36
|
+
- `token_uri`: `https://login.microsoftonline.com/common/oauth2/v2.0/token`
|
|
37
|
+
- `redirect_uri`: `http://localhost`
|
|
38
|
+
- or a mailx-level `providers/microsoft.json` if oauthsupport expects per-provider JSON.
|
|
39
|
+
8. Per-user tokens (refresh + access) still live in `~/.mailx/tokens/<user-email>/` the same as today — that's where the *user-specific* state goes. The client ID is not in `~/.mailx/`; it ships with the app.
|
|
40
|
+
|
|
41
|
+
`tenant` options if needed:
|
|
42
|
+
- `"common"` — both personal and work/school (default for mailx)
|
|
43
|
+
- `"consumers"` — personal only
|
|
44
|
+
- a specific tenant GUID — single-tenant, work-only
|
|
45
|
+
|
|
46
|
+
## 5. Test
|
|
47
|
+
|
|
48
|
+
The Outlook dispatcher wiring (`isOutlookAccount()` / `getOutlookProvider()`) inside `ImapManager` is still a TODO — see *Outlook Graph API driver — full wiring* in TODO.md. Registration above gets the credentials baked into the code so the dispatcher work can flip the switch without further Azure work.
|
|
49
|
+
|
|
50
|
+
## Production app verification
|
|
51
|
+
|
|
52
|
+
Not needed until mailx is distributed outside Bob's family / close circle. Unverified apps show a "this app wasn't verified by Microsoft" warning on the consent screen — users can proceed through it. Verification requires a publisher domain + privacy policy + terms of service; skip until it matters.
|
|
53
|
+
|
|
54
|
+
## Reference links
|
|
55
|
+
|
|
56
|
+
- OAuth2 authorization code flow with PKCE: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow
|
|
57
|
+
- Graph Mail API reference: https://learn.microsoft.com/graph/api/resources/mail-api-overview
|
|
58
|
+
- Throttling guidance (mirrors Gmail's model — token bucket + Retry-After): https://learn.microsoft.com/graph/throttling
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Config files
|
|
2
|
+
|
|
3
|
+
Help text shown in-app next to each JSONC config file in the **Settings → Edit config files...** dialog. Each `##` section is matched by filename.
|
|
4
|
+
|
|
5
|
+
Comments (`//`, `/* */`) and trailing commas are allowed — mailx parses these files with a JSONC parser.
|
|
6
|
+
|
|
7
|
+
## accounts.jsonc
|
|
8
|
+
|
|
9
|
+
Defines the mail accounts mailx syncs. Stored on Google Drive so it's shared across all your devices.
|
|
10
|
+
|
|
11
|
+
```jsonc
|
|
12
|
+
{
|
|
13
|
+
"accounts": [
|
|
14
|
+
{
|
|
15
|
+
"id": "gmail", // short, unique, lowercase — used as folder name on disk
|
|
16
|
+
"name": "Bob", // display name used as the From: name
|
|
17
|
+
"email": "bob@gmail.com", // primary address for this account
|
|
18
|
+
"defaultSend": true, // pre-select this account in compose
|
|
19
|
+
"identityDomains": ["bob.ma"], // extra domains to recognize as "you" for reply-from detection
|
|
20
|
+
"sig": { // signature appended to NEW messages
|
|
21
|
+
"text": "Bob Frankston\nhttps://frankston.com"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "work",
|
|
26
|
+
"name": "Bob",
|
|
27
|
+
"email": "bob@example.com",
|
|
28
|
+
"imap": { // explicit IMAP/SMTP for non-auto-detected providers
|
|
29
|
+
"host": "imap.example.com",
|
|
30
|
+
"port": 993,
|
|
31
|
+
"tls": true
|
|
32
|
+
},
|
|
33
|
+
"smtp": {
|
|
34
|
+
"host": "smtp.example.com",
|
|
35
|
+
"port": 465,
|
|
36
|
+
"tls": true
|
|
37
|
+
},
|
|
38
|
+
"spam": "Junk" // override the spam folder name for this account
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Fields:**
|
|
45
|
+
|
|
46
|
+
- `id` — unique, lowercase, no spaces. Changing it renames the local cache folder.
|
|
47
|
+
- `name` — display name used in the From: header.
|
|
48
|
+
- `email` — primary identity address.
|
|
49
|
+
- `defaultSend` — if multiple accounts, the one with `defaultSend: true` is pre-selected in compose.
|
|
50
|
+
- `identityDomains` — additional domains to recognize as yours (aliases, plus-addressing, forwarded domains). Used for reply auto-From detection.
|
|
51
|
+
- `imap` / `smtp` — server config. Omit for Gmail/Outlook/Yahoo/iCloud (auto-detected from the email domain).
|
|
52
|
+
- `spam` — override the spam/junk folder name if auto-detection picks the wrong one.
|
|
53
|
+
- `sig` — signature object. `text` is appended to NEW messages with the standard `-- ` separator (newlines preserved). Only applied to brand-new messages today; replies/forwards skip it. Future options will cover replies, per-account toggles, and a `"html": true` flag for raw HTML signatures.
|
|
54
|
+
|
|
55
|
+
Gmail accounts automatically use the Gmail REST API (no IMAP). Everything else uses IMAP via iflow-direct.
|
|
56
|
+
|
|
57
|
+
Restart mailx after editing to pick up changes.
|
|
58
|
+
|
|
59
|
+
## allowlist.jsonc
|
|
60
|
+
|
|
61
|
+
Controls which remote images/content are allowed in the message viewer without the "Show images" banner.
|
|
62
|
+
|
|
63
|
+
```jsonc
|
|
64
|
+
{
|
|
65
|
+
"domains": [
|
|
66
|
+
"github.com", // wildcard subdomains implied
|
|
67
|
+
"stripe.com",
|
|
68
|
+
"list-manage.com" // Mailchimp tracking — remove if you want to block
|
|
69
|
+
],
|
|
70
|
+
"senders": [
|
|
71
|
+
"notifications@github.com", // full address match
|
|
72
|
+
"*@anthropic.com" // wildcard: anything from this domain
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Fields:**
|
|
78
|
+
|
|
79
|
+
- `domains` — remote images are fetched if their host matches (with subdomain wildcarding).
|
|
80
|
+
- `senders` — messages from these senders get images auto-loaded regardless of image host.
|
|
81
|
+
|
|
82
|
+
Content-Security-Policy still blocks scripts regardless — this only affects image/CSS loading.
|
|
83
|
+
|
|
84
|
+
## clients.jsonc
|
|
85
|
+
|
|
86
|
+
Per-device registrations. Each mailx install writes its hostname here so you can see which devices have synced recently.
|
|
87
|
+
|
|
88
|
+
```jsonc
|
|
89
|
+
{
|
|
90
|
+
"clients": [
|
|
91
|
+
{
|
|
92
|
+
"id": "desktop-abc123", // auto-generated on first run
|
|
93
|
+
"hostname": "MYPC",
|
|
94
|
+
"platform": "win32",
|
|
95
|
+
"lastSeen": "2026-04-18T14:00:00Z"
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Normally there's no reason to edit this by hand. Useful for removing stale device entries, or migrating a device to a new ID.
|
|
102
|
+
|
|
103
|
+
## config.jsonc
|
|
104
|
+
|
|
105
|
+
**Local per-machine configuration**, stored at `~/.mailx/config.jsonc`. This file is NOT synced to the cloud — it's how each machine knows where the shared config lives and which store path to use.
|
|
106
|
+
|
|
107
|
+
```jsonc
|
|
108
|
+
{
|
|
109
|
+
"sharedDir": {
|
|
110
|
+
"provider": "gdrive", // cloud provider (only "gdrive" supported)
|
|
111
|
+
"path": "mailx", // folder name on Google Drive
|
|
112
|
+
"folderId": "1ABC...xyz" // GDrive folder ID (API-resolved, don't edit)
|
|
113
|
+
},
|
|
114
|
+
"storePath": "C:/Users/Bob/.mailx/store", // where .eml message bodies live locally
|
|
115
|
+
|
|
116
|
+
"accountOverrides": { // optional: per-machine overrides to accounts.jsonc
|
|
117
|
+
"work": {
|
|
118
|
+
"imap": { "host": "127.0.0.1", "port": 1143 } // e.g., tunnel on this machine only
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Fields:**
|
|
125
|
+
|
|
126
|
+
- `sharedDir.provider` — cloud storage. Currently only `gdrive`.
|
|
127
|
+
- `sharedDir.path` / `folderId` — the folder on the cloud where shared config + cache metadata live.
|
|
128
|
+
- `storePath` — where `.eml` bodies are cached locally. Changing requires moving existing files.
|
|
129
|
+
- `accountOverrides` — override parts of an account's config on this machine only (different IMAP host when behind a VPN, for example). Keys match the `id` in accounts.jsonc.
|
|
130
|
+
|
|
131
|
+
Delete and re-run `mailx -setup` to reconfigure from scratch.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Edit-in-Word .docx round-trip — design
|
|
2
|
+
|
|
3
|
+
> Replaces the current .html round-trip so saving in Word "just works" without
|
|
4
|
+
> Save-As-Web-Page gymnastics. Adds a focus-back hint on save so the user
|
|
5
|
+
> doesn't have to Alt-Tab manually.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Today, Edit-in-Word writes `<editId>.html` and watches it. Word opens it but
|
|
10
|
+
when the user hits Ctrl+S, Word's default save format is **.docx**, not .html.
|
|
11
|
+
Word writes a *new* `<editId>.docx` file; the watcher misses the save; the
|
|
12
|
+
user's edits never reload into rmfmail.
|
|
13
|
+
|
|
14
|
+
Two options previously discussed (B watch-both-extensions; C inline hint).
|
|
15
|
+
Picking **A** here: pre-convert to .docx so Word's natural save flow lands in
|
|
16
|
+
the file we're watching.
|
|
17
|
+
|
|
18
|
+
## Pieces
|
|
19
|
+
|
|
20
|
+
1. **HTML → DOCX** at edit-start. Library: `html-to-docx` (Node, ~80KB,
|
|
21
|
+
actively maintained). Output is a Node `Buffer`. Write to
|
|
22
|
+
`~/.rmfmail/external-edit/<editId>.docx`.
|
|
23
|
+
2. **Watch `<editId>.docx`** instead of `.html`. Same `fs.watch(dir)` mechanic;
|
|
24
|
+
filter by `<editId>.docx` rather than `<editId>.html`.
|
|
25
|
+
3. **DOCX → HTML** on save. Library: `mammoth` (Node, ~250KB, the de facto
|
|
26
|
+
DOCX-to-HTML converter). Output is HTML string. Strip its outer wrapper.
|
|
27
|
+
Emit `wordEditUpdated` as today.
|
|
28
|
+
4. **Focus rmfmail on save**. After parsing the docx, post a host-level
|
|
29
|
+
"focus this window" message via msger. On Windows that's
|
|
30
|
+
`SetForegroundWindow` on the WebView2 hwnd — msger already has the hwnd
|
|
31
|
+
from window create. Add a `focusWindow` IPC method.
|
|
32
|
+
5. **In-compose external-edit indicator**. While `wordEditId` is set, the
|
|
33
|
+
compose body editor area shows a non-editable status panel:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
╭───────────────────────────────────────╮
|
|
37
|
+
│ Editing in Word… │
|
|
38
|
+
│ Save in Word → reloads here. │
|
|
39
|
+
│ │
|
|
40
|
+
│ [ Reload now ] [ Send ] [ Discard ] │
|
|
41
|
+
╰───────────────────────────────────────╯
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **Reload now**: read the .docx and parse manually (in case the watcher
|
|
45
|
+
missed an event)
|
|
46
|
+
- **Send**: parse current .docx, set as body, fire send (skips the
|
|
47
|
+
intermediate "switch back to compose, click Send" step)
|
|
48
|
+
- **Discard**: stop the watcher, delete the temp file, restore original body
|
|
49
|
+
|
|
50
|
+
## Library deps
|
|
51
|
+
|
|
52
|
+
| Library | Direction | Size | Notes |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| `html-to-docx` | HTML → DOCX | ~80KB | Pure JS Node. `await asBlob(html)` returns a Buffer. |
|
|
55
|
+
| `mammoth` | DOCX → HTML | ~250KB | De facto standard. Lossy on some Word features (text effects, complex tables) but covers email-shaped content. |
|
|
56
|
+
|
|
57
|
+
Total ~330KB added to mailx-service. Acceptable; only loaded on Edit-in-Word
|
|
58
|
+
click via dynamic `import()` so cold start cost is zero.
|
|
59
|
+
|
|
60
|
+
## What we lose
|
|
61
|
+
|
|
62
|
+
- Round-trip fidelity isn't perfect. Word's docx supports things mammoth
|
|
63
|
+
doesn't (footnotes, comments, advanced styling). For email composition this
|
|
64
|
+
is fine — users aren't writing footnotes — but worth knowing.
|
|
65
|
+
- Rich images: html-to-docx handles inline images (data: URIs) reasonably;
|
|
66
|
+
mammoth round-trips them as `<img src="data:...">`. Should work but needs
|
|
67
|
+
testing.
|
|
68
|
+
|
|
69
|
+
## Migration
|
|
70
|
+
|
|
71
|
+
- Existing watchers at `<editId>.html` keep working until the user reopens an
|
|
72
|
+
Edit-in-Word session. New session uses .docx.
|
|
73
|
+
- Optionally clean up old `external-edit/*.html` files at startup.
|
|
74
|
+
|
|
75
|
+
## Implementation order
|
|
76
|
+
|
|
77
|
+
1. `npm install html-to-docx mammoth` in mailx-service.
|
|
78
|
+
2. Replace `.html` write at `mailx-service/index.ts:660` with .docx via
|
|
79
|
+
html-to-docx.
|
|
80
|
+
3. Replace watcher target at line 745 with `<editId>.docx`.
|
|
81
|
+
4. Replace read+parse at line 755 with mammoth → HTML.
|
|
82
|
+
5. Add `focusWindow` IPC + msger hook.
|
|
83
|
+
6. Wire in-compose external-edit indicator panel (compose.ts).
|
|
84
|
+
7. Test with a Word install on Windows.
|
|
85
|
+
8. Skip Word path on Mac (Mac Word's docx behavior is the same; should work
|
|
86
|
+
without changes — but verify).
|
|
87
|
+
|
|
88
|
+
## Open questions
|
|
89
|
+
|
|
90
|
+
- Does Mac Word treat .docx the same as Windows Word? (Should — same file
|
|
91
|
+
format. Verify after impl.)
|
|
92
|
+
- LibreOffice fallback: opens .docx natively, saves to same .docx by default.
|
|
93
|
+
No special handling needed.
|
|
94
|
+
- Default editor on Linux is usually LibreOffice; Word path n/a.
|
|
95
|
+
|
|
96
|
+
## Status
|
|
97
|
+
|
|
98
|
+
Design captured; implementation deferred until user confirms scope.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# mailx Host Abstraction + msgview Parity Plan
|
|
2
|
+
|
|
3
|
+
**Status:** step 1 (abstraction package) implemented 2026-04-20. msgview adapter (step 2+) deferred until a Mac client or arm64-without-webkit fallback is actually needed. Resume cold from this doc.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
1. **msger covers most of what we need.** Windows + mainstream x86_64 Linux run msger fine today. The Rust/wry binary links against `libwebkit2gtk-4.1.so.0`; on Debian trixie / Raspberry Pi OS arm64 you install it once (`sudo apt install libwebkit2gtk-4.1-0`) and msger works. Not a regression, just a missing system dep. Older distros with only webkit2gtk 4.0 would need an msger rebuild.
|
|
8
|
+
2. **msgview fills specific gaps.** Electron's bundled Chromium is the natural path for Mac (where msger would need separate webkit work) and a fallback for niche Linux systems where webkit2gtk-4.1 isn't available and can't be installed. It also sidesteps two msger pain points — multi-monitor drag bugs and WebView2 Evergreen auto-update on Windows — but those aren't load-bearing reasons to move existing Windows/Linux users off msger.
|
|
9
|
+
3. **We want mailx to run on both without the call sites caring.** mailx imports a host abstraction; the abstraction picks msger or msgview at runtime. Step 1 (done 2026-04-20) puts the seam in place even before msgview lands, so future platform work is an adapter drop-in instead of a call-site rewrite.
|
|
10
|
+
4. **msger and msgview must stay app-agnostic.** Architectural rule from mailx/CLAUDE.md: *"msger MUST have ZERO knowledge of mailx, iflow, or any application code."* Same rule extends to msgview. Any mailx-specific glue living in the wrappers today must move to mailx. The 2026-04-20 `_msgapi*` service-callback rename in msger Rust was the prerequisite that made both hosts share one generic JS contract.
|
|
11
|
+
|
|
12
|
+
## Current coupling (survey)
|
|
13
|
+
|
|
14
|
+
Searched msger + msgview for `mailx` references:
|
|
15
|
+
|
|
16
|
+
- **msger/shower.ts:125** — doc comment on `setAppName()` says `e.g. setAppName("mailx")`. That's a doc reference only; `setAppName` itself is generic (app passes its own name for per-user bin dir + AUMID). OK to leave, or reword the example to `"myapp"`.
|
|
17
|
+
- **msger/msger-plan.md:70** — explicit rule `"mailxapi.js content (belongs in mailx)"`. Already correct.
|
|
18
|
+
- **msger Rust src** — no mailx/iflow/oauth references. Clean.
|
|
19
|
+
- **msgview** — no mailx references. Pristine.
|
|
20
|
+
|
|
21
|
+
**Conclusion:** the wrappers are already app-agnostic in code. The factoring work is on the mailx side: today mailx calls `showMessageBox`/`showService` directly from `@bobfrankston/msger`. We introduce a host abstraction so the import swap is one line, and document the contract so the next app (msga, etc.) can use the same interface.
|
|
22
|
+
|
|
23
|
+
## Current mailx → msger surface
|
|
24
|
+
|
|
25
|
+
From `bin/mailx.ts:825-900` and `client/lib/mailxapi.js`:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { showMessageBox, showService, setAppName } from "@bobfrankston/msger";
|
|
29
|
+
|
|
30
|
+
// Desktop launch:
|
|
31
|
+
const handle = showService({
|
|
32
|
+
title, url, contentDir, initScript, icon, aumid,
|
|
33
|
+
size: { width, height }, pos: { x, y },
|
|
34
|
+
escapeCloses: false,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
handle.onRequest(async (req) => { /* dispatch req._action → MailxService */ });
|
|
38
|
+
handle.send({ _cbid, result }); // responses and push events
|
|
39
|
+
// handle.close(); handle.result (promise resolving on window close)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**In-WebView side** (`client/lib/mailxapi.js` injected via `initScript`):
|
|
43
|
+
- Exposes `window.callNode(action, args) → Promise<result>` built on `window.msgapi.sendToHost(...)`
|
|
44
|
+
- Receives server-push events via the IPC channel
|
|
45
|
+
- `api-client.ts` auto-detects IPC vs HTTP and routes through `callNode` when IPC is present
|
|
46
|
+
|
|
47
|
+
**That's the entire surface.** Small and stable.
|
|
48
|
+
|
|
49
|
+
## The `MailxHost` interface
|
|
50
|
+
|
|
51
|
+
One interface, two implementations. Lives in mailx (not in msger/msgview).
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// packages/mailx-host/index.ts
|
|
55
|
+
export interface MailxHostOptions {
|
|
56
|
+
title: string;
|
|
57
|
+
url: string; // relative to contentDir (e.g. "index.html")
|
|
58
|
+
contentDir: string; // served via custom protocol
|
|
59
|
+
initScript: string; // mailxapi.js contents, injected pre-page
|
|
60
|
+
icon?: string;
|
|
61
|
+
aumid?: string; // Windows taskbar identity
|
|
62
|
+
size?: { width: number; height: number };
|
|
63
|
+
pos?: { x: number; y: number };
|
|
64
|
+
escapeCloses?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MailxHostHandle {
|
|
68
|
+
onRequest(fn: (req: any) => void | Promise<void>): void;
|
|
69
|
+
send(msg: any): void; // push to WebView
|
|
70
|
+
close(): void;
|
|
71
|
+
result: Promise<{ closed: boolean }>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface MailxHost {
|
|
75
|
+
setAppName(name: string): void;
|
|
76
|
+
showService(opts: MailxHostOptions): MailxHostHandle;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Two impls:
|
|
81
|
+
|
|
82
|
+
- **`packages/mailx-host-msger/index.ts`** — wraps `@bobfrankston/msger`, delegates 1:1. ~30 LOC.
|
|
83
|
+
- **`packages/mailx-host-msgview/index.ts`** — wraps Electron. Creates `BrowserWindow`, installs `protocol.handle()` for `contentDir`, sets `webPreferences.preload` pointing at a shim that loads the `initScript` string and exposes the same `window.msgapi` / `window.callNode` surface, wires `ipcMain.handle` ↔ `handle.onRequest` / `webContents.send` ↔ `handle.send`. ~150 LOC including the preload shim.
|
|
84
|
+
|
|
85
|
+
Selection at runtime in `bin/mailx.ts`: env var `MAILX_HOST=msger|msgview` (default msger on Windows, msgview on Linux/Mac), or `--host=` flag.
|
|
86
|
+
|
|
87
|
+
## msgview parity checklist (from msgx/parity.md)
|
|
88
|
+
|
|
89
|
+
Mapping each msger capability to Electron:
|
|
90
|
+
|
|
91
|
+
| msger feature | Electron equivalent | Work needed |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| Service mode (bidir IPC via stdin/stdout) | `ipcMain.handle` / `webContents.send` | Wire in host shim. Electron's IPC is in-process, strictly better than JSON-line stdio. |
|
|
94
|
+
| `initScript` injection | `webPreferences.preload` | Preload shim evaluates the injected script string in page context before load. |
|
|
95
|
+
| `msger.localhost` custom protocol | `protocol.handle()` (Electron 25+) | Register in host shim on `app.whenReady()`. |
|
|
96
|
+
| `window.msgapi` (TCP/UDP/HTTP, UI control) | Expose via preload + `contextBridge` | Re-implement the subset mailx uses — TCP for iflow transport, UI control (resize, minimize, etc.). Node APIs available in main process, expose narrow surface. |
|
|
97
|
+
| AUMID for taskbar pin | `app.setAppUserModelId(aumid)` | One line. |
|
|
98
|
+
| Navigation gating (same-origin stays, cross-origin external) | `webContents.setWindowOpenHandler` + `will-navigate` | Reuse msger's `url_origin()` logic. |
|
|
99
|
+
| Multi-monitor drag | Chromium native | Disappears for free — msger's `ScaleFactorChanged` bug doesn't exist here. |
|
|
100
|
+
| WebView2 Evergreen auto-update killing windows | N/A | Chromium bundled, doesn't apply. |
|
|
101
|
+
| Stable exe path / timestamped-copy dance | N/A | Electron installer handles this differently (Squirrel/NSIS); no hardlink trick needed. |
|
|
102
|
+
| EPERM cleanup on npm upgrade | N/A | Different install mechanism. |
|
|
103
|
+
| **Detach mode** (child outlives caller) | Harder | See below. |
|
|
104
|
+
| Log path option | Electron `app.setPath("logs", ...)` | Trivial. |
|
|
105
|
+
|
|
106
|
+
### Detach mode — the one real design decision
|
|
107
|
+
|
|
108
|
+
msger's detach mode lets the mailx launcher exit while the window keeps running in a forked msger process. Electron doesn't have a natural equivalent — Electron apps are one process tree rooted at the Electron main process.
|
|
109
|
+
|
|
110
|
+
**Option A: launch Electron as a separate binary.** Install `msgview` as a CLI (`msgview --url=… --content-dir=… --init-script=…`), mailx's host-msgview shim spawns it detached. Closest analog to msger's current model. Downside: two processes (mailx service + Electron), and the service must manage IPC over stdio again.
|
|
111
|
+
|
|
112
|
+
**Option B: run mailx service INSIDE the Electron main process.** mailx becomes an Electron app. `bin/mailx.ts` bootstraps Electron, creates `MailxService` + `ImapManager` in the main process, opens `BrowserWindow` with preload. Single process, IPC is in-process calls. No detach needed — the window IS the service. This is cleaner and matches how msgview is already structured for its own use case.
|
|
113
|
+
|
|
114
|
+
**Recommendation: Option B.** Requires `bin/mailx.ts` to detect host mode and, when msgview is selected, re-exec under Electron (or use Electron as the node runtime from the start). The `mailx` CLI entry still works — it just launches Electron instead of plain node. For `--server` mode we keep plain node (no window needed, so no Electron).
|
|
115
|
+
|
|
116
|
+
## Factoring plan — keep msger/msgview independent of mailx
|
|
117
|
+
|
|
118
|
+
Where mailx-specific code lives today and where it needs to end up:
|
|
119
|
+
|
|
120
|
+
| Code | Today | Target |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `client/lib/mailxapi.js` | mailx | mailx (unchanged — already correct) |
|
|
123
|
+
| `packages/mailx-service/jsonrpc.ts` | mailx | mailx (unchanged) |
|
|
124
|
+
| `bin/mailx.ts` direct `import "@bobfrankston/msger"` | mailx | Replace with `import { getHost } from "@mailx/host"` |
|
|
125
|
+
| `showService` options shape (title, url, initScript, aumid, etc.) | msger-specific type | Move to `packages/mailx-host/` — msger stays generic, mailx owns the mailx-flavored option shape |
|
|
126
|
+
| `setAppName("mailx")` | called from mailx | Still called from mailx, but through the host abstraction |
|
|
127
|
+
| `window.msgapi` surface | msger defines it | Keep as msger's native API. `mailx-host-msgview` re-implements the same shape via Electron preload so mailx code in the WebView doesn't care which host it's under. |
|
|
128
|
+
| Per-user bin dir `%LOCALAPPDATA%\mailx\bin\` | msger's `getUserBinDir(_appName)` | Unchanged — `setAppName("mailx")` already drives this generically |
|
|
129
|
+
|
|
130
|
+
**Net effect:** msger and msgview keep zero mailx references. mailx gains three small packages (`mailx-host`, `mailx-host-msger`, `mailx-host-msgview`) that encapsulate the coupling. Next app (msga, etc.) can either copy the `mailx-host-*` pattern or use msger/msgview directly.
|
|
131
|
+
|
|
132
|
+
### The `window.msgapi` question
|
|
133
|
+
|
|
134
|
+
mailx code running in the WebView currently calls `window.msgapi.sendToHost(...)`. That's a msger-defined global. Two paths:
|
|
135
|
+
|
|
136
|
+
- **Keep the name.** `mailx-host-msgview`'s preload exposes `window.msgapi` with the same shape. mailx WebView code is unchanged. msger owns the de-facto standard; msgview implements it.
|
|
137
|
+
- **Rename to `window.mailxHost`.** mailx owns the name; msger and msgview each expose it via their respective injection mechanisms. Cleaner separation but more churn.
|
|
138
|
+
|
|
139
|
+
**Recommendation: keep `window.msgapi`** — it's already documented as a generic host API, msger defines the canonical shape, and msgview implementing it keeps the WebView code host-agnostic without rename churn.
|
|
140
|
+
|
|
141
|
+
## Migration sequence
|
|
142
|
+
|
|
143
|
+
1. **Create `packages/mailx-host`** with the interface + msger impl. Swap `bin/mailx.ts` to use it. Behavior identical; this is pure refactor. **Done 2026-04-20.** The package re-exports `showMessageBox` / `showService` / `setAppName` from msger and exposes a `selectHost()` seam for future impl dispatch. `MAILX_HOST=msger|msgview` env var honored; default is msger on Windows + Linux, would-be msgview on Mac (throws today until step 2 lands).
|
|
144
|
+
2. **Create `packages/mailx-host-msgview`** (deferred until Mac or arm64-fallback need). Add Electron as a dependency. Implement the shim. Add `msgview` package to `y:/dev/utils/msgx/msgview/` if it doesn't already expose a programmatic API (needs check — msgview currently is a standalone JSON viewer, may need its guts extracted into a library).
|
|
145
|
+
3. **Detach decision** (deferred). Pick Option B. Add Electron bootstrap path to `bin/mailx.ts`: when `MAILX_HOST=msgview`, re-exec under Electron. Test on Linux (pi5a) and Windows.
|
|
146
|
+
4. **Port `window.msgapi` surface** used by mailx into the msgview preload. Audit `client/lib/mailxapi.js` for every `msgapi.*` call — that's the minimum surface to re-implement.
|
|
147
|
+
5. **Iflow transport under Electron main.** `NodeTcpTransport` already uses `node:net` / `node:tls`; Electron main has full node, so this should be drop-in. Verify TLS works (Electron bundles OpenSSL, not system).
|
|
148
|
+
6. **Build + packaging.** Decide: separate npm packages for `mailx-host-msger` and `mailx-host-msgview`, or one package with lazy-loaded impls? Probably lazy-load so Electron isn't dragged in for Windows users. Host selection picks impl; unused impl is never required.
|
|
149
|
+
7. **Documentation** in msgx/parity.md: update with msgview side. (Step 1 doc updates done 2026-04-20.)
|
|
150
|
+
|
|
151
|
+
## Open questions
|
|
152
|
+
|
|
153
|
+
- **Is msgview already a library, or is it a standalone app?** Need to check `y:/dev/utils/msgx/msgview/index.ts` — if it's CLI-only, we either (a) extract its core into a library `@bobfrankston/msgview-core` or (b) just build the Electron shell directly inside `mailx-host-msgview`. (b) is simpler if msgview's current feature set doesn't include anything we need to reuse.
|
|
154
|
+
- **Windows: keep msger-only.** msger on Windows works well. msgview on Windows stays available via explicit `MAILX_HOST=msgview` for users who want to trade WebView2 Evergreen risk for Electron's bundle size.
|
|
155
|
+
- **Auto-update.** msger has timestamped-exe + stable-hardlink. Electron has Squirrel auto-update. Different mechanisms — do we care about parity here or run two update strategies?
|
|
156
|
+
- **AUMID across hosts.** `com.frankston.mailx` should be the same on both so taskbar pins work whichever host is running.
|
|
157
|
+
- **pi5a as a build host.** User mentioned moving compile tooling from pi4c to pi5a. That's a separate decision, but relevant context: Rust cross-compile for msger aarch64 is still the primary Linux arm64 path (apt-install the webkit2gtk-4.1 system dep is cheap). msgview arm64 only matters if a specific user ends up on a distro that can't provide webkit2gtk-4.1.
|
|
158
|
+
|
|
159
|
+
## Files to read when resuming
|
|
160
|
+
|
|
161
|
+
- `y:/dev/utils/msgx/parity.md` — the authoritative msger feature list
|
|
162
|
+
- `y:/dev/utils/msgx/msger/shower.ts` — current msger API surface
|
|
163
|
+
- `y:/dev/utils/msgx/msger/msger-plan.md` — msger's independence rule (line 3)
|
|
164
|
+
- `y:/dev/utils/msgx/msgview/index.ts` + `main.ts` — see what msgview exposes today
|
|
165
|
+
- `y:/dev/email/mailx/bin/mailx.ts:820-900` — mailx's current msger usage
|
|
166
|
+
- `y:/dev/email/mailx/client/lib/mailxapi.js` — the in-WebView IPC bridge (full `window.msgapi` surface used by mailx)
|
|
167
|
+
- `y:/dev/email/mailx/client/lib/api-client.ts` — IPC vs HTTP auto-detection
|
|
168
|
+
- `y:/dev/email/mailx/packages/mailx-service/jsonrpc.ts` — dispatcher (stays unchanged)
|
|
169
|
+
- `y:/dev/email/mailx/CLAUDE.md` — architectural rules (msger independence, transport injection)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# Local-first reconciliation plan
|
|
2
|
+
|
|
3
|
+
> Status: **plan, not implementation**. Reviewed before any code is touched.
|
|
4
|
+
> Owner: Claude. Approver: Bob.
|
|
5
|
+
|
|
6
|
+
## The rule
|
|
7
|
+
|
|
8
|
+
The local store is the source of truth for everything the user sees and does.
|
|
9
|
+
The server is a separate, asynchronously-reconciled mirror.
|
|
10
|
+
**No user-action code path waits on a network call.**
|
|
11
|
+
|
|
12
|
+
Every user-visible read returns from local SQLite + local files in microseconds.
|
|
13
|
+
Every user-visible write commits to the local store synchronously and returns
|
|
14
|
+
the local id immediately. Server-side mirroring is the sync layer's job, not
|
|
15
|
+
the UI's.
|
|
16
|
+
|
|
17
|
+
This document defines the invariants, the new API split, the event protocol,
|
|
18
|
+
the migration order, and the visible-state model.
|
|
19
|
+
|
|
20
|
+
## Invariants the implementation must enforce
|
|
21
|
+
|
|
22
|
+
1. **Reads never await server.** `getMessage`, `getMessages`, `getUnifiedInbox`,
|
|
23
|
+
`searchMessages` (local scope), `getCalendarEvents`, `getTasks`, autocomplete —
|
|
24
|
+
all read from local DB and return synchronously. If the IPC layer can't be
|
|
25
|
+
sync, it must return a Promise that resolves on the same tick.
|
|
26
|
+
|
|
27
|
+
2. **Writes ACK from a local commit.**
|
|
28
|
+
- `saveDraft` writes the local Drafts row + `.eml` and returns the local UUID
|
|
29
|
+
in the same tick. The IMAP push is queued, never awaited.
|
|
30
|
+
- `send` writes the local Outbox row + `.ltr` and returns immediately. SMTP
|
|
31
|
+
and Sent-folder append are background work.
|
|
32
|
+
- `move`, `flag`, `delete` commit the local row mutation, return, and queue
|
|
33
|
+
the server-side mirror.
|
|
34
|
+
|
|
35
|
+
3. **The sync queue is the only IMAP-touching code path.** A single background
|
|
36
|
+
reconciler drains queued local actions to the server, polls for server
|
|
37
|
+
changes, and emits events to the UI. Nothing else opens an IMAP client.
|
|
38
|
+
|
|
39
|
+
4. **Local UUIDs are stable; server UIDs are metadata.** Every message gets a
|
|
40
|
+
local UUID at first sight (sync, draft save, server-search hit). Sync
|
|
41
|
+
maintains a UUID ↔ (account, folder, server-UID) map. Server-side moves
|
|
42
|
+
change the UID; the UUID stays. Local moves keep the UUID and queue a
|
|
43
|
+
server-side move. Tombstones (UUID + deleted flag) prevent re-fetch.
|
|
44
|
+
|
|
45
|
+
5. **Slow / unreachable server is not a UI failure mode.** Connection caps,
|
|
46
|
+
IDLE drops, OAuth refresh hangs, 60-second IMAP timeouts — none reach the
|
|
47
|
+
click → render path. They surface in a non-blocking sync-status indicator.
|
|
48
|
+
|
|
49
|
+
## Service-side API split
|
|
50
|
+
|
|
51
|
+
Today's `MailxService` mixes local reads with server-touching code. Split into
|
|
52
|
+
three layers:
|
|
53
|
+
|
|
54
|
+
### Layer 1: `LocalStore` (sync, never touches network)
|
|
55
|
+
|
|
56
|
+
Pure local reads + local commits. All synchronous (or trivially async over
|
|
57
|
+
sql.js / fs). Throws if asked to do anything network-y.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
class LocalStore {
|
|
61
|
+
// Reads
|
|
62
|
+
getMessage(uuid: string): LocalMessage | null;
|
|
63
|
+
getMessageByUid(accountId: string, uid: number): LocalMessage | null;
|
|
64
|
+
getMessages(query: MessageQuery): LocalMessage[];
|
|
65
|
+
getUnifiedInbox(page, pageSize): { items: LocalMessage[]; total: number };
|
|
66
|
+
searchMessages(query: string, ...): LocalMessage[]; // FTS5 only
|
|
67
|
+
getCalendarEvents(fromMs, toMs): CalEvent[];
|
|
68
|
+
getTasks(includeCompleted): Task[];
|
|
69
|
+
searchContacts(query): Contact[];
|
|
70
|
+
getMessageBody(uuid: string): { html?: string; text?: string; attachments: ... } | null;
|
|
71
|
+
|
|
72
|
+
// Writes — return local UUID, queue server mirror
|
|
73
|
+
saveDraft(draft: ComposeDraft): { uuid: string }; // queues
|
|
74
|
+
sendMessage(msg: ComposeMessage): { uuid: string }; // queues
|
|
75
|
+
moveMessages(uuids: string[], toFolderId: number): { ok: true };
|
|
76
|
+
flagMessages(uuids: string[], flags: string[]): { ok: true };
|
|
77
|
+
deleteMessages(uuids: string[]): { ok: true };
|
|
78
|
+
createCalendarEvent(ev): { uuid: string };
|
|
79
|
+
updateCalendarEvent(uuid, patch): { ok: true };
|
|
80
|
+
deleteCalendarEvent(uuid): { ok: true };
|
|
81
|
+
createTask(t): { uuid: string };
|
|
82
|
+
updateTask(uuid, patch): { ok: true };
|
|
83
|
+
deleteTask(uuid): { ok: true };
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Every UI IPC call routes through this layer first. The webview never sees
|
|
88
|
+
beyond this.
|
|
89
|
+
|
|
90
|
+
### Layer 2: `SyncQueue` (async, mediates server)
|
|
91
|
+
|
|
92
|
+
The only code path that opens IMAP clients, calls Gmail API, posts SMTP, etc.
|
|
93
|
+
Single-threaded per account. Queue persisted across restarts.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
class SyncQueue {
|
|
97
|
+
enqueueMove(uuid, fromFolderId, toFolderId): void;
|
|
98
|
+
enqueueFlag(uuid, addFlags, removeFlags): void;
|
|
99
|
+
enqueueDelete(uuid): void;
|
|
100
|
+
enqueueDraftPush(uuid): void;
|
|
101
|
+
enqueueSend(uuid): void;
|
|
102
|
+
enqueueBodyRefresh(uuid): void; // fire-and-forget; reconciler may dedupe
|
|
103
|
+
|
|
104
|
+
// Periodic
|
|
105
|
+
runPoll(): Promise<void>; // checks server for new mail, etc.
|
|
106
|
+
|
|
107
|
+
// Event emit
|
|
108
|
+
on(event: "mirrored" | "conflict" | "error", handler): void;
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Items in the queue are persistent (SQLite `sync_queue` table with `kind`,
|
|
113
|
+
`payload`, `attempts`, `created_at`, `last_attempt`, `next_attempt`, `status`).
|
|
114
|
+
|
|
115
|
+
### Layer 3: `Reconciler` (background loop)
|
|
116
|
+
|
|
117
|
+
Drains the queue, polls for server changes, emits events.
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
class Reconciler {
|
|
121
|
+
start(): void; // long-running tick
|
|
122
|
+
stop(): void;
|
|
123
|
+
syncNow(accountId?): void; // user-initiated "sync now"
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Reconciler events:
|
|
128
|
+
- `messageUpserted(uuid, source: "local"|"server")` — UI updates that row
|
|
129
|
+
- `messageRemoved(uuid)` — UI removes the row
|
|
130
|
+
- `bodyAvailable(uuid)` — UI re-fetches body (will hit local cache instantly)
|
|
131
|
+
- `syncStateChanged(accountId, state)` — status indicator updates
|
|
132
|
+
- `conflict(uuid, kind, detail)` — user gets a banner ("local move failed
|
|
133
|
+
on server because folder is gone")
|
|
134
|
+
|
|
135
|
+
## Event protocol (local store → UI)
|
|
136
|
+
|
|
137
|
+
Events flow one-way: store changes → UI redraws affected rows.
|
|
138
|
+
|
|
139
|
+
| Event | When | UI behavior |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `messageUpserted` | new mail synced; flag changed; body cached; envelope refreshed | re-render that row in place; if it's the focused row, re-render preview |
|
|
142
|
+
| `messageRemoved` | server-side delete reconciled; local delete with no undo window | remove row; advance focus to next |
|
|
143
|
+
| `bodyAvailable` | body fetch completed (from prefetch or on-demand reconcile) | re-render preview if this is the focused row |
|
|
144
|
+
| `folderCountsChanged` | unread counts changed | update folder-tree pill |
|
|
145
|
+
| `syncStateChanged` | reconciler started/stopped, account auth issue, sync error | update status bar indicator |
|
|
146
|
+
| `conflict` | local action failed on server | show banner with retry/discard |
|
|
147
|
+
|
|
148
|
+
Events carry a "sequence number" so the UI can drop stale events when the
|
|
149
|
+
user has navigated away from the affected row.
|
|
150
|
+
|
|
151
|
+
## Visible state model
|
|
152
|
+
|
|
153
|
+
Each row carries three independent indicators:
|
|
154
|
+
|
|
155
|
+
| Indicator | Source | Meaning |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| Body cached dot (existing teal/blue) | `body_path != null && file exists` | Tap is instant |
|
|
158
|
+
| Local-only outline (new) | `dirty=true && providerId=null` | Not yet pushed to server |
|
|
159
|
+
| Reconciliation pending (new) | `dirty=true && providerId!=null` | Push queued; will sync soon |
|
|
160
|
+
|
|
161
|
+
Only "body cached" affects how a click renders. The other two are advisory —
|
|
162
|
+
the user knows their action is committed locally and the server side will
|
|
163
|
+
catch up.
|
|
164
|
+
|
|
165
|
+
A status-bar pill shows `Sync OK` / `Syncing N items` / `Sync errors (click to view)`.
|
|
166
|
+
That's the only UI surface that reflects server state.
|
|
167
|
+
|
|
168
|
+
## Migration order
|
|
169
|
+
|
|
170
|
+
Land in this order so each step is independently shippable:
|
|
171
|
+
|
|
172
|
+
1. **Add `LocalStore` interface and route ALL UI IPC through it.** Service
|
|
173
|
+
methods become thin wrappers that pull from local DB only. No behavior
|
|
174
|
+
change yet — server fetches just stop happening on every UI call. Bodies
|
|
175
|
+
that aren't cached return null with a "not cached" hint.
|
|
176
|
+
|
|
177
|
+
2. **UI handles the "not cached" case explicitly.** Show a placeholder-with-
|
|
178
|
+
spinner that says "downloading body…" with no time pressure (the click is
|
|
179
|
+
already responsive — body just takes time to arrive). Listen for
|
|
180
|
+
`bodyAvailable` event and re-render. Remove the 15s retry timer band-aid.
|
|
181
|
+
Remove `body-broken` red-dot band-aid.
|
|
182
|
+
|
|
183
|
+
3. **Move all current `await imap*` calls in the service into `SyncQueue`
|
|
184
|
+
enqueue calls.** Drafts: write local + enqueue push. Send: write local +
|
|
185
|
+
enqueue SMTP. Move/flag/delete: commit local + enqueue mirror. Body fetch
|
|
186
|
+
on click: read local file or enqueue refresh (with `bodyAvailable` emit
|
|
187
|
+
when done).
|
|
188
|
+
|
|
189
|
+
4. **Build the `Reconciler` loop** that drains the queue, runs periodic polls,
|
|
190
|
+
and emits events. Replace the current `syncAll` with a reconciler that
|
|
191
|
+
processes both queue items and "sync from server" pulls in priority order.
|
|
192
|
+
|
|
193
|
+
5. **Add the visible-state indicators** (local-only outline, pending dot,
|
|
194
|
+
sync-status pill). Wire to the new event stream.
|
|
195
|
+
|
|
196
|
+
6. **Migrate the conflict path.** When a queued item fails on the server
|
|
197
|
+
(folder deleted, message gone, auth lapsed), surface a single banner
|
|
198
|
+
listing the affected items with retry/discard.
|
|
199
|
+
|
|
200
|
+
7. **Delete the band-aids**: 60s body-fetch timeout, 15s client retry timer,
|
|
201
|
+
`body-broken` class, "Fetching message body…" placeholder, all stale-gen
|
|
202
|
+
`if (gen !== showMessageGeneration) return` early-returns.
|
|
203
|
+
|
|
204
|
+
Each step ships standalone. After step 3 the UI is already responsive
|
|
205
|
+
even if the reconciler isn't built — it just stops doing sync until step 4.
|
|
206
|
+
|
|
207
|
+
## Concrete call-site changes
|
|
208
|
+
|
|
209
|
+
### Client side (TypeScript)
|
|
210
|
+
|
|
211
|
+
Files to touch and what changes:
|
|
212
|
+
|
|
213
|
+
- `client/components/message-viewer.ts` — `showMessage` becomes synchronous.
|
|
214
|
+
Reads from local cache, never awaits a server fetch. Body display routed
|
|
215
|
+
through new `bodyAvailable` event listener.
|
|
216
|
+
- `client/components/message-list.ts` — same for `loadMessages`,
|
|
217
|
+
`loadUnifiedInbox`, `loadSearchResults` (local scope only).
|
|
218
|
+
- `client/compose/compose.ts` — `saveDraft` and `send` get the local UUID
|
|
219
|
+
back immediately, no spinner.
|
|
220
|
+
- `client/components/calendar-sidebar.ts` — `getCalendarEvents` /
|
|
221
|
+
`createCalendarEvent` etc. pure local; events update on `messageUpserted`.
|
|
222
|
+
- `client/lib/api-client.ts` — every method documented as "local read" or
|
|
223
|
+
"queues server mirror". No method returns a server result directly anymore.
|
|
224
|
+
- `client/app.ts` — wire the new event types into the existing service-
|
|
225
|
+
channel listener.
|
|
226
|
+
|
|
227
|
+
### Service side (TypeScript)
|
|
228
|
+
|
|
229
|
+
- `mailx-service/index.ts` — split. The `MailxService` class stays as the
|
|
230
|
+
IPC entry point but delegates: reads → `LocalStore`, writes → `LocalStore`
|
|
231
|
+
+ `SyncQueue.enqueue*`, no more direct `imapManager.*` calls from UI
|
|
232
|
+
handlers.
|
|
233
|
+
- New file `mailx-service/local-store.ts` — pure local reads/writes.
|
|
234
|
+
- New file `mailx-service/sync-queue.ts` — persistent queue + enqueue API.
|
|
235
|
+
- New file `mailx-service/reconciler.ts` — background loop, draining +
|
|
236
|
+
polling, emits events.
|
|
237
|
+
- `mailx-imap/index.ts` — becomes a worker library called only by the
|
|
238
|
+
reconciler. UI never sees it.
|
|
239
|
+
|
|
240
|
+
### Database
|
|
241
|
+
|
|
242
|
+
- New table `sync_queue (id, kind, payload, attempts, created_at,
|
|
243
|
+
last_attempt, next_attempt, status, account_id)`.
|
|
244
|
+
- New columns on `messages`: `dirty BOOLEAN` (local-only or pending),
|
|
245
|
+
`last_local_change_ms` (so reconciler picks oldest first).
|
|
246
|
+
- Existing `body_path` semantics stay.
|
|
247
|
+
|
|
248
|
+
## What the user sees afterward
|
|
249
|
+
|
|
250
|
+
- Every click renders in <50ms regardless of server state.
|
|
251
|
+
- Drafts save and Reply opens are instant.
|
|
252
|
+
- New mail appears in the list as soon as the reconciler pulls it (no
|
|
253
|
+
user wait).
|
|
254
|
+
- A persistent status pill in the bottom-right shows whether sync is
|
|
255
|
+
current, lagging, or failing.
|
|
256
|
+
- Body fetches that haven't completed show a spinner inside the preview
|
|
257
|
+
pane that doesn't block interaction with the list.
|
|
258
|
+
- Multi-account: bobma's slowness affects only bobma's status pill, never
|
|
259
|
+
the UI as a whole.
|
|
260
|
+
|
|
261
|
+
## Effort
|
|
262
|
+
|
|
263
|
+
Realistic for solo work, your pace:
|
|
264
|
+
|
|
265
|
+
- Step 1 (route UI through LocalStore): 1-2 days
|
|
266
|
+
- Step 2 (UI body-not-cached handling): 1 day
|
|
267
|
+
- Step 3 (move IMAP calls into SyncQueue): 2-3 days
|
|
268
|
+
- Step 4 (build Reconciler): 2-3 days
|
|
269
|
+
- Step 5 (visible-state indicators): 1 day
|
|
270
|
+
- Step 6 (conflict path): 1 day
|
|
271
|
+
- Step 7 (delete band-aids): 1 day
|
|
272
|
+
|
|
273
|
+
Total: ~9-12 working days, shipped incrementally. Each step is its own
|
|
274
|
+
publish; no big-bang.
|
|
275
|
+
|
|
276
|
+
## Decisions (2026-05-06)
|
|
277
|
+
|
|
278
|
+
1. **sync_queue concurrency**: priority lanes (interactive > sync > prefetch
|
|
279
|
+
> backfill) **plus** multiple handlers per lane. The interactive lane is
|
|
280
|
+
reserved for on-demand body fetches the user just clicked; sync runs in
|
|
281
|
+
parallel on a different lane. Per-account serialization still applies
|
|
282
|
+
inside each lane to keep us under Dovecot/Gmail connection caps.
|
|
283
|
+
|
|
284
|
+
2. **Multi-device conflict**: last-writer-wins. The case (drag-at-the-same-time
|
|
285
|
+
on two devices) is rare enough that no banner / merge UI is needed. The
|
|
286
|
+
later push wins; the earlier one is silently superseded.
|
|
287
|
+
|
|
288
|
+
3. **Tombstones**: live forever, no retention policy. Cost is ~200 bytes per
|
|
289
|
+
row in the `messages` table — negligible even at 10k deletes/year × 10
|
|
290
|
+
years. They're shared across devices implicitly: every device syncs the
|
|
291
|
+
same server state, so a delete on phone propagates to desktop via the
|
|
292
|
+
server reconcile, no peer-to-peer sync needed. Local-only tombstones
|
|
293
|
+
(queued delete not yet pushed) stay per-device until the queue drains.
|
|
294
|
+
|
|
295
|
+
4. **Calendar / tasks / contacts**: same model. No special cases. Today's
|
|
296
|
+
two-way cache code is already on this pattern; the refactor just makes
|
|
297
|
+
message paths match.
|
|
298
|
+
|
|
299
|
+
5. **Outbox**: keep the existing flow as-is. The Drafts → Outbox → Sent
|
|
300
|
+
directory-based queue works and isn't blocking the UI today; subsuming
|
|
301
|
+
it would be churn for no user benefit.
|
|
302
|
+
|
|
303
|
+
Step 1 starts immediately.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# npmglobalize: transitive workspace dep gap on standalone publish
|
|
2
|
+
|
|
3
|
+
## Symptom
|
|
4
|
+
|
|
5
|
+
A workspace member with `npmVisibility: "public"` (`@bobfrankston/mailx-imap`) gets
|
|
6
|
+
published as a standalone registry package. Its tarball declares
|
|
7
|
+
`@bobfrankston/mailx-settings: ^0.1.6` as a registry dep — but
|
|
8
|
+
`mailx-settings` is *also* a workspace member, with no `.globalize.json5`,
|
|
9
|
+
so it was never published. End user runs `npm install -g @bobfrankston/mailx`
|
|
10
|
+
on a fresh machine and gets:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
404 Not Found - GET https://registry.npmjs.org/@bobfrankston%2fmailx-settings
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Reproducer (mailx workspace, 2026-05-02):
|
|
17
|
+
- `app/packages/mailx-imap/.globalize.json5` → `npmVisibility: "public"`
|
|
18
|
+
- `app/packages/mailx-imap/package.json` → `dependencies: { "@bobfrankston/mailx-settings": "file:../mailx-settings" }`
|
|
19
|
+
- `app/packages/mailx-settings/` exists, no `.globalize.json5`
|
|
20
|
+
- `npmglobalize` from workspace root: builds everything, publishes mailx-imap and the parent mailx, exits with success
|
|
21
|
+
- `mailx-imap`'s published `package.json` rewrote `file:../mailx-settings` → `^0.1.6`
|
|
22
|
+
- `mailx-settings` was never published
|
|
23
|
+
|
|
24
|
+
## What npmglobalize does today
|
|
25
|
+
|
|
26
|
+
When publishing a standalone-public workspace member, file: deps that point
|
|
27
|
+
at *other workspace members* get rewritten to registry version constraints
|
|
28
|
+
(`^X.Y.Z`) — same as it'd do for any external file: dep. No check that the
|
|
29
|
+
referenced workspace member is itself public/published.
|
|
30
|
+
|
|
31
|
+
Result: a tarball whose declared deps 404 on a clean install. The publish
|
|
32
|
+
itself succeeds; the breakage shows up at install time on a remote machine.
|
|
33
|
+
|
|
34
|
+
## What npmglobalize should do
|
|
35
|
+
|
|
36
|
+
Pick one (preference order):
|
|
37
|
+
|
|
38
|
+
### 1. Fail loud at publish time (cheapest, most defensible)
|
|
39
|
+
|
|
40
|
+
Before publishing a workspace member as standalone-public, walk its file:
|
|
41
|
+
deps. For each that resolves to another workspace member:
|
|
42
|
+
- If that member is also public → fine, the registry constraint will resolve.
|
|
43
|
+
- If that member is **not** public → **refuse to publish** with a clear error:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Refusing to publish @bobfrankston/mailx-imap as a standalone registry
|
|
47
|
+
package: it depends on @bobfrankston/mailx-settings (workspace member,
|
|
48
|
+
not public). Published tarball would 404 on install.
|
|
49
|
+
|
|
50
|
+
Fixes:
|
|
51
|
+
- Add .globalize.json5 with npmVisibility: "public" to mailx-settings
|
|
52
|
+
(and any of its transitive workspace deps).
|
|
53
|
+
- Or remove .globalize.json5 from mailx-imap so it stays bundled
|
|
54
|
+
under the parent's tarball.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This is the behavior I'd ask for first. It's predictable, errors at the
|
|
58
|
+
site of the mistake, and doesn't silently change packaging.
|
|
59
|
+
|
|
60
|
+
### 2. Bundle transitive workspace deps in the standalone tarball
|
|
61
|
+
|
|
62
|
+
When publishing a standalone-public workspace member, copy any non-public
|
|
63
|
+
workspace deps **into the tarball** as bundled directories, and keep file:
|
|
64
|
+
paths instead of rewriting to registry constraints. The published tarball
|
|
65
|
+
becomes self-contained.
|
|
66
|
+
|
|
67
|
+
Trade-off: tarball gets bigger and pulls in copies on every standalone
|
|
68
|
+
publish. Multiple public members each carry their own copy of shared
|
|
69
|
+
internal deps — duplication on disk, but install-time correctness.
|
|
70
|
+
|
|
71
|
+
### 3. Auto-promote transitive workspace deps to public
|
|
72
|
+
|
|
73
|
+
If a public member depends on a non-public workspace member, automatically
|
|
74
|
+
promote the dep to public and publish it too. Cascading.
|
|
75
|
+
|
|
76
|
+
Trade-off: silently expands the publish surface area — one new public
|
|
77
|
+
package can flip the entire transitive closure to public. Surprising and
|
|
78
|
+
hard to undo. Less defensible than (1) or (2).
|
|
79
|
+
|
|
80
|
+
## Recommendation
|
|
81
|
+
|
|
82
|
+
Implement (1). It's a one-pass dependency walk before the publish step,
|
|
83
|
+
returning a clear error message. The other options can layer on later if
|
|
84
|
+
the workflow demands them — but the loud failure prevents the silent-bad-
|
|
85
|
+
publish from ever shipping in the first place.
|
|
86
|
+
|
|
87
|
+
## Edge cases (1) needs to handle
|
|
88
|
+
|
|
89
|
+
- **External file: deps** (not workspace members): `file:../../MailApps/iflow-direct`
|
|
90
|
+
resolves outside the workspace. Today these get rewritten to registry
|
|
91
|
+
versions and presumably *those* deps are published separately. Don't
|
|
92
|
+
block on those — only on workspace-internal file: deps.
|
|
93
|
+
- **Public member depends on public member**: fine, registry resolves both.
|
|
94
|
+
- **Public member depends on a registry version that exists**: fine.
|
|
95
|
+
- **Public member depends on a workspace member with `noPublish: true`**:
|
|
96
|
+
same gap, same fail. Treat noPublish=true workspace members as "not public".
|
|
97
|
+
- **Cyclic deps**: shouldn't apply here (workspace dep graphs are DAGs by
|
|
98
|
+
npm's own constraint), but if the walk hits one, abort with a different
|
|
99
|
+
message.
|
|
100
|
+
|
|
101
|
+
## Why this matters
|
|
102
|
+
|
|
103
|
+
Symptom is "Mac install fails" — but the root cause is npmglobalize
|
|
104
|
+
publishing a tarball it can't possibly install correctly. The user has
|
|
105
|
+
no signal at publish time; the breakage is invisible until someone tries
|
|
106
|
+
to install the package on a fresh machine. Fail-at-publish makes the
|
|
107
|
+
contract explicit: *if it publishes, it installs.*
|
package/docs/outlook.md
CHANGED
|
@@ -1,31 +1,93 @@
|
|
|
1
1
|
# Outlook.com / Microsoft 365 Support
|
|
2
2
|
|
|
3
|
-
Status (2026-06-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Status (2026-06-21): **native Graph support implemented and wired as a
|
|
4
|
+
PUBLIC client (PKCE, no secret).** The provider, the Microsoft tokenProvider,
|
|
5
|
+
and the full three-way dispatcher routing (sync, body fetch, flags/move/trash,
|
|
6
|
+
send via Graph `/sendMail`) are done. The **public client_id ships bundled in
|
|
7
|
+
the build** (`packages/mailx-imap/microsoft-credentials.json`), so a fresh
|
|
8
|
+
install needs no credential placement. The only human step is the one-time Azure
|
|
9
|
+
app registration.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
> ## ⬇ WHAT ONLY YOU DO (one-time, ~10 min — already done for the `rmfmail` app)
|
|
12
|
+
>
|
|
13
|
+
> The app `rmfmail` is registered; its public client_id
|
|
14
|
+
> `f0df4236-5d26-4aa3-a243-47f043b9db36` is committed in the build. These steps
|
|
15
|
+
> are the recipe to reproduce it (e.g. a fresh app, or a fork):
|
|
16
|
+
>
|
|
17
|
+
> 1. **Register an Azure app** (full walkthrough in [§A](#a-register-the-azure-app--the-part-only-you-can-do) below):
|
|
18
|
+
> - Entra → App registrations → New registration → name `rmfmail`,
|
|
19
|
+
> account type **"any org directory + personal Microsoft accounts"**.
|
|
20
|
+
> - Redirect URI: platform **"Mobile and desktop applications"** = **`http://localhost`**.
|
|
21
|
+
> - **Authentication → Advanced settings → "Allow public client flows" → Yes.**
|
|
22
|
+
> (Required for the no-secret PKCE flow.)
|
|
23
|
+
> - **No client secret.** Skip "Certificates & secrets" entirely — a public
|
|
24
|
+
> client doesn't use one (this is what removes the 2-year renewal chore).
|
|
25
|
+
> - API permissions → Microsoft Graph → **Delegated**: `Mail.ReadWrite`,
|
|
26
|
+
> `Mail.Send`, `offline_access`, `openid`, `profile`.
|
|
27
|
+
> 2. **Put the public client_id in the build** — edit
|
|
28
|
+
> `packages/mailx-imap/microsoft-credentials.json` (already present for
|
|
29
|
+
> `rmfmail`; client_id only, **never a secret**):
|
|
30
|
+
> ```json
|
|
31
|
+
> {
|
|
32
|
+
> "installed": {
|
|
33
|
+
> "client_id": "<Application (client) ID>",
|
|
34
|
+
> "auth_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
35
|
+
> "token_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
36
|
+
> "redirect_uris": ["http://localhost"]
|
|
37
|
+
> }
|
|
38
|
+
> }
|
|
39
|
+
> ```
|
|
40
|
+
> A per-machine override at `~/.rmfmail/microsoft-credentials.json` (note:
|
|
41
|
+
> **`.rmfmail`, not `.mailx`** — that's `getConfigDir()`) takes precedence if
|
|
42
|
+
> present, but isn't needed.
|
|
43
|
+
> 3. **Add an Outlook account** to `accounts.jsonc` (cloud-canonical copy, e.g.
|
|
44
|
+
> `<Drive>/Home/.rmfmail/accounts.jsonc`) — just `{ "id": "...", "email":
|
|
45
|
+
> "you@outlook.com" }`; the `@outlook.com`/M365 domain auto-routes to Graph.
|
|
46
|
+
> Restart; first sync pops the Microsoft consent at `http://localhost`; approve.
|
|
47
|
+
>
|
|
48
|
+
> ## 🔁 Renewal / expiry — what you have to do over time
|
|
49
|
+
>
|
|
50
|
+
> **Nothing on a schedule.** Because this is a public client with **no secret**:
|
|
51
|
+
> - **Client secret** — none exists, so there is **no 2-year (or any) secret
|
|
52
|
+
> renewal**. (A confidential setup would force rotating the secret before its
|
|
53
|
+
> expiry; we deliberately avoid that.)
|
|
54
|
+
> - **Client ID** — permanent; never expires.
|
|
55
|
+
> - **Refresh token** — cached per account under `~/.rmfmail/tokens/<email>/`;
|
|
56
|
+
> rmfmail refreshes it automatically. If it ever goes stale (you revoke access,
|
|
57
|
+
> change the password, or Microsoft invalidates it), rmfmail just re-shows the
|
|
58
|
+
> consent once. Not a scheduled task.
|
|
59
|
+
> - **API permissions** — only re-touched if you add a feature needing a new
|
|
60
|
+
> scope (e.g. Calendar). Existing mail scopes don't expire.
|
|
61
|
+
>
|
|
62
|
+
> If consent fails with `invalid_request` / "client secret required", the app
|
|
63
|
+
> isn't set as a public client — re-check "Allow public client flows = Yes" and
|
|
64
|
+
> that no leftover per-machine `~/.rmfmail/microsoft-credentials.json` is passing
|
|
65
|
+
> a stale secret. Tokens that mint but sync-401 usually mean a scope/audience
|
|
66
|
+
> mismatch (Graph vs IMAP) — we mint **Graph**.
|
|
67
|
+
|
|
68
|
+
The rest of this doc is the implementation reference and the detailed Azure
|
|
69
|
+
walkthrough.
|
|
10
70
|
|
|
11
71
|
---
|
|
12
72
|
|
|
13
|
-
## What already exists
|
|
73
|
+
## What already exists (now wired — 2026-06-20)
|
|
14
74
|
|
|
15
75
|
| Piece | Where | State |
|
|
16
76
|
|---|---|---|
|
|
17
|
-
| Graph API provider | `@bobfrankston/mailx-sync/outlook.ts` (re-exported `packages/mailx-imap/providers/outlook-api.ts`) | **
|
|
18
|
-
|
|
|
77
|
+
| Graph API provider | `@bobfrankston/mailx-sync/outlook.ts` (re-exported `packages/mailx-imap/providers/outlook-api.ts`) | **Full**: read (`listFolders`, `fetchSince/ByDate/ByUids/One`, `getUids`) + write-back (`setFlags`, `trashMessage`, `moveMessage`), `sendRaw` (POST /sendMail), `fetchBodiesBatch`, provider_id-first identity, rate-limit + Retry-After backoff. Mirrors `gmail.ts`. |
|
|
78
|
+
| Microsoft tokenProvider | `packages/mailx-imap/index.ts` `addAccount` | **Done**: `isOutlookCfg(account)` reads `~/.rmfmail/microsoft-credentials.json` if present, else the **bundled** `packages/mailx-imap/microsoft-credentials.json`; mints Graph scopes (`Mail.ReadWrite Mail.Send offline_access openid profile`). |
|
|
79
|
+
| Bundled public creds | `packages/mailx-imap/microsoft-credentials.json` | **Done**: public client_id only (no secret), committed + shipped with the package — fresh installs need no creds placement. |
|
|
80
|
+
| Provider routing | `packages/mailx-imap/index.ts` | **Done**: `isOutlookAccount`/`isApiAccount`/`getApiProvider`/`getOutlookProvider`; ~15 dispatch sites route Outlook through the REST path like Gmail. Send via `sendRawForAccount` → Graph `/sendMail`. |
|
|
81
|
+
| Host auto-config | `packages/mailx-settings/index.ts:503` (`outlook.com`, `hotmail.com` → `outlook.office365.com:993` / `smtp.office365.com:587`, `auth: "oauth2"`) | Done (the `auth:"oauth2"` flag is what lands Outlook in the tokenProvider branch) |
|
|
19
82
|
| MX-based detection | `bin/mailx.ts:1307` (`*.outlook.com` / `*.protection.outlook.com` MX → Microsoft 365) | Done |
|
|
20
|
-
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
token to present.
|
|
83
|
+
| OAuth flow (PKCE) | `@bobfrankston/oauthsupport` `OAuthTokenManager.ts` | **PKCE added 2026-06-21**: `client_secret` now optional; auth-code flow sends `code_challenge`/`code_verifier` (S256). Public clients (Microsoft, no secret) and confidential clients (Google, secret) both work through one path. |
|
|
84
|
+
|
|
85
|
+
**The one remaining gate is the Azure app registration** (your part, top of this
|
|
86
|
+
doc) — and it's a public client, so once the app is registered with "Allow
|
|
87
|
+
public client flows" enabled, Outlook authenticates and syncs/sends entirely via
|
|
88
|
+
Graph — no IMAP, no SMTP, no secret to manage. (Historical note: send does NOT
|
|
89
|
+
use the old OAuth-SMTP path at `index.ts:1533`, because a Graph-scoped token has
|
|
90
|
+
the wrong audience for SMTP; Outlook sends via Graph `/sendMail`.)
|
|
29
91
|
|
|
30
92
|
---
|
|
31
93
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# `rules.jsonc` — per-user message classification rules (design proposal)
|
|
2
|
+
|
|
3
|
+
> Draft for discussion. Nothing implemented yet. Captures the mechanism we need to agree on before writing code.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Per-sender flagging (the existing `flaggedSenders` / `flaggedDomains` in `allowlist.jsonc`) is unreliable: spammers rotate addresses faster than the user can list them. A pattern-based system that runs on first sight of a message is more durable. It also covers the cases where the user wants:
|
|
8
|
+
|
|
9
|
+
- Auto-move newsletters to a folder
|
|
10
|
+
- Star anything from a specific person
|
|
11
|
+
- Drop everything matching a phishing keyword pattern
|
|
12
|
+
- Mark certain mailing-list traffic as read on arrival
|
|
13
|
+
|
|
14
|
+
The mechanism should be **declarative** (JSONC, no code), **deterministic** (same input → same output), and **fast** (no body-parse for header-only rules).
|
|
15
|
+
|
|
16
|
+
## Where the file lives
|
|
17
|
+
|
|
18
|
+
- **Per-user, synced**: `My Drive/home/.rmfmail/rules.jsonc` — your personal rules, available on every device.
|
|
19
|
+
- **Disjoint from `contact-rules.jsonc`**: that one is *global, app-shipped* — applies to everyone, deployed on every release. `rules.jsonc` is *yours alone*.
|
|
20
|
+
- **Disjoint from `allowlist.jsonc`**: keep `senders/domains/recipients` (remote-content allow) and `flaggedSenders/flaggedDomains` (sender-watch list) where they are. `rules.jsonc` is a *new* layer on top.
|
|
21
|
+
|
|
22
|
+
## Schema (proposed)
|
|
23
|
+
|
|
24
|
+
```jsonc
|
|
25
|
+
{
|
|
26
|
+
"version": 1,
|
|
27
|
+
"rules": [
|
|
28
|
+
{
|
|
29
|
+
"name": "Newsletter auto-archive", // human label, shown in match logs
|
|
30
|
+
"when": "incoming", // "incoming" | "outgoing" | "always"; default "incoming"
|
|
31
|
+
"match": [
|
|
32
|
+
{ "header": "list-id" }, // exists check (any non-empty value)
|
|
33
|
+
{ "header": "subject", "regex": "(?i)(unsubscribe|newsletter|digest)" }
|
|
34
|
+
],
|
|
35
|
+
"matchMode": "any", // "all" | "any"; default "all"
|
|
36
|
+
"actions": [
|
|
37
|
+
{ "type": "moveTo", "folder": "Newsletters" },
|
|
38
|
+
{ "type": "markRead", "value": true }
|
|
39
|
+
],
|
|
40
|
+
"stop": true // first match wins; remaining rules skipped
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "Star anything from boss",
|
|
44
|
+
"match": [
|
|
45
|
+
{ "header": "from", "contains": "boss@example.com" }
|
|
46
|
+
],
|
|
47
|
+
"actions": [
|
|
48
|
+
{ "type": "flag", "value": true } // sets \\Flagged
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "Junk obvious phishing-domain shapes",
|
|
53
|
+
"match": [
|
|
54
|
+
{ "senderDomain": { "regex": "^[a-z0-9-]{1,3}\\.[a-z]{2,}$" } },
|
|
55
|
+
{ "header": "subject", "regex": "(?i)(verify your|account suspended|click here to)" }
|
|
56
|
+
],
|
|
57
|
+
"actions": [ { "type": "moveTo", "folder": "Junk" } ],
|
|
58
|
+
"stop": true
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Match conditions
|
|
65
|
+
|
|
66
|
+
Each entry in `match[]` is one predicate:
|
|
67
|
+
|
|
68
|
+
| Field | Operator | Example | Notes |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| `header: "name"` | bare = exists | `{ "header": "list-id" }` | true when header is present and non-empty |
|
|
71
|
+
| `header: "name"` + `regex` | regex match | `{ "header": "from", "regex": "(?i)@example\\.com$" }` | case-insensitive by default; explicit `(?i)` allowed |
|
|
72
|
+
| `header: "name"` + `contains` | substring | `{ "header": "subject", "contains": "[urgent]" }` | case-insensitive |
|
|
73
|
+
| `header: "name"` + `equals` | exact | `{ "header": "to", "equals": "team@bob.ma" }` | case-insensitive |
|
|
74
|
+
| `senderDomain: { regex \| contains \| equals }` | extracts domain from From | shortcut so users don't have to remember the from-header trick |
|
|
75
|
+
| `body: { regex \| contains }` | matches against text body | slower (requires body fetch); only run if no header rule rejected first |
|
|
76
|
+
| `account: "id"` | scope to one account | `{ "account": "bobma" }` | combined with other predicates via matchMode |
|
|
77
|
+
| `folder: "name"` | scope to incoming folder | rare; useful for cross-folder rules |
|
|
78
|
+
|
|
79
|
+
### Actions
|
|
80
|
+
|
|
81
|
+
| Type | Param | Effect |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `moveTo` | `folder: "name"` | IMAP move to that folder; queued in sync-actions |
|
|
84
|
+
| `delete` | — | Move to Trash (true delete is rarely what users want) |
|
|
85
|
+
| `markRead` | `value: true \| false` | Set/clear `\\Seen` |
|
|
86
|
+
| `flag` | `value: true \| false` | Set/clear `\\Flagged` (the per-message ⚑/★) |
|
|
87
|
+
| `addLabel` | `label: "name"` | Gmail label add (Gmail only) |
|
|
88
|
+
| `tag` | `tag: "name"` | Local-only tag (no IMAP equivalent; for filtering UI) |
|
|
89
|
+
| `notify` | `message: "text"` | Desktop notification with custom message |
|
|
90
|
+
| `priority` | `value: true \| false` | Mark this message as from a priority sender. Row gets `.priority` class; CSS highlights it (e.g. gold left border, bolder sender name) — strongest while unread, fades when read. |
|
|
91
|
+
|
|
92
|
+
Deferred (require more design):
|
|
93
|
+
- `forwardTo` — auto-forward (loop risk, deliverability)
|
|
94
|
+
- `sendReply` — auto-reply with template (vacation responder territory)
|
|
95
|
+
|
|
96
|
+
### Evaluation order
|
|
97
|
+
|
|
98
|
+
1. Rules apply in array order.
|
|
99
|
+
2. Multiple rules can match a single message; all their actions accumulate.
|
|
100
|
+
3. `stop: true` on a matching rule halts further evaluation for that message.
|
|
101
|
+
4. Conflicts (e.g. two `moveTo` in the same evaluation) → last one wins.
|
|
102
|
+
|
|
103
|
+
## When rules run
|
|
104
|
+
|
|
105
|
+
- **Incoming**: during sync, after metadata is inserted into the local DB but before the row is shown in the list. Sync emits a `folderCountsChanged` event after rules apply, so the user sees the post-rule state.
|
|
106
|
+
- **Manual "apply rules to existing"**: a Settings button (or `rmfmail -applyrules` CLI) walks every cached message and applies. Useful for cleaning up an existing INBOX after adding new rules.
|
|
107
|
+
- **Live edit**: when `rules.jsonc` saves, the in-memory rule set reloads. Existing messages aren't re-evaluated until next sync (or manual apply).
|
|
108
|
+
|
|
109
|
+
## Migration
|
|
110
|
+
|
|
111
|
+
When `rules.jsonc` first loads, if `flaggedSenders` / `flaggedDomains` in `allowlist.jsonc` are non-empty AND the user has no `rules.jsonc` yet, auto-generate a starter file with one rule per entry:
|
|
112
|
+
|
|
113
|
+
```jsonc
|
|
114
|
+
{
|
|
115
|
+
"version": 1,
|
|
116
|
+
"rules": [
|
|
117
|
+
{
|
|
118
|
+
"name": "Migrated: warn on sender boss@spam.com",
|
|
119
|
+
"match": [{ "header": "from", "contains": "boss@spam.com" }],
|
|
120
|
+
"actions": [{ "type": "tag", "tag": "warn" }]
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The `tag` action is intentionally weak — same effect as today's per-sender `flaggedSenders` (just paints a warning banner), no destructive action. User can then refine to `moveTo: Junk` etc.
|
|
127
|
+
|
|
128
|
+
## Priority senders (companion to watch list)
|
|
129
|
+
|
|
130
|
+
Symmetric concept: a user-curated list of *important* senders whose unread messages should visually stand out in the list. Same data shape as `flaggedSenders` (now "watch list"), opposite intent.
|
|
131
|
+
|
|
132
|
+
Two ways to source the list:
|
|
133
|
+
|
|
134
|
+
- **A. Reuse `contacts.jsonc → preferred[]`** — every preferred contact's incoming mail is "priority" by default. Single concept; simpler. May couple two things that should be separate (autocomplete-preferred vs. visually-prominent).
|
|
135
|
+
- **B. New explicit list** — `prioritySenders` / `priorityDomains` (added to `allowlist.jsonc` for symmetry with the watch list, OR a new `priority` array in `contacts.jsonc`). Independent of autocomplete preference.
|
|
136
|
+
|
|
137
|
+
Either way, the rendering is the same:
|
|
138
|
+
|
|
139
|
+
- Row gains `.priority` class when sender / domain matches.
|
|
140
|
+
- CSS: `.ml-row.priority.unread` → distinct styling (gold accent, bolder name, possibly a small star/dot indicator that's *not* the per-message ⚑/★).
|
|
141
|
+
- After the message is read, the priority cue de-emphasizes (CSS `.ml-row.priority:not(.unread)` is more subtle) — Bob's intent: "at least as long as the messages are unread".
|
|
142
|
+
|
|
143
|
+
Once `rules.jsonc` lands, the same effect can be expressed via a rule + the `priority` action above. The dedicated list could either:
|
|
144
|
+
- Be removed (rules-only), OR
|
|
145
|
+
- Stay as a fast-path the rule engine doesn't need to consult on every message.
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
1. **Is the action set right?** Listed: `moveTo / delete / markRead / flag / addLabel / tag / notify`. Anything missing? Anything you'd cut to keep the surface tight?
|
|
150
|
+
2. **Body matching:** include `body: { regex / contains }`? Slower (requires body-fetch before list shows the row), but useful for catching specific phishing patterns. Could gate behind `requiresBody: true` per-rule so the sync path knows to fetch first.
|
|
151
|
+
3. **Per-account scoping:** is `account` predicate enough, or do you want a top-level `account` field on the rule?
|
|
152
|
+
4. **First-match-wins by default vs all-match-applies:** I have all-match with explicit `stop`. Should it be the other way (first-match-wins, with explicit `continue`)?
|
|
153
|
+
5. **Editing UI:** JSONC editor only (matches today's pattern), or build a structured rule builder later?
|
|
154
|
+
6. **Legacy migration:** auto-translate `flaggedSenders` / `flaggedDomains` to rules with `tag: "warn"` action? Keep both systems running indefinitely? Or deprecate the old one and force migration?
|
|
155
|
+
7. **Concept of "test mode":** would you want a `--dryrun` action set ("log what would happen, don't actually move/flag")? Useful for shipping a rule confidently.
|
|
156
|
+
8. **Performance bound:** how many rules is "a lot"? My internal sense is 50-200 is fine for header-only matching. Body-matching needs to be careful.
|
|
157
|
+
|
|
158
|
+
## Status
|
|
159
|
+
|
|
160
|
+
- Priority-sender feature **shipped as a standalone facility** ahead of the rules engine. Implementation lives in `mailx-store/db.ts` (in-memory index) + `mailx-service/index.ts` (`getPriorityLists` / `setPrioritySender` / `setPriorityDomain`), with Android parity in `mailx-store-web/web-service.ts`. Visual: `.ml-row.priority` class with gold left bar + bold sender name. View → "Priority senders only" toggle. Right-click "Mark sender as priority" in the viewer.
|
|
161
|
+
- **TODO when rules engine ships:** add a `priority` action so priority can be expressed as a rule, not only as a contacts.jsonc property. Keep the contacts.jsonc field as a fast path that doesn't require running the engine on every row render. Alternatively, deprecate the field and migrate to a rule.
|
|
162
|
+
|
|
163
|
+
## Implementation order (once approved)
|
|
164
|
+
|
|
165
|
+
1. Schema + parser in `mailx-store/rules.ts` (pure function, fully testable)
|
|
166
|
+
2. Evaluator: `evaluate(message, rules) → ActionList`
|
|
167
|
+
3. Hook into desktop sync (mailx-imap) — fire after metadata insert
|
|
168
|
+
4. Hook into Android sync (mailx-store-web) — same place
|
|
169
|
+
5. JSONC editor file-list adds `rules.jsonc`
|
|
170
|
+
6. Rename `flaggedSenders/flaggedDomains` → keep field names for compat; UI already uses "watch list" wording
|
|
171
|
+
7. Migration step on first launch with new file
|
|
172
|
+
8. `rules.md` doc deployed to GDrive (same mechanism as the other `.md` files in 1.0.498+)
|
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,CAgDxE;AAED;;;;8BAI8B;AAC9B,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAkB9E;AAED;;qCAEqC;AACrC,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsCjF;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;AAmFD,MAAM,WAAW,gBAAgB;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAA;KAAE,CAAC;IAChF,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAA;KAAE,CAAC;CASnF;AAmDD;;;;uEAIuE;AACvE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAE9E;AAED;;;;;;;;;;;;4EAY4E;AAC5E,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAcpD;AAED;;;0EAG0E;AAC1E,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;qDAEqD;AACrD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,GAAG,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,GAAG,EAAE,eAAe,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
|
@@ -602,15 +602,16 @@ export function normalizeAccount(acct, globalName, globalSig, globalSignature) {
|
|
|
602
602
|
const provider = PROVIDERS[domain];
|
|
603
603
|
const user = acct.imap?.user || acct.user || email;
|
|
604
604
|
// P14: auto-derive id and label so a known-provider account works with just
|
|
605
|
-
// { email, password? } in accounts.jsonc.
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
// the
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
605
|
+
// { email, password? } in accounts.jsonc.
|
|
606
|
+
// - id/label MIRROR each other when only one is given (the normal case is
|
|
607
|
+
// that they're the same): label→id already existed; id→label added.
|
|
608
|
+
// - When NEITHER is given, the auto-id is the FULL email address. A bare
|
|
609
|
+
// local-part ("bob") or domain stem is ambiguous — bob@aol.com and
|
|
610
|
+
// bob@gmail.com would collide — so the whole address is the only
|
|
611
|
+
// unambiguous derivation. label then prefers the provider's display name.
|
|
612
|
+
const autoId = email || "account";
|
|
612
613
|
return {
|
|
613
|
-
id: acct.id || autoId,
|
|
614
|
+
id: acct.id || acct.label || autoId,
|
|
614
615
|
name: acct.name || globalName || localPart,
|
|
615
616
|
label: acct.label || provider?.label || acct.id || autoId,
|
|
616
617
|
email,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-settings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
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.20",
|
|
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.20",
|
|
37
37
|
"jsonc-parser": "^3.3.1"
|
|
38
38
|
}
|
|
39
39
|
}
|