@zaclinley/dsh-mailharbor 0.19.0 → 0.20.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 +34 -0
- package/lib/client.js +120 -31
- package/lib/client.js.map +3 -3
- package/lib/index.js +236 -126
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1027,20 +1027,21 @@ async function draftMime(account, draft) {
|
|
|
1027
1027
|
return Buffer.from(info.message);
|
|
1028
1028
|
}
|
|
1029
1029
|
|
|
1030
|
-
// src/host/
|
|
1031
|
-
init_address();
|
|
1032
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
1033
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1034
|
-
import { join as join3 } from "node:path";
|
|
1035
|
-
import { ImapFlow } from "imapflow";
|
|
1036
|
-
import { simpleParser } from "mailparser";
|
|
1030
|
+
// src/host/references.ts
|
|
1037
1031
|
import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
|
|
1038
1032
|
|
|
1039
1033
|
// src/host/mail-db.ts
|
|
1040
1034
|
init_address();
|
|
1041
|
-
init_inline_images();
|
|
1042
1035
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
1043
1036
|
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
1037
|
+
|
|
1038
|
+
// src/host/subject-key.ts
|
|
1039
|
+
function subjectKey(subject) {
|
|
1040
|
+
return subject.replace(/^(\s*(re|fw|fwd|回复|答复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// src/host/mail-db.ts
|
|
1044
|
+
init_inline_images();
|
|
1044
1045
|
var SCHEMA = `
|
|
1045
1046
|
CREATE TABLE IF NOT EXISTS emails (
|
|
1046
1047
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -1124,6 +1125,13 @@ function ensureMailSchema(file) {
|
|
|
1124
1125
|
if (!emailColumns.some((column) => column.name === "refs_indexed")) {
|
|
1125
1126
|
db.exec("ALTER TABLE emails ADD COLUMN refs_indexed INTEGER DEFAULT 0");
|
|
1126
1127
|
}
|
|
1128
|
+
try {
|
|
1129
|
+
db.exec("CREATE TABLE IF NOT EXISTS email_subjects (email_id INTEGER PRIMARY KEY, key TEXT NOT NULL)");
|
|
1130
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_email_subjects_key ON email_subjects(key)");
|
|
1131
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_emails_in_reply ON emails(in_reply_to)");
|
|
1132
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_emails_message_id_lower ON emails(lower(message_id))");
|
|
1133
|
+
} catch {
|
|
1134
|
+
}
|
|
1127
1135
|
} finally {
|
|
1128
1136
|
db.close();
|
|
1129
1137
|
}
|
|
@@ -1135,6 +1143,7 @@ var MailDatabase = class {
|
|
|
1135
1143
|
ftsChecked = false;
|
|
1136
1144
|
ftsAvailable = false;
|
|
1137
1145
|
threadRefs = /* @__PURE__ */ new Map();
|
|
1146
|
+
threadCache = /* @__PURE__ */ new Map();
|
|
1138
1147
|
constructor(file, options = {}) {
|
|
1139
1148
|
this.file = file;
|
|
1140
1149
|
this.options = options;
|
|
@@ -1189,6 +1198,7 @@ var MailDatabase = class {
|
|
|
1189
1198
|
}
|
|
1190
1199
|
this.db = void 0;
|
|
1191
1200
|
this.threadRefs.clear();
|
|
1201
|
+
this.threadCache.clear();
|
|
1192
1202
|
}
|
|
1193
1203
|
status() {
|
|
1194
1204
|
try {
|
|
@@ -1392,9 +1402,15 @@ var MailDatabase = class {
|
|
|
1392
1402
|
const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
1393
1403
|
const base = normalize2(seed.subject);
|
|
1394
1404
|
if (base === "") return [];
|
|
1405
|
+
const cacheKey = `${seed.messageId ?? ""}|${base}`;
|
|
1406
|
+
const cached = this.threadCache.get(cacheKey);
|
|
1407
|
+
if (cached !== void 0 && Date.now() - cached.at < 3e4) return cached.items;
|
|
1395
1408
|
const seedIdForQuery = (seed.messageId ?? "").replace(/^<|>$/g, "").toLowerCase();
|
|
1396
1409
|
const candidates = this.threadCandidates(base, limit, seedIdForQuery);
|
|
1397
|
-
if (candidates.length <= 1)
|
|
1410
|
+
if (candidates.length <= 1) {
|
|
1411
|
+
this.threadCache.set(cacheKey, { at: Date.now(), items: candidates });
|
|
1412
|
+
return candidates;
|
|
1413
|
+
}
|
|
1398
1414
|
const refs = this.referencesFor(candidates.slice(0, 120).map((item) => Number(item.id)));
|
|
1399
1415
|
const byMessageId = /* @__PURE__ */ new Map();
|
|
1400
1416
|
for (const item of candidates) {
|
|
@@ -1432,7 +1448,10 @@ var MailDatabase = class {
|
|
|
1432
1448
|
refs: [...refs.entries()].slice(0, 3)
|
|
1433
1449
|
});
|
|
1434
1450
|
}
|
|
1435
|
-
if (edges === 0)
|
|
1451
|
+
if (edges === 0) {
|
|
1452
|
+
this.threadCache.set(cacheKey, { at: Date.now(), items: candidates });
|
|
1453
|
+
return candidates;
|
|
1454
|
+
}
|
|
1436
1455
|
const seen = /* @__PURE__ */ new Set();
|
|
1437
1456
|
const queue = [byMessageId.get(startId)];
|
|
1438
1457
|
while (queue.length > 0) {
|
|
@@ -1443,65 +1462,49 @@ var MailDatabase = class {
|
|
|
1443
1462
|
if (!seen.has(next)) queue.push(next);
|
|
1444
1463
|
}
|
|
1445
1464
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1465
|
+
const result = seen.size <= 1 ? candidates : candidates.filter((item) => seen.has(Number(item.id)));
|
|
1466
|
+
this.threadCache.set(cacheKey, { at: Date.now(), items: result });
|
|
1467
|
+
return result;
|
|
1448
1468
|
}
|
|
1449
|
-
/** Subject candidates
|
|
1469
|
+
/** Subject candidates from a normalized-key table, then FTS, then LIKE. */
|
|
1450
1470
|
threadCandidates(base, limit, seedId = "") {
|
|
1451
|
-
const
|
|
1452
|
-
const words = base.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3);
|
|
1453
|
-
const token = words.sort((left, right) => right.length - left.length)[0] ?? base.slice(0, 8);
|
|
1454
|
-
const pattern = `%${token.replace(/[%_]/g, "\\$&")}%`;
|
|
1455
|
-
try {
|
|
1456
|
-
const rows = this.open().prepare(
|
|
1457
|
-
`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,
|
|
1458
1472
|
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
1459
1473
|
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1460
1474
|
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1461
|
-
FROM emails e
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
const
|
|
1465
|
-
|
|
1475
|
+
FROM emails e`;
|
|
1476
|
+
let items = [];
|
|
1477
|
+
try {
|
|
1478
|
+
const byKey = this.open().prepare(
|
|
1479
|
+
`${select} JOIN email_subjects s ON s.email_id = e.id
|
|
1480
|
+
WHERE s.key = ?
|
|
1481
|
+
ORDER BY e.date DESC LIMIT 500`
|
|
1482
|
+
).all(base);
|
|
1483
|
+
if (byKey.length > 0) {
|
|
1484
|
+
items = byKey.map((row) => rowToSummary(row));
|
|
1485
|
+
} else {
|
|
1486
|
+
const words = base.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 3);
|
|
1487
|
+
const token = words.sort((left, right) => right.length - left.length)[0] ?? base.slice(0, 8);
|
|
1488
|
+
const ftsQuery = `subject:"${token.replace(/"/g, '""')}"`;
|
|
1489
|
+
const rows = this.open().prepare(
|
|
1490
|
+
`${select} JOIN emails_fts f ON f.rowid = e.id
|
|
1491
|
+
WHERE emails_fts MATCH ?
|
|
1492
|
+
ORDER BY e.date DESC LIMIT 500`
|
|
1493
|
+
).all(ftsQuery);
|
|
1494
|
+
items = rows.map((row) => rowToSummary(row)).filter((item) => subjectKey(item.subject) === base);
|
|
1495
|
+
}
|
|
1496
|
+
} catch {
|
|
1497
|
+
const page = this.list({ mailbox: "all", query: base, limit: Math.min(500, limit * 2) });
|
|
1498
|
+
return page.items.filter((item) => subjectKey(item.subject) === base).slice(0, limit);
|
|
1499
|
+
}
|
|
1500
|
+
if (seedId !== "" && this.hasRefColumns()) {
|
|
1501
|
+
try {
|
|
1466
1502
|
const linked = this.open().prepare(
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1470
|
-
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1471
|
-
FROM emails e
|
|
1472
|
-
WHERE lower(e.in_reply_to) = ? OR lower(e."references") LIKE ? ESCAPE '\\'
|
|
1503
|
+
`${select}
|
|
1504
|
+
WHERE e.in_reply_to = ?
|
|
1473
1505
|
ORDER BY e.date DESC LIMIT 100`
|
|
1474
|
-
).all(seedId
|
|
1506
|
+
).all(seedId);
|
|
1475
1507
|
const known = new Set(items.map((item) => item.id));
|
|
1476
|
-
try {
|
|
1477
|
-
const seedRow = this.open().prepare(
|
|
1478
|
-
'SELECT in_reply_to, "references", raw_eml FROM emails WHERE lower(message_id) = ? LIMIT 1'
|
|
1479
|
-
).get(seedId);
|
|
1480
|
-
const parents = `${seedRow?.references ?? ""} ${seedRow?.in_reply_to ?? ""}`.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
1481
|
-
if (parents.length === 0 && seedRow?.raw_eml !== void 0 && seedRow.raw_eml !== null) {
|
|
1482
|
-
for (const header of ["references", "in-reply-to"]) {
|
|
1483
|
-
for (const found of headerIds(seedRow.raw_eml, header)) parents.push(found);
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
if (parents.length > 0) {
|
|
1487
|
-
const marks = parents.map(() => "?").join(",");
|
|
1488
|
-
const ancestors = this.open().prepare(
|
|
1489
|
-
`SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
1490
|
-
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
1491
|
-
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
1492
|
-
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet
|
|
1493
|
-
FROM emails e WHERE lower(e.message_id) IN (${marks}) LIMIT 100`
|
|
1494
|
-
).all(...parents);
|
|
1495
|
-
for (const row of ancestors) {
|
|
1496
|
-
const item = rowToSummary(row);
|
|
1497
|
-
if (!known.has(item.id)) {
|
|
1498
|
-
items.push(item);
|
|
1499
|
-
known.add(item.id);
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
} catch {
|
|
1504
|
-
}
|
|
1505
1508
|
for (const row of linked) {
|
|
1506
1509
|
const item = rowToSummary(row);
|
|
1507
1510
|
if (!known.has(item.id)) {
|
|
@@ -1509,11 +1512,33 @@ var MailDatabase = class {
|
|
|
1509
1512
|
known.add(item.id);
|
|
1510
1513
|
}
|
|
1511
1514
|
}
|
|
1515
|
+
const seedRow = this.open().prepare(
|
|
1516
|
+
'SELECT in_reply_to, "references", raw_eml FROM emails WHERE lower(message_id) = ? LIMIT 1'
|
|
1517
|
+
).get(seedId);
|
|
1518
|
+
const parents = `${seedRow?.references ?? ""} ${seedRow?.in_reply_to ?? ""}`.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
1519
|
+
if (parents.length === 0 && seedRow?.raw_eml !== void 0 && seedRow.raw_eml !== null) {
|
|
1520
|
+
for (const header of ["references", "in-reply-to"]) {
|
|
1521
|
+
for (const found of headerIds(seedRow.raw_eml, header)) parents.push(found);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
if (parents.length > 0) {
|
|
1525
|
+
const marks = parents.map(() => "?").join(",");
|
|
1526
|
+
const ancestors = this.open().prepare(
|
|
1527
|
+
`${select}
|
|
1528
|
+
WHERE lower(e.message_id) IN (${marks}) LIMIT 100`
|
|
1529
|
+
).all(...parents);
|
|
1530
|
+
for (const row of ancestors) {
|
|
1531
|
+
const item = rowToSummary(row);
|
|
1532
|
+
if (!known.has(item.id)) {
|
|
1533
|
+
items.push(item);
|
|
1534
|
+
known.add(item.id);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
} catch {
|
|
1512
1539
|
}
|
|
1513
|
-
return items.slice(0, limit);
|
|
1514
|
-
} catch {
|
|
1515
|
-
return [];
|
|
1516
1540
|
}
|
|
1541
|
+
return items.slice(0, limit);
|
|
1517
1542
|
}
|
|
1518
1543
|
/** Read (and cache) In-Reply-To / References for a set of local ids. */
|
|
1519
1544
|
referencesFor(ids) {
|
|
@@ -1522,11 +1547,11 @@ var MailDatabase = class {
|
|
|
1522
1547
|
const placeholders = missing.map(() => "?").join(",");
|
|
1523
1548
|
try {
|
|
1524
1549
|
const withRefs = this.hasRefColumns();
|
|
1525
|
-
const columns = withRefs ? 'id, message_id,
|
|
1550
|
+
const columns = withRefs ? 'id, message_id, in_reply_to, "references", refs_indexed' : "id, message_id, raw_eml";
|
|
1526
1551
|
const rows = this.open().prepare(`SELECT ${columns} FROM emails WHERE id IN (${placeholders})`).all(...missing);
|
|
1552
|
+
const needRaw = [];
|
|
1527
1553
|
for (const row of rows) {
|
|
1528
1554
|
const id = Number(row.id);
|
|
1529
|
-
const raw = row.raw_eml;
|
|
1530
1555
|
const refs = [];
|
|
1531
1556
|
for (const stored of [row.references ?? "", row.in_reply_to ?? ""]) {
|
|
1532
1557
|
for (const found of String(stored).split(/\s+/)) {
|
|
@@ -1534,16 +1559,28 @@ var MailDatabase = class {
|
|
|
1534
1559
|
if (value.includes("@")) refs.push(value);
|
|
1535
1560
|
}
|
|
1536
1561
|
}
|
|
1537
|
-
if (refs.length === 0 &&
|
|
1538
|
-
|
|
1539
|
-
for (const found of headerIds(raw, header)) refs.push(found);
|
|
1540
|
-
}
|
|
1562
|
+
if (refs.length === 0 && (!withRefs || Number(row.refs_indexed ?? 0) !== 1)) {
|
|
1563
|
+
needRaw.push(id);
|
|
1541
1564
|
}
|
|
1542
1565
|
this.threadRefs.set(id, {
|
|
1543
1566
|
messageId: String(row.message_id ?? "").replace(/^<|>$/g, "").toLowerCase(),
|
|
1544
1567
|
refs: [...new Set(refs)]
|
|
1545
1568
|
});
|
|
1546
1569
|
}
|
|
1570
|
+
if (needRaw.length > 0) {
|
|
1571
|
+
const marks = needRaw.map(() => "?").join(",");
|
|
1572
|
+
const rawRows = this.open().prepare(`SELECT id, raw_eml FROM emails WHERE id IN (${marks})`).all(...needRaw);
|
|
1573
|
+
for (const row of rawRows) {
|
|
1574
|
+
const id = Number(row.id);
|
|
1575
|
+
const entry = this.threadRefs.get(id);
|
|
1576
|
+
if (entry === void 0 || row.raw_eml === null || row.raw_eml === void 0) continue;
|
|
1577
|
+
const refs = [...entry.refs];
|
|
1578
|
+
for (const header of ["references", "in-reply-to"]) {
|
|
1579
|
+
for (const found of headerIds(row.raw_eml, header)) refs.push(found);
|
|
1580
|
+
}
|
|
1581
|
+
this.threadRefs.set(id, { ...entry, refs: [...new Set(refs)] });
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1547
1584
|
} catch {
|
|
1548
1585
|
}
|
|
1549
1586
|
}
|
|
@@ -1760,7 +1797,117 @@ function rowToSummary(row) {
|
|
|
1760
1797
|
};
|
|
1761
1798
|
}
|
|
1762
1799
|
|
|
1800
|
+
// src/host/references.ts
|
|
1801
|
+
function extractReferences(raw, messageId) {
|
|
1802
|
+
const empty = { inReplyTo: null, references: null };
|
|
1803
|
+
if (raw === null || raw === void 0) return empty;
|
|
1804
|
+
const text2 = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 2e5))).toString("utf8");
|
|
1805
|
+
const separator = text2.search(/\r?\n\r?\n/);
|
|
1806
|
+
const head = separator >= 0 ? text2.slice(0, separator) : text2;
|
|
1807
|
+
const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
|
|
1808
|
+
const value = (name2) => {
|
|
1809
|
+
const match = new RegExp(`^${name2}:\\s*(.*)$`, "im").exec(unfolded);
|
|
1810
|
+
return match === null ? "" : String(match[1] ?? "");
|
|
1811
|
+
};
|
|
1812
|
+
const ids = (value2) => value2.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
1813
|
+
const referenceIds = ids(value("references"));
|
|
1814
|
+
const replyTo = ids(value("in-reply-to"));
|
|
1815
|
+
const filteredRefs = referenceIds.filter((id) => id !== messageId.toLowerCase());
|
|
1816
|
+
return {
|
|
1817
|
+
inReplyTo: replyTo[0] ?? null,
|
|
1818
|
+
references: filteredRefs.length > 0 ? filteredRefs.join(" ") : null
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
function backfillReferences(file, batchSize = 300) {
|
|
1822
|
+
ensureMailSchema(file);
|
|
1823
|
+
const db = new DatabaseSync3(file);
|
|
1824
|
+
try {
|
|
1825
|
+
db.exec("PRAGMA busy_timeout = 10000");
|
|
1826
|
+
const rows = db.prepare("SELECT id, message_id, raw_eml FROM emails WHERE refs_indexed = 0 ORDER BY id LIMIT ?").all(batchSize);
|
|
1827
|
+
if (rows.length === 0) return { updated: 0, remaining: 0 };
|
|
1828
|
+
const update = db.prepare(
|
|
1829
|
+
'UPDATE emails SET in_reply_to = ?, "references" = ?, refs_indexed = 1 WHERE id = ?'
|
|
1830
|
+
);
|
|
1831
|
+
db.exec("BEGIN");
|
|
1832
|
+
try {
|
|
1833
|
+
for (const row of rows) {
|
|
1834
|
+
const headers = extractReferences(row.raw_eml, String(row.message_id ?? ""));
|
|
1835
|
+
update.run(headers.inReplyTo, headers.references, Number(row.id));
|
|
1836
|
+
}
|
|
1837
|
+
db.exec("COMMIT");
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
db.exec("ROLLBACK");
|
|
1840
|
+
throw error;
|
|
1841
|
+
}
|
|
1842
|
+
const remaining = Number(
|
|
1843
|
+
db.prepare("SELECT COUNT(*) AS count FROM emails WHERE refs_indexed = 0").get()?.count ?? 0
|
|
1844
|
+
);
|
|
1845
|
+
return { updated: rows.length, remaining };
|
|
1846
|
+
} finally {
|
|
1847
|
+
db.close();
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
function optimizeDatabase(file) {
|
|
1851
|
+
ensureMailSchema(file);
|
|
1852
|
+
const db = new DatabaseSync3(file);
|
|
1853
|
+
try {
|
|
1854
|
+
db.exec("PRAGMA busy_timeout = 10000");
|
|
1855
|
+
db.exec("PRAGMA optimize");
|
|
1856
|
+
try {
|
|
1857
|
+
db.exec("INSERT INTO emails_fts(emails_fts) VALUES('optimize')");
|
|
1858
|
+
} catch {
|
|
1859
|
+
}
|
|
1860
|
+
try {
|
|
1861
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1862
|
+
} catch {
|
|
1863
|
+
}
|
|
1864
|
+
return { ok: true };
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1867
|
+
} finally {
|
|
1868
|
+
db.close();
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
function backfillSubjects(file, batchSize = 300) {
|
|
1872
|
+
ensureMailSchema(file);
|
|
1873
|
+
const db = new DatabaseSync3(file);
|
|
1874
|
+
try {
|
|
1875
|
+
db.exec("PRAGMA busy_timeout = 10000");
|
|
1876
|
+
const rows = db.prepare(
|
|
1877
|
+
`SELECT e.id, e.subject FROM emails e
|
|
1878
|
+
WHERE NOT EXISTS (SELECT 1 FROM email_subjects s WHERE s.email_id = e.id)
|
|
1879
|
+
ORDER BY e.id LIMIT ?`
|
|
1880
|
+
).all(batchSize);
|
|
1881
|
+
if (rows.length === 0) return { updated: 0, remaining: 0 };
|
|
1882
|
+
const insert = db.prepare("INSERT OR REPLACE INTO email_subjects (email_id, key) VALUES (?, ?)");
|
|
1883
|
+
db.exec("BEGIN");
|
|
1884
|
+
try {
|
|
1885
|
+
for (const row of rows) insert.run(Number(row.id), subjectKey(String(row.subject ?? "")));
|
|
1886
|
+
db.exec("COMMIT");
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
db.exec("ROLLBACK");
|
|
1889
|
+
throw error;
|
|
1890
|
+
}
|
|
1891
|
+
const remaining = Number(
|
|
1892
|
+
db.prepare(
|
|
1893
|
+
`SELECT COUNT(*) AS count FROM emails e
|
|
1894
|
+
WHERE NOT EXISTS (SELECT 1 FROM email_subjects s WHERE s.email_id = e.id)`
|
|
1895
|
+
).get()?.count ?? 0
|
|
1896
|
+
);
|
|
1897
|
+
return { updated: rows.length, remaining };
|
|
1898
|
+
} finally {
|
|
1899
|
+
db.close();
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1763
1903
|
// src/host/backup.ts
|
|
1904
|
+
init_address();
|
|
1905
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1906
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1907
|
+
import { join as join3 } from "node:path";
|
|
1908
|
+
import { ImapFlow } from "imapflow";
|
|
1909
|
+
import { simpleParser } from "mailparser";
|
|
1910
|
+
import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
|
|
1764
1911
|
var BATCH = 25;
|
|
1765
1912
|
function sha256(data) {
|
|
1766
1913
|
return createHash2("sha256").update(data).digest("hex");
|
|
@@ -1842,7 +1989,7 @@ var BackupEngine = class {
|
|
|
1842
1989
|
}
|
|
1843
1990
|
openDb() {
|
|
1844
1991
|
ensureMailSchema(this.runtime.mailDb);
|
|
1845
|
-
const db = new
|
|
1992
|
+
const db = new DatabaseSync4(this.runtime.mailDb);
|
|
1846
1993
|
db.exec("PRAGMA journal_mode=WAL");
|
|
1847
1994
|
db.exec("PRAGMA busy_timeout=10000");
|
|
1848
1995
|
db.exec("PRAGMA synchronous=NORMAL");
|
|
@@ -1891,6 +2038,10 @@ var BackupEngine = class {
|
|
|
1891
2038
|
);
|
|
1892
2039
|
if (inserted.changes === 0) return 0;
|
|
1893
2040
|
const emailId = Number(inserted.lastInsertRowid);
|
|
2041
|
+
db.prepare("INSERT OR REPLACE INTO email_subjects (email_id, key) VALUES (?, ?)").run(
|
|
2042
|
+
emailId,
|
|
2043
|
+
subjectKey(subject)
|
|
2044
|
+
);
|
|
1894
2045
|
const attachments = parsed.attachments ?? [];
|
|
1895
2046
|
const insertAttachment = db.prepare(
|
|
1896
2047
|
"INSERT INTO attachments (email_id, filename, content_type, size, hash, content_id, disposition) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
@@ -2096,7 +2247,7 @@ var BackupEngine = class {
|
|
|
2096
2247
|
};
|
|
2097
2248
|
if (!existsSync3(dbFile)) return empty;
|
|
2098
2249
|
try {
|
|
2099
|
-
const db = new
|
|
2250
|
+
const db = new DatabaseSync4(dbFile, { readOnly: true });
|
|
2100
2251
|
try {
|
|
2101
2252
|
const latest = db.prepare("SELECT * FROM backup_logs ORDER BY id DESC LIMIT 1").get();
|
|
2102
2253
|
const success = db.prepare("SELECT completed_at FROM backup_logs WHERE status = 'completed' ORDER BY id DESC LIMIT 1").get();
|
|
@@ -2972,7 +3123,7 @@ ${conversation.trim()}
|
|
|
2972
3123
|
}
|
|
2973
3124
|
|
|
2974
3125
|
// src/host/version.ts
|
|
2975
|
-
var VERSION = "0.
|
|
3126
|
+
var VERSION = "0.20.0";
|
|
2976
3127
|
|
|
2977
3128
|
// src/host/api.ts
|
|
2978
3129
|
init_smtp();
|
|
@@ -3194,6 +3345,10 @@ function buildRouter(runtime) {
|
|
|
3194
3345
|
}
|
|
3195
3346
|
return { ok: true, count };
|
|
3196
3347
|
});
|
|
3348
|
+
router.post("/maintenance/optimize", ({ url }) => {
|
|
3349
|
+
const { account } = accountFrom(runtime, url);
|
|
3350
|
+
return optimizeDatabase(runtime.paths(account).mailDb);
|
|
3351
|
+
});
|
|
3197
3352
|
router.get("/maintenance/references", ({ url }) => {
|
|
3198
3353
|
const { account } = accountFrom(runtime, url);
|
|
3199
3354
|
return runtime.referencesStatus(account);
|
|
@@ -4244,7 +4399,7 @@ function writeAttachment(res, filename, contentType, data, inline = false) {
|
|
|
4244
4399
|
"content-disposition",
|
|
4245
4400
|
`${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(filename)}`
|
|
4246
4401
|
);
|
|
4247
|
-
res.setHeader("cache-control", "
|
|
4402
|
+
res.setHeader("cache-control", "private, max-age=86400");
|
|
4248
4403
|
res.setHeader("x-content-type-options", "nosniff");
|
|
4249
4404
|
res.setHeader(
|
|
4250
4405
|
"content-security-policy",
|
|
@@ -4698,58 +4853,6 @@ var MailFlagsStore = class {
|
|
|
4698
4853
|
}
|
|
4699
4854
|
};
|
|
4700
4855
|
|
|
4701
|
-
// src/host/references.ts
|
|
4702
|
-
import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
|
|
4703
|
-
function extractReferences(raw, messageId) {
|
|
4704
|
-
const empty = { inReplyTo: null, references: null };
|
|
4705
|
-
if (raw === null || raw === void 0) return empty;
|
|
4706
|
-
const text2 = Buffer.from(raw.subarray(0, Math.min(raw.byteLength, 2e5))).toString("utf8");
|
|
4707
|
-
const separator = text2.search(/\r?\n\r?\n/);
|
|
4708
|
-
const head = separator >= 0 ? text2.slice(0, separator) : text2;
|
|
4709
|
-
const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
|
|
4710
|
-
const value = (name2) => {
|
|
4711
|
-
const match = new RegExp(`^${name2}:\\s*(.*)$`, "im").exec(unfolded);
|
|
4712
|
-
return match === null ? "" : String(match[1] ?? "");
|
|
4713
|
-
};
|
|
4714
|
-
const ids = (value2) => value2.split(/\s+/).map((item) => item.replace(/^<|>$/g, "").toLowerCase()).filter((item) => item.includes("@"));
|
|
4715
|
-
const referenceIds = ids(value("references"));
|
|
4716
|
-
const replyTo = ids(value("in-reply-to"));
|
|
4717
|
-
const filteredRefs = referenceIds.filter((id) => id !== messageId.toLowerCase());
|
|
4718
|
-
return {
|
|
4719
|
-
inReplyTo: replyTo[0] ?? null,
|
|
4720
|
-
references: filteredRefs.length > 0 ? filteredRefs.join(" ") : null
|
|
4721
|
-
};
|
|
4722
|
-
}
|
|
4723
|
-
function backfillReferences(file, batchSize = 300) {
|
|
4724
|
-
ensureMailSchema(file);
|
|
4725
|
-
const db = new DatabaseSync4(file);
|
|
4726
|
-
try {
|
|
4727
|
-
db.exec("PRAGMA busy_timeout = 10000");
|
|
4728
|
-
const rows = db.prepare("SELECT id, message_id, raw_eml FROM emails WHERE refs_indexed = 0 ORDER BY id LIMIT ?").all(batchSize);
|
|
4729
|
-
if (rows.length === 0) return { updated: 0, remaining: 0 };
|
|
4730
|
-
const update = db.prepare(
|
|
4731
|
-
'UPDATE emails SET in_reply_to = ?, "references" = ?, refs_indexed = 1 WHERE id = ?'
|
|
4732
|
-
);
|
|
4733
|
-
db.exec("BEGIN");
|
|
4734
|
-
try {
|
|
4735
|
-
for (const row of rows) {
|
|
4736
|
-
const headers = extractReferences(row.raw_eml, String(row.message_id ?? ""));
|
|
4737
|
-
update.run(headers.inReplyTo, headers.references, Number(row.id));
|
|
4738
|
-
}
|
|
4739
|
-
db.exec("COMMIT");
|
|
4740
|
-
} catch (error) {
|
|
4741
|
-
db.exec("ROLLBACK");
|
|
4742
|
-
throw error;
|
|
4743
|
-
}
|
|
4744
|
-
const remaining = Number(
|
|
4745
|
-
db.prepare("SELECT COUNT(*) AS count FROM emails WHERE refs_indexed = 0").get()?.count ?? 0
|
|
4746
|
-
);
|
|
4747
|
-
return { updated: rows.length, remaining };
|
|
4748
|
-
} finally {
|
|
4749
|
-
db.close();
|
|
4750
|
-
}
|
|
4751
|
-
}
|
|
4752
|
-
|
|
4753
4856
|
// src/host/tasks.ts
|
|
4754
4857
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
4755
4858
|
import { mkdirSync as mkdirSync7 } from "node:fs";
|
|
@@ -5359,6 +5462,13 @@ var MailHarborRuntime = class {
|
|
|
5359
5462
|
if (result.remaining <= 0 || result.updated === 0) break;
|
|
5360
5463
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
5361
5464
|
}
|
|
5465
|
+
for (; ; ) {
|
|
5466
|
+
const result = backfillSubjects(dbFile, 300);
|
|
5467
|
+
status.indexed += result.updated;
|
|
5468
|
+
status.remaining = result.remaining;
|
|
5469
|
+
if (result.remaining <= 0 || result.updated === 0) break;
|
|
5470
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
5471
|
+
}
|
|
5362
5472
|
} catch (error) {
|
|
5363
5473
|
status.error = error instanceof Error ? error.message : String(error);
|
|
5364
5474
|
} finally {
|