@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/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
- /** All archived messages in the same conversation (normalized subject). */
1364
- thread(subject, limit = 200) {
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 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);
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.17.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 items = runtime.database(account).thread(subject);
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 DatabaseSync4 } from "node:sqlite";
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 DatabaseSync4(this.file);
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));