@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/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/README.zh.md +2 -1
- package/lib/client.js +283 -7
- package/lib/client.js.map +2 -2
- package/lib/index.js +495 -8
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
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: {
|
|
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.
|
|
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
|
-
|
|
3143
|
-
if (
|
|
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
|
-
|
|
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));
|