@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 +10 -0
- package/client/components/calendar-sidebar.js +12 -0
- package/client/compose/compose.js +31 -18
- package/client/lib/api-client.js +3 -0
- package/client/package.json +1 -1
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +11 -2
- package/packages/mailx-imap/index.js +65 -27
- package/packages/mailx-service/google-sync.d.ts +6 -0
- package/packages/mailx-service/google-sync.js +12 -1
- package/packages/mailx-service/index.d.ts +29 -1
- package/packages/mailx-service/index.js +140 -39
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-store/db.d.ts +14 -0
- package/packages/mailx-store/db.js +59 -0
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
|
|
418
|
-
// just visible) so they're prompted to fill it.
|
|
419
|
-
// the service call fails or the user starts
|
|
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 }) =>
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
package/client/lib/api-client.js
CHANGED
|
@@ -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
|
}
|
package/client/package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
3680
|
-
params.set("syncToken",
|
|
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
|
|
3687
|
-
|
|
3688
|
-
|
|
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
|
|
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
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
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
|
-
|
|
3728
|
-
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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(
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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(
|
|
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) {
|