@zaclinley/dsh-mailharbor 0.14.0 → 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
@@ -712,7 +712,12 @@ var DEFAULTS = {
712
712
  render: { listLimit: 40 },
713
713
  projects: { root: join2(homedir2(), "work", "projects") },
714
714
  oauth: {
715
- 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
+ },
716
721
  microsoft: {
717
722
  clientId: "",
718
723
  scopes: [
@@ -856,6 +861,7 @@ function loadConfig() {
856
861
  oauth: {
857
862
  google: {
858
863
  clientId: stringOr(googleRaw.clientId, DEFAULTS.oauth.google.clientId),
864
+ clientSecret: stringOr(googleRaw.clientSecret, DEFAULTS.oauth.google.clientSecret),
859
865
  redirectPort: intOr(googleRaw.redirectPort, DEFAULTS.oauth.google.redirectPort, 1024, 65535),
860
866
  scopes: Array.isArray(googleRaw.scopes) ? googleRaw.scopes.filter((item) => typeof item === "string") : DEFAULTS.oauth.google.scopes
861
867
  },
@@ -901,7 +907,7 @@ function buildAccount(input, existing) {
901
907
  username: input.username,
902
908
  imap: input.imap,
903
909
  smtp: input.smtp,
904
- auth: { type: "password" },
910
+ auth: input.auth ?? { type: "password" },
905
911
  caldav: null,
906
912
  enabled: true,
907
913
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -1012,6 +1018,26 @@ async function openPath(path) {
1012
1018
  }
1013
1019
  await run("xdg-open", [path]);
1014
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
+ }
1015
1041
 
1016
1042
  // src/host/api.ts
1017
1043
  import { existsSync as existsSync6, rmSync } from "node:fs";
@@ -1052,6 +1078,134 @@ async function draftMime(account, draft) {
1052
1078
  return Buffer.from(info.message);
1053
1079
  }
1054
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
+
1055
1209
  // src/host/backup.ts
1056
1210
  init_address();
1057
1211
  import { createHash as createHash2 } from "node:crypto";
@@ -2793,7 +2947,7 @@ ${conversation.trim()}
2793
2947
  }
2794
2948
 
2795
2949
  // src/host/version.ts
2796
- var VERSION = "0.14.0";
2950
+ var VERSION = "0.15.0";
2797
2951
 
2798
2952
  // src/host/api.ts
2799
2953
  init_smtp();
@@ -2844,6 +2998,7 @@ function createApiHandler(runtime) {
2844
2998
  }
2845
2999
  };
2846
3000
  }
3001
+ var pendingOAuth = /* @__PURE__ */ new Map();
2847
3002
  function buildRouter(runtime) {
2848
3003
  const router = new Router();
2849
3004
  router.get("/health", () => ({ ok: true, version: VERSION }));
@@ -3007,6 +3162,231 @@ function buildRouter(runtime) {
3007
3162
  }
3008
3163
  return { ok: true, count };
3009
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
+ });
3010
3390
  router.get("/messages/thread", ({ url }) => {
3011
3391
  const { account } = accountFrom(runtime, url);
3012
3392
  const subject = url.searchParams.get("subject") ?? "";
@@ -3139,8 +3519,9 @@ function buildRouter(runtime) {
3139
3519
  const password = typeof input.password === "string" ? input.password : "";
3140
3520
  const config = runtime.current;
3141
3521
  const account = buildAccount(input, config.accounts);
3142
- if (password === "") throw new HttpError(400, "\u8BF7\u8F93\u5165\u5BC6\u7801\u6216\u5E94\u7528\u4E13\u7528\u5BC6\u7801");
3143
- 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) {
3144
3525
  const provider = await verifyImap(account, password);
3145
3526
  await provider.dispose();
3146
3527
  try {
@@ -3148,7 +3529,7 @@ function buildRouter(runtime) {
3148
3529
  } catch {
3149
3530
  }
3150
3531
  }
3151
- runtime.credentials.remember(account, password, input.remember !== false);
3532
+ if (!oauth) runtime.credentials.remember(account, password, input.remember !== false);
3152
3533
  const accounts = [...config.accounts, account];
3153
3534
  saveAccounts(accounts, account.id);
3154
3535
  runtime.refresh();
@@ -3669,6 +4050,23 @@ function buildRouter(runtime) {
3669
4050
  if (payload.ai !== void 0) patch.ai = asObject(payload.ai);
3670
4051
  if (payload.read !== void 0) patch.read = asObject(payload.read);
3671
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
+ }
3672
4070
  if (payload.projects !== void 0) {
3673
4071
  const root = String(asObject(payload.projects).root ?? "").trim();
3674
4072
  if (root === "") throw new HttpError(400, "\u9879\u76EE\u76EE\u5F55\u4E0D\u80FD\u4E3A\u7A7A");
@@ -3817,7 +4215,8 @@ function settingsView(config) {
3817
4215
  },
3818
4216
  read: { markOnOpen: config.read.markOnOpen, pollSeconds: config.read.pollSeconds },
3819
4217
  render: { listLimit: config.render.listLimit },
3820
- projects: { root: config.projects.root }
4218
+ projects: { root: config.projects.root },
4219
+ oauth: config.oauth
3821
4220
  };
3822
4221
  }
3823
4222
  function taskView(task) {
@@ -4194,6 +4593,32 @@ var CredentialManager = class {
4194
4593
  this.session.delete(account.id);
4195
4594
  this.secrets.delete(account.keychainService, account.email);
4196
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
+ }
4197
4622
  /** Read an arbitrary secret (for example a CalDAV app password). */
4198
4623
  readSecret(service, account) {
4199
4624
  return this.secrets.read(service, account);
@@ -4984,16 +5409,69 @@ var MailHarborRuntime = class {
4984
5409
  paths(account) {
4985
5410
  return accountRuntime(account);
4986
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
+ }
4987
5457
  /** Credentials for an account, with a clear message when the user must log in. */
4988
5458
  credentialsFor(account) {
4989
5459
  if (account.auth.type === "oauth2") {
4990
- 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 };
4991
5466
  }
4992
5467
  const resolved = this.credentials.resolve(account);
4993
5468
  if (resolved === void 0) throw new Error("\u8BF7\u5148\u767B\u5F55\u90AE\u7BB1\u8D26\u53F7");
4994
5469
  return { user: account.username || account.email, pass: resolved.password };
4995
5470
  }
4996
5471
  hasCredentials(account) {
5472
+ if (account.auth.type === "oauth2") {
5473
+ return this.credentials.readOAuth(account) !== void 0;
5474
+ }
4997
5475
  return this.credentials.resolve(account) !== void 0;
4998
5476
  }
4999
5477
  imapSettings(account) {
@@ -5556,6 +6034,15 @@ function apply(ctx) {
5556
6034
  }
5557
6035
  const handler = createApiHandler(runtime);
5558
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));
5559
6046
  const start = () => {
5560
6047
  disposers.push(ctx.webServer.register({ kind: "prefix", path: ROUTE, handler }));
5561
6048
  if (ctx.tools !== void 0) disposers.push(...registerMailTools(ctx.tools, runtime));