@zaclinley/dsh-mailharbor 0.19.0 → 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,
@@ -1124,6 +1125,13 @@ function ensureMailSchema(file) {
1124
1125
  if (!emailColumns.some((column) => column.name === "refs_indexed")) {
1125
1126
  db.exec("ALTER TABLE emails ADD COLUMN refs_indexed INTEGER DEFAULT 0");
1126
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)");
1131
+ db.exec("CREATE INDEX IF NOT EXISTS idx_emails_in_reply ON emails(in_reply_to)");
1132
+ db.exec("CREATE INDEX IF NOT EXISTS idx_emails_message_id_lower ON emails(lower(message_id))");
1133
+ } catch {
1134
+ }
1127
1135
  } finally {
1128
1136
  db.close();
1129
1137
  }
@@ -1135,6 +1143,7 @@ var MailDatabase = class {
1135
1143
  ftsChecked = false;
1136
1144
  ftsAvailable = false;
1137
1145
  threadRefs = /* @__PURE__ */ new Map();
1146
+ threadCache = /* @__PURE__ */ new Map();
1138
1147
  constructor(file, options = {}) {
1139
1148
  this.file = file;
1140
1149
  this.options = options;
@@ -1189,6 +1198,7 @@ var MailDatabase = class {
1189
1198
  }
1190
1199
  this.db = void 0;
1191
1200
  this.threadRefs.clear();
1201
+ this.threadCache.clear();
1192
1202
  }
1193
1203
  status() {
1194
1204
  try {
@@ -1392,9 +1402,15 @@ var MailDatabase = class {
1392
1402
  const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
1393
1403
  const base = normalize2(seed.subject);
1394
1404
  if (base === "") return [];
1405
+ const cacheKey = `${seed.messageId ?? ""}|${base}`;
1406
+ const cached = this.threadCache.get(cacheKey);
1407
+ if (cached !== void 0 && Date.now() - cached.at < 3e4) return cached.items;
1395
1408
  const seedIdForQuery = (seed.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
1396
1409
  const candidates = this.threadCandidates(base, limit, seedIdForQuery);
1397
- if (candidates.length <= 1) return candidates;
1410
+ if (candidates.length <= 1) {
1411
+ this.threadCache.set(cacheKey, { at: Date.now(), items: candidates });
1412
+ return candidates;
1413
+ }
1398
1414
  const refs = this.referencesFor(candidates.slice(0, 120).map((item) => Number(item.id)));
1399
1415
  const byMessageId = /* @__PURE__ */ new Map();
1400
1416
  for (const item of candidates) {
@@ -1432,7 +1448,10 @@ var MailDatabase = class {
1432
1448
  refs: [...refs.entries()].slice(0, 3)
1433
1449
  });
1434
1450
  }
1435
- if (edges === 0) return candidates;
1451
+ if (edges === 0) {
1452
+ this.threadCache.set(cacheKey, { at: Date.now(), items: candidates });
1453
+ return candidates;
1454
+ }
1436
1455
  const seen = /* @__PURE__ */ new Set();
1437
1456
  const queue = [byMessageId.get(startId)];
1438
1457
  while (queue.length > 0) {
@@ -1443,65 +1462,49 @@ var MailDatabase = class {
1443
1462
  if (!seen.has(next)) queue.push(next);
1444
1463
  }
1445
1464
  }
1446
- if (seen.size <= 1) return candidates;
1447
- return candidates.filter((item) => seen.has(Number(item.id)));
1465
+ const result = seen.size <= 1 ? candidates : candidates.filter((item) => seen.has(Number(item.id)));
1466
+ this.threadCache.set(cacheKey, { at: Date.now(), items: result });
1467
+ return result;
1448
1468
  }
1449
- /** Subject candidates via a distinctive token, not FTS (punctuation-safe). */
1469
+ /** Subject candidates from a normalized-key table, then FTS, then LIKE. */
1450
1470
  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,
1471
+ const select = `SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
1458
1472
  e.message_id, e.date, length(e.body_text) AS text_length,
1459
1473
  (SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
1460
1474
  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()) {
1475
+ FROM emails e`;
1476
+ let items = [];
1477
+ try {
1478
+ const byKey = this.open().prepare(
1479
+ `${select} JOIN email_subjects s ON s.email_id = e.id
1480
+ WHERE s.key = ?
1481
+ ORDER BY e.date DESC LIMIT 500`
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 {
1466
1502
  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 '\\'
1503
+ `${select}
1504
+ WHERE e.in_reply_to = ?
1473
1505
  ORDER BY e.date DESC LIMIT 100`
1474
- ).all(seedId, `%${seedId.replace(/[%_]/g, "\\$&")}%`);
1506
+ ).all(seedId);
1475
1507
  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
1508
  for (const row of linked) {
1506
1509
  const item = rowToSummary(row);
1507
1510
  if (!known.has(item.id)) {
@@ -1509,11 +1512,33 @@ var MailDatabase = class {
1509
1512
  known.add(item.id);
1510
1513
  }
1511
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 {
1512
1539
  }
1513
- return items.slice(0, limit);
1514
- } catch {
1515
- return [];
1516
1540
  }
1541
+ return items.slice(0, limit);
1517
1542
  }
1518
1543
  /** Read (and cache) In-Reply-To / References for a set of local ids. */
1519
1544
  referencesFor(ids) {
@@ -1522,11 +1547,11 @@ var MailDatabase = class {
1522
1547
  const placeholders = missing.map(() => "?").join(",");
1523
1548
  try {
1524
1549
  const withRefs = this.hasRefColumns();
1525
- const columns = withRefs ? 'id, message_id, raw_eml, in_reply_to, "references"' : "id, message_id, raw_eml";
1550
+ const columns = withRefs ? 'id, message_id, in_reply_to, "references", refs_indexed' : "id, message_id, raw_eml";
1526
1551
  const rows = this.open().prepare(`SELECT ${columns} FROM emails WHERE id IN (${placeholders})`).all(...missing);
1552
+ const needRaw = [];
1527
1553
  for (const row of rows) {
1528
1554
  const id = Number(row.id);
1529
- const raw = row.raw_eml;
1530
1555
  const refs = [];
1531
1556
  for (const stored of [row.references ?? "", row.in_reply_to ?? ""]) {
1532
1557
  for (const found of String(stored).split(/\s+/)) {
@@ -1534,16 +1559,28 @@ var MailDatabase = class {
1534
1559
  if (value.includes("@")) refs.push(value);
1535
1560
  }
1536
1561
  }
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
- }
1562
+ if (refs.length === 0 && (!withRefs || Number(row.refs_indexed ?? 0) !== 1)) {
1563
+ needRaw.push(id);
1541
1564
  }
1542
1565
  this.threadRefs.set(id, {
1543
1566
  messageId: String(row.message_id ?? "").replace(/^<|>$/g, "").toLowerCase(),
1544
1567
  refs: [...new Set(refs)]
1545
1568
  });
1546
1569
  }
1570
+ if (needRaw.length > 0) {
1571
+ const marks = needRaw.map(() => "?").join(",");
1572
+ const rawRows = this.open().prepare(`SELECT id, raw_eml FROM emails WHERE id IN (${marks})`).all(...needRaw);
1573
+ for (const row of rawRows) {
1574
+ const id = Number(row.id);
1575
+ const entry = this.threadRefs.get(id);
1576
+ if (entry === void 0 || row.raw_eml === null || row.raw_eml === void 0) continue;
1577
+ const refs = [...entry.refs];
1578
+ for (const header of ["references", "in-reply-to"]) {
1579
+ for (const found of headerIds(row.raw_eml, header)) refs.push(found);
1580
+ }
1581
+ this.threadRefs.set(id, { ...entry, refs: [...new Set(refs)] });
1582
+ }
1583
+ }
1547
1584
  } catch {
1548
1585
  }
1549
1586
  }
@@ -1760,7 +1797,117 @@ function rowToSummary(row) {
1760
1797
  };
1761
1798
  }
1762
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
+
1763
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";
1764
1911
  var BATCH = 25;
1765
1912
  function sha256(data) {
1766
1913
  return createHash2("sha256").update(data).digest("hex");
@@ -1842,7 +1989,7 @@ var BackupEngine = class {
1842
1989
  }
1843
1990
  openDb() {
1844
1991
  ensureMailSchema(this.runtime.mailDb);
1845
- const db = new DatabaseSync3(this.runtime.mailDb);
1992
+ const db = new DatabaseSync4(this.runtime.mailDb);
1846
1993
  db.exec("PRAGMA journal_mode=WAL");
1847
1994
  db.exec("PRAGMA busy_timeout=10000");
1848
1995
  db.exec("PRAGMA synchronous=NORMAL");
@@ -1891,6 +2038,10 @@ var BackupEngine = class {
1891
2038
  );
1892
2039
  if (inserted.changes === 0) return 0;
1893
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
+ );
1894
2045
  const attachments = parsed.attachments ?? [];
1895
2046
  const insertAttachment = db.prepare(
1896
2047
  "INSERT INTO attachments (email_id, filename, content_type, size, hash, content_id, disposition) VALUES (?, ?, ?, ?, ?, ?, ?)"
@@ -2096,7 +2247,7 @@ var BackupEngine = class {
2096
2247
  };
2097
2248
  if (!existsSync3(dbFile)) return empty;
2098
2249
  try {
2099
- const db = new DatabaseSync3(dbFile, { readOnly: true });
2250
+ const db = new DatabaseSync4(dbFile, { readOnly: true });
2100
2251
  try {
2101
2252
  const latest = db.prepare("SELECT * FROM backup_logs ORDER BY id DESC LIMIT 1").get();
2102
2253
  const success = db.prepare("SELECT completed_at FROM backup_logs WHERE status = 'completed' ORDER BY id DESC LIMIT 1").get();
@@ -2972,7 +3123,7 @@ ${conversation.trim()}
2972
3123
  }
2973
3124
 
2974
3125
  // src/host/version.ts
2975
- var VERSION = "0.19.0";
3126
+ var VERSION = "0.20.0";
2976
3127
 
2977
3128
  // src/host/api.ts
2978
3129
  init_smtp();
@@ -3194,6 +3345,10 @@ function buildRouter(runtime) {
3194
3345
  }
3195
3346
  return { ok: true, count };
3196
3347
  });
3348
+ router.post("/maintenance/optimize", ({ url }) => {
3349
+ const { account } = accountFrom(runtime, url);
3350
+ return optimizeDatabase(runtime.paths(account).mailDb);
3351
+ });
3197
3352
  router.get("/maintenance/references", ({ url }) => {
3198
3353
  const { account } = accountFrom(runtime, url);
3199
3354
  return runtime.referencesStatus(account);
@@ -4244,7 +4399,7 @@ function writeAttachment(res, filename, contentType, data, inline = false) {
4244
4399
  "content-disposition",
4245
4400
  `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(filename)}`
4246
4401
  );
4247
- res.setHeader("cache-control", "no-store");
4402
+ res.setHeader("cache-control", "private, max-age=86400");
4248
4403
  res.setHeader("x-content-type-options", "nosniff");
4249
4404
  res.setHeader(
4250
4405
  "content-security-policy",
@@ -4698,58 +4853,6 @@ var MailFlagsStore = class {
4698
4853
  }
4699
4854
  };
4700
4855
 
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
-
4753
4856
  // src/host/tasks.ts
4754
4857
  import { randomUUID as randomUUID4 } from "node:crypto";
4755
4858
  import { mkdirSync as mkdirSync7 } from "node:fs";
@@ -5359,6 +5462,13 @@ var MailHarborRuntime = class {
5359
5462
  if (result.remaining <= 0 || result.updated === 0) break;
5360
5463
  await new Promise((resolve) => setTimeout(resolve, 40));
5361
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
+ }
5362
5472
  } catch (error) {
5363
5473
  status.error = error instanceof Error ? error.message : String(error);
5364
5474
  } finally {