@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,604 @@
1
+ /**
2
+ * Agent mailbox transport, backed by the official `agently-cli`.
3
+ *
4
+ * This transport speaks the same contract as the IMAP/SMTP one
5
+ * (`transport.mjs`); only the wire protocol differs. All protocol knowledge —
6
+ * OAuth, token storage, refresh, retry — belongs to the CLI, which is the only
7
+ * implementation the server accepts refresh tokens from.
8
+ *
9
+ * Command shapes come from `--print-output-schema`, not guesswork.
10
+ */
11
+
12
+ import {
13
+ AgentMailCliError,
14
+ cliEnv,
15
+ isCliAvailable,
16
+ runCli,
17
+ startCliLogin,
18
+ } from './agently-cli.mjs';
19
+ import {
20
+ MAX_REPLY_CHARS,
21
+ normalizeAddress,
22
+ stripQuotedHistory,
23
+ } from '../mail-format.mjs';
24
+ import { assertTransport, replySubject } from '../transport.mjs';
25
+
26
+ /** Re-exported so callers keep one import site for Agent mailbox errors. */
27
+ export { AgentMailCliError as AgentMailError };
28
+
29
+ /** How many messages one poll may return. */
30
+ const DEFAULT_PAGE_SIZE = 25;
31
+
32
+ /**
33
+ * The most list pages one poll may walk.
34
+ *
35
+ * The provider caps a page at 25 summaries and rate-limits tightly, so the walk
36
+ * that seeds a fresh mailbox is bounded rather than following `next_cursor`
37
+ * through an arbitrarily long backlog. The bound is deliberately generous: a
38
+ * walk that stops short of the oldest mail cannot return it without either
39
+ * skipping the mail beneath or stranding it behind a cursor, so the seed walk
40
+ * must be allowed to reach the true bottom. 40 pages covers a 1000-message
41
+ * backlog, and an ordinary poll stops at the cursor after one page.
42
+ */
43
+ const MAX_LIST_PAGES = 40;
44
+
45
+ /** Mailbox folders the CLI understands. */
46
+ const INBOX = 'inbox';
47
+
48
+ /**
49
+ * Begin an authorization and return the URL the user opens or scans.
50
+ *
51
+ * `agently-cli auth login` prints the URL and then blocks until the scan
52
+ * completes, so the process is left running and the caller observes the outcome
53
+ * through `agentMailAuthorizationStatus`.
54
+ */
55
+ export async function startAgentMailAuthorization({ signal, workspace } = {}) {
56
+ const started = await startCliLogin({ signal, workspace });
57
+ return {
58
+ browserUrl: started.browserUrl,
59
+ inputCode: started.inputCode,
60
+ // The window belongs to the server; this is the CLI's own default.
61
+ expiresInMs: 600_000,
62
+ };
63
+ }
64
+
65
+ /** The authorization status, as the CLI reports it. */
66
+ export async function agentMailAuthorizationStatus({ signal, workspace } = {}) {
67
+ try {
68
+ const { document } = await runCli(['auth', 'status'], { signal, env: cliEnv(workspace) });
69
+ const data = document?.data ?? {};
70
+ return {
71
+ loggedIn: data.logged_in === true,
72
+ status: String(data.status ?? ''),
73
+ message: String(data.message ?? ''),
74
+ workspace: String(data.workspace ?? ''),
75
+ };
76
+ } catch (error) {
77
+ if (error?.code === 'auth') return { loggedIn: false, status: 'not_logged_in', message: error.message };
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ /** Force a token refresh through the CLI. */
83
+ export async function refreshAgentMailToken({ signal } = {}) {
84
+ await runCli(['auth', 'refresh'], { signal });
85
+ return true;
86
+ }
87
+
88
+ /** The account's own address, from `+me`. */
89
+ export async function fetchAgentMailIdentity({ signal, workspace } = {}) {
90
+ const { document } = await runCli(['+me'], { signal, env: cliEnv(workspace) });
91
+ const aliases = Array.isArray(document?.data?.aliases) ? document.data.aliases : [];
92
+ const primary = aliases.find((entry) => entry?.is_primary) ?? aliases[0];
93
+ const address = normalizeAddress(primary?.email);
94
+ if (!address) {
95
+ throw new AgentMailCliError('agently-cli reported no mailbox address', {
96
+ code: 'identity-missing',
97
+ });
98
+ }
99
+ return {
100
+ address,
101
+ aliasId: String(primary?.alias_id ?? '').trim(),
102
+ name: String(primary?.name ?? '').trim(),
103
+ // The provider publishes its own limits here rather than in its docs, so
104
+ // they are read at runtime instead of being hard-coded.
105
+ rateLimits: normalizeRateLimits(document?.data?.rate_limits),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * The provider's declared limits, normalized to a stable shape.
111
+ *
112
+ * Returns null when the account reports none, so callers can fall back rather
113
+ * than treating an absent field as "unlimited".
114
+ */
115
+ export function normalizeRateLimits(raw) {
116
+ if (!raw || typeof raw !== 'object') return null;
117
+ // The payload nests limits by capability in some revisions and is flat in
118
+ // others, so the object is flattened one level before reading.
119
+ const flat = { ...raw };
120
+ for (const value of Object.values(raw)) {
121
+ if (value && typeof value === 'object') Object.assign(flat, value);
122
+ }
123
+ const positive = (...keys) => {
124
+ for (const key of keys) {
125
+ const value = Number(flat[key]);
126
+ if (Number.isFinite(value) && value > 0) return value;
127
+ }
128
+ return null;
129
+ };
130
+ const limits = {
131
+ perMinute: positive('requests_per_minute', 'per_minute', 'rpm'),
132
+ perHour: positive('requests_per_hour', 'per_hour', 'rph'),
133
+ dailySendQuota: positive('daily_send_quota', 'per_day'),
134
+ };
135
+ const normalized = Object.fromEntries(
136
+ Object.entries(limits).filter(([, value]) => value !== null),
137
+ );
138
+ return Object.keys(normalized).length > 0 ? normalized : null;
139
+ }
140
+
141
+ /** One message summary or full message, in this channel's shape. */
142
+ export function normalizeAgentMailMessage(raw) {
143
+ if (!raw || typeof raw !== 'object') return null;
144
+ const id = String(raw.message_id ?? '').trim();
145
+ if (!id) return null;
146
+ const addressOf = (entry) => normalizeAddress(entry?.email ?? entry);
147
+ const people = (list) => (Array.isArray(list) ? list : [])
148
+ .map((entry) => ({ address: addressOf(entry), name: entry?.name }))
149
+ .filter((entry) => entry.address);
150
+ return {
151
+ // `uid` is the CLI's own id, used for every follow-up command.
152
+ uid: id,
153
+ // The RFC Message-ID is the thread key; it only comes with the full read.
154
+ messageId: String(raw.rfc_message_id ?? '').trim(),
155
+ from: { value: people(raw.from ? [raw.from] : []) },
156
+ to: { value: people(raw.to) },
157
+ cc: { value: people(raw.cc) },
158
+ subject: String(raw.subject ?? ''),
159
+ text: typeof raw.body === 'string' ? raw.body : String(raw.snippet ?? ''),
160
+ html: '',
161
+ // Field names follow what the runtime reads (`filename`/`size`/
162
+ // `contentType`); the API's own names live on the right, and the earlier
163
+ // aliases (`fileName`/`bytes`/`mediaType`) were simply ignored, so an
164
+ // inbound attachment arrived with no name, no size and no type.
165
+ attachments: (Array.isArray(raw.attachments) ? raw.attachments : []).map((file) => ({
166
+ filename: String(file?.filename ?? 'attachment'),
167
+ size: Number(file?.size) || 0,
168
+ ...(file?.content_type ? { contentType: String(file.content_type) } : {}),
169
+ attachmentId: String(file?.attachment_id ?? ''),
170
+ // A large attachment exposes a download URL instead of an id.
171
+ ...(file?.download_url ? { downloadUrl: String(file.download_url) } : {}),
172
+ })),
173
+ headers: { get: () => undefined },
174
+ };
175
+ }
176
+
177
+ export class AgentMailTransport {
178
+ #config;
179
+ #signal;
180
+ #connected = false;
181
+ #address = '';
182
+ // Indirection so tests can drive the protocol without the real binary.
183
+ #run = runCli;
184
+ #workspace = '';
185
+ // undefined = not yet probed; '' = use the CLI default.
186
+ #workspaceResolved;
187
+
188
+ /** Test seam: swap the CLI runner. Not part of the transport contract. */
189
+ __setRunCliForTests(impl) {
190
+ if (typeof impl === 'function') this.#run = impl;
191
+ }
192
+
193
+ /**
194
+ * The workspace to call the CLI in.
195
+ *
196
+ * agently-cli separates accounts per workspace, so a mailbox normally reads
197
+ * from its own. That matters only when several workspaces have logins: with a
198
+ * single authorized account every mailbox belongs to it, and pinning each one
199
+ * to a workspace nobody logged into just reports "authorization required".
200
+ * The pinned workspace is therefore used when it has a login, and the CLI's
201
+ * default otherwise.
202
+ */
203
+ async #workspaceFor() {
204
+ if (this.#workspaceResolved !== undefined) return this.#workspaceResolved;
205
+ let resolved = '';
206
+ if (this.#workspace) {
207
+ try {
208
+ const { document } = await this.#run(['auth', 'status'], { signal: this.#signal, env: cliEnv(this.#workspace) });
209
+ if (document?.data?.logged_in === true) resolved = this.#workspace;
210
+ } catch {
211
+ // No login there; fall through to the CLI default.
212
+ }
213
+ }
214
+ this.#workspaceResolved = resolved;
215
+ return resolved;
216
+ }
217
+
218
+ /**
219
+ * The mailbox this transport is actually talking to.
220
+ *
221
+ * A fallback to the CLI default is only safe when that login owns this
222
+ * address. Reusing another mailbox's login would silently send this
223
+ * mailbox's replies from the wrong sender, which the recipient sees as a
224
+ * stranger answering them.
225
+ */
226
+ async #assertIdentity(address) {
227
+ // Callers pass the mailbox as `address` (the runtime and the bind probe
228
+ // both do); `platformId` is the same value under its config-store name.
229
+ // Reading only one of them left `wanted` empty and skipped the check.
230
+ const wanted = normalizeAddress(this.#config.address) || normalizeAddress(this.#config.platformId);
231
+ if (!wanted) {
232
+ throw new AgentMailCliError(
233
+ 'the mailbox address is unknown, so the CLI login cannot be verified',
234
+ { code: 'identity-unknown' },
235
+ );
236
+ }
237
+ if (address === wanted) return;
238
+ throw new AgentMailCliError(
239
+ `agently-cli is logged in as ${address}, not ${wanted}; authorize this mailbox separately`,
240
+ { code: 'identity-mismatch' },
241
+ );
242
+ }
243
+
244
+ /** Invalidate the cached workspace, after an authorization for instance. */
245
+ __resetWorkspaceCache() {
246
+ this.#workspaceResolved = undefined;
247
+ }
248
+
249
+ /** Call the CLI in this mailbox's workspace, resolved once and cached. */
250
+ async #call(args, options = {}) {
251
+ const workspace = await this.#workspaceFor();
252
+ return this.#run(args, { ...options, env: { ...cliEnv(workspace), ...(options.env ?? {}) } });
253
+ }
254
+
255
+ constructor({ config, signal } = {}) {
256
+ if (!config || typeof config !== 'object') {
257
+ throw new TypeError('AgentMailTransport requires a config');
258
+ }
259
+ if (!isCliAvailable()) {
260
+ throw new AgentMailCliError(
261
+ 'the Agent mailbox requires @tencent-qqmail/agently-cli, which is not installed',
262
+ { code: 'cli-missing' },
263
+ );
264
+ }
265
+ this.#config = config;
266
+ this.#signal = signal;
267
+ this.#address = normalizeAddress(config.address);
268
+ // Stable per-mailbox workspace derived from the address, so a login is
269
+ // never shared between two mailboxes.
270
+ this.#workspace = String(config.workspace ?? config.platformId ?? config.address ?? '').trim();
271
+ }
272
+
273
+ get address() {
274
+ return this.#address || this.#config.address || '';
275
+ }
276
+
277
+ get transportKey() {
278
+ return 'agent-mail';
279
+ }
280
+
281
+ /** The CLI holds the session, so this only proves it is usable. */
282
+ async connect() {
283
+ if (this.#connected) return;
284
+ // Goes through the instance runner so tests never spawn the real binary.
285
+ const { document } = await this.#call(['+me'], { signal: this.#signal });
286
+ const aliases = Array.isArray(document?.data?.aliases) ? document.data.aliases : [];
287
+ const primary = aliases.find((entry) => entry?.is_primary) ?? aliases[0];
288
+ const address = normalizeAddress(primary?.email);
289
+ if (!address) {
290
+ throw new AgentMailCliError('agently-cli reported no mailbox address', {
291
+ code: 'identity-missing',
292
+ });
293
+ }
294
+ await this.#assertIdentity(address);
295
+ this.#address = address;
296
+ this.#connected = true;
297
+ }
298
+
299
+ async disconnect() {
300
+ // Stateless: each command is its own process, so there is nothing to close.
301
+ this.#connected = false;
302
+ }
303
+
304
+ /**
305
+ * The newest id, used to seed a cursor.
306
+ *
307
+ * Reads the list summary only. Going through `listMessages` without an
308
+ * allowlist meant "no filter", so seeding a cursor downloaded the body of the
309
+ * newest message — mail the policy may well refuse, fetched before anyone
310
+ * asked for it. The newest id is in the first summary already.
311
+ */
312
+ async latestUid() {
313
+ const { document } = await this.#call(
314
+ ['message', '+list', '--dir', INBOX, '--limit', '1'],
315
+ { signal: this.#signal },
316
+ );
317
+ const items = Array.isArray(document?.data?.data) ? document.data.data : [];
318
+ return String(items[0]?.message_id ?? '').trim() || 0;
319
+ }
320
+
321
+ /**
322
+ * New mail, oldest first.
323
+ *
324
+ * The provider lists newest-first and caps a page at 25 summaries, so the
325
+ * oldest unhandled batch is usually only reachable by following the page
326
+ * cursor: with a 30-message backlog the five oldest sit on the second page.
327
+ * Reversing a single page therefore both skipped the backlog (the cursor then
328
+ * advanced past it) and, on the next poll, re-delivered the same handled mail
329
+ * — burning rate limit on bodies already read.
330
+ *
331
+ * The walk here is over summaries only. A summary is filtered by the cursor
332
+ * and the sender allowlist first, so a body is fetched exactly once, for mail
333
+ * this call actually returns.
334
+ */
335
+ async listMessages({ afterUid = null, limit = DEFAULT_PAGE_SIZE, allowSenders = null } = {}) {
336
+ // A Set — even an empty one — means the caller supplied a policy: an
337
+ // empty allowlist admits nobody, so no body is fetched at all. Treating
338
+ // it as "no filter" downloaded mail the policy had already refused.
339
+ const allowed = allowSenders instanceof Set ? allowSenders : null;
340
+ const handledUid = afterUid === null || afterUid === undefined ? null : String(afterUid);
341
+ // Newest-first summaries of unhandled, allowed mail, newest first across
342
+ // every page walked; reversed into age order once the walk ends.
343
+ const pending = [];
344
+ let cursor = '';
345
+ // Whether the walk actually reached the handled boundary. When the page cap
346
+ // stops it first, the backlog below the deepest page is still unseen, so the
347
+ // batch must stay contiguous with the cursor instead of skipping ahead.
348
+ // With no cursor there is no known boundary to reach, so the walk simply
349
+ // goes as deep as the page cap allows.
350
+ let reachedHandled = false;
351
+
352
+ for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
353
+ const args = ['message', '+list', '--dir', INBOX, '--limit', String(DEFAULT_PAGE_SIZE)];
354
+ if (cursor) args.push('--cursor', cursor);
355
+ const { document } = await this.#call(args, { signal: this.#signal });
356
+ const items = Array.isArray(document?.data?.data) ? document.data.data : [];
357
+ if (items.length === 0) {
358
+ // The mailbox ends here, so nothing older than this page is pending.
359
+ reachedHandled = true;
360
+ break;
361
+ }
362
+
363
+ // Summaries arrive newest-first, so stopping at the cursor keeps the
364
+ // backlog older than it out of this batch.
365
+ for (const raw of items) {
366
+ const id = String(raw?.message_id ?? '').trim();
367
+ if (!id) continue;
368
+ if (handledUid !== null && id === handledUid) {
369
+ reachedHandled = true;
370
+ break;
371
+ }
372
+ if (allowed && !allowed.has(normalizeAddress(raw?.from?.email))) continue;
373
+ pending.push(raw);
374
+ }
375
+
376
+ // The walk stops once the cursor is covered: everything older than it is
377
+ // already handled, so paging further would only re-read processed mail.
378
+ //
379
+ // `limit` deliberately never stops the walk: pages are newest-first, so
380
+ // the oldest unhandled mail sits on the LAST page reached, not the first.
381
+ // Breaking as soon as `limit` summaries had been collected is what left
382
+ // the 5-message tail of a 30-message backlog on an unread page and then
383
+ // advanced the cursor past it.
384
+ if (reachedHandled) break;
385
+
386
+ const next = String(document?.data?.pagination?.next_cursor ?? '');
387
+ if (!next || next === cursor) {
388
+ reachedHandled = true;
389
+ break;
390
+ }
391
+ cursor = next;
392
+ }
393
+
394
+ // `pending` is newest-first, so the oldest `limit` entries are its tail.
395
+ // That tail is only the mailbox's oldest unhandled mail once the walk has
396
+ // reached the handled boundary (or the end of a fresh mailbox). While the
397
+ // backlog below the deepest page is still unseen, the tail would advance
398
+ // the cursor past pages nobody read, stranding them; the head is taken
399
+ // instead, which keeps the batch contiguous so the remaining backlog is
400
+ // drained from the top on the polls that follow.
401
+ const batch = (reachedHandled ? pending.slice(-limit) : pending.slice(0, limit)).reverse();
402
+ return this.#oldestFirst(batch, allowed);
403
+ }
404
+
405
+ /** Oldest-first, with each message's body and RFC id filled in. */
406
+ async #oldestFirst(items, allowed) {
407
+ // The caller already ordered these oldest-first; reversing here would undo
408
+ // the ordering the limit was applied to.
409
+ const ordered = items.slice();
410
+ const loaded = [];
411
+ for (const raw of ordered) {
412
+ loaded.push(await this.#readMessage(raw, allowed));
413
+ }
414
+ return loaded.filter(Boolean);
415
+ }
416
+
417
+ /** Read one message in full, falling back to the summary if that fails. */
418
+ async #readMessage(raw, allowed) {
419
+ const summary = normalizeAgentMailMessage(raw);
420
+ if (!summary) return null;
421
+ if (allowed && !allowed.has(normalizeAddress(summary.from?.value?.[0]?.address))) return null;
422
+ try {
423
+ const { document } = await this.#call(['message', '+read', '--id', summary.uid], {
424
+ signal: this.#signal,
425
+ });
426
+ const full = normalizeAgentMailMessage(document?.data ?? {});
427
+ if (!full) return summary;
428
+ const attachments = full.attachments.length > 0 ? full.attachments : summary.attachments;
429
+ return {
430
+ ...summary,
431
+ // The full read is the only place the thread id and body appear.
432
+ messageId: full.messageId || summary.messageId,
433
+ text: full.text || summary.text,
434
+ html: full.html || summary.html,
435
+ attachments: this.#withAttachmentContent(summary.uid, attachments),
436
+ cc: full.cc.value.length > 0 ? full.cc : summary.cc,
437
+ };
438
+ } catch {
439
+ // A failed read must not lose the mail; the snippet keeps it usable.
440
+ return summary;
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Attach a content loader to each attachment.
446
+ *
447
+ * The transport contract asks for `content`, but the API exposes only an id,
448
+ * so the bytes are fetched from `attachment +download` on demand. A failed
449
+ * download leaves that attachment without content rather than losing the
450
+ * message — its name and size are still reported.
451
+ */
452
+ #withAttachmentContent(messageId, attachments) {
453
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
454
+ return attachments.map((file) => ({
455
+ ...file,
456
+ content: async () => {
457
+ if (!file.attachmentId) return undefined;
458
+ try {
459
+ const { document } = await this.#call([
460
+ 'attachment', '+download',
461
+ '--msg', messageId,
462
+ '--att', file.attachmentId,
463
+ ], { signal: this.#signal });
464
+ const saved = document?.data?.saved_to;
465
+ if (!saved) return undefined;
466
+ const { readFile } = await import('node:fs/promises');
467
+ return await readFile(saved);
468
+ } catch {
469
+ return undefined;
470
+ }
471
+ },
472
+ }));
473
+ }
474
+
475
+ /** Reply, threading on the message's own RFC id. */
476
+ async sendReply({
477
+ to, subject, text, transportMessageId, references, attachments = [], headers,
478
+ } = {}) {
479
+ const body = this.#composeBody(text);
480
+ // The CLI takes the text through --body; `--body-file -` is read as a
481
+ // literal filename and fails with ENOENT.
482
+ const args = [
483
+ 'message', '+reply',
484
+ '--id', String(transportMessageId ?? ''),
485
+ ];
486
+ if (attachments.length > 0) {
487
+ const uploaded = await this.#uploadAttachments(attachments);
488
+ for (const id of uploaded) args.push('--attachment', id);
489
+ }
490
+ await this.#withConfirmation(args, body, headers);
491
+ return { sent: true, to, subject: subject ?? replySubject(''), references };
492
+ }
493
+
494
+ /** Send a new message rather than a reply. */
495
+ async sendText({ to, subject, text, attachments = [], headers } = {}) {
496
+ const body = this.#composeBody(text);
497
+ const args = ['message', '+send', '--to', String(to ?? '')];
498
+ if (subject) args.push('--subject', String(subject));
499
+ if (attachments.length > 0) {
500
+ const uploaded = await this.#uploadAttachments(attachments);
501
+ for (const id of uploaded) args.push('--attachment', id);
502
+ }
503
+ await this.#withConfirmation(args, body, headers);
504
+ return { sent: true, to, subject };
505
+ }
506
+
507
+ /** Reply bodies are trimmed to the same ceiling as the IMAP transport. */
508
+ #composeBody(text) {
509
+ return stripQuotedHistory(String(text ?? '')).slice(0, MAX_REPLY_CHARS);
510
+ }
511
+
512
+ /** Upload local attachments and return their ids. */
513
+ async #uploadAttachments(attachments) {
514
+ const ids = [];
515
+ for (const file of attachments) {
516
+ // A path can be uploaded directly; the runtime hands over bytes instead
517
+ // (`{ filename, content, contentType }`), which must be written out
518
+ // first — the earlier version read only `path`, skipped every real
519
+ // attachment, and the send still reported success.
520
+ let path = file?.path ?? file?.filePath ?? null;
521
+ let cleanup = null;
522
+ if (!path) {
523
+ const bytes = file?.content ?? file?.bytes ?? file?.data;
524
+ if (!bytes) {
525
+ throw new AgentMailCliError(
526
+ `attachment ${String(file?.filename ?? '(unnamed)')} has neither a path nor content`,
527
+ { code: 'attachment-unreadable' },
528
+ );
529
+ }
530
+ const written = await this.#writeTempAttachment(file?.filename, bytes);
531
+ if (!written) {
532
+ throw new AgentMailCliError(
533
+ `attachment ${String(file?.filename ?? '(unnamed)')} could not be staged for upload`,
534
+ { code: 'attachment-unwritable' },
535
+ );
536
+ }
537
+ ({ path, cleanup } = written);
538
+ }
539
+ try {
540
+ const { document } = await this.#call(['attachment', '+upload', '--file', path], {
541
+ signal: this.#signal,
542
+ });
543
+ const id = String(document?.data?.attachment_id ?? '').trim();
544
+ if (id) ids.push(id);
545
+ } finally {
546
+ if (cleanup) await cleanup();
547
+ }
548
+ }
549
+ return ids;
550
+ }
551
+
552
+ /** Write attachment bytes to a temp file so the CLI can upload them. */
553
+ async #writeTempAttachment(filename, bytes) {
554
+ try {
555
+ const { mkdtemp, writeFile, rm } = await import('node:fs/promises');
556
+ const { tmpdir } = await import('node:os');
557
+ const { join, basename } = await import('node:path');
558
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-email-att-'));
559
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
560
+ const safe = basename(String(filename ?? 'attachment')).replace(/[^\w.-]/g, '_') || 'attachment';
561
+ const path = join(dir, safe);
562
+ await writeFile(path, buffer);
563
+ return { path, cleanup: () => rm(dir, { recursive: true, force: true }).catch(() => {}) };
564
+ } catch {
565
+ return null;
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Run a sending command, completing the two-step confirmation.
571
+ *
572
+ * The first call returns `confirmation_token` with exit 0; resending the same
573
+ * arguments plus that token performs the send.
574
+ */
575
+ async #withConfirmation(args, body, headers) {
576
+ // agently-cli exposes no --header flag; extra headers are dropped rather
577
+ // than passed as an argument it would reject.
578
+ // The body travels as an argument: `--body-file -` is read as a literal
579
+ // filename and fails with ENOENT, so stdin is not an option here.
580
+ const withBody = [...args, '--body', body];
581
+ const first = await this.#call(withBody, { signal: this.#signal });
582
+ const token = String(first.document?.data?.confirmation_token ?? '').trim();
583
+ if (!token) return first.document;
584
+ const confirmed = await this.#call(
585
+ [...withBody, '--confirmation-token', token],
586
+ { signal: this.#signal },
587
+ );
588
+ return confirmed.document;
589
+ }
590
+ }
591
+
592
+ assertTransport(AgentMailTransport.prototype, 'AgentMailTransport');
593
+
594
+ /**
595
+ * Build a transport whose CLI calls are supplied by the caller.
596
+ *
597
+ * Tests need to drive list/read/send without the real binary; production
598
+ * constructs `AgentMailTransport` directly.
599
+ */
600
+ export function createAgentMailTransportForTests({ config, runCliImpl }) {
601
+ const transport = new AgentMailTransport({ config });
602
+ transport.__setRunCliForTests(runCliImpl);
603
+ return transport;
604
+ }