@bobfrankston/mailx 1.0.385 → 1.0.386

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.385",
3
+ "version": "1.0.386",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.7",
25
25
  "@bobfrankston/miscinfo": "^1.0.9",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
- "@bobfrankston/msger": "^0.1.348",
27
+ "@bobfrankston/msger": "^0.1.349",
28
28
  "@bobfrankston/mailx-host": "^0.1.4",
29
29
  "@capacitor/android": "^8.3.0",
30
30
  "@capacitor/cli": "^8.3.0",
@@ -88,7 +88,7 @@
88
88
  "@bobfrankston/iflow-node": "^0.1.7",
89
89
  "@bobfrankston/miscinfo": "^1.0.9",
90
90
  "@bobfrankston/oauthsupport": "^1.0.24",
91
- "@bobfrankston/msger": "^0.1.348",
91
+ "@bobfrankston/msger": "^0.1.349",
92
92
  "@bobfrankston/mailx-host": "^0.1.4",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
@@ -133,6 +133,11 @@ export declare class ImapManager extends EventEmitter {
133
133
  private getBodyClient;
134
134
  /** Drop the body-fetch connection (e.g. after a socket error). */
135
135
  private dropBodyClient;
136
+ /** Force-close every pooled client for an account — ops, body, any
137
+ * lingering ones in openClients. Used when the server reports its
138
+ * connection cap is hit so our slot count drops to zero on the
139
+ * server side before backoff expires. */
140
+ closeAllClients(accountId: string): Promise<void>;
136
141
  /** Disconnect the persistent operational connection for an account */
137
142
  disconnectOps(accountId: string): Promise<void>;
138
143
  /** Legacy API — callers that still create/destroy connections.
@@ -498,6 +498,50 @@ export class ImapManager extends EventEmitter {
498
498
  }
499
499
  catch { /* */ }
500
500
  }
501
+ /** Force-close every pooled client for an account — ops, body, any
502
+ * lingering ones in openClients. Used when the server reports its
503
+ * connection cap is hit so our slot count drops to zero on the
504
+ * server side before backoff expires. */
505
+ async closeAllClients(accountId) {
506
+ const ops = this.opsClients.get(accountId);
507
+ this.opsClients.delete(accountId);
508
+ if (ops) {
509
+ try {
510
+ await (ops._realLogout || ops.logout)();
511
+ }
512
+ catch { /* */ }
513
+ try {
514
+ ops.destroy?.();
515
+ }
516
+ catch { /* */ }
517
+ }
518
+ const body = this.bodyClients.get(accountId);
519
+ this.bodyClients.delete(accountId);
520
+ if (body) {
521
+ try {
522
+ await (body._realLogout || body.logout)();
523
+ }
524
+ catch { /* */ }
525
+ try {
526
+ body.destroy?.();
527
+ }
528
+ catch { /* */ }
529
+ }
530
+ const open = this.openClients.get(accountId);
531
+ if (open) {
532
+ for (const c of Array.from(open)) {
533
+ try {
534
+ await (c._realLogout || c.logout)?.();
535
+ }
536
+ catch { /* */ }
537
+ try {
538
+ c.destroy?.();
539
+ }
540
+ catch { /* */ }
541
+ }
542
+ open.clear();
543
+ }
544
+ }
501
545
  /** Disconnect the persistent operational connection for an account */
