@zaclinley/dsh-mailharbor 0.12.0 → 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
@@ -170,8 +170,15 @@ function flagsOf(value) {
170
170
  function isSeen(flags) {
171
171
  return flags.some((flag) => flag.toLowerCase() === "\\seen");
172
172
  }
173
+ function isInlineImage(attachment) {
174
+ const type = String(attachment.contentType ?? "").toLowerCase();
175
+ if (!type.startsWith("image/")) return false;
176
+ const disposition = String(attachment.contentDisposition ?? "").toLowerCase();
177
+ const cid = String(attachment.cid ?? "");
178
+ return disposition === "inline" || cid !== "" && disposition !== "attachment";
179
+ }
173
180
  function attachmentList(parsed) {
174
- return (parsed.attachments ?? []).map((attachment, index) => ({
181
+ return (parsed.attachments ?? []).filter((attachment) => !isInlineImage(attachment)).map((attachment, index) => ({
175
182
  id: String(index),
176
183
  filename: String(attachment.filename ?? "attachment"),
177
184
  contentType: String(attachment.contentType ?? "application/octet-stream"),
@@ -992,6 +999,13 @@ function ensureMailSchema(file) {
992
999
  db.exec(FTS);
993
1000
  } catch {
994
1001
  }
1002
+ const columns = db.prepare("PRAGMA table_info(attachments)").all();
1003
+ if (!columns.some((column) => column.name === "content_id")) {
1004
+ db.exec("ALTER TABLE attachments ADD COLUMN content_id TEXT");
1005
+ }
1006
+ if (!columns.some((column) => column.name === "disposition")) {
1007
+ db.exec("ALTER TABLE attachments ADD COLUMN disposition TEXT");
1008
+ }
995
1009
  } finally {
996
1010
  db.close();
997
1011
  }
@@ -1019,6 +1033,27 @@ var MailDatabase = class {
1019
1033
  this.db.exec("PRAGMA busy_timeout = 3000");
1020
1034
  return this.db;
1021
1035
  }
1036
+ /** Whether the backup has added inline-image columns yet. */
1037
+ hasAttachmentColumns() {
1038
+ try {
1039
+ const columns = this.open().prepare("PRAGMA table_info(attachments)").all();
1040
+ return columns.some((column) => column.name === "content_id");
1041
+ } catch {
1042
+ return false;
1043
+ }
1044
+ }
1045
+ attachmentCountSql() {
1046
+ if (!this.hasAttachmentColumns()) {
1047
+ return "(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count";
1048
+ }
1049
+ return `(SELECT COUNT(*) FROM attachments a
1050
+ WHERE a.email_id = e.id
1051
+ AND NOT (a.content_type LIKE 'image/%' AND (
1052
+ a.disposition = 'inline'
1053
+ OR (a.content_id IS NOT NULL AND a.content_id <> '' AND a.disposition IS NOT 'attachment')
1054
+ OR lower(a.filename) GLOB 'image[0-9]*'
1055
+ ))) AS attachment_count`;
1056
+ }
1022
1057
  close() {
1023
1058
  if (this.db === void 0) return;
1024
1059
  try {
@@ -1066,6 +1101,23 @@ var MailDatabase = class {
1066
1101
  where.push("e.date >= ?");
1067
1102
  params.push(query.since);
1068
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
+ }
1069
1121
  const text2 = String(query.query ?? "").trim();
1070
1122
  if (text2 !== "") {
1071
1123
  const tokens = text2.split(/\s+/).filter(Boolean);
@@ -1089,7 +1141,7 @@ var MailDatabase = class {
1089
1141
  const clause = where.length === 0 ? "" : `WHERE ${where.join(" AND ")}`;
1090
1142
  const columns = `SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
1091
1143
  e.message_id, e.date, length(e.body_text) AS text_length,
1092
- (SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
1144
+ ${this.attachmentCountSql()},
1093
1145
  substr(COALESCE(e.body_text, ''), 1, 240) AS snippet`;
1094
1146
  const total = Number(
1095
1147
  db.prepare(`SELECT COUNT(*) AS count FROM emails e ${clause}`).get(...params)?.count ?? 0
@@ -1144,7 +1196,7 @@ var MailDatabase = class {
1144
1196
  const clause = `WHERE ${where.join(" AND ")}`;
1145
1197
  const columns = `SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
1146
1198
  e.message_id, e.date, length(e.body_text) AS text_length,
1147
- (SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
1199
+ ${this.attachmentCountSql()},
1148
1200
  substr(COALESCE(e.body_text, ''), 1, 240) AS snippet`;
1149
1201
  const total = Number(
1150
1202
  db.prepare(`SELECT COUNT(*) AS count FROM emails e ${clause}`).get(...params)?.count ?? 0
@@ -1152,6 +1204,28 @@ var MailDatabase = class {
1152
1204
  const rows = db.prepare(`${columns} FROM emails e ${clause} ORDER BY e.date DESC, e.id DESC LIMIT ?`).all(...params, limit);
1153
1205
  return { items: rows.map((row) => rowToSummary(row)), total, offset: 0, limit };
1154
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
+ }
1155
1229
  get(localId) {
1156
1230
  const db = this.open();
1157
1231
  const row = db.prepare(
@@ -1160,7 +1234,20 @@ var MailDatabase = class {
1160
1234
  FROM emails WHERE id = ?`
1161
1235
  ).get(localId);
1162
1236
  if (row === void 0) return void 0;
1163
- const attachments = db.prepare("SELECT id, filename, content_type, size FROM attachments WHERE email_id = ? ORDER BY id").all(localId).map((item) => ({
1237
+ const cidText = String(row.body_html ?? "").toLowerCase();
1238
+ const attachmentColumns = this.hasAttachmentColumns() ? "id, filename, content_type, size, content_id, disposition" : "id, filename, content_type, size";
1239
+ const attachments = db.prepare(`SELECT ${attachmentColumns} FROM attachments WHERE email_id = ? ORDER BY id`).all(localId).filter((item) => {
1240
+ const type = String(item.content_type ?? "").toLowerCase();
1241
+ if (!type.startsWith("image/")) return true;
1242
+ const disposition = String(item.disposition ?? "").toLowerCase();
1243
+ const contentId = String(item.content_id ?? "").toLowerCase();
1244
+ if (disposition === "inline") return false;
1245
+ if (contentId !== "" && disposition !== "attachment") return false;
1246
+ const filename = String(item.filename ?? "").toLowerCase();
1247
+ if (filename !== "" && cidText.includes(`cid:${filename}`)) return false;
1248
+ if (/^image\d+\.[a-z0-9]+$/.test(filename)) return false;
1249
+ return true;
1250
+ }).map((item) => ({
1164
1251
  id: String(item.id),
1165
1252
  filename: String(item.filename ?? "attachment"),
1166
1253
  contentType: String(item.content_type ?? "application/octet-stream"),
@@ -1318,10 +1405,57 @@ var BackupEngine = class {
1318
1405
  runtime;
1319
1406
  credentials;
1320
1407
  batchSize;
1408
+ onFlags;
1321
1409
  constructor(runtime, credentials, options = {}) {
1322
1410
  this.runtime = runtime;
1323
1411
  this.credentials = credentials;
1324
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
+ }
1325
1459
  }
1326
1460
  attachmentsDir() {
1327
1461
  return this.runtime.attachments;
@@ -1377,7 +1511,7 @@ var BackupEngine = class {
1377
1511
  const emailId = Number(inserted.lastInsertRowid);
1378
1512
  const attachments = parsed.attachments ?? [];
1379
1513
  const insertAttachment = db.prepare(
1380
- "INSERT INTO attachments (email_id, filename, content_type, size, hash) VALUES (?, ?, ?, ?, ?)"
1514
+ "INSERT INTO attachments (email_id, filename, content_type, size, hash, content_id, disposition) VALUES (?, ?, ?, ?, ?, ?, ?)"
1381
1515
  );
1382
1516
  for (const attachment of attachments) {
1383
1517
  const data = attachment.content;
@@ -1388,7 +1522,9 @@ var BackupEngine = class {
1388
1522
  String(attachment.filename ?? "attachment"),
1389
1523
  String(attachment.contentType ?? "application/octet-stream"),
1390
1524
  stored.size,
1391
- stored.hash
1525
+ stored.hash,
1526
+ attachment.cid === void 0 ? null : String(attachment.cid).replace(/^<|>$/g, ""),
1527
+ attachment.contentDisposition === void 0 ? null : String(attachment.contentDisposition)
1392
1528
  );
1393
1529
  }
1394
1530
  try {
@@ -1468,6 +1604,7 @@ var BackupEngine = class {
1468
1604
  abortIfNeeded(signal);
1469
1605
  const batch = uids.slice(offset, offset + this.batchSize);
1470
1606
  db.exec("BEGIN");
1607
+ const flagEntries = [];
1471
1608
  try {
1472
1609
  let batchLastUid = committedUid;
1473
1610
  for await (const message of client.fetch(
@@ -1488,6 +1625,13 @@ var BackupEngine = class {
1488
1625
  boxAdded += added;
1489
1626
  newCount += added;
1490
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
+ }
1491
1635
  } catch (error) {
1492
1636
  failCount += 1;
1493
1637
  emit({
@@ -1498,6 +1642,7 @@ var BackupEngine = class {
1498
1642
  }
1499
1643
  this.saveState(db, path, uidValidity, batchLastUid);
1500
1644
  db.exec("COMMIT");
1645
+ if (flagEntries.length > 0) this.onFlags?.(flagEntries);
1501
1646
  committedUid = batchLastUid;
1502
1647
  emit({ mailbox: path, added: boxAdded, message: `${path}: \u5DF2\u65B0\u589E ${String(boxAdded)} \u5C01` });
1503
1648
  } catch (error) {
@@ -1505,6 +1650,7 @@ var BackupEngine = class {
1505
1650
  throw error;
1506
1651
  }
1507
1652
  }
1653
+ if (path.toUpperCase() === "INBOX") await this.reconcileReadState(client);
1508
1654
  mailboxes.push({ name: path, added: boxAdded });
1509
1655
  emit({ mailbox: path, added: boxAdded, message: `${path}: \u65B0\u589E ${String(boxAdded)} \u5C01` });
1510
1656
  } finally {
@@ -2444,7 +2590,7 @@ ${conversation.trim()}
2444
2590
  }
2445
2591
 
2446
2592
  // src/host/version.ts
2447
- var VERSION = "0.12.0";
2593
+ var VERSION = "0.12.2";
2448
2594
 
2449
2595
  // src/host/api.ts
2450
2596
  init_smtp();
@@ -2547,7 +2693,14 @@ function buildRouter(runtime) {
2547
2693
  const items = await listServer(runtime, account, mailbox, query, limit, offset);
2548
2694
  return { ...items, offset, limit };
2549
2695
  }
2550
- 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
+ });
2551
2704
  return { ...page, items: page.items.map((item) => localSummary(runtime, account, item)) };
2552
2705
  });
2553
2706
  router.get("/messages/lookup", ({ url }) => {
@@ -2643,21 +2796,13 @@ function buildRouter(runtime) {
2643
2796
  router.get("/unread", async ({ url }) => {
2644
2797
  const requested = url.searchParams.get("accountId");
2645
2798
  const accounts = requested === null || requested === "" || requested === "all" ? runtime.current.accounts.filter((item) => item.enabled !== false) : [accountById(runtime, requested)];
2646
- let server = 0;
2647
- let answered = 0;
2648
- await Promise.all(
2649
- accounts.map(async (account) => {
2650
- if (!runtime.hasCredentials(account)) return;
2651
- try {
2652
- const result = await runtime.provider(account).unseen();
2653
- server += result.count;
2654
- answered += 1;
2655
- } catch {
2656
- }
2657
- })
2658
- );
2659
- if (answered > 0) return { unread: server, source: "server" };
2660
- 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);
2661
2806
  return { unread: local, source: "local" };
2662
2807
  });
2663
2808
  router.get("/accounts", () => ({ accounts: runtime.current.accounts }));
@@ -3350,6 +3495,18 @@ function projectView(project) {
3350
3495
  updatedAt: project.updatedAt
3351
3496
  };
3352
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
+ }
3353
3510
  function localSummary(runtime, account, row) {
3354
3511
  const store = runtime.readState(account);
3355
3512
  const key = row.messageId !== void 0 && row.messageId !== "" ? `mid:${row.messageId}` : `local:${row.id}`;
@@ -4492,7 +4649,11 @@ var MailHarborRuntime = class {
4492
4649
  exitCode: null
4493
4650
  };
4494
4651
  const job = { status, controller, promise: Promise.resolve() };
4495
- 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
+ });
4496
4657
  job.promise = (async () => {
4497
4658
  try {
4498
4659
  const result = await engine.run({