dsh-mishu 0.5.1 → 0.6.2
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/README.md +20 -4
- package/lib/client.js +576 -275
- package/lib/core/github/device.js +129 -0
- package/lib/core/github/token.js +2 -0
- package/lib/core/index.js +6 -4
- package/lib/core/mail/account.js +61 -5
- package/lib/core/mail/sync.js +34 -5
- package/lib/core/restart.js +179 -0
- package/lib/core/secrets.js +102 -2
- package/lib/core/settings.js +2 -2
- package/lib/dsh/api.js +44 -5
- package/lib/dsh/config.js +2 -1
- package/lib/dsh/runtime.js +8 -3
- package/lib/types/core/github/device.d.ts +64 -0
- package/lib/types/core/github/token.d.ts +2 -0
- package/lib/types/core/index.d.ts +6 -3
- package/lib/types/core/mail/account.d.ts +25 -1
- package/lib/types/core/mail/sync.d.ts +8 -2
- package/lib/types/core/restart.d.ts +88 -0
- package/lib/types/core/secrets.d.ts +50 -4
- package/lib/types/dsh/config.d.ts +2 -0
- package/lib/types/dsh/runtime.d.ts +10 -1
- package/package.json +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Sign in with GitHub" without pasting a token: GitHub's device flow.
|
|
3
|
+
*
|
|
4
|
+
* The secretary asks GitHub for a short code, you enter it at
|
|
5
|
+
* github.com/login/device (on any computer) and approve, and the secretary
|
|
6
|
+
* receives a token, checks it like a pasted one and keeps it in the
|
|
7
|
+
* keychain (or the secrets file). It needs the client id of a GitHub OAuth
|
|
8
|
+
* App with device flow enabled; the id is public, there is no secret.
|
|
9
|
+
*
|
|
10
|
+
* https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
|
|
11
|
+
*/
|
|
12
|
+
import { bi, MishuError } from "../i18n.js";
|
|
13
|
+
import { saveGithubToken } from "./token.js";
|
|
14
|
+
/** Notifications, and repo so CI runs, pull requests and issues of private repositories can be looked up. */
|
|
15
|
+
export const DEVICE_SCOPES = 'notifications repo';
|
|
16
|
+
export class GithubDeviceLogin {
|
|
17
|
+
options;
|
|
18
|
+
current = { state: 'idle' };
|
|
19
|
+
/** Bumped by every start and cancel, so an older poll loop stops quietly. */
|
|
20
|
+
generation = 0;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
}
|
|
24
|
+
status() {
|
|
25
|
+
if (this.current.state === 'waiting' && this.now() > this.current.expiresAt) {
|
|
26
|
+
this.current = { state: 'failed', message: EXPIRED };
|
|
27
|
+
}
|
|
28
|
+
return this.current;
|
|
29
|
+
}
|
|
30
|
+
cancel() {
|
|
31
|
+
this.generation++;
|
|
32
|
+
this.current = { state: 'idle' };
|
|
33
|
+
}
|
|
34
|
+
/** Ask GitHub for a code; polling for your approval runs in the background. */
|
|
35
|
+
async start() {
|
|
36
|
+
if (this.options.clientId === '') {
|
|
37
|
+
throw new MishuError(bi('还没有配置 GitHub 应用,没法用 GitHub 登录。可以改用下面的"粘贴 token"。', 'No GitHub app is configured, so signing in with GitHub is not available. Paste a token below instead.'));
|
|
38
|
+
}
|
|
39
|
+
const generation = ++this.generation;
|
|
40
|
+
const answer = await this.form('https://github.com/login/device/code', { client_id: this.options.clientId, scope: DEVICE_SCOPES });
|
|
41
|
+
const deviceCode = String(answer.device_code ?? '');
|
|
42
|
+
const userCode = String(answer.user_code ?? '');
|
|
43
|
+
if (deviceCode === '' || userCode === '')
|
|
44
|
+
throw new MishuError(failure(answer));
|
|
45
|
+
const expiresAt = this.now() + Number(answer.expires_in ?? 900) * 1000;
|
|
46
|
+
this.current = { state: 'waiting', userCode, verificationUri: String(answer.verification_uri ?? 'https://github.com/login/device'), expiresAt };
|
|
47
|
+
void this.poll(generation, deviceCode, Number(answer.interval ?? 5), expiresAt);
|
|
48
|
+
return this.current;
|
|
49
|
+
}
|
|
50
|
+
async poll(generation, deviceCode, interval, expiresAt) {
|
|
51
|
+
const stale = () => generation !== this.generation;
|
|
52
|
+
try {
|
|
53
|
+
while (!stale()) {
|
|
54
|
+
await this.sleep(interval * 1000);
|
|
55
|
+
if (stale())
|
|
56
|
+
return;
|
|
57
|
+
if (this.now() > expiresAt) {
|
|
58
|
+
this.current = { state: 'failed', message: EXPIRED };
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const answer = await this.form('https://github.com/login/oauth/access_token', {
|
|
62
|
+
client_id: this.options.clientId,
|
|
63
|
+
device_code: deviceCode,
|
|
64
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
65
|
+
});
|
|
66
|
+
if (stale())
|
|
67
|
+
return;
|
|
68
|
+
if (typeof answer.access_token === 'string' && answer.access_token !== '') {
|
|
69
|
+
const { login } = await saveGithubToken(this.options.secrets, answer.access_token, this.options.fetch);
|
|
70
|
+
if (!stale())
|
|
71
|
+
this.current = { state: 'done', login };
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
switch (answer.error) {
|
|
75
|
+
case 'authorization_pending':
|
|
76
|
+
break;
|
|
77
|
+
case 'slow_down':
|
|
78
|
+
interval = Number(answer.interval ?? interval + 5);
|
|
79
|
+
break;
|
|
80
|
+
case 'expired_token':
|
|
81
|
+
this.current = { state: 'failed', message: EXPIRED };
|
|
82
|
+
return;
|
|
83
|
+
case 'access_denied':
|
|
84
|
+
this.current = { state: 'failed', message: bi('你在 GitHub 上取消了授权。', 'You cancelled the authorization on GitHub.') };
|
|
85
|
+
return;
|
|
86
|
+
default:
|
|
87
|
+
this.current = { state: 'failed', message: failure(answer) };
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (!stale())
|
|
94
|
+
this.current = { state: 'failed', message: error instanceof MishuError ? error.text : bi(`连不上 GitHub:${error.message}`, `Could not reach GitHub: ${error.message}`) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async form(url, fields) {
|
|
98
|
+
const post = this.options.post ?? globalThis.fetch;
|
|
99
|
+
const response = await post(url, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded', 'user-agent': 'dsh-mishu' },
|
|
102
|
+
body: new URLSearchParams(fields).toString(),
|
|
103
|
+
});
|
|
104
|
+
const text = await response.text();
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { error: `http_${response.status}`, error_description: text.slice(0, 200) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
now() {
|
|
113
|
+
return (this.options.now ?? Date.now)();
|
|
114
|
+
}
|
|
115
|
+
sleep(ms) {
|
|
116
|
+
return (this.options.sleep ?? (wait => new Promise(resolve => setTimeout(resolve, wait))))(ms);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const EXPIRED = bi('登录码过期了,再点一次"用 GitHub 登录"。', 'The sign-in code expired; click "Sign in with GitHub" again.');
|
|
120
|
+
function failure(answer) {
|
|
121
|
+
const what = String(answer.error_description ?? answer.error ?? 'unknown error');
|
|
122
|
+
if (answer.error === 'http_404' || /^not found$/i.test(String(answer.error ?? answer.message ?? ''))) {
|
|
123
|
+
return bi('GitHub 找不到这个应用:Client ID 不对。', 'GitHub does not know this app: the client id is wrong.');
|
|
124
|
+
}
|
|
125
|
+
if (answer.error === 'device_flow_disabled') {
|
|
126
|
+
return bi('这个 GitHub 应用没有打开"设备登录"(Enable Device Flow)。', 'This GitHub app does not have device flow enabled.');
|
|
127
|
+
}
|
|
128
|
+
return bi(`GitHub 登录没成功:${what}`, `Signing in with GitHub failed: ${what}`);
|
|
129
|
+
}
|
package/lib/core/github/token.js
CHANGED
|
@@ -12,6 +12,8 @@ import { bi, MishuError } from "../i18n.js";
|
|
|
12
12
|
import { GitHubClient, GitHubError } from "./client.js";
|
|
13
13
|
/** The keychain entry for a token pasted on the settings page. */
|
|
14
14
|
export const GITHUB_SECRET = 'github';
|
|
15
|
+
/** The OAuth App behind "Sign in with GitHub" (device flow, "DSH 小秘书"); public, not a secret. */
|
|
16
|
+
export const GITHUB_CLIENT_ID = 'Ov23liejlNOyARQKgy5x';
|
|
15
17
|
/** Opens GitHub's "new classic token" page with the two scopes ticked. */
|
|
16
18
|
export const NEW_TOKEN_URL = 'https://github.com/settings/tokens/new?scopes=notifications,repo&description=DSH%20mishu';
|
|
17
19
|
/** `ghPath` is for tests: a fixed path, or `null` for "not installed". */
|
package/lib/core/index.js
CHANGED
|
@@ -11,19 +11,21 @@ export { GitHubClient, GitHubError } from "./github/client.js";
|
|
|
11
11
|
export { gatherFacts, Lookups, parseCheckSuiteTitle } from "./github/facts.js";
|
|
12
12
|
export { CLOSING_REASONS, judge } from "./github/rules.js";
|
|
13
13
|
export { syncGithub } from "./github/sync.js";
|
|
14
|
-
export {
|
|
14
|
+
export { DEVICE_SCOPES, GithubDeviceLogin } from "./github/device.js";
|
|
15
|
+
export { deleteGithubToken, GITHUB_CLIENT_ID, GITHUB_SECRET, NEW_TOKEN_URL, resolveGithubToken, saveGithubToken } from "./github/token.js";
|
|
15
16
|
export { bi, decode, detectLang, errorText, LANGS, MishuError, pick } from "./i18n.js";
|
|
16
17
|
export { addSeries, describeActiveSeries, describeSeries, resolveSeries, stopSeries, tickManual, upcoming, } from "./manual.js";
|
|
17
18
|
export { DEFAULT_DEMOTION, isActive, isDemoted, setStatusByUser, snoozeByUser } from "./matters.js";
|
|
18
19
|
export { CATEGORIES, CATEGORY_LABELS, DEFAULT_LEAD_DAYS, REPEATS } from "./recur.js";
|
|
19
20
|
export { MishuStore } from "./store.js";
|
|
20
21
|
export { addDays, dayOf, formatMoment } from "./time.js";
|
|
21
|
-
export { MAIL_PRESETS, presetById, secretAccount } from "./mail/account.js";
|
|
22
|
+
export { MAIL_PRESETS, normalizePassword, presetById, presetForAddress, providerMismatch, secretAccount } from "./mail/account.js";
|
|
22
23
|
export { ImapFetcher } from "./mail/imap.js";
|
|
23
24
|
export { judgeMail } from "./mail/rules.js";
|
|
24
25
|
export { describeMailError, MAIL_SOURCE, mailReportKey, syncMail } from "./mail/sync.js";
|
|
25
26
|
export { findProgram } from "./exec.js";
|
|
26
|
-
export {
|
|
27
|
+
export { restartPlan, scheduleRestart } from "./restart.js";
|
|
28
|
+
export { FileSecrets, KeychainOrFile, MemorySecrets, secretEnvName, secretFromEnv, systemSecrets } from "./secrets.js";
|
|
27
29
|
export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings } from "./settings.js";
|
|
28
30
|
export const DEFAULT_HOME = '~/.mishu';
|
|
29
31
|
/** `~` and `~/…` expand to the home directory on every platform. */
|
|
@@ -36,5 +38,5 @@ export function expandHome(path) {
|
|
|
36
38
|
}
|
|
37
39
|
export function mishuPaths(home) {
|
|
38
40
|
const root = expandHome(home);
|
|
39
|
-
return { root, db: join(root, 'mishu.db') };
|
|
41
|
+
return { root, db: join(root, 'mishu.db'), secrets: join(root, 'secrets.json') };
|
|
40
42
|
}
|
package/lib/core/mail/account.js
CHANGED
|
@@ -6,37 +6,93 @@
|
|
|
6
6
|
* system keychain under `mail:<id>`, never in the notebook.
|
|
7
7
|
*/
|
|
8
8
|
import { bi, MishuError } from "../i18n.js";
|
|
9
|
+
/** Steps, and the same steps as one numbered paragraph. */
|
|
10
|
+
function guide(steps) {
|
|
11
|
+
return {
|
|
12
|
+
steps,
|
|
13
|
+
howTo: bi(steps.map((step, i) => `${i + 1}. ${step.text.zh}`).join(' '), steps.map((step, i) => `${i + 1}. ${step.text.en}`).join(' ')),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const NETEASE = (name, url) => guide([
|
|
17
|
+
{ text: bi(`打开 ${name} 邮箱网页版并登录。`, `Open ${name} Mail on the web and sign in.`), link: { url, label: bi(`打开 ${name} 邮箱`, `Open ${name} Mail`) } },
|
|
18
|
+
{ text: bi('点上方的「设置」→「POP3/SMTP/IMAP」,开启「IMAP/SMTP 服务」。', 'Click Settings → POP3/SMTP/IMAP at the top and turn on "IMAP/SMTP".') },
|
|
19
|
+
{ text: bi('按提示用手机验证,页面会给你一个「客户端授权码」,复制后粘贴到下面的密码框(不是邮箱登录密码)。', 'Verify with your phone as asked; the page shows a client authorization code. Copy it into the password box below (not your mail password).') },
|
|
20
|
+
]);
|
|
9
21
|
export const MAIL_PRESETS = [
|
|
10
22
|
{
|
|
11
23
|
id: 'qq', label: bi('QQ 邮箱', 'QQ Mail'), host: 'imap.qq.com', port: 993, secure: true, supported: true,
|
|
12
|
-
|
|
24
|
+
domains: ['qq.com', 'vip.qq.com', 'foxmail.com'], passwordLength: 16,
|
|
25
|
+
...guide([
|
|
26
|
+
{ text: bi('打开 QQ 邮箱网页版并登录。', 'Open QQ Mail on the web and sign in.'), link: { url: 'https://mail.qq.com/', label: bi('打开 QQ 邮箱', 'Open QQ Mail') } },
|
|
27
|
+
{ text: bi('点「设置」→「账号与安全」→「安全设置」(旧版界面是「设置」→「账户」),找到「IMAP/SMTP 服务」,点「开启」。', 'Click Settings → Account and Security → Security (Settings → Account in the older layout), find "IMAP/SMTP" and turn it on.') },
|
|
28
|
+
{ text: bi('按提示用手机验证,页面会显示一个 16 位授权码,复制后粘贴到下面的密码框(不是 QQ 密码)。改过 QQ 密码后,旧授权码会失效。', 'Verify with your phone as asked; the page shows a 16-character authorization code. Copy it into the password box below (not your QQ password). Changing your QQ password expires old codes.') },
|
|
29
|
+
]),
|
|
13
30
|
},
|
|
14
31
|
{
|
|
15
32
|
id: '163', label: bi('网易 163 邮箱', 'NetEase 163'), host: 'imap.163.com', port: 993, secure: true, supported: true,
|
|
16
|
-
|
|
33
|
+
domains: ['163.com'], ...NETEASE('163', 'https://mail.163.com/'),
|
|
17
34
|
},
|
|
18
35
|
{
|
|
19
36
|
id: '126', label: bi('网易 126 邮箱', 'NetEase 126'), host: 'imap.126.com', port: 993, secure: true, supported: true,
|
|
20
|
-
|
|
37
|
+
domains: ['126.com'], ...NETEASE('126', 'https://mail.126.com/'),
|
|
21
38
|
},
|
|
22
39
|
{
|
|
23
40
|
id: 'gmail', label: bi('Gmail', 'Gmail'), host: 'imap.gmail.com', port: 993, secure: true, supported: true,
|
|
24
|
-
|
|
41
|
+
domains: ['gmail.com', 'googlemail.com'], passwordLength: 16,
|
|
42
|
+
...guide([
|
|
43
|
+
{
|
|
44
|
+
text: bi('开启 Google 账号的两步验证(已经开了就跳过这步)。', 'Turn on 2-Step Verification for your Google account (skip this if it is on).'),
|
|
45
|
+
link: { url: 'https://myaccount.google.com/signinoptions/twosv', label: bi('打开两步验证', 'Open 2-Step Verification') },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
text: bi('打开「应用专用密码」页面,名字随便填(比如"小秘书"),点「创建」。如果页面说"设置不可用",说明第 1 步还没做。', 'Open the App passwords page, type any name ("secretary") and click Create. If the page says the setting is not available, step 1 is not done yet.'),
|
|
49
|
+
link: { url: 'https://myaccount.google.com/apppasswords', label: bi('打开应用专用密码', 'Open App passwords') },
|
|
50
|
+
},
|
|
51
|
+
{ text: bi('把弹出来的 16 位密码复制,粘贴到下面的密码框(带不带空格都行)。它只显示这一次。', 'Copy the 16-character password it shows into the password box below (spaces or not). It is shown only once.') },
|
|
52
|
+
]),
|
|
25
53
|
findMessage: id => `https://mail.google.com/mail/u/0/#search/rfc822msgid:${encodeURIComponent(id)}`,
|
|
26
54
|
},
|
|
27
55
|
{
|
|
28
56
|
id: 'icloud', label: bi('iCloud 邮件', 'iCloud Mail'), host: 'imap.mail.me.com', port: 993, secure: true, supported: true,
|
|
29
|
-
|
|
57
|
+
domains: ['icloud.com', 'me.com', 'mac.com'],
|
|
58
|
+
...guide([
|
|
59
|
+
{ text: bi('打开 Apple 账户页面并登录。', 'Open your Apple Account page and sign in.'), link: { url: 'https://account.apple.com/account/manage', label: bi('打开 Apple 账户', 'Open Apple Account') } },
|
|
60
|
+
{ text: bi('点「登录与安全」→「App 专用密码」,点「+」,名字随便填。', 'Click Sign-In and Security → App-Specific Passwords, click +, and type any name.') },
|
|
61
|
+
{ text: bi('把显示的密码(xxxx-xxxx-xxxx-xxxx)复制,粘贴到下面的密码框。', 'Copy the password it shows (xxxx-xxxx-xxxx-xxxx) into the password box below.') },
|
|
62
|
+
]),
|
|
30
63
|
},
|
|
31
64
|
{
|
|
32
65
|
id: 'outlook', label: bi('Outlook / Hotmail', 'Outlook / Hotmail'), host: 'outlook.office365.com', port: 993, secure: true, supported: false,
|
|
66
|
+
domains: ['outlook.com', 'hotmail.com', 'live.com', 'msn.com'], steps: [],
|
|
33
67
|
howTo: bi('微软已经不允许用密码登录 IMAP,需要 OAuth 授权,小秘书暂时还不支持。', 'Microsoft no longer allows password logins over IMAP; it needs OAuth, which the secretary does not support yet.'),
|
|
34
68
|
},
|
|
35
69
|
{
|
|
36
70
|
id: 'custom', label: bi('其他(自己填服务器)', 'Other (enter the server)'), host: '', port: 993, secure: true, supported: true,
|
|
71
|
+
domains: [], steps: [],
|
|
37
72
|
howTo: bi('填你的邮件服务商给的 IMAP 服务器、端口和密码(很多服务商要求用专门的客户端授权码)。', 'Enter the IMAP server, port and password your provider gives you (many require a separate app password).'),
|
|
38
73
|
},
|
|
39
74
|
];
|
|
75
|
+
/** The provider an address belongs to, from its domain; `undefined` when we do not know it. */
|
|
76
|
+
export function presetForAddress(address) {
|
|
77
|
+
const domain = address.trim().toLowerCase().split('@')[1] ?? '';
|
|
78
|
+
return MAIL_PRESETS.find(p => p.domains.includes(domain));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The password as the server wants it. App passwords and codes are shown in
|
|
82
|
+
* groups ("abcd efgh ijkl mnop") and copied with the spaces; they never
|
|
83
|
+
* contain one, so spaces go when the provider fixes the length.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizePassword(presetId, password) {
|
|
86
|
+
return presetById(presetId).passwordLength === undefined ? password : password.replace(/\s+/g, '');
|
|
87
|
+
}
|
|
88
|
+
/** An account whose address belongs to another provider than its server ("…@gmail.com" on imap.qq.com). */
|
|
89
|
+
export function providerMismatch(account) {
|
|
90
|
+
const expected = presetForAddress(account.address);
|
|
91
|
+
if (expected === undefined || expected.id === account.preset || account.preset === 'custom')
|
|
92
|
+
return undefined;
|
|
93
|
+
const domain = account.address.split('@')[1];
|
|
94
|
+
return bi(`地址是 ${domain},服务器却是 ${presetById(account.preset).label.zh}的 ${account.host}。点「编辑」把服务商改成「${expected.label.zh}」。`, `The address is ${domain} but the server is ${account.host} (${presetById(account.preset).label.en}). Click Edit and choose "${expected.label.en}".`);
|
|
95
|
+
}
|
|
40
96
|
export function presetById(id) {
|
|
41
97
|
return MAIL_PRESETS.find(p => p.id === id) ?? MAIL_PRESETS[MAIL_PRESETS.length - 1];
|
|
42
98
|
}
|
package/lib/core/mail/sync.js
CHANGED
|
@@ -11,15 +11,44 @@ import { bi } from "../i18n.js";
|
|
|
11
11
|
import { presetById } from "./account.js";
|
|
12
12
|
import { judgeMail } from "./rules.js";
|
|
13
13
|
export const MAIL_SOURCE = 'mail';
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/** What some providers' refusals mean, in words you can act on. */
|
|
15
|
+
const KNOWN_REFUSALS = [
|
|
16
|
+
{
|
|
17
|
+
presets: ['gmail'], match: /application-specific password required/i,
|
|
18
|
+
text: bi('Google 不接受账号的登录密码:这里要填「应用专用密码」(16 位)。按步骤第 1、2 步生成一个,大约一分钟。', 'Google does not take your account password here: it needs an app password (16 characters). Steps 1 and 2 make one in about a minute.'),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
presets: ['gmail'], match: /invalid credentials/i,
|
|
22
|
+
text: bi('Google 不认这个应用专用密码:可能抄错了,或者已经在 Google 账号里删掉了。按步骤第 2 步重新生成一个再试。', 'Google does not accept this app password: it may be mistyped or deleted from your Google account. Make a new one with step 2 and try again.'),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
presets: ['gmail'], match: /web login required|log in via your web browser/i,
|
|
26
|
+
text: bi('Google 拦下了这次登录:先在浏览器里登录一次 Gmail,再回来试。', 'Google blocked this sign-in: sign in to Gmail in a browser once, then try again.'),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
presets: ['qq'], match: /login fail/i,
|
|
30
|
+
text: bi('QQ 邮箱不让登录:最常见是还没开启 IMAP 服务,或者填的是 QQ 密码而不是授权码;改过 QQ 密码后,旧授权码也会失效。', 'QQ Mail refused the sign-in: usually IMAP is not turned on, or this is your QQ password instead of the authorization code; changing your QQ password also expires old codes.'),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
presets: ['163', '126'], match: /unsafe login/i,
|
|
34
|
+
text: bi('网易邮箱拦下了这次登录:确认设置里已开启 IMAP 服务,并且填的是客户端授权码。', 'NetEase Mail blocked this sign-in: check that IMAP is turned on in its settings and that this is the client authorization code.'),
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* A connection or login failure, said so a person knows what to fix. With
|
|
39
|
+
* `steps` (the default) a login failure also repeats where the provider's
|
|
40
|
+
* app password comes from; the add-account dialog shows those steps already.
|
|
41
|
+
*/
|
|
42
|
+
export function describeMailError(error, account, options = {}) {
|
|
16
43
|
const raw = error.responseText ?? error.message ?? String(error);
|
|
17
|
-
const
|
|
44
|
+
const known = KNOWN_REFUSALS.find(k => k.presets.includes(account.preset) && k.match.test(raw));
|
|
45
|
+
const auth = known !== undefined || /auth|login|password|credential|AUTHENTICATIONFAILED|LOGIN failed/i.test(raw);
|
|
18
46
|
if (!auth)
|
|
19
47
|
return bi(`连接 ${account.host}:${account.port} 失败:${raw}`, `Could not reach ${account.host}:${account.port}: ${raw}`);
|
|
20
48
|
const preset = presetById(account.preset);
|
|
21
|
-
const
|
|
22
|
-
|
|
49
|
+
const steps = options.steps !== false && preset.id !== 'custom' ? preset.howTo : undefined;
|
|
50
|
+
const what = known?.text ?? bi('登录失败:服务器不接受这个用户名或密码。多数邮箱要先开启 IMAP,再填客户端授权码或应用专用密码,不是网页登录密码。', 'Login failed: the server rejected the user name or password. Most providers need IMAP turned on and an app password, not your web password.');
|
|
51
|
+
return bi(`${what.zh}(服务器原话:${raw})${steps === undefined ? '' : `\n${preset.label.zh}:${steps.zh}`}`, `${what.en} (The server said: ${raw})${steps === undefined ? '' : `\n${preset.label.en}: ${steps.en}`}`);
|
|
23
52
|
}
|
|
24
53
|
export function mailReportKey(accountId) {
|
|
25
54
|
return `mail.lastSync.${accountId}`;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restarting DSH after plugins were reinstalled or upgraded.
|
|
3
|
+
*
|
|
4
|
+
* In the desktop app, DSH runs as a child of the app's main process
|
|
5
|
+
* (Electron as Node, `ELECTRON_RUN_AS_NODE=1`). The `dsh` command runs on
|
|
6
|
+
* the same binary the same way, so the test is the parent: only when the
|
|
7
|
+
* parent process IS this app binary is there an app to restart (from a
|
|
8
|
+
* terminal the parent is a shell, and `__CFBundleIdentifier` names the
|
|
9
|
+
* terminal app — never trusted). A restart asks the app to
|
|
10
|
+
* quit the normal way — so it still asks first when a task is running — and
|
|
11
|
+
* opens it again once it is gone. That is done by a small detached helper,
|
|
12
|
+
* because this process ends with the app.
|
|
13
|
+
*
|
|
14
|
+
* macOS AppleScript `quit`, then `open -b <bundle id>` (tested)
|
|
15
|
+
* Windows `taskkill /PID` without /F (a close request), then the app again
|
|
16
|
+
* Linux SIGTERM to the app, then the app again
|
|
17
|
+
*
|
|
18
|
+
* On a server set up with dsh-vps, DSH is a child of the dsh-gate login
|
|
19
|
+
* gateway (`$GATE_HOME/gate/server.js`, run by systemd), which starts DSH
|
|
20
|
+
* again whenever it exits and picks up the new sign-in token itself. There
|
|
21
|
+
* a restart is simply DSH exiting cleanly; the page reconnects.
|
|
22
|
+
*
|
|
23
|
+
* DSH started with the `dsh` command is not restarted from here: its web
|
|
24
|
+
* address (with a new sign-in token) is printed in that terminal, which a
|
|
25
|
+
* detached replacement could not reach. The page says what to do instead.
|
|
26
|
+
*/
|
|
27
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
28
|
+
import { readFileSync, readlinkSync } from 'node:fs';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
import { join, posix } from 'node:path';
|
|
31
|
+
import { findProgram } from "./exec.js";
|
|
32
|
+
import { bi } from "./i18n.js";
|
|
33
|
+
/** `<x>.app` holding a macOS executable, if any. */
|
|
34
|
+
function appBundle(execPath) {
|
|
35
|
+
const at = execPath.indexOf('.app/');
|
|
36
|
+
return at < 0 ? undefined : execPath.slice(0, at + 4);
|
|
37
|
+
}
|
|
38
|
+
function parentExecutable(ppid, platform) {
|
|
39
|
+
try {
|
|
40
|
+
if (platform === 'linux')
|
|
41
|
+
return readlinkSync(`/proc/${ppid}/exe`);
|
|
42
|
+
if (platform === 'win32') {
|
|
43
|
+
const ps = findProgram('powershell');
|
|
44
|
+
return ps === undefined ? undefined : execFileSync(ps, ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${ppid}).Path`], { timeout: 10_000, windowsHide: true }).toString().trim();
|
|
45
|
+
}
|
|
46
|
+
return execFileSync('/bin/ps', ['-o', 'comm=', '-p', String(ppid)], { timeout: 5000 }).toString().trim();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function bundleIdOf(app) {
|
|
53
|
+
try {
|
|
54
|
+
return execFileSync('/usr/bin/defaults', ['read', `${app}/Contents/Info`, 'CFBundleIdentifier'], { timeout: 5000 }).toString().trim();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function parentArguments(ppid) {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(`/proc/${ppid}/cmdline`, 'utf8').split('\0').filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function here() {
|
|
69
|
+
const facts = {
|
|
70
|
+
env: process.env, platform: process.platform, execPath: process.execPath, ppid: process.ppid, electron: process.versions.electron,
|
|
71
|
+
parentExe: undefined, bundleId: undefined, parentArgs: undefined,
|
|
72
|
+
};
|
|
73
|
+
if (facts.platform === 'linux' && facts.env.GATE_HOME !== undefined)
|
|
74
|
+
facts.parentArgs = parentArguments(facts.ppid);
|
|
75
|
+
if (facts.electron === undefined || facts.env.ELECTRON_RUN_AS_NODE !== '1' || facts.ppid <= 1)
|
|
76
|
+
return facts;
|
|
77
|
+
facts.parentExe = parentExecutable(facts.ppid, facts.platform);
|
|
78
|
+
const app = facts.platform === 'darwin' ? appBundle(facts.execPath) : undefined;
|
|
79
|
+
if (app !== undefined)
|
|
80
|
+
facts.bundleId = bundleIdOf(app);
|
|
81
|
+
return facts;
|
|
82
|
+
}
|
|
83
|
+
function samePath(a, b, platform) {
|
|
84
|
+
return a !== undefined && (platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
85
|
+
}
|
|
86
|
+
const FROM_TERMINAL = bi('这个 DSH 是用 dsh 命令启动的,小秘书没法替它重启:回到运行 dsh 的那个终端按 Ctrl+C,再运行同一条命令。', 'This DSH was started with the dsh command, so the secretary cannot restart it: press Ctrl+C in the terminal running dsh, then run the same command again.');
|
|
87
|
+
/** How DSH can be restarted from here, or why it cannot. */
|
|
88
|
+
export function restartPlan(facts = here()) {
|
|
89
|
+
// dsh-vps: the parent must be that very gateway, not just a shell that has GATE_HOME set.
|
|
90
|
+
const gateHome = facts.env.GATE_HOME;
|
|
91
|
+
if (gateHome !== undefined && gateHome !== '') {
|
|
92
|
+
const gate = posix.join(gateHome, 'gate', 'server.js');
|
|
93
|
+
if (facts.parentArgs?.includes(gate) === true)
|
|
94
|
+
return { kind: 'gate', gate };
|
|
95
|
+
}
|
|
96
|
+
const desktop = facts.electron !== undefined && facts.env.ELECTRON_RUN_AS_NODE === '1' && facts.ppid > 1 && samePath(facts.parentExe, facts.execPath, facts.platform);
|
|
97
|
+
if (!desktop)
|
|
98
|
+
return { kind: 'none', why: FROM_TERMINAL };
|
|
99
|
+
if (facts.platform === 'darwin') {
|
|
100
|
+
const bundleId = facts.bundleId ?? '';
|
|
101
|
+
if (/^[\w.-]+$/.test(bundleId))
|
|
102
|
+
return { kind: 'macos-app', bundleId, appPid: facts.ppid };
|
|
103
|
+
return { kind: 'none', why: bi('没找到桌面版的应用标识,没法替它重启。请退出 DSH 再打开。', 'Could not tell which app this is, so it cannot be restarted from here. Quit DSH and open it again.') };
|
|
104
|
+
}
|
|
105
|
+
if (facts.platform === 'win32' || facts.platform === 'linux')
|
|
106
|
+
return { kind: 'desktop-app', platform: facts.platform, exe: facts.execPath, appPid: facts.ppid };
|
|
107
|
+
return { kind: 'none', why: bi('这个系统上没法替桌面版重启。请退出 DSH 再打开。', 'The desktop app cannot be restarted from here on this system. Quit DSH and open it again.') };
|
|
108
|
+
}
|
|
109
|
+
const MAC_TOOLS = { osascript: '/usr/bin/osascript', open: '/usr/bin/open' };
|
|
110
|
+
/**
|
|
111
|
+
* Source of the detached helper: ask the app to quit, wait until it is gone
|
|
112
|
+
* (giving up quietly when you cancel the quit), open it again, and check it
|
|
113
|
+
* came back. Every step is noted in `log`, the only account of a restart
|
|
114
|
+
* once this process is gone.
|
|
115
|
+
*
|
|
116
|
+
* The helper itself runs as Node (`ELECTRON_RUN_AS_NODE=1`), and whatever it
|
|
117
|
+
* launches inherits its environment — `open` included ("Opened applications
|
|
118
|
+
* inherit environment variables", open(1)). So the app is always started
|
|
119
|
+
* without that variable; with it, the app starts as bare Node and exits.
|
|
120
|
+
*/
|
|
121
|
+
export function restartHelperSource(plan, log, tools = MAC_TOOLS) {
|
|
122
|
+
const mac = plan.kind === 'macos-app';
|
|
123
|
+
const app = mac ? `application id ${JSON.stringify(plan.bundleId)}` : '';
|
|
124
|
+
const quit = mac
|
|
125
|
+
? `spawnSync(${JSON.stringify(tools.osascript)}, ['-e', ${JSON.stringify(`tell ${app} to quit`)}], { timeout: 180000 })`
|
|
126
|
+
: plan.platform === 'win32'
|
|
127
|
+
? `spawnSync('taskkill', ['/PID', String(pid)], { windowsHide: true })`
|
|
128
|
+
: `process.kill(pid, 'SIGTERM')`;
|
|
129
|
+
// Answers whether the app runs again; only macOS can ask, elsewhere the launch is trusted.
|
|
130
|
+
const running = mac
|
|
131
|
+
? `String(spawnSync(${JSON.stringify(tools.osascript)}, ['-e', ${JSON.stringify(`${app} is running`)}], { timeout: 10000 }).stdout).trim() === 'true'`
|
|
132
|
+
: 'true';
|
|
133
|
+
const open = mac
|
|
134
|
+
? `const r = spawnSync(${JSON.stringify(tools.open)}, ['-b', ${JSON.stringify(plan.bundleId)}], { env: clean, timeout: 60000 }); note('open exited ' + r.status + (String(r.stderr || '').trim() ? ': ' + String(r.stderr).trim() : ''))`
|
|
135
|
+
: `spawn(${JSON.stringify(plan.exe)}, [], { detached: true, stdio: 'ignore', env: clean }).unref()`;
|
|
136
|
+
return [
|
|
137
|
+
"const { spawn, spawnSync } = require('node:child_process')",
|
|
138
|
+
"const fs = require('node:fs')",
|
|
139
|
+
`const pid = ${plan.appPid}`,
|
|
140
|
+
`const log = ${JSON.stringify(log)}`,
|
|
141
|
+
"const clean = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'ELECTRON_RUN_AS_NODE'))",
|
|
142
|
+
"const note = (line) => { try { fs.appendFileSync(log, new Date().toISOString() + ' ' + line + '\\n') } catch {} }",
|
|
143
|
+
'const sleep = (ms) => new Promise(r => setTimeout(r, ms))',
|
|
144
|
+
"const alive = () => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }",
|
|
145
|
+
`const running = () => { try { return ${running} } catch { return false } }`,
|
|
146
|
+
`const open = () => { try { ${open} } catch (e) { note('could not open the app: ' + (e && e.message)) } }`,
|
|
147
|
+
'const main = async () => {',
|
|
148
|
+
// Let the page receive its answer before the app starts closing.
|
|
149
|
+
' await sleep(400)',
|
|
150
|
+
" note('asking the app (pid ' + pid + ') to quit')",
|
|
151
|
+
` try { ${quit} } catch (e) { note('quit request failed: ' + (e && e.message)) }`,
|
|
152
|
+
' const until = Date.now() + 180000',
|
|
153
|
+
' while (alive() && Date.now() < until) await sleep(300)',
|
|
154
|
+
" if (alive()) { note('the app is still running (the quit was cancelled?); not reopening'); return }",
|
|
155
|
+
' await sleep(800)',
|
|
156
|
+
' for (let attempt = 1; attempt <= 2; attempt++) {',
|
|
157
|
+
" note('opening the app (attempt ' + attempt + ')')",
|
|
158
|
+
' open()',
|
|
159
|
+
' const by = Date.now() + 20000',
|
|
160
|
+
' while (!running() && Date.now() < by) await sleep(500)',
|
|
161
|
+
" if (running()) { note('the app is running again'); return }",
|
|
162
|
+
' }',
|
|
163
|
+
" note('the app did not come back; open it yourself')",
|
|
164
|
+
'}',
|
|
165
|
+
"main().catch(e => note('helper failed: ' + (e && e.stack || e)))",
|
|
166
|
+
].join('\n');
|
|
167
|
+
}
|
|
168
|
+
/** Start the restart: under dsh-gate, exit; in the desktop app, start the helper and say where it logs. Runs with this process's Node (Electron as Node in the app). */
|
|
169
|
+
export function scheduleRestart(plan, exit = () => process.kill(process.pid, 'SIGTERM')) {
|
|
170
|
+
if (plan.kind === 'gate') {
|
|
171
|
+
// Let the page receive its answer, then leave the normal way; the gateway starts DSH again.
|
|
172
|
+
setTimeout(exit, 400);
|
|
173
|
+
return { reconnect: true };
|
|
174
|
+
}
|
|
175
|
+
const log = join(tmpdir(), `dsh-mishu-restart-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.log`);
|
|
176
|
+
const helper = spawn(process.execPath, ['-e', restartHelperSource(plan, log)], { detached: true, stdio: 'ignore', env: process.env, windowsHide: true });
|
|
177
|
+
helper.unref();
|
|
178
|
+
return { log };
|
|
179
|
+
}
|
package/lib/core/secrets.js
CHANGED
|
@@ -7,9 +7,17 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Secrets travel to the helper on stdin only, never as command-line
|
|
9
9
|
* arguments, which other processes on the machine could read. Everything is
|
|
10
|
-
* stored under the service name "dsh-mishu".
|
|
11
|
-
*
|
|
10
|
+
* stored under the service name "dsh-mishu".
|
|
11
|
+
*
|
|
12
|
+
* A computer without a usable keychain (a Linux server with no desktop,
|
|
13
|
+
* say) keeps them in `secrets.json` in the notebook directory instead:
|
|
14
|
+
* mode 600, so only your own system account can read it. That is how gh,
|
|
15
|
+
* git and Thunderbird manage on such machines, and it is said plainly on
|
|
16
|
+
* the settings page: protected by file permissions, not encrypted.
|
|
17
|
+
* `secretFromEnv` remains a last resort.
|
|
12
18
|
*/
|
|
19
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname } from 'node:path';
|
|
13
21
|
import { findProgram, runProgram } from "./exec.js";
|
|
14
22
|
import { bi } from "./i18n.js";
|
|
15
23
|
export const SERVICE = 'dsh-mishu';
|
|
@@ -168,3 +176,95 @@ export function secretFromEnv(account) {
|
|
|
168
176
|
const value = process.env[secretEnvName(account)];
|
|
169
177
|
return value === undefined || value === '' ? undefined : value;
|
|
170
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Secrets in one JSON file, readable by your system account only (600).
|
|
181
|
+
* Written to a temporary file first and renamed, so a crash never leaves
|
|
182
|
+
* half a file; a file that cannot be read is never overwritten.
|
|
183
|
+
*/
|
|
184
|
+
export class FileSecrets {
|
|
185
|
+
kind = 'file';
|
|
186
|
+
path;
|
|
187
|
+
constructor(path) { this.path = path; }
|
|
188
|
+
async status() { return { available: true }; }
|
|
189
|
+
read() {
|
|
190
|
+
if (!existsSync(this.path))
|
|
191
|
+
return {};
|
|
192
|
+
// Put the permissions back if something loosened them.
|
|
193
|
+
if (process.platform !== 'win32' && (statSync(this.path).mode & 0o077) !== 0)
|
|
194
|
+
chmodSync(this.path, 0o600);
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
throw new Error(`${this.path} cannot be read (${error.message}); fix or remove it`);
|
|
201
|
+
}
|
|
202
|
+
const secrets = parsed.secrets;
|
|
203
|
+
return typeof secrets === 'object' && secrets !== null ? { ...secrets } : {};
|
|
204
|
+
}
|
|
205
|
+
write(secrets) {
|
|
206
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
207
|
+
const temp = `${this.path}.${process.pid}.tmp`;
|
|
208
|
+
writeFileSync(temp, `${JSON.stringify({ version: 1, secrets }, null, 2)}\n`, { mode: 0o600 });
|
|
209
|
+
renameSync(temp, this.path);
|
|
210
|
+
}
|
|
211
|
+
async get(account) { return this.read()[account]; }
|
|
212
|
+
async set(account, secret) {
|
|
213
|
+
checkAccount(account);
|
|
214
|
+
checkSecret(secret);
|
|
215
|
+
this.write({ ...this.read(), [account]: secret });
|
|
216
|
+
}
|
|
217
|
+
async delete(account) {
|
|
218
|
+
const secrets = this.read();
|
|
219
|
+
if (!(account in secrets))
|
|
220
|
+
return;
|
|
221
|
+
delete secrets[account];
|
|
222
|
+
this.write(secrets);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The keychain when it works, else the file. Reads look in both, so a
|
|
227
|
+
* secret saved to the file before a keychain was set up is still found;
|
|
228
|
+
* saving to the keychain removes the file's copy.
|
|
229
|
+
*/
|
|
230
|
+
export class KeychainOrFile {
|
|
231
|
+
keychain;
|
|
232
|
+
file;
|
|
233
|
+
usable;
|
|
234
|
+
constructor(keychain, file) {
|
|
235
|
+
this.keychain = keychain;
|
|
236
|
+
this.file = file;
|
|
237
|
+
}
|
|
238
|
+
get kind() { return this.keychain.kind; }
|
|
239
|
+
keychainStatus() {
|
|
240
|
+
// Asked once per run: a missing helper does not appear while DSH runs.
|
|
241
|
+
this.usable ??= this.keychain.status().catch((error) => ({ available: false, why: bi(error.message, error.message) }));
|
|
242
|
+
return this.usable;
|
|
243
|
+
}
|
|
244
|
+
async status() {
|
|
245
|
+
const keychain = await this.keychainStatus();
|
|
246
|
+
return keychain.available ? keychain : { available: true, file: { path: this.file.path, why: keychain.why } };
|
|
247
|
+
}
|
|
248
|
+
async get(account) {
|
|
249
|
+
if ((await this.keychainStatus()).available) {
|
|
250
|
+
const stored = await this.keychain.get(account);
|
|
251
|
+
if (stored !== undefined)
|
|
252
|
+
return stored;
|
|
253
|
+
}
|
|
254
|
+
return this.file.get(account);
|
|
255
|
+
}
|
|
256
|
+
async set(account, secret) {
|
|
257
|
+
if ((await this.keychainStatus()).available) {
|
|
258
|
+
await this.keychain.set(account, secret);
|
|
259
|
+
await this.file.delete(account);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
await this.file.set(account, secret);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async delete(account) {
|
|
266
|
+
if ((await this.keychainStatus()).available)
|
|
267
|
+
await this.keychain.delete(account);
|
|
268
|
+
await this.file.delete(account);
|
|
269
|
+
}
|
|
270
|
+
}
|
package/lib/core/settings.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* password goes to the system keychain (see secrets.ts), never here.
|
|
7
7
|
*/
|
|
8
8
|
import { MishuError, bi } from "./i18n.js";
|
|
9
|
-
import { resolveAccount, secretAccount } from "./mail/account.js";
|
|
9
|
+
import { normalizePassword, resolveAccount, secretAccount } from "./mail/account.js";
|
|
10
10
|
import { MAIL_SOURCE } from "./mail/sync.js";
|
|
11
11
|
import { CATEGORIES } from "./recur.js";
|
|
12
12
|
import { secretFromEnv } from "./secrets.js";
|
|
@@ -70,7 +70,7 @@ export async function saveMailAccount(store, secrets, input, password, now) {
|
|
|
70
70
|
const account = resolveAccount({ ...existing, ...input }, id);
|
|
71
71
|
const keychain = (await secrets.status()).available;
|
|
72
72
|
if (keychain && password !== undefined && password !== '')
|
|
73
|
-
await secrets.set(secretAccount(id), password);
|
|
73
|
+
await secrets.set(secretAccount(id), normalizePassword(account.preset, password));
|
|
74
74
|
else if (keychain && existing === undefined && await secrets.get(secretAccount(id)) === undefined && secretFromEnv(secretAccount(id)) === undefined) {
|
|
75
75
|
throw new MishuError(bi('新邮箱要填密码(授权码)', 'a new account needs its password (app password)'));
|
|
76
76
|
}
|