@zaclinley/dsh-mailharbor 0.19.1 → 0.21.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
@@ -989,7 +989,8 @@ async function openPath(path) {
989
989
  }
990
990
 
991
991
  // src/host/api.ts
992
- import { existsSync as existsSync6, rmSync } from "node:fs";
992
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, rmSync, writeFileSync as writeFileSync5 } from "node:fs";
993
+ import { basename, join as join6 } from "node:path";
993
994
 
994
995
  // src/host/mail-write.ts
995
996
  import { DatabaseSync } from "node:sqlite";
@@ -1027,20 +1028,21 @@ async function draftMime(account, draft) {
1027
1028
  return Buffer.from(info.message);
1028
1029
  }
1029
1030
 
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";
1031
+ // src/host/references.ts
1037
1032
  import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
1038
1033
 
1039
1034
  // src/host/mail-db.ts
1040
1035
  init_address();
1041
- init_inline_images();
1042
1036
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1043
1037
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
1038
+
1039
+ // src/host/subject-key.ts
1040
+ function subjectKey(subject) {
1041
+ return subject.replace(/^(\s*(re|fw|fwd|回复|答复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
1042
+ }
1043
+
1044
+ // src/host/mail-db.ts
1045
+ init_inline_images();
1044
1046
  var SCHEMA = `
1045
1047
  CREATE TABLE IF NOT EXISTS emails (
1046
1048
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1125,6 +1127,8 @@ function ensureMailSchema(file) {
1125
1127
  db.exec("ALTER TABLE emails ADD COLUMN refs_indexed INTEGER DEFAULT 0");
1126
1128
  }
1127
1129
  try {
1130
+ db.exec("CREATE TABLE IF NOT EXISTS email_subjects (email_id INTEGER PRIMARY KEY, key TEXT NOT NULL)");
1131
+ db.exec("CREATE INDEX IF NOT EXISTS idx_email_subjects_key ON email_subjects(key)");
1128
1132
  db.exec("CREATE INDEX IF NOT EXISTS idx_emails_in_reply ON emails(in_reply_to)");
1129
1133
  db.exec("CREATE INDEX IF NOT EXISTS idx_emails_message_id_lower ON emails(lower(message_id))");
1130
1134
  } catch {
@@ -1463,64 +1467,45 @@ var MailDatabase = class {
1463
1467
  this.threadCache.set(cacheKey, { at: Date.now(), items: result });
1464
1468
  return result;
1465
1469
  }
1466
- /** Subject candidates via a distinctive token, not FTS (punctuation-safe). */
1470
+ /** Subject candidates from a normalized-key table, then FTS, then LIKE. */
1467
1471
  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
1472
  const select = `SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
1472
1473
  e.message_id, e.date, length(e.body_text) AS text_length,
1473
1474
  (SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
1474
1475
  substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
1475
1476
  FROM emails e`;
1477
+ let items = [];
1476
1478
  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 ?
1479
+ const byKey = this.open().prepare(
1480
+ `${select} JOIN email_subjects s ON s.email_id = e.id
1481
+ WHERE s.key = ?
1481
1482
  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()) {
1483
+ ).all(base);
1484
+ if (byKey.length > 0) {
1485
+ items = byKey.map((row) => rowToSummary(row));
1486
+ } else {
1487
+ const words = base.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3);
1488
+ const token = words.sort((left, right) => right.length - left.length)[0] ?? base.slice(0, 8);
1489
+ const ftsQuery = `subject:"${token.replace(/"/g, '""')}"`;
1490
+ const rows = this.open().prepare(
1491
+ `${select} JOIN emails_fts f ON f.rowid = e.id
1492
+ WHERE emails_fts MATCH ?
1493
+ ORDER BY e.date DESC LIMIT 500`
1494
+ ).all(ftsQuery);
1495
+ items = rows.map((row) => rowToSummary(row)).filter((item) => subjectKey(item.subject) === base);
1496
+ }
1497
+ } catch {
1498
+ const page = this.list({ mailbox: "all", query: base, limit: Math.min(500, limit * 2) });
1499
+ return page.items.filter((item) => subjectKey(item.subject) === base).slice(0, limit);
1500
+ }
1501
+ if (seedId !== "" && this.hasRefColumns()) {
1502
+ try {
1485
1503
  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
1504
+ `${select}
1491
1505
  WHERE e.in_reply_to = ?
1492
1506
  ORDER BY e.date DESC LIMIT 100`
1493
1507
  ).all(seedId);
1494
1508
  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
1509
  for (const row of linked) {
1525
1510
  const item = rowToSummary(row);
1526
1511
  if (!known.has(item.id)) {
@@ -1528,12 +1513,33 @@ var MailDatabase = class {
1528
1513
  known.add(item.id);
1529
1514
  }
1530
1515
  }
1516
+ const seedRow = this.open().prepare(
1517
+ 'SELECT in_reply_to, "references", raw_eml FROM emails WHERE lower(message_id) = ? LIMIT 1'
1518
+ ).get(seedId);
1519
+ const parents = `${seedRow?.references ?? ""} ${seedRow?.in_reply_to ?? ""}`.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
1520
+ if (parents.length === 0 && seedRow?.raw_eml !== void 0 && seedRow.raw_eml !== null) {
1521
+ for (const header of ["references", "in-reply-to"]) {
1522
+ for (const found of headerIds(seedRow.raw_eml, header)) parents.push(found);
1523
+ }
1524
+ }
1525
+ if (parents.length > 0) {
1526
+ const marks = parents.map(() => "?").join(",");
1527
+ const ancestors = this.open().prepare(
1528
+ `${select}
1529
+ WHERE lower(e.message_id) IN (${marks}) LIMIT 100`
1530
+ ).all(...parents);
1531
+ for (const row of ancestors) {
1532
+ const item = rowToSummary(row);
1533
+ if (!known.has(item.id)) {
1534
+ items.push(item);
1535
+ known.add(item.id);
1536
+ }
1537
+ }
1538
+ }
1539
+ } catch {
1531
1540
  }
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
1541
  }
1542
+ return items.slice(0, limit);
1537
1543
  }
