@xmanrui/dsh-im 4.22.0 → 4.24.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.
Files changed (148) hide show
  1. package/README.en.md +5 -4
  2. package/README.md +5 -4
  3. package/lib/client.js +2584 -1153
  4. package/lib/index.js +290 -298
  5. package/package.json +14 -2
  6. package/plugin-src/client/channel-card-meta.js +2 -0
  7. package/plugin-src/client/channel-logos.js +11 -0
  8. package/plugin-src/client/channels/dingtalk/api.js +5 -1
  9. package/plugin-src/client/channels/dingtalk/index.js +15 -13
  10. package/plugin-src/client/channels/email/api.js +52 -0
  11. package/plugin-src/client/channels/email/index.js +728 -0
  12. package/plugin-src/client/channels/email/styles.js +51 -0
  13. package/plugin-src/client/channels/feishu/api.js +4 -2
  14. package/plugin-src/client/channels/feishu/index.js +23 -7
  15. package/plugin-src/client/channels/imessage/index.js +2 -1
  16. package/plugin-src/client/channels/office/api.js +2 -0
  17. package/plugin-src/client/channels/office/index.js +6 -5
  18. package/plugin-src/client/channels/qq/api.js +7 -0
  19. package/plugin-src/client/channels/qq/index.js +16 -3
  20. package/plugin-src/client/channels/shared/token-api.js +8 -1
  21. package/plugin-src/client/channels/shared/token-channel.js +28 -3
  22. package/plugin-src/client/channels/slack/index.js +2 -1
  23. package/plugin-src/client/channels/telegram/index.js +3 -0
  24. package/plugin-src/client/channels/telegram/styles.js +12 -0
  25. package/plugin-src/client/channels/telegram/thinking-traces.js +25 -0
  26. package/plugin-src/client/channels/wecom/api.js +7 -1
  27. package/plugin-src/client/channels/wecom/index.js +16 -3
  28. package/plugin-src/client/channels/wecom-app/api.js +6 -1
  29. package/plugin-src/client/channels/wecom-app/index.js +16 -3
  30. package/plugin-src/client/channels/weixin/api.js +2 -1
  31. package/plugin-src/client/channels/weixin/connection-error.js +1 -71
  32. package/plugin-src/client/channels/whatsapp/api.js +5 -0
  33. package/plugin-src/client/channels/whatsapp/index.js +16 -3
  34. package/plugin-src/client/connection-error.js +87 -0
  35. package/plugin-src/client/global-settings.js +81 -1
  36. package/plugin-src/client/i18n.js +69 -1
  37. package/plugin-src/client/index.js +40 -4
  38. package/plugin-src/client/last-message-error.js +2 -1
  39. package/plugin-src/client/styles.js +8 -2
  40. package/plugin-src/host/build.mjs +4 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +2 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
  43. package/plugin-src/host/channels/email/availability.mjs +60 -0
  44. package/plugin-src/host/channels/email/index.mjs +36 -0
  45. package/plugin-src/host/channels/email/production.mjs +58 -0
  46. package/plugin-src/host/channels/email/rpc.mjs +163 -0
  47. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  48. package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
  49. package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
  50. package/plugin-src/host/channels/office/rpc.mjs +3 -1
  51. package/plugin-src/host/channels/qq/production.mjs +2 -0
  52. package/plugin-src/host/channels/qq/rpc.mjs +5 -3
  53. package/plugin-src/host/channels/shared/production.mjs +52 -5
  54. package/plugin-src/host/channels/shared/rpc.mjs +14 -3
  55. package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
  56. package/plugin-src/host/channels/shared/startup.mjs +11 -6
  57. package/plugin-src/host/channels/shared/thinking-traces-rpc.mjs +11 -0
  58. package/plugin-src/host/channels/slack/production.mjs +2 -0
  59. package/plugin-src/host/channels/slack/rpc.mjs +5 -3
  60. package/plugin-src/host/channels/wecom/production.mjs +2 -0
  61. package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
  62. package/plugin-src/host/channels/wecom-app/production.mjs +2 -0
  63. package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
  64. package/plugin-src/host/channels/weixin/production.mjs +2 -0
  65. package/plugin-src/host/channels/whatsapp/production.mjs +2 -0
  66. package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
  67. package/plugin-src/host/delivery-adapter.mjs +11 -0
  68. package/plugin-src/host/image-input-rpc.mjs +24 -0
  69. package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
  70. package/plugin-src/host/index.mjs +3 -0
  71. package/plugin-src/host/modern-harness-api.mjs +7 -2
  72. package/plugin-src/management-rpc.mjs +12 -2
  73. package/scripts/verify-package.mjs +13 -5
  74. package/src/channels/dingtalk/connection-error.mjs +5 -12
  75. package/src/channels/dingtalk/device-auth.mjs +4 -1
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
  77. package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
  78. package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
  79. package/src/channels/discord/discord-api.mjs +3 -3
  80. package/src/channels/discord/discord-runtime.mjs +18 -12
  81. package/src/channels/email/config-store.mjs +194 -0
  82. package/src/channels/email/email-api.mjs +20 -0
  83. package/src/channels/email/email-bridge.mjs +17 -0
  84. package/src/channels/email/email-controller.mjs +824 -0
  85. package/src/channels/email/email-runtime.mjs +699 -0
  86. package/src/channels/email/harness-client.mjs +7 -0
  87. package/src/channels/email/mail-format.mjs +91 -0
  88. package/src/channels/email/state-store.mjs +176 -0
  89. package/src/channels/email/transport.mjs +64 -0
  90. package/src/channels/email/transports/agent-mail.mjs +604 -0
  91. package/src/channels/email/transports/agently-cli.mjs +288 -0
  92. package/src/channels/email/transports/imap-smtp.mjs +165 -0
  93. package/src/channels/feishu/bridge.mjs +39 -11
  94. package/src/channels/feishu/feishu-channel.mjs +35 -0
  95. package/src/channels/feishu/feishu-runtime.mjs +15 -8
  96. package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
  97. package/src/channels/feishu/live-cot.mjs +260 -0
  98. package/src/channels/feishu/message-utils.mjs +3 -2
  99. package/src/channels/feishu/multi-bot-controller.mjs +56 -47
  100. package/src/channels/feishu/registration-manager.mjs +8 -4
  101. package/src/channels/feishu/repair-manager.mjs +2 -1
  102. package/src/channels/feishu/slash-command-registry.mjs +17 -0
  103. package/src/channels/feishu/step-push-mode.mjs +10 -4
  104. package/src/channels/imessage/runtime.mjs +9 -5
  105. package/src/channels/office/office-controller.mjs +23 -12
  106. package/src/channels/office/office-runtime.mjs +6 -3
  107. package/src/channels/office/office-transport.mjs +1 -0
  108. package/src/channels/qq/qq-bridge.mjs +7 -5
  109. package/src/channels/qq/qq-controller.mjs +56 -42
  110. package/src/channels/qq/qq-runtime.mjs +16 -11
  111. package/src/channels/shared/bot-workspace-store.mjs +29 -10
  112. package/src/channels/shared/connection-error.mjs +191 -0
  113. package/src/channels/shared/connection-test.mjs +3 -1
  114. package/src/channels/shared/conversation-state-store.mjs +46 -1
  115. package/src/channels/shared/diagnostic-details.mjs +95 -0
  116. package/src/channels/shared/harness-client.mjs +287 -57
  117. package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
  118. package/src/channels/shared/i18n-en/email.mjs +25 -0
  119. package/src/channels/shared/i18n-en/image-input.mjs +17 -0
  120. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  121. package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
  122. package/src/channels/shared/i18n-en.mjs +6 -0
  123. package/src/channels/shared/image-input-policy.mjs +35 -0
  124. package/src/channels/shared/image-input-settings-store.mjs +60 -0
  125. package/src/channels/shared/image-input.mjs +124 -0
  126. package/src/channels/shared/image-prompt.mjs +15 -7
  127. package/src/channels/shared/message-failure.mjs +12 -0
  128. package/src/channels/shared/semantic/reply-reference.mjs +3 -3
  129. package/src/channels/shared/text-harness-bridge.mjs +150 -25
  130. package/src/channels/shared/token-bot-controller.mjs +52 -47
  131. package/src/channels/slack/slack-api.mjs +3 -3
  132. package/src/channels/slack/slack-controller.mjs +59 -51
  133. package/src/channels/slack/slack-runtime.mjs +18 -12
  134. package/src/channels/telegram/config-store.mjs +4 -1
  135. package/src/channels/telegram/telegram-api.mjs +2 -2
  136. package/src/channels/telegram/telegram-controller.mjs +13 -1
  137. package/src/channels/telegram/telegram-runtime.mjs +215 -14
  138. package/src/channels/wecom/wecom-bridge.mjs +15 -6
  139. package/src/channels/wecom/wecom-controller.mjs +51 -37
  140. package/src/channels/wecom/wecom-runtime.mjs +10 -6
  141. package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
  142. package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
  143. package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
  144. package/src/channels/weixin/connection-error.mjs +26 -92
  145. package/src/channels/weixin/diagnostic-details.mjs +1 -63
  146. package/src/channels/weixin/weixin-bridge.mjs +7 -5
  147. package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
  148. package/src/channels/whatsapp/whatsapp-runtime.mjs +35 -8
