@agenticmail/api 0.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/REFERENCE.md ADDED
@@ -0,0 +1,1314 @@
1
+ # @agenticmail/api — Technical Reference
2
+
3
+ Complete technical reference for the AgenticMail REST API server. All routes are prefixed with `/api/agenticmail` unless otherwise noted.
4
+
5
+ ---
6
+
7
+ ## Exports
8
+
9
+ The package exports a single factory function:
10
+
11
+ ```typescript
12
+ import { createApp } from '@agenticmail/api';
13
+ ```
14
+
15
+ ### `createApp(options): Promise<Express>`
16
+
17
+ Creates and configures the Express application with all routes, middleware, and background services.
18
+
19
+ **Options:**
20
+ ```typescript
21
+ {
22
+ masterKey: string;
23
+ smtp: { host: string; port: number };
24
+ imap: { host: string; port: number };
25
+ stalwart: { url: string; user: string; pass: string };
26
+ dataDir: string;
27
+ }
28
+ ```
29
+
30
+ **Returns:** Configured Express app ready to `.listen()`.
31
+
32
+ ---
33
+
34
+ ## Authentication
35
+
36
+ ### Bearer Token Authentication
37
+
38
+ All endpoints (except `/health` and `/mail/inbound`) require:
39
+ ```
40
+ Authorization: Bearer <token>
41
+ ```
42
+
43
+ **Token types:**
44
+ | Prefix | Type | Scope |
45
+ |--------|------|-------|
46
+ | `mk_` | Master key | Full admin access |
47
+ | `ak_` | Agent key | Scoped to owning agent |
48
+
49
+ ### Timing-Safe Comparison
50
+
51
+ Key comparison uses SHA-256 hashing + `timingSafeEqual`:
52
+ ```typescript
53
+ function safeEqual(a: string, b: string): boolean {
54
+ const ha = createHash('sha256').update(a).digest();
55
+ const hb = createHash('sha256').update(b).digest();
56
+ return timingSafeEqual(ha, hb);
57
+ }
58
+ ```
59
+ This prevents timing side-channel attacks against key validation.
60
+
61
+ ### Activity Throttling
62
+
63
+ Agent activity (`last_activity_at`) is updated at most once per 60 seconds per agent via an in-memory timestamp cache. SQL: `UPDATE agents SET last_activity_at = datetime('now') WHERE id = ?`
64
+
65
+ ### Auth Guard Functions
66
+
67
+ | Function | Behavior |
68
+ |----------|----------|
69
+ | `requireMaster()` | Returns 403 if `!req.isMaster` |
70
+ | `requireAuth()` | Returns 403 if `!req.agent && !req.isMaster` |
71
+ | `requireAgent()` | Returns 403 if `!req.agent` (master alone insufficient) |
72
+
73
+ ---
74
+
75
+ ## Middleware Stack
76
+
77
+ Applied in this order:
78
+
79
+ 1. **CORS** — `cors()` with default options (all origins)
80
+ 2. **JSON Body Parser** — `express.json({ limit: '10mb' })`
81
+ 3. **Global Rate Limiter** — 100 requests per IP per 60-second window
82
+ 4. **Health routes** — mounted before auth (no auth required)
83
+ 5. **Inbound webhook** — mounted before auth (uses `X-Inbound-Secret`)
84
+ 6. **Auth middleware** — Bearer token validation
85
+ 7. **Protected routes** — all remaining routes
86
+ 8. **404 handler** — `{ error: 'Not found' }`
87
+ 9. **Error handler** — categorizes errors by message pattern
88
+
89
+ ### Error Handler
90
+
91
+ Maps errors to HTTP status codes:
92
+
93
+ | Status | Trigger |
94
+ |--------|---------|
95
+ | 400 | `SyntaxError` with `.status === 400` (malformed JSON) |
96
+ | 400 | Message contains "invalid", "required", or "must " |
97
+ | 404 | Message contains "not found" (but not "not found a") |
98
+ | 409 | Message contains "already exists" or "unique constraint" |
99
+ | Custom | `err.statusCode` property (if set) |
100
+ | 500 | Default fallback (response: `{ error: 'Internal server error' }`) |
101
+
102
+ ---
103
+
104
+ ## Routes: Health
105
+
106
+ ### GET /health
107
+
108
+ **Auth:** None
109
+
110
+ **Response (200):**
111
+ ```json
112
+ {
113
+ "status": "ok",
114
+ "services": { "api": "ok", "stalwart": "ok" },
115
+ "timestamp": "ISO-8601"
116
+ }
117
+ ```
118
+
119
+ **Response (503):** Stalwart unreachable → `{ "status": "degraded", "services": { "api": "ok", "stalwart": "unreachable" } }`
120
+
121
+ ---
122
+
123
+ ## Routes: Mail Operations
124
+
125
+ ### Connection Caching
126
+
127
+ | Parameter | Value |
128
+ |-----------|-------|
129
+ | `CACHE_TTL_MS` | 600,000 (10 minutes) |
130
+ | `MAX_CACHE_SIZE` | 100 entries |
131
+ | Eviction interval | 60 seconds |
132
+ | Sender cache key | `${stalwartPrincipal}:${fromEmail}` |
133
+ | Receiver cache key | `${stalwartPrincipal}` |
134
+ | Concurrent dedup | `receiverPending: Map<string, Promise<MailReceiver>>` |
135
+ | Draining flag | Prevents new connections during shutdown |
136
+
137
+ ### POST /mail/send
138
+
139
+ **Auth:** Agent (`requireAgent`)
140
+
141
+ **Request Body:**
142
+ ```json
143
+ {
144
+ "to": "string | string[]", // Required
145
+ "subject": "string", // Required
146
+ "text": "string", // Optional
147
+ "html": "string", // Optional
148
+ "cc": "string | string[]", // Optional
149
+ "bcc": "string | string[]", // Optional
150
+ "replyTo": "string", // Optional
151
+ "inReplyTo": "string", // Optional (Message-ID for threading)
152
+ "references": "string[]", // Optional (thread chain)
153
+ "attachments": [{ // Optional
154
+ "filename": "string",
155
+ "contentType": "string",
156
+ "content": "Buffer | string",
157
+ "encoding": "string"
158
+ }],
159
+ "allowSensitive": "boolean" // Optional (master bypass only)
160
+ }
161
+ ```
162
+
163
+ **Outbound Guard Flow:**
164
+ 1. Master key + `allowSensitive: true` → bypasses all scanning
165
+ 2. Agent key → `scanOutboundEmail()` always runs regardless of `allowSensitive`
166
+ 3. If blocked → stored in `pending_outbound`, notification sent to owner
167
+ 4. If allowed (with warnings) → sent with `outboundWarnings` in response
168
+ 5. If clean → sent normally
169
+
170
+ **Blocked Email Storage:**
171
+ ```sql
172
+ INSERT INTO pending_outbound (id, agent_id, mail_options, warnings, summary, status, created_at)
173
+ VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'))
174
+ ```
175
+
176
+ **Notification Email:**
177
+ - To: `gatewayConfig.relay.email` (owner's relay address)
178
+ - Subject: `[Approval Required] Blocked email from "{agentName}" — "{subject}"`
179
+ - Body: Full email preview, all security warnings, pending ID, reply instructions
180
+ - Reply detection keywords: approve/yes/lgtm/go ahead/send/ok (approve), reject/no/deny/cancel/block (reject)
181
+
182
+ **Display Name Logic:**
183
+ - If `agent.metadata.ownerName`: `"${agent.name} from ${ownerName}"`
184
+ - Else: `agent.name`
185
+
186
+ **Routing Priority:**
187
+ 1. Try `gatewayManager.routeOutbound(agentName, mailOpts)`
188
+ 2. Fallback to local SMTP via `getSender()`
189
+ 3. Best-effort save to Sent folder
190
+
191
+ **Response (200):**
192
+ ```json
193
+ {
194
+ "sent": true,
195
+ "messageId": "string",
196
+ "timestamp": "ISO-8601",
197
+ "outboundWarnings": [...], // Optional
198
+ "outboundSummary": "string" // Optional
199
+ }
200
+ ```
201
+
202
+ **Response (blocked):**
203
+ ```json
204
+ {
205
+ "sent": false,
206
+ "blocked": true,
207
+ "pendingId": "uuid",
208
+ "warnings": [...],
209
+ "summary": "string"
210
+ }
211
+ ```
212
+
213
+ ---
214
+
215
+ ### GET /mail/inbox
216
+
217
+ **Auth:** Agent
218
+
219
+ **Query Params:**
220
+ | Param | Default | Range |
221
+ |-------|---------|-------|
222
+ | `limit` | 20 | 1–200 |
223
+ | `offset` | 0 | 0+ |
224
+
225
+ **Response:**
226
+ ```json
227
+ {
228
+ "messages": [{
229
+ "uid": 1,
230
+ "subject": "string",
231
+ "from": [{ "name": "string", "address": "string" }],
232
+ "to": [{ "name": "string", "address": "string" }],
233
+ "date": "ISO-8601",
234
+ "flags": ["\\Seen", "\\Flagged"],
235
+ "size": 1234
236
+ }],
237
+ "count": 20,
238
+ "total": 150
239
+ }
240
+ ```
241
+
242
+ ---
243
+
244
+ ### GET /mail/digest
245
+
246
+ **Auth:** Agent
247
+
248
+ **Query Params:**
249
+ | Param | Default | Range |
250
+ |-------|---------|-------|
251
+ | `limit` | 20 | 1–50 |
252
+ | `offset` | 0 | 0+ |
253
+ | `previewLength` | 200 | 50–500 |
254
+ | `folder` | "INBOX" | any valid folder |
255
+
256
+ **Response:** Same as inbox but each message includes `"preview": "First N chars..."`.
257
+
258
+ ---
259
+
260
+ ### GET /mail/messages/:uid
261
+
262
+ **Auth:** Agent
263
+
264
+ **Query Params:** `folder` (default: "INBOX")
265
+
266
+ **Response:**
267
+ ```json
268
+ {
269
+ "uid": 1,
270
+ "subject": "string",
271
+ "from": [...],
272
+ "to": [...],
273
+ "cc": [...],
274
+ "date": "ISO-8601",
275
+ "messageId": "string",
276
+ "inReplyTo": "string",
277
+ "references": "string",
278
+ "text": "string",
279
+ "html": "string",
280
+ "attachments": [{
281
+ "filename": "string",
282
+ "contentType": "string",
283
+ "size": 1234
284
+ }],
285
+ "security": {
286
+ "internal": false,
287
+ "spamScore": 0,
288
+ "isSpam": false,
289
+ "isWarning": false,
290
+ "topCategory": "string | null",
291
+ "matches": ["ruleId", ...],
292
+ "sanitized": false,
293
+ "sanitizeDetections": [...]
294
+ }
295
+ }
296
+ ```
297
+
298
+ **Spam scoring logic:**
299
+ - Internal emails (agent-to-agent on same system) → skip scoring, return `spamScore: 0`
300
+ - External emails → `scoreEmail(parsed)` + `sanitizeEmailContent()`
301
+
302
+ ---
303
+
304
+ ### GET /mail/messages/:uid/attachments/:index
305
+
306
+ **Auth:** Agent
307
+
308
+ **Path Params:** `uid` (integer), `index` (0-based)
309
+
310
+ **Query Params:** `folder` (default: "INBOX")
311
+
312
+ **Response:** Binary data with appropriate `Content-Type`, `Content-Disposition`, `Content-Length` headers.
313
+
314
+ **Status:** 404 if attachment not found.
315
+
316
+ ---
317
+
318
+ ### GET /mail/messages/:uid/spam-score
319
+
320
+ **Auth:** Agent
321
+
322
+ **Response:**
323
+ ```json
324
+ {
325
+ "score": 0,
326
+ "isSpam": false,
327
+ "isWarning": false,
328
+ "matches": [],
329
+ "topCategory": null,
330
+ "internal": true
331
+ }
332
+ ```
333
+
334
+ ---
335
+
336
+ ### POST /mail/search
337
+
338
+ **Auth:** Agent
339
+
340
+ **Request Body:**
341
+ ```json
342
+ {
343
+ "from": "string", // Optional
344
+ "to": "string", // Optional
345
+ "subject": "string", // Optional
346
+ "text": "string", // Optional (body text)
347
+ "since": "ISO-8601", // Optional
348
+ "before": "ISO-8601", // Optional
349
+ "seen": true, // Optional (true=read, false=unread)
350
+ "searchRelay": false // Optional (also search relay account)
351
+ }
352
+ ```
353
+
354
+ **Response:**
355
+ ```json
356
+ {
357
+ "uids": [1, 2, 3],
358
+ "relayResults": [{ // Only if searchRelay=true
359
+ "uid": 1,
360
+ "source": "relay",
361
+ "account": "email@gmail.com",
362
+ "messageId": "string",
363
+ "subject": "string",
364
+ "from": [...],
365
+ "to": [...],
366
+ "date": "ISO-8601",
367
+ "flags": [...]
368
+ }]
369
+ }
370
+ ```
371
+
372
+ ---
373
+
374
+ ### POST /mail/import-relay
375
+
376
+ **Auth:** Agent
377
+
378
+ **Request Body:** `{ "uid": 123 }`
379
+
380
+ **Response:** `{ "ok": true, "message": "Email imported to local inbox." }`
381
+
382
+ **Status:** 400 if no gateway or import failed.
383
+
384
+ ---
385
+
386
+ ### DELETE /mail/messages/:uid
387
+
388
+ **Auth:** Agent
389
+
390
+ **Response:** 204 (no content)
391
+
392
+ ---
393
+
394
+ ### POST /mail/messages/:uid/seen
395
+
396
+ **Auth:** Agent
397
+
398
+ **Response:** `{ "ok": true }`
399
+
400
+ ---
401
+
402
+ ### POST /mail/messages/:uid/unseen
403
+
404
+ **Auth:** Agent
405
+
406
+ **Response:** `{ "ok": true }`
407
+
408
+ ---
409
+
410
+ ### POST /mail/messages/:uid/move
411
+
412
+ **Auth:** Agent
413
+
414
+ **Request Body:** `{ "from": "INBOX", "to": "Archive" }`
415
+
416
+ **Response:** `{ "ok": true }`
417
+
418
+ ---
419
+
420
+ ### GET /mail/folders
421
+
422
+ **Auth:** Agent
423
+
424
+ **Response:** `{ "folders": ["INBOX", "Sent Items", "Drafts", ...] }`
425
+
426
+ ---
427
+
428
+ ### POST /mail/folders
429
+
430
+ **Auth:** Agent
431
+
432
+ **Request Body:** `{ "name": "Projects" }`
433
+
434
+ **Validation:** Name <= 200 chars, no `\`, `*`, `%` characters.
435
+
436
+ **Response:** `{ "ok": true, "folder": "Projects" }`
437
+
438
+ ---
439
+
440
+ ### GET /mail/folders/:folder
441
+
442
+ **Auth:** Agent
443
+
444
+ **Query Params:** `limit` (1–200, default 20), `offset` (0+)
445
+
446
+ **Response:**
447
+ ```json
448
+ {
449
+ "messages": [...],
450
+ "count": 20,
451
+ "total": 150,
452
+ "folder": "Archive"
453
+ }
454
+ ```
455
+
456
+ ---
457
+
458
+ ## Routes: Batch Operations
459
+
460
+ All batch endpoints require Agent auth. Max 1000 UIDs per request. All UIDs must be positive integers.
461
+
462
+ ### POST /mail/batch/delete
463
+
464
+ **Request:** `{ "uids": [1, 2, 3], "folder": "INBOX" }`
465
+
466
+ **Response:** `{ "ok": true, "deleted": 3 }`
467
+
468
+ ### POST /mail/batch/seen
469
+
470
+ **Request:** `{ "uids": [1, 2, 3], "folder": "INBOX" }`
471
+
472
+ **Response:** `{ "ok": true, "marked": 3 }`
473
+
474
+ ### POST /mail/batch/unseen
475
+
476
+ **Request:** `{ "uids": [1, 2, 3], "folder": "INBOX" }`
477
+
478
+ **Response:** `{ "ok": true, "marked": 3 }`
479
+
480
+ ### POST /mail/batch/move
481
+
482
+ **Request:** `{ "uids": [1, 2, 3], "from": "INBOX", "to": "Archive" }`
483
+
484
+ **Response:** `{ "ok": true, "moved": 3 }`
485
+
486
+ ### POST /mail/batch/read
487
+
488
+ **Request:** `{ "uids": [1, 2, 3], "folder": "INBOX" }`
489
+
490
+ **Response:** `{ "messages": [{uid, subject, from, to, text, html, ...}], "count": 3 }`
491
+
492
+ ---
493
+
494
+ ## Routes: Spam
495
+
496
+ ### GET /mail/spam
497
+
498
+ **Auth:** Agent
499
+
500
+ **Query Params:** `limit` (1–200, default 20), `offset` (0+)
501
+
502
+ **Response:** Same structure as folder listing (messages from Spam folder).
503
+
504
+ ### POST /mail/messages/:uid/spam
505
+
506
+ **Auth:** Agent
507
+
508
+ **Request:** `{ "folder": "INBOX" }` (optional, source folder)
509
+
510
+ Creates Spam folder if needed, moves message there.
511
+
512
+ **Response:** `{ "ok": true, "movedToSpam": true }`
513
+
514
+ ### POST /mail/messages/:uid/not-spam
515
+
516
+ **Auth:** Agent
517
+
518
+ Moves message from Spam to INBOX.
519
+
520
+ **Response:** `{ "ok": true, "movedToInbox": true }`
521
+
522
+ ---
523
+
524
+ ## Routes: Pending Outbound (Human-Only Approval)
525
+
526
+ ### GET /mail/pending
527
+
528
+ **Auth:** Both (agent sees own, master sees all)
529
+
530
+ **Response:**
531
+ ```json
532
+ {
533
+ "pending": [{
534
+ "id": "uuid",
535
+ "agentId": "uuid",
536
+ "to": "string | string[]",
537
+ "subject": "string",
538
+ "warnings": [...],
539
+ "summary": "string",
540
+ "status": "pending | approved | rejected",
541
+ "createdAt": "datetime",
542
+ "resolvedAt": "datetime | null",
543
+ "resolvedBy": "master | null"
544
+ }],
545
+ "count": 5
546
+ }
547
+ ```
548
+
549
+ ### GET /mail/pending/:id
550
+
551
+ **Auth:** Both (agent sees own only, master sees any)
552
+
553
+ **Response:** Full pending email details including `mailOptions`.
554
+
555
+ ### POST /mail/pending/:id/approve
556
+
557
+ **Auth:** Master only (403 for agent keys)
558
+
559
+ **Flow:**
560
+ 1. Look up pending email, verify status is "pending"
561
+ 2. Look up originating agent
562
+ 3. Re-parse stored `mailOptions` from JSON
563
+ 4. Refresh `fromName` from current agent metadata
564
+ 5. Reconstitute Buffer objects (attachments)
565
+ 6. Send via gateway first, fallback to local SMTP
566
+ 7. Update: `status = 'approved'`, `resolved_at = datetime('now')`, `resolved_by = 'master'`
567
+
568
+ **Response:** `{ "sent": true, "messageId": "...", "approved": true, "pendingId": "uuid" }`
569
+
570
+ ### POST /mail/pending/:id/reject
571
+
572
+ **Auth:** Master only (403 for agent keys)
573
+
574
+ Updates: `status = 'rejected'`, `resolved_at = datetime('now')`, `resolved_by = 'master'`
575
+
576
+ **Response:** `{ "ok": true, "rejected": true, "pendingId": "uuid" }`
577
+
578
+ ---
579
+
580
+ ## Routes: Events (SSE)
581
+
582
+ ### GET /events
583
+
584
+ **Auth:** Agent (`requireAgent`)
585
+
586
+ **Connection Limit:** 5 per agent (returns 429 if exceeded)
587
+
588
+ **SSE Headers:**
589
+ ```
590
+ Content-Type: text/event-stream
591
+ Cache-Control: no-cache
592
+ Connection: keep-alive
593
+ X-Accel-Buffering: no
594
+ ```
595
+
596
+ **Keepalive:** `: ping\n\n` every 30 seconds
597
+
598
+ ### Event Types
599
+
600
+ **`connected`**
601
+ ```json
602
+ { "type": "connected", "agentId": "uuid" }
603
+ ```
604
+
605
+ **`new`** — New email received
606
+ ```json
607
+ {
608
+ "type": "new",
609
+ "uid": 123,
610
+ "subject": "string",
611
+ "from": [{ "name": "string", "address": "string" }],
612
+ "date": "ISO-8601",
613
+ "spam": { // Only for external emails flagged as spam
614
+ "score": 75,
615
+ "isSpam": true,
616
+ "topCategory": "phishing",
617
+ "matches": ["ruleId"],
618
+ "movedToSpam": true
619
+ },
620
+ "warning": { // Only for elevated-score external emails
621
+ "score": 40,
622
+ "isWarning": true,
623
+ "topCategory": "link_analysis",
624
+ "matches": ["ruleId"]
625
+ },
626
+ "rule": { // Only if an email rule matched
627
+ "ruleId": "uuid",
628
+ "ruleName": "Auto-archive newsletters",
629
+ "actions": { "move_to": "Archive" }
630
+ }
631
+ }
632
+ ```
633
+
634
+ **Processing order for new emails:**
635
+ 1. Relay detection (check `X-AgenticMail-Relay` header)
636
+ 2. Internal check (skip spam filter for agent-to-agent)
637
+ 3. Spam scoring → auto-move to Spam if threshold exceeded
638
+ 4. Rule evaluation → first matching rule's actions execute
639
+ 5. Event pushed to all agent's SSE connections
640
+
641
+ **`expunge`** — Message deleted
642
+ ```json
643
+ { "type": "expunge", "uid": 123 }
644
+ ```
645
+
646
+ **`flags`** — Flags changed
647
+ ```json
648
+ { "type": "flags", "uid": 123, "flags": ["\\Seen"] }
649
+ ```
650
+
651
+ **`error`** — IMAP connection error
652
+ ```json
653
+ { "type": "error", "message": "Connection lost" }
654
+ ```
655
+
656
+ **`task`** — Task assigned (pushed via `pushEventToAgent()`)
657
+ ```json
658
+ { "type": "task", "taskId": "uuid", "taskType": "string", "from": "agentName" }
659
+ ```
660
+
661
+ ### Helper Functions
662
+
663
+ | Function | Purpose |
664
+ |----------|---------|
665
+ | `pushEventToAgent(agentId, event)` | Push event to specific agent's SSE connections |
666
+ | `broadcastEvent(event)` | Push event to ALL active SSE connections |
667
+ | `closeAllWatchers()` | Stop all watchers, clear all connections (shutdown) |
668
+
669
+ ---
670
+
671
+ ## Routes: Accounts
672
+
673
+ ### POST /accounts
674
+
675
+ **Auth:** Master
676
+
677
+ **Request Body:**
678
+ ```json
679
+ {
680
+ "name": "string", // Required, max 64 chars, /^[a-zA-Z0-9._-]+$/
681
+ "domain": "string", // Optional
682
+ "password": "string", // Optional (auto-generated if omitted)
683
+ "metadata": {}, // Optional (object, _prefixed keys stripped)
684
+ "role": "string", // Optional (must be in AGENT_ROLES)
685
+ "persistent": true // Optional (first agent auto-persistent)
686
+ }
687
+ ```
688
+
689
+ **Response (201):** Sanitized agent object (internal `_`-prefixed metadata stripped)
690
+
691
+ ### GET /accounts
692
+
693
+ **Auth:** Master
694
+
695
+ **Response:** `{ "agents": [Agent, ...] }`
696
+
697
+ ### GET /accounts/directory
698
+
699
+ **Auth:** Both
700
+
701
+ **Response:** `{ "agents": [{ "name": "...", "email": "...", "role": "..." }] }`
702
+
703
+ ### GET /accounts/directory/:name
704
+
705
+ **Auth:** Both
706
+
707
+ **Response:** `{ "name", "email", "role" }` or 404
708
+
709
+ ### GET /accounts/me
710
+
711
+ **Auth:** Agent (`requireAgent`)
712
+
713
+ **Response:** Sanitized agent object
714
+
715
+ ### PATCH /accounts/me
716
+
717
+ **Auth:** Agent
718
+
719
+ **Request:** `{ "metadata": { "ownerName": "John" } }`
720
+
721
+ **Response:** Updated agent
722
+
723
+ ### GET /accounts/:id
724
+
725
+ **Auth:** Master
726
+
727
+ **Response:** Sanitized agent or 404
728
+
729
+ ### DELETE /accounts/:id
730
+
731
+ **Auth:** Master
732
+
733
+ **Query Params:**
734
+ - `archive` (default: true) — archive emails before deletion
735
+ - `reason` — deletion reason
736
+ - `deletedBy` (default: "api")
737
+
738
+ **Validation:** Cannot delete last remaining agent (400).
739
+
740
+ **Response:** Deletion summary (if archive=true) or 204
741
+
742
+ ### PATCH /accounts/:id/persistent
743
+
744
+ **Auth:** Master
745
+
746
+ **Request:** `{ "persistent": true }`
747
+
748
+ **Response:** `{ "ok": true, "persistent": true }`
749
+
750
+ ### GET /accounts/inactive
751
+
752
+ **Auth:** Master
753
+
754
+ **Query Params:** `hours` (default: 24, min: 1)
755
+
756
+ Uses `COALESCE(last_activity_at, created_at)` so new agents aren't flagged.
757
+
758
+ **Response:** `{ "agents": [...], "count": 3 }`
759
+
760
+ ### POST /accounts/cleanup
761
+
762
+ **Auth:** Master
763
+
764
+ **Request:** `{ "hours": 24, "dryRun": false }`
765
+
766
+ **Response:** `{ "deleted": ["agent1"], "count": 1, "dryRun": false }`
767
+
768
+ ### GET /accounts/deletions
769
+
770
+ **Auth:** Master
771
+
772
+ **Response:** `{ "deletions": [DeletionReport, ...] }`
773
+
774
+ ### GET /accounts/deletions/:id
775
+
776
+ **Auth:** Master
777
+
778
+ **Response:** Full deletion report or 404
779
+
780
+ ---
781
+
782
+ ## Routes: Tasks
783
+
784
+ ### POST /tasks/assign
785
+
786
+ **Auth:** Both
787
+
788
+ **Request Body:**
789
+ ```json
790
+ {
791
+ "assignee": "string", // Required (agent name)
792
+ "taskType": "string", // Optional (default: "generic")
793
+ "payload": {}, // Optional
794
+ "expiresInSeconds": 3600 // Optional
795
+ }
796
+ ```
797
+
798
+ **Notification cascade:**
799
+ 1. SSE push to assignee via `pushEventToAgent()`
800
+ 2. Broadcast to all SSE if no direct watcher
801
+ 3. Email notification (fire-and-forget)
802
+
803
+ **Response (201):**
804
+ ```json
805
+ { "id": "uuid", "assignee": "name", "assigneeId": "uuid", "status": "pending" }
806
+ ```
807
+
808
+ ### GET /tasks/pending
809
+
810
+ **Auth:** Agent
811
+
812
+ **Query Params:** `assignee` (optional — check different agent's tasks)
813
+
814
+ **Response:** `{ "tasks": [...], "count": 5 }`
815
+
816
+ ### GET /tasks/assigned
817
+
818
+ **Auth:** Both
819
+
820
+ **Response:** Tasks assigned BY current user (limit 50)
821
+
822
+ ### GET /tasks/:id
823
+
824
+ **Auth:** Both
825
+
826
+ **Response:** Full task object with parsed payload/result
827
+
828
+ ### POST /tasks/:id/claim
829
+
830
+ **Auth:** Agent
831
+
832
+ Updates: `pending → claimed`, sets `claimed_at`.
833
+
834
+ Capability-based: any agent with task ID can claim (supports sub-agents).
835
+
836
+ **Response:** Claimed task object or 404
837
+
838
+ ### POST /tasks/:id/result
839
+
840
+ **Auth:** Agent
841
+
842
+ **Request:** `{ "result": {} }`
843
+
844
+ Updates: `claimed → completed`, stores result, sets `completed_at`.
845
+
846
+ **Instant RPC wake:** If assigner is long-polling via `/tasks/rpc`, resolver is called immediately.
847
+
848
+ **Response:** `{ "ok": true, "taskId": "...", "status": "completed" }`
849
+
850
+ ### POST /tasks/:id/fail
851
+
852
+ **Auth:** Agent
853
+
854
+ **Request:** `{ "error": "reason" }`
855
+
856
+ Updates: `claimed → failed`, stores error, sets `completed_at`.
857
+
858
+ **Response:** `{ "ok": true, "taskId": "...", "status": "failed" }`
859
+
860
+ ### POST /tasks/rpc
861
+
862
+ **Auth:** Both
863
+
864
+ **Request Body:**
865
+ ```json
866
+ {
867
+ "target": "string", // Required (agent name)
868
+ "task": "string", // Required (task description)
869
+ "payload": {}, // Optional
870
+ "timeout": 180 // Optional (5–300 seconds, default 180)
871
+ }
872
+ ```
873
+
874
+ **Mechanism:**
875
+ 1. Creates task with `task_type = 'rpc'`
876
+ 2. Disables socket timeout: `req.socket.setTimeout(0)`
877
+ 3. Pushes SSE event + email notification
878
+ 4. Registers resolver in `rpcResolvers: Map<string, Function>`
879
+ 5. Polls every 2 seconds as fallback
880
+ 6. When `/result` or `/fail` called → resolver fires instantly
881
+
882
+ **Response:**
883
+ ```json
884
+ {
885
+ "taskId": "uuid",
886
+ "status": "completed | failed | timeout | disconnected",
887
+ "result": {}, // If completed
888
+ "error": "string", // If failed
889
+ "message": "string" // If timeout
890
+ }
891
+ ```
892
+
893
+ ---
894
+
895
+ ## Routes: Gateway
896
+
897
+ ### GET /gateway/setup-guide
898
+
899
+ **Auth:** Master
900
+
901
+ Returns comparison of relay mode vs domain mode with pros/cons and requirements.
902
+
903
+ ### GET /gateway/status
904
+
905
+ **Auth:** Master
906
+
907
+ Returns current gateway configuration and status.
908
+
909
+ ### POST /gateway/relay
910
+
911
+ **Auth:** Master
912
+
913
+ **Request Body:**
914
+ ```json
915
+ {
916
+ "provider": "gmail | outlook | custom", // Default: "custom"
917
+ "email": "string", // Required
918
+ "password": "string", // Required (app password)
919
+ "smtpHost": "string", // Optional (preset for gmail/outlook)
920
+ "smtpPort": 465, // Optional
921
+ "imapHost": "string", // Optional
922
+ "imapPort": 993, // Optional
923
+ "agentName": "string", // Optional (auto-create agent)
924
+ "agentRole": "string", // Optional
925
+ "skipDefaultAgent": false // Optional
926
+ }
927
+ ```
928
+
929
+ **Provider Presets:**
930
+ - Gmail: `smtp.gmail.com:465`, `imap.gmail.com:993`
931
+ - Outlook: `smtp-mail.outlook.com:587`, `outlook.office365.com:993`
932
+
933
+ **Response:**
934
+ ```json
935
+ {
936
+ "status": "ok",
937
+ "mode": "relay",
938
+ "email": "you@gmail.com",
939
+ "provider": "gmail",
940
+ "agent": {
941
+ "id": "uuid",
942
+ "name": "secretary",
943
+ "email": "secretary@local",
944
+ "apiKey": "ak_...",
945
+ "role": "admin",
946
+ "subAddress": "you+secretary@gmail.com"
947
+ }
948
+ }
949
+ ```
950
+
951
+ ### POST /gateway/domain
952
+
953
+ **Auth:** Master
954
+
955
+ **Request Body:**
956
+ ```json
957
+ {
958
+ "cloudflareToken": "string", // Required
959
+ "cloudflareAccountId": "string", // Required
960
+ "domain": "string", // Optional (use existing)
961
+ "purchase": {}, // Optional (buy new domain)
962
+ "gmailRelay": { // Optional (Gmail for outbound)
963
+ "email": "string",
964
+ "appPassword": "string"
965
+ }
966
+ }
967
+ ```
968
+
969
+ ### POST /gateway/domain/alias-setup
970
+
971
+ **Auth:** Master
972
+
973
+ **Request:** `{ "agentEmail": "secretary@yourdomain.com", "agentDisplayName": "Secretary" }`
974
+
975
+ Returns step-by-step Gmail "Send mail as" alias instructions with SMTP settings:
976
+ - Server: `smtp.gmail.com`
977
+ - Port: 465
978
+ - Security: SSL
979
+
980
+ ### GET /gateway/domain/payment-setup
981
+
982
+ **Auth:** Master
983
+
984
+ Returns Cloudflare payment method setup instructions.
985
+
986
+ ### POST /gateway/domain/purchase
987
+
988
+ **Auth:** Master
989
+
990
+ **Request:** `{ "keywords": ["mycompany"], "tld": "com" }`
991
+
992
+ **Response:** `{ "domains": [{ "domain": "...", "price": "..." }] }`
993
+
994
+ ### GET /gateway/domain/dns
995
+
996
+ **Auth:** Master
997
+
998
+ **Response:** `{ "domain": "...", "dns": [...] }`
999
+
1000
+ ### POST /gateway/tunnel/start
1001
+
1002
+ **Auth:** Master
1003
+
1004
+ **Response:** `{ "status": "ok", "tunnel": {...} }`
1005
+
1006
+ ### POST /gateway/tunnel/stop
1007
+
1008
+ **Auth:** Master
1009
+
1010
+ **Response:** `{ "status": "ok", "tunnel": {...} }`
1011
+
1012
+ ### POST /gateway/test
1013
+
1014
+ **Auth:** Master
1015
+
1016
+ **Request:** `{ "to": "test@example.com" }`
1017
+
1018
+ **Response:** `{ "status": "ok", "messageId": "..." }` or 400 if no gateway
1019
+
1020
+ ---
1021
+
1022
+ ## Routes: Domains
1023
+
1024
+ ### POST /domains
1025
+
1026
+ **Auth:** Master
1027
+
1028
+ **Request:** `{ "domain": "yourdomain.com" }`
1029
+
1030
+ **Response (201):** Setup result from `DomainManager`
1031
+
1032
+ ### GET /domains
1033
+
1034
+ **Auth:** Master
1035
+
1036
+ **Response:** `{ "domains": [...] }`
1037
+
1038
+ ### GET /domains/:domain/dns
1039
+
1040
+ **Auth:** Master
1041
+
1042
+ **Response:** `{ "records": [...] }`
1043
+
1044
+ ### POST /domains/:domain/verify
1045
+
1046
+ **Auth:** Master
1047
+
1048
+ **Response:** `{ "domain": "...", "verified": true }`
1049
+
1050
+ ### DELETE /domains/:domain
1051
+
1052
+ **Auth:** Master
1053
+
1054
+ **Response:** 204 or 404
1055
+
1056
+ ---
1057
+
1058
+ ## Routes: Inbound Webhook
1059
+
1060
+ ### POST /mail/inbound
1061
+
1062
+ **Auth:** `X-Inbound-Secret` header (NOT Bearer token)
1063
+
1064
+ **Default secret:** `inbound_2sabi_secret_key` (configurable via `AGENTICMAIL_INBOUND_SECRET`)
1065
+
1066
+ **Request Body:**
1067
+ ```json
1068
+ {
1069
+ "from": "sender@external.com",
1070
+ "to": "agentname@yourdomain.com",
1071
+ "subject": "string",
1072
+ "rawEmail": "base64-encoded-mime"
1073
+ }
1074
+ ```
1075
+
1076
+ **Flow:**
1077
+ 1. Validate secret header
1078
+ 2. Extract local part from `to` address → agent lookup
1079
+ 3. Deduplication check: `gatewayManager.isAlreadyDelivered(messageId, agentName)`
1080
+ 4. Decode base64 → parse with `parseEmail()`
1081
+ 5. Deliver to agent's mailbox via SMTP (authenticated as agent)
1082
+ 6. Custom headers: `X-AgenticMail-Inbound: cloudflare-worker`, `X-Original-From`, `X-Original-Message-Id`
1083
+ 7. Preserve threading: `inReplyTo`, `references`
1084
+ 8. Record delivery: `gatewayManager.recordDelivery(messageId, agentName)`
1085
+
1086
+ **Response:** `{ "ok": true, "delivered": "agent@domain.com" }`
1087
+
1088
+ **Duplicate Response:** `{ "ok": true, "delivered": "agent@domain.com", "duplicate": true }`
1089
+
1090
+ ---
1091
+
1092
+ ## Routes: Features
1093
+
1094
+ ### Contacts
1095
+
1096
+ | Method | Path | Auth | Description |
1097
+ |--------|------|------|-------------|
1098
+ | `GET` | `/contacts` | Agent | List contacts (`ORDER BY name, email`) |
1099
+ | `POST` | `/contacts` | Agent | Create/update contact. Body: `{ name, email, notes }` |
1100
+ | `DELETE` | `/contacts/:id` | Agent | Delete contact |
1101
+
1102
+ SQL uses `INSERT OR REPLACE` — adding a contact with existing email updates it.
1103
+
1104
+ ### Drafts
1105
+
1106
+ | Method | Path | Auth | Description |
1107
+ |--------|------|------|-------------|
1108
+ | `GET` | `/drafts` | Agent | List drafts (`ORDER BY updated_at DESC`) |
1109
+ | `POST` | `/drafts` | Agent | Create draft. Body: `{ to, subject, text, html, cc, bcc, inReplyTo, references }` |
1110
+ | `PUT` | `/drafts/:id` | Agent | Update draft (all fields) |
1111
+ | `DELETE` | `/drafts/:id` | Agent | Delete draft |
1112
+ | `POST` | `/drafts/:id/send` | Agent | Send draft and delete it |
1113
+
1114
+ ### Signatures
1115
+
1116
+ | Method | Path | Auth | Description |
1117
+ |--------|------|------|-------------|
1118
+ | `GET` | `/signatures` | Agent | List signatures (`ORDER BY is_default DESC, name`) |
1119
+ | `POST` | `/signatures` | Agent | Create. Body: `{ name, text, html, isDefault }` |
1120
+ | `DELETE` | `/signatures/:id` | Agent | Delete |
1121
+
1122
+ Setting `isDefault: true` automatically unsets all other signatures.
1123
+
1124
+ ### Templates
1125
+
1126
+ | Method | Path | Auth | Description |
1127
+ |--------|------|------|-------------|
1128
+ | `GET` | `/templates` | Agent | List templates (`ORDER BY name`) |
1129
+ | `POST` | `/templates` | Agent | Create. Body: `{ name, subject, text, html }` |
1130
+ | `DELETE` | `/templates/:id` | Agent | Delete |
1131
+ | `POST` | `/templates/:id/send` | Agent | Send from template |
1132
+
1133
+ **Template Send:**
1134
+ ```json
1135
+ {
1136
+ "to": "string", // Required
1137
+ "variables": { // Optional
1138
+ "name": "John",
1139
+ "company": "Acme"
1140
+ },
1141
+ "cc": "string",
1142
+ "bcc": "string"
1143
+ }
1144
+ ```
1145
+ Replaces `{{ variableName }}` in subject and body with provided values.
1146
+
1147
+ ### Scheduled Emails
1148
+
1149
+ | Method | Path | Auth | Description |
1150
+ |--------|------|------|-------------|
1151
+ | `GET` | `/scheduled` | Agent | List scheduled (`ORDER BY send_at ASC`) |
1152
+ | `POST` | `/scheduled` | Agent | Schedule email |
1153
+ | `DELETE` | `/scheduled/:id` | Agent | Cancel (pending only) |
1154
+
1155
+ **Schedule Request:**
1156
+ ```json
1157
+ {
1158
+ "to": "string", // Required
1159
+ "subject": "string", // Required
1160
+ "text": "string",
1161
+ "html": "string",
1162
+ "cc": "string",
1163
+ "bcc": "string",
1164
+ "sendAt": "string" // Required (see format list below)
1165
+ }
1166
+ ```
1167
+
1168
+ **Supported `sendAt` formats:**
1169
+ | Format | Example |
1170
+ |--------|---------|
1171
+ | ISO 8601 | `2026-02-14T10:00:00Z` |
1172
+ | Relative | `in 30 minutes`, `in 2 hours` |
1173
+ | Named | `tomorrow 8am`, `tomorrow 2pm` |
1174
+ | Day of week | `next monday 9am`, `next friday 2pm` |
1175
+ | MM-DD-YYYY | `02-14-2026 3:30 PM EST` |
1176
+ | Casual | `tonight`, `this evening` (20:00) |
1177
+
1178
+ **Timezone abbreviations supported:** EST, EDT, CST, CDT, MST, MDT, PST, PDT, GMT, UTC, BST, CET, CEST, IST, JST, AEST, AEDT, NZST, NZDT, WAT, EAT, SAST, HKT, SGT, KST, HST, AKST, AKDT, AST, ADT, NST, NDT
1179
+
1180
+ ### Tags
1181
+
1182
+ | Method | Path | Auth | Description |
1183
+ |--------|------|------|-------------|
1184
+ | `GET` | `/tags` | Agent | List tags (`ORDER BY name`) |
1185
+ | `POST` | `/tags` | Agent | Create. Body: `{ name, color }` (default color: `#888888`) |
1186
+ | `DELETE` | `/tags/:id` | Agent | Delete tag |
1187
+ | `POST` | `/tags/:id/messages` | Agent | Tag a message. Body: `{ uid, folder }` |
1188
+ | `DELETE` | `/tags/:id/messages/:uid` | Agent | Untag. Query: `?folder=INBOX` |
1189
+ | `GET` | `/tags/:id/messages` | Agent | List messages with tag |
1190
+ | `GET` | `/messages/:uid/tags` | Agent | List tags on message |
1191
+
1192
+ ### Email Rules
1193
+
1194
+ | Method | Path | Auth | Description |
1195
+ |--------|------|------|-------------|
1196
+ | `GET` | `/rules` | Agent | List rules (`ORDER BY priority DESC, created_at`) |
1197
+ | `POST` | `/rules` | Agent | Create rule |
1198
+ | `DELETE` | `/rules/:id` | Agent | Delete rule |
1199
+
1200
+ **Rule Request (201):**
1201
+ ```json
1202
+ {
1203
+ "name": "Auto-archive newsletters",
1204
+ "conditions": {
1205
+ "from_contains": "newsletter",
1206
+ "from_exact": "news@example.com",
1207
+ "subject_contains": "weekly update",
1208
+ "subject_regex": "\\[Newsletter\\]",
1209
+ "to_contains": "list",
1210
+ "has_attachment": true
1211
+ },
1212
+ "actions": {
1213
+ "mark_read": true,
1214
+ "delete": false,
1215
+ "move_to": "Archive"
1216
+ },
1217
+ "priority": 10,
1218
+ "enabled": true
1219
+ }
1220
+ ```
1221
+
1222
+ **Rule evaluation:** Runs on every new email (via SSE event handler). Rules checked by priority (highest first). First match wins. Conditions are case-insensitive. `subject_regex` uses JavaScript RegExp.
1223
+
1224
+ ---
1225
+
1226
+ ## Background Services
1227
+
1228
+ ### Scheduled Email Sender
1229
+
1230
+ **Interval:** 30,000ms (30 seconds)
1231
+
1232
+ **Per cycle:**
1233
+ 1. Query: `SELECT * FROM scheduled_emails WHERE status = 'pending' AND send_at <= datetime('now')`
1234
+ 2. For each: look up agent, build mail options, send via gateway or SMTP
1235
+ 3. On success: `status = 'sent'`, `sent_at = datetime('now')`
1236
+ 4. On failure: `status = 'failed'`, `error = message`
1237
+
1238
+ **Housekeeping (runs each cycle):**
1239
+ - `DELETE FROM delivered_messages WHERE delivered_at < datetime('now', '-30 days')`
1240
+ - `DELETE FROM spam_log WHERE created_at < datetime('now', '-30 days')`
1241
+
1242
+ ### Gateway Resume
1243
+
1244
+ On startup, calls `gatewayManager.resume()` to restore relay polling or domain tunnel from saved configuration.
1245
+
1246
+ ---
1247
+
1248
+ ## Shutdown Sequence
1249
+
1250
+ 1. Set `shuttingDown = true`
1251
+ 2. Clear scheduled sender interval
1252
+ 3. `closeAllWatchers()` — stop all SSE watchers and IMAP connections
1253
+ 4. `closeCaches()` — set `draining = true`, close all cached SMTP/IMAP connections
1254
+ 5. `gatewayManager.shutdown()` — stop relay polling, tunnel
1255
+ 6. `server.close()` — stop accepting connections
1256
+ 7. `setTimeout(() => process.exit(0), 5000)` — force exit safety net
1257
+
1258
+ Signals: SIGTERM and SIGINT both trigger shutdown.
1259
+
1260
+ ---
1261
+
1262
+ ## Constants Summary
1263
+
1264
+ | Constant | Value | Location |
1265
+ |----------|-------|----------|
1266
+ | Request body limit | 10 MB | `app.ts` |
1267
+ | Rate limit window | 60,000ms | `app.ts` |
1268
+ | Rate limit max | 100 requests/window | `app.ts` |
1269
+ | Activity throttle | 60,000ms | `auth.ts` |
1270
+ | Cache TTL | 600,000ms (10 min) | `mail.ts` |
1271
+ | Max cache entries | 100 | `mail.ts` |
1272
+ | Cache eviction interval | 60,000ms | `mail.ts` |
1273
+ | Max SSE per agent | 5 | `events.ts` |
1274
+ | SSE ping interval | 30,000ms | `events.ts` |
1275
+ | Scheduled sender interval | 30,000ms | `features.ts` |
1276
+ | Data retention (spam_log) | 30 days | `features.ts` |
1277
+ | Data retention (delivered_messages) | 30 days | `features.ts` |
1278
+ | Max batch UIDs | 1,000 | `mail.ts` |
1279
+ | Max folder name length | 200 chars | `mail.ts` |
1280
+ | RPC timeout min | 5,000ms | `tasks.ts` |
1281
+ | RPC timeout max | 300,000ms (5 min) | `tasks.ts` |
1282
+ | RPC timeout default | 180,000ms (3 min) | `tasks.ts` |
1283
+ | RPC poll interval | 2,000ms | `tasks.ts` |
1284
+ | Shutdown force exit | 5,000ms | `index.ts` |
1285
+ | Agent name max length | 64 chars | `accounts.ts` |
1286
+ | Default tag color | `#888888` | `features.ts` |
1287
+ | Digest preview default | 200 chars | `mail.ts` |
1288
+ | Digest preview range | 50–500 chars | `mail.ts` |
1289
+ | Inbox limit range | 1–200 | `mail.ts` |
1290
+ | Digest limit range | 1–50 | `mail.ts` |
1291
+ | Default inbound secret | `inbound_2sabi_secret_key` | `inbound.ts` |
1292
+
1293
+ ---
1294
+
1295
+ ## Environment Variables
1296
+
1297
+ | Variable | Required | Default | Description |
1298
+ |----------|----------|---------|-------------|
1299
+ | `AGENTICMAIL_MASTER_KEY` | Yes | — | Master API key |
1300
+ | `AGENTICMAIL_API_PORT` | No | `3100` | Server port |
1301
+ | `STALWART_URL` | No | `http://localhost:8080` | Stalwart admin URL |
1302
+ | `STALWART_ADMIN_USER` | No | `admin` | Stalwart admin user |
1303
+ | `STALWART_ADMIN_PASSWORD` | No | `changeme` | Stalwart admin password |
1304
+ | `SMTP_HOST` | No | `localhost` | SMTP host |
1305
+ | `SMTP_PORT` | No | `587` | SMTP port |
1306
+ | `IMAP_HOST` | No | `localhost` | IMAP host |
1307
+ | `IMAP_PORT` | No | `143` | IMAP port |
1308
+ | `AGENTICMAIL_INBOUND_SECRET` | No | `inbound_2sabi_secret_key` | Inbound webhook secret |
1309
+
1310
+ ---
1311
+
1312
+ ## License
1313
+
1314
+ [MIT](./LICENSE) - Ope Olatunji ([@ope-olatunji](https://github.com/ope-olatunji))