@bobfrankston/mailx-imap 0.1.85 → 0.1.87
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.js +131 -107
- package/package.json +15 -15
package/index.js
CHANGED
|
@@ -986,20 +986,11 @@ export class ImapManager extends EventEmitter {
|
|
|
986
986
|
// worth of latency instead of the full loop's worth.
|
|
987
987
|
const BATCH_SIZE = 50;
|
|
988
988
|
let stored = 0;
|
|
989
|
-
let
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
} };
|
|
995
|
-
const rollbackTxn = () => { if (inTxn) {
|
|
996
|
-
this.db.rollbackTransaction();
|
|
997
|
-
inTxn = false;
|
|
998
|
-
} };
|
|
999
|
-
try {
|
|
1000
|
-
startTxn();
|
|
1001
|
-
let batchCount = 0;
|
|
1002
|
-
for (const msg of msgs) {
|
|
989
|
+
for (let i = 0; i < msgs.length; i += BATCH_SIZE) {
|
|
990
|
+
const chunk = msgs.slice(i, i + BATCH_SIZE);
|
|
991
|
+
// Phase 1 — async prep, NO transaction held.
|
|
992
|
+
const prepared = [];
|
|
993
|
+
for (const msg of chunk) {
|
|
1003
994
|
// Debug: log subjects with non-ASCII to trace encoding issues
|
|
1004
995
|
if (msg.subject && /[^\x00-\x7F]/.test(msg.subject)) {
|
|
1005
996
|
const hex = Buffer.from(msg.subject, "utf-8").subarray(0, 40).toString("hex");
|
|
@@ -1046,43 +1037,45 @@ export class ImapManager extends EventEmitter {
|
|
|
1046
1037
|
flags.push("\\Answered");
|
|
1047
1038
|
if (msg.draft)
|
|
1048
1039
|
flags.push("\\Draft");
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1040
|
+
prepared.push({
|
|
1041
|
+
upsert: {
|
|
1042
|
+
accountId, folderId, uid: msg.uid,
|
|
1043
|
+
messageId: msg.messageId || "",
|
|
1044
|
+
inReplyTo: msg.inReplyTo || "",
|
|
1045
|
+
references: [],
|
|
1046
|
+
date: toFiniteDateMs(msg.date),
|
|
1047
|
+
subject: msg.subject || "",
|
|
1048
|
+
from: toEmailAddress(msg.from?.[0] || {}),
|
|
1049
|
+
to: toEmailAddresses(msg.to || []),
|
|
1050
|
+
cc: toEmailAddresses(msg.cc || []),
|
|
1051
|
+
flags, size: msg.size || 0, hasAttachments, preview, bodyPath,
|
|
1052
|
+
},
|
|
1053
|
+
ftsBody,
|
|
1060
1054
|
});
|
|
1061
|
-
// Index the full body for search. extractPreview already
|
|
1062
|
-
// parsed `source`, so the body text is free here — and
|
|
1063
|
-
// indexing it at sync time means a word buried in the body
|
|
1064
|
-
// is searchable without the user first opening the message.
|
|
1065
|
-
if (ftsRowId && ftsBody)
|
|
1066
|
-
this.db.updateFtsBody(ftsRowId, ftsBody);
|
|
1067
|
-
stored++;
|
|
1068
|
-
batchCount++;
|
|
1069
|
-
if (batchCount >= BATCH_SIZE) {
|
|
1070
|
-
commitTxn();
|
|
1071
|
-
// Hand the event loop to any waiting IPC (user clicks,
|
|
1072
|
-
// outbox drains, alarm polls). setImmediate runs AFTER
|
|
1073
|
-
// pending I/O callbacks, so a stack of getMessage IPCs
|
|
1074
|
-
// that arrived during the previous chunk gets serviced
|
|
1075
|
-
// before we start the next batch.
|
|
1076
|
-
await new Promise(r => setImmediate(r));
|
|
1077
|
-
batchCount = 0;
|
|
1078
|
-
startTxn();
|
|
1079
|
-
}
|
|
1080
1055
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1056
|
+
// Phase 2 — synchronous transaction, no awaits inside. runInTxn
|
|
1057
|
+
// owns its own BEGIN/COMMIT (or joins an already-open txn) and
|
|
1058
|
+
// ROLLBACKs on any throw, so a bad row can't leak an open txn.
|
|
1059
|
+
try {
|
|
1060
|
+
this.db.runInTxn(() => {
|
|
1061
|
+
for (const r of prepared) {
|
|
1062
|
+
const ftsRowId = this.db.upsertMessage(r.upsert);
|
|
1063
|
+
// Index the full body for search. extractPreview already
|
|
1064
|
+
// parsed `source`, so the body text is free here — and
|
|
1065
|
+
// indexing it at sync time means a word buried in the
|
|
1066
|
+
// body is searchable without first opening the message.
|
|
1067
|
+
if (ftsRowId && r.ftsBody)
|
|
1068
|
+
this.db.updateFtsBody(ftsRowId, r.ftsBody);
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
stored += prepared.length;
|
|
1072
|
+
}
|
|
1073
|
+
catch (e) {
|
|
1074
|
+
console.error(` storeMessages error: ${e.message}`);
|
|
1075
|
+
}
|
|
1076
|
+
// Hand the event loop to any waiting IPC (user clicks, outbox
|
|
1077
|
+
// drains, alarm polls) between batches.
|
|
1078
|
+
await new Promise(r => setImmediate(r));
|
|
1086
1079
|
}
|
|
1087
1080
|
return stored;
|
|
1088
1081
|
}
|
|
@@ -1561,45 +1554,43 @@ export class ImapManager extends EventEmitter {
|
|
|
1561
1554
|
const batchSize = 50;
|
|
1562
1555
|
for (let batchStart = 0; batchStart < messages.length; batchStart += batchSize) {
|
|
1563
1556
|
const batchEnd = Math.min(batchStart + batchSize, messages.length);
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
// Store metadata
|
|
1602
|
-
const ftsRowId = this.db.upsertMessage({
|
|
1557
|
+
// Phase 1 — async prep (write .eml, parse preview), NO transaction
|
|
1558
|
+
// held. The previous version kept a raw BEGIN open across
|
|
1559
|
+
// `await putMessage` / `await extractPreview`, so a concurrent
|
|
1560
|
+
// store on the same connection could hit BEGIN mid-await →
|
|
1561
|
+
// "cannot start a transaction within a transaction", wedging the
|
|
1562
|
+
// sync. Same fix + cause as storeMessages above (Bob 2026-06-11).
|
|
1563
|
+
//
|
|
1564
|
+
// NOTE: the old `if (msg.uid <= highestUid) continue` skip is
|
|
1565
|
+
// intentionally gone — gap-fills recover UIDs in the scanned range,
|
|
1566
|
+
// and upsertMessage's UNIQUE(account,folder,uid) constraint dedupes
|
|
1567
|
+
// (truly-present rows become a cheap UPDATE).
|
|
1568
|
+
const prepared = [];
|
|
1569
|
+
for (let i = batchStart; i < batchEnd; i++) {
|
|
1570
|
+
const msg = messages[i];
|
|
1571
|
+
// Tombstone-skip: in-flight local delete/move awaiting server
|
|
1572
|
+
// confirmation (cleared on success or permanent failure).
|
|
1573
|
+
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
1574
|
+
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
const source = msg.source || "";
|
|
1578
|
+
let bodyPath = "";
|
|
1579
|
+
if (source) {
|
|
1580
|
+
bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
|
|
1581
|
+
}
|
|
1582
|
+
const parsed = await extractPreview(source);
|
|
1583
|
+
const flags = [];
|
|
1584
|
+
if (msg.seen)
|
|
1585
|
+
flags.push("\\Seen");
|
|
1586
|
+
if (msg.flagged)
|
|
1587
|
+
flags.push("\\Flagged");
|
|
1588
|
+
if (msg.answered)
|
|
1589
|
+
flags.push("\\Answered");
|
|
1590
|
+
if (msg.draft)
|
|
1591
|
+
flags.push("\\Draft");
|
|
1592
|
+
prepared.push({
|
|
1593
|
+
upsert: {
|
|
1603
1594
|
accountId,
|
|
1604
1595
|
folderId,
|
|
1605
1596
|
uid: msg.uid,
|
|
@@ -1615,20 +1606,27 @@ export class ImapManager extends EventEmitter {
|
|
|
1615
1606
|
size: msg.size || 0,
|
|
1616
1607
|
hasAttachments: parsed.hasAttachments,
|
|
1617
1608
|
preview: parsed.preview,
|
|
1618
|
-
bodyPath
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1609
|
+
bodyPath,
|
|
1610
|
+
},
|
|
1611
|
+
ftsBody: parsed.bodyText || "",
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
// Phase 2 — synchronous transaction, no awaits inside. runInTxn
|
|
1615
|
+
// owns BEGIN/COMMIT (or joins an open txn) and ROLLBACKs on throw.
|
|
1616
|
+
try {
|
|
1617
|
+
this.db.runInTxn(() => {
|
|
1618
|
+
for (const r of prepared) {
|
|
1619
|
+
const ftsRowId = this.db.upsertMessage(r.upsert);
|
|
1620
|
+
// Full body into FTS — parse above already produced the
|
|
1621
|
+
// text, so search covers unopened messages too.
|
|
1622
|
+
if (ftsRowId && r.ftsBody)
|
|
1623
|
+
this.db.updateFtsBody(ftsRowId, r.ftsBody);
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
newCount += prepared.length;
|
|
1628
1627
|
}
|
|
1629
1628
|
catch (e) {
|
|
1630
1629
|
console.error(` transaction error: ${e.message}`);
|
|
1631
|
-
this.db.rollbackTransaction();
|
|
1632
1630
|
throw e;
|
|
1633
1631
|
}
|
|
1634
1632
|
// Emit progress and notify client after each batch
|
|
@@ -4193,19 +4191,37 @@ export class ImapManager extends EventEmitter {
|
|
|
4193
4191
|
if (host !== this.hostname)
|
|
4194
4192
|
continue;
|
|
4195
4193
|
const pid = parseInt(pidStr);
|
|
4196
|
-
|
|
4197
|
-
|
|
4194
|
+
let ageMs = Infinity;
|
|
4195
|
+
try {
|
|
4196
|
+
ageMs = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs;
|
|
4197
|
+
}
|
|
4198
|
+
catch { /* */ }
|
|
4199
|
+
if (pid === myPid) {
|
|
4200
|
+
// Our own claim. Normally we're actively sending it — leave
|
|
4201
|
+
// it. But the send is now bounded (60s APPEND timeout), so
|
|
4202
|
+
// an OWN claim older than STALE_CLAIM_MS means the send
|
|
4203
|
+
// hung past its timeout, or a prior tick orphaned it (the
|
|
4204
|
+
// release rename failed). Without reclaiming it the file
|
|
4205
|
+
// sits in `.sending-` forever — recovery used to skip every
|
|
4206
|
+
// own-PID claim unconditionally, so a transient connection
|
|
4207
|
+
// wedge pinned the message even after the link recovered
|
|
4208
|
+
// (Bob 2026-06-11: two messages stuck .sending-<ourpid>
|
|
4209
|
+
// while SELECT Outbox was already succeeding again).
|
|
4210
|
+
if (ageMs < STALE_CLAIM_MS)
|
|
4211
|
+
continue;
|
|
4212
|
+
try {
|
|
4213
|
+
fs.renameSync(path.join(dir, f), path.join(dir, original));
|
|
4214
|
+
console.log(` [outbox] Recovered our own stale claim ${f} → ${original} (hung ${Math.round(ageMs / 60_000)}m)`);
|
|
4215
|
+
}
|
|
4216
|
+
catch { /* ignore */ }
|
|
4217
|
+
continue;
|
|
4218
|
+
}
|
|
4198
4219
|
let alive = false;
|
|
4199
4220
|
try {
|
|
4200
4221
|
process.kill(pid, 0);
|
|
4201
4222
|
alive = true;
|
|
4202
4223
|
}
|
|
4203
4224
|
catch { /* dead */ }
|
|
4204
|
-
let ageMs = Infinity;
|
|
4205
|
-
try {
|
|
4206
|
-
ageMs = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs;
|
|
4207
|
-
}
|
|
4208
|
-
catch { /* */ }
|
|
4209
4225
|
// Live PID + recent mtime → genuine sibling owner, leave it.
|
|
4210
4226
|
// Live PID + ancient mtime → recycled PID, sweep. Dead PID → sweep.
|
|
4211
4227
|
if (alive && ageMs < STALE_CLAIM_MS)
|
|
@@ -4338,7 +4354,15 @@ export class ImapManager extends EventEmitter {
|
|
|
4338
4354
|
}
|
|
4339
4355
|
try {
|
|
4340
4356
|
const raw = fs.readFileSync(claimedPath, "utf-8");
|
|
4341
|
-
|
|
4357
|
+
// Bound the APPEND. On a wedged connection (Dovecot
|
|
4358
|
+
// ETIMEDOUT storm) the bare await can hang the full
|
|
4359
|
+
// 300s inactivity timeout, pinning the file in
|
|
4360
|
+
// `.sending-` state the whole time and reading to the
|
|
4361
|
+
// user as "stuck, not sending" (Bob 2026-06-11). A
|
|
4362
|
+
// 60s cap force-closes the socket and throws, so the
|
|
4363
|
+
// catch below releases the claim and the next tick
|
|
4364
|
+
// retries instead of hanging for 5 minutes.
|
|
4365
|
+
await withTimeout(client.appendMessage(outboxPath, raw, ["\\Seen"]), 60_000, client, `outbox APPEND ${file}`);
|
|
4342
4366
|
fs.renameSync(claimedPath, path.join(sentDir, file));
|
|
4343
4367
|
console.log(` [outbox] Moved ${file} to IMAP Outbox → sent/`);
|
|
4344
4368
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.87",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
12
|
+
"@bobfrankston/mailx-types": "^0.1.19",
|
|
13
13
|
"@bobfrankston/mailx-settings": "^0.1.26",
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
15
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
16
|
-
"@bobfrankston/tcp-transport": "^0.1.
|
|
17
|
-
"@bobfrankston/smtp-direct": "^0.1.
|
|
18
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
19
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
14
|
+
"@bobfrankston/mailx-store": "^0.1.46",
|
|
15
|
+
"@bobfrankston/iflow-direct": "^0.1.53",
|
|
16
|
+
"@bobfrankston/tcp-transport": "^0.1.7",
|
|
17
|
+
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
18
|
+
"@bobfrankston/mailx-sync": "^0.1.20",
|
|
19
|
+
"@bobfrankston/oauthsupport": "^1.0.32"
|
|
20
20
|
},
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
@@ -37,14 +37,14 @@
|
|
|
37
37
|
},
|
|
38
38
|
".transformedSnapshot": {
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
40
|
+
"@bobfrankston/mailx-types": "^0.1.19",
|
|
41
41
|
"@bobfrankston/mailx-settings": "^0.1.26",
|
|
42
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
43
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
44
|
-
"@bobfrankston/tcp-transport": "^0.1.
|
|
45
|
-
"@bobfrankston/smtp-direct": "^0.1.
|
|
46
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
47
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
42
|
+
"@bobfrankston/mailx-store": "^0.1.46",
|
|
43
|
+
"@bobfrankston/iflow-direct": "^0.1.53",
|
|
44
|
+
"@bobfrankston/tcp-transport": "^0.1.7",
|
|
45
|
+
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
46
|
+
"@bobfrankston/mailx-sync": "^0.1.20",
|
|
47
|
+
"@bobfrankston/oauthsupport": "^1.0.32"
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
}
|