@bobfrankston/mailx 1.0.425 → 1.0.428

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/bin/mailx.js CHANGED
@@ -1422,6 +1422,16 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1422
1422
  svc.getTasks(false)
1423
1423
  .catch((e) => console.error(` [tasks] poll error: ${e?.message || e}`));
1424
1424
  }, CAL_POLL_MS);
1425
+ // Contacts poll — incremental sync via People API syncToken (persisted
1426
+ // per-account in the kv table). Cheap after the first run because only
1427
+ // changed/deleted contacts come back. 15-minute cadence picks up new
1428
+ // contacts added on phone / web without restart while staying well
1429
+ // under the People API rate limit.
1430
+ const CONTACTS_POLL_MS = 15 * 60_000;
1431
+ setInterval(() => {
1432
+ svc.syncGoogleContacts()
1433
+ .catch((e) => console.error(` [contacts] poll error: ${e?.message || e}`));
1434
+ }, CONTACTS_POLL_MS);
1425
1435
  // Auto-update: periodically check npm for a newer version and push a
1426
1436
  // notification to the WebView so the user can update with one click.
1427
1437
  const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
@@ -381,6 +381,18 @@ export function initCalendarSidebar() {
381
381
  refresh();
382
382
  else if (event?.type === "tasksUpdated")
383
383
  renderTasks();
384
+ else if (event?.type === "quotaError") {
385
+ // Surface a non-clickable banner — daily quota will reset
386
+ // on its own, no user action helps. Idempotent so the
387
+ // banner doesn't flash on repeat poll attempts.
388
+ const host = event.feature === "tasks"
389
+ ? document.getElementById("cal-side-tasks")
390
+ : document.getElementById("cal-side-body");
391
+ if (host && !host.querySelector(".cal-side-quota-error")) {
392
+ const msg = event.message || `Google ${event.feature} quota exceeded — try again later.`;
393
+ host.innerHTML = `<div class="cal-side-empty cal-side-quota-error">${escapeHtml(msg)}</div>`;
394
+ }
395
+ }
384
396
  else if (event?.type === "authScopeError") {
385
397
  // Surface a visible hint right in the affected pane so the
386
398
  // user doesn't stare at an empty list wondering why. Only
@@ -413,26 +413,39 @@ function applyInit(init) {
413
413
  }
414
414
  else if (init.to && init.to.length === 1) {
415
415
  // Q49: heuristic auto-expand — when replying/composing to a single
416
- // recipient, check sent-history. If the user has previously Cc'd
417
- // anyone on a message to this recipient, expand the Cc row (empty,
418
- // just visible) so they're prompted to fill it. Fire-and-forget; if
419
- // the service call fails or the user starts typing Cc manually
420
- // before it resolves, the answer doesn't matter.
416
+ // recipient, check sent-history. If the user has previously Cc'd or
417
+ // Bcc'd anyone on a message to this recipient, expand the matching
418
+ // row (empty, just visible) so they're prompted to fill it.
419
+ // Fire-and-forget; if the service call fails or the user starts
420
+ // typing manually before it resolves, the answer doesn't matter.
421
421
  const firstEmail = init.to[0]?.address || "";
422
422
  if (firstEmail) {
423
- import("../lib/api-client.js").then(({ hasCcHistoryTo }) => hasCcHistoryTo(firstEmail)
424
- .then(res => {
425
- if (!res?.hasCc)
426
- return;
427
- const ccRowEl = document.getElementById("compose-cc-row");
428
- const ccBtn = document.getElementById("btn-toggle-cc");
429
- // Only expand if user hasn't already interacted
430
- if (ccRowEl?.hidden && !ccInput.value) {
431
- ccRowEl.hidden = false;
432
- ccBtn?.classList.add("active");
433
- }
434
- })
435
- .catch(() => { }));
423
+ import("../lib/api-client.js").then(({ hasCcHistoryTo, hasBccHistoryTo }) => {
424
+ hasCcHistoryTo(firstEmail)
425
+ .then(res => {
426
+ if (!res?.hasCc)
427
+ return;
428
+ const ccRowEl = document.getElementById("compose-cc-row");
429
+ const ccBtn = document.getElementById("btn-toggle-cc");
430
+ if (ccRowEl?.hidden && !ccInput.value) {
431
+ ccRowEl.hidden = false;
432
+ ccBtn?.classList.add("active");
433
+ }
434
+ })
435
+ .catch(() => { });
436
+ hasBccHistoryTo(firstEmail)
437
+ .then(res => {
438
+ if (!res?.hasBcc)
439
+ return;
440
+ const bccRowEl = document.getElementById("compose-bcc-row");
441
+ const bccBtn = document.getElementById("btn-toggle-bcc");
442
+ if (bccRowEl?.hidden && !bccInput.value) {
443
+ bccRowEl.hidden = false;
444
+ bccBtn?.classList.add("active");
445
+ }
446
+ })
447
+ .catch(() => { });
448
+ });
436
449
  }
437
450
  }
438
451
  // C42: append the account's signature (if configured) BEFORE rendering
@@ -194,6 +194,9 @@ export function searchContacts(query) {
194
194
  export function hasCcHistoryTo(email) {
195
195
  return ipc().hasCcHistoryTo(email);
196
196
  }
197
+ export function hasBccHistoryTo(email) {
198
+ return ipc().hasBccHistoryTo(email);
199
+ }
197
200
  export function listContacts(query, page = 1, pageSize = 100) {
198
201
  return ipc().listContacts(query, page, pageSize);
199
202
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-client",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.425",
3
+ "version": "1.0.428",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -37,7 +37,7 @@
37
37
  "@bobfrankston/miscinfo": "^1.0.10",
38
38
  "@bobfrankston/oauthsupport": "^1.0.25",
39
39
  "@bobfrankston/msger": "^0.1.361",
40
- "@bobfrankston/mailx-host": "^0.1.7",
40
+ "@bobfrankston/mailx-host": "^0.1.8",
41
41
  "@capacitor/android": "^8.3.0",
42
42
  "@capacitor/cli": "^8.3.0",
43
43
  "@capacitor/core": "^8.3.0",
@@ -101,7 +101,7 @@
101
101
  "@bobfrankston/miscinfo": "^1.0.10",
102
102
  "@bobfrankston/oauthsupport": "^1.0.25",
103
103
  "@bobfrankston/msger": "^0.1.361",
104
- "@bobfrankston/mailx-host": "^0.1.7",
104
+ "@bobfrankston/mailx-host": "^0.1.8",
105
105
  "@capacitor/android": "^8.3.0",
106
106
  "@capacitor/cli": "^8.3.0",
107
107
  "@capacitor/core": "^8.3.0",
@@ -376,12 +376,21 @@ export declare class ImapManager extends EventEmitter {
376
376
  watchConfigFiles(): void;
377
377
  /** Stop all config file watchers */
378
378
  stopWatchingConfig(): void;
379
- private contactsSyncToken;
379
+ /** Per-account in-flight guard so concurrent calls (startup + periodic
380
+ * timer + manual button) share one round-trip instead of stacking. */
381
+ private contactsSyncing;
380
382
  /** Get an OAuth token for Google APIs (contacts, calendar, etc.)
381
383
  * Uses the SAME token as IMAP — scopes are combined in one grant */
382
384
  private getContactsToken;
383
- /** Sync contacts from Google People API */
385
+ /** Sync contacts from Google People API. Incremental: persists
386
+ * `nextSyncToken` per account in the kv table (`scope='contacts'`,
387
+ * `key=accountId`) so subsequent calls only fetch changed/deleted rows.
388
+ * First-ever call passes `requestSyncToken=true` so Google returns a
389
+ * token to use next time; without that, the first response has no
390
+ * `nextSyncToken` and incremental never kicks in. Returns the number of
391
+ * contacts added or removed in this run. */
384
392
  syncGoogleContacts(accountId: string): Promise<number>;
393
+ private syncGoogleContactsImpl;
385
394
  /** Sync contacts for all OAuth accounts */
386
395
  syncAllContacts(): Promise<void>;
387
396
  /** Shut down all watchers and timers */
@@ -3652,22 +3652,43 @@ export class ImapManager extends EventEmitter {
3652
3652
  }
3653
3653
  this.cloudPollTimers = [];
3654
3654
  }
3655
- // ── Google Contacts Sync ──
3656
- contactsSyncToken = null;
3655
+ // ── Google Contacts Sync (incremental via People API syncToken) ──
3656
+ /** Per-account in-flight guard so concurrent calls (startup + periodic
3657
+ * timer + manual button) share one round-trip instead of stacking. */
3658
+ contactsSyncing = new Map();
3657
3659
  /** Get an OAuth token for Google APIs (contacts, calendar, etc.)
3658
3660
  * Uses the SAME token as IMAP — scopes are combined in one grant */
3659
3661
  async getContactsToken(accountId) {
3660
3662
  // Reuse the IMAP token — it now includes contacts.readonly scope
3661
3663
  return this.getOAuthToken(accountId);
3662
3664
  }
3663
- /** Sync contacts from Google People API */
3665
+ /** Sync contacts from Google People API. Incremental: persists
3666
+ * `nextSyncToken` per account in the kv table (`scope='contacts'`,
3667
+ * `key=accountId`) so subsequent calls only fetch changed/deleted rows.
3668
+ * First-ever call passes `requestSyncToken=true` so Google returns a
3669
+ * token to use next time; without that, the first response has no
3670
+ * `nextSyncToken` and incremental never kicks in. Returns the number of
3671
+ * contacts added or removed in this run. */
3664
3672
  async syncGoogleContacts(accountId) {
3673
+ // Coalesce concurrent calls for the same account.
3674
+ const inFlight = this.contactsSyncing.get(accountId);
3675
+ if (inFlight)
3676
+ return inFlight;
3677
+ const promise = this.syncGoogleContactsImpl(accountId)
3678
+ .finally(() => this.contactsSyncing.delete(accountId));
3679
+ this.contactsSyncing.set(accountId, promise);
3680
+ return promise;
3681
+ }
3682
+ async syncGoogleContactsImpl(accountId) {
3665
3683
  const token = await this.getContactsToken(accountId);
3666
3684
  if (!token)
3667
3685
  return 0;
3668
- let added = 0;
3686
+ let changed = 0;
3669
3687
  let nextPageToken;
3670
3688
  const now = Date.now();
3689
+ // Per-account persisted sync token (survives restarts). Empty means
3690
+ // we've never completed an initial sync for this account.
3691
+ let syncToken = this.db.getKv("contacts", accountId) || "";
3671
3692
  try {
3672
3693
  do {
3673
3694
  const params = new URLSearchParams({
@@ -3676,46 +3697,58 @@ export class ImapManager extends EventEmitter {
3676
3697
  });
3677
3698
  if (nextPageToken)
3678
3699
  params.set("pageToken", nextPageToken);
3679
- if (this.contactsSyncToken)
3680
- params.set("syncToken", this.contactsSyncToken);
3700
+ if (syncToken) {
3701
+ params.set("syncToken", syncToken);
3702
+ }
3703
+ else {
3704
+ // First-ever sync for this account — ask Google to give us
3705
+ // a token in the response so the NEXT call can be cheap.
3706
+ params.set("requestSyncToken", "true");
3707
+ }
3681
3708
  const url = `https://people.googleapis.com/v1/people/me/connections?${params}`;
3682
3709
  const res = await fetch(url, {
3683
3710
  headers: { Authorization: `Bearer ${token}` },
3684
3711
  });
3685
3712
  if (res.status === 410) {
3686
- // Sync token expired do full sync
3687
- this.contactsSyncToken = null;
3688
- return this.syncGoogleContacts(accountId);
3713
+ // Sync token expired (Google retains tokens for ~7 days).
3714
+ // Drop the stored token and recurse for a full sync — the
3715
+ // in-flight guard is on syncGoogleContacts (the public
3716
+ // wrapper), not Impl, so recursion is safe.
3717
+ this.db.setKv("contacts", accountId, null);
3718
+ return this.syncGoogleContactsImpl(accountId);
3689
3719
  }
3690
3720
  if (!res.ok) {
3691
3721
  const err = await res.text();
3692
- console.error(` [contacts] API error: ${res.status} ${err}`);
3693
- return added;
3722
+ console.error(` [contacts] API error for ${accountId}: ${res.status} ${err}`);
3723
+ return changed;
3694
3724
  }
3695
3725
  const data = await res.json();
3696
3726
  if (data.connections) {
3697
3727
  for (const person of data.connections) {
3728
+ const googleId = person.resourceName || "";
3729
+ // Incremental responses tag deleted contacts via
3730
+ // metadata.deleted=true (and emit no other fields).
3731
+ // Drop those rows so autocomplete stops surfacing them.
3732
+ if (person.metadata?.deleted) {
3733
+ const removed = this.db.deleteContactByGoogleId(googleId);
3734
+ if (removed > 0)
3735
+ changed += removed;
3736
+ continue;
3737
+ }
3698
3738
  const emails = person.emailAddresses || [];
3699
3739
  const names = person.names || [];
3700
3740
  const orgs = person.organizations || [];
3701
3741
  const name = names[0]?.displayName || "";
3702
3742
  const org = orgs[0]?.name || "";
3703
- const googleId = person.resourceName || "";
3704
3743
  for (const emailEntry of emails) {
3705
3744
  const email = emailEntry.value?.toLowerCase();
3706
3745
  if (!email)
3707
3746
  continue;
3708
- // Upsert into contacts
3709
3747
  const existing = this.db.searchContacts(email, 1);
3710
- if (existing.length > 0 && existing[0].email === email) {
3711
- // Update name/org if from google
3712
- this.db.recordSentAddress(name, email);
3713
- }
3714
- else {
3715
- this.db.recordSentAddress(name, email);
3716
- added++;
3717
- }
3718
- // Update google-specific fields
3748
+ const wasNew = !(existing.length > 0 && existing[0].email === email);
3749
+ this.db.recordSentAddress(name, email);
3750
+ if (wasNew)
3751
+ changed++;
3719
3752
  try {
3720
3753
  this.db.db.prepare("UPDATE contacts SET source = 'google', google_id = ?, organization = ?, updated_at = ? WHERE email = ?").run(googleId, org, now, email);
3721
3754
  }
@@ -3724,15 +3757,20 @@ export class ImapManager extends EventEmitter {
3724
3757
  }
3725
3758
  }
3726
3759
  nextPageToken = data.nextPageToken;
3727
- if (data.nextSyncToken)
3728
- this.contactsSyncToken = data.nextSyncToken;
3760
+ // Google only returns nextSyncToken on the LAST page of a
3761
+ // sync run (sentinel that the snapshot is consistent). When
3762
+ // it appears, persist it for next time.
3763
+ if (data.nextSyncToken) {
3764
+ this.db.setKv("contacts", accountId, data.nextSyncToken);
3765
+ syncToken = data.nextSyncToken;
3766
+ }
3729
3767
  } while (nextPageToken);
3730
- console.log(` [contacts] Synced ${added} new contacts from Google`);
3768
+ console.log(` [contacts] ${accountId}: ${changed} change(s) (${syncToken ? "incremental" : "full"})`);
3731
3769
  }
3732
3770
  catch (e) {
3733
- console.error(` [contacts] Sync error: ${e.message}`);
3771
+ console.error(` [contacts] Sync error for ${accountId}: ${e.message}`);
3734
3772
  }
3735
- return added;
3773
+ return changed;
3736
3774
  }
3737
3775
  /** Sync contacts for all OAuth accounts */
3738
3776
  async syncAllContacts() {
@@ -10,6 +10,12 @@
10
10
  * either retries via the store_sync drainer or surfaces to the UI.
11
11
  */
12
12
  type TokenProvider = () => Promise<string>;
13
+ export declare class GoogleHttpError extends Error {
14
+ status: number;
15
+ statusText: string;
16
+ body: string;
17
+ constructor(status: number, statusText: string, body: string);
18
+ }
13
19
  export interface GCalEvent {
14
20
  id: string;
15
21
  summary: string;
@@ -9,6 +9,17 @@
9
9
  * Error handling: throws on network / HTTP errors. Caller catches and
10
10
  * either retries via the store_sync drainer or surfaces to the UI.
11
11
  */
12
+ export class GoogleHttpError extends Error {
13
+ status;
14
+ statusText;
15
+ body;
16
+ constructor(status, statusText, body) {
17
+ super(`Google API ${status} ${statusText}: ${body.slice(0, 300)}`);
18
+ this.status = status;
19
+ this.statusText = statusText;
20
+ this.body = body;
21
+ }
22
+ }
12
23
  async function googleFetch(tokenProvider, url, init = {}) {
13
24
  const token = await tokenProvider();
14
25
  const headers = new Headers(init.headers || {});
@@ -18,7 +29,7 @@ async function googleFetch(tokenProvider, url, init = {}) {
18
29
  const res = await fetch(url, { ...init, headers });
19
30
  if (!res.ok) {
20
31
  const body = await res.text().catch(() => "");
21
- throw new Error(`Google API ${res.status} ${res.statusText}: ${body.slice(0, 300)}`);
32
+ throw new GoogleHttpError(res.status, res.statusText, body);
22
33
  }
23
34
  return res;
24
35
  }
@@ -52,6 +52,21 @@ export declare class MailxService {
52
52
  * 5-min poll / sidebar nav re-fired the event and the client re-rendered
53
53
  * the red banner. Cleared when the user hits Re-authenticate. */
54
54
  private scopeErrorEmitted;
55
+ /** Quota cooldown — feature → epoch-ms when the next API call is allowed.
56
+ * Set when Google returns 429 (rate limit / daily-quota exceeded). While
57
+ * cooldown is in effect, getCalendarEvents/getTasks return local DB rows
58
+ * without firing a refresh. Heuristic cooldown is one hour; the daily
59
+ * Google Tasks quota actually resets at Pacific midnight, but a one-hour
60
+ * short-circuit keeps the log clean and avoids hammering after a burst. */
61
+ private quotaCooldown;
62
+ /** Sticky "quota exceeded" emit guard — same shape as scopeErrorEmitted. */
63
+ private quotaErrorEmitted;
64
+ /** In-flight refresh promises keyed by feature, so concurrent UI calls
65
+ * share one Google round-trip instead of stacking N parallel fetches.
66
+ * The fire-and-forget loop where `tasksUpdated` re-triggers `getTasks`
67
+ * used to spawn a new refresh on every event RTT — this dedupes them. */
68
+ private refreshingCalendar;
69
+ private refreshingTasks;
55
70
  /** Delete the cached Google OAuth token (the one used for Calendar / Tasks
56
71
  * / Contacts scopes — NOT the IMAP token which `reauthenticate()` handles)
57
72
  * and clear the sticky auth-error state so a subsequent refresh can
@@ -68,9 +83,19 @@ export declare class MailxService {
68
83
  * re-renders with pulled-in rows. Fire-and-forget-with-event, not
69
84
  * fire-and-forget-and-pray. */
70
85
  getCalendarEvents(fromMs: number, toMs: number): Promise<any[]>;
86
+ /** Returns true if the feature is currently in a quota-exceeded cooldown. */
87
+ private inQuotaCooldown;
88
+ /** Single error-handling path for Google refresh failures.
89
+ * Distinguishes 429 (quota) from 401/403 (scope) so each gets the right
90
+ * cooldown + sticky-emit treatment without duplicating the regex blocks. */
91
+ private handleGoogleRefreshError;
71
92
  /** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
72
93
  * server-side deletions. Returns true if anything changed so callers
73
- * can decide whether to emit a refresh event. */
94
+ * can decide whether to emit a refresh event. `changed` is only true
95
+ * when at least one row's data actually differs — without this guard
96
+ * the UI's `calendarUpdated` listener re-triggers `getCalendarEvents`,
97
+ * which fires another `refreshCalendarEvents`, which emits again, etc.
98
+ * Tight loop = 429 quota burn. */
74
99
  private refreshCalendarEvents;
75
100
  createCalendarEventLocal(ev: {
76
101
  title: string;
@@ -162,6 +187,9 @@ export declare class MailxService {
162
187
  * address. True when at least one past sent message to the same recipient
163
188
  * had a non-empty Cc field. */
164
189
  hasCcHistoryTo(email: string): boolean;
190
+ /** Q49: same shape, for Bcc. Sent folder is the only place Bcc appears,
191
+ * so the signal is local-only but still reflects the user's habit. */
192
+ hasBccHistoryTo(email: string): boolean;
165
193
  syncGoogleContacts(): Promise<void>;
166
194
  seedContacts(): number;
167
195
  /** Explicit add to address book — used by the right-click "Add to contacts"
@@ -71,6 +71,32 @@ async function detectEmailProvider(domain) {
71
71
  return null;
72
72
  }
73
73
  // sanitizeHtml and encodeQuotedPrintable imported from @bobfrankston/mailx-types (shared with Android)
74
+ /** Compare a local task row to an incoming Google task projection. Used by
75
+ * refreshTasks to skip no-op upserts that would otherwise emit `tasksUpdated`
76
+ * on every poll, feeding back into the UI's getTasks-on-event listener and
77
+ * burning the daily Google Tasks API quota. */
78
+ function taskRowEquals(prior, fresh) {
79
+ return prior.providerId === fresh.providerId
80
+ && prior.title === fresh.title
81
+ && (prior.notes || "") === (fresh.notes || "")
82
+ && (prior.dueMs ?? null) === (fresh.dueMs ?? null)
83
+ && (prior.completedMs ?? null) === (fresh.completedMs ?? null)
84
+ && (prior.etag || "") === (fresh.etag || "");
85
+ }
86
+ /** Same shape as taskRowEquals — compares the calendar-event fields the
87
+ * Google projection actually carries, ignoring derived/local-only columns. */
88
+ function calendarRowEquals(prior, fresh) {
89
+ return prior.providerId === fresh.providerId
90
+ && prior.title === fresh.title
91
+ && prior.startMs === fresh.startMs
92
+ && prior.endMs === fresh.endMs
93
+ && !!prior.allDay === !!fresh.allDay
94
+ && (prior.location || "") === (fresh.location || "")
95
+ && (prior.notes || "") === (fresh.notes || "")
96
+ && (prior.etag || "") === (fresh.etag || "")
97
+ && (prior.recurringEventId || null) === (fresh.recurringEventId || null)
98
+ && (prior.htmlLink || null) === (fresh.htmlLink || null);
99
+ }
74
100
  // ── Service ──
75
101
  export class MailxService {
76
102
  db;
@@ -454,6 +480,21 @@ export class MailxService {
454
480
  * 5-min poll / sidebar nav re-fired the event and the client re-rendered
455
481
  * the red banner. Cleared when the user hits Re-authenticate. */
456
482
  scopeErrorEmitted = new Set();
483
+ /** Quota cooldown — feature → epoch-ms when the next API call is allowed.
484
+ * Set when Google returns 429 (rate limit / daily-quota exceeded). While
485
+ * cooldown is in effect, getCalendarEvents/getTasks return local DB rows
486
+ * without firing a refresh. Heuristic cooldown is one hour; the daily
487
+ * Google Tasks quota actually resets at Pacific midnight, but a one-hour
488
+ * short-circuit keeps the log clean and avoids hammering after a burst. */
489
+ quotaCooldown = new Map();
490
+ /** Sticky "quota exceeded" emit guard — same shape as scopeErrorEmitted. */
491
+ quotaErrorEmitted = new Set();
492
+ /** In-flight refresh promises keyed by feature, so concurrent UI calls
493
+ * share one Google round-trip instead of stacking N parallel fetches.
494
+ * The fire-and-forget loop where `tasksUpdated` re-triggers `getTasks`
495
+ * used to spawn a new refresh on every event RTT — this dedupes them. */
496
+ refreshingCalendar = new Map();
497
+ refreshingTasks = new Map();
457
498
  /** Delete the cached Google OAuth token (the one used for Calendar / Tasks
458
499
  * / Contacts scopes — NOT the IMAP token which `reauthenticate()` handles)
459
500
  * and clear the sticky auth-error state so a subsequent refresh can
@@ -483,6 +524,8 @@ export class MailxService {
483
524
  // can fire a fresh banner. Also trigger a kickoff refresh so the
484
525
  // browser consent pops open now instead of on next sidebar nav.
485
526
  this.scopeErrorEmitted.clear();
527
+ this.quotaCooldown.clear();
528
+ this.quotaErrorEmitted.clear();
486
529
  const now = Date.now();
487
530
  const horizonMs = 90 * 86400_000;
488
531
  this.getCalendarEvents(now, now + horizonMs); // fire-and-forget — triggers consent via primaryTokenProvider
@@ -509,29 +552,78 @@ export class MailxService {
509
552
  const acct = this.getPrimaryAccount("calendar");
510
553
  if (!acct)
511
554
  return [];
512
- this.refreshCalendarEvents(acct.id, fromMs, toMs)
513
- .then(changed => {
514
- if (changed)
515
- this.imapManager.emit("calendarUpdated", { accountId: acct.id });
516
- })
517
- .catch(e => {
518
- const msg = String(e?.message || e);
519
- console.error(`[calendar] refresh failed: ${msg}`);
520
- if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
521
- if (!this.scopeErrorEmitted.has("calendar")) {
522
- this.scopeErrorEmitted.add("calendar");
523
- this.imapManager.emit("authScopeError", {
524
- feature: "calendar",
525
- message: "Google Calendar access needs re-consent.",
526
- });
527
- }
555
+ const acctId = acct.id;
556
+ // Skip the network entirely while in quota cooldown — return DB rows.
557
+ if (!this.inQuotaCooldown("calendar")) {
558
+ let promise = this.refreshingCalendar.get(acctId);
559
+ if (!promise) {
560
+ promise = this.refreshCalendarEvents(acctId, fromMs, toMs)
561
+ .finally(() => this.refreshingCalendar.delete(acctId));
562
+ this.refreshingCalendar.set(acctId, promise);
563
+ promise
564
+ .then(changed => {
565
+ if (changed)
566
+ this.imapManager.emit("calendarUpdated", { accountId: acctId });
567
+ })
568
+ .catch(e => this.handleGoogleRefreshError("calendar", e));
528
569
  }
529
- });
530
- return this.db.getCalendarEvents(acct.id, fromMs, toMs);
570
+ }
571
+ return this.db.getCalendarEvents(acctId, fromMs, toMs);
572
+ }
573
+ /** Returns true if the feature is currently in a quota-exceeded cooldown. */
574
+ inQuotaCooldown(feature) {
575
+ const until = this.quotaCooldown.get(feature);
576
+ if (!until)
577
+ return false;
578
+ if (Date.now() < until)
579
+ return true;
580
+ this.quotaCooldown.delete(feature);
581
+ this.quotaErrorEmitted.delete(feature);
582
+ return false;
583
+ }
584
+ /** Single error-handling path for Google refresh failures.
585
+ * Distinguishes 429 (quota) from 401/403 (scope) so each gets the right
586
+ * cooldown + sticky-emit treatment without duplicating the regex blocks. */
587
+ handleGoogleRefreshError(feature, e) {
588
+ const msg = String(e?.message || e);
589
+ const status = e instanceof gsync.GoogleHttpError ? e.status : 0;
590
+ const is429 = status === 429 || /\b429\b|rateLimitExceeded|quotaExceeded|userRateLimitExceeded/i.test(msg);
591
+ const isScope = !is429 && (status === 401 || status === 403
592
+ || /insufficient (authentication )?scope|PERMISSION_DENIED|\b403\b/i.test(msg));
593
+ console.error(`[${feature}] refresh failed: ${msg}`);
594
+ if (is429) {
595
+ const cooldownMs = 60 * 60_000; // one hour heuristic
596
+ this.quotaCooldown.set(feature, Date.now() + cooldownMs);
597
+ if (!this.quotaErrorEmitted.has(feature)) {
598
+ this.quotaErrorEmitted.add(feature);
599
+ this.imapManager.emit("quotaError", {
600
+ feature,
601
+ message: `Google ${feature} daily quota exceeded — try again later.`,
602
+ untilMs: Date.now() + cooldownMs,
603
+ });
604
+ }
605
+ return;
606
+ }
607
+ if (isScope) {
608
+ if (!this.scopeErrorEmitted.has(feature)) {
609
+ this.scopeErrorEmitted.add(feature);
610
+ const labels = {
611
+ calendar: "Google Calendar", tasks: "Google Tasks", contacts: "Google Contacts",
612
+ };
613
+ this.imapManager.emit("authScopeError", {
614
+ feature,
615
+ message: `${labels[feature] || feature} access needs re-consent.`,
616
+ });
617
+ }
618
+ }
531
619
  }
532
620
  /** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
533
621
  * server-side deletions. Returns true if anything changed so callers
534
- * can decide whether to emit a refresh event. */
622
+ * can decide whether to emit a refresh event. `changed` is only true
623
+ * when at least one row's data actually differs — without this guard
624
+ * the UI's `calendarUpdated` listener re-triggers `getCalendarEvents`,
625
+ * which fires another `refreshCalendarEvents`, which emits again, etc.
626
+ * Tight loop = 429 quota burn. */
535
627
  async refreshCalendarEvents(accountId, fromMs, toMs) {
536
628
  const tp = await this.primaryTokenProvider("calendar");
537
629
  const events = await gsync.listCalendarEvents(tp, fromMs, toMs);
@@ -545,6 +637,8 @@ export class MailxService {
545
637
  const local = gsync.calendarEventToLocal(ev, accountId);
546
638
  seenProviderIds.add(ev.id);
547
639
  const existing = this.db.getCalendarEventByProviderId(accountId, ev.id);
640
+ if (existing && calendarRowEquals(existing, local))
641
+ continue;
548
642
  this.db.upsertCalendarEvent({ uuid: existing?.uuid, ...local });
549
643
  changed = true;
550
644
  }
@@ -613,26 +707,23 @@ export class MailxService {
613
707
  const acct = this.getPrimaryAccount("tasks");
614
708
  if (!acct)
615
709
  return [];
616
- this.refreshTasks(acct.id, includeCompleted)
617
- .then(changed => {
618
- if (changed)
619
- this.imapManager.emit("tasksUpdated", { accountId: acct.id });
620
- })
621
- .catch(e => {
622
- const msg = String(e?.message || e);
623
- console.error(`[tasks] refresh failed: ${msg}`);
624
- if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
625
- if (!this.scopeErrorEmitted.has("tasks")) {
626
- this.scopeErrorEmitted.add("tasks");
627
- console.error(`[tasks] Your cached OAuth token doesn't include the 'tasks' scope.`);
628
- this.imapManager.emit("authScopeError", {
629
- feature: "tasks",
630
- message: "Google Tasks access needs re-consent.",
631
- });
632
- }
710
+ const acctId = acct.id;
711
+ if (!this.inQuotaCooldown("tasks")) {
712
+ const key = `${acctId}:${includeCompleted ? 1 : 0}`;
713
+ let promise = this.refreshingTasks.get(key);
714
+ if (!promise) {
715
+ promise = this.refreshTasks(acctId, includeCompleted)
716
+ .finally(() => this.refreshingTasks.delete(key));
717
+ this.refreshingTasks.set(key, promise);
718
+ promise
719
+ .then(changed => {
720
+ if (changed)
721
+ this.imapManager.emit("tasksUpdated", { accountId: acctId });
722
+ })
723
+ .catch(e => this.handleGoogleRefreshError("tasks", e));
633
724
  }
634
- });
635
- return this.db.getTasks(acct.id, includeCompleted);
725
+ }
726
+ return this.db.getTasks(acctId, includeCompleted);
636
727
  }
637
728
  async refreshTasks(accountId, includeCompleted) {
638
729
  const tp = await this.primaryTokenProvider("tasks");
@@ -644,8 +735,13 @@ export class MailxService {
644
735
  for (const t of tasks) {
645
736
  const local = gsync.taskToLocal(t, accountId);
646
737
  const prior = existing.find(e => e.providerId === t.id);
647
- this.db.upsertTask({ uuid: prior?.uuid, ...local });
648
738
  seen.add(t.id);
739
+ // Skip the upsert when nothing actually differs. Otherwise every
740
+ // refresh emits `tasksUpdated`, the UI listener calls `getTasks`,
741
+ // which fires another `refreshTasks` — tight loop, 429 quota burn.
742
+ if (prior && taskRowEquals(prior, local))
743
+ continue;
744
+ this.db.upsertTask({ uuid: prior?.uuid, ...local });
649
745
  changed = true;
650
746
  }
651
747
  // Server-side delete reconciliation: any non-dirty local task whose
@@ -1414,6 +1510,11 @@ export class MailxService {
1414
1510
  hasCcHistoryTo(email) {
1415
1511
  return this.db.hasCcHistoryTo(email);
1416
1512
  }
1513
+ /** Q49: same shape, for Bcc. Sent folder is the only place Bcc appears,
1514
+ * so the signal is local-only but still reflects the user's habit. */
1515
+ hasBccHistoryTo(email) {
1516
+ return this.db.hasBccHistoryTo(email);
1517
+ }
1417
1518
  async syncGoogleContacts() {
1418
1519
  await this.imapManager.syncAllContacts();
1419
1520
  }
@@ -142,6 +142,8 @@ async function dispatchAction(svc, action, p) {
142
142
  return svc.searchContacts(p.query);
143
143
  case "hasCcHistoryTo":
144
144
  return { hasCc: svc.hasCcHistoryTo(p.email) };
145
+ case "hasBccHistoryTo":
146
+ return { hasBcc: svc.hasBccHistoryTo(p.email) };
145
147
  case "addContact":
146
148
  return { ok: svc.addContact(p.name, p.email) };
147
149
  case "listContacts":
@@ -10,6 +10,10 @@ export declare class MailxDB {
10
10
  /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
11
11
  * runs at startup). The user-facing message names the recovery command. */
12
12
  private verifySchema;
13
+ /** Fetch a string from the kv table. Returns null when not set. */
14
+ getKv(scope: string, key: string): string | null;
15
+ /** Upsert a kv row. Pass `null` to delete. */
16
+ setKv(scope: string, key: string, value: string | null): void;
13
17
  /** One-time: assign UUIDs to every `messages` row that's missing one.
14
18
  * Runs on every startup but the WHERE clause makes it a no-op after the
15
19
  * first pass. */
@@ -27,6 +31,10 @@ export declare class MailxDB {
27
31
  * Sent folder's row count is typically a few thousand at most; acceptable
28
32
  * on the compose-open path. */
29
33
  hasCcHistoryTo(recipientEmail: string): boolean;
34
+ /** Same shape as hasCcHistoryTo for the Bcc field. Bcc only appears in the
35
+ * user's own Sent copy, so it's still a reliable signal that this user
36
+ * habitually Bccs when writing to this recipient. */
37
+ hasBccHistoryTo(recipientEmail: string): boolean;
30
38
  /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
31
39
  * is empty (e.g. provider stripped the header) — without a stable id we
32
40
  * can't check against future sync results anyway. */
@@ -215,6 +223,12 @@ export declare class MailxDB {
215
223
  upsertContact(name: string, email: string): void;
216
224
  /** Delete a contact by email (address book UI). */
217
225
  deleteContact(email: string): void;
226
+ /** Delete contact rows by Google People resourceName. Used by the
227
+ * incremental People sync when a person comes back with `metadata.deleted = true`
228
+ * — the email may have already changed/disappeared, but the resourceName
229
+ * is stable. Removes all rows tied to that Google identity (a single
230
+ * contact can have multiple email addresses, each is its own row). */
231
+ deleteContactByGoogleId(googleId: string): number;
218
232
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
219
233
  searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
220
234
  /** Rebuild FTS index from existing messages */
@@ -219,6 +219,17 @@ const SCHEMA = `
219
219
  -- by target_uuid alone ("is this uuid queued for any op?") would otherwise
220
220
  -- table-scan. Cheap; store_sync is tiny and write-heavy.
221
221
  CREATE INDEX IF NOT EXISTS idx_store_sync_target_uuid ON store_sync(target_uuid);
222
+ -- Generic per-scope/per-key string store. Used for sync tokens (Google
223
+ -- People nextSyncToken per account, Gmail history-id, calendar sync token,
224
+ -- etc.) and any other small bits of state that need to outlive a process
225
+ -- restart but don't deserve their own table. Keyed by (scope, key).
226
+ CREATE TABLE IF NOT EXISTS kv (
227
+ scope TEXT NOT NULL,
228
+ key TEXT NOT NULL,
229
+ value TEXT,
230
+ updated_at INTEGER NOT NULL,
231
+ PRIMARY KEY(scope, key)
232
+ );
222
233
  `;
223
234
  export class MailxDB {
224
235
  db;
@@ -277,6 +288,7 @@ export class MailxDB {
277
288
  verifySchema() {
278
289
  const required = {
279
290
  messages: ["thread_id", "provider_id", "uuid"],
291
+ calendar_events: ["recurring_event_id", "html_link"],
280
292
  };
281
293
  for (const [table, cols] of Object.entries(required)) {
282
294
  let actual;
@@ -293,6 +305,20 @@ export class MailxDB {
293
305
  }
294
306
  }
295
307
  }
308
+ /** Fetch a string from the kv table. Returns null when not set. */
309
+ getKv(scope, key) {
310
+ const r = this.db.prepare("SELECT value FROM kv WHERE scope = ? AND key = ?").get(scope, key);
311
+ return r?.value ?? null;
312
+ }
313
+ /** Upsert a kv row. Pass `null` to delete. */
314
+ setKv(scope, key, value) {
315
+ if (value === null) {
316
+ this.db.prepare("DELETE FROM kv WHERE scope = ? AND key = ?").run(scope, key);
317
+ return;
318
+ }
319
+ this.db.prepare("INSERT INTO kv (scope, key, value, updated_at) VALUES (?, ?, ?, ?) "
320
+ + "ON CONFLICT(scope, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at").run(scope, key, value, Date.now());
321
+ }
296
322
  /** One-time: assign UUIDs to every `messages` row that's missing one.
297
323
  * Runs on every startup but the WHERE clause makes it a no-op after the
298
324
  * first pass. */
@@ -364,6 +390,28 @@ export class MailxDB {
364
390
  return false;
365
391
  }
366
392
  }
393
+ /** Same shape as hasCcHistoryTo for the Bcc field. Bcc only appears in the
394
+ * user's own Sent copy, so it's still a reliable signal that this user
395
+ * habitually Bccs when writing to this recipient. */
396
+ hasBccHistoryTo(recipientEmail) {
397
+ const email = (recipientEmail || "").trim().toLowerCase();
398
+ if (!email)
399
+ return false;
400
+ try {
401
+ const row = this.db.prepare(`
402
+ SELECT 1 FROM messages m
403
+ JOIN folders f ON m.folder_id = f.id
404
+ WHERE f.special_use = 'sent'
405
+ AND lower(m.to_json) LIKE ?
406
+ AND m.bcc_json IS NOT NULL AND m.bcc_json != '[]' AND m.bcc_json != ''
407
+ LIMIT 1
408
+ `).get(`%"${email}"%`);
409
+ return !!row;
410
+ }
411
+ catch {
412
+ return false;
413
+ }
414
+ }
367
415
  // ── Tombstones (local-delete record so server echo can't resurrect) ──
368
416
  /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
369
417
  * is empty (e.g. provider stripped the header) — without a stable id we
@@ -1155,6 +1203,17 @@ export class MailxDB {
1155
1203
  deleteContact(email) {
1156
1204
  this.db.prepare("DELETE FROM contacts WHERE email = ?").run(email);
1157
1205
  }
1206
+ /** Delete contact rows by Google People resourceName. Used by the
1207
+ * incremental People sync when a person comes back with `metadata.deleted = true`
1208
+ * — the email may have already changed/disappeared, but the resourceName
1209
+ * is stable. Removes all rows tied to that Google identity (a single
1210
+ * contact can have multiple email addresses, each is its own row). */
1211
+ deleteContactByGoogleId(googleId) {
1212
+ if (!googleId)
1213
+ return 0;
1214
+ const r = this.db.prepare("DELETE FROM contacts WHERE google_id = ?").run(googleId);
1215
+ return Number(r.changes || 0);
1216
+ }
1158
1217
  // ── Search ──
1159
1218
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
1160
1219
  searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {