@bobfrankston/rmfmail 1.1.44 → 1.1.49

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 (46) hide show
  1. package/TODO.md +9 -0
  2. package/client/app.bundle.js +403 -83
  3. package/client/app.bundle.js.map +4 -4
  4. package/client/app.js +190 -35
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +175 -34
  7. package/client/components/calendar-sidebar.js +221 -88
  8. package/client/components/calendar-sidebar.js.map +1 -1
  9. package/client/components/calendar-sidebar.ts +224 -83
  10. package/client/components/message-viewer.js +24 -1
  11. package/client/components/message-viewer.js.map +1 -1
  12. package/client/components/message-viewer.ts +25 -1
  13. package/client/compose/compose.bundle.js +14 -0
  14. package/client/compose/compose.bundle.js.map +2 -2
  15. package/client/compose/spellcheck.js +15 -0
  16. package/client/compose/spellcheck.js.map +1 -1
  17. package/client/compose/spellcheck.ts +14 -0
  18. package/client/help/search-help.js +75 -0
  19. package/client/help/search-help.js.map +1 -0
  20. package/client/help/search-help.ts +75 -0
  21. package/client/index.html +7 -7
  22. package/client/lib/api-client.js +5 -0
  23. package/client/lib/api-client.js.map +1 -1
  24. package/client/lib/api-client.ts +5 -0
  25. package/client/lib/mailxapi.js +1 -0
  26. package/client/styles/components.css +204 -6
  27. package/docs/search.md +5 -1
  28. package/package.json +1 -1
  29. package/packages/mailx-service/google-sync.d.ts +3 -0
  30. package/packages/mailx-service/google-sync.d.ts.map +1 -1
  31. package/packages/mailx-service/google-sync.js +1 -0
  32. package/packages/mailx-service/google-sync.js.map +1 -1
  33. package/packages/mailx-service/google-sync.ts +4 -0
  34. package/packages/mailx-service/index.d.ts +11 -0
  35. package/packages/mailx-service/index.d.ts.map +1 -1
  36. package/packages/mailx-service/index.js +99 -127
  37. package/packages/mailx-service/index.js.map +1 -1
  38. package/packages/mailx-service/index.ts +91 -122
  39. package/packages/mailx-service/jsonrpc.js +2 -0
  40. package/packages/mailx-service/jsonrpc.js.map +1 -1
  41. package/packages/mailx-service/jsonrpc.ts +2 -0
  42. package/packages/mailx-settings/index.d.ts.map +1 -1
  43. package/packages/mailx-settings/index.js +4 -1
  44. package/packages/mailx-settings/index.js.map +1 -1
  45. package/packages/mailx-settings/index.ts +4 -1
  46. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-45524 → node_modules.npmglobalize-stash-43988}/.package-lock.json +0 -0
@@ -80,7 +80,8 @@ function calendarRowEquals(prior, fresh) {
80
80
  && (prior.notes || "") === (fresh.notes || "")
81
81
  && (prior.etag || "") === (fresh.etag || "")
82
82
  && (prior.recurringEventId || null) === (fresh.recurringEventId || null)
83
- && (prior.htmlLink || null) === (fresh.htmlLink || null);
83
+ && (prior.htmlLink || null) === (fresh.htmlLink || null)
84
+ && (prior.calendarId || "") === (fresh.calendarId || "");
84
85
  }
