@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/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/README.zh.md +2 -1
- package/lib/client.js +305 -24
- package/lib/client.js.map +2 -2
- package/lib/index.js +577 -15
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -128,14 +128,14 @@ function transportOptions(settings, credentials, security = {}) {
|
|
|
128
128
|
pool: false
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
|
-
function
|
|
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 =
|
|
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 =
|
|
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: {
|
|
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.
|
|
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
|
|
3035
|
-
|
|
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
|
-
|
|
3070
|
-
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) {
|
|
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
|
-
|
|
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));
|