@bobfrankston/rmfmail 1.2.108 → 1.2.112

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.
Files changed (41) hide show
  1. package/TODO.md +1 -0
  2. package/client/android-bootstrap.bundle.js +93 -4
  3. package/client/android-bootstrap.bundle.js.map +3 -3
  4. package/client/app.bundle.js +4 -3
  5. package/client/app.bundle.js.map +2 -2
  6. package/client/components/calendar-sidebar.js +4 -3
  7. package/client/components/calendar-sidebar.js.map +1 -1
  8. package/client/components/calendar-sidebar.ts +4 -3
  9. package/package.json +7 -7
  10. package/packages/mailx-imap/index.d.ts.map +1 -1
  11. package/packages/mailx-imap/index.js +17 -3
  12. package/packages/mailx-imap/index.js.map +1 -1
  13. package/packages/mailx-imap/index.ts +16 -3
  14. package/packages/mailx-imap/package-lock.json +2 -2
  15. package/packages/mailx-imap/package.json +1 -1
  16. package/packages/mailx-service/index.d.ts +9 -0
  17. package/packages/mailx-service/index.d.ts.map +1 -1
  18. package/packages/mailx-service/index.js +133 -77
  19. package/packages/mailx-service/index.js.map +1 -1
  20. package/packages/mailx-service/index.ts +107 -55
  21. package/packages/mailx-settings/package.json +1 -1
  22. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -1
  23. package/packages/mailx-store-web/android-bootstrap.js +25 -0
  24. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  25. package/packages/mailx-store-web/android-bootstrap.ts +25 -0
  26. package/packages/mailx-store-web/db.d.ts +7 -0
  27. package/packages/mailx-store-web/db.d.ts.map +1 -1
  28. package/packages/mailx-store-web/db.js +24 -1
  29. package/packages/mailx-store-web/db.js.map +1 -1
  30. package/packages/mailx-store-web/db.ts +20 -1
  31. package/packages/mailx-store-web/package.json +1 -1
  32. package/packages/mailx-store-web/web-service.d.ts +15 -2
  33. package/packages/mailx-store-web/web-service.d.ts.map +1 -1
  34. package/packages/mailx-store-web/web-service.js +35 -2
  35. package/packages/mailx-store-web/web-service.js.map +1 -1
  36. package/packages/mailx-store-web/web-service.ts +33 -2
  37. package/packages/mailx-store-web/web-settings.d.ts +6 -0
  38. package/packages/mailx-store-web/web-settings.d.ts.map +1 -1
  39. package/packages/mailx-store-web/web-settings.js +20 -0
  40. package/packages/mailx-store-web/web-settings.js.map +1 -1
  41. package/packages/mailx-store-web/web-settings.ts +20 -0
package/TODO.md CHANGED
@@ -19,6 +19,7 @@ These items don't need user input — source-only changes, compile-verified befo
19
19
  | ~~5~~ | ~~**C126 — Cold-start latency (~28s observed)**~~ — **MOSTLY DONE** (2026-05-11). Root cause IDed from boot log: 43 s of cascading ES module imports through msger's custom protocol IPC (one roundtrip per file). Fixes shipped: (a) bundling via esbuild (`bin/build-bundles.mjs`) collapses the cascade to one fetch per entry point — `client/app.bundle.js` 345 kb, `client/compose/compose.bundle.js` 99 kb; (b) boot-snapshot hydration — bundled app saves `folder-tree` + `ml-body` innerHTML to localStorage every 30 s; inline script in `index.html` restores at cold start before the bundle has fetched. Sub-bullet (c) [500 ms placeholder pause] not in current code at cited line — skipped as stale. Expected cold start now 3-5 s (full WebView2 + bundle parse), with near-instant *feel* via hydrated snapshot after the first run. | M | |
20
20
  | **6** | **C125 — Unify desktop + Android IMAP code paths** | M | Today's split (`mailx-imap` Node-only + `mailx-store-web` browser-only) duplicates orchestration that's protocol-identical. Differences boil down to (a) transport factory choice (b) whether clients are persistent — both parameters, not architectural divides. Plan: extract `mailx-imap-core` with ImapManager + ops queue + fast-lane + sync orchestration over a `Transport` and a `Storage` interface, no Node-specific imports. `mailx-imap` and `mailx-store-web` become thin shims that wire engine-specific Storage + Transport. `{persistent: true\|false}` flag on ImapManager covers the lifetime split (no fast-lane queue when ephemeral). Pre-req for Android non-Gmail IMAP without forking sync code. |
