@zaclinley/dsh-mailharbor 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -128,14 +128,14 @@ function transportOptions(settings, credentials, security = {}) {
128
128
  pool: false
129
129
  };
130
130
  }
131
- function createTransport(settings, credentials, security = {}) {
131
+ function createTransport2(settings, credentials, security = {}) {
132
132
  return nodemailer.createTransport(transportOptions(settings, credentials, security));
133
133
  }
134
134
  function recipientsOf(value) {
135
135
  return value.split(/[,;]/).map((part) => addressOf(part)).filter((address) => isEmail(address));
136
136
  }
137
137
  async function verifySmtp(settings, credentials, security = {}) {
138
- const transport = createTransport(settings, credentials, security);
138
+ const transport = createTransport2(settings, credentials, security);
139
139
  try {
140
140
  await transport.verify();
141
141
  return { ok: true, host: settings.host, port: Number(settings.port) };
@@ -149,7 +149,7 @@ async function sendMail(settings, credentials, account, mail, security = {}) {
149
149
  const to = recipientsOf(mail.to);
150
150
  const cc = recipientsOf(mail.cc ?? "");
151
151
  if (to.length === 0) throw new Error("\u6536\u4EF6\u4EBA\u4E0D\u80FD\u4E3A\u7A7A");
152
- const transport = createTransport(settings, credentials, security);
152
+ const transport = createTransport2(settings, credentials, security);
153
153
  try {
154
154
  const info = await transport.sendMail({
155
155
  from: settings.fromName === "" ? from : { name: settings.fromName, address: from },
@@ -572,6 +572,28 @@ var init_imap = __esm({
572
572
  }
573
573
  });
574
574
  }
575
+ /** Append a message to the account's Drafts mailbox. */
576
+ async appendDraft(raw) {
577
+ const boxes = await this.mailboxes();
578
+ const box = boxes.find((item) => String(item.specialUse ?? "").toLowerCase() === "\\drafts") ?? boxes.find((item) => /drafts|草稿/i.test(item.name)) ?? boxes.find((item) => item.name.toLowerCase() === "drafts");
579
+ const path = box?.path ?? "Drafts";
580
+ return this.run(async (client) => {
581
+ const result = await client.append(path, raw, ["\\Draft"]);
582
+ if (result === false) throw new Error("\u670D\u52A1\u5668\u62D2\u7EDD\u4FDD\u5B58\u8349\u7A3F");
583
+ return { mailbox: path, uid: Number(result.uid) };
584
+ });
585
+ }
586
+ /** Delete one message (used when replacing a mirrored draft). */
587
+ async deleteMessage(mailbox, uid) {
588
+ return this.run(async (client) => {
589
+ const lock = await client.getMailboxLock(mailbox);
590
+ try {
591
+ await client.messageDelete(String(uid), { uid: true });
592
+ } finally {
593
+ lock.release();
594
+ }
595
+ });
596
+ }
575
597
  /** Move one message to another mailbox (server-side). */
576
598
  async move(mailbox, uid, target) {
577
599
  return this.run(async (client) => {
@@ -751,6 +773,7 @@ function normalizeAccount(input, index = 0, existing = []) {
751
773
  dataDir: stringOr(input.dataDir, `accounts/${id}`),
752
774
  auth: input.auth?.type === "oauth2" ? { type: "oauth2", provider: input.auth.provider } : { type: "password" },
753
775
  caldav: normalizeCaldav(input.caldav),
776
+ signature: stringOr(input.signature, "").slice(0, 5e3),
754
777
  enabled: input.enabled !== false,
755
778
  createdAt: stringOr(input.createdAt, (/* @__PURE__ */ new Date()).toISOString())
756
779
  };
@@ -990,6 +1013,9 @@ async function openPath(path) {
990
1013
  await run("xdg-open", [path]);
991
1014
  }
992
1015
 
1016
+ // src/host/api.ts
1017
+ import { existsSync as existsSync6, rmSync } from "node:fs";
1018
+
993
1019
  // src/host/mail-write.ts
994
1020
  import { DatabaseSync } from "node:sqlite";
995
1021
  function moveLocalMessage(file, id, mailbox) {
@@ -1002,6 +1028,30 @@ function moveLocalMessage(file, id, mailbox) {
1002
1028
  }
1003
1029
  }
1004
1030
 
1031
+ // src/host/draft-mime.ts
1032
+ import { createTransport } from "nodemailer";
1033
+ async function draftMime(account, draft) {
1034
+ const transport = createTransport({ streamTransport: true, buffer: true, newline: "unix" });
1035
+ const from = account.smtp.fromName === "" ? account.email : `"${account.smtp.fromName}" <${account.email}>`;
1036
+ const info = await transport.sendMail({
1037
+ from,
1038
+ to: draft.to,
1039
+ cc: draft.cc,
1040
+ bcc: draft.bcc,
1041
+ subject: draft.subject,
1042
+ html: draft.html,
1043
+ attachments: draft.attachments.map((file) => ({
1044
+ filename: file.filename,
1045
+ contentType: file.contentType,
1046
+ content: Buffer.from(file.data, "base64")
1047
+ })),
1048
+ ...draft.inReplyTo === void 0 ? {} : { inReplyTo: draft.inReplyTo },
1049
+ ...draft.references === void 0 ? {} : { references: draft.references }
1050
+ });
1051
+ if (Buffer.isBuffer(info.message)) return info.message;
1052
+ return Buffer.from(info.message);
1053
+ }
1054
+
1005
1055
  // src/host/backup.ts
1006
1056
  init_address();
1007
1057
  import { createHash as createHash2 } from "node:crypto";
@@ -1287,6 +1337,50 @@ var MailDatabase = class {
1287
1337
  const rows = db.prepare(`${columns} FROM emails e ${clause} ORDER BY e.date DESC, e.id DESC LIMIT ?`).all(...params, limit);
1288
1338
  return { items: rows.map((row) => rowToSummary(row)), total, offset: 0, limit };
1289
1339
  }
1340
+ /** Frequent senders and recipients, for compose autocomplete. */
1341
+ contacts(query = "", limit = 50) {
1342
+ const text2 = query.trim().toLowerCase();
1343
+ const counts = /* @__PURE__ */ new Map();
1344
+ const add = (name2, address) => {
1345
+ const key = address.toLowerCase();
1346
+ if (key === "" || !key.includes("@")) return;
1347
+ const existing = counts.get(key);
1348
+ if (existing === void 0) counts.set(key, { name: name2, address, count: 1 });
1349
+ else {
1350
+ existing.count += 1;
1351
+ if (existing.name === "" && name2 !== "") existing.name = name2;
1352
+ }
1353
+ };
1354
+ try {
1355
+ const db = this.open();
1356
+ for (const row of db.prepare(
1357
+ `SELECT from_name AS name, from_addr AS address FROM emails
1358
+ WHERE from_addr <> '' AND from_addr LIKE '%@%' ORDER BY date DESC LIMIT 3000`
1359
+ ).all()) {
1360
+ add(String(row.name ?? ""), String(row.address ?? ""));
1361
+ }
1362
+ for (const row of db.prepare(
1363
+ `SELECT to_addr AS address, cc AS copies FROM emails
1364
+ WHERE (to_addr <> '' OR cc <> '') ORDER BY date DESC LIMIT 1500`
1365
+ ).all()) {
1366
+ for (const entry of parseAddressList(String(row.address ?? ""))) add(entry.name, entry.address);
1367
+ for (const entry of parseAddressList(String(row.copies ?? ""))) add(entry.name, entry.address);
1368
+ }
1369
+ } catch {
1370
+ return [];
1371
+ }
1372
+ return [...counts.values()].filter(
1373
+ (entry) => text2 === "" || entry.address.toLowerCase().includes(text2) || entry.name.toLowerCase().includes(text2)
1374
+ ).sort((left, right) => right.count - left.count).slice(0, limit);
1375
+ }
1376
+ /** All archived messages in the same conversation (normalized subject). */
1377
+ thread(subject, limit = 200) {
1378
+ const normalize2 = (value) => value.replace(/^(\s*(re|fw|fwd|回复|转发)\s*[::]\s*)+/gi, "").replace(/\s+/g, " ").trim().toLowerCase();
1379
+ const base = normalize2(subject);
1380
+ if (base === "") return [];
1381
+ const page = this.list({ mailbox: "all", query: base, limit: Math.min(500, limit * 2) });
1382
+ return page.items.filter((item) => normalize2(item.subject) === base).slice(0, limit);
1383
+ }
1290
1384
  /** Number of unread keys that still exist in this backup. */
1291
1385
  countUnread(keys) {
1292
1386
  const mids = keys.mids.slice(0, 500);
@@ -2699,7 +2793,7 @@ ${conversation.trim()}
2699
2793
  }
2700
2794
 
2701
2795
  // src/host/version.ts
2702
- var VERSION = "0.13.0";
2796
+ var VERSION = "0.14.0";
2703
2797
 
2704
2798
  // src/host/api.ts
2705
2799
  init_smtp();
@@ -2913,6 +3007,17 @@ function buildRouter(runtime) {
2913
3007
  }
2914
3008
  return { ok: true, count };
2915
3009
  });
3010
+ router.get("/messages/thread", ({ url }) => {
3011
+ const { account } = accountFrom(runtime, url);
3012
+ const subject = url.searchParams.get("subject") ?? "";
3013
+ const items = runtime.database(account).thread(subject);
3014
+ return { items: items.map((item) => localSummary(runtime, account, item)) };
3015
+ });
3016
+ router.get("/contacts", ({ url }) => {
3017
+ const { account } = accountFrom(runtime, url);
3018
+ const query = url.searchParams.get("q") ?? "";
3019
+ return { items: runtime.database(account).contacts(query, 50) };
3020
+ });
2916
3021
  router.get("/messages/:id", async ({ url, params }) => {
2917
3022
  const { account } = accountFrom(runtime, url);
2918
3023
  const source = sourceOf(url);
@@ -2972,13 +3077,40 @@ function buildRouter(runtime) {
2972
3077
  router.post("/drafts", async ({ url, body }) => {
2973
3078
  const { account } = accountFrom(runtime, url);
2974
3079
  const payload = await body();
2975
- const draft = runtime.drafts(account).save(normalizeDraftInput(payload, account.id));
2976
- return { draft };
3080
+ const store = runtime.drafts(account);
3081
+ const draft = store.save(normalizeDraftInput(payload, account.id));
3082
+ if (payload.remote !== true) return { draft };
3083
+ if (!runtime.hasCredentials(account)) return { draft, syncError: "\u672A\u767B\u5F55\uFF0C\u8349\u7A3F\u4EC5\u4FDD\u5B58\u5728\u672C\u5730" };
3084
+ try {
3085
+ const raw = await draftMime(account, draft);
3086
+ const provider = runtime.provider(account);
3087
+ const appended = await provider.appendDraft(raw);
3088
+ if (draft.remoteUid !== void 0 && draft.remoteMailbox !== void 0) {
3089
+ try {
3090
+ await provider.deleteMessage(draft.remoteMailbox, draft.remoteUid);
3091
+ } catch {
3092
+ }
3093
+ }
3094
+ const updated = store.save({ ...draft, remoteUid: appended.uid, remoteMailbox: appended.mailbox });
3095
+ return { draft: updated };
3096
+ } catch (error) {
3097
+ return {
3098
+ draft,
3099
+ syncError: error instanceof Error ? error.message : String(error)
3100
+ };
3101
+ }
2977
3102
  });
2978
- router.delete("/drafts/:id", ({ url, params }) => {
3103
+ router.delete("/drafts/:id", async ({ url, params }) => {
2979
3104
  const { account } = accountFrom(runtime, url);
3105
+ const draft = runtime.drafts(account).get(params.id ?? "");
2980
3106
  const removed = runtime.drafts(account).remove(params.id ?? "");
2981
3107
  if (!removed) throw new HttpError(404, "\u8349\u7A3F\u4E0D\u5B58\u5728");
3108
+ if (draft?.remoteUid !== void 0 && draft.remoteMailbox !== void 0 && runtime.hasCredentials(account)) {
3109
+ try {
3110
+ await runtime.provider(account).deleteMessage(draft.remoteMailbox, draft.remoteUid);
3111
+ } catch {
3112
+ }
3113
+ }
2982
3114
  return { ok: true };
2983
3115
  });
2984
3116
  router.post("/send", async ({ url, body }) => {
@@ -3035,6 +3167,7 @@ function buildRouter(runtime) {
3035
3167
  imap: { ...existing.imap, ...asObject(payload.imap) },
3036
3168
  smtp: { ...existing.smtp, ...asObject(payload.smtp) },
3037
3169
  caldav: payload.caldav === null ? null : payload.caldav === void 0 ? existing.caldav : { ...asObject(payload.caldav) },
3170
+ signature: typeof payload.signature === "string" ? payload.signature.slice(0, 5e3) : existing.signature,
3038
3171
  enabled: payload.enabled === void 0 ? existing.enabled : payload.enabled !== false
3039
3172
  };
3040
3173
  const account = normalizeAccount(
@@ -3443,17 +3576,48 @@ function buildRouter(runtime) {
3443
3576
  if (project === void 0) throw new HttpError(404, "\u9879\u76EE\u4E0D\u5B58\u5728");
3444
3577
  return { project: projectView(project) };
3445
3578
  });
3446
- router.delete("/projects/:id", ({ params }) => {
3579
+ router.delete("/projects/:id", async ({ params, url }) => {
3447
3580
  const id = String(params.id ?? "");
3448
3581
  const project = runtime.projects().get(id);
3449
3582
  if (project === void 0) throw new HttpError(404, "\u9879\u76EE\u4E0D\u5B58\u5728");
3583
+ const warnings = [];
3450
3584
  const removed = runtime.projects().remove(id);
3451
3585
  const unlinked = runtime.tasks().clearProject(id);
3586
+ let workspaceRemoved = false;
3587
+ if (project.workspaceId !== "" && runtime.workspaceRegistry?.delete !== void 0) {
3588
+ try {
3589
+ workspaceRemoved = await runtime.workspaceRegistry.delete(project.workspaceId);
3590
+ } catch (error) {
3591
+ warnings.push(`\u79FB\u9664\u5DE5\u4F5C\u7A7A\u95F4\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
3592
+ }
3593
+ } else if (project.workspaceId !== "") {
3594
+ 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");
3595
+ }
3596
+ let filesRemoved = false;
3597
+ if (url.searchParams.get("files") === "1") {
3598
+ try {
3599
+ const root = runtime.current.projects.root;
3600
+ const insideRoot = project.path === root || project.path.startsWith(`${root}/`);
3601
+ const marker = existsSync6(`${project.path}/project.json`);
3602
+ if (!insideRoot && !marker) {
3603
+ 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");
3604
+ } else if (project.path === root || project.path === "/") {
3605
+ warnings.push("\u62D2\u7EDD\u5220\u9664\u9879\u76EE\u6839\u76EE\u5F55");
3606
+ } else {
3607
+ rmSync(project.path, { recursive: true, force: true });
3608
+ filesRemoved = true;
3609
+ }
3610
+ } catch (error) {
3611
+ warnings.push(`\u5220\u9664\u6587\u4EF6\u5939\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
3612
+ }
3613
+ }
3452
3614
  return {
3453
3615
  ok: removed,
3454
3616
  path: project.path,
3455
3617
  unlinkedTasks: unlinked,
3456
- workspaceKept: project.workspaceId !== ""
3618
+ workspaceRemoved,
3619
+ filesRemoved,
3620
+ warnings
3457
3621
  };
3458
3622
  });
3459
3623
  router.post("/projects/:id/open", async ({ params }) => {
@@ -3835,7 +3999,7 @@ async function sendFromPayload(runtime, account, payload) {
3835
3999
  const totalBytes = attachments.reduce((sum, file) => sum + file.content.length, 0);
3836
4000
  if (totalBytes > 15 * 1024 * 1024) throw new HttpError(413, "\u9644\u4EF6\u5408\u8BA1\u4E0D\u80FD\u8D85\u8FC7 15 MB");
3837
4001
  const { sendMail: sendMail2 } = await Promise.resolve().then(() => (init_smtp(), smtp_exports));
3838
- const signature = runtime.current.signature;
4002
+ const signature = (account.signature ?? "").trim() !== "" ? String(account.signature).trim() : runtime.current.signature;
3839
4003
  const bodyText2 = signature === "" ? text2 : `${text2}
3840
4004
 
3841
4005
  --
@@ -4051,7 +4215,7 @@ var CredentialManager = class {
4051
4215
  };
4052
4216
 
4053
4217
  // src/host/ai-store.ts
4054
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync5 } from "node:fs";
4218
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync5 } from "node:fs";
4055
4219
  import { dirname as dirname2 } from "node:path";
4056
4220
  var MAX_THREADS = 50;
4057
4221
  var MAX_CARDS_PER_THREAD = 50;
@@ -4066,7 +4230,7 @@ var AiThreadStore = class {
4066
4230
  if (this.cache !== null) return this.cache;
4067
4231
  let parsed = null;
4068
4232
  try {
4069
- if (existsSync6(this.file)) parsed = JSON.parse(readFileSync5(this.file, "utf8"));
4233
+ if (existsSync7(this.file)) parsed = JSON.parse(readFileSync5(this.file, "utf8"));
4070
4234
  } catch {
4071
4235
  parsed = null;
4072
4236
  }
@@ -4594,7 +4758,9 @@ var DraftStore = class {
4594
4758
  data: text(file.data)
4595
4759
  })) : [],
4596
4760
  inReplyTo: text(input.inReplyTo),
4597
- references: text(input.references)
4761
+ references: text(input.references),
4762
+ ...input.remoteUid === void 0 ? existing?.remoteUid === void 0 ? {} : { remoteUid: existing.remoteUid } : { remoteUid: Number(input.remoteUid) },
4763
+ ...input.remoteMailbox === void 0 ? existing?.remoteMailbox === void 0 ? {} : { remoteMailbox: existing.remoteMailbox } : { remoteMailbox: text(input.remoteMailbox) }
4598
4764
  };
4599
4765
  if (existing !== void 0) {
4600
4766
  Object.assign(existing, fields, { updatedAt: now2 });
@@ -4900,12 +5066,13 @@ var MailHarborRuntime = class {
4900
5066
  const running = this.backups.get(account.id);
4901
5067
  if (running !== void 0 && running.status.running) throw new Error("\u5907\u4EFD\u5DF2\u5728\u8FDB\u884C\u4E2D");
4902
5068
  const controller = new AbortController();
5069
+ const previous = this.backups.get(account.id)?.status;
4903
5070
  const status = {
4904
5071
  running: true,
4905
5072
  accountId: account.id,
4906
5073
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4907
5074
  finishedAt: null,
4908
- lastSuccessAt: null,
5075
+ lastSuccessAt: previous?.lastSuccessAt ?? null,
4909
5076
  total: 0,
4910
5077
  added: 0,
4911
5078
  message: "\u51C6\u5907\u5907\u4EFD",