@zaclinley/dsh-mailharbor 0.0.0-stage → 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/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +173 -2
- package/README.zh.md +164 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2104 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +2941 -0
- package/lib/index.js.map +7 -0
- package/package.json +113 -4
- package/scripts/migrate-legacy.mjs +318 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2941 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res, err) => function __init() {
|
|
4
|
+
if (err) throw err[0];
|
|
5
|
+
try {
|
|
6
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
7
|
+
} catch (e) {
|
|
8
|
+
throw err = [e], e;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name2 in all)
|
|
13
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// src/host/address.ts
|
|
17
|
+
function splitAddressHeader(value) {
|
|
18
|
+
const text2 = value.trim();
|
|
19
|
+
if (text2 === "") return [];
|
|
20
|
+
const parts = [];
|
|
21
|
+
let current = "";
|
|
22
|
+
let quoted = false;
|
|
23
|
+
let angle = 0;
|
|
24
|
+
for (const ch of text2) {
|
|
25
|
+
if (ch === '"') quoted = !quoted;
|
|
26
|
+
if (ch === "<" && !quoted) angle += 1;
|
|
27
|
+
if (ch === ">" && !quoted && angle > 0) angle -= 1;
|
|
28
|
+
if ((ch === "," || ch === ";") && !quoted && angle === 0) {
|
|
29
|
+
parts.push(current);
|
|
30
|
+
current = "";
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
current += ch;
|
|
34
|
+
}
|
|
35
|
+
parts.push(current);
|
|
36
|
+
return parts.map((part) => part.trim()).filter((part) => part !== "");
|
|
37
|
+
}
|
|
38
|
+
function parseAddress(token) {
|
|
39
|
+
const text2 = token.trim();
|
|
40
|
+
if (text2 === "") return null;
|
|
41
|
+
const angled = /^(.*?)\s*<([^>]+)>\s*$/.exec(text2);
|
|
42
|
+
if (angled !== null) {
|
|
43
|
+
const name2 = (angled[1] ?? "").trim().replace(/^"|"$/g, "").trim();
|
|
44
|
+
const address = (angled[2] ?? "").trim();
|
|
45
|
+
return address === "" ? null : { name: name2, address };
|
|
46
|
+
}
|
|
47
|
+
if (/^[^\s@]+@[^\s@]+$/.test(text2)) return { name: "", address: text2 };
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function parseAddressList(value) {
|
|
51
|
+
return splitAddressHeader(value).map(parseAddress).filter((address) => address !== null);
|
|
52
|
+
}
|
|
53
|
+
function formatAddressList(list) {
|
|
54
|
+
return list.map((address) => address.name !== "" ? `${address.name} <${address.address}>` : address.address).filter((text2) => text2 !== "").join(", ");
|
|
55
|
+
}
|
|
56
|
+
function addressOf(value) {
|
|
57
|
+
const angled = /<([^>]+)>/.exec(value);
|
|
58
|
+
return (angled === null ? value : angled[1] ?? "").trim();
|
|
59
|
+
}
|
|
60
|
+
function isEmail(value) {
|
|
61
|
+
return /^[^\s@]+@[^\s@]+$/.test(value.trim());
|
|
62
|
+
}
|
|
63
|
+
var init_address = __esm({
|
|
64
|
+
"src/host/address.ts"() {
|
|
65
|
+
"use strict";
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// src/host/smtp.ts
|
|
70
|
+
var smtp_exports = {};
|
|
71
|
+
__export(smtp_exports, {
|
|
72
|
+
recipientsOf: () => recipientsOf,
|
|
73
|
+
sendMail: () => sendMail,
|
|
74
|
+
verifySmtp: () => verifySmtp
|
|
75
|
+
});
|
|
76
|
+
import nodemailer from "nodemailer";
|
|
77
|
+
function transportOptions(settings, credentials, security = {}) {
|
|
78
|
+
const secure = settings.secure !== false;
|
|
79
|
+
return {
|
|
80
|
+
host: settings.host,
|
|
81
|
+
port: Number(settings.port),
|
|
82
|
+
secure,
|
|
83
|
+
// Plain submission ports must upgrade; never send credentials in the clear.
|
|
84
|
+
requireTLS: !secure,
|
|
85
|
+
auth: credentials.accessToken !== void 0 ? { type: "OAuth2", user: credentials.user, accessToken: credentials.accessToken } : { user: credentials.user, pass: credentials.pass ?? "" },
|
|
86
|
+
tls: { rejectUnauthorized: security.allowSelfSigned !== true, servername: settings.host },
|
|
87
|
+
connectionTimeout: 2e4,
|
|
88
|
+
greetingTimeout: 2e4,
|
|
89
|
+
socketTimeout: 12e4,
|
|
90
|
+
pool: false
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function createTransport(settings, credentials, security = {}) {
|
|
94
|
+
return nodemailer.createTransport(transportOptions(settings, credentials, security));
|
|
95
|
+
}
|
|
96
|
+
function recipientsOf(value) {
|
|
97
|
+
return value.split(/[,;]/).map((part) => addressOf(part)).filter((address) => isEmail(address));
|
|
98
|
+
}
|
|
99
|
+
async function verifySmtp(settings, credentials, security = {}) {
|
|
100
|
+
const transport = createTransport(settings, credentials, security);
|
|
101
|
+
try {
|
|
102
|
+
await transport.verify();
|
|
103
|
+
return { ok: true, host: settings.host, port: Number(settings.port) };
|
|
104
|
+
} finally {
|
|
105
|
+
transport.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function sendMail(settings, credentials, account, mail, security = {}) {
|
|
109
|
+
const from = addressOf(mail.from ?? account);
|
|
110
|
+
if (!isEmail(from)) throw new Error("\u53D1\u4EF6\u4EBA\u5730\u5740\u65E0\u6548");
|
|
111
|
+
const to = recipientsOf(mail.to);
|
|
112
|
+
const cc = recipientsOf(mail.cc ?? "");
|
|
113
|
+
if (to.length === 0) throw new Error("\u6536\u4EF6\u4EBA\u4E0D\u80FD\u4E3A\u7A7A");
|
|
114
|
+
const transport = createTransport(settings, credentials, security);
|
|
115
|
+
try {
|
|
116
|
+
const info = await transport.sendMail({
|
|
117
|
+
from: settings.fromName === "" ? from : { name: settings.fromName, address: from },
|
|
118
|
+
to,
|
|
119
|
+
...cc.length > 0 ? { cc } : {},
|
|
120
|
+
subject: mail.subject,
|
|
121
|
+
text: mail.text,
|
|
122
|
+
...mail.html !== void 0 && mail.html.trim() !== "" ? { html: mail.html } : {},
|
|
123
|
+
...mail.inReplyTo !== void 0 && mail.inReplyTo !== "" ? { inReplyTo: mail.inReplyTo } : {},
|
|
124
|
+
...mail.references !== void 0 && mail.references !== "" ? { references: mail.references } : {},
|
|
125
|
+
...mail.attachments !== void 0 && mail.attachments.length > 0 ? {
|
|
126
|
+
attachments: mail.attachments.map((file) => ({
|
|
127
|
+
filename: file.filename,
|
|
128
|
+
content: file.content,
|
|
129
|
+
contentType: file.contentType
|
|
130
|
+
}))
|
|
131
|
+
} : {}
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
messageId: String(info.messageId ?? ""),
|
|
135
|
+
accepted: (info.accepted ?? []).map((entry) => String(entry)),
|
|
136
|
+
rejected: (info.rejected ?? []).map((entry) => String(entry))
|
|
137
|
+
};
|
|
138
|
+
} finally {
|
|
139
|
+
transport.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
var init_smtp = __esm({
|
|
143
|
+
"src/host/smtp.ts"() {
|
|
144
|
+
"use strict";
|
|
145
|
+
init_address();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// src/host/imap.ts
|
|
150
|
+
var imap_exports = {};
|
|
151
|
+
__export(imap_exports, {
|
|
152
|
+
ImapProvider: () => ImapProvider,
|
|
153
|
+
addressOf: () => addressOf,
|
|
154
|
+
formatAddressList: () => formatAddressList,
|
|
155
|
+
parseAddressList: () => parseAddressList
|
|
156
|
+
});
|
|
157
|
+
import { ImapFlow as ImapFlow2 } from "imapflow";
|
|
158
|
+
import { simpleParser as simpleParser2 } from "mailparser";
|
|
159
|
+
function normalizeId2(value) {
|
|
160
|
+
return String(value ?? "").trim().replace(/^<|>$/g, "").trim();
|
|
161
|
+
}
|
|
162
|
+
function isoDate(value) {
|
|
163
|
+
const date = value instanceof Date ? value : value === void 0 || value === null ? void 0 : new Date(String(value));
|
|
164
|
+
return date instanceof Date && !Number.isNaN(date.getTime()) ? date.toISOString() : "";
|
|
165
|
+
}
|
|
166
|
+
function flagsOf(value) {
|
|
167
|
+
if (value === void 0 || value === null) return [];
|
|
168
|
+
return [...value].map((flag) => String(flag));
|
|
169
|
+
}
|
|
170
|
+
function isSeen(flags) {
|
|
171
|
+
return flags.some((flag) => flag.toLowerCase() === "\\seen");
|
|
172
|
+
}
|
|
173
|
+
function attachmentList(parsed) {
|
|
174
|
+
return (parsed.attachments ?? []).map((attachment, index) => ({
|
|
175
|
+
id: String(index),
|
|
176
|
+
filename: String(attachment.filename ?? "attachment"),
|
|
177
|
+
contentType: String(attachment.contentType ?? "application/octet-stream"),
|
|
178
|
+
size: Number(attachment.size ?? attachment.content?.length ?? 0)
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
function toMessage(message) {
|
|
182
|
+
const flags = flagsOf(message.flags);
|
|
183
|
+
const addressList = (value) => (value ?? []).map((entry) => ({ name: entry.name ?? "", address: entry.address ?? "" })).filter((address) => address.address !== "");
|
|
184
|
+
return {
|
|
185
|
+
uid: Number(message.uid),
|
|
186
|
+
messageId: normalizeId2(message.envelope?.messageId),
|
|
187
|
+
subject: String(message.envelope?.subject ?? "(\u65E0\u4E3B\u9898)"),
|
|
188
|
+
from: addressList(message.envelope?.from)[0] ?? { name: "", address: "" },
|
|
189
|
+
to: addressList(message.envelope?.to),
|
|
190
|
+
cc: addressList(message.envelope?.cc),
|
|
191
|
+
date: isoDate(message.internalDate ?? message.envelope?.date),
|
|
192
|
+
size: Number(message.size ?? 0),
|
|
193
|
+
flags,
|
|
194
|
+
read: isSeen(flags)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function messageOf3(error) {
|
|
198
|
+
return error instanceof Error ? error.message : String(error);
|
|
199
|
+
}
|
|
200
|
+
function isFatal(error) {
|
|
201
|
+
if (error instanceof Error) {
|
|
202
|
+
const code = error.code ?? "";
|
|
203
|
+
if (/^(NoConnection|ConnectionClosed|ConnectionNotAvailable|EConnectionClosed|ETIMEDOUT|ECONNRESET|EPIPE)$/.test(
|
|
204
|
+
code
|
|
205
|
+
)) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return /connection (closed|lost|not available)|socket/i.test(error.message);
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
var ImapProvider;
|
|
213
|
+
var init_imap = __esm({
|
|
214
|
+
"src/host/imap.ts"() {
|
|
215
|
+
"use strict";
|
|
216
|
+
init_address();
|
|
217
|
+
ImapProvider = class {
|
|
218
|
+
settings;
|
|
219
|
+
credentials;
|
|
220
|
+
options;
|
|
221
|
+
client = null;
|
|
222
|
+
connecting = null;
|
|
223
|
+
queue = Promise.resolve();
|
|
224
|
+
disposed = false;
|
|
225
|
+
constructor(settings, credentials, options = {}) {
|
|
226
|
+
this.settings = settings;
|
|
227
|
+
this.credentials = credentials;
|
|
228
|
+
this.options = options;
|
|
229
|
+
}
|
|
230
|
+
async connect() {
|
|
231
|
+
if (this.client !== null && this.client.usable) return this.client;
|
|
232
|
+
if (this.connecting !== null) return this.connecting;
|
|
233
|
+
this.connecting = (async () => {
|
|
234
|
+
const client = new ImapFlow2({
|
|
235
|
+
host: this.settings.host,
|
|
236
|
+
port: Number(this.settings.port),
|
|
237
|
+
secure: this.settings.secure !== false,
|
|
238
|
+
auth: this.credentials.accessToken !== void 0 ? { user: this.credentials.user, accessToken: this.credentials.accessToken } : { user: this.credentials.user, pass: this.credentials.pass ?? "" },
|
|
239
|
+
logger: false,
|
|
240
|
+
tls: { rejectUnauthorized: true },
|
|
241
|
+
connectionTimeout: this.options.connectionTimeoutMs ?? 15e3,
|
|
242
|
+
greetingTimeout: this.options.connectionTimeoutMs ?? 15e3,
|
|
243
|
+
socketTimeout: this.options.socketTimeoutMs ?? 6e4
|
|
244
|
+
});
|
|
245
|
+
client.on("error", () => {
|
|
246
|
+
});
|
|
247
|
+
client.on("close", () => {
|
|
248
|
+
if (this.client === client) this.client = null;
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
await client.connect();
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`IMAP \u8FDE\u63A5\u5931\u8D25 (${this.settings.host}:${String(this.settings.port)}): ${messageOf3(error)}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
this.client = client;
|
|
258
|
+
return client;
|
|
259
|
+
})();
|
|
260
|
+
try {
|
|
261
|
+
return await this.connecting;
|
|
262
|
+
} finally {
|
|
263
|
+
this.connecting = null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async reset(reason) {
|
|
267
|
+
const client = this.client;
|
|
268
|
+
this.client = null;
|
|
269
|
+
if (client !== null) {
|
|
270
|
+
try {
|
|
271
|
+
await client.logout();
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!this.disposed) this.options.onReconnect?.(reason);
|
|
276
|
+
}
|
|
277
|
+
/** Serialize one operation on this account's connection. */
|
|
278
|
+
run(operation) {
|
|
279
|
+
const task = this.queue.then(
|
|
280
|
+
async () => {
|
|
281
|
+
const client = await this.connect();
|
|
282
|
+
try {
|
|
283
|
+
return await operation(client);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (isFatal(error)) await this.reset(messageOf3(error));
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
async () => {
|
|
290
|
+
const client = await this.connect();
|
|
291
|
+
return operation(client);
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
this.queue = task.then(
|
|
295
|
+
() => void 0,
|
|
296
|
+
() => void 0
|
|
297
|
+
);
|
|
298
|
+
return task;
|
|
299
|
+
}
|
|
300
|
+
async check() {
|
|
301
|
+
return this.run(async (client) => {
|
|
302
|
+
const boxes = await this.listMailboxes(client);
|
|
303
|
+
return { ok: true, mailboxes: boxes.map((box) => ({ name: box.path, count: box.messages })) };
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
async mailboxes() {
|
|
307
|
+
return this.run((client) => this.listMailboxes(client));
|
|
308
|
+
}
|
|
309
|
+
async listMailboxes(client) {
|
|
310
|
+
const listed = await client.list();
|
|
311
|
+
const result = [];
|
|
312
|
+
for (const box of listed) {
|
|
313
|
+
if (box.flags?.has?.("\\Noselect") === true) continue;
|
|
314
|
+
let messages = 0;
|
|
315
|
+
try {
|
|
316
|
+
const status = await client.status(box.path, { messages: true });
|
|
317
|
+
if (status !== false) messages = Number(status.messages ?? 0);
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
result.push({
|
|
321
|
+
path: box.path,
|
|
322
|
+
name: box.name ?? box.path,
|
|
323
|
+
specialUse: box.specialUse ?? null,
|
|
324
|
+
messages
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
async list(mailbox, limit) {
|
|
330
|
+
return this.run(async (client) => {
|
|
331
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
332
|
+
try {
|
|
333
|
+
const total = Number(client.mailbox?.exists ?? 0);
|
|
334
|
+
if (total === 0) return { total: 0, items: [] };
|
|
335
|
+
const start = Math.max(1, total - limit + 1);
|
|
336
|
+
const items = [];
|
|
337
|
+
for await (const message of client.fetch(`${String(start)}:*`, {
|
|
338
|
+
uid: true,
|
|
339
|
+
envelope: true,
|
|
340
|
+
flags: true,
|
|
341
|
+
internalDate: true,
|
|
342
|
+
size: true
|
|
343
|
+
})) {
|
|
344
|
+
items.push(toMessage(message));
|
|
345
|
+
}
|
|
346
|
+
items.sort((left, right) => right.date.localeCompare(left.date));
|
|
347
|
+
return { total, items };
|
|
348
|
+
} finally {
|
|
349
|
+
lock.release();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
async read(mailbox, uid) {
|
|
354
|
+
return this.run(async (client) => {
|
|
355
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
356
|
+
try {
|
|
357
|
+
const message = await client.fetchOne(
|
|
358
|
+
uid,
|
|
359
|
+
{ uid: true, source: true, flags: true, envelope: true, internalDate: true, size: true },
|
|
360
|
+
{ uid: true }
|
|
361
|
+
);
|
|
362
|
+
if (message === false || message === void 0 || message.source === void 0) {
|
|
363
|
+
throw new Error("\u90AE\u4EF6\u4E0D\u5B58\u5728");
|
|
364
|
+
}
|
|
365
|
+
const parsed = await simpleParser2(message.source);
|
|
366
|
+
const base = toMessage(message);
|
|
367
|
+
const html = typeof parsed.html === "string" ? parsed.html : Buffer.isBuffer(parsed.html) ? parsed.html.toString("utf8") : null;
|
|
368
|
+
return {
|
|
369
|
+
...base,
|
|
370
|
+
text: typeof parsed.text === "string" ? parsed.text : "",
|
|
371
|
+
html: html === "" ? null : html,
|
|
372
|
+
attachments: attachmentList(parsed),
|
|
373
|
+
inReplyTo: normalizeId2(parsed.inReplyTo),
|
|
374
|
+
references: String(parsed.references ?? "").split(/\s+/).map(normalizeId2).filter((item) => item !== "")
|
|
375
|
+
};
|
|
376
|
+
} finally {
|
|
377
|
+
lock.release();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
async attachment(mailbox, uid, part) {
|
|
382
|
+
return this.run(async (client) => {
|
|
383
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
384
|
+
try {
|
|
385
|
+
const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
|
|
386
|
+
if (message === false || message === void 0 || message.source === void 0) {
|
|
387
|
+
throw new Error("\u90AE\u4EF6\u4E0D\u5B58\u5728");
|
|
388
|
+
}
|
|
389
|
+
const parsed = await simpleParser2(message.source);
|
|
390
|
+
const attachment = parsed.attachments?.[part];
|
|
391
|
+
if (attachment === void 0) throw new Error("\u9644\u4EF6\u4E0D\u5B58\u5728");
|
|
392
|
+
return {
|
|
393
|
+
filename: String(attachment.filename ?? "attachment"),
|
|
394
|
+
contentType: String(attachment.contentType ?? "application/octet-stream"),
|
|
395
|
+
dataBase64: attachment.content.toString("base64")
|
|
396
|
+
};
|
|
397
|
+
} finally {
|
|
398
|
+
lock.release();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
async unseen() {
|
|
403
|
+
return this.run(async (client) => {
|
|
404
|
+
const lock = await client.getMailboxLock("INBOX");
|
|
405
|
+
try {
|
|
406
|
+
const found = await client.search({ seen: false }, { uid: true });
|
|
407
|
+
const uids = Array.isArray(found) ? found : [];
|
|
408
|
+
const items = [];
|
|
409
|
+
if (uids.length > 0) {
|
|
410
|
+
const range = uids.slice(-200).join(",");
|
|
411
|
+
for await (const message of client.fetch(range, { uid: true, envelope: true }, { uid: true })) {
|
|
412
|
+
items.push({
|
|
413
|
+
uid: Number(message.uid),
|
|
414
|
+
messageId: normalizeId2(message.envelope?.messageId)
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { count: uids.length, items };
|
|
419
|
+
} finally {
|
|
420
|
+
lock.release();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
async setSeen(mailbox, uid, seen) {
|
|
425
|
+
return this.run(async (client) => {
|
|
426
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
427
|
+
try {
|
|
428
|
+
if (seen) await client.messageFlagsAdd(uid, ["\\Seen"], { uid: true });
|
|
429
|
+
else await client.messageFlagsRemove(uid, ["\\Seen"], { uid: true });
|
|
430
|
+
const message = await client.fetchOne(uid, { uid: true, flags: true }, { uid: true });
|
|
431
|
+
const flags = flagsOf(message === false || message === void 0 ? [] : message.flags);
|
|
432
|
+
return { read: isSeen(flags), flags };
|
|
433
|
+
} finally {
|
|
434
|
+
lock.release();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
/** Set \Seen by Message-ID, which is how local-archive rows reach the server. */
|
|
439
|
+
async setSeenByMessageId(mailbox, messageId, seen) {
|
|
440
|
+
return this.run(async (client) => {
|
|
441
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
442
|
+
try {
|
|
443
|
+
const id = normalizeId2(messageId);
|
|
444
|
+
let found = await client.search({ header: { "Message-ID": id } }, { uid: true });
|
|
445
|
+
if (!Array.isArray(found) || found.length === 0) {
|
|
446
|
+
found = await client.search({ header: { "Message-ID": `<${id}>` } }, { uid: true });
|
|
447
|
+
}
|
|
448
|
+
const uids = Array.isArray(found) ? found : [];
|
|
449
|
+
const uid = uids[uids.length - 1];
|
|
450
|
+
if (uid === void 0) return { found: false };
|
|
451
|
+
if (seen) await client.messageFlagsAdd(uid, ["\\Seen"], { uid: true });
|
|
452
|
+
else await client.messageFlagsRemove(uid, ["\\Seen"], { uid: true });
|
|
453
|
+
const message = await client.fetchOne(uid, { uid: true, flags: true }, { uid: true });
|
|
454
|
+
const flags = flagsOf(message === false || message === void 0 ? [] : message.flags);
|
|
455
|
+
return { found: true, uid: Number(uid), read: isSeen(flags) };
|
|
456
|
+
} finally {
|
|
457
|
+
lock.release();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
async dispose() {
|
|
462
|
+
this.disposed = true;
|
|
463
|
+
await this.reset("dispose");
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// src/host/api.ts
|
|
470
|
+
init_address();
|
|
471
|
+
|
|
472
|
+
// src/host/config.ts
|
|
473
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
474
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
475
|
+
|
|
476
|
+
// src/host/paths.ts
|
|
477
|
+
import { homedir } from "node:os";
|
|
478
|
+
import { isAbsolute, join } from "node:path";
|
|
479
|
+
var DATA_ROOT = process.env.DSH_MAILHARBOR_DATA_DIR ?? join(homedir(), ".dsh", "mailharbor");
|
|
480
|
+
var CONFIG_PATH = process.env.DSH_MAILHARBOR_CONFIG ?? join(DATA_ROOT, "config.json");
|
|
481
|
+
var ACCOUNTS_DIR = join(DATA_ROOT, "accounts");
|
|
482
|
+
var STATE_DIR = join(DATA_ROOT, "state");
|
|
483
|
+
var REPORTS_DIR = join(DATA_ROOT, "reports");
|
|
484
|
+
var LOGS_DIR = join(DATA_ROOT, "logs");
|
|
485
|
+
function accountRoot(id) {
|
|
486
|
+
const safe = id.replace(/[^A-Za-z0-9_-]/g, "_") || "unknown";
|
|
487
|
+
return join(ACCOUNTS_DIR, safe);
|
|
488
|
+
}
|
|
489
|
+
function accountDataDir(account) {
|
|
490
|
+
const configured = typeof account.dataDir === "string" ? account.dataDir.trim() : "";
|
|
491
|
+
if (configured === "") return accountRoot(account.id);
|
|
492
|
+
return isAbsolute(configured) ? configured : join(DATA_ROOT, configured);
|
|
493
|
+
}
|
|
494
|
+
function accountMailDb(account) {
|
|
495
|
+
return join(accountDataDir(account), "mail.db");
|
|
496
|
+
}
|
|
497
|
+
function accountAttachmentsDir(account) {
|
|
498
|
+
return join(accountDataDir(account), "attachments");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/host/config.ts
|
|
502
|
+
var DEFAULTS = {
|
|
503
|
+
signature: "",
|
|
504
|
+
ai: {
|
|
505
|
+
enabled: true,
|
|
506
|
+
provider: "deepseek-account",
|
|
507
|
+
model: "deepseek-flash",
|
|
508
|
+
styleSampleCount: 100,
|
|
509
|
+
maxChars: 4e4
|
|
510
|
+
},
|
|
511
|
+
read: { markOnOpen: true, pollSeconds: 60 },
|
|
512
|
+
render: { listLimit: 40 },
|
|
513
|
+
oauth: {
|
|
514
|
+
google: { clientId: "", redirectPort: 8765, scopes: ["https://mail.google.com/"] },
|
|
515
|
+
microsoft: {
|
|
516
|
+
clientId: "",
|
|
517
|
+
scopes: [
|
|
518
|
+
"offline_access",
|
|
519
|
+
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
|
520
|
+
"https://outlook.office.com/SMTP.Send"
|
|
521
|
+
]
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
var ACCOUNT_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
526
|
+
var EMAIL = /^[^@\s]+@[^@\s]+$/;
|
|
527
|
+
function stringOr(value, fallback) {
|
|
528
|
+
return typeof value === "string" ? value : fallback;
|
|
529
|
+
}
|
|
530
|
+
function intOr(value, fallback, min, max) {
|
|
531
|
+
const parsed = Number(value);
|
|
532
|
+
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
|
|
533
|
+
return parsed;
|
|
534
|
+
}
|
|
535
|
+
function hostOr(value, fallback) {
|
|
536
|
+
const text2 = stringOr(value, fallback).trim();
|
|
537
|
+
return text2 === "" ? fallback : text2;
|
|
538
|
+
}
|
|
539
|
+
function deriveAccountId(email, existing = []) {
|
|
540
|
+
const taken = new Set(existing);
|
|
541
|
+
const base = `acc_${createHash("sha1").update(email.trim().toLowerCase()).digest("hex").slice(0, 8)}`;
|
|
542
|
+
if (!taken.has(base)) return base;
|
|
543
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
544
|
+
const candidate = `acc_${createHash("sha1").update(`${email}:${attempt}`).digest("hex").slice(0, 8)}`;
|
|
545
|
+
if (!taken.has(candidate)) return candidate;
|
|
546
|
+
}
|
|
547
|
+
return `acc_${randomUUID().slice(0, 8)}`;
|
|
548
|
+
}
|
|
549
|
+
function normalizeAccount(input, index = 0, existing = []) {
|
|
550
|
+
const email = stringOr(input.email, "").trim();
|
|
551
|
+
if (!EMAIL.test(email)) throw new Error("\u90AE\u7BB1\u5730\u5740\u683C\u5F0F\u4E0D\u6B63\u786E");
|
|
552
|
+
const id = typeof input.id === "string" && ACCOUNT_ID.test(input.id) ? input.id : deriveAccountId(email, [...existing, `index:${String(index)}`]);
|
|
553
|
+
const domain = email.split("@")[1] ?? "example.com";
|
|
554
|
+
const username = stringOr(input.username, email).trim() || email;
|
|
555
|
+
return {
|
|
556
|
+
id,
|
|
557
|
+
label: stringOr(input.label, email).trim() || email,
|
|
558
|
+
email,
|
|
559
|
+
username,
|
|
560
|
+
imap: {
|
|
561
|
+
host: hostOr(input.imap?.host, `imap.${domain}`),
|
|
562
|
+
port: intOr(input.imap?.port, 993, 1, 65535),
|
|
563
|
+
secure: input.imap?.secure !== false
|
|
564
|
+
},
|
|
565
|
+
smtp: {
|
|
566
|
+
host: hostOr(input.smtp?.host, `smtp.${domain}`),
|
|
567
|
+
port: intOr(input.smtp?.port, 465, 1, 65535),
|
|
568
|
+
secure: input.smtp?.secure !== false,
|
|
569
|
+
fromName: stringOr(input.smtp?.fromName, "")
|
|
570
|
+
},
|
|
571
|
+
keychainService: stringOr(input.keychainService, `dsh-mailharbor:${id}`),
|
|
572
|
+
dataDir: stringOr(input.dataDir, `accounts/${id}`),
|
|
573
|
+
auth: input.auth?.type === "oauth2" ? { type: "oauth2", provider: input.auth.provider } : { type: "password" },
|
|
574
|
+
caldav: normalizeCaldav(input.caldav),
|
|
575
|
+
enabled: input.enabled !== false,
|
|
576
|
+
createdAt: stringOr(input.createdAt, (/* @__PURE__ */ new Date()).toISOString())
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function normalizeCaldav(value) {
|
|
580
|
+
if (value === null || value === void 0) return null;
|
|
581
|
+
const url = stringOr(value.url, "").trim();
|
|
582
|
+
if (url === "") return null;
|
|
583
|
+
return {
|
|
584
|
+
url,
|
|
585
|
+
username: stringOr(value.username, "").trim(),
|
|
586
|
+
...typeof value.calendarHref === "string" && value.calendarHref !== "" ? { calendarHref: value.calendarHref } : {}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function readFileConfig() {
|
|
590
|
+
try {
|
|
591
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
592
|
+
const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
593
|
+
return parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
594
|
+
} catch {
|
|
595
|
+
return {};
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function loadConfig() {
|
|
599
|
+
const file = readFileConfig();
|
|
600
|
+
const rawAccounts = Array.isArray(file.accounts) ? file.accounts : [];
|
|
601
|
+
const accounts = [];
|
|
602
|
+
for (const [index, raw] of rawAccounts.entries()) {
|
|
603
|
+
if (raw === null || typeof raw !== "object") continue;
|
|
604
|
+
try {
|
|
605
|
+
accounts.push(
|
|
606
|
+
normalizeAccount(
|
|
607
|
+
raw,
|
|
608
|
+
index,
|
|
609
|
+
accounts.map((account) => account.id)
|
|
610
|
+
)
|
|
611
|
+
);
|
|
612
|
+
} catch {
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const seen = /* @__PURE__ */ new Set();
|
|
616
|
+
const unique = accounts.filter((account) => {
|
|
617
|
+
if (seen.has(account.id)) return false;
|
|
618
|
+
seen.add(account.id);
|
|
619
|
+
return true;
|
|
620
|
+
});
|
|
621
|
+
const requested = stringOr(file.activeAccountId, "");
|
|
622
|
+
const activeAccountId = unique.some((account) => account.id === requested) ? requested : unique.find((account) => account.enabled !== false)?.id ?? null;
|
|
623
|
+
const aiRaw = file.ai ?? {};
|
|
624
|
+
const readRaw = file.read ?? {};
|
|
625
|
+
const renderRaw = file.render ?? {};
|
|
626
|
+
const oauthRaw = file.oauth ?? {};
|
|
627
|
+
const googleRaw = oauthRaw.google ?? {};
|
|
628
|
+
const microsoftRaw = oauthRaw.microsoft ?? {};
|
|
629
|
+
return {
|
|
630
|
+
dataDir: DATA_ROOT,
|
|
631
|
+
configFile: CONFIG_PATH,
|
|
632
|
+
accounts: unique,
|
|
633
|
+
activeAccountId,
|
|
634
|
+
signature: stringOr(file.signature, DEFAULTS.signature),
|
|
635
|
+
ai: {
|
|
636
|
+
enabled: aiRaw.enabled !== false,
|
|
637
|
+
provider: stringOr(aiRaw.provider, DEFAULTS.ai.provider),
|
|
638
|
+
model: stringOr(aiRaw.model, DEFAULTS.ai.model),
|
|
639
|
+
styleSampleCount: intOr(aiRaw.styleSampleCount, DEFAULTS.ai.styleSampleCount, 10, 200),
|
|
640
|
+
maxChars: intOr(aiRaw.maxChars, DEFAULTS.ai.maxChars, 1e3, 2e5)
|
|
641
|
+
},
|
|
642
|
+
read: {
|
|
643
|
+
markOnOpen: readRaw.markOnOpen !== false,
|
|
644
|
+
pollSeconds: intOr(readRaw.pollSeconds, DEFAULTS.read.pollSeconds, 15, 3600)
|
|
645
|
+
},
|
|
646
|
+
render: {
|
|
647
|
+
listLimit: intOr(renderRaw.listLimit, DEFAULTS.render.listLimit, 10, 200)
|
|
648
|
+
},
|
|
649
|
+
oauth: {
|
|
650
|
+
google: {
|
|
651
|
+
clientId: stringOr(googleRaw.clientId, DEFAULTS.oauth.google.clientId),
|
|
652
|
+
redirectPort: intOr(googleRaw.redirectPort, DEFAULTS.oauth.google.redirectPort, 1024, 65535),
|
|
653
|
+
scopes: Array.isArray(googleRaw.scopes) ? googleRaw.scopes.filter((item) => typeof item === "string") : DEFAULTS.oauth.google.scopes
|
|
654
|
+
},
|
|
655
|
+
microsoft: {
|
|
656
|
+
clientId: stringOr(microsoftRaw.clientId, DEFAULTS.oauth.microsoft.clientId),
|
|
657
|
+
scopes: Array.isArray(microsoftRaw.scopes) ? microsoftRaw.scopes.filter((item) => typeof item === "string") : DEFAULTS.oauth.microsoft.scopes
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
revision: Date.now()
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function writeConfigFile(patch) {
|
|
664
|
+
const current = readFileConfig();
|
|
665
|
+
const next = { ...current, ...patch };
|
|
666
|
+
if (current.accounts !== void 0 || patch.accounts !== void 0) {
|
|
667
|
+
next.accounts = patch.accounts ?? current.accounts;
|
|
668
|
+
}
|
|
669
|
+
mkdirSync(DATA_ROOT, { recursive: true });
|
|
670
|
+
const temp = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
671
|
+
writeFileSync(temp, `${JSON.stringify(next, null, 2)}
|
|
672
|
+
`, { mode: 384 });
|
|
673
|
+
renameSync(temp, CONFIG_PATH);
|
|
674
|
+
return next;
|
|
675
|
+
}
|
|
676
|
+
function saveSettings(patch) {
|
|
677
|
+
writeConfigFile(patch);
|
|
678
|
+
}
|
|
679
|
+
function saveAccounts(accounts, activeAccountId) {
|
|
680
|
+
writeConfigFile({ accounts, activeAccountId });
|
|
681
|
+
}
|
|
682
|
+
function buildAccount(input, existing) {
|
|
683
|
+
const email = input.email.trim();
|
|
684
|
+
if (!EMAIL.test(email)) throw new Error("\u90AE\u7BB1\u5730\u5740\u683C\u5F0F\u4E0D\u6B63\u786E");
|
|
685
|
+
const id = deriveAccountId(
|
|
686
|
+
email,
|
|
687
|
+
existing.map((account) => account.id)
|
|
688
|
+
);
|
|
689
|
+
return normalizeAccount(
|
|
690
|
+
{
|
|
691
|
+
id,
|
|
692
|
+
email,
|
|
693
|
+
label: input.label,
|
|
694
|
+
username: input.username,
|
|
695
|
+
imap: input.imap,
|
|
696
|
+
smtp: input.smtp,
|
|
697
|
+
auth: { type: "password" },
|
|
698
|
+
caldav: null,
|
|
699
|
+
enabled: true,
|
|
700
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
701
|
+
},
|
|
702
|
+
0,
|
|
703
|
+
existing.map((account) => account.id)
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
function accountRuntime(account) {
|
|
707
|
+
const root = accountDataDir(account);
|
|
708
|
+
return {
|
|
709
|
+
account,
|
|
710
|
+
root,
|
|
711
|
+
mailDb: accountMailDb(account),
|
|
712
|
+
attachments: accountAttachmentsDir(account),
|
|
713
|
+
keychainService: account.keychainService || `dsh-mailharbor:${account.id}`,
|
|
714
|
+
imap: account.imap,
|
|
715
|
+
smtp: account.smtp,
|
|
716
|
+
auth: account.auth
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
function requireAccount(config, id) {
|
|
720
|
+
const account = config.accounts.find((item) => item.id === id) ?? config.accounts.find((item) => item.id === config.activeAccountId);
|
|
721
|
+
if (account === void 0) throw new Error("\u8BF7\u5148\u6DFB\u52A0\u4E00\u4E2A\u90AE\u7BB1\u8D26\u53F7");
|
|
722
|
+
return account;
|
|
723
|
+
}
|
|
724
|
+
function activeAccount(config) {
|
|
725
|
+
return config.accounts.find((account) => account.id === config.activeAccountId) ?? null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/host/backup.ts
|
|
729
|
+
init_address();
|
|
730
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
731
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
732
|
+
import { join as join2 } from "node:path";
|
|
733
|
+
import { ImapFlow } from "imapflow";
|
|
734
|
+
import { simpleParser } from "mailparser";
|
|
735
|
+
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
736
|
+
|
|
737
|
+
// src/host/mail-db.ts
|
|
738
|
+
init_address();
|
|
739
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
740
|
+
import { DatabaseSync } from "node:sqlite";
|
|
741
|
+
var SCHEMA = `
|
|
742
|
+
CREATE TABLE IF NOT EXISTS emails (
|
|
743
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
744
|
+
mailbox TEXT NOT NULL,
|
|
745
|
+
uid INTEGER,
|
|
746
|
+
subject TEXT,
|
|
747
|
+
from_name TEXT,
|
|
748
|
+
from_addr TEXT,
|
|
749
|
+
to_addr TEXT,
|
|
750
|
+
cc TEXT,
|
|
751
|
+
date TEXT,
|
|
752
|
+
message_id TEXT UNIQUE,
|
|
753
|
+
body_text TEXT,
|
|
754
|
+
body_html TEXT,
|
|
755
|
+
raw_eml BLOB,
|
|
756
|
+
hash TEXT,
|
|
757
|
+
backup_log_id INTEGER,
|
|
758
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
759
|
+
);
|
|
760
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
761
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
762
|
+
email_id INTEGER NOT NULL,
|
|
763
|
+
filename TEXT NOT NULL,
|
|
764
|
+
content_type TEXT,
|
|
765
|
+
size INTEGER,
|
|
766
|
+
data BLOB,
|
|
767
|
+
hash TEXT
|
|
768
|
+
);
|
|
769
|
+
CREATE TABLE IF NOT EXISTS backup_logs (
|
|
770
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
771
|
+
started_at TEXT NOT NULL,
|
|
772
|
+
completed_at TEXT,
|
|
773
|
+
mailboxes TEXT,
|
|
774
|
+
new_count INTEGER DEFAULT 0,
|
|
775
|
+
fail_count INTEGER DEFAULT 0,
|
|
776
|
+
total_emails INTEGER DEFAULT 0,
|
|
777
|
+
cumulative_hash TEXT,
|
|
778
|
+
status TEXT DEFAULT 'running',
|
|
779
|
+
error_info TEXT
|
|
780
|
+
);
|
|
781
|
+
CREATE TABLE IF NOT EXISTS backup_state (
|
|
782
|
+
mailbox TEXT PRIMARY KEY,
|
|
783
|
+
uid_validity TEXT,
|
|
784
|
+
last_uid INTEGER,
|
|
785
|
+
updated_at TEXT
|
|
786
|
+
);
|
|
787
|
+
`;
|
|
788
|
+
var INDEXES = `
|
|
789
|
+
CREATE INDEX IF NOT EXISTS idx_emails_mailbox_date ON emails(mailbox, date);
|
|
790
|
+
CREATE INDEX IF NOT EXISTS idx_emails_date ON emails(date);
|
|
791
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_email ON attachments(email_id);
|
|
792
|
+
`;
|
|
793
|
+
var FTS = `CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5(
|
|
794
|
+
subject, from_name, from_addr, to_addr, cc, body_text, att_filename,
|
|
795
|
+
content='', tokenize='trigram'
|
|
796
|
+
)`;
|
|
797
|
+
function ensureMailSchema(file) {
|
|
798
|
+
const db = new DatabaseSync(file);
|
|
799
|
+
try {
|
|
800
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
801
|
+
db.exec(SCHEMA);
|
|
802
|
+
db.exec(INDEXES);
|
|
803
|
+
try {
|
|
804
|
+
db.exec(FTS);
|
|
805
|
+
} catch {
|
|
806
|
+
}
|
|
807
|
+
} finally {
|
|
808
|
+
db.close();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
var MailDatabase = class {
|
|
812
|
+
file;
|
|
813
|
+
options;
|
|
814
|
+
db;
|
|
815
|
+
ftsChecked = false;
|
|
816
|
+
ftsAvailable = false;
|
|
817
|
+
constructor(file, options = {}) {
|
|
818
|
+
this.file = file;
|
|
819
|
+
this.options = options;
|
|
820
|
+
}
|
|
821
|
+
get path() {
|
|
822
|
+
return this.file;
|
|
823
|
+
}
|
|
824
|
+
isOpen() {
|
|
825
|
+
return this.db !== void 0;
|
|
826
|
+
}
|
|
827
|
+
open() {
|
|
828
|
+
if (this.db !== void 0) return this.db;
|
|
829
|
+
if (!existsSync2(this.file)) throw new Error(`\u90AE\u4EF6\u6570\u636E\u5E93\u4E0D\u5B58\u5728: ${this.file}`);
|
|
830
|
+
this.db = new DatabaseSync(this.file, { readOnly: true });
|
|
831
|
+
this.db.exec("PRAGMA busy_timeout = 3000");
|
|
832
|
+
return this.db;
|
|
833
|
+
}
|
|
834
|
+
close() {
|
|
835
|
+
if (this.db === void 0) return;
|
|
836
|
+
try {
|
|
837
|
+
this.db.close();
|
|
838
|
+
} catch {
|
|
839
|
+
}
|
|
840
|
+
this.db = void 0;
|
|
841
|
+
}
|
|
842
|
+
status() {
|
|
843
|
+
try {
|
|
844
|
+
const row = this.open().prepare("SELECT COUNT(*) AS count FROM emails").get();
|
|
845
|
+
return { ok: true, database: this.file, total: Number(row?.count ?? 0) };
|
|
846
|
+
} catch (error) {
|
|
847
|
+
return { ok: false, database: this.file, total: 0, error: messageOf(error) };
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/** Per-mailbox counts, largest first. */
|
|
851
|
+
mailboxes() {
|
|
852
|
+
const rows = this.open().prepare("SELECT mailbox, COUNT(*) AS count FROM emails GROUP BY mailbox ORDER BY count DESC").all();
|
|
853
|
+
return rows.map((row) => ({ name: String(row.mailbox), count: Number(row.count ?? 0) }));
|
|
854
|
+
}
|
|
855
|
+
supportsFts() {
|
|
856
|
+
if (this.ftsChecked) return this.ftsAvailable;
|
|
857
|
+
this.ftsChecked = true;
|
|
858
|
+
try {
|
|
859
|
+
const row = this.open().prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'emails_fts'").get();
|
|
860
|
+
this.ftsAvailable = row !== void 0;
|
|
861
|
+
} catch {
|
|
862
|
+
this.ftsAvailable = false;
|
|
863
|
+
}
|
|
864
|
+
return this.ftsAvailable;
|
|
865
|
+
}
|
|
866
|
+
list(query = {}) {
|
|
867
|
+
const db = this.open();
|
|
868
|
+
const mailbox = String(query.mailbox ?? "all");
|
|
869
|
+
const offset = Math.max(0, Math.trunc(query.offset ?? 0));
|
|
870
|
+
const limit = Math.min(500, Math.max(1, Math.trunc(query.limit ?? 40)));
|
|
871
|
+
const where = [];
|
|
872
|
+
const params = [];
|
|
873
|
+
if (mailbox !== "" && mailbox !== "all") {
|
|
874
|
+
where.push("e.mailbox = ?");
|
|
875
|
+
params.push(mailbox);
|
|
876
|
+
}
|
|
877
|
+
if (typeof query.since === "string" && query.since !== "") {
|
|
878
|
+
where.push("e.date >= ?");
|
|
879
|
+
params.push(query.since);
|
|
880
|
+
}
|
|
881
|
+
const text2 = String(query.query ?? "").trim();
|
|
882
|
+
if (text2 !== "") {
|
|
883
|
+
const tokens = text2.split(/\s+/).filter(Boolean);
|
|
884
|
+
const short = tokens.some((token) => token.length < 3);
|
|
885
|
+
if (!short && this.supportsFts()) {
|
|
886
|
+
const like = `%${text2}%`;
|
|
887
|
+
where.push(
|
|
888
|
+
"(e.id IN (SELECT rowid FROM emails_fts WHERE emails_fts MATCH ?) OR e.subject LIKE ? OR e.from_name LIKE ? OR e.from_addr LIKE ? OR e.to_addr LIKE ?)"
|
|
889
|
+
);
|
|
890
|
+
params.push(tokens.map(quoteMatch).join(" AND "), like, like, like, like);
|
|
891
|
+
} else {
|
|
892
|
+
for (const token of tokens) {
|
|
893
|
+
const like = `%${token}%`;
|
|
894
|
+
where.push(
|
|
895
|
+
"(e.subject LIKE ? OR e.from_name LIKE ? OR e.from_addr LIKE ? OR e.to_addr LIKE ? OR e.body_text LIKE ?)"
|
|
896
|
+
);
|
|
897
|
+
params.push(like, like, like, like, like);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const clause = where.length === 0 ? "" : `WHERE ${where.join(" AND ")}`;
|
|
902
|
+
const columns = `SELECT e.id, e.mailbox, e.uid, e.subject, e.from_name, e.from_addr, e.to_addr, e.cc,
|
|
903
|
+
e.message_id, e.date, length(e.body_text) AS text_length,
|
|
904
|
+
(SELECT COUNT(*) FROM attachments a WHERE a.email_id = e.id) AS attachment_count,
|
|
905
|
+
substr(COALESCE(e.body_text, ''), 1, 240) AS snippet`;
|
|
906
|
+
const total = Number(
|
|
907
|
+
db.prepare(`SELECT COUNT(*) AS count FROM emails e ${clause}`).get(...params)?.count ?? 0
|
|
908
|
+
);
|
|
909
|
+
const direction = query.order === "oldest" ? "ASC" : "DESC";
|
|
910
|
+
const rows = db.prepare(
|
|
911
|
+
`${columns} FROM emails e ${clause} ORDER BY e.date ${direction}, e.id ${direction} LIMIT ? OFFSET ?`
|
|
912
|
+
).all(...params, limit, offset);
|
|
913
|
+
return { items: rows.map((row) => rowToSummary(row)), total, offset, limit };
|
|
914
|
+
}
|
|
915
|
+
get(localId) {
|
|
916
|
+
const db = this.open();
|
|
917
|
+
const row = db.prepare(
|
|
918
|
+
`SELECT id, mailbox, uid, subject, from_name, from_addr, to_addr, cc, date, message_id,
|
|
919
|
+
body_text, body_html
|
|
920
|
+
FROM emails WHERE id = ?`
|
|
921
|
+
).get(localId);
|
|
922
|
+
if (row === void 0) return void 0;
|
|
923
|
+
const attachments = db.prepare("SELECT id, filename, content_type, size FROM attachments WHERE email_id = ? ORDER BY id").all(localId).map((item) => ({
|
|
924
|
+
id: String(item.id),
|
|
925
|
+
filename: String(item.filename ?? "attachment"),
|
|
926
|
+
contentType: String(item.content_type ?? "application/octet-stream"),
|
|
927
|
+
size: Number(item.size ?? 0)
|
|
928
|
+
}));
|
|
929
|
+
const max = this.options.maxBodyChars ?? 4e5;
|
|
930
|
+
const bodyText = String(row.body_text ?? "");
|
|
931
|
+
const bodyHtml = String(row.body_html ?? "");
|
|
932
|
+
return {
|
|
933
|
+
...rowToSummary(row),
|
|
934
|
+
cc: parseAddressList(String(row.cc ?? "")),
|
|
935
|
+
replyTo: [],
|
|
936
|
+
text: truncate(bodyText, max),
|
|
937
|
+
html: bodyHtml === "" ? null : truncate(bodyHtml, max * 2),
|
|
938
|
+
attachments,
|
|
939
|
+
references: []
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
/** Read one attachment's bytes; the payload may live in a file (content-addressed). */
|
|
943
|
+
attachment(emailId, attachmentId) {
|
|
944
|
+
const db = this.open();
|
|
945
|
+
const row = db.prepare("SELECT filename, content_type, data, hash FROM attachments WHERE id = ? AND email_id = ?").get(attachmentId, emailId);
|
|
946
|
+
if (row === void 0) return void 0;
|
|
947
|
+
let data = row.data instanceof Uint8Array ? row.data : null;
|
|
948
|
+
if (data === null && typeof row.hash === "string" && row.hash !== "") {
|
|
949
|
+
const root = this.options.attachmentsRoot ?? "";
|
|
950
|
+
if (root !== "") {
|
|
951
|
+
const file = `${root}/${row.hash.slice(0, 2)}/${row.hash.slice(2, 4)}/${row.hash}`;
|
|
952
|
+
if (existsSync2(file)) data = readFileSyncSafe(file);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (data === null) return void 0;
|
|
956
|
+
return {
|
|
957
|
+
filename: String(row.filename ?? "attachment"),
|
|
958
|
+
contentType: String(row.content_type ?? "application/octet-stream"),
|
|
959
|
+
data
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
/** Whether the backup already holds a message with this Message-ID. */
|
|
963
|
+
hasMessageId(messageId) {
|
|
964
|
+
const id = messageId.trim().replace(/^<|>$/g, "");
|
|
965
|
+
if (id === "") return false;
|
|
966
|
+
try {
|
|
967
|
+
return this.open().prepare("SELECT 1 AS found FROM emails WHERE message_id = ? LIMIT 1").get(id) !== void 0;
|
|
968
|
+
} catch {
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
/** Latest backup runs, for the settings page. */
|
|
973
|
+
backupSummary() {
|
|
974
|
+
try {
|
|
975
|
+
const db = this.open();
|
|
976
|
+
const lastCompleted = db.prepare(
|
|
977
|
+
`SELECT id, started_at, completed_at, new_count, total_emails, status
|
|
978
|
+
FROM backup_logs WHERE status = 'completed' ORDER BY id DESC LIMIT 1`
|
|
979
|
+
).get() ?? null;
|
|
980
|
+
const latest = db.prepare(
|
|
981
|
+
"SELECT id, started_at, completed_at, new_count, total_emails, status FROM backup_logs ORDER BY id DESC LIMIT 1"
|
|
982
|
+
).get() ?? null;
|
|
983
|
+
return {
|
|
984
|
+
ok: true,
|
|
985
|
+
lastCompleted: toPlain(lastCompleted),
|
|
986
|
+
latest: toPlain(latest),
|
|
987
|
+
total: Number(db.prepare("SELECT COUNT(*) AS count FROM emails").get()?.count ?? 0)
|
|
988
|
+
};
|
|
989
|
+
} catch (error) {
|
|
990
|
+
return { ok: false, lastCompleted: null, latest: null, total: 0, error: messageOf(error) };
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
function toPlain(row) {
|
|
995
|
+
if (row === void 0 || row === null || typeof row !== "object") return null;
|
|
996
|
+
return { ...row };
|
|
997
|
+
}
|
|
998
|
+
function readFileSyncSafe(file) {
|
|
999
|
+
try {
|
|
1000
|
+
return readFileSync2(file);
|
|
1001
|
+
} catch {
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
function messageOf(error) {
|
|
1006
|
+
return error instanceof Error ? error.message : String(error);
|
|
1007
|
+
}
|
|
1008
|
+
function truncate(value, max) {
|
|
1009
|
+
return value.length <= max ? value : `${value.slice(0, max)}
|
|
1010
|
+
|
|
1011
|
+
\u2026\uFF08\u5DF2\u622A\u65AD\uFF09`;
|
|
1012
|
+
}
|
|
1013
|
+
function quoteMatch(text2) {
|
|
1014
|
+
return `"${text2.replaceAll('"', '""')}"`;
|
|
1015
|
+
}
|
|
1016
|
+
function rowToSummary(row) {
|
|
1017
|
+
const fromName = String(row.from_name ?? "");
|
|
1018
|
+
const fromAddr = String(row.from_addr ?? "");
|
|
1019
|
+
return {
|
|
1020
|
+
id: String(row.id),
|
|
1021
|
+
accountId: "",
|
|
1022
|
+
uid: row.uid === null || row.uid === void 0 ? void 0 : Number(row.uid),
|
|
1023
|
+
messageId: String(row.message_id ?? ""),
|
|
1024
|
+
subject: String(row.subject ?? "(\u65E0\u4E3B\u9898)"),
|
|
1025
|
+
from: { name: fromName, address: fromAddr },
|
|
1026
|
+
to: parseAddressList(String(row.to_addr ?? "")),
|
|
1027
|
+
date: String(row.date ?? ""),
|
|
1028
|
+
unread: false,
|
|
1029
|
+
flagged: false,
|
|
1030
|
+
hasAttachments: Number(row.attachment_count ?? 0) > 0,
|
|
1031
|
+
snippet: String(row.snippet ?? "").replace(/\s+/g, " ").trim(),
|
|
1032
|
+
mailbox: String(row.mailbox ?? "")
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// src/host/backup.ts
|
|
1037
|
+
var BATCH = 25;
|
|
1038
|
+
function sha256(data) {
|
|
1039
|
+
return createHash2("sha256").update(data).digest("hex");
|
|
1040
|
+
}
|
|
1041
|
+
function normalizeId(value) {
|
|
1042
|
+
return String(value ?? "").trim().replace(/^<|>$/g, "").trim();
|
|
1043
|
+
}
|
|
1044
|
+
function stamp(value) {
|
|
1045
|
+
const date = value instanceof Date ? value : value === void 0 || value === null ? void 0 : new Date(String(value));
|
|
1046
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return "";
|
|
1047
|
+
const pad = (number) => String(number).padStart(2, "0");
|
|
1048
|
+
return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
1049
|
+
}
|
|
1050
|
+
function abortIfNeeded(signal) {
|
|
1051
|
+
if (signal?.aborted === true) {
|
|
1052
|
+
const error = new Error("\u5907\u4EFD\u5DF2\u53D6\u6D88");
|
|
1053
|
+
error.code = "backup-cancelled";
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
var BackupEngine = class {
|
|
1058
|
+
runtime;
|
|
1059
|
+
credentials;
|
|
1060
|
+
batchSize;
|
|
1061
|
+
constructor(runtime, credentials, options = {}) {
|
|
1062
|
+
this.runtime = runtime;
|
|
1063
|
+
this.credentials = credentials;
|
|
1064
|
+
this.batchSize = options.batchSize ?? BATCH;
|
|
1065
|
+
}
|
|
1066
|
+
attachmentsDir() {
|
|
1067
|
+
return this.runtime.attachments;
|
|
1068
|
+
}
|
|
1069
|
+
openDb() {
|
|
1070
|
+
ensureMailSchema(this.runtime.mailDb);
|
|
1071
|
+
const db = new DatabaseSync2(this.runtime.mailDb);
|
|
1072
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
1073
|
+
db.exec("PRAGMA busy_timeout=10000");
|
|
1074
|
+
db.exec("PRAGMA synchronous=NORMAL");
|
|
1075
|
+
return db;
|
|
1076
|
+
}
|
|
1077
|
+
writeAttachment(data) {
|
|
1078
|
+
const hash = sha256(data);
|
|
1079
|
+
const dir = join2(this.attachmentsDir(), hash.slice(0, 2), hash.slice(2, 4));
|
|
1080
|
+
const file = join2(dir, hash);
|
|
1081
|
+
mkdirSync2(dir, { recursive: true });
|
|
1082
|
+
if (!existsSync3(file)) writeFileSync2(file, data, { mode: 384 });
|
|
1083
|
+
return { hash, size: data.length };
|
|
1084
|
+
}
|
|
1085
|
+
insertEmail(db, mailbox, message, parsed, logId) {
|
|
1086
|
+
const source = message.source ?? Buffer.alloc(0);
|
|
1087
|
+
const hash = sha256(source);
|
|
1088
|
+
const from = parsed.from?.value?.[0] ?? { name: "", address: "" };
|
|
1089
|
+
const messageId = normalizeId(parsed.messageId ?? message.envelope?.messageId);
|
|
1090
|
+
const toText = parsedAddressText(parsed.to) || formatAddressList(parseAddressList(addressTextOf(message.envelope?.to)));
|
|
1091
|
+
const ccText = parsedAddressText(parsed.cc) || formatAddressList(parseAddressList(addressTextOf(message.envelope?.cc)));
|
|
1092
|
+
const subject = String(parsed.subject ?? message.envelope?.subject ?? "(\u65E0\u4E3B\u9898)");
|
|
1093
|
+
const text2 = typeof parsed.text === "string" ? parsed.text : "";
|
|
1094
|
+
const html = typeof parsed.html === "string" ? parsed.html : "";
|
|
1095
|
+
const inserted = db.prepare(
|
|
1096
|
+
`INSERT OR IGNORE INTO emails
|
|
1097
|
+
(mailbox, uid, subject, from_name, from_addr, to_addr, cc, date, message_id,
|
|
1098
|
+
body_text, body_html, raw_eml, hash, backup_log_id)
|
|
1099
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1100
|
+
).run(
|
|
1101
|
+
mailbox,
|
|
1102
|
+
Number(message.uid),
|
|
1103
|
+
subject,
|
|
1104
|
+
String(from.name ?? ""),
|
|
1105
|
+
String(from.address ?? ""),
|
|
1106
|
+
toText,
|
|
1107
|
+
ccText,
|
|
1108
|
+
stamp(parsed.date ?? message.internalDate ?? message.envelope?.date),
|
|
1109
|
+
messageId === "" ? null : messageId,
|
|
1110
|
+
text2,
|
|
1111
|
+
html,
|
|
1112
|
+
source,
|
|
1113
|
+
hash,
|
|
1114
|
+
logId
|
|
1115
|
+
);
|
|
1116
|
+
if (inserted.changes === 0) return 0;
|
|
1117
|
+
const emailId = Number(inserted.lastInsertRowid);
|
|
1118
|
+
const attachments = parsed.attachments ?? [];
|
|
1119
|
+
const insertAttachment = db.prepare(
|
|
1120
|
+
"INSERT INTO attachments (email_id, filename, content_type, size, hash) VALUES (?, ?, ?, ?, ?)"
|
|
1121
|
+
);
|
|
1122
|
+
for (const attachment of attachments) {
|
|
1123
|
+
const data = attachment.content;
|
|
1124
|
+
if (data === void 0 || data === null) continue;
|
|
1125
|
+
const stored = this.writeAttachment(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
1126
|
+
insertAttachment.run(
|
|
1127
|
+
emailId,
|
|
1128
|
+
String(attachment.filename ?? "attachment"),
|
|
1129
|
+
String(attachment.contentType ?? "application/octet-stream"),
|
|
1130
|
+
stored.size,
|
|
1131
|
+
stored.hash
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
try {
|
|
1135
|
+
db.prepare(
|
|
1136
|
+
`INSERT INTO emails_fts (rowid, subject, from_name, from_addr, to_addr, cc, body_text, att_filename)
|
|
1137
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1138
|
+
).run(
|
|
1139
|
+
emailId,
|
|
1140
|
+
subject,
|
|
1141
|
+
String(from.name ?? ""),
|
|
1142
|
+
String(from.address ?? ""),
|
|
1143
|
+
toText,
|
|
1144
|
+
ccText,
|
|
1145
|
+
text2,
|
|
1146
|
+
attachments.map((attachment) => attachment.filename).filter(Boolean).join(" ")
|
|
1147
|
+
);
|
|
1148
|
+
} catch {
|
|
1149
|
+
}
|
|
1150
|
+
return 1;
|
|
1151
|
+
}
|
|
1152
|
+
async run(options = {}) {
|
|
1153
|
+
const emit = (event) => options.onEvent?.(event);
|
|
1154
|
+
const signal = options.signal;
|
|
1155
|
+
const db = this.openDb();
|
|
1156
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1157
|
+
const logId = Number(
|
|
1158
|
+
db.prepare(`INSERT INTO backup_logs (started_at, status) VALUES (?, 'running')`).run(startedAt).lastInsertRowid
|
|
1159
|
+
);
|
|
1160
|
+
const client = new ImapFlow({
|
|
1161
|
+
host: this.runtime.imap.host,
|
|
1162
|
+
port: Number(this.runtime.imap.port),
|
|
1163
|
+
secure: this.runtime.imap.secure !== false,
|
|
1164
|
+
auth: this.credentials.accessToken !== void 0 ? { user: this.credentials.user, accessToken: this.credentials.accessToken } : { user: this.credentials.user, pass: this.credentials.pass ?? "" },
|
|
1165
|
+
logger: false,
|
|
1166
|
+
tls: { rejectUnauthorized: true },
|
|
1167
|
+
connectionTimeout: 2e4,
|
|
1168
|
+
greetingTimeout: 2e4,
|
|
1169
|
+
socketTimeout: 12e4
|
|
1170
|
+
});
|
|
1171
|
+
client.on("error", () => {
|
|
1172
|
+
});
|
|
1173
|
+
let newCount = 0;
|
|
1174
|
+
let failCount = 0;
|
|
1175
|
+
const mailboxes = [];
|
|
1176
|
+
emit({ message: `\u5F00\u59CB\u5907\u4EFD ${this.runtime.account.email}` });
|
|
1177
|
+
try {
|
|
1178
|
+
await client.connect();
|
|
1179
|
+
const boxes = await client.list();
|
|
1180
|
+
for (const box of boxes) {
|
|
1181
|
+
abortIfNeeded(signal);
|
|
1182
|
+
if (box.flags?.has?.("\\Noselect") === true) continue;
|
|
1183
|
+
const path = box.path;
|
|
1184
|
+
emit({ mailbox: path, message: `\u68C0\u67E5 ${path}` });
|
|
1185
|
+
const lock = await client.getMailboxLock(path);
|
|
1186
|
+
let boxAdded = 0;
|
|
1187
|
+
try {
|
|
1188
|
+
const uidValidity = String(
|
|
1189
|
+
client.mailbox?.uidValidity ?? ""
|
|
1190
|
+
);
|
|
1191
|
+
let state = db.prepare("SELECT uid_validity, last_uid FROM backup_state WHERE mailbox = ?").get(path);
|
|
1192
|
+
if (state === void 0) {
|
|
1193
|
+
const dbMax = Number(
|
|
1194
|
+
db.prepare("SELECT MAX(uid) AS maxUid FROM emails WHERE mailbox = ?").get(path)?.maxUid ?? 0
|
|
1195
|
+
);
|
|
1196
|
+
this.saveState(db, path, uidValidity, dbMax);
|
|
1197
|
+
state = { uid_validity: uidValidity, last_uid: dbMax };
|
|
1198
|
+
} else if (uidValidity !== "" && String(state.uid_validity ?? "") !== uidValidity) {
|
|
1199
|
+
this.saveState(db, path, uidValidity, 0);
|
|
1200
|
+
state = { uid_validity: uidValidity, last_uid: 0 };
|
|
1201
|
+
emit({ mailbox: path, message: `${path} \u7684 UIDVALIDITY \u53D8\u5316\uFF0C\u91CD\u65B0\u540C\u6B65` });
|
|
1202
|
+
}
|
|
1203
|
+
const lastUid = Number(state.last_uid ?? 0);
|
|
1204
|
+
const found = await client.search({ uid: `${String(lastUid + 1)}:*` }, { uid: true });
|
|
1205
|
+
const uids = (Array.isArray(found) ? found : []).filter((uid) => Number(uid) > lastUid).sort((left, right) => left - right);
|
|
1206
|
+
let committedUid = lastUid;
|
|
1207
|
+
for (let offset = 0; offset < uids.length; offset += this.batchSize) {
|
|
1208
|
+
abortIfNeeded(signal);
|
|
1209
|
+
const batch = uids.slice(offset, offset + this.batchSize);
|
|
1210
|
+
db.exec("BEGIN");
|
|
1211
|
+
try {
|
|
1212
|
+
let batchLastUid = committedUid;
|
|
1213
|
+
for await (const message of client.fetch(
|
|
1214
|
+
batch.join(","),
|
|
1215
|
+
{ uid: true, source: true, flags: true, internalDate: true },
|
|
1216
|
+
{ uid: true }
|
|
1217
|
+
)) {
|
|
1218
|
+
abortIfNeeded(signal);
|
|
1219
|
+
try {
|
|
1220
|
+
const parsed = await simpleParser(message.source ?? Buffer.alloc(0));
|
|
1221
|
+
const added = this.insertEmail(
|
|
1222
|
+
db,
|
|
1223
|
+
path,
|
|
1224
|
+
message,
|
|
1225
|
+
parsed,
|
|
1226
|
+
logId
|
|
1227
|
+
);
|
|
1228
|
+
boxAdded += added;
|
|
1229
|
+
newCount += added;
|
|
1230
|
+
batchLastUid = Math.max(batchLastUid, Number(message.uid));
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
failCount += 1;
|
|
1233
|
+
emit({
|
|
1234
|
+
mailbox: path,
|
|
1235
|
+
message: `\u90AE\u4EF6 ${String(message.uid)} \u89E3\u6790\u5931\u8D25: ${messageOf2(error)}`
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
this.saveState(db, path, uidValidity, batchLastUid);
|
|
1240
|
+
db.exec("COMMIT");
|
|
1241
|
+
committedUid = batchLastUid;
|
|
1242
|
+
emit({ mailbox: path, added: boxAdded, message: `${path}: \u5DF2\u65B0\u589E ${String(boxAdded)} \u5C01` });
|
|
1243
|
+
} catch (error) {
|
|
1244
|
+
db.exec("ROLLBACK");
|
|
1245
|
+
throw error;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
mailboxes.push({ name: path, added: boxAdded });
|
|
1249
|
+
emit({ mailbox: path, added: boxAdded, message: `${path}: \u65B0\u589E ${String(boxAdded)} \u5C01` });
|
|
1250
|
+
} finally {
|
|
1251
|
+
lock.release();
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const total = Number(db.prepare("SELECT COUNT(*) AS n FROM emails").get()?.n ?? 0);
|
|
1255
|
+
db.prepare(
|
|
1256
|
+
`UPDATE backup_logs SET completed_at = ?, mailboxes = ?, new_count = ?, fail_count = ?,
|
|
1257
|
+
total_emails = ?, status = 'completed' WHERE id = ?`
|
|
1258
|
+
).run((/* @__PURE__ */ new Date()).toISOString(), JSON.stringify(mailboxes), newCount, failCount, total, logId);
|
|
1259
|
+
emit({
|
|
1260
|
+
total,
|
|
1261
|
+
added: newCount,
|
|
1262
|
+
message: `\u5907\u4EFD\u5B8C\u6210\uFF1A\u65B0\u589E ${String(newCount)} \u5C01\uFF0C\u5171 ${String(total)} \u5C01`
|
|
1263
|
+
});
|
|
1264
|
+
return { ok: true, newCount, failCount, total, mailboxes };
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
const total = Number(db.prepare("SELECT COUNT(*) AS n FROM emails").get()?.n ?? 0);
|
|
1267
|
+
db.prepare(
|
|
1268
|
+
`UPDATE backup_logs SET completed_at = ?, mailboxes = ?, new_count = ?, fail_count = ?,
|
|
1269
|
+
total_emails = ?, status = 'failed', error_info = ? WHERE id = ?`
|
|
1270
|
+
).run(
|
|
1271
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
1272
|
+
JSON.stringify(mailboxes),
|
|
1273
|
+
newCount,
|
|
1274
|
+
failCount,
|
|
1275
|
+
total,
|
|
1276
|
+
messageOf2(error).slice(0, 1e3),
|
|
1277
|
+
logId
|
|
1278
|
+
);
|
|
1279
|
+
throw error;
|
|
1280
|
+
} finally {
|
|
1281
|
+
try {
|
|
1282
|
+
await client.logout();
|
|
1283
|
+
} catch {
|
|
1284
|
+
}
|
|
1285
|
+
db.close();
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
saveState(db, mailbox, uidValidity, lastUid) {
|
|
1289
|
+
db.prepare(
|
|
1290
|
+
`INSERT INTO backup_state (mailbox, uid_validity, last_uid, updated_at) VALUES (?, ?, ?, ?)
|
|
1291
|
+
ON CONFLICT(mailbox) DO UPDATE SET uid_validity = excluded.uid_validity,
|
|
1292
|
+
last_uid = excluded.last_uid, updated_at = excluded.updated_at`
|
|
1293
|
+
).run(mailbox, uidValidity, Number(lastUid), (/* @__PURE__ */ new Date()).toISOString());
|
|
1294
|
+
}
|
|
1295
|
+
/** Latest backup summary for the settings page. */
|
|
1296
|
+
static status(dbFile) {
|
|
1297
|
+
const empty = {
|
|
1298
|
+
running: false,
|
|
1299
|
+
accountId: null,
|
|
1300
|
+
startedAt: null,
|
|
1301
|
+
finishedAt: null,
|
|
1302
|
+
lastSuccessAt: null,
|
|
1303
|
+
total: 0,
|
|
1304
|
+
added: 0,
|
|
1305
|
+
message: "",
|
|
1306
|
+
output: [],
|
|
1307
|
+
exitCode: null
|
|
1308
|
+
};
|
|
1309
|
+
if (!existsSync3(dbFile)) return empty;
|
|
1310
|
+
try {
|
|
1311
|
+
const db = new DatabaseSync2(dbFile, { readOnly: true });
|
|
1312
|
+
try {
|
|
1313
|
+
const latest = db.prepare("SELECT * FROM backup_logs ORDER BY id DESC LIMIT 1").get();
|
|
1314
|
+
const success = db.prepare("SELECT completed_at FROM backup_logs WHERE status = 'completed' ORDER BY id DESC LIMIT 1").get();
|
|
1315
|
+
const total = Number(db.prepare("SELECT COUNT(*) AS n FROM emails").get()?.n ?? 0);
|
|
1316
|
+
return {
|
|
1317
|
+
...empty,
|
|
1318
|
+
running: latest?.status === "running",
|
|
1319
|
+
startedAt: latest?.started_at === void 0 ? null : String(latest.started_at),
|
|
1320
|
+
finishedAt: latest?.completed_at === void 0 ? null : String(latest.completed_at),
|
|
1321
|
+
lastSuccessAt: success?.completed_at === void 0 ? null : String(success.completed_at),
|
|
1322
|
+
total,
|
|
1323
|
+
added: Number(latest?.new_count ?? 0),
|
|
1324
|
+
message: latest?.status === "failed" ? String(latest?.error_info ?? "\u5907\u4EFD\u5931\u8D25") : "",
|
|
1325
|
+
exitCode: latest?.status === "failed" ? 1 : 0
|
|
1326
|
+
};
|
|
1327
|
+
} finally {
|
|
1328
|
+
db.close();
|
|
1329
|
+
}
|
|
1330
|
+
} catch {
|
|
1331
|
+
return empty;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
function parsedAddressText(value) {
|
|
1336
|
+
if (value === void 0 || value === null) return "";
|
|
1337
|
+
const items = Array.isArray(value) ? value : [value];
|
|
1338
|
+
return formatAddressList(
|
|
1339
|
+
items.map((item) => ({
|
|
1340
|
+
name: String(item.name ?? ""),
|
|
1341
|
+
address: String(item.address ?? "")
|
|
1342
|
+
})).filter((address) => address.address !== "")
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
function addressTextOf(list) {
|
|
1346
|
+
return (list ?? []).map((item) => {
|
|
1347
|
+
const entry = item;
|
|
1348
|
+
return entry.name ? `${entry.name} <${String(entry.address ?? "")}>` : String(entry.address ?? "");
|
|
1349
|
+
}).filter((text2) => text2 !== "").join(", ");
|
|
1350
|
+
}
|
|
1351
|
+
function messageOf2(error) {
|
|
1352
|
+
return error instanceof Error ? error.message : String(error);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// src/host/http.ts
|
|
1356
|
+
var HttpError = class extends Error {
|
|
1357
|
+
status;
|
|
1358
|
+
code;
|
|
1359
|
+
constructor(status, message, code) {
|
|
1360
|
+
super(message);
|
|
1361
|
+
this.status = status;
|
|
1362
|
+
this.code = code;
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
function sendJson(res, status, payload) {
|
|
1366
|
+
const body = Buffer.from(JSON.stringify(payload), "utf8");
|
|
1367
|
+
res.statusCode = status;
|
|
1368
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
1369
|
+
res.setHeader("content-length", String(body.length));
|
|
1370
|
+
res.setHeader("cache-control", "no-store");
|
|
1371
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
1372
|
+
res.end(body);
|
|
1373
|
+
}
|
|
1374
|
+
async function readJsonBody(req, limit = 2 * 1024 * 1024) {
|
|
1375
|
+
const chunks = [];
|
|
1376
|
+
let size = 0;
|
|
1377
|
+
for await (const chunk of req) {
|
|
1378
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1379
|
+
size += buffer.length;
|
|
1380
|
+
if (size > limit) throw new HttpError(413, "\u8BF7\u6C42\u4F53\u8FC7\u5927");
|
|
1381
|
+
chunks.push(buffer);
|
|
1382
|
+
}
|
|
1383
|
+
if (size === 0) return {};
|
|
1384
|
+
try {
|
|
1385
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1386
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1387
|
+
throw new HttpError(400, "\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
|
|
1388
|
+
}
|
|
1389
|
+
return parsed;
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
if (error instanceof HttpError) throw error;
|
|
1392
|
+
throw new HttpError(400, "\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON");
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
function isTrustedRequest(req) {
|
|
1396
|
+
const origin = req.headers.origin;
|
|
1397
|
+
if (origin === void 0 || origin === "") return true;
|
|
1398
|
+
if (origin.startsWith("dsh-app://")) return true;
|
|
1399
|
+
try {
|
|
1400
|
+
const parsed = new URL(origin);
|
|
1401
|
+
const host = req.headers.host ?? "";
|
|
1402
|
+
return parsed.host === host;
|
|
1403
|
+
} catch {
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
function assertTrusted(req) {
|
|
1408
|
+
if (!isTrustedRequest(req)) throw new HttpError(403, "\u6765\u6E90\u4E0D\u88AB\u5141\u8BB8", "forbidden");
|
|
1409
|
+
}
|
|
1410
|
+
var Router = class {
|
|
1411
|
+
routes = [];
|
|
1412
|
+
add(method, path, handler) {
|
|
1413
|
+
this.routes.push({
|
|
1414
|
+
method: method.toUpperCase(),
|
|
1415
|
+
segments: path.split("/").filter((segment) => segment !== ""),
|
|
1416
|
+
handler
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
get(path, handler) {
|
|
1420
|
+
this.add("GET", path, handler);
|
|
1421
|
+
}
|
|
1422
|
+
post(path, handler) {
|
|
1423
|
+
this.add("POST", path, handler);
|
|
1424
|
+
}
|
|
1425
|
+
patch(path, handler) {
|
|
1426
|
+
this.add("PATCH", path, handler);
|
|
1427
|
+
}
|
|
1428
|
+
delete(path, handler) {
|
|
1429
|
+
this.add("DELETE", path, handler);
|
|
1430
|
+
}
|
|
1431
|
+
match(method, pathname) {
|
|
1432
|
+
const parts = pathname.split("/").filter((segment) => segment !== "");
|
|
1433
|
+
for (const route of this.routes) {
|
|
1434
|
+
if (route.method !== method.toUpperCase()) continue;
|
|
1435
|
+
if (route.segments.length !== parts.length) continue;
|
|
1436
|
+
const params = {};
|
|
1437
|
+
let matched = true;
|
|
1438
|
+
for (const [index, segment] of route.segments.entries()) {
|
|
1439
|
+
const actual = parts[index] ?? "";
|
|
1440
|
+
if (segment.startsWith(":")) {
|
|
1441
|
+
params[segment.slice(1)] = decodeURIComponent(actual);
|
|
1442
|
+
} else if (segment !== actual) {
|
|
1443
|
+
matched = false;
|
|
1444
|
+
break;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
if (matched) return { handler: route.handler, params, path: pathname };
|
|
1448
|
+
}
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
};
|
|
1452
|
+
|
|
1453
|
+
// src/host/api.ts
|
|
1454
|
+
init_smtp();
|
|
1455
|
+
|
|
1456
|
+
// src/host/html.ts
|
|
1457
|
+
function htmlToText(html) {
|
|
1458
|
+
return html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|li|tr|h[1-6])>/gi, "\n").replace(/<li[^>]*>/gi, "\u2022 ").replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/\n{3,}/g, "\n\n").trim();
|
|
1459
|
+
}
|
|
1460
|
+
function escapeHtml(value) {
|
|
1461
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// src/host/api.ts
|
|
1465
|
+
var API_PREFIX = "/mail-plugin/api";
|
|
1466
|
+
function createApiHandler(runtime) {
|
|
1467
|
+
const router = buildRouter(runtime);
|
|
1468
|
+
return async function handler(req, res) {
|
|
1469
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1470
|
+
if (!url.pathname.startsWith(API_PREFIX)) {
|
|
1471
|
+
sendJson(res, 404, { error: "Not found" });
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const pathname = url.pathname.slice(API_PREFIX.length) || "/";
|
|
1475
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1476
|
+
try {
|
|
1477
|
+
if (method !== "GET" && method !== "HEAD") assertTrusted(req);
|
|
1478
|
+
const match = router.match(method, pathname);
|
|
1479
|
+
if (match === null) throw new HttpError(404, `\u672A\u77E5\u63A5\u53E3: ${method} ${pathname}`);
|
|
1480
|
+
runtime.refresh();
|
|
1481
|
+
const context = {
|
|
1482
|
+
req,
|
|
1483
|
+
res,
|
|
1484
|
+
url,
|
|
1485
|
+
params: match.params,
|
|
1486
|
+
body: () => readJsonBody(req, method === "POST" && pathname === "/send" ? 25 * 1024 * 1024 : 2 * 1024 * 1024)
|
|
1487
|
+
};
|
|
1488
|
+
const result = await match.handler(context);
|
|
1489
|
+
if (result !== void 0) sendJson(res, 200, result);
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
if (res.headersSent) {
|
|
1492
|
+
res.end();
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
sendJson(res, error instanceof HttpError ? error.status : 500, {
|
|
1496
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1497
|
+
...error instanceof HttpError && error.code !== void 0 ? { code: error.code } : {}
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
function buildRouter(runtime) {
|
|
1503
|
+
const router = new Router();
|
|
1504
|
+
router.get("/health", () => ({ ok: true, version: VERSION }));
|
|
1505
|
+
router.get("/state", () => appState(runtime));
|
|
1506
|
+
router.get("/folders", async ({ url }) => {
|
|
1507
|
+
const { account } = accountFrom(runtime, url);
|
|
1508
|
+
const source = sourceOf(url);
|
|
1509
|
+
if (source === "server") {
|
|
1510
|
+
const provider = runtime.provider(account);
|
|
1511
|
+
const boxes = await provider.mailboxes();
|
|
1512
|
+
return {
|
|
1513
|
+
items: boxes.map((box) => ({ name: box.path, label: box.name, count: box.messages }))
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
return { items: runtime.database(account).mailboxes() };
|
|
1517
|
+
});
|
|
1518
|
+
router.get("/messages", async ({ url }) => {
|
|
1519
|
+
const { account } = accountFrom(runtime, url);
|
|
1520
|
+
const source = sourceOf(url);
|
|
1521
|
+
const mailbox = url.searchParams.get("mailbox") ?? "all";
|
|
1522
|
+
const query = url.searchParams.get("query") ?? "";
|
|
1523
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
1524
|
+
const limit = Number(url.searchParams.get("limit") ?? runtime.current.render.listLimit);
|
|
1525
|
+
if (source === "server") {
|
|
1526
|
+
const items = await listServer(runtime, account, mailbox, query, limit);
|
|
1527
|
+
return { ...items, offset: 0, limit };
|
|
1528
|
+
}
|
|
1529
|
+
const db = runtime.database(account);
|
|
1530
|
+
if (mailbox !== "all" && mailbox !== "") {
|
|
1531
|
+
const page2 = db.list({ mailbox, query, offset, limit });
|
|
1532
|
+
return { ...page2, items: page2.items.map((item) => localSummary(runtime, account, item)) };
|
|
1533
|
+
}
|
|
1534
|
+
const page = db.list({ mailbox: "all", query, offset, limit });
|
|
1535
|
+
return { ...page, items: page.items.map((item) => localSummary(runtime, account, item)) };
|
|
1536
|
+
});
|
|
1537
|
+
router.get("/messages/:id", async ({ url, params }) => {
|
|
1538
|
+
const { account } = accountFrom(runtime, url);
|
|
1539
|
+
const source = sourceOf(url);
|
|
1540
|
+
const mailbox = url.searchParams.get("mailbox") ?? "INBOX";
|
|
1541
|
+
const id = params.id ?? "";
|
|
1542
|
+
if (source === "server") {
|
|
1543
|
+
const provider = runtime.provider(account);
|
|
1544
|
+
const message = await provider.read(mailbox, Number(id));
|
|
1545
|
+
return serverDetail(runtime, account, message, mailbox);
|
|
1546
|
+
}
|
|
1547
|
+
const detail = runtime.database(account).get(Number(id));
|
|
1548
|
+
if (detail === void 0) throw new HttpError(404, "\u90AE\u4EF6\u4E0D\u5B58\u5728");
|
|
1549
|
+
return localDetail(runtime, account, detail);
|
|
1550
|
+
});
|
|
1551
|
+
router.post("/messages/:id/read", async ({ url, params, body }) => {
|
|
1552
|
+
const { account } = accountFrom(runtime, url);
|
|
1553
|
+
const source = sourceOf(url);
|
|
1554
|
+
const mailbox = url.searchParams.get("mailbox") ?? "INBOX";
|
|
1555
|
+
const payload = await body();
|
|
1556
|
+
const read = payload.read !== false;
|
|
1557
|
+
if (source === "server") {
|
|
1558
|
+
const provider = runtime.provider(account);
|
|
1559
|
+
const result = await provider.setSeen(mailbox, Number(params.id), read);
|
|
1560
|
+
return { ok: true, read: result.read };
|
|
1561
|
+
}
|
|
1562
|
+
const db = runtime.database(account);
|
|
1563
|
+
const detail = db.get(Number(params.id));
|
|
1564
|
+
if (detail === void 0) throw new HttpError(404, "\u90AE\u4EF6\u4E0D\u5B58\u5728");
|
|
1565
|
+
const store = runtime.readState(account);
|
|
1566
|
+
const key = detail.messageId !== "" ? `mid:${detail.messageId}` : `local:${detail.id}`;
|
|
1567
|
+
store.setUnread(key, !read);
|
|
1568
|
+
if (typeof detail.messageId === "string" && detail.messageId !== "") {
|
|
1569
|
+
try {
|
|
1570
|
+
const provider = runtime.provider(account);
|
|
1571
|
+
const mailboxName = detail.mailbox === "" ? "INBOX" : detail.mailbox;
|
|
1572
|
+
await provider.setSeenByMessageId(mailboxName, detail.messageId, read);
|
|
1573
|
+
} catch {
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
return { ok: true, read };
|
|
1577
|
+
});
|
|
1578
|
+
router.get("/attachment", async ({ url, res }) => {
|
|
1579
|
+
const { account } = accountFrom(runtime, url);
|
|
1580
|
+
const source = sourceOf(url);
|
|
1581
|
+
if (source === "server") {
|
|
1582
|
+
const mailbox = url.searchParams.get("mailbox") ?? "INBOX";
|
|
1583
|
+
const uid = Number(url.searchParams.get("uid") ?? 0);
|
|
1584
|
+
const part = Number(url.searchParams.get("part") ?? 0);
|
|
1585
|
+
const provider = runtime.provider(account);
|
|
1586
|
+
const file2 = await provider.attachment(mailbox, uid, part);
|
|
1587
|
+
writeAttachment(res, file2.filename, file2.contentType, Buffer.from(file2.dataBase64, "base64"));
|
|
1588
|
+
return void 0;
|
|
1589
|
+
}
|
|
1590
|
+
const emailId = Number(url.searchParams.get("emailId") ?? 0);
|
|
1591
|
+
const attachmentId = Number(url.searchParams.get("attachmentId") ?? 0);
|
|
1592
|
+
const file = runtime.database(account).attachment(emailId, attachmentId);
|
|
1593
|
+
if (file === void 0) throw new HttpError(404, "\u9644\u4EF6\u4E0D\u5B58\u5728");
|
|
1594
|
+
writeAttachment(res, file.filename, file.contentType, Buffer.from(file.data));
|
|
1595
|
+
return void 0;
|
|
1596
|
+
});
|
|
1597
|
+
router.get("/drafts", ({ url }) => {
|
|
1598
|
+
const { account } = accountFrom(runtime, url);
|
|
1599
|
+
return { items: runtime.drafts(account).list() };
|
|
1600
|
+
});
|
|
1601
|
+
router.post("/drafts", async ({ url, body }) => {
|
|
1602
|
+
const { account } = accountFrom(runtime, url);
|
|
1603
|
+
const payload = await body();
|
|
1604
|
+
const draft = runtime.drafts(account).save(normalizeDraftInput(payload, account.id));
|
|
1605
|
+
return { draft };
|
|
1606
|
+
});
|
|
1607
|
+
router.delete("/drafts/:id", ({ url, params }) => {
|
|
1608
|
+
const { account } = accountFrom(runtime, url);
|
|
1609
|
+
const removed = runtime.drafts(account).remove(params.id ?? "");
|
|
1610
|
+
if (!removed) throw new HttpError(404, "\u8349\u7A3F\u4E0D\u5B58\u5728");
|
|
1611
|
+
return { ok: true };
|
|
1612
|
+
});
|
|
1613
|
+
router.post("/send", async ({ url, body }) => {
|
|
1614
|
+
const { account } = accountFrom(runtime, url);
|
|
1615
|
+
const payload = await body();
|
|
1616
|
+
const result = await sendFromPayload(runtime, account, payload);
|
|
1617
|
+
return result;
|
|
1618
|
+
});
|
|
1619
|
+
router.get("/unread", async ({ url }) => {
|
|
1620
|
+
const { account } = accountFrom(runtime, url);
|
|
1621
|
+
if (!runtime.hasCredentials(account)) {
|
|
1622
|
+
return { unread: runtime.readState(account).count(), source: "local" };
|
|
1623
|
+
}
|
|
1624
|
+
try {
|
|
1625
|
+
const provider = runtime.provider(account);
|
|
1626
|
+
const result = await provider.unseen();
|
|
1627
|
+
return { unread: result.count, source: "server" };
|
|
1628
|
+
} catch {
|
|
1629
|
+
return { unread: runtime.readState(account).count(), source: "local" };
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
router.get("/accounts", () => ({ accounts: runtime.current.accounts }));
|
|
1633
|
+
router.post("/accounts", async ({ body }) => {
|
|
1634
|
+
const payload = await body();
|
|
1635
|
+
const input = payload;
|
|
1636
|
+
if (typeof input.email !== "string") throw new HttpError(400, "\u7F3A\u5C11\u90AE\u7BB1\u5730\u5740");
|
|
1637
|
+
const password = typeof input.password === "string" ? input.password : "";
|
|
1638
|
+
const config = runtime.current;
|
|
1639
|
+
const account = buildAccount(input, config.accounts);
|
|
1640
|
+
if (password === "") throw new HttpError(400, "\u8BF7\u8F93\u5165\u5BC6\u7801\u6216\u5E94\u7528\u4E13\u7528\u5BC6\u7801");
|
|
1641
|
+
if (input.connect !== false) {
|
|
1642
|
+
const provider = await verifyImap(account, password);
|
|
1643
|
+
await provider.dispose();
|
|
1644
|
+
try {
|
|
1645
|
+
await verifySmtp(account.smtp, { user: account.username, pass: password });
|
|
1646
|
+
} catch {
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
runtime.credentials.remember(account, password, input.remember !== false);
|
|
1650
|
+
const accounts = [...config.accounts, account];
|
|
1651
|
+
saveAccounts(accounts, account.id);
|
|
1652
|
+
runtime.refresh();
|
|
1653
|
+
return { account, state: appState(runtime) };
|
|
1654
|
+
});
|
|
1655
|
+
router.patch("/accounts/:id", async ({ params, body }) => {
|
|
1656
|
+
const config = runtime.current;
|
|
1657
|
+
const id = params.id ?? "";
|
|
1658
|
+
const existing = config.accounts.find((account2) => account2.id === id);
|
|
1659
|
+
if (existing === void 0) throw new HttpError(404, "\u8D26\u53F7\u4E0D\u5B58\u5728");
|
|
1660
|
+
const payload = await body();
|
|
1661
|
+
const merged = {
|
|
1662
|
+
...existing,
|
|
1663
|
+
label: typeof payload.label === "string" ? payload.label : existing.label,
|
|
1664
|
+
username: typeof payload.username === "string" ? payload.username : existing.username,
|
|
1665
|
+
imap: { ...existing.imap, ...asObject(payload.imap) },
|
|
1666
|
+
smtp: { ...existing.smtp, ...asObject(payload.smtp) },
|
|
1667
|
+
caldav: payload.caldav === null ? null : payload.caldav === void 0 ? existing.caldav : { ...asObject(payload.caldav) },
|
|
1668
|
+
enabled: payload.enabled === void 0 ? existing.enabled : payload.enabled !== false
|
|
1669
|
+
};
|
|
1670
|
+
const account = normalizeAccount(
|
|
1671
|
+
merged,
|
|
1672
|
+
0,
|
|
1673
|
+
config.accounts.map((item) => item.id)
|
|
1674
|
+
);
|
|
1675
|
+
account.id = existing.id;
|
|
1676
|
+
account.createdAt = existing.createdAt;
|
|
1677
|
+
const accounts = config.accounts.map((item) => item.id === existing.id ? account : item);
|
|
1678
|
+
saveAccounts(accounts, config.activeAccountId);
|
|
1679
|
+
runtime.invalidate(existing.id);
|
|
1680
|
+
return { account };
|
|
1681
|
+
});
|
|
1682
|
+
router.delete("/accounts/:id", ({ params }) => {
|
|
1683
|
+
const config = runtime.current;
|
|
1684
|
+
const id = params.id ?? "";
|
|
1685
|
+
const accounts = config.accounts.filter((account) => account.id !== id);
|
|
1686
|
+
if (accounts.length === config.accounts.length) throw new HttpError(404, "\u8D26\u53F7\u4E0D\u5B58\u5728");
|
|
1687
|
+
const active = config.activeAccountId === id ? accounts[0]?.id ?? null : config.activeAccountId;
|
|
1688
|
+
saveAccounts(accounts, active);
|
|
1689
|
+
runtime.invalidate(id);
|
|
1690
|
+
return { ok: true };
|
|
1691
|
+
});
|
|
1692
|
+
router.post("/accounts/:id/activate", ({ params }) => {
|
|
1693
|
+
const config = runtime.current;
|
|
1694
|
+
const id = params.id ?? "";
|
|
1695
|
+
if (!config.accounts.some((account) => account.id === id)) throw new HttpError(404, "\u8D26\u53F7\u4E0D\u5B58\u5728");
|
|
1696
|
+
saveAccounts(config.accounts, id);
|
|
1697
|
+
runtime.refresh();
|
|
1698
|
+
return { ok: true, activeAccountId: id };
|
|
1699
|
+
});
|
|
1700
|
+
router.post("/accounts/:id/login", async ({ params, body }) => {
|
|
1701
|
+
const account = accountById(runtime, params.id);
|
|
1702
|
+
const payload = await body();
|
|
1703
|
+
const password = typeof payload.password === "string" ? payload.password : "";
|
|
1704
|
+
if (password === "") throw new HttpError(400, "\u8BF7\u8F93\u5165\u5BC6\u7801\u6216\u5E94\u7528\u4E13\u7528\u5BC6\u7801");
|
|
1705
|
+
const provider = await verifyImap(account, password);
|
|
1706
|
+
await provider.dispose();
|
|
1707
|
+
const stored = runtime.credentials.remember(account, password, payload.remember !== false);
|
|
1708
|
+
return { ok: true, stored: stored.stored };
|
|
1709
|
+
});
|
|
1710
|
+
router.post("/accounts/:id/logout", ({ params }) => {
|
|
1711
|
+
const account = accountById(runtime, params.id);
|
|
1712
|
+
runtime.credentials.forget(account);
|
|
1713
|
+
runtime.invalidate(account.id);
|
|
1714
|
+
return { ok: true };
|
|
1715
|
+
});
|
|
1716
|
+
router.post("/accounts/:id/check", async ({ params }) => {
|
|
1717
|
+
const account = accountById(runtime, params.id);
|
|
1718
|
+
const provider = runtime.provider(account);
|
|
1719
|
+
const imap = await provider.check();
|
|
1720
|
+
let smtp = { ok: false };
|
|
1721
|
+
try {
|
|
1722
|
+
const credentials = runtime.credentialsFor(account);
|
|
1723
|
+
await verifySmtp(account.smtp, credentials);
|
|
1724
|
+
smtp = { ok: true };
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
smtp = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1727
|
+
}
|
|
1728
|
+
return { ok: true, imap, smtp };
|
|
1729
|
+
});
|
|
1730
|
+
router.get("/settings", () => settingsView(runtime.current));
|
|
1731
|
+
router.patch("/settings", async ({ body }) => {
|
|
1732
|
+
const payload = await body();
|
|
1733
|
+
const patch = {};
|
|
1734
|
+
if (typeof payload.signature === "string") patch.signature = payload.signature.slice(0, 5e3);
|
|
1735
|
+
if (payload.ai !== void 0) patch.ai = asObject(payload.ai);
|
|
1736
|
+
if (payload.read !== void 0) patch.read = asObject(payload.read);
|
|
1737
|
+
if (payload.render !== void 0) patch.render = asObject(payload.render);
|
|
1738
|
+
saveSettings(patch);
|
|
1739
|
+
runtime.refresh();
|
|
1740
|
+
return { settings: settingsView(runtime.current) };
|
|
1741
|
+
});
|
|
1742
|
+
router.get("/backup/status", ({ url }) => {
|
|
1743
|
+
const { account } = accountFrom(runtime, url);
|
|
1744
|
+
return runtime.backupStatus(account);
|
|
1745
|
+
});
|
|
1746
|
+
router.post("/backup/start", async ({ url }) => {
|
|
1747
|
+
const { account } = accountFrom(runtime, url);
|
|
1748
|
+
runtime.startBackup(account);
|
|
1749
|
+
return { ok: true, status: runtime.backupStatus(account) };
|
|
1750
|
+
});
|
|
1751
|
+
router.post("/backup/stop", ({ url }) => {
|
|
1752
|
+
const { account } = accountFrom(runtime, url);
|
|
1753
|
+
const stopped = runtime.stopBackup(account);
|
|
1754
|
+
return { ok: stopped, status: runtime.backupStatus(account) };
|
|
1755
|
+
});
|
|
1756
|
+
return router;
|
|
1757
|
+
}
|
|
1758
|
+
var VERSION = "0.1.0";
|
|
1759
|
+
function accountById(runtime, id) {
|
|
1760
|
+
const account = runtime.current.accounts.find((item) => item.id === id);
|
|
1761
|
+
if (account === void 0) throw new HttpError(404, "\u8D26\u53F7\u4E0D\u5B58\u5728");
|
|
1762
|
+
return account;
|
|
1763
|
+
}
|
|
1764
|
+
function accountFrom(runtime, url) {
|
|
1765
|
+
const requested = url.searchParams.get("accountId");
|
|
1766
|
+
const config = runtime.current;
|
|
1767
|
+
const account = requested === null || requested === "" || requested === "active" ? config.accounts.find((item) => item.id === config.activeAccountId) ?? config.accounts[0] : config.accounts.find((item) => item.id === requested);
|
|
1768
|
+
if (account === void 0) throw new HttpError(400, "\u8BF7\u5148\u6DFB\u52A0\u4E00\u4E2A\u90AE\u7BB1\u8D26\u53F7");
|
|
1769
|
+
return { account, config };
|
|
1770
|
+
}
|
|
1771
|
+
function sourceOf(url) {
|
|
1772
|
+
return url.searchParams.get("source") === "server" ? "server" : "local";
|
|
1773
|
+
}
|
|
1774
|
+
async function verifyImap(account, password) {
|
|
1775
|
+
const { ImapProvider: ImapProvider2 } = await Promise.resolve().then(() => (init_imap(), imap_exports));
|
|
1776
|
+
const provider = new ImapProvider2(account.imap, { user: account.username || account.email, pass: password });
|
|
1777
|
+
try {
|
|
1778
|
+
await provider.check();
|
|
1779
|
+
return provider;
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
await provider.dispose();
|
|
1782
|
+
throw new HttpError(400, `\u8FDE\u63A5\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}`);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
function appState(runtime) {
|
|
1786
|
+
const config = runtime.current;
|
|
1787
|
+
const account = config.accounts.find((item) => item.id === config.activeAccountId) ?? null;
|
|
1788
|
+
return {
|
|
1789
|
+
version: VERSION,
|
|
1790
|
+
settings: settingsView(config),
|
|
1791
|
+
accounts: config.accounts,
|
|
1792
|
+
activeAccountId: config.activeAccountId,
|
|
1793
|
+
backup: account === null ? emptyBackup() : runtime.backupStatus(account),
|
|
1794
|
+
unread: account === null ? 0 : runtime.readState(account).count(),
|
|
1795
|
+
loggedIn: account !== null && runtime.hasCredentials(account)
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
function emptyBackup() {
|
|
1799
|
+
return {
|
|
1800
|
+
running: false,
|
|
1801
|
+
accountId: null,
|
|
1802
|
+
startedAt: null,
|
|
1803
|
+
finishedAt: null,
|
|
1804
|
+
lastSuccessAt: null,
|
|
1805
|
+
total: 0,
|
|
1806
|
+
added: 0,
|
|
1807
|
+
message: "",
|
|
1808
|
+
output: [],
|
|
1809
|
+
exitCode: null
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
function settingsView(config) {
|
|
1813
|
+
return {
|
|
1814
|
+
dataDir: config.dataDir,
|
|
1815
|
+
configFile: config.configFile,
|
|
1816
|
+
signature: config.signature,
|
|
1817
|
+
accounts: config.accounts,
|
|
1818
|
+
activeAccountId: config.activeAccountId,
|
|
1819
|
+
ai: {
|
|
1820
|
+
enabled: config.ai.enabled,
|
|
1821
|
+
provider: config.ai.provider,
|
|
1822
|
+
model: config.ai.model,
|
|
1823
|
+
styleSampleCount: config.ai.styleSampleCount
|
|
1824
|
+
},
|
|
1825
|
+
read: { markOnOpen: config.read.markOnOpen, pollSeconds: config.read.pollSeconds },
|
|
1826
|
+
render: { listLimit: config.render.listLimit }
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
function localSummary(runtime, account, row) {
|
|
1830
|
+
const store = runtime.readState(account);
|
|
1831
|
+
const key = row.messageId !== void 0 && row.messageId !== "" ? `mid:${row.messageId}` : `local:${row.id}`;
|
|
1832
|
+
return { ...row, accountId: account.id, unread: store.isUnread(key), backedUp: true };
|
|
1833
|
+
}
|
|
1834
|
+
function localDetail(runtime, account, detail) {
|
|
1835
|
+
return localSummary(runtime, account, detail);
|
|
1836
|
+
}
|
|
1837
|
+
function serverSummary(account, message, mailbox, backedUp) {
|
|
1838
|
+
return {
|
|
1839
|
+
id: String(message.uid),
|
|
1840
|
+
accountId: account.id,
|
|
1841
|
+
uid: message.uid,
|
|
1842
|
+
messageId: message.messageId,
|
|
1843
|
+
subject: message.subject,
|
|
1844
|
+
from: message.from,
|
|
1845
|
+
to: message.to,
|
|
1846
|
+
date: message.date,
|
|
1847
|
+
unread: !message.read,
|
|
1848
|
+
flagged: false,
|
|
1849
|
+
hasAttachments: false,
|
|
1850
|
+
snippet: "",
|
|
1851
|
+
size: message.size,
|
|
1852
|
+
mailbox,
|
|
1853
|
+
backedUp
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
function serverDetail(runtime, account, message, mailbox) {
|
|
1857
|
+
let backedUp = false;
|
|
1858
|
+
try {
|
|
1859
|
+
backedUp = message.messageId !== "" && runtime.database(account).hasMessageId(message.messageId);
|
|
1860
|
+
} catch {
|
|
1861
|
+
backedUp = false;
|
|
1862
|
+
}
|
|
1863
|
+
return {
|
|
1864
|
+
id: String(message.uid),
|
|
1865
|
+
accountId: account.id,
|
|
1866
|
+
uid: message.uid,
|
|
1867
|
+
messageId: message.messageId,
|
|
1868
|
+
subject: message.subject,
|
|
1869
|
+
from: message.from,
|
|
1870
|
+
to: message.to,
|
|
1871
|
+
cc: message.cc,
|
|
1872
|
+
replyTo: [],
|
|
1873
|
+
date: message.date,
|
|
1874
|
+
unread: !message.read,
|
|
1875
|
+
flagged: false,
|
|
1876
|
+
hasAttachments: message.attachments.length > 0,
|
|
1877
|
+
snippet: "",
|
|
1878
|
+
size: message.size,
|
|
1879
|
+
mailbox,
|
|
1880
|
+
backedUp,
|
|
1881
|
+
text: message.text,
|
|
1882
|
+
html: message.html,
|
|
1883
|
+
attachments: message.attachments,
|
|
1884
|
+
inReplyTo: message.inReplyTo,
|
|
1885
|
+
references: message.references
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
async function listServer(runtime, account, mailbox, query, limit) {
|
|
1889
|
+
const provider = runtime.provider(account);
|
|
1890
|
+
const result = await provider.list(mailbox === "all" ? "INBOX" : mailbox, Math.min(200, limit * 2));
|
|
1891
|
+
let database = null;
|
|
1892
|
+
try {
|
|
1893
|
+
database = runtime.database(account);
|
|
1894
|
+
} catch {
|
|
1895
|
+
database = null;
|
|
1896
|
+
}
|
|
1897
|
+
const text2 = query.trim().toLowerCase();
|
|
1898
|
+
const items = result.items.filter((message) => {
|
|
1899
|
+
if (text2 === "") return true;
|
|
1900
|
+
return [message.subject, message.from.name, message.from.address, formatAddressList(message.to)].join(" ").toLowerCase().includes(text2);
|
|
1901
|
+
}).slice(0, limit).map(
|
|
1902
|
+
(message) => serverSummary(account, message, mailbox, database?.hasMessageId(message.messageId) ?? false)
|
|
1903
|
+
);
|
|
1904
|
+
return { items, total: result.total };
|
|
1905
|
+
}
|
|
1906
|
+
function writeAttachment(res, filename, contentType, data) {
|
|
1907
|
+
res.statusCode = 200;
|
|
1908
|
+
res.setHeader("content-type", contentType);
|
|
1909
|
+
res.setHeader("content-length", String(data.length));
|
|
1910
|
+
res.setHeader("content-disposition", `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
|
|
1911
|
+
res.setHeader("cache-control", "no-store");
|
|
1912
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
1913
|
+
res.end(data);
|
|
1914
|
+
}
|
|
1915
|
+
function asObject(value) {
|
|
1916
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1917
|
+
}
|
|
1918
|
+
function normalizeDraftInput(payload, accountId) {
|
|
1919
|
+
const attachments = Array.isArray(payload.attachments) ? payload.attachments.map((item) => asObject(item)).filter((item) => typeof item.filename === "string" && typeof item.data === "string").map((item) => ({
|
|
1920
|
+
filename: String(item.filename),
|
|
1921
|
+
contentType: typeof item.contentType === "string" ? item.contentType : "application/octet-stream",
|
|
1922
|
+
data: String(item.data)
|
|
1923
|
+
})) : [];
|
|
1924
|
+
return {
|
|
1925
|
+
...typeof payload.id === "string" ? { id: payload.id } : {},
|
|
1926
|
+
accountId,
|
|
1927
|
+
to: stringOf(payload.to),
|
|
1928
|
+
cc: stringOf(payload.cc),
|
|
1929
|
+
bcc: stringOf(payload.bcc),
|
|
1930
|
+
subject: stringOf(payload.subject),
|
|
1931
|
+
html: stringOf(payload.html),
|
|
1932
|
+
attachments,
|
|
1933
|
+
...typeof payload.inReplyTo === "string" ? { inReplyTo: payload.inReplyTo } : {},
|
|
1934
|
+
...typeof payload.references === "string" ? { references: payload.references } : {}
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
function stringOf(value) {
|
|
1938
|
+
return typeof value === "string" ? value : "";
|
|
1939
|
+
}
|
|
1940
|
+
async function sendFromPayload(runtime, account, payload) {
|
|
1941
|
+
const to = stringOf(payload.to);
|
|
1942
|
+
if (to.trim() === "") throw new HttpError(400, "\u6536\u4EF6\u4EBA\u4E0D\u80FD\u4E3A\u7A7A");
|
|
1943
|
+
const subject = stringOf(payload.subject);
|
|
1944
|
+
const html = stringOf(payload.html);
|
|
1945
|
+
const text2 = stringOf(payload.text) !== "" ? stringOf(payload.text) : htmlToText(html);
|
|
1946
|
+
const attachments = Array.isArray(payload.attachments) ? payload.attachments.map((item) => asObject(item)).filter((item) => typeof item.filename === "string" && typeof item.data === "string").map((item) => ({
|
|
1947
|
+
filename: String(item.filename),
|
|
1948
|
+
contentType: typeof item.contentType === "string" && item.contentType !== "" ? item.contentType : "application/octet-stream",
|
|
1949
|
+
content: Buffer.from(String(item.data), "base64")
|
|
1950
|
+
})) : [];
|
|
1951
|
+
const totalBytes = attachments.reduce((sum, file) => sum + file.content.length, 0);
|
|
1952
|
+
if (totalBytes > 15 * 1024 * 1024) throw new HttpError(413, "\u9644\u4EF6\u5408\u8BA1\u4E0D\u80FD\u8D85\u8FC7 15 MB");
|
|
1953
|
+
const { sendMail: sendMail2 } = await Promise.resolve().then(() => (init_smtp(), smtp_exports));
|
|
1954
|
+
const signature = runtime.current.signature;
|
|
1955
|
+
const bodyText = signature === "" ? text2 : `${text2}
|
|
1956
|
+
|
|
1957
|
+
--
|
|
1958
|
+
${signature}`;
|
|
1959
|
+
const bodyHtml = signature === "" ? html : `${html}<p>--</p><p>${escapeHtml(signature).replaceAll("\n", "<br>")}</p>`;
|
|
1960
|
+
const result = await sendMail2(
|
|
1961
|
+
account.smtp,
|
|
1962
|
+
runtime.credentialsFor(account),
|
|
1963
|
+
account.username || account.email,
|
|
1964
|
+
{
|
|
1965
|
+
to,
|
|
1966
|
+
cc: stringOf(payload.cc),
|
|
1967
|
+
subject,
|
|
1968
|
+
text: bodyText,
|
|
1969
|
+
html: bodyHtml,
|
|
1970
|
+
...typeof payload.inReplyTo === "string" ? { inReplyTo: payload.inReplyTo } : {},
|
|
1971
|
+
...typeof payload.references === "string" ? { references: payload.references } : {},
|
|
1972
|
+
attachments
|
|
1973
|
+
}
|
|
1974
|
+
);
|
|
1975
|
+
return { ok: true, result };
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// src/host/runtime.ts
|
|
1979
|
+
import { statSync } from "node:fs";
|
|
1980
|
+
import { join as join4 } from "node:path";
|
|
1981
|
+
|
|
1982
|
+
// src/host/secrets.ts
|
|
1983
|
+
import { spawnSync } from "node:child_process";
|
|
1984
|
+
function defaultRun(command, args, options = {}) {
|
|
1985
|
+
const result = spawnSync(command, args, {
|
|
1986
|
+
encoding: "utf8",
|
|
1987
|
+
timeout: options.timeout ?? 1e4,
|
|
1988
|
+
input: options.input,
|
|
1989
|
+
stdio: options.input === void 0 ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
|
|
1990
|
+
});
|
|
1991
|
+
return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
1992
|
+
}
|
|
1993
|
+
function defaultHasCommand(command, args) {
|
|
1994
|
+
const result = spawnSync(command, args, { timeout: 3e3, stdio: "ignore" });
|
|
1995
|
+
return result.error === void 0 && result.status === 0;
|
|
1996
|
+
}
|
|
1997
|
+
function psString(value) {
|
|
1998
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
1999
|
+
}
|
|
2000
|
+
var SecretStore = class {
|
|
2001
|
+
provider;
|
|
2002
|
+
run;
|
|
2003
|
+
hasCommand;
|
|
2004
|
+
constructor(options = {}) {
|
|
2005
|
+
const platform = options.platform ?? process.platform;
|
|
2006
|
+
this.run = options.run ?? defaultRun;
|
|
2007
|
+
this.hasCommand = options.hasCommand ?? defaultHasCommand;
|
|
2008
|
+
if (platform === "darwin") {
|
|
2009
|
+
this.provider = "macos";
|
|
2010
|
+
} else if (platform === "win32") {
|
|
2011
|
+
this.provider = this.hasCommand("powershell.exe", [
|
|
2012
|
+
"-NoProfile",
|
|
2013
|
+
"-NonInteractive",
|
|
2014
|
+
"-Command",
|
|
2015
|
+
"exit 0"
|
|
2016
|
+
]) ? "windows" : "none";
|
|
2017
|
+
} else if (platform === "linux") {
|
|
2018
|
+
this.provider = this.hasCommand("secret-tool", ["--version"]) ? "libsecret" : "none";
|
|
2019
|
+
} else {
|
|
2020
|
+
this.provider = "none";
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
available() {
|
|
2024
|
+
return this.provider !== "none";
|
|
2025
|
+
}
|
|
2026
|
+
read(service, account) {
|
|
2027
|
+
if (service === "" || account === "") return void 0;
|
|
2028
|
+
if (this.provider === "macos") {
|
|
2029
|
+
const result = this.run("security", ["find-generic-password", "-s", service, "-a", account, "-w"]);
|
|
2030
|
+
if (!result.ok) return void 0;
|
|
2031
|
+
return trimSecret(result.stdout);
|
|
2032
|
+
}
|
|
2033
|
+
if (this.provider === "libsecret") {
|
|
2034
|
+
const result = this.run("secret-tool", ["lookup", "service", service, "account", account]);
|
|
2035
|
+
if (!result.ok) return void 0;
|
|
2036
|
+
return trimSecret(result.stdout);
|
|
2037
|
+
}
|
|
2038
|
+
if (this.provider === "windows") {
|
|
2039
|
+
const script = [
|
|
2040
|
+
"$ErrorActionPreference = 'Stop'",
|
|
2041
|
+
"[Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] | Out-Null",
|
|
2042
|
+
"$vault = New-Object Windows.Security.Credentials.PasswordVault",
|
|
2043
|
+
`$resource = ${psString(service)}`,
|
|
2044
|
+
`$user = ${psString(account)}`,
|
|
2045
|
+
"$cred = $vault.FindAllByResource($resource) | Where-Object { $_.UserName -eq $user } | Select-Object -First 1",
|
|
2046
|
+
"if ($null -eq $cred) { exit 1 }",
|
|
2047
|
+
"$cred.RetrievePassword()",
|
|
2048
|
+
"[Console]::Out.Write($cred.Password)"
|
|
2049
|
+
].join("\n");
|
|
2050
|
+
const result = this.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "-"], {
|
|
2051
|
+
input: script
|
|
2052
|
+
});
|
|
2053
|
+
if (!result.ok) return void 0;
|
|
2054
|
+
return trimSecret(result.stdout);
|
|
2055
|
+
}
|
|
2056
|
+
return void 0;
|
|
2057
|
+
}
|
|
2058
|
+
write(service, account, secret) {
|
|
2059
|
+
if (service === "" || account === "" || secret === "") return false;
|
|
2060
|
+
if (this.provider === "macos") {
|
|
2061
|
+
return this.run("security", ["add-generic-password", "-U", "-s", service, "-a", account, "-w", secret]).ok;
|
|
2062
|
+
}
|
|
2063
|
+
if (this.provider === "libsecret") {
|
|
2064
|
+
return this.run("secret-tool", ["store", "--label", service, "service", service, "account", account], {
|
|
2065
|
+
input: secret
|
|
2066
|
+
}).ok;
|
|
2067
|
+
}
|
|
2068
|
+
if (this.provider === "windows") {
|
|
2069
|
+
const encoded = Buffer.from(secret, "utf8").toString("base64");
|
|
2070
|
+
const script = [
|
|
2071
|
+
"$ErrorActionPreference = 'Stop'",
|
|
2072
|
+
"[Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] | Out-Null",
|
|
2073
|
+
"$vault = New-Object Windows.Security.Credentials.PasswordVault",
|
|
2074
|
+
`$resource = ${psString(service)}`,
|
|
2075
|
+
`$user = ${psString(account)}`,
|
|
2076
|
+
`$password = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))`,
|
|
2077
|
+
"$vault.FindAllByResource($resource) | Where-Object { $_.UserName -eq $user } | ForEach-Object { $vault.Remove($_) }",
|
|
2078
|
+
"$vault.Add((New-Object Windows.Security.Credentials.PasswordCredential($resource, $user, $password)))"
|
|
2079
|
+
].join("\n");
|
|
2080
|
+
return this.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "-"], {
|
|
2081
|
+
input: script
|
|
2082
|
+
}).ok;
|
|
2083
|
+
}
|
|
2084
|
+
return false;
|
|
2085
|
+
}
|
|
2086
|
+
delete(service, account) {
|
|
2087
|
+
if (service === "" || account === "") return false;
|
|
2088
|
+
if (this.provider === "macos") {
|
|
2089
|
+
return this.run("security", ["delete-generic-password", "-s", service, "-a", account]).ok;
|
|
2090
|
+
}
|
|
2091
|
+
if (this.provider === "libsecret") {
|
|
2092
|
+
return this.run("secret-tool", ["clear", "service", service, "account", account]).ok;
|
|
2093
|
+
}
|
|
2094
|
+
if (this.provider === "windows") {
|
|
2095
|
+
const script = [
|
|
2096
|
+
"$ErrorActionPreference = 'Stop'",
|
|
2097
|
+
"[Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] | Out-Null",
|
|
2098
|
+
"$vault = New-Object Windows.Security.Credentials.PasswordVault",
|
|
2099
|
+
`$resource = ${psString(service)}`,
|
|
2100
|
+
`$user = ${psString(account)}`,
|
|
2101
|
+
"$vault.FindAllByResource($resource) | Where-Object { $_.UserName -eq $user } | ForEach-Object { $vault.Remove($_) }"
|
|
2102
|
+
].join("\n");
|
|
2103
|
+
return this.run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "-"], {
|
|
2104
|
+
input: script
|
|
2105
|
+
}).ok;
|
|
2106
|
+
}
|
|
2107
|
+
return false;
|
|
2108
|
+
}
|
|
2109
|
+
};
|
|
2110
|
+
function trimSecret(value) {
|
|
2111
|
+
const text2 = value.replace(/\r?\n$/, "");
|
|
2112
|
+
return text2 === "" ? void 0 : text2;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// src/host/accounts.ts
|
|
2116
|
+
function envKeyFor(account) {
|
|
2117
|
+
const suffix = account.id.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
|
|
2118
|
+
return `DSH_MAILHARBOR_PASSWORD_${suffix}`;
|
|
2119
|
+
}
|
|
2120
|
+
var CredentialManager = class {
|
|
2121
|
+
session = /* @__PURE__ */ new Map();
|
|
2122
|
+
secrets;
|
|
2123
|
+
constructor(secrets = new SecretStore()) {
|
|
2124
|
+
this.secrets = secrets;
|
|
2125
|
+
}
|
|
2126
|
+
get provider() {
|
|
2127
|
+
return this.secrets.provider;
|
|
2128
|
+
}
|
|
2129
|
+
resolve(account) {
|
|
2130
|
+
const session = this.session.get(account.id);
|
|
2131
|
+
if (session !== void 0 && session !== "") return { password: session, source: "session" };
|
|
2132
|
+
const fromEnv = process.env[envKeyFor(account)];
|
|
2133
|
+
if (typeof fromEnv === "string" && fromEnv !== "") return { password: fromEnv, source: "env" };
|
|
2134
|
+
const stored = this.secrets.read(account.keychainService, account.email);
|
|
2135
|
+
if (stored !== void 0) return { password: stored, source: "keychain" };
|
|
2136
|
+
return void 0;
|
|
2137
|
+
}
|
|
2138
|
+
/** Keep the password for this run and, when asked, for later runs. */
|
|
2139
|
+
remember(account, password, persist) {
|
|
2140
|
+
if (password === "") throw new Error("\u5BC6\u7801\u4E0D\u80FD\u4E3A\u7A7A");
|
|
2141
|
+
this.session.set(account.id, password);
|
|
2142
|
+
if (!persist) return { stored: false };
|
|
2143
|
+
return { stored: this.secrets.write(account.keychainService, account.email, password) };
|
|
2144
|
+
}
|
|
2145
|
+
forget(account) {
|
|
2146
|
+
this.session.delete(account.id);
|
|
2147
|
+
this.secrets.delete(account.keychainService, account.email);
|
|
2148
|
+
}
|
|
2149
|
+
status(account) {
|
|
2150
|
+
const resolved = this.resolve(account);
|
|
2151
|
+
return {
|
|
2152
|
+
hasPassword: resolved !== void 0,
|
|
2153
|
+
source: resolved?.source ?? null,
|
|
2154
|
+
provider: this.secrets.provider
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
// src/host/drafts.ts
|
|
2160
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2161
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2162
|
+
import { dirname, join as join3 } from "node:path";
|
|
2163
|
+
function text(value) {
|
|
2164
|
+
return value === void 0 || value === null ? "" : String(value);
|
|
2165
|
+
}
|
|
2166
|
+
function isAttachment(value) {
|
|
2167
|
+
return value !== null && typeof value === "object" && typeof value.filename === "string" && typeof value.data === "string";
|
|
2168
|
+
}
|
|
2169
|
+
function isDraft(value) {
|
|
2170
|
+
return value !== null && typeof value === "object" && typeof value.id === "string";
|
|
2171
|
+
}
|
|
2172
|
+
function draftPath(root) {
|
|
2173
|
+
return join3(root, "drafts.json");
|
|
2174
|
+
}
|
|
2175
|
+
var DraftStore = class {
|
|
2176
|
+
file;
|
|
2177
|
+
items = [];
|
|
2178
|
+
accountId;
|
|
2179
|
+
constructor(file, accountId) {
|
|
2180
|
+
this.file = file;
|
|
2181
|
+
this.accountId = accountId;
|
|
2182
|
+
this.load();
|
|
2183
|
+
}
|
|
2184
|
+
load() {
|
|
2185
|
+
try {
|
|
2186
|
+
const parsed = JSON.parse(readFileSync3(this.file, "utf8"));
|
|
2187
|
+
this.items = Array.isArray(parsed) ? parsed.filter(isDraft) : [];
|
|
2188
|
+
} catch {
|
|
2189
|
+
this.items = [];
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
flush() {
|
|
2193
|
+
mkdirSync3(dirname(this.file), { recursive: true });
|
|
2194
|
+
const temp = `${this.file}.${String(process.pid)}.${String(Date.now())}.tmp`;
|
|
2195
|
+
writeFileSync3(temp, `${JSON.stringify(this.items, null, 2)}
|
|
2196
|
+
`, { encoding: "utf8", mode: 384 });
|
|
2197
|
+
renameSync2(temp, this.file);
|
|
2198
|
+
}
|
|
2199
|
+
list() {
|
|
2200
|
+
return [...this.items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).map((draft) => ({ ...draft }));
|
|
2201
|
+
}
|
|
2202
|
+
get(id) {
|
|
2203
|
+
const found = this.items.find((draft) => draft.id === id);
|
|
2204
|
+
return found === void 0 ? void 0 : { ...found };
|
|
2205
|
+
}
|
|
2206
|
+
save(input) {
|
|
2207
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2208
|
+
const existing = input.id === void 0 ? void 0 : this.items.find((draft2) => draft2.id === input.id);
|
|
2209
|
+
const fields = {
|
|
2210
|
+
to: text(input.to),
|
|
2211
|
+
cc: text(input.cc),
|
|
2212
|
+
bcc: text(input.bcc),
|
|
2213
|
+
subject: text(input.subject),
|
|
2214
|
+
html: text(input.html),
|
|
2215
|
+
attachments: Array.isArray(input.attachments) ? input.attachments.filter(isAttachment).map((file) => ({
|
|
2216
|
+
filename: text(file.filename).slice(0, 200),
|
|
2217
|
+
contentType: text(file.contentType).slice(0, 160),
|
|
2218
|
+
data: text(file.data)
|
|
2219
|
+
})) : [],
|
|
2220
|
+
inReplyTo: text(input.inReplyTo),
|
|
2221
|
+
references: text(input.references)
|
|
2222
|
+
};
|
|
2223
|
+
if (existing !== void 0) {
|
|
2224
|
+
Object.assign(existing, fields, { updatedAt: now });
|
|
2225
|
+
this.flush();
|
|
2226
|
+
return { ...existing };
|
|
2227
|
+
}
|
|
2228
|
+
const draft = {
|
|
2229
|
+
id: randomUUID2(),
|
|
2230
|
+
accountId: this.accountId,
|
|
2231
|
+
...fields,
|
|
2232
|
+
updatedAt: now
|
|
2233
|
+
};
|
|
2234
|
+
this.items.push(draft);
|
|
2235
|
+
this.flush();
|
|
2236
|
+
return { ...draft };
|
|
2237
|
+
}
|
|
2238
|
+
remove(id) {
|
|
2239
|
+
const index = this.items.findIndex((draft) => draft.id === id);
|
|
2240
|
+
if (index < 0) return false;
|
|
2241
|
+
this.items.splice(index, 1);
|
|
2242
|
+
this.flush();
|
|
2243
|
+
return true;
|
|
2244
|
+
}
|
|
2245
|
+
location() {
|
|
2246
|
+
return this.file;
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
|
|
2250
|
+
// src/host/runtime.ts
|
|
2251
|
+
init_imap();
|
|
2252
|
+
|
|
2253
|
+
// src/host/read-state.ts
|
|
2254
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2255
|
+
import { dirname as dirname2 } from "node:path";
|
|
2256
|
+
var ReadStateStore = class {
|
|
2257
|
+
file;
|
|
2258
|
+
keys = null;
|
|
2259
|
+
constructor(file) {
|
|
2260
|
+
this.file = file;
|
|
2261
|
+
}
|
|
2262
|
+
load() {
|
|
2263
|
+
if (this.keys !== null) return this.keys;
|
|
2264
|
+
try {
|
|
2265
|
+
const parsed = JSON.parse(readFileSync4(this.file, "utf8"));
|
|
2266
|
+
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.unread) ? parsed.unread : [];
|
|
2267
|
+
this.keys = new Set(list.filter((key) => typeof key === "string" && key !== ""));
|
|
2268
|
+
} catch {
|
|
2269
|
+
this.keys = /* @__PURE__ */ new Set();
|
|
2270
|
+
}
|
|
2271
|
+
return this.keys;
|
|
2272
|
+
}
|
|
2273
|
+
flush() {
|
|
2274
|
+
mkdirSync4(dirname2(this.file), { recursive: true });
|
|
2275
|
+
const temp = `${this.file}.${String(process.pid)}.tmp`;
|
|
2276
|
+
writeFileSync4(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
|
|
2277
|
+
`, {
|
|
2278
|
+
encoding: "utf8",
|
|
2279
|
+
mode: 384
|
|
2280
|
+
});
|
|
2281
|
+
renameSync3(temp, this.file);
|
|
2282
|
+
}
|
|
2283
|
+
isUnread(key) {
|
|
2284
|
+
return key !== "" && this.load().has(key);
|
|
2285
|
+
}
|
|
2286
|
+
setUnread(key, unread) {
|
|
2287
|
+
const normalized = key.trim();
|
|
2288
|
+
if (normalized === "") return false;
|
|
2289
|
+
const keys = this.load();
|
|
2290
|
+
const has = keys.has(normalized);
|
|
2291
|
+
if (unread && !has) {
|
|
2292
|
+
keys.add(normalized);
|
|
2293
|
+
this.flush();
|
|
2294
|
+
return true;
|
|
2295
|
+
}
|
|
2296
|
+
if (!unread && has) {
|
|
2297
|
+
keys.delete(normalized);
|
|
2298
|
+
this.flush();
|
|
2299
|
+
return true;
|
|
2300
|
+
}
|
|
2301
|
+
return false;
|
|
2302
|
+
}
|
|
2303
|
+
sync(entries) {
|
|
2304
|
+
let changed = false;
|
|
2305
|
+
const keys = this.load();
|
|
2306
|
+
for (const entry of entries) {
|
|
2307
|
+
const key = String(entry.key ?? "").trim();
|
|
2308
|
+
if (key === "") continue;
|
|
2309
|
+
if (entry.unread && !keys.has(key)) {
|
|
2310
|
+
keys.add(key);
|
|
2311
|
+
changed = true;
|
|
2312
|
+
} else if (!entry.unread && keys.has(key)) {
|
|
2313
|
+
keys.delete(key);
|
|
2314
|
+
changed = true;
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
if (changed) this.flush();
|
|
2318
|
+
}
|
|
2319
|
+
count() {
|
|
2320
|
+
return this.load().size;
|
|
2321
|
+
}
|
|
2322
|
+
list() {
|
|
2323
|
+
return [...this.load()];
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
|
|
2327
|
+
// src/host/runtime.ts
|
|
2328
|
+
var MAX_OUTPUT_LINES = 200;
|
|
2329
|
+
var MailHarborRuntime = class {
|
|
2330
|
+
config;
|
|
2331
|
+
configStamp = 0;
|
|
2332
|
+
credentials;
|
|
2333
|
+
providers = /* @__PURE__ */ new Map();
|
|
2334
|
+
databases = /* @__PURE__ */ new Map();
|
|
2335
|
+
draftStores = /* @__PURE__ */ new Map();
|
|
2336
|
+
readStores = /* @__PURE__ */ new Map();
|
|
2337
|
+
backups = /* @__PURE__ */ new Map();
|
|
2338
|
+
disposed = false;
|
|
2339
|
+
constructor(options = {}) {
|
|
2340
|
+
this.config = loadConfig();
|
|
2341
|
+
this.credentials = options.credentials ?? new CredentialManager();
|
|
2342
|
+
}
|
|
2343
|
+
get current() {
|
|
2344
|
+
return this.config;
|
|
2345
|
+
}
|
|
2346
|
+
/** Re-read the config file when it changed on disk. */
|
|
2347
|
+
refresh() {
|
|
2348
|
+
if (this.disposed) return this.config;
|
|
2349
|
+
let stamp3 = 0;
|
|
2350
|
+
try {
|
|
2351
|
+
const stats = statSync(this.config.configFile);
|
|
2352
|
+
stamp3 = stats.mtimeMs;
|
|
2353
|
+
} catch {
|
|
2354
|
+
stamp3 = 0;
|
|
2355
|
+
}
|
|
2356
|
+
if (stamp3 !== this.configStamp) {
|
|
2357
|
+
this.configStamp = stamp3;
|
|
2358
|
+
this.config = loadConfig();
|
|
2359
|
+
this.prune();
|
|
2360
|
+
}
|
|
2361
|
+
return this.config;
|
|
2362
|
+
}
|
|
2363
|
+
prune() {
|
|
2364
|
+
const live = new Set(this.config.accounts.map((account) => account.id));
|
|
2365
|
+
for (const id of [...this.providers.keys()]) {
|
|
2366
|
+
if (!live.has(id)) {
|
|
2367
|
+
void this.providers.get(id)?.value.dispose();
|
|
2368
|
+
this.providers.delete(id);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
for (const id of [...this.databases.keys()]) {
|
|
2372
|
+
if (!live.has(id)) {
|
|
2373
|
+
this.databases.get(id)?.value.close();
|
|
2374
|
+
this.databases.delete(id);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
for (const id of [...this.draftStores.keys()]) if (!live.has(id)) this.draftStores.delete(id);
|
|
2378
|
+
for (const id of [...this.readStores.keys()]) if (!live.has(id)) this.readStores.delete(id);
|
|
2379
|
+
}
|
|
2380
|
+
/** Drop cached connections and stores for one account after a settings change. */
|
|
2381
|
+
invalidate(accountId) {
|
|
2382
|
+
void this.providers.get(accountId)?.value.dispose();
|
|
2383
|
+
this.providers.delete(accountId);
|
|
2384
|
+
this.databases.get(accountId)?.value.close();
|
|
2385
|
+
this.databases.delete(accountId);
|
|
2386
|
+
this.draftStores.delete(accountId);
|
|
2387
|
+
this.readStores.delete(accountId);
|
|
2388
|
+
this.refresh();
|
|
2389
|
+
}
|
|
2390
|
+
active() {
|
|
2391
|
+
return activeAccount(this.config);
|
|
2392
|
+
}
|
|
2393
|
+
account(id) {
|
|
2394
|
+
return requireAccount(this.config, id);
|
|
2395
|
+
}
|
|
2396
|
+
paths(account) {
|
|
2397
|
+
return accountRuntime(account);
|
|
2398
|
+
}
|
|
2399
|
+
/** Credentials for an account, with a clear message when the user must log in. */
|
|
2400
|
+
credentialsFor(account) {
|
|
2401
|
+
if (account.auth.type === "oauth2") {
|
|
2402
|
+
throw new Error("OAuth2 \u8D26\u53F7\u6682\u672A\u5728\u6B64\u7248\u672C\u542F\u7528\uFF0C\u8BF7\u4F7F\u7528\u5E94\u7528\u4E13\u7528\u5BC6\u7801\u767B\u5F55");
|
|
2403
|
+
}
|
|
2404
|
+
const resolved = this.credentials.resolve(account);
|
|
2405
|
+
if (resolved === void 0) throw new Error("\u8BF7\u5148\u767B\u5F55\u90AE\u7BB1\u8D26\u53F7");
|
|
2406
|
+
return { user: account.username || account.email, pass: resolved.password };
|
|
2407
|
+
}
|
|
2408
|
+
hasCredentials(account) {
|
|
2409
|
+
return this.credentials.resolve(account) !== void 0;
|
|
2410
|
+
}
|
|
2411
|
+
imapSettings(account) {
|
|
2412
|
+
return account.imap;
|
|
2413
|
+
}
|
|
2414
|
+
provider(account) {
|
|
2415
|
+
const signature = `${account.imap.host}:${String(account.imap.port)}:${String(account.imap.secure)}:${account.username}:${account.auth.type}`;
|
|
2416
|
+
const existing = this.providers.get(account.id);
|
|
2417
|
+
if (existing !== void 0 && existing.signature === signature) return existing.value;
|
|
2418
|
+
if (existing !== void 0) void existing.value.dispose();
|
|
2419
|
+
const created = new ImapProvider(account.imap, this.credentialsFor(account), {
|
|
2420
|
+
onReconnect: () => {
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
this.providers.set(account.id, { signature, value: created });
|
|
2424
|
+
return created;
|
|
2425
|
+
}
|
|
2426
|
+
database(account) {
|
|
2427
|
+
const root = this.paths(account);
|
|
2428
|
+
const existing = this.databases.get(account.id);
|
|
2429
|
+
if (existing !== void 0 && existing.signature === root.mailDb) return existing.value;
|
|
2430
|
+
if (existing !== void 0) existing.value.close();
|
|
2431
|
+
const created = new MailDatabase(root.mailDb, {
|
|
2432
|
+
maxBodyChars: 4e5,
|
|
2433
|
+
attachmentsRoot: root.attachments
|
|
2434
|
+
});
|
|
2435
|
+
this.databases.set(account.id, { signature: root.mailDb, value: created });
|
|
2436
|
+
return created;
|
|
2437
|
+
}
|
|
2438
|
+
drafts(account) {
|
|
2439
|
+
const file = draftPath(this.paths(account).root);
|
|
2440
|
+
const existing = this.draftStores.get(account.id);
|
|
2441
|
+
if (existing !== void 0 && existing.signature === file) return existing.value;
|
|
2442
|
+
const created = new DraftStore(file, account.id);
|
|
2443
|
+
this.draftStores.set(account.id, { signature: file, value: created });
|
|
2444
|
+
return created;
|
|
2445
|
+
}
|
|
2446
|
+
readState(account) {
|
|
2447
|
+
const file = join4(this.paths(account).root, "read-state.json");
|
|
2448
|
+
const existing = this.readStores.get(account.id);
|
|
2449
|
+
if (existing !== void 0 && existing.signature === file) return existing.value;
|
|
2450
|
+
const created = new ReadStateStore(file);
|
|
2451
|
+
this.readStores.set(account.id, { signature: file, value: created });
|
|
2452
|
+
return created;
|
|
2453
|
+
}
|
|
2454
|
+
backupStatus(account) {
|
|
2455
|
+
const job = this.backups.get(account.id);
|
|
2456
|
+
if (job !== void 0) return { ...job.status, output: [...job.status.output] };
|
|
2457
|
+
const persisted = BackupEngine.status(this.paths(account).mailDb);
|
|
2458
|
+
return { ...persisted, accountId: account.id };
|
|
2459
|
+
}
|
|
2460
|
+
/** Start an incremental backup; returns the job so the caller can stream output. */
|
|
2461
|
+
startBackup(account, onEvent) {
|
|
2462
|
+
const running = this.backups.get(account.id);
|
|
2463
|
+
if (running !== void 0 && running.status.running) throw new Error("\u5907\u4EFD\u5DF2\u5728\u8FDB\u884C\u4E2D");
|
|
2464
|
+
const controller = new AbortController();
|
|
2465
|
+
const status = {
|
|
2466
|
+
running: true,
|
|
2467
|
+
accountId: account.id,
|
|
2468
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2469
|
+
finishedAt: null,
|
|
2470
|
+
lastSuccessAt: null,
|
|
2471
|
+
total: 0,
|
|
2472
|
+
added: 0,
|
|
2473
|
+
message: "\u51C6\u5907\u5907\u4EFD",
|
|
2474
|
+
output: [],
|
|
2475
|
+
exitCode: null
|
|
2476
|
+
};
|
|
2477
|
+
const job = { status, controller, promise: Promise.resolve() };
|
|
2478
|
+
const engine = new BackupEngine(accountRuntime(account), this.credentialsFor(account));
|
|
2479
|
+
job.promise = (async () => {
|
|
2480
|
+
try {
|
|
2481
|
+
const result = await engine.run({
|
|
2482
|
+
signal: controller.signal,
|
|
2483
|
+
onEvent: (event) => {
|
|
2484
|
+
status.message = event.message;
|
|
2485
|
+
if (event.mailbox !== void 0) status.message = event.message;
|
|
2486
|
+
if (event.added !== void 0) status.added = event.added;
|
|
2487
|
+
if (event.total !== void 0) status.total = event.total;
|
|
2488
|
+
status.output.push(event.message);
|
|
2489
|
+
if (status.output.length > MAX_OUTPUT_LINES)
|
|
2490
|
+
status.output.splice(0, status.output.length - MAX_OUTPUT_LINES);
|
|
2491
|
+
onEvent?.(event);
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
status.total = result.total;
|
|
2495
|
+
status.added = result.newCount;
|
|
2496
|
+
status.exitCode = 0;
|
|
2497
|
+
status.message = `\u5907\u4EFD\u5B8C\u6210\uFF1A\u65B0\u589E ${String(result.newCount)} \u5C01\uFF0C\u5171 ${String(result.total)} \u5C01`;
|
|
2498
|
+
} catch (error) {
|
|
2499
|
+
status.exitCode = 1;
|
|
2500
|
+
status.message = error instanceof Error ? error.message : String(error);
|
|
2501
|
+
status.output.push(status.message);
|
|
2502
|
+
} finally {
|
|
2503
|
+
status.running = false;
|
|
2504
|
+
status.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2505
|
+
if (status.exitCode === 0) status.lastSuccessAt = status.finishedAt;
|
|
2506
|
+
this.databases.get(account.id)?.value.close();
|
|
2507
|
+
this.databases.delete(account.id);
|
|
2508
|
+
}
|
|
2509
|
+
})();
|
|
2510
|
+
this.backups.set(account.id, job);
|
|
2511
|
+
return job;
|
|
2512
|
+
}
|
|
2513
|
+
stopBackup(account) {
|
|
2514
|
+
const job = this.backups.get(account.id);
|
|
2515
|
+
if (job === void 0 || !job.status.running) return false;
|
|
2516
|
+
job.controller.abort();
|
|
2517
|
+
return true;
|
|
2518
|
+
}
|
|
2519
|
+
async dispose() {
|
|
2520
|
+
this.disposed = true;
|
|
2521
|
+
for (const job of this.backups.values()) job.controller.abort();
|
|
2522
|
+
for (const provider of this.providers.values()) await provider.value.dispose();
|
|
2523
|
+
this.providers.clear();
|
|
2524
|
+
for (const db of this.databases.values()) db.value.close();
|
|
2525
|
+
this.databases.clear();
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
|
|
2529
|
+
// src/host/tools.ts
|
|
2530
|
+
init_address();
|
|
2531
|
+
var MAX_BODY = 2e4;
|
|
2532
|
+
var MAX_LIST = 50;
|
|
2533
|
+
function textOf(args, key) {
|
|
2534
|
+
if (args === null || typeof args !== "object") return "";
|
|
2535
|
+
const value = args[key];
|
|
2536
|
+
return typeof value === "string" ? value : "";
|
|
2537
|
+
}
|
|
2538
|
+
function numberOf(args, key) {
|
|
2539
|
+
if (args === null || typeof args !== "object") return void 0;
|
|
2540
|
+
const value = args[key];
|
|
2541
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
2542
|
+
}
|
|
2543
|
+
function clip(text2, max = MAX_BODY) {
|
|
2544
|
+
return text2.length <= max ? text2 : `${text2.slice(0, max)}
|
|
2545
|
+
\u2026\uFF08\u5DF2\u622A\u65AD\uFF0C\u5171 ${text2.length} \u5B57\u7B26\uFF09`;
|
|
2546
|
+
}
|
|
2547
|
+
function line(value) {
|
|
2548
|
+
return value.replace(/\s+/g, " ").trim();
|
|
2549
|
+
}
|
|
2550
|
+
function stamp2(value) {
|
|
2551
|
+
return value.slice(0, 19).replace("T", " ");
|
|
2552
|
+
}
|
|
2553
|
+
function summaryLine(item, index) {
|
|
2554
|
+
const flags = [
|
|
2555
|
+
item.backedUp === false ? "\u672A\u5907\u4EFD" : item.backedUp === true ? "\u5DF2\u5907\u4EFD" : "",
|
|
2556
|
+
item.hasAttachments ? "\u6709\u9644\u4EF6" : "",
|
|
2557
|
+
item.unread ? "\u672A\u8BFB" : ""
|
|
2558
|
+
].filter(Boolean);
|
|
2559
|
+
return `${String(index + 1)}. [id=${item.id}] ${stamp2(item.date)} \xB7 ${line(item.from.name || item.from.address)} \xB7 ${line(item.subject)}${flags.length === 0 ? "" : ` \xB7 ${flags.join(" \xB7 ")}`}
|
|
2560
|
+
${item.snippet}`;
|
|
2561
|
+
}
|
|
2562
|
+
function plainSchema(properties, required = []) {
|
|
2563
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
2564
|
+
}
|
|
2565
|
+
function textResult(_label, schema = { type: "string" }) {
|
|
2566
|
+
return {
|
|
2567
|
+
schema,
|
|
2568
|
+
render: (_args, value) => [
|
|
2569
|
+
{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value) }
|
|
2570
|
+
]
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
function tool(name2, description, parameters, output, execute) {
|
|
2574
|
+
return {
|
|
2575
|
+
name: name2,
|
|
2576
|
+
description,
|
|
2577
|
+
parameters,
|
|
2578
|
+
output,
|
|
2579
|
+
timeoutMs: 12e4,
|
|
2580
|
+
isConcurrencySafe: () => true,
|
|
2581
|
+
presentCall: (args) => ({ card: "generic", title: name2, kind: "read", rawInput: args }),
|
|
2582
|
+
execute: async (args) => execute(args)
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
function registerMailTools(tools, runtime) {
|
|
2586
|
+
const activeAccount2 = () => {
|
|
2587
|
+
const account = runtime.active();
|
|
2588
|
+
if (account === null) throw new Error("\u8BF7\u5148\u5728 MailHarbor \u9762\u677F\u91CC\u6DFB\u52A0\u4E00\u4E2A\u90AE\u7BB1\u8D26\u53F7");
|
|
2589
|
+
return account;
|
|
2590
|
+
};
|
|
2591
|
+
const definitions = [
|
|
2592
|
+
tool(
|
|
2593
|
+
"mail_status",
|
|
2594
|
+
"\u67E5\u770B\u90AE\u7BB1\u72B6\u6001\uFF1A\u8D26\u53F7\u3001\u662F\u5426\u5DF2\u767B\u5F55\u3001\u672C\u5730\u5907\u4EFD\u90AE\u4EF6\u6570\u3001\u4E0A\u6B21\u5907\u4EFD\u65F6\u95F4\u3002\u53EA\u8BFB\uFF0C\u4E0D\u4FEE\u6539\u4EFB\u4F55\u5185\u5BB9\u3002",
|
|
2595
|
+
plainSchema({}),
|
|
2596
|
+
textResult("\u72B6\u6001", {
|
|
2597
|
+
type: "object",
|
|
2598
|
+
properties: {
|
|
2599
|
+
account: { type: "string" },
|
|
2600
|
+
loggedIn: { type: "boolean" },
|
|
2601
|
+
localTotal: { type: "number" },
|
|
2602
|
+
lastBackup: { type: "string" },
|
|
2603
|
+
backupRunning: { type: "boolean" }
|
|
2604
|
+
},
|
|
2605
|
+
required: ["account", "loggedIn", "localTotal"]
|
|
2606
|
+
}),
|
|
2607
|
+
async () => {
|
|
2608
|
+
const account = activeAccount2();
|
|
2609
|
+
const status = runtime.database(account).status();
|
|
2610
|
+
const backup = runtime.backupStatus(account);
|
|
2611
|
+
return {
|
|
2612
|
+
account: account.email,
|
|
2613
|
+
loggedIn: runtime.hasCredentials(account),
|
|
2614
|
+
localTotal: status.total,
|
|
2615
|
+
lastBackup: backup.lastSuccessAt ?? "",
|
|
2616
|
+
backupRunning: backup.running
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
),
|
|
2620
|
+
tool(
|
|
2621
|
+
"mail_list",
|
|
2622
|
+
"\u5217\u51FA\u672C\u5730\u5907\u4EFD\u6216\u670D\u52A1\u5668\u4E0A\u7684\u90AE\u4EF6\u6458\u8981\u3002source=local \u8BFB\u672C\u5730\u5907\u4EFD\uFF08\u5FEB\u3001\u53EF\u641C\u7D22\uFF09\uFF0Csource=server \u8BFB\u670D\u52A1\u5668\u5B9E\u65F6\u3002",
|
|
2623
|
+
plainSchema({
|
|
2624
|
+
source: { type: "string", enum: ["local", "server"], description: "\u9ED8\u8BA4 local" },
|
|
2625
|
+
mailbox: { type: "string", description: "\u90AE\u7BB1\u6587\u4EF6\u5939\u8DEF\u5F84\uFF0C\u9ED8\u8BA4 all\uFF08\u672C\u5730\uFF09\u6216 INBOX\uFF08\u670D\u52A1\u5668\uFF09" },
|
|
2626
|
+
query: { type: "string", description: "\u5173\u952E\u8BCD\uFF0C\u4EC5\u672C\u5730\u641C\u7D22" },
|
|
2627
|
+
limit: { type: "number", description: "\u6700\u591A\u8FD4\u56DE\u591A\u5C11\u5C01\uFF0C\u9ED8\u8BA4 20\uFF0C\u6700\u5927 50" }
|
|
2628
|
+
}),
|
|
2629
|
+
textResult("\u90AE\u4EF6\u5217\u8868", {
|
|
2630
|
+
type: "object",
|
|
2631
|
+
properties: {
|
|
2632
|
+
source: { type: "string" },
|
|
2633
|
+
total: { type: "number" },
|
|
2634
|
+
items: {
|
|
2635
|
+
type: "array",
|
|
2636
|
+
items: {
|
|
2637
|
+
type: "object",
|
|
2638
|
+
properties: {
|
|
2639
|
+
id: { type: "string" },
|
|
2640
|
+
subject: { type: "string" },
|
|
2641
|
+
from: { type: "string" },
|
|
2642
|
+
date: { type: "string" },
|
|
2643
|
+
unread: { type: "boolean" },
|
|
2644
|
+
backedUp: { type: "boolean" }
|
|
2645
|
+
},
|
|
2646
|
+
required: ["id", "subject", "from", "date", "unread", "backedUp"]
|
|
2647
|
+
}
|
|
2648
|
+
},
|
|
2649
|
+
text: { type: "string" }
|
|
2650
|
+
},
|
|
2651
|
+
required: ["source", "total", "items", "text"]
|
|
2652
|
+
}),
|
|
2653
|
+
async (args) => {
|
|
2654
|
+
const account = activeAccount2();
|
|
2655
|
+
const source = textOf(args, "source") === "server" ? "server" : "local";
|
|
2656
|
+
const mailbox = textOf(args, "mailbox") !== "" ? textOf(args, "mailbox") : source === "server" ? "INBOX" : "all";
|
|
2657
|
+
const limit = Math.min(MAX_LIST, Math.max(1, numberOf(args, "limit") ?? 20));
|
|
2658
|
+
const query = textOf(args, "query");
|
|
2659
|
+
let items = [];
|
|
2660
|
+
let total = 0;
|
|
2661
|
+
if (source === "server") {
|
|
2662
|
+
const provider = runtime.provider(account);
|
|
2663
|
+
const result = await provider.list(mailbox, limit);
|
|
2664
|
+
total = result.total;
|
|
2665
|
+
const db = runtime.database(account);
|
|
2666
|
+
items = result.items.map((message) => ({
|
|
2667
|
+
id: String(message.uid),
|
|
2668
|
+
accountId: account.id,
|
|
2669
|
+
uid: message.uid,
|
|
2670
|
+
messageId: message.messageId,
|
|
2671
|
+
subject: message.subject,
|
|
2672
|
+
from: message.from,
|
|
2673
|
+
to: message.to,
|
|
2674
|
+
date: message.date,
|
|
2675
|
+
unread: !message.read,
|
|
2676
|
+
flagged: false,
|
|
2677
|
+
hasAttachments: false,
|
|
2678
|
+
snippet: "",
|
|
2679
|
+
mailbox,
|
|
2680
|
+
backedUp: message.messageId !== "" && db.hasMessageId(message.messageId)
|
|
2681
|
+
}));
|
|
2682
|
+
} else {
|
|
2683
|
+
const page = runtime.database(account).list({
|
|
2684
|
+
mailbox,
|
|
2685
|
+
query,
|
|
2686
|
+
limit,
|
|
2687
|
+
offset: 0
|
|
2688
|
+
});
|
|
2689
|
+
total = page.total;
|
|
2690
|
+
const readState = runtime.readState(account);
|
|
2691
|
+
items = page.items.map((item) => ({
|
|
2692
|
+
...item,
|
|
2693
|
+
accountId: account.id,
|
|
2694
|
+
unread: item.messageId !== void 0 && item.messageId !== "" ? readState.isUnread(`mid:${item.messageId}`) : readState.isUnread(`local:${item.id}`),
|
|
2695
|
+
backedUp: true
|
|
2696
|
+
}));
|
|
2697
|
+
}
|
|
2698
|
+
const head = `${source === "server" ? "\u670D\u52A1\u5668\u5B9E\u65F6" : "\u672C\u5730\u5907\u4EFD"} \xB7 ${mailbox} \xB7 ${String(items.length)}/${String(total)} \u5C01`;
|
|
2699
|
+
return {
|
|
2700
|
+
source,
|
|
2701
|
+
total,
|
|
2702
|
+
items: items.map((item) => ({
|
|
2703
|
+
id: item.id,
|
|
2704
|
+
subject: item.subject,
|
|
2705
|
+
from: item.from.name || item.from.address,
|
|
2706
|
+
date: stamp2(item.date),
|
|
2707
|
+
unread: item.unread,
|
|
2708
|
+
backedUp: item.backedUp ?? true
|
|
2709
|
+
})),
|
|
2710
|
+
text: items.length === 0 ? `${head}
|
|
2711
|
+
\uFF08\u6CA1\u6709\u90AE\u4EF6\uFF09` : `${head}
|
|
2712
|
+
|
|
2713
|
+
${items.map(summaryLine).join("\n")}`
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2716
|
+
),
|
|
2717
|
+
tool(
|
|
2718
|
+
"mail_read",
|
|
2719
|
+
"\u8BFB\u53D6\u4E00\u5C01\u90AE\u4EF6\u7684\u5168\u6587\u3002\u672C\u5730\u5907\u4EFD\u7528 id\uFF1B\u670D\u52A1\u5668\u90AE\u4EF6\u7528 uid \u5E76\u4F20 source=server\u3001mailbox\u3002",
|
|
2720
|
+
plainSchema(
|
|
2721
|
+
{
|
|
2722
|
+
id: { type: "string", description: "\u672C\u5730\u90AE\u4EF6 id \u6216\u670D\u52A1\u5668 uid" },
|
|
2723
|
+
source: { type: "string", enum: ["local", "server"], description: "\u9ED8\u8BA4 local" },
|
|
2724
|
+
mailbox: { type: "string", description: "\u670D\u52A1\u5668\u6587\u4EF6\u5939\u8DEF\u5F84\uFF0C\u9ED8\u8BA4 INBOX" }
|
|
2725
|
+
},
|
|
2726
|
+
["id"]
|
|
2727
|
+
),
|
|
2728
|
+
textResult("\u90AE\u4EF6\u5168\u6587", {
|
|
2729
|
+
type: "object",
|
|
2730
|
+
properties: {
|
|
2731
|
+
subject: { type: "string" },
|
|
2732
|
+
from: { type: "string" },
|
|
2733
|
+
date: { type: "string" },
|
|
2734
|
+
text: { type: "string" },
|
|
2735
|
+
attachments: { type: "array", items: { type: "object" } }
|
|
2736
|
+
},
|
|
2737
|
+
required: ["subject", "from", "date", "text", "attachments"]
|
|
2738
|
+
}),
|
|
2739
|
+
async (args) => {
|
|
2740
|
+
const account = activeAccount2();
|
|
2741
|
+
const id = textOf(args, "id");
|
|
2742
|
+
if (id === "") throw new Error("\u7F3A\u5C11\u90AE\u4EF6 id");
|
|
2743
|
+
const source = textOf(args, "source") === "server" ? "server" : "local";
|
|
2744
|
+
if (source === "server") {
|
|
2745
|
+
const mailbox = textOf(args, "mailbox") !== "" ? textOf(args, "mailbox") : "INBOX";
|
|
2746
|
+
const message = await runtime.provider(account).read(mailbox, Number(id));
|
|
2747
|
+
const detail2 = {
|
|
2748
|
+
...message,
|
|
2749
|
+
id: String(message.uid),
|
|
2750
|
+
accountId: account.id,
|
|
2751
|
+
cc: message.cc,
|
|
2752
|
+
replyTo: [],
|
|
2753
|
+
unread: !message.read,
|
|
2754
|
+
flagged: false,
|
|
2755
|
+
hasAttachments: message.attachments.length > 0,
|
|
2756
|
+
snippet: "",
|
|
2757
|
+
mailbox,
|
|
2758
|
+
backedUp: message.messageId !== "" && runtime.database(account).hasMessageId(message.messageId)
|
|
2759
|
+
};
|
|
2760
|
+
return {
|
|
2761
|
+
subject: detail2.subject,
|
|
2762
|
+
from: `${detail2.from.name} <${detail2.from.address}>`.trim(),
|
|
2763
|
+
date: stamp2(detail2.date),
|
|
2764
|
+
text: clip(detail2.text),
|
|
2765
|
+
attachments: detail2.attachments.map((file) => ({
|
|
2766
|
+
filename: file.filename,
|
|
2767
|
+
contentType: file.contentType,
|
|
2768
|
+
size: file.size
|
|
2769
|
+
}))
|
|
2770
|
+
};
|
|
2771
|
+
}
|
|
2772
|
+
const detail = runtime.database(account).get(Number(id));
|
|
2773
|
+
if (detail === void 0) throw new Error("\u90AE\u4EF6\u4E0D\u5B58\u5728");
|
|
2774
|
+
return {
|
|
2775
|
+
subject: detail.subject,
|
|
2776
|
+
from: `${detail.from.name} <${detail.from.address}>`.trim(),
|
|
2777
|
+
date: stamp2(detail.date),
|
|
2778
|
+
text: clip(detail.text),
|
|
2779
|
+
attachments: detail.attachments.map((file) => ({
|
|
2780
|
+
filename: file.filename,
|
|
2781
|
+
contentType: file.contentType,
|
|
2782
|
+
size: file.size
|
|
2783
|
+
}))
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
),
|
|
2787
|
+
tool(
|
|
2788
|
+
"mail_search",
|
|
2789
|
+
"\u5728\u672C\u5730\u5907\u4EFD\u4E2D\u5168\u6587\u641C\u7D22\uFF08\u4E3B\u9898\u3001\u53D1\u4EF6\u4EBA\u3001\u6536\u4EF6\u4EBA\u3001\u6B63\u6587\u3001\u9644\u4EF6\u540D\uFF09\u3002\u53EA\u8BFB\u3002",
|
|
2790
|
+
plainSchema(
|
|
2791
|
+
{
|
|
2792
|
+
query: { type: "string", description: "\u641C\u7D22\u5173\u952E\u8BCD" },
|
|
2793
|
+
limit: { type: "number", description: "\u6700\u591A\u8FD4\u56DE\u591A\u5C11\u5C01\uFF0C\u9ED8\u8BA4 20" }
|
|
2794
|
+
},
|
|
2795
|
+
["query"]
|
|
2796
|
+
),
|
|
2797
|
+
textResult("\u641C\u7D22\u7ED3\u679C"),
|
|
2798
|
+
async (args) => {
|
|
2799
|
+
const account = activeAccount2();
|
|
2800
|
+
const query = textOf(args, "query");
|
|
2801
|
+
if (query === "") throw new Error("\u7F3A\u5C11\u641C\u7D22\u5173\u952E\u8BCD");
|
|
2802
|
+
const limit = Math.min(MAX_LIST, Math.max(1, numberOf(args, "limit") ?? 20));
|
|
2803
|
+
const page = runtime.database(account).list({ mailbox: "all", query, limit, offset: 0 });
|
|
2804
|
+
const readState = runtime.readState(account);
|
|
2805
|
+
return page.items.length === 0 ? `\u6CA1\u6709\u5339\u914D\u300C${query}\u300D\u7684\u90AE\u4EF6` : `\u641C\u7D22\u300C${query}\u300D\uFF1A${String(page.total)} \u5C01\u4E2D\u524D ${String(page.items.length)} \u5C01
|
|
2806
|
+
|
|
2807
|
+
${page.items.map(
|
|
2808
|
+
(item, index) => summaryLine(
|
|
2809
|
+
{
|
|
2810
|
+
...item,
|
|
2811
|
+
accountId: account.id,
|
|
2812
|
+
unread: readState.isUnread(`local:${item.id}`),
|
|
2813
|
+
backedUp: true
|
|
2814
|
+
},
|
|
2815
|
+
index
|
|
2816
|
+
)
|
|
2817
|
+
).join("\n")}`;
|
|
2818
|
+
}
|
|
2819
|
+
),
|
|
2820
|
+
tool(
|
|
2821
|
+
"mail_drafts",
|
|
2822
|
+
"\u5217\u51FA\u5F53\u524D\u8D26\u53F7\u7684\u8349\u7A3F\u3002\u53EA\u8BFB\uFF0C\u4E0D\u4F1A\u53D1\u9001\u3002",
|
|
2823
|
+
plainSchema({}),
|
|
2824
|
+
textResult("\u8349\u7A3F\u5217\u8868", {
|
|
2825
|
+
type: "object",
|
|
2826
|
+
properties: { drafts: { type: "array", items: { type: "object" } }, text: { type: "string" } },
|
|
2827
|
+
required: ["drafts", "text"]
|
|
2828
|
+
}),
|
|
2829
|
+
async () => {
|
|
2830
|
+
const account = activeAccount2();
|
|
2831
|
+
const drafts = runtime.drafts(account).list().map((draft) => ({
|
|
2832
|
+
id: draft.id,
|
|
2833
|
+
subject: draft.subject,
|
|
2834
|
+
to: draft.to,
|
|
2835
|
+
updatedAt: draft.updatedAt
|
|
2836
|
+
}));
|
|
2837
|
+
return {
|
|
2838
|
+
drafts,
|
|
2839
|
+
text: drafts.length === 0 ? "\uFF08\u6CA1\u6709\u8349\u7A3F\uFF09" : drafts.map(
|
|
2840
|
+
(draft, index) => `${String(index + 1)}. [${draft.id}] ${stamp2(draft.updatedAt)} \xB7 \u2192 ${line(draft.to)} \xB7 ${line(draft.subject)}`
|
|
2841
|
+
).join("\n")
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
),
|
|
2845
|
+
tool(
|
|
2846
|
+
"mail_draft",
|
|
2847
|
+
"\u65B0\u5EFA\u6216\u66F4\u65B0\u4E00\u5C01\u8349\u7A3F\u3002\u53EA\u5199\u672C\u5730\u8349\u7A3F\uFF0C\u4E0D\u4F1A\u53D1\u9001\uFF1B\u53D1\u9001\u4ECD\u9700\u7528\u6237\u5728\u9762\u677F\u91CC\u786E\u8BA4\u3002",
|
|
2848
|
+
plainSchema(
|
|
2849
|
+
{
|
|
2850
|
+
id: { type: "string", description: "\u66F4\u65B0\u5DF2\u6709\u8349\u7A3F\u65F6\u4F20\u5165" },
|
|
2851
|
+
to: { type: "string" },
|
|
2852
|
+
cc: { type: "string" },
|
|
2853
|
+
bcc: { type: "string" },
|
|
2854
|
+
subject: { type: "string" },
|
|
2855
|
+
html: { type: "string", description: "\u6B63\u6587 HTML" },
|
|
2856
|
+
inReplyTo: { type: "string" },
|
|
2857
|
+
references: { type: "string" }
|
|
2858
|
+
},
|
|
2859
|
+
["subject"]
|
|
2860
|
+
),
|
|
2861
|
+
textResult("\u8349\u7A3F", {
|
|
2862
|
+
type: "object",
|
|
2863
|
+
properties: { id: { type: "string" }, subject: { type: "string" }, updatedAt: { type: "string" } },
|
|
2864
|
+
required: ["id", "subject", "updatedAt"]
|
|
2865
|
+
}),
|
|
2866
|
+
async (args) => {
|
|
2867
|
+
const account = activeAccount2();
|
|
2868
|
+
const draft = runtime.drafts(account).save({
|
|
2869
|
+
...textOf(args, "id") !== "" ? { id: textOf(args, "id") } : {},
|
|
2870
|
+
to: textOf(args, "to"),
|
|
2871
|
+
cc: textOf(args, "cc"),
|
|
2872
|
+
bcc: textOf(args, "bcc"),
|
|
2873
|
+
subject: textOf(args, "subject"),
|
|
2874
|
+
html: textOf(args, "html"),
|
|
2875
|
+
inReplyTo: textOf(args, "inReplyTo"),
|
|
2876
|
+
references: textOf(args, "references")
|
|
2877
|
+
});
|
|
2878
|
+
return { id: draft.id, subject: draft.subject, updatedAt: draft.updatedAt };
|
|
2879
|
+
}
|
|
2880
|
+
),
|
|
2881
|
+
tool(
|
|
2882
|
+
"mail_backup",
|
|
2883
|
+
"\u89E6\u53D1\u4E00\u6B21\u589E\u91CF\u5907\u4EFD\uFF08\u540E\u53F0\u6267\u884C\uFF09\u3002\u5907\u4EFD\u4F1A\u8BFB\u53D6\u670D\u52A1\u5668\u4E0A\u7684\u5168\u90E8\u90AE\u4EF6\uFF0C\u53EF\u80FD\u6301\u7EED\u51E0\u5206\u949F\uFF1B\u53EA\u5728\u7528\u6237\u660E\u786E\u8981\u6C42\u65F6\u8C03\u7528\u3002",
|
|
2884
|
+
plainSchema({}),
|
|
2885
|
+
textResult("\u5907\u4EFD\u72B6\u6001", {
|
|
2886
|
+
type: "object",
|
|
2887
|
+
properties: { started: { type: "boolean" }, message: { type: "string" } },
|
|
2888
|
+
required: ["started", "message"]
|
|
2889
|
+
}),
|
|
2890
|
+
async () => {
|
|
2891
|
+
const account = activeAccount2();
|
|
2892
|
+
try {
|
|
2893
|
+
runtime.startBackup(account);
|
|
2894
|
+
return { started: true, message: "\u5907\u4EFD\u5DF2\u5728\u540E\u53F0\u5F00\u59CB\uFF0C\u8FDB\u5EA6\u53EF\u5728\u9762\u677F\u7684\u5907\u4EFD\u5F39\u7A97\u67E5\u770B" };
|
|
2895
|
+
} catch (error) {
|
|
2896
|
+
return { started: false, message: error instanceof Error ? error.message : String(error) };
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
)
|
|
2900
|
+
];
|
|
2901
|
+
return definitions.map((definition) => tools.register(definition));
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// src/host/index.ts
|
|
2905
|
+
var name = "mailharbor";
|
|
2906
|
+
var inject = ["webServer", "tools"];
|
|
2907
|
+
var VERSION2 = "0.2.0";
|
|
2908
|
+
var ROUTE = "/mail-plugin";
|
|
2909
|
+
function apply(ctx) {
|
|
2910
|
+
const runtime = new MailHarborRuntime();
|
|
2911
|
+
const handler = createApiHandler(runtime);
|
|
2912
|
+
const disposers = [];
|
|
2913
|
+
const start = () => {
|
|
2914
|
+
disposers.push(ctx.webServer.register({ kind: "prefix", path: ROUTE, handler }));
|
|
2915
|
+
if (ctx.tools !== void 0) disposers.push(...registerMailTools(ctx.tools, runtime));
|
|
2916
|
+
};
|
|
2917
|
+
const stop = () => {
|
|
2918
|
+
for (const dispose of disposers.splice(0)) {
|
|
2919
|
+
try {
|
|
2920
|
+
dispose();
|
|
2921
|
+
} catch {
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
void runtime.dispose();
|
|
2925
|
+
};
|
|
2926
|
+
if (typeof ctx.effect === "function") {
|
|
2927
|
+
ctx.effect(() => {
|
|
2928
|
+
start();
|
|
2929
|
+
return stop;
|
|
2930
|
+
}, "mailharbor: routes and tools");
|
|
2931
|
+
} else {
|
|
2932
|
+
start();
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
export {
|
|
2936
|
+
VERSION2 as VERSION,
|
|
2937
|
+
apply,
|
|
2938
|
+
inject,
|
|
2939
|
+
name
|
|
2940
|
+
};
|
|
2941
|
+
//# sourceMappingURL=index.js.map
|