@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/CHANGELOG.md +30 -0
- package/lib/client.js +218 -10
- package/lib/client.js.map +2 -2
- package/lib/index.js +423 -48
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
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
|
|
3406
|
-
return {
|
|
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
|
|
3886
|
-
import { dirname as
|
|
3887
|
-
import { DatabaseSync as
|
|
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
|
-
|
|
3936
|
-
const db = new
|
|
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
|
|
4176
|
-
import { dirname as
|
|
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(
|
|
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
|
-
|
|
4569
|
+
mkdirSync8(dirname5(this.file), { recursive: true });
|
|
4208
4570
|
const temp = `${this.file}.${String(process.pid)}.${String(Date.now())}.tmp`;
|
|
4209
|
-
|
|
4571
|
+
writeFileSync7(temp, `${JSON.stringify(this.items, null, 2)}
|
|
4210
4572
|
`, { encoding: "utf8", mode: 384 });
|
|
4211
|
-
|
|
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
|
|
4269
|
-
import { dirname as
|
|
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(
|
|
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
|
-
|
|
4650
|
+
mkdirSync9(dirname6(this.file), { recursive: true });
|
|
4289
4651
|
const temp = `${this.file}.${String(process.pid)}.tmp`;
|
|
4290
|
-
|
|
4652
|
+
writeFileSync8(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
|
|
4291
4653
|
`, {
|
|
4292
4654
|
encoding: "utf8",
|
|
4293
4655
|
mode: 384
|
|
4294
4656
|
});
|
|
4295
|
-
|
|
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({
|