@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/CHANGELOG.md +27 -0
- package/lib/client.js +52 -8
- package/lib/client.js.map +2 -2
- package/lib/index.js +185 -24
- package/lib/index.js.map +3 -3
- package/package.json +1 -1
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
|
-
(
|
|
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
|
-
(
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
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({
|