@zaclinley/dsh-mailharbor 0.12.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -66,6 +66,44 @@ var init_address = __esm({
66
66
  }
67
67
  });
68
68
 
69
+ // src/host/inline-images.ts
70
+ function normalize(value) {
71
+ return value.trim().replace(/^<|>$/g, "").toLowerCase();
72
+ }
73
+ function dataUrl(image) {
74
+ if (image.data === void 0 || !image.contentType.toLowerCase().startsWith("image/")) return null;
75
+ if (image.data.byteLength > MAX_INLINE_BYTES) return null;
76
+ return `data:${image.contentType};base64,${Buffer.from(image.data).toString("base64")}`;
77
+ }
78
+ function inlineCidImages(html, images) {
79
+ if (!/cid:/i.test(html)) return html;
80
+ const byCid = /* @__PURE__ */ new Map();
81
+ const byName = [];
82
+ for (const image of images) {
83
+ const url = dataUrl(image);
84
+ if (url === null) continue;
85
+ if (image.cid !== void 0 && image.cid.trim() !== "") byCid.set(normalize(image.cid), url);
86
+ if (image.filename !== void 0 && image.filename.trim() !== "") {
87
+ byName.push({ name: normalize(image.filename), url });
88
+ }
89
+ }
90
+ if (byCid.size === 0 && byName.length === 0) return html;
91
+ return html.replace(/cid:([^"'\s)>]+)/gi, (match, rawCid) => {
92
+ const cid = normalize(rawCid);
93
+ const exact = byCid.get(cid);
94
+ if (exact !== void 0) return exact;
95
+ const named = byName.find((entry) => cid.includes(entry.name));
96
+ return named === void 0 ? match : named.url;
97
+ });
98
+ }
99
+ var MAX_INLINE_BYTES;
100
+ var init_inline_images = __esm({
101
+ "src/host/inline-images.ts"() {
102
+ "use strict";
103
+ MAX_INLINE_BYTES = 2e6;
104
+ }
105
+ });
106
+
69
107
  // src/host/smtp.ts
70
108
  var smtp_exports = {};
71
109
  __export(smtp_exports, {
@@ -221,6 +259,7 @@ var init_imap = __esm({
221
259
  "src/host/imap.ts"() {
222
260
  "use strict";
223
261
  init_address();
262
+ init_inline_images();
224
263
  ImapProvider = class _ImapProvider {
225
264
  settings;
226
265
  credentials;
@@ -451,10 +490,19 @@ var init_imap = __esm({
451
490
  const parsed = await simpleParser2(message.source);
452
491
  const base = toMessage(message);
453
492
  const html = typeof parsed.html === "string" ? parsed.html : Buffer.isBuffer(parsed.html) ? parsed.html.toString("utf8") : null;
493
+ const inlineImages = (parsed.attachments ?? []).filter((attachment) => {
494
+ const type = String(attachment.contentType ?? "").toLowerCase();
495
+ return type.startsWith("image/") && attachment.content !== void 0;
496
+ }).map((attachment) => ({
497
+ cid: attachment.cid,
498
+ filename: attachment.filename,
499
+ contentType: String(attachment.contentType ?? "image/png"),
500
+ data: Buffer.isBuffer(attachment.content) ? attachment.content : new Uint8Array(attachment.content)
501
+ }));
454
502
  const detail = {
455
503
  ...base,
456
504
  text: typeof parsed.text === "string" ? parsed.text : "",
457
- html: html === "" ? null : html,
505
+ html: html === null || html === "" ? null : inlineCidImages(html, inlineImages),
458
506
  attachments: attachmentList(parsed),
459
507
  inReplyTo: normalizeId2(parsed.inReplyTo),
460
508
  references: String(parsed.references ?? "").split(/\s+/).map(normalizeId2).filter((item) => item !== "")
@@ -513,6 +561,28 @@ var init_imap = __esm({
513
561
  }
514
562
  });
515
563
  }
564
+ async setFlag(mailbox, uid, flag, value) {
565
+ return this.run(async (client) => {
566
+ const lock = await client.getMailboxLock(mailbox);
567
+ try {
568
+ if (value) await client.messageFlagsAdd(String(uid), [flag], { uid: true });
569
+ else await client.messageFlagsRemove(String(uid), [flag], { uid: true });
570
+ } finally {
571
+ lock.release();
572
+ }
573
+ });
574
+ }
575
+ /** Move one message to another mailbox (server-side). */
576
+ async move(mailbox, uid, target) {
577
+ return this.run(async (client) => {
578
+ const lock = await client.getMailboxLock(mailbox);
579
+ try {
580
+ await client.messageMove(String(uid), target, { uid: true });
581
+ } finally {
582
+ lock.release();
583
+ }
584
+ });
585
+ }
516
586
  async setSeen(mailbox, uid, seen) {
517
587
  this.pageCache.clear();
518
588
  return this.run(async (client) => {
@@ -681,6 +751,7 @@ function normalizeAccount(input, index = 0, existing = []) {
681
751
  dataDir: stringOr(input.dataDir, `accounts/${id}`),
682
752
  auth: input.auth?.type === "oauth2" ? { type: "oauth2", provider: input.auth.provider } : { type: "password" },
683
753
  caldav: normalizeCaldav(input.caldav),
754
+ signature: stringOr(input.signature, "").slice(0, 5e3),
684
755
  enabled: input.enabled !== false,
685
756
  createdAt: stringOr(input.createdAt, (/* @__PURE__ */ new Date()).toISOString())
686
757
  };
@@ -920,6 +991,21 @@ async function openPath(path) {
920
991
  await run("xdg-open", [path]);
921
992
  }
922
993
 
994
+ // src/host/api.ts
995
+ import { existsSync as existsSync6, rmSync } from "node:fs";
996
+
997
+ // src/host/mail-write.ts
998
+ import { DatabaseSync } from "node:sqlite";
999
+ function moveLocalMessage(file, id, mailbox) {
1000
+ const db = new DatabaseSync(file);
1001
+ try {
1002
+ db.exec("PRAGMA busy_timeout = 5000");
1003
+ db.prepare("UPDATE emails SET mailbox = ? WHERE id = ?").run(mailbox, id);
1004
+ } finally {
1005
+ db.close();
1006
+ }
1007
+ }
1008
+
923
1009
  // src/host/backup.ts
924
1010
  init_address();
925
1011
  import { createHash as createHash2 } from "node:crypto";
@@ -927,12 +1013,13 @@ import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as wr
927
1013
  import { join as join3 } from "node:path";
928
1014
  import { ImapFlow } from "imapflow";
929
1015
  import { simpleParser } from "mailparser";
930
- import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
1016
+ import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
931
1017
 
932
1018
  // src/host/mail-db.ts
933
1019
  init_address();
1020
+ init_inline_images();
934
1021
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
935
- import { DatabaseSync } from "node:sqlite";
1022
+ import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
936
1023
  var SCHEMA = `
937
1024
  CREATE TABLE IF NOT EXISTS emails (
938
1025
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -990,7 +1077,7 @@ var FTS = `CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5(
990
1077
  content='', tokenize='trigram'
991
1078
  )`;
992
1079
  function ensureMailSchema(file) {
993
- const db = new DatabaseSync(file);
1080
+ const db = new DatabaseSync2(file);
994
1081
  try {
995
1082
  db.exec("PRAGMA journal_mode=WAL");
996
1083
  db.exec(SCHEMA);
@@ -1029,7 +1116,7 @@ var MailDatabase = class {
1029
1116
  open() {
1030
1117
  if (this.db !== void 0) return this.db;
1031
1118
  if (!existsSync2(this.file)) throw new Error(`\u90AE\u4EF6\u6570\u636E\u5E93\u4E0D\u5B58\u5728: ${this.file}`);
1032
- this.db = new DatabaseSync(this.file, { readOnly: true });
1119
+ this.db = new DatabaseSync2(this.file, { readOnly: true });
1033
1120
  this.db.exec("PRAGMA busy_timeout = 3000");
1034
1121
  return this.db;
1035
1122
  }
@@ -1204,6 +1291,50 @@ var MailDatabase = class {
1204
1291
  const rows = db.prepare(`${columns} FROM emails e ${clause} ORDER BY e.date DESC, e.id DESC LIMIT ?`).all(...params, limit);
1205
1292
  return { items: rows.map((row) => rowToSummary(row)), total, offset: 0, limit };
1206
1293
  }
1294
+ /** Frequent senders and recipients, for compose autocomplete. */
1295
+ contacts(query = "", limit = 50) {
1296
+ const text2 = query.trim().toLowerCase();
1297
+ const counts = /* @__PURE__ */ new Map();
1298
+ const add = (name2, address) => {
1299
+ const key = address.toLowerCase();
1300
+ if (key === "" || !key.includes("@")) return;
1301
+ const existing = counts.get(key);
1302
+ if (existing === void 0) counts.set(key, { name: name2, address, count: 1 });
1303
+ else {
1304
+ existing.count += 1;
1305
+ if (existing.name === "" && name2 !== "") existing.name = name2;
1306
+ }
1307
+ };
1308
+ try {
1309
+ const db = this.open();
1310
+ for (const row of db.prepare(
1311
+ `SELECT from_name AS name, from_addr AS address FROM emails
1312
+ WHERE from_addr <> '' AND from_addr LIKE '%@%' ORDER BY date DESC LIMIT 3000`
1313
+ ).all()) {
1314
+ add(String(row.name ?? ""), String(row.address ?? ""));
1315
+ }
1316
+ for (const row of db.prepare(
1317
+ `SELECT to_addr AS address, cc AS copies FROM emails
1318
+ WHERE (to_addr <> '' OR cc <> '') ORDER BY date DESC LIMIT 1500`
1319
+ ).all()) {
1320
+ for (const entry of parseAddressList(String(row.address ?? ""))) add(entry.name, entry.address);
1321
+ for (const entry of parseAddressList(String(row.copies ?? ""))) add(entry.name, entry.address);
1322
+ }
1323
+ } catch {
1324
+ return [];
1325
+ }
1326
+ return [...counts.values()].filter(
1327
+ (entry) => text2 === "" || entry.address.toLowerCase().includes(text2) || entry.name.toLowerCase().includes(text2)
1328
+ ).sort((left, right) => right.count - left.count).slice(0, limit);
1329
+ }
1330
+ /** All archived messages in the same conversation (normalized subject). */
1331
+ thread(subject, limit = 200) {
1332
+ const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
1333
+ const base = normalize2(subject);
1334
+ if (base === "") return [];
1335
+ const page = this.list({ mailbox: "all", query: base, limit: Math.min(500, limit * 2) });
1336
+ return page.items.filter((item) => normalize2(item.subject) === base).slice(0, limit);
1337
+ }
1207
1338
  /** Number of unread keys that still exist in this backup. */
1208
1339
  countUnread(keys) {
1209
1340
  const mids = keys.mids.slice(0, 500);
@@ -1234,9 +1365,14 @@ var MailDatabase = class {
1234
1365
  FROM emails WHERE id = ?`
1235
1366
  ).get(localId);
1236
1367
  if (row === void 0) return void 0;
1237
- const cidText = String(row.body_html ?? "").toLowerCase();
1368
+ const bodyHtml = String(row.body_html ?? "");
1369
+ const cidText = bodyHtml.toLowerCase();
1370
+ const wantsInline = cidText.includes("cid:");
1238
1371
  const attachmentColumns = this.hasAttachmentColumns() ? "id, filename, content_type, size, content_id, disposition" : "id, filename, content_type, size";
1239
- const attachments = db.prepare(`SELECT ${attachmentColumns} FROM attachments WHERE email_id = ? ORDER BY id`).all(localId).filter((item) => {
1372
+ const rows = db.prepare(
1373
+ `SELECT ${attachmentColumns}${wantsInline ? ", data, hash" : ""} FROM attachments WHERE email_id = ? ORDER BY id`
1374
+ ).all(localId);
1375
+ const attachments = rows.filter((item) => {
1240
1376
  const type = String(item.content_type ?? "").toLowerCase();
1241
1377
  if (!type.startsWith("image/")) return true;
1242
1378
  const disposition = String(item.disposition ?? "").toLowerCase();
@@ -1255,17 +1391,38 @@ var MailDatabase = class {
1255
1391
  }));
1256
1392
  const max = this.options.maxBodyChars ?? 4e5;
1257
1393
  const bodyText2 = String(row.body_text ?? "");
1258
- const bodyHtml = String(row.body_html ?? "");
1394
+ const inlineImages = wantsInline ? rows.filter(
1395
+ (item) => String(item.content_type ?? "").toLowerCase().startsWith("image/")
1396
+ ).map((item) => ({
1397
+ cid: item.content_id === null || item.content_id === void 0 ? void 0 : String(item.content_id),
1398
+ filename: String(item.filename ?? ""),
1399
+ contentType: String(item.content_type ?? "image/png"),
1400
+ data: this.attachmentBytes(item)
1401
+ })).filter((item) => item.data !== void 0) : [];
1259
1402
  return {
1260
1403
  ...rowToSummary(row),
1261
1404
  cc: parseAddressList(String(row.cc ?? "")),
1262
1405
  replyTo: [],
1263
1406
  text: truncate(bodyText2, max),
1264
- html: bodyHtml === "" ? null : truncate(bodyHtml, max * 2),
1407
+ html: bodyHtml === "" ? null : truncate(inlineCidImages(bodyHtml, inlineImages), max * 2),
1265
1408
  attachments,
1266
1409
  references: []
1267
1410
  };
1268
1411
  }
1412
+ attachmentBytes(row) {
1413
+ const data = row.data;
1414
+ if (data instanceof Uint8Array) return data;
1415
+ const hash = typeof row.hash === "string" ? row.hash : "";
1416
+ const root = this.options.attachmentsRoot ?? "";
1417
+ if (hash === "" || root === "") return void 0;
1418
+ const file = `${root}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`;
1419
+ if (!existsSync2(file)) return void 0;
1420
+ try {
1421
+ return readFileSyncSafe(file) ?? void 0;
1422
+ } catch {
1423
+ return void 0;
1424
+ }
1425
+ }
1269
1426
  /** Read one attachment's bytes; the payload may live in a file (content-addressed). */
1270
1427
  attachment(emailId, attachmentId) {
1271
1428
  const db = this.open();
@@ -1462,7 +1619,7 @@ var BackupEngine = class {
1462
1619
  }
1463
1620
  openDb() {
1464
1621
  ensureMailSchema(this.runtime.mailDb);
1465
- const db = new DatabaseSync2(this.runtime.mailDb);
1622
+ const db = new DatabaseSync3(this.runtime.mailDb);
1466
1623
  db.exec("PRAGMA journal_mode=WAL");
1467
1624
  db.exec("PRAGMA busy_timeout=10000");
1468
1625
  db.exec("PRAGMA synchronous=NORMAL");
@@ -1714,7 +1871,7 @@ var BackupEngine = class {
1714
1871
  };
1715
1872
  if (!existsSync3(dbFile)) return empty;
1716
1873
  try {
1717
- const db = new DatabaseSync2(dbFile, { readOnly: true });
1874
+ const db = new DatabaseSync3(dbFile, { readOnly: true });
1718
1875
  try {
1719
1876
  const latest = db.prepare("SELECT * FROM backup_logs ORDER BY id DESC LIMIT 1").get();
1720
1877
  const success = db.prepare("SELECT completed_at FROM backup_logs WHERE status = 'completed' ORDER BY id DESC LIMIT 1").get();
@@ -2590,7 +2747,7 @@ ${conversation.trim()}
2590
2747
  }
2591
2748
 
2592
2749
  // src/host/version.ts
2593
- var VERSION = "0.12.2";
2750
+ var VERSION = "0.13.1";
2594
2751
 
2595
2752
  // src/host/api.ts
2596
2753
  init_smtp();
@@ -2719,6 +2876,102 @@ function buildRouter(runtime) {
2719
2876
  }
2720
2877
  throw new HttpError(400, "\u65E0\u6CD5\u5B9A\u4F4D\u8FD9\u5C01\u90AE\u4EF6");
2721
2878
  });
2879
+ router.post("/messages/:id/star", async ({ url, params, body }) => {
2880
+ const payload = await body();
2881
+ const { account } = accountFrom(runtime, url);
2882
+ const detail = await loadDetail(
2883
+ runtime,
2884
+ account,
2885
+ sourceOfPayload(payload),
2886
+ String(params.id ?? ""),
2887
+ mailboxOfPayload(payload)
2888
+ );
2889
+ const starred = payload.starred !== false;
2890
+ const key = messageKeyOf(detail);
2891
+ runtime.flags(account).set(key, starred);
2892
+ if (detail.uid !== void 0) {
2893
+ try {
2894
+ await runtime.provider(account).setFlag(detail.mailbox, detail.uid, "\\Flagged", starred);
2895
+ } catch {
2896
+ }
2897
+ }
2898
+ return { starred };
2899
+ });
2900
+ router.post("/messages/:id/move", async ({ url, params, body }) => {
2901
+ const payload = await body();
2902
+ const { account } = accountFrom(runtime, url);
2903
+ const target = String(payload.target ?? "").trim();
2904
+ if (target === "") throw new HttpError(400, "\u7F3A\u5C11\u76EE\u6807\u6587\u4EF6\u5939");
2905
+ const detail = runtime.database(account).get(Number(params.id));
2906
+ if (detail === void 0) throw new HttpError(404, "\u90AE\u4EF6\u4E0D\u5B58\u5728");
2907
+ if (detail.uid !== void 0) {
2908
+ try {
2909
+ await runtime.provider(account).move(detail.mailbox, detail.uid, target);
2910
+ } catch {
2911
+ }
2912
+ }
2913
+ moveLocalMessage(runtime.paths(account).mailDb, Number(params.id), target);
2914
+ return { ok: true, mailbox: target };
2915
+ });
2916
+ router.post("/messages/bulk", async ({ body }) => {
2917
+ const payload = await body();
2918
+ const account = accountFromPayload(runtime, payload);
2919
+ const action = String(payload.action ?? "");
2920
+ const ids = Array.isArray(payload.ids) ? payload.ids.map((id) => Number(id)) : [];
2921
+ if (ids.length === 0) return { ok: true, count: 0 };
2922
+ const database = runtime.database(account);
2923
+ const flags = runtime.flags(account);
2924
+ const readState = runtime.readState(account);
2925
+ let count = 0;
2926
+ for (const id of ids) {
2927
+ const detail = database.get(id);
2928
+ if (detail === void 0) continue;
2929
+ const key = messageKeyOf(detail);
2930
+ if (action === "read" || action === "unread") {
2931
+ readState.setUnread(key, action === "unread");
2932
+ if (detail.uid !== void 0) {
2933
+ try {
2934
+ await runtime.provider(account).setSeen(detail.mailbox, detail.uid, action === "read");
2935
+ } catch {
2936
+ }
2937
+ }
2938
+ count += 1;
2939
+ } else if (action === "archive" || action === "delete" || action === "move") {
2940
+ const target = String(payload.target ?? "").trim();
2941
+ if (target === "") continue;
2942
+ if (detail.uid !== void 0) {
2943
+ try {
2944
+ await runtime.provider(account).move(detail.mailbox, detail.uid, target);
2945
+ } catch {
2946
+ }
2947
+ }
2948
+ moveLocalMessage(runtime.paths(account).mailDb, Number(detail.id), target);
2949
+ count += 1;
2950
+ } else if (action === "star" || action === "unstar") {
2951
+ const starred = action === "star";
2952
+ flags.set(key, starred);
2953
+ if (detail.uid !== void 0) {
2954
+ try {
2955
+ await runtime.provider(account).setFlag(detail.mailbox, detail.uid, "\\Flagged", starred);
2956
+ } catch {
2957
+ }
2958
+ }
2959
+ count += 1;
2960
+ }
2961
+ }
2962
+ return { ok: true, count };
2963
+ });
2964
+ router.get("/messages/thread", ({ url }) => {
2965
+ const { account } = accountFrom(runtime, url);
2966
+ const subject = url.searchParams.get("subject") ?? "";
2967
+ const items = runtime.database(account).thread(subject);
2968
+ return { items: items.map((item) => localSummary(runtime, account, item)) };
2969
+ });
2970
+ router.get("/contacts", ({ url }) => {
2971
+ const { account } = accountFrom(runtime, url);
2972
+ const query = url.searchParams.get("q") ?? "";
2973
+ return { items: runtime.database(account).contacts(query, 50) };
2974
+ });
2722
2975
  router.get("/messages/:id", async ({ url, params }) => {
2723
2976
  const { account } = accountFrom(runtime, url);
2724
2977
  const source = sourceOf(url);
@@ -2841,6 +3094,7 @@ function buildRouter(runtime) {
2841
3094
  imap: { ...existing.imap, ...asObject(payload.imap) },
2842
3095
  smtp: { ...existing.smtp, ...asObject(payload.smtp) },
2843
3096
  caldav: payload.caldav === null ? null : payload.caldav === void 0 ? existing.caldav : { ...asObject(payload.caldav) },
3097
+ signature: typeof payload.signature === "string" ? payload.signature.slice(0, 5e3) : existing.signature,
2844
3098
  enabled: payload.enabled === void 0 ? existing.enabled : payload.enabled !== false
2845
3099
  };
2846
3100
  const account = normalizeAccount(
@@ -3249,17 +3503,48 @@ function buildRouter(runtime) {
3249
3503
  if (project === void 0) throw new HttpError(404, "\u9879\u76EE\u4E0D\u5B58\u5728");
3250
3504
  return { project: projectView(project) };
3251
3505
  });
3252
- router.delete("/projects/:id", ({ params }) => {
3506
+ router.delete("/projects/:id", async ({ params, url }) => {
3253
3507
  const id = String(params.id ?? "");
3254
3508
  const project = runtime.projects().get(id);
3255
3509
  if (project === void 0) throw new HttpError(404, "\u9879\u76EE\u4E0D\u5B58\u5728");
3510
+ const warnings = [];
3256
3511
  const removed = runtime.projects().remove(id);
3257
3512
  const unlinked = runtime.tasks().clearProject(id);
3513
+ let workspaceRemoved = false;
3514
+ if (project.workspaceId !== "" && runtime.workspaceRegistry?.delete !== void 0) {
3515
+ try {
3516
+ workspaceRemoved = await runtime.workspaceRegistry.delete(project.workspaceId);
3517
+ } catch (error) {
3518
+ warnings.push(`\u79FB\u9664\u5DE5\u4F5C\u7A7A\u95F4\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
3519
+ }
3520
+ } else if (project.workspaceId !== "") {
3521
+ warnings.push("\u5F53\u524D DSH \u7EC4\u5408\u4E0D\u652F\u6301\u81EA\u52A8\u79FB\u9664\u5DE5\u4F5C\u7A7A\u95F4\uFF0C\u8BF7\u5728\u4FA7\u680F\u624B\u52A8\u5220\u9664");
3522
+ }
3523
+ let filesRemoved = false;
3524
+ if (url.searchParams.get("files") === "1") {
3525
+ try {
3526
+ const root = runtime.current.projects.root;
3527
+ const insideRoot = project.path === root || project.path.startsWith(`${root}/`);
3528
+ const marker = existsSync6(`${project.path}/project.json`);
3529
+ if (!insideRoot && !marker) {
3530
+ warnings.push("\u9879\u76EE\u76EE\u5F55\u4E0D\u5728\u9ED8\u8BA4\u9879\u76EE\u6839\u76EE\u5F55\u4E0B\uFF0C\u51FA\u4E8E\u5B89\u5168\u8003\u8651\u672A\u5220\u9664\u6587\u4EF6");
3531
+ } else if (project.path === root || project.path === "/") {
3532
+ warnings.push("\u62D2\u7EDD\u5220\u9664\u9879\u76EE\u6839\u76EE\u5F55");
3533
+ } else {
3534
+ rmSync(project.path, { recursive: true, force: true });
3535
+ filesRemoved = true;
3536
+ }
3537
+ } catch (error) {
3538
+ warnings.push(`\u5220\u9664\u6587\u4EF6\u5939\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
3539
+ }
3540
+ }
3258
3541
  return {
3259
3542
  ok: removed,
3260
3543
  path: project.path,
3261
3544
  unlinkedTasks: unlinked,
3262
- workspaceKept: project.workspaceId !== ""
3545
+ workspaceRemoved,
3546
+ filesRemoved,
3547
+ warnings
3263
3548
  };
3264
3549
  });
3265
3550
  router.post("/projects/:id/open", async ({ params }) => {
@@ -3507,10 +3792,19 @@ function unreadKeys(runtime, account) {
3507
3792
  }
3508
3793
  return { mids, ids };
3509
3794
  }
3795
+ function messageKeyOf(row) {
3796
+ return row.messageId !== void 0 && row.messageId !== "" ? `mid:${row.messageId}` : `local:${row.id}`;
3797
+ }
3510
3798
  function localSummary(runtime, account, row) {
3511
3799
  const store = runtime.readState(account);
3512
- const key = row.messageId !== void 0 && row.messageId !== "" ? `mid:${row.messageId}` : `local:${row.id}`;
3513
- return { ...row, accountId: account.id, unread: store.isUnread(key), backedUp: true };
3800
+ const key = messageKeyOf(row);
3801
+ return {
3802
+ ...row,
3803
+ accountId: account.id,
3804
+ unread: store.isUnread(key),
3805
+ starred: runtime.flags(account).has(key),
3806
+ backedUp: true
3807
+ };
3514
3808
  }
3515
3809
  function localDetail(runtime, account, detail) {
3516
3810
  return localSummary(runtime, account, detail);
@@ -3632,7 +3926,7 @@ async function sendFromPayload(runtime, account, payload) {
3632
3926
  const totalBytes = attachments.reduce((sum, file) => sum + file.content.length, 0);
3633
3927
  if (totalBytes > 15 * 1024 * 1024) throw new HttpError(413, "\u9644\u4EF6\u5408\u8BA1\u4E0D\u80FD\u8D85\u8FC7 15 MB");
3634
3928
  const { sendMail: sendMail2 } = await Promise.resolve().then(() => (init_smtp(), smtp_exports));
3635
- const signature = runtime.current.signature;
3929
+ const signature = (account.signature ?? "").trim() !== "" ? String(account.signature).trim() : runtime.current.signature;
3636
3930
  const bodyText2 = signature === "" ? text2 : `${text2}
3637
3931
 
3638
3932
  --
@@ -3848,7 +4142,7 @@ var CredentialManager = class {
3848
4142
  };
3849
4143
 
3850
4144
  // src/host/ai-store.ts
3851
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync5 } from "node:fs";
4145
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync5 } from "node:fs";
3852
4146
  import { dirname as dirname2 } from "node:path";
3853
4147
  var MAX_THREADS = 50;
3854
4148
  var MAX_CARDS_PER_THREAD = 50;
@@ -3863,7 +4157,7 @@ var AiThreadStore = class {
3863
4157
  if (this.cache !== null) return this.cache;
3864
4158
  let parsed = null;
3865
4159
  try {
3866
- if (existsSync6(this.file)) parsed = JSON.parse(readFileSync5(this.file, "utf8"));
4160
+ if (existsSync7(this.file)) parsed = JSON.parse(readFileSync5(this.file, "utf8"));
3867
4161
  } catch {
3868
4162
  parsed = null;
3869
4163
  }
@@ -3987,11 +4281,63 @@ function prune(data) {
3987
4281
  }
3988
4282
  }
3989
4283
 
4284
+ // src/host/mail-flags.ts
4285
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync6, renameSync as renameSync5, writeFileSync as writeFileSync6 } from "node:fs";
4286
+ import { dirname as dirname3 } from "node:path";
4287
+ var MailFlagsStore = class {
4288
+ file;
4289
+ keys = null;
4290
+ constructor(file) {
4291
+ this.file = file;
4292
+ }
4293
+ load() {
4294
+ if (this.keys !== null) return this.keys;
4295
+ try {
4296
+ const parsed = JSON.parse(readFileSync6(this.file, "utf8"));
4297
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.starred) ? parsed.starred : [];
4298
+ this.keys = new Set(list.filter((key) => typeof key === "string" && key !== ""));
4299
+ } catch {
4300
+ this.keys = /* @__PURE__ */ new Set();
4301
+ }
4302
+ return this.keys;
4303
+ }
4304
+ flush() {
4305
+ mkdirSync6(dirname3(this.file), { recursive: true });
4306
+ const temp = `${this.file}.${String(process.pid)}.tmp`;
4307
+ writeFileSync6(temp, `${JSON.stringify({ starred: [...this.load()] }, null, 2)}
4308
+ `, {
4309
+ encoding: "utf8",
4310
+ mode: 384
4311
+ });
4312
+ renameSync5(temp, this.file);
4313
+ }
4314
+ has(key) {
4315
+ return key !== "" && this.load().has(key);
4316
+ }
4317
+ set(key, starred) {
4318
+ const normalized = key.trim();
4319
+ if (normalized === "") return false;
4320
+ const keys = this.load();
4321
+ const has = keys.has(normalized);
4322
+ if (starred && !has) {
4323
+ keys.add(normalized);
4324
+ this.flush();
4325
+ return true;
4326
+ }
4327
+ if (!starred && has) {
4328
+ keys.delete(normalized);
4329
+ this.flush();
4330
+ return true;
4331
+ }
4332
+ return false;
4333
+ }
4334
+ };
4335
+
3990
4336
  // src/host/tasks.ts
3991
4337
  import { randomUUID as randomUUID4 } from "node:crypto";
3992
- import { mkdirSync as mkdirSync6 } from "node:fs";
3993
- import { dirname as dirname3 } from "node:path";
3994
- import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
4338
+ import { mkdirSync as mkdirSync7 } from "node:fs";
4339
+ import { dirname as dirname4 } from "node:path";
4340
+ import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
3995
4341
  var STATUSES = /* @__PURE__ */ new Set(["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"]);
3996
4342
  function now() {
3997
4343
  return (/* @__PURE__ */ new Date()).toISOString();
@@ -4039,8 +4385,8 @@ var TaskStore = class {
4039
4385
  }
4040
4386
  open() {
4041
4387
  if (this.db !== null) return this.db;
4042
- mkdirSync6(dirname3(this.file), { recursive: true });
4043
- const db = new DatabaseSync3(this.file);
4388
+ mkdirSync7(dirname4(this.file), { recursive: true });
4389
+ const db = new DatabaseSync4(this.file);
4044
4390
  db.exec("PRAGMA journal_mode=WAL");
4045
4391
  db.exec("PRAGMA busy_timeout=10000");
4046
4392
  db.exec(`
@@ -4279,8 +4625,8 @@ var TaskStore = class {
4279
4625
 
4280
4626
  // src/host/drafts.ts
4281
4627
  import { randomUUID as randomUUID5 } from "node:crypto";
4282
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, renameSync as renameSync5, writeFileSync as writeFileSync6 } from "node:fs";
4283
- import { dirname as dirname4, join as join6 } from "node:path";
4628
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync7, renameSync as renameSync6, writeFileSync as writeFileSync7 } from "node:fs";
4629
+ import { dirname as dirname5, join as join6 } from "node:path";
4284
4630
  function text(value) {
4285
4631
  return value === void 0 || value === null ? "" : String(value);
4286
4632
  }
@@ -4304,18 +4650,18 @@ var DraftStore = class {
4304
4650
  }
4305
4651
  load() {
4306
4652
  try {
4307
- const parsed = JSON.parse(readFileSync6(this.file, "utf8"));
4653
+ const parsed = JSON.parse(readFileSync7(this.file, "utf8"));
4308
4654
  this.items = Array.isArray(parsed) ? parsed.filter(isDraft) : [];
4309
4655
  } catch {
4310
4656
  this.items = [];
4311
4657
  }
4312
4658
  }
4313
4659
  flush() {
4314
- mkdirSync7(dirname4(this.file), { recursive: true });
4660
+ mkdirSync8(dirname5(this.file), { recursive: true });
4315
4661
  const temp = `${this.file}.${String(process.pid)}.${String(Date.now())}.tmp`;
4316
- writeFileSync6(temp, `${JSON.stringify(this.items, null, 2)}
4662
+ writeFileSync7(temp, `${JSON.stringify(this.items, null, 2)}
4317
4663
  `, { encoding: "utf8", mode: 384 });
4318
- renameSync5(temp, this.file);
4664
+ renameSync6(temp, this.file);
4319
4665
  }
4320
4666
  list() {
4321
4667
  return [...this.items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).map((draft) => ({ ...draft }));
@@ -4372,8 +4718,8 @@ var DraftStore = class {
4372
4718
  init_imap();
4373
4719
 
4374
4720
  // src/host/read-state.ts
4375
- import { mkdirSync as mkdirSync8, readFileSync as readFileSync7, renameSync as renameSync6, writeFileSync as writeFileSync7 } from "node:fs";
4376
- import { dirname as dirname5 } from "node:path";
4721
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync8, renameSync as renameSync7, writeFileSync as writeFileSync8 } from "node:fs";
4722
+ import { dirname as dirname6 } from "node:path";
4377
4723
  var ReadStateStore = class {
4378
4724
  file;
4379
4725
  keys = null;
@@ -4383,7 +4729,7 @@ var ReadStateStore = class {
4383
4729
  load() {
4384
4730
  if (this.keys !== null) return this.keys;
4385
4731
  try {
4386
- const parsed = JSON.parse(readFileSync7(this.file, "utf8"));
4732
+ const parsed = JSON.parse(readFileSync8(this.file, "utf8"));
4387
4733
  const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.unread) ? parsed.unread : [];
4388
4734
  this.keys = new Set(list.filter((key) => typeof key === "string" && key !== ""));
4389
4735
  } catch {
@@ -4392,14 +4738,14 @@ var ReadStateStore = class {
4392
4738
  return this.keys;
4393
4739
  }
4394
4740
  flush() {
4395
- mkdirSync8(dirname5(this.file), { recursive: true });
4741
+ mkdirSync9(dirname6(this.file), { recursive: true });
4396
4742
  const temp = `${this.file}.${String(process.pid)}.tmp`;
4397
- writeFileSync7(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
4743
+ writeFileSync8(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
4398
4744
  `, {
4399
4745
  encoding: "utf8",
4400
4746
  mode: 384
4401
4747
  });
4402
- renameSync6(temp, this.file);
4748
+ renameSync7(temp, this.file);
4403
4749
  }
4404
4750
  isUnread(key) {
4405
4751
  return key !== "" && this.load().has(key);
@@ -4463,6 +4809,7 @@ var MailHarborRuntime = class {
4463
4809
  aiService = null;
4464
4810
  projectStore = null;
4465
4811
  taskStore = null;
4812
+ flagStores = /* @__PURE__ */ new Map();
4466
4813
  disposed = false;
4467
4814
  constructor(options = {}) {
4468
4815
  this.config = loadConfig();
@@ -4617,6 +4964,14 @@ var MailHarborRuntime = class {
4617
4964
  this.aiStores.set(account.id, { signature: file, value: created });
4618
4965
  return created;
4619
4966
  }
4967
+ flags(account) {
4968
+ const file = join7(this.paths(account).root, "starred.json");
4969
+ const existing = this.flagStores.get(account.id);
4970
+ if (existing !== void 0 && existing.signature === file) return existing.value;
4971
+ const created = new MailFlagsStore(file);
4972
+ this.flagStores.set(account.id, { signature: file, value: created });
4973
+ return created;
4974
+ }
4620
4975
  readState(account) {
4621
4976
  const file = join7(this.paths(account).root, "read-state.json");
4622
4977
  const existing = this.readStores.get(account.id);
@@ -4636,12 +4991,13 @@ var MailHarborRuntime = class {
4636
4991
  const running = this.backups.get(account.id);
4637
4992
  if (running !== void 0 && running.status.running) throw new Error("\u5907\u4EFD\u5DF2\u5728\u8FDB\u884C\u4E2D");
4638
4993
  const controller = new AbortController();
4994
+ const previous = this.backups.get(account.id)?.status;
4639
4995
  const status = {
4640
4996
  running: true,
4641
4997
  accountId: account.id,
4642
4998
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4643
4999
  finishedAt: null,
4644
- lastSuccessAt: null,
5000
+ lastSuccessAt: previous?.lastSuccessAt ?? null,
4645
5001
  total: 0,
4646
5002
  added: 0,
4647
5003
  message: "\u51C6\u5907\u5907\u4EFD",