@zaclinley/dsh-mailharbor 0.19.1 → 0.20.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
@@ -1027,20 +1027,21 @@ async function draftMime(account, draft) {
1027
1027
  return Buffer.from(info.message);
1028
1028
  }
1029
1029
 
1030
- // src/host/backup.ts
1031
- init_address();
1032
- import { createHash as createHash2 } from "node:crypto";
1033
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
1034
- import { join as join3 } from "node:path";
1035
- import { ImapFlow } from "imapflow";
1036
- import { simpleParser } from "mailparser";
1030
+ // src/host/references.ts
1037
1031
  import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
1038
1032
 
1039
1033
  // src/host/mail-db.ts
1040
1034
  init_address();
1041
- init_inline_images();
1042
1035
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1043
1036
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
1037
+
1038
+ // src/host/subject-key.ts
1039
+ function subjectKey(subject) {
1040
+ return subject.replace(/^(\s*(re|fw|fwd|回复|答复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
1041
+ }
1042
+
1043
+ // src/host/mail-db.ts
1044
+ init_inline_images();
1044
1045
  var SCHEMA = `
1045
1046
  CREATE TABLE IF NOT EXISTS emails (
1046
1047
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1125,6 +1126,8 @@ function ensureMailSchema(file) {
1125
1126
  db.exec("ALTER TABLE emails ADD COLUMN refs_indexed INTEGER DEFAULT 0");
1126
1127
  }
1127
1128
  try {
1129
+ db.exec("CREATE TABLE IF NOT EXISTS email_subjects (email_id INTEGER PRIMARY KEY, key TEXT NOT NULL)");
1130
+ db.exec("CREATE INDEX IF NOT EXISTS idx_email_subjects_key ON email_subjects(key)");
1128
1131
  db.exec("CREATE INDEX IF NOT EXISTS idx_emails_in_reply ON emails(in_reply_to)");
1129
1132
  db.exec("CREATE INDEX IF NOT EXISTS idx_emails_message_id_lower ON emails(lower(message_id))");
1130
1133
  } catch {
@@ -1463,64 +1466,45 @@ var MailDatabase = class {
1463
1466
  this.threadCache.set(cacheKey, { at: Date.now(), items: result });
1464
1467
  return result;
1465
1468
  }
1466
- /** Subject candidates via a distinctive token, not FTS (punctuation-safe). */
1469
+ /** Subject candidates from a normalized-key table, then FTS, then LIKE. */
1467
1470
  threadCandidates(base, limit, seedId = "") {
1468
- const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
1469
- const words = base.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3);
1470
- const token = words.sort((left, right) => right.length - left.length)[0] ?? base.slice(0, 8);
1471
1471
  const select = `SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
1472
1472
  e.message_id, e.date, length(e.body_text) AS text_length,
1473
1473
  (SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
1474
1474
  substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
1475
1475
  FROM emails e`;
1476
+ let items = [];
1476
1477
  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 ?
1478
+ const byKey = this.open().prepare(
1479
+ `${select} JOIN email_subjects s ON s.email_id = e.id
1480
+ WHERE s.key = ?
1481
1481
  ORDER BY e.date DESC LIMIT 500`
1482
- ).all(ftsQuery);
1483
- const items = rows.map((row) => rowToSummary(row)).filter((item) => normalize2(item.subject) === base);
1484
- if (seedId !== "" && this.hasRefColumns()) {
1482
+ ).all(base);
1483
+ if (byKey.length > 0) {
1484
+ items = byKey.map((row) => rowToSummary(row));
1485
+ } else {
1486
+ const words = base.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3);
1487
+ const token = words.sort((left, right) => right.length - left.length)[0] ?? base.slice(0, 8);
1488
+ const ftsQuery = `subject:"${token.replace(/"/g, '""')}"`;
1489
+ const rows = this.open().prepare(
1490
+ `${select} JOIN emails_fts f ON f.rowid = e.id
1491
+ WHERE emails_fts MATCH ?
1492
+ ORDER BY e.date DESC LIMIT 500`
1493
+ ).all(ftsQuery);
1494
+ items = rows.map((row) => rowToSummary(row)).filter((item) => subjectKey(item.subject) === base);
1495
+ }
1496
+ } catch {
1497
+ const page = this.list({ mailbox: "all", query: base, limit: Math.min(500, limit * 2) });
1498
+ return page.items.filter((item) => subjectKey(item.subject) === base).slice(0, limit);
1499
+ }
1500
+ if (seedId !== "" && this.hasRefColumns()) {
1501
+ try {
1485
1502
  const linked = 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
1503
+ `${select}
1491
1504
  WHERE e.in_reply_to = ?
1492
1505
  ORDER BY e.date DESC LIMIT 100`
1493
1506
  ).all(seedId);
1494
1507
  const known = new Set(items.map((item) => item.id));
1495
- try {
1496
- const seedRow = this.open().prepare(
1497
- 'SELECT in_reply_to, "references", raw_eml FROM emails WHERE lower(message_id) = ? LIMIT 1'
1498
- ).get(seedId);
1499
- const parents = `${seedRow?.references ?? ""} ${seedRow?.in_reply_to ?? ""}`.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
1500
- if (parents.length === 0 && seedRow?.raw_eml !== void 0 && seedRow.raw_eml !== null) {
1501
- for (const header of ["references", "in-reply-to"]) {
1502
- for (const found of headerIds(seedRow.raw_eml, header)) parents.push(found);
1503
- }
1504
- }
1505
- if (parents.length > 0) {
1506
- const marks = parents.map(() => "?").join(",");
1507
- const ancestors = this.open().prepare(
1508
- `SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
1509
- e.message_id, e.date, length(e.body_text) AS text_length,
1510
- (SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
1511
- substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
1512
- FROM emails e WHERE lower(e.message_id) IN (${marks}) LIMIT 100`
1513
- ).all(...parents);
1514
- for (const row of ancestors) {
1515
- const item = rowToSummary(row);
1516
- if (!known.has(item.id)) {
1517
- items.push(item);
1518
- known.add(item.id);
1519
- }
1520
- }
1521
- }
1522
- } catch {
1523
- }
1524
1508
  for (const row of linked) {
1525
1509
  const item = rowToSummary(row);
1526
1510
  if (!known.has(item.id)) {
@@ -1528,12 +1512,33 @@ var MailDatabase = class {
1528
1512
  known.add(item.id);
1529
1513
  }
1530
1514
  }
1515
+ const seedRow = this.open().prepare(
1516
+ 'SELECT in_reply_to, "references", raw_eml FROM emails WHERE lower(message_id) = ? LIMIT 1'
1517
+ ).get(seedId);
1518
+ const parents = `${seedRow?.references ?? ""} ${seedRow?.in_reply_to ?? ""}`.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
1519
+ if (parents.length === 0 && seedRow?.raw_eml !== void 0 && seedRow.raw_eml !== null) {
1520
+ for (const header of ["references", "in-reply-to"]) {
1521
+ for (const found of headerIds(seedRow.raw_eml, header)) parents.push(found);
1522
+ }
1523
+ }
1524
+ if (parents.length > 0) {
1525
+ const marks = parents.map(() => "?").join(",");
1526
+ const ancestors = this.open().prepare(
1527
+ `${select}
1528
+ WHERE lower(e.message_id) IN (${marks}) LIMIT 100`
1529
+ ).all(...parents);
1530
+ for (const row of ancestors) {
1531
+ const item = rowToSummary(row);
1532
+ if (!known.has(item.id)) {
1533
+ items.push(item);
1534
+ known.add(item.id);
1535
+ }
1536
+ }
1537
+ }
1538
+ } catch {
1531
1539
  }
1532
- return items.slice(0, limit);
1533
- } catch {
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);
1536
1540
  }