@@ -0,0 +1,824 @@
1
+ import { extractConnectionEvidence, atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
2
+ import { t } from '../shared/i18n.mjs';
3
+ import {
4
+ createAccessPolicy,
5
+ createAccessPolicyScope,
6
+ } from '../shared/access-policy.mjs';
7
+ import { assertTransport } from './transport.mjs';
8
+ import { ImapSmtpTransport } from './transports/imap-smtp.mjs';
9
+ import {
10
+ AgentMailTransport,
11
+ agentMailAuthorizationStatus,
12
+ fetchAgentMailIdentity,
13
+ startAgentMailAuthorization,
14
+ } from './transports/agent-mail.mjs';
15
+ import {
16
+ DEFAULT_EMAIL_TRANSPORT,
17
+ EMAIL_TRANSPORTS,
18
+ normalizeEmailTransport,
19
+ } from './config-store.mjs';
20
+ import {
21
+ EMAIL_PROVIDERS,
22
+ EmailConfigStore,
23
+ deriveEmailBotIdentity,
24
+ maskEmailBotId,
25
+ normalizeEmailAddress,
26
+ normalizeEmailAccessPolicy,
27
+ } from './config-store.mjs';
28
+ import { EmailStateStore } from './state-store.mjs';
29
+ import { EMAIL_DESCRIPTOR } from './email-bridge.mjs';
30
+
31
+ /**
32
+ * Fallback validity window for a QR authorization, used only when the server
33
+ * does not state one. Normally the server's `expires_in` governs.
34
+ */
35
+ const AGENT_MAIL_AUTH_TTL_MS = 600_000;
36
+
37
+ /**
38
+ * Credential payload for one mailbox.
39
+ *
40
+ * The whole payload lives under one credential ref as JSON — the channel's
41
+ * established shape, unlike the single-secret channels — because a mailbox
42
+ * needs its address alongside its secret.
43
+ *
44
+ * A standard mailbox carries an app password; the Agent mailbox carries an
45
+ * OAuth pair instead, since it has no password at all.
46
+ */
47
+ function normalizeCredential(value) {
48
+ if (!value || typeof value !== 'object') return null;
49
+ const address = typeof value.address === 'string' ? value.address.trim().toLowerCase() : '';
50
+ if (!address) return null;
51
+ const password = typeof value.password === 'string' ? value.password : '';
52
+ const accessToken = typeof value.accessToken === 'string' ? value.accessToken : '';
53
+ const refreshToken = typeof value.refreshToken === 'string' ? value.refreshToken : '';
54
+ if (!password && !accessToken) return null;
55
+ return {
56
+ address,
57
+ ...(password ? { password } : {}),
58
+ ...(accessToken ? { accessToken } : {}),
59
+ ...(refreshToken ? { refreshToken } : {}),
60
+ };
61
+ }
62
+
63
+ export class EmailController {
64
+ #credentials;
65
+ #configStore;
66
+ #createRuntime;
67
+ #transports;
68
+ #pendingAuth = null;
69
+ #syncAccessPolicy;
70
+ #ensureWorkspace;
71
+ #stateFor;
72
+ #listWorkspaceSessions;
73
+ #botWorkspaceFor;
74
+ #defaultWorkspace;
75
+ #deleteState;
76
+ #logger;
77
+ #diagnostics;
78
+ #runtimes = new Map();
79
+ #errors = new Map();
80
+ #revision = 0;
81
+ #closed = false;
82
+ #transitions = new Map();
83
+
84
+ constructor({
85
+ credentials,
86
+ configStore,
87
+ createRuntime,
88
+ deleteState = async () => {},
89
+ logger = console,
90
+ // Transport implementations by key. A new mail protocol is a new entry
91
+ // here, not another channel.
92
+ transports = {
93
+ 'imap-smtp': (options) => new ImapSmtpTransport(options),
94
+ 'agent-mail': (options) => new AgentMailTransport(options),
95
+ },
96
+ // Host-owned hook: the mailbox allowlist and the Harness access policy are
97
+ // two separate stores, so a changed allowlist must be pushed into the
98
+ // policy or the channel keeps rejecting the new senders.
99
+ syncAccessPolicy = null,
100
+ // Host-owned hook: create the bot's workspace record if it does not exist
101
+ // yet. The access policy lives *inside* that record, so a freshly bound
102
+ // mailbox has nowhere to push its allowlist until this has run.
103
+ ensureWorkspace = null,
104
+ // Optional: supplied when the channel supports pinning a chat to an
105
+ // existing session (the settings page session picker).
106
+ stateFor = null,
107
+ listWorkspaceSessions = null,
108
+ botWorkspaceFor = null,
109
+ defaultWorkspace = null,
110
+ }) {
111
+ if (!credentials || typeof credentials.resolve !== 'function'
112
+ || typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
113
+ throw new TypeError('EmailController requires the DSH credential provider');
114
+ }
115
+ if (!configStore || typeof configStore.list !== 'function'
116
+ || typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
117
+ throw new TypeError('EmailController requires a config store');
118
+ }
119
+ if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
120
+ this.#credentials = credentials;
121
+ this.#configStore = configStore;
122
+ this.#createRuntime = createRuntime;
123
+ if (!transports || typeof transports !== 'object') {
124
+ throw new TypeError('EmailController requires a transport registry');
125
+ }
126
+ this.#transports = transports;
127
+ this.#syncAccessPolicy = typeof syncAccessPolicy === 'function' ? syncAccessPolicy : null;
128
+ this.#ensureWorkspace = typeof ensureWorkspace === 'function' ? ensureWorkspace : null;
129
+ this.#stateFor = typeof stateFor === 'function' ? stateFor : null;
130
+ this.#listWorkspaceSessions = typeof listWorkspaceSessions === 'function'
131
+ ? listWorkspaceSessions : null;
132
+ this.#botWorkspaceFor = typeof botWorkspaceFor === 'function' ? botWorkspaceFor : null;
133
+ this.#defaultWorkspace = typeof defaultWorkspace === 'string' ? defaultWorkspace : null;
134
+ this.#deleteState = deleteState;
135
+ this.#logger = logger;
136
+ this.#diagnostics = createConnectionDiagnostics({ channel: 'email', logger });
137
+ }
138
+
139
+ async initialize() {
140
+ if (this.#closed) return this.status();
141
+ for (const config of this.#configStore.list()) {
142
+ await this.#withBotTransition(config.botId, async () => {
143
+ // initialize() runs on every supervisor health check. Without this
144
+ // guard each check tore down the running runtime and started a new
145
+ // one, so its poll loop never completed a single pass and no mail was
146
+ // ever read.
147
+ if (this.#runtimes.get(config.botId)?.status?.ready) return;
148
+ try {
149
+ const secrets = await this.#resolveSecrets(config);
150
+ if (!secrets) return;
151
+ await this.#startRuntime(config, secrets);
152
+ this.#errors.delete(config.botId);
153
+ } catch (error) {
154
+ this.#errors.set(config.botId, this.#safeError('connection-failed', error));
155
+ this.#logger.warn?.(`[dsh-im:email] bot ${config.botId} failed to start:`, extractConnectionEvidence(error).details);
156
+ }
157
+ });
158
+ }
159
+ return this.status();
160
+ }
161
+
162
+ /** Connect one mailbox. The password is verified against IMAP/SMTP before it
163
+ * is persisted, so a bad app password fails here rather than silently
164
+ * producing a bot that never receives mail. */
165
+ async bindMailbox({
166
+ address, password, accessToken, refreshToken, provider, transport,
167
+ imapHost, imapPort, smtpHost, smtpPort, allowedSenders,
168
+ } = {}) {
169
+ if (this.#closed) throw new Error(`${EMAIL_DESCRIPTOR.label} controller is closed`);
170
+ const normalizedAddress = normalizeEmailAddress(address);
171
+ const transportKey = normalizeEmailTransport(transport);
172
+ const definition = EMAIL_TRANSPORTS[transportKey];
173
+ // Only the IMAP/SMTP transport needs a password; others authorize another
174
+ // way (the Agent mailbox does so by QR code).
175
+ const needsPassword = definition.fields.includes('password');
176
+ // A standard mailbox carries an app password. The Agent mailbox carries
177
+ // nothing: agently-cli keeps its own credentials in the system keychain, so
178
+ // only the address is stored here (to name and bind the mailbox).
179
+ const credential = needsPassword
180
+ ? normalizeCredential({ address: normalizedAddress, password })
181
+ : Object.freeze({ address: normalizedAddress });
182
+ if (needsPassword && !credential) {
183
+ throw new TypeError(t('邮箱地址与应用密码均为必填'));
184
+ }
185
+ // Server hosts only apply to transports that speak a mail protocol.
186
+ const needsHosts = definition.fields.includes('hosts');
187
+ const preset = needsHosts ? EMAIL_PROVIDERS[provider] ?? null : null;
188
+ const security = {
189
+ transport: transportKey,
190
+ ...(needsHosts ? { provider: preset?.key ?? 'custom' } : {}),
191
+ ...(needsHosts ? {
192
+ imapHost: imapHost || preset?.imapHost,
193
+ imapPort: imapPort ?? preset?.imapPort ?? 993,
194
+ smtpHost: smtpHost || preset?.smtpHost,
195
+ smtpPort: smtpPort ?? preset?.smtpPort ?? 465,
196
+ } : {}),
197
+ allowedSenders: normalizeEmailAccessPolicy({ allowedSenders }).allowedSenders,
198
+ };
199
+ if (needsHosts && (!security.imapHost || !security.smtpHost)) {
200
+ throw new TypeError(t('请选择邮箱服务商或填写 IMAP/SMTP 服务器地址'));
201
+ }
202
+ // An empty allowlist is allowed so a mailbox can be connected first and
203
+ // authorized later. It stays fail-closed: the access policy derived from an
204
+ // empty list admits nobody, so the mailbox can be read but not driven.
205
+ const identity = deriveEmailBotIdentity(normalizedAddress);
206
+ await this.#withBotTransition(identity.botId, async () => {
207
+ const previousConfig = this.#configStore.getByPlatformId(normalizedAddress);
208
+ // resolve() returns a wrapper; the plain secret lives on .value.
209
+ const previousResult = await atConnectionStage('credential.read', () => this.#credentials.resolve(identity.tokenRef), 'credential-store');
210
+ const previousCredential = previousResult?.value;
211
+ const probe = this.#createTransport({
212
+ address: normalizedAddress,
213
+ ...(credential.password ? { password: credential.password } : {}),
214
+ ...(credential.accessToken ? { accessToken: credential.accessToken } : {}),
215
+ ...(credential.refreshToken ? { refreshToken: credential.refreshToken } : {}),
216
+ ...security,
217
+ });
218
+ try {
219
+ await probe.connect();
220
+ await probe.disconnect();
221
+ } catch (error) {
222
+ throw this.#diagnostics.report(error, { reuse: true, stage: 'credential.verify', operation: 'bot.bind-mailbox',
223
+ publicError: { code: 'email-bind-failed', message: t('邮箱连接失败,请检查地址、应用密码与服务器设置') } });
224
+ }
225
+ const config = {
226
+ botId: identity.botId,
227
+ platformId: normalizedAddress,
228
+ tokenRef: identity.tokenRef,
229
+ name: normalizedAddress,
230
+ username: normalizedAddress,
231
+ createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
232
+ connectedAt: new Date().toISOString(),
233
+ ...security,
234
+ };
235
+ await atConnectionStage('credential.save', () => this.#credentials.set(identity.tokenRef, JSON.stringify(credential)), 'credential-store');
236
+ try {
237
+ await atConnectionStage('account.save', () => this.#configStore.save(config), 'account-config');
238
+ } catch (error) {
239
+ await this.#restoreCredential(identity.tokenRef, previousCredential);
240
+ throw error;
241
+ }
242
+ // From here the mailbox is on disk, so every later failure must either
243
+ // undo that write or leave state a retry can recover from. A mailbox saved
244
+ // but neither reachable nor authorized is exactly the broken state this
245
+ // guards against.
246
+ try {
247
+ // The access policy lives *inside* the bot's workspace record, so that
248
+ // record has to exist before the allowlist can be pushed into it. The
249
+ // runtime used to be the only thing that created it — and the runtime
250
+ // starts after this — so on a brand-new mailbox the push failed with
251
+ // "workspace-bot-not-found", leaving the config on disk with no runtime
252
+ // and no policy.
253
+ await this.#ensureBotWorkspace(identity.botId, config);
254
+ // The allowlist has to reach the Harness now, not only when the settings
255
+ // are edited later: without it a freshly bound mailbox admits nobody, so
256
+ // its first mail is refused for the wrong reason.
257
+ await this.#applyAllowlistToPolicy(identity.botId, config);
258
+ await this.#stopRuntime(identity.botId);
259
+ try {
260
+ await this.#startRuntime(config, credential);
261
+ this.#errors.delete(identity.botId);
262
+ } catch (error) {
263
+ this.#errors.set(identity.botId, this.#safeError('connection-failed', error));
264
+ }
265
+ } catch (error) {
266
+ // Roll the mailbox back instead of persisting a half-bound one, so the
267
+ // user sees a clean failure and a plain retry works.
268
+ await this.#rollbackBind(config, previousConfig, previousCredential);
269
+ throw error;
270
+ }
271
+ this.#touch();
272
+ });
273
+ return this.status();
274
+ }
275
+
276
+ /** Patch a subset of settings (hosts, ports, allowlist) without reconnecting. */
277
+ async updateMailboxSettings(botId, update = {}) {
278
+ if (this.#closed) throw new Error(`${EMAIL_DESCRIPTOR.label} controller is closed`);
279
+ return this.#withBotTransition(botId, async () => {
280
+ const config = this.#requireConfig(botId);
281
+ const next = { ...config };
282
+ // A mailbox can move between protocols, so the transport is patchable
283
+ // like any other setting rather than fixed at bind time.
284
+ if (update.transport !== undefined) {
285
+ next.transport = normalizeEmailTransport(update.transport);
286
+ }
287
+ if (update.provider !== undefined) next.provider = update.provider;
288
+ if (update.imapHost !== undefined) next.imapHost = update.imapHost;
289
+ if (update.imapPort !== undefined) next.imapPort = update.imapPort;
290
+ if (update.smtpHost !== undefined) next.smtpHost = update.smtpHost;
291
+ if (update.smtpPort !== undefined) next.smtpPort = update.smtpPort;
292
+ if (update.allowedSenders !== undefined) {
293
+ next.allowedSenders = normalizeEmailAccessPolicy({ allowedSenders: update.allowedSenders }).allowedSenders;
294
+ }
295
+ const allowlistChanged = update.allowedSenders !== undefined;
296
+ const saved = await atConnectionStage('account.save', () => this.#configStore.save(next), 'account-config');
297
+ if (allowlistChanged) await this.#applyAllowlistToPolicy(botId, saved);
298
+ // Host, allowlist and approval changes take effect immediately.
299
+ await this.#stopRuntime(botId);
300
+ const secrets = await this.#resolveSecrets(saved);
301
+ if (secrets) {
302
+ try {
303
+ await this.#startRuntime(saved, secrets);
304
+ this.#errors.delete(botId);
305
+ } catch (error) {
306
+ this.#errors.set(botId, this.#safeError('connection-failed', error));
307
+ }
308
+ }
309
+ this.#touch();
310
+ return this.status();
311
+ });
312
+ }
313
+
314
+ async reconnectBot(botId) {
315
+ if (this.#closed) throw new Error(`${EMAIL_DESCRIPTOR.label} controller is closed`);
316
+ return this.#withBotTransition(botId, async () => {
317
+ const config = this.#requireConfig(botId);
318
+ await this.#stopRuntime(botId);
319
+ const secrets = await this.#resolveSecrets(config);
320
+ if (!secrets) throw new Error(t('邮箱凭据已丢失,请重新绑定'));
321
+ await this.#startRuntime(config, secrets);
322
+ this.#errors.delete(botId);
323
+ this.#touch();
324
+ return this.status();
325
+ });
326
+ }
327
+
328
+ async sendConnectionTest(botId) {
329
+ const runtime = this.#runtimes.get(botId);
330
+ if (!runtime) throw new Error(t('邮箱尚未连接'));
331
+ await runtime.sendConnectionTest(t('邮箱通道连接正常。'));
332
+ return { sent: true };
333
+ }
334
+
335
+ async sendProactiveText(botId, target, text, options = {}) {
336
+ const runtime = this.#runtimes.get(botId);
337
+ if (!runtime) {
338
+ const error = new Error(t('邮箱尚未连接'));
339
+ error.code = 'bot-not-connected';
340
+ throw error;
341
+ }
342
+ return runtime.sendProactiveText(target, text, options);
343
+ }
344
+
345
+ async deleteBot(botId) {
346
+ const warnings = [];
347
+ return this.#withBotTransition(botId, async () => {
348
+ const config = this.#configStore.get(botId);
349
+ await this.#stopRuntime(botId);
350
+ let removed;
351
+ try { removed = await atConnectionStage('account.remove', () => this.#configStore.remove(botId), 'account-config'); }
352
+ catch (error) {
353
+ if (this.#configStore.get(botId)) throw error;
354
+ removed = config;
355
+ warnings.push(this.#diagnostics.report(error, { operation: 'bot.delete', stage: 'workspace.cleanup', warning: true,
356
+ publicError: { code: 'workspace-cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError);
357
+ }
358
+ if (removed?.tokenRef) await atConnectionStage('credential.remove', () => this.#credentials.unset(removed.tokenRef), 'credential-store').catch(error => { warnings.push(this.#diagnostics.report(error, { reuse: true, operation: 'bot.delete', stage: 'credential.remove', warning: true, publicError: { code: 'cleanup-failed', message: '账号已移除,但登录凭据清理失败。' } }).publicError); });
359
+ await this.#deleteState(botId).catch(error => { warnings.push(this.#diagnostics.report(error, { reuse: true, operation: 'bot.delete', stage: 'state.cleanup', resource: 'account-state', warning: true, publicError: { code: 'cleanup-failed', message: '账号已移除,但本地状态清理失败。' } }).publicError); });
360
+ this.#errors.delete(botId);
361
+ this.#touch();
362
+ return { ...this.status(), ...(warnings.length ? { warnings } : {}) };
363
+ });
364
+ }
365
+
366
+ status() {
367
+ const bots = this.#configStore.list().map((config) => {
368
+ const runtime = this.#runtimes.get(config.botId);
369
+ const runtimeStatus = runtime?.status ?? null;
370
+ const error = runtimeStatus?.error ?? this.#errors.get(config.botId);
371
+ // The connection supervisor and the settings client both read these
372
+ // fields, so a mailbox reports connectivity the same way every other
373
+ // token channel does.
374
+ const connected = runtimeStatus?.ready === true
375
+ && runtimeStatus.connectionState === 'connected';
376
+ const state = connected ? 'connected'
377
+ : error ? 'error'
378
+ : runtimeStatus?.connectionState === 'connecting' ? 'connecting' : 'disconnected';
379
+ return {
380
+ botId: config.botId,
381
+ // The shared client reads the identity from `bot`, so the name must live
382
+ // there — a top-level name is ignored and the UI falls back to
383
+ // "<channel>机器人". The address doubles as the display name, which
384
+ // beats a generic label.
385
+ bot: {
386
+ name: config.name || config.platformId,
387
+ username: config.username || config.platformId,
388
+ idMasked: maskEmailBotId(config.platformId),
389
+ },
390
+ // The raw address is already semi-public, but the UI shows the masked
391
+ // form for consistency with other channels.
392
+ platformId: maskEmailBotId(config.platformId),
393
+ // Which protocol this mailbox speaks. The settings page needs it to
394
+ // show the right form; without it an Agent mailbox was described with
395
+ // IMAP/SMTP fields it does not use.
396
+ transport: config.transport ?? DEFAULT_EMAIL_TRANSPORT,
397
+ provider: config.provider,
398
+ imapHost: config.imapHost,
399
+ imapPort: config.imapPort,
400
+ smtpHost: config.smtpHost,
401
+ smtpPort: config.smtpPort,
402
+ allowedSenders: config.allowedSenders ?? [],
403
+ createdAt: config.createdAt,
404
+ connectedAt: config.connectedAt,
405
+ connected,
406
+ configured: true,
407
+ state,
408
+ health: {
409
+ status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
410
+ summary: connected
411
+ ? t('邮箱通道运行正常')
412
+ : error?.message ?? t('邮箱通道尚未连接'),
413
+ },
414
+ ...(error ? { error } : {}),
415
+ runtime: runtimeStatus,
416
+ };
417
+ });
418
+ const connectedCount = bots.filter((bot) => bot.connected).length;
419
+ return {
420
+ revision: this.#revision,
421
+ bots,
422
+ totals: { configured: bots.length, connected: connectedCount },
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Current session bindings for one mailbox, plus the senders known to this
428
+ * mailbox so the settings page can offer a per-sender override.
429
+ */
430
+ async getSessionBinding(botId) {
431
+ const config = this.#requireConfig(botId);
432
+ const state = await this.#stateFor?.(botId);
433
+ const bindings = state?.emailBindings?.() ?? { account: null, senders: {} };
434
+ const senders = Array.isArray(config.allowedSenders) ? config.allowedSenders : [];
435
+ return {
436
+ botId,
437
+ account: bindings.account ?? null,
438
+ senders: bindings.senders ?? {},
439
+ // The picker offers these as the per-sender rows.
440
+ knownSenders: senders,
441
+ };
442
+ }
443
+
444
+ /**
445
+ * Replace the bindings. An empty value clears them, which restores the
446
+ * default behaviour of one Harness session per mail thread.
447
+ */
448
+ async setSessionBinding(botId, value = {}) {
449
+ this.#requireConfig(botId);
450
+ if (!this.#stateFor) throw new Error(t('当前环境不支持会话绑定'));
451
+ const state = await this.#stateFor(botId);
452
+ if (typeof state?.setEmailBindings !== 'function') {
453
+ throw new Error(t('当前环境不支持会话绑定'));
454
+ }
455
+ const account = typeof value.account === 'string' && value.account.trim()
456
+ ? value.account.trim() : null;
457
+ const senders = {};
458
+ if (value.senders && typeof value.senders === 'object' && !Array.isArray(value.senders)) {
459
+ for (const [address, sessionId] of Object.entries(value.senders)) {
460
+ const key = typeof address === 'string' ? address.trim().toLowerCase() : '';
461
+ const session = typeof sessionId === 'string' ? sessionId.trim() : '';
462
+ if (key && session) senders[key] = session;
463
+ }
464
+ }
465
+ await state.setEmailBindings({ account, senders });
466
+ this.#touch();
467
+ return this.getSessionBinding(botId);
468
+ }
469
+
470
+ /**
471
+ * The bot's workspace, tolerating both a synchronous accessor and an async
472
+ * one, and treating a failure as "unknown" rather than failing the listing.
473
+ */
474
+ async #resolveBotWorkspace(botId) {
475
+ if (!this.#botWorkspaceFor) return null;
476
+ try {
477
+ return (await this.#botWorkspaceFor(botId)) ?? null;
478
+ } catch {
479
+ return null;
480
+ }
481
+ }
482
+
483
+ /** Sessions available to bind to, for the settings picker. */
484
+ async listSessions(botId) {
485
+ const config = this.#requireConfig(botId);
486
+ // The bot's own workspace is authoritative; defaultWorkspace is only a
487
+ // fallback for hosts that do not expose a per-bot workspace.
488
+ const workspace = (await this.#resolveBotWorkspace(botId)) ?? this.#defaultWorkspace;
489
+ if (!workspace || !this.#listWorkspaceSessions) {
490
+ return { botId, workspace: workspace ?? null, sessions: [] };
491
+ }
492
+ const listed = await this.#listWorkspaceSessions(workspace);
493
+ const sessions = Array.isArray(listed?.sessions) ? listed.sessions : [];
494
+ return {
495
+ botId,
496
+ workspace: listed?.workspace ?? workspace,
497
+ sessions: sessions.map((session) => ({
498
+ sessionId: session.sessionId ?? session.id ?? '',
499
+ title: String(session.title ?? '').slice(0, 120),
500
+ updatedAt: session.updatedAt ?? null,
501
+ })).filter((session) => session.sessionId),
502
+ config,
503
+ };
504
+ }
505
+
506
+ /**
507
+ * The shared token-bot RPC handler addresses mailbox binding through the
508
+ * generic `bindCredentials` endpoint, so the mailbox-specific method is
509
+ * exposed under that name as well.
510
+ */
511
+ bindCredentials(payload) {
512
+ return this.bindMailbox(payload);
513
+ }
514
+
515
+ async close() {
516
+ if (this.#closed) return;
517
+ this.#closed = true;
518
+ for (const botId of [...this.#runtimes.keys()]) {
519
+ await this.#stopRuntime(botId).catch(() => {});
520
+ }
521
+ }
522
+
523
+ // ── internals ────────────────────────────────────────────────────────────
524
+
525
+ /**
526
+ * Build the Harness access policy for a mailbox: both scopes are allowlists
527
+ * seeded from the configured senders, so mail from anyone else is refused.
528
+ */
529
+ #accessPolicyFor(config) {
530
+ const senders = Array.isArray(config?.allowedSenders) ? config.allowedSenders : [];
531
+ const scope = createAccessPolicyScope({
532
+ mode: 'allowlist',
533
+ open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
534
+ allowlist: { users: senders.map((id) => ({ id, canExecuteCommands: true })) },
535
+ });
536
+ return createAccessPolicy({ direct: scope, group: scope });
537
+ }
538
+
539
+ /**
540
+ * Make sure the bot's workspace record exists before anything is written to
541
+ * it. The access policy is stored inside that record, so on a brand-new
542
+ * mailbox `#applyAllowlistToPolicy` has no bot to address yet and the store
543
+ * refuses it with "workspace-bot-not-found". Ordering the policy push after
544
+ * the runtime would fix that too, but it would also make the runtime — and
545
+ * the whole transport handshake behind it — a precondition for a purely
546
+ * local write, and would leave a mailbox briefly bound with no policy.
547
+ *
548
+ * Hosts without the hook (and the unit tests) skip this: their syncAccessPolicy
549
+ * has its own storage and never needed the record.
550
+ */
551
+ async #ensureBotWorkspace(botId, config) {
552
+ if (!this.#ensureWorkspace) return;
553
+ try {
554
+ await this.#ensureWorkspace(botId, config);
555
+ } catch (error) {
556
+ this.#logger.warn?.('[dsh-im:email] failed to prepare the bot workspace:', extractConnectionEvidence(error).details);
557
+ throw new Error(t('邮箱访问策略同步失败,请重试'));
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Undo a bind that failed after its config was written. Restoring the
563
+ * credential first, then the config, keeps the pair consistent: a config
564
+ * pointing at a token ref that no longer holds a secret is the one state the
565
+ * channel cannot recover from on its own.
566
+ *
567
+ * A brand-new mailbox is removed outright; a re-bind of an existing one is
568
+ * restored to the config it had, so the user's previous working mailbox
569
+ * survives a failed re-bind.
570
+ */
571
+ async #rollbackBind(config, previousConfig, previousCredential) {
572
+ await this.#restoreCredential(config.tokenRef, previousCredential);
573
+ try {
574
+ if (previousConfig) await atConnectionStage('account.save', () => this.#configStore.save(previousConfig), 'account-config');
575
+ else await atConnectionStage('account.remove', () => this.#configStore.remove(config.botId), 'account-config');
576
+ } catch (error) {
577
+ // The bind failure is the error the user needs to see; a rollback that
578
+ // could not complete is reported alongside it rather than replacing it.
579
+ this.#logger.warn?.('[dsh-im:email] failed to roll back the mailbox config:', extractConnectionEvidence(error).details);
580
+ }
581
+ }
582
+
583
+ /** Push the mailbox allowlist into the Harness access policy. */
584
+ async #applyAllowlistToPolicy(botId, config) {
585
+ if (!this.#syncAccessPolicy) return;
586
+ try {
587
+ await this.#syncAccessPolicy(botId, this.#accessPolicyFor(config));
588
+ } catch (error) {
589
+ // The mailbox itself is already saved and connected; a policy push
590
+ // failure must not undo that, but it is surfaced for diagnosis.
591
+ this.#logger.warn?.('[dsh-im:email] failed to sync the access policy:', extractConnectionEvidence(error).details);
592
+ throw new Error(t('邮箱访问策略同步失败,请重试'));
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Instantiate the transport a mailbox is configured to use. The registry is
598
+ * injected so tests can substitute one, and an unknown key falls back to the
599
+ * default rather than failing the whole channel.
600
+ */
601
+ #createTransport(config) {
602
+ const key = normalizeEmailTransport(config?.transport ?? DEFAULT_EMAIL_TRANSPORT);
603
+ const factory = this.#transports[key] ?? this.#transports[DEFAULT_EMAIL_TRANSPORT];
604
+ if (typeof factory !== 'function') {
605
+ throw new TypeError(`No transport registered for ${key}`);
606
+ }
607
+ return assertTransport(factory({ config }), `email transport ${key}`);
608
+ }
609
+
610
+ #requireConfig(botId) {
611
+ const config = this.#configStore.get(botId);
612
+ if (!config) throw new Error(t('未找到该邮箱配置'));
613
+ return config;
614
+ }
615
+
616
+ /**
617
+ * The credential provider resolves a ref to a wrapper object whose payload
618
+ * lives on `.value` (the same contract the shared token controller uses), so
619
+ * the stored JSON is unwrapped from there rather than parsed directly.
620
+ */
621
+ async #resolveSecrets(config) {
622
+ const result = await atConnectionStage('credential.read', () => this.#credentials.resolve(config.tokenRef), 'credential-store');
623
+ const stored = result?.value;
624
+ if (typeof stored === 'string' && stored) {
625
+ try {
626
+ // The mailbox secret is stored as JSON so one ref carries the address
627
+ // and the app password together.
628
+ const parsed = normalizeCredential(JSON.parse(stored));
629
+ if (parsed) return parsed;
630
+ } catch {
631
+ // Fall through to the transport-specific default below.
632
+ }
633
+ }
634
+ // The Agent mailbox needs no secret of ours: agently-cli keeps its own
635
+ // credentials in the system keychain. Returning null here used to skip the
636
+ // mailbox entirely, so it silently never started.
637
+ if (normalizeEmailTransport(config.transport) === 'agent-mail') {
638
+ return Object.freeze({ address: config.platformId });
639
+ }
640
+ return null;
641
+ }
642
+
643
+ async #startRuntime(config, credential) {
644
+ // Production owns state/workspace resolution; the controller only passes
645
+ // the identity and the mailbox secret through.
646
+ const runtime = await atConnectionStage('runtime.prepare', () => this.#createRuntime({
647
+ botId: config.botId,
648
+ config,
649
+ // `token` stays for the shared runtime shape; the full credential is what
650
+ // a transport actually needs, since a mailbox may carry tokens instead of
651
+ // a password.
652
+ token: credential.password ?? credential.accessToken,
653
+ credential,
654
+ // The runtime must build the transport the mailbox is configured for.
655
+ // Its own default is IMAP/SMTP, so without this an Agent mailbox was
656
+ // dialled as a mail server and failed with ECONNREFUSED on port 993.
657
+ createTransport: (options) => this.#createTransport(options.config ?? options),
658
+ }));
659
+ if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
660
+ throw new TypeError('createRuntime returned an invalid Email runtime');
661
+ }
662
+ this.#runtimes.set(config.botId, runtime);
663
+ try {
664
+ await runtime.start();
665
+ } catch (error) {
666
+ this.#runtimes.delete(config.botId);
667
+ await runtime.stop().catch(() => {});
668
+ throw error;
669
+ }
670
+ }
671
+
672
+ async #stopRuntime(botId) {
673
+ const runtime = this.#runtimes.get(botId);
674
+ this.#runtimes.delete(botId);
675
+ await runtime?.stop().catch((error) => {
676
+ this.#logger.warn?.(`[dsh-im:email] bot ${botId} failed to stop cleanly:`, extractConnectionEvidence(error).details);
677
+ });
678
+ }
679
+
680
+ /** Roll a credential ref back to its previous plain value, or clear it. */
681
+ /**
682
+ * Begin an out-of-band authorization for a transport that needs one (the
683
+ * Agent mailbox authorizes by WeChat QR code rather than a password). The
684
+ * pending device code is held until the matching poll completes it.
685
+ */
686
+ async startAuthorization({ transport, hostname, workspace } = {}) {
687
+ const key = normalizeEmailTransport(transport ?? DEFAULT_EMAIL_TRANSPORT);
688
+ if (key !== 'agent-mail') {
689
+ throw new TypeError(t('该接入方式不需要扫码授权'));
690
+ }
691
+ // The official CLI owns the Agent mailbox protocol, including the token
692
+ // refresh: a hand-written client was refused one (invalid_grant), so the
693
+ // mailbox died an hour after every authorization.
694
+ //
695
+ // The CLI isolates accounts per workspace, so a mailbox authorizes into its
696
+ // own. Sharing one workspace made every Agent mailbox read the first
697
+ // account that logged in.
698
+ const scope = String(workspace ?? '').trim();
699
+ const device = await startAgentMailAuthorization({ workspace: scope });
700
+ const expiresAt = Date.now() + (device.expiresInMs ?? AGENT_MAIL_AUTH_TTL_MS);
701
+ const pending = { transport: key, workspace: scope, startedAt: Date.now(), expiresAt };
702
+ this.#pendingAuth = pending;
703
+ // Persisted too: the code outlives a reload, and losing it would strand an
704
+ // authorization the user already completed.
705
+ await this.#storePendingAuth(pending);
706
+ return {
707
+ transport: key,
708
+ // The authorization page embeds its own WeChat QR, so the URL is what the
709
+ // user opens or scans; there is no one-shot scan payload to render.
710
+ browserUrl: device.browserUrl,
711
+ inputCode: device.inputCode,
712
+ expiresAt,
713
+ expiresInMs: expiresAt - Date.now(),
714
+ };
715
+ }
716
+
717
+ /**
718
+ * Check a pending authorization once.
719
+ *
720
+ * The CLI stores the credentials itself, so nothing is returned to bind —
721
+ * only the address, which the panel shows and names the mailbox after.
722
+ */
723
+ async pollAuthorization() {
724
+ // The in-memory copy is authoritative; the stored one survives a restart so
725
+ // a completed scan can still be redeemed.
726
+ const pending = this.#pendingAuth ?? await this.#loadPendingAuth();
727
+ if (!pending) throw new TypeError(t('扫码授权尚未开始'));
728
+ if (Date.now() > pending.expiresAt) {
729
+ this.#pendingAuth = null;
730
+ await this.#storePendingAuth(null);
731
+ throw new TypeError(t('扫码授权已超时,请重新发起'));
732
+ }
733
+ const status = await agentMailAuthorizationStatus({ workspace: pending.workspace });
734
+ if (!status.loggedIn) {
735
+ return { status: status.status || 'pending', authorized: false };
736
+ }
737
+ this.#pendingAuth = null;
738
+ await this.#storePendingAuth(null);
739
+ // The mailbox address is what the account is named and bound as, and the
740
+ // server already knows it — so it is read here rather than typed.
741
+ let identity = null;
742
+ try {
743
+ identity = await fetchAgentMailIdentity({ workspace: pending.workspace });
744
+ } catch (error) {
745
+ this.#logger.warn?.('[dsh-im:email] unable to resolve the mailbox identity:', extractConnectionEvidence(error).details);
746
+ }
747
+ return {
748
+ status: 'authorized',
749
+ authorized: true,
750
+ ...(identity ? { address: identity.address, name: identity.name } : {}),
751
+ };
752
+ }
753
+
754
+ /** Find the mailbox state that owns a pending authorization. */
755
+ async #stateForPendingAuth() {
756
+ if (!this.#stateFor) return null;
757
+ const [first] = this.#configStore.list();
758
+ if (!first) return null;
759
+ return this.#stateFor(first.botId).catch(() => null);
760
+ }
761
+
762
+ async #storePendingAuth(pending) {
763
+ const state = await this.#stateForPendingAuth();
764
+ await state?.setPendingAuth?.(pending).catch(() => {});
765
+ }
766
+
767
+ async #loadPendingAuth() {
768
+ const state = await this.#stateForPendingAuth();
769
+ const stored = state?.pendingAuth?.() ?? null;
770
+ if (!stored) return null;
771
+ if (Date.now() > stored.expiresAt) return null;
772
+ return stored;
773
+ }
774
+
775
+ /**
776
+ * Persist tokens a transport rotated mid-flight. The Agent mailbox hands back
777
+ * a new refresh token on every refresh, so losing the write would break the
778
+ * next refresh and eventually sign the mailbox out.
779
+ */
780
+ async persistTokens(botId, tokens = {}) {
781
+ const config = this.#configStore.list().find((entry) => entry.botId === botId);
782
+ if (!config) return false;
783
+ const accessToken = typeof tokens.accessToken === 'string' ? tokens.accessToken : '';
784
+ if (!accessToken) return false;
785
+ const current = (await this.#resolveSecrets(config)) ?? {};
786
+ const next = {
787
+ ...current,
788
+ accessToken,
789
+ ...(typeof tokens.refreshToken === 'string' && tokens.refreshToken
790
+ ? { refreshToken: tokens.refreshToken } : {}),
791
+ };
792
+ await atConnectionStage('credential.save', () => this.#credentials.set(config.tokenRef, JSON.stringify(next)), 'credential-store');
793
+ this.#touch();
794
+ return true;
795
+ }
796
+
797
+ async #restoreCredential(tokenRef, previous) {
798
+ if (typeof previous !== 'string' || !previous) {
799
+ await atConnectionStage('credential.remove', () => this.#credentials.unset(tokenRef), 'credential-store').catch(() => {});
800
+ return;
801
+ }
802
+ await atConnectionStage('credential.save', () => this.#credentials.set(tokenRef, previous), 'credential-store').catch(() => {});
803
+ }
804
+
805
+ get diagnostics() { return this.#diagnostics; }
806
+ #safeError(code, error) {
807
+ return this.#diagnostics.report(error, { reuse: true, stage: 'connection.start', code }).publicError;
808
+ }
809
+
810
+ #touch() {
811
+ this.#revision += 1;
812
+ }
813
+
814
+ async #withBotTransition(botId, task) {
815
+ const previous = this.#transitions.get(botId) ?? Promise.resolve();
816
+ const next = previous.then(task, task).finally(() => {
817
+ if (this.#transitions.get(botId) === next) this.#transitions.delete(botId);
818
+ });
819
+ this.#transitions.set(botId, next);
820
+ return next;
821
+ }
822
+ }
823
+
824
+ export { EmailConfigStore, EmailStateStore };