@puwenhui/dsh-email 0.2.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/lib/plugin.mjs ADDED
@@ -0,0 +1,1108 @@
1
+ // src/config.ts
2
+ import fs from "node:fs";
3
+ import z from "@deepseek-ai/schemastery";
4
+ function loadLocal() {
5
+ for (const candidate of ["config.local.json", "../email-plugin/config.local.json"]) {
6
+ try {
7
+ return JSON.parse(fs.readFileSync(new URL(candidate, import.meta.url), "utf8"));
8
+ } catch {
9
+ }
10
+ }
11
+ return {};
12
+ }
13
+ function resolveConfig(overrides = {}) {
14
+ const env = process.env;
15
+ const local = loadLocal();
16
+ // 空数组跳过:schemastery 的 z.array 缺省会产出 [],不能让空配置覆盖内置默认文件夹
17
+ const pick = (...vals) => vals.find((v) => v !== void 0 && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0));
18
+ const tlsModeRaw = pick(overrides.tlsMode, env.EMAIL_IMAP_TLS, env.PROBE_IMAP_TLS, local.tls, "tls");
19
+ const host = pick(overrides.host, env.EMAIL_IMAP_HOST, env.PROBE_IMAP_HOST, local.host, "imap.263.net");
20
+ const user = pick(overrides.user, env.EMAIL_IMAP_USER, env.PROBE_IMAP_USER, local.user) ?? "";
21
+ const pass = pick(overrides.pass, env.EMAIL_IMAP_PASS, env.PROBE_IMAP_PASS, local.pass) ?? "";
22
+ const cfg = {
23
+ host,
24
+ port: Number(pick(overrides.port, env.EMAIL_IMAP_PORT, env.PROBE_IMAP_PORT, local.port, 993)),
25
+ tlsMode: ["tls", "starttls", "none"].includes(String(tlsModeRaw)) ? tlsModeRaw : "tls",
26
+ user,
27
+ pass,
28
+ dbPath: pick(overrides.dbPath, env.EMAIL_DB_PATH, local.dbPath, "mail.db"),
29
+ folders: pick(overrides.folders, local.folders, ["INBOX", "\u5DF2\u53D1\u9001"]),
30
+ backfillDays: Number(pick(overrides.backfillDays, env.EMAIL_BACKFILL_DAYS, local.backfillDays, 90)),
31
+ pollSeconds: Number(pick(overrides.pollSeconds, env.EMAIL_POLL_SECONDS, 60)),
32
+ maxSourceBytes: Number(pick(overrides.maxSourceBytes, env.EMAIL_MAX_SOURCE_BYTES, local.maxSourceBytes, 10 * 1024 * 1024)),
33
+ // SMTP \u53D1\u4EF6\uFF1A263 \u4E3A smtp.263.net:25 \u65E0 SSL\uFF08secure \u7531 port===465 \u63A8\u5B9A\uFF0C
34
+ // 587 \u8D70 STARTTLS\uFF09\uFF1B\u5BC6\u7801\u56FA\u5B9A\u8D70 EMAIL_SMTP_PASS\uFF0C\u7F3A\u7701\u56DE\u9000 IMAP \u5BC6\u7801
35
+ smtpHost: pick(overrides.smtpHost, env.EMAIL_SMTP_HOST, local.smtpHost, host.replace(/^imap\./, "smtp.")),
36
+ smtpPort: Number(pick(overrides.smtpPort, env.EMAIL_SMTP_PORT, local.smtpPort, 25)),
37
+ smtpUser: pick(overrides.smtpUser, env.EMAIL_SMTP_USER, local.smtpUser) ?? user,
38
+ smtpPass: pick(env.EMAIL_SMTP_PASS, local.smtpPass) ?? pass,
39
+ // \u9644\u4EF6\u4E0B\u8F7D\u4FDD\u5B58\u76EE\u5F55\uFF08\u76F8\u5BF9\u63D2\u4EF6\u76EE\u5F55\uFF09
40
+ attachmentDir: pick(overrides.attachmentDir, env.EMAIL_ATTACHMENT_DIR, local.attachmentDir, "data/attachments")
41
+ };
42
+ if (!cfg.user || !cfg.pass) throw new Error("\u7F3A\u5C11\u8D26\u53F7\u914D\u7F6E\uFF1A\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF EMAIL_IMAP_USER / EMAIL_IMAP_PASS\uFF08\u6216 config.local.json / CLI \u53C2\u6570\uFF09");
43
+ return cfg;
44
+ }
45
+
46
+ // src/store.ts
47
+ import { DatabaseSync } from "node:sqlite";
48
+ import { fileURLToPath } from "node:url";
49
+ import path from "node:path";
50
+ var SCHEMA = `
51
+ CREATE TABLE IF NOT EXISTS messages(
52
+ mailbox TEXT NOT NULL,
53
+ uid INTEGER NOT NULL,
54
+ message_id TEXT,
55
+ in_reply_to TEXT,
56
+ refs TEXT,
57
+ thread_id INTEGER NOT NULL,
58
+ from_addr TEXT NOT NULL DEFAULT '',
59
+ from_name TEXT NOT NULL DEFAULT '',
60
+ to_addrs TEXT NOT NULL DEFAULT '[]',
61
+ cc_addrs TEXT NOT NULL DEFAULT '[]',
62
+ subject TEXT NOT NULL DEFAULT '',
63
+ subject_norm TEXT NOT NULL DEFAULT '',
64
+ date TEXT NOT NULL DEFAULT '',
65
+ size INTEGER NOT NULL DEFAULT 0,
66
+ snippet TEXT NOT NULL DEFAULT '',
67
+ body_text TEXT NOT NULL DEFAULT '',
68
+ flags TEXT NOT NULL DEFAULT '[]',
69
+ has_attachment INTEGER NOT NULL DEFAULT 0,
70
+ fetched_at TEXT NOT NULL DEFAULT '',
71
+ PRIMARY KEY(mailbox, uid)
72
+ );
73
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
74
+ CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date);
75
+ CREATE INDEX IF NOT EXISTS idx_messages_subjectnorm ON messages(subject_norm);
76
+ CREATE TABLE IF NOT EXISTS threads(
77
+ id INTEGER PRIMARY KEY,
78
+ subject_norm TEXT NOT NULL DEFAULT '',
79
+ first_at TEXT NOT NULL DEFAULT '',
80
+ last_at TEXT NOT NULL DEFAULT '',
81
+ msg_count INTEGER NOT NULL DEFAULT 0,
82
+ unseen INTEGER NOT NULL DEFAULT 0,
83
+ participants TEXT NOT NULL DEFAULT '[]',
84
+ last_subject TEXT NOT NULL DEFAULT '',
85
+ importance INTEGER NOT NULL DEFAULT 0
86
+ );
87
+ CREATE TABLE IF NOT EXISTS attachments(
88
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
89
+ mailbox TEXT NOT NULL,
90
+ uid INTEGER NOT NULL,
91
+ part_id TEXT NOT NULL DEFAULT '',
92
+ filename TEXT NOT NULL DEFAULT '',
93
+ content_type TEXT NOT NULL DEFAULT '',
94
+ size INTEGER NOT NULL DEFAULT 0,
95
+ UNIQUE(mailbox, uid, part_id)
96
+ );
97
+ CREATE INDEX IF NOT EXISTS idx_attachments_thread ON attachments(mailbox, uid);
98
+ CREATE VIRTUAL TABLE IF NOT EXISTS mail_fts USING fts5(
99
+ subject, from_text, body, thread_id UNINDEXED, mailbox UNINDEXED, uid UNINDEXED,
100
+ tokenize='trigram'
101
+ );
102
+ CREATE TABLE IF NOT EXISTS sync_state(
103
+ mailbox TEXT PRIMARY KEY,
104
+ uidvalidity INTEGER NOT NULL DEFAULT 0,
105
+ last_uid INTEGER NOT NULL DEFAULT 0,
106
+ last_sync_at TEXT NOT NULL DEFAULT ''
107
+ );
108
+ `;
109
+ var Store = class {
110
+ db;
111
+ constructor(dbPath) {
112
+ const resolved = dbPath === ":memory:" ? ":memory:" : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", dbPath);
113
+ this.db = new DatabaseSync(resolved);
114
+ this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
115
+ this.db.exec(SCHEMA);
116
+ }
117
+ /* ---------- 同步状态 ---------- */
118
+ getSyncState(mailbox) {
119
+ return this.db.prepare("SELECT * FROM sync_state WHERE mailbox=?").get(mailbox);
120
+ }
121
+ setSyncState(mailbox, uidvalidity, lastUid) {
122
+ this.db.prepare("INSERT INTO sync_state(mailbox,uidvalidity,last_uid,last_sync_at) VALUES(?,?,?,?) ON CONFLICT(mailbox) DO UPDATE SET uidvalidity=excluded.uidvalidity, last_uid=excluded.last_uid, last_sync_at=excluded.last_sync_at").run(mailbox, uidvalidity, lastUid, (/* @__PURE__ */ new Date()).toISOString());
123
+ }
124
+ /* ---------- 线程 ---------- */
125
+ allocThreadId() {
126
+ const row = this.db.prepare("SELECT COALESCE(MAX(id),0)+1 AS next FROM threads").get();
127
+ return row.next;
128
+ }
129
+ createThread(id, subjectNorm) {
130
+ this.db.prepare("INSERT OR IGNORE INTO threads(id, subject_norm) VALUES(?,?)").run(id, subjectNorm);
131
+ }
132
+ threadParticipants(threadId) {
133
+ const row = this.db.prepare("SELECT participants FROM threads WHERE id=?").get(threadId);
134
+ try {
135
+ return row ? JSON.parse(row.participants) : [];
136
+ } catch {
137
+ return [];
138
+ }
139
+ }
140
+ /** 全量重建线程聚合(同步后/重算后调用)。 */
141
+ refreshThreadAggregates() {
142
+ this.db.exec(`
143
+ UPDATE threads SET
144
+ first_at = COALESCE((SELECT MIN(date) FROM messages m WHERE m.thread_id=threads.id AND m.date!=''),''),
145
+ last_at = COALESCE((SELECT MAX(date) FROM messages m WHERE m.thread_id=threads.id AND m.date!=''),''),
146
+ msg_count = (SELECT COUNT(*) FROM messages m WHERE m.thread_id=threads.id),
147
+ unseen = (SELECT COUNT(*) FROM messages m WHERE m.thread_id=threads.id AND m.flags NOT LIKE '%"\\Seen"%'),
148
+ last_subject = COALESCE((SELECT subject FROM messages m WHERE m.thread_id=threads.id AND m.date=(SELECT MAX(date) FROM messages m2 WHERE m2.thread_id=threads.id) LIMIT 1),'')
149
+ `);
150
+ }
151
+ /* ---------- 邮件 ---------- */
152
+ upsertMessage(row) {
153
+ this.db.prepare(`INSERT INTO messages(mailbox,uid,message_id,in_reply_to,refs,thread_id,from_addr,from_name,to_addrs,cc_addrs,subject,subject_norm,date,size,snippet,body_text,flags,has_attachment,fetched_at)
154
+ VALUES(@mailbox,@uid,@message_id,@in_reply_to,@refs,@thread_id,@from_addr,@from_name,@to_addrs,@cc_addrs,@subject,@subject_norm,@date,@size,@snippet,@body_text,@flags,@has_attachment,@fetched_at)
155
+ ON CONFLICT(mailbox,uid) DO UPDATE SET
156
+ message_id=excluded.message_id, in_reply_to=excluded.in_reply_to, refs=excluded.refs, thread_id=excluded.thread_id,
157
+ from_addr=excluded.from_addr, from_name=excluded.from_name, to_addrs=excluded.to_addrs, cc_addrs=excluded.cc_addrs,
158
+ subject=excluded.subject, subject_norm=excluded.subject_norm, date=excluded.date, size=excluded.size,
159
+ snippet=excluded.snippet, body_text=excluded.body_text, flags=excluded.flags, has_attachment=excluded.has_attachment,
160
+ fetched_at=excluded.fetched_at`).run(row);
161
+ this.db.prepare("DELETE FROM mail_fts WHERE mailbox=? AND uid=?").run(row.mailbox, row.uid);
162
+ this.db.prepare("INSERT INTO mail_fts(subject,from_text,body,thread_id,mailbox,uid) VALUES(?,?,?,?,?,?)").run(row.subject, `${row.from_name} ${row.from_addr}`, row.body_text.slice(0, 1e5), row.thread_id, row.mailbox, row.uid);
163
+ }
164
+ updateFlags(mailbox, uid, flags) {
165
+ this.db.prepare("UPDATE messages SET flags=? WHERE mailbox=? AND uid=?").run(flags, mailbox, uid);
166
+ }
167
+ wipeFolder(mailbox) {
168
+ this.db.prepare("DELETE FROM messages WHERE mailbox=?").run(mailbox);
169
+ this.db.prepare("DELETE FROM mail_fts WHERE mailbox=?").run(mailbox);
170
+ this.db.prepare("DELETE FROM sync_state WHERE mailbox=?").run(mailbox);
171
+ }
172
+ clearThreading() {
173
+ this.db.prepare("UPDATE messages SET thread_id=0").run();
174
+ this.db.prepare("DELETE FROM threads").run();
175
+ }
176
+ setThreadId(mailbox, uid, threadId) {
177
+ this.db.prepare("UPDATE messages SET thread_id=? WHERE mailbox=? AND uid=?").run(threadId, mailbox, uid);
178
+ }
179
+ messageCount(mailbox) {
180
+ const row = mailbox ? this.db.prepare("SELECT COUNT(*) AS c FROM messages WHERE mailbox=?").get(mailbox) : this.db.prepare("SELECT COUNT(*) AS c FROM messages").get();
181
+ return row.c;
182
+ }
183
+ oldestDate() {
184
+ const row = this.db.prepare("SELECT MIN(date) AS d FROM messages WHERE date!=''").get();
185
+ return row.d || "";
186
+ }
187
+ };
188
+
189
+ // src/sync.ts
190
+ import { ImapFlow } from "imapflow";
191
+ import { simpleParser } from "mailparser";
192
+
193
+ // src/thread.ts
194
+ function normalizeSubject(subject) {
195
+ let s = (subject || "").normalize("NFKC").replace(/\s+/g, " ").trim();
196
+ const prefixes = /^(re(\d+)?|fw(d)?|fwd|aw|sv|回复|答复|转发|转寄|回覆)\s*[::]\s*/i;
197
+ for (let i = 0; i < 10; i++) {
198
+ const next = s.replace(prefixes, "");
199
+ if (next === s) break;
200
+ s = next;
201
+ }
202
+ return s.trim();
203
+ }
204
+ function extractMessageIds(refs) {
205
+ const raw = String(refs || "");
206
+ const out = [];
207
+ const re = /<([^<>]+)>/g;
208
+ let m;
209
+ while ((m = re.exec(raw)) !== null) if (!out.includes(m[1])) out.push(m[1]);
210
+ return out;
211
+ }
212
+ function domainOf(addr) {
213
+ const at = String(addr || "").lastIndexOf("@");
214
+ return at === -1 ? "" : String(addr).slice(at + 1).toLowerCase();
215
+ }
216
+ function createThreadResolver(allocThreadId, onThreadCreated) {
217
+ const r = {
218
+ idToThread: /* @__PURE__ */ new Map(),
219
+ subjectIndex: /* @__PURE__ */ new Map(),
220
+ resolve(c) {
221
+ for (let i = c.ancestors.length - 1; i >= 0; i--) {
222
+ const t = this.idToThread.get(c.ancestors[i]);
223
+ if (t !== void 0) return t;
224
+ }
225
+ if (c.messageId && this.idToThread.has(c.messageId)) return this.idToThread.get(c.messageId);
226
+ const candidates = this.subjectIndex.get(c.subjectNorm) || [];
227
+ for (const tid2 of candidates) {
228
+ const parts = c.threadParticipants || [];
229
+ const sameAddr = parts.some((p) => p.toLowerCase() === c.fromAddr.toLowerCase());
230
+ const sameDomain = parts.some((p) => domainOf(p) === domainOf(c.fromAddr) && domainOf(c.fromAddr) !== "");
231
+ if (sameAddr || sameDomain) return tid2;
232
+ }
233
+ const tid = allocThreadId();
234
+ onThreadCreated(tid, c.subjectNorm);
235
+ return tid;
236
+ },
237
+ register(messageId, ancestors, threadId) {
238
+ if (messageId) this.idToThread.set(messageId, threadId);
239
+ for (const a of ancestors) if (!this.idToThread.has(a)) this.idToThread.set(a, threadId);
240
+ }
241
+ };
242
+ return r;
243
+ }
244
+
245
+ // src/htmltext.ts
246
+ function htmlToText(html) {
247
+ return html.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|tr|li|h[1-6])>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;/gi, "'").replace(/[ \t\u00a0]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
248
+ }
249
+
250
+ // src/sync.ts
251
+ var PER_MAIL_TIMEOUT_MS = 2e4;
252
+ function withTimeout(p, ms, tag) {
253
+ let settled = false;
254
+ return Promise.race([
255
+ p.then((value) => {
256
+ settled = true;
257
+ return { ok: true, value };
258
+ }, () => {
259
+ settled = true;
260
+ return { ok: false };
261
+ }),
262
+ new Promise((res) => setTimeout(() => {
263
+ if (!settled) console.warn(`[dsh-email][sync] \u23F1 ${tag} \u8D85\u65F6`);
264
+ res({ ok: false });
265
+ }, ms))
266
+ ]);
267
+ }
268
+ var SyncEngine = class {
269
+ constructor(cfg, store) {
270
+ this.cfg = cfg;
271
+ this.store = store;
272
+ }
273
+ cfg;
274
+ store;
275
+ client = null;
276
+ async connect() {
277
+ if (this.client) return this.client;
278
+ this.client = await this.makeClient();
279
+ return this.client;
280
+ }
281
+ async makeClient() {
282
+ const client = new ImapFlow({
283
+ host: this.cfg.host,
284
+ port: this.cfg.port,
285
+ secure: this.cfg.tlsMode === "tls",
286
+ auth: { user: this.cfg.user, pass: this.cfg.pass },
287
+ logger: false,
288
+ connectionTimeout: 2e4,
289
+ greetingTimeout: 2e4,
290
+ socketTimeout: 6e4
291
+ });
292
+ await client.connect();
293
+ return client;
294
+ }
295
+ /** 强制重连(单封 source 卡死后协议失步,唯一安全的恢复方式)。 */
296
+ async reconnect() {
297
+ try {
298
+ this.client?.close();
299
+ } catch {
300
+ }
301
+ this.client = null;
302
+ return this.connect();
303
+ }
304
+ async close() {
305
+ try {
306
+ this.client?.logout();
307
+ } catch {
308
+ try {
309
+ this.client?.close();
310
+ } catch {
311
+ }
312
+ }
313
+ this.client = null;
314
+ }
315
+ async syncOnce() {
316
+ let client = await this.connect();
317
+ const reports = [];
318
+ for (const folder of this.cfg.folders) {
319
+ reports.push(await this.syncFolder(client, folder));
320
+ client = this.client ?? client;
321
+ }
322
+ this.store.refreshThreadAggregates();
323
+ return reports;
324
+ }
325
+ async syncFolder(clientIn, folder) {
326
+ let client = clientIn;
327
+ const t0 = Date.now();
328
+ const log = (m) => console.log(`[dsh-email][sync] ${folder}: ${m}\uFF08+${Date.now() - t0}ms\uFF09`);
329
+ log("\u5F00\u59CB");
330
+ let lock = await client.getMailboxLock(folder);
331
+ const report = { folder, fetched: 0, bodyOk: 0, bodySkipped: 0, bodyHang: 0, lastUid: 0, rescan: false };
332
+ try {
333
+ const uidValidity = Number(client.mailbox.uidValidity ?? 0);
334
+ const state = this.store.getSyncState(folder);
335
+ let lastUid = state?.last_uid ?? 0;
336
+ if (state && Number(state.uidvalidity) !== uidValidity) {
337
+ this.store.wipeFolder(folder);
338
+ lastUid = 0;
339
+ report.rescan = true;
340
+ }
341
+ let targetUids = [];
342
+ if (lastUid === 0) {
343
+ const since = new Date(Date.now() - this.cfg.backfillDays * 864e5);
344
+ const found = await client.search({ since }, { uid: true });
345
+ targetUids = [...new Set(found ?? [])].sort((a, b) => a - b);
346
+ } else {
347
+ const found = await client.search({ uid: `${lastUid + 1}:*` }, { uid: true });
348
+ targetUids = [...new Set(found ?? [])].filter((u) => u > lastUid).sort((a, b) => a - b);
349
+ }
350
+ report.lastUid = Math.max(lastUid, ...targetUids, 0);
351
+ log(`\u63A2\u6D4B ${targetUids.length} \u4E2A\u65B0 uid\uFF08lastUid=${lastUid}\uFF09`);
352
+ const metaBatch = 40;
353
+ const rows = [];
354
+ for (let i = 0; i < targetUids.length; i += metaBatch) {
355
+ const batch = targetUids.slice(i, i + metaBatch);
356
+ const range = `${batch[0]}:${batch[batch.length - 1]}`;
357
+ for await (const m of client.fetch(range, { uid: true, envelope: true, internalDate: true, size: true, flags: true }, { uid: true })) {
358
+ if (!batch.includes(m.uid)) continue;
359
+ const row = this.envelopeToRow(folder, m);
360
+ if (row) {
361
+ rows.push(row);
362
+ this.store.upsertMessage(row);
363
+ report.fetched++;
364
+ }
365
+ }
366
+ log(`\u5143\u6570\u636E ${Math.min(i + metaBatch, targetUids.length)}/${targetUids.length}`);
367
+ }
368
+ for (const row of rows) {
369
+ if ((row.size ?? 0) > this.cfg.maxSourceBytes) {
370
+ report.bodySkipped++;
371
+ continue;
372
+ }
373
+ const existing = this.store.db.prepare("SELECT body_text FROM messages WHERE mailbox=? AND uid=?").get(row.mailbox, row.uid);
374
+ if (existing && existing.body_text) {
375
+ report.bodyOk++;
376
+ continue;
377
+ }
378
+ const got = await withTimeout(this.fetchSource(client, row.uid), PER_MAIL_TIMEOUT_MS, `source ${row.uid}`);
379
+ if (!got.ok) {
380
+ report.bodyHang++;
381
+ client = await this.reconnect();
382
+ lock.release();
383
+ lock = await client.getMailboxLock(folder);
384
+ continue;
385
+ }
386
+ if (got.value) {
387
+ await this.applySource(row, got.value);
388
+ report.bodyOk++;
389
+ } else report.bodySkipped++;
390
+ if ((report.bodyOk + report.bodySkipped + report.bodyHang) % 10 === 0) log(`\u6B63\u6587 ${report.bodyOk + report.bodySkipped + report.bodyHang}/${rows.length}`);
391
+ }
392
+ log("\u5237\u65B0 flags\u2026");
393
+ await this.refreshFlags(client, folder);
394
+ this.store.setSyncState(folder, uidValidity, report.lastUid);
395
+ log(`\u5B8C\u6210 fetched=${report.fetched} bodyOk=${report.bodyOk} skip=${report.bodySkipped} hang=${report.bodyHang}`);
396
+ return report;
397
+ } finally {
398
+ lock.release();
399
+ }
400
+ }
401
+ /** 单封 source:返回 Buffer 或 null(无数据)。 */
402
+ async fetchSource(client, uid) {
403
+ for await (const m of client.fetch(String(uid), { source: true }, { uid: true })) return m.source ?? null;
404
+ return null;
405
+ }
406
+ /** imapflow envelope 对象 → MessageRow(无线程归并的元数据行)。 */
407
+ envelopeToRow(folder, m) {
408
+ const env = m.envelope;
409
+ if (!env) return null;
410
+ const messageId = String(env.messageId || "").replace(/[<>]/g, "") || null;
411
+ const subject = env.subject ?? "";
412
+ const date = new Date(env.date || m.internalDate || 0);
413
+ const toAddrs = (env.to ?? []).map((a) => a.address).filter(Boolean);
414
+ const ccAddrs = (env.cc ?? []).map((a) => a.address).filter(Boolean);
415
+ const row = {
416
+ mailbox: folder,
417
+ uid: m.uid,
418
+ message_id: messageId,
419
+ in_reply_to: env.inReplyTo ? String(env.inReplyTo) : null,
420
+ refs: null,
421
+ thread_id: 0,
422
+ from_addr: env.from?.[0]?.address ?? "",
423
+ from_name: env.from?.[0]?.name ?? "",
424
+ to_addrs: JSON.stringify(toAddrs),
425
+ cc_addrs: JSON.stringify(ccAddrs),
426
+ subject,
427
+ subject_norm: normalizeSubject(subject),
428
+ date: date instanceof Date && !Number.isNaN(date.getTime()) ? date.toISOString() : "",
429
+ size: m.size ?? 0,
430
+ snippet: "",
431
+ body_text: "",
432
+ flags: JSON.stringify([...m.flags ?? []]),
433
+ has_attachment: 0,
434
+ fetched_at: (/* @__PURE__ */ new Date()).toISOString()
435
+ };
436
+ row.thread_id = this.resolveThreadFor(row);
437
+ return row;
438
+ }
439
+ /** 解析 source 并回填正文/附件/References(更新 DB 与 FTS)。 */
440
+ async applySource(row, source) {
441
+ try {
442
+ const parsed = await simpleParser(source);
443
+ const refsHeader = parsed.references ? String(parsed.references) : null;
444
+ const bodyText = parsed.text?.trim() || (parsed.html ? htmlToText(String(parsed.html)) : "");
445
+ const snippet = bodyText.replace(/\s+/g, " ").slice(0, 200);
446
+ this.store.db.prepare("UPDATE messages SET body_text=?, snippet=?, refs=COALESCE(?,refs), has_attachment=? WHERE mailbox=? AND uid=?").run(bodyText.slice(0, 2e5), snippet, refsHeader, (parsed.attachments?.length ?? 0) > 0 ? 1 : 0, row.mailbox, row.uid);
447
+ this.store.db.prepare("DELETE FROM mail_fts WHERE mailbox=? AND uid=?").run(row.mailbox, row.uid);
448
+ this.store.db.prepare("INSERT INTO mail_fts(subject,from_text,body,thread_id,mailbox,uid) VALUES(?,?,?,?,?,?)").run(row.subject, `${row.from_name} ${row.from_addr}`, bodyText.slice(0, 1e5), row.thread_id, row.mailbox, row.uid);
449
+ // 附件元数据入库(partId 定位 MIME 部件,供 email_attachment_list / get 使用)
450
+ this.store.db.prepare("DELETE FROM attachments WHERE mailbox=? AND uid=?").run(row.mailbox, row.uid);
451
+ const insAtt = this.store.db.prepare("INSERT OR IGNORE INTO attachments(mailbox,uid,part_id,filename,content_type,size) VALUES(?,?,?,?,?,?)");
452
+ for (const att of parsed.attachments ?? []) {
453
+ insAtt.run(row.mailbox, row.uid, String(att.partId ?? ""), att.filename || "(未命名)", att.contentType || "", Number(att.size ?? att.content?.length ?? 0));
454
+ }
455
+ } catch (e) {
456
+ console.error(`[dsh-email][sync] \u89E3\u6790\u5931\u8D25 ${row.mailbox}#${row.uid}: ${String(e)}`);
457
+ }
458
+ }
459
+ async refreshFlags(client, folder) {
460
+ const rows = this.store.db.prepare("SELECT uid FROM messages WHERE mailbox=? ORDER BY uid").all(folder);
461
+ const CHUNK = 100;
462
+ for (let i = 0; i < rows.length; i += CHUNK) {
463
+ const chunk = rows.slice(i, i + CHUNK);
464
+ const range = `${chunk[0].uid}:${chunk[chunk.length - 1].uid}`;
465
+ for await (const m of client.fetch(range, { uid: true, flags: true }, { uid: true })) {
466
+ this.store.updateFlags(folder, m.uid, JSON.stringify([...m.flags ?? []]));
467
+ }
468
+ }
469
+ }
470
+ /* ---------- 线程归并(内存 resolver + DB 冷启动) ---------- */
471
+ threadResolver = createThreadResolver(
472
+ () => this.store.allocThreadId(),
473
+ (id, subjectNorm) => {
474
+ this.store.createThread(id, subjectNorm);
475
+ }
476
+ );
477
+ resolverWarm = false;
478
+ warmResolver() {
479
+ if (this.resolverWarm) return;
480
+ this.resolverWarm = true;
481
+ const rows = this.store.db.prepare("SELECT message_id, refs, thread_id FROM messages WHERE message_id IS NOT NULL AND message_id!=''").all();
482
+ for (const r of rows) this.threadResolver.register(r.message_id, extractMessageIds(r.refs ?? ""), r.thread_id);
483
+ const threads = this.store.db.prepare("SELECT id, subject_norm FROM threads").all();
484
+ for (const t of threads) {
485
+ const arr = this.threadResolver.subjectIndex.get(t.subject_norm) ?? [];
486
+ arr.push(t.id);
487
+ this.threadResolver.subjectIndex.set(t.subject_norm, arr);
488
+ }
489
+ }
490
+ resolveThreadFor(row) {
491
+ this.warmResolver();
492
+ const ancestors = extractMessageIds(row.in_reply_to ?? "");
493
+ const participants = this.knownParticipantsFor(row.subject_norm);
494
+ const tid = this.threadResolver.resolve({
495
+ messageId: row.message_id,
496
+ ancestors,
497
+ subjectNorm: row.subject_norm,
498
+ fromAddr: row.from_addr,
499
+ threadParticipants: participants
500
+ });
501
+ this.threadResolver.register(row.message_id, ancestors, tid);
502
+ const arr = this.threadResolver.subjectIndex.get(row.subject_norm) ?? [];
503
+ if (!arr.includes(tid)) {
504
+ arr.push(tid);
505
+ this.threadResolver.subjectIndex.set(row.subject_norm, arr);
506
+ }
507
+ try {
508
+ const rowP = this.store.db.prepare("SELECT participants FROM threads WHERE id=?").get(tid);
509
+ if (rowP) {
510
+ const list = JSON.parse(rowP.participants);
511
+ for (const p of [row.from_addr, ...JSON.parse(row.to_addrs ?? "[]"), ...participants]) if (p && !list.includes(p)) list.push(p);
512
+ this.store.db.prepare("UPDATE threads SET participants=? WHERE id=?").run(JSON.stringify(list.slice(0, 50)), tid);
513
+ }
514
+ } catch {
515
+ }
516
+ return tid;
517
+ }
518
+ knownParticipantsFor(subjectNorm) {
519
+ const rows = this.store.db.prepare("SELECT participants FROM threads WHERE subject_norm=?").all(subjectNorm);
520
+ const out = [];
521
+ for (const r of rows) {
522
+ try {
523
+ for (const p of JSON.parse(r.participants)) if (!out.includes(p)) out.push(p);
524
+ } catch {
525
+ }
526
+ }
527
+ return out;
528
+ }
529
+ };
530
+
531
+ // src/search.ts
532
+ function likeEscape(s) {
533
+ return s.replace(/[%_\\]/g, (c) => `\\${c}`);
534
+ }
535
+ function search(store, f) {
536
+ const limit = Math.min(Math.max(f.limit ?? 20, 1), 200);
537
+ const where = [];
538
+ const params = {};
539
+ let baseJoin = "FROM messages m";
540
+ let useFts = false;
541
+ if (f.query && f.query.trim()) {
542
+ const q = f.query.trim();
543
+ if (q.length >= 3) {
544
+ useFts = true;
545
+ baseJoin = "FROM mail_fts f JOIN messages m ON m.mailbox=f.mailbox AND m.uid=f.uid";
546
+ where.push(`f.mail_fts MATCH @match`);
547
+ params.match = `"${q.replace(/"/g, " ")}"`;
548
+ } else {
549
+ const esc = `%${likeEscape(q)}%`;
550
+ where.push(`(m.subject LIKE @q ESCAPE '\\' OR m.from_name LIKE @q ESCAPE '\\' OR m.from_addr LIKE @q ESCAPE '\\' OR m.snippet LIKE @q ESCAPE '\\')`);
551
+ params.q = esc;
552
+ }
553
+ }
554
+ if (f.from) {
555
+ where.push("(m.from_addr LIKE @from ESCAPE '\\' OR m.from_name LIKE @from ESCAPE '\\')");
556
+ params.from = `%${likeEscape(f.from)}%`;
557
+ }
558
+ if (f.sinceDays && f.sinceDays > 0) {
559
+ where.push("m.date >= @since");
560
+ params.since = new Date(Date.now() - f.sinceDays * 864e5).toISOString();
561
+ }
562
+ if (f.threadId) {
563
+ where.push("m.thread_id = @thread");
564
+ params.thread = f.threadId;
565
+ }
566
+ if (f.unreadOnly) {
567
+ where.push(`m.flags NOT LIKE '%\\"\\\\Seen\\"%'`);
568
+ }
569
+ if (f.hasAttachment) {
570
+ where.push("m.has_attachment = 1");
571
+ }
572
+ const sql = `SELECT m.mailbox, m.uid, m.subject, m.from_name, m.from_addr, m.date, m.snippet, m.thread_id, m.flags, m.has_attachment
573
+ ${baseJoin} ${where.length ? "WHERE " + where.join(" AND ") : ""}
574
+ ORDER BY m.date DESC LIMIT ${limit} OFFSET ${Math.max(f.offset ?? 0, 0)}`;
575
+ const rows = store.db.prepare(sql).all(params);
576
+ return rows.map((r) => ({
577
+ mailbox: r.mailbox,
578
+ uid: r.uid,
579
+ subject: r.subject ?? "",
580
+ from_name: r.from_name ?? "",
581
+ from_addr: r.from_addr ?? "",
582
+ date: r.date ?? "",
583
+ snippet: r.snippet ?? "",
584
+ thread_id: r.thread_id,
585
+ unread: !String(r.flags ?? "[]").includes("\\Seen"),
586
+ has_attachment: r.has_attachment
587
+ }));
588
+ }
589
+ function listThreads(store, top = 15) {
590
+ const rows = store.db.prepare("SELECT * FROM threads ORDER BY last_at DESC LIMIT ?").all(top);
591
+ return rows.map((r) => ({
592
+ id: r.id,
593
+ subject_norm: r.subject_norm,
594
+ last_subject: r.last_subject ?? r.subject_norm,
595
+ msg_count: r.msg_count,
596
+ unseen: r.unseen,
597
+ first_at: r.first_at,
598
+ last_at: r.last_at,
599
+ participants: (() => {
600
+ try {
601
+ return JSON.parse(r.participants);
602
+ } catch {
603
+ return [];
604
+ }
605
+ })()
606
+ }));
607
+ }
608
+ function threadTimeline(store, threadId, includeBody = false) {
609
+ const rows = store.db.prepare("SELECT * FROM messages WHERE thread_id=? ORDER BY date ASC").all(threadId);
610
+ return rows.map((r) => ({
611
+ mailbox: r.mailbox,
612
+ uid: r.uid,
613
+ subject: r.subject,
614
+ from_name: r.from_name,
615
+ from_addr: r.from_addr,
616
+ date: r.date,
617
+ snippet: r.snippet,
618
+ unread: !String(r.flags ?? "").includes("\\Seen"),
619
+ ...includeBody ? { body: (r.body_text || "").slice(0, 4e3) } : {}
620
+ }));
621
+ }
622
+ function stats(store) {
623
+ const total = store.messageCount();
624
+ const byFolder = store.db.prepare("SELECT mailbox, COUNT(*) AS c FROM messages GROUP BY mailbox").all();
625
+ const threads = store.db.prepare("SELECT COUNT(*) AS c FROM threads").get().c;
626
+ const multi = store.db.prepare("SELECT COUNT(*) AS c FROM threads WHERE msg_count>1").get().c;
627
+ const unseen = store.db.prepare(`SELECT COUNT(*) AS c FROM messages WHERE flags NOT LIKE '%\\"\\\\Seen"%'`).get().c;
628
+ return { total, byFolder, threads, multiMemberThreads: multi, unseen, oldest: store.oldestDate() };
629
+ }
630
+
631
+ // src/service.ts
632
+ var EmailService = class {
633
+ cfg;
634
+ store;
635
+ engine;
636
+ pollTimer = null;
637
+ constructor(cfg) {
638
+ this.cfg = resolveConfig(cfg);
639
+ this.store = new Store(this.cfg.dbPath);
640
+ this.engine = new SyncEngine(this.cfg, this.store);
641
+ }
642
+ async sync() {
643
+ // \u8BB0\u5F55\u540C\u6B65\u524D\u5404\u6587\u4EF6\u5939\u6C34\u4F4D\uFF0C\u7528\u4E8E\u540C\u6B65\u540E\u8BC6\u522B\u300C\u672C\u6B21\u65B0\u589E\u300D\u90AE\u4EF6\u5E76\u505A\u91CD\u8981\u5224\u5B9A
644
+ const before = {};
645
+ for (const st of this.store.db.prepare("SELECT * FROM sync_state").all()) before[st.mailbox] = st.last_uid;
646
+ const reports = await this.engine.syncOnce();
647
+ this.notifyNewMails(before);
648
+ return reports.map((r) => `${r.folder}: \u65B0\u589E ${r.fetched}\uFF08\u6B63\u6587 ${r.bodyOk} \u6210\u529F/${r.bodySkipped} \u8DF3\u8FC7/${r.bodyHang} \u5361\u6B7B\u8DF3\u8FC7\uFF09${r.rescan ? "\uFF08UIDVALIDITY \u53D8\u5316\u5168\u91CF\u91CD\u626B\uFF09" : ""}`).join("; ");
649
+ }
650
+ async shutdown() {
651
+ this.stopPolling();
652
+ await this.engine.close();
653
+ }
654
+ /** 同步后判定新增邮件是否「重要」(全部开关/发件人白名单/主题关键词),命中则 SSE 广播。 */
655
+ notifyNewMails(before) {
656
+ const cfg = settingsOverride?.() ?? {};
657
+ if (cfg.notifyEnabled !== true) return;
658
+ const fromList = Array.isArray(cfg.notifyFrom) ? cfg.notifyFrom : [];
659
+ const kwList = Array.isArray(cfg.notifyKeywords) ? cfg.notifyKeywords : [];
660
+ const important = [];
661
+ for (const folder of this.cfg.folders) {
662
+ if (folder === "已发送") continue;
663
+ const last = before[folder] ?? 0;
664
+ const rows = this.store.db.prepare("SELECT mailbox, uid, subject, from_name, from_addr, snippet FROM messages WHERE mailbox=? AND uid>? ORDER BY uid").all(folder, last);
665
+ for (const r of rows) {
666
+ const fromHit = fromList.some((f) => f && (r.from_addr.includes(f) || (r.from_name ?? "").includes(f)));
667
+ const kwHit = kwList.some((k) => k && r.subject.includes(k));
668
+ if (cfg.notifyAll === true || fromHit || kwHit) {
669
+ important.push({ subject: r.subject || "(无主题)", from: r.from_name || r.from_addr, snippet: (r.snippet ?? "").slice(0, 80), tag: `${r.mailbox}#${r.uid}` });
670
+ }
671
+ }
672
+ }
673
+ broadcastImportantMails(important);
674
+ if (important.length > 0) console.log(`[dsh-email][notify] 命中 ${important.length} 封重要新邮件,已广播`);
675
+ }
676
+ startPolling(seconds = this.cfg.pollSeconds) {
677
+ if (this.pollTimer) return;
678
+ const run = () => {
679
+ this.sync().catch((e) => console.error("[dsh-email][email] \u8F6E\u8BE2\u540C\u6B65\u5931\u8D25:", String(e)));
680
+ };
681
+ this.pollTimer = setInterval(run, Math.max(30, seconds) * 1e3);
682
+ }
683
+ stopPolling() {
684
+ if (this.pollTimer) {
685
+ clearInterval(this.pollTimer);
686
+ this.pollTimer = null;
687
+ }
688
+ }
689
+ search = (f) => search(this.store, f);
690
+ threads = (top) => listThreads(this.store, top);
691
+ timeline = (threadId, includeBody) => threadTimeline(this.store, threadId, includeBody);
692
+ recent = (hours = 24) => search(this.store, { sinceDays: hours / 24, limit: 50 });
693
+ stats = () => stats(this.store);
694
+ /** 标记已读:mailbox+uid 精确标记,或 thread_id 标记整个线程。 */
695
+ async markRead({ mailbox, uid, threadId }) {
696
+ let targets;
697
+ if (mailbox && uid) {
698
+ targets = [{ mailbox, uid }];
699
+ } else if (threadId) {
700
+ targets = this.store.db.prepare("SELECT mailbox, uid FROM messages WHERE thread_id=?").all(threadId);
701
+ } else {
702
+ throw new Error("需要 mailbox+uid 或 thread_id");
703
+ }
704
+ const client = await this.engine.connect();
705
+ let marked = 0;
706
+ for (const t of targets) {
707
+ try {
708
+ const lock = await client.getMailboxLock(t.mailbox);
709
+ try {
710
+ await client.messageFlagsAdd(String(t.uid), ["\\Seen"], { uid: true });
711
+ } finally {
712
+ lock.release();
713
+ }
714
+ } catch (e) {
715
+ console.error(`[dsh-email][markRead] ${t.mailbox}#${t.uid}: ${String(e)}`);
716
+ continue;
717
+ }
718
+ // 本地 flags 同步追加 \Seen,保持与服务端一致
719
+ try {
720
+ const row = this.store.db.prepare("SELECT flags FROM messages WHERE mailbox=? AND uid=?").get(t.mailbox, t.uid);
721
+ const flags = new Set(JSON.parse(row?.flags ?? "[]"));
722
+ flags.add("\\Seen");
723
+ this.store.updateFlags(t.mailbox, t.uid, JSON.stringify([...flags]));
724
+ } catch {
725
+ }
726
+ marked++;
727
+ }
728
+ this.store.refreshThreadAggregates();
729
+ return { marked, total: targets.length };
730
+ }
731
+ /** 列出附件元数据:thread_id 或 mailbox+uid。 */
732
+ listAttachments({ threadId, mailbox, uid }) {
733
+ const conds = [];
734
+ const args = [];
735
+ if (threadId) {
736
+ conds.push("m.thread_id=?");
737
+ args.push(threadId);
738
+ }
739
+ if (mailbox) {
740
+ conds.push("a.mailbox=?");
741
+ args.push(mailbox);
742
+ }
743
+ if (uid) {
744
+ conds.push("a.uid=?");
745
+ args.push(uid);
746
+ }
747
+ if (!conds.length) throw new Error("需要 thread_id 或 mailbox+uid");
748
+ return this.store.db.prepare(
749
+ `SELECT a.id, a.mailbox, a.uid, a.part_id, a.filename, a.content_type, a.size, m.subject, m.date
750
+ FROM attachments a JOIN messages m ON m.mailbox=a.mailbox AND m.uid=a.uid
751
+ WHERE ${conds.join(" AND ")} ORDER BY m.date DESC, a.id`
752
+ ).all(...args);
753
+ }
754
+ /** 下载附件到本地目录并返回文件路径(内容经原件重新解析)。 */
755
+ async getAttachment(id, saveDirOverride) {
756
+ const att = this.store.db.prepare("SELECT * FROM attachments WHERE id=?").get(id);
757
+ if (!att) throw new Error(`附件 #${id} 不存在(正文同步后附件元数据才入库,先跑 email_sync)`);
758
+ const client = await this.engine.connect();
759
+ const lock = await client.getMailboxLock(att.mailbox);
760
+ let source;
761
+ try {
762
+ source = await this.engine.fetchSource(client, att.uid);
763
+ } finally {
764
+ lock.release();
765
+ }
766
+ if (!source) throw new Error(`取原件失败 ${att.mailbox}#${att.uid}`);
767
+ const parsed = await simpleParser(source);
768
+ const list = parsed.attachments ?? [];
769
+ const hit = list.find((a) => String(a.partId ?? "") === att.part_id) ?? list[0];
770
+ if (!hit?.content) throw new Error("附件内容解析失败");
771
+ const dir = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..", saveDirOverride || this.cfg.attachmentDir);
772
+ fs.mkdirSync(dir, { recursive: true });
773
+ const safe = String(hit.filename || att.filename || `attachment-${id}`).replace(/[\\/:*?"<>|]/g, "_");
774
+ const file = path.join(dir, `${att.mailbox}-${att.uid}-${safe}`);
775
+ fs.writeFileSync(file, hit.content);
776
+ return { path: file, filename: hit.filename || att.filename, size: hit.content.length, content_type: hit.contentType || att.content_type };
777
+ }
778
+ /** SMTP 发件;支持按线程/消息回复(自动补 In-Reply-To、References 与 Re: 前缀)。 */
779
+ async send({ to, subject, body, cc, replyToThreadId, replyToMessageId }) {
780
+ const nodemailer = (await import("nodemailer")).default;
781
+ let inReplyTo;
782
+ let references;
783
+ if (replyToThreadId || replyToMessageId) {
784
+ const orig = this.store.db.prepare(
785
+ `SELECT message_id, refs, in_reply_to, subject FROM messages WHERE ${replyToMessageId ? "message_id=?" : "thread_id=?"} ORDER BY date DESC LIMIT 1`
786
+ ).get(replyToMessageId ?? replyToThreadId);
787
+ if (!orig?.message_id) throw new Error("找不到要回复的原邮件(先 email_sync 同步)");
788
+ inReplyTo = `<${orig.message_id}>`;
789
+ const refList = [orig.refs, orig.in_reply_to, orig.message_id].filter(Boolean).join(" ").split(/\s+/).filter(Boolean);
790
+ references = [...new Set(refList)].map((r) => `<${r.replace(/[<>]/g, "")}>`).join(" ");
791
+ if (!String(subject ?? "").toLowerCase().startsWith("re:")) subject = `Re: ${orig.subject || subject || ""}`;
792
+ }
793
+ const transporter = nodemailer.createTransport({
794
+ host: this.cfg.smtpHost,
795
+ port: this.cfg.smtpPort,
796
+ secure: this.cfg.smtpPort === 465,
797
+ auth: { user: this.cfg.smtpUser, pass: this.cfg.smtpPass },
798
+ connectionTimeout: 2e4
799
+ });
800
+ try {
801
+ const info = await transporter.sendMail({
802
+ from: `"DSH" <${this.cfg.smtpUser}>`,
803
+ to: Array.isArray(to) ? to.join(",") : to,
804
+ cc: cc ? (Array.isArray(cc) ? cc.join(",") : cc) : void 0,
805
+ subject,
806
+ text: body,
807
+ headers: inReplyTo ? { "In-Reply-To": inReplyTo, References: references } : {}
808
+ });
809
+ return {
810
+ message_id: info.messageId,
811
+ accepted: info.accepted,
812
+ rejected: info.rejected,
813
+ ...inReplyTo ? { in_reply_to: inReplyTo } : {}
814
+ };
815
+ } finally {
816
+ try {
817
+ await transporter.close();
818
+ } catch {
819
+ }
820
+ }
821
+ }
822
+ };
823
+
824
+ // src/plugin-entry.ts
825
+ var name = "dsh-email-tools";
826
+ var inject = ["tools"];
827
+ var svc = null;
828
+ var svcError = null;
829
+ // 模块级保存 apply(ctx, config) 第二参数传入的 entry 配置
830
+ var cfg = {};
831
+ // 设置命名空间的当前权威值(installSection 的 setSource 注入)
832
+ var settingsOverride = null;
833
+ // 重要邮件提醒:宿主侧判定 → SSE 广播给已订阅的浏览器连接
834
+ var notifyListeners = new Set();
835
+ function broadcastImportantMails(mails) {
836
+ if (notifyListeners.size === 0 || mails.length === 0) return;
837
+ const payload = `event: important-mail\ndata: ${JSON.stringify(mails)}\n\n`;
838
+ for (const write of notifyListeners) {
839
+ try { write(payload); } catch { }
840
+ }
841
+ }
842
+ function getConfig() {
843
+ return cfg;
844
+ }
845
+ function makeService() {
846
+ if (svc) return svc;
847
+ const c = { ...getConfig(), ...(settingsOverride?.() ?? {}) };
848
+ const user = c.user ?? process.env.EMAIL_IMAP_USER ?? process.env.PROBE_IMAP_USER ?? "";
849
+ const pass = process.env[c.passwordEnv ?? "EMAIL_IMAP_PASS"] ?? process.env.PROBE_IMAP_PASS ?? "";
850
+ if (!user || !pass) throw new Error("\u90AE\u7BB1\u8D26\u53F7\u672A\u914D\u7F6E\uFF1A\u8BF7\u5728 $DSH_HOME/.env \u8BBE\u7F6E EMAIL_IMAP_USER / EMAIL_IMAP_PASS\uFF08\u6216\u7ECF\u8BBE\u7F6E\u9875\u914D\u7F6E\uFF09");
851
+ svc = new EmailService({
852
+ host: c.host,
853
+ port: c.port,
854
+ tlsMode: c.tlsMode,
855
+ dbPath: c.dbPath,
856
+ folders: c.folders,
857
+ backfillDays: c.backfillDays,
858
+ pollSeconds: c.pollSeconds,
859
+ maxSourceBytes: c.maxSourceBytes,
860
+ smtpHost: c.smtpHost,
861
+ smtpPort: c.smtpPort,
862
+ smtpUser: c.smtpUser,
863
+ attachmentDir: c.attachmentDir,
864
+ user,
865
+ pass
866
+ });
867
+ return svc;
868
+ }
869
+ function service(ctx) {
870
+ if (svcError) throw new Error(svcError);
871
+ try {
872
+ const s = makeService();
873
+ svcError = null;
874
+ return s;
875
+ } catch (e) {
876
+ svcError = String(e.message ?? e);
877
+ throw e;
878
+ }
879
+ }
880
+ function apply(ctx, c = {}) {
881
+ cfg = c;
882
+ // 参考官方 defineTool(@deepseek-ai/dsh-tools schema.ts parameterSchemaSpecToJsonSchema)
883
+ // 的 spec 编译:扁平参数 spec {字段: {type, required?, description?, items?}}
884
+ // 编译为标准 JSON Schema;output.schema 用空对象表示「任意 JSON」
885
+ const compileParameters = (spec) => {
886
+ const properties = {};
887
+ const required = [];
888
+ for (const [key, def] of Object.entries(spec ?? {})) {
889
+ const { required: req, ...rest } = def;
890
+ properties[key] = rest;
891
+ if (req) required.push(key);
892
+ }
893
+ return { type: "object", properties, ...(required.length ? { required } : {}) };
894
+ };
895
+ const T = (name2, description, parameters, execute) => ctx.tools.register({
896
+ name: name2,
897
+ description,
898
+ parameters: compileParameters(parameters),
899
+ output: {
900
+ schema: {},
901
+ render: (_args, value) => [{ type: "text", text: JSON.stringify(value) }]
902
+ },
903
+ async execute(args) {
904
+ return await Promise.resolve(execute(args)).catch((e) => ({ ok: false, error: String(e?.message ?? e) }));
905
+ }
906
+ });
907
+ T("email_search", "\u641C\u7D22\u672C\u5730\u90AE\u4EF6\u7D22\u5F15\uFF08\u5168\u6587/\u4E3B\u9898/\u53D1\u4EF6\u4EBA\uFF0C\u4E2D\u6587\u53EF\u7528\uFF1B\u670D\u52A1\u7AEF\u641C\u7D22\u4E0D\u53EF\u7528\u6545\u5168\u90E8\u672C\u5730\u68C0\u7D22\uFF09\u3002query \u81F3\u5C11 3 \u4E2A\u5B57\u7B26\u8D70\u5168\u6587\u7D22\u5F15\uFF0C\u77ED\u8BCD\u81EA\u52A8\u56DE\u9000\u6A21\u7CCA\u5339\u914D\u3002", {
908
+ query: { type: "string", description: "\u5173\u952E\u8BCD\uFF08\u4E2D\u6587/\u82F1\u6587\u5747\u53EF\uFF09" },
909
+ from: { type: "string", description: "\u6309\u53D1\u4EF6\u4EBA\u5730\u5740\u6216\u59D3\u540D\u8FC7\u6EE4\uFF08\u5B50\u4E32\uFF09" },
910
+ since_days: { type: "number", description: "\u53EA\u770B\u6700\u8FD1 N \u5929" },
911
+ thread_id: { type: "number", description: "\u9650\u5B9A\u7EBF\u7A0B id" },
912
+ unread_only: { type: "boolean", description: "\u53EA\u770B\u672A\u8BFB" },
913
+ has_attachment: { type: "boolean", description: "\u53EA\u770B\u5E26\u9644\u4EF6" },
914
+ limit: { type: "number", description: "\u8FD4\u56DE\u6761\u6570\u4E0A\u9650\uFF08\u9ED8\u8BA4 20\uFF0C\u6700\u5927 200\uFF09" }
915
+ }, (args) => {
916
+ const hits = service(ctx).search({
917
+ query: args.query,
918
+ from: args.from,
919
+ sinceDays: args.since_days,
920
+ threadId: args.thread_id,
921
+ unreadOnly: args.unread_only,
922
+ hasAttachment: args.has_attachment,
923
+ limit: args.limit
924
+ });
925
+ return { ok: true, count: hits.length, hits };
926
+ });
927
+ T("email_recent", "\u5217\u51FA\u6700\u8FD1\u65B0\u90AE\u4EF6\uFF08\u9ED8\u8BA4 24 \u5C0F\u65F6\uFF09\u3002", {
928
+ hours: { type: "number", description: "\u56DE\u770B\u5C0F\u65F6\u6570\uFF08\u9ED8\u8BA4 24\uFF09" }
929
+ }, (args) => {
930
+ const hits = service(ctx).recent(args.hours ?? 24);
931
+ return { ok: true, count: hits.length, hits };
932
+ });
933
+ T("email_threads", "\u5217\u51FA\u90AE\u4EF6\u7EBF\u7A0B\uFF08\u540C\u4E00\u4E3B\u9898\u5F80\u6765\u7684\u5F52\u5E76\u7EC4\uFF09\uFF0C\u6309\u6700\u8FD1\u6D3B\u8DC3\u6392\u5E8F\u3002", {
934
+ top: { type: "number", description: "\u8FD4\u56DE\u7EBF\u7A0B\u6570\uFF08\u9ED8\u8BA4 15\uFF09" }
935
+ }, (args) => {
936
+ const list = service(ctx).threads(args.top ?? 15);
937
+ return { ok: true, count: list.length, threads: list };
938
+ });
939
+ T("email_thread_view", "\u67E5\u770B\u4E00\u4E2A\u7EBF\u7A0B\u7684\u5B8C\u6574\u65F6\u95F4\u7EBF\uFF08\u8C01\u5728\u4F55\u65F6\u8BF4\u4E86\u4EC0\u4E48\uFF09\u3002thread_id \u53EF\u4ECE email_search/email_threads \u83B7\u5F97\u3002", {
940
+ thread_id: { type: "number", required: true, description: "\u7EBF\u7A0B id" },
941
+ full_body: { type: "boolean", description: "\u662F\u5426\u5E26\u6B63\u6587\u7247\u6BB5\uFF08\u9ED8\u8BA4\u4EC5\u6458\u8981\uFF09" }
942
+ }, (args) => {
943
+ const timeline = service(ctx).timeline(args.thread_id, args.full_body);
944
+ return { ok: true, count: timeline.length, timeline };
945
+ });
946
+ T("email_sync", "\u624B\u52A8\u89E6\u53D1\u4E00\u6B21\u90AE\u4EF6\u540C\u6B65\uFF08\u5E38\u89C4\u60C5\u51B5\u4E0B\u540E\u53F0\u8F6E\u8BE2\u81EA\u52A8\u540C\u6B65\uFF0C\u65E0\u9700\u8C03\u7528\uFF09\u3002\u8FD4\u56DE\u5404\u6587\u4EF6\u5939\u540C\u6B65\u62A5\u544A\u3002", {}, async () => {
947
+ const report = await service(ctx).sync();
948
+ return { ok: true, report };
949
+ });
950
+ T("email_stats", "\u672C\u5730\u90AE\u4EF6\u5E93\u7EDF\u8BA1\uFF1A\u603B\u6570/\u7EBF\u7A0B\u6570/\u672A\u8BFB/\u65F6\u95F4\u8303\u56F4\u3002", {}, () => service(ctx).stats());
951
+ T("email_mark_read", "\u6807\u8BB0\u90AE\u4EF6\u4E3A\u5DF2\u8BFB\uFF1A\u7ED9 mailbox+uid \u6807\u5355\u5C01\uFF0C\u6216\u7ED9 thread_id \u6807\u6574\u4E2A\u7EBF\u7A0B\u3002", {
952
+ mailbox: { type: "string", description: "\u90AE\u7BB1\u6587\u4EF6\u5939\uFF08\u5982 INBOX\uFF09" },
953
+ uid: { type: "number", description: "\u6D88\u606F uid\uFF08\u4E0E mailbox \u914D\u5408\u4F7F\u7528\uFF09" },
954
+ thread_id: { type: "number", description: "\u7EBF\u7A0B id\uFF08\u6807\u8BB0\u6574\u4E2A\u7EBF\u7A0B\u7684\u6240\u6709\u672A\u8BFB\uFF09" }
955
+ }, (args) => service(ctx).markRead(args));
956
+ T("email_attachment_list", "\u5217\u51FA\u90AE\u4EF6\u9644\u4EF6\uFF1A\u6309 thread_id \u6216 mailbox+uid\u3002\u8FD4\u56DE\u9644\u4EF6 id\u3001\u6587\u4EF6\u540D\u3001\u7C7B\u578B\u3001\u5927\u5C0F\u3002", {
957
+ thread_id: { type: "number", description: "\u7EBF\u7A0B id" },
958
+ mailbox: { type: "string", description: "\u90AE\u7BB1\u6587\u4EF6\u5939" },
959
+ uid: { type: "number", description: "\u6D88\u606F uid" }
960
+ }, (args) => service(ctx).listAttachments(args));
961
+ T("email_attachment_get", "\u4E0B\u8F7D\u9644\u4EF6\u5230\u672C\u5730\u5E76\u8FD4\u56DE\u6587\u4EF6\u8DEF\u5F84\uFF08\u540E\u7EED\u53EF\u7528\u8BFB\u6587\u4EF6\u5DE5\u5177\u67E5\u770B\uFF09\u3002", {
962
+ attachment_id: { type: "number", required: true, description: "email_attachment_list \u8FD4\u56DE\u7684\u9644\u4EF6 id" },
963
+ save_dir: { type: "string", description: "\u4FDD\u5B58\u76EE\u5F55\uFF08\u76F8\u5BF9\u63D2\u4EF6\u76EE\u5F55\uFF0C\u7F3A\u7701\u7528\u914D\u7F6E\u7684 attachmentDir\uFF09" }
964
+ }, (args) => service(ctx).getAttachment(args.attachment_id, args.save_dir));
965
+ T("email_send", "\u53D1\u9001\u90AE\u4EF6\uFF08SMTP\uFF09\u3002\u56DE\u590D\u573A\u666F\u7ED9 reply_to_thread_id \u6216 reply_to_message_id\uFF0C\u81EA\u52A8\u8865 In-Reply-To/References \u4E0E Re: \u524D\u7F00\uFF0C\u5BF9\u65B9\u90AE\u4EF6\u5BA2\u6237\u7AEF\u4F1A\u5F52\u5230\u540C\u4E00\u4F1A\u8BDD\u3002", {
966
+ to: { type: "string", required: true, description: "\u6536\u4EF6\u4EBA\u5730\u5740\uFF0C\u591A\u4E2A\u7528\u9017\u53F7\u5206\u9694" },
967
+ body: { type: "string", required: true, description: "\u6B63\u6587\uFF08\u7EAF\u6587\u672C\uFF09" },
968
+ subject: { type: "string", description: "\u4E3B\u9898\uFF08\u56DE\u590D\u65F6\u53EF\u7701\u7565\uFF0C\u81EA\u52A8\u751F\u6210 Re:\uFF09" },
969
+ cc: { type: "string", description: "\u6284\u9001\uFF0C\u591A\u4E2A\u9017\u53F7\u5206\u9694" },
970
+ reply_to_thread_id: { type: "number", description: "\u8981\u56DE\u590D\u7684\u7EBF\u7A0B id" },
971
+ reply_to_message_id: { type: "string", description: "\u8981\u56DE\u590D\u7684\u539F\u90AE\u4EF6 message-id" }
972
+ }, (args) => service(ctx).send(args));
973
+
974
+
975
+ // ── 邮箱连接配置卡(设置 → 插件 → 邮件):宿主 settings 命名空间 ──
976
+ // 值持久化在宿主 settings 文档;配置变更即时生效(销毁服务实例,下次调用按新配置重建)。
977
+ ctx.inject(["settings"], (sctx) => {
978
+ try {
979
+ sctx.settings.installSection(ctx, "dsh-email", MailSettings, {
980
+ user: process.env.EMAIL_IMAP_USER ?? "",
981
+ host: "imap.263.net",
982
+ port: 993,
983
+ tlsMode: "tls",
984
+ smtpHost: "smtp.263.net",
985
+ smtpPort: 25,
986
+ pollSeconds: 60,
987
+ backfillDays: 90
988
+ }, {
989
+ setSource: (current) => { settingsOverride = current; },
990
+ onChange: () => {
991
+ try { svc?.stopPolling(); void svc?.shutdown(); } catch { }
992
+ svc = null;
993
+ svcError = null;
994
+ // 配置变更后立即按新配置重建服务并恢复后台轮询
995
+ // (轮询定时器在服务实例上,只销毁不重建会让同步静默停摆)
996
+ try {
997
+ service(ctx).startPolling();
998
+ console.log("[dsh-email][settings] 配置已变更,服务按新配置重建并恢复轮询");
999
+ } catch (e) {
1000
+ console.warn("[dsh-email][settings] 新配置下服务重建暂缓(如凭据不全),待下次调用诊断:", String(e?.message ?? e));
1001
+ }
1002
+ }
1003
+ });
1004
+ console.log("[dsh-email][settings] 命名空间 dsh-email 注册成功");
1005
+ } catch (e) {
1006
+ console.error("[dsh-email][settings] 命名空间注册失败:", e);
1007
+ }
1008
+ });
1009
+
1010
+ // ── 重要邮件提醒通道(SSE):浏览器 EventSource 订阅 /dsh-email/notify ──
1011
+ ctx.inject(["webServer"], (hostCtx) => {
1012
+ hostCtx.effect(() => hostCtx.webServer.register({
1013
+ kind: "exact",
1014
+ path: "/dsh-email/notify",
1015
+ handler: (request, response) => {
1016
+ // 与 dshmarket 下载路由同款信任边界:loopback 对端、无代理转发头、
1017
+ // 无 Origin 放行(EventSource 同源 GET 不带 Origin)、有 Origin 必须==Host
1018
+ const loopbackOk = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(request.socket.remoteAddress ?? "");
1019
+ const noProxy = request.headers.forwarded === undefined
1020
+ && request.headers["x-forwarded-for"] === undefined
1021
+ && request.headers["x-real-ip"] === undefined;
1022
+ let originOk = true;
1023
+ if (typeof request.headers.origin === "string" && typeof request.headers.host === "string") {
1024
+ try { originOk = new URL(request.headers.origin).host === request.headers.host; } catch { originOk = false; }
1025
+ }
1026
+ if (!loopbackOk || !noProxy || !originOk) { response.writeHead(403); response.end(); return; }
1027
+ response.writeHead(200, {
1028
+ "content-type": "text/event-stream",
1029
+ "cache-control": "no-store",
1030
+ "connection": "keep-alive"
1031
+ });
1032
+ response.write("retry: 3000\n\n");
1033
+ const write = (chunk) => response.write(chunk);
1034
+ notifyListeners.add(write);
1035
+ const heartbeat = setInterval(() => { try { response.write(": ping\n\n"); } catch { } }, 25000);
1036
+ request.on("close", () => { clearInterval(heartbeat); notifyListeners.delete(write); });
1037
+ }
1038
+ }), "dsh-email: notify sse");
1039
+ });
1040
+
1041
+ const user = c.user ?? process.env.EMAIL_IMAP_USER;
1042
+ const pass = process.env[c.passwordEnv ?? "EMAIL_IMAP_PASS"];
1043
+ if (user && pass) {
1044
+ const timer = setTimeout(() => {
1045
+ try {
1046
+ service(ctx).startPolling(c.pollSeconds ?? 60);
1047
+ } catch {
1048
+ }
1049
+ }, 1e4);
1050
+ ctx.effect(() => {
1051
+ clearTimeout(timer);
1052
+ svc?.stopPolling();
1053
+ void svc?.shutdown();
1054
+ // 配置变更触发 fiber 重挂载:清掉单例,下次调用按新配置重建服务
1055
+ svc = null;
1056
+ svcError = null;
1057
+ });
1058
+ } else {
1059
+ ctx.effect(() => {
1060
+ void svc?.shutdown();
1061
+ svc = null;
1062
+ svcError = null;
1063
+ });
1064
+ }
1065
+ }
1066
+ // 设置命名空间的配置 schema(设置 → 插件 → 邮件卡自动渲染表单;密码脱敏由宿主处理)
1067
+ var MailSettings = z.object({
1068
+ user: z.string().description("邮箱账号(IMAP 登录名)"),
1069
+ pass: z.string().role("secret").description("邮箱密码(未设置时回退环境变量 EMAIL_IMAP_PASS)"),
1070
+ host: z.string().description("IMAP 收件服务器(263:imap.263.net)"),
1071
+ port: z.number().description("IMAP 端口(默认 993,SSL)"),
1072
+ tlsMode: z.string().description("加密方式:tls / starttls / none(默认 tls)"),
1073
+ smtpHost: z.string().description("SMTP 发件服务器(263:smtp.263.net)"),
1074
+ smtpPort: z.number().description("SMTP 端口(263 为 25 无 SSL;465 自动 SSL)"),
1075
+ smtpUser: z.string().description("SMTP 账号(默认同邮箱账号)"),
1076
+ smtpPass: z.string().role("secret").description("SMTP 密码(默认同 IMAP 密码)"),
1077
+ folders: z.array(z.string()).description("同步的文件夹(默认 INBOX 与 已发送)"),
1078
+ pollSeconds: z.number().default(60).description("后台同步间隔秒(默认 60)"),
1079
+ backfillDays: z.number().description("首次同步回填天数(默认 90)"),
1080
+ notifyEnabled: z.boolean().default(false).description("开启重要新邮件提醒(浏览器桌面通知 + 页内横幅)"),
1081
+ notifyAll: z.boolean().default(false).description("所有新邮件都提醒(慎开,容易骚扰)"),
1082
+ notifyFrom: z.array(z.string()).description("重要发件人白名单(地址或姓名,子串匹配)"),
1083
+ notifyKeywords: z.array(z.string()).description("主题关键词(命中任一即提醒)")
1084
+ });
1085
+ // 宿主插件配置页渲染的配置 schema(cordis 读取插件导出的 Config)。
1086
+ // 字段与 resolveConfig 的 override 入参一一对应;密码只走环境变量
1087
+ // (passwordEnv 指定变量名),不落任何配置文件。
1088
+ export const Config = z.object({
1089
+ user: z.string().description("邮箱账号(IMAP 登录名;留空回退环境变量 EMAIL_IMAP_USER)"),
1090
+ passwordEnv: z.string().default("EMAIL_IMAP_PASS").description("存邮箱密码的环境变量名"),
1091
+ host: z.string().description("IMAP 服务器地址(默认 imap.263.net)"),
1092
+ port: z.number().description("IMAP 端口(默认 993)"),
1093
+ tlsMode: z.string().description("TLS 模式:tls / starttls / none(默认 tls)"),
1094
+ dbPath: z.string().description("本地 SQLite 索引库路径(默认 mail.db,相对插件目录)"),
1095
+ folders: z.array(z.string()).description("同步的文件夹列表(默认 INBOX 与 已发送)"),
1096
+ backfillDays: z.number().description("首次回填天数(默认 90)"),
1097
+ pollSeconds: z.number().default(60).description("轮询同步间隔秒数(最小 30)"),
1098
+ smtpHost: z.string().description("SMTP 服务器(默认按 IMAP host 推导,如 smtp.263.net)"),
1099
+ smtpPort: z.number().description("SMTP 端口(默认 25;465 自动 SSL,587 走 STARTTLS)"),
1100
+ smtpUser: z.string().description("SMTP 账号(默认同 IMAP user;密码走环境变量 EMAIL_SMTP_PASS,缺省回退 IMAP 密码)"),
1101
+ attachmentDir: z.string().description("附件下载保存目录(相对插件目录,默认 data/attachments)"),
1102
+ maxSourceBytes: z.number().description("单封邮件正文抓取的大小上限字节(默认 10MB;超限邮件跳过正文与附件索引)")
1103
+ });
1104
+ export {
1105
+ apply,
1106
+ inject,
1107
+ name
1108
+ };