1541
+ return items.slice(0, limit);
1537
1542
  }
1538
1543
  /** Read (and cache) In-Reply-To / References for a set of local ids. */
1539
1544
  referencesFor(ids) {
@@ -1792,7 +1797,117 @@ function rowToSummary(row) {
1792
1797
  };
1793
1798
  }
1794
1799
 
1800
+ // src/host/references.ts
1801
+ function extractReferences(raw, messageId) {
1802
+ const empty = { inReplyTo: null, references: null };
1803
+ if (raw === null || raw === void 0) return empty;
1804
+ const text2 = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 2e5))).toString("utf8");
1805
+ const separator = text2.search(/\r?\n\r?\n/);
1806
+ const head = separator >= 0 ? text2.slice(0, separator) : text2;
1807
+ const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
1808
+ const value = (name2) => {
1809
+ const match = new RegExp(`^${name2}:\\s*(.*)$`, "im").exec(unfolded);
1810
+ return match === null ? "" : String(match[1] ?? "");
1811
+ };
1812
+ const ids = (value2) => value2.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
1813
+ const referenceIds = ids(value("references"));
1814
+ const replyTo = ids(value("in-reply-to"));
1815
+ const filteredRefs = referenceIds.filter((id) => id !== messageId.toLowerCase());
1816
+ return {
1817
+ inReplyTo: replyTo[0] ?? null,
1818
+ references: filteredRefs.length > 0 ? filteredRefs.join(" ") : null
1819
+ };
1820
+ }
1821
+ function backfillReferences(file, batchSize = 300) {
1822
+ ensureMailSchema(file);
1823
+ const db = new DatabaseSync3(file);
1824
+ try {
1825
+ db.exec("PRAGMA busy_timeout = 10000");
1826
+ const rows = db.prepare("SELECT id, message_id, raw_eml FROM emails WHERE refs_indexed = 0 ORDER BY id LIMIT ?").all(batchSize);
1827
+ if (rows.length === 0) return { updated: 0, remaining: 0 };
1828
+ const update = db.prepare(
1829
+ 'UPDATE emails SET in_reply_to = ?, "references" = ?, refs_indexed = 1 WHERE id = ?'
1830
+ );
1831
+ db.exec("BEGIN");
1832
+ try {
1833
+ for (const row of rows) {
1834
+ const headers = extractReferences(row.raw_eml, String(row.message_id ?? ""));
1835
+ update.run(headers.inReplyTo, headers.references, Number(row.id));
1836
+ }
1837
+ db.exec("COMMIT");
1838
+ } catch (error) {
1839
+ db.exec("ROLLBACK");
1840
+ throw error;
1841
+ }
1842
+ const remaining = Number(
1843
+ db.prepare("SELECT COUNT(*) AS count FROM emails WHERE refs_indexed = 0").get()?.count ?? 0
1844
+ );
1845
+ return { updated: rows.length, remaining };
1846
+ } finally {
1847
+ db.close();
1848
+ }
1849
+ }
1850
+ function optimizeDatabase(file) {
1851
+ ensureMailSchema(file);
1852
+ const db = new DatabaseSync3(file);
1853
+ try {
1854
+ db.exec("PRAGMA busy_timeout = 10000");
1855
+ db.exec("PRAGMA optimize");
1856
+ try {
1857
+ db.exec("INSERT INTO emails_fts(emails_fts) VALUES('optimize')");
1858
+ } catch {
1859
+ }
1860
+ try {
1861
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1862
+ } catch {
1863
+ }
1864
+ return { ok: true };
1865
+ } catch (error) {
1866
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1867
+ } finally {
1868
+ db.close();
1869
+ }
1870
+ }
1871
+ function backfillSubjects(file, batchSize = 300) {
1872
+ ensureMailSchema(file);
1873
+ const db = new DatabaseSync3(file);
1874
+ try {
1875
+ db.exec("PRAGMA busy_timeout = 10000");
1876
+ const rows = db.prepare(
1877
+ `SELECT e.id, e.subject FROM emails e
1878
+ WHERE NOT EXISTS (SELECT 1 FROM email_subjects s WHERE s.email_id = e.id)
1879
+ ORDER BY e.id LIMIT ?`
1880
+ ).all(batchSize);
1881
+ if (rows.length === 0) return { updated: 0, remaining: 0 };
1882
+ const insert = db.prepare("INSERT OR REPLACE INTO email_subjects (email_id, key) VALUES (?, ?)");
1883
+ db.exec("BEGIN");
1884
+ try {
1885
+ for (const row of rows) insert.run(Number(row.id), subjectKey(String(row.subject ?? "")));
1886
+ db.exec("COMMIT");
1887
+ } catch (error) {
1888
+ db.exec("ROLLBACK");
1889
+ throw error;
1890
+ }
1891
+ const remaining = Number(
1892
+ db.prepare(
1893
+ `SELECT COUNT(*) AS count FROM emails e
1894
+ WHERE NOT EXISTS (SELECT 1 FROM email_subjects s WHERE s.email_id = e.id)`
1895
+ ).get()?.count ?? 0
1896
+ );
1897
+ return { updated: rows.length, remaining };
1898
+ } finally {
1899
+ db.close();
1900
+ }
1901
+ }
1902
+
1795
1903
  // src/host/backup.ts
