@xmanrui/dsh-im 4.22.0 → 4.23.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/README.en.md +3 -3
- package/README.md +3 -3
- package/lib/client.js +2505 -1152
- package/lib/index.js +290 -299
- package/package.json +8 -1
- package/plugin-src/client/channel-card-meta.js +2 -0
- package/plugin-src/client/channel-logos.js +11 -0
- package/plugin-src/client/channels/dingtalk/api.js +5 -1
- package/plugin-src/client/channels/dingtalk/index.js +15 -13
- package/plugin-src/client/channels/email/api.js +52 -0
- package/plugin-src/client/channels/email/index.js +728 -0
- package/plugin-src/client/channels/email/styles.js +51 -0
- package/plugin-src/client/channels/feishu/api.js +4 -2
- package/plugin-src/client/channels/feishu/index.js +16 -3
- package/plugin-src/client/channels/imessage/index.js +2 -1
- package/plugin-src/client/channels/office/api.js +2 -0
- package/plugin-src/client/channels/office/index.js +6 -5
- package/plugin-src/client/channels/qq/api.js +7 -0
- package/plugin-src/client/channels/qq/index.js +16 -3
- package/plugin-src/client/channels/shared/token-api.js +5 -1
- package/plugin-src/client/channels/shared/token-channel.js +28 -3
- package/plugin-src/client/channels/slack/index.js +2 -1
- package/plugin-src/client/channels/wecom/api.js +7 -1
- package/plugin-src/client/channels/wecom/index.js +16 -3
- package/plugin-src/client/channels/wecom-app/api.js +6 -1
- package/plugin-src/client/channels/wecom-app/index.js +16 -3
- package/plugin-src/client/channels/weixin/api.js +2 -1
- package/plugin-src/client/channels/weixin/connection-error.js +1 -71
- package/plugin-src/client/channels/whatsapp/api.js +5 -0
- package/plugin-src/client/channels/whatsapp/index.js +16 -3
- package/plugin-src/client/connection-error.js +87 -0
- package/plugin-src/client/global-settings.js +81 -1
- package/plugin-src/client/i18n.js +61 -0
- package/plugin-src/client/index.js +30 -2
- package/plugin-src/client/last-message-error.js +2 -1
- package/plugin-src/client/styles.js +6 -0
- package/plugin-src/host/build.mjs +4 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +2 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
- package/plugin-src/host/channels/email/availability.mjs +60 -0
- package/plugin-src/host/channels/email/index.mjs +36 -0
- package/plugin-src/host/channels/email/production.mjs +58 -0
- package/plugin-src/host/channels/email/rpc.mjs +163 -0
- package/plugin-src/host/channels/feishu/production.mjs +3 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
- package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
- package/plugin-src/host/channels/office/rpc.mjs +3 -1
- package/plugin-src/host/channels/qq/production.mjs +2 -0
- package/plugin-src/host/channels/qq/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/production.mjs +52 -5
- package/plugin-src/host/channels/shared/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
- package/plugin-src/host/channels/shared/startup.mjs +11 -6
- package/plugin-src/host/channels/slack/production.mjs +2 -0
- package/plugin-src/host/channels/slack/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom/production.mjs +2 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom-app/production.mjs +2 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
- package/plugin-src/host/channels/weixin/production.mjs +2 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +2 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
- package/plugin-src/host/delivery-adapter.mjs +11 -0
- package/plugin-src/host/image-input-rpc.mjs +24 -0
- package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
- package/plugin-src/host/index.mjs +3 -0
- package/plugin-src/management-rpc.mjs +12 -2
- package/scripts/verify-package.mjs +3 -1
- package/src/channels/dingtalk/connection-error.mjs +5 -12
- package/src/channels/dingtalk/device-auth.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
- package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
- package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
- package/src/channels/discord/discord-api.mjs +3 -3
- package/src/channels/discord/discord-runtime.mjs +18 -12
- package/src/channels/email/config-store.mjs +194 -0
- package/src/channels/email/email-api.mjs +20 -0
- package/src/channels/email/email-bridge.mjs +17 -0
- package/src/channels/email/email-controller.mjs +824 -0
- package/src/channels/email/email-runtime.mjs +692 -0
- package/src/channels/email/harness-client.mjs +7 -0
- package/src/channels/email/mail-format.mjs +91 -0
- package/src/channels/email/state-store.mjs +176 -0
- package/src/channels/email/transport.mjs +64 -0
- package/src/channels/email/transports/agent-mail.mjs +593 -0
- package/src/channels/email/transports/agently-cli.mjs +288 -0
- package/src/channels/email/transports/imap-smtp.mjs +165 -0
- package/src/channels/feishu/bridge.mjs +8 -7
- package/src/channels/feishu/feishu-runtime.mjs +15 -8
- package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
- package/src/channels/feishu/message-utils.mjs +3 -2
- package/src/channels/feishu/multi-bot-controller.mjs +56 -47
- package/src/channels/feishu/registration-manager.mjs +8 -4
- package/src/channels/feishu/repair-manager.mjs +2 -1
- package/src/channels/imessage/runtime.mjs +9 -5
- package/src/channels/office/office-controller.mjs +23 -12
- package/src/channels/office/office-runtime.mjs +6 -3
- package/src/channels/office/office-transport.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +7 -5
- package/src/channels/qq/qq-controller.mjs +56 -42
- package/src/channels/qq/qq-runtime.mjs +16 -11
- package/src/channels/shared/bot-workspace-store.mjs +29 -10
- package/src/channels/shared/connection-error.mjs +191 -0
- package/src/channels/shared/connection-test.mjs +3 -1
- package/src/channels/shared/conversation-state-store.mjs +46 -1
- package/src/channels/shared/diagnostic-details.mjs +95 -0
- package/src/channels/shared/harness-client.mjs +63 -18
- package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
- package/src/channels/shared/i18n-en/email.mjs +25 -0
- package/src/channels/shared/i18n-en/image-input.mjs +17 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
- package/src/channels/shared/i18n-en.mjs +6 -0
- package/src/channels/shared/image-input-policy.mjs +35 -0
- package/src/channels/shared/image-input-settings-store.mjs +60 -0
- package/src/channels/shared/image-input.mjs +124 -0
- package/src/channels/shared/image-prompt.mjs +15 -7
- package/src/channels/shared/message-failure.mjs +12 -0
- package/src/channels/shared/semantic/reply-reference.mjs +3 -3
- package/src/channels/shared/text-harness-bridge.mjs +90 -11
- package/src/channels/shared/token-bot-controller.mjs +52 -47
- package/src/channels/slack/slack-api.mjs +3 -3
- package/src/channels/slack/slack-controller.mjs +59 -51
- package/src/channels/slack/slack-runtime.mjs +18 -12
- package/src/channels/telegram/telegram-api.mjs +2 -2
- package/src/channels/telegram/telegram-runtime.mjs +17 -13
- package/src/channels/wecom/wecom-bridge.mjs +15 -6
- package/src/channels/wecom/wecom-controller.mjs +51 -37
- package/src/channels/wecom/wecom-runtime.mjs +10 -6
- package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
- package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
- package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
- package/src/channels/weixin/connection-error.mjs +26 -92
- package/src/channels/weixin/diagnostic-details.mjs +1 -63
- package/src/channels/weixin/weixin-bridge.mjs +7 -5
- package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
- package/src/channels/whatsapp/whatsapp-runtime.mjs +35 -8
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { ConnectionError } from '../../connection-error.js';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { EmailLogoGlyph } from '../../channel-logos.js';
|
|
4
|
+
import { createTokenChannelSettings } from '../shared/token-channel.js';
|
|
5
|
+
import { EMAIL_ENDPOINTS, emailClientApi } from './api.js';
|
|
6
|
+
import { installEmailStyles } from './styles.js';
|
|
7
|
+
import { h } from '../../i18n.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Provider presets mirror the host-side table so the form can prefill hosts.
|
|
11
|
+
* Keeping the copy local means the panel works before any RPC round-trip.
|
|
12
|
+
*/
|
|
13
|
+
const PROVIDERS = [
|
|
14
|
+
{ key: 'qq', label: 'QQ 邮箱', imapHost: 'imap.qq.com', imapPort: 993, smtpHost: 'smtp.qq.com', smtpPort: 465 },
|
|
15
|
+
{ key: '163', label: '163 邮箱', imapHost: 'imap.163.com', imapPort: 993, smtpHost: 'smtp.163.com', smtpPort: 465 },
|
|
16
|
+
{ key: 'gmail', label: 'Gmail', imapHost: 'imap.gmail.com', imapPort: 993, smtpHost: 'smtp.gmail.com', smtpPort: 465 },
|
|
17
|
+
{ key: 'custom', label: '自定义服务器' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const PROVIDER_HINTS = {
|
|
21
|
+
qq: '在 QQ 邮箱「设置 → 账户」中开启 IMAP/SMTP 服务,并生成 16 位授权码(不是登录密码)。',
|
|
22
|
+
'163': '在 163 邮箱「设置 → POP3/SMTP/IMAP」中开启服务,并新增授权密码。',
|
|
23
|
+
gmail: '需要先开启两步验证,再生成「应用专用密码」。',
|
|
24
|
+
custom: '请填写邮箱服务商提供的 IMAP 与 SMTP 服务器地址及端口。',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How the mailbox is reached. Mirrors the Host's EMAIL_TRANSPORTS: the choice
|
|
29
|
+
* decides which fields the form asks for, so one channel serves every mail
|
|
30
|
+
* protocol instead of one tab per protocol.
|
|
31
|
+
*/
|
|
32
|
+
const TRANSPORTS = [
|
|
33
|
+
{
|
|
34
|
+
key: 'imap-smtp',
|
|
35
|
+
label: 'IMAP / SMTP(任意邮箱)',
|
|
36
|
+
hint: '用邮箱地址 + 授权码接入,适用于 QQ、163、Gmail 及自建邮箱。',
|
|
37
|
+
needsPassword: true,
|
|
38
|
+
needsProvider: true,
|
|
39
|
+
needsHosts: true,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: 'agent-mail',
|
|
43
|
+
label: '腾讯 Agent 邮箱',
|
|
44
|
+
hint: '用微信扫码授权接入,无需授权码与服务器地址。',
|
|
45
|
+
needsPassword: false,
|
|
46
|
+
needsProvider: false,
|
|
47
|
+
needsHosts: false,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function field(label, control, hint) {
|
|
52
|
+
return h('label', { className: 'dim-emailField' },
|
|
53
|
+
h('span', null, label),
|
|
54
|
+
control,
|
|
55
|
+
hint ? h('span', { className: 'dim-emailHint' }, hint) : null);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The "waiting for authorization" line, split into translatable pieces. The
|
|
60
|
+
* validity window is the server's, not an assumed one.
|
|
61
|
+
*/
|
|
62
|
+
function waitingHint(session) {
|
|
63
|
+
const minutes = session?.expiresInMs ? Math.round(session.expiresInMs / 60_000) : null;
|
|
64
|
+
if (!minutes) return ['等待授权中…'];
|
|
65
|
+
return ['等待授权中,', String(minutes), ' 分钟', '内有效,可保持本页打开。'];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** How often the bindable session list is refreshed while the panel is open. */
|
|
69
|
+
const SESSION_REFRESH_MS = 15_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* QR authorization for the Agent mailbox.
|
|
73
|
+
*
|
|
74
|
+
* There is no one-shot scan payload: the authorization page embeds its own
|
|
75
|
+
* WeChat QR, so the user opens this URL (or scans it) and signs in there. The
|
|
76
|
+
* page is polled until the server reports the authorization.
|
|
77
|
+
*/
|
|
78
|
+
function AgentMailAuth({ rpcCall, endpoints, disabled, address = '', onAuthorized, onError, blocked = false }) {
|
|
79
|
+
const [session, setSession] = React.useState(null);
|
|
80
|
+
const [status, setStatus] = React.useState('idle');
|
|
81
|
+
const [error, setError] = React.useState(null);
|
|
82
|
+
|
|
83
|
+
const invoke = React.useCallback(async (endpoint, payload) => {
|
|
84
|
+
const response = await rpcCall(endpoint, payload);
|
|
85
|
+
if (response && typeof response === 'object' && 'ok' in response) {
|
|
86
|
+
if (response.ok === false) throw new Error(response.error?.message ?? '请求失败');
|
|
87
|
+
return response.value;
|
|
88
|
+
}
|
|
89
|
+
return response;
|
|
90
|
+
}, [rpcCall]);
|
|
91
|
+
|
|
92
|
+
const begin = async () => {
|
|
93
|
+
setError(null);
|
|
94
|
+
setStatus('starting');
|
|
95
|
+
try {
|
|
96
|
+
// The CLI isolates accounts per workspace, so the address being added
|
|
97
|
+
// becomes its workspace — otherwise every Agent mailbox shares the first
|
|
98
|
+
// login and shows that account's address.
|
|
99
|
+
const started = await invoke(endpoints.startAuth, {
|
|
100
|
+
transport: 'agent-mail',
|
|
101
|
+
...(address.trim() ? { workspace: address.trim().toLowerCase() } : {}),
|
|
102
|
+
});
|
|
103
|
+
setSession(started);
|
|
104
|
+
setStatus('waiting');
|
|
105
|
+
} catch (startError) {
|
|
106
|
+
setStatus('idle');
|
|
107
|
+
setError(startError);
|
|
108
|
+
onError?.(startError);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Poll while waiting; the authorization happens in another tab or app.
|
|
113
|
+
React.useEffect(() => {
|
|
114
|
+
if (status !== 'waiting' || !session) return undefined;
|
|
115
|
+
let cancelled = false;
|
|
116
|
+
const timer = setInterval(async () => {
|
|
117
|
+
try {
|
|
118
|
+
const result = await invoke(endpoints.pollAuth, {});
|
|
119
|
+
if (cancelled) return;
|
|
120
|
+
if (result?.authorized) {
|
|
121
|
+
setStatus('authorized');
|
|
122
|
+
onAuthorized?.({
|
|
123
|
+
transport: 'agent-mail',
|
|
124
|
+
// agently-cli keeps the credentials in the system keychain, so no
|
|
125
|
+
// token passes through here — only the address to bind.
|
|
126
|
+
// The server resolves the mailbox address; carry it through or the
|
|
127
|
+
// field stays empty and the bind has nothing to use.
|
|
128
|
+
...(result.address ? { address: result.address } : {}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
} catch (pollError) {
|
|
132
|
+
if (cancelled) return;
|
|
133
|
+
// Stop waiting and say so: silently dropping back to idle left the user
|
|
134
|
+
// staring at "connecting" while nothing was happening.
|
|
135
|
+
setStatus('idle');
|
|
136
|
+
setSession(null);
|
|
137
|
+
setError(pollError);
|
|
138
|
+
onError?.(pollError);
|
|
139
|
+
}
|
|
140
|
+
}, 3000);
|
|
141
|
+
return () => { cancelled = true; clearInterval(timer); };
|
|
142
|
+
}, [endpoints, invoke, onAuthorized, session, status]);
|
|
143
|
+
|
|
144
|
+
if (status === 'authorized') {
|
|
145
|
+
// The panel submits on its own, but only once an address and at least one
|
|
146
|
+
// allowed sender exist; say so rather than implying work is under way.
|
|
147
|
+
return h('p', { className: 'dim-emailHint', role: 'status' },
|
|
148
|
+
blocked
|
|
149
|
+
? '授权成功;请填写邮箱地址与允许的发件人后完成接入。'
|
|
150
|
+
: '授权成功,正在接入邮箱…');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return h('div', { className: 'dim-emailAuth' },
|
|
154
|
+
h('p', { className: 'dim-emailHint' },
|
|
155
|
+
'腾讯 Agent 邮箱需要微信扫码授权。点下面的按钮生成授权链接,在打开的页面里用微信扫码登录并确认。'),
|
|
156
|
+
session
|
|
157
|
+
? h('div', { className: 'dim-emailAuthPanel' },
|
|
158
|
+
h('div', { className: 'dim-emailAuthRow' },
|
|
159
|
+
h('span', null, '授权链接'),
|
|
160
|
+
h('a', {
|
|
161
|
+
href: session.browserUrl, target: '_blank', rel: 'noreferrer',
|
|
162
|
+
className: 'dim-emailAuthLink',
|
|
163
|
+
}, session.browserUrl)),
|
|
164
|
+
session.inputCode
|
|
165
|
+
? h('div', { className: 'dim-emailAuthRow' },
|
|
166
|
+
h('span', null, '配对码'),
|
|
167
|
+
h('code', null, session.inputCode))
|
|
168
|
+
: null,
|
|
169
|
+
h('p', { className: 'dim-emailHint' },
|
|
170
|
+
status === 'waiting'
|
|
171
|
+
// Plain strings only (a template literal with mixed quotes trips the
|
|
172
|
+
// i18n guard), and the window comes from the server rather than an
|
|
173
|
+
// assumed five minutes.
|
|
174
|
+
? h('span', null, ...waitingHint(session))
|
|
175
|
+
: null))
|
|
176
|
+
: null,
|
|
177
|
+
error ? h(ConnectionError, { error: error }) : null,
|
|
178
|
+
h('div', { className: 'ddt-actions dim-viewActions' },
|
|
179
|
+
h('button', {
|
|
180
|
+
type: 'button', className: 'ddt-button', disabled: disabled || status === 'starting',
|
|
181
|
+
onClick: () => { void begin(); },
|
|
182
|
+
}, !session ? '生成授权链接' : '重新生成')));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Mailbox credential form: address + app password + provider (or explicit
|
|
187
|
+
* hosts) + the sender allowlist, which is required because a mail address is
|
|
188
|
+
* forgeable and an open mailbox would let anyone drive the Harness.
|
|
189
|
+
*/
|
|
190
|
+
function MailboxPanel({ busy, error, onSubmit, onCancel, rpcCall, endpoints }) {
|
|
191
|
+
const [transportKey, setTransportKey] = React.useState('imap-smtp');
|
|
192
|
+
// Held only in memory: the panel hands the pair straight to the bind call.
|
|
193
|
+
const [tokens, setTokens] = React.useState(null);
|
|
194
|
+
// A completed authorization submits on its own; the user should not have to
|
|
195
|
+
// click again after scanning.
|
|
196
|
+
const [autoBind, setAutoBind] = React.useState(null);
|
|
197
|
+
const [provider, setProvider] = React.useState('qq');
|
|
198
|
+
const [address, setAddress] = React.useState('');
|
|
199
|
+
const [password, setPassword] = React.useState('');
|
|
200
|
+
const [allowedSenders, setAllowedSenders] = React.useState('');
|
|
201
|
+
const [imapHost, setImapHost] = React.useState('');
|
|
202
|
+
const [imapPort, setImapPort] = React.useState('');
|
|
203
|
+
const [smtpHost, setSmtpHost] = React.useState('');
|
|
204
|
+
const [smtpPort, setSmtpPort] = React.useState('');
|
|
205
|
+
const transport = TRANSPORTS.find((entry) => entry.key === transportKey) ?? TRANSPORTS[0];
|
|
206
|
+
const custom = transport.needsProvider && provider === 'custom';
|
|
207
|
+
// The Agent mailbox authorizes by QR code, so it has no password to collect.
|
|
208
|
+
const isAgentMail = transport.key === 'agent-mail';
|
|
209
|
+
const maySubmit = Boolean(address.trim())
|
|
210
|
+
&& (!transport.needsPassword || Boolean(password))
|
|
211
|
+
&& (!isAgentMail || Boolean(tokens));
|
|
212
|
+
|
|
213
|
+
// `granted` lets the auto-submit path pass the tokens it just received,
|
|
214
|
+
// since React state has not re-rendered with them yet.
|
|
215
|
+
const submit = (granted = null) => onSubmit({
|
|
216
|
+
// Prefer the typed address, but an Agent mailbox gets one from the server.
|
|
217
|
+
address: (address.trim() || granted?.address || ''),
|
|
218
|
+
transport: transport.key,
|
|
219
|
+
...(transport.needsPassword ? { password } : {}),
|
|
220
|
+
...(isAgentMail && (granted ?? tokens) ? { ...(granted ?? tokens) } : {}),
|
|
221
|
+
...(transport.needsProvider ? { provider } : {}),
|
|
222
|
+
allowedSenders: allowedSenders
|
|
223
|
+
.split(/[\s,;,;]+/)
|
|
224
|
+
.map((value) => value.trim())
|
|
225
|
+
.filter(Boolean),
|
|
226
|
+
...(custom ? {
|
|
227
|
+
imapHost: imapHost.trim(), imapPort: imapPort.trim() || undefined,
|
|
228
|
+
smtpHost: smtpHost.trim(), smtpPort: smtpPort.trim() || undefined,
|
|
229
|
+
} : {}),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Once an authorization lands, finish the job without a second click.
|
|
233
|
+
React.useEffect(() => {
|
|
234
|
+
if (!autoBind || busy) return;
|
|
235
|
+
// The address may have arrived with the authorization.
|
|
236
|
+
if (!address.trim() && !autoBind.address) return;
|
|
237
|
+
const senders = allowedSenders.split(/[\s,;,;]+/).map((v) => v.trim()).filter(Boolean);
|
|
238
|
+
// An empty allowlist is allowed: the mailbox connects first and senders are
|
|
239
|
+
// authorized later. It stays fail-closed, so nobody can drive it yet.
|
|
240
|
+
// Consume the pending bind first so a re-render cannot submit twice.
|
|
241
|
+
setAutoBind(null);
|
|
242
|
+
onSubmit({
|
|
243
|
+
// The server-resolved address is used when the field is still empty.
|
|
244
|
+
address: (address.trim() || autoBind.address || ''),
|
|
245
|
+
transport: transport.key,
|
|
246
|
+
...(isAgentMail ? { ...autoBind } : {}),
|
|
247
|
+
allowedSenders: senders,
|
|
248
|
+
});
|
|
249
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
250
|
+
}, [autoBind, address, allowedSenders, busy]);
|
|
251
|
+
|
|
252
|
+
return h('section', { className: 'ddt-card dim-surfaceCard dim-emailPanel' },
|
|
253
|
+
h('h3', null, '接入邮箱'),
|
|
254
|
+
h('p', null, 'DeepSeek Harness 会读取该邮箱的新邮件作为指令,并在同一邮件线程内回复处理结果。'),
|
|
255
|
+
h('div', { className: 'dim-emailFields' },
|
|
256
|
+
field('接入方式', h('select', {
|
|
257
|
+
value: transportKey,
|
|
258
|
+
onChange: (event) => setTransportKey(event.target.value),
|
|
259
|
+
disabled: busy,
|
|
260
|
+
}, TRANSPORTS.map((entry) => h('option', { key: entry.key, value: entry.key }, entry.label))),
|
|
261
|
+
h('span', { className: 'dim-emailHint' }, transport.hint)),
|
|
262
|
+
field('邮箱地址',
|
|
263
|
+
h('input', {
|
|
264
|
+
type: 'email', value: address, disabled: busy,
|
|
265
|
+
// The Agent mailbox address comes from the authorization, so it is
|
|
266
|
+
// filled in rather than typed.
|
|
267
|
+
...(isAgentMail
|
|
268
|
+
? { readOnly: true, placeholder: '授权后自动填入' }
|
|
269
|
+
: { placeholder: 'your-name@qq.com',
|
|
270
|
+
onChange: (event) => setAddress(event.target.value) }),
|
|
271
|
+
}),
|
|
272
|
+
isAgentMail ? '由授权结果自动填入,无需手工填写。' : null),
|
|
273
|
+
transport.needsProvider ? field('邮箱服务商', h('select', {
|
|
274
|
+
value: provider,
|
|
275
|
+
onChange: (event) => setProvider(event.target.value),
|
|
276
|
+
disabled: busy,
|
|
277
|
+
}, PROVIDERS.map((entry) => h('option', { key: entry.key, value: entry.key }, entry.label))),
|
|
278
|
+
h('span', { className: 'dim-emailHint' }, PROVIDER_HINTS[provider])) : null,
|
|
279
|
+
transport.needsPassword ? field('应用密码 / 授权码', h('input', {
|
|
280
|
+
type: 'password', value: password, placeholder: 'IMAP/SMTP 授权码', disabled: busy,
|
|
281
|
+
onChange: (event) => setPassword(event.target.value),
|
|
282
|
+
}), '不是邮箱登录密码;请在邮箱设置中单独生成。') : null,
|
|
283
|
+
custom ? h('div', { className: 'dim-emailGrid' },
|
|
284
|
+
field('IMAP 服务器', h('input', {
|
|
285
|
+
value: imapHost, placeholder: 'imap.example.com', disabled: busy,
|
|
286
|
+
onChange: (event) => setImapHost(event.target.value),
|
|
287
|
+
})),
|
|
288
|
+
field('端口', h('input', {
|
|
289
|
+
value: imapPort, placeholder: '993', disabled: busy,
|
|
290
|
+
onChange: (event) => setImapPort(event.target.value),
|
|
291
|
+
}))) : null,
|
|
292
|
+
custom ? h('div', { className: 'dim-emailGrid' },
|
|
293
|
+
field('SMTP 服务器', h('input', {
|
|
294
|
+
value: smtpHost, placeholder: 'smtp.example.com', disabled: busy,
|
|
295
|
+
onChange: (event) => setSmtpHost(event.target.value),
|
|
296
|
+
})),
|
|
297
|
+
field('端口', h('input', {
|
|
298
|
+
value: smtpPort, placeholder: '465', disabled: busy,
|
|
299
|
+
onChange: (event) => setSmtpPort(event.target.value),
|
|
300
|
+
}))) : null,
|
|
301
|
+
isAgentMail
|
|
302
|
+
? h(AgentMailAuth, {
|
|
303
|
+
rpcCall,
|
|
304
|
+
endpoints,
|
|
305
|
+
disabled: busy,
|
|
306
|
+
// The address doubles as the CLI workspace, so the component needs it.
|
|
307
|
+
address,
|
|
308
|
+
// Auto-submit: the panel says "connecting", so it must actually
|
|
309
|
+
// connect. Requiring a second click stranded users who had already
|
|
310
|
+
// scanned, and a reload lost the token entirely.
|
|
311
|
+
onAuthorized: (granted) => {
|
|
312
|
+
// The server resolves the address, so the field fills itself.
|
|
313
|
+
if (granted?.address) setAddress(granted.address);
|
|
314
|
+
setTokens(granted);
|
|
315
|
+
setAutoBind(granted);
|
|
316
|
+
},
|
|
317
|
+
// Only the address gates authorization: the allowlist is optional now,
|
|
318
|
+
// and requiring it here made the button unclickable for a new mailbox.
|
|
319
|
+
blocked: !address.trim() && !tokens?.address,
|
|
320
|
+
onError: () => setTokens(null),
|
|
321
|
+
})
|
|
322
|
+
: null,
|
|
323
|
+
field('允许的发件人', h('textarea', {
|
|
324
|
+
value: allowedSenders, disabled: busy,
|
|
325
|
+
placeholder: 'me@example.com\n同事@example.com',
|
|
326
|
+
onChange: (event) => setAllowedSenders(event.target.value),
|
|
327
|
+
}), '必填。只有这些地址发来的邮件会触发 Harness;多个地址用换行或逗号分隔。')),
|
|
328
|
+
error ? h(ConnectionError, { error: error }) : null,
|
|
329
|
+
h('div', { className: 'ddt-actions dim-viewActions' },
|
|
330
|
+
h('button', { type: 'button', className: 'ddt-button', onClick: onCancel, disabled: busy }, '取消'),
|
|
331
|
+
h('button', {
|
|
332
|
+
type: 'button', className: 'ddt-button', 'data-kind': 'primary',
|
|
333
|
+
// Wrap so the click event is not mistaken for granted tokens.
|
|
334
|
+
onClick: () => submit(), disabled: busy || !maySubmit,
|
|
335
|
+
}, busy ? '正在连接邮箱…' : '连接邮箱')));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Session binding: how incoming mail maps onto Harness sessions.
|
|
340
|
+
*
|
|
341
|
+
* Three levels, highest first:
|
|
342
|
+
* 1. a per-sender binding,
|
|
343
|
+
* 2. the account-wide binding,
|
|
344
|
+
* 3. no binding — every mail thread starts its own session.
|
|
345
|
+
*/
|
|
346
|
+
/**
|
|
347
|
+
* Session ids are long uuids. The picker shows the title, falling back to a
|
|
348
|
+
* shortened id, because the per-sender column is too narrow for the full id.
|
|
349
|
+
*/
|
|
350
|
+
function shortenSessionId(sessionId) {
|
|
351
|
+
const text = String(sessionId ?? '');
|
|
352
|
+
return text.length <= 20 ? text : `${text.slice(0, 8)}…${text.slice(-6)}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Pickers are narrow, so a very long title is trimmed from the middle with the
|
|
357
|
+
* tail kept — automation titles carry their distinguishing timestamp at the
|
|
358
|
+
* end. The full id remains the option's tooltip.
|
|
359
|
+
*/
|
|
360
|
+
function sessionLabel(session) {
|
|
361
|
+
const title = String(session?.title ?? '').trim();
|
|
362
|
+
if (!title) return shortenSessionId(session?.sessionId);
|
|
363
|
+
if (title.length <= 22) return title;
|
|
364
|
+
// 13px CJK / 7px latin: 22 visible characters stay inside the ~200px the
|
|
365
|
+
// narrowest picker gives the label.
|
|
366
|
+
return `${title.slice(0, 12)}…${title.slice(-8)}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function SessionBindingPanel({
|
|
370
|
+
account, rpcCall, endpoints, onChanged, disabled, registerReload = null,
|
|
371
|
+
}) {
|
|
372
|
+
const [binding, setBinding] = React.useState(null);
|
|
373
|
+
const [sessions, setSessions] = React.useState([]);
|
|
374
|
+
const [accountSession, setAccountSession] = React.useState('');
|
|
375
|
+
const [senderRows, setSenderRows] = React.useState([]);
|
|
376
|
+
const [busy, setBusy] = React.useState(false);
|
|
377
|
+
const [error, setError] = React.useState(null);
|
|
378
|
+
const [notice, setNotice] = React.useState(null);
|
|
379
|
+
|
|
380
|
+
// The card receives the raw RPC bridge, whose response wraps the payload in
|
|
381
|
+
// { ok, value }; the settings panel works with the unwrapped value.
|
|
382
|
+
const invoke = React.useCallback(async (endpoint, payload) => {
|
|
383
|
+
const response = await rpcCall(endpoint, payload);
|
|
384
|
+
if (response && typeof response === 'object' && 'ok' in response) {
|
|
385
|
+
if (response.ok === false) {
|
|
386
|
+
throw new Error(response.error?.message ?? '请求失败');
|
|
387
|
+
}
|
|
388
|
+
return response.value;
|
|
389
|
+
}
|
|
390
|
+
return response;
|
|
391
|
+
}, [rpcCall]);
|
|
392
|
+
|
|
393
|
+
const load = React.useCallback(async () => {
|
|
394
|
+
if (typeof rpcCall !== 'function') return;
|
|
395
|
+
try {
|
|
396
|
+
const [current, listed] = await Promise.all([
|
|
397
|
+
invoke(endpoints.getBinding, { botId: account.botId }),
|
|
398
|
+
invoke(endpoints.listSessions, { botId: account.botId }),
|
|
399
|
+
]);
|
|
400
|
+
setBinding(current);
|
|
401
|
+
setAccountSession(current?.account ?? '');
|
|
402
|
+
const senders = current?.senders ?? {};
|
|
403
|
+
setSenderRows((current?.knownSenders ?? []).map((address) => ({
|
|
404
|
+
address, sessionId: senders[address] ?? '',
|
|
405
|
+
})));
|
|
406
|
+
setSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
|
407
|
+
} catch (loadError) {
|
|
408
|
+
setError(loadError);
|
|
409
|
+
}
|
|
410
|
+
}, [account.botId, endpoints, invoke, rpcCall]);
|
|
411
|
+
|
|
412
|
+
React.useEffect(() => { void load(); }, [load]);
|
|
413
|
+
// Let the allowlist form refresh this panel: the allowlist decides which
|
|
414
|
+
// senders get a row, and the parent's own reload only re-reads channel state.
|
|
415
|
+
React.useEffect(() => {
|
|
416
|
+
registerReload?.(load);
|
|
417
|
+
return () => registerReload?.(null);
|
|
418
|
+
}, [load, registerReload]);
|
|
419
|
+
|
|
420
|
+
// Sessions are created elsewhere — chats, automations, other channels — so a
|
|
421
|
+
// list fetched once when the panel opened goes stale while it stays open.
|
|
422
|
+
React.useEffect(() => {
|
|
423
|
+
if (typeof rpcCall !== 'function') return undefined;
|
|
424
|
+
let cancelled = false;
|
|
425
|
+
const refresh = setInterval(async () => {
|
|
426
|
+
try {
|
|
427
|
+
const listed = await invoke(endpoints.listSessions, { botId: account.botId });
|
|
428
|
+
if (cancelled) return;
|
|
429
|
+
setSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
|
430
|
+
} catch {
|
|
431
|
+
// A failed refresh keeps the list already on screen.
|
|
432
|
+
}
|
|
433
|
+
}, SESSION_REFRESH_MS);
|
|
434
|
+
// A background timer must not hold a process open (server-side render,
|
|
435
|
+
// tests, or a headless client).
|
|
436
|
+
refresh.unref?.();
|
|
437
|
+
return () => { cancelled = true; clearInterval(refresh); };
|
|
438
|
+
}, [account.botId, endpoints, invoke, rpcCall]);
|
|
439
|
+
|
|
440
|
+
const persist = async (nextAccount, nextRows) => {
|
|
441
|
+
setBusy(true);
|
|
442
|
+
setError(null);
|
|
443
|
+
setNotice(null);
|
|
444
|
+
try {
|
|
445
|
+
const senders = {};
|
|
446
|
+
for (const row of nextRows) {
|
|
447
|
+
if (row?.address && row.sessionId) senders[row.address] = row.sessionId;
|
|
448
|
+
}
|
|
449
|
+
await invoke(endpoints.setBinding, {
|
|
450
|
+
botId: account.botId,
|
|
451
|
+
account: nextAccount || null,
|
|
452
|
+
senders,
|
|
453
|
+
});
|
|
454
|
+
setNotice('会话绑定已保存。');
|
|
455
|
+
await onChanged?.({ silent: true });
|
|
456
|
+
} catch (saveError) {
|
|
457
|
+
setError(saveError);
|
|
458
|
+
} finally {
|
|
459
|
+
setBusy(false);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const options = (selected, placeholder) => [
|
|
464
|
+
h('option', { key: '__none', value: '' }, placeholder),
|
|
465
|
+
...sessions.map((session) => h('option', {
|
|
466
|
+
key: session.sessionId,
|
|
467
|
+
value: session.sessionId,
|
|
468
|
+
// The full id is a long uuid; it stays available as the tooltip and the
|
|
469
|
+
// value, while the visible label keeps the title within the narrow
|
|
470
|
+
// per-sender column.
|
|
471
|
+
title: session.sessionId,
|
|
472
|
+
}, sessionLabel(session))),
|
|
473
|
+
// Keep an unknown-but-set id selectable so loading never silently drops it.
|
|
474
|
+
...(selected && !sessions.some((s) => s.sessionId === selected)
|
|
475
|
+
? [h('option', { key: selected, value: selected, title: selected }, shortenSessionId(selected))]
|
|
476
|
+
: []),
|
|
477
|
+
];
|
|
478
|
+
|
|
479
|
+
// The picker truncates long titles, so the current choice is echoed in full
|
|
480
|
+
// underneath it; nothing is lost to the narrow column.
|
|
481
|
+
const selectionHint = (sessionId) => {
|
|
482
|
+
if (!sessionId) return null;
|
|
483
|
+
const found = sessions.find((session) => session.sessionId === sessionId);
|
|
484
|
+
const title = String(found?.title ?? '').trim();
|
|
485
|
+
if (!title || title.length <= 28) return null;
|
|
486
|
+
// Split so the prefix is translated while the title stays verbatim.
|
|
487
|
+
return h('span', { className: 'dim-emailHint dim-emailBindingSelected', title },
|
|
488
|
+
'已选:', title);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const locked = disabled || busy;
|
|
492
|
+
|
|
493
|
+
return h('section', { className: 'dim-emailPanel dim-emailBinding' },
|
|
494
|
+
h('div', { className: 'dim-emailBindingHead' },
|
|
495
|
+
h('h4', null, '会话绑定'),
|
|
496
|
+
// The list also refreshes on a timer; this is for an immediate re-read.
|
|
497
|
+
h('button', {
|
|
498
|
+
type: 'button', className: 'ddt-button', disabled: locked,
|
|
499
|
+
onClick: () => { void load(); },
|
|
500
|
+
}, '刷新会话列表')),
|
|
501
|
+
h('p', { className: 'dim-emailHint' },
|
|
502
|
+
'不绑定则每封新邮件开启一个新会话;绑定固定会话后,来信都在该会话内继续。'),
|
|
503
|
+
h('div', { className: 'dim-emailFields' },
|
|
504
|
+
field('固定会话(账号级)',
|
|
505
|
+
h('select', {
|
|
506
|
+
value: accountSession,
|
|
507
|
+
disabled: locked,
|
|
508
|
+
onChange: (event) => setAccountSession(event.target.value),
|
|
509
|
+
}, options(accountSession, '不绑定(每封新邮件新建会话)')),
|
|
510
|
+
selectionHint(accountSession)),
|
|
511
|
+
senderRows.length
|
|
512
|
+
? h('div', { className: 'dim-emailFields' },
|
|
513
|
+
h('span', { className: 'dim-emailHint' }, '按发件人覆盖(优先于账号级)'),
|
|
514
|
+
...senderRows.map((row, index) => h('div', { key: row.address, className: 'dim-emailBindingRow' },
|
|
515
|
+
h('span', { className: 'dim-emailBindingSender', title: row.address }, row.address),
|
|
516
|
+
h('select', {
|
|
517
|
+
value: row.sessionId,
|
|
518
|
+
disabled: locked,
|
|
519
|
+
onChange: (event) => {
|
|
520
|
+
const next = [...senderRows];
|
|
521
|
+
next[index] = { ...row, sessionId: event.target.value };
|
|
522
|
+
setSenderRows(next);
|
|
523
|
+
},
|
|
524
|
+
}, options(row.sessionId, '跟随账号级')))),
|
|
525
|
+
// Several senders often belong together; one click points them all at
|
|
526
|
+
// the same session instead of repeating the choice per row.
|
|
527
|
+
h('div', { className: 'ddt-actions dim-viewActions' },
|
|
528
|
+
h('button', {
|
|
529
|
+
type: 'button', className: 'ddt-button', disabled: locked || !accountSession,
|
|
530
|
+
onClick: () => setSenderRows(senderRows.map((row) => ({ ...row, sessionId: accountSession }))),
|
|
531
|
+
}, '所有发件人同上')))
|
|
532
|
+
: h('p', { className: 'dim-emailHint' }, '尚无可覆盖的发件人(先在上方配置允许的发件人)。')),
|
|
533
|
+
error ? h(ConnectionError, { error: error }) : null,
|
|
534
|
+
notice ? h('p', { className: 'dim-emailHint', role: 'status' }, notice) : null,
|
|
535
|
+
h('div', { className: 'ddt-actions dim-viewActions' },
|
|
536
|
+
h('button', {
|
|
537
|
+
type: 'button', className: 'ddt-button', disabled: locked,
|
|
538
|
+
onClick: () => { void persist(accountSession, senderRows); },
|
|
539
|
+
}, busy ? '正在保存…' : '保存绑定'),
|
|
540
|
+
h('button', {
|
|
541
|
+
type: 'button', className: 'ddt-button', disabled: locked,
|
|
542
|
+
onClick: () => {
|
|
543
|
+
setAccountSession('');
|
|
544
|
+
const cleared = senderRows.map((row) => ({ ...row, sessionId: '' }));
|
|
545
|
+
setSenderRows(cleared);
|
|
546
|
+
void persist('', cleared);
|
|
547
|
+
},
|
|
548
|
+
}, '清除绑定')));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Per-account settings: edit hosts and the allowlist without reconnecting. */
|
|
552
|
+
/**
|
|
553
|
+
* Re-authorize an already-bound Agent mailbox.
|
|
554
|
+
*
|
|
555
|
+
* agently-cli holds the login, and it can expire or be dropped. Reconnecting
|
|
556
|
+
* cannot fix that — the login itself has to be renewed — so the same scan the
|
|
557
|
+
* add form uses is offered here.
|
|
558
|
+
*/
|
|
559
|
+
function MailboxReauthorize({ address, disabled, rpcCall, endpoints, onDone }) {
|
|
560
|
+
const [session, setSession] = React.useState(null);
|
|
561
|
+
const done = session !== null;
|
|
562
|
+
const invoke = React.useCallback(async (endpoint, payload) => {
|
|
563
|
+
const response = await rpcCall(endpoint, payload);
|
|
564
|
+
if (response && typeof response === 'object' && 'ok' in response) {
|
|
565
|
+
if (response.ok === false) throw new Error(response.error?.message ?? '请求失败');
|
|
566
|
+
return response.value;
|
|
567
|
+
}
|
|
568
|
+
return response;
|
|
569
|
+
}, [rpcCall]);
|
|
570
|
+
|
|
571
|
+
// The scan happens in another tab or on a phone, so the outcome is polled.
|
|
572
|
+
React.useEffect(() => {
|
|
573
|
+
if (!done) return undefined;
|
|
574
|
+
let cancelled = false;
|
|
575
|
+
const timer = setInterval(async () => {
|
|
576
|
+
try {
|
|
577
|
+
const result = await invoke(endpoints.pollAuth, {});
|
|
578
|
+
if (cancelled || !result?.authorized) return;
|
|
579
|
+
clearInterval(timer);
|
|
580
|
+
await onDone?.({ silent: true });
|
|
581
|
+
} catch {
|
|
582
|
+
// A failed poll keeps waiting; the scan may still be in flight.
|
|
583
|
+
}
|
|
584
|
+
}, 3_000);
|
|
585
|
+
return () => { cancelled = true; clearInterval(timer); };
|
|
586
|
+
}, [done, endpoints, invoke, onDone]);
|
|
587
|
+
|
|
588
|
+
return h('div', { className: 'dim-emailAuth' },
|
|
589
|
+
h('p', { className: 'dim-emailHint' },
|
|
590
|
+
done
|
|
591
|
+
? '已生成授权链接:在打开的页面里用微信扫码并确认,连接会自动恢复。'
|
|
592
|
+
: '当前邮箱的授权已失效。重新扫码即可恢复,无需移除这个邮箱。'),
|
|
593
|
+
done
|
|
594
|
+
? h('div', { className: 'dim-emailAuthPanel' },
|
|
595
|
+
session.browserUrl
|
|
596
|
+
? h('div', { className: 'dim-emailAuthRow' },
|
|
597
|
+
h('span', null, '授权链接'),
|
|
598
|
+
h('a', {
|
|
599
|
+
href: session.browserUrl, target: '_blank', rel: 'noreferrer',
|
|
600
|
+
className: 'dim-emailAuthLink',
|
|
601
|
+
}, session.browserUrl))
|
|
602
|
+
: h('p', { className: 'dim-emailHint' }, '未能获取授权链接,请重新生成。'),
|
|
603
|
+
session.inputCode
|
|
604
|
+
? h('div', { className: 'dim-emailAuthRow' },
|
|
605
|
+
h('span', null, '配对码'),
|
|
606
|
+
h('code', null, session.inputCode))
|
|
607
|
+
: null,
|
|
608
|
+
h('p', { className: 'dim-emailHint' }, '等待扫码完成…'))
|
|
609
|
+
: null,
|
|
610
|
+
h('div', { className: 'ddt-actions dim-viewActions' },
|
|
611
|
+
h('button', {
|
|
612
|
+
type: 'button', className: 'ddt-button', disabled,
|
|
613
|
+
onClick: async () => {
|
|
614
|
+
// The address doubles as the CLI workspace, so the scan lands in the
|
|
615
|
+
// same one this mailbox reads from.
|
|
616
|
+
const started = await invoke(endpoints.startAuth, {
|
|
617
|
+
transport: 'agent-mail', workspace: address,
|
|
618
|
+
});
|
|
619
|
+
// The link is what the user scans or opens, so it must be shown.
|
|
620
|
+
setSession(started ?? {});
|
|
621
|
+
},
|
|
622
|
+
}, done ? '重新生成' : '重新扫码授权')));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function MailboxSettings({ account, busy, error, onSave, onCancel, rpcCall, endpoints, onChanged }) {
|
|
626
|
+
// The settings panel shows the re-authorize control only for the transport
|
|
627
|
+
// that authorizes out of band.
|
|
628
|
+
const isAgentMail = (account?.transport ?? 'imap-smtp') === 'agent-mail';
|
|
629
|
+
const [allowedSenders, setAllowedSenders] = React.useState(
|
|
630
|
+
(account?.allowedSenders ?? []).join('\n'),
|
|
631
|
+
);
|
|
632
|
+
// The binding panel owns the sender rows; saving the allowlist must reload
|
|
633
|
+
// that panel, because the parent's own reload only re-reads channel state.
|
|
634
|
+
const bindingReload = React.useRef(null);
|
|
635
|
+
const saveAllowlist = async () => {
|
|
636
|
+
const senders = allowedSenders
|
|
637
|
+
.split(/[\s,;,;]+/).map((value) => value.trim()).filter(Boolean);
|
|
638
|
+
await onSave?.({ allowedSenders: senders });
|
|
639
|
+
await onChanged?.({ silent: true });
|
|
640
|
+
await bindingReload.current?.();
|
|
641
|
+
};
|
|
642
|
+
return h('section', { className: 'dim-emailPanel' },
|
|
643
|
+
// An Agent mailbox authorizes against agently-cli, whose login can lapse.
|
|
644
|
+
// Without this the only way back was to remove and re-add the mailbox.
|
|
645
|
+
isAgentMail
|
|
646
|
+
? h(MailboxReauthorize, {
|
|
647
|
+
address: account?.platformId ?? '',
|
|
648
|
+
disabled: busy,
|
|
649
|
+
rpcCall,
|
|
650
|
+
endpoints,
|
|
651
|
+
onDone: onChanged,
|
|
652
|
+
})
|
|
653
|
+
: null,
|
|
654
|
+
h('div', { className: 'dim-emailFields' },
|
|
655
|
+
field('允许的发件人', h('textarea', {
|
|
656
|
+
value: allowedSenders, disabled: busy,
|
|
657
|
+
onChange: (event) => setAllowedSenders(event.target.value),
|
|
658
|
+
}), '保存后会重新连接邮箱以使设置立即生效。')),
|
|
659
|
+
error ? h(ConnectionError, { error: error }) : null,
|
|
660
|
+
h('div', { className: 'ddt-actions dim-viewActions' },
|
|
661
|
+
h('button', { type: 'button', className: 'ddt-button', onClick: onCancel, disabled: busy }, '取消'),
|
|
662
|
+
h('button', {
|
|
663
|
+
type: 'button', className: 'ddt-button', 'data-kind': 'primary', disabled: busy,
|
|
664
|
+
onClick: () => { void saveAllowlist(); },
|
|
665
|
+
}, busy ? '正在保存…' : '保存')),
|
|
666
|
+
h(SessionBindingPanel, {
|
|
667
|
+
account, rpcCall, endpoints, onChanged, disabled: busy,
|
|
668
|
+
registerReload: (reloadFn) => { bindingReload.current = reloadFn; },
|
|
669
|
+
}));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export const EMAIL_SETTINGS_DEFINITION = Object.freeze({
|
|
673
|
+
channel: 'Email',
|
|
674
|
+
endpoints: EMAIL_ENDPOINTS,
|
|
675
|
+
api: emailClientApi,
|
|
676
|
+
LogoGlyph: EmailLogoGlyph,
|
|
677
|
+
installStyles: installEmailStyles,
|
|
678
|
+
pageClass: 'dim-pageEmail',
|
|
679
|
+
avatarClass: 'dim-avatarEmail',
|
|
680
|
+
connectionLabel: ' IMAP/SMTP 邮箱',
|
|
681
|
+
tokenPlaceholder: '',
|
|
682
|
+
emptyTitle: '接入邮箱',
|
|
683
|
+
emptyDescription: '使用现有邮箱收发指令:邮件进来触发 Harness,处理结果以回信形式送达。',
|
|
684
|
+
platformLabel: '邮箱地址',
|
|
685
|
+
// The mailbox form supplies every field itself: the address is the identity
|
|
686
|
+
// and the password is the secret, so the values pass through unchanged
|
|
687
|
+
// rather than being reduced to a single token.
|
|
688
|
+
credentialPayload: (values) => values,
|
|
689
|
+
CredentialPanel: MailboxPanel,
|
|
690
|
+
credentialAriaLabel: '配置邮箱收发',
|
|
691
|
+
credentialOpenLabel: '配置邮箱',
|
|
692
|
+
credentialNoun: '邮箱配置',
|
|
693
|
+
emptyActionLabel: '配置邮箱',
|
|
694
|
+
AccountSettings: MailboxSettings,
|
|
695
|
+
accountSettingsEndpoint: EMAIL_ENDPOINTS.updateMailbox,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const channel = createTokenChannelSettings(EMAIL_SETTINGS_DEFINITION);
|
|
699
|
+
|
|
700
|
+
export const EmailSettingsTab = channel.SettingsTab;
|
|
701
|
+
export const EmailAccountCard = channel.AccountCard;
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Whether the Host currently offers the email channel.
|
|
705
|
+
*
|
|
706
|
+
* The Host is the single authority on the channel's availability. The
|
|
707
|
+
* settings rail asks once through this hook and simply omits the mailbox entry
|
|
708
|
+
* point while it is closed — an absent default (`null`, before the answer
|
|
709
|
+
* arrives, or when the call fails) hides the entry rather than briefly flashing
|
|
710
|
+
* a form the Host would refuse.
|
|
711
|
+
*/
|
|
712
|
+
export function useEmailChannelEnabled(rpcCall) {
|
|
713
|
+
const [enabled, setEnabled] = React.useState(null);
|
|
714
|
+
React.useEffect(() => {
|
|
715
|
+
if (typeof rpcCall !== 'function') return undefined;
|
|
716
|
+
let active = true;
|
|
717
|
+
Promise.resolve()
|
|
718
|
+
.then(() => rpcCall(EMAIL_ENDPOINTS.availability, {}))
|
|
719
|
+
.then((result) => {
|
|
720
|
+
if (!active) return;
|
|
721
|
+
const value = result?.ok === false ? null : (result?.value ?? result);
|
|
722
|
+
setEnabled(value?.enabled === true);
|
|
723
|
+
})
|
|
724
|
+
.catch(() => { if (active) setEnabled(false); });
|
|
725
|
+
return () => { active = false; };
|
|
726
|
+
}, [rpcCall]);
|
|
727
|
+
return enabled === true;
|
|
728
|
+
}
|