@zaclinley/dsh-mailharbor 0.16.1 → 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 {
@@ -1209,6 +1226,18 @@ var MailDatabase = class {
1209
1226
  where.push("e.date >= ?");
1210
1227
  params.push(query.since);
1211
1228
  }
1229
+ if (typeof query.before === "string" && query.before !== "") {
1230
+ where.push("e.date < ?");
1231
+ params.push(query.before.length === 10 ? `${query.before} 23:59:59` : query.before);
1232
+ }
1233
+ if (typeof query.from === "string" && query.from.trim() !== "") {
1234
+ const needle = `%${query.from.trim().toLowerCase()}%`;
1235
+ where.push("(lower(e.from_addr) LIKE ? OR lower(e.from_name) LIKE ?)");
1236
+ params.push(needle, needle);
1237
+ }
1238
+ if (query.attachmentsOnly === true) {
1239
+ where.push("EXISTS (SELECT 1 FROM attachments a WHERE a.email_id = e.id)");
1240
+ }
1212
1241
  if (query.unread !== void 0) {
1213
1242
  const mids = query.unread.mids.slice(0, 500);
1214
1243
  const ids = query.unread.ids.slice(0, 500);
@@ -1348,13 +1377,174 @@ var MailDatabase = class {
1348
1377
  (entry) => text2 === "" || entry.address.toLowerCase().includes(text2) || entry.name.toLowerCase().includes(text2)
1349
1378
  ).sort((left, right) => right.count - left.count).slice(0, limit);
1350
1379
  }
1351
- /** All archived messages in the same conversation (normalized subject). */
1352
- 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) {
1353
1389
  const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
1354
- const base = normalize2(subject);
1390
+ const base = normalize2(seed.subject);
1355
1391
  if (base === "") return [];
1356
- const page = this.list({ mailbox: "all", query: base, limit: Math.min(500, limit * 2) });
1357
- 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;
1358
1548
  }
1359
1549
  /** Number of unread keys that still exist in this backup. */
1360
1550
  countUnread(keys) {
@@ -1538,6 +1728,15 @@ function truncate(value, max) {
1538
1728
  function quoteMatch(text2) {
1539
1729
  return `"${text2.replaceAll('"', '""')}"`;
1540
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
+ }
1541
1740
  function rowToSummary(row) {
1542
1741
  const fromName = String(row.from_name ?? "");
1543
1742
  const fromAddr = String(row.from_addr ?? "");
@@ -1667,8 +1866,8 @@ var BackupEngine = class {
1667
1866
  const inserted = db.prepare(
1668
1867
  `INSERT OR IGNORE INTO emails
1669
1868
  (mailbox, uid, subject, from_name, from_addr, to_addr, cc, date, message_id,
1670
- body_text, body_html, raw_eml, hash, backup_log_id)
1671
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1869
+ in_reply_to, "references", body_text, body_html, raw_eml, hash, backup_log_id)
1870
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1672
1871
  ).run(
1673
1872
  mailbox,
1674
1873
  Number(message.uid),
@@ -1679,6 +1878,8 @@ var BackupEngine = class {
1679
1878
  ccText,
1680
1879
  stamp(parsed.date ?? message.internalDate ?? message.envelope?.date),
1681
1880
  messageId === "" ? null : messageId,
1881
+ normalizeId(parsed.inReplyTo ?? "") || null,
1882
+ String(parsed.references ?? "").split(/\s+/).map(normalizeId).filter((item) => item !== "").join(" ") || null,
1682
1883
  text2,
1683
1884
  html,
1684
1885
  source,
@@ -2768,7 +2969,7 @@ ${conversation.trim()}
2768
2969
  }
2769
2970
 
2770
2971
  // src/host/version.ts
2771
- var VERSION = "0.16.1";
2972
+ var VERSION = "0.18.0";
2772
2973
 
2773
2974
  // src/host/api.ts
2774
2975
  init_smtp();
@@ -2872,11 +3073,19 @@ function buildRouter(runtime) {
2872
3073
  return { ...items, offset, limit };
2873
3074
  }
2874
3075
  const unreadOnly = url.searchParams.get("unread") === "1";
3076
+ const from = url.searchParams.get("from") ?? "";
3077
+ const since = url.searchParams.get("since") ?? "";
3078
+ const before = url.searchParams.get("before") ?? "";
3079
+ const attachmentsOnly = url.searchParams.get("attachment") === "1";
2875
3080
  const page = runtime.database(account).list({
2876
3081
  mailbox: mailbox === "" ? "all" : mailbox,
2877
3082
  query,
2878
3083
  offset,
2879
3084
  limit,
3085
+ ...from.trim() === "" ? {} : { from },
3086
+ ...since === "" ? {} : { since },
3087
+ ...before === "" ? {} : { before },
3088
+ ...attachmentsOnly ? { attachmentsOnly: true } : {},
2880
3089
  ...unreadOnly ? { unread: unreadKeys(runtime, account) } : {}
2881
3090
  });
2882
3091
  return { ...page, items: page.items.map((item) => localSummary(runtime, account, item)) };
@@ -3054,10 +3263,11 @@ function buildRouter(runtime) {
3054
3263
  runtime.refresh();
3055
3264
  return { ok: true, addedAccounts: added, state: appState(runtime) };
3056
3265
  });
3057
- router.get("/messages/thread", ({ url }) => {
3266
+ router.get("/messages/thread", async ({ url }) => {
3058
3267
  const { account } = accountFrom(runtime, url);
3059
3268
  const subject = url.searchParams.get("subject") ?? "";
3060
- const items = runtime.database(account).thread(subject);
3269
+ const messageId = url.searchParams.get("messageId") ?? "";
3270
+ const items = await runtime.database(account).thread({ subject, messageId });
3061
3271
  return { items: items.map((item) => localSummary(runtime, account, item)) };
3062
3272
  });
3063
3273
  router.get("/contacts", ({ url }) => {