@zaclinley/dsh-mailharbor 0.12.1 → 0.12.2

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
@@ -1101,6 +1101,23 @@ var MailDatabase = class {
1101
1101
  where.push("e.date >= ?");
1102
1102
  params.push(query.since);
1103
1103
  }
1104
+ if (query.unread !== void 0) {
1105
+ const mids = query.unread.mids.slice(0, 500);
1106
+ const ids = query.unread.ids.slice(0, 500);
1107
+ if (mids.length === 0 && ids.length === 0) {
1108
+ return { items: [], total: 0, offset, limit };
1109
+ }
1110
+ const parts = [];
1111
+ if (mids.length > 0) {
1112
+ parts.push(`e.message_id IN (${mids.map(() => "?").join(",")})`);
1113
+ params.push(...mids);
1114
+ }
1115
+ if (ids.length > 0) {
1116
+ parts.push(`e.id IN (${ids.map(() => "?").join(",")})`);
1117
+ params.push(...ids);
1118
+ }
1119
+ where.push(`(${parts.join(" OR ")})`);
1120
+ }
1104
1121
  const text2 = String(query.query ?? "").trim();
1105
1122
  if (text2 !== "") {
1106
1123
  const tokens = text2.split(/\s+/).filter(Boolean);
@@ -1187,6 +1204,28 @@ var MailDatabase = class {
1187
1204
  const rows = db.prepare(`${columns} FROM emails e ${clause} ORDER BY e.date DESC, e.id DESC LIMIT ?`).all(...params, limit);
1188
1205
  return { items: rows.map((row) => rowToSummary(row)), total, offset: 0, limit };
1189
1206
  }
1207
+ /** Number of unread keys that still exist in this backup. */
1208
+ countUnread(keys) {
1209
+ const mids = keys.mids.slice(0, 500);
1210
+ const ids = keys.ids.slice(0, 500);
1211
+ if (mids.length === 0 && ids.length === 0) return 0;
1212
+ const parts = [];
1213
+ const params = [];
1214
+ if (mids.length > 0) {
1215
+ parts.push(`message_id IN (${mids.map(() => "?").join(",")})`);
1216
+ params.push(...mids);
1217
+ }
1218
+ if (ids.length > 0) {
1219
+ parts.push(`id IN (${ids.map(() => "?").join(",")})`);
1220
+ params.push(...ids);
1221
+ }
1222
+ try {
1223
+ const row = this.open().prepare(`SELECT COUNT(*) AS count FROM emails WHERE ${parts.join(" OR ")}`).get(...params);
1224
+ return Number(row?.count ?? 0);
1225
+ } catch {
1226
+ return 0;
1227
+ }
1228
+ }
1190
1229
  get(localId) {
1191
1230
  const db = this.open();
1192
1231
  const row = db.prepare(
@@ -1366,10 +1405,57 @@ var BackupEngine = class {
1366
1405
  runtime;
1367
1406
  credentials;
1368
1407
  batchSize;
1408
+ onFlags;
1369
1409
  constructor(runtime, credentials, options = {}) {
1370
1410
  this.runtime = runtime;
1371
1411
  this.credentials = credentials;
1372
1412
  this.batchSize = options.batchSize ?? BATCH;
1413
+ this.onFlags = options.onFlags;
1414
+ }
1415
+ /** Mirror IMAP \Seen flags into the local unread store. */
1416
+ async reconcileReadState(client) {
1417
+ if (this.onFlags === void 0) return;
1418
+ const exists = Number(client.mailbox?.exists ?? 0);
1419
+ if (exists === 0) return;
1420
+ const entries = /* @__PURE__ */ new Map();
1421
+ const add = (messageId, unread) => {
1422
+ if (messageId !== "") entries.set(`mid:${messageId}`, unread);
1423
+ };
1424
+ const flagsOf2 = (flags) => [...flags ?? []].map((flag) => String(flag).toLowerCase());
1425
+ const start = Math.max(1, exists - 299);
1426
+ try {
1427
+ for await (const message of client.fetch(`${String(start)}:${String(exists)}`, {
1428
+ uid: true,
1429
+ envelope: true,
1430
+ flags: true
1431
+ })) {
1432
+ const messageId = normalizeId(message.envelope?.messageId);
1433
+ add(messageId, !flagsOf2(message.flags).includes("\\seen"));
1434
+ }
1435
+ } catch {
1436
+ }
1437
+ try {
1438
+ const unseen = await client.search({ seen: false }, { uid: true });
1439
+ const uids = (Array.isArray(unseen) ? unseen : []).map(Number).filter((uid) => Number.isFinite(uid));
1440
+ for (let index = 0; index < uids.length; index += 200) {
1441
+ const range = uids.slice(index, index + 200).join(",");
1442
+ if (range === "") continue;
1443
+ for await (const message of client.fetch(
1444
+ range,
1445
+ { uid: true, envelope: true, flags: true },
1446
+ { uid: true }
1447
+ )) {
1448
+ const messageId = normalizeId(
1449
+ message.envelope?.messageId
1450
+ );
1451
+ add(messageId, true);
1452
+ }
1453
+ }
1454
+ } catch {
1455
+ }
1456
+ if (entries.size > 0) {
1457
+ this.onFlags([...entries.entries()].map(([key, unread]) => ({ key, unread })));
1458
+ }
1373
1459
  }
1374
1460
  attachmentsDir() {
1375
1461
  return this.runtime.attachments;
@@ -1518,6 +1604,7 @@ var BackupEngine = class {
1518
1604
  abortIfNeeded(signal);
1519
1605
  const batch = uids.slice(offset, offset + this.batchSize);
1520
1606
  db.exec("BEGIN");
1607
+ const flagEntries = [];
1521
1608
  try {
1522
1609
  let batchLastUid = committedUid;
1523
1610
  for await (const message of client.fetch(
@@ -1538,6 +1625,13 @@ var BackupEngine = class {
1538
1625
  boxAdded += added;
1539
1626
  newCount += added;
1540
1627
  batchLastUid = Math.max(batchLastUid, Number(message.uid));
1628
+ const messageId = normalizeId(parsed.messageId ?? "");
1629
+ if (messageId !== "") {
1630
+ const flags = [...message.flags ?? []].map(
1631
+ (flag) => String(flag).toLowerCase()
1632
+ );
1633
+ flagEntries.push({ key: `mid:${messageId}`, unread: !flags.includes("\\seen") });
1634
+ }
1541
1635
  } catch (error) {
1542
1636
  failCount += 1;
1543
1637
  emit({
@@ -1548,6 +1642,7 @@ var BackupEngine = class {
1548
1642
  }
1549
1643
  this.saveState(db, path, uidValidity, batchLastUid);
1550
1644
  db.exec("COMMIT");
1645
+ if (flagEntries.length > 0) this.onFlags?.(flagEntries);
1551
1646
  committedUid = batchLastUid;
1552
1647
  emit({ mailbox: path, added: boxAdded, message: `${path}: \u5DF2\u65B0\u589E ${String(boxAdded)} \u5C01` });
1553
1648
  } catch (error) {
@@ -1555,6 +1650,7 @@ var BackupEngine = class {
1555
1650
  throw error;
1556
1651
  }
1557
1652
  }
1653
+ if (path.toUpperCase() === "INBOX") await this.reconcileReadState(client);
1558
1654
  mailboxes.push({ name: path, added: boxAdded });
1559
1655
  emit({ mailbox: path, added: boxAdded, message: `${path}: \u65B0\u589E ${String(boxAdded)} \u5C01` });
1560
1656
  } finally {
@@ -2494,7 +2590,7 @@ ${conversation.trim()}
2494
2590
  }
2495
2591
 
2496
2592
  // src/host/version.ts
2497
- var VERSION = "0.12.1";
2593
+ var VERSION = "0.12.2";
2498
2594
 
2499
2595
  // src/host/api.ts
2500
2596
  init_smtp();
@@ -2597,7 +2693,14 @@ function buildRouter(runtime) {
2597
2693
  const items = await listServer(runtime, account, mailbox, query, limit, offset);
2598
2694
  return { ...items, offset, limit };
2599
2695
  }
2600
- const page = runtime.database(account).list({ mailbox: mailbox === "" ? "all" : mailbox, query, offset, limit });
2696
+ const unreadOnly = url.searchParams.get("unread") === "1";
2697
+ const page = runtime.database(account).list({
2698
+ mailbox: mailbox === "" ? "all" : mailbox,
2699
+ query,
2700
+ offset,
2701
+ limit,
2702
+ ...unreadOnly ? { unread: unreadKeys(runtime, account) } : {}
2703
+ });
2601
2704
  return { ...page, items: page.items.map((item) => localSummary(runtime, account, item)) };
2602
2705
  });
2603
2706
  router.get("/messages/lookup", ({ url }) => {
@@ -2693,21 +2796,13 @@ function buildRouter(runtime) {
2693
2796
  router.get("/unread", async ({ url }) => {
2694
2797
  const requested = url.searchParams.get("accountId");
2695
2798
  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);
2799
+ const local = accounts.reduce((sum, account) => {
2800
+ try {
2801
+ return sum + runtime.database(account).countUnread(unreadKeys(runtime, account));
2802
+ } catch {
2803
+ return sum;
2804
+ }
2805
+ }, 0);
2711
2806
  return { unread: local, source: "local" };
2712
2807
  });
2713
2808
  router.get("/accounts", () => ({ accounts: runtime.current.accounts }));
@@ -3400,6 +3495,18 @@ function projectView(project) {
3400
3495
  updatedAt: project.updatedAt
3401
3496
  };
3402
3497
  }
3498
+ function unreadKeys(runtime, account) {
3499
+ const mids = [];
3500
+ const ids = [];
3501
+ for (const key of runtime.readState(account).list()) {
3502
+ if (key.startsWith("mid:")) mids.push(key.slice(4));
3503
+ else if (key.startsWith("local:")) {
3504
+ const id = Number(key.slice(6));
3505
+ if (Number.isFinite(id)) ids.push(id);
3506
+ }
3507
+ }
3508
+ return { mids, ids };
3509
+ }
3403
3510
  function localSummary(runtime, account, row) {
3404
3511
  const store = runtime.readState(account);
3405
3512
  const key = row.messageId !== void 0 && row.messageId !== "" ? `mid:${row.messageId}` : `local:${row.id}`;
@@ -4542,7 +4649,11 @@ var MailHarborRuntime = class {
4542
4649
  exitCode: null
4543
4650
  };
4544
4651
  const job = { status, controller, promise: Promise.resolve() };
4545
- const engine = new BackupEngine(accountRuntime(account), this.credentialsFor(account));
4652
+ const engine = new BackupEngine(accountRuntime(account), this.credentialsFor(account), {
4653
+ onFlags: (entries) => {
4654
+ this.readState(account).sync(entries);
4655
+ }
4656
+ });
4546
4657
  job.promise = (async () => {
4547
4658
  try {
4548
4659
  const result = await engine.run({