@agenticmail/enterprise 0.5.49 → 0.5.51

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,362 @@
1
+ /**
2
+ * Pending Email Follow-Up Scheduler
3
+ *
4
+ * When an outbound email is blocked by the security guard, this scheduler
5
+ * sets up automatic follow-up reminders on an escalating schedule:
6
+ *
7
+ * 12 hours → 6 hours → 3 hours → 1 hour (final) → 3-day cooldown → repeat
8
+ *
9
+ * Reminders are delivered via OpenClaw's system event mechanism, which injects
10
+ * them into the agent's next prompt.
11
+ *
12
+ * Follow-up state is persisted to disk so reminders survive process restarts.
13
+ */
14
+
15
+ import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
16
+ import { join, dirname } from 'node:path';
17
+
18
+ // ── Types ────────────────────────────────────────────────────────────
19
+
20
+ interface FollowUpEntry {
21
+ pendingId: string;
22
+ recipient: string;
23
+ subject: string;
24
+ /** 0-indexed step within the current cycle */
25
+ step: number;
26
+ /** How many full 4-step cycles have completed */
27
+ cycle: number;
28
+ /** ISO timestamp when the next reminder should fire */
29
+ nextFireAt: string;
30
+ /** ISO timestamp when this follow-up was first created */
31
+ createdAt: string;
32
+ /** Session key for system event delivery */
33
+ sessionKey: string;
34
+ /** API URL + key for status checks */
35
+ apiUrl: string;
36
+ apiKey: string;
37
+ }
38
+
39
+ interface PersistedState {
40
+ version: 1;
41
+ entries: FollowUpEntry[];
42
+ }
43
+
44
+ // ── Constants ────────────────────────────────────────────────────────
45
+
46
+ // Escalating intervals within each cycle
47
+ const STEP_DELAYS_MS = [
48
+ 12 * 3_600_000, // 0 → 12 hours
49
+ 6 * 3_600_000, // 1 → 6 hours
50
+ 3 * 3_600_000, // 2 → 3 hours
51
+ 1 * 3_600_000, // 3 → 1 hour (final before cooldown)
52
+ ];
53
+
54
+ // Cooldown after completing a full cycle
55
+ const COOLDOWN_MS = 3 * 24 * 3_600_000; // 3 days
56
+
57
+ // Heartbeat interval for checking if pending emails have been resolved
58
+ const HEARTBEAT_INTERVAL_MS = 5 * 60_000; // 5 minutes
59
+
60
+ // ── Module state (singleton) ─────────────────────────────────────────
61
+
62
+ let _api: any = null;
63
+ let _stateFilePath: string = '';
64
+ const tracked = new Map<string, FollowUpEntry>();
65
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
66
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
67
+
68
+ // ── Initialization ───────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Initialize the follow-up system with the OpenClaw plugin API.
72
+ * Must be called during plugin activation before any follow-ups are scheduled.
73
+ */
74
+ export function initFollowUpSystem(api: any): void {
75
+ _api = api;
76
+
77
+ // Resolve state file path for persistence
78
+ try {
79
+ const stateDir = api?.runtime?.state?.resolveStateDir?.();
80
+ if (stateDir) {
81
+ _stateFilePath = join(stateDir, 'agenticmail-followups.json');
82
+ }
83
+ } catch { /* no persistence */ }
84
+
85
+ // Restore any persisted follow-ups from a previous process
86
+ restoreState();
87
+ }
88
+
89
+ // ── Public API ───────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Start follow-up reminders for a blocked pending email.
93
+ *
94
+ * @param pendingId UUID of the pending outbound email
95
+ * @param recipient Who the email was addressed to (for display)
96
+ * @param subject Original email subject (for display)
97
+ * @param sessionKey OpenClaw session key for system event delivery
98
+ * @param apiUrl AgenticMail API base URL (for status checks)
99
+ * @param apiKey Agent API key (for status checks)
100
+ */
101
+ export function scheduleFollowUp(
102
+ pendingId: string,
103
+ recipient: string,
104
+ subject: string,
105
+ sessionKey: string,
106
+ apiUrl: string,
107
+ apiKey: string,
108
+ ): void {
109
+ if (tracked.has(pendingId)) return;
110
+
111
+ const entry: FollowUpEntry = {
112
+ pendingId,
113
+ recipient,
114
+ subject,
115
+ step: 0,
116
+ cycle: 0,
117
+ nextFireAt: new Date(Date.now() + STEP_DELAYS_MS[0]).toISOString(),
118
+ createdAt: new Date().toISOString(),
119
+ sessionKey,
120
+ apiUrl,
121
+ apiKey,
122
+ };
123
+
124
+ tracked.set(pendingId, entry);
125
+ armTimer(pendingId, entry);
126
+ startHeartbeat();
127
+ persistState();
128
+ }
129
+
130
+ /** Cancel follow-ups for a specific pending email (e.g. approved/rejected). */
131
+ export function cancelFollowUp(pendingId: string): void {
132
+ if (!tracked.has(pendingId)) return;
133
+ clearTimer(pendingId);
134
+ tracked.delete(pendingId);
135
+ persistState();
136
+ }
137
+
138
+ /** Cancel all follow-ups (e.g. on shutdown / agent_end). */
139
+ export function cancelAllFollowUps(): void {
140
+ for (const id of tracked.keys()) {
141
+ clearTimer(id);
142
+ }
143
+ tracked.clear();
144
+ timers.clear();
145
+ stopHeartbeat();
146
+ persistState();
147
+ }
148
+
149
+ /** Number of actively tracked pending emails. */
150
+ export function activeFollowUpCount(): number {
151
+ return tracked.size;
152
+ }
153
+
154
+ /** Get summary of all active follow-ups (for diagnostics). */
155
+ export function getFollowUpSummary(): Array<{
156
+ pendingId: string;
157
+ recipient: string;
158
+ subject: string;
159
+ step: number;
160
+ cycle: number;
161
+ nextFireAt: string;
162
+ }> {
163
+ return Array.from(tracked.values()).map(e => ({
164
+ pendingId: e.pendingId,
165
+ recipient: e.recipient,
166
+ subject: e.subject,
167
+ step: e.step,
168
+ cycle: e.cycle,
169
+ nextFireAt: e.nextFireAt,
170
+ }));
171
+ }
172
+
173
+ // ── Timer Management ─────────────────────────────────────────────────
174
+
175
+ function armTimer(pendingId: string, entry: FollowUpEntry): void {
176
+ clearTimer(pendingId);
177
+ const delay = Math.max(0, new Date(entry.nextFireAt).getTime() - Date.now());
178
+ const timer = setTimeout(() => fire(pendingId), delay);
179
+ timer.unref();
180
+ timers.set(pendingId, timer);
181
+ }
182
+
183
+ function clearTimer(pendingId: string): void {
184
+ const timer = timers.get(pendingId);
185
+ if (timer) {
186
+ clearTimeout(timer);
187
+ timers.delete(pendingId);
188
+ }
189
+ }
190
+
191
+ // ── Reminder Delivery ────────────────────────────────────────────────
192
+
193
+ async function fire(pendingId: string): Promise<void> {
194
+ const entry = tracked.get(pendingId);
195
+ if (!entry) return;
196
+
197
+ // Check whether the email is still pending
198
+ const stillPending = await checkStillPending(entry);
199
+ if (!stillPending) {
200
+ clearTimer(pendingId);
201
+ tracked.delete(pendingId);
202
+ persistState();
203
+ return;
204
+ }
205
+
206
+ const { recipient, subject, step, cycle } = entry;
207
+ const isFinal = step === STEP_DELAYS_MS.length - 1;
208
+ const isPostCooldown = step >= STEP_DELAYS_MS.length;
209
+
210
+ // Build the reminder message
211
+ let message: string;
212
+ if (isPostCooldown) {
213
+ const totalDays = 3 * (cycle + 1);
214
+ message = [
215
+ `[FOLLOW-UP REMINDER — cycle ${cycle + 2}]`,
216
+ `Your blocked email to ${recipient} (subject: "${subject}") has been pending for over ${totalDays} days.`,
217
+ `Starting a new follow-up cycle. Please remind your owner that this email still needs their review.`,
218
+ `Pending ID: ${pendingId}`,
219
+ ].join('\n');
220
+ } else if (isFinal) {
221
+ message = [
222
+ `[FINAL FOLLOW-UP]`,
223
+ `Your blocked email to ${recipient} (subject: "${subject}") is STILL pending approval.`,
224
+ `This is the last reminder before a 3-day cooldown. Please urgently remind your owner.`,
225
+ `Let them know you will not follow up again for 3 days unless they respond.`,
226
+ `Pending ID: ${pendingId}`,
227
+ ].join('\n');
228
+ } else {
229
+ const nextDelayH = STEP_DELAYS_MS[step + 1] / 3_600_000;
230
+ message = [
231
+ `[FOLLOW-UP REMINDER ${step + 1}/${STEP_DELAYS_MS.length}]`,
232
+ `Your blocked email to ${recipient} (subject: "${subject}") is still pending owner approval.`,
233
+ `Please follow up with your owner — ask if they've reviewed the notification email.`,
234
+ `Next reminder in ${nextDelayH} hour${nextDelayH !== 1 ? 's' : ''}.`,
235
+ `Pending ID: ${pendingId}`,
236
+ ].join('\n');
237
+ }
238
+
239
+ // Deliver via OpenClaw system event (injected into agent's next prompt)
240
+ deliverReminder(message, entry.sessionKey);
241
+
242
+ // Schedule the next follow-up
243
+ const nextStep = isPostCooldown ? 0 : step + 1;
244
+ const nextCycle = isPostCooldown ? cycle + 1 : cycle;
245
+ const nextDelay = nextStep < STEP_DELAYS_MS.length ? STEP_DELAYS_MS[nextStep] : COOLDOWN_MS;
246
+
247
+ entry.step = nextStep;
248
+ entry.cycle = nextCycle;
249
+ entry.nextFireAt = new Date(Date.now() + nextDelay).toISOString();
250
+
251
+ armTimer(pendingId, entry);
252
+ persistState();
253
+ }
254
+
255
+ function deliverReminder(text: string, sessionKey: string): void {
256
+ try {
257
+ if (_api?.runtime?.system?.enqueueSystemEvent && sessionKey) {
258
+ _api.runtime.system.enqueueSystemEvent(text, { sessionKey });
259
+ } else {
260
+ console.warn('[agenticmail] Cannot deliver follow-up reminder: no system event API or session key');
261
+ }
262
+ } catch (err) {
263
+ console.warn(`[agenticmail] Follow-up delivery error: ${(err as Error).message}`);
264
+ }
265
+ }
266
+
267
+ // ── Status Checks ────────────────────────────────────────────────────
268
+
269
+ async function checkStillPending(entry: FollowUpEntry): Promise<boolean> {
270
+ try {
271
+ const res = await fetch(
272
+ `${entry.apiUrl}/api/agenticmail/mail/pending/${encodeURIComponent(entry.pendingId)}`,
273
+ {
274
+ headers: { 'Authorization': `Bearer ${entry.apiKey}` },
275
+ signal: AbortSignal.timeout(10_000),
276
+ },
277
+ );
278
+ if (!res.ok) return false;
279
+ const data: any = await res.json();
280
+ return data?.status === 'pending';
281
+ } catch {
282
+ return true; // assume still pending on error
283
+ }
284
+ }
285
+
286
+ // ── Heartbeat ────────────────────────────────────────────────────────
287
+
288
+ function startHeartbeat(): void {
289
+ if (heartbeatTimer) return;
290
+ heartbeatTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS);
291
+ heartbeatTimer.unref();
292
+ }
293
+
294
+ function stopHeartbeat(): void {
295
+ if (heartbeatTimer) {
296
+ clearInterval(heartbeatTimer);
297
+ heartbeatTimer = null;
298
+ }
299
+ }
300
+
301
+ async function heartbeat(): Promise<void> {
302
+ if (tracked.size === 0) {
303
+ stopHeartbeat();
304
+ return;
305
+ }
306
+
307
+ for (const [pendingId, entry] of tracked) {
308
+ try {
309
+ const stillPending = await checkStillPending(entry);
310
+ if (!stillPending) {
311
+ clearTimer(pendingId);
312
+ tracked.delete(pendingId);
313
+ persistState();
314
+ }
315
+ } catch {
316
+ // API unreachable — skip, will retry next heartbeat
317
+ }
318
+ }
319
+
320
+ if (tracked.size === 0) stopHeartbeat();
321
+ }
322
+
323
+ // ── Persistence ──────────────────────────────────────────────────────
324
+
325
+ function persistState(): void {
326
+ if (!_stateFilePath) return;
327
+ try {
328
+ const state: PersistedState = {
329
+ version: 1,
330
+ entries: Array.from(tracked.values()),
331
+ };
332
+ mkdirSync(dirname(_stateFilePath), { recursive: true });
333
+ writeFileSync(_stateFilePath, JSON.stringify(state, null, 2), 'utf-8');
334
+ } catch (err) {
335
+ console.warn(`[agenticmail] Failed to persist follow-up state: ${(err as Error).message}`);
336
+ }
337
+ }
338
+
339
+ function restoreState(): void {
340
+ if (!_stateFilePath) return;
341
+ try {
342
+ const raw = readFileSync(_stateFilePath, 'utf-8');
343
+ const state: PersistedState = JSON.parse(raw);
344
+ if (state.version !== 1 || !Array.isArray(state.entries)) return;
345
+
346
+ for (const entry of state.entries) {
347
+ // Skip entries whose fire time has long passed (> 1 day overdue)
348
+ const overdue = Date.now() - new Date(entry.nextFireAt).getTime();
349
+ if (overdue > 24 * 3_600_000) continue;
350
+
351
+ tracked.set(entry.pendingId, entry);
352
+ armTimer(entry.pendingId, entry);
353
+ }
354
+
355
+ if (tracked.size > 0) {
356
+ startHeartbeat();
357
+ console.log(`[agenticmail] Restored ${tracked.size} follow-up reminder(s) from disk`);
358
+ }
359
+ } catch {
360
+ // No persisted state or invalid — start fresh
361
+ }
362
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * AgenticMail Anonymous Telemetry
3
+ *
4
+ * Collects anonymous usage counts to help improve the product.
5
+ * NO personal data, API keys, emails, or content is ever collected.
6
+ *
7
+ * Opt out: set AGENTICMAIL_TELEMETRY=0 or DO_NOT_TRACK=1
8
+ *
9
+ * What we collect:
10
+ * - Tool call counts (which tools are popular)
11
+ * - Package version
12
+ * - Anonymous install ID (random UUID, no PII)
13
+ * - OS platform (e.g. "darwin", "linux")
14
+ */
15
+
16
+ import { randomUUID } from 'crypto';
17
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { homedir } from 'os';
20
+ import { platform } from 'process';
21
+
22
+ const TELEMETRY_ENDPOINT = 'https://agenticmail.io/api/telemetry';
23
+ const BATCH_INTERVAL_MS = 60_000; // flush every 60 seconds
24
+ const MAX_BATCH_SIZE = 100; // flush if batch gets this big
25
+
26
+ interface TelemetryEvent {
27
+ tool: string;
28
+ ts: number;
29
+ }
30
+
31
+ let installId: string | null = null;
32
+ let packageVersion: string = 'unknown';
33
+ let disabled: boolean | null = null;
34
+ let batch: TelemetryEvent[] = [];
35
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
36
+ let flushing = false;
37
+
38
+ /** Check if telemetry is disabled */
39
+ function isDisabled(): boolean {
40
+ if (disabled !== null) return disabled;
41
+ disabled = (
42
+ process.env.AGENTICMAIL_TELEMETRY === '0' ||
43
+ process.env.AGENTICMAIL_TELEMETRY === 'false' ||
44
+ process.env.DO_NOT_TRACK === '1' ||
45
+ process.env.DO_NOT_TRACK === 'true' ||
46
+ process.env.CI === 'true' // don't count CI runs
47
+ );
48
+ return disabled;
49
+ }
50
+
51
+ /** Get or create the anonymous install ID */
52
+ function getInstallId(): string {
53
+ if (installId) return installId;
54
+
55
+ try {
56
+ const dir = join(homedir(), '.agenticmail');
57
+ const idFile = join(dir, '.telemetry-id');
58
+
59
+ if (existsSync(idFile)) {
60
+ const id = readFileSync(idFile, 'utf8').trim();
61
+ if (id && id.length > 10) {
62
+ installId = id;
63
+ return installId;
64
+ }
65
+ }
66
+
67
+ // Generate new ID
68
+ installId = randomUUID();
69
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
70
+ writeFileSync(idFile, installId, 'utf8');
71
+ return installId;
72
+ } catch {
73
+ // If we can't persist, use a session-only ID
74
+ installId = randomUUID();
75
+ return installId;
76
+ }
77
+ }
78
+
79
+ /** Set the package version for telemetry events */
80
+ export function setTelemetryVersion(version: string): void {
81
+ packageVersion = version;
82
+ }
83
+
84
+ /** Record a tool call (fire-and-forget, never throws) */
85
+ export function recordToolCall(toolName: string): void {
86
+ try {
87
+ if (isDisabled()) return;
88
+
89
+ batch.push({ tool: toolName, ts: Date.now() });
90
+
91
+ // Flush if batch is full
92
+ if (batch.length >= MAX_BATCH_SIZE) {
93
+ flush();
94
+ return;
95
+ }
96
+
97
+ // Schedule a flush if not already scheduled
98
+ if (!flushTimer) {
99
+ flushTimer = setTimeout(() => {
100
+ flushTimer = null;
101
+ flush();
102
+ }, BATCH_INTERVAL_MS);
103
+ // Don't keep the process alive just for telemetry
104
+ if (flushTimer && typeof flushTimer === 'object' && 'unref' in flushTimer) {
105
+ flushTimer.unref();
106
+ }
107
+ }
108
+ } catch {
109
+ // Never throw from telemetry
110
+ }
111
+ }
112
+
113
+ /** Flush the current batch to the server */
114
+ function flush(): void {
115
+ if (flushing || batch.length === 0) return;
116
+ flushing = true;
117
+
118
+ const events = batch;
119
+ batch = [];
120
+
121
+ // Aggregate: count calls per tool
122
+ const toolCounts: Record<string, number> = {};
123
+ for (const e of events) {
124
+ toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
125
+ }
126
+
127
+ const payload = {
128
+ id: getInstallId(),
129
+ v: packageVersion,
130
+ p: platform,
131
+ tools: toolCounts,
132
+ n: events.length, // total calls in this batch
133
+ };
134
+
135
+ // Fire and forget — never await, never throw
136
+ fetch(TELEMETRY_ENDPOINT, {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify(payload),
140
+ signal: AbortSignal.timeout(5_000),
141
+ }).catch(() => {
142
+ // Silently ignore all errors
143
+ }).finally(() => {
144
+ flushing = false;
145
+ });
146
+ }
147
+
148
+ /** Flush remaining events on process exit */
149
+ export function flushTelemetry(): void {
150
+ if (flushTimer) {
151
+ clearTimeout(flushTimer);
152
+ flushTimer = null;
153
+ }
154
+ flush();
155
+ }
156
+
157
+ // Flush on exit (best effort)
158
+ try {
159
+ process.on('beforeExit', flushTelemetry);
160
+ process.on('SIGINT', flushTelemetry);
161
+ process.on('SIGTERM', flushTelemetry);
162
+ } catch {
163
+ // ignore — might not have process events in all environments
164
+ }