@bobfrankston/mailx-imap 0.1.142 → 0.1.146
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/index.d.ts +9 -0
- package/index.js +87 -5
- package/package.json +3 -3
package/index.d.ts
CHANGED
|
@@ -68,6 +68,15 @@ export declare class ImapManager extends EventEmitter {
|
|
|
68
68
|
* giving two watchers each for bobma/gmail and pushing aol into
|
|
69
69
|
* "[LIMIT] LOGIN Rate limit hit". This set closes that window. */
|
|
70
70
|
private watchStarting;
|
|
71
|
+
/** Accounts whose IDLE watcher was refused for a CREDENTIAL reason, with
|
|
72
|
+
* the error text. Retrying those every 60s forever is pure waste: the
|
|
73
|
+
* outlook account has failed `AUTHENTICATE failed.` on every boot for
|
|
74
|
+
* weeks (its mail syncs over Graph, so nothing is lost — but each attempt
|
|
75
|
+
* burns a connection slot and buries the log). Suppressed until the
|
|
76
|
+
* account re-authenticates, which clears the entry. Transient refusals
|
|
77
|
+
* (rate limits, network) are NOT recorded here — they keep the normal
|
|
78
|
+
* 60s deadman cadence. */
|
|
79
|
+
private watchAuthFailed;
|
|
71
80
|
private fetchClients;
|
|
72
81
|
/** The Store is the architectural nexus — owner of MailxDB +
|
|
73
82
|
* FileMessageStore + the event bus. This package (mailx-imap) is a
|
package/index.js
CHANGED
|
@@ -210,6 +210,15 @@ export class ImapManager extends EventEmitter {
|
|
|
210
210
|
* giving two watchers each for bobma/gmail and pushing aol into
|
|
211
211
|
* "[LIMIT] LOGIN Rate limit hit". This set closes that window. */
|
|
212
212
|
watchStarting = new Set();
|
|
213
|
+
/** Accounts whose IDLE watcher was refused for a CREDENTIAL reason, with
|
|
214
|
+
* the error text. Retrying those every 60s forever is pure waste: the
|
|
215
|
+
* outlook account has failed `AUTHENTICATE failed.` on every boot for
|
|
216
|
+
* weeks (its mail syncs over Graph, so nothing is lost — but each attempt
|
|
217
|
+
* burns a connection slot and buries the log). Suppressed until the
|
|
218
|
+
* account re-authenticates, which clears the entry. Transient refusals
|
|
219
|
+
* (rate limits, network) are NOT recorded here — they keep the normal
|
|
220
|
+
* 60s deadman cadence. */
|
|
221
|
+
watchAuthFailed = new Map();
|
|
213
222
|
fetchClients = new Map();
|
|
214
223
|
/** The Store is the architectural nexus — owner of MailxDB +
|
|
215
224
|
* FileMessageStore + the event bus. This package (mailx-imap) is a
|
|
@@ -371,6 +380,9 @@ export class ImapManager extends EventEmitter {
|
|
|
371
380
|
if (config?.tokenProvider) {
|
|
372
381
|
console.log(` [reauth] ${accountId}: success`);
|
|
373
382
|
this.accountErrorShown.delete(accountId);
|
|
383
|
+
// Fresh credentials — let IDLE try again on an account whose
|
|
384
|
+
// watcher was suppressed for an auth failure.
|
|
385
|
+
this.watchAuthFailed.delete(accountId);
|
|
374
386
|
this.syncInbox().catch(() => { });
|
|
375
387
|
return true;
|
|
376
388
|
}
|
|
@@ -903,6 +915,15 @@ export class ImapManager extends EventEmitter {
|
|
|
903
915
|
const skipSemaphore = purpose === "idle";
|
|
904
916
|
const releaseHostSlot = skipSemaphore ? (() => { }) : await this.acquireHostSlot(host);
|
|
905
917
|
let client;
|
|
918
|
+
// Forward declaration: the connect wrapper below is DEFINED here but
|
|
919
|
+
// only CALLED after newClient returns, by which time the real
|
|
920
|
+
// markClosed is installed. A connect that throws (aol "[LIMIT] LOGIN
|
|
921
|
+
// Rate limit hit", outlook "AUTHENTICATE failed") used to leave the
|
|
922
|
+
// client registered in openClients forever AND never release its host
|
|
923
|
+
// semaphore permit — the counter climbed to 141 over a 37-hour session
|
|
924
|
+
// (Bob 2026-08-05 shutdown log) and every leaked permit is one fewer
|
|
925
|
+
// concurrent connection that account can ever open again.
|
|
926
|
+
let markClosed = () => { };
|
|
906
927
|
try {
|
|
907
928
|
// Verbose IMAP wire trace — diagnostic for silently-hanging
|
|
908
929
|
// commands. It was left permanently ON for the ops/fast lanes, but
|
|
@@ -954,10 +975,21 @@ export class ImapManager extends EventEmitter {
|
|
|
954
975
|
const total = Date.now() - connT0;
|
|
955
976
|
if (total > 3000)
|
|
956
977
|
console.log(` [conn-slow] ${accountId} (${purpose}): connect took ${total}ms — ${phaseTimings()}`);
|
|
978
|
+
// A socket that dies on its own — server drop, TLS
|
|
979
|
+
// reset, idle timeout — never routes through
|
|
980
|
+
// logout()/destroy(), so without this the connection
|
|
981
|
+
// stays "open" in our books and its semaphore permit
|
|
982
|
+
// is gone for the life of the process. Node always
|
|
983
|
+
// emits 'close' after 'error', so one listener covers
|
|
984
|
+
// both.
|
|
985
|
+
const sock = client?.native?.transport?.socket;
|
|
986
|
+
if (typeof sock?.once === "function")
|
|
987
|
+
sock.once("close", () => markClosed("socket-close"));
|
|
957
988
|
return r;
|
|
958
989
|
}
|
|
959
990
|
catch (e) {
|
|
960
991
|
emitPhase("failed", e?.message || String(e));
|
|
992
|
+
markClosed("connect-failed");
|
|
961
993
|
throw e;
|
|
962
994
|
}
|
|
963
995
|
};
|
|
@@ -975,10 +1007,17 @@ export class ImapManager extends EventEmitter {
|
|
|
975
1007
|
open.add(client);
|
|
976
1008
|
console.log(` [conn+] ${accountId} (${purpose}) — ${open.size} open`);
|
|
977
1009
|
let closed = false;
|
|
978
|
-
|
|
1010
|
+
markClosed = (how) => {
|
|
979
1011
|
if (closed)
|
|
980
1012
|
return;
|
|
981
1013
|
closed = true;
|
|
1014
|
+
// Flag the client itself so the IDLE watcher health-check (which
|
|
1015
|
+
// inspects `_dead` alongside the raw socket state) re-establishes
|
|
1016
|
+
// a watcher whose socket died under it.
|
|
1017
|
+
try {
|
|
1018
|
+
client._dead = true;
|
|
1019
|
+
}
|
|
1020
|
+
catch { /* frozen client — the socket checks still catch it */ }
|
|
982
1021
|
open.delete(client);
|
|
983
1022
|
releaseHostSlot();
|
|
984
1023
|
console.log(` [conn-] ${accountId} (${purpose}/${how}) — ${open.size} open`);
|
|
@@ -3236,6 +3275,10 @@ export class ImapManager extends EventEmitter {
|
|
|
3236
3275
|
console.log(` [idle] ${accountId}: watcher already being established — skipping duplicate`);
|
|
3237
3276
|
continue;
|
|
3238
3277
|
}
|
|
3278
|
+
// Credentials were refused last time. Don't re-offer them every
|
|
3279
|
+
// 60s — wait for a re-auth to clear this. See watchAuthFailed.
|
|
3280
|
+
if (this.watchAuthFailed.has(accountId))
|
|
3281
|
+
continue;
|
|
3239
3282
|
this.watchStarting.add(accountId);
|
|
3240
3283
|
try {
|
|
3241
3284
|
// IDLE keeps its own dedicated socket — once the connection
|
|
@@ -3336,6 +3379,17 @@ export class ImapManager extends EventEmitter {
|
|
|
3336
3379
|
? parts.join(" ")
|
|
3337
3380
|
: `<no message> ${typeof e} ${e?.constructor?.name || ""}`.trim();
|
|
3338
3381
|
console.error(` [idle] Failed to watch ${accountId}: ${desc}`);
|
|
3382
|
+
// Separate "these credentials are wrong" from "the server is
|
|
3383
|
+
// busy right now". A rate limit ([LIMIT] LOGIN Rate limit hit
|
|
3384
|
+
// — aol does this when we over-connect) says nothing about the
|
|
3385
|
+
// credentials and must stay retryable, so it is checked FIRST
|
|
3386
|
+
// even though its text also contains "Login failed".
|
|
3387
|
+
const transient = /\[LIMIT\]|rate limit|too many|try again|timed? ?out|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket/i.test(desc);
|
|
3388
|
+
const authFailed = !transient && /authenticat|login failed|AUTHENTICATIONFAILED|invalid credential|password|\[AUTH/i.test(desc);
|
|
3389
|
+
if (authFailed) {
|
|
3390
|
+
this.watchAuthFailed.set(accountId, desc);
|
|
3391
|
+
console.error(` [idle] ${accountId}: credentials refused — suppressing IDLE retries until this account re-authenticates`);
|
|
3392
|
+
}
|
|
3339
3393
|
}
|
|
3340
3394
|
finally {
|
|
3341
3395
|
this.watchStarting.delete(accountId);
|
|
@@ -6127,7 +6181,11 @@ export class ImapManager extends EventEmitter {
|
|
|
6127
6181
|
try {
|
|
6128
6182
|
do {
|
|
6129
6183
|
const params = new URLSearchParams({
|
|
6130
|
-
|
|
6184
|
+
// The full contact card. Before 2026-08-07 this asked for
|
|
6185
|
+
// names/emails/orgs only, so phone numbers, job titles,
|
|
6186
|
+
// postal addresses, websites, notes and birthdays that
|
|
6187
|
+
// Google already held were thrown away on every sync.
|
|
6188
|
+
personFields: "names,emailAddresses,organizations,photos,phoneNumbers,addresses,urls,biographies,birthdays",
|
|
6131
6189
|
pageSize: "100",
|
|
6132
6190
|
});
|
|
6133
6191
|
if (nextPageToken)
|
|
@@ -6178,7 +6236,25 @@ export class ImapManager extends EventEmitter {
|
|
|
6178
6236
|
const names = person.names || [];
|
|
6179
6237
|
const orgs = person.organizations || [];
|
|
6180
6238
|
const name = names[0]?.displayName || "";
|
|
6181
|
-
|
|
6239
|
+
// Google returns each field as an array of typed
|
|
6240
|
+
// entries (home/work/other) ordered with the primary
|
|
6241
|
+
// first; we keep the first of each. Birthdays come as
|
|
6242
|
+
// {date:{year?,month,day}} — year is optional, and a
|
|
6243
|
+
// missing one is normal (people omit it), so format as
|
|
6244
|
+
// YYYY-MM-DD when known and --MM-DD when not.
|
|
6245
|
+
const bd = person.birthdays?.[0]?.date;
|
|
6246
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
6247
|
+
const card = {
|
|
6248
|
+
organization: orgs[0]?.name || "",
|
|
6249
|
+
title: orgs[0]?.title || "",
|
|
6250
|
+
phone: person.phoneNumbers?.[0]?.value || "",
|
|
6251
|
+
address: person.addresses?.[0]?.formattedValue || "",
|
|
6252
|
+
website: person.urls?.[0]?.value || "",
|
|
6253
|
+
notes: person.biographies?.[0]?.value || "",
|
|
6254
|
+
birthday: bd?.month && bd?.day
|
|
6255
|
+
? `${bd.year ? bd.year : "-"}-${pad(bd.month)}-${pad(bd.day)}`
|
|
6256
|
+
: "",
|
|
6257
|
+
};
|
|
6182
6258
|
for (const emailEntry of emails) {
|
|
6183
6259
|
const email = emailEntry.value?.toLowerCase();
|
|
6184
6260
|
if (!email)
|
|
@@ -6189,9 +6265,15 @@ export class ImapManager extends EventEmitter {
|
|
|
6189
6265
|
if (wasNew)
|
|
6190
6266
|
changed++;
|
|
6191
6267
|
try {
|
|
6192
|
-
this.db.db.prepare(
|
|
6268
|
+
this.db.db.prepare(`UPDATE contacts SET source = 'google', google_id = ?, organization = ?, title = ?,
|
|
6269
|
+
phone = ?, address = ?, website = ?, notes = ?, birthday = ?, updated_at = ?
|
|
6270
|
+
WHERE email = ?`).run(googleId, card.organization, card.title, card.phone, card.address, card.website, card.notes, card.birthday, now, email);
|
|
6271
|
+
}
|
|
6272
|
+
catch (e) {
|
|
6273
|
+
// Loud: a schema drift here silently reverts the
|
|
6274
|
+
// address book to name+email only.
|
|
6275
|
+
console.error(` [contacts] field update failed for ${email}: ${e?.message || e}`);
|
|
6193
6276
|
}
|
|
6194
|
-
catch { /* ignore */ }
|
|
6195
6277
|
}
|
|
6196
6278
|
}
|
|
6197
6279
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.146",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "^0.1.38",
|
|
13
13
|
"@bobfrankston/mailx-settings": "^0.1.48",
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
14
|
+
"@bobfrankston/mailx-store": "^0.1.81",
|
|
15
15
|
"@bobfrankston/iflow-direct": "^0.1.65",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.8",
|
|
17
17
|
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bobfrankston/mailx-types": "^0.1.38",
|
|
41
41
|
"@bobfrankston/mailx-settings": "^0.1.48",
|
|
42
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
42
|
+
"@bobfrankston/mailx-store": "^0.1.81",
|
|
43
43
|
"@bobfrankston/iflow-direct": "^0.1.65",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.8",
|
|
45
45
|
"@bobfrankston/smtp-direct": "^0.1.9",
|