@zaclinley/dsh-mailharbor 0.12.1 → 0.13.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
@@ -66,6 +66,44 @@ var init_address = __esm({
66
66
  }
67
67
  });
68
68
 
69
+ // src/host/inline-images.ts
70
+ function normalize(value) {
71
+ return value.trim().replace(/^<|>$/g, "").toLowerCase();
72
+ }
73
+ function dataUrl(image) {
74
+ if (image.data === void 0 || !image.contentType.toLowerCase().startsWith("image/")) return null;
75
+ if (image.data.byteLength > MAX_INLINE_BYTES) return null;
76
+ return `data:${image.contentType};base64,${Buffer.from(image.data).toString("base64")}`;
77
+ }
78
+ function inlineCidImages(html, images) {
79
+ if (!/cid:/i.test(html)) return html;
80
+ const byCid = /* @__PURE__ */ new Map();
81
+ const byName = [];
82
+ for (const image of images) {
83
+ const url = dataUrl(image);
84
+ if (url === null) continue;
85
+ if (image.cid !== void 0 && image.cid.trim() !== "") byCid.set(normalize(image.cid), url);
86
+ if (image.filename !== void 0 && image.filename.trim() !== "") {
87
+ byName.push({ name: normalize(image.filename), url });
88
+ }
89
+ }
90
+ if (byCid.size === 0 && byName.length === 0) return html;
91
+ return html.replace(/cid:([^"'\s)>]+)/gi, (match, rawCid) => {
92
+ const cid = normalize(rawCid);
93
+ const exact = byCid.get(cid);
94
+ if (exact !== void 0) return exact;
95
+ const named = byName.find((entry) => cid.includes(entry.name));
96
+ return named === void 0 ? match : named.url;
97
+ });
98
+ }
99
+ var MAX_INLINE_BYTES;
100
+ var init_inline_images = __esm({
101
+ "src/host/inline-images.ts"() {
102
+ "use strict";
103
+ MAX_INLINE_BYTES = 2e6;
104
+ }
105
+ });
106
+
69
107
  // src/host/smtp.ts
70
108
  var smtp_exports = {};
71
109
  __export(smtp_exports, {
@@ -221,6 +259,7 @@ var init_imap = __esm({
221
259
  "src/host/imap.ts"() {
222
260
  "use strict";
223
261
  init_address();
262
+ init_inline_images();
224
263
  ImapProvider = class _ImapProvider {
225
264
  settings;
226
265
  credentials;
@@ -451,10 +490,19 @@ var init_imap = __esm({
451
490
  const parsed = await simpleParser2(message.source);
452
491
  const base = toMessage(message);
453
492
  const html = typeof parsed.html === "string" ? parsed.html : Buffer.isBuffer(parsed.html) ? parsed.html.toString("utf8") : null;
493
+ const inlineImages = (parsed.attachments ?? []).filter((attachment) => {
494
+ const type = String(attachment.contentType ?? "").toLowerCase();
495
+ return type.startsWith("image/") && attachment.content !== void 0;
496
+ }).map((attachment) => ({
497
+ cid: attachment.cid,
498
+ filename: attachment.filename,
499
+ contentType: String(attachment.contentType ?? "image/png"),
500
+ data: Buffer.isBuffer(attachment.content) ? attachment.content : new Uint8Array(attachment.content)
501
+ }));
454
502
  const detail = {
455
503
  ...base,
456
504
  text: typeof parsed.text === "string" ? parsed.text : "",
457
- html: html === "" ? null : html,
505
+ html: html === null || html === "" ? null : inlineCidImages(html, inlineImages),
458
506
  attachments: attachmentList(parsed),
459
507
  inReplyTo: normalizeId2(parsed.inReplyTo),
460
508
  references: String(parsed.references ?? "").split(/\s+/).map(normalizeId2).filter((item) => item !== "")
@@ -513,6 +561,28 @@ var init_imap = __esm({
513
561
  }
514
562
  });
515
563
  }
564
+ async setFlag(mailbox, uid, flag, value) {
565
+ return this.run(async (client) => {
566
+ const lock = await client.getMailboxLock(mailbox);
567
+ try {
568
+ if (value) await client.messageFlagsAdd(String(uid), [flag], { uid: true });
569
+ else await client.messageFlagsRemove(String(uid), [flag], { uid: true });
570
+ } finally {
571
+ lock.release();
572
+ }
573
+ });
574
+ }
575
+ /** Move one message to another mailbox (server-side). */
576
+ async move(mailbox, uid, target) {
577
+ return this.run(async (client) => {
578
+ const lock = await client.getMailboxLock(mailbox);
579
+ try {
580
+ await client.messageMove(String(uid), target, { uid: true });
581
+ } finally {
582
+ lock.release();
583
+ }
584
+ });
585
+ }
516
586
  async setSeen(mailbox, uid, seen) {
517
587
  this.pageCache.clear();
518
588
  return this.run(async (client) => {
@@ -920,6 +990,18 @@ async function openPath(path) {
920
990
  await run("xdg-open", [path]);
921
991
  }
922
992
 
993
+ // src/host/mail-write.ts
994
+ import { DatabaseSync } from "node:sqlite";
995
+ function moveLocalMessage(file, id, mailbox) {
996
+ const db = new DatabaseSync(file);
997
+ try {
998
+ db.exec("PRAGMA busy_timeout = 5000");
999
+ db.prepare("UPDATE emails SET mailbox = ? WHERE id = ?").run(mailbox, id);
1000
+ } finally {
1001
+ db.close();
1002
+ }
1003
+ }
1004
+
923
1005
  // src/host/backup.ts
924
1006
  init_address();
925
1007
  import { createHash as createHash2 } from "node:crypto";
@@ -927,12 +1009,13 @@ import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as wr
927
1009
  import { join as join3 } from "node:path";
928
1010
  import { ImapFlow } from "imapflow";
929
1011
  import { simpleParser } from "mailparser";
930
- import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
1012
+ import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
931
1013
 
932
1014
  // src/host/mail-db.ts
933
1015
  init_address();
1016
+ init_inline_images();
934
1017
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
935
- import { DatabaseSync } from "node:sqlite";
1018
+ import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
936
1019
  var SCHEMA = `
937
1020
  CREATE TABLE IF NOT EXISTS emails (
938
1021
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -990,7 +1073,7 @@ var FTS = `CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5(
990
1073
  content='', tokenize='trigram'
991
1074
  )`;
992
1075
  function ensureMailSchema(file) {
993
- const db = new DatabaseSync(file);
1076
+ const db = new DatabaseSync2(file);
994
1077
  try {
995
1078
  db.exec("PRAGMA journal_mode=WAL");
996
1079
  db.exec(SCHEMA);
@@ -1029,7 +1112,7 @@ var MailDatabase = class {
1029
1112
  open() {
1030
1113
  if (this.db !== void 0) return this.db;
1031
1114
  if (!existsSync2(this.file)) throw new Error(`\u90AE\u4EF6\u6570\u636E\u5E93\u4E0D\u5B58\u5728: ${this.file}`);
1032
- this.db = new DatabaseSync(this.file, { readOnly: true });
1115
+ this.db = new DatabaseSync2(this.file, { readOnly: true });
1033
1116
  this.db.exec("PRAGMA busy_timeout = 3000");
1034
1117
  return this.db;
1035
1118
  }
@@ -1101,6 +1184,23 @@ var MailDatabase = class {
1101
1184
  where.push("e.date >= ?");
1102
1185
  params.push(query.since);
1103
1186
  }
1187
+ if (query.unread !== void 0) {
1188
+ const mids = query.unread.mids.slice(0, 500);
1189
+ const ids = query.unread.ids.slice(0, 500);
1190
+ if (mids.length === 0 && ids.length === 0) {
1191
+ return { items: [], total: 0, offset, limit };
1192
+ }
1193
+ const parts = [];
1194
+ if (mids.length > 0) {
1195
+ parts.push(`e.message_id IN (${mids.map(() => "?").join(",")})`);
1196
+ params.push(...mids);
1197
+ }
1198
+ if (ids.length > 0) {
1199
+ parts.push(`e.id IN (${ids.map(() => "?").join(",")})`);
1200
+ params.push(...ids);
1201
+ }
1202
+ where.push(`(${parts.join(" OR ")})`);
1203
+ }
1104
1204
  const text2 = String(query.query ?? "").trim();
1105
1205
  if (text2 !== "") {
1106
1206
  const tokens = text2.split(/\s+/).filter(Boolean);
@@ -1187,6 +1287,28 @@ var MailDatabase = class {
1187
1287
  const rows = db.prepare(`${columns} FROM emails e ${clause} ORDER BY e.date DESC, e.id DESC LIMIT ?`).all(...params, limit);
1188
1288
  return { items: rows.map((row) => rowToSummary(row)), total, offset: 0, limit };
1189
1289
  }
1290
+ /** Number of unread keys that still exist in this backup. */
1291
+ countUnread(keys) {
1292
+ const mids = keys.mids.slice(0, 500);
1293
+ const ids = keys.ids.slice(0, 500);
1294
+ if (mids.length === 0 && ids.length === 0) return 0;
1295
+ const parts = [];
1296
+ const params = [];
1297
+ if (mids.length > 0) {
1298
+ parts.push(`message_id IN (${mids.map(() => "?").join(",")})`);
1299
+ params.push(...mids);
1300
+ }
1301
+ if (ids.length > 0) {
1302
+ parts.push(`id IN (${ids.map(() => "?").join(",")})`);
1303
+ params.push(...ids);
1304
+ }
1305
+ try {
1306
+ const row = this.open().prepare(`SELECT COUNT(*) AS count FROM emails WHERE ${parts.join(" OR ")}`).get(...params);
1307
+ return Number(row?.count ?? 0);
1308
+ } catch {
1309
+ return 0;
1310
+ }
1311
+ }
1190
1312
  get(localId) {
1191
1313
  const db = this.open();
1192
1314
  const row = db.prepare(
@@ -1195,9 +1317,14 @@ var MailDatabase = class {
1195
1317
  FROM emails WHERE id = ?`
1196
1318
  ).get(localId);
1197
1319
  if (row === void 0) return void 0;
1198
- const cidText = String(row.body_html ?? "").toLowerCase();
1320
+ const bodyHtml = String(row.body_html ?? "");
1321
+ const cidText = bodyHtml.toLowerCase();
1322
+ const wantsInline = cidText.includes("cid:");
1199
1323
  const attachmentColumns = this.hasAttachmentColumns() ? "id, filename, content_type, size, content_id, disposition" : "id, filename, content_type, size";
1200
- const attachments = db.prepare(`SELECT ${attachmentColumns} FROM attachments WHERE email_id = ? ORDER BY id`).all(localId).filter((item) => {
1324
+ const rows = db.prepare(
1325
+ `SELECT ${attachmentColumns}${wantsInline ? ", data, hash" : ""} FROM attachments WHERE email_id = ? ORDER BY id`
1326
+ ).all(localId);
1327
+ const attachments = rows.filter((item) => {
1201
1328
  const type = String(item.content_type ?? "").toLowerCase();
1202
1329
  if (!type.startsWith("image/")) return true;
1203
1330
  const disposition = String(item.disposition ?? "").toLowerCase();
@@ -1216,17 +1343,38 @@ var MailDatabase = class {
1216
1343
  }));
1217
1344
  const max = this.options.maxBodyChars ?? 4e5;
1218
1345
  const bodyText2 = String(row.body_text ?? "");
1219
- const bodyHtml = String(row.body_html ?? "");
1346
+ const inlineImages = wantsInline ? rows.filter(
1347
+ (item) => String(item.content_type ?? "").toLowerCase().startsWith("image/")
1348
+ ).map((item) => ({
1349
+ cid: item.content_id === null || item.content_id === void 0 ? void 0 : String(item.content_id),
1350
+ filename: String(item.filename ?? ""),
1351
+ contentType: String(item.content_type ?? "image/png"),
1352
+ data: this.attachmentBytes(item)
1353
+ })).filter((item) => item.data !== void 0) : [];
1220
1354
  return {
1221
1355
  ...rowToSummary(row),
1222
1356
  cc: parseAddressList(String(row.cc ?? "")),
1223
1357
  replyTo: [],
1224
1358
  text: truncate(bodyText2, max),
1225
- html: bodyHtml === "" ? null : truncate(bodyHtml, max * 2),
1359
+ html: bodyHtml === "" ? null : truncate(inlineCidImages(bodyHtml, inlineImages), max * 2),
1226
1360
  attachments,
1227
1361
  references: []
1228
1362
  };
1229
1363
  }
1364
+ attachmentBytes(row) {
1365
+ const data = row.data;
1366
+ if (data instanceof Uint8Array) return data;
1367
+ const hash = typeof row.hash === "string" ? row.hash : "";
1368
+ const root = this.options.attachmentsRoot ?? "";
1369
+ if (hash === "" || root === "") return void 0;
1370
+ const file = `${root}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`;
1371
+ if (!existsSync2(file)) return void 0;
1372
+ try {
1373
+ return readFileSyncSafe(file) ?? void 0;
1374
+ } catch {
1375
+ return void 0;
1376
+ }
1377
+ }
1230
1378
  /** Read one attachment's bytes; the payload may live in a file (content-addressed). */
1231
1379
  attachment(emailId, attachmentId) {
1232
1380
  const db = this.open();
@@ -1366,17 +1514,64 @@ var BackupEngine = class {
1366
1514
  runtime;
1367
1515
  credentials;
1368
1516
  batchSize;
1517
+ onFlags;
1369
1518
  constructor(runtime, credentials, options = {}) {
1370
1519
  this.runtime = runtime;
1371
1520
  this.credentials = credentials;
1372
1521
  this.batchSize = options.batchSize ?? BATCH;
1522
+ this.onFlags = options.onFlags;
1523
+ }
1524
+ /** Mirror IMAP \Seen flags into the local unread store. */
1525
+ async reconcileReadState(client) {
1526
+ if (this.onFlags === void 0) return;
1527
+ const exists = Number(client.mailbox?.exists ?? 0);
1528
+ if (exists === 0) return;
1529
+ const entries = /* @__PURE__ */ new Map();
1530
+ const add = (messageId, unread) => {
1531
+ if (messageId !== "") entries.set(`mid:${messageId}`, unread);
1532
+ };
1533
+ const flagsOf2 = (flags) => [...flags ?? []].map((flag) => String(flag).toLowerCase());
1534
+ const start = Math.max(1, exists - 299);
1535
+ try {
1536
+ for await (const message of client.fetch(`${String(start)}:${String(exists)}`, {
1537
+ uid: true,
1538
+ envelope: true,
1539
+ flags: true
1540
+ })) {
1541
+ const messageId = normalizeId(message.envelope?.messageId);
1542
+ add(messageId, !flagsOf2(message.flags).includes("\\seen"));
1543
+ }
1544
+ } catch {
1545
+ }
1546
+ try {
1547
+ const unseen = await client.search({ seen: false }, { uid: true });
1548
+ const uids = (Array.isArray(unseen) ? unseen : []).map(Number).filter((uid) => Number.isFinite(uid));
1549
+ for (let index = 0; index < uids.length; index += 200) {
1550
+ const range = uids.slice(index, index + 200).join(",");
1551
+ if (range === "") continue;
1552
+ for await (const message of client.fetch(
1553
+ range,
1554
+ { uid: true, envelope: true, flags: true },
1555
+ { uid: true }
1556
+ )) {
1557
+ const messageId = normalizeId(
1558
+ message.envelope?.messageId
1559
+ );
1560
+ add(messageId, true);
1561
+ }
1562
+ }
1563
+ } catch {
1564
+ }
1565
+ if (entries.size > 0) {
1566
+ this.onFlags([...entries.entries()].map(([key, unread]) => ({ key, unread })));
1567
+ }
1373
1568
  }
1374
1569
  attachmentsDir() {
1375
1570
  return this.runtime.attachments;
1376
1571
  }
1377
1572
  openDb() {
1378
1573
  ensureMailSchema(this.runtime.mailDb);
1379
- const db = new DatabaseSync2(this.runtime.mailDb);
1574
+ const db = new DatabaseSync3(this.runtime.mailDb);
1380
1575
  db.exec("PRAGMA journal_mode=WAL");
1381
1576
  db.exec("PRAGMA busy_timeout=10000");
1382
1577
  db.exec("PRAGMA synchronous=NORMAL");
@@ -1518,6 +1713,7 @@ var BackupEngine = class {
1518
1713
  abortIfNeeded(signal);
1519
1714
  const batch = uids.slice(offset, offset + this.batchSize);
1520
1715
  db.exec("BEGIN");
1716
+ const flagEntries = [];
1521
1717
  try {
1522
1718
  let batchLastUid = committedUid;
1523
1719
  for await (const message of client.fetch(
@@ -1538,6 +1734,13 @@ var BackupEngine = class {
1538
1734
  boxAdded += added;
1539
1735
  newCount += added;
1540
1736
  batchLastUid = Math.max(batchLastUid, Number(message.uid));
1737
+ const messageId = normalizeId(parsed.messageId ?? "");
1738
+ if (messageId !== "") {
1739
+ const flags = [...message.flags ?? []].map(
1740
+ (flag) => String(flag).toLowerCase()
1741
+ );
1742
+ flagEntries.push({ key: `mid:${messageId}`, unread: !flags.includes("\\seen") });
1743
+ }
1541
1744
  } catch (error) {
1542
1745
  failCount += 1;
1543
1746
  emit({
@@ -1548,6 +1751,7 @@ var BackupEngine = class {
1548
1751
  }
1549
1752
  this.saveState(db, path, uidValidity, batchLastUid);
1550
1753
  db.exec("COMMIT");
1754
+ if (flagEntries.length > 0) this.onFlags?.(flagEntries);
1551
1755
  committedUid = batchLastUid;
1552
1756
  emit({ mailbox: path, added: boxAdded, message: `${path}: \u5DF2\u65B0\u589E ${String(boxAdded)} \u5C01` });
1553
1757
  } catch (error) {
@@ -1555,6 +1759,7 @@ var BackupEngine = class {
1555
1759
  throw error;
1556
1760
  }
1557
1761
  }
1762
+ if (path.toUpperCase() === "INBOX") await this.reconcileReadState(client);
1558
1763
  mailboxes.push({ name: path, added: boxAdded });
1559
1764
  emit({ mailbox: path, added: boxAdded, message: `${path}: \u65B0\u589E ${String(boxAdded)} \u5C01` });
1560
1765
  } finally {
@@ -1618,7 +1823,7 @@ var BackupEngine = class {
1618
1823
  };
1619
1824
  if (!existsSync3(dbFile)) return empty;
1620
1825
  try {
1621
- const db = new DatabaseSync2(dbFile, { readOnly: true });
1826
+ const db = new DatabaseSync3(dbFile, { readOnly: true });
1622
1827
  try {
1623
1828
  const latest = db.prepare("SELECT * FROM backup_logs ORDER BY id DESC LIMIT 1").get();
1624
1829
  const success = db.prepare("SELECT completed_at FROM backup_logs WHERE status = 'completed' ORDER BY id DESC LIMIT 1").get();
@@ -2494,7 +2699,7 @@ ${conversation.trim()}
2494
2699
  }
2495
2700
 
2496
2701
  // src/host/version.ts
2497
- var VERSION = "0.12.1";
2702
+ var VERSION = "0.13.0";
2498
2703
 
2499
2704
  // src/host/api.ts
2500
2705
  init_smtp();
@@ -2597,7 +2802,14 @@ function buildRouter(runtime) {
2597
2802
  const items = await listServer(runtime, account, mailbox, query, limit, offset);
2598
2803
  return { ...items, offset, limit };
2599
2804
  }
2600
- const page = runtime.database(account).list({ mailbox: mailbox === "" ? "all" : mailbox, query, offset, limit });
2805
+ const unreadOnly = url.searchParams.get("unread") === "1";
2806
+ const page = runtime.database(account).list({
2807
+ mailbox: mailbox === "" ? "all" : mailbox,
2808
+ query,
2809
+ offset,
2810
+ limit,
2811
+ ...unreadOnly ? { unread: unreadKeys(runtime, account) } : {}
2812
+ });
2601
2813
  return { ...page, items: page.items.map((item) => localSummary(runtime, account, item)) };
2602
2814
  });
2603
2815
  router.get("/messages/lookup", ({ url }) => {
@@ -2616,6 +2828,91 @@ function buildRouter(runtime) {
2616
2828
  }
2617
2829
  throw new HttpError(400, "\u65E0\u6CD5\u5B9A\u4F4D\u8FD9\u5C01\u90AE\u4EF6");
2618
2830
  });
2831
+ router.post("/messages/:id/star", async ({ url, params, body }) => {
2832
+ const payload = await body();
2833
+ const { account } = accountFrom(runtime, url);
2834
+ const detail = await loadDetail(
2835
+ runtime,
2836
+ account,
2837
+ sourceOfPayload(payload),
2838
+ String(params.id ?? ""),
2839
+ mailboxOfPayload(payload)
2840
+ );
2841
+ const starred = payload.starred !== false;
2842
+ const key = messageKeyOf(detail);
2843
+ runtime.flags(account).set(key, starred);
2844
+ if (detail.uid !== void 0) {
2845
+ try {
2846
+ await runtime.provider(account).setFlag(detail.mailbox, detail.uid, "\\Flagged", starred);
2847
+ } catch {
2848
+ }
2849
+ }
2850
+ return { starred };
2851
+ });
2852
+ router.post("/messages/:id/move", async ({ url, params, body }) => {
2853
+ const payload = await body();
2854
+ const { account } = accountFrom(runtime, url);
2855
+ const target = String(payload.target ?? "").trim();
2856
+ if (target === "") throw new HttpError(400, "\u7F3A\u5C11\u76EE\u6807\u6587\u4EF6\u5939");
2857
+ const detail = runtime.database(account).get(Number(params.id));
2858
+ if (detail === void 0) throw new HttpError(404, "\u90AE\u4EF6\u4E0D\u5B58\u5728");
2859
+ if (detail.uid !== void 0) {
2860
+ try {
2861
+ await runtime.provider(account).move(detail.mailbox, detail.uid, target);
2862
+ } catch {
2863
+ }
2864
+ }
2865
+ moveLocalMessage(runtime.paths(account).mailDb, Number(params.id), target);
2866
+ return { ok: true, mailbox: target };
2867
+ });
2868
+ router.post("/messages/bulk", async ({ body }) => {
2869
+ const payload = await body();
2870
+ const account = accountFromPayload(runtime, payload);
2871
+ const action = String(payload.action ?? "");
2872
+ const ids = Array.isArray(payload.ids) ? payload.ids.map((id) => Number(id)) : [];
2873
+ if (ids.length === 0) return { ok: true, count: 0 };
2874
+ const database = runtime.database(account);
2875
+ const flags = runtime.flags(account);
2876
+ const readState = runtime.readState(account);
2877
+ let count = 0;
2878
+ for (const id of ids) {
2879
+ const detail = database.get(id);
2880
+ if (detail === void 0) continue;
2881
+ const key = messageKeyOf(detail);
2882
+ if (action === "read" || action === "unread") {
2883
+ readState.setUnread(key, action === "unread");
2884
+ if (detail.uid !== void 0) {
2885
+ try {
2886
+ await runtime.provider(account).setSeen(detail.mailbox, detail.uid, action === "read");
2887
+ } catch {
2888
+ }
2889
+ }
2890
+ count += 1;
2891
+ } else if (action === "archive" || action === "delete" || action === "move") {
2892
+ const target = String(payload.target ?? "").trim();
2893
+ if (target === "") continue;
2894
+ if (detail.uid !== void 0) {
2895
+ try {
2896
+ await runtime.provider(account).move(detail.mailbox, detail.uid, target);
2897
+ } catch {
2898
+ }
2899
+ }
2900
+ moveLocalMessage(runtime.paths(account).mailDb, Number(detail.id), target);
2901
+ count += 1;
2902
+ } else if (action === "star" || action === "unstar") {
2903
+ const starred = action === "star";
2904
+ flags.set(key, starred);
2905
+ if (detail.uid !== void 0) {
2906
+ try {
2907
+ await runtime.provider(account).setFlag(detail.mailbox, detail.uid, "\\Flagged", starred);
2908
+ } catch {
2909
+ }
2910
+ }
2911
+ count += 1;
2912
+ }
2913
+ }
2914
+ return { ok: true, count };
2915
+ });
2619
2916
  router.get("/messages/:id", async ({ url, params }) => {
2620
2917
  const { account } = accountFrom(runtime, url);
2621
2918
  const source = sourceOf(url);
@@ -2693,21 +2990,13 @@ function buildRouter(runtime) {
2693
2990
  router.get("/unread", async ({ url }) => {
2694
2991
  const requested = url.searchParams.get("accountId");
2695
2992
  const accounts = requested === null || requested === "" || requested === "all" ? runtime.current.accounts.filter((item) => item.enabled !== false) : [accountById(runtime, requested)];
2696
- let server = 0;
2697
- let answered = 0;
2698
- await Promise.all(
2699
- accounts.map(async (account) => {
2700
- if (!runtime.hasCredentials(account)) return;
2701
- try {
2702
- const result = await runtime.provider(account).unseen();
2703
- server += result.count;
2704
- answered += 1;
2705
- } catch {
2706
- }
2707
- })
2708
- );
2709
- if (answered > 0) return { unread: server, source: "server" };
2710
- const local = accounts.reduce((sum, account) => sum + runtime.readState(account).count(), 0);
2993
+ const local = accounts.reduce((sum, account) => {
2994
+ try {
2995
+ return sum + runtime.database(account).countUnread(unreadKeys(runtime, account));
2996
+ } catch {
2997
+ return sum;
2998
+ }
2999
+ }, 0);
2711
3000
  return { unread: local, source: "local" };
2712
3001
  });
2713
3002
  router.get("/accounts", () => ({ accounts: runtime.current.accounts }));
@@ -3400,10 +3689,31 @@ function projectView(project) {
3400
3689
  updatedAt: project.updatedAt
3401
3690
  };
3402
3691
  }
3692
+ function unreadKeys(runtime, account) {
3693
+ const mids = [];
3694
+ const ids = [];
3695
+ for (const key of runtime.readState(account).list()) {
3696
+ if (key.startsWith("mid:")) mids.push(key.slice(4));
3697
+ else if (key.startsWith("local:")) {
3698
+ const id = Number(key.slice(6));
3699
+ if (Number.isFinite(id)) ids.push(id);
3700
+ }
3701
+ }
3702
+ return { mids, ids };
3703
+ }
3704
+ function messageKeyOf(row) {
3705
+ return row.messageId !== void 0 && row.messageId !== "" ? `mid:${row.messageId}` : `local:${row.id}`;
3706
+ }
3403
3707
  function localSummary(runtime, account, row) {
3404
3708
  const store = runtime.readState(account);
3405
- const key = row.messageId !== void 0 && row.messageId !== "" ? `mid:${row.messageId}` : `local:${row.id}`;
3406
- return { ...row, accountId: account.id, unread: store.isUnread(key), backedUp: true };
3709
+ const key = messageKeyOf(row);
3710
+ return {
3711
+ ...row,
3712
+ accountId: account.id,
3713
+ unread: store.isUnread(key),
3714
+ starred: runtime.flags(account).has(key),
3715
+ backedUp: true
3716
+ };
3407
3717
  }
3408
3718
  function localDetail(runtime, account, detail) {
3409
3719
  return localSummary(runtime, account, detail);
@@ -3880,11 +4190,63 @@ function prune(data) {
3880
4190
  }
3881
4191
  }
3882
4192
 
4193
+ // src/host/mail-flags.ts
4194
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync6, renameSync as renameSync5, writeFileSync as writeFileSync6 } from "node:fs";
4195
+ import { dirname as dirname3 } from "node:path";
4196
+ var MailFlagsStore = class {
4197
+ file;
4198
+ keys = null;
4199
+ constructor(file) {
4200
+ this.file = file;
4201
+ }
4202
+ load() {
4203
+ if (this.keys !== null) return this.keys;
4204
+ try {
4205
+ const parsed = JSON.parse(readFileSync6(this.file, "utf8"));
4206
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.starred) ? parsed.starred : [];
4207
+ this.keys = new Set(list.filter((key) => typeof key === "string" && key !== ""));
4208
+ } catch {
4209
+ this.keys = /* @__PURE__ */ new Set();
4210
+ }
4211
+ return this.keys;
4212
+ }
4213
+ flush() {
4214
+ mkdirSync6(dirname3(this.file), { recursive: true });
4215
+ const temp = `${this.file}.${String(process.pid)}.tmp`;
4216
+ writeFileSync6(temp, `${JSON.stringify({ starred: [...this.load()] }, null, 2)}
4217
+ `, {
4218
+ encoding: "utf8",
4219
+ mode: 384
4220
+ });
4221
+ renameSync5(temp, this.file);
4222
+ }
4223
+ has(key) {
4224
+ return key !== "" && this.load().has(key);
4225
+ }
4226
+ set(key, starred) {
4227
+ const normalized = key.trim();
4228
+ if (normalized === "") return false;
4229
+ const keys = this.load();
4230
+ const has = keys.has(normalized);
4231
+ if (starred && !has) {
4232
+ keys.add(normalized);
4233
+ this.flush();
4234
+ return true;
4235
+ }
4236
+ if (!starred && has) {
4237
+ keys.delete(normalized);
4238
+ this.flush();
4239
+ return true;
4240
+ }
4241
+ return false;
4242
+ }
4243
+ };
4244
+
3883
4245
  // src/host/tasks.ts
3884
4246
  import { randomUUID as randomUUID4 } from "node:crypto";
3885
- import { mkdirSync as mkdirSync6 } from "node:fs";
3886
- import { dirname as dirname3 } from "node:path";
3887
- import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
4247
+ import { mkdirSync as mkdirSync7 } from "node:fs";
4248
+ import { dirname as dirname4 } from "node:path";
4249
+ import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
3888
4250
  var STATUSES = /* @__PURE__ */ new Set(["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"]);
3889
4251
  function now() {
3890
4252
  return (/* @__PURE__ */ new Date()).toISOString();
@@ -3932,8 +4294,8 @@ var TaskStore = class {
3932
4294
  }
3933
4295
  open() {
3934
4296
  if (this.db !== null) return this.db;
3935
- mkdirSync6(dirname3(this.file), { recursive: true });
3936
- const db = new DatabaseSync3(this.file);
4297
+ mkdirSync7(dirname4(this.file), { recursive: true });
4298
+ const db = new DatabaseSync4(this.file);
3937
4299
  db.exec("PRAGMA journal_mode=WAL");
3938
4300
  db.exec("PRAGMA busy_timeout=10000");
3939
4301
  db.exec(`
@@ -4172,8 +4534,8 @@ var TaskStore = class {
4172
4534
 
4173
4535
  // src/host/drafts.ts
4174
4536
  import { randomUUID as randomUUID5 } from "node:crypto";
4175
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, renameSync as renameSync5, writeFileSync as writeFileSync6 } from "node:fs";
4176
- import { dirname as dirname4, join as join6 } from "node:path";
4537
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync7, renameSync as renameSync6, writeFileSync as writeFileSync7 } from "node:fs";
4538
+ import { dirname as dirname5, join as join6 } from "node:path";
4177
4539
  function text(value) {
4178
4540
  return value === void 0 || value === null ? "" : String(value);
4179
4541
  }
@@ -4197,18 +4559,18 @@ var DraftStore = class {
4197
4559
  }
4198
4560
  load() {
4199
4561
  try {
4200
- const parsed = JSON.parse(readFileSync6(this.file, "utf8"));
4562
+ const parsed = JSON.parse(readFileSync7(this.file, "utf8"));
4201
4563
  this.items = Array.isArray(parsed) ? parsed.filter(isDraft) : [];
4202
4564
  } catch {
4203
4565
  this.items = [];
4204
4566
  }
4205
4567
  }
4206
4568
  flush() {
4207
- mkdirSync7(dirname4(this.file), { recursive: true });
4569
+ mkdirSync8(dirname5(this.file), { recursive: true });
4208
4570
  const temp = `${this.file}.${String(process.pid)}.${String(Date.now())}.tmp`;
4209
- writeFileSync6(temp, `${JSON.stringify(this.items, null, 2)}
4571
+ writeFileSync7(temp, `${JSON.stringify(this.items, null, 2)}
4210
4572
  `, { encoding: "utf8", mode: 384 });
4211
- renameSync5(temp, this.file);
4573
+ renameSync6(temp, this.file);
4212
4574
  }
4213
4575
  list() {
4214
4576
  return [...this.items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).map((draft) => ({ ...draft }));
@@ -4265,8 +4627,8 @@ var DraftStore = class {
4265
4627
  init_imap();
4266
4628
 
4267
4629
  // src/host/read-state.ts
4268
- import { mkdirSync as mkdirSync8, readFileSync as readFileSync7, renameSync as renameSync6, writeFileSync as writeFileSync7 } from "node:fs";
4269
- import { dirname as dirname5 } from "node:path";
4630
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync8, renameSync as renameSync7, writeFileSync as writeFileSync8 } from "node:fs";
4631
+ import { dirname as dirname6 } from "node:path";
4270
4632
  var ReadStateStore = class {
4271
4633
  file;
4272
4634
  keys = null;
@@ -4276,7 +4638,7 @@ var ReadStateStore = class {
4276
4638
  load() {
4277
4639
  if (this.keys !== null) return this.keys;
4278
4640
  try {
4279
- const parsed = JSON.parse(readFileSync7(this.file, "utf8"));
4641
+ const parsed = JSON.parse(readFileSync8(this.file, "utf8"));
4280
4642
  const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.unread) ? parsed.unread : [];
4281
4643
  this.keys = new Set(list.filter((key) => typeof key === "string" && key !== ""));
4282
4644
  } catch {
@@ -4285,14 +4647,14 @@ var ReadStateStore = class {
4285
4647
  return this.keys;
4286
4648
  }
4287
4649
  flush() {
4288
- mkdirSync8(dirname5(this.file), { recursive: true });
4650
+ mkdirSync9(dirname6(this.file), { recursive: true });
4289
4651
  const temp = `${this.file}.${String(process.pid)}.tmp`;
4290
- writeFileSync7(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
4652
+ writeFileSync8(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
4291
4653
  `, {
4292
4654
  encoding: "utf8",
4293
4655
  mode: 384
4294
4656
  });
4295
- renameSync6(temp, this.file);
4657
+ renameSync7(temp, this.file);
4296
4658
  }
4297
4659
  isUnread(key) {
4298
4660
  return key !== "" && this.load().has(key);
@@ -4356,6 +4718,7 @@ var MailHarborRuntime = class {
4356
4718
  aiService = null;
4357
4719
  projectStore = null;
4358
4720
  taskStore = null;
4721
+ flagStores = /* @__PURE__ */ new Map();
4359
4722
  disposed = false;
4360
4723
  constructor(options = {}) {
4361
4724
  this.config = loadConfig();
@@ -4510,6 +4873,14 @@ var MailHarborRuntime = class {
4510
4873
  this.aiStores.set(account.id, { signature: file, value: created });
4511
4874
  return created;
4512
4875
  }
4876
+ flags(account) {
4877
+ const file = join7(this.paths(account).root, "starred.json");
4878
+ const existing = this.flagStores.get(account.id);
4879
+ if (existing !== void 0 && existing.signature === file) return existing.value;
4880
+ const created = new MailFlagsStore(file);
4881
+ this.flagStores.set(account.id, { signature: file, value: created });
4882
+ return created;
4883
+ }
4513
4884
  readState(account) {
4514
4885
  const file = join7(this.paths(account).root, "read-state.json");
4515
4886
  const existing = this.readStores.get(account.id);
@@ -4542,7 +4913,11 @@ var MailHarborRuntime = class {
4542
4913
  exitCode: null
4543
4914
  };
4544
4915
  const job = { status, controller, promise: Promise.resolve() };
4545
- const engine = new BackupEngine(accountRuntime(account), this.credentialsFor(account));
4916
+ const engine = new BackupEngine(accountRuntime(account), this.credentialsFor(account), {
4917
+ onFlags: (entries) => {
4918
+ this.readState(account).sync(entries);
4919
+ }
4920
+ });
4546
4921
  job.promise = (async () => {
4547
4922
  try {
4548
4923
  const result = await engine.run({