@zaclinley/dsh-mailharbor 0.18.0 → 0.19.1
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 +31 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +99 -16
- package/lib/client.js.map +2 -2
- package/lib/index.js +161 -24
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1121,6 +1121,14 @@ function ensureMailSchema(file) {
|
|
|
1121
1121
|
if (!emailColumns.some((column) => column.name === "references")) {
|
|
1122
1122
|
db.exec('ALTER TABLE emails ADD COLUMN "references" TEXT');
|
|
1123
1123
|
}
|
|
1124
|
+
if (!emailColumns.some((column) => column.name === "refs_indexed")) {
|
|
1125
|
+
db.exec("ALTER TABLE emails ADD COLUMN refs_indexed INTEGER DEFAULT 0");
|
|
1126
|
+
}
|
|
1127
|
+
try {
|
|
1128
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_emails_in_reply ON emails(in_reply_to)");
|
|
1129
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_emails_message_id_lower ON emails(lower(message_id))");
|
|
1130
|
+
} catch {
|
|
1131
|
+
}
|
|
1124
1132
|
} finally {
|
|
1125
1133
|
db.close();
|
|
1126
1134
|
}
|
|
@@ -1132,6 +1140,7 @@ var MailDatabase = class {
|
|
|
1132
1140
|
ftsChecked = false;
|
|
1133
1141
|
ftsAvailable = false;
|
|
1134
1142
|
threadRefs = /* @__PURE__ */ new Map();
|
|
1143
|
+
threadCache = /* @__PURE__ */ new Map();
|
|
1135
1144
|
constructor(file, options = {}) {
|
|
1136
1145
|
this.file = file;
|
|
1137
1146
|
this.options = options;
|
|
@@ -1186,6 +1195,7 @@ var MailDatabase = class {
|
|
|
1186
1195
|
}
|
|
1187
1196
|
this.db = void 0;
|
|
1188
1197
|
this.threadRefs.clear();
|
|
1198
|
+
this.threadCache.clear();
|
|
1189
1199
|
}
|
|
1190
1200
|
status() {
|
|
1191
1201
|
try {
|
|
@@ -1389,9 +1399,15 @@ var MailDatabase = class {
|
|
|
1389
1399
|
const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
1390
1400
|
const base = normalize2(seed.subject);
|
|
1391
1401
|
if (base === "") return [];
|
|
1402
|
+
const cacheKey = `${seed.messageId ?? ""}|${base}`;
|
|
1403
|
+
const cached = this.threadCache.get(cacheKey);
|
|
1404
|
+
if (cached !== void 0 && Date.now() - cached.at < 3e4) return cached.items;
|
|
1392
1405
|
const seedIdForQuery = (seed.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
|
|
1393
1406
|
const candidates = this.threadCandidates(base, limit, seedIdForQuery);
|
|
1394
|
-
if (candidates.length <= 1)
|
|
1407
|
+
if (candidates.length <= 1) {
|
|
1408
|
+
this.threadCache.set(cacheKey, { at: Date.now(), items: candidates });
|
|
1409
|
+
return candidates;
|
|
1410
|
+
}
|
|
1395
1411
|
const refs = this.referencesFor(candidates.slice(0, 120).map((item) => Number(item.id)));
|
|
1396
1412
|
const byMessageId = /* @__PURE__ */ new Map();
|
|
1397
1413
|
for (const item of candidates) {
|
|
@@ -1429,7 +1445,10 @@ var MailDatabase = class {
|
|
|
1429
1445
|
refs: [...refs.entries()].slice(0, 3)
|
|
1430
1446
|
});
|
|
1431
1447
|
}
|
|
1432
|
-
if (edges === 0)
|
|
1448
|
+
if (edges === 0) {
|
|
1449
|
+
this.threadCache.set(cacheKey, { at: Date.now(), items: candidates });
|
|
1450
|
+
return candidates;
|
|
1451
|
+
}
|
|
1433
1452
|
const seen = /* @__PURE__ */ new Set();
|
|
1434
1453
|
const queue = [byMessageId.get(startId)];
|
|
1435
1454
|
while (queue.length > 0) {
|
|
@@ -1440,24 +1459,27 @@ var MailDatabase = class {
|
|
|
1440
1459
|
if (!seen.has(next)) queue.push(next);
|
|
1441
1460
|
}
|
|
1442
1461
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1462
|
+
const result = seen.size <= 1 ? candidates : candidates.filter((item) => seen.has(Number(item.id)));
|
|
1463
|
+
this.threadCache.set(cacheKey, { at: Date.now(), items: result });
|
|
1464
|
+
return result;
|
|
1445
1465
|
}
|
|
1446
1466
|
/** Subject candidates via a distinctive token, not FTS (punctuation-safe). */
|
|
1447
1467
|
threadCandidates(base, limit, seedId = "") {
|
|
1448
1468
|
const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
1449
1469
|
const words = base.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3);
|
|
1450
1470
|
const token = words.sort((left, right) => right.length - left.length)[0] ?? base.slice(0, 8);
|
|
1451
|
-
const
|
|
1452
|
-
try {
|
|
1453
|
-
const rows = this.open().prepare(
|
|
1454
|
-
`SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
1471
|
+
const select = `SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
1455
1472
|
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
1456
1473
|
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1457
1474
|
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1458
|
-
FROM emails e
|
|
1459
|
-
|
|
1460
|
-
|
|
1475
|
+
FROM emails e`;
|
|
1476
|
+
try {
|
|
1477
|
+
const ftsQuery = `subject:"${token.replace(/"/g, '""')}"`;
|
|
1478
|
+
const rows = this.open().prepare(
|
|
1479
|
+
`${select} JOIN emails_fts f ON f.rowid = e.id
|
|
1480
|
+
WHERE emails_fts MATCH ?
|
|
1481
|
+
ORDER BY e.date DESC LIMIT 500`
|
|
1482
|
+
).all(ftsQuery);
|
|
1461
1483
|
const items = rows.map((row) => rowToSummary(row)).filter((item) => normalize2(item.subject) === base);
|
|
1462
1484
|
if (seedId !== "" && this.hasRefColumns()) {
|
|
1463
1485
|
const linked = this.open().prepare(
|
|
@@ -1466,9 +1488,9 @@ var MailDatabase = class {
|
|
|
1466
1488
|
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1467
1489
|
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1468
1490
|
FROM emails e
|
|
1469
|
-
WHERE
|
|
1491
|
+
WHERE e.in_reply_to = ?
|
|
1470
1492
|
ORDER BY e.date DESC LIMIT 100`
|
|
1471
|
-
).all(seedId
|
|
1493
|
+
).all(seedId);
|
|
1472
1494
|
const known = new Set(items.map((item) => item.id));
|
|
1473
1495
|
try {
|
|
1474
1496
|
const seedRow = this.open().prepare(
|
|
@@ -1509,7 +1531,8 @@ var MailDatabase = class {
|
|
|
1509
1531
|
}
|
|
1510
1532
|
return items.slice(0, limit);
|
|
1511
1533
|
} catch {
|
|
1512
|
-
|
|
1534
|
+
const page = this.list({ mailbox: "all", query: base, limit: Math.min(500, limit * 2) });
|
|
1535
|
+
return page.items.filter((item) => normalize2(item.subject) === base).slice(0, limit);
|
|
1513
1536
|
}
|
|
1514
1537
|
}
|
|
1515
1538
|
/** Read (and cache) In-Reply-To / References for a set of local ids. */
|
|
@@ -1519,11 +1542,11 @@ var MailDatabase = class {
|
|
|
1519
1542
|
const placeholders = missing.map(() => "?").join(",");
|
|
1520
1543
|
try {
|
|
1521
1544
|
const withRefs = this.hasRefColumns();
|
|
1522
|
-
const columns = withRefs ? 'id, message_id,
|
|
1545
|
+
const columns = withRefs ? 'id, message_id, in_reply_to, "references", refs_indexed' : "id, message_id, raw_eml";
|
|
1523
1546
|
const rows = this.open().prepare(`SELECT ${columns} FROM emails WHERE id IN (${placeholders})`).all(...missing);
|
|
1547
|
+
const needRaw = [];
|
|
1524
1548
|
for (const row of rows) {
|
|
1525
1549
|
const id = Number(row.id);
|
|
1526
|
-
const raw = row.raw_eml;
|
|
1527
1550
|
const refs = [];
|
|
1528
1551
|
for (const stored of [row.references ?? "", row.in_reply_to ?? ""]) {
|
|
1529
1552
|
for (const found of String(stored).split(/\s+/)) {
|
|
@@ -1531,16 +1554,28 @@ var MailDatabase = class {
|
|
|
1531
1554
|
if (value.includes("@")) refs.push(value);
|
|
1532
1555
|
}
|
|
1533
1556
|
}
|
|
1534
|
-
if (refs.length === 0 &&
|
|
1535
|
-
|
|
1536
|
-
for (const found of headerIds(raw, header)) refs.push(found);
|
|
1537
|
-
}
|
|
1557
|
+
if (refs.length === 0 && (!withRefs || Number(row.refs_indexed ?? 0) !== 1)) {
|
|
1558
|
+
needRaw.push(id);
|
|
1538
1559
|
}
|
|
1539
1560
|
this.threadRefs.set(id, {
|
|
1540
1561
|
messageId: String(row.message_id ?? "").replace(/^<|>$/g, "").toLowerCase(),
|
|
1541
1562
|
refs: [...new Set(refs)]
|
|
1542
1563
|
});
|
|
1543
1564
|
}
|
|
1565
|
+
if (needRaw.length > 0) {
|
|
1566
|
+
const marks = needRaw.map(() => "?").join(",");
|
|
1567
|
+
const rawRows = this.open().prepare(`SELECT id, raw_eml FROM emails WHERE id IN (${marks})`).all(...needRaw);
|
|
1568
|
+
for (const row of rawRows) {
|
|
1569
|
+
const id = Number(row.id);
|
|
1570
|
+
const entry = this.threadRefs.get(id);
|
|
1571
|
+
if (entry === void 0 || row.raw_eml === null || row.raw_eml === void 0) continue;
|
|
1572
|
+
const refs = [...entry.refs];
|
|
1573
|
+
for (const header of ["references", "in-reply-to"]) {
|
|
1574
|
+
for (const found of headerIds(row.raw_eml, header)) refs.push(found);
|
|
1575
|
+
}
|
|
1576
|
+
this.threadRefs.set(id, { ...entry, refs: [...new Set(refs)] });
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1544
1579
|
} catch {
|
|
1545
1580
|
}
|
|
1546
1581
|
}
|
|
@@ -2969,7 +3004,7 @@ ${conversation.trim()}
|
|
|
2969
3004
|
}
|
|
2970
3005
|
|
|
2971
3006
|
// src/host/version.ts
|
|
2972
|
-
var VERSION = "0.
|
|
3007
|
+
var VERSION = "0.19.1";
|
|
2973
3008
|
|
|
2974
3009
|
// src/host/api.ts
|
|
2975
3010
|
init_smtp();
|
|
@@ -3191,6 +3226,15 @@ function buildRouter(runtime) {
|
|
|
3191
3226
|
}
|
|
3192
3227
|
return { ok: true, count };
|
|
3193
3228
|
});
|
|
3229
|
+
router.get("/maintenance/references", ({ url }) => {
|
|
3230
|
+
const { account } = accountFrom(runtime, url);
|
|
3231
|
+
return runtime.referencesStatus(account);
|
|
3232
|
+
});
|
|
3233
|
+
router.post("/maintenance/references", ({ url }) => {
|
|
3234
|
+
const { account } = accountFrom(runtime, url);
|
|
3235
|
+
runtime.startReferencesBackfill(account);
|
|
3236
|
+
return runtime.referencesStatus(account);
|
|
3237
|
+
});
|
|
3194
3238
|
router.get("/transfer/export", () => {
|
|
3195
3239
|
const config = runtime.current;
|
|
3196
3240
|
return {
|
|
@@ -4304,7 +4348,7 @@ ${signature}`;
|
|
|
4304
4348
|
}
|
|
4305
4349
|
|
|
4306
4350
|
// src/host/runtime.ts
|
|
4307
|
-
import { statSync } from "node:fs";
|
|
4351
|
+
import { existsSync as existsSync8, statSync } from "node:fs";
|
|
4308
4352
|
import { join as join7 } from "node:path";
|
|
4309
4353
|
|
|
4310
4354
|
// src/host/secrets.ts
|
|
@@ -4686,11 +4730,63 @@ var MailFlagsStore = class {
|
|
|
4686
4730
|
}
|
|
4687
4731
|
};
|
|
4688
4732
|
|
|
4733
|
+
// src/host/references.ts
|
|
4734
|
+
import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
|
|
4735
|
+
function extractReferences(raw, messageId) {
|
|
4736
|
+
const empty = { inReplyTo: null, references: null };
|
|
4737
|
+
if (raw === null || raw === void 0) return empty;
|
|
4738
|
+
const text2 = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 2e5))).toString("utf8");
|
|
4739
|
+
const separator = text2.search(/\r?\n\r?\n/);
|
|
4740
|
+
const head = separator >= 0 ? text2.slice(0, separator) : text2;
|
|
4741
|
+
const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
|
|
4742
|
+
const value = (name2) => {
|
|
4743
|
+
const match = new RegExp(`^${name2}:\\s*(.*)$`, "im").exec(unfolded);
|
|
4744
|
+
return match === null ? "" : String(match[1] ?? "");
|
|
4745
|
+
};
|
|
4746
|
+
const ids = (value2) => value2.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
4747
|
+
const referenceIds = ids(value("references"));
|
|
4748
|
+
const replyTo = ids(value("in-reply-to"));
|
|
4749
|
+
const filteredRefs = referenceIds.filter((id) => id !== messageId.toLowerCase());
|
|
4750
|
+
return {
|
|
4751
|
+
inReplyTo: replyTo[0] ?? null,
|
|
4752
|
+
references: filteredRefs.length > 0 ? filteredRefs.join(" ") : null
|
|
4753
|
+
};
|
|
4754
|
+
}
|
|
4755
|
+
function backfillReferences(file, batchSize = 300) {
|
|
4756
|
+
ensureMailSchema(file);
|
|
4757
|
+
const db = new DatabaseSync4(file);
|
|
4758
|
+
try {
|
|
4759
|
+
db.exec("PRAGMA busy_timeout = 10000");
|
|
4760
|
+
const rows = db.prepare("SELECT id, message_id, raw_eml FROM emails WHERE refs_indexed = 0 ORDER BY id LIMIT ?").all(batchSize);
|
|
4761
|
+
if (rows.length === 0) return { updated: 0, remaining: 0 };
|
|
4762
|
+
const update = db.prepare(
|
|
4763
|
+
'UPDATE emails SET in_reply_to = ?, "references" = ?, refs_indexed = 1 WHERE id = ?'
|
|
4764
|
+
);
|
|
4765
|
+
db.exec("BEGIN");
|
|
4766
|
+
try {
|
|
4767
|
+
for (const row of rows) {
|
|
4768
|
+
const headers = extractReferences(row.raw_eml, String(row.message_id ?? ""));
|
|
4769
|
+
update.run(headers.inReplyTo, headers.references, Number(row.id));
|
|
4770
|
+
}
|
|
4771
|
+
db.exec("COMMIT");
|
|
4772
|
+
} catch (error) {
|
|
4773
|
+
db.exec("ROLLBACK");
|
|
4774
|
+
throw error;
|
|
4775
|
+
}
|
|
4776
|
+
const remaining = Number(
|
|
4777
|
+
db.prepare("SELECT COUNT(*) AS count FROM emails WHERE refs_indexed = 0").get()?.count ?? 0
|
|
4778
|
+
);
|
|
4779
|
+
return { updated: rows.length, remaining };
|
|
4780
|
+
} finally {
|
|
4781
|
+
db.close();
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
|
|
4689
4785
|
// src/host/tasks.ts
|
|
4690
4786
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
4691
4787
|
import { mkdirSync as mkdirSync7 } from "node:fs";
|
|
4692
4788
|
import { dirname as dirname4 } from "node:path";
|
|
4693
|
-
import { DatabaseSync as
|
|
4789
|
+
import { DatabaseSync as DatabaseSync5 } from "node:sqlite";
|
|
4694
4790
|
var STATUSES = /* @__PURE__ */ new Set(["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"]);
|
|
4695
4791
|
function now() {
|
|
4696
4792
|
return (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -4739,7 +4835,7 @@ var TaskStore = class {
|
|
|
4739
4835
|
open() {
|
|
4740
4836
|
if (this.db !== null) return this.db;
|
|
4741
4837
|
mkdirSync7(dirname4(this.file), { recursive: true });
|
|
4742
|
-
const db = new
|
|
4838
|
+
const db = new DatabaseSync5(this.file);
|
|
4743
4839
|
db.exec("PRAGMA journal_mode=WAL");
|
|
4744
4840
|
db.exec("PRAGMA busy_timeout=10000");
|
|
4745
4841
|
db.exec(`
|
|
@@ -5165,6 +5261,7 @@ var MailHarborRuntime = class {
|
|
|
5165
5261
|
projectStore = null;
|
|
5166
5262
|
taskStore = null;
|
|
5167
5263
|
flagStores = /* @__PURE__ */ new Map();
|
|
5264
|
+
referenceJobs = /* @__PURE__ */ new Map();
|
|
5168
5265
|
disposed = false;
|
|
5169
5266
|
constructor(options = {}) {
|
|
5170
5267
|
this.config = loadConfig();
|
|
@@ -5264,6 +5361,43 @@ var MailHarborRuntime = class {
|
|
|
5264
5361
|
paths(account) {
|
|
5265
5362
|
return accountRuntime(account);
|
|
5266
5363
|
}
|
|
5364
|
+
referencesStatus(account) {
|
|
5365
|
+
return this.referenceJobs.get(account.id) ?? {
|
|
5366
|
+
running: false,
|
|
5367
|
+
indexed: 0,
|
|
5368
|
+
remaining: null,
|
|
5369
|
+
error: null
|
|
5370
|
+
};
|
|
5371
|
+
}
|
|
5372
|
+
/** Backfill In-Reply-To/References for archived mail, one batch at a time. */
|
|
5373
|
+
startReferencesBackfill(account) {
|
|
5374
|
+
const existing = this.referenceJobs.get(account.id);
|
|
5375
|
+
if (existing?.running === true) return;
|
|
5376
|
+
const dbFile = this.paths(account).mailDb;
|
|
5377
|
+
if (!existsSync8(dbFile)) return;
|
|
5378
|
+
const status = {
|
|
5379
|
+
running: true,
|
|
5380
|
+
indexed: 0,
|
|
5381
|
+
remaining: null,
|
|
5382
|
+
error: null
|
|
5383
|
+
};
|
|
5384
|
+
this.referenceJobs.set(account.id, status);
|
|
5385
|
+
void (async () => {
|
|
5386
|
+
try {
|
|
5387
|
+
for (; ; ) {
|
|
5388
|
+
const result = backfillReferences(dbFile, 300);
|
|
5389
|
+
status.indexed += result.updated;
|
|
5390
|
+
status.remaining = result.remaining;
|
|
5391
|
+
if (result.remaining <= 0 || result.updated === 0) break;
|
|
5392
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
5393
|
+
}
|
|
5394
|
+
} catch (error) {
|
|
5395
|
+
status.error = error instanceof Error ? error.message : String(error);
|
|
5396
|
+
} finally {
|
|
5397
|
+
status.running = false;
|
|
5398
|
+
}
|
|
5399
|
+
})();
|
|
5400
|
+
}
|
|
5267
5401
|
/** Credentials for an account, with a clear message when the user must log in. */
|
|
5268
5402
|
credentialsFor(account) {
|
|
5269
5403
|
const resolved = this.credentials.resolve(account);
|
|
@@ -5833,6 +5967,9 @@ function apply(ctx) {
|
|
|
5833
5967
|
}
|
|
5834
5968
|
const handler = createApiHandler(runtime);
|
|
5835
5969
|
const disposers = [];
|
|
5970
|
+
if (activeAccount2 !== void 0) {
|
|
5971
|
+
setTimeout(() => runtime.startReferencesBackfill(activeAccount2), 3e3);
|
|
5972
|
+
}
|
|
5836
5973
|
const start = () => {
|
|
5837
5974
|
disposers.push(ctx.webServer.register({ kind: "prefix", path: ROUTE, handler }));
|
|
5838
5975
|
if (ctx.tools !== void 0) disposers.push(...registerMailTools(ctx.tools, runtime));
|