1904
+ init_address();
1905
+ import { createHash as createHash2 } from "node:crypto";
1906
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
1907
+ import { join as join3 } from "node:path";
1908
+ import { ImapFlow } from "imapflow";
1909
+ import { simpleParser } from "mailparser";
1910
+ import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
1796
1911
  var BATCH = 25;
1797
1912
  function sha256(data) {
1798
1913
  return createHash2("sha256").update(data).digest("hex");
@@ -1874,7 +1989,7 @@ var BackupEngine = class {
1874
1989
  }
1875
1990
  openDb() {
1876
1991
  ensureMailSchema(this.runtime.mailDb);
1877
- const db = new DatabaseSync3(this.runtime.mailDb);
1992
+ const db = new DatabaseSync4(this.runtime.mailDb);
1878
1993
  db.exec("PRAGMA journal_mode=WAL");
1879
1994
  db.exec("PRAGMA busy_timeout=10000");
1880
1995
  db.exec("PRAGMA synchronous=NORMAL");
@@ -1923,6 +2038,10 @@ var BackupEngine = class {
1923
2038
  );
1924
2039
  if (inserted.changes === 0) return 0;
1925
2040
  const emailId = Number(inserted.lastInsertRowid);
2041
+ db.prepare("INSERT OR REPLACE INTO email_subjects (email_id, key) VALUES (?, ?)").run(
2042
+ emailId,
2043
+ subjectKey(subject)
2044
+ );
1926
2045
  const attachments = parsed.attachments ?? [];
1927
2046
  const insertAttachment = db.prepare(
1928
2047
  "INSERT INTO attachments (email_id, filename, content_type, size, hash, content_id, disposition) VALUES (?, ?, ?, ?, ?, ?, ?)"
@@ -2128,7 +2247,7 @@ var BackupEngine = class {
2128
2247
  };
2129
2248
  if (!existsSync3(dbFile)) return empty;
2130
2249
  try {
2131
- const db = new DatabaseSync3(dbFile, { readOnly: true });
2250
+ const db = new DatabaseSync4(dbFile, { readOnly: true });
2132
2251
  try {
2133
2252
  const latest = db.prepare("SELECT * FROM backup_logs ORDER BY id DESC LIMIT 1").get();
2134
2253
  const success = db.prepare("SELECT completed_at FROM backup_logs WHERE status = 'completed' ORDER BY id DESC LIMIT 1").get();
@@ -3004,7 +3123,7 @@ ${conversation.trim()}
3004
3123
  }
3005
3124
 
3006
3125
  // src/host/version.ts
3007
- var VERSION = "0.19.1";
3126
+ var VERSION = "0.20.0";
3008
3127
 
3009
3128
  // src/host/api.ts
3010
3129
  init_smtp();
@@ -3226,6 +3345,10 @@ function buildRouter(runtime) {
3226
3345
  }
3227
3346
  return { ok: true, count };
3228
3347
  });
3348
+ router.post("/maintenance/optimize", ({ url }) => {
3349
+ const { account } = accountFrom(runtime, url);
3350
+ return optimizeDatabase(runtime.paths(account).mailDb);
3351
+ });
3229
3352
  router.get("/maintenance/references", ({ url }) => {
3230
3353
  const { account } = accountFrom(runtime, url);
3231
3354
  return runtime.referencesStatus(account);
@@ -4276,7 +4399,7 @@ function writeAttachment(res, filename, contentType, data, inline = false) {
4276
4399
  "content-disposition",
4277
4400
  `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(filename)}`
4278
4401
  );
4279
- res.setHeader("cache-control", "no-store");
4402
+ res.setHeader("cache-control", "private, max-age=86400");
4280
4403
  res.setHeader("x-content-type-options", "nosniff");
4281
4404
  res.setHeader(
4282
4405
  "content-security-policy",
@@ -4730,58 +4853,6 @@ var MailFlagsStore = class {
4730
4853
  }
4731
4854
  };
4732
4855
 
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
-
4785
4856
  // src/host/tasks.ts
4786
4857
  import { randomUUID as randomUUID4 } from "node:crypto";
4787
4858
  import { mkdirSync as mkdirSync7 } from "node:fs";
@@ -5391,6 +5462,13 @@ var MailHarborRuntime = class {
5391
5462
  if (result.remaining <= 0 || result.updated === 0) break;
5392
5463
  await new Promise((resolve) => setTimeout(resolve, 40));
5393
5464
  }
5465
+ for (; ; ) {
5466
+ const result = backfillSubjects(dbFile, 300);
5467
+ status.indexed += result.updated;
5468
+ status.remaining = result.remaining;
5469
+ if (result.remaining <= 0 || result.updated === 0) break;
5470
+ await new Promise((resolve) => setTimeout(resolve, 40));
5471
+ }
5394
5472
  } catch (error) {
5395
5473
  status.error = error instanceof Error ? error.message : String(error);
5396
5474
  } finally {