@zaclinley/dsh-mailharbor 0.13.1 → 0.15.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) => {
@@ -690,7 +712,12 @@ var DEFAULTS = {
690
712
  render: { listLimit: 40 },
691
713
  projects: { root: join2(homedir2(), "work", "projects") },
692
714
  oauth: {
693
- google: { clientId: "", redirectPort: 8765, scopes: ["https://mail.google.com/"] },
715
+ google: {
716
+ clientId: "",
717
+ clientSecret: "",
718
+ redirectPort: 8765,
719
+ scopes: ["https://mail.google.com/"]
720
+ },
694
721
  microsoft: {
695
722
  clientId: "",
696
723
  scopes: [
@@ -834,6 +861,7 @@ function loadConfig() {
834
861
  oauth: {
835
862
  google: {
836
863
  clientId: stringOr(googleRaw.clientId, DEFAULTS.oauth.google.clientId),
864
+ clientSecret: stringOr(googleRaw.clientSecret, DEFAULTS.oauth.google.clientSecret),
837
865
  redirectPort: intOr(googleRaw.redirectPort, DEFAULTS.oauth.google.redirectPort, 1024, 65535),
838
866
  scopes: Array.isArray(googleRaw.scopes) ? googleRaw.scopes.filter((item) => typeof item === "string") : DEFAULTS.oauth.google.scopes
839
867
  },
@@ -879,7 +907,7 @@ function buildAccount(input, existing) {
879
907
  username: input.username,
880
908
  imap: input.imap,
881
909
  smtp: input.smtp,
882
- auth: { type: "password" },
910
+ auth: input.auth ?? { type: "password" },
883
911
  caldav: null,
884
912
  enabled: true,
885
913
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -990,6 +1018,26 @@ async function openPath(path) {
990
1018
  }
991
1019
  await run("xdg-open", [path]);
992
1020
  }
1021
+ async function openExternal(url) {
1022
+ try {
1023
+ const require2 = createRequire(import.meta.url);
1024
+ const electron = require2("electron");
1025
+ if (electron.shell !== void 0) {
1026
+ await electron.shell.openExternal(url);
1027
+ return;
1028
+ }
1029
+ } catch {
1030
+ }
1031
+ if (process.platform === "darwin") {
1032
+ await run("open", [url]);
1033
+ return;
1034
+ }
1035
+ if (process.platform === "win32") {
1036
+ await run("cmd", ["/c", "start", "", url]);
1037
+ return;
1038
+ }
1039
+ await run("xdg-open", [url]);
1040
+ }
993
1041
 
994
1042
  // src/host/api.ts
995
1043
  import { existsSync as existsSync6, rmSync } from "node:fs";
@@ -1006,6 +1054,158 @@ function moveLocalMessage(file, id, mailbox) {
1006
1054
  }
1007
1055
  }
1008
1056
 
1057
+ // src/host/draft-mime.ts
1058
+ import { createTransport } from "nodemailer";
1059
+ async function draftMime(account, draft) {
1060
+ const transport = createTransport({ streamTransport: true, buffer: true, newline: "unix" });
1061
+ const from = account.smtp.fromName === "" ? account.email : `"${account.smtp.fromName}" <${account.email}>`;
1062
+ const info = await transport.sendMail({
1063
+ from,
1064
+ to: draft.to,
1065
+ cc: draft.cc,
1066
+ bcc: draft.bcc,
1067
+ subject: draft.subject,
1068
+ html: draft.html,
1069
+ attachments: draft.attachments.map((file) => ({
1070
+ filename: file.filename,
1071
+ contentType: file.contentType,
1072
+ content: Buffer.from(file.data, "base64")
1073
+ })),
1074
+ ...draft.inReplyTo === void 0 ? {} : { inReplyTo: draft.inReplyTo },
1075
+ ...draft.references === void 0 ? {} : { references: draft.references }
1076
+ });
1077
+ if (Buffer.isBuffer(info.message)) return info.message;
1078
+ return Buffer.from(info.message);
1079
+ }
1080
+
1081
+ // src/host/api.ts
1082
+ import { createHash as createHash3, randomBytes } from "node:crypto";
1083
+ import { createServer } from "node:http";
1084
+
1085
+ // src/host/oauth.ts
1086
+ var TOKEN_ENDPOINTS = {
1087
+ google: "https://oauth2.googleapis.com/token",
1088
+ microsoft: "https://login.microsoftonline.com/common/oauth2/v2.0/token"
1089
+ };
1090
+ var DEVICE_ENDPOINTS = {
1091
+ google: "https://oauth2.googleapis.com/device/code",
1092
+ microsoft: "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode"
1093
+ };
1094
+ function form(values) {
1095
+ return new URLSearchParams(values).toString();
1096
+ }
1097
+ function tokensFrom(payload, previousRefresh = "") {
1098
+ const accessToken = typeof payload.access_token === "string" ? payload.access_token : "";
1099
+ if (accessToken === "") throw new Error("OAuth \u54CD\u5E94\u7F3A\u5C11 access_token");
1100
+ const refreshToken = typeof payload.refresh_token === "string" && payload.refresh_token !== "" ? payload.refresh_token : previousRefresh;
1101
+ const expiresIn = Number(payload.expires_in ?? 3600);
1102
+ return {
1103
+ accessToken,
1104
+ refreshToken,
1105
+ expiresAt: Date.now() + Math.max(60, expiresIn) * 1e3,
1106
+ ...typeof payload.scope === "string" ? { scope: payload.scope } : {},
1107
+ ...typeof payload.token_type === "string" ? { tokenType: payload.token_type } : {}
1108
+ };
1109
+ }
1110
+ async function refreshTokens(app, refreshToken) {
1111
+ if (refreshToken === "") throw new Error("OAuth \u5237\u65B0\u4EE4\u724C\u4E3A\u7A7A");
1112
+ const body = {
1113
+ grant_type: "refresh_token",
1114
+ client_id: app.clientId,
1115
+ refresh_token: refreshToken
1116
+ };
1117
+ if (app.provider === "google") body.client_secret = app.clientSecret;
1118
+ const response = await fetch(TOKEN_ENDPOINTS[app.provider], {
1119
+ method: "POST",
1120
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1121
+ body: form(body)
1122
+ });
1123
+ const payload = await response.json().catch(() => ({}));
1124
+ if (!response.ok) {
1125
+ const reason = typeof payload.error_description === "string" ? payload.error_description : typeof payload.error === "string" ? payload.error : `HTTP ${String(response.status)}`;
1126
+ throw new Error(`OAuth \u5237\u65B0\u5931\u8D25\uFF1A${reason}`);
1127
+ }
1128
+ return tokensFrom(payload, refreshToken);
1129
+ }
1130
+ function googleAuthUrl(app, state, challenge, redirectUri) {
1131
+ const params = new URLSearchParams({
1132
+ client_id: app.clientId,
1133
+ redirect_uri: redirectUri,
1134
+ response_type: "code",
1135
+ scope: app.scopes.join(" "),
1136
+ access_type: "offline",
1137
+ prompt: "consent",
1138
+ state,
1139
+ code_challenge: challenge,
1140
+ code_challenge_method: "S256"
1141
+ });
1142
+ return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
1143
+ }
1144
+ async function exchangeGoogleCode(app, code, verifier, redirectUri) {
1145
+ const response = await fetch(TOKEN_ENDPOINTS.google, {
1146
+ method: "POST",
1147
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1148
+ body: form({
1149
+ code,
1150
+ client_id: app.clientId,
1151
+ client_secret: app.clientSecret,
1152
+ redirect_uri: redirectUri,
1153
+ grant_type: "authorization_code",
1154
+ code_verifier: verifier
1155
+ })
1156
+ });
1157
+ const payload = await response.json().catch(() => ({}));
1158
+ if (!response.ok) {
1159
+ throw new Error(
1160
+ `OAuth \u6388\u6743\u5931\u8D25\uFF1A${typeof payload.error_description === "string" ? payload.error_description : `HTTP ${String(response.status)}`}`
1161
+ );
1162
+ }
1163
+ return tokensFrom(payload);
1164
+ }
1165
+ async function startDeviceFlow(app) {
1166
+ const body = {
1167
+ client_id: app.clientId,
1168
+ scope: app.scopes.join(" ")
1169
+ };
1170
+ if (app.provider === "microsoft") body.scope = [...app.scopes, "offline_access"].join(" ");
1171
+ const response = await fetch(DEVICE_ENDPOINTS[app.provider], {
1172
+ method: "POST",
1173
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1174
+ body: form(body)
1175
+ });
1176
+ const payload = await response.json().catch(() => ({}));
1177
+ if (!response.ok) {
1178
+ throw new Error(
1179
+ `OAuth \u8BBE\u5907\u7801\u8BF7\u6C42\u5931\u8D25\uFF1A${typeof payload.error_description === "string" ? payload.error_description : `HTTP ${String(response.status)}`}`
1180
+ );
1181
+ }
1182
+ return {
1183
+ deviceCode: String(payload.device_code ?? ""),
1184
+ userCode: String(payload.user_code ?? ""),
1185
+ verificationUri: String(payload.verification_uri ?? payload.verification_url ?? ""),
1186
+ interval: Math.max(2, Number(payload.interval ?? 5)),
1187
+ expiresAt: Date.now() + Math.max(60, Number(payload.expires_in ?? 900)) * 1e3
1188
+ };
1189
+ }
1190
+ async function pollDeviceFlow(app, deviceCode) {
1191
+ const response = await fetch(TOKEN_ENDPOINTS[app.provider], {
1192
+ method: "POST",
1193
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1194
+ body: form({
1195
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
1196
+ client_id: app.clientId,
1197
+ device_code: deviceCode
1198
+ })
1199
+ });
1200
+ const payload = await response.json().catch(() => ({}));
1201
+ if (response.ok) return { status: "ready", tokens: tokensFrom(payload) };
1202
+ const error = String(payload.error ?? "");
1203
+ if (error === "authorization_pending" || error === "slow_down") return { status: "pending" };
1204
+ throw new Error(
1205
+ `OAuth \u8BBE\u5907\u7801\u767B\u5F55\u5931\u8D25\uFF1A${typeof payload.error_description === "string" ? payload.error_description : error}`
1206
+ );
1207
+ }
1208
+
1009
1209
  // src/host/backup.ts
1010
1210
  init_address();
1011
1211
  import { createHash as createHash2 } from "node:crypto";
@@ -2747,7 +2947,7 @@ ${conversation.trim()}
2747
2947
  }
2748
2948
 
2749
2949
  // src/host/version.ts
2750
- var VERSION = "0.13.1";
2950
+ var VERSION = "0.15.0";
2751
2951
 
2752
2952
  // src/host/api.ts
2753
2953
  init_smtp();
@@ -2798,6 +2998,7 @@ function createApiHandler(runtime) {
2798
2998
  }
2799
2999
  };
2800
3000
  }
3001
+ var pendingOAuth = /* @__PURE__ */ new Map();
2801
3002
  function buildRouter(runtime) {
2802
3003
  const router = new Router();
2803
3004
  router.get("/health", () => ({ ok: true, version: VERSION }));
@@ -2961,6 +3162,231 @@ function buildRouter(runtime) {
2961
3162
  }
2962
3163
  return { ok: true, count };
2963
3164
  });
3165
+ router.get("/transfer/export", () => {
3166
+ const config = runtime.current;
3167
+ return {
3168
+ format: "mailharbor-transfer",
3169
+ version: 1,
3170
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
3171
+ settings: {
3172
+ signature: config.signature,
3173
+ read: config.read,
3174
+ render: config.render,
3175
+ ai: {
3176
+ enabled: config.ai.enabled,
3177
+ provider: config.ai.provider,
3178
+ model: config.ai.model,
3179
+ summaryLength: config.ai.summaryLength
3180
+ },
3181
+ oauth: {
3182
+ google: {
3183
+ clientId: config.oauth.google.clientId,
3184
+ redirectPort: config.oauth.google.redirectPort
3185
+ },
3186
+ microsoft: { clientId: config.oauth.microsoft.clientId }
3187
+ }
3188
+ },
3189
+ accounts: config.accounts.map((account) => ({
3190
+ email: account.email,
3191
+ label: account.label,
3192
+ username: account.username,
3193
+ imap: account.imap,
3194
+ smtp: account.smtp,
3195
+ auth: account.auth,
3196
+ enabled: account.enabled
3197
+ }))
3198
+ };
3199
+ });
3200
+ router.post("/transfer/import", async ({ body }) => {
3201
+ const payload = await body();
3202
+ if (payload.format !== "mailharbor-transfer" || !Array.isArray(payload.accounts)) {
3203
+ throw new HttpError(400, "\u4E0D\u662F\u6709\u6548\u7684 MailHarbor \u914D\u7F6E\u6587\u4EF6");
3204
+ }
3205
+ const config = runtime.current;
3206
+ const accounts = [...config.accounts];
3207
+ let added = 0;
3208
+ for (const raw of payload.accounts) {
3209
+ const email = String(raw.email ?? "").trim();
3210
+ if (email === "" || accounts.some((account) => account.email.toLowerCase() === email.toLowerCase()))
3211
+ continue;
3212
+ try {
3213
+ const account = normalizeAccount(
3214
+ {
3215
+ email,
3216
+ label: typeof raw.label === "string" ? raw.label : void 0,
3217
+ username: typeof raw.username === "string" ? raw.username : void 0,
3218
+ imap: asObject(raw.imap),
3219
+ smtp: asObject(raw.smtp),
3220
+ ...raw.auth !== void 0 ? { auth: raw.auth } : {}
3221
+ },
3222
+ accounts.length,
3223
+ accounts.map((account2) => account2.id)
3224
+ );
3225
+ accounts.push(account);
3226
+ added += 1;
3227
+ } catch {
3228
+ }
3229
+ }
3230
+ if (added > 0) saveAccounts(accounts, config.activeAccountId ?? accounts[0]?.id ?? null);
3231
+ const settings = asObject(payload.settings);
3232
+ const patch = {};
3233
+ if (typeof settings.signature === "string") patch.signature = settings.signature.slice(0, 5e3);
3234
+ const read = asObject(settings.read);
3235
+ if (typeof read.markOnOpen === "boolean") patch.read = { markOnOpen: read.markOnOpen };
3236
+ const render = asObject(settings.render);
3237
+ if (typeof render.listLimit === "number") patch.render = { listLimit: render.listLimit };
3238
+ const ai = asObject(settings.ai);
3239
+ if (ai.summaryLength !== void 0) patch.ai = { summaryLength: ai.summaryLength };
3240
+ if (Object.keys(patch).length > 0) saveSettings(patch);
3241
+ const oauth = asObject(settings.oauth);
3242
+ const google = asObject(oauth.google);
3243
+ const microsoft = asObject(oauth.microsoft);
3244
+ if (typeof google.clientId === "string" || typeof microsoft.clientId === "string") {
3245
+ saveSettings({
3246
+ oauth: {
3247
+ google: {
3248
+ clientId: typeof google.clientId === "string" ? google.clientId : config.oauth.google.clientId,
3249
+ clientSecret: config.oauth.google.clientSecret,
3250
+ redirectPort: typeof google.redirectPort === "number" ? google.redirectPort : config.oauth.google.redirectPort,
3251
+ scopes: config.oauth.google.scopes
3252
+ },
3253
+ microsoft: {
3254
+ clientId: typeof microsoft.clientId === "string" ? microsoft.clientId : config.oauth.microsoft.clientId,
3255
+ scopes: config.oauth.microsoft.scopes
3256
+ }
3257
+ }
3258
+ });
3259
+ }
3260
+ runtime.refresh();
3261
+ return { ok: true, addedAccounts: added, state: appState(runtime) };
3262
+ });
3263
+ router.get("/oauth/status", ({ url }) => {
3264
+ const { account } = accountFrom(runtime, url);
3265
+ const tokens = runtime.oauthTokens(account);
3266
+ let appConfigured = false;
3267
+ try {
3268
+ runtime.oauthApp(account);
3269
+ appConfigured = true;
3270
+ } catch {
3271
+ appConfigured = false;
3272
+ }
3273
+ return {
3274
+ authorized: tokens !== void 0,
3275
+ expiresAt: tokens?.expiresAt ?? null,
3276
+ provider: account.auth.type === "oauth2" ? account.auth.provider : null,
3277
+ appConfigured
3278
+ };
3279
+ });
3280
+ router.post("/oauth/start", async ({ body }) => {
3281
+ const payload = await body();
3282
+ const account = accountFromPayload(runtime, payload);
3283
+ const app = runtime.oauthApp(account);
3284
+ const state = randomBytes(16).toString("hex");
3285
+ const expiresAt = Date.now() + 10 * 6e4;
3286
+ if (app.provider === "microsoft") {
3287
+ const device = await startDeviceFlow(app);
3288
+ pendingOAuth.set(state, {
3289
+ accountId: account.id,
3290
+ provider: "microsoft",
3291
+ status: "pending",
3292
+ deviceCode: device.deviceCode,
3293
+ interval: device.interval,
3294
+ expiresAt
3295
+ });
3296
+ return {
3297
+ state,
3298
+ flow: "microsoft",
3299
+ userCode: device.userCode,
3300
+ verificationUri: device.verificationUri,
3301
+ authUrl: device.verificationUri,
3302
+ interval: device.interval
3303
+ };
3304
+ }
3305
+ const verifier = randomBytes(32).toString("base64url");
3306
+ const challenge = createHash3("sha256").update(verifier).digest("base64url");
3307
+ const redirectUri = `http://127.0.0.1:${String(app.redirectPort)}/oauth/callback`;
3308
+ const authUrl = googleAuthUrl(app, state, challenge, redirectUri);
3309
+ const entry = {
3310
+ accountId: account.id,
3311
+ provider: "google",
3312
+ status: "pending",
3313
+ expiresAt
3314
+ };
3315
+ pendingOAuth.set(state, entry);
3316
+ const server = createServer((request, response) => {
3317
+ const target = new URL(request.url ?? "/", "http://127.0.0.1");
3318
+ if (target.pathname !== "/oauth/callback") {
3319
+ response.writeHead(404).end();
3320
+ return;
3321
+ }
3322
+ const code = target.searchParams.get("code");
3323
+ const error = target.searchParams.get("error");
3324
+ void (async () => {
3325
+ try {
3326
+ if (error !== null) throw new Error(error);
3327
+ if (code === null) throw new Error("\u7F3A\u5C11\u6388\u6743\u7801");
3328
+ const tokens = await exchangeGoogleCode(app, code, verifier, redirectUri);
3329
+ runtime.saveOAuthTokens(account, tokens);
3330
+ entry.status = "ready";
3331
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
3332
+ response.end("<p>MailHarbor \u6388\u6743\u6210\u529F\uFF0C\u53EF\u4EE5\u5173\u95ED\u6B64\u9875\u9762\u3002</p>");
3333
+ } catch (caught) {
3334
+ entry.status = "error";
3335
+ entry.message = caught instanceof Error ? caught.message : String(caught);
3336
+ response.writeHead(500, { "content-type": "text/html; charset=utf-8" });
3337
+ response.end(`<p>\u6388\u6743\u5931\u8D25\uFF1A${entry.message}</p>`);
3338
+ } finally {
3339
+ setTimeout(() => {
3340
+ server.close();
3341
+ }, 500);
3342
+ }
3343
+ })();
3344
+ });
3345
+ await new Promise((resolve, reject) => {
3346
+ server.once("error", reject);
3347
+ server.listen(app.redirectPort, "127.0.0.1", () => resolve());
3348
+ });
3349
+ setTimeout(() => {
3350
+ server.close();
3351
+ if (entry.status === "pending") {
3352
+ entry.status = "error";
3353
+ entry.message = "\u6388\u6743\u8D85\u65F6\uFF0C\u8BF7\u91CD\u8BD5";
3354
+ }
3355
+ }, 10 * 6e4).unref();
3356
+ await openExternal(authUrl).catch(() => void 0);
3357
+ return { state, flow: "google", authUrl, expiresAt };
3358
+ });
3359
+ router.post("/oauth/poll", async ({ body }) => {
3360
+ const payload = await body();
3361
+ const state = String(payload.state ?? "");
3362
+ const entry = pendingOAuth.get(state);
3363
+ if (entry === void 0) throw new HttpError(404, "\u6388\u6743\u4F1A\u8BDD\u4E0D\u5B58\u5728\u6216\u5DF2\u8FC7\u671F");
3364
+ if (Date.now() > entry.expiresAt) {
3365
+ entry.status = "error";
3366
+ entry.message = "\u6388\u6743\u8D85\u65F6\uFF0C\u8BF7\u91CD\u8BD5";
3367
+ }
3368
+ if (entry.status !== "pending") {
3369
+ if (entry.status === "ready") pendingOAuth.delete(state);
3370
+ return { status: entry.status, message: entry.message };
3371
+ }
3372
+ if (entry.provider === "microsoft" && entry.deviceCode !== void 0) {
3373
+ const account = runtime.account(entry.accountId);
3374
+ const result = await pollDeviceFlow(runtime.oauthApp(account), entry.deviceCode);
3375
+ if (result.status === "ready") {
3376
+ runtime.saveOAuthTokens(account, result.tokens);
3377
+ pendingOAuth.delete(state);
3378
+ return { status: "ready" };
3379
+ }
3380
+ }
3381
+ return { status: "pending" };
3382
+ });
3383
+ router.post("/oauth/revoke", async ({ body }) => {
3384
+ const payload = await body();
3385
+ const account = accountFromPayload(runtime, payload);
3386
+ runtime.credentials.forgetOAuth(account);
3387
+ runtime.invalidate(account.id);
3388
+ return { ok: true };
3389
+ });
2964
3390
  router.get("/messages/thread", ({ url }) => {
2965
3391
  const { account } = accountFrom(runtime, url);
2966
3392
  const subject = url.searchParams.get("subject") ?? "";
@@ -3031,13 +3457,40 @@ function buildRouter(runtime) {
3031
3457
  router.post("/drafts", async ({ url, body }) => {
3032
3458
  const { account } = accountFrom(runtime, url);
3033
3459
  const payload = await body();
3034
- const draft = runtime.drafts(account).save(normalizeDraftInput(payload, account.id));
3035
- return { draft };
3460
+ const store = runtime.drafts(account);
3461
+ const draft = store.save(normalizeDraftInput(payload, account.id));
3462
+ if (payload.remote !== true) return { draft };
3463
+ if (!runtime.hasCredentials(account)) return { draft, syncError: "\u672A\u767B\u5F55\uFF0C\u8349\u7A3F\u4EC5\u4FDD\u5B58\u5728\u672C\u5730" };
3464
+ try {
3465
+ const raw = await draftMime(account, draft);
3466
+ const provider = runtime.provider(account);
3467
+ const appended = await provider.appendDraft(raw);
3468
+ if (draft.remoteUid !== void 0 && draft.remoteMailbox !== void 0) {
3469
+ try {
3470
+ await provider.deleteMessage(draft.remoteMailbox, draft.remoteUid);
3471
+ } catch {
3472
+ }
3473
+ }
3474
+ const updated = store.save({ ...draft, remoteUid: appended.uid, remoteMailbox: appended.mailbox });
3475
+ return { draft: updated };
3476
+ } catch (error) {
3477
+ return {
3478
+ draft,
3479
+ syncError: error instanceof Error ? error.message : String(error)
3480
+ };
3481
+ }
3036
3482
  });
3037
- router.delete("/drafts/:id", ({ url, params }) => {
3483
+ router.delete("/drafts/:id", async ({ url, params }) => {
3038
3484
  const { account } = accountFrom(runtime, url);
3485
+ const draft = runtime.drafts(account).get(params.id ?? "");
3039
3486
  const removed = runtime.drafts(account).remove(params.id ?? "");
3040
3487
  if (!removed) throw new HttpError(404, "\u8349\u7A3F\u4E0D\u5B58\u5728");
3488
+ if (draft?.remoteUid !== void 0 && draft.remoteMailbox !== void 0 && runtime.hasCredentials(account)) {
3489
+ try {
3490
+ await runtime.provider(account).deleteMessage(draft.remoteMailbox, draft.remoteUid);
3491
+ } catch {
3492
+ }
3493
+ }
3041
3494
  return { ok: true };
3042
3495
  });
3043
3496
  router.post("/send", async ({ url, body }) => {
@@ -3066,8 +3519,9 @@ function buildRouter(runtime) {
3066
3519
  const password = typeof input.password === "string" ? input.password : "";
3067
3520
  const config = runtime.current;
3068
3521
  const account = buildAccount(input, config.accounts);
3069
- if (password === "") throw new HttpError(400, "\u8BF7\u8F93\u5165\u5BC6\u7801\u6216\u5E94\u7528\u4E13\u7528\u5BC6\u7801");
3070
- if (input.connect !== false) {
3522
+ const oauth = account.auth.type === "oauth2";
3523
+ if (!oauth && password === "") throw new HttpError(400, "\u8BF7\u8F93\u5165\u5BC6\u7801\u6216\u5E94\u7528\u4E13\u7528\u5BC6\u7801");
3524
+ if (!oauth && input.connect !== false) {
3071
3525
  const provider = await verifyImap(account, password);
3072
3526
  await provider.dispose();
3073
3527
  try {
@@ -3075,7 +3529,7 @@ function buildRouter(runtime) {
3075
3529
  } catch {
3076
3530
  }
3077
3531
  }
3078
- runtime.credentials.remember(account, password, input.remember !== false);
3532
+ if (!oauth) runtime.credentials.remember(account, password, input.remember !== false);
3079
3533
  const accounts = [...config.accounts, account];
3080
3534
  saveAccounts(accounts, account.id);
3081
3535
  runtime.refresh();
@@ -3596,6 +4050,23 @@ function buildRouter(runtime) {
3596
4050
  if (payload.ai !== void 0) patch.ai = asObject(payload.ai);
3597
4051
  if (payload.read !== void 0) patch.read = asObject(payload.read);
3598
4052
  if (payload.render !== void 0) patch.render = asObject(payload.render);
4053
+ if (payload.oauth !== void 0) {
4054
+ const oauth = asObject(payload.oauth);
4055
+ const google = asObject(oauth.google);
4056
+ const microsoft = asObject(oauth.microsoft);
4057
+ patch.oauth = {
4058
+ google: {
4059
+ clientId: typeof google.clientId === "string" ? google.clientId.trim() : runtime.current.oauth.google.clientId,
4060
+ clientSecret: typeof google.clientSecret === "string" ? google.clientSecret.trim() : runtime.current.oauth.google.clientSecret,
4061
+ redirectPort: typeof google.redirectPort === "number" ? google.redirectPort : runtime.current.oauth.google.redirectPort,
4062
+ scopes: runtime.current.oauth.google.scopes
4063
+ },
4064
+ microsoft: {
4065
+ clientId: typeof microsoft.clientId === "string" ? microsoft.clientId.trim() : runtime.current.oauth.microsoft.clientId,
4066
+ scopes: runtime.current.oauth.microsoft.scopes
4067
+ }
4068
+ };
4069
+ }
3599
4070
  if (payload.projects !== void 0) {
3600
4071
  const root = String(asObject(payload.projects).root ?? "").trim();
3601
4072
  if (root === "") throw new HttpError(400, "\u9879\u76EE\u76EE\u5F55\u4E0D\u80FD\u4E3A\u7A7A");
@@ -3744,7 +4215,8 @@ function settingsView(config) {
3744
4215
  },
3745
4216
  read: { markOnOpen: config.read.markOnOpen, pollSeconds: config.read.pollSeconds },
3746
4217
  render: { listLimit: config.render.listLimit },
3747
- projects: { root: config.projects.root }
4218
+ projects: { root: config.projects.root },
4219
+ oauth: config.oauth
3748
4220
  };
3749
4221
  }
3750
4222
  function taskView(task) {
@@ -4121,6 +4593,32 @@ var CredentialManager = class {
4121
4593
  this.session.delete(account.id);
4122
4594
  this.secrets.delete(account.keychainService, account.email);
4123
4595
  }
4596
+ oauthService(account) {
4597
+ return `${account.keychainService}:oauth`;
4598
+ }
4599
+ readOAuth(account) {
4600
+ const raw = this.secrets.read(this.oauthService(account), account.email);
4601
+ if (raw === void 0 || raw.trim() === "") return void 0;
4602
+ try {
4603
+ const parsed = JSON.parse(raw);
4604
+ if (typeof parsed.accessToken !== "string" || typeof parsed.refreshToken !== "string") return void 0;
4605
+ return {
4606
+ accessToken: parsed.accessToken,
4607
+ refreshToken: parsed.refreshToken,
4608
+ expiresAt: Number(parsed.expiresAt ?? 0),
4609
+ ...typeof parsed.scope === "string" ? { scope: parsed.scope } : {},
4610
+ ...typeof parsed.tokenType === "string" ? { tokenType: parsed.tokenType } : {}
4611
+ };
4612
+ } catch {
4613
+ return void 0;
4614
+ }
4615
+ }
4616
+ writeOAuth(account, tokens) {
4617
+ return this.secrets.write(this.oauthService(account), account.email, JSON.stringify(tokens));
4618
+ }
4619
+ forgetOAuth(account) {
4620
+ this.secrets.delete(this.oauthService(account), account.email);
4621
+ }
4124
4622
  /** Read an arbitrary secret (for example a CalDAV app password). */
4125
4623
  readSecret(service, account) {
4126
4624
  return this.secrets.read(service, account);
@@ -4685,7 +5183,9 @@ var DraftStore = class {
4685
5183
  data: text(file.data)
4686
5184
  })) : [],
4687
5185
  inReplyTo: text(input.inReplyTo),
4688
- references: text(input.references)
5186
+ references: text(input.references),
5187
+ ...input.remoteUid === void 0 ? existing?.remoteUid === void 0 ? {} : { remoteUid: existing.remoteUid } : { remoteUid: Number(input.remoteUid) },
5188
+ ...input.remoteMailbox === void 0 ? existing?.remoteMailbox === void 0 ? {} : { remoteMailbox: existing.remoteMailbox } : { remoteMailbox: text(input.remoteMailbox) }
4689
5189
  };
4690
5190
  if (existing !== void 0) {
4691
5191
  Object.assign(existing, fields, { updatedAt: now2 });
@@ -4909,16 +5409,69 @@ var MailHarborRuntime = class {
4909
5409
  paths(account) {
4910
5410
  return accountRuntime(account);
4911
5411
  }
5412
+ /** OAuth app configuration for one account's provider. */
5413
+ oauthApp(account) {
5414
+ if (account.auth.type !== "oauth2") throw new Error("\u8FD9\u4E2A\u8D26\u53F7\u4E0D\u662F OAuth2 \u8D26\u53F7");
5415
+ const provider = account.auth.provider;
5416
+ const settings = this.current.oauth;
5417
+ if (provider === "google") {
5418
+ if (settings.google.clientId === "") throw new Error("\u8BF7\u5148\u5728\u8BBE\u7F6E\u91CC\u586B\u5199 Google OAuth Client ID");
5419
+ return {
5420
+ provider,
5421
+ clientId: settings.google.clientId,
5422
+ clientSecret: settings.google.clientSecret,
5423
+ redirectPort: settings.google.redirectPort,
5424
+ scopes: settings.google.scopes.length > 0 ? settings.google.scopes : ["https://mail.google.com/"]
5425
+ };
5426
+ }
5427
+ if (settings.microsoft.clientId === "") throw new Error("\u8BF7\u5148\u5728\u8BBE\u7F6E\u91CC\u586B\u5199 Microsoft OAuth Client ID");
5428
+ return {
5429
+ provider,
5430
+ clientId: settings.microsoft.clientId,
5431
+ clientSecret: "",
5432
+ redirectPort: 0,
5433
+ scopes: settings.microsoft.scopes.length > 0 ? settings.microsoft.scopes : [
5434
+ "offline_access",
5435
+ "https://outlook.office.com/IMAP.AccessAsUser.All",
5436
+ "https://outlook.office.com/SMTP.Send"
5437
+ ]
5438
+ };
5439
+ }
5440
+ oauthTokens(account) {
5441
+ return this.credentials.readOAuth(account);
5442
+ }
5443
+ saveOAuthTokens(account, tokens) {
5444
+ this.credentials.writeOAuth(account, tokens);
5445
+ this.invalidate(account.id);
5446
+ }
5447
+ /** Refresh when the access token is close to expiring; safe to call often. */
5448
+ async refreshOAuth(account) {
5449
+ if (account.auth.type !== "oauth2") return void 0;
5450
+ const tokens = this.credentials.readOAuth(account);
5451
+ if (tokens === void 0) return void 0;
5452
+ if (tokens.expiresAt - Date.now() > 10 * 6e4) return tokens;
5453
+ const refreshed = await refreshTokens(this.oauthApp(account), tokens.refreshToken);
5454
+ this.saveOAuthTokens(account, refreshed);
5455
+ return refreshed;
5456
+ }
4912
5457
  /** Credentials for an account, with a clear message when the user must log in. */
4913
5458
  credentialsFor(account) {
4914
5459
  if (account.auth.type === "oauth2") {
4915
- 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");
5460
+ const tokens = this.credentials.readOAuth(account);
5461
+ if (tokens === void 0) throw new Error("\u8BF7\u5148\u5B8C\u6210 OAuth \u6388\u6743");
5462
+ if (tokens.expiresAt !== 0 && tokens.expiresAt <= Date.now()) {
5463
+ throw new Error("OAuth \u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u6388\u6743");
5464
+ }
5465
+ return { user: account.username || account.email, accessToken: tokens.accessToken };
4916
5466
  }
4917
5467
  const resolved = this.credentials.resolve(account);
4918
5468
  if (resolved === void 0) throw new Error("\u8BF7\u5148\u767B\u5F55\u90AE\u7BB1\u8D26\u53F7");
4919
5469
  return { user: account.username || account.email, pass: resolved.password };
4920
5470
  }
4921
5471
  hasCredentials(account) {
5472
+ if (account.auth.type === "oauth2") {
5473
+ return this.credentials.readOAuth(account) !== void 0;
5474
+ }
4922
5475
  return this.credentials.resolve(account) !== void 0;
4923
5476
  }
4924
5477
  imapSettings(account) {
@@ -5481,6 +6034,15 @@ function apply(ctx) {
5481
6034
  }
5482
6035
  const handler = createApiHandler(runtime);
5483
6036
  const disposers = [];
6037
+ const refreshOAuth = () => {
6038
+ for (const account of runtime.current.accounts) {
6039
+ if (account.auth.type !== "oauth2" || !runtime.hasCredentials(account)) continue;
6040
+ void runtime.refreshOAuth(account).catch(() => void 0);
6041
+ }
6042
+ };
6043
+ refreshOAuth();
6044
+ const oauthTimer = setInterval(refreshOAuth, 15 * 6e4);
6045
+ disposers.push(() => clearInterval(oauthTimer));
5484
6046
  const start = () => {
5485
6047
  disposers.push(ctx.webServer.register({ kind: "prefix", path: ROUTE, handler }));
5486
6048
  if (ctx.tools !== void 0) disposers.push(...registerMailTools(ctx.tools, runtime));