@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/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
- /** All archived messages in the same conversation (normalized subject). */
1364
- thread(subject, limit = 200) {
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 page = this.list({ mailbox: "all", query: base, limit: Math.min(500, limit * 2) });
1369
- return page.items.filter((item) => normalize2(item.subject) === base).slice(0, limit);
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.17.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 items = runtime.database(account).thread(subject);
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 }) => {