85
86
  export class MailxService {
86
87
  store;
@@ -1249,6 +1250,29 @@ export class MailxService {
1249
1250
  console.log(` [calendar] getCalendarEvents → ${rows.length} rows (${recurring} recurring, ${holidays} holiday) for window ${new Date(fromMs).toISOString().slice(0, 10)}..${new Date(toMs).toISOString().slice(0, 10)}`);
1250
1251
  return rows;
1251
1252
  }
1253
+ /** List the user's *selected* Google calendars (id, display name, color,
1254
+ * primary flag) so the sidebar can render one checkbox + icon per
1255
+ * calendar. The user curates the set by selecting calendars in Google;
1256
+ * mailx only reflects it. Returns [] on quota cooldown / auth failure
1257
+ * — the sidebar then just shows no per-calendar controls. */
1258
+ async getCalendars() {
1259
+ const acct = this.getPrimaryAccount("calendar");
1260
+ if (!acct)
1261
+ return [];
1262
+ if (this.inQuotaCooldown("calendar"))
1263
+ return [];
1264
+ try {
1265
+ const tp = await this.primaryTokenProvider("calendar");
1266
+ const all = await gsync.listCalendars(tp);
1267
+ return all
1268
+ .filter(c => c.selected)
1269
+ .map(c => ({ id: c.id, name: c.summary, color: c.backgroundColor || "", primary: c.primary }));
1270
+ }
1271
+ catch (e) {
1272
+ this.handleGoogleRefreshError("calendar", e);
1273
+ return [];
1274
+ }
1275
+ }
1252
1276
  /** Returns true if the feature is currently in a quota-exceeded cooldown. */
1253
1277
  inQuotaCooldown(feature) {
1254
1278
  const until = this.quotaCooldown.get(feature);
@@ -1315,6 +1339,11 @@ export class MailxService {
1315
1339
  // calendarList; mailx now does too. Failing to list calendars
1316
1340
  // falls back to primary so single-calendar users still work.
1317
1341
  let calendarsToFetch = [{ id: "primary", label: "primary", defaultReminderMinutes: [] }];
1342
+ // A failed enumeration / fetch returns fewer events than really
1343
+ // exist; running the delete-reconciliation then purges live rows
1344
+ // (the same data-loss shape as the Gmail partial-list bug). Track
1345
+ // failures and skip the purge when any occurred.
1346
+ let fetchFailed = false;
1318
1347
  try {
1319
1348
  const all = await gsync.listCalendars(tp);
1320
1349
  const selected = all.filter(c => c.selected);
@@ -1330,6 +1359,9 @@ export class MailxService {
1330
1359
  }
1331
1360
  }
1332
1361
  catch (e) {
1362
+ // listCalendars failed — we'd fetch only `primary` and miss every
1363
+ // secondary calendar's events. Treat as a failed pass.
1364
+ fetchFailed = true;
1333
1365
  console.warn(` [calendar] listCalendars failed (${e?.message || e}) — falling back to primary`);
1334
1366
  }
1335
1367
  const fetchResults = await Promise.all(calendarsToFetch.map(async (c) => {
@@ -1339,144 +1371,64 @@ export class MailxService {
1339
1371
  return { events: ev, calendarId: c.id, defaultReminderMinutes: c.defaultReminderMinutes };
1340
1372
  }
1341
1373
  catch (e) {
1374
+ fetchFailed = true;
1342
1375
  console.warn(` [calendar] fetch from "${c.label}" failed: ${e?.message || e}`);
1343
1376
  return { events: [], calendarId: c.id, defaultReminderMinutes: c.defaultReminderMinutes };
1344
1377
  }
1345
1378
  }));
1346
- // Flatten with each event remembering its source calendar's default
1347
- // reminder list, so calendarEventToLocal can apply the right
1348
- // defaults when `reminders.useDefault === true`.
1379
+ // Flatten with each event remembering its source calendar id + that
1380
+ // calendar's default reminder list. The `calendarId` rides onto every
1381
+ // row so the sidebar can show a per-calendar icon and the user can
1382
+ // hide a calendar without touching Google.
1349
1383
  const eventsWithDefaults = [];
1350
1384
  for (const fr of fetchResults) {
1351
1385
  for (const ev of fr.events) {
1352
- eventsWithDefaults.push({ ev, defaultReminderMinutes: fr.defaultReminderMinutes });
1353
- }
1354
- }
1355
- const events = eventsWithDefaults.map(x => x.ev);
1356
- // Holiday calendars (Q131 + Bob 2026-05-11 second-toggle request).
1357
- // Each enabled toggle pulls a separate Google public calendar; rows
1358
- // are tagged with the source `calendar_id` so toggling one off only
1359
- // purges that source's rows, not the others. Future generalization
1360
- // to arbitrary user-supplied calendars is filed as Q140.
1361
- // Cached — refreshCalendarEvents runs on a poll AND on every
1362
- // getCalendarEvents call (envelope-first pattern). Synchronous
1363
- // GDrive read of accounts.jsonc here was a major contributor to
1364
- // the daemon-event-loop wedge that broke preview rendering.
1365
- const settings = this.getCachedSettings();
1366
- // `en.usa#holiday` is Google's "Holidays in United States" calendar
1367
- // which is *mixed*: federal (MLK, Memorial Day, July 4, Thanksgiving,
1368
- // …) PLUS Christian (Ascension Day, Pentecost, Catholic/Protestant
1369
- // /Orthodox/Armenian variants), Jewish observances, Ethiopian Jewish,
1370
- // etc. `en.usa.official#holiday` is the federal-secular-only variant.
1371
- // Per Bob 2026-05-11: "is there one for just secular holidays?" —
1372
- // yes, this is it.
1373
- const HOLIDAY_SOURCES = [
1374
- {
1375
- enabled: !!settings?.calendar?.showHolidays,
1376
- calId: "en.usa.official#holiday@group.v.calendar.google.com",
1377
- label: "US-official",
1378
- },
1379
- {
1380
- enabled: !!settings?.calendar?.showJewishHolidays,
1381
- // `en.judaism#holiday` is Google's Jewish-only feed.
1382
- // `en.jewish#holiday` (the obvious guess) is actually
1383
- // mis-curated and includes Christian observances
1384
- // (Ascension Eve / Day, Pentecost Eve / Day, Catholic /
1385
- // Protestant / Armenian / Orthodox variants) — Google
1386
- // packs cross-tradition events into the "regional"
1387
- // calendars. Bob 2026-05-11 confirmed `en.judaism` is
1388
- // the clean source via Google's embed URL.
1389
- calId: "en.judaism#holiday@group.v.calendar.google.com",
1390
- label: "Jewish",
1391
- },
1392
- ];
1393
- // Fan out enabled holiday-source fetches in parallel — Google's API
1394
- // handles each independently and the results merge by calId after.
1395
- // Earlier code awaited each in series, doubling latency when both
1396
- // US and Jewish sources were enabled (the common case for users who
1397
- // turn both on).
1398
- const enabledSources = HOLIDAY_SOURCES.filter(s => s.enabled);
1399
- const bundleResults = await Promise.all(enabledSources.map(async (src) => {
1400
- try {
1401
- const ev = await gsync.listCalendarEvents(tp, fromMs, toMs, src.calId);
1402
- console.log(` [calendar] pulled ${ev.length} ${src.label} holiday events`);
1403
- return { calId: src.calId, events: ev };
1386
+ eventsWithDefaults.push({ ev, calendarId: fr.calendarId, defaultReminderMinutes: fr.defaultReminderMinutes });
1404
1387
  }
1405
- catch (e) {
1406
- console.warn(` [calendar] ${src.label} holiday pull failed: ${e?.message || e}`);
1407
- return null;
1408
- }
1409
- }));
1410
- const holidayBundles = bundleResults.filter((b) => b !== null);
1411
- const enabledHolidayCalIds = new Set(HOLIDAY_SOURCES.filter(s => s.enabled).map(s => s.calId));
1388
+ }
1389
+ // Holiday calendars are no longer hard-coded (the old HOLIDAY_SOURCES
1390
+ // pull + `showHolidays`/`showJewishHolidays` toggles are retired). The
1391
+ // user curates which calendars exist by selecting them in Google
1392
+ // Calendar; mailx enumerates them above and tags every row with its
1393
+ // source `calendarId`. The sidebar derives holiday/birthday/personal
1394
+ // kind from that id and lets the user hide calendars per-mailx.
1412
1395
  let changed = false;
1413
1396
  // Upsert by provider_id — dedup globally, not just within the window,
1414
1397
  // so an event whose start moves outside the prior query range doesn't
1415
- // get a second row on the next pull.
1398
+ // get a second row on the next pull. If the same event id arrives
1399
+ // from two calendars, last-write-wins on `calendarId` (one row, one
1400
+ // source icon — multi-source badges are a future refinement, Q143).
1416
1401
  const seenProviderIds = new Set();
1417
- for (const { ev, defaultReminderMinutes } of eventsWithDefaults) {
1418
- const local = gsync.calendarEventToLocal(ev, accountId, defaultReminderMinutes);
1402
+ for (const { ev, calendarId, defaultReminderMinutes } of eventsWithDefaults) {
1403
+ const local = { ...gsync.calendarEventToLocal(ev, accountId, defaultReminderMinutes), calendarId };
1419
1404
  seenProviderIds.add(ev.id);
1420
1405
  const existing = this.db.getCalendarEventByProviderId(accountId, ev.id);
1421
- if (existing && calendarRowEquals(existing, local) && !existing.isHoliday)
1406
+ if (existing && calendarRowEquals(existing, local))
1422
1407
  continue;
1423
- this.db.upsertCalendarEvent({ uuid: existing?.uuid, ...local, isHoliday: false });
1408
+ this.db.upsertCalendarEvent({ uuid: existing?.uuid, ...local });
1424
1409
  changed = true;
1425
1410
  }
1426
- // First-wins guard. Google's holiday calendars overlap: a Christian
1427
- // observance can appear in both `en.usa#holiday` and `en.jewish#holiday`
1428
- // with the same event id. Without this guard the second bundle's
1429
- // upsert overwrites the first row's `calendarId`, and the sidebar's
1430
- // color classifier (us green, jewish blue) sees the wrong tag
1431
- // Bob 2026-05-11 observed Catholic/Protestant entries colored blue
1432
- // (Jewish-source color) instead of green (US-source). Earlier
1433
- // bundles in HOLIDAY_SOURCES win; reorder the array to change
1434
- // precedence if needed.
1435
- const seenInHolidayPass = new Set();
1436
- for (const bundle of holidayBundles) {
1437
- for (const ev of bundle.events) {
1438
- if (seenInHolidayPass.has(ev.id))
1439
- continue;
1440
- seenInHolidayPass.add(ev.id);
1441
- const baseLocal = gsync.calendarEventToLocal(ev, accountId);
1442
- // Override calendarId so reconcile (below) can tell which
1443
- // holiday source this row belongs to — toggling one source
1444
- // off must not purge rows from another active source.
1445
- const local = { ...baseLocal, calendarId: bundle.calId };
1446
- seenProviderIds.add(ev.id);
1447
- const existing = this.db.getCalendarEventByProviderId(accountId, ev.id);
1448
- if (existing && calendarRowEquals(existing, local) && existing.isHoliday)
1411
+ // Server-side delete reconciliation: any local non-dirty row whose
1412
+ // start falls in the queried window and whose provider_id wasn't
1413
+ // returned was deleted on Google or its calendar was deselected in
1414
+ // Google (no longer enumerated, so its events stop arriving). Both
1415
+ // mean the row should go. SKIPPED when any fetch failed this pass
1416
+ // a partial result would otherwise purge live events.
1417
+ if (!fetchFailed) {
1418
+ const localWindow = this.db.getCalendarEvents(accountId, fromMs, toMs);
1419
+ for (const row of localWindow) {
1420
+ if (!row.providerId)
1421
+ continue; // local-only, never pushed
1422
+ if (row.dirty)
1423
+ continue; // locally edited, pending push
1424
+ if (seenProviderIds.has(row.providerId))
1449
1425
  continue;
1450
- this.db.upsertCalendarEvent({ uuid: existing?.uuid, ...local, isHoliday: true });
1426
+ this.db.purgeCalendarEvent(row.uuid);
1451
1427
  changed = true;
1452
1428
  }
1453
1429
  }
1454
- // Server-side delete reconciliation: any local non-dirty row whose
1455
- // start falls in the queried window and whose provider_id wasn't
1456
- // returned must have been deleted on Google. Holiday rows are
1457
- // partitioned by `calendarId`: only purge a holiday row when its
1458
- // source calendar is currently disabled. Sources that are enabled
1459
- // but returned nothing for this row keep the row (transient fail).
1460
- const localWindow = this.db.getCalendarEvents(accountId, fromMs, toMs);
1461
- for (const row of localWindow) {
1462
- if (!row.providerId)
1463
- continue; // local-only, never pushed
1464
- if (row.dirty)
1465
- continue; // locally edited, pending push
1466
- if (seenProviderIds.has(row.providerId))
1467
- continue;
1468
- if (row.isHoliday) {
1469
- const fromActiveSource = enabledHolidayCalIds.has(row.calendarId || "");
1470
- if (!fromActiveSource) {
1471
- // Source toggled off — purge.
1472
- this.db.purgeCalendarEvent(row.uuid);
1473
- changed = true;
1474
- }
1475
- // Source enabled but row missing → transient; keep.
1476
- continue;
1477
- }
1478
- this.db.purgeCalendarEvent(row.uuid);
1479
- changed = true;
1430
+ else {
1431
+ console.warn(` [calendar] fetch incomplete skipping delete reconciliation to protect the local cache`);
1480
1432
  }
1481
1433
  return changed;
1482
1434
  }
@@ -1555,6 +1507,12 @@ export class MailxService {
1555
1507
  // a single `@default` query keeps single-list users working when
1556
1508
  // the listTaskLists call fails.
1557
1509
  let lists = [{ id: "@default", title: "@default" }];
1510
+ // Track whether ANY fetch failed. A failed fetch returns an empty
1511
+ // list indistinguishable from "all tasks deleted on the server" —
1512
+ // running the delete-reconciliation then wipes the whole local task
1513
+ // cache. `firstError` is surfaced so the quota cooldown gets armed.
1514
+ let fetchFailed = false;
1515
+ let firstError = null;
1558
1516
  try {
1559
1517
  const all = await gsync.listTaskLists(tp);
1560
1518
  if (all.length > 0) {
@@ -1563,6 +1521,8 @@ export class MailxService {
1563
1521
  }
1564
1522
  }
1565
1523
  catch (e) {
1524
+ fetchFailed = true;
1525
+ firstError = e;
1566
1526
  console.warn(` [tasks] listTaskLists failed (${e?.message || e}) — falling back to @default`);
1567
1527
  }
1568
1528
  const fetchResults = await Promise.all(lists.map(async (l) => {
@@ -1572,6 +1532,8 @@ export class MailxService {
1572
1532
  return t;
1573
1533
  }
1574
1534
  catch (e) {
1535
+ fetchFailed = true;
1536
+ firstError = firstError || e;
1575
1537
  console.warn(` [tasks] fetch from "${l.title}" failed: ${e?.message || e}`);
1576
1538
  return [];
1577
1539
  }
@@ -1592,15 +1554,25 @@ export class MailxService {
1592
1554
  this.db.upsertTask({ uuid: prior?.uuid, ...local });
1593
1555
  changed = true;
1594
1556
  }
1595
- // Server-side delete reconciliation: any non-dirty local task whose
1596
- // provider_id wasn't returned has been deleted on Google. Purge.
1597
- for (const row of existing) {
1598
- if (!row.providerId || row.dirty)
1599
- continue;
1600
- if (seen.has(row.providerId))
1601
- continue;
1602
- this.db.purgeTask(row.uuid);
1603
- changed = true;
1557
+ // Server-side delete reconciliation ONLY when every list fetched
1558
+ // cleanly. On a failed fetch (429, network) the server result is
1559
+ // an empty list, which the purge below would read as "everything
1560
+ // was deleted" and wipe the entire local task cache. Guard it.
1561
+ if (!fetchFailed) {
1562
+ for (const row of existing) {
1563
+ if (!row.providerId || row.dirty)
1564
+ continue;
1565
+ if (seen.has(row.providerId))
1566
+ continue;
1567
+ this.db.purgeTask(row.uuid);
1568
+ changed = true;
1569
+ }
1570
+ }
1571
+ else {
1572
+ console.warn(` [tasks] fetch incomplete — skipping delete reconciliation to protect the local cache`);
1573
+ // Arm the quota cooldown. Without this the 429 is swallowed here
1574
+ // and the 30 s poll hammers Google's Tasks API forever.
1575
+ this.handleGoogleRefreshError("tasks", firstError);
1604
1576
  }
1605
1577
  return changed;
1606
1578
  }