@alfe.ai/openclaw-email 0.0.0-stage → 0.1.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.
@@ -0,0 +1,1795 @@
1
+ let node_module = require("node:module");
2
+ let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
3
+ let _alfe_ai_config = require("@alfe.ai/config");
4
+ let _alfe_ai_email = require("@alfe.ai/email");
5
+ let _alfe_ai_openclaw_plugin_kit = require("@alfe.ai/openclaw-plugin-kit");
6
+ let node_crypto = require("node:crypto");
7
+ let _auriclabs_roles = require("@auriclabs/roles");
8
+ let node_fs_promises = require("node:fs/promises");
9
+ let node_path = require("node:path");
10
+ //#region src/hash.ts
11
+ /**
12
+ * Session and target identifiers derived from mailbox and thread ids.
13
+ *
14
+ * OpenClaw lowercases peer ids, and Graph conversation ids are
15
+ * case-sensitive, so ids are hashed to lowercase hex rather than embedded.
16
+ */
17
+ function shortHash(value) {
18
+ return (0, node_crypto.createHash)("sha256").update(value, "utf8").digest("hex").slice(0, 16);
19
+ }
20
+ /** Mailbox addresses are case-insensitive. */
21
+ function mailboxHash(address) {
22
+ return shortHash(address.trim().toLowerCase());
23
+ }
24
+ /** Provider thread ids are case-sensitive. */
25
+ function threadHash(threadId) {
26
+ return shortHash(threadId);
27
+ }
28
+ /** One OpenClaw session per email thread (`session.dmScope: per-channel-peer`). */
29
+ function peerIdFor(mailboxAddress, threadId) {
30
+ return `email:${mailboxHash(mailboxAddress)}:${threadHash(threadId)}`;
31
+ }
32
+ /** Outbound target for a thread. `sendText` gets no session, so the thread rides in `to`. */
33
+ function threadTarget(hash) {
34
+ return `thread:${hash}`;
35
+ }
36
+ const THREAD_TARGET = /^thread:([0-9a-f]{16})$/;
37
+ function parseThreadTarget(to) {
38
+ return THREAD_TARGET.exec(to?.trim() ?? "")?.[1] ?? null;
39
+ }
40
+ /** `a***@example.com`: enough to correlate logs without logging the address. */
41
+ function maskAddress(address) {
42
+ const at = address.lastIndexOf("@");
43
+ if (at <= 0) return "***";
44
+ return `${address.slice(0, 1)}***${address.slice(at)}`;
45
+ }
46
+ //#endregion
47
+ //#region src/channel.ts
48
+ /**
49
+ * The registered OpenClaw `email` channel.
50
+ *
51
+ * Turn replies go through dispatch's `deliver` callback; this channel carries
52
+ * sends made OUTSIDE a turn (the `message` tool, cron, follow-ups). 9.5's
53
+ * outbound `sendText` context has no session or peer, so the thread is
54
+ * encoded in the target: `thread:<threadHash>`, the same value dispatch sets
55
+ * as the session's `OriginatingTo`.
56
+ */
57
+ const CHANNEL_ID = "email";
58
+ const ACCOUNT_ID$1 = "default";
59
+ function createEmailChannelPlugin(getHandle) {
60
+ return {
61
+ id: CHANNEL_ID,
62
+ meta: {
63
+ id: CHANNEL_ID,
64
+ label: "Email",
65
+ selectionLabel: "Email (Gmail & Outlook)",
66
+ detailLabel: "Email",
67
+ docsPath: "/channels/email",
68
+ docsLabel: "email",
69
+ blurb: "Email threads in the agent's connected Gmail or Outlook mailbox.",
70
+ systemImage: "envelope",
71
+ order: 110,
72
+ forceAccountBinding: false,
73
+ showConfigured: true
74
+ },
75
+ capabilities: {
76
+ chatTypes: ["direct"],
77
+ reactions: false,
78
+ edit: false,
79
+ unsend: false,
80
+ reply: true,
81
+ effects: false,
82
+ groupManagement: false,
83
+ threads: false,
84
+ media: false,
85
+ nativeCommands: false,
86
+ polls: false
87
+ },
88
+ config: {
89
+ listAccountIds() {
90
+ return [ACCOUNT_ID$1];
91
+ },
92
+ resolveAccount(_cfg, accountId) {
93
+ return {
94
+ accountId: accountId ?? ACCOUNT_ID$1,
95
+ enabled: true
96
+ };
97
+ },
98
+ defaultAccountId() {
99
+ return ACCOUNT_ID$1;
100
+ },
101
+ isEnabled() {
102
+ return true;
103
+ },
104
+ isConfigured() {
105
+ return getHandle() !== null;
106
+ },
107
+ describeAccount(account) {
108
+ const status = getHandle()?.status();
109
+ return {
110
+ accountId: account.accountId,
111
+ enabled: true,
112
+ configured: status !== void 0,
113
+ running: status?.state === "ok",
114
+ statusState: status?.state ?? "stopped",
115
+ ...status?.reason ? { stateReason: status.reason } : {},
116
+ lastError: status?.state === "error" ? status.message ?? null : null,
117
+ lastMessageAt: status?.lastMessageAt ?? null
118
+ };
119
+ }
120
+ },
121
+ outbound: {
122
+ deliveryMode: "direct",
123
+ textChunkLimit: 1e5,
124
+ resolveTarget(params) {
125
+ const hash = parseThreadTarget(params.to);
126
+ if (!hash) return {
127
+ ok: false,
128
+ error: /* @__PURE__ */ new Error("Invalid email target: use thread:<threadHash>")
129
+ };
130
+ const handle = getHandle();
131
+ if (handle && !handle.hasThread(hash)) return {
132
+ ok: false,
133
+ error: /* @__PURE__ */ new Error("Unknown email thread for this mailbox")
134
+ };
135
+ return {
136
+ ok: true,
137
+ to: `thread:${hash}`
138
+ };
139
+ },
140
+ async sendText(ctx) {
141
+ const handle = getHandle();
142
+ if (!handle) throw new Error("Email channel is not running; outbound email is disabled");
143
+ const outcome = await handle.sendToThread(ctx.to, ctx.text);
144
+ if (outcome.kind !== "sent") throw new Error(`Email not sent (${outcome.kind})`);
145
+ return {
146
+ channel: CHANNEL_ID,
147
+ messageId: outcome.messageId,
148
+ target: {
149
+ kind: "conversation",
150
+ id: ctx.to
151
+ },
152
+ timestamp: Date.now()
153
+ };
154
+ }
155
+ }
156
+ };
157
+ }
158
+ //#endregion
159
+ //#region src/credentials.ts
160
+ /**
161
+ * - `reconnect`: the Connection cannot serve this mailbox until the user
162
+ * reconnects it.
163
+ * - `agent-auth`: the Agent API rejected THIS AGENT's own token (401). That is
164
+ * an agent credential problem, not a mailbox one; reconnecting the mailbox
165
+ * would not fix it.
166
+ */
167
+ var CredentialError = class extends Error {
168
+ constructor(message, reason = "reconnect") {
169
+ super(message);
170
+ this.reason = reason;
171
+ this.name = "CredentialError";
172
+ }
173
+ };
174
+ /** Treat a token as expired this long before its stated expiry. */
175
+ const EXPIRY_SKEW_MS = 6e4;
176
+ /** Agent API statuses that mean "this Connection is unusable", not "try again". */
177
+ const RECONNECT_STATUSES = new Set([
178
+ 400,
179
+ 403,
180
+ 404
181
+ ]);
182
+ var ConnectionCredentials = class {
183
+ described = null;
184
+ storedToken = null;
185
+ inflight = null;
186
+ refreshConflicts = 0;
187
+ constructor(client, connectionId, now = Date.now) {
188
+ this.client = client;
189
+ this.connectionId = connectionId;
190
+ this.now = now;
191
+ }
192
+ /** Fetch the Connection once to learn its provider and mailbox. */
193
+ async describe() {
194
+ if (this.described) return this.described;
195
+ const creds = await this.call(() => this.client.getConnectionCredentials(this.connectionId));
196
+ if (creds.provider !== "google" && creds.provider !== "microsoft") throw new CredentialError("The email channel's Connection is not a Google or Microsoft mailbox");
197
+ if (typeof creds.accountIdentifier !== "string" || !creds.accountIdentifier.includes("@")) throw new CredentialError("The email channel's Connection has no mailbox address");
198
+ this.described = {
199
+ provider: creds.provider,
200
+ accountIdentifier: creds.accountIdentifier
201
+ };
202
+ const expiresAt = parseExpiry(creds.accessTokenExpiresAt);
203
+ if (typeof creds.accessToken === "string" && creds.accessToken !== "" && expiresAt > this.now() + EXPIRY_SKEW_MS) this.storedToken = {
204
+ token: creds.accessToken,
205
+ expiresAt
206
+ };
207
+ return this.described;
208
+ }
209
+ /** The driver's {@link AccessTokenProvider}. */
210
+ getAccessToken = () => {
211
+ this.inflight ??= this.nextToken().finally(() => {
212
+ this.inflight = null;
213
+ });
214
+ return this.inflight;
215
+ };
216
+ async nextToken() {
217
+ const { provider, accountIdentifier } = await this.describe();
218
+ const stored = this.storedToken;
219
+ this.storedToken = null;
220
+ if (stored && stored.expiresAt > this.now() + EXPIRY_SKEW_MS) return stored.token;
221
+ let refreshed;
222
+ try {
223
+ refreshed = await this.call(() => provider === "microsoft" ? this.client.refreshMicrosoftAccountToken(accountIdentifier, { connectionId: this.connectionId }) : this.client.refreshSocialAccount("google", accountIdentifier, { connectionId: this.connectionId }));
224
+ } catch (err) {
225
+ if (err?.status !== 409) {
226
+ this.refreshConflicts = 0;
227
+ throw err;
228
+ }
229
+ this.refreshConflicts += 1;
230
+ if (this.refreshConflicts >= 3) throw new CredentialError(`Connect refused ${String(this.refreshConflicts)} refreshes in a row because this mailbox no longer resolves to the channel's Connection`);
231
+ throw err;
232
+ }
233
+ this.refreshConflicts = 0;
234
+ if (typeof refreshed.accessToken !== "string" || refreshed.accessToken === "") throw new CredentialError("Connect returned no access token for the mailbox");
235
+ return refreshed.accessToken;
236
+ }
237
+ async call(fn) {
238
+ try {
239
+ return await fn();
240
+ } catch (err) {
241
+ const status = err?.status;
242
+ if (status === 401) throw new CredentialError("The Agent API rejected this agent's token", "agent-auth");
243
+ if (typeof status === "number" && RECONNECT_STATUSES.has(status)) throw new CredentialError(`Connect rejected the mailbox Connection (${String(status)})`);
244
+ throw err;
245
+ }
246
+ }
247
+ };
248
+ function parseExpiry(value) {
249
+ if (typeof value !== "string" || value === "") return 0;
250
+ const ms = Date.parse(value);
251
+ return Number.isFinite(ms) ? ms : 0;
252
+ }
253
+ //#endregion
254
+ //#region src/errors.ts
255
+ /**
256
+ * Errors only the user can fix by reconnecting the mailbox: revoked consent,
257
+ * a token without a needed scope (an Outlook grant from before Mail.Send), or
258
+ * a Connection Connect will no longer serve.
259
+ */
260
+ function isReconnectError(err) {
261
+ return err instanceof _alfe_ai_email.MailboxAuthError || err instanceof _alfe_ai_email.MailboxScopeError || err instanceof CredentialError && err.reason === "reconnect";
262
+ }
263
+ /** The Agent API rejected this agent's own token: not fixable by reconnecting the mailbox. */
264
+ function isAgentAuthError(err) {
265
+ return err instanceof CredentialError && err.reason === "agent-auth";
266
+ }
267
+ function reconnectMessage(err) {
268
+ if (err instanceof _alfe_ai_email.MailboxScopeError) return "The mailbox connection is missing a permission email needs (for Outlook, Mail.Send); reconnect the account";
269
+ if (err instanceof _alfe_ai_email.MailboxAuthError) return "The mailbox rejected its credentials; reconnect the account";
270
+ return `${err instanceof Error ? err.message : String(err)}; reconnect the account`;
271
+ }
272
+ //#endregion
273
+ //#region src/host-version.ts
274
+ /**
275
+ * Tolerant OpenClaw host-version gate (copy of
276
+ * `@alfe.ai/openclaw-identity/src/host-version.ts`; kept local so the plugins
277
+ * do not depend on each other).
278
+ *
279
+ * `api.runtime.version` is the host's VERSION string; `OPENCLAW_BUNDLED_VERSION`
280
+ * can override it. Accept a bare `YYYY.M.P`, a branded `OpenClaw 2026.9.5 (sha)`
281
+ * line, or a pre-release / build suffix (`2026.9.5-beta.1` counts as its base
282
+ * release). Anything unparseable is "unknown" and the caller refuses to start.
283
+ */
284
+ const VERSION_TOKEN = /(?:^|[^\d.])(\d{4})\.(\d+)\.(\d+)(?=$|[^\d.]|\.[^\d])/u;
285
+ function parseHostVersion(value) {
286
+ if (typeof value !== "string") return void 0;
287
+ const match = VERSION_TOKEN.exec(value.trim());
288
+ if (!match) return void 0;
289
+ return [
290
+ Number(match[1]),
291
+ Number(match[2]),
292
+ Number(match[3])
293
+ ];
294
+ }
295
+ /** True only when `version` parses and is at least `minimum`. */
296
+ function isHostAtLeast(version, minimum) {
297
+ const have = parseHostVersion(version);
298
+ const want = parseHostVersion(minimum);
299
+ if (!have || !want) return false;
300
+ for (let i = 0; i < 3; i++) if (have[i] !== want[i]) return (have[i] ?? 0) > (want[i] ?? 0);
301
+ return true;
302
+ }
303
+ /**
304
+ * The tool gate in `@alfe.ai/openclaw-identity` needs 9.5's per-sender hook
305
+ * context (`before_tool_call` `ctx.requester`). Mirrors
306
+ * `package.json#openclaw.install.minHostVersion`.
307
+ */
308
+ const MIN_HOST_VERSION = "2026.9.5";
309
+ //#endregion
310
+ //#region src/admission.ts
311
+ /**
312
+ * Sender admission: may this sender instruct this agent over email?
313
+ *
314
+ * Same evaluation as `@alfe.ai/openclaw-identity`'s `agent-exec-gate.ts`
315
+ * (copied, not imported, so the plugins stay independent): one
316
+ * `createAbility(raw permission strings).has(...)` call against the scoped
317
+ * `agent:<agentId>` gate, so `cannot` (`!agent:chat`) entries are honoured.
318
+ */
319
+ function allowsAgentChat(permissions, agentId) {
320
+ if (!agentId) return false;
321
+ const ability = (0, _auriclabs_roles.createAbility)(permissions);
322
+ const gatePermission = {
323
+ subject: "agent",
324
+ action: "chat",
325
+ scope: `agent:${agentId}`
326
+ };
327
+ return ability.has(gatePermission);
328
+ }
329
+ //#endregion
330
+ //#region src/state.ts
331
+ /**
332
+ * Per-mailbox poller state: `<stateDir>/email/<mailboxHash>.json`.
333
+ *
334
+ * One file holds the sync cursor, the dedupe LRU, the replied set, the thread
335
+ * map out-of-turn sends resolve against, loop-cap counters, the resolve retry
336
+ * queue and the admitted-but-unfinished turns. Writes are atomic (owner-only
337
+ * temp file, then rename) and serialised. A missing, unreadable or invalid
338
+ * file starts fresh, which reseeds the cursor, so history is never replayed.
339
+ */
340
+ const LIMITS = {
341
+ seen: 500,
342
+ replied: 500,
343
+ threads: 500,
344
+ retry: 100,
345
+ pending: 100
346
+ };
347
+ /** Bounds on what a thread record keeps from attacker-controlled headers. */
348
+ const PARENT_LIMITS = {
349
+ addresses: 50,
350
+ references: 20,
351
+ subject: 998,
352
+ address: 320,
353
+ name: 256,
354
+ id: 998
355
+ };
356
+ const HOUR_MS = 60 * 6e4;
357
+ function stateFilePath(stateDir, mailbox) {
358
+ return (0, node_path.join)(stateDir, "email", `${mailboxHash(mailbox)}.json`);
359
+ }
360
+ function freshState(mailbox) {
361
+ return {
362
+ version: 1,
363
+ mailbox,
364
+ cursor: null,
365
+ seen: [],
366
+ replied: [],
367
+ threads: {},
368
+ replies: {},
369
+ retry: [],
370
+ pending: [],
371
+ lastMessageAt: 0
372
+ };
373
+ }
374
+ var StateStore = class {
375
+ data;
376
+ writeChain = Promise.resolve();
377
+ constructor(file, mailbox, log) {
378
+ this.file = file;
379
+ this.mailbox = mailbox;
380
+ this.log = log;
381
+ this.data = freshState(mailbox);
382
+ }
383
+ async load() {
384
+ try {
385
+ const parsed = JSON.parse(await (0, node_fs_promises.readFile)(this.file, "utf8"));
386
+ if (!isMailboxState(parsed)) throw new Error("invalid state shape");
387
+ if (parsed.mailbox !== this.mailbox) throw new Error("state belongs to another mailbox");
388
+ this.data = parsed;
389
+ } catch (err) {
390
+ if (!isMissingFile(err)) this.log.warn(`Ignoring unreadable email state; the mailbox will be reseeded (${errorMessage$1(err)})`);
391
+ this.data = freshState(this.mailbox);
392
+ }
393
+ }
394
+ /** Atomic, serialised write of the current state. */
395
+ save() {
396
+ const contents = JSON.stringify(this.data);
397
+ const next = this.writeChain.then(async () => {
398
+ const temporary = `${this.file}.${String(process.pid)}.${(0, node_crypto.randomUUID)()}.tmp`;
399
+ try {
400
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(this.file), {
401
+ recursive: true,
402
+ mode: 448
403
+ });
404
+ await (0, node_fs_promises.writeFile)(temporary, contents, { mode: 384 });
405
+ await (0, node_fs_promises.rename)(temporary, this.file);
406
+ } catch (err) {
407
+ await (0, node_fs_promises.rm)(temporary, { force: true }).catch(() => void 0);
408
+ throw err;
409
+ }
410
+ });
411
+ this.writeChain = next.catch(() => void 0);
412
+ return next;
413
+ }
414
+ get cursor() {
415
+ return this.data.cursor;
416
+ }
417
+ set cursor(value) {
418
+ this.data.cursor = value;
419
+ }
420
+ get lastMessageAt() {
421
+ return this.data.lastMessageAt;
422
+ }
423
+ set lastMessageAt(value) {
424
+ this.data.lastMessageAt = value;
425
+ }
426
+ hasSeenId(id) {
427
+ return this.data.seen.some((entry) => entry.id === id);
428
+ }
429
+ hasSeenMessageId(mid) {
430
+ return mid !== "" && this.data.seen.some((entry) => entry.mid === mid);
431
+ }
432
+ markSeen(id, mid) {
433
+ this.data.seen = this.data.seen.filter((entry) => entry.id !== id);
434
+ this.data.seen.push({
435
+ id,
436
+ mid
437
+ });
438
+ this.trim(this.data.seen, LIMITS.seen, "dedupe");
439
+ }
440
+ isReplied(mid) {
441
+ return this.data.replied.includes(mid);
442
+ }
443
+ markReplied(mid) {
444
+ if (this.data.replied.includes(mid)) return;
445
+ this.data.replied.push(mid);
446
+ this.trim(this.data.replied, LIMITS.replied, "replied");
447
+ }
448
+ /** Undo {@link markReplied} for a reply that was definitely not sent. */
449
+ unmarkReplied(mid) {
450
+ this.data.replied = this.data.replied.filter((entry) => entry !== mid);
451
+ }
452
+ /** Undo {@link recordReply} for a reply that was definitely not sent. */
453
+ forgetReply(hash, at) {
454
+ if (!Object.hasOwn(this.data.replies, hash)) return;
455
+ const times = this.data.replies[hash] ?? [];
456
+ const index = times.lastIndexOf(at);
457
+ if (index !== -1) times.splice(index, 1);
458
+ }
459
+ repliesInLastHour(hash, now) {
460
+ return (this.data.replies[hash] ?? []).filter((at) => now - at < HOUR_MS).length;
461
+ }
462
+ recordReply(hash, now) {
463
+ const recent = (this.data.replies[hash] ?? []).filter((at) => now - at < HOUR_MS);
464
+ recent.push(now);
465
+ this.data.replies[hash] = recent;
466
+ for (const [key, times] of Object.entries(this.data.replies)) if (!times.some((at) => now - at < HOUR_MS)) delete this.data.replies[key];
467
+ }
468
+ getThread(hash) {
469
+ return Object.hasOwn(this.data.threads, hash) ? this.data.threads[hash] : void 0;
470
+ }
471
+ upsertThread(hash, record) {
472
+ this.data.threads[hash] = record;
473
+ const entries = Object.entries(this.data.threads);
474
+ if (entries.length <= LIMITS.threads) return;
475
+ entries.sort((a, b) => a[1].lastActivityAt - b[1].lastActivityAt);
476
+ for (const [key] of entries.slice(0, entries.length - LIMITS.threads)) delete this.data.threads[key];
477
+ this.log.debug(`Email state thread map full; evicted ${String(entries.length - LIMITS.threads)} oldest thread(s)`);
478
+ }
479
+ retryEntries() {
480
+ return this.data.retry;
481
+ }
482
+ hasRetry(id) {
483
+ return this.data.retry.some((entry) => entry.id === id);
484
+ }
485
+ getRetry(id) {
486
+ return this.data.retry.find((entry) => entry.id === id);
487
+ }
488
+ putRetry(entry) {
489
+ this.removeRetry(entry.id);
490
+ this.data.retry.push(entry);
491
+ this.trim(this.data.retry, LIMITS.retry, "retry");
492
+ }
493
+ removeRetry(id) {
494
+ this.data.retry = this.data.retry.filter((entry) => entry.id !== id);
495
+ }
496
+ pendingEntries() {
497
+ return this.data.pending;
498
+ }
499
+ addPending(entry) {
500
+ this.removePending(entry.id);
501
+ this.data.pending.push(entry);
502
+ this.trim(this.data.pending, LIMITS.pending, "pending");
503
+ }
504
+ removePending(id) {
505
+ this.data.pending = this.data.pending.filter((entry) => entry.id !== id);
506
+ }
507
+ trim(list, max, what) {
508
+ const dropped = trimFront(list, max);
509
+ if (dropped > 0) this.log.debug(`Email state ${what} list full; dropped ${String(dropped)} oldest entr${dropped === 1 ? "y" : "ies"}`);
510
+ }
511
+ /** For tests and diagnostics. */
512
+ snapshot() {
513
+ return structuredClone(this.data);
514
+ }
515
+ };
516
+ function trimFront(list, max) {
517
+ const excess = list.length - max;
518
+ if (excess <= 0) return 0;
519
+ list.splice(0, excess);
520
+ return excess;
521
+ }
522
+ function isMissingFile(err) {
523
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
524
+ }
525
+ function errorMessage$1(err) {
526
+ return err instanceof Error ? err.message : String(err);
527
+ }
528
+ function isRecord(value) {
529
+ return typeof value === "object" && value !== null && !Array.isArray(value);
530
+ }
531
+ function isString(value) {
532
+ return typeof value === "string";
533
+ }
534
+ function isFiniteNumber(value) {
535
+ return typeof value === "number" && Number.isFinite(value);
536
+ }
537
+ function isBoundedString(value, max) {
538
+ return typeof value === "string" && value.length <= max;
539
+ }
540
+ function isAddress(value) {
541
+ return isRecord(value) && isBoundedString(value.address, PARENT_LIMITS.address) && (value.name === void 0 || isBoundedString(value.name, PARENT_LIMITS.name));
542
+ }
543
+ function isAddressList(value) {
544
+ return Array.isArray(value) && value.length <= PARENT_LIMITS.addresses && value.every(isAddress);
545
+ }
546
+ function isParentRef(value) {
547
+ const id = (v) => isBoundedString(v, PARENT_LIMITS.id);
548
+ return isRecord(value) && id(value.id) && id(value.threadId) && id(value.messageId) && Array.isArray(value.references) && value.references.length <= PARENT_LIMITS.references && value.references.every(id) && isAddress(value.from) && isAddressList(value.to) && isAddressList(value.cc) && isBoundedString(value.subject, PARENT_LIMITS.subject) && typeof value.verified === "boolean" && (value.threadIndex === void 0 || id(value.threadIndex)) && (value.threadTopic === void 0 || isBoundedString(value.threadTopic, PARENT_LIMITS.subject));
549
+ }
550
+ function isIdPair(value) {
551
+ return isRecord(value) && isString(value.id) && isString(value.threadId);
552
+ }
553
+ function isMailboxState(value) {
554
+ if (!isRecord(value) || value.version !== 1 || !isString(value.mailbox)) return false;
555
+ if (value.cursor !== null && !isString(value.cursor)) return false;
556
+ if (!isFiniteNumber(value.lastMessageAt)) return false;
557
+ if (!Array.isArray(value.seen) || value.seen.length > LIMITS.seen) return false;
558
+ if (!value.seen.every((entry) => isRecord(entry) && isString(entry.id) && isString(entry.mid))) return false;
559
+ if (!Array.isArray(value.replied) || value.replied.length > LIMITS.replied || !value.replied.every(isString)) return false;
560
+ if (!isRecord(value.threads) || Object.keys(value.threads).length > LIMITS.threads) return false;
561
+ for (const thread of Object.values(value.threads)) if (!isRecord(thread) || !isString(thread.threadId) || !isFiniteNumber(thread.lastActivityAt) || !isParentRef(thread.parent)) return false;
562
+ if (!isRecord(value.replies)) return false;
563
+ for (const times of Object.values(value.replies)) if (!Array.isArray(times) || !times.every(isFiniteNumber)) return false;
564
+ if (!Array.isArray(value.retry) || value.retry.length > LIMITS.retry) return false;
565
+ if (!value.retry.every((entry) => isIdPair(entry) && isFiniteNumber(entry.attempts) && isFiniteNumber(entry.nextAt) && (entry.recovering === void 0 || typeof entry.recovering === "boolean"))) return false;
566
+ if (!Array.isArray(value.pending) || value.pending.length > LIMITS.pending || !value.pending.every(isIdPair)) return false;
567
+ return true;
568
+ }
569
+ //#endregion
570
+ //#region src/outbound.ts
571
+ /**
572
+ * Outbound email: one reply per turn, reply idempotency, loop cap, and
573
+ * out-of-turn sends to a thread target.
574
+ *
575
+ * Turn replies arrive through dispatch's `deliver` callback once per reply
576
+ * payload; {@link TurnBuffer} collects them and the pipeline sends a single
577
+ * email when the dispatch promise resolves (the turn-end signal on 9.5).
578
+ *
579
+ * Idempotency is keyed by the inbound Message-ID: the key is recorded and
580
+ * persisted BEFORE the send, so a restart never re-sends a recorded reply (a
581
+ * crash mid-send may lose it). Within one send, a retry happens only after a
582
+ * provider throttle (429) or a connection failure, never after a timeout or
583
+ * 5xx where the provider may already have accepted the message; a connection
584
+ * that drops after the provider accepted the request is the one remaining way
585
+ * to get a duplicate.
586
+ *
587
+ * Recipients: a DMARC-verified sender gets reply-all (the thread's people);
588
+ * an unverified sender gets a reply to From only, so a forged From cannot turn
589
+ * the agent into a relay to arbitrary To/Cc addresses. Either way at most
590
+ * {@link MAX_RECIPIENTS} addresses are kept.
591
+ */
592
+ /** Delays before each send retry (3 retries). */
593
+ const SEND_RETRY_DELAYS_MS = [
594
+ 1e3,
595
+ 4e3,
596
+ 16e3
597
+ ];
598
+ /** Agent output is bounded before it becomes an email. */
599
+ const MAX_REPLY_CHARS = 1e5;
600
+ /** Collects a turn's reply payloads. */
601
+ var TurnBuffer = class {
602
+ parts = [];
603
+ push(payload) {
604
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
605
+ if (text) this.parts.push(text);
606
+ }
607
+ text() {
608
+ return this.parts.join("\n\n");
609
+ }
610
+ };
611
+ /** What a reply needs from the inbound email, bounded before it is persisted. */
612
+ function parentRefOf(email, verified, log) {
613
+ const limit = PARENT_LIMITS;
614
+ const list = (entries, what) => {
615
+ if (entries.length > limit.addresses) log?.warn(`Email ${what} list truncated to ${String(limit.addresses)} of ${String(entries.length)} addresses`);
616
+ return entries.slice(0, limit.addresses).map(boundAddress);
617
+ };
618
+ return {
619
+ id: email.id.slice(0, limit.id),
620
+ threadId: email.threadId.slice(0, limit.id),
621
+ messageId: email.messageId.slice(0, limit.id),
622
+ references: email.references.slice(-limit.references).map((ref) => ref.slice(0, limit.id)),
623
+ from: boundAddress(email.from),
624
+ to: list(email.to, "To"),
625
+ cc: list(email.cc, "Cc"),
626
+ subject: email.subject.slice(0, limit.subject),
627
+ verified,
628
+ ...email.threadIndex !== void 0 ? { threadIndex: email.threadIndex.slice(0, limit.id) } : {},
629
+ ...email.threadTopic !== void 0 ? { threadTopic: email.threadTopic.slice(0, limit.subject) } : {}
630
+ };
631
+ }
632
+ function boundAddress(entry) {
633
+ return {
634
+ address: entry.address.slice(0, PARENT_LIMITS.address),
635
+ ...entry.name !== void 0 ? { name: entry.name.slice(0, PARENT_LIMITS.name) } : {}
636
+ };
637
+ }
638
+ /**
639
+ * Who the reply goes to. Unverified senders: From only. Verified: From + To +
640
+ * Cc. Own address removed, deduped case-insensitively, capped.
641
+ */
642
+ function replyAudience(parent, ownAddress, log) {
643
+ const seen = new Set([ownAddress.trim().toLowerCase()]);
644
+ const keep = (entries) => entries.filter((entry) => {
645
+ const key = entry.address.trim().toLowerCase();
646
+ if (key === "" || seen.has(key)) return false;
647
+ seen.add(key);
648
+ return true;
649
+ });
650
+ const to = keep(parent.verified ? [parent.from, ...parent.to] : [parent.from]);
651
+ const cc = parent.verified ? keep(parent.cc) : [];
652
+ const total = to.length + cc.length;
653
+ if (total <= 20) return {
654
+ to,
655
+ cc
656
+ };
657
+ log.warn(`Email reply recipients capped at ${String(20)} of ${String(total)}`);
658
+ const cappedTo = to.slice(0, 20);
659
+ return {
660
+ to: cappedTo,
661
+ cc: cc.slice(0, 20 - cappedTo.length)
662
+ };
663
+ }
664
+ function asParentEmail(parent, audience) {
665
+ const [from = { address: "" }, ...to] = audience.to;
666
+ return {
667
+ id: parent.id,
668
+ threadId: parent.threadId,
669
+ messageId: parent.messageId,
670
+ references: parent.references,
671
+ from,
672
+ to,
673
+ cc: audience.cc,
674
+ subject: parent.subject,
675
+ ...parent.threadIndex !== void 0 ? { threadIndex: parent.threadIndex } : {},
676
+ ...parent.threadTopic !== void 0 ? { threadTopic: parent.threadTopic } : {},
677
+ textBody: "",
678
+ headers: /* @__PURE__ */ new Map(),
679
+ headerList: [],
680
+ attachments: []
681
+ };
682
+ }
683
+ /**
684
+ * Send one reply on a thread. `inboundKey` (the inbound Message-ID) makes the
685
+ * send idempotent; out-of-turn sends have none.
686
+ */
687
+ async function sendReply(deps, args) {
688
+ const markdown = bound(args.markdown.trim());
689
+ if (!markdown) return { kind: "empty" };
690
+ if (args.inboundKey !== void 0 && deps.state.isReplied(args.inboundKey)) return { kind: "duplicate" };
691
+ const now = deps.now();
692
+ if (deps.state.repliesInLastHour(args.threadHash, now) >= deps.loopCap) {
693
+ deps.log.warn(`Email reply suppressed: thread reached ${String(deps.loopCap)} replies in the last hour`);
694
+ return { kind: "capped" };
695
+ }
696
+ const audience = replyAudience(args.parent, deps.ownAddress, deps.log);
697
+ const composed = (0, _alfe_ai_email.composeReply)({
698
+ parent: asParentEmail(args.parent, audience),
699
+ ownAddress: deps.ownAddress,
700
+ markdown
701
+ });
702
+ if (composed.recipients.length === 0) {
703
+ deps.log.warn("Email reply skipped: the thread has no recipients other than this mailbox");
704
+ return { kind: "no-recipients" };
705
+ }
706
+ if (args.inboundKey !== void 0) deps.state.markReplied(args.inboundKey);
707
+ deps.state.recordReply(args.threadHash, now);
708
+ await deps.state.save();
709
+ for (let attempt = 0;; attempt++) try {
710
+ await deps.driver.sendMime(composed.raw, { threadId: args.parent.threadId });
711
+ return {
712
+ kind: "sent",
713
+ messageId: messageIdOf(composed.raw)
714
+ };
715
+ } catch (err) {
716
+ if (!isSafeToRetrySend(err) || attempt >= SEND_RETRY_DELAYS_MS.length) {
717
+ if (isDefinitelyNotSent(err)) {
718
+ if (args.inboundKey !== void 0) deps.state.unmarkReplied(args.inboundKey);
719
+ deps.state.forgetReply(args.threadHash, now);
720
+ await deps.state.save().catch(() => void 0);
721
+ }
722
+ throw err;
723
+ }
724
+ const delay = SEND_RETRY_DELAYS_MS[attempt] ?? 0;
725
+ const retryAfter = err?.retryAfterMs;
726
+ const wait = typeof retryAfter === "number" && retryAfter > delay ? Math.min(retryAfter, 6e4) : delay;
727
+ deps.log.warn(`Email send failed; retrying in ${String(wait)}ms (${errorMessage(err)})`);
728
+ await deps.sleep(wait);
729
+ }
730
+ }
731
+ /** Out-of-turn send (message tool, cron, follow-ups) to `thread:<threadHash>`. */
732
+ async function sendToThread(deps, to, text) {
733
+ const hash = parseThreadTarget(to);
734
+ if (!hash) throw new Error("Invalid email target: use thread:<threadHash>");
735
+ const thread = deps.state.getThread(hash);
736
+ if (!thread) throw new Error("Unknown email thread: this mailbox has no conversation with that target");
737
+ return await sendReply(deps, {
738
+ threadHash: hash,
739
+ parent: thread.parent,
740
+ markdown: text
741
+ });
742
+ }
743
+ /**
744
+ * Retry a send only when the provider cannot have accepted it: a throttle, or
745
+ * a fetch that failed to connect. A timeout, a 5xx, auth and scope errors are
746
+ * final (the message may already be out, or retrying cannot help).
747
+ */
748
+ function isSafeToRetrySend(err) {
749
+ if (err instanceof _alfe_ai_email.MailboxRateLimitError) return true;
750
+ if (err instanceof _alfe_ai_email.MailboxHttpError) return false;
751
+ if (!(err instanceof TypeError)) return false;
752
+ const code = err.cause?.code;
753
+ return typeof code === "string" && CONNECT_ERRORS.has(code);
754
+ }
755
+ /**
756
+ * The provider refused the send before accepting it: auth, scope, throttle,
757
+ * any other 4xx except 408 (request timeout), or a failed connect. A 5xx,
758
+ * a timeout or an unknown error is ambiguous and keeps the at-most-once key.
759
+ */
760
+ function isDefinitelyNotSent(err) {
761
+ if (err instanceof _alfe_ai_email.MailboxAuthError || err instanceof _alfe_ai_email.MailboxScopeError) return true;
762
+ if (err instanceof _alfe_ai_email.MailboxHttpError) return err.status >= 400 && err.status < 500 && err.status !== 408;
763
+ return isSafeToRetrySend(err);
764
+ }
765
+ const CONNECT_ERRORS = new Set([
766
+ "ECONNREFUSED",
767
+ "ENOTFOUND",
768
+ "EAI_AGAIN",
769
+ "ENETUNREACH",
770
+ "EHOSTUNREACH",
771
+ "UND_ERR_CONNECT_TIMEOUT"
772
+ ]);
773
+ function bound(text) {
774
+ return text.length > MAX_REPLY_CHARS ? `${text.slice(0, MAX_REPLY_CHARS)}\n\n[reply truncated]` : text;
775
+ }
776
+ function messageIdOf(raw) {
777
+ return /^Message-ID: (<[^>\r\n]+>)/im.exec(raw)?.[1] ?? "";
778
+ }
779
+ function errorMessage(err) {
780
+ return err instanceof Error ? err.message : String(err);
781
+ }
782
+ //#endregion
783
+ //#region src/sender-context.ts
784
+ /**
785
+ * Cross-plugin sender-context registry (contract `alfe.senderContext.v1`),
786
+ * write side.
787
+ *
788
+ * `@alfe.ai/openclaw-identity` reads this before re-resolving a sender in its
789
+ * tool gate, because stock OpenClaw message metadata drops Alfe identity
790
+ * fields and the identity plugin's own resolve cannot repeat this channel's
791
+ * DMARC-verified `emailVerified` evidence.
792
+ *
793
+ * The store lives on `globalThis` under a registry symbol so it survives the
794
+ * OpenClaw 9.x double plugin load and is shared across packages without a
795
+ * plugin-kit dependency. Each package ships its own copy of this contract;
796
+ * keep the key, the key format, and the entry shape identical to
797
+ * `packages/openclaw-identity/src/sender-context.ts`.
798
+ *
799
+ * - Key: `${sessionKey}\u0000${senderId}`, using OpenClaw's run `SessionKey`
800
+ * and `SenderId`.
801
+ * - This plugin owns its entries: written before dispatch, deleted when the
802
+ * dispatch promise resolves. `expiresAt` (epoch ms) is the crash backstop,
803
+ * refreshed while a long turn is still running.
804
+ */
805
+ const KEY = Symbol.for("alfe.senderContext.v1");
806
+ function senderContextStore() {
807
+ const g = globalThis;
808
+ return g[KEY] ??= /* @__PURE__ */ new Map();
809
+ }
810
+ function senderContextKey(sessionKey, senderId) {
811
+ return `${sessionKey}\u0000${senderId}`;
812
+ }
813
+ /** Entries live at most this long without a refresh. */
814
+ const SENDER_CONTEXT_TTL_MS = 2 * 6e4;
815
+ function writeSenderContext(sessionKey, senderId, entry, now = Date.now()) {
816
+ const written = {
817
+ identityId: entry.identityId,
818
+ status: entry.status,
819
+ permissions: [...entry.permissions],
820
+ expiresAt: now + SENDER_CONTEXT_TTL_MS
821
+ };
822
+ senderContextStore().set(senderContextKey(sessionKey, senderId), written);
823
+ return written;
824
+ }
825
+ /** Extend the TTL of an entry this writer wrote, if it is still the live one. */
826
+ function touchSenderContext(sessionKey, senderId, written, now = Date.now()) {
827
+ if (senderContextStore().get(senderContextKey(sessionKey, senderId)) === written) written.expiresAt = now + SENDER_CONTEXT_TTL_MS;
828
+ }
829
+ /** Delete the entry only if it is still the one this writer wrote. */
830
+ function deleteSenderContext(sessionKey, senderId, written) {
831
+ const store = senderContextStore();
832
+ const key = senderContextKey(sessionKey, senderId);
833
+ if (store.get(key) === written) store.delete(key);
834
+ }
835
+ var SenderContextKeeper = class {
836
+ entries = /* @__PURE__ */ new Map();
837
+ timer = null;
838
+ stopped = false;
839
+ constructor(deps) {
840
+ this.deps = deps;
841
+ }
842
+ /** Write (or refresh) the entry for an admitted inbound, before its dispatch. */
843
+ admit(sessionKey, senderId, sender) {
844
+ if (this.stopped) return;
845
+ const now = this.deps.now();
846
+ const key = `${sessionKey}\u0000${senderId}`;
847
+ if (this.entries.get(key)) this.entries.delete(key);
848
+ this.entries.set(key, {
849
+ sessionKey,
850
+ senderId,
851
+ from: sender.from,
852
+ verified: sender.verified,
853
+ written: writeSenderContext(sessionKey, senderId, sender.context, now),
854
+ lastAdmittedAt: now,
855
+ lastResolvedAt: now,
856
+ resolving: null
857
+ });
858
+ this.evictOverflow();
859
+ this.ensureTimer();
860
+ }
861
+ /** One heartbeat: expire, re-resolve or rewrite every kept entry. Exposed for tests. */
862
+ async tick() {
863
+ const now = this.deps.now();
864
+ const retention = this.deps.retentionMs ?? 18e5;
865
+ const reResolve = this.deps.reResolveMs ?? 3e5;
866
+ const work = [];
867
+ for (const [key, kept] of [...this.entries]) if (now - kept.lastAdmittedAt >= retention) this.drop(key, kept);
868
+ else if (now - kept.lastResolvedAt >= reResolve) {
869
+ kept.resolving ??= this.reResolve(key, kept).finally(() => {
870
+ kept.resolving = null;
871
+ });
872
+ work.push(kept.resolving);
873
+ } else touchSenderContext(kept.sessionKey, kept.senderId, kept.written, now);
874
+ await Promise.all(work);
875
+ if (this.entries.size === 0) this.clearTimer();
876
+ }
877
+ /** Stop heartbeats and delete every entry this keeper owns. */
878
+ stop() {
879
+ this.stopped = true;
880
+ this.clearTimer();
881
+ for (const [key, kept] of [...this.entries]) this.drop(key, kept);
882
+ }
883
+ size() {
884
+ return this.entries.size;
885
+ }
886
+ async reResolve(key, kept) {
887
+ let keep = false;
888
+ let reason = "denied";
889
+ try {
890
+ const resolved = await this.deps.identity.resolveIdentity({
891
+ provider: "email",
892
+ platformId: `email:${kept.from}`,
893
+ email: kept.from,
894
+ emailVerified: kept.verified
895
+ });
896
+ if (resolved.permissionsUnavailable === true) reason = "permissions unavailable";
897
+ else if (allowsAgentChat(resolved.permissions, this.deps.agentId)) {
898
+ keep = true;
899
+ if (this.entries.get(key) !== kept) return;
900
+ const now = this.deps.now();
901
+ deleteSenderContext(kept.sessionKey, kept.senderId, kept.written);
902
+ kept.written = writeSenderContext(kept.sessionKey, kept.senderId, {
903
+ identityId: resolved.identityId,
904
+ status: resolved.status,
905
+ permissions: resolved.permissions
906
+ }, now);
907
+ kept.lastResolvedAt = now;
908
+ }
909
+ } catch (err) {
910
+ reason = `resolve failed: ${errorMessage(err)}`;
911
+ }
912
+ if (!keep && this.entries.get(key) === kept) {
913
+ this.deps.log.info(`Email sender context released on re-resolve (${reason})`);
914
+ this.drop(key, kept);
915
+ }
916
+ }
917
+ drop(key, kept) {
918
+ deleteSenderContext(kept.sessionKey, kept.senderId, kept.written);
919
+ if (this.entries.get(key) === kept) this.entries.delete(key);
920
+ }
921
+ evictOverflow() {
922
+ const max = this.deps.maxEntries ?? 200;
923
+ while (this.entries.size > max) {
924
+ const oldest = this.entries.entries().next();
925
+ if (oldest.done) return;
926
+ this.drop(oldest.value[0], oldest.value[1]);
927
+ this.deps.log.debug("Email sender context limit reached; evicted the least recently admitted sender");
928
+ }
929
+ }
930
+ ensureTimer() {
931
+ if (this.timer || this.stopped) return;
932
+ this.timer = setInterval(() => {
933
+ this.tick().catch((err) => {
934
+ this.deps.log.warn(`Email sender context heartbeat failed: ${errorMessage(err)}`);
935
+ });
936
+ }, this.deps.heartbeatMs ?? 6e4);
937
+ this.timer.unref();
938
+ }
939
+ clearTimer() {
940
+ if (this.timer) clearInterval(this.timer);
941
+ this.timer = null;
942
+ }
943
+ };
944
+ //#endregion
945
+ //#region src/pipeline.ts
946
+ /**
947
+ * Inbound pipeline, in order:
948
+ * dedupe → filters → resolve → admission → per-thread serialisation →
949
+ * sender-context registry write (kept after the turn) → dispatch → reply → markHandled.
950
+ *
951
+ * Admission and enqueue are quick, so the poller is never blocked by a long
952
+ * agent turn; the turn itself runs on the thread's promise chain.
953
+ *
954
+ * Per-thread serialisation: OpenClaw would merge a second message into an
955
+ * active run, and `ctx.requester.senderId` would still name the first sender.
956
+ * Each thread's turns therefore chain on the previous turn's dispatch promise
957
+ * (which resolves at turn end on 9.5; `agent_end` is fire-and-forget and not
958
+ * ordered after delivery), so every message runs as its own turn with its own
959
+ * sender context.
960
+ *
961
+ * Denied, filtered and dropped messages are left unread; only a completed
962
+ * turn marks its message handled.
963
+ */
964
+ const RESOLVE_RETRY_DELAYS_MS = [
965
+ 3e4,
966
+ 2 * 6e4,
967
+ 10 * 6e4
968
+ ];
969
+ const CHANNEL = "email";
970
+ const ACCOUNT_ID = "default";
971
+ const MAX_BODY_CHARS = 32e3;
972
+ const MAX_ATTACHMENTS_LISTED = 10;
973
+ var EmailPipeline = class {
974
+ chains = /* @__PURE__ */ new Map();
975
+ now;
976
+ sleep;
977
+ loopCap;
978
+ retryDelays;
979
+ /** Keeps admitted senders' registry entries alive across the session (see sender-keeper.ts). */
980
+ senders;
981
+ turnTimeoutMs;
982
+ outbound;
983
+ stopped = false;
984
+ warnedNoRoute = false;
985
+ constructor(deps) {
986
+ this.deps = deps;
987
+ this.now = deps.now ?? Date.now;
988
+ this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
989
+ this.loopCap = deps.loopCap ?? 10;
990
+ this.retryDelays = deps.retryDelaysMs ?? RESOLVE_RETRY_DELAYS_MS;
991
+ this.senders = new SenderContextKeeper({
992
+ identity: deps.identity,
993
+ agentId: deps.agentId,
994
+ log: deps.log,
995
+ now: this.now,
996
+ ...deps.senderKeeper
997
+ });
998
+ this.turnTimeoutMs = deps.turnTimeoutMs ?? 9e5;
999
+ this.outbound = {
1000
+ driver: deps.driver,
1001
+ state: deps.state,
1002
+ ownAddress: deps.ownAddress,
1003
+ log: deps.log,
1004
+ now: this.now,
1005
+ sleep: this.sleep,
1006
+ loopCap: this.loopCap
1007
+ };
1008
+ }
1009
+ /** A message a sync round surfaced. */
1010
+ async processSynced(message) {
1011
+ const { state } = this.deps;
1012
+ if (state.hasSeenId(message.id) || state.hasRetry(message.id)) return "skipped";
1013
+ return await this.admit(message, {
1014
+ retry: void 0,
1015
+ recovering: false
1016
+ });
1017
+ }
1018
+ /** A due retry-queue entry. */
1019
+ async processRetry(entry) {
1020
+ return await this.admit(entry, {
1021
+ retry: entry,
1022
+ recovering: entry.recovering === true
1023
+ });
1024
+ }
1025
+ /** A turn that was admitted but had not finished when the process stopped. */
1026
+ async processPending(entry) {
1027
+ return await this.admit(entry, {
1028
+ retry: void 0,
1029
+ recovering: true
1030
+ });
1031
+ }
1032
+ /** Out-of-turn send to `thread:<threadHash>`. */
1033
+ async sendToThread(to, text) {
1034
+ try {
1035
+ const outcome = await sendToThread(this.outbound, to, text);
1036
+ if (outcome.kind === "sent") this.deps.onSent?.();
1037
+ return outcome;
1038
+ } catch (err) {
1039
+ if (isReconnectError(err)) this.deps.onReconnectRequired?.(err);
1040
+ throw err;
1041
+ }
1042
+ }
1043
+ /** Stop starting new turns. Running turns finish; unfinished ones stay pending. */
1044
+ stop() {
1045
+ this.stopped = true;
1046
+ }
1047
+ /** After the drain: stop sender-context heartbeats and delete this plugin's entries. */
1048
+ close() {
1049
+ this.stopped = true;
1050
+ this.senders.stop();
1051
+ }
1052
+ /** Resolves when every queued turn has settled. */
1053
+ async idle() {
1054
+ while (this.chains.size > 0) await Promise.all([...this.chains.values()]);
1055
+ }
1056
+ async admit(message, ctx) {
1057
+ const { state, driver, log } = this.deps;
1058
+ let email;
1059
+ let raw;
1060
+ try {
1061
+ raw = await driver.fetchRaw(message.id);
1062
+ email = await (0, _alfe_ai_email.parseEmail)(raw, {
1063
+ id: message.id,
1064
+ threadId: message.threadId
1065
+ });
1066
+ } catch (err) {
1067
+ if (isUnprocessable(err)) {
1068
+ log.warn(`Email skipped: the message cannot be processed (${errorName(err)})`);
1069
+ this.finishWithoutTurn(message.id, "");
1070
+ await state.save();
1071
+ return "dropped";
1072
+ }
1073
+ throw err;
1074
+ }
1075
+ const mid = email.messageId;
1076
+ const inboundKey = replyKeyOf(email);
1077
+ if (!ctx.recovering && state.hasSeenMessageId(mid)) {
1078
+ this.finishWithoutTurn(message.id, mid);
1079
+ await state.save();
1080
+ return "skipped";
1081
+ }
1082
+ const ignoreReason = (0, _alfe_ai_email.shouldIgnore)(email, this.deps.ownAddress);
1083
+ if (ignoreReason !== null) {
1084
+ log.debug(`Email ignored (${ignoreReason})`);
1085
+ this.finishWithoutTurn(message.id, mid);
1086
+ await state.save();
1087
+ return "ignored";
1088
+ }
1089
+ const thread = threadHash(email.threadId);
1090
+ if (state.repliesInLastHour(thread, this.now()) >= this.loopCap) {
1091
+ log.warn(`Email ignored: thread reached the ${String(this.loopCap)}-replies-per-hour loop cap`);
1092
+ this.finishWithoutTurn(message.id, mid);
1093
+ await state.save();
1094
+ return "ignored";
1095
+ }
1096
+ const from = senderAddressOf(email);
1097
+ const fromDomain = from.slice(from.lastIndexOf("@") + 1);
1098
+ const verdictOrPromise = this.deps.evaluateSender ? this.deps.evaluateSender(email, fromDomain, driver.trust, this.deps.ownAddress) : (0, _alfe_ai_email.evaluateAuth)({
1099
+ headerList: email.headerList,
1100
+ raw
1101
+ }, fromDomain, driver.trust, this.deps.ownAddress, { receivedAt: driver.receivedAt?.(message.id) });
1102
+ const verdict = await Promise.resolve(verdictOrPromise);
1103
+ let resolved;
1104
+ try {
1105
+ resolved = await this.deps.identity.resolveIdentity({
1106
+ provider: "email",
1107
+ platformId: `email:${from}`,
1108
+ email: from,
1109
+ emailVerified: verdict.verified
1110
+ });
1111
+ if (resolved.permissionsUnavailable === true) throw new Error("permissions unavailable");
1112
+ } catch (err) {
1113
+ return await this.resolveFailed(message, ctx, err);
1114
+ }
1115
+ state.removeRetry(message.id);
1116
+ if (!allowsAgentChat(resolved.permissions, this.deps.agentId)) {
1117
+ log.info(`Email from ${maskAddress(from)} not admitted (no agent:chat; auth ${verdict.reason})`);
1118
+ this.finishWithoutTurn(message.id, mid);
1119
+ await state.save();
1120
+ return "denied";
1121
+ }
1122
+ if (ctx.recovering && state.isReplied(inboundKey)) {
1123
+ await this.markHandled(email.id);
1124
+ this.finishWithoutTurn(message.id, mid);
1125
+ await state.save();
1126
+ return "skipped";
1127
+ }
1128
+ const now = this.now();
1129
+ state.markSeen(message.id, mid);
1130
+ const parent = parentRefOf(email, verdict.verified, log);
1131
+ state.upsertThread(thread, {
1132
+ threadId: email.threadId,
1133
+ parent,
1134
+ lastActivityAt: now
1135
+ });
1136
+ state.addPending({
1137
+ id: message.id,
1138
+ threadId: message.threadId
1139
+ });
1140
+ state.lastMessageAt = now;
1141
+ await state.save();
1142
+ this.enqueue(thread, () => this.runTurn(email, parent, thread, {
1143
+ identityId: resolved.identityId,
1144
+ status: resolved.status,
1145
+ permissions: resolved.permissions
1146
+ }));
1147
+ return "dispatched";
1148
+ }
1149
+ async resolveFailed(message, ctx, err) {
1150
+ const { state, log } = this.deps;
1151
+ const attempts = ctx.retry === void 0 ? 0 : ctx.retry.attempts + 1;
1152
+ if (attempts >= this.retryDelays.length) {
1153
+ log.error(`Email dropped: sender resolve failed after ${String(attempts)} retries (${errorMessage(err)})`);
1154
+ this.finishWithoutTurn(message.id, "");
1155
+ await state.save();
1156
+ return "dropped";
1157
+ }
1158
+ log.warn(`Email sender resolve failed; retry ${String(attempts + 1)} queued (${errorMessage(err)})`);
1159
+ state.putRetry({
1160
+ id: message.id,
1161
+ threadId: message.threadId,
1162
+ attempts,
1163
+ nextAt: this.now() + (this.retryDelays[attempts] ?? 0),
1164
+ ...ctx.recovering ? { recovering: true } : {}
1165
+ });
1166
+ await state.save();
1167
+ return "queued";
1168
+ }
1169
+ finishWithoutTurn(id, mid) {
1170
+ const { state } = this.deps;
1171
+ if (mid !== "" || !state.hasSeenId(id)) state.markSeen(id, mid);
1172
+ state.removeRetry(id);
1173
+ state.removePending(id);
1174
+ }
1175
+ enqueue(thread, task) {
1176
+ const next = (this.chains.get(thread) ?? Promise.resolve()).then(task).catch((err) => {
1177
+ this.deps.log.error(`Email turn failed: ${errorMessage(err)}`);
1178
+ });
1179
+ this.chains.set(thread, next);
1180
+ next.finally(() => {
1181
+ if (this.chains.get(thread) === next) this.chains.delete(thread);
1182
+ });
1183
+ }
1184
+ async runTurn(email, parent, thread, sender) {
1185
+ if (this.stopped) return;
1186
+ const { runtime, state, log, ownAddress } = this.deps;
1187
+ const cfg = runtime.readConfig();
1188
+ const peer = {
1189
+ kind: "direct",
1190
+ id: peerIdFor(ownAddress, email.threadId)
1191
+ };
1192
+ const from = senderAddressOf(email);
1193
+ const senderId = `email:${from}`;
1194
+ const sessionKey = this.resolveSessionKey(cfg, peer);
1195
+ if (sessionKey) this.senders.admit(sessionKey, senderId, {
1196
+ from,
1197
+ verified: parent.verified,
1198
+ context: sender
1199
+ });
1200
+ const buffer = new TurnBuffer();
1201
+ let failed = false;
1202
+ let timedOut = false;
1203
+ let timer;
1204
+ const timeout = new Promise((resolve) => {
1205
+ timer = setTimeout(() => {
1206
+ resolve("timeout");
1207
+ }, this.turnTimeoutMs);
1208
+ });
1209
+ try {
1210
+ const dispatched = runtime.dispatch({
1211
+ cfg,
1212
+ runtime: { channel: runtime.channelRuntime },
1213
+ channel: CHANNEL,
1214
+ channelLabel: "Email",
1215
+ accountId: ACCOUNT_ID,
1216
+ peer,
1217
+ senderId,
1218
+ senderAddress: senderId,
1219
+ recipientAddress: `email:${ownAddress.toLowerCase()}`,
1220
+ conversationLabel: `[Email] ${from}`,
1221
+ rawBody: renderBody(email),
1222
+ messageId: email.messageId || email.id,
1223
+ timestamp: this.now(),
1224
+ provider: CHANNEL,
1225
+ originatingTo: threadTarget(thread),
1226
+ deliver: (payload) => {
1227
+ buffer.push(payload);
1228
+ return Promise.resolve();
1229
+ },
1230
+ onRecordError: (err) => {
1231
+ failed = true;
1232
+ log.error(`Email session record error: ${errorMessage(err)}`);
1233
+ },
1234
+ onDispatchError: (err, info) => {
1235
+ failed = true;
1236
+ log.error(`Email dispatch error (${info.kind}): ${errorMessage(err)}`);
1237
+ }
1238
+ });
1239
+ if (await Promise.race([dispatched, timeout]) === "timeout") {
1240
+ failed = true;
1241
+ timedOut = true;
1242
+ log.error(`Email turn exceeded ${String(this.turnTimeoutMs)}ms; releasing the thread without a reply`);
1243
+ dispatched.catch(() => void 0);
1244
+ }
1245
+ } catch (err) {
1246
+ failed = true;
1247
+ log.error(`Email dispatch failed: ${errorMessage(err)}`);
1248
+ } finally {
1249
+ clearTimeout(timer);
1250
+ }
1251
+ try {
1252
+ const markdown = timedOut ? "" : buffer.text();
1253
+ if ((await sendReply(this.outbound, {
1254
+ threadHash: thread,
1255
+ parent,
1256
+ markdown,
1257
+ inboundKey: replyKeyOf(email)
1258
+ })).kind === "sent") this.deps.onSent?.();
1259
+ } catch (err) {
1260
+ failed = true;
1261
+ log.error(`Email reply could not be sent: ${errorMessage(err)}`);
1262
+ if (isReconnectError(err)) this.deps.onReconnectRequired?.(err);
1263
+ }
1264
+ if (!failed) await this.markHandled(email.id);
1265
+ state.removePending(email.id);
1266
+ await state.save().catch((err) => {
1267
+ log.error(`Email state save failed: ${errorMessage(err)}`);
1268
+ });
1269
+ }
1270
+ resolveSessionKey(cfg, peer) {
1271
+ const { runtime, log } = this.deps;
1272
+ try {
1273
+ const session = cfg.session;
1274
+ const key = (runtime.resolveRoute?.({
1275
+ cfg,
1276
+ channel: CHANNEL,
1277
+ accountId: ACCOUNT_ID,
1278
+ peer,
1279
+ runtime: runtime.channelRuntime,
1280
+ sessionStore: session?.store
1281
+ }))?.route.sessionKey;
1282
+ if (typeof key === "string" && key !== "") return key;
1283
+ } catch (err) {
1284
+ log.warn(`Email session route could not be resolved (${errorMessage(err)})`);
1285
+ }
1286
+ if (!this.warnedNoRoute) {
1287
+ this.warnedNoRoute = true;
1288
+ log.warn("Email sender context unavailable: the identity plugin will resolve senders without email verification");
1289
+ }
1290
+ return null;
1291
+ }
1292
+ async markHandled(id) {
1293
+ try {
1294
+ await this.deps.driver.markHandled(id);
1295
+ } catch (err) {
1296
+ this.deps.log.warn(`Email could not be marked handled: ${errorMessage(err)}`);
1297
+ }
1298
+ }
1299
+ };
1300
+ /** Untrusted content, bounded before it reaches the model. */
1301
+ function renderBody(email) {
1302
+ const subject = stripControl(email.subject).slice(0, 500);
1303
+ let body = email.textBody.length > MAX_BODY_CHARS ? `${email.textBody.slice(0, MAX_BODY_CHARS)}\n\n[message truncated]` : email.textBody;
1304
+ const attachments = email.attachments.slice(0, MAX_ATTACHMENTS_LISTED).map((a) => `${stripControl(a.filename).slice(0, 200) || "unnamed"} (${stripControl(a.contentType).slice(0, 100)}, ${String(a.size)} bytes)`);
1305
+ if (attachments.length > 0) {
1306
+ const more = email.attachments.length - attachments.length;
1307
+ body += `\n\n[Attachments, content not included: ${attachments.join(", ")}${more > 0 ? `, and ${String(more)} more` : ""}]`;
1308
+ }
1309
+ return `Subject: ${subject}\n\n${body}`;
1310
+ }
1311
+ function stripControl(value) {
1312
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
1313
+ }
1314
+ /** Permanent per-message failures: skip the message rather than block the mailbox. */
1315
+ function isUnprocessable(err) {
1316
+ if (err instanceof _alfe_ai_email.MessageTooLargeError) return true;
1317
+ return err?.status === 404;
1318
+ }
1319
+ function errorName(err) {
1320
+ return err instanceof Error ? err.name : "unknown";
1321
+ }
1322
+ /** The reply idempotency key: the inbound Message-ID, else the provider id. */
1323
+ function replyKeyOf(email) {
1324
+ return email.messageId || `id:${email.id}`;
1325
+ }
1326
+ /** Lowercased From address with control characters removed (it reaches ctx and logs). */
1327
+ function senderAddressOf(email) {
1328
+ return stripControl(email.from.address).replace(/\s+/g, "").toLowerCase();
1329
+ }
1330
+ //#endregion
1331
+ //#region src/poller.ts
1332
+ /**
1333
+ * The mailbox poller: one per configured mailbox (v1: one per agent).
1334
+ *
1335
+ * Adaptive interval, as in the Google Chat poller:
1336
+ * active (mail in the last 5 min) → 1 s
1337
+ * warm (mail in the last 30 min) → 5 s
1338
+ * idle → 15 s
1339
+ * dormant (2 h or more without mail) → 30 s
1340
+ *
1341
+ * The first run seeds the cursor (`seedCursor()`), so historic mail is never
1342
+ * answered. An expired cursor reseeds with a warning; nothing is replayed.
1343
+ * The cursor advances only after every message of a sync round has been
1344
+ * decided (admitted, queued for retry, or skipped), so a transient failure
1345
+ * re-reads the round and dedupe absorbs the repeats.
1346
+ *
1347
+ * Revoked consent, a missing scope, or an unusable Connection puts the
1348
+ * channel in the `error` state with reason `reconnect`; the poller keeps the
1349
+ * cursor and retries slowly, resuming where it stopped once the user
1350
+ * reconnects.
1351
+ */
1352
+ const INTERVALS_MS = {
1353
+ active: 1e3,
1354
+ warm: 5e3,
1355
+ idle: 15e3,
1356
+ dormant: 3e4
1357
+ };
1358
+ const ACTIVE_WINDOW_MS = 5 * 6e4;
1359
+ const WARM_WINDOW_MS = 30 * 6e4;
1360
+ const DORMANT_AFTER_MS = 120 * 6e4;
1361
+ const RECONNECT_BACKOFF_MS = 5 * 6e4;
1362
+ const MAX_ERROR_BACKOFF_MS = 6e4;
1363
+ /** A message that fails this many rounds in a row is skipped so it cannot block the mailbox. */
1364
+ const MAX_MESSAGE_FAILURES = 3;
1365
+ function computeInterval(lastMessageAt, now) {
1366
+ if (lastMessageAt <= 0) return INTERVALS_MS.idle;
1367
+ const elapsed = now - lastMessageAt;
1368
+ if (elapsed < ACTIVE_WINDOW_MS) return INTERVALS_MS.active;
1369
+ if (elapsed < WARM_WINDOW_MS) return INTERVALS_MS.warm;
1370
+ if (elapsed < DORMANT_AFTER_MS) return INTERVALS_MS.idle;
1371
+ return INTERVALS_MS.dormant;
1372
+ }
1373
+ var EmailPoller = class {
1374
+ session = null;
1375
+ recovering = null;
1376
+ timer = null;
1377
+ running = false;
1378
+ inflight = null;
1379
+ errorStreak = 0;
1380
+ failures = /* @__PURE__ */ new Map();
1381
+ now;
1382
+ constructor(deps) {
1383
+ this.deps = deps;
1384
+ this.now = deps.now ?? Date.now;
1385
+ }
1386
+ start() {
1387
+ if (this.running) return;
1388
+ this.running = true;
1389
+ this.schedule(0);
1390
+ }
1391
+ async stop() {
1392
+ this.running = false;
1393
+ if (this.timer) clearTimeout(this.timer);
1394
+ this.timer = null;
1395
+ await this.inflight?.catch(() => void 0);
1396
+ const session = this.session;
1397
+ session?.pipeline.stop();
1398
+ if (session) await this.drain(session);
1399
+ session?.pipeline.close();
1400
+ await session?.state.save().catch((err) => {
1401
+ this.deps.log.error(`Email state save failed on stop: ${errorMessage(err)}`);
1402
+ });
1403
+ this.deps.status.stopped();
1404
+ }
1405
+ async drain(session) {
1406
+ let timer;
1407
+ const limit = this.deps.drainTimeoutMs ?? 3e4;
1408
+ const timedOut = new Promise((resolve) => {
1409
+ timer = setTimeout(() => {
1410
+ resolve("timeout");
1411
+ }, limit);
1412
+ });
1413
+ const result = await Promise.race([session.pipeline.idle().then(() => "idle"), timedOut]);
1414
+ clearTimeout(timer);
1415
+ if (result === "timeout") this.deps.log.warn(`Email stop: turns still running after ${String(limit)}ms; they stay pending and re-run after restart`);
1416
+ }
1417
+ /** The live session, for out-of-turn sends. */
1418
+ current() {
1419
+ return this.session;
1420
+ }
1421
+ /**
1422
+ * One poll round. Returns the delay before the next round. Exposed for tests;
1423
+ * the timer loop calls it.
1424
+ */
1425
+ async cycle() {
1426
+ try {
1427
+ const delay = await this.round();
1428
+ this.errorStreak = 0;
1429
+ this.deps.status.ok();
1430
+ return delay;
1431
+ } catch (err) {
1432
+ return this.onError(err);
1433
+ }
1434
+ }
1435
+ schedule(delay) {
1436
+ if (!this.running) return;
1437
+ this.timer = setTimeout(() => {
1438
+ this.timer = null;
1439
+ this.inflight = this.cycle().then((next) => {
1440
+ this.inflight = null;
1441
+ this.schedule(next);
1442
+ });
1443
+ }, delay);
1444
+ }
1445
+ async round() {
1446
+ if (!this.session) {
1447
+ this.session = await this.deps.bootstrap();
1448
+ this.recovering = new Set(this.session.state.pendingEntries().map((entry) => entry.id));
1449
+ }
1450
+ const { driver, state, pipeline } = this.session;
1451
+ if (state.cursor === null) {
1452
+ state.cursor = await driver.seedCursor();
1453
+ await state.save();
1454
+ this.deps.log.info("Email mailbox seeded; only mail arriving from now on will be answered");
1455
+ return this.interval(state);
1456
+ }
1457
+ await this.recoverPending();
1458
+ await this.processDueRetries();
1459
+ const result = await driver.sync(state.cursor);
1460
+ if (result.kind === "cursor-expired") {
1461
+ this.deps.log.warn("Email sync cursor expired; reseeding (mail received while offline beyond the provider window is not answered)");
1462
+ state.cursor = await driver.seedCursor();
1463
+ await state.save();
1464
+ return this.interval(state);
1465
+ }
1466
+ for (const message of result.messages) await this.guarded(message.id, () => pipeline.processSynced(message), () => {
1467
+ state.markSeen(message.id, "");
1468
+ });
1469
+ state.cursor = result.nextCursor;
1470
+ await state.save();
1471
+ if (result.messages.length > 0) this.deps.status.messageSeen(this.now());
1472
+ return this.interval(state);
1473
+ }
1474
+ async recoverPending() {
1475
+ const session = this.session;
1476
+ if (!session || !this.recovering || this.recovering.size === 0) return;
1477
+ const { state, pipeline } = session;
1478
+ for (const entry of [...state.pendingEntries()]) {
1479
+ if (!this.recovering.has(entry.id)) continue;
1480
+ await this.guarded(entry.id, () => pipeline.processPending(entry), () => {
1481
+ state.removePending(entry.id);
1482
+ state.removeRetry(entry.id);
1483
+ state.markSeen(entry.id, "");
1484
+ });
1485
+ this.recovering.delete(entry.id);
1486
+ }
1487
+ this.recovering.clear();
1488
+ }
1489
+ async processDueRetries() {
1490
+ const session = this.session;
1491
+ if (!session) return;
1492
+ const now = this.now();
1493
+ const { state, pipeline } = session;
1494
+ for (const entry of [...state.retryEntries()]) {
1495
+ if (entry.nextAt > now) continue;
1496
+ await this.guarded(entry.id, () => pipeline.processRetry(entry), () => {
1497
+ state.removeRetry(entry.id);
1498
+ state.removePending(entry.id);
1499
+ state.markSeen(entry.id, "");
1500
+ });
1501
+ }
1502
+ }
1503
+ /**
1504
+ * Run one message's step. Throttling and credential problems are not the
1505
+ * message's fault and fail the round as-is. Any other error fails the round
1506
+ * too (so it is retried in order), but after {@link MAX_MESSAGE_FAILURES}
1507
+ * consecutive failures the message is given up so it cannot block the mailbox.
1508
+ */
1509
+ async guarded(id, step, giveUp) {
1510
+ try {
1511
+ await step();
1512
+ this.failures.delete(id);
1513
+ } catch (err) {
1514
+ if (isReconnectError(err) || isAgentAuthError(err) || err instanceof _alfe_ai_email.MailboxRateLimitError) throw err;
1515
+ const count = (this.failures.get(id) ?? 0) + 1;
1516
+ if (count < MAX_MESSAGE_FAILURES) {
1517
+ this.failures.set(id, count);
1518
+ throw err;
1519
+ }
1520
+ this.failures.delete(id);
1521
+ this.deps.log.error(`Email skipped after ${String(count)} failed attempts: ${errorMessage(err)}`);
1522
+ giveUp();
1523
+ await this.session?.state.save();
1524
+ }
1525
+ }
1526
+ interval(state) {
1527
+ return computeInterval(state.lastMessageAt, this.now());
1528
+ }
1529
+ onError(err) {
1530
+ if (isReconnectError(err)) {
1531
+ this.deps.status.error("reconnect", reconnectMessage(err));
1532
+ return RECONNECT_BACKOFF_MS;
1533
+ }
1534
+ if (isAgentAuthError(err)) {
1535
+ this.deps.status.error("agent-auth", "The Agent API rejected this agent's token; check the agent's Alfe API key");
1536
+ return RECONNECT_BACKOFF_MS;
1537
+ }
1538
+ this.errorStreak += 1;
1539
+ const backoff = Math.min(2e3 * 2 ** (this.errorStreak - 1), MAX_ERROR_BACKOFF_MS);
1540
+ const retryAfter = err?.retryAfterMs;
1541
+ const delay = typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter > 0 ? Math.max(backoff, Math.min(retryAfter, 15 * 6e4)) : backoff;
1542
+ this.deps.log.warn(`Email poll failed; retrying in ${String(delay)}ms (${errorMessage(err)})`);
1543
+ return delay;
1544
+ }
1545
+ };
1546
+ //#endregion
1547
+ //#region src/runtime-config.ts
1548
+ function readRuntimeConfig(config) {
1549
+ if (typeof config?.current === "function") return config.current() ?? {};
1550
+ if (typeof config?.loadConfig === "function") return config.loadConfig();
1551
+ throw new Error("OpenClaw plugin runtime exposes neither config.current() nor config.loadConfig()");
1552
+ }
1553
+ //#endregion
1554
+ //#region src/status.ts
1555
+ var ChannelStatus = class {
1556
+ current = {
1557
+ state: "starting",
1558
+ lastMessageAt: null
1559
+ };
1560
+ constructor(log) {
1561
+ this.log = log;
1562
+ }
1563
+ snapshot() {
1564
+ return { ...this.current };
1565
+ }
1566
+ /**
1567
+ * A successful poll must not hide a send-side error (an Outlook token that
1568
+ * reads mail but lacks Mail.Send), so each path clears only its own error.
1569
+ */
1570
+ ok(source = "poll") {
1571
+ if (this.current.state === "error") {
1572
+ if (this.current.source !== source) return;
1573
+ this.log.info("Email channel recovered");
1574
+ }
1575
+ this.current = {
1576
+ state: "ok",
1577
+ lastMessageAt: this.current.lastMessageAt
1578
+ };
1579
+ }
1580
+ error(reason, message, source = "poll") {
1581
+ const changed = this.current.state !== "error" || this.current.reason !== reason || this.current.message !== message;
1582
+ this.current = {
1583
+ state: "error",
1584
+ reason,
1585
+ message,
1586
+ source,
1587
+ lastMessageAt: this.current.lastMessageAt
1588
+ };
1589
+ if (changed) this.log.error(`Email channel error (${reason}): ${message}`);
1590
+ }
1591
+ stopped() {
1592
+ this.current = {
1593
+ state: "stopped",
1594
+ lastMessageAt: this.current.lastMessageAt
1595
+ };
1596
+ }
1597
+ messageSeen(at) {
1598
+ this.current.lastMessageAt = at;
1599
+ }
1600
+ };
1601
+ //#endregion
1602
+ //#region src/plugin.ts
1603
+ /**
1604
+ * @alfe.ai/openclaw-email — chat with the agent through its connected Gmail
1605
+ * or Outlook mailbox.
1606
+ *
1607
+ * mailbox (poll 1-30 s via @alfe.ai/email drivers)
1608
+ * → EmailPoller → EmailPipeline (dedupe, filters, resolve, agent:chat admission)
1609
+ * → dispatchInboundDirectDmWithRuntime() per thread, serialised
1610
+ * ← deliver() payloads buffered → one reply-all email per turn
1611
+ * registered `email` channel → out-of-turn sends to `thread:<threadHash>`
1612
+ *
1613
+ * Requires OpenClaw >= 2026.9.5 (`package.json#openclaw.install.minHostVersion`
1614
+ * plus a runtime check): the per-sender tool gate in
1615
+ * `@alfe.ai/openclaw-identity` relies on 9.5's `before_tool_call`
1616
+ * `ctx.requester`.
1617
+ *
1618
+ * Config (`plugins.entries.<id>.config`, projected from the agent's Connect
1619
+ * `email` Channel by desired state): `{ channelId, connectionId }`.
1620
+ */
1621
+ const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
1622
+ const PLUGIN_ID = "@alfe.ai/openclaw-email";
1623
+ function parsePluginConfig(value) {
1624
+ if (typeof value !== "object" || value === null) return null;
1625
+ const { channelId, connectionId } = value;
1626
+ if (typeof channelId !== "string" || channelId.trim() === "") return null;
1627
+ if (typeof connectionId !== "string" || connectionId.trim() === "") return null;
1628
+ return {
1629
+ channelId: channelId.trim(),
1630
+ connectionId: connectionId.trim()
1631
+ };
1632
+ }
1633
+ const ACTIVATION_KEY = (0, _alfe_ai_openclaw_plugin_kit.getActivationKey)("email");
1634
+ const RUNTIME_KEY = Symbol.for("alfe.openclaw-email.runtime.v1");
1635
+ function runtimeHandle() {
1636
+ const g = globalThis;
1637
+ return g[RUNTIME_KEY] ??= {
1638
+ poller: null,
1639
+ status: null
1640
+ };
1641
+ }
1642
+ function channelHandle() {
1643
+ const { poller, status } = runtimeHandle();
1644
+ if (!poller || !status) return null;
1645
+ return {
1646
+ status: () => status.snapshot(),
1647
+ hasThread: (hash) => poller.current()?.state.getThread(hash) !== void 0,
1648
+ sendToThread: async (to, text) => {
1649
+ const session = poller.current();
1650
+ if (!session) throw new Error("Email channel is still starting; retry shortly");
1651
+ return await session.pipeline.sendToThread(to, text);
1652
+ }
1653
+ };
1654
+ }
1655
+ async function stopRunning(log) {
1656
+ const handle = runtimeHandle();
1657
+ const poller = handle.poller;
1658
+ handle.poller = null;
1659
+ await poller?.stop();
1660
+ (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(ACTIVATION_KEY);
1661
+ log.info("Email channel stopped");
1662
+ }
1663
+ const plugin = {
1664
+ id: PLUGIN_ID,
1665
+ name: "Email",
1666
+ description: "Email channel via the agent's connected Gmail or Outlook mailbox",
1667
+ version: pkg.version,
1668
+ activate(api) {
1669
+ const log = api.logger;
1670
+ const mode = api.registrationMode;
1671
+ if (mode !== void 0 && mode !== "full") {
1672
+ api.registerChannel(createEmailChannelPlugin(() => null));
1673
+ return;
1674
+ }
1675
+ if (!isHostAtLeast(api.runtime?.version, "2026.9.5")) {
1676
+ log.error(`Email channel requires OpenClaw >= ${MIN_HOST_VERSION} (host reports ${describeVersion(api.runtime?.version)}); not activating`);
1677
+ return;
1678
+ }
1679
+ api.registerChannel(createEmailChannelPlugin(channelHandle));
1680
+ api.registerService({
1681
+ id: "email-poller",
1682
+ start: (ctx) => {
1683
+ startService(api, ctx);
1684
+ },
1685
+ stop: async () => {
1686
+ await stopRunning(log);
1687
+ }
1688
+ });
1689
+ log.info("Email channel registered");
1690
+ },
1691
+ async deactivate(api) {
1692
+ await stopRunning(api.logger);
1693
+ }
1694
+ };
1695
+ function startService(api, ctx) {
1696
+ const log = api.logger;
1697
+ (0, _alfe_ai_openclaw_plugin_kit.guardedStart)(ACTIVATION_KEY, log, () => {
1698
+ const handle = runtimeHandle();
1699
+ const status = new ChannelStatus(log);
1700
+ handle.status = status;
1701
+ const config = parsePluginConfig(api.pluginConfig);
1702
+ if (!config) {
1703
+ status.error("config", "Plugin config needs channelId and connectionId");
1704
+ (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(ACTIVATION_KEY);
1705
+ return;
1706
+ }
1707
+ const runtime = api.runtime;
1708
+ const dispatch = (0, _alfe_ai_openclaw_plugin_kit.resolveOpenClawSdk)(log, { unresolvableNote: "OpenClaw SDK not resolvable; email dispatch is disabled" });
1709
+ const resolveRoute = (0, _alfe_ai_openclaw_plugin_kit.resolveOpenClawSdk)(log, {
1710
+ specifier: "openclaw/plugin-sdk/inbound-envelope",
1711
+ exportName: "resolveInboundRouteEnvelopeBuilderWithRuntime",
1712
+ unresolvableNote: "OpenClaw route resolver not resolvable; email sender context is disabled"
1713
+ });
1714
+ const stateDir = ctx.stateDir ?? runtime?.state?.resolveStateDir?.();
1715
+ if (!dispatch || !runtime || !stateDir) {
1716
+ log.error("Email channel runtime prerequisites unavailable; polling disabled");
1717
+ (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(ACTIVATION_KEY);
1718
+ return;
1719
+ }
1720
+ const alfe = (0, _alfe_ai_config.resolveConfig)();
1721
+ const client = new _alfe_ai_agent_api_client.AgentApiClient({
1722
+ apiKey: alfe.apiKey,
1723
+ apiUrl: alfe.apiUrl
1724
+ });
1725
+ const credentials = new ConnectionCredentials(client, config.connectionId);
1726
+ const bootstrap = async () => {
1727
+ const described = await credentials.describe();
1728
+ const driver = described.provider === "google" ? new _alfe_ai_email.GmailDriver({ getAccessToken: credentials.getAccessToken }) : new _alfe_ai_email.GraphDriver({ getAccessToken: credentials.getAccessToken });
1729
+ const { address } = await driver.whoami();
1730
+ const mailbox = address.trim().toLowerCase();
1731
+ const { agentId } = await client.whoami();
1732
+ const state = new StateStore(stateFilePath(stateDir, mailbox), mailbox, log);
1733
+ await state.load();
1734
+ const pipeline = new EmailPipeline({
1735
+ driver,
1736
+ state,
1737
+ identity: client,
1738
+ agentId,
1739
+ ownAddress: mailbox,
1740
+ runtime: {
1741
+ dispatch,
1742
+ resolveRoute,
1743
+ readConfig: () => readRuntimeConfig(runtime.config),
1744
+ channelRuntime: runtime.channel
1745
+ },
1746
+ log,
1747
+ onReconnectRequired: (err) => {
1748
+ status.error("reconnect", reconnectMessage(err), "send");
1749
+ },
1750
+ onSent: () => {
1751
+ status.ok("send");
1752
+ }
1753
+ });
1754
+ log.info(`Email channel polling ${described.provider} mailbox for channel ${config.channelId}`);
1755
+ return {
1756
+ driver,
1757
+ state,
1758
+ pipeline
1759
+ };
1760
+ };
1761
+ const poller = new EmailPoller({
1762
+ bootstrap,
1763
+ status,
1764
+ log
1765
+ });
1766
+ handle.poller = poller;
1767
+ poller.start();
1768
+ });
1769
+ }
1770
+ function describeVersion(value) {
1771
+ return typeof value === "string" ? value.slice(0, 64) : "no version";
1772
+ }
1773
+ var plugin_default = {
1774
+ ...plugin,
1775
+ register: plugin.activate.bind(plugin)
1776
+ };
1777
+ //#endregion
1778
+ Object.defineProperty(exports, "PLUGIN_ID", {
1779
+ enumerable: true,
1780
+ get: function() {
1781
+ return PLUGIN_ID;
1782
+ }
1783
+ });
1784
+ Object.defineProperty(exports, "parsePluginConfig", {
1785
+ enumerable: true,
1786
+ get: function() {
1787
+ return parsePluginConfig;
1788
+ }
1789
+ });
1790
+ Object.defineProperty(exports, "plugin_default", {
1791
+ enumerable: true,
1792
+ get: function() {
1793
+ return plugin_default;
1794
+ }
1795
+ });