@agenticmail/core 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,1219 @@
1
+ # @agenticmail/core — Technical Reference
2
+
3
+ Complete API reference for developers and AI agents. Every exported class, function, type, constant, method signature, configuration option, database table, and detection rule.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Exports Overview](#exports-overview)
10
+ - [Configuration](#configuration)
11
+ - [Account Management](#account-management)
12
+ - [Mail Operations](#mail-operations)
13
+ - [Email Parsing](#email-parsing)
14
+ - [Inbox Watching](#inbox-watching)
15
+ - [Spam Filter](#spam-filter)
16
+ - [Outbound Guard](#outbound-guard)
17
+ - [Email Sanitizer](#email-sanitizer)
18
+ - [Gateway Manager](#gateway-manager)
19
+ - [Relay Gateway](#relay-gateway)
20
+ - [Cloudflare Client](#cloudflare-client)
21
+ - [Tunnel Manager](#tunnel-manager)
22
+ - [DNS Configurator](#dns-configurator)
23
+ - [Domain Purchaser](#domain-purchaser)
24
+ - [Relay Bridge](#relay-bridge)
25
+ - [Stalwart Admin](#stalwart-admin)
26
+ - [Domain Manager](#domain-manager)
27
+ - [Storage](#storage)
28
+ - [Search Index](#search-index)
29
+ - [Setup](#setup)
30
+ - [Database Schema](#database-schema)
31
+ - [Constants](#constants)
32
+
33
+ ---
34
+
35
+ ## Exports Overview
36
+
37
+ 87 items exported from the barrel (`src/index.ts`):
38
+
39
+ ### Classes (17)
40
+ `AgenticMailClient`, `AccountManager`, `AgentDeletionService`, `MailSender`, `MailReceiver`, `InboxWatcher`, `GatewayManager`, `RelayGateway`, `CloudflareClient`, `TunnelManager`, `DNSConfigurator`, `DomainPurchaser`, `RelayBridge`, `StalwartAdmin`, `DomainManager`, `EmailSearchIndex`, `SetupManager`, `DependencyChecker`, `DependencyInstaller`
41
+
42
+ ### Functions (9)
43
+ `resolveConfig`, `ensureDataDir`, `saveConfig`, `parseEmail`, `scoreEmail`, `isInternalEmail`, `scanOutboundEmail`, `buildInboundSecurityAdvisory`, `sanitizeEmail`, `getDatabase`, `closeDatabase`, `createTestDatabase`, `startRelayBridge`
44
+
45
+ ### Types & Interfaces (55+)
46
+ `AgenticMailConfig`, `AgenticMailClientOptions`, `Agent`, `CreateAgentOptions`, `AgentRole`, `DeletionReport`, `DeletionSummary`, `ArchivedEmail`, `ArchiveAndDeleteOptions`, `SendMailOptions`, `SendResult`, `SendResultWithRaw`, `Attachment`, `EmailEnvelope`, `AddressInfo`, `ParsedEmail`, `ParsedAttachment`, `MailboxInfo`, `SearchCriteria`, `MailSenderOptions`, `MailReceiverOptions`, `FolderInfo`, `InboxWatcherOptions`, `InboxEvent`, `InboxNewEvent`, `InboxExpungeEvent`, `InboxFlagsEvent`, `WatcherOptions`, `SpamResult`, `SpamRuleMatch`, `SpamCategory`, `SanitizeResult`, `SanitizeDetection`, `OutboundScanResult`, `OutboundScanInput`, `OutboundWarning`, `OutboundCategory`, `Severity`, `SecurityAdvisory`, `AttachmentAdvisory`, `LinkAdvisory`, `GatewayMode`, `GatewayConfig`, `GatewayStatus`, `GatewayManagerOptions`, `LocalSmtpConfig`, `RelayConfig`, `RelayProvider`, `DomainModeConfig`, `PurchasedDomain`, `InboundEmail`, `DomainSearchResult`, `DomainPurchaseResult`, `DnsSetupResult`, `TunnelConfig`, `RelayBridgeOptions`, `StalwartAdminOptions`, `StalwartPrincipal`, `DomainInfo`, `DnsRecord`, `DomainSetupResult`, `SearchableEmail`, `DependencyStatus`, `InstallProgress`, `SetupConfig`, `SetupResult`
47
+
48
+ ### Constants (5)
49
+ `AGENT_ROLES`, `DEFAULT_AGENT_ROLE`, `DEFAULT_AGENT_NAME`, `SPAM_THRESHOLD`, `WARNING_THRESHOLD`, `RELAY_PRESETS`
50
+
51
+ ---
52
+
53
+ ## Configuration
54
+
55
+ ### `resolveConfig(overrides?: Partial<AgenticMailConfig>): AgenticMailConfig`
56
+ Loads configuration from environment variables, config file, and programmatic overrides (in that priority order).
57
+
58
+ ### `ensureDataDir(config: AgenticMailConfig): void`
59
+ Creates the data directory (`~/.agenticmail/` by default) if it doesn't exist.
60
+
61
+ ### `saveConfig(config: AgenticMailConfig): void`
62
+ Saves configuration to `{dataDir}/config.json` with file mode 0600.
63
+
64
+ ### `AgenticMailConfig`
65
+ ```typescript
66
+ interface AgenticMailConfig {
67
+ masterKey: string;
68
+ stalwart: { url: string; adminUser: string; adminPassword: string };
69
+ smtp: { host: string; port: number };
70
+ imap: { host: string; port: number };
71
+ api: { port: number; host: string };
72
+ gateway?: { mode: GatewayMode; relay?: RelayConfig; domain?: DomainModeConfig };
73
+ dataDir: string;
74
+ }
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Account Management
80
+
81
+ ### `AccountManager`
82
+ ```typescript
83
+ class AccountManager {
84
+ constructor(db: Database.Database, stalwart: StalwartAdmin)
85
+
86
+ create(options: CreateAgentOptions): Promise<Agent>
87
+ getById(id: string): Promise<Agent | null>
88
+ getByApiKey(apiKey: string): Promise<Agent | null>
89
+ getByName(name: string): Promise<Agent | null>
90
+ getByRole(role: AgentRole): Promise<Agent[]>
91
+ list(): Promise<Agent[]>
92
+ delete(id: string): Promise<boolean>
93
+ updateMetadata(id: string, metadata: Record<string, unknown>): Promise<Agent | null>
94
+ getCredentials(id: string): Promise<{ email; password; principal; smtpHost; smtpPort; imapHost; imapPort } | null>
95
+ }
96
+ ```
97
+
98
+ **`create()`** — Validates name against `/^[a-zA-Z0-9._-]+$/`, generates UUID + API key (`ak_` + 48 hex) + password (32 hex), creates Stalwart principal, inserts DB record. Rolls back Stalwart on DB failure.
99
+
100
+ **`updateMetadata()`** — Merge semantics. Preserves internal `_`-prefixed fields. Users cannot overwrite `_password` or `_gateway`.
101
+
102
+ **`getCredentials()`** — Returns hardcoded localhost SMTP(587)/IMAP(143) with password from `metadata._password`.
103
+
104
+ ### Types
105
+
106
+ ```typescript
107
+ interface Agent {
108
+ id: string; // UUID
109
+ name: string; // email-safe unique name
110
+ email: string; // principal@domain
111
+ apiKey: string; // ak_... (48 hex chars)
112
+ stalwartPrincipal: string; // lowercase principal name
113
+ createdAt: string; // ISO timestamp
114
+ updatedAt: string; // ISO timestamp
115
+ metadata: Record<string, unknown>; // flexible JSON (internal fields: _password, _gateway)
116
+ role: AgentRole;
117
+ }
118
+
119
+ interface CreateAgentOptions {
120
+ name: string; // required, must match /^[a-zA-Z0-9._-]+$/
121
+ domain?: string; // default: 'localhost'
122
+ password?: string; // auto-generated if omitted
123
+ metadata?: Record<string, unknown>;
124
+ gateway?: 'relay' | 'domain'; // stored in metadata._gateway
125
+ role?: AgentRole; // default: 'secretary'
126
+ }
127
+
128
+ type AgentRole = 'secretary' | 'assistant' | 'researcher' | 'writer' | 'custom';
129
+ ```
130
+
131
+ ### `AgentDeletionService`
132
+ ```typescript
133
+ class AgentDeletionService {
134
+ constructor(db: Database.Database, accountManager: AccountManager, config: AgenticMailConfig)
135
+
136
+ archiveAndDelete(agentId: string, options?: ArchiveAndDeleteOptions): Promise<DeletionReport>
137
+ getReport(deletionId: string): DeletionReport | null
138
+ listReports(): DeletionSummary[]
139
+ }
140
+ ```
141
+
142
+ **`archiveAndDelete()`** — Prevents deleting last agent. Connects to IMAP, archives all emails (inbox, sent, custom folders, up to 10,000 per folder), builds summary with top 10 correspondents, saves JSON to `~/.agenticmail/deletions/{name}_{timestamp}.json`, inserts DB record, then deletes agent.
143
+
144
+ ```typescript
145
+ interface DeletionReport {
146
+ id: string; // del_{uuid}
147
+ agent: { id; name; email; role; createdAt };
148
+ deletedAt: string;
149
+ deletedBy: string;
150
+ reason?: string;
151
+ emails: {
152
+ inbox: ArchivedEmail[];
153
+ sent: ArchivedEmail[];
154
+ other: Record<string, ArchivedEmail[]>;
155
+ };
156
+ summary: {
157
+ totalEmails: number;
158
+ inboxCount: number;
159
+ sentCount: number;
160
+ otherCount: number;
161
+ folders: string[];
162
+ firstEmailDate?: string;
163
+ lastEmailDate?: string;
164
+ topCorrespondents: Array<{ address: string; count: number }>;
165
+ };
166
+ }
167
+ ```
168
+
169
+ ---
170
+
171
+ ## Mail Operations
172
+
173
+ ### `MailSender`
174
+ ```typescript
175
+ class MailSender {
176
+ constructor(options: MailSenderOptions)
177
+
178
+ send(mail: SendMailOptions): Promise<SendResultWithRaw>
179
+ verify(): Promise<boolean>
180
+ close(): void
181
+ }
182
+ ```
183
+
184
+ **Timeouts:** connectionTimeout=10s, greetingTimeout=10s, socketTimeout=15s. TLS: rejectUnauthorized=false.
185
+
186
+ **`send()`** — Builds RFC822 via MailComposer, returns messageId + envelope + raw Buffer. Supports `fromName` for display name in From header.
187
+
188
+ ```typescript
189
+ interface MailSenderOptions {
190
+ host: string;
191
+ port: number;
192
+ email: string; // From address
193
+ password: string;
194
+ authUser?: string; // defaults to email
195
+ secure?: boolean; // default: false
196
+ }
197
+
198
+ interface SendMailOptions {
199
+ to: string | string[];
200
+ subject: string;
201
+ text?: string;
202
+ html?: string;
203
+ cc?: string | string[];
204
+ bcc?: string | string[];
205
+ replyTo?: string;
206
+ inReplyTo?: string; // Message-ID for threading
207
+ references?: string[]; // ancestor Message-IDs
208
+ attachments?: Attachment[];
209
+ headers?: Record<string, string>;
210
+ fromName?: string; // display name in From header
211
+ }
212
+
213
+ interface Attachment {
214
+ filename: string;
215
+ content: Buffer | string;
216
+ contentType?: string;
217
+ encoding?: string; // e.g., 'base64'
218
+ }
219
+
220
+ interface SendResultWithRaw extends SendResult {
221
+ raw: Buffer; // RFC822 bytes for Sent folder
222
+ }
223
+ ```
224
+
225
+ ### `MailReceiver`
226
+ ```typescript
227
+ class MailReceiver {
228
+ constructor(options: MailReceiverOptions)
229
+
230
+ connect(): Promise<void>
231
+ disconnect(): Promise<void>
232
+ get usable(): boolean
233
+
234
+ // Listing
235
+ listEnvelopes(mailbox?: string, options?: { limit?: number; offset?: number }): Promise<EmailEnvelope[]>
236
+ getMailboxInfo(mailbox?: string): Promise<MailboxInfo>
237
+
238
+ // Reading
239
+ fetchMessage(uid: number, mailbox?: string): Promise<Buffer>
240
+ batchFetch(uids: number[], mailbox?: string): Promise<Map<number, Buffer>>
241
+
242
+ // Searching
243
+ search(criteria: SearchCriteria, mailbox?: string): Promise<number[]>
244
+
245
+ // Flags
246
+ markSeen(uid: number, mailbox?: string): Promise<void>
247
+ markUnseen(uid: number, mailbox?: string): Promise<void>
248
+ batchMarkSeen(uids: number[], mailbox?: string): Promise<void>
249
+ batchMarkUnseen(uids: number[], mailbox?: string): Promise<void>
250
+
251
+ // Delete & Move
252
+ deleteMessage(uid: number, mailbox?: string): Promise<void>
253
+ batchDelete(uids: number[], mailbox?: string): Promise<void>
254
+ moveMessage(uid: number, fromMailbox: string, toMailbox: string): Promise<void>
255
+ batchMove(uids: number[], fromMailbox: string, toMailbox: string): Promise<void>
256
+
257
+ // Folders
258
+ listFolders(): Promise<FolderInfo[]>
259
+ createFolder(path: string): Promise<void>
260
+
261
+ // Advanced
262
+ appendMessage(raw: Buffer, mailbox: string, flags?: string[]): Promise<void>
263
+ getImapClient(): ImapFlow
264
+ }
265
+ ```
266
+
267
+ **`listEnvelopes()`** — Pagination: default limit=20 (max 1000), offset=0. Returns newest first.
268
+
269
+ **`appendMessage()`** — Default flags: `['\\Seen']`. Attaches current Date.
270
+
271
+ ```typescript
272
+ interface EmailEnvelope {
273
+ uid: number;
274
+ seq: number;
275
+ messageId: string;
276
+ subject: string;
277
+ from: AddressInfo[];
278
+ to: AddressInfo[];
279
+ date: Date;
280
+ flags: Set<string>;
281
+ size: number;
282
+ }
283
+
284
+ interface SearchCriteria {
285
+ from?: string;
286
+ to?: string;
287
+ subject?: string;
288
+ since?: Date;
289
+ before?: Date;
290
+ seen?: boolean;
291
+ text?: string; // body text search
292
+ }
293
+
294
+ interface FolderInfo {
295
+ path: string;
296
+ name: string;
297
+ specialUse?: string; // \\Sent, \\Drafts, \\Trash, etc.
298
+ flags: string[];
299
+ }
300
+
301
+ interface MailboxInfo {
302
+ name: string;
303
+ exists: number;
304
+ recent: number;
305
+ unseen: number;
306
+ }
307
+ ```
308
+
309
+ ---
310
+
311
+ ## Email Parsing
312
+
313
+ ### `parseEmail(raw: Buffer | string): Promise<ParsedEmail>`
314
+
315
+ Uses mailparser's `simpleParser`. Special handling:
316
+ - **X-Original-From header**: If present and from address is `@localhost`, replaces with the original external sender (relay email detection).
317
+ - **References**: Normalizes single string to array.
318
+ - **Attachments**: Extracts filename (default 'unnamed'), contentType, size, content Buffer.
319
+
320
+ ```typescript
321
+ interface ParsedEmail {
322
+ messageId: string;
323
+ subject: string;
324
+ from: AddressInfo[];
325
+ to: AddressInfo[];
326
+ cc?: AddressInfo[];
327
+ replyTo?: AddressInfo[];
328
+ date: Date;
329
+ text?: string;
330
+ html?: string;
331
+ inReplyTo?: string;
332
+ references?: string[];
333
+ attachments: ParsedAttachment[];
334
+ headers: Map<string, string>;
335
+ }
336
+
337
+ interface ParsedAttachment {
338
+ filename: string;
339
+ contentType: string;
340
+ size: number;
341
+ content: Buffer;
342
+ }
343
+ ```
344
+
345
+ ---
346
+
347
+ ## Inbox Watching
348
+
349
+ ### `InboxWatcher`
350
+ ```typescript
351
+ class InboxWatcher extends EventEmitter {
352
+ constructor(options: InboxWatcherOptions)
353
+
354
+ start(): Promise<void>
355
+ stop(): Promise<void>
356
+ isWatching(): boolean
357
+
358
+ // Events
359
+ on(event: 'new', listener: (e: InboxNewEvent) => void): this
360
+ on(event: 'expunge', listener: (e: InboxExpungeEvent) => void): this
361
+ on(event: 'flags', listener: (e: InboxFlagsEvent) => void): this
362
+ on(event: 'error', listener: (err: Error) => void): this
363
+ on(event: 'close', listener: () => void): this
364
+ }
365
+ ```
366
+
367
+ **`start()`** — Creates fresh ImapFlow client, connects, acquires mailbox lock (held for IDLE). When `exists` event fires (new messages), calculates range, fetches and parses all new messages, emits 'new' for each. `expunge` and `flags` events forwarded directly.
368
+
369
+ **`stop()`** — Removes all event listeners, releases lock, logs out. Idempotent.
370
+
371
+ ```typescript
372
+ interface InboxWatcherOptions {
373
+ host: string;
374
+ port: number;
375
+ email: string;
376
+ password: string;
377
+ secure?: boolean; // default: false
378
+ }
379
+
380
+ interface InboxNewEvent {
381
+ type: 'new';
382
+ uid: number;
383
+ message?: ParsedEmail; // present if autoFetch=true (default)
384
+ }
385
+
386
+ interface InboxExpungeEvent {
387
+ type: 'expunge';
388
+ seq: number; // IMAP sequence number
389
+ }
390
+
391
+ interface InboxFlagsEvent {
392
+ type: 'flags';
393
+ uid: number;
394
+ flags: Set<string>;
395
+ }
396
+ ```
397
+
398
+ ---
399
+
400
+ ## Spam Filter
401
+
402
+ ### `scoreEmail(email: ParsedEmail): SpamResult`
403
+
404
+ Runs 47 rules across 9 categories. Each rule is try-catch wrapped. Concatenates subject + text + html for pattern testing.
405
+
406
+ ### `isInternalEmail(email: ParsedEmail, localDomains?: string[]): boolean`
407
+
408
+ Returns true if from address is `@localhost` (or in localDomains). **Exception:** If from is `@localhost` but replyTo has an external domain, returns false (relay email detection).
409
+
410
+ ```typescript
411
+ interface SpamResult {
412
+ score: number; // 0-100+
413
+ isSpam: boolean; // score >= SPAM_THRESHOLD (40)
414
+ isWarning: boolean; // score >= WARNING_THRESHOLD (20) && < SPAM_THRESHOLD
415
+ matches: SpamRuleMatch[];
416
+ topCategory: SpamCategory | null; // category with highest total score
417
+ }
418
+
419
+ interface SpamRuleMatch {
420
+ ruleId: string;
421
+ category: SpamCategory;
422
+ score: number;
423
+ description: string;
424
+ }
425
+
426
+ type SpamCategory =
427
+ | 'prompt_injection'
428
+ | 'social_engineering'
429
+ | 'data_exfiltration'
430
+ | 'phishing'
431
+ | 'header_anomaly'
432
+ | 'content_spam'
433
+ | 'link_analysis'
434
+ | 'authentication'
435
+ | 'attachment_risk';
436
+ ```
437
+
438
+ ### Complete Rule Inventory (47 rules)
439
+
440
+ | Rule ID | Category | Score | What it detects |
441
+ |---------|----------|-------|-----------------|
442
+ | pi_ignore_instructions | prompt_injection | 25 | "ignore previous/prior instructions" |
443
+ | pi_you_are_now | prompt_injection | 25 | "you are now a..." roleplay injection |
444
+ | pi_system_delimiter | prompt_injection | 20 | [SYSTEM], [INST], <<SYS>>, <\|im_start\|> |
445
+ | pi_new_instructions | prompt_injection | 20 | "new instructions:" / "override instructions:" |
446
+ | pi_act_as | prompt_injection | 15 | "act as a" / "pretend to be" |
447
+ | pi_do_not_mention | prompt_injection | 15 | "do not mention/tell/reveal that" |
448
+ | pi_invisible_unicode | prompt_injection | 20 | Tag chars (U+E0001-E007F), dense zero-width |
449
+ | pi_jailbreak | prompt_injection | 20 | "DAN", "jailbreak", "bypass safety" |
450
+ | pi_base64_injection | prompt_injection | 15 | 100+ char base64 blocks |
451
+ | pi_markdown_injection | prompt_injection | 10 | \`\`\`system, \`\`\`python exec |
452
+ | se_owner_impersonation | social_engineering | 20 | "your owner/admin asked/told/wants" |
453
+ | se_secret_request | social_engineering | 15 | "share your API key/password/secret" |
454
+ | se_impersonate_system | social_engineering | 15 | "this is a system/security automated message" |
455
+ | se_urgency_authority | social_engineering | 10 | urgency + authority/threat language combined |
456
+ | se_money_request | social_engineering | 15 | "send me $X", "wire transfer" |
457
+ | se_gift_card | social_engineering | 20 | "buy me gift cards", iTunes/Google Play |
458
+ | se_ceo_fraud | social_engineering | 15 | CEO/CFO/CTO + payment/wire/urgent |
459
+ | de_forward_all | data_exfiltration | 20 | "forward all/every emails" |
460
+ | de_search_credentials | data_exfiltration | 20 | "search inbox for password" |
461
+ | de_send_to_external | data_exfiltration | 15 | "send the/all to external@email" |
462
+ | de_dump_instructions | data_exfiltration | 15 | "reveal system prompt" / "dump instructions" |
463
+ | de_webhook_exfil | data_exfiltration | 15 | webhook/ngrok/pipedream/requestbin URLs |
464
+ | ph_spoofed_sender | phishing | 10 | brand name in From but mismatched domain |
465
+ | ph_credential_harvest | phishing | 15 | "verify your account" + links present |
466
+ | ph_suspicious_links | phishing | 10 | IP in URL, shorteners, 4+ subdomains |
467
+ | ph_data_uri | phishing | 15 | data:text/html or javascript: in hrefs |
468
+ | ph_homograph | phishing | 15 | punycode (xn--) or mixed Cyrillic+Latin domain |
469
+ | ph_mismatched_display_url | phishing | 10 | link text URL != href URL domain |
470
+ | ph_login_urgency | phishing | 10 | "click here/sign in" + urgency words |
471
+ | ph_unsubscribe_missing | phishing | 3 | 5+ links, no List-Unsubscribe header |
472
+ | auth_spf_fail | authentication | 15 | SPF fail/softfail in Authentication-Results |
473
+ | auth_dkim_fail | authentication | 15 | DKIM fail in Authentication-Results |
474
+ | auth_dmarc_fail | authentication | 20 | DMARC fail in Authentication-Results |
475
+ | auth_no_auth_results | authentication | 3 | missing Authentication-Results header |
476
+ | at_executable | attachment_risk | 25 | .exe/.bat/.cmd/.ps1/.sh/.dll/.scr/.vbs/.js/.msi/.com |
477
+ | at_double_extension | attachment_risk | 20 | .pdf.exe, .doc.bat, etc. |
478
+ | at_archive_carrier | attachment_risk | 15 | .zip/.rar/.7z/.tar.gz/.tgz |
479
+ | at_html_attachment | attachment_risk | 10 | .html/.htm/.svg |
480
+ | ha_missing_message_id | header_anomaly | 5 | no Message-ID header |
481
+ | ha_empty_from | header_anomaly | 10 | empty or missing From |
482
+ | ha_reply_to_mismatch | header_anomaly | 5 | Reply-To domain != From domain |
483
+ | cs_all_caps_subject | content_spam | 5 | subject >80% uppercase (min 10 chars) |
484
+ | cs_lottery_scam | content_spam | 25 | lottery/prize/"Nigerian prince" |
485
+ | cs_crypto_scam | content_spam | 10 | crypto investment/"guaranteed returns" |
486
+ | cs_excessive_punctuation | content_spam | 3 | 4+ !!!! or ???? in subject |
487
+ | cs_pharmacy_spam | content_spam | 15 | viagra/cialis/"online pharmacy" |
488
+ | cs_weight_loss | content_spam | 10 | "diet pill"/"lose 30 lbs" |
489
+ | cs_html_only_no_text | content_spam | 5 | HTML body but no plain text |
490
+ | cs_spam_word_density | content_spam | 10-20 | >5 spam words=10pts, >10 words=20pts |
491
+ | la_excessive_links | link_analysis | 5 | 10+ unique links |
492
+
493
+ ---
494
+
495
+ ## Outbound Guard
496
+
497
+ ### `scanOutboundEmail(input: OutboundScanInput): OutboundScanResult`
498
+
499
+ Skips all scanning if every recipient ends with `@localhost`. Strips HTML tags and decodes entities before scanning text. Scans attachment content for text-scannable types.
500
+
501
+ ### `buildInboundSecurityAdvisory(attachments, spamMatches): SecurityAdvisory`
502
+
503
+ Analyzes attachments for risk (executables, archives, double extensions, HTML files) and extracts link warnings from spam matches.
504
+
505
+ ```typescript
506
+ interface OutboundScanInput {
507
+ to: string | string[];
508
+ subject?: string;
509
+ text?: string;
510
+ html?: string;
511
+ attachments?: Array<{
512
+ filename?: string;
513
+ contentType?: string;
514
+ content?: Buffer | string;
515
+ encoding?: string;
516
+ }>;
517
+ }
518
+
519
+ interface OutboundScanResult {
520
+ warnings: OutboundWarning[];
521
+ hasHighSeverity: boolean;
522
+ hasMediumSeverity: boolean;
523
+ blocked: boolean; // true if ANY high-severity warning
524
+ summary: string;
525
+ }
526
+
527
+ interface OutboundWarning {
528
+ category: OutboundCategory;
529
+ severity: Severity;
530
+ ruleId: string;
531
+ description: string;
532
+ match: string; // up to 80 chars of matched text
533
+ }
534
+
535
+ type OutboundCategory = 'pii' | 'credential' | 'system_internal' | 'owner_privacy' | 'attachment_risk';
536
+ type Severity = 'high' | 'medium';
537
+ ```
538
+
539
+ ### Complete Rule Inventory (34+ text rules + attachment rules)
540
+
541
+ #### PII Rules
542
+
543
+ | Rule ID | Severity | Pattern |
544
+ |---------|----------|---------|
545
+ | ob_ssn | HIGH | `\b\d{3}-\d{2}-\d{4}\b` |
546
+ | ob_ssn_obfuscated | HIGH | XXX.XX.XXXX, "ssn #123456789" variants |
547
+ | ob_credit_card | HIGH | `\b(?:\d{4}[-\s]?){3}\d{4}\b` |
548
+ | ob_phone | MEDIUM | US phone (optional +1, parens, dots) |
549
+ | ob_bank_routing | HIGH | routing/account number with 6-17 digits |
550
+ | ob_drivers_license | HIGH | driver's license + alphanumeric ID |
551
+ | ob_dob | MEDIUM | DOB/born on + date formats |
552
+ | ob_passport | HIGH | passport + 6-12 char ID |
553
+ | ob_tax_id | HIGH | EIN/TIN/tax id + XX-XXXXXXX |
554
+ | ob_itin | HIGH | ITIN + 9XX-XX-XXXX |
555
+ | ob_medicare | HIGH | medicare/medicaid + 8-14 char ID |
556
+ | ob_immigration | HIGH | A-number/alien number + 8-9 digits |
557
+ | ob_pin | MEDIUM | PIN/pin code = 4-8 digits |
558
+ | ob_security_qa | MEDIUM | security Q&A / mother's maiden name |
559
+
560
+ #### Financial Rules
561
+
562
+ | Rule ID | Severity | Pattern |
563
+ |---------|----------|---------|
564
+ | ob_iban | HIGH | Country code + 2 digits + alphanumeric blocks |
565
+ | ob_swift | MEDIUM | SWIFT/BIC code (6 alpha + 2 alphanum + optional 3) |
566
+ | ob_crypto_wallet | HIGH | Bitcoin (bc1...), Legacy (1.../3...), Ethereum (0x...) |
567
+ | ob_wire_transfer | HIGH | wire transfer terms + account details |
568
+
569
+ #### Credential Rules
570
+
571
+ | Rule ID | Severity | Pattern |
572
+ |---------|----------|---------|
573
+ | ob_api_key | HIGH | sk_/pk_/rk_/api_key_ + 20+ chars, sk-proj-... |
574
+ | ob_aws_key | HIGH | `AKIA[A-Z0-9]{16}` |
575
+ | ob_password_value | HIGH | p[a@4]ss(w[o0]rd)? := value |
576
+ | ob_private_key | HIGH | `-----BEGIN (RSA\|EC\|DSA\|OPENSSH) PRIVATE KEY-----` |
577
+ | ob_bearer_token | HIGH | `Bearer [a-zA-Z0-9_\-.]{20,}` |
578
+ | ob_connection_string | HIGH | mongodb/postgres/mysql/redis/amqp:// |
579
+ | ob_github_token | HIGH | ghp_/gho_/ghu_/ghs_/ghr_/github_pat_ + 20+ chars |
580
+ | ob_stripe_key | HIGH | sk_live_/pk_live_/rk_live_/sk_test_ + 20+ chars |
581
+ | ob_jwt | HIGH | eyJ...eyJ...eyJ... (3 base64url segments) |
582
+ | ob_webhook_url | HIGH | hooks.slack.com, discord webhooks, webhook.site |
583
+ | ob_env_block | HIGH | 3+ consecutive KEY=VALUE lines |
584
+ | ob_seed_phrase | HIGH | seed phrase/recovery phrase/mnemonic + content |
585
+ | ob_2fa_codes | HIGH | 2FA backup/recovery codes (series of 4-8 char codes) |
586
+ | ob_credential_pair | HIGH | username=X password=Y pairs |
587
+ | ob_oauth_token | HIGH | access_token/refresh_token/oauth_token = value |
588
+ | ob_vpn_creds | HIGH | VPN/OpenVPN/WireGuard + password/key/secret |
589
+
590
+ #### System Internal Rules
591
+
592
+ | Rule ID | Severity | Pattern |
593
+ |---------|----------|---------|
594
+ | ob_private_ip | MEDIUM | 10.x.x.x, 192.168.x.x, 172.16-31.x.x |
595
+ | ob_file_path | MEDIUM | /Users/, /home/, /etc/, C:\Users\ paths |
596
+ | ob_env_variable | MEDIUM | KEY_URL/KEY_SECRET/KEY_TOKEN = value |
597
+
598
+ #### Owner Privacy Rules
599
+
600
+ | Rule ID | Severity | Pattern |
601
+ |---------|----------|---------|
602
+ | ob_owner_info | HIGH | "owner's name/address/phone/email/ssn" |
603
+ | ob_personal_reveal | HIGH | "the person who runs/owns me is/lives/named" |
604
+
605
+ #### Attachment Rules
606
+
607
+ | Extension Type | Severity | Extensions |
608
+ |----------------|----------|------------|
609
+ | Sensitive files | HIGH | .pem, .key, .p12, .pfx, .env, .credentials, .keystore, .jks, .p8 |
610
+ | Data files | MEDIUM | .db, .sqlite, .sqlite3, .sql, .csv, .tsv, .json, .yml, .yaml, .conf, .config, .ini |
611
+
612
+ Text-scannable extensions (content scanned through all rules): .txt, .csv, .json, .xml, .yaml, .yml, .md, .log, .env, .conf, .config, .ini, .sql, .js, .ts, .py, .sh, .html, .htm, .css, .toml
613
+
614
+ ---
615
+
616
+ ## Email Sanitizer
617
+
618
+ ### `sanitizeEmail(email: ParsedEmail): SanitizeResult`
619
+
620
+ ```typescript
621
+ interface SanitizeResult {
622
+ text: string; // cleaned plain text
623
+ html: string; // cleaned HTML
624
+ detections: SanitizeDetection[];
625
+ wasModified: boolean;
626
+ }
627
+
628
+ interface SanitizeDetection {
629
+ type: string; // detection identifier
630
+ description: string;
631
+ count: number;
632
+ }
633
+ ```
634
+
635
+ **Invisible Unicode patterns removed:**
636
+ | Pattern | Chars |
637
+ |---------|-------|
638
+ | invisible_tags | U+E0001-E007F |
639
+ | zero_width | U+200B, U+200C, U+200D, U+FEFF (when 3+ consecutive) |
640
+ | bidi_control | U+202A-202E, U+2066-2069 |
641
+ | soft_hyphen | U+00AD |
642
+ | word_joiner | U+2060 |
643
+
644
+ **Hidden HTML patterns removed:**
645
+ | Pattern | What it catches |
646
+ |---------|----------------|
647
+ | hidden_css | display:none, visibility:hidden, font-size:0, opacity:0 |
648
+ | white_on_white | same foreground/background color (#fff/#ffffff/white) |
649
+ | offscreen | position:absolute/fixed + left/top: -9999+ |
650
+ | script_tags | `<script>...</script>` |
651
+ | data_uri | src/href/action with data:text/html or javascript: |
652
+ | suspicious_comment | HTML comments containing: ignore, system, instruction, prompt, inject |
653
+ | hidden_iframe | `<iframe>` with width/height=0 or display:none |
654
+
655
+ ---
656
+
657
+ ## Gateway Manager
658
+
659
+ ### `GatewayManager`
660
+ ```typescript
661
+ class GatewayManager {
662
+ constructor(options: GatewayManagerOptions)
663
+
664
+ // Setup
665
+ setupRelay(config: RelayConfig, options?: { createDefaultAgent?: boolean }): Promise<void>
666
+ setupDomain(options: {
667
+ cloudflareToken: string;
668
+ cloudflareAccountId: string;
669
+ domain?: string;
670
+ purchase?: { keywords: string[]; tlds?: string[] };
671
+ gmailRelay?: { email: string; appPassword: string };
672
+ outboundWorkerUrl?: string;
673
+ outboundSecret?: string;
674
+ }): Promise<{ domain; zoneId; tunnelId; dkimPublicKey; dnsRecords; outboundRelay?; nextSteps }>
675
+
676
+ // Email routing
677
+ routeOutbound(agentName: string, mail: SendMailOptions): Promise<SendResult | { pendingId: string }>
678
+ sendViaStalwart(agentName: string, mail: SendMailOptions): Promise<SendResult>
679
+ sendTestEmail(to: string): Promise<SendResult>
680
+
681
+ // Relay search & import
682
+ searchRelay(criteria: SearchCriteria): Promise<RelaySearchResult[]>
683
+ importRelayMessage(relayUid: number, agentName: string): Promise<void>
684
+
685
+ // Lifecycle
686
+ resume(): Promise<void>
687
+ getStatus(): GatewayStatus
688
+ getMode(): GatewayMode
689
+ getConfig(): GatewayConfig | null
690
+ getStalwart(): StalwartAdmin
691
+
692
+ // Deduplication
693
+ isAlreadyDelivered(messageId: string, agentName: string): boolean
694
+ recordDelivery(messageId: string, agentName: string): void
695
+ }
696
+ ```
697
+
698
+ **`routeOutbound()`** — If all recipients are `@localhost`, routes locally. Otherwise routes through relay or Stalwart depending on mode.
699
+
700
+ **`sendViaStalwart()`** — Rewrites `@localhost` → `@domain` in sender address, submits to Stalwart SMTP (port 587).
701
+
702
+ **Inbound delivery (internal `deliverInboundLocally()`):**
703
+ - Authenticates as the target agent (Stalwart requires sender=auth user)
704
+ - Runs spam filter via `scoreEmail()`
705
+ - Detects approval reply emails (matches In-Reply-To against `pending_outbound.notification_message_id`)
706
+ - Approval patterns recognized: `approve[d]?`, `yes`, `send`, `go ahead`, `lgtm`, `ok`
707
+ - Rejection patterns recognized: `reject[ed]?`, `no`, `deny`, `don't send`, `cancel`, `block`
708
+ - Adds headers: `X-AgenticMail-Relay`, `X-Original-From`, `X-Original-Message-Id`
709
+
710
+ **Domain mode setup (17 steps) returns:**
711
+ ```typescript
712
+ {
713
+ domain: string;
714
+ zoneId: string;
715
+ tunnelId: string;
716
+ dkimPublicKey: string;
717
+ dnsRecords: DnsRecord[];
718
+ outboundRelay?: { configured: boolean; smtpHost: string };
719
+ nextSteps: string[]; // e.g., Gmail "Send mail as" instructions
720
+ }
721
+ ```
722
+
723
+ ---
724
+
725
+ ## Relay Gateway
726
+
727
+ ### `RelayGateway`
728
+ ```typescript
729
+ class RelayGateway {
730
+ constructor(options?: { onInboundMail?: (email: InboundEmail, agentName: string) => Promise<void>; defaultAgentName?: string })
731
+
732
+ setup(config: RelayConfig): Promise<void>
733
+ sendViaRelay(agentName: string, mail: SendMailOptions): Promise<SendResult>
734
+ startPolling(intervalMs?: number): void // default: 30000
735
+ stopPolling(): void
736
+ searchRelay(criteria: SearchCriteria, maxResults?: number): Promise<RelaySearchResult[]>
737
+ fetchRelayMessage(uid: number): Promise<InboundEmail>
738
+ setLastSeenUid(uid: number): void
739
+ trackSentMessage(messageId: string, agentName: string): void
740
+ isConfigured(): boolean
741
+ isPolling(): boolean
742
+ getConfig(): RelayConfig | null
743
+ shutdown(): Promise<void>
744
+ }
745
+ ```
746
+
747
+ **Polling details:**
748
+ - Uses `setTimeout` (not `setInterval`) for natural backoff
749
+ - Backoff: `interval * 2^(failures-1)`, capped at 5 minutes
750
+ - Connection timeout: 30 seconds per poll
751
+ - First poll: scans UID range `uidNext-50` to `*`
752
+ - Subsequent: only `lastSeenUid+1` to `*`
753
+ - Never permanently stops — always reschedules
754
+ - Logs every 5 consecutive failures
755
+
756
+ **Agent routing priority:**
757
+ 1. Sub-address in To/CC/Delivered-To/X-Original-To (`user+agent@domain`)
758
+ 2. In-Reply-To matched against tracked sent messages
759
+ 3. References chain (newest first)
760
+ 4. Default agent fallback
761
+
762
+ **Sent message tracking:** Map capped at 10,000 entries.
763
+
764
+ ```typescript
765
+ interface InboundEmail {
766
+ messageId: string;
767
+ from: string;
768
+ to: string[];
769
+ subject: string;
770
+ text?: string;
771
+ html?: string;
772
+ date: Date;
773
+ inReplyTo?: string;
774
+ references?: string[];
775
+ attachments: Array<{ filename: string; contentType: string; size: number; content: Buffer }>;
776
+ }
777
+ ```
778
+
779
+ ---
780
+
781
+ ## Cloudflare Client
782
+
783
+ ### `CloudflareClient`
784
+ ```typescript
785
+ class CloudflareClient {
786
+ constructor(apiToken: string, accountId: string)
787
+
788
+ // Zones
789
+ listZones(): Promise<CloudflareZone[]>
790
+ getZone(domain: string): Promise<CloudflareZone>
791
+ createZone(domain: string): Promise<CloudflareZone>
792
+
793
+ // DNS
794
+ listDnsRecords(zoneId: string): Promise<CloudflareDnsRecord[]>
795
+ createDnsRecord(zoneId: string, record: { type; name; content; ttl?; priority?; proxied? }): Promise<CloudflareDnsRecord>
796
+ deleteDnsRecord(zoneId: string, recordId: string): Promise<void>
797
+
798
+ // Registrar
799
+ searchDomains(query: string): Promise<CloudflareDomainAvailability[]>
800
+ checkAvailability(domain: string): Promise<CloudflareDomainAvailability>
801
+ purchaseDomain(domain: string, autoRenew?: boolean): Promise<void>
802
+ listRegisteredDomains(): Promise<any[]>
803
+
804
+ // Tunnels
805
+ createTunnel(name: string): Promise<CloudflareTunnel>
806
+ getTunnel(tunnelId: string): Promise<CloudflareTunnel>
807
+ getTunnelToken(tunnelId: string): Promise<string>
808
+ createTunnelRoute(tunnelId: string, hostname: string, service: string, options?: { apiService?: string; apiPort?: number }): Promise<void>
809
+ deleteTunnel(tunnelId: string): Promise<void>
810
+ listTunnels(): Promise<CloudflareTunnel[]>
811
+
812
+ // Email Routing
813
+ enableEmailRouting(zoneId: string): Promise<void>
814
+ disableEmailRouting(zoneId: string): Promise<void>
815
+ getEmailRoutingStatus(zoneId: string): Promise<any>
816
+ setCatchAllWorkerRule(zoneId: string, workerName: string): Promise<void>
817
+
818
+ // Workers
819
+ deployEmailWorker(scriptName: string, scriptContent: string, envVars: Record<string, string>): Promise<void>
820
+ deleteWorker(scriptName: string): Promise<void>
821
+ }
822
+ ```
823
+
824
+ **API base:** `https://api.cloudflare.com/client/v4`
825
+
826
+ **`createTunnel()`** — Reuses existing tunnel if name matches. Generates random 32-byte secret.
827
+
828
+ **`createTunnelRoute()`** — Creates ingress: `/api/agenticmail/*` → apiService (port 3100), `*` → primary service (port 8080), catch-all → 404.
829
+
830
+ **`deployEmailWorker()`** — Multipart form upload with ES module metadata and plain_text env var bindings. Compatibility date: 2024-01-01.
831
+
832
+ ---
833
+
834
+ ## Tunnel Manager
835
+
836
+ ### `TunnelManager`
837
+ ```typescript
838
+ class TunnelManager {
839
+ constructor(cf: CloudflareClient)
840
+
841
+ install(): Promise<string> // returns binary path
842
+ create(name: string): Promise<TunnelConfig>
843
+ start(tunnelToken: string): Promise<void>
844
+ createIngress(tunnelId: string, domain: string, smtpPort?: number, httpPort?: number, apiPort?: number): Promise<void>
845
+ stop(): Promise<void>
846
+ status(): { running: boolean; pid?: number }
847
+ healthCheck(tunnelId: string): Promise<boolean>
848
+ }
849
+ ```
850
+
851
+ **`install()`** — Priority: managed binary (`~/.agenticmail/bin/cloudflared`) → system-wide (`which`) → download from GitHub releases. Platform detection: darwin-arm64, darwin-amd64, linux-arm64, linux-amd64. Atomic install: write .tmp, chmod 0755, rename.
852
+
853
+ **`start()`** — Spawns cloudflared with `--no-autoupdate`. Token passed via env var (not CLI arg). Waits up to 30 seconds for "Registered tunnel connection" or "Connection registered" in stdout/stderr.
854
+
855
+ **`stop()`** — SIGTERM with 5 second timeout.
856
+
857
+ ---
858
+
859
+ ## DNS Configurator
860
+
861
+ ### `DNSConfigurator`
862
+ ```typescript
863
+ class DNSConfigurator {
864
+ constructor(cf: CloudflareClient)
865
+
866
+ detectPublicIp(): Promise<string>
867
+ configureForEmail(domain: string, zoneId: string, options?: {
868
+ serverIp?: string;
869
+ dkimSelector?: string;
870
+ dkimPublicKey?: string;
871
+ }): Promise<DnsSetupResult>
872
+ configureForTunnel(domain: string, zoneId: string, tunnelId: string): Promise<DnsSetupResult>
873
+ verify(domain: string): Promise<{ mx: boolean; spf: boolean; dmarc: boolean }>
874
+ }
875
+ ```
876
+
877
+ **`configureForEmail()`** records created:
878
+ - SPF TXT: `v=spf1 ip4:{serverIp} include:_spf.mx.cloudflare.net mx ~all`
879
+ - DMARC TXT: `v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain}`
880
+ - DKIM TXT: `v=DKIM1; k=rsa; p={publicKey}` (if key provided)
881
+ - Preserves Cloudflare Email Routing `_dc-mx.*` MX records
882
+ - Removes conflicting foreign MX records
883
+
884
+ **`configureForTunnel()`** records created:
885
+ - CNAME: `{domain} → {tunnelId}.cfargotunnel.com` (proxied)
886
+ - CNAME: `mail.{domain} → {tunnelId}.cfargotunnel.com` (proxied)
887
+ - Removes conflicting A/AAAA/CNAME records
888
+
889
+ ---
890
+
891
+ ## Domain Purchaser
892
+
893
+ ### `DomainPurchaser`
894
+ ```typescript
895
+ class DomainPurchaser {
896
+ constructor(cf: CloudflareClient)
897
+
898
+ searchAvailable(keywords: string[], tlds?: string[]): Promise<DomainSearchResult[]>
899
+ purchase(domain: string, autoRenew?: boolean): Promise<void> // throws — use CF dashboard
900
+ getStatus(domain: string): Promise<any>
901
+ listRegistered(): Promise<any[]>
902
+ }
903
+ ```
904
+
905
+ Default TLDs: `.com`, `.net`, `.io`, `.dev`
906
+
907
+ **Note:** `purchase()` throws an error because Cloudflare API tokens only get read access to registrar. Users must purchase via Cloudflare dashboard.
908
+
909
+ ---
910
+
911
+ ## Relay Bridge
912
+
913
+ ### `RelayBridge`
914
+ ```typescript
915
+ class RelayBridge {
916
+ constructor(options: RelayBridgeOptions)
917
+
918
+ start(): Promise<void>
919
+ stop(): Promise<void>
920
+ }
921
+
922
+ function startRelayBridge(options: RelayBridgeOptions): Promise<RelayBridge>
923
+ ```
924
+
925
+ Local HTTP-to-SMTP bridge on `127.0.0.1:{port}`. Validates `X-Relay-Secret` header. Accepts POST `/send` with JSON payload, submits to Stalwart SMTP for DKIM signing and delivery.
926
+
927
+ ---
928
+
929
+ ## Stalwart Admin
930
+
931
+ ### `StalwartAdmin`
932
+ ```typescript
933
+ class StalwartAdmin {
934
+ constructor(options: StalwartAdminOptions)
935
+
936
+ // Principals
937
+ createPrincipal(principal: StalwartPrincipal): Promise<void>
938
+ getPrincipal(name: string): Promise<StalwartPrincipal>
939
+ updatePrincipal(name: string, changes: Partial<StalwartPrincipal>): Promise<void>
940
+ addEmailAlias(name: string, email: string): Promise<void>
941
+ deletePrincipal(name: string): Promise<void>
942
+ listPrincipals(type?: string): Promise<string[]>
943
+
944
+ // Domains
945
+ ensureDomain(domain: string): Promise<void> // idempotent
946
+
947
+ // Health
948
+ healthCheck(): Promise<boolean> // 5s timeout
949
+
950
+ // Settings
951
+ getSetting(key: string): Promise<string | undefined>
952
+ getSettings(prefix: string): Promise<Record<string, string>>
953
+ updateSetting(key: string, value: string): Promise<void> // via stalwart-cli in Docker
954
+
955
+ // Config
956
+ setHostname(domain: string): Promise<void> // modifies stalwart.toml on host
957
+
958
+ // DKIM
959
+ createDkimSignature(domain: string, selector?: string): Promise<{ signatureId: string; publicKey: string }>
960
+ hasDkimSignature(domain: string): Promise<boolean>
961
+
962
+ // Outbound Relay
963
+ configureOutboundRelay(config: {
964
+ smtpHost: string;
965
+ smtpPort: number;
966
+ username: string;
967
+ password: string;
968
+ routeName?: string; // default: 'gmail'
969
+ }): Promise<void> // modifies stalwart.toml, restarts container
970
+ }
971
+ ```
972
+
973
+ **Request timeout:** 15 seconds. Auth: HTTP Basic.
974
+
975
+ **`updateSetting()`** — Uses stalwart-cli inside Docker container. Deletes then adds config. Verifies by reading back.
976
+
977
+ **`createDkimSignature()`** — Idempotent. Signature ID: `agenticmail-{domain with dots→dashes}`. Default selector: `agenticmail`. Creates via `stalwart-cli dkim create rsa`. Sets signing rules in `auth.dkim.sign.*`. Returns base64 public key for DNS TXT record.
978
+
979
+ **`configureOutboundRelay()`** — Appends relay route and strategy to stalwart.toml. Routes: local domains → 'local', everything else → relay. Restarts container (15s wait).
980
+
981
+ ```typescript
982
+ interface StalwartPrincipal {
983
+ type: 'individual' | 'group' | 'domain' | 'list' | 'apiKey';
984
+ name: string;
985
+ secrets?: string[];
986
+ emails?: string[];
987
+ description?: string;
988
+ quota?: number;
989
+ memberOf?: string[];
990
+ members?: string[];
991
+ roles?: string[];
992
+ }
993
+ ```
994
+
995
+ ---
996
+
997
+ ## Domain Manager
998
+
999
+ ### `DomainManager`
1000
+ ```typescript
1001
+ class DomainManager {
1002
+ constructor(db: Database.Database, stalwart: StalwartAdmin)
1003
+
1004
+ setup(domain: string): Promise<DomainSetupResult>
1005
+ get(domain: string): Promise<DomainInfo | null>
1006
+ list(): Promise<DomainInfo[]>
1007
+ getDnsRecords(domain: string): Promise<DnsRecord[]>
1008
+ verify(domain: string): Promise<boolean> // checks MX records
1009
+ delete(domain: string): Promise<boolean>
1010
+ }
1011
+ ```
1012
+
1013
+ ---
1014
+
1015
+ ## Storage
1016
+
1017
+ ### `getDatabase(config: AgenticMailConfig | string): Database.Database`
1018
+
1019
+ Singleton. Opens SQLite with WAL mode and foreign keys. Runs all pending migrations automatically.
1020
+
1021
+ ### `closeDatabase(): void`
1022
+
1023
+ Closes and resets singleton.
1024
+
1025
+ ### `createTestDatabase(): Database.Database`
1026
+
1027
+ In-memory database with all migrations applied. For testing.
1028
+
1029
+ ---
1030
+
1031
+ ## Search Index
1032
+
1033
+ ### `EmailSearchIndex`
1034
+ ```typescript
1035
+ class EmailSearchIndex {
1036
+ constructor(db: Database.Database)
1037
+
1038
+ index(email: SearchableEmail): void
1039
+ search(agentId: string, query: string, limit?: number): Array<{ uid: number; rank: number }>
1040
+ deleteByAgent(agentId: string): void
1041
+ }
1042
+ ```
1043
+
1044
+ **`search()`** — Wraps query in quotes (phrase search). Escapes internal quotes. Limit: 1-1000 (default 20). Returns empty on FTS5 syntax error. Ordered by FTS5 rank.
1045
+
1046
+ ```typescript
1047
+ interface SearchableEmail {
1048
+ agentId: string;
1049
+ messageId: string;
1050
+ subject: string;
1051
+ fromAddress: string;
1052
+ toAddress: string;
1053
+ bodyText: string;
1054
+ receivedAt: Date;
1055
+ }
1056
+ ```
1057
+
1058
+ ---
1059
+
1060
+ ## Setup
1061
+
1062
+ ### `SetupManager`
1063
+ ```typescript
1064
+ class SetupManager {
1065
+ constructor(onProgress?: InstallProgress)
1066
+
1067
+ checkDependencies(): Promise<{ docker; stalwart; cloudflared }>
1068
+ installAll(composePath?: string): Promise<void>
1069
+ ensureDocker(): Promise<void>
1070
+ ensureStalwart(composePath?: string): Promise<void>
1071
+ ensureCloudflared(): Promise<void>
1072
+ getComposePath(): string
1073
+ initConfig(): Promise<SetupConfig>
1074
+ isInitialized(): boolean
1075
+ }
1076
+ ```
1077
+
1078
+ ### `DependencyChecker`
1079
+ ```typescript
1080
+ class DependencyChecker {
1081
+ checkAll(): Promise<DependencyStatus[]>
1082
+ checkDocker(): Promise<DependencyStatus>
1083
+ checkStalwart(): Promise<DependencyStatus>
1084
+ checkCloudflared(): Promise<DependencyStatus>
1085
+ }
1086
+ ```
1087
+
1088
+ ### `DependencyInstaller`
1089
+ ```typescript
1090
+ class DependencyInstaller {
1091
+ constructor(onProgress?: InstallProgress)
1092
+
1093
+ installDocker(): Promise<void> // Homebrew (macOS) or official script (Linux)
1094
+ startStalwart(composePath: string): Promise<void>
1095
+ installCloudflared(): Promise<void> // GitHub releases download
1096
+ installAll(composePath: string): Promise<void>
1097
+ }
1098
+ ```
1099
+
1100
+ ---
1101
+
1102
+ ## Database Schema
1103
+
1104
+ ### Tables
1105
+
1106
+ ```sql
1107
+ -- agents (migration 001 + 003 + 009)
1108
+ CREATE TABLE agents (
1109
+ id TEXT PRIMARY KEY,
1110
+ name TEXT UNIQUE NOT NULL,
1111
+ email TEXT UNIQUE NOT NULL,
1112
+ api_key TEXT UNIQUE NOT NULL,
1113
+ stalwart_principal TEXT NOT NULL,
1114
+ role TEXT DEFAULT 'secretary',
1115
+ created_at TEXT DEFAULT (datetime('now')),
1116
+ updated_at TEXT DEFAULT (datetime('now')),
1117
+ last_activity_at TEXT,
1118
+ persistent INTEGER DEFAULT 0,
1119
+ metadata TEXT DEFAULT '{}'
1120
+ );
1121
+
1122
+ -- pending_outbound (migration 012 + 013)
1123
+ CREATE TABLE pending_outbound (
1124
+ id TEXT PRIMARY KEY,
1125
+ agent_id TEXT NOT NULL,
1126
+ mail_options TEXT NOT NULL, -- JSON: to, subject, text, html, cc, bcc, etc.
1127
+ warnings TEXT NOT NULL, -- JSON array of OutboundWarning
1128
+ summary TEXT,
1129
+ status TEXT DEFAULT 'pending', -- pending | approved | rejected
1130
+ notification_message_id TEXT, -- Message-ID of owner notification email
1131
+ created_at TEXT DEFAULT (datetime('now')),
1132
+ resolved_at TEXT,
1133
+ resolved_by TEXT, -- 'master' | 'owner-reply'
1134
+ error TEXT
1135
+ );
1136
+ CREATE INDEX idx_pending_agent_status ON pending_outbound(agent_id, status);
1137
+ CREATE INDEX idx_pending_notification ON pending_outbound(notification_message_id);
1138
+
1139
+ -- agent_tasks (migration 010)
1140
+ CREATE TABLE agent_tasks (
1141
+ id TEXT PRIMARY KEY,
1142
+ assigner_id TEXT,
1143
+ assignee_id TEXT,
1144
+ task_type TEXT DEFAULT 'generic',
1145
+ payload TEXT, -- JSON
1146
+ status TEXT DEFAULT 'pending', -- pending | claimed | completed | failed
1147
+ result TEXT,
1148
+ error TEXT,
1149
+ created_at TEXT DEFAULT (datetime('now')),
1150
+ claimed_at TEXT,
1151
+ completed_at TEXT,
1152
+ expires_at TEXT
1153
+ );
1154
+
1155
+ -- gateway_config (migration 002)
1156
+ CREATE TABLE gateway_config (
1157
+ id TEXT PRIMARY KEY DEFAULT 'default',
1158
+ mode TEXT DEFAULT 'none', -- relay | domain | none
1159
+ config TEXT DEFAULT '{}', -- JSON: RelayConfig or DomainModeConfig
1160
+ created_at TEXT DEFAULT (datetime('now'))
1161
+ );
1162
+
1163
+ -- delivered_messages (migration 004)
1164
+ CREATE TABLE delivered_messages (
1165
+ message_id TEXT NOT NULL,
1166
+ agent_name TEXT NOT NULL,
1167
+ delivered_at TEXT DEFAULT (datetime('now')),
1168
+ PRIMARY KEY (message_id, agent_name)
1169
+ );
1170
+
1171
+ -- email_search (FTS5, migration 001)
1172
+ CREATE VIRTUAL TABLE email_search USING fts5(
1173
+ agent_id, message_id, subject, from_address, to_address, body_text, received_at
1174
+ );
1175
+
1176
+ -- Plus: domains, config, purchased_domains, contacts, drafts, signatures,
1177
+ -- templates, scheduled_emails, tags, message_tags, email_rules, agent_deletions, spam_log
1178
+ ```
1179
+
1180
+ ---
1181
+
1182
+ ## Constants
1183
+
1184
+ | Constant | Value | Description |
1185
+ |----------|-------|-------------|
1186
+ | `SPAM_THRESHOLD` | 40 | Score >= 40 → classified as spam |
1187
+ | `WARNING_THRESHOLD` | 20 | Score 20-39 → warning flag |
1188
+ | `AGENT_ROLES` | `['secretary', 'assistant', 'researcher', 'writer', 'custom']` | Valid agent roles |
1189
+ | `DEFAULT_AGENT_ROLE` | `'secretary'` | Default role for new agents |
1190
+ | `DEFAULT_AGENT_NAME` | `'secretary'` | Default agent name |
1191
+ | `RELAY_PRESETS` | `{ gmail: {...}, outlook: {...} }` | SMTP/IMAP presets for Gmail and Outlook |
1192
+
1193
+ ### Timeouts & Limits
1194
+
1195
+ | Setting | Value | Context |
1196
+ |---------|-------|---------|
1197
+ | SMTP connection timeout | 10,000ms | MailSender |
1198
+ | SMTP greeting timeout | 10,000ms | MailSender |
1199
+ | SMTP socket timeout | 15,000ms | MailSender |
1200
+ | Stalwart API request timeout | 15,000ms | StalwartAdmin |
1201
+ | Stalwart health check timeout | 5,000ms | StalwartAdmin |
1202
+ | Relay poll interval (initial) | 30,000ms | RelayGateway |
1203
+ | Relay poll backoff cap | 300,000ms (5 min) | RelayGateway |
1204
+ | Relay connection timeout | 30,000ms | RelayGateway |
1205
+ | Tunnel startup timeout | 30,000ms | TunnelManager |
1206
+ | Tunnel stop timeout | 5,000ms | TunnelManager |
1207
+ | Stalwart container wait | 30,000ms | DependencyInstaller |
1208
+ | Docker daemon wait | 60,000ms | DependencyInstaller |
1209
+ | Max emails per folder (archive) | 10,000 | AgentDeletionService |
1210
+ | Sent message tracking cap | 10,000 entries | RelayGateway |
1211
+ | IMAP list max limit | 1,000 | MailReceiver |
1212
+ | FTS5 search max limit | 1,000 | EmailSearchIndex |
1213
+ | Outbound match preview | 80 chars | scanOutboundEmail |
1214
+
1215
+ ---
1216
+
1217
+ ## License
1218
+
1219
+ [MIT](./LICENSE) - Ope Olatunji ([@ope-olatunji](https://github.com/ope-olatunji))