1538
1544
  /** Read (and cache) In-Reply-To / References for a set of local ids. */
1539
1545
  referencesFor(ids) {
@@ -1792,7 +1798,117 @@ function rowToSummary(row) {
1792
1798
  };
1793
1799
  }
1794
1800
 
1801
+ // src/host/references.ts
1802
+ function extractReferences(raw, messageId) {
1803
+ const empty = { inReplyTo: null, references: null };
1804
+ if (raw === null || raw === void 0) return empty;
1805
+ const text2 = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 2e5))).toString("utf8");
1806
+ const separator = text2.search(/\r?\n\r?\n/);
1807
+ const head = separator >= 0 ? text2.slice(0, separator) : text2;
1808
+ const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
1809
+ const value = (name2) => {
1810
+ const match = new RegExp(`^${name2}:\\s*(.*)$`, "im").exec(unfolded);
1811
+ return match === null ? "" : String(match[1] ?? "");
1812
+ };
1813
+ const ids = (value2) => value2.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
1814
+ const referenceIds = ids(value("references"));
1815
+ const replyTo = ids(value("in-reply-to"));
1816
+ const filteredRefs = referenceIds.filter((id) => id !== messageId.toLowerCase());
1817
+ return {
1818
+ inReplyTo: replyTo[0] ?? null,
1819
+ references: filteredRefs.length > 0 ? filteredRefs.join(" ") : null
1820
+ };
1821
+ }
1822
+ function backfillReferences(file, batchSize = 300) {
1823
+ ensureMailSchema(file);
1824
+ const db = new DatabaseSync3(file);
1825
+ try {
1826
+ db.exec("PRAGMA busy_timeout = 10000");
1827
+ const rows = db.prepare("SELECT id, message_id, raw_eml FROM emails WHERE refs_indexed = 0 ORDER BY id LIMIT ?").all(batchSize);
1828
+ if (rows.length === 0) return { updated: 0, remaining: 0 };
1829
+ const update = db.prepare(
1830
+ 'UPDATE emails SET in_reply_to = ?, "references" = ?, refs_indexed = 1 WHERE id = ?'
1831
+ );
1832
+ db.exec("BEGIN");
1833
+ try {
1834
+ for (const row of rows) {
1835
+ const headers = extractReferences(row.raw_eml, String(row.message_id ?? ""));
1836
+ update.run(headers.inReplyTo, headers.references, Number(row.id));
1837
+ }
1838
+ db.exec("COMMIT");
1839
+ } catch (error) {
1840
+ db.exec("ROLLBACK");
1841
+ throw error;
1842
+ }
1843
+ const remaining = Number(
1844
+ db.prepare("SELECT COUNT(*) AS count FROM emails WHERE refs_indexed = 0").get()?.count ?? 0
1845
+ );
1846
+ return { updated: rows.length, remaining };
1847
+ } finally {
1848
+ db.close();
1849
+ }
1850
+ }
1851
+ function optimizeDatabase(file) {
1852
+ ensureMailSchema(file);
1853
+ const db = new DatabaseSync3(file);
1854
+ try {
1855
+ db.exec("PRAGMA busy_timeout = 10000");
1856
+ db.exec("PRAGMA optimize");
1857
+ try {
1858
+ db.exec("INSERT INTO emails_fts(emails_fts) VALUES('optimize')");
1859
+ } catch {
1860
+ }
1861
+ try {
1862
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1863
+ } catch {
1864
+ }
1865
+ return { ok: true };
1866
+ } catch (error) {
1867
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1868
+ } finally {
1869
+ db.close();
1870
+ }
1871
+ }
1872
+ function backfillSubjects(file, batchSize = 300) {
1873
+ ensureMailSchema(file);
1874
+ const db = new DatabaseSync3(file);
1875
+ try {
1876
+ db.exec("PRAGMA busy_timeout = 10000");
1877
+ const rows = db.prepare(
1878
+ `SELECT e.id, e.subject FROM emails e
1879
+ WHERE NOT EXISTS (SELECT 1 FROM email_subjects s WHERE s.email_id = e.id)
1880
+ ORDER BY e.id LIMIT ?`
1881
+ ).all(batchSize);
1882
+ if (rows.length === 0) return { updated: 0, remaining: 0 };
1883
+ const insert = db.prepare("INSERT OR REPLACE INTO email_subjects (email_id, key) VALUES (?, ?)");
1884
+ db.exec("BEGIN");
1885
+ try {
1886
+ for (const row of rows) insert.run(Number(row.id), subjectKey(String(row.subject ?? "")));
1887
+ db.exec("COMMIT");
1888
+ } catch (error) {
1889
+ db.exec("ROLLBACK");
1890
+ throw error;
1891
+ }
1892
+ const remaining = Number(
1893
+ db.prepare(
1894
+ `SELECT COUNT(*) AS count FROM emails e
1895
+ WHERE NOT EXISTS (SELECT 1 FROM email_subjects s WHERE s.email_id = e.id)`
1896
+ ).get()?.count ?? 0
1897
+ );
1898
+ return { updated: rows.length, remaining };
1899
+ } finally {
1900
+ db.close();
1901
+ }
1902
+ }
1903
+
1795
1904
  // src/host/backup.ts
