@zaclinley/dsh-mailharbor 0.17.0 → 0.18.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 +16 -0
- package/lib/client.js +15 -7
- package/lib/client.js.map +2 -2
- package/lib/index.js +200 -10
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1114,6 +1114,13 @@ 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
|
+
}
|
|
1117
1124
|
} finally {
|
|
1118
1125
|
db.close();
|
|
1119
1126
|
}
|
|
@@ -1124,6 +1131,7 @@ var MailDatabase = class {
|
|
|
1124
1131
|
db;
|
|
1125
1132
|
ftsChecked = false;
|
|
1126
1133
|
ftsAvailable = false;
|
|
1134
|
+
threadRefs = /* @__PURE__ */ new Map();
|
|
1127
1135
|
constructor(file, options = {}) {
|
|
1128
1136
|
this.file = file;
|
|
1129
1137
|
this.options = options;
|
|
@@ -1150,6 +1158,14 @@ var MailDatabase = class {
|
|
|
1150
1158
|
return false;
|
|
1151
1159
|
}
|
|
1152
1160
|
}
|
|
1161
|
+
hasRefColumns() {
|
|
1162
|
+
try {
|
|
1163
|
+
const columns = this.open().prepare("PRAGMA table_info(emails)").all();
|
|
1164
|
+
return columns.some((column) => column.name === "in_reply_to");
|
|
1165
|
+
} catch {
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1153
1169
|
attachmentCountSql() {
|
|
1154
1170
|
if (!this.hasAttachmentColumns()) {
|
|
1155
1171
|
return "(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count";
|
|
@@ -1169,6 +1185,7 @@ var MailDatabase = class {
|
|
|
1169
1185
|
} catch {
|
|
1170
1186
|
}
|
|
1171
1187
|
this.db = void 0;
|
|
1188
|
+
this.threadRefs.clear();
|
|
1172
1189
|
}
|
|
1173
1190
|
status() {
|
|
1174
1191
|
try {
|
|
@@ -1360,13 +1377,174 @@ var MailDatabase = class {
|
|
|
1360
1377
|
(entry) => text2 === "" || entry.address.toLowerCase().includes(text2) || entry.name.toLowerCase().includes(text2)
|
|
1361
1378
|
).sort((left, right) => right.count - left.count).slice(0, limit);
|
|
1362
1379
|
}
|
|
1363
|
-
/**
|
|
1364
|
-
|
|
1380
|
+
/**
|
|
1381
|
+
* Conversation for one message.
|
|
1382
|
+
*
|
|
1383
|
+
* Subject grouping is only the candidate pool; inside it we read
|
|
1384
|
+
* In-Reply-To/References from the stored raw message and keep the connected
|
|
1385
|
+
* component of the seed. That stops unrelated mail with the same subject
|
|
1386
|
+
* from being merged while still working for old backups.
|
|
1387
|
+
*/
|
|
1388
|
+
thread(seed, limit = 200) {
|
|
1365
1389
|
const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
1366
|
-
const base = normalize2(subject);
|
|
1390
|
+
const base = normalize2(seed.subject);
|
|
1367
1391
|
if (base === "") return [];
|
|
1368
|
-
const
|
|
1369
|
-
|
|
1392
|
+
const seedIdForQuery = (seed.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
|
|
1393
|
+
const candidates = this.threadCandidates(base, limit, seedIdForQuery);
|
|
1394
|
+
if (candidates.length <= 1) return candidates;
|
|
1395
|
+
const refs = this.referencesFor(candidates.slice(0, 120).map((item) => Number(item.id)));
|
|
1396
|
+
const byMessageId = /* @__PURE__ */ new Map();
|
|
1397
|
+
for (const item of candidates) {
|
|
1398
|
+
const id = item.messageId === void 0 ? "" : item.messageId.replace(/^<|>$/g, "").toLowerCase();
|
|
1399
|
+
if (id !== "") byMessageId.set(id, Number(item.id));
|
|
1400
|
+
}
|
|
1401
|
+
const seedId = (seed.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
|
|
1402
|
+
const startId = byMessageId.has(seedId) ? seedId : (candidates[0]?.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
|
|
1403
|
+
if (startId === "" || !byMessageId.has(startId)) return candidates;
|
|
1404
|
+
const neighbours = /* @__PURE__ */ new Map();
|
|
1405
|
+
const link = (left, right) => {
|
|
1406
|
+
if (left === right) return;
|
|
1407
|
+
if (!neighbours.has(left)) neighbours.set(left, /* @__PURE__ */ new Set());
|
|
1408
|
+
if (!neighbours.has(right)) neighbours.set(right, /* @__PURE__ */ new Set());
|
|
1409
|
+
neighbours.get(left)?.add(right);
|
|
1410
|
+
neighbours.get(right)?.add(left);
|
|
1411
|
+
};
|
|
1412
|
+
let edges = 0;
|
|
1413
|
+
for (const item of candidates) {
|
|
1414
|
+
const local = refs.get(Number(item.id));
|
|
1415
|
+
if (local === void 0 || local.refs.length === 0) continue;
|
|
1416
|
+
for (const ref of local.refs) {
|
|
1417
|
+
const target = byMessageId.get(ref);
|
|
1418
|
+
if (target !== void 0) {
|
|
1419
|
+
link(Number(item.id), target);
|
|
1420
|
+
edges += 1;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
if (process.env.MAILHARBOR_DEBUG_THREAD === "1") {
|
|
1425
|
+
console.error("THREAD", {
|
|
1426
|
+
base,
|
|
1427
|
+
candidates: candidates.length,
|
|
1428
|
+
edges,
|
|
1429
|
+
refs: [...refs.entries()].slice(0, 3)
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
if (edges === 0) return candidates;
|
|
1433
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1434
|
+
const queue = [byMessageId.get(startId)];
|
|
1435
|
+
while (queue.length > 0) {
|
|
1436
|
+
const id = queue.shift();
|
|
1437
|
+
if (seen.has(id)) continue;
|
|
1438
|
+
seen.add(id);
|
|
1439
|
+
for (const next of neighbours.get(id) ?? []) {
|
|
1440
|
+
if (!seen.has(next)) queue.push(next);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (seen.size <= 1) return candidates;
|
|
1444
|
+
return candidates.filter((item) => seen.has(Number(item.id)));
|
|
1445
|
+
}
|
|
1446
|
+
/** Subject candidates via a distinctive token, not FTS (punctuation-safe). */
|
|
1447
|
+
threadCandidates(base, limit, seedId = "") {
|
|
1448
|
+
const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
1449
|
+
const words = base.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3);
|
|
1450
|
+
const token = words.sort((left, right) => right.length - left.length)[0] ?? base.slice(0, 8);
|
|
1451
|
+
const pattern = `%${token.replace(/[%_]/g, "\\$&")}%`;
|
|
1452
|
+
try {
|
|
1453
|
+
const rows = this.open().prepare(
|
|
1454
|
+
`SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
1455
|
+
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
1456
|
+
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1457
|
+
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1458
|
+
FROM emails e WHERE lower(e.subject) LIKE ? ESCAPE '\\'
|
|
1459
|
+
ORDER BY e.date DESC LIMIT ?`
|
|
1460
|
+
).all(pattern, 500);
|
|
1461
|
+
const items = rows.map((row) => rowToSummary(row)).filter((item) => normalize2(item.subject) === base);
|
|
1462
|
+
if (seedId !== "" && this.hasRefColumns()) {
|
|
1463
|
+
const linked = this.open().prepare(
|
|
1464
|
+
`SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
1465
|
+
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
1466
|
+
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1467
|
+
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1468
|
+
FROM emails e
|
|
1469
|
+
WHERE lower(e.in_reply_to) = ? OR lower(e."references") LIKE ? ESCAPE '\\'
|
|
1470
|
+
ORDER BY e.date DESC LIMIT 100`
|
|
1471
|
+
).all(seedId, `%${seedId.replace(/[%_]/g, "\\$&")}%`);
|
|
1472
|
+
const known = new Set(items.map((item) => item.id));
|
|
1473
|
+
try {
|
|
1474
|
+
const seedRow = this.open().prepare(
|
|
1475
|
+
'SELECT in_reply_to, "references", raw_eml FROM emails WHERE lower(message_id) = ? LIMIT 1'
|
|
1476
|
+
).get(seedId);
|
|
1477
|
+
const parents = `${seedRow?.references ?? ""} ${seedRow?.in_reply_to ?? ""}`.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
1478
|
+
if (parents.length === 0 && seedRow?.raw_eml !== void 0 && seedRow.raw_eml !== null) {
|
|
1479
|
+
for (const header of ["references", "in-reply-to"]) {
|
|
1480
|
+
for (const found of headerIds(seedRow.raw_eml, header)) parents.push(found);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (parents.length > 0) {
|
|
1484
|
+
const marks = parents.map(() => "?").join(",");
|
|
1485
|
+
const ancestors = this.open().prepare(
|
|
1486
|
+
`SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
1487
|
+
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
1488
|
+
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1489
|
+
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1490
|
+
FROM emails e WHERE lower(e.message_id) IN (${marks}) LIMIT 100`
|
|
1491
|
+
).all(...parents);
|
|
1492
|
+
for (const row of ancestors) {
|
|
1493
|
+
const item = rowToSummary(row);
|
|
1494
|
+
if (!known.has(item.id)) {
|
|
1495
|
+
items.push(item);
|
|
1496
|
+
known.add(item.id);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
} catch {
|
|
1501
|
+
}
|
|
1502
|
+
for (const row of linked) {
|
|
1503
|
+
const item = rowToSummary(row);
|
|
1504
|
+
if (!known.has(item.id)) {
|
|
1505
|
+
items.push(item);
|
|
1506
|
+
known.add(item.id);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
return items.slice(0, limit);
|
|
1511
|
+
} catch {
|
|
1512
|
+
return [];
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
/** Read (and cache) In-Reply-To / References for a set of local ids. */
|
|
1516
|
+
referencesFor(ids) {
|
|
1517
|
+
const missing = ids.filter((id) => !this.threadRefs.has(id));
|
|
1518
|
+
if (missing.length > 0) {
|
|
1519
|
+
const placeholders = missing.map(() => "?").join(",");
|
|
1520
|
+
try {
|
|
1521
|
+
const withRefs = this.hasRefColumns();
|
|
1522
|
+
const columns = withRefs ? 'id, message_id, raw_eml, in_reply_to, "references"' : "id, message_id, raw_eml";
|
|
1523
|
+
const rows = this.open().prepare(`SELECT ${columns} FROM emails WHERE id IN (${placeholders})`).all(...missing);
|
|
1524
|
+
for (const row of rows) {
|
|
1525
|
+
const id = Number(row.id);
|
|
1526
|
+
const raw = row.raw_eml;
|
|
1527
|
+
const refs = [];
|
|
1528
|
+
for (const stored of [row.references ?? "", row.in_reply_to ?? ""]) {
|
|
1529
|
+
for (const found of String(stored).split(/\s+/)) {
|
|
1530
|
+
const value = found.replace(/^<|>$/g, "").toLowerCase();
|
|
1531
|
+
if (value.includes("@")) refs.push(value);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (refs.length === 0 && raw !== void 0 && raw !== null) {
|
|
1535
|
+
for (const header of ["references", "in-reply-to"]) {
|
|
1536
|
+
for (const found of headerIds(raw, header)) refs.push(found);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
this.threadRefs.set(id, {
|
|
1540
|
+
messageId: String(row.message_id ?? "").replace(/^<|>$/g, "").toLowerCase(),
|
|
1541
|
+
refs: [...new Set(refs)]
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
} catch {
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
return this.threadRefs;
|
|
1370
1548
|
}
|
|
1371
1549
|
/** Number of unread keys that still exist in this backup. */
|
|
1372
1550
|
countUnread(keys) {
|
|
@@ -1550,6 +1728,15 @@ function truncate(value, max) {
|
|
|
1550
1728
|
function quoteMatch(text2) {
|
|
1551
1729
|
return `"${text2.replaceAll('"', '""')}"`;
|
|
1552
1730
|
}
|
|
1731
|
+
function headerIds(raw, header) {
|
|
1732
|
+
const text2 = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 2e5))).toString("utf8");
|
|
1733
|
+
const separator = text2.search(/\r?\n\r?\n/);
|
|
1734
|
+
const head = separator >= 0 ? text2.slice(0, separator) : text2;
|
|
1735
|
+
const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
|
|
1736
|
+
const match = new RegExp(`^${header}:\\s*(.*)$`, "im").exec(unfolded);
|
|
1737
|
+
if (match === null) return [];
|
|
1738
|
+
return String(match[1] ?? "").split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
1739
|
+
}
|
|
1553
1740
|
function rowToSummary(row) {
|
|
1554
1741
|
const fromName = String(row.from_name ?? "");
|
|
1555
1742
|
const fromAddr = String(row.from_addr ?? "");
|
|
@@ -1679,8 +1866,8 @@ var BackupEngine = class {
|
|
|
1679
1866
|
const inserted = db.prepare(
|
|
1680
1867
|
`INSERT OR IGNORE INTO emails
|
|
1681
1868
|
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1869
|
+
in_reply_to, "references", body_text, body_html, raw_eml, hash, backup_log_id)
|
|
1870
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1684
1871
|
).run(
|
|
1685
1872
|
mailbox,
|
|
1686
1873
|
Number(message.uid),
|
|
@@ -1691,6 +1878,8 @@ var BackupEngine = class {
|
|
|
1691
1878
|
ccText,
|
|
1692
1879
|
stamp(parsed.date ?? message.internalDate ?? message.envelope?.date),
|
|
1693
1880
|
messageId === "" ? null : messageId,
|
|
1881
|
+
normalizeId(parsed.inReplyTo ?? "") || null,
|
|
1882
|
+
String(parsed.references ?? "").split(/\s+/).map(normalizeId).filter((item) => item !== "").join(" ") || null,
|
|
1694
1883
|
text2,
|
|
1695
1884
|
html,
|
|
1696
1885
|
source,
|
|
@@ -2780,7 +2969,7 @@ ${conversation.trim()}
|
|
|
2780
2969
|
}
|
|
2781
2970
|
|
|
2782
2971
|
// src/host/version.ts
|
|
2783
|
-
var VERSION = "0.
|
|
2972
|
+
var VERSION = "0.18.0";
|
|
2784
2973
|
|
|
2785
2974
|
// src/host/api.ts
|
|
2786
2975
|
init_smtp();
|
|
@@ -3074,10 +3263,11 @@ function buildRouter(runtime) {
|
|
|
3074
3263
|
runtime.refresh();
|
|
3075
3264
|
return { ok: true, addedAccounts: added, state: appState(runtime) };
|
|
3076
3265
|
});
|
|
3077
|
-
router.get("/messages/thread", ({ url }) => {
|
|
3266
|
+
router.get("/messages/thread", async ({ url }) => {
|
|
3078
3267
|
const { account } = accountFrom(runtime, url);
|
|
3079
3268
|
const subject = url.searchParams.get("subject") ?? "";
|
|
3080
|
-
const
|
|
3269
|
+
const messageId = url.searchParams.get("messageId") ?? "";
|
|
3270
|
+
const items = await runtime.database(account).thread({ subject, messageId });
|
|
3081
3271
|
return { items: items.map((item) => localSummary(runtime, account, item)) };
|
|
3082
3272
|
});
|
|
3083
3273
|
router.get("/contacts", ({ url }) => {
|