@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.
Files changed (3) hide show
  1. package/index.d.ts +9 -0
  2. package/index.js +87 -5
  3. 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
- const markClosed = (how) => {
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
- personFields: "names,emailAddresses,organizations,photos",
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
- const org = orgs[0]?.name || "";
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("UPDATE contacts SET source = 'google', google_id = ?, organization = ?, updated_at = ? WHERE email = ?").run(googleId, org, now, email);
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.142",
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.80",
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.80",
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",