@zaclinley/dsh-mailharbor 0.18.0 → 0.19.1

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
@@ -1121,6 +1121,14 @@ function ensureMailSchema(file) {
1121
1121
  if (!emailColumns.some((column) => column.name === "references")) {
1122
1122
  db.exec('ALTER TABLE emails ADD COLUMN "references" TEXT');
1123
1123
  }
1124
+ if (!emailColumns.some((column) => column.name === "refs_indexed")) {
1125
+ db.exec("ALTER TABLE emails ADD COLUMN refs_indexed INTEGER DEFAULT 0");
1126
+ }
1127
+ try {
1128
+ db.exec("CREATE INDEX IF NOT EXISTS idx_emails_in_reply ON emails(in_reply_to)");
1129
+ db.exec("CREATE INDEX IF NOT EXISTS idx_emails_message_id_lower ON emails(lower(message_id))");
1130
+ } catch {
1131
+ }
1124
1132
  } finally {
1125
1133
  db.close();
1126
1134
  }
@@ -1132,6 +1140,7 @@ var MailDatabase = class {
1132
1140
  ftsChecked = false;
1133
1141
  ftsAvailable = false;
1134
1142
  threadRefs = /* @__PURE__ */ new Map();
1143
+ threadCache = /* @__PURE__ */ new Map();
1135
1144
  constructor(file, options = {}) {
1136
1145
  this.file = file;
1137
1146
  this.options = options;
@@ -1186,6 +1195,7 @@ var MailDatabase = class {
1186
1195
  }
1187
1196
  this.db = void 0;
1188
1197
  this.threadRefs.clear();
1198
+ this.threadCache.clear();
1189
1199
  }
1190
1200
  status() {
1191
1201
  try {
@@ -1389,9 +1399,15 @@ var MailDatabase = class {
1389
1399
  const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
1390
1400
  const base = normalize2(seed.subject);
1391
1401
  if (base === "") return [];
1402
+ const cacheKey = `${seed.messageId ?? ""}|${base}`;
1403
+ const cached = this.threadCache.get(cacheKey);
1404
+ if (cached !== void 0 && Date.now() - cached.at < 3e4) return cached.items;
1392
1405
  const seedIdForQuery = (seed.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
1393
1406
  const candidates = this.threadCandidates(base, limit, seedIdForQuery);
1394
- if (candidates.length <= 1) return candidates;
1407
+ if (candidates.length <= 1) {
1408
+ this.threadCache.set(cacheKey, { at: Date.now(), items: candidates });
1409
+ return candidates;
1410
+ }
1395
1411
  const refs = this.referencesFor(candidates.slice(0, 120).map((item) => Number(item.id)));
1396
1412
  const byMessageId = /* @__PURE__ */ new Map();
1397
1413
  for (const item of candidates) {
@@ -1429,7 +1445,10 @@ var MailDatabase = class {
1429
1445
  refs: [...refs.entries()].slice(0, 3)
1430
1446
  });
1431
1447
  }
1432
- if (edges === 0) return candidates;
1448
+ if (edges === 0) {
1449
+ this.threadCache.set(cacheKey, { at: Date.now(), items: candidates });
1450
+ return candidates;
1451
+ }
1433
1452
  const seen = /* @__PURE__ */ new Set();
1434
1453
  const queue = [byMessageId.get(startId)];
1435
1454
  while (queue.length > 0) {
@@ -1440,24 +1459,27 @@ var MailDatabase = class {
1440
1459
  if (!seen.has(next)) queue.push(next);
1441
1460
  }
1442
1461
  }
1443
- if (seen.size <= 1) return candidates;
1444
- return candidates.filter((item) => seen.has(Number(item.id)));
1462
+ const result = seen.size <= 1 ? candidates : candidates.filter((item) => seen.has(Number(item.id)));
1463
+ this.threadCache.set(cacheKey, { at: Date.now(), items: result });
1464
+ return result;
1445
1465
  }
1446
1466
  /** Subject candidates via a distinctive token, not FTS (punctuation-safe). */
1447
1467
  threadCandidates(base, limit, seedId = "") {
1448
1468
  const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
1449
1469
  const words = base.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3);
1450
1470
  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,
1471
+ const select = `SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
1455
1472
  e.message_id, e.date, length(e.body_text) AS text_length,
1456
1473
  (SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
1457
1474
  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);
1475
+ FROM emails e`;
1476
+ try {
1477
+ const ftsQuery = `subject:"${token.replace(/"/g, '""')}"`;
1478
+ const rows = this.open().prepare(
1479
+ `${select} JOIN emails_fts f ON f.rowid = e.id
1480
+ WHERE emails_fts MATCH ?
1481
+ ORDER BY e.date DESC LIMIT 500`
1482
+ ).all(ftsQuery);
1461
1483
  const items = rows.map((row) => rowToSummary(row)).filter((item) => normalize2(item.subject) === base);
1462
1484
  if (seedId !== "" && this.hasRefColumns()) {
1463
1485
  const linked = this.open().prepare(
@@ -1466,9 +1488,9 @@ var MailDatabase = class {
1466
1488
  (SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
1467
1489
  substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
1468
1490
  FROM emails e
1469
- WHERE lower(e.in_reply_to) = ? OR lower(e."references") LIKE ? ESCAPE '\\'
1491
+ WHERE e.in_reply_to = ?
1470
1492
  ORDER BY e.date DESC LIMIT 100`
1471
- ).all(seedId, `%${seedId.replace(/[%_]/g, "\\$&")}%`);
1493
+ ).all(seedId);
1472
1494
  const known = new Set(items.map((item) => item.id));
1473
1495
  try {
1474
1496
  const seedRow = this.open().prepare(
@@ -1509,7 +1531,8 @@ var MailDatabase = class {
1509
1531
  }
1510
1532
  return items.slice(0, limit);
1511
1533
  } catch {
1512
- return [];
1534
+ const page = this.list({ mailbox: "all", query: base, limit: Math.min(500, limit * 2) });
1535
+ return page.items.filter((item) => normalize2(item.subject) === base).slice(0, limit);
1513
1536
  }
1514
1537
  }
1515
1538
  /** Read (and cache) In-Reply-To / References for a set of local ids. */
@@ -1519,11 +1542,11 @@ var MailDatabase = class {
1519
1542
  const placeholders = missing.map(() => "?").join(",");
1520
1543
  try {
1521
1544
  const withRefs = this.hasRefColumns();
1522
- const columns = withRefs ? 'id, message_id, raw_eml, in_reply_to, "references"' : "id, message_id, raw_eml";
1545
+ const columns = withRefs ? 'id, message_id, in_reply_to, "references", refs_indexed' : "id, message_id, raw_eml";
1523
1546
  const rows = this.open().prepare(`SELECT ${columns} FROM emails WHERE id IN (${placeholders})`).all(...missing);
1547
+ const needRaw = [];
1524
1548
  for (const row of rows) {
1525
1549
  const id = Number(row.id);
1526
- const raw = row.raw_eml;
1527
1550
  const refs = [];
1528
1551
  for (const stored of [row.references ?? "", row.in_reply_to ?? ""]) {
1529
1552
  for (const found of String(stored).split(/\s+/)) {
@@ -1531,16 +1554,28 @@ var MailDatabase = class {
1531
1554
  if (value.includes("@")) refs.push(value);
1532
1555
  }
1533
1556
  }
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
- }
1557
+ if (refs.length === 0 && (!withRefs || Number(row.refs_indexed ?? 0) !== 1)) {
1558
+ needRaw.push(id);
1538
1559
  }
1539
1560
  this.threadRefs.set(id, {
1540
1561
  messageId: String(row.message_id ?? "").replace(/^<|>$/g, "").toLowerCase(),
1541
1562
  refs: [...new Set(refs)]
1542
1563
  });
1543
1564
  }
1565
+ if (needRaw.length > 0) {
1566
+ const marks = needRaw.map(() => "?").join(",");
1567
+ const rawRows = this.open().prepare(`SELECT id, raw_eml FROM emails WHERE id IN (${marks})`).all(...needRaw);
1568
+ for (const row of rawRows) {
1569
+ const id = Number(row.id);
1570
+ const entry = this.threadRefs.get(id);
1571
+ if (entry === void 0 || row.raw_eml === null || row.raw_eml === void 0) continue;
1572
+ const refs = [...entry.refs];
1573
+ for (const header of ["references", "in-reply-to"]) {
1574
+ for (const found of headerIds(row.raw_eml, header)) refs.push(found);
1575
+ }
1576
+ this.threadRefs.set(id, { ...entry, refs: [...new Set(refs)] });
1577
+ }
1578
+ }
1544
1579
  } catch {
1545
1580
  }
1546
1581
  }
@@ -2969,7 +3004,7 @@ ${conversation.trim()}
2969
3004
  }
2970
3005
 
2971
3006
  // src/host/version.ts
2972
- var VERSION = "0.18.0";
3007
+ var VERSION = "0.19.1";
2973
3008
 
2974
3009
  // src/host/api.ts
2975
3010
  init_smtp();
@@ -3191,6 +3226,15 @@ function buildRouter(runtime) {
3191
3226
  }
3192
3227
  return { ok: true, count };
3193
3228
  });
3229
+ router.get("/maintenance/references", ({ url }) => {
3230
+ const { account } = accountFrom(runtime, url);
3231
+ return runtime.referencesStatus(account);
3232
+ });
3233
+ router.post("/maintenance/references", ({ url }) => {
3234
+ const { account } = accountFrom(runtime, url);
3235
+ runtime.startReferencesBackfill(account);
3236
+ return runtime.referencesStatus(account);
3237
+ });
3194
3238
  router.get("/transfer/export", () => {
3195
3239
  const config = runtime.current;
3196
3240
  return {
@@ -4304,7 +4348,7 @@ ${signature}`;
4304
4348
  }
4305
4349
 
4306
4350
  // src/host/runtime.ts
4307
- import { statSync } from "node:fs";
4351
+ import { existsSync as existsSync8, statSync } from "node:fs";
4308
4352
  import { join as join7 } from "node:path";
4309
4353
 
4310
4354
  // src/host/secrets.ts
@@ -4686,11 +4730,63 @@ var MailFlagsStore = class {
4686
4730
  }
4687
4731
  };
4688
4732
 
4733
+ // src/host/references.ts
4734
+ import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
4735
+ function extractReferences(raw, messageId) {
4736
+ const empty = { inReplyTo: null, references: null };
4737
+ if (raw === null || raw === void 0) return empty;
4738
+ const text2 = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 2e5))).toString("utf8");
4739
+ const separator = text2.search(/\r?\n\r?\n/);
4740
+ const head = separator >= 0 ? text2.slice(0, separator) : text2;
4741
+ const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
4742
+ const value = (name2) => {
4743
+ const match = new RegExp(`^${name2}:\\s*(.*)$`, "im").exec(unfolded);
4744
+ return match === null ? "" : String(match[1] ?? "");
4745
+ };
4746
+ const ids = (value2) => value2.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
4747
+ const referenceIds = ids(value("references"));
4748
+ const replyTo = ids(value("in-reply-to"));
4749
+ const filteredRefs = referenceIds.filter((id) => id !== messageId.toLowerCase());
4750
+ return {
4751
+ inReplyTo: replyTo[0] ?? null,
4752
+ references: filteredRefs.length > 0 ? filteredRefs.join(" ") : null
4753
+ };
4754
+ }
4755
+ function backfillReferences(file, batchSize = 300) {
4756
+ ensureMailSchema(file);
4757
+ const db = new DatabaseSync4(file);
4758
+ try {
4759
+ db.exec("PRAGMA busy_timeout = 10000");
4760
+ const rows = db.prepare("SELECT id, message_id, raw_eml FROM emails WHERE refs_indexed = 0 ORDER BY id LIMIT ?").all(batchSize);
4761
+ if (rows.length === 0) return { updated: 0, remaining: 0 };
4762
+ const update = db.prepare(
4763
+ 'UPDATE emails SET in_reply_to = ?, "references" = ?, refs_indexed = 1 WHERE id = ?'
4764
+ );
4765
+ db.exec("BEGIN");
4766
+ try {
4767
+ for (const row of rows) {
4768
+ const headers = extractReferences(row.raw_eml, String(row.message_id ?? ""));
4769
+ update.run(headers.inReplyTo, headers.references, Number(row.id));
4770
+ }
4771
+ db.exec("COMMIT");
4772
+ } catch (error) {
4773
+ db.exec("ROLLBACK");
4774
+ throw error;
4775
+ }
4776
+ const remaining = Number(
4777
+ db.prepare("SELECT COUNT(*) AS count FROM emails WHERE refs_indexed = 0").get()?.count ?? 0
4778
+ );
4779
+ return { updated: rows.length, remaining };
4780
+ } finally {
4781
+ db.close();
4782
+ }
4783
+ }
4784
+
4689
4785
  // src/host/tasks.ts
4690
4786
  import { randomUUID as randomUUID4 } from "node:crypto";
4691
4787
  import { mkdirSync as mkdirSync7 } from "node:fs";
4692
4788
  import { dirname as dirname4 } from "node:path";
4693
- import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
4789
+ import { DatabaseSync as DatabaseSync5 } from "node:sqlite";
4694
4790
  var STATUSES = /* @__PURE__ */ new Set(["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"]);
4695
4791
  function now() {
4696
4792
  return (/* @__PURE__ */ new Date()).toISOString();
@@ -4739,7 +4835,7 @@ var TaskStore = class {
4739
4835
  open() {
4740
4836
  if (this.db !== null) return this.db;
4741
4837
  mkdirSync7(dirname4(this.file), { recursive: true });
4742
- const db = new DatabaseSync4(this.file);
4838
+ const db = new DatabaseSync5(this.file);
4743
4839
  db.exec("PRAGMA journal_mode=WAL");
4744
4840
  db.exec("PRAGMA busy_timeout=10000");
4745
4841
  db.exec(`
@@ -5165,6 +5261,7 @@ var MailHarborRuntime = class {
5165
5261
  projectStore = null;
5166
5262
  taskStore = null;
5167
5263
  flagStores = /* @__PURE__ */ new Map();
5264
+ referenceJobs = /* @__PURE__ */ new Map();
5168
5265
  disposed = false;
5169
5266
  constructor(options = {}) {
5170
5267
  this.config = loadConfig();
@@ -5264,6 +5361,43 @@ var MailHarborRuntime = class {
5264
5361
  paths(account) {
5265
5362
  return accountRuntime(account);
5266
5363
  }
5364
+ referencesStatus(account) {
5365
+ return this.referenceJobs.get(account.id) ?? {
5366
+ running: false,
5367
+ indexed: 0,
5368
+ remaining: null,
5369
+ error: null
5370
+ };
5371
+ }
5372
+ /** Backfill In-Reply-To/References for archived mail, one batch at a time. */
5373
+ startReferencesBackfill(account) {
5374
+ const existing = this.referenceJobs.get(account.id);
5375
+ if (existing?.running === true) return;
5376
+ const dbFile = this.paths(account).mailDb;
5377
+ if (!existsSync8(dbFile)) return;
5378
+ const status = {
5379
+ running: true,
5380
+ indexed: 0,
5381
+ remaining: null,
5382
+ error: null
5383
+ };
5384
+ this.referenceJobs.set(account.id, status);
5385
+ void (async () => {
5386
+ try {
5387
+ for (; ; ) {
5388
+ const result = backfillReferences(dbFile, 300);
5389
+ status.indexed += result.updated;
5390
+ status.remaining = result.remaining;
5391
+ if (result.remaining <= 0 || result.updated === 0) break;
5392
+ await new Promise((resolve) => setTimeout(resolve, 40));
5393
+ }
5394
+ } catch (error) {
5395
+ status.error = error instanceof Error ? error.message : String(error);
5396
+ } finally {
5397
+ status.running = false;
5398
+ }
5399
+ })();
5400
+ }
5267
5401
  /** Credentials for an account, with a clear message when the user must log in. */
5268
5402
  credentialsFor(account) {
5269
5403
  const resolved = this.credentials.resolve(account);
@@ -5833,6 +5967,9 @@ function apply(ctx) {
5833
5967
  }
5834
5968
  const handler = createApiHandler(runtime);
5835
5969
  const disposers = [];
5970
+ if (activeAccount2 !== void 0) {
5971
+ setTimeout(() => runtime.startReferencesBackfill(activeAccount2), 3e3);
5972
+ }
5836
5973
  const start = () => {
5837
5974
  disposers.push(ctx.webServer.register({ kind: "prefix", path: ROUTE, handler }));
5838
5975
  if (ctx.tools !== void 0) disposers.push(...registerMailTools(ctx.tools, runtime));