@zaclinley/dsh-mailharbor 0.17.0 → 0.19.0
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/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +92 -11
- package/lib/client.js.map +2 -2
- package/lib/index.js +308 -13
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1114,6 +1114,16 @@ function ensureMailSchema(file) {
|
|
|
1114
1114
|
if (!columns.some((column) => column.name === "disposition")) {
|
|
1115
1115
|
db.exec("ALTER TABLE attachments ADD COLUMN disposition TEXT");
|
|
1116
1116
|
}
|
|
1117
|
+
const emailColumns = db.prepare("PRAGMA table_info(emails)").all();
|
|
1118
|
+
if (!emailColumns.some((column) => column.name === "in_reply_to")) {
|
|
1119
|
+
db.exec("ALTER TABLE emails ADD COLUMN in_reply_to TEXT");
|
|
1120
|
+
}
|
|
1121
|
+
if (!emailColumns.some((column) => column.name === "references")) {
|
|
1122
|
+
db.exec('ALTER TABLE emails ADD COLUMN "references" TEXT');
|
|
1123
|
+
}
|
|
1124
|
+
if (!emailColumns.some((column) => column.name === "refs_indexed")) {
|
|
1125
|
+
db.exec("ALTER TABLE emails ADD COLUMN refs_indexed INTEGER DEFAULT 0");
|
|
1126
|
+
}
|
|
1117
1127
|
} finally {
|
|
1118
1128
|
db.close();
|
|
1119
1129
|
}
|
|
@@ -1124,6 +1134,7 @@ var MailDatabase = class {
|
|
|
1124
1134
|
db;
|
|
1125
1135
|
ftsChecked = false;
|
|
1126
1136
|
ftsAvailable = false;
|
|
1137
|
+
threadRefs = /* @__PURE__ */ new Map();
|
|
1127
1138
|
constructor(file, options = {}) {
|
|
1128
1139
|
this.file = file;
|
|
1129
1140
|
this.options = options;
|
|
@@ -1150,6 +1161,14 @@ var MailDatabase = class {
|
|
|
1150
1161
|
return false;
|
|
1151
1162
|
}
|
|
1152
1163
|
}
|
|
1164
|
+
hasRefColumns() {
|
|
1165
|
+
try {
|
|
1166
|
+
const columns = this.open().prepare("PRAGMA table_info(emails)").all();
|
|
1167
|
+
return columns.some((column) => column.name === "in_reply_to");
|
|
1168
|
+
} catch {
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1153
1172
|
attachmentCountSql() {
|
|
1154
1173
|
if (!this.hasAttachmentColumns()) {
|
|
1155
1174
|
return "(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count";
|
|
@@ -1169,6 +1188,7 @@ var MailDatabase = class {
|
|
|
1169
1188
|
} catch {
|
|
1170
1189
|
}
|
|
1171
1190
|
this.db = void 0;
|
|
1191
|
+
this.threadRefs.clear();
|
|
1172
1192
|
}
|
|
1173
1193
|
status() {
|
|
1174
1194
|
try {
|
|
@@ -1360,13 +1380,174 @@ var MailDatabase = class {
|
|
|
1360
1380
|
(entry) => text2 === "" || entry.address.toLowerCase().includes(text2) || entry.name.toLowerCase().includes(text2)
|
|
1361
1381
|
).sort((left, right) => right.count - left.count).slice(0, limit);
|
|
1362
1382
|
}
|
|
1363
|
-
/**
|
|
1364
|
-
|
|
1383
|
+
/**
|
|
1384
|
+
* Conversation for one message.
|
|
1385
|
+
*
|
|
1386
|
+
* Subject grouping is only the candidate pool; inside it we read
|
|
1387
|
+
* In-Reply-To/References from the stored raw message and keep the connected
|
|
1388
|
+
* component of the seed. That stops unrelated mail with the same subject
|
|
1389
|
+
* from being merged while still working for old backups.
|
|
1390
|
+
*/
|
|
1391
|
+
thread(seed, limit = 200) {
|
|
1365
1392
|
const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
1366
|
-
const base = normalize2(subject);
|
|
1393
|
+
const base = normalize2(seed.subject);
|
|
1367
1394
|
if (base === "") return [];
|
|
1368
|
-
const
|
|
1369
|
-
|
|
1395
|
+
const seedIdForQuery = (seed.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
|
|
1396
|
+
const candidates = this.threadCandidates(base, limit, seedIdForQuery);
|
|
1397
|
+
if (candidates.length <= 1) return candidates;
|
|
1398
|
+
const refs = this.referencesFor(candidates.slice(0, 120).map((item) => Number(item.id)));
|
|
1399
|
+
const byMessageId = /* @__PURE__ */ new Map();
|
|
1400
|
+
for (const item of candidates) {
|
|
1401
|
+
const id = item.messageId === void 0 ? "" : item.messageId.replace(/^<|>$/g, "").toLowerCase();
|
|
1402
|
+
if (id !== "") byMessageId.set(id, Number(item.id));
|
|
1403
|
+
}
|
|
1404
|
+
const seedId = (seed.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
|
|
1405
|
+
const startId = byMessageId.has(seedId) ? seedId : (candidates[0]?.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
|
|
1406
|
+
if (startId === "" || !byMessageId.has(startId)) return candidates;
|
|
1407
|
+
const neighbours = /* @__PURE__ */ new Map();
|
|
1408
|
+
const link = (left, right) => {
|
|
1409
|
+
if (left === right) return;
|
|
1410
|
+
if (!neighbours.has(left)) neighbours.set(left, /* @__PURE__ */ new Set());
|
|
1411
|
+
if (!neighbours.has(right)) neighbours.set(right, /* @__PURE__ */ new Set());
|
|
1412
|
+
neighbours.get(left)?.add(right);
|
|
1413
|
+
neighbours.get(right)?.add(left);
|
|
1414
|
+
};
|
|
1415
|
+
let edges = 0;
|
|
1416
|
+
for (const item of candidates) {
|
|
1417
|
+
const local = refs.get(Number(item.id));
|
|
1418
|
+
if (local === void 0 || local.refs.length === 0) continue;
|
|
1419
|
+
for (const ref of local.refs) {
|
|
1420
|
+
const target = byMessageId.get(ref);
|
|
1421
|
+
if (target !== void 0) {
|
|
1422
|
+
link(Number(item.id), target);
|
|
1423
|
+
edges += 1;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
if (process.env.MAILHARBOR_DEBUG_THREAD === "1") {
|
|
1428
|
+
console.error("THREAD", {
|
|
1429
|
+
base,
|
|
1430
|
+
candidates: candidates.length,
|
|
1431
|
+
edges,
|
|
1432
|
+
refs: [...refs.entries()].slice(0, 3)
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
if (edges === 0) return candidates;
|
|
1436
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1437
|
+
const queue = [byMessageId.get(startId)];
|
|
1438
|
+
while (queue.length > 0) {
|
|
1439
|
+
const id = queue.shift();
|
|
1440
|
+
if (seen.has(id)) continue;
|
|
1441
|
+
seen.add(id);
|
|
1442
|
+
for (const next of neighbours.get(id) ?? []) {
|
|
1443
|
+
if (!seen.has(next)) queue.push(next);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (seen.size <= 1) return candidates;
|
|
1447
|
+
return candidates.filter((item) => seen.has(Number(item.id)));
|
|
1448
|
+
}
|
|
1449
|
+
/** Subject candidates via a distinctive token, not FTS (punctuation-safe). */
|
|
1450
|
+
threadCandidates(base, limit, seedId = "") {
|
|
1451
|
+
const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
1452
|
+
const words = base.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3);
|
|
1453
|
+
const token = words.sort((left, right) => right.length - left.length)[0] ?? base.slice(0, 8);
|
|
1454
|
+
const pattern = `%${token.replace(/[%_]/g, "\\$&")}%`;
|
|
1455
|
+
try {
|
|
1456
|
+
const rows = this.open().prepare(
|
|
1457
|
+
`SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
1458
|
+
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
1459
|
+
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1460
|
+
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1461
|
+
FROM emails e WHERE lower(e.subject) LIKE ? ESCAPE '\\'
|
|
1462
|
+
ORDER BY e.date DESC LIMIT ?`
|
|
1463
|
+
).all(pattern, 500);
|
|
1464
|
+
const items = rows.map((row) => rowToSummary(row)).filter((item) => normalize2(item.subject) === base);
|
|
1465
|
+
if (seedId !== "" && this.hasRefColumns()) {
|
|
1466
|
+
const linked = this.open().prepare(
|
|
1467
|
+
`SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
1468
|
+
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
1469
|
+
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1470
|
+
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1471
|
+
FROM emails e
|
|
1472
|
+
WHERE lower(e.in_reply_to) = ? OR lower(e."references") LIKE ? ESCAPE '\\'
|
|
1473
|
+
ORDER BY e.date DESC LIMIT 100`
|
|
1474
|
+
).all(seedId, `%${seedId.replace(/[%_]/g, "\\$&")}%`);
|
|
1475
|
+
const known = new Set(items.map((item) => item.id));
|
|
1476
|
+
try {
|
|
1477
|
+
const seedRow = this.open().prepare(
|
|
1478
|
+
'SELECT in_reply_to, "references", raw_eml FROM emails WHERE lower(message_id) = ? LIMIT 1'
|
|
1479
|
+
).get(seedId);
|
|
1480
|
+
const parents = `${seedRow?.references ?? ""} ${seedRow?.in_reply_to ?? ""}`.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
1481
|
+
if (parents.length === 0 && seedRow?.raw_eml !== void 0 && seedRow.raw_eml !== null) {
|
|
1482
|
+
for (const header of ["references", "in-reply-to"]) {
|
|
1483
|
+
for (const found of headerIds(seedRow.raw_eml, header)) parents.push(found);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (parents.length > 0) {
|
|
1487
|
+
const marks = parents.map(() => "?").join(",");
|
|
1488
|
+
const ancestors = this.open().prepare(
|
|
1489
|
+
`SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
1490
|
+
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
1491
|
+
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1492
|
+
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1493
|
+
FROM emails e WHERE lower(e.message_id) IN (${marks}) LIMIT 100`
|
|
1494
|
+
).all(...parents);
|
|
1495
|
+
for (const row of ancestors) {
|
|
1496
|
+
const item = rowToSummary(row);
|
|
1497
|
+
if (!known.has(item.id)) {
|
|
1498
|
+
items.push(item);
|
|
1499
|
+
known.add(item.id);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
} catch {
|
|
1504
|
+
}
|
|
1505
|
+
for (const row of linked) {
|
|
1506
|
+
const item = rowToSummary(row);
|
|
1507
|
+
if (!known.has(item.id)) {
|
|
1508
|
+
items.push(item);
|
|
1509
|
+
known.add(item.id);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
return items.slice(0, limit);
|
|
1514
|
+
} catch {
|
|
1515
|
+
return [];
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
/** Read (and cache) In-Reply-To / References for a set of local ids. */
|
|
1519
|
+
referencesFor(ids) {
|
|
1520
|
+
const missing = ids.filter((id) => !this.threadRefs.has(id));
|
|
1521
|
+
if (missing.length > 0) {
|
|
1522
|
+
const placeholders = missing.map(() => "?").join(",");
|
|
1523
|
+
try {
|
|
1524
|
+
const withRefs = this.hasRefColumns();
|
|
1525
|
+
const columns = withRefs ? 'id, message_id, raw_eml, in_reply_to, "references"' : "id, message_id, raw_eml";
|
|
1526
|
+
const rows = this.open().prepare(`SELECT ${columns} FROM emails WHERE id IN (${placeholders})`).all(...missing);
|
|
1527
|
+
for (const row of rows) {
|
|
1528
|
+
const id = Number(row.id);
|
|
1529
|
+
const raw = row.raw_eml;
|
|
1530
|
+
const refs = [];
|
|
1531
|
+
for (const stored of [row.references ?? "", row.in_reply_to ?? ""]) {
|
|
1532
|
+
for (const found of String(stored).split(/\s+/)) {
|
|
1533
|
+
const value = found.replace(/^<|>$/g, "").toLowerCase();
|
|
1534
|
+
if (value.includes("@")) refs.push(value);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
if (refs.length === 0 && raw !== void 0 && raw !== null) {
|
|
1538
|
+
for (const header of ["references", "in-reply-to"]) {
|
|
1539
|
+
for (const found of headerIds(raw, header)) refs.push(found);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
this.threadRefs.set(id, {
|
|
1543
|
+
messageId: String(row.message_id ?? "").replace(/^<|>$/g, "").toLowerCase(),
|
|
1544
|
+
refs: [...new Set(refs)]
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
} catch {
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return this.threadRefs;
|
|
1370
1551
|
}
|
|
1371
1552
|
/** Number of unread keys that still exist in this backup. */
|
|
1372
1553
|
countUnread(keys) {
|
|
@@ -1550,6 +1731,15 @@ function truncate(value, max) {
|
|
|
1550
1731
|
function quoteMatch(text2) {
|
|
1551
1732
|
return `"${text2.replaceAll('"', '""')}"`;
|
|
1552
1733
|
}
|
|
1734
|
+
function headerIds(raw, header) {
|
|
1735
|
+
const text2 = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 2e5))).toString("utf8");
|
|
1736
|
+
const separator = text2.search(/\r?\n\r?\n/);
|
|
1737
|
+
const head = separator >= 0 ? text2.slice(0, separator) : text2;
|
|
1738
|
+
const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
|
|
1739
|
+
const match = new RegExp(`^${header}:\\s*(.*)$`, "im").exec(unfolded);
|
|
1740
|
+
if (match === null) return [];
|
|
1741
|
+
return String(match[1] ?? "").split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
1742
|
+
}
|
|
1553
1743
|
function rowToSummary(row) {
|
|
1554
1744
|
const fromName = String(row.from_name ?? "");
|
|
1555
1745
|
const fromAddr = String(row.from_addr ?? "");
|
|
@@ -1679,8 +1869,8 @@ var BackupEngine = class {
|
|
|
1679
1869
|
const inserted = db.prepare(
|
|
1680
1870
|
`INSERT OR IGNORE INTO emails
|
|
1681
1871
|
(mailbox, uid, subject, from_name, from_addr, to_addr, cc, date, message_id,
|
|
1682
|
-
body_text, body_html, raw_eml, hash, backup_log_id)
|
|
1683
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1872
|
+
in_reply_to, "references", body_text, body_html, raw_eml, hash, backup_log_id)
|
|
1873
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1684
1874
|
).run(
|
|
1685
1875
|
mailbox,
|
|
1686
1876
|
Number(message.uid),
|
|
@@ -1691,6 +1881,8 @@ var BackupEngine = class {
|
|
|
1691
1881
|
ccText,
|
|
1692
1882
|
stamp(parsed.date ?? message.internalDate ?? message.envelope?.date),
|
|
1693
1883
|
messageId === "" ? null : messageId,
|
|
1884
|
+
normalizeId(parsed.inReplyTo ?? "") || null,
|
|
1885
|
+
String(parsed.references ?? "").split(/\s+/).map(normalizeId).filter((item) => item !== "").join(" ") || null,
|
|
1694
1886
|
text2,
|
|
1695
1887
|
html,
|
|
1696
1888
|
source,
|
|
@@ -2780,7 +2972,7 @@ ${conversation.trim()}
|
|
|
2780
2972
|
}
|
|
2781
2973
|
|
|
2782
2974
|
// src/host/version.ts
|
|
2783
|
-
var VERSION = "0.
|
|
2975
|
+
var VERSION = "0.19.0";
|
|
2784
2976
|
|
|
2785
2977
|
// src/host/api.ts
|
|
2786
2978
|
init_smtp();
|
|
@@ -3002,6 +3194,15 @@ function buildRouter(runtime) {
|
|
|
3002
3194
|
}
|
|
3003
3195
|
return { ok: true, count };
|
|
3004
3196
|
});
|
|
3197
|
+
router.get("/maintenance/references", ({ url }) => {
|
|
3198
|
+
const { account } = accountFrom(runtime, url);
|
|
3199
|
+
return runtime.referencesStatus(account);
|
|
3200
|
+
});
|
|
3201
|
+
router.post("/maintenance/references", ({ url }) => {
|
|
3202
|
+
const { account } = accountFrom(runtime, url);
|
|
3203
|
+
runtime.startReferencesBackfill(account);
|
|
3204
|
+
return runtime.referencesStatus(account);
|
|
3205
|
+
});
|
|
3005
3206
|
router.get("/transfer/export", () => {
|
|
3006
3207
|
const config = runtime.current;
|
|
3007
3208
|
return {
|
|
@@ -3074,10 +3275,11 @@ function buildRouter(runtime) {
|
|
|
3074
3275
|
runtime.refresh();
|
|
3075
3276
|
return { ok: true, addedAccounts: added, state: appState(runtime) };
|
|
3076
3277
|
});
|
|
3077
|
-
router.get("/messages/thread", ({ url }) => {
|
|
3278
|
+
router.get("/messages/thread", async ({ url }) => {
|
|
3078
3279
|
const { account } = accountFrom(runtime, url);
|
|
3079
3280
|
const subject = url.searchParams.get("subject") ?? "";
|
|
3080
|
-
const
|
|
3281
|
+
const messageId = url.searchParams.get("messageId") ?? "";
|
|
3282
|
+
const items = await runtime.database(account).thread({ subject, messageId });
|
|
3081
3283
|
return { items: items.map((item) => localSummary(runtime, account, item)) };
|
|
3082
3284
|
});
|
|
3083
3285
|
router.get("/contacts", ({ url }) => {
|
|
@@ -4114,7 +4316,7 @@ ${signature}`;
|
|
|
4114
4316
|
}
|
|
4115
4317
|
|
|
4116
4318
|
// src/host/runtime.ts
|
|
4117
|
-
import { statSync } from "node:fs";
|
|
4319
|
+
import { existsSync as existsSync8, statSync } from "node:fs";
|
|
4118
4320
|
import { join as join7 } from "node:path";
|
|
4119
4321
|
|
|
4120
4322
|
// src/host/secrets.ts
|
|
@@ -4496,11 +4698,63 @@ var MailFlagsStore = class {
|
|
|
4496
4698
|
}
|
|
4497
4699
|
};
|
|
4498
4700
|
|
|
4701
|
+
// src/host/references.ts
|
|
4702
|
+
import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
|
|
4703
|
+
function extractReferences(raw, messageId) {
|
|
4704
|
+
const empty = { inReplyTo: null, references: null };
|
|
4705
|
+
if (raw === null || raw === void 0) return empty;
|
|
4706
|
+
const text2 = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 2e5))).toString("utf8");
|
|
4707
|
+
const separator = text2.search(/\r?\n\r?\n/);
|
|
4708
|
+
const head = separator >= 0 ? text2.slice(0, separator) : text2;
|
|
4709
|
+
const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
|
|
4710
|
+
const value = (name2) => {
|
|
4711
|
+
const match = new RegExp(`^${name2}:\\s*(.*)$`, "im").exec(unfolded);
|
|
4712
|
+
return match === null ? "" : String(match[1] ?? "");
|
|
4713
|
+
};
|
|
4714
|
+
const ids = (value2) => value2.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
4715
|
+
const referenceIds = ids(value("references"));
|
|
4716
|
+
const replyTo = ids(value("in-reply-to"));
|
|
4717
|
+
const filteredRefs = referenceIds.filter((id) => id !== messageId.toLowerCase());
|
|
4718
|
+
return {
|
|
4719
|
+
inReplyTo: replyTo[0] ?? null,
|
|
4720
|
+
references: filteredRefs.length > 0 ? filteredRefs.join(" ") : null
|
|
4721
|
+
};
|
|
4722
|
+
}
|
|
4723
|
+
function backfillReferences(file, batchSize = 300) {
|
|
4724
|
+
ensureMailSchema(file);
|
|
4725
|
+
const db = new DatabaseSync4(file);
|
|
4726
|
+
try {
|
|
4727
|
+
db.exec("PRAGMA busy_timeout = 10000");
|
|
4728
|
+
const rows = db.prepare("SELECT id, message_id, raw_eml FROM emails WHERE refs_indexed = 0 ORDER BY id LIMIT ?").all(batchSize);
|
|
4729
|
+
if (rows.length === 0) return { updated: 0, remaining: 0 };
|
|
4730
|
+
const update = db.prepare(
|
|
4731
|
+
'UPDATE emails SET in_reply_to = ?, "references" = ?, refs_indexed = 1 WHERE id = ?'
|
|
4732
|
+
);
|
|
4733
|
+
db.exec("BEGIN");
|
|
4734
|
+
try {
|
|
4735
|
+
for (const row of rows) {
|
|
4736
|
+
const headers = extractReferences(row.raw_eml, String(row.message_id ?? ""));
|
|
4737
|
+
update.run(headers.inReplyTo, headers.references, Number(row.id));
|
|
4738
|
+
}
|
|
4739
|
+
db.exec("COMMIT");
|
|
4740
|
+
} catch (error) {
|
|
4741
|
+
db.exec("ROLLBACK");
|
|
4742
|
+
throw error;
|
|
4743
|
+
}
|
|
4744
|
+
const remaining = Number(
|
|
4745
|
+
db.prepare("SELECT COUNT(*) AS count FROM emails WHERE refs_indexed = 0").get()?.count ?? 0
|
|
4746
|
+
);
|
|
4747
|
+
return { updated: rows.length, remaining };
|
|
4748
|
+
} finally {
|
|
4749
|
+
db.close();
|
|
4750
|
+
}
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4499
4753
|
// src/host/tasks.ts
|
|
4500
4754
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
4501
4755
|
import { mkdirSync as mkdirSync7 } from "node:fs";
|
|
4502
4756
|
import { dirname as dirname4 } from "node:path";
|
|
4503
|
-
import { DatabaseSync as
|
|
4757
|
+
import { DatabaseSync as DatabaseSync5 } from "node:sqlite";
|
|
4504
4758
|
var STATUSES = /* @__PURE__ */ new Set(["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"]);
|
|
4505
4759
|
function now() {
|
|
4506
4760
|
return (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -4549,7 +4803,7 @@ var TaskStore = class {
|
|
|
4549
4803
|
open() {
|
|
4550
4804
|
if (this.db !== null) return this.db;
|
|
4551
4805
|
mkdirSync7(dirname4(this.file), { recursive: true });
|
|
4552
|
-
const db = new
|
|
4806
|
+
const db = new DatabaseSync5(this.file);
|
|
4553
4807
|
db.exec("PRAGMA journal_mode=WAL");
|
|
4554
4808
|
db.exec("PRAGMA busy_timeout=10000");
|
|
4555
4809
|
db.exec(`
|
|
@@ -4975,6 +5229,7 @@ var MailHarborRuntime = class {
|
|
|
4975
5229
|
projectStore = null;
|
|
4976
5230
|
taskStore = null;
|
|
4977
5231
|
flagStores = /* @__PURE__ */ new Map();
|
|
5232
|
+
referenceJobs = /* @__PURE__ */ new Map();
|
|
4978
5233
|
disposed = false;
|
|
4979
5234
|
constructor(options = {}) {
|
|
4980
5235
|
this.config = loadConfig();
|
|
@@ -5074,6 +5329,43 @@ var MailHarborRuntime = class {
|
|
|
5074
5329
|
paths(account) {
|
|
5075
5330
|
return accountRuntime(account);
|
|
5076
5331
|
}
|
|
5332
|
+
referencesStatus(account) {
|
|
5333
|
+
return this.referenceJobs.get(account.id) ?? {
|
|
5334
|
+
running: false,
|
|
5335
|
+
indexed: 0,
|
|
5336
|
+
remaining: null,
|
|
5337
|
+
error: null
|
|
5338
|
+
};
|
|
5339
|
+
}
|
|
5340
|
+
/** Backfill In-Reply-To/References for archived mail, one batch at a time. */
|
|
5341
|
+
startReferencesBackfill(account) {
|
|
5342
|
+
const existing = this.referenceJobs.get(account.id);
|
|
5343
|
+
if (existing?.running === true) return;
|
|
5344
|
+
const dbFile = this.paths(account).mailDb;
|
|
5345
|
+
if (!existsSync8(dbFile)) return;
|
|
5346
|
+
const status = {
|
|
5347
|
+
running: true,
|
|
5348
|
+
indexed: 0,
|
|
5349
|
+
remaining: null,
|
|
5350
|
+
error: null
|
|
5351
|
+
};
|
|
5352
|
+
this.referenceJobs.set(account.id, status);
|
|
5353
|
+
void (async () => {
|
|
5354
|
+
try {
|
|
5355
|
+
for (; ; ) {
|
|
5356
|
+
const result = backfillReferences(dbFile, 300);
|
|
5357
|
+
status.indexed += result.updated;
|
|
5358
|
+
status.remaining = result.remaining;
|
|
5359
|
+
if (result.remaining <= 0 || result.updated === 0) break;
|
|
5360
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
5361
|
+
}
|
|
5362
|
+
} catch (error) {
|
|
5363
|
+
status.error = error instanceof Error ? error.message : String(error);
|
|
5364
|
+
} finally {
|
|
5365
|
+
status.running = false;
|
|
5366
|
+
}
|
|
5367
|
+
})();
|
|
5368
|
+
}
|
|
5077
5369
|
/** Credentials for an account, with a clear message when the user must log in. */
|
|
5078
5370
|
credentialsFor(account) {
|
|
5079
5371
|
const resolved = this.credentials.resolve(account);
|
|
@@ -5643,6 +5935,9 @@ function apply(ctx) {
|
|
|
5643
5935
|
}
|
|
5644
5936
|
const handler = createApiHandler(runtime);
|
|
5645
5937
|
const disposers = [];
|
|
5938
|
+
if (activeAccount2 !== void 0) {
|
|
5939
|
+
setTimeout(() => runtime.startReferencesBackfill(activeAccount2), 3e3);
|
|
5940
|
+
}
|
|
5646
5941
|
const start = () => {
|
|
5647
5942
|
disposers.push(ctx.webServer.register({ kind: "prefix", path: ROUTE, handler }));
|
|
5648
5943
|
if (ctx.tools !== void 0) disposers.push(...registerMailTools(ctx.tools, runtime));
|