@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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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 }));
|