502
546
  async disconnectOps(accountId) {
503
547
  const client = this.opsClients.get(accountId);
@@ -1441,7 +1485,16 @@ export class ImapManager extends EventEmitter {
1441
1485
  /** Handle sync errors — classify and emit appropriate UI events */
1442
1486
  handleSyncError(accountId, errMsg) {
1443
1487
  if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
1444
- this.connectionBackoff.set(accountId, Date.now() + 60000);
1488
+ // Dovecot connection cap hit. 60s was too short — the server
1489
+ // tracks slots with a decay window, and mailx was racing right
1490
+ // back into the cap every time. Extend to 5 min AND close all
1491
+ // pooled clients so the server's count drops to zero. Also
1492
+ // mark all of this account's folder-cooldowns so prefetch
1493
+ // doesn't try to reopen during the backoff.
1494
+ const BACKOFF_MS = 5 * 60_000;
1495
+ this.connectionBackoff.set(accountId, Date.now() + BACKOFF_MS);
1496
+ this.closeAllClients(accountId).catch(() => { });
1497
+ console.warn(` [conn] ${accountId}: server connection cap hit — closing all clients + ${BACKOFF_MS / 1000}s backoff`);
1445
1498
  }
1446
1499
  const config = this.configs.get(accountId);
1447
1500
  const isOAuth = !!config?.tokenProvider;
@@ -2139,9 +2192,19 @@ export class ImapManager extends EventEmitter {
2139
2192
  this.clearFolderErrors(accountId, folder.path);
2140
2193
  }
2141
2194
  catch (e) {
2142
- console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${e.message}`);
2195
+ const msg = String(e?.message || "");
2196
+ console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${msg}`);
2143
2197
  counters.errors++;
2144
2198
  this.recordFolderError(accountId, folder.path);
2199
+ // Server connection cap hit during prefetch — this is why
2200
+ // bobma log shows "100+ bodies to fetch" with no follow-up
2201
+ // "done": subsequent folders also hit the cap, burn the
2202
+ // budget, and nothing progresses. Route through the
2203
+ // sync-error handler so backoff + closeAllClients kick in.
2204
+ if (/max_userip_connections|Too many simultaneous/i.test(msg)) {
2205
+ this.handleSyncError(accountId, msg);
2206
+ break;
2207
+ }
2145
2208
  if (counters.errors >= ERROR_BUDGET)
2146
2209
  break;
2147
2210
  }
@@ -1023,32 +1023,41 @@ export class MailxDB {
1023
1023
  }
1024
1024
  /** Search contacts by name or email prefix */
1025
1025
  searchContacts(query, limit = 10) {
1026
- // Two-pass ranking so autocomplete feels responsive: rows whose name or
1027
- // local-part starts with the query rank first (exact prefix match is
1028
- // usually what the user wants), then substring matches fill out the
1029
- // rest. Within each tier, sort by (recency-weighted use_count) so a
1030
- // contact the user messaged today beats one from two years ago even
1031
- // if the older one has more total sends. Recency bonus: +1 per send
1032
- // decayed by ~half every 30 days.
1033
- const prefixQ = `${query}%`;
1026
+ // Ranking: prefix matches beat substring matches, then recency-weighted
1027
+ // use_count within a tier. Recency decay: half-life of 30 days, so a
1028
+ // contact used today edges out one from months ago even with a lower
1029
+ // raw use_count. Computed in JS since SQLite lacks exp/log.
1030
+ //
1031
+ // Wrapped in try/catch + simple-query fallback so a (hypothetical) SQL
1032
+ // edge case on exotic input can never leave autocomplete showing blank.
1033
+ // The rank-0 baseline is identical behavior to the original query.
1034
1034
  const substr = `%${query}%`;
1035
- const rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
1036
- (CASE
1037
- WHEN lower(name) LIKE lower(?) THEN 3
1038
- WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
1039
- WHEN email LIKE ? OR name LIKE ? THEN 1
1040
- ELSE 0
1041
- END) AS match_rank
1042
- FROM contacts
1043
- WHERE email LIKE ? OR name LIKE ?
1044
- ORDER BY match_rank DESC, use_count DESC, last_used DESC
1045
- LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit);
1046
- // Recency-weighted rescore, best-in-JS since SQLite lacks log/exp
1047
- // natively. 30-day half-life close enough to "recent contacts
1048
- // float up" without being fussy about the exact decay curve.
1035
+ let rows;
1036
+ try {
1037
+ const prefixQ = `${query}%`;
1038
+ rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
1039
+ (CASE
1040
+ WHEN lower(name) LIKE lower(?) THEN 3
1041
+ WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
1042
+ WHEN email LIKE ? OR name LIKE ? THEN 1
1043
+ ELSE 0
1044
+ END) AS match_rank
1045
+ FROM contacts
1046
+ WHERE email LIKE ? OR name LIKE ?
1047
+ ORDER BY match_rank DESC, use_count DESC, last_used DESC
1048
+ LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit);
1049
+ }
1050
+ catch (e) {
1051
+ console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
1052
+ rows = this.db.prepare(`SELECT name, email, source, use_count, last_used, 0 AS match_rank
1053
+ FROM contacts
1054
+ WHERE email LIKE ? OR name LIKE ?
1055
+ ORDER BY use_count DESC, last_used DESC
1056
+ LIMIT ?`).all(substr, substr, limit);
1057
+ }
1049
1058
  const now = Date.now();
1050
1059
  const HALF_LIFE_MS = 30 * 86400_000;
1051
- const score = (r) => r.match_rank * 10_000
1060
+ const score = (r) => (r.match_rank || 0) * 10_000
1052
1061
  + (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
1053
1062
  rows.sort((a, b) => score(b) - score(a));
1054
1063
  return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));