21
21
  | **5** | **C124 — mailto handler on Linux + macOS** | S | Counterpart to P115 (Win). Linux: write a `~/.local/share/applications/rmfmail.desktop` with `MimeType=x-scheme-handler/mailto;` + `Exec=node /path/to/mailx.js --mailto %u`, `xdg-mime default rmfmail.desktop x-scheme-handler/mailto`, `update-desktop-database`. No exe-launcher needed — Linux pickers read `Name=` from the `.desktop` directly. macOS: real `.app` bundle (LaunchServices won't register a bare script) with `Info.plist` `CFBundleURLTypes` declaring `mailto` + a tiny launcher binary; `LSSetDefaultHandlerForURLScheme` programmatically sets the default. Rust `rmfmailto-src/` crate already portable — drop the Win32 imports and `cargo build --target x86_64-apple-darwin` produces the macOS launcher. |
22
+ | **7** | **C154 — Audit Android bridge vs MailxApi contract** | S | The hand-written `installBridge()` api object in `mailx-store-web/android-bootstrap.ts` exposes ~45 fewer methods than `client/lib/api-client.ts` can call (`getThreadMessages`, `getOutboxStatus`, `unsubscribeOneClick`, calendar/tasks, user-dict, …). Any missing one fails as `parent bridge has no method "X"` from the compose/app IPC relay — that's how the ⊘ denylist button broke on the phone (fixed v1.2.109: added `addToDenylist`/`addPreferredContact`/`hasBccHistoryTo`/`loadContactsConfig`). Web-service already `implements MailxApi` with explicit stubs, so the gap is purely the bridge layer. Audit each remaining name: expose real impls + notImpl stubs (arg shapes per `client/lib/mailxapi.js`), consciously comment-skip desktop-only ones, and add a typed check or generic stub pass-through so the class of bug can't recur. |
22
23
  ~~done — C120 read transport diagnostics~~ shipped v1.0.583 (per-folder timeout error now includes `[conn#X r=YB w=ZB writes=N sinceLastRead=Tms]` snapshot for the doomed socket).
23
24
  | 11 | **[P116](#ext116) — Signature edit in Settings menu** | M | Deferred v1.0.401 — turns out to be M (multi-account modal + save path + JSONC round-trip), not S. Real work when Settings UI gets its proper pass. |
24
25
  | 13 | **S56 full AbortController plumbing** | L | Thread `AbortSignal` through `getMessage → fetchMessageBody → provider.fetchOne`. Touches IMAP + Gmail + Outlook providers. |
@@ -2568,6 +2568,7 @@ var web_settings_exports = {};
2568
2568
  __export(web_settings_exports, {
2569
2569
  clearSettings: () => clearSettings,
2570
2570
  cloudRead: () => cloudRead,
2571
+ cloudStat: () => cloudStat,
2571
2572
  cloudWrite: () => cloudWrite,
2572
2573
  getDeviceId: () => getDeviceId,
2573
2574
  getHistoryDays: () => getHistoryDays,
@@ -2647,6 +2648,23 @@ async function cloudRead(filename) {
2647
2648
  async function cloudWrite(filename, content) {
2648
2649
  return gDriveWrite(filename, content);
2649
2650
  }
2651
+ async function cloudStat(filename) {
2652
+ if (!tokenProvider || !gDriveFolderId)
2653
+ return null;
2654
+ try {
2655
+ const token = await tokenProvider();
2656
+ const q = encodeURIComponent(`name='${filename}' and '${gDriveFolderId}' in parents and trashed=false`);
2657
+ const res = await globalThis.fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,modifiedTime)`, { headers: { "Authorization": `Bearer ${token}` } });
2658
+ if (!res.ok)
2659
+ return null;
2660
+ const data = await res.json();
2661
+ const f = data.files?.[0];
2662
+ return f?.id ? { id: f.id, modifiedTime: f.modifiedTime || "" } : null;
2663
+ } catch (e) {
2664
+ console.error(`[settings] GDrive stat ${filename}: ${e.message}`);
2665
+ return null;
2666
+ }
2667
+ }
2650
2668
  async function gDriveRead(filename) {
2651
2669
  if (!tokenProvider || !gDriveFolderId)
2652
2670
  return null;
@@ -5329,6 +5347,28 @@ var WebMailxDB = class {
5329
5347
  setContactsDenylist(emails) {
5330
5348
  this._denylist = new Set((emails || []).map((e) => (e || "").trim().toLowerCase()).filter(Boolean));
5331
5349
  }
5350
+ /** Regex patterns from contacts.jsonc#denylistPatterns — same semantics
5351
+ * as desktop (mailx-store db.ts setContactsDenyPatterns): compiled
5352
+ * case-insensitive, tested against the lowercased address, invalid
5353
+ * patterns skipped with a warning. */
5354
+ _denyPatterns = [];
5355
+ setContactsDenyPatterns(patterns) {
5356
+ this._denyPatterns = [];
5357
+ for (const p of patterns || []) {
5358
+ if (!p)
5359
+ continue;
5360
+ try {
5361
+ this._denyPatterns.push(new RegExp(p, "i"));
5362
+ } catch (e) {
5363
+ console.warn(`[contacts] invalid denylistPattern "${p}": ${e.message}`);
5364
+ }
5365
+ }
5366
+ }
5367
+ isDenylisted(emailLower) {
5368
+ if (this._denylist.has(emailLower))
5369
+ return true;
5370
+ return this._denyPatterns.some((re) => re.test(emailLower));
5371
+ }
5332
5372
  searchContacts(query, limit = 10) {
5333
5373
  query = (query || "").trim();
5334
5374
  if (!query)
@@ -5337,7 +5377,7 @@ var WebMailxDB = class {
5337
5377
  const rows = this.all(`SELECT name, email, source, use_count as useCount FROM contacts
5338
5378
  WHERE email LIKE ? OR name LIKE ?
5339
5379
  ORDER BY use_count DESC, last_used DESC LIMIT ?`, [q, q, limit + 25]);
5340
- return rows.filter((r) => !this._denylist.has((r.email || "").toLowerCase())).slice(0, limit);
5380
+ return rows.filter((r) => !this.isDenylisted((r.email || "").toLowerCase())).slice(0, limit);
5341
5381
  }
5342
5382
  /** Address-book listing. Same shape as mailx-store/db.ts:listContacts so
5343
5383
  * the address-book modal renders identically on desktop and Android. */
@@ -5732,7 +5772,7 @@ function decodeBody(body, encoding, charset = "utf-8") {
5732
5772
  return new TextDecoder("utf-8").decode(bytes);
5733
5773
  }
5734
5774
  }
5735
- var WebMailxService = class {
5775
+ var WebMailxService = class _WebMailxService {
5736
5776
  db;
5737
5777
  bodyStore;
5738
5778
  syncManager;
@@ -6130,8 +6170,30 @@ ${bodyEncoded}`;
6130
6170
  query = (query || "").trim();
6131
6171
  if (query.length < 1)
6132
6172
  return [];
6173
+ void this.maybeRefreshContactsConfig().catch(() => {
6174
+ });
6133
6175
  return this.db.searchContacts(query);
6134
6176
  }
6177
+ /** Re-pull contacts.jsonc only when the Drive copy actually changed.
6178
+ * One metadata request (cloudStat) throttled to a few minutes; the
6179
+ * full download + reload runs only on a new modifiedTime. Kicked from
6180
+ * autocomplete, the periodic sync tick, and app-resume. */
6181
+ _contactsStatAt = 0;
6182
+ _contactsModifiedTime = null;
6183
+ static CONTACTS_STAT_MS = 3 * 60 * 1e3;
6184
+ async maybeRefreshContactsConfig() {
6185
+ const now = Date.now();
6186
+ if (now - this._contactsStatAt < _WebMailxService.CONTACTS_STAT_MS)
6187
+ return;
6188
+ this._contactsStatAt = now;
6189
+ const { cloudStat: cloudStat2 } = await Promise.resolve().then(() => (init_web_settings(), web_settings_exports));
6190
+ const stat = await cloudStat2("contacts.jsonc");
6191
+ if (!stat || stat.modifiedTime === this._contactsModifiedTime)
6192
+ return;
6193
+ this._contactsModifiedTime = stat.modifiedTime;
6194
+ console.log(`[contacts] contacts.jsonc changed on Drive (${stat.modifiedTime}) \u2014 reloading`);
6195
+ await this.loadContactsConfig();
6196
+ }
6135
6197
  /** Address-book listing — paginated, filterable. Mirrors mailx-service's
6136
6198
  * signature so the same client-side address-book modal works on Android
6137
6199
  * without an "ipc(...).listContacts is not a function" crash. */
@@ -6394,8 +6456,13 @@ ${bodyEncoded}`;
6394
6456
  * local contacts table. Desktop flushes the full union there (everything
6395
6457
  * it discovered from its mailbox corpus + Google contacts + preferred);
6396
6458
  * this is how an Android device gets contacts it never saw in its own
6397
- * (partial) on-device corpus. Called once at startup re-importing on a
6398
- * loop would bump `use_count` every pass and distort autocomplete rank. */
6459
+ * (partial) on-device corpus. Called at startup AND on a periodic tick /
6460
+ * app-resume / after a so a denylist edit made on another device
6461
+ * reaches a running phone (Bob 2026-07-07: GDrive edit was still being
6462
+ * offered). The denylist refresh runs every call; the contact IMPORT
6463
+ * runs once per session — recordSentAddress bumps `use_count`, so
6464
+ * re-importing on every reload would inflate autocomplete rank. */
6465
+ _contactsImported = false;
6399
6466
  async loadContactsConfig() {
6400
6467
  try {
6401
6468
  const { cloudRead: cloudRead2 } = await Promise.resolve().then(() => (init_web_settings(), web_settings_exports));
@@ -6406,6 +6473,10 @@ ${bodyEncoded}`;
6406
6473
  if (!cfg)
6407
6474
  return null;
6408
6475
  this.db.setContactsDenylist(Array.isArray(cfg.denylist) ? cfg.denylist : []);
6476
+ this.db.setContactsDenyPatterns(Array.isArray(cfg.denylistPatterns) ? cfg.denylistPatterns : []);
6477
+ if (this._contactsImported)
6478
+ return { imported: 0 };
6479
+ this._contactsImported = true;
6409
6480
  let imported = 0;
6410
6481
  for (const list of [cfg.preferred, cfg.discovered]) {
6411
6482
  if (!Array.isArray(list))
@@ -10995,6 +11066,7 @@ async function initAndroid() {
10995
11066
  syncManager.processSyncActions(account.id).catch((e) => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
10996
11067
  }
10997
11068
  syncManager.syncAll().catch((e) => console.error(`[android] Periodic sync error: ${e.message}`));
11069
+ service.maybeRefreshContactsConfig().catch((e) => console.error(`[android] contacts.jsonc freshness check: ${e?.message || e}`));
10998
11070
  if (++contactsSyncTickCounter % 8 === 0) {
10999
11071
  for (const account of db.getAccounts()) {
11000
11072
  if (!account.email || !isGoogleAccount(account)) continue;
@@ -11010,6 +11082,7 @@ async function initAndroid() {
11010
11082
  syncManager.processSendQueue(account.id).catch((e) => console.error(`[android] resume send-drain ${account.id}: ${e.message}`));
11011
11083
  }
11012
11084
  syncManager.syncAll().catch((e) => console.error(`[android] Resume sync error: ${e.message}`));
11085
+ service.maybeRefreshContactsConfig().catch((e) => console.error(`[android] resume contacts.jsonc check: ${e?.message || e}`));
11013
11086
  }
11014
11087
  });
11015
11088
  console.log("[android] Initialization complete");
@@ -11103,6 +11176,22 @@ function installBridge() {
11103
11176
  deleteContact: (email) => service.deleteContact(email),
11104
11177
  addContact: (name, email) => service.addContact(name || "", email),
11105
11178
  hasCcHistoryTo: (email) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
11179
+ hasBccHistoryTo: (email) => ({ hasBcc: service.hasBccHistoryTo?.(email) ?? false }),
11180
+ // ⊘ / ★ in compose autocomplete. The service has implemented these
11181
+ // since the contacts-config share (web-service.ts), but they were
11182
+ // never exposed here, so the compose iframe's IPC relay failed with
11183
+ // `parent bridge has no method "addToDenylist"` (Bob 2026-07-07
11184
+ // screenshot). Signatures mirror client/lib/mailxapi.js — positional
11185
+ // args in, object built here.
11186
+ addPreferredContact: async (name, email, source, organization) => {
11187
+ await service.addPreferredContact({ name, email, source, organization });
11188
+ return { ok: true };
11189
+ },
11190
+ addToDenylist: async (email) => {
11191
+ await service.addToDenylist(email);
11192
+ return { ok: true };
11193
+ },
11194
+ loadContactsConfig: () => service.loadContactsConfig(),
11106
11195
  syncAll: async () => {
11107
11196
  await service.syncAll();
11108
11197
  return { ok: true };