1905
+ init_address();
1906
+ import { createHash as createHash2 } from "node:crypto";
1907
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
1908
+ import { join as join3 } from "node:path";
1909
+ import { ImapFlow } from "imapflow";
1910
+ import { simpleParser } from "mailparser";
1911
+ import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
1796
1912
  var BATCH = 25;
1797
1913
  function sha256(data) {
1798
1914
  return createHash2("sha256").update(data).digest("hex");
@@ -1874,7 +1990,7 @@ var BackupEngine = class {
1874
1990
  }
1875
1991
  openDb() {
1876
1992
  ensureMailSchema(this.runtime.mailDb);
1877
- const db = new DatabaseSync3(this.runtime.mailDb);
1993
+ const db = new DatabaseSync4(this.runtime.mailDb);
1878
1994
  db.exec("PRAGMA journal_mode=WAL");
1879
1995
  db.exec("PRAGMA busy_timeout=10000");
1880
1996
  db.exec("PRAGMA synchronous=NORMAL");
@@ -1923,6 +2039,10 @@ var BackupEngine = class {
1923
2039
  );
1924
2040
  if (inserted.changes === 0) return 0;
1925
2041
  const emailId = Number(inserted.lastInsertRowid);
2042
+ db.prepare("INSERT OR REPLACE INTO email_subjects (email_id, key) VALUES (?, ?)").run(
2043
+ emailId,
2044
+ subjectKey(subject)
2045
+ );
1926
2046
  const attachments = parsed.attachments ?? [];
1927
2047
  const insertAttachment = db.prepare(
1928
2048
  "INSERT INTO attachments (email_id, filename, content_type, size, hash, content_id, disposition) VALUES (?, ?, ?, ?, ?, ?, ?)"
@@ -2128,7 +2248,7 @@ var BackupEngine = class {
2128
2248
  };
2129
2249
  if (!existsSync3(dbFile)) return empty;
2130
2250
  try {
2131
- const db = new DatabaseSync3(dbFile, { readOnly: true });
2251
+ const db = new DatabaseSync4(dbFile, { readOnly: true });
2132
2252
  try {
2133
2253
  const latest = db.prepare("SELECT * FROM backup_logs ORDER BY id DESC LIMIT 1").get();
2134
2254
  const success = db.prepare("SELECT completed_at FROM backup_logs WHERE status = 'completed' ORDER BY id DESC LIMIT 1").get();
@@ -3004,7 +3124,7 @@ ${conversation.trim()}
3004
3124
  }
3005
3125
 
3006
3126
  // src/host/version.ts
3007
- var VERSION = "0.19.1";
3127
+ var VERSION = "0.21.0";
3008
3128
 
3009
3129
  // src/host/api.ts
3010
3130
  init_smtp();
@@ -3158,6 +3278,13 @@ function buildRouter(runtime) {
3158
3278
  try {
3159
3279
  await runtime.provider(account).setFlag(detail.mailbox, detail.uid, "\\Flagged", starred);
3160
3280
  } catch {
3281
+ runtime.actionQueue(account).enqueue({
3282
+ type: "flag",
3283
+ mailbox: detail.mailbox,
3284
+ uid: detail.uid,
3285
+ flag: "\\Flagged",
3286
+ value: starred
3287
+ });
3161
3288
  }
3162
3289
  }
3163
3290
  return { starred };
@@ -3173,6 +3300,12 @@ function buildRouter(runtime) {
3173
3300
  try {
3174
3301
  await runtime.provider(account).move(detail.mailbox, detail.uid, target);
3175
3302
  } catch {
3303
+ runtime.actionQueue(account).enqueue({
3304
+ type: "move",
3305
+ mailbox: detail.mailbox,
3306
+ uid: detail.uid,
3307
+ target
3308
+ });
3176
3309
  }
3177
3310
  }
3178
3311
  moveLocalMessage(runtime.paths(account).mailDb, Number(params.id), target);
@@ -3198,6 +3331,13 @@ function buildRouter(runtime) {
3198
3331
  try {
3199
3332
  await runtime.provider(account).setSeen(detail.mailbox, detail.uid, action === "read");
3200
3333
  } catch {
3334
+ runtime.actionQueue(account).enqueue({
3335
+ type: "flag",
3336
+ mailbox: detail.mailbox,
3337
+ uid: detail.uid,
3338
+ flag: "\\Seen",
3339
+ value: action === "read"
3340
+ });
3201
3341
  }
3202
3342
  }
3203
3343
  count += 1;
@@ -3208,6 +3348,12 @@ function buildRouter(runtime) {
3208
3348
  try {
3209
3349
  await runtime.provider(account).move(detail.mailbox, detail.uid, target);
3210
3350
  } catch {
3351
+ runtime.actionQueue(account).enqueue({
3352
+ type: "move",
3353
+ mailbox: detail.mailbox,
3354
+ uid: detail.uid,
3355
+ target
3356
+ });
3211
3357
  }
3212
3358
  }
3213
3359
  moveLocalMessage(runtime.paths(account).mailDb, Number(detail.id), target);
@@ -3219,6 +3365,13 @@ function buildRouter(runtime) {
3219
3365
  try {
3220
3366
  await runtime.provider(account).setFlag(detail.mailbox, detail.uid, "\\Flagged", starred);
3221
3367
  } catch {
3368
+ runtime.actionQueue(account).enqueue({
3369
+ type: "flag",
3370
+ mailbox: detail.mailbox,
3371
+ uid: detail.uid,
3372
+ flag: "\\Flagged",
3373
+ value: starred
3374
+ });
3222
3375
  }
3223
3376
  }
3224
3377
  count += 1;
@@ -3226,6 +3379,10 @@ function buildRouter(runtime) {
3226
3379
  }
3227
3380
  return { ok: true, count };
3228
3381
  });
3382
+ router.post("/maintenance/optimize", ({ url }) => {
3383
+ const { account } = accountFrom(runtime, url);
3384
+ return optimizeDatabase(runtime.paths(account).mailDb);
3385
+ });
3229
3386
  router.get("/maintenance/references", ({ url }) => {
3230
3387
  const { account } = accountFrom(runtime, url);
3231
3388
  return runtime.referencesStatus(account);
@@ -3836,7 +3993,49 @@ function buildRouter(runtime) {
3836
3993
  throw aiHttpError(error);
3837
3994
  }
3838
3995
  });
3839
- router.get("/projects", () => ({ items: runtime.projects().list().map(projectView) }));
3996
+ router.post("/projects/:id/attachment", async ({ params, body }) => {
3997
+ const payload = await body();
3998
+ const account = accountFromPayload(runtime, payload);
3999
+ const project = runtime.projects().get(String(params.id ?? ""));
4000
+ if (project === void 0) throw new HttpError(404, "\u9879\u76EE\u4E0D\u5B58\u5728");
4001
+ const emailId = Number(payload.emailId ?? 0);
4002
+ const attachmentId = Number(payload.attachmentId ?? 0);
4003
+ const file = runtime.database(account).attachment(emailId, attachmentId);
4004
+ if (file === void 0) throw new HttpError(404, "\u9644\u4EF6\u4E0D\u5B58\u5728");
4005
+ const dir = join6(project.path, "attachments");
4006
+ mkdirSync5(dir, { recursive: true });
4007
+ const safe = basename(file.filename).replace(/[/\\:*?"<>|]/g, "_") || "attachment";
4008
+ let target = join6(dir, safe);
4009
+ let index = 1;
4010
+ while (existsSync6(target)) {
4011
+ const dot = safe.lastIndexOf(".");
4012
+ const stem = dot > 0 ? safe.slice(0, dot) : safe;
4013
+ const ext = dot > 0 ? safe.slice(dot) : "";
4014
+ target = join6(dir, `${stem}-${String(index)}${ext}`);
4015
+ index += 1;
4016
+ }
4017
+ writeFileSync5(target, Buffer.from(file.data));
4018
+ return { ok: true, path: target, filename: basename(target) };
4019
+ });
4020
+ router.get("/projects", () => {
4021
+ const counts = /* @__PURE__ */ new Map();
4022
+ for (const account of runtime.current.accounts) {
4023
+ for (const task of runtime.tasks().list({ accountId: account.id })) {
4024
+ const projectId = task.projectId ?? "";
4025
+ if (projectId === "") continue;
4026
+ const entry = counts.get(projectId) ?? { total: 0, done: 0 };
4027
+ entry.total += 1;
4028
+ if (task.status === "COMPLETED") entry.done += 1;
4029
+ counts.set(projectId, entry);
4030
+ }
4031
+ }
4032
+ return {
4033
+ items: runtime.projects().list().map((project) => {
4034
+ const stat = counts.get(project.id);
4035
+ return { ...projectView(project), taskTotal: stat?.total ?? 0, taskDone: stat?.done ?? 0 };
4036
+ })
4037
+ };
4038
+ });
3840
4039
  router.get("/projects/for", ({ url }) => {
3841
4040
  const account = accountFromPayload(runtime, { accountId: url.searchParams.get("accountId") ?? void 0 });
3842
4041
  const key = url.searchParams.get("key") ?? "";
@@ -4276,7 +4475,7 @@ function writeAttachment(res, filename, contentType, data, inline = false) {
4276
4475
  "content-disposition",
4277
4476
  `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(filename)}`
4278
4477
  );
4279
- res.setHeader("cache-control", "no-store");
4478
+ res.setHeader("cache-control", "private, max-age=86400");
4280
4479
  res.setHeader("x-content-type-options", "nosniff");
4281
4480
  res.setHeader(
4282
4481
  "content-security-policy",
@@ -4349,7 +4548,7 @@ ${signature}`;
4349
4548
 
4350
4549
  // src/host/runtime.ts
4351
4550
  import { existsSync as existsSync8, statSync } from "node:fs";
4352
- import { join as join7 } from "node:path";
4551
+ import { join as join8 } from "node:path";
4353
4552
 
4354
4553
  // src/host/secrets.ts
4355
4554
  import { spawnSync } from "node:child_process";
@@ -4539,7 +4738,7 @@ var CredentialManager = class {
4539
4738
  };
4540
4739
 
4541
4740
  // src/host/ai-store.ts
4542
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync5 } from "node:fs";
4741
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "node:fs";
4543
4742
  import { dirname as dirname2 } from "node:path";
4544
4743
  var MAX_THREADS = 50;
4545
4744
  var MAX_CARDS_PER_THREAD = 50;
@@ -4567,9 +4766,9 @@ var AiThreadStore = class {
4567
4766
  prune(data);
4568
4767
  const text2 = `${JSON.stringify(data, null, 2)}
4569
4768
  `;
4570
- mkdirSync5(dirname2(this.file), { recursive: true });
4769
+ mkdirSync6(dirname2(this.file), { recursive: true });
4571
4770
  const temp = `${this.file}.${String(process.pid)}.tmp`;
4572
- writeFileSync5(temp, text2, { mode: 384 });
4771
+ writeFileSync6(temp, text2, { mode: 384 });
4573
4772
  renameSync4(temp, this.file);
4574
4773
  }
4575
4774
  get(key) {
@@ -4679,7 +4878,7 @@ function prune(data) {
4679
4878
  }
4680
4879
 
4681
4880
  // src/host/mail-flags.ts
4682
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync6, renameSync as renameSync5, writeFileSync as writeFileSync6 } from "node:fs";
4881
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, renameSync as renameSync5, writeFileSync as writeFileSync7 } from "node:fs";
4683
4882
  import { dirname as dirname3 } from "node:path";
4684
4883
  var MailFlagsStore = class {
4685
4884
  file;
@@ -4699,9 +4898,9 @@ var MailFlagsStore = class {
4699
4898
  return this.keys;
4700
4899
  }
4701
4900
  flush() {
4702
- mkdirSync6(dirname3(this.file), { recursive: true });
4901
+ mkdirSync7(dirname3(this.file), { recursive: true });
4703
4902
  const temp = `${this.file}.${String(process.pid)}.tmp`;
4704
- writeFileSync6(temp, `${JSON.stringify({ starred: [...this.load()] }, null, 2)}
4903
+ writeFileSync7(temp, `${JSON.stringify({ starred: [...this.load()] }, null, 2)}
4705
4904
  `, {
4706
4905
  encoding: "utf8",
4707
4906
  mode: 384
@@ -4730,62 +4929,87 @@ var MailFlagsStore = class {
4730
4929
  }
4731
4930
  };
4732
4931
 
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");
4932
+ // src/host/action-queue.ts
4933
+ import { randomUUID as randomUUID4 } from "node:crypto";
4934
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync7, renameSync as renameSync6, writeFileSync as writeFileSync8 } from "node:fs";
4935
+ import { dirname as dirname4 } from "node:path";
4936
+ var ActionQueueStore = class {
4937
+ file;
4938
+ items = null;
4939
+ constructor(file) {
4940
+ this.file = file;
4941
+ }
4942
+ load() {
4943
+ if (this.items !== null) return this.items;
4766
4944
  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;
4945
+ const parsed = JSON.parse(readFileSync7(this.file, "utf8"));
4946
+ this.items = Array.isArray(parsed) ? parsed.filter((item) => typeof item?.id === "string") : [];
4947
+ } catch {
4948
+ this.items = [];
4775
4949
  }
4776
- const remaining = Number(
4777
- db.prepare("SELECT COUNT(*) AS count FROM emails WHERE refs_indexed = 0").get()?.count ?? 0
4950
+ return this.items;
4951
+ }
4952
+ flush() {
4953
+ mkdirSync8(dirname4(this.file), { recursive: true });
4954
+ const temp = `${this.file}.${String(process.pid)}.tmp`;
4955
+ writeFileSync8(temp, `${JSON.stringify(this.load(), null, 2)}
4956
+ `, { encoding: "utf8", mode: 384 });
4957
+ renameSync6(temp, this.file);
4958
+ }
4959
+ list() {
4960
+ return [...this.load()];
4961
+ }
4962
+ enqueue(action) {
4963
+ const items = this.load();
4964
+ const filtered = items.filter(
4965
+ (item) => !(item.type === action.type && item.mailbox === action.mailbox && item.uid === action.uid && (action.type === "move" || item.flag === action.flag))
4778
4966
  );
4779
- return { updated: rows.length, remaining };
4780
- } finally {
4781
- db.close();
4967
+ filtered.push({ ...action, id: randomUUID4(), createdAt: (/* @__PURE__ */ new Date()).toISOString() });
4968
+ this.items = filtered;
4969
+ this.flush();
4782
4970
  }
4783
- }
4971
+ remove(ids) {
4972
+ if (ids.length === 0) return;
4973
+ const set = new Set(ids);
4974
+ this.items = this.load().filter((item) => !set.has(item.id));
4975
+ this.flush();
4976
+ }
4977
+ /** Try every queued action; keep the ones the server still rejects. */
4978
+ async flushTo(executor) {
4979
+ const pending = this.list();
4980
+ const done = [];
4981
+ let failed = 0;
4982
+ for (const action of pending) {
4983
+ try {
4984
+ if (action.type === "flag") {
4985
+ await executor.setFlag(
4986
+ action.mailbox,
4987
+ action.uid,
4988
+ action.flag ?? "\\Flagged",
4989
+ action.value !== false
4990
+ );
4991
+ } else if (action.target !== void 0) {
4992
+ await executor.move(action.mailbox, action.uid, action.target);
4993
+ }
4994
+ done.push(action.id);
4995
+ } catch {
4996
+ failed += 1;
4997
+ const current = this.load().find((item) => item.id === action.id);
4998
+ if (current !== void 0) {
4999
+ current.attempts = (current.attempts ?? 0) + 1;
5000
+ if (current.attempts >= 5) done.push(action.id);
5001
+ }
5002
+ }
5003
+ }
5004
+ this.remove(done);
5005
+ return { sent: done.length, failed };
5006
+ }
5007
+ };
4784
5008
 
4785
5009
  // src/host/tasks.ts
4786
- import { randomUUID as randomUUID4 } from "node:crypto";
4787
- import { mkdirSync as mkdirSync7 } from "node:fs";
4788
- import { dirname as dirname4 } from "node:path";
5010
+ import { randomUUID as randomUUID5 } from "node:crypto";
5011
+ import { mkdirSync as mkdirSync9 } from "node:fs";
5012
+ import { dirname as dirname5 } from "node:path";
4789
5013
  import { DatabaseSync as DatabaseSync5 } from "node:sqlite";
4790
5014
  var STATUSES = /* @__PURE__ */ new Set(["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"]);
4791
5015
  function now() {
@@ -4834,7 +5058,7 @@ var TaskStore = class {
4834
5058
  }
4835
5059
  open() {
4836
5060
  if (this.db !== null) return this.db;
4837
- mkdirSync7(dirname4(this.file), { recursive: true });
5061
+ mkdirSync9(dirname5(this.file), { recursive: true });
4838
5062
  const db = new DatabaseSync5(this.file);
4839
5063
  db.exec("PRAGMA journal_mode=WAL");
4840
5064
  db.exec("PRAGMA busy_timeout=10000");
@@ -4911,7 +5135,7 @@ var TaskStore = class {
4911
5135
  if (title === "") throw new Error("\u4EFB\u52A1\u6807\u9898\u4E0D\u80FD\u4E3A\u7A7A");
4912
5136
  if (title.length > 300) throw new Error("\u4EFB\u52A1\u6807\u9898\u8FC7\u957F");
4913
5137
  const status = statusOf(input.status);
4914
- const id = `task_${randomUUID4()}`;
5138
+ const id = `task_${randomUUID5()}`;
4915
5139
  const stamp3 = now();
4916
5140
  this.open().prepare(
4917
5141
  `INSERT INTO tasks (id, account_id, project_id, uid, title, notes, due, status, priority, owner,
@@ -4921,7 +5145,7 @@ var TaskStore = class {
4921
5145
  id,
4922
5146
  input.accountId,
4923
5147
  input.projectId ?? null,
4924
- `${randomUUID4()}@dsh-mailharbor`,
5148
+ `${randomUUID5()}@dsh-mailharbor`,
4925
5149
  title,
4926
5150
  String(input.notes ?? ""),
4927
5151
  String(input.due ?? ""),
@@ -5017,7 +5241,7 @@ var TaskStore = class {
5017
5241
  );
5018
5242
  return this.get(String(existing.id));
5019
5243
  }
5020
- const id = `task_${randomUUID4()}`;
5244
+ const id = `task_${randomUUID5()}`;
5021
5245
  const stamp3 = now();
5022
5246
  db.prepare(
5023
5247
  `INSERT INTO tasks (id, account_id, calendar_href, href, etag, uid, title, notes, due, status, priority,
@@ -5029,7 +5253,7 @@ var TaskStore = class {
5029
5253
  input.calendarHref,
5030
5254
  input.href,
5031
5255
  input.etag,
5032
- input.todo.uid || `${randomUUID4()}@dsh-mailharbor`,
5256
+ input.todo.uid || `${randomUUID5()}@dsh-mailharbor`,
5033
5257
  String(input.todo.title || "(\u65E0\u6807\u9898)"),
5034
5258
  String(input.todo.notes ?? ""),
5035
5259
  String(input.todo.due ?? ""),
@@ -5073,9 +5297,9 @@ var TaskStore = class {
5073
5297
  };
5074
5298
 
5075
5299
  // src/host/drafts.ts
5076
- import { randomUUID as randomUUID5 } from "node:crypto";
5077
- import { mkdirSync as mkdirSync8, readFileSync as readFileSync7, renameSync as renameSync6, writeFileSync as writeFileSync7 } from "node:fs";
5078
- import { dirname as dirname5, join as join6 } from "node:path";
5300
+ import { randomUUID as randomUUID6 } from "node:crypto";
5301
+ import { mkdirSync as mkdirSync10, readFileSync as readFileSync8, renameSync as renameSync7, writeFileSync as writeFileSync9 } from "node:fs";
5302
+ import { dirname as dirname6, join as join7 } from "node:path";
5079
5303
  function text(value) {
5080
5304
  return value === void 0 || value === null ? "" : String(value);
5081
5305
  }
@@ -5086,7 +5310,7 @@ function isDraft(value) {
5086
5310
  return value !== null && typeof value === "object" && typeof value.id === "string";
5087
5311
  }
5088
5312
  function draftPath(root) {
5089
- return join6(root, "drafts.json");
5313
+ return join7(root, "drafts.json");
5090
5314
  }
5091
5315
  var DraftStore = class {
5092
5316
  file;
@@ -5099,18 +5323,18 @@ var DraftStore = class {
5099
5323
  }
5100
5324
  load() {
5101
5325
  try {
5102
- const parsed = JSON.parse(readFileSync7(this.file, "utf8"));
5326
+ const parsed = JSON.parse(readFileSync8(this.file, "utf8"));
5103
5327
  this.items = Array.isArray(parsed) ? parsed.filter(isDraft) : [];
5104
5328
  } catch {
5105
5329
  this.items = [];
5106
5330
  }
5107
5331
  }
5108
5332
  flush() {
5109
- mkdirSync8(dirname5(this.file), { recursive: true });
5333
+ mkdirSync10(dirname6(this.file), { recursive: true });
5110
5334
  const temp = `${this.file}.${String(process.pid)}.${String(Date.now())}.tmp`;
5111
- writeFileSync7(temp, `${JSON.stringify(this.items, null, 2)}
5335
+ writeFileSync9(temp, `${JSON.stringify(this.items, null, 2)}
5112
5336
  `, { encoding: "utf8", mode: 384 });
5113
- renameSync6(temp, this.file);
5337
+ renameSync7(temp, this.file);
5114
5338
  }
5115
5339
  list() {
5116
5340
  return [...this.items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).map((draft) => ({ ...draft }));
@@ -5144,7 +5368,7 @@ var DraftStore = class {
5144
5368
  return { ...existing };
5145
5369
  }
5146
5370
  const draft = {
5147
- id: randomUUID5(),
5371
+ id: randomUUID6(),
5148
5372
  accountId: this.accountId,
5149
5373
  ...fields,
5150
5374
  updatedAt: now2
@@ -5169,8 +5393,8 @@ var DraftStore = class {
5169
5393
  init_imap();
5170
5394
 
5171
5395
  // src/host/read-state.ts
5172
- import { mkdirSync as mkdirSync9, readFileSync as readFileSync8, renameSync as renameSync7, writeFileSync as writeFileSync8 } from "node:fs";
5173
- import { dirname as dirname6 } from "node:path";
5396
+ import { mkdirSync as mkdirSync11, readFileSync as readFileSync9, renameSync as renameSync8, writeFileSync as writeFileSync10 } from "node:fs";
5397
+ import { dirname as dirname7 } from "node:path";
5174
5398
  var ReadStateStore = class {
5175
5399
  file;
5176
5400
  keys = null;
@@ -5180,7 +5404,7 @@ var ReadStateStore = class {
5180
5404
  load() {
5181
5405
  if (this.keys !== null) return this.keys;
5182
5406
  try {
5183
- const parsed = JSON.parse(readFileSync8(this.file, "utf8"));
5407
+ const parsed = JSON.parse(readFileSync9(this.file, "utf8"));
5184
5408
  const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.unread) ? parsed.unread : [];
5185
5409
  this.keys = new Set(list.filter((key) => typeof key === "string" && key !== ""));
5186
5410
  } catch {
@@ -5189,14 +5413,14 @@ var ReadStateStore = class {
5189
5413
  return this.keys;
5190
5414
  }
5191
5415
  flush() {
5192
- mkdirSync9(dirname6(this.file), { recursive: true });
5416
+ mkdirSync11(dirname7(this.file), { recursive: true });
5193
5417
  const temp = `${this.file}.${String(process.pid)}.tmp`;
5194
- writeFileSync8(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
5418
+ writeFileSync10(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
5195
5419
  `, {
5196
5420
  encoding: "utf8",
5197
5421
  mode: 384
5198
5422
  });
5199
- renameSync7(temp, this.file);
5423
+ renameSync8(temp, this.file);
5200
5424
  }
5201
5425
  isUnread(key) {
5202
5426
  return key !== "" && this.load().has(key);
@@ -5261,6 +5485,7 @@ var MailHarborRuntime = class {
5261
5485
  projectStore = null;
5262
5486
  taskStore = null;
5263
5487
  flagStores = /* @__PURE__ */ new Map();
5488
+ actionQueues = /* @__PURE__ */ new Map();
5264
5489
  referenceJobs = /* @__PURE__ */ new Map();
5265
5490
  disposed = false;
5266
5491
  constructor(options = {}) {
@@ -5275,13 +5500,13 @@ var MailHarborRuntime = class {
5275
5500
  }
5276
5501
  projects() {
5277
5502
  if (this.projectStore === null) {
5278
- this.projectStore = new ProjectStore(join7(this.config.dataDir, "projects.json"));
5503
+ this.projectStore = new ProjectStore(join8(this.config.dataDir, "projects.json"));
5279
5504
  }
5280
5505
  return this.projectStore;
5281
5506
  }
5282
5507
  tasks() {
5283
5508
  if (this.taskStore === null) {
5284
- this.taskStore = new TaskStore(join7(this.config.dataDir, "state", "state.db"));
5509
+ this.taskStore = new TaskStore(join8(this.config.dataDir, "state", "state.db"));
5285
5510
  }
5286
5511
  return this.taskStore;
5287
5512
  }
@@ -5361,6 +5586,22 @@ var MailHarborRuntime = class {
5361
5586
  paths(account) {
5362
5587
  return accountRuntime(account);
5363
5588
  }
5589
+ actionQueue(account) {
5590
+ const file = join8(this.paths(account).root, "actions-queue.json");
5591
+ const existing = this.actionQueues.get(account.id);
5592
+ if (existing !== void 0 && existing.signature === file) return existing.value;
5593
+ const created = new ActionQueueStore(file);
5594
+ this.actionQueues.set(account.id, { signature: file, value: created });
5595
+ return created;
5596
+ }
5597
+ async flushActions(account) {
5598
+ if (!this.hasCredentials(account)) return { sent: 0, failed: 0 };
5599
+ try {
5600
+ return await this.actionQueue(account).flushTo(this.provider(account));
5601
+ } catch {
5602
+ return { sent: 0, failed: 0 };
5603
+ }
5604
+ }
5364
5605
  referencesStatus(account) {
5365
5606
  return this.referenceJobs.get(account.id) ?? {
5366
5607
  running: false,
@@ -5391,6 +5632,13 @@ var MailHarborRuntime = class {
5391
5632
  if (result.remaining <= 0 || result.updated === 0) break;
5392
5633
  await new Promise((resolve) => setTimeout(resolve, 40));
5393
5634
  }
5635
+ for (; ; ) {
5636
+ const result = backfillSubjects(dbFile, 300);
5637
+ status.indexed += result.updated;
5638
+ status.remaining = result.remaining;
5639
+ if (result.remaining <= 0 || result.updated === 0) break;
5640
+ await new Promise((resolve) => setTimeout(resolve, 40));
5641
+ }
5394
5642
  } catch (error) {
5395
5643
  status.error = error instanceof Error ? error.message : String(error);
5396
5644
  } finally {
@@ -5443,7 +5691,7 @@ var MailHarborRuntime = class {
5443
5691
  return created;
5444
5692
  }
5445
5693
  aiThreads(account) {
5446
- const file = join7(this.paths(account).root, "ai-threads.json");
5694
+ const file = join8(this.paths(account).root, "ai-threads.json");
5447
5695
  const existing = this.aiStores.get(account.id);
5448
5696
  if (existing !== void 0 && existing.signature === file) return existing.value;
5449
5697
  const created = new AiThreadStore(file);
@@ -5451,7 +5699,7 @@ var MailHarborRuntime = class {
5451
5699
  return created;
5452
5700
  }
5453
5701
  flags(account) {
5454
- const file = join7(this.paths(account).root, "starred.json");
5702
+ const file = join8(this.paths(account).root, "starred.json");
5455
5703
  const existing = this.flagStores.get(account.id);
5456
5704
  if (existing !== void 0 && existing.signature === file) return existing.value;
5457
5705
  const created = new MailFlagsStore(file);
@@ -5459,7 +5707,7 @@ var MailHarborRuntime = class {
5459
5707
  return created;
5460
5708
  }
5461
5709
  readState(account) {
5462
- const file = join7(this.paths(account).root, "read-state.json");
5710
+ const file = join8(this.paths(account).root, "read-state.json");
5463
5711
  const existing = this.readStores.get(account.id);
5464
5712
  if (existing !== void 0 && existing.signature === file) return existing.value;
5465
5713
  const created = new ReadStateStore(file);
@@ -5525,6 +5773,7 @@ var MailHarborRuntime = class {
5525
5773
  if (status.exitCode === 0) status.lastSuccessAt = status.finishedAt;
5526
5774
  this.databases.get(account.id)?.value.close();
5527
5775
  this.databases.delete(account.id);
5776
+ if (status.exitCode === 0) void this.flushActions(account).catch(() => void 0);
5528
5777
  }
5529
5778
  })();
5530
5779
  this.backups.set(account.id, job);
@@ -5969,6 +6218,7 @@ function apply(ctx) {
5969
6218
  const disposers = [];
5970
6219
  if (activeAccount2 !== void 0) {
5971
6220
  setTimeout(() => runtime.startReferencesBackfill(activeAccount2), 3e3);
6221
+ setTimeout(() => void runtime.flushActions(activeAccount2).catch(() => void 0), 6e3);
5972
6222
  }
5973
6223
  const start = () => {
5974
6224
  disposers.push(ctx.webServer.register({ kind: "prefix", path: ROUTE, handler }));