@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/dist/index.js ADDED
@@ -0,0 +1,3248 @@
1
+ // src/index.ts
2
+ import "dotenv/config";
3
+ import { networkInterfaces } from "os";
4
+
5
+ // src/app.ts
6
+ import express from "express";
7
+ import cors from "cors";
8
+ import rateLimit from "express-rate-limit";
9
+ import {
10
+ resolveConfig,
11
+ getDatabase,
12
+ StalwartAdmin,
13
+ AccountManager,
14
+ DomainManager,
15
+ GatewayManager as GatewayManager2
16
+ } from "@agenticmail/core";
17
+
18
+ // src/middleware/auth.ts
19
+ import { createHash, timingSafeEqual } from "crypto";
20
+ var lastActivityUpdate = /* @__PURE__ */ new Map();
21
+ var ACTIVITY_THROTTLE_MS = 6e4;
22
+ function touchActivity(db, agentId) {
23
+ const now = Date.now();
24
+ const last = lastActivityUpdate.get(agentId) ?? 0;
25
+ if (now - last > ACTIVITY_THROTTLE_MS) {
26
+ lastActivityUpdate.set(agentId, now);
27
+ try {
28
+ db.prepare("UPDATE agents SET last_activity_at = datetime('now') WHERE id = ?").run(agentId);
29
+ } catch {
30
+ }
31
+ }
32
+ }
33
+ function safeEqual(a, b) {
34
+ const ha = createHash("sha256").update(a).digest();
35
+ const hb = createHash("sha256").update(b).digest();
36
+ return timingSafeEqual(ha, hb);
37
+ }
38
+ function createAuthMiddleware(masterKey, accountManager, db) {
39
+ return async (req, res, next) => {
40
+ const authHeader = req.headers.authorization;
41
+ if (!authHeader?.startsWith("Bearer ")) {
42
+ res.status(401).json({ error: "Missing or invalid Authorization header" });
43
+ return;
44
+ }
45
+ const token = authHeader.slice(7);
46
+ if (!token) {
47
+ res.status(401).json({ error: "Invalid API key" });
48
+ return;
49
+ }
50
+ if (masterKey && safeEqual(token, masterKey)) {
51
+ req.isMaster = true;
52
+ next();
53
+ return;
54
+ }
55
+ try {
56
+ const agent = await accountManager.getByApiKey(token);
57
+ if (agent) {
58
+ req.agent = agent;
59
+ if (db) {
60
+ touchActivity(db, agent.id);
61
+ }
62
+ next();
63
+ return;
64
+ }
65
+ } catch (err) {
66
+ next(err);
67
+ return;
68
+ }
69
+ res.status(401).json({ error: "Invalid API key" });
70
+ };
71
+ }
72
+ function requireMaster(req, res, next) {
73
+ if (!req.isMaster) {
74
+ res.status(403).json({ error: "Master API key required" });
75
+ return;
76
+ }
77
+ next();
78
+ }
79
+ function requireAuth(req, res, next) {
80
+ if (!req.agent && !req.isMaster) {
81
+ res.status(403).json({ error: "Authentication required" });
82
+ return;
83
+ }
84
+ next();
85
+ }
86
+ function requireAgent(req, res, next) {
87
+ if (!req.agent) {
88
+ res.status(403).json({ error: "Agent API key required (master key alone is not sufficient)" });
89
+ return;
90
+ }
91
+ next();
92
+ }
93
+
94
+ // src/middleware/error-handler.ts
95
+ function errorHandler(err, req, res, _next) {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ console.error(`[ERROR] ${req.method} ${req.path}:`, err instanceof Error ? err.stack || message : message);
98
+ if (res.headersSent) return;
99
+ if (err instanceof SyntaxError && err.status === 400) {
100
+ res.status(400).json({ error: "Invalid JSON in request body" });
101
+ return;
102
+ }
103
+ if (typeof err.statusCode === "number") {
104
+ res.status(err.statusCode).json({ error: message });
105
+ return;
106
+ }
107
+ const lowerMsg = message.toLowerCase();
108
+ if (lowerMsg.includes("not found") && !lowerMsg.includes("not found a")) {
109
+ res.status(404).json({ error: message });
110
+ return;
111
+ }
112
+ if (lowerMsg.includes("already exists") || lowerMsg.includes("unique constraint")) {
113
+ res.status(409).json({ error: "Resource already exists" });
114
+ return;
115
+ }
116
+ if (lowerMsg.includes("invalid") || lowerMsg.includes("required") || lowerMsg.includes("must ")) {
117
+ res.status(400).json({ error: message });
118
+ return;
119
+ }
120
+ res.status(500).json({ error: "Internal server error" });
121
+ }
122
+
123
+ // src/routes/health.ts
124
+ import { Router } from "express";
125
+ var ABOUT = {
126
+ name: "\u{1F380} AgenticMail",
127
+ version: "0.2.26",
128
+ description: "\u{1F380} AgenticMail \u2014 Email infrastructure for AI agents. Send, receive, coordinate, and automate email with full DKIM/SPF/DMARC authentication.",
129
+ author: {
130
+ name: "Ope Olatunji",
131
+ github: "https://github.com/agenticmail/agenticmail"
132
+ },
133
+ license: "MIT",
134
+ repository: "https://github.com/agenticmail/agenticmail",
135
+ contributing: "Contributions and feature requests welcome! Visit the GitHub repo to open issues, suggest features, or submit pull requests.",
136
+ tools: 54,
137
+ features: {
138
+ email: {
139
+ summary: "Full email lifecycle \u2014 send, receive, reply, forward, search, batch operations",
140
+ highlights: [
141
+ "DKIM/SPF/DMARC authentication out of the box",
142
+ "Custom domain support via Cloudflare (agent@yourdomain.com)",
143
+ "Gmail/Outlook relay mode for quick setup",
144
+ "Batch operations for token-efficient bulk processing",
145
+ "Server-side rules for auto-triage before the agent even sees the email"
146
+ ]
147
+ },
148
+ coordination: {
149
+ summary: "Structured multi-agent coordination that replaces fire-and-forget session spawning",
150
+ highlights: [
151
+ "Task queue with assign \u2192 claim \u2192 submit lifecycle (persistent, survives crashes)",
152
+ "Synchronous RPC \u2014 call another agent and wait for structured results",
153
+ "Push notifications via SSE \u2014 no wasted polling cycles",
154
+ "Agent discovery \u2014 agents find each other by name and role",
155
+ "Email threading \u2014 agents naturally build conversation history"
156
+ ],
157
+ comparison: {
158
+ without_agenticmail: {
159
+ method: "sessions_spawn + sessions_send + sessions_history",
160
+ problems: [
161
+ "No persistence \u2014 if a sub-agent crashes, all context is lost",
162
+ "No structured results \u2014 just text messages, no schemas or status tracking",
163
+ "No task lifecycle \u2014 no way to know if a task was claimed, in progress, or completed",
164
+ "No agent discovery \u2014 agents cannot find or learn about each other",
165
+ "Polling required \u2014 must repeatedly check sessions_history to see if work is done",
166
+ "No async handoff \u2014 parent must stay alive waiting for the child to finish"
167
+ ]
168
+ },
169
+ with_agenticmail: {
170
+ method: "assign_task \u2192 claim_task \u2192 submit_result (or call_agent for sync RPC)",
171
+ benefits: [
172
+ "Persistent task state \u2014 tasks survive agent crashes and restarts",
173
+ "Structured results \u2014 JSON payloads with status tracking (pending \u2192 claimed \u2192 completed)",
174
+ "Push-based \u2014 agents get notified instantly when tasks complete (SSE + email)",
175
+ "Agent discovery \u2014 list_agents shows all available agents by name and role",
176
+ "Async capable \u2014 assign a task and check results later, no blocking required",
177
+ "Audit trail \u2014 every coordination action is an email, naturally logged"
178
+ ]
179
+ }
180
+ }
181
+ },
182
+ security: {
183
+ summary: "Enterprise-grade email security for autonomous agents",
184
+ highlights: [
185
+ "Outbound PII/credential scanning (SSN, credit cards, API keys, passwords \u2014 including attachments)",
186
+ "Human-in-the-loop approval for blocked emails \u2014 owner gets notified, agent cannot self-approve",
187
+ "Inbound spam filtering with scoring (phishing, lottery scams, social engineering detection)",
188
+ "Agent cannot bypass security guardrails \u2014 architectural enforcement, not just prompt rules"
189
+ ]
190
+ }
191
+ },
192
+ impact: {
193
+ tokenSavings: {
194
+ estimate: "~60% fewer tokens on multi-agent coordination tasks",
195
+ explanation: 'Without \u{1F380} AgenticMail, agents poll sessions_history repeatedly to check if sub-agents finished \u2014 each poll costs 500-2000 tokens and most return "still working." With push notifications and structured task results, the coordinator gets notified exactly once when work completes. For a 5-agent team doing 10 tasks, that eliminates roughly 40-80 redundant polling calls.'
196
+ },
197
+ reliability: {
198
+ estimate: "Near-zero lost work from agent crashes",
199
+ explanation: "Session-based coordination loses all context when a sub-agent times out or crashes. \u{1F380} AgenticMail tasks persist in the database \u2014 a crashed agent can be restarted and pick up exactly where it left off. The task queue acts as a durable work ledger."
200
+ },
201
+ productivity: {
202
+ estimate: "3-5x more effective multi-agent workflows",
203
+ explanation: "Agents can discover teammates, delegate structured tasks, get push notifications on completion, and build on each other's results through email threads. This turns a collection of isolated agents into an actual coordinated team. The difference is like going from passing sticky notes under a door to having a proper project management system."
204
+ }
205
+ }
206
+ };
207
+ function createHealthRoutes(stalwart) {
208
+ const router = Router();
209
+ router.get("/health", async (_req, res) => {
210
+ try {
211
+ const stalwartOk = await stalwart.healthCheck();
212
+ res.status(stalwartOk ? 200 : 503).json({
213
+ status: stalwartOk ? "ok" : "degraded",
214
+ version: ABOUT.version,
215
+ services: {
216
+ api: "ok",
217
+ stalwart: stalwartOk ? "ok" : "unreachable"
218
+ },
219
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
220
+ });
221
+ } catch {
222
+ res.status(500).json({
223
+ status: "error",
224
+ version: ABOUT.version,
225
+ services: { api: "ok", stalwart: "unreachable" },
226
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
227
+ });
228
+ }
229
+ });
230
+ router.get("/about", (_req, res) => {
231
+ res.json(ABOUT);
232
+ });
233
+ return router;
234
+ }
235
+
236
+ // src/routes/accounts.ts
237
+ import { Router as Router2 } from "express";
238
+ import { AGENT_ROLES, AgentDeletionService } from "@agenticmail/core";
239
+ function sanitizeAgent(agent) {
240
+ if (!agent) return agent;
241
+ const { metadata, ...rest } = agent;
242
+ if (metadata && typeof metadata === "object") {
243
+ const clean = {};
244
+ for (const [k, v] of Object.entries(metadata)) {
245
+ if (!k.startsWith("_")) clean[k] = v;
246
+ }
247
+ return { ...rest, metadata: clean };
248
+ }
249
+ return agent;
250
+ }
251
+ function createAccountRoutes(accountManager, db, config) {
252
+ const router = Router2();
253
+ const deletionService = new AgentDeletionService(db, accountManager, config);
254
+ router.post("/accounts", requireMaster, async (req, res, next) => {
255
+ try {
256
+ if (!req.body || typeof req.body !== "object") {
257
+ res.status(400).json({ error: "Request body must be JSON" });
258
+ return;
259
+ }
260
+ const { name: name2, domain, password, metadata, role, persistent } = req.body;
261
+ if (!name2 || typeof name2 !== "string") {
262
+ res.status(400).json({ error: "name is required and must be a string" });
263
+ return;
264
+ }
265
+ if (name2.length > 64) {
266
+ res.status(400).json({ error: "name must be 64 characters or fewer" });
267
+ return;
268
+ }
269
+ if (metadata !== void 0 && (typeof metadata !== "object" || Array.isArray(metadata) || metadata === null)) {
270
+ res.status(400).json({ error: "metadata must be an object" });
271
+ return;
272
+ }
273
+ if (password !== void 0 && typeof password !== "string") {
274
+ res.status(400).json({ error: "password must be a string" });
275
+ return;
276
+ }
277
+ if (role !== void 0 && !AGENT_ROLES.includes(role)) {
278
+ res.status(400).json({ error: `Invalid role. Must be one of: ${AGENT_ROLES.join(", ")}` });
279
+ return;
280
+ }
281
+ const cleanMeta = metadata ? Object.fromEntries(
282
+ Object.entries(metadata).filter(([k]) => !k.startsWith("_"))
283
+ ) : void 0;
284
+ const agent = await accountManager.create({ name: name2, domain, password: password || void 0, metadata: cleanMeta, role });
285
+ try {
286
+ db.prepare("UPDATE agents SET last_activity_at = datetime('now') WHERE id = ?").run(agent.id);
287
+ } catch {
288
+ }
289
+ const agentCount = db.prepare("SELECT COUNT(*) as cnt FROM agents").get()?.cnt ?? 0;
290
+ const shouldPersist = persistent || agentCount <= 1;
291
+ if (shouldPersist) {
292
+ try {
293
+ db.prepare("UPDATE agents SET persistent = 1 WHERE id = ?").run(agent.id);
294
+ } catch {
295
+ }
296
+ }
297
+ res.status(201).json(sanitizeAgent(agent));
298
+ } catch (err) {
299
+ const msg = err.message ?? "";
300
+ if (msg.includes("UNIQUE") || msg.includes("unique") || msg.includes("already exists") || msg.includes("duplicate")) {
301
+ res.status(409).json({ error: `Agent "${name}" already exists` });
302
+ return;
303
+ }
304
+ next(err);
305
+ }
306
+ });
307
+ router.get("/accounts", requireMaster, async (_req, res, next) => {
308
+ try {
309
+ const agents = await accountManager.list();
310
+ res.json({ agents: agents.map(sanitizeAgent) });
311
+ } catch (err) {
312
+ next(err);
313
+ }
314
+ });
315
+ router.get("/accounts/directory", requireAuth, async (_req, res, next) => {
316
+ try {
317
+ const agents = await accountManager.list();
318
+ const directory = agents.map((a) => ({ name: a.name, email: a.email, role: a.role }));
319
+ res.json({ agents: directory });
320
+ } catch (err) {
321
+ next(err);
322
+ }
323
+ });
324
+ router.get("/accounts/directory/:name", requireAuth, async (req, res, next) => {
325
+ try {
326
+ const agent = await accountManager.getByName(req.params.name);
327
+ if (!agent) {
328
+ res.status(404).json({ error: "Agent not found" });
329
+ return;
330
+ }
331
+ res.json({ name: agent.name, email: agent.email, role: agent.role });
332
+ } catch (err) {
333
+ next(err);
334
+ }
335
+ });
336
+ router.get("/accounts/me", requireAgent, async (req, res) => {
337
+ if (req.agent) {
338
+ res.json(sanitizeAgent(req.agent));
339
+ } else {
340
+ res.status(404).json({ error: "Agent not found" });
341
+ }
342
+ });
343
+ router.get("/accounts/deletions", requireMaster, async (_req, res, next) => {
344
+ try {
345
+ const reports = deletionService.listReports();
346
+ res.json({ deletions: reports });
347
+ } catch (err) {
348
+ next(err);
349
+ }
350
+ });
351
+ router.get("/accounts/deletions/:id", requireMaster, async (req, res, next) => {
352
+ try {
353
+ const report = deletionService.getReport(req.params.id);
354
+ if (!report) {
355
+ res.status(404).json({ error: "Deletion report not found" });
356
+ return;
357
+ }
358
+ res.json(report);
359
+ } catch (err) {
360
+ next(err);
361
+ }
362
+ });
363
+ router.get("/accounts/inactive", requireMaster, async (_req, res, next) => {
364
+ try {
365
+ const hours = Math.max(parseInt(_req.query.hours) || 24, 1);
366
+ const rows = db.prepare(
367
+ `SELECT id, name, email, role, last_activity_at, persistent, created_at FROM agents
368
+ WHERE persistent = 0 AND COALESCE(last_activity_at, created_at) < datetime('now', '-${hours} hours')
369
+ ORDER BY COALESCE(last_activity_at, created_at) ASC`
370
+ ).all();
371
+ res.json({ agents: rows, count: rows.length });
372
+ } catch (err) {
373
+ next(err);
374
+ }
375
+ });
376
+ router.post("/accounts/cleanup", requireMaster, async (req, res, next) => {
377
+ try {
378
+ const hours = Math.max(parseInt(req.body?.hours) || 24, 1);
379
+ const dryRun = req.body?.dryRun === true;
380
+ const rows = db.prepare(
381
+ `SELECT id, name, email FROM agents
382
+ WHERE persistent = 0 AND COALESCE(last_activity_at, created_at) < datetime('now', '-${hours} hours')`
383
+ ).all();
384
+ if (dryRun) {
385
+ res.json({ wouldDelete: rows, count: rows.length, dryRun: true });
386
+ return;
387
+ }
388
+ const deleted = [];
389
+ for (const row of rows) {
390
+ try {
391
+ await accountManager.delete(row.id);
392
+ deleted.push(row.name);
393
+ } catch {
394
+ }
395
+ }
396
+ res.json({ deleted, count: deleted.length });
397
+ } catch (err) {
398
+ next(err);
399
+ }
400
+ });
401
+ router.get("/accounts/:id", requireMaster, async (req, res, next) => {
402
+ try {
403
+ const agent = await accountManager.getById(req.params.id);
404
+ if (!agent) {
405
+ res.status(404).json({ error: "Agent not found" });
406
+ return;
407
+ }
408
+ res.json(sanitizeAgent(agent));
409
+ } catch (err) {
410
+ next(err);
411
+ }
412
+ });
413
+ router.patch("/accounts/me", requireAgent, async (req, res, next) => {
414
+ try {
415
+ const agent = req.agent;
416
+ const { metadata } = req.body || {};
417
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
418
+ res.status(400).json({ error: "metadata must be an object" });
419
+ return;
420
+ }
421
+ const updated = await accountManager.updateMetadata(agent.id, metadata);
422
+ if (!updated) {
423
+ res.status(404).json({ error: "Agent not found" });
424
+ return;
425
+ }
426
+ res.json(sanitizeAgent(updated));
427
+ } catch (err) {
428
+ next(err);
429
+ }
430
+ });
431
+ router.patch("/accounts/:id/persistent", requireMaster, async (req, res, next) => {
432
+ try {
433
+ const persistent = req.body?.persistent === true ? 1 : 0;
434
+ const result = db.prepare("UPDATE agents SET persistent = ? WHERE id = ?").run(persistent, req.params.id);
435
+ if (result.changes === 0) {
436
+ res.status(404).json({ error: "Agent not found" });
437
+ return;
438
+ }
439
+ res.json({ ok: true, persistent: persistent === 1 });
440
+ } catch (err) {
441
+ next(err);
442
+ }
443
+ });
444
+ router.delete("/accounts/:id", requireMaster, async (req, res, next) => {
445
+ try {
446
+ const allAgents = await accountManager.list();
447
+ if (allAgents.length <= 1) {
448
+ res.status(400).json({ error: "Cannot delete the last agent. At least one agent must remain." });
449
+ return;
450
+ }
451
+ const archive = req.query.archive !== "false";
452
+ const reason = req.query.reason || void 0;
453
+ const deletedBy = req.query.deletedBy || "api";
454
+ if (archive) {
455
+ const report = await deletionService.archiveAndDelete(req.params.id, { deletedBy, reason });
456
+ const { emails: _emails, ...summary } = report;
457
+ res.json(summary);
458
+ } else {
459
+ const deleted = await accountManager.delete(req.params.id);
460
+ if (!deleted) {
461
+ res.status(404).json({ error: "Agent not found" });
462
+ return;
463
+ }
464
+ res.status(204).send();
465
+ }
466
+ } catch (err) {
467
+ const msg = err.message ?? "";
468
+ if (msg.includes("not found")) {
469
+ res.status(404).json({ error: msg });
470
+ return;
471
+ }
472
+ next(err);
473
+ }
474
+ });
475
+ return router;
476
+ }
477
+
478
+ // src/routes/mail.ts
479
+ import { Router as Router3 } from "express";
480
+ import crypto from "crypto";
481
+ import {
482
+ MailSender,
483
+ MailReceiver,
484
+ parseEmail,
485
+ scoreEmail,
486
+ sanitizeEmail,
487
+ isInternalEmail,
488
+ scanOutboundEmail
489
+ } from "@agenticmail/core";
490
+ var senderCache = /* @__PURE__ */ new Map();
491
+ var receiverCache = /* @__PURE__ */ new Map();
492
+ var receiverPending = /* @__PURE__ */ new Map();
493
+ var CACHE_TTL_MS = 10 * 60 * 1e3;
494
+ var MAX_CACHE_SIZE = 100;
495
+ var draining = false;
496
+ function getAgentPassword(agent) {
497
+ return agent.metadata?._password || agent.name;
498
+ }
499
+ var evictionTimer = null;
500
+ function startEvictionTimer() {
501
+ if (evictionTimer) return;
502
+ evictionTimer = setInterval(evictStaleEntries, 6e4);
503
+ }
504
+ function evictStaleEntries() {
505
+ const now = Date.now();
506
+ for (const [key, entry] of senderCache) {
507
+ if (now - entry.createdAt > CACHE_TTL_MS) {
508
+ try {
509
+ entry.sender.close();
510
+ } catch {
511
+ }
512
+ senderCache.delete(key);
513
+ }
514
+ }
515
+ for (const [key, entry] of receiverCache) {
516
+ if (now - entry.createdAt > CACHE_TTL_MS) {
517
+ entry.receiver.disconnect().catch(() => {
518
+ });
519
+ receiverCache.delete(key);
520
+ }
521
+ }
522
+ }
523
+ function getSender(authUser, fromEmail, password, config) {
524
+ if (draining) throw new Error("Server is shutting down");
525
+ const cacheKey = `${authUser}:${fromEmail}`;
526
+ const cached = senderCache.get(cacheKey);
527
+ if (cached) return cached.sender;
528
+ if (senderCache.size >= MAX_CACHE_SIZE) {
529
+ const oldest = senderCache.keys().next().value;
530
+ if (oldest) {
531
+ try {
532
+ senderCache.get(oldest)?.sender.close();
533
+ } catch {
534
+ }
535
+ senderCache.delete(oldest);
536
+ }
537
+ }
538
+ const sender = new MailSender({
539
+ host: config.smtp.host,
540
+ port: config.smtp.port,
541
+ email: fromEmail,
542
+ password,
543
+ authUser
544
+ });
545
+ senderCache.set(cacheKey, { sender, createdAt: Date.now() });
546
+ startEvictionTimer();
547
+ return sender;
548
+ }
549
+ async function getReceiver(authUser, password, config) {
550
+ if (draining) throw new Error("Server is shutting down");
551
+ const cached = receiverCache.get(authUser);
552
+ if (cached) {
553
+ try {
554
+ const client = cached.receiver.getImapClient();
555
+ if (client.usable) return cached.receiver;
556
+ } catch {
557
+ }
558
+ try {
559
+ await cached.receiver.disconnect();
560
+ } catch {
561
+ }
562
+ receiverCache.delete(authUser);
563
+ }
564
+ const pending = receiverPending.get(authUser);
565
+ if (pending) return pending;
566
+ const promise = createReceiver(authUser, password, config);
567
+ receiverPending.set(authUser, promise);
568
+ try {
569
+ return await promise;
570
+ } finally {
571
+ receiverPending.delete(authUser);
572
+ }
573
+ }
574
+ async function createReceiver(authUser, password, config) {
575
+ if (receiverCache.size >= MAX_CACHE_SIZE) {
576
+ const oldest = receiverCache.keys().next().value;
577
+ if (oldest) {
578
+ receiverCache.get(oldest)?.receiver.disconnect().catch(() => {
579
+ });
580
+ receiverCache.delete(oldest);
581
+ }
582
+ }
583
+ const receiver = new MailReceiver({
584
+ host: config.imap.host,
585
+ port: config.imap.port,
586
+ email: authUser,
587
+ password
588
+ });
589
+ try {
590
+ await receiver.connect();
591
+ } catch (err) {
592
+ try {
593
+ await receiver.disconnect();
594
+ } catch {
595
+ }
596
+ throw err;
597
+ }
598
+ receiverCache.set(authUser, { receiver, createdAt: Date.now() });
599
+ startEvictionTimer();
600
+ return receiver;
601
+ }
602
+ async function closeCaches() {
603
+ draining = true;
604
+ if (evictionTimer) {
605
+ clearInterval(evictionTimer);
606
+ evictionTimer = null;
607
+ }
608
+ for (const [, entry] of senderCache) {
609
+ try {
610
+ entry.sender.close();
611
+ } catch {
612
+ }
613
+ }
614
+ senderCache.clear();
615
+ for (const [, entry] of receiverCache) {
616
+ try {
617
+ await entry.receiver.disconnect();
618
+ } catch {
619
+ }
620
+ }
621
+ receiverCache.clear();
622
+ }
623
+ function saveSentCopy(authUser, password, config, raw) {
624
+ (async () => {
625
+ try {
626
+ const receiver = await getReceiver(authUser, password, config);
627
+ await receiver.appendMessage(raw, "Sent Items", ["\\Seen"]);
628
+ } catch (err) {
629
+ console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
630
+ }
631
+ })();
632
+ }
633
+ function createMailRoutes(accountManager, config, db, gatewayManager) {
634
+ const router = Router3();
635
+ router.post("/mail/send", requireAgent, async (req, res, next) => {
636
+ try {
637
+ if (!req.body || typeof req.body !== "object") {
638
+ res.status(400).json({ error: "Request body must be JSON" });
639
+ return;
640
+ }
641
+ const agent = req.agent;
642
+ const { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, allowSensitive } = req.body;
643
+ if (!to || !subject) {
644
+ res.status(400).json({ error: "to and subject are required" });
645
+ return;
646
+ }
647
+ if (typeof to !== "string" && !Array.isArray(to)) {
648
+ res.status(400).json({ error: "to must be a string or array of strings" });
649
+ return;
650
+ }
651
+ let outboundWarnings;
652
+ let outboundSummary;
653
+ if (!(allowSensitive && req.isMaster)) {
654
+ const scanResult = scanOutboundEmail({
655
+ to: Array.isArray(to) ? to.join(", ") : to,
656
+ subject,
657
+ text,
658
+ html,
659
+ attachments: Array.isArray(attachments) ? attachments.map((a) => ({
660
+ filename: a.filename || "",
661
+ contentType: a.contentType,
662
+ content: a.content,
663
+ encoding: a.encoding
664
+ })) : void 0
665
+ });
666
+ if (scanResult.blocked) {
667
+ const pendingId = crypto.randomUUID();
668
+ const ownerName2 = agent.metadata?.ownerName;
669
+ const fromName2 = ownerName2 ? `${agent.name} from ${ownerName2}` : agent.name;
670
+ const mailOptions = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName: fromName2 };
671
+ db.prepare(
672
+ `INSERT INTO pending_outbound (id, agent_id, mail_options, warnings, summary) VALUES (?, ?, ?, ?, ?)`
673
+ ).run(pendingId, agent.id, JSON.stringify(mailOptions), JSON.stringify(scanResult.warnings), scanResult.summary);
674
+ if (gatewayManager) {
675
+ const ownerEmail = gatewayManager.getConfig()?.relay?.email;
676
+ if (ownerEmail) {
677
+ const warningList = scanResult.warnings.map((w) => ` - [${w.severity.toUpperCase()}] ${w.ruleId}: ${w.description}${w.match ? ` (matched: ${w.match})` : ""}`).join("\n");
678
+ const recipientLine = Array.isArray(to) ? to.join(", ") : to;
679
+ const emailPreview = [
680
+ "\u2500".repeat(50),
681
+ `From: ${fromName2} <${agent.email}>`,
682
+ `To: ${recipientLine}`
683
+ ];
684
+ if (cc) emailPreview.push(`CC: ${Array.isArray(cc) ? cc.join(", ") : cc}`);
685
+ if (bcc) emailPreview.push(`BCC: ${Array.isArray(bcc) ? bcc.join(", ") : bcc}`);
686
+ emailPreview.push(`Subject: ${subject}`);
687
+ if (Array.isArray(attachments) && attachments.length > 0) {
688
+ const attNames = attachments.map((a) => a.filename || "unnamed").join(", ");
689
+ emailPreview.push(`Attachments: ${attNames}`);
690
+ }
691
+ emailPreview.push("\u2500".repeat(50));
692
+ if (text) emailPreview.push("", text);
693
+ else if (html) emailPreview.push("", "[HTML content \u2014 see original for formatted version]");
694
+ else emailPreview.push("", "[No body content]");
695
+ emailPreview.push("\u2500".repeat(50));
696
+ gatewayManager.routeOutbound(agent.name, {
697
+ to: ownerEmail,
698
+ subject: `[Approval Required] Blocked email from "${agent.name}" \u2014 "${subject}"`,
699
+ text: [
700
+ `Your agent "${agent.name}" attempted to send an email that was blocked by the outbound security guard.`,
701
+ "",
702
+ "SECURITY WARNINGS:",
703
+ warningList,
704
+ "",
705
+ "FULL EMAIL FOR REVIEW:",
706
+ ...emailPreview,
707
+ "",
708
+ `Pending ID: ${pendingId}`,
709
+ "",
710
+ "ACTION REQUIRED:",
711
+ 'Reply "approve" to this email to send it, or "reject" to discard it.',
712
+ "If you do not respond, the agent will follow up with you."
713
+ ].join("\n"),
714
+ fromName: "Agentic Mail"
715
+ }).then((result2) => {
716
+ if (result2?.messageId) {
717
+ db.prepare("UPDATE pending_outbound SET notification_message_id = ? WHERE id = ?").run(result2.messageId, pendingId);
718
+ }
719
+ }).catch(() => {
720
+ });
721
+ }
722
+ }
723
+ res.json({
724
+ sent: false,
725
+ blocked: true,
726
+ pendingId,
727
+ warnings: scanResult.warnings,
728
+ summary: scanResult.summary
729
+ });
730
+ return;
731
+ }
732
+ if (scanResult.warnings.length > 0) {
733
+ outboundWarnings = scanResult.warnings;
734
+ outboundSummary = scanResult.summary;
735
+ }
736
+ }
737
+ const ownerName = agent.metadata?.ownerName;
738
+ const fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
739
+ const mailOpts = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName };
740
+ const password = getAgentPassword(agent);
741
+ if (gatewayManager) {
742
+ const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
743
+ if (gatewayResult) {
744
+ if (gatewayResult.raw) {
745
+ saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
746
+ }
747
+ const { raw: _raw2, ...response2 } = gatewayResult;
748
+ res.json({ ...response2, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
749
+ return;
750
+ }
751
+ }
752
+ const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
753
+ const result = await sender.send(mailOpts);
754
+ saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
755
+ const { raw: _raw, ...response } = result;
756
+ res.json({ ...response, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
757
+ } catch (err) {
758
+ next(err);
759
+ }
760
+ });
761
+ router.get("/mail/inbox", requireAgent, async (req, res, next) => {
762
+ try {
763
+ const agent = req.agent;
764
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
765
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
766
+ const password = getAgentPassword(agent);
767
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
768
+ const mailboxInfo = await receiver.getMailboxInfo("INBOX");
769
+ const envelopes = await receiver.listEnvelopes("INBOX", { limit, offset });
770
+ res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists });
771
+ } catch (err) {
772
+ next(err);
773
+ }
774
+ });
775
+ router.get("/mail/messages/:uid", requireAgent, async (req, res, next) => {
776
+ try {
777
+ const agent = req.agent;
778
+ const uid = parseInt(req.params.uid);
779
+ if (isNaN(uid) || uid < 1) {
780
+ res.status(400).json({ error: "Invalid UID" });
781
+ return;
782
+ }
783
+ const folder = req.query.folder || "INBOX";
784
+ const password = getAgentPassword(agent);
785
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
786
+ const raw = await receiver.fetchMessage(uid, folder);
787
+ const parsed = await parseEmail(raw);
788
+ if (isInternalEmail(parsed)) {
789
+ res.json({
790
+ ...parsed,
791
+ security: { internal: true, spamScore: 0, isSpam: false, isWarning: false }
792
+ });
793
+ return;
794
+ }
795
+ const sanitized = sanitizeEmail(parsed);
796
+ const spamScore = scoreEmail(parsed);
797
+ res.json({
798
+ ...parsed,
799
+ text: sanitized.text,
800
+ html: sanitized.html,
801
+ security: {
802
+ spamScore: spamScore.score,
803
+ isSpam: spamScore.isSpam,
804
+ isWarning: spamScore.isWarning,
805
+ topCategory: spamScore.topCategory,
806
+ matches: spamScore.matches.map((m) => m.ruleId),
807
+ sanitized: sanitized.wasModified,
808
+ sanitizeDetections: sanitized.detections
809
+ }
810
+ });
811
+ } catch (err) {
812
+ next(err);
813
+ }
814
+ });
815
+ router.get("/mail/messages/:uid/attachments/:index", requireAgent, async (req, res, next) => {
816
+ try {
817
+ const agent = req.agent;
818
+ const uid = parseInt(req.params.uid);
819
+ const index = parseInt(req.params.index);
820
+ if (isNaN(uid) || uid < 1) {
821
+ res.status(400).json({ error: "Invalid UID" });
822
+ return;
823
+ }
824
+ if (isNaN(index) || index < 0) {
825
+ res.status(400).json({ error: "Invalid attachment index" });
826
+ return;
827
+ }
828
+ const folder = req.query.folder || "INBOX";
829
+ const password = getAgentPassword(agent);
830
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
831
+ const raw = await receiver.fetchMessage(uid, folder);
832
+ const parsed = await parseEmail(raw);
833
+ if (!parsed.attachments || index >= parsed.attachments.length) {
834
+ res.status(404).json({ error: "Attachment not found" });
835
+ return;
836
+ }
837
+ const att = parsed.attachments[index];
838
+ res.setHeader("Content-Type", att.contentType || "application/octet-stream");
839
+ res.setHeader("Content-Disposition", `attachment; filename="${att.filename.replace(/"/g, '\\"')}"`);
840
+ res.setHeader("Content-Length", att.content.length);
841
+ res.send(att.content);
842
+ } catch (err) {
843
+ next(err);
844
+ }
845
+ });
846
+ router.post("/mail/search", requireAgent, async (req, res, next) => {
847
+ try {
848
+ if (!req.body || typeof req.body !== "object") {
849
+ res.status(400).json({ error: "Request body must be JSON" });
850
+ return;
851
+ }
852
+ const agent = req.agent;
853
+ const { from, to, subject, since, before, seen, text, searchRelay } = req.body;
854
+ const password = getAgentPassword(agent);
855
+ const sinceDate = since ? new Date(since) : void 0;
856
+ const beforeDate = before ? new Date(before) : void 0;
857
+ if (sinceDate && isNaN(sinceDate.getTime())) {
858
+ res.status(400).json({ error: 'Invalid "since" date' });
859
+ return;
860
+ }
861
+ if (beforeDate && isNaN(beforeDate.getTime())) {
862
+ res.status(400).json({ error: 'Invalid "before" date' });
863
+ return;
864
+ }
865
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
866
+ const uids = await receiver.search({
867
+ from,
868
+ to,
869
+ subject,
870
+ since: sinceDate,
871
+ before: beforeDate,
872
+ seen,
873
+ text
874
+ });
875
+ let relayResults;
876
+ if (searchRelay === true && gatewayManager) {
877
+ try {
878
+ const relayHits = await gatewayManager.searchRelay({
879
+ from,
880
+ to,
881
+ subject,
882
+ text,
883
+ since: sinceDate,
884
+ before: beforeDate,
885
+ seen
886
+ });
887
+ if (relayHits.length > 0) {
888
+ relayResults = relayHits.map((r) => ({
889
+ uid: r.uid,
890
+ source: r.source,
891
+ account: r.account,
892
+ messageId: r.messageId,
893
+ subject: r.subject,
894
+ from: r.from,
895
+ to: r.to,
896
+ date: r.date,
897
+ flags: r.flags
898
+ }));
899
+ }
900
+ } catch {
901
+ }
902
+ }
903
+ res.json({ uids, ...relayResults ? { relayResults } : {} });
904
+ } catch (err) {
905
+ next(err);
906
+ }
907
+ });
908
+ router.post("/mail/import-relay", requireAgent, async (req, res, next) => {
909
+ try {
910
+ const { uid } = req.body || {};
911
+ if (!uid || typeof uid !== "number" || uid < 1) {
912
+ res.status(400).json({ error: "uid (number) is required" });
913
+ return;
914
+ }
915
+ if (!gatewayManager) {
916
+ res.status(400).json({ error: "No gateway configured" });
917
+ return;
918
+ }
919
+ const agent = req.agent;
920
+ const result = await gatewayManager.importRelayMessage(uid, agent.name);
921
+ if (!result.success) {
922
+ res.status(400).json({ error: result.error || "Import failed" });
923
+ return;
924
+ }
925
+ res.json({ ok: true, message: "Email imported to local inbox. Use /inbox or list_inbox to see it." });
926
+ } catch (err) {
927
+ next(err);
928
+ }
929
+ });
930
+ router.post("/mail/messages/:uid/seen", requireAgent, async (req, res, next) => {
931
+ try {
932
+ const agent = req.agent;
933
+ const uid = parseInt(req.params.uid);
934
+ if (isNaN(uid) || uid < 1) {
935
+ res.status(400).json({ error: "Invalid UID" });
936
+ return;
937
+ }
938
+ const password = getAgentPassword(agent);
939
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
940
+ await receiver.markSeen(uid);
941
+ res.json({ ok: true });
942
+ } catch (err) {
943
+ next(err);
944
+ }
945
+ });
946
+ router.delete("/mail/messages/:uid", requireAgent, async (req, res, next) => {
947
+ try {
948
+ const agent = req.agent;
949
+ const uid = parseInt(req.params.uid);
950
+ if (isNaN(uid) || uid < 1) {
951
+ res.status(400).json({ error: "Invalid UID" });
952
+ return;
953
+ }
954
+ const password = getAgentPassword(agent);
955
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
956
+ await receiver.deleteMessage(uid);
957
+ res.status(204).send();
958
+ } catch (err) {
959
+ next(err);
960
+ }
961
+ });
962
+ router.post("/mail/messages/:uid/unseen", requireAgent, async (req, res, next) => {
963
+ try {
964
+ const agent = req.agent;
965
+ const uid = parseInt(req.params.uid);
966
+ if (isNaN(uid) || uid < 1) {
967
+ res.status(400).json({ error: "Invalid UID" });
968
+ return;
969
+ }
970
+ const password = getAgentPassword(agent);
971
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
972
+ await receiver.markUnseen(uid);
973
+ res.json({ ok: true });
974
+ } catch (err) {
975
+ next(err);
976
+ }
977
+ });
978
+ router.post("/mail/messages/:uid/move", requireAgent, async (req, res, next) => {
979
+ try {
980
+ const agent = req.agent;
981
+ const uid = parseInt(req.params.uid);
982
+ if (isNaN(uid) || uid < 1) {
983
+ res.status(400).json({ error: "Invalid UID" });
984
+ return;
985
+ }
986
+ const { from: fromFolder, to: toFolder } = req.body || {};
987
+ if (!toFolder) {
988
+ res.status(400).json({ error: "to (destination folder) is required" });
989
+ return;
990
+ }
991
+ const password = getAgentPassword(agent);
992
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
993
+ await receiver.moveMessage(uid, fromFolder || "INBOX", toFolder);
994
+ res.json({ ok: true });
995
+ } catch (err) {
996
+ next(err);
997
+ }
998
+ });
999
+ router.get("/mail/folders", requireAgent, async (req, res, next) => {
1000
+ try {
1001
+ const agent = req.agent;
1002
+ const password = getAgentPassword(agent);
1003
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1004
+ const folders = await receiver.listFolders();
1005
+ res.json({ folders });
1006
+ } catch (err) {
1007
+ next(err);
1008
+ }
1009
+ });
1010
+ router.post("/mail/folders", requireAgent, async (req, res, next) => {
1011
+ try {
1012
+ const agent = req.agent;
1013
+ const { name: name2 } = req.body || {};
1014
+ if (!name2 || typeof name2 !== "string" || !name2.trim()) {
1015
+ res.status(400).json({ error: "name is required" });
1016
+ return;
1017
+ }
1018
+ if (name2.length > 200 || /[\\*%]/.test(name2)) {
1019
+ res.status(400).json({ error: "Invalid folder name" });
1020
+ return;
1021
+ }
1022
+ const password = getAgentPassword(agent);
1023
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1024
+ await receiver.createFolder(name2);
1025
+ res.json({ ok: true, folder: name2 });
1026
+ } catch (err) {
1027
+ next(err);
1028
+ }
1029
+ });
1030
+ router.get("/mail/folders/:folder", requireAgent, async (req, res, next) => {
1031
+ try {
1032
+ const agent = req.agent;
1033
+ const folder = decodeURIComponent(req.params.folder);
1034
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
1035
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
1036
+ const password = getAgentPassword(agent);
1037
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1038
+ let mailboxInfo;
1039
+ try {
1040
+ mailboxInfo = await receiver.getMailboxInfo(folder);
1041
+ } catch {
1042
+ res.json({ messages: [], count: 0, total: 0, folder });
1043
+ return;
1044
+ }
1045
+ const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
1046
+ res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder });
1047
+ } catch (err) {
1048
+ next(err);
1049
+ }
1050
+ });
1051
+ function validateUids(raw) {
1052
+ if (!Array.isArray(raw) || raw.length === 0) return null;
1053
+ if (raw.length > 1e3) return null;
1054
+ const nums = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0);
1055
+ return nums.length > 0 ? nums : null;
1056
+ }
1057
+ router.post("/mail/batch/delete", requireAgent, async (req, res, next) => {
1058
+ try {
1059
+ const agent = req.agent;
1060
+ const { uids: rawUids, folder } = req.body || {};
1061
+ const uids = validateUids(rawUids);
1062
+ if (!uids) {
1063
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1064
+ return;
1065
+ }
1066
+ const password = getAgentPassword(agent);
1067
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1068
+ await receiver.batchDelete(uids, folder || "INBOX");
1069
+ res.json({ ok: true, deleted: uids.length });
1070
+ } catch (err) {
1071
+ next(err);
1072
+ }
1073
+ });
1074
+ router.post("/mail/batch/seen", requireAgent, async (req, res, next) => {
1075
+ try {
1076
+ const agent = req.agent;
1077
+ const { uids: rawUids, folder } = req.body || {};
1078
+ const uids = validateUids(rawUids);
1079
+ if (!uids) {
1080
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1081
+ return;
1082
+ }
1083
+ const password = getAgentPassword(agent);
1084
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1085
+ await receiver.batchMarkSeen(uids, folder || "INBOX");
1086
+ res.json({ ok: true, marked: uids.length });
1087
+ } catch (err) {
1088
+ next(err);
1089
+ }
1090
+ });
1091
+ router.post("/mail/batch/unseen", requireAgent, async (req, res, next) => {
1092
+ try {
1093
+ const agent = req.agent;
1094
+ const { uids: rawUids, folder } = req.body || {};
1095
+ const uids = validateUids(rawUids);
1096
+ if (!uids) {
1097
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1098
+ return;
1099
+ }
1100
+ const password = getAgentPassword(agent);
1101
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1102
+ await receiver.batchMarkUnseen(uids, folder || "INBOX");
1103
+ res.json({ ok: true, marked: uids.length });
1104
+ } catch (err) {
1105
+ next(err);
1106
+ }
1107
+ });
1108
+ router.post("/mail/batch/move", requireAgent, async (req, res, next) => {
1109
+ try {
1110
+ const agent = req.agent;
1111
+ const { uids: rawUids, from: fromFolder, to: toFolder } = req.body || {};
1112
+ const uids = validateUids(rawUids);
1113
+ if (!uids) {
1114
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1115
+ return;
1116
+ }
1117
+ if (!toFolder) {
1118
+ res.status(400).json({ error: "to (destination folder) is required" });
1119
+ return;
1120
+ }
1121
+ const password = getAgentPassword(agent);
1122
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1123
+ await receiver.batchMove(uids, fromFolder || "INBOX", toFolder);
1124
+ res.json({ ok: true, moved: uids.length });
1125
+ } catch (err) {
1126
+ next(err);
1127
+ }
1128
+ });
1129
+ router.post("/mail/batch/read", requireAgent, async (req, res, next) => {
1130
+ try {
1131
+ const agent = req.agent;
1132
+ const { uids: rawUids, folder } = req.body || {};
1133
+ const uids = validateUids(rawUids);
1134
+ if (!uids) {
1135
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1136
+ return;
1137
+ }
1138
+ const password = getAgentPassword(agent);
1139
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1140
+ const rawMap = await receiver.batchFetch(uids, folder || "INBOX");
1141
+ const messages = [];
1142
+ for (const [uid, raw] of rawMap) {
1143
+ const parsed = await parseEmail(raw);
1144
+ messages.push({ uid, ...parsed });
1145
+ }
1146
+ res.json({ messages, count: messages.length });
1147
+ } catch (err) {
1148
+ next(err);
1149
+ }
1150
+ });
1151
+ router.get("/mail/spam", requireAgent, async (req, res, next) => {
1152
+ try {
1153
+ const agent = req.agent;
1154
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
1155
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
1156
+ const password = getAgentPassword(agent);
1157
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1158
+ let mailboxInfo;
1159
+ try {
1160
+ mailboxInfo = await receiver.getMailboxInfo("Spam");
1161
+ } catch {
1162
+ res.json({ messages: [], count: 0, total: 0, folder: "Spam" });
1163
+ return;
1164
+ }
1165
+ const envelopes = await receiver.listEnvelopes("Spam", { limit, offset });
1166
+ res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder: "Spam" });
1167
+ } catch (err) {
1168
+ next(err);
1169
+ }
1170
+ });
1171
+ router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
1172
+ try {
1173
+ const agent = req.agent;
1174
+ const uid = parseInt(req.params.uid);
1175
+ if (isNaN(uid) || uid < 1) {
1176
+ res.status(400).json({ error: "Invalid UID" });
1177
+ return;
1178
+ }
1179
+ const folder = req.body?.folder || "INBOX";
1180
+ const password = getAgentPassword(agent);
1181
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1182
+ try {
1183
+ await receiver.createFolder("Spam");
1184
+ } catch {
1185
+ }
1186
+ await receiver.moveMessage(uid, folder, "Spam");
1187
+ res.json({ ok: true, movedToSpam: true });
1188
+ } catch (err) {
1189
+ next(err);
1190
+ }
1191
+ });
1192
+ router.post("/mail/messages/:uid/not-spam", requireAgent, async (req, res, next) => {
1193
+ try {
1194
+ const agent = req.agent;
1195
+ const uid = parseInt(req.params.uid);
1196
+ if (isNaN(uid) || uid < 1) {
1197
+ res.status(400).json({ error: "Invalid UID" });
1198
+ return;
1199
+ }
1200
+ const password = getAgentPassword(agent);
1201
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1202
+ await receiver.moveMessage(uid, "Spam", "INBOX");
1203
+ res.json({ ok: true, movedToInbox: true });
1204
+ } catch (err) {
1205
+ next(err);
1206
+ }
1207
+ });
1208
+ router.get("/mail/messages/:uid/spam-score", requireAgent, async (req, res, next) => {
1209
+ try {
1210
+ const agent = req.agent;
1211
+ const uid = parseInt(req.params.uid);
1212
+ if (isNaN(uid) || uid < 1) {
1213
+ res.status(400).json({ error: "Invalid UID" });
1214
+ return;
1215
+ }
1216
+ const folder = req.query.folder || "INBOX";
1217
+ const password = getAgentPassword(agent);
1218
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1219
+ const raw = await receiver.fetchMessage(uid, folder);
1220
+ const parsed = await parseEmail(raw);
1221
+ if (isInternalEmail(parsed)) {
1222
+ res.json({ score: 0, isSpam: false, isWarning: false, matches: [], topCategory: null, internal: true });
1223
+ return;
1224
+ }
1225
+ const result = scoreEmail(parsed);
1226
+ res.json(result);
1227
+ } catch (err) {
1228
+ next(err);
1229
+ }
1230
+ });
1231
+ router.get("/mail/digest", requireAgent, async (req, res, next) => {
1232
+ try {
1233
+ const agent = req.agent;
1234
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 50);
1235
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
1236
+ const previewLen = Math.min(Math.max(parseInt(req.query.previewLength) || 200, 50), 500);
1237
+ const folder = req.query.folder || "INBOX";
1238
+ const password = getAgentPassword(agent);
1239
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1240
+ const mailboxInfo = await receiver.getMailboxInfo(folder);
1241
+ const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
1242
+ const uids = envelopes.map((e) => e.uid);
1243
+ const rawMap = uids.length > 0 ? await receiver.batchFetch(uids, folder) : /* @__PURE__ */ new Map();
1244
+ const messages = [];
1245
+ for (const env of envelopes) {
1246
+ let preview = "";
1247
+ const raw = rawMap.get(env.uid);
1248
+ if (raw) {
1249
+ const parsed = await parseEmail(raw);
1250
+ preview = (parsed.text || "").slice(0, previewLen);
1251
+ }
1252
+ messages.push({
1253
+ uid: env.uid,
1254
+ subject: env.subject,
1255
+ from: env.from,
1256
+ to: env.to,
1257
+ date: env.date,
1258
+ flags: [...env.flags],
1259
+ size: env.size,
1260
+ preview
1261
+ });
1262
+ }
1263
+ res.json({ messages, count: messages.length, total: mailboxInfo.exists });
1264
+ } catch (err) {
1265
+ next(err);
1266
+ }
1267
+ });
1268
+ router.get("/mail/pending", requireAuth, async (req, res) => {
1269
+ const rows = req.isMaster ? db.prepare(
1270
+ `SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
1271
+ FROM pending_outbound ORDER BY created_at DESC LIMIT 50`
1272
+ ).all() : db.prepare(
1273
+ `SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
1274
+ FROM pending_outbound WHERE agent_id = ? ORDER BY created_at DESC LIMIT 50`
1275
+ ).all(req.agent.id);
1276
+ const pending = rows.map((r) => {
1277
+ const opts = JSON.parse(r.mail_options);
1278
+ return {
1279
+ id: r.id,
1280
+ agentId: r.agent_id,
1281
+ to: opts.to,
1282
+ subject: opts.subject,
1283
+ warnings: JSON.parse(r.warnings),
1284
+ summary: r.summary,
1285
+ status: r.status,
1286
+ createdAt: r.created_at,
1287
+ resolvedAt: r.resolved_at,
1288
+ resolvedBy: r.resolved_by
1289
+ };
1290
+ });
1291
+ res.json({ pending, count: pending.length });
1292
+ });
1293
+ router.get("/mail/pending/:id", requireAuth, async (req, res) => {
1294
+ const row = req.isMaster ? db.prepare(`SELECT * FROM pending_outbound WHERE id = ?`).get(req.params.id) : db.prepare(`SELECT * FROM pending_outbound WHERE id = ? AND agent_id = ?`).get(req.params.id, req.agent.id);
1295
+ if (!row) {
1296
+ res.status(404).json({ error: "Pending email not found" });
1297
+ return;
1298
+ }
1299
+ res.json({
1300
+ id: row.id,
1301
+ mailOptions: JSON.parse(row.mail_options),
1302
+ warnings: JSON.parse(row.warnings),
1303
+ summary: row.summary,
1304
+ status: row.status,
1305
+ createdAt: row.created_at,
1306
+ resolvedAt: row.resolved_at,
1307
+ resolvedBy: row.resolved_by
1308
+ });
1309
+ });
1310
+ router.post("/mail/pending/:id/approve", requireMaster, async (req, res, next) => {
1311
+ try {
1312
+ const row = db.prepare(
1313
+ `SELECT * FROM pending_outbound WHERE id = ?`
1314
+ ).get(req.params.id);
1315
+ if (!row) {
1316
+ res.status(404).json({ error: "Pending email not found" });
1317
+ return;
1318
+ }
1319
+ if (row.status !== "pending") {
1320
+ res.status(400).json({ error: `Email already ${row.status}` });
1321
+ return;
1322
+ }
1323
+ const agent = await accountManager.getById(row.agent_id);
1324
+ if (!agent) {
1325
+ res.status(404).json({ error: "Agent account no longer exists" });
1326
+ return;
1327
+ }
1328
+ const mailOpts = JSON.parse(row.mail_options);
1329
+ const ownerName = agent.metadata?.ownerName;
1330
+ mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
1331
+ if (Array.isArray(mailOpts.attachments)) {
1332
+ for (const att of mailOpts.attachments) {
1333
+ if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
1334
+ att.content = Buffer.from(att.content.data);
1335
+ }
1336
+ }
1337
+ }
1338
+ const password = getAgentPassword(agent);
1339
+ let response;
1340
+ if (gatewayManager) {
1341
+ const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
1342
+ if (gatewayResult) {
1343
+ if (gatewayResult.raw) {
1344
+ saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
1345
+ }
1346
+ const { raw: _raw, ...rest } = gatewayResult;
1347
+ response = rest;
1348
+ }
1349
+ }
1350
+ if (!response) {
1351
+ const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
1352
+ const result = await sender.send(mailOpts);
1353
+ saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
1354
+ const { raw: _raw, ...rest } = result;
1355
+ response = rest;
1356
+ }
1357
+ db.prepare(
1358
+ `UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
1359
+ ).run("master", row.id);
1360
+ res.json({ ...response, approved: true, pendingId: row.id });
1361
+ } catch (err) {
1362
+ next(err);
1363
+ }
1364
+ });
1365
+ router.post("/mail/pending/:id/reject", requireMaster, async (req, res) => {
1366
+ const row = db.prepare(
1367
+ `SELECT * FROM pending_outbound WHERE id = ?`
1368
+ ).get(req.params.id);
1369
+ if (!row) {
1370
+ res.status(404).json({ error: "Pending email not found" });
1371
+ return;
1372
+ }
1373
+ if (row.status !== "pending") {
1374
+ res.status(400).json({ error: `Email already ${row.status}` });
1375
+ return;
1376
+ }
1377
+ db.prepare(
1378
+ `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
1379
+ ).run("master", row.id);
1380
+ res.json({ ok: true, rejected: true, pendingId: row.id });
1381
+ });
1382
+ return router;
1383
+ }
1384
+
1385
+ // src/routes/inbound.ts
1386
+ import { Router as Router4 } from "express";
1387
+ import {
1388
+ parseEmail as parseEmail2,
1389
+ MailSender as MailSender2
1390
+ } from "@agenticmail/core";
1391
+ var INBOUND_SECRET = process.env.AGENTICMAIL_INBOUND_SECRET || "inbound_2sabi_secret_key";
1392
+ var DEBUG = () => !!process.env.AGENTICMAIL_DEBUG;
1393
+ function createInboundRoutes(accountManager, config, gatewayManager) {
1394
+ const router = Router4();
1395
+ router.post("/mail/inbound", async (req, res, next) => {
1396
+ try {
1397
+ const secret = req.headers["x-inbound-secret"];
1398
+ if (secret !== INBOUND_SECRET) {
1399
+ res.status(401).json({ error: "Invalid inbound secret" });
1400
+ return;
1401
+ }
1402
+ const { from, to, subject, rawEmail } = req.body;
1403
+ if (!to || !rawEmail) {
1404
+ res.status(400).json({ error: "to and rawEmail are required" });
1405
+ return;
1406
+ }
1407
+ const recipientEmail = typeof to === "string" ? to : to[0];
1408
+ const localPart = recipientEmail.split("@")[0];
1409
+ const agent = await accountManager.getByName(localPart);
1410
+ if (!agent) {
1411
+ console.warn(`[Inbound] No agent found for "${localPart}" (${recipientEmail})`);
1412
+ res.status(404).json({ error: `No agent found for ${recipientEmail}` });
1413
+ return;
1414
+ }
1415
+ const agentPassword = agent.metadata?._password;
1416
+ if (!agentPassword) {
1417
+ console.warn(`[Inbound] No password for agent "${agent.name}"`);
1418
+ res.status(500).json({ error: "Agent has no password configured" });
1419
+ return;
1420
+ }
1421
+ const rawBuffer = Buffer.from(rawEmail, "base64");
1422
+ const parsed = await parseEmail2(rawBuffer);
1423
+ const originalMessageId = parsed.messageId;
1424
+ if (originalMessageId && gatewayManager?.isAlreadyDelivered(originalMessageId, agent.name)) {
1425
+ if (DEBUG()) console.log(`[Inbound] Skipping duplicate: ${originalMessageId} \u2192 ${agent.name}`);
1426
+ res.json({ ok: true, delivered: agent.email, duplicate: true });
1427
+ return;
1428
+ }
1429
+ if (DEBUG()) console.log(`[Inbound] Delivering email to ${agent.email} from ${from} (subject: ${subject || parsed.subject})`);
1430
+ const sender = new MailSender2({
1431
+ host: config.smtp.host,
1432
+ port: config.smtp.port,
1433
+ email: agent.email,
1434
+ password: agentPassword,
1435
+ authUser: agent.stalwartPrincipal
1436
+ });
1437
+ try {
1438
+ await sender.send({
1439
+ to: agent.email,
1440
+ subject: parsed.subject || subject || "(no subject)",
1441
+ text: parsed.text || void 0,
1442
+ html: parsed.html || void 0,
1443
+ replyTo: from || parsed.from?.[0]?.address,
1444
+ inReplyTo: parsed.inReplyTo,
1445
+ references: parsed.references,
1446
+ headers: {
1447
+ "X-AgenticMail-Inbound": "cloudflare-worker",
1448
+ "X-Original-From": from || parsed.from?.[0]?.address || "",
1449
+ ...parsed.messageId ? { "X-Original-Message-Id": parsed.messageId } : {}
1450
+ },
1451
+ attachments: parsed.attachments?.map((a) => ({
1452
+ filename: a.filename,
1453
+ content: a.content,
1454
+ contentType: a.contentType
1455
+ }))
1456
+ });
1457
+ if (originalMessageId) gatewayManager?.recordDelivery(originalMessageId, agent.name);
1458
+ if (DEBUG()) console.log(`[Inbound] Delivered to ${agent.email}`);
1459
+ res.json({ ok: true, delivered: agent.email });
1460
+ } finally {
1461
+ sender.close();
1462
+ }
1463
+ } catch (err) {
1464
+ next(err);
1465
+ }
1466
+ });
1467
+ return router;
1468
+ }
1469
+
1470
+ // src/routes/events.ts
1471
+ import { Router as Router6 } from "express";
1472
+ import {
1473
+ InboxWatcher,
1474
+ MailReceiver as MailReceiver2,
1475
+ parseEmail as parseEmail3,
1476
+ scoreEmail as scoreEmail2,
1477
+ isInternalEmail as isInternalEmail2
1478
+ } from "@agenticmail/core";
1479
+ import { v4 as uuidv42 } from "uuid";
1480
+
1481
+ // src/routes/features.ts
1482
+ import { Router as Router5 } from "express";
1483
+ import { v4 as uuidv4 } from "uuid";
1484
+ import {
1485
+ MailSender as MailSender3
1486
+ } from "@agenticmail/core";
1487
+ function parseScheduleTime(input) {
1488
+ const trimmed = input.trim();
1489
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(trimmed)) {
1490
+ const d = new Date(trimmed);
1491
+ return isNaN(d.getTime()) ? null : d;
1492
+ }
1493
+ const lower = trimmed.toLowerCase();
1494
+ const relativeMatch = lower.match(/^in\s+(\d+)\s+(minute|minutes|min|mins|hour|hours|hr|hrs|day|days)$/);
1495
+ if (relativeMatch) {
1496
+ const amount = parseInt(relativeMatch[1], 10);
1497
+ const unit = relativeMatch[2];
1498
+ const now = Date.now();
1499
+ if (unit.startsWith("min")) return new Date(now + amount * 6e4);
1500
+ if (unit.startsWith("h")) return new Date(now + amount * 36e5);
1501
+ if (unit.startsWith("d")) return new Date(now + amount * 864e5);
1502
+ }
1503
+ const tomorrowMatch = lower.match(/^tomorrow\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
1504
+ if (tomorrowMatch) {
1505
+ const tomorrow = /* @__PURE__ */ new Date();
1506
+ tomorrow.setDate(tomorrow.getDate() + 1);
1507
+ let hour = parseInt(tomorrowMatch[1], 10);
1508
+ const min = tomorrowMatch[2] ? parseInt(tomorrowMatch[2], 10) : 0;
1509
+ const ampm = tomorrowMatch[3];
1510
+ if (ampm === "pm" && hour !== 12) hour += 12;
1511
+ if (ampm === "am" && hour === 12) hour = 0;
1512
+ tomorrow.setHours(hour, min, 0, 0);
1513
+ return tomorrow;
1514
+ }
1515
+ const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
1516
+ const nextDayMatch = lower.match(/^next\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
1517
+ if (nextDayMatch) {
1518
+ const targetDay = dayNames.indexOf(nextDayMatch[1]);
1519
+ let hour = parseInt(nextDayMatch[2], 10);
1520
+ const min = nextDayMatch[3] ? parseInt(nextDayMatch[3], 10) : 0;
1521
+ const ampm = nextDayMatch[4];
1522
+ if (ampm === "pm" && hour !== 12) hour += 12;
1523
+ if (ampm === "am" && hour === 12) hour = 0;
1524
+ const result = /* @__PURE__ */ new Date();
1525
+ const currentDay = result.getDay();
1526
+ let daysUntil = targetDay - currentDay;
1527
+ if (daysUntil <= 0) daysUntil += 7;
1528
+ result.setDate(result.getDate() + daysUntil);
1529
+ result.setHours(hour, min, 0, 0);
1530
+ return result;
1531
+ }
1532
+ if (lower === "tonight" || lower === "this evening") {
1533
+ const d = /* @__PURE__ */ new Date();
1534
+ d.setHours(20, 0, 0, 0);
1535
+ if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
1536
+ return d;
1537
+ }
1538
+ const humanMatch = trimmed.match(
1539
+ /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\s*(.+)?$/
1540
+ );
1541
+ if (humanMatch) {
1542
+ const [, mStr, dStr, yStr, hStr, minStr, ampmRaw, tzRaw] = humanMatch;
1543
+ const month = parseInt(mStr, 10);
1544
+ const day = parseInt(dStr, 10);
1545
+ const year = parseInt(yStr, 10);
1546
+ let hour = parseInt(hStr, 10);
1547
+ const min = parseInt(minStr, 10);
1548
+ const ampm = ampmRaw.toUpperCase();
1549
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour < 1 || hour > 12) return null;
1550
+ if (ampm === "PM" && hour !== 12) hour += 12;
1551
+ if (ampm === "AM" && hour === 12) hour = 0;
1552
+ const result = new Date(year, month - 1, day, hour, min, 0, 0);
1553
+ if (tzRaw?.trim()) {
1554
+ const TZ_OFFSETS = {
1555
+ EST: -5,
1556
+ EDT: -4,
1557
+ CST: -6,
1558
+ CDT: -5,
1559
+ MST: -7,
1560
+ MDT: -6,
1561
+ PST: -8,
1562
+ PDT: -7,
1563
+ GMT: 0,
1564
+ UTC: 0,
1565
+ BST: 1,
1566
+ CET: 1,
1567
+ CEST: 2,
1568
+ IST: 5.5,
1569
+ JST: 9,
1570
+ AEST: 10,
1571
+ AEDT: 11,
1572
+ NZST: 12,
1573
+ NZDT: 13,
1574
+ WAT: 1,
1575
+ EAT: 3,
1576
+ SAST: 2,
1577
+ HKT: 8,
1578
+ SGT: 8,
1579
+ KST: 9,
1580
+ HST: -10,
1581
+ AKST: -9,
1582
+ AKDT: -8,
1583
+ AST: -4,
1584
+ ADT: -3,
1585
+ NST: -3.5,
1586
+ NDT: -2.5
1587
+ };
1588
+ const tz = tzRaw.trim().toUpperCase();
1589
+ if (TZ_OFFSETS[tz] !== void 0) {
1590
+ const tzOffsetMs = TZ_OFFSETS[tz] * 36e5;
1591
+ const serverOffsetMs = result.getTimezoneOffset() * -6e4;
1592
+ const diff = serverOffsetMs - tzOffsetMs;
1593
+ result.setTime(result.getTime() + diff);
1594
+ }
1595
+ }
1596
+ return isNaN(result.getTime()) ? null : result;
1597
+ }
1598
+ const fallback = new Date(trimmed);
1599
+ return isNaN(fallback.getTime()) ? null : fallback;
1600
+ }
1601
+ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
1602
+ const router = Router5();
1603
+ router.get("/contacts", requireAgent, async (req, res, next) => {
1604
+ try {
1605
+ const rows = db.prepare("SELECT * FROM contacts WHERE agent_id = ? ORDER BY name, email").all(req.agent.id);
1606
+ res.json({ contacts: rows });
1607
+ } catch (err) {
1608
+ next(err);
1609
+ }
1610
+ });
1611
+ router.post("/contacts", requireAgent, async (req, res, next) => {
1612
+ try {
1613
+ const { name: name2, email, notes } = req.body || {};
1614
+ if (!email) {
1615
+ res.status(400).json({ error: "email is required" });
1616
+ return;
1617
+ }
1618
+ const id = uuidv4();
1619
+ db.prepare("INSERT OR REPLACE INTO contacts (id, agent_id, name, email, notes) VALUES (?, ?, ?, ?, ?)").run(id, req.agent.id, name2 || null, email, notes || null);
1620
+ res.json({ ok: true, id, email });
1621
+ } catch (err) {
1622
+ next(err);
1623
+ }
1624
+ });
1625
+ router.delete("/contacts/:id", requireAgent, async (req, res, next) => {
1626
+ try {
1627
+ const result = db.prepare("DELETE FROM contacts WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1628
+ if (result.changes === 0) {
1629
+ res.status(404).json({ error: "Contact not found" });
1630
+ return;
1631
+ }
1632
+ res.json({ ok: true });
1633
+ } catch (err) {
1634
+ next(err);
1635
+ }
1636
+ });
1637
+ router.get("/drafts", requireAgent, async (req, res, next) => {
1638
+ try {
1639
+ const rows = db.prepare("SELECT * FROM drafts WHERE agent_id = ? ORDER BY updated_at DESC").all(req.agent.id);
1640
+ res.json({ drafts: rows });
1641
+ } catch (err) {
1642
+ next(err);
1643
+ }
1644
+ });
1645
+ router.post("/drafts", requireAgent, async (req, res, next) => {
1646
+ try {
1647
+ const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
1648
+ const id = uuidv4();
1649
+ db.prepare(`INSERT INTO drafts (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, in_reply_to, refs)
1650
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
1651
+ id,
1652
+ req.agent.id,
1653
+ to || null,
1654
+ subject || null,
1655
+ text || null,
1656
+ html || null,
1657
+ cc || null,
1658
+ bcc || null,
1659
+ inReplyTo || null,
1660
+ references ? JSON.stringify(references) : null
1661
+ );
1662
+ res.json({ ok: true, id });
1663
+ } catch (err) {
1664
+ next(err);
1665
+ }
1666
+ });
1667
+ router.put("/drafts/:id", requireAgent, async (req, res, next) => {
1668
+ try {
1669
+ const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
1670
+ const result = db.prepare(`UPDATE drafts SET to_addr=?, subject=?, text_body=?, html_body=?,
1671
+ cc=?, bcc=?, in_reply_to=?, refs=?, updated_at=datetime('now')
1672
+ WHERE id=? AND agent_id=?`).run(
1673
+ to || null,
1674
+ subject || null,
1675
+ text || null,
1676
+ html || null,
1677
+ cc || null,
1678
+ bcc || null,
1679
+ inReplyTo || null,
1680
+ references ? JSON.stringify(references) : null,
1681
+ req.params.id,
1682
+ req.agent.id
1683
+ );
1684
+ if (result.changes === 0) {
1685
+ res.status(404).json({ error: "Draft not found" });
1686
+ return;
1687
+ }
1688
+ res.json({ ok: true });
1689
+ } catch (err) {
1690
+ next(err);
1691
+ }
1692
+ });
1693
+ router.delete("/drafts/:id", requireAgent, async (req, res, next) => {
1694
+ try {
1695
+ const result = db.prepare("DELETE FROM drafts WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1696
+ if (result.changes === 0) {
1697
+ res.status(404).json({ error: "Draft not found" });
1698
+ return;
1699
+ }
1700
+ res.json({ ok: true });
1701
+ } catch (err) {
1702
+ next(err);
1703
+ }
1704
+ });
1705
+ router.post("/drafts/:id/send", requireAgent, async (req, res, next) => {
1706
+ try {
1707
+ const draft = db.prepare("SELECT * FROM drafts WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
1708
+ if (!draft) {
1709
+ res.status(404).json({ error: "Draft not found" });
1710
+ return;
1711
+ }
1712
+ if (!draft.to_addr) {
1713
+ res.status(400).json({ error: "Draft has no recipient" });
1714
+ return;
1715
+ }
1716
+ const agent = req.agent;
1717
+ const mailOpts = {
1718
+ to: draft.to_addr,
1719
+ subject: draft.subject || "(no subject)",
1720
+ text: draft.text_body || void 0,
1721
+ html: draft.html_body || void 0,
1722
+ cc: draft.cc || void 0,
1723
+ bcc: draft.bcc || void 0,
1724
+ inReplyTo: draft.in_reply_to || void 0,
1725
+ references: draft.refs ? JSON.parse(draft.refs) : void 0
1726
+ };
1727
+ if (gatewayManager) {
1728
+ const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
1729
+ if (gatewayResult) {
1730
+ db.prepare("DELETE FROM drafts WHERE id = ?").run(draft.id);
1731
+ res.json(gatewayResult);
1732
+ return;
1733
+ }
1734
+ }
1735
+ const password = getAgentPassword(agent);
1736
+ const sender = new MailSender3({
1737
+ host: config.smtp.host,
1738
+ port: config.smtp.port,
1739
+ email: agent.email,
1740
+ password,
1741
+ authUser: agent.stalwartPrincipal
1742
+ });
1743
+ try {
1744
+ const result = await sender.send(mailOpts);
1745
+ db.prepare("DELETE FROM drafts WHERE id = ?").run(draft.id);
1746
+ res.json(result);
1747
+ } finally {
1748
+ sender.close();
1749
+ }
1750
+ } catch (err) {
1751
+ next(err);
1752
+ }
1753
+ });
1754
+ router.get("/signatures", requireAgent, async (req, res, next) => {
1755
+ try {
1756
+ const rows = db.prepare("SELECT * FROM signatures WHERE agent_id = ? ORDER BY is_default DESC, name").all(req.agent.id);
1757
+ res.json({ signatures: rows });
1758
+ } catch (err) {
1759
+ next(err);
1760
+ }
1761
+ });
1762
+ router.post("/signatures", requireAgent, async (req, res, next) => {
1763
+ try {
1764
+ const { name: name2, text, html, isDefault } = req.body || {};
1765
+ if (!name2) {
1766
+ res.status(400).json({ error: "name is required" });
1767
+ return;
1768
+ }
1769
+ const id = uuidv4();
1770
+ if (isDefault) {
1771
+ db.prepare("UPDATE signatures SET is_default = 0 WHERE agent_id = ?").run(req.agent.id);
1772
+ }
1773
+ db.prepare("INSERT OR REPLACE INTO signatures (id, agent_id, name, text_content, html_content, is_default) VALUES (?, ?, ?, ?, ?, ?)").run(id, req.agent.id, name2, text || null, html || null, isDefault ? 1 : 0);
1774
+ res.json({ ok: true, id });
1775
+ } catch (err) {
1776
+ next(err);
1777
+ }
1778
+ });
1779
+ router.delete("/signatures/:id", requireAgent, async (req, res, next) => {
1780
+ try {
1781
+ const result = db.prepare("DELETE FROM signatures WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1782
+ if (result.changes === 0) {
1783
+ res.status(404).json({ error: "Signature not found" });
1784
+ return;
1785
+ }
1786
+ res.json({ ok: true });
1787
+ } catch (err) {
1788
+ next(err);
1789
+ }
1790
+ });
1791
+ router.get("/templates", requireAgent, async (req, res, next) => {
1792
+ try {
1793
+ const rows = db.prepare("SELECT * FROM templates WHERE agent_id = ? ORDER BY name").all(req.agent.id);
1794
+ res.json({ templates: rows });
1795
+ } catch (err) {
1796
+ next(err);
1797
+ }
1798
+ });
1799
+ router.post("/templates", requireAgent, async (req, res, next) => {
1800
+ try {
1801
+ const { name: name2, subject, text, html } = req.body || {};
1802
+ if (!name2) {
1803
+ res.status(400).json({ error: "name is required" });
1804
+ return;
1805
+ }
1806
+ const id = uuidv4();
1807
+ db.prepare("INSERT OR REPLACE INTO templates (id, agent_id, name, subject, text_body, html_body) VALUES (?, ?, ?, ?, ?, ?)").run(id, req.agent.id, name2, subject || null, text || null, html || null);
1808
+ res.json({ ok: true, id });
1809
+ } catch (err) {
1810
+ next(err);
1811
+ }
1812
+ });
1813
+ router.delete("/templates/:id", requireAgent, async (req, res, next) => {
1814
+ try {
1815
+ const result = db.prepare("DELETE FROM templates WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1816
+ if (result.changes === 0) {
1817
+ res.status(404).json({ error: "Template not found" });
1818
+ return;
1819
+ }
1820
+ res.json({ ok: true });
1821
+ } catch (err) {
1822
+ next(err);
1823
+ }
1824
+ });
1825
+ router.get("/scheduled", requireAgent, async (req, res, next) => {
1826
+ try {
1827
+ const rows = db.prepare("SELECT * FROM scheduled_emails WHERE agent_id = ? ORDER BY send_at ASC").all(req.agent.id);
1828
+ res.json({ scheduled: rows });
1829
+ } catch (err) {
1830
+ next(err);
1831
+ }
1832
+ });
1833
+ router.post("/scheduled", requireAgent, async (req, res, next) => {
1834
+ try {
1835
+ const { to, subject, text, html, cc, bcc, sendAt } = req.body || {};
1836
+ if (!to || !subject || !sendAt) {
1837
+ res.status(400).json({ error: "to, subject, and sendAt are required" });
1838
+ return;
1839
+ }
1840
+ const sendDate = parseScheduleTime(String(sendAt));
1841
+ if (!sendDate || isNaN(sendDate.getTime())) {
1842
+ res.status(400).json({
1843
+ error: "Invalid sendAt date. Accepted formats: ISO 8601 (2026-02-14T10:00:00), presets (in 30 minutes, in 1 hour, in 3 hours, tomorrow 8am, tomorrow 9am, next monday 9am), or MM-DD-YYYY H:MM AM/PM TZ"
1844
+ });
1845
+ return;
1846
+ }
1847
+ if (sendDate.getTime() <= Date.now()) {
1848
+ res.status(400).json({ error: "sendAt must be in the future" });
1849
+ return;
1850
+ }
1851
+ const id = uuidv4();
1852
+ db.prepare(`INSERT INTO scheduled_emails (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, send_at)
1853
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, req.agent.id, to, subject, text || null, html || null, cc || null, bcc || null, sendDate.toISOString());
1854
+ res.json({ ok: true, id, sendAt: sendDate.toISOString() });
1855
+ } catch (err) {
1856
+ next(err);
1857
+ }
1858
+ });
1859
+ router.delete("/scheduled/:id", requireAgent, async (req, res, next) => {
1860
+ try {
1861
+ const result = db.prepare("DELETE FROM scheduled_emails WHERE id = ? AND agent_id = ? AND status = 'pending'").run(req.params.id, req.agent.id);
1862
+ if (result.changes === 0) {
1863
+ res.status(404).json({ error: "Scheduled email not found or already sent" });
1864
+ return;
1865
+ }
1866
+ res.json({ ok: true });
1867
+ } catch (err) {
1868
+ next(err);
1869
+ }
1870
+ });
1871
+ router.get("/tags", requireAgent, async (req, res, next) => {
1872
+ try {
1873
+ const rows = db.prepare("SELECT * FROM tags WHERE agent_id = ? ORDER BY name").all(req.agent.id);
1874
+ res.json({ tags: rows });
1875
+ } catch (err) {
1876
+ next(err);
1877
+ }
1878
+ });
1879
+ router.post("/tags", requireAgent, async (req, res, next) => {
1880
+ try {
1881
+ const { name: name2, color } = req.body || {};
1882
+ if (!name2) {
1883
+ res.status(400).json({ error: "name is required" });
1884
+ return;
1885
+ }
1886
+ const id = uuidv4();
1887
+ db.prepare("INSERT OR IGNORE INTO tags (id, agent_id, name, color) VALUES (?, ?, ?, ?)").run(id, req.agent.id, name2.trim(), color || "#888888");
1888
+ res.json({ ok: true, id, name: name2.trim(), color: color || "#888888" });
1889
+ } catch (err) {
1890
+ next(err);
1891
+ }
1892
+ });
1893
+ router.delete("/tags/:id", requireAgent, async (req, res, next) => {
1894
+ try {
1895
+ const result = db.prepare("DELETE FROM tags WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1896
+ if (result.changes === 0) {
1897
+ res.status(404).json({ error: "Tag not found" });
1898
+ return;
1899
+ }
1900
+ res.json({ ok: true });
1901
+ } catch (err) {
1902
+ next(err);
1903
+ }
1904
+ });
1905
+ router.post("/tags/:id/messages", requireAgent, async (req, res, next) => {
1906
+ try {
1907
+ const { uid, folder } = req.body || {};
1908
+ if (!uid) {
1909
+ res.status(400).json({ error: "uid is required" });
1910
+ return;
1911
+ }
1912
+ const tag = db.prepare("SELECT * FROM tags WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
1913
+ if (!tag) {
1914
+ res.status(404).json({ error: "Tag not found" });
1915
+ return;
1916
+ }
1917
+ db.prepare("INSERT OR IGNORE INTO message_tags (agent_id, message_uid, tag_id, folder) VALUES (?, ?, ?, ?)").run(req.agent.id, uid, req.params.id, folder || "INBOX");
1918
+ res.json({ ok: true });
1919
+ } catch (err) {
1920
+ next(err);
1921
+ }
1922
+ });
1923
+ router.delete("/tags/:id/messages/:uid", requireAgent, async (req, res, next) => {
1924
+ try {
1925
+ const folder = req.query.folder || "INBOX";
1926
+ db.prepare("DELETE FROM message_tags WHERE agent_id = ? AND message_uid = ? AND tag_id = ? AND folder = ?").run(req.agent.id, parseInt(String(req.params.uid)), req.params.id, folder);
1927
+ res.json({ ok: true });
1928
+ } catch (err) {
1929
+ next(err);
1930
+ }
1931
+ });
1932
+ router.get("/tags/:id/messages", requireAgent, async (req, res, next) => {
1933
+ try {
1934
+ const tag = db.prepare("SELECT * FROM tags WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
1935
+ if (!tag) {
1936
+ res.status(404).json({ error: "Tag not found" });
1937
+ return;
1938
+ }
1939
+ const rows = db.prepare(
1940
+ "SELECT message_uid, folder FROM message_tags WHERE agent_id = ? AND tag_id = ? ORDER BY created_at DESC"
1941
+ ).all(req.agent.id, req.params.id);
1942
+ res.json({ tag, messages: rows.map((r) => ({ uid: r.message_uid, folder: r.folder })) });
1943
+ } catch (err) {
1944
+ next(err);
1945
+ }
1946
+ });
1947
+ router.get("/messages/:uid/tags", requireAgent, async (req, res, next) => {
1948
+ try {
1949
+ const rows = db.prepare(`
1950
+ SELECT t.* FROM tags t
1951
+ JOIN message_tags mt ON mt.tag_id = t.id
1952
+ WHERE mt.agent_id = ? AND mt.message_uid = ?
1953
+ ORDER BY t.name
1954
+ `).all(req.agent.id, parseInt(String(req.params.uid)));
1955
+ res.json({ tags: rows });
1956
+ } catch (err) {
1957
+ next(err);
1958
+ }
1959
+ });
1960
+ router.post("/templates/:id/send", requireAgent, async (req, res, next) => {
1961
+ try {
1962
+ const template = db.prepare("SELECT * FROM templates WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
1963
+ if (!template) {
1964
+ res.status(404).json({ error: "Template not found" });
1965
+ return;
1966
+ }
1967
+ const { to, variables, cc, bcc } = req.body || {};
1968
+ if (!to) {
1969
+ res.status(400).json({ error: "to is required" });
1970
+ return;
1971
+ }
1972
+ const applyVars = (text, vars2) => text.replace(/\{\{(\w+)\}\}/g, (m, key) => vars2[key] ?? m);
1973
+ const vars = variables && typeof variables === "object" ? variables : {};
1974
+ const mailOpts = {
1975
+ to,
1976
+ subject: applyVars(template.subject || "(no subject)", vars),
1977
+ text: template.text_body ? applyVars(template.text_body, vars) : void 0,
1978
+ html: template.html_body ? applyVars(template.html_body, vars) : void 0,
1979
+ cc: cc || void 0,
1980
+ bcc: bcc || void 0
1981
+ };
1982
+ const agent = req.agent;
1983
+ if (gatewayManager) {
1984
+ const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
1985
+ if (gatewayResult) {
1986
+ res.json(gatewayResult);
1987
+ return;
1988
+ }
1989
+ }
1990
+ const password = getAgentPassword(agent);
1991
+ const sender = new MailSender3({
1992
+ host: config.smtp.host,
1993
+ port: config.smtp.port,
1994
+ email: agent.email,
1995
+ password,
1996
+ authUser: agent.stalwartPrincipal
1997
+ });
1998
+ try {
1999
+ const result = await sender.send(mailOpts);
2000
+ res.json(result);
2001
+ } finally {
2002
+ sender.close();
2003
+ }
2004
+ } catch (err) {
2005
+ next(err);
2006
+ }
2007
+ });
2008
+ router.get("/rules", requireAgent, async (req, res, next) => {
2009
+ try {
2010
+ const rows = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? ORDER BY priority DESC, created_at").all(req.agent.id);
2011
+ res.json({ rules: rows.map((r) => ({ ...r, conditions: JSON.parse(r.conditions), actions: JSON.parse(r.actions) })) });
2012
+ } catch (err) {
2013
+ next(err);
2014
+ }
2015
+ });
2016
+ router.post("/rules", requireAgent, async (req, res, next) => {
2017
+ try {
2018
+ const { name: name2, conditions, actions, priority, enabled } = req.body || {};
2019
+ if (!name2) {
2020
+ res.status(400).json({ error: "name is required" });
2021
+ return;
2022
+ }
2023
+ const id = uuidv4();
2024
+ db.prepare(
2025
+ "INSERT INTO email_rules (id, agent_id, name, priority, enabled, conditions, actions) VALUES (?, ?, ?, ?, ?, ?, ?)"
2026
+ ).run(id, req.agent.id, name2, priority ?? 0, enabled !== false ? 1 : 0, JSON.stringify(conditions || {}), JSON.stringify(actions || {}));
2027
+ res.status(201).json({ id, name: name2, conditions: conditions || {}, actions: actions || {}, priority: priority ?? 0, enabled: enabled !== false });
2028
+ } catch (err) {
2029
+ next(err);
2030
+ }
2031
+ });
2032
+ router.delete("/rules/:id", requireAgent, async (req, res, next) => {
2033
+ try {
2034
+ const result = db.prepare("DELETE FROM email_rules WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
2035
+ if (result.changes === 0) {
2036
+ res.status(404).json({ error: "Rule not found" });
2037
+ return;
2038
+ }
2039
+ res.json({ ok: true });
2040
+ } catch (err) {
2041
+ next(err);
2042
+ }
2043
+ });
2044
+ return router;
2045
+ }
2046
+ function evaluateRules(db, agentId, email) {
2047
+ const rules = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? AND enabled = 1 ORDER BY priority DESC").all(agentId);
2048
+ for (const rule of rules) {
2049
+ const cond = JSON.parse(rule.conditions);
2050
+ let match = true;
2051
+ const fromAddr = (email.from?.[0]?.address ?? "").toLowerCase();
2052
+ const toAddr = (email.to?.[0]?.address ?? "").toLowerCase();
2053
+ const subject = (email.subject ?? "").toLowerCase();
2054
+ if (cond.from_contains && !fromAddr.includes(cond.from_contains.toLowerCase())) match = false;
2055
+ if (cond.from_exact && fromAddr !== cond.from_exact.toLowerCase()) match = false;
2056
+ if (cond.subject_contains && !subject.includes(cond.subject_contains.toLowerCase())) match = false;
2057
+ if (cond.subject_regex) {
2058
+ try {
2059
+ if (!new RegExp(cond.subject_regex, "i").test(email.subject ?? "")) match = false;
2060
+ } catch {
2061
+ match = false;
2062
+ }
2063
+ }
2064
+ if (cond.to_contains && !toAddr.includes(cond.to_contains.toLowerCase())) match = false;
2065
+ if (cond.has_attachment === true && (!email.attachments || email.attachments.length === 0)) match = false;
2066
+ if (match) return { ruleId: rule.id, actions: JSON.parse(rule.actions) };
2067
+ }
2068
+ return null;
2069
+ }
2070
+ function startScheduledSender(db, accountManager, config, gatewayManager) {
2071
+ return setInterval(async () => {
2072
+ try {
2073
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2074
+ const pending = db.prepare(
2075
+ "SELECT * FROM scheduled_emails WHERE status = 'pending' AND send_at <= ?"
2076
+ ).all(now);
2077
+ for (const row of pending) {
2078
+ try {
2079
+ const agent = await accountManager.getById(row.agent_id);
2080
+ if (!agent) {
2081
+ db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run("Agent not found", row.id);
2082
+ continue;
2083
+ }
2084
+ const mailOpts = {
2085
+ to: row.to_addr,
2086
+ subject: row.subject,
2087
+ text: row.text_body || void 0,
2088
+ html: row.html_body || void 0,
2089
+ cc: row.cc || void 0,
2090
+ bcc: row.bcc || void 0
2091
+ };
2092
+ if (gatewayManager) {
2093
+ const gResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
2094
+ if (gResult) {
2095
+ db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
2096
+ continue;
2097
+ }
2098
+ }
2099
+ const password = agent.metadata?._password || agent.name;
2100
+ const sender = new MailSender3({
2101
+ host: config.smtp.host,
2102
+ port: config.smtp.port,
2103
+ email: agent.email,
2104
+ password,
2105
+ authUser: agent.stalwartPrincipal
2106
+ });
2107
+ try {
2108
+ await sender.send(mailOpts);
2109
+ db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
2110
+ } finally {
2111
+ sender.close();
2112
+ }
2113
+ } catch (err) {
2114
+ db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run(err.message, row.id);
2115
+ }
2116
+ }
2117
+ try {
2118
+ db.prepare("DELETE FROM delivered_messages WHERE delivered_at < datetime('now', '-30 days')").run();
2119
+ } catch {
2120
+ }
2121
+ try {
2122
+ db.prepare("DELETE FROM spam_log WHERE created_at < datetime('now', '-30 days')").run();
2123
+ } catch {
2124
+ }
2125
+ } catch {
2126
+ }
2127
+ }, 3e4);
2128
+ }
2129
+
2130
+ // src/routes/events.ts
2131
+ var MAX_SSE_PER_AGENT = 5;
2132
+ var activeWatchers = /* @__PURE__ */ new Map();
2133
+ function pushEventToAgent(agentId, event) {
2134
+ const watchers = activeWatchers.get(agentId);
2135
+ if (!watchers || watchers.size === 0) return false;
2136
+ const data = `data: ${JSON.stringify(event)}
2137
+
2138
+ `;
2139
+ for (const entry of watchers) {
2140
+ try {
2141
+ entry.res.write(data);
2142
+ } catch {
2143
+ }
2144
+ }
2145
+ return true;
2146
+ }
2147
+ function broadcastEvent(event) {
2148
+ const data = `data: ${JSON.stringify(event)}
2149
+
2150
+ `;
2151
+ let count = 0;
2152
+ for (const [, watchers] of activeWatchers) {
2153
+ for (const entry of watchers) {
2154
+ try {
2155
+ entry.res.write(data);
2156
+ count++;
2157
+ } catch {
2158
+ }
2159
+ }
2160
+ }
2161
+ return count;
2162
+ }
2163
+ async function closeAllWatchers() {
2164
+ for (const [, watchers] of activeWatchers) {
2165
+ for (const entry of watchers) {
2166
+ try {
2167
+ await entry.watcher.stop();
2168
+ } catch {
2169
+ }
2170
+ try {
2171
+ entry.res.end();
2172
+ } catch {
2173
+ }
2174
+ }
2175
+ }
2176
+ activeWatchers.clear();
2177
+ }
2178
+ function createEventRoutes(accountManager, config, db) {
2179
+ const router = Router6();
2180
+ router.get("/events", requireAgent, async (req, res, next) => {
2181
+ try {
2182
+ const agent = req.agent;
2183
+ const password = getAgentPassword(agent);
2184
+ const agentWatchers = activeWatchers.get(agent.id) ?? /* @__PURE__ */ new Set();
2185
+ if (agentWatchers.size >= MAX_SSE_PER_AGENT) {
2186
+ res.status(429).json({ error: `Maximum ${MAX_SSE_PER_AGENT} concurrent SSE connections per agent` });
2187
+ return;
2188
+ }
2189
+ const watcher = new InboxWatcher({
2190
+ host: config.imap.host,
2191
+ port: config.imap.port,
2192
+ email: agent.stalwartPrincipal,
2193
+ password
2194
+ });
2195
+ try {
2196
+ await watcher.start();
2197
+ } catch (err) {
2198
+ res.status(500).json({ error: "Failed to start event stream: " + (err instanceof Error ? err.message : String(err)) });
2199
+ return;
2200
+ }
2201
+ res.setHeader("Content-Type", "text/event-stream");
2202
+ res.setHeader("Cache-Control", "no-cache");
2203
+ res.setHeader("Connection", "keep-alive");
2204
+ res.setHeader("X-Accel-Buffering", "no");
2205
+ res.flushHeaders();
2206
+ let closed = false;
2207
+ const entry = { watcher, res };
2208
+ activeWatchers.set(agent.id, agentWatchers);
2209
+ agentWatchers.add(entry);
2210
+ const safeWrite = (data) => {
2211
+ if (!closed) {
2212
+ try {
2213
+ res.write(data);
2214
+ } catch {
2215
+ }
2216
+ }
2217
+ };
2218
+ safeWrite(`data: ${JSON.stringify({ type: "connected", agentId: agent.id })}
2219
+
2220
+ `);
2221
+ watcher.on("new", async (event) => {
2222
+ if (db) touchActivity(db, agent.id);
2223
+ if (db && event.uid) {
2224
+ try {
2225
+ const receiver = new MailReceiver2({
2226
+ host: config.imap.host,
2227
+ port: config.imap.port,
2228
+ email: agent.stalwartPrincipal,
2229
+ password,
2230
+ secure: false
2231
+ });
2232
+ await receiver.connect();
2233
+ try {
2234
+ const raw = await receiver.fetchMessage(event.uid);
2235
+ const parsed = await parseEmail3(raw);
2236
+ const isRelay = !!parsed.headers.get("x-agenticmail-relay");
2237
+ const internal = !isRelay && isInternalEmail2(parsed);
2238
+ if (internal) {
2239
+ const ruleResult2 = evaluateRules(db, agent.id, parsed);
2240
+ if (ruleResult2) {
2241
+ const actions = ruleResult2.actions;
2242
+ if (actions.mark_read) await receiver.markSeen(event.uid);
2243
+ if (actions.delete) {
2244
+ await receiver.deleteMessage(event.uid);
2245
+ return;
2246
+ }
2247
+ if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
2248
+ event.ruleApplied = { ruleId: ruleResult2.ruleId, actions };
2249
+ }
2250
+ safeWrite(`data: ${JSON.stringify(event)}
2251
+
2252
+ `);
2253
+ return;
2254
+ }
2255
+ const spamResult = scoreEmail2(parsed);
2256
+ try {
2257
+ db.prepare(
2258
+ "INSERT INTO spam_log (id, agent_id, message_uid, score, flags, category, is_spam) VALUES (?, ?, ?, ?, ?, ?, ?)"
2259
+ ).run(
2260
+ uuidv42(),
2261
+ agent.id,
2262
+ event.uid,
2263
+ spamResult.score,
2264
+ JSON.stringify(spamResult.matches.map((m) => m.ruleId)),
2265
+ spamResult.topCategory,
2266
+ spamResult.isSpam ? 1 : 0
2267
+ );
2268
+ } catch {
2269
+ }
2270
+ if (spamResult.isSpam) {
2271
+ try {
2272
+ await receiver.createFolder("Spam");
2273
+ } catch {
2274
+ }
2275
+ await receiver.moveMessage(event.uid, "INBOX", "Spam");
2276
+ event.spam = { score: spamResult.score, category: spamResult.topCategory, movedToSpam: true };
2277
+ safeWrite(`data: ${JSON.stringify(event)}
2278
+
2279
+ `);
2280
+ return;
2281
+ }
2282
+ if (spamResult.isWarning) {
2283
+ event.spamWarning = { score: spamResult.score, category: spamResult.topCategory, matches: spamResult.matches.map((m) => m.ruleId) };
2284
+ }
2285
+ const ruleResult = evaluateRules(db, agent.id, parsed);
2286
+ if (ruleResult) {
2287
+ const actions = ruleResult.actions;
2288
+ if (actions.mark_read) await receiver.markSeen(event.uid);
2289
+ if (actions.delete) {
2290
+ await receiver.deleteMessage(event.uid);
2291
+ return;
2292
+ }
2293
+ if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
2294
+ event.ruleApplied = { ruleId: ruleResult.ruleId, actions };
2295
+ }
2296
+ } finally {
2297
+ await receiver.disconnect();
2298
+ }
2299
+ } catch (err) {
2300
+ console.error("[SSE] Spam/rule evaluation error:", err.message);
2301
+ }
2302
+ }
2303
+ safeWrite(`data: ${JSON.stringify(event)}
2304
+
2305
+ `);
2306
+ });
2307
+ watcher.on("expunge", (event) => {
2308
+ safeWrite(`data: ${JSON.stringify(event)}
2309
+
2310
+ `);
2311
+ });
2312
+ watcher.on("flags", (event) => {
2313
+ safeWrite(`data: ${JSON.stringify(event)}
2314
+
2315
+ `);
2316
+ });
2317
+ watcher.on("error", (err) => {
2318
+ safeWrite(`data: ${JSON.stringify({ type: "error", message: err.message })}
2319
+
2320
+ `);
2321
+ });
2322
+ const pingInterval = setInterval(() => {
2323
+ safeWrite(`: ping
2324
+
2325
+ `);
2326
+ }, 3e4);
2327
+ req.on("close", () => {
2328
+ closed = true;
2329
+ clearInterval(pingInterval);
2330
+ agentWatchers.delete(entry);
2331
+ if (agentWatchers.size === 0) activeWatchers.delete(agent.id);
2332
+ watcher.removeAllListeners();
2333
+ watcher.stop().catch((err) => {
2334
+ console.error("[SSE] Watcher cleanup error:", err);
2335
+ });
2336
+ });
2337
+ } catch (err) {
2338
+ next(err);
2339
+ }
2340
+ });
2341
+ return router;
2342
+ }
2343
+
2344
+ // src/routes/domains.ts
2345
+ import { Router as Router7 } from "express";
2346
+ function createDomainRoutes(domainManager) {
2347
+ const router = Router7();
2348
+ router.post("/domains", requireMaster, async (req, res, next) => {
2349
+ try {
2350
+ const { domain } = req.body;
2351
+ if (!domain) {
2352
+ res.status(400).json({ error: "domain is required" });
2353
+ return;
2354
+ }
2355
+ const result = await domainManager.setup(domain);
2356
+ res.status(201).json(result);
2357
+ } catch (err) {
2358
+ next(err);
2359
+ }
2360
+ });
2361
+ router.get("/domains", requireMaster, async (_req, res, next) => {
2362
+ try {
2363
+ const domains = await domainManager.list();
2364
+ res.json({ domains });
2365
+ } catch (err) {
2366
+ next(err);
2367
+ }
2368
+ });
2369
+ router.get("/domains/:domain/dns", requireMaster, async (req, res, next) => {
2370
+ try {
2371
+ const records = await domainManager.getDnsRecords(req.params.domain);
2372
+ res.json({ records });
2373
+ } catch (err) {
2374
+ next(err);
2375
+ }
2376
+ });
2377
+ router.post("/domains/:domain/verify", requireMaster, async (req, res, next) => {
2378
+ try {
2379
+ const verified = await domainManager.verify(req.params.domain);
2380
+ res.json({ domain: req.params.domain, verified });
2381
+ } catch (err) {
2382
+ next(err);
2383
+ }
2384
+ });
2385
+ router.delete("/domains/:domain", requireMaster, async (req, res, next) => {
2386
+ try {
2387
+ const deleted = await domainManager.delete(req.params.domain);
2388
+ if (!deleted) {
2389
+ res.status(404).json({ error: "Domain not found" });
2390
+ return;
2391
+ }
2392
+ res.status(204).send();
2393
+ } catch (err) {
2394
+ next(err);
2395
+ }
2396
+ });
2397
+ return router;
2398
+ }
2399
+
2400
+ // src/routes/gateway.ts
2401
+ import { Router as Router8 } from "express";
2402
+ import {
2403
+ RELAY_PRESETS,
2404
+ AGENT_ROLES as AGENT_ROLES2
2405
+ } from "@agenticmail/core";
2406
+ function createGatewayRoutes(gatewayManager) {
2407
+ const router = Router8();
2408
+ router.get("/gateway/setup-guide", requireMaster, async (_req, res) => {
2409
+ res.json({
2410
+ modes: [
2411
+ {
2412
+ mode: "relay",
2413
+ difficulty: "Beginner",
2414
+ description: "Use your existing Gmail or Outlook account to send/receive emails. No domain needed.",
2415
+ fromAddress: "yourname+agentname@gmail.com",
2416
+ requirements: [
2417
+ "A Gmail or Outlook email account",
2418
+ "An app password (not your regular password)"
2419
+ ],
2420
+ setup: {
2421
+ tool: "agenticmail_setup_relay",
2422
+ params: { provider: "gmail", email: "you@gmail.com", password: "xxxx xxxx xxxx xxxx" }
2423
+ },
2424
+ howToGetAppPassword: {
2425
+ gmail: "https://myaccount.google.com/apppasswords (requires 2FA enabled)",
2426
+ outlook: "https://account.live.com/proofs/AppPassword"
2427
+ },
2428
+ pros: ["Quick setup (< 2 min)", "No domain purchase needed", "Free"],
2429
+ cons: ["Emails show as yourname+agent@gmail.com", "Less professional", "Tied to personal email"]
2430
+ },
2431
+ {
2432
+ mode: "domain",
2433
+ difficulty: "Advanced",
2434
+ description: "Use your own domain for professional agent emails (agent@yourdomain.com). Full DKIM/SPF/DMARC authentication.",
2435
+ fromAddress: "agentname@yourdomain.com",
2436
+ requirements: [
2437
+ "A Cloudflare account (free tier works)",
2438
+ "A Cloudflare API token (see tokenPermissions below for exact settings)",
2439
+ "A domain name (can purchase during setup, ~$10/yr for .com)",
2440
+ "A Gmail account + app password for outbound relay (recommended)"
2441
+ ],
2442
+ setup: {
2443
+ tool: "agenticmail_setup_domain",
2444
+ params: {
2445
+ cloudflareToken: "your-api-token",
2446
+ cloudflareAccountId: "your-account-id",
2447
+ domain: "yourdomain.com",
2448
+ gmailRelay: { email: "you@gmail.com", appPassword: "xxxx xxxx xxxx xxxx" }
2449
+ }
2450
+ },
2451
+ postSetup: [
2452
+ 'Add each agent email as a Gmail "Send mail as" alias (use agenticmail_setup_gmail_alias tool for instructions)',
2453
+ "DNS propagation takes 5-30 minutes",
2454
+ "DKIM signing is automatic"
2455
+ ],
2456
+ howToGetCloudflareToken: "https://dash.cloudflare.com/profile/api-tokens \u2192 Create Token \u2192 Custom Token",
2457
+ howToGetAccountId: "https://dash.cloudflare.com \u2192 click any site \u2192 right sidebar shows Account ID",
2458
+ tokenPermissions: {
2459
+ instructions: "Go to https://dash.cloudflare.com/profile/api-tokens \u2192 Create Token \u2192 Custom Token (Get started)",
2460
+ zone: [
2461
+ { permission: "Zone > Zone > Read", reason: "Look up zone ID for your domain" },
2462
+ { permission: "Zone > DNS > Edit", reason: "Create/manage DNS records (SPF, DKIM, DMARC, CNAME)" },
2463
+ { permission: "Zone > Email Routing Rules > Edit", reason: "Set catch-all rule to route emails to worker" }
2464
+ ],
2465
+ account: [
2466
+ { permission: "Account > Cloudflare Tunnel > Edit", reason: "Create and configure tunnel for inbound SMTP" },
2467
+ { permission: "Account > Workers Scripts > Edit", reason: "Deploy the inbound email worker" },
2468
+ { permission: "Account > Email Routing Addresses > Edit", reason: "Enable/disable Email Routing on zones" },
2469
+ { permission: "Account > Account Settings > Read", reason: "Verify account access" }
2470
+ ],
2471
+ optional: [
2472
+ { permission: "Account > Registrar: Domains > Edit", reason: "Only needed if purchasing a domain via the tool (optional)" }
2473
+ ],
2474
+ zoneResources: "Include > All zones (or specific zone if you prefer)",
2475
+ accountResources: "Include > your account"
2476
+ },
2477
+ domainPurchase: {
2478
+ note: "Cloudflare API only supports READ access for registrar \u2014 domains must be purchased manually.",
2479
+ options: [
2480
+ {
2481
+ option: "A",
2482
+ label: "Buy from Cloudflare Registrar (recommended \u2014 at-cost pricing, no markup)",
2483
+ url: "https://dash.cloudflare.com/?to=/:account/domain-registration",
2484
+ steps: [
2485
+ "Open the link above",
2486
+ "Search for your desired domain",
2487
+ "Add a payment method if needed (Billing page)",
2488
+ "Complete the purchase \u2014 domain is automatically in your Cloudflare account"
2489
+ ]
2490
+ },
2491
+ {
2492
+ option: "B",
2493
+ label: "Buy from another registrar (Namecheap, GoDaddy, etc.) then point to Cloudflare",
2494
+ steps: [
2495
+ "Purchase your domain from any registrar",
2496
+ "Add the domain to Cloudflare: https://dash.cloudflare.com/?to=/:account/add-site",
2497
+ "Cloudflare will show you 2 nameservers to set",
2498
+ "Update nameservers at your registrar",
2499
+ "Wait for DNS propagation (5-30 min)"
2500
+ ]
2501
+ }
2502
+ ]
2503
+ },
2504
+ pros: ["Professional emails (agent@yourdomain.com)", "Full DKIM/SPF/DMARC", "Multiple agents with unique addresses", "Better deliverability"],
2505
+ cons: ["Requires Cloudflare account", "Domain costs ~$10/yr", "More setup steps", "Gmail alias step needed for outbound"]
2506
+ }
2507
+ ]
2508
+ });
2509
+ });
2510
+ router.post("/gateway/relay", requireMaster, async (req, res, next) => {
2511
+ try {
2512
+ const { provider, email, password, smtpHost, smtpPort, imapHost, imapPort, agentName, agentRole, skipDefaultAgent } = req.body;
2513
+ if (!email || !password) {
2514
+ res.status(400).json({ error: "email and password are required" });
2515
+ return;
2516
+ }
2517
+ if (agentRole && !AGENT_ROLES2.includes(agentRole)) {
2518
+ res.status(400).json({ error: `Invalid agentRole. Must be one of: ${AGENT_ROLES2.join(", ")}` });
2519
+ return;
2520
+ }
2521
+ const prov = provider ?? "custom";
2522
+ const preset = prov === "gmail" || prov === "outlook" ? RELAY_PRESETS[prov] : null;
2523
+ const config = {
2524
+ provider: prov,
2525
+ email,
2526
+ password,
2527
+ smtpHost: smtpHost ?? preset?.smtpHost ?? "localhost",
2528
+ smtpPort: smtpPort ?? preset?.smtpPort ?? 587,
2529
+ imapHost: imapHost ?? preset?.imapHost ?? "localhost",
2530
+ imapPort: imapPort ?? preset?.imapPort ?? 993
2531
+ };
2532
+ const result = await gatewayManager.setupRelay(config, {
2533
+ defaultAgentName: agentName,
2534
+ defaultAgentRole: agentRole,
2535
+ skipDefaultAgent
2536
+ });
2537
+ const response = {
2538
+ status: "ok",
2539
+ mode: "relay",
2540
+ email: config.email,
2541
+ provider: config.provider
2542
+ };
2543
+ if (result.agent) {
2544
+ response.agent = {
2545
+ id: result.agent.id,
2546
+ name: result.agent.name,
2547
+ email: result.agent.email,
2548
+ apiKey: result.agent.apiKey,
2549
+ role: result.agent.role,
2550
+ subAddress: `${email.split("@")[0]}+${result.agent.name}@${email.split("@")[1]}`
2551
+ };
2552
+ }
2553
+ res.json(response);
2554
+ } catch (err) {
2555
+ next(err);
2556
+ }
2557
+ });
2558
+ router.post("/gateway/domain", requireMaster, async (req, res, next) => {
2559
+ try {
2560
+ const { cloudflareToken, cloudflareAccountId, domain, purchase, gmailRelay } = req.body;
2561
+ if (!cloudflareToken || !cloudflareAccountId) {
2562
+ res.status(400).json({ error: "cloudflareToken and cloudflareAccountId are required" });
2563
+ return;
2564
+ }
2565
+ const result = await gatewayManager.setupDomain({
2566
+ cloudflareToken,
2567
+ cloudflareAccountId,
2568
+ domain,
2569
+ purchase,
2570
+ gmailRelay
2571
+ });
2572
+ res.json({ status: "ok", mode: "domain", ...result });
2573
+ } catch (err) {
2574
+ next(err);
2575
+ }
2576
+ });
2577
+ router.post("/gateway/domain/alias-setup", requireMaster, async (req, res, next) => {
2578
+ try {
2579
+ const config = gatewayManager.getConfig();
2580
+ if (config.mode !== "domain" || !config.domain) {
2581
+ res.status(400).json({ error: "Domain mode not configured" });
2582
+ return;
2583
+ }
2584
+ const { agentEmail, agentDisplayName } = req.body;
2585
+ if (!agentEmail) {
2586
+ res.status(400).json({ error: "agentEmail is required (e.g. secretary@yourdomain.com)" });
2587
+ return;
2588
+ }
2589
+ const stalwart = gatewayManager.getStalwart();
2590
+ const gmailUsername = await stalwart.getSettings("queue.route.gmail.auth");
2591
+ const relayEmail = gmailUsername?.["username"];
2592
+ if (!relayEmail) {
2593
+ res.status(400).json({ error: "Gmail relay not configured. Set up domain with gmailRelay first." });
2594
+ return;
2595
+ }
2596
+ const domain = config.domain.domain;
2597
+ const displayName = agentDisplayName || agentEmail.split("@")[0];
2598
+ res.json({
2599
+ status: "ok",
2600
+ instructions: {
2601
+ summary: `Add "${agentEmail}" as a "Send mail as" alias in Gmail`,
2602
+ gmailSettingsUrl: "https://mail.google.com/mail/u/0/#settings/accounts",
2603
+ steps: [
2604
+ { step: 1, action: "Navigate to Gmail settings", url: "https://mail.google.com/mail/u/0/#settings/accounts" },
2605
+ { step: 2, action: 'Click "Add another email address" under "Send mail as"' },
2606
+ { step: 3, action: "Fill name and email", fields: { name: displayName, email: agentEmail, treatAsAlias: false } },
2607
+ { step: 4, action: 'Click "Next Step"' },
2608
+ {
2609
+ step: 5,
2610
+ action: "IMPORTANT: Gmail auto-fills wrong SMTP values. Change ALL fields to:",
2611
+ fields: {
2612
+ smtpServer: "smtp.gmail.com",
2613
+ port: 465,
2614
+ username: relayEmail,
2615
+ password: "[app password - same one used during domain setup]",
2616
+ security: "SSL"
2617
+ }
2618
+ },
2619
+ { step: 6, action: 'Click "Add Account"' },
2620
+ { step: 7, action: `Check AgenticMail inbox for verification email from gmail-noreply@google.com, extract the confirmation link or code` },
2621
+ { step: 8, action: "Open the confirmation link or enter the code to complete verification" }
2622
+ ]
2623
+ },
2624
+ domain,
2625
+ agentEmail
2626
+ });
2627
+ } catch (err) {
2628
+ next(err);
2629
+ }
2630
+ });
2631
+ router.get("/gateway/domain/payment-setup", requireMaster, async (_req, res) => {
2632
+ const config = gatewayManager.getConfig();
2633
+ const accountId = config.mode === "domain" && config.domain ? config.domain.cloudflareAccountId : void 0;
2634
+ res.json({
2635
+ status: "ok",
2636
+ instructions: {
2637
+ summary: "Add a payment method to your Cloudflare account (required for domain purchases)",
2638
+ options: [
2639
+ {
2640
+ option: "A",
2641
+ label: "Add it yourself (2 minutes)",
2642
+ steps: [
2643
+ { step: 1, action: "Open Cloudflare billing page", url: accountId ? `https://dash.cloudflare.com/${accountId}/billing` : "https://dash.cloudflare.com/?to=/:account/billing" },
2644
+ { step: 2, action: 'Click "Payment Info" tab or "Manage" next to Payment Method' },
2645
+ { step: 3, action: 'Click "Add payment method"' },
2646
+ { step: 4, action: "Enter card details (prepaid debit cards with spending limits work fine)" },
2647
+ { step: 5, action: 'Click "Save" or "Add"' }
2648
+ ]
2649
+ },
2650
+ {
2651
+ option: "B",
2652
+ label: "Let your AI agent do it via browser automation",
2653
+ requirements: ["Agent must have browser tool access", "You must be logged into Cloudflare in your browser"],
2654
+ steps: [
2655
+ { step: 1, action: "Agent navigates to Cloudflare billing page", url: accountId ? `https://dash.cloudflare.com/${accountId}/billing` : "https://dash.cloudflare.com/?to=/:account/billing" },
2656
+ { step: 2, action: 'Agent clicks "Payment Info" or "Manage" next to Payment Method' },
2657
+ { step: 3, action: 'Agent clicks "Add payment method"' },
2658
+ { step: 4, action: "Agent fills in card number, expiry, CVC, and billing info", note: "User provides card details via chat. Agent types them into the form. Details are NOT stored anywhere." },
2659
+ { step: 5, action: 'Agent clicks "Save" \u2014 user should verify the details on screen before confirming' }
2660
+ ],
2661
+ securityNote: "Card details go directly to Cloudflare via their secure form. AgenticMail never stores or sees your card information."
2662
+ }
2663
+ ]
2664
+ }
2665
+ });
2666
+ });
2667
+ router.get("/gateway/status", requireMaster, async (_req, res, next) => {
2668
+ try {
2669
+ const status = gatewayManager.getStatus();
2670
+ res.json(status);
2671
+ } catch (err) {
2672
+ next(err);
2673
+ }
2674
+ });
2675
+ router.post("/gateway/domain/purchase", requireMaster, async (req, res, next) => {
2676
+ try {
2677
+ const { keywords, tld } = req.body;
2678
+ if (!keywords?.length) {
2679
+ res.status(400).json({ error: "keywords array is required" });
2680
+ return;
2681
+ }
2682
+ const purchaser = gatewayManager.getDomainPurchaser();
2683
+ if (!purchaser) {
2684
+ res.status(400).json({ error: "Domain mode not configured. Set up Cloudflare credentials first." });
2685
+ return;
2686
+ }
2687
+ const results = await purchaser.searchAvailable(keywords, tld ? [tld] : void 0);
2688
+ res.json({ domains: results });
2689
+ } catch (err) {
2690
+ next(err);
2691
+ }
2692
+ });
2693
+ router.get("/gateway/domain/dns", requireMaster, async (_req, res, next) => {
2694
+ try {
2695
+ const config = gatewayManager.getConfig();
2696
+ if (config.mode !== "domain" || !config.domain) {
2697
+ res.status(400).json({ error: "Domain mode not configured" });
2698
+ return;
2699
+ }
2700
+ const dnsConfig = gatewayManager.getDNSConfigurator();
2701
+ if (!dnsConfig) {
2702
+ res.status(400).json({ error: "DNS configurator not available" });
2703
+ return;
2704
+ }
2705
+ const verification = await dnsConfig.verify(config.domain.domain);
2706
+ res.json({ domain: config.domain.domain, dns: verification });
2707
+ } catch (err) {
2708
+ next(err);
2709
+ }
2710
+ });
2711
+ router.post("/gateway/tunnel/start", requireMaster, async (_req, res, next) => {
2712
+ try {
2713
+ const config = gatewayManager.getConfig();
2714
+ if (config.mode !== "domain" || !config.domain?.tunnelToken) {
2715
+ res.status(400).json({ error: "Domain mode with tunnel not configured" });
2716
+ return;
2717
+ }
2718
+ const tunnel = gatewayManager.getTunnelManager();
2719
+ if (!tunnel) {
2720
+ res.status(400).json({ error: "Tunnel manager not available" });
2721
+ return;
2722
+ }
2723
+ await tunnel.start(config.domain.tunnelToken);
2724
+ res.json({ status: "ok", tunnel: tunnel.status() });
2725
+ } catch (err) {
2726
+ next(err);
2727
+ }
2728
+ });
2729
+ router.post("/gateway/tunnel/stop", requireMaster, async (_req, res, next) => {
2730
+ try {
2731
+ const tunnel = gatewayManager.getTunnelManager();
2732
+ if (!tunnel) {
2733
+ res.status(400).json({ error: "Tunnel manager not available" });
2734
+ return;
2735
+ }
2736
+ await tunnel.stop();
2737
+ res.json({ status: "ok", tunnel: tunnel.status() });
2738
+ } catch (err) {
2739
+ next(err);
2740
+ }
2741
+ });
2742
+ router.post("/gateway/test", requireMaster, async (req, res, next) => {
2743
+ try {
2744
+ const { to } = req.body;
2745
+ if (!to) {
2746
+ res.status(400).json({ error: "to email address is required" });
2747
+ return;
2748
+ }
2749
+ const result = await gatewayManager.sendTestEmail(to);
2750
+ if (result) {
2751
+ res.json({ status: "ok", messageId: result.messageId });
2752
+ } else {
2753
+ res.status(400).json({ error: "No gateway configured or destination is local" });
2754
+ }
2755
+ } catch (err) {
2756
+ next(err);
2757
+ }
2758
+ });
2759
+ return router;
2760
+ }
2761
+
2762
+ // src/routes/tasks.ts
2763
+ import { Router as Router9 } from "express";
2764
+ import { v4 as uuidv43 } from "uuid";
2765
+ import { MailSender as MailSender4 } from "@agenticmail/core";
2766
+ var rpcResolvers = /* @__PURE__ */ new Map();
2767
+ function createTaskRoutes(db, accountManager, config) {
2768
+ const router = Router9();
2769
+ router.post("/tasks/assign", requireAuth, async (req, res, next) => {
2770
+ try {
2771
+ const { assignee, taskType, payload, expiresInSeconds } = req.body || {};
2772
+ if (!assignee) {
2773
+ res.status(400).json({ error: "assignee (agent name) is required" });
2774
+ return;
2775
+ }
2776
+ const target = await accountManager.getByName(assignee);
2777
+ if (!target) {
2778
+ res.status(404).json({ error: `Agent "${assignee}" not found` });
2779
+ return;
2780
+ }
2781
+ const assignerId = req.agent?.id ?? "master";
2782
+ const id = uuidv43();
2783
+ const expiresAt = expiresInSeconds ? new Date(Date.now() + expiresInSeconds * 1e3).toISOString() : null;
2784
+ db.prepare(
2785
+ "INSERT INTO agent_tasks (id, assigner_id, assignee_id, task_type, payload, expires_at) VALUES (?, ?, ?, ?, ?, ?)"
2786
+ ).run(id, assignerId, target.id, taskType || "generic", JSON.stringify(payload || {}), expiresAt);
2787
+ const taskEvent = {
2788
+ type: "task",
2789
+ taskId: id,
2790
+ taskType: taskType || "generic",
2791
+ assignee: target.name,
2792
+ from: req.agent?.name ?? "system"
2793
+ };
2794
+ if (!pushEventToAgent(target.id, taskEvent)) {
2795
+ broadcastEvent(taskEvent);
2796
+ }
2797
+ if (req.agent) {
2798
+ const notifSender = new MailSender4({
2799
+ host: config.smtp.host,
2800
+ port: config.smtp.port,
2801
+ email: req.agent.email,
2802
+ password: getAgentPassword(req.agent),
2803
+ authUser: req.agent.stalwartPrincipal
2804
+ });
2805
+ notifSender.send({
2806
+ to: target.email,
2807
+ subject: `[Task] ${taskType || "generic"} from ${req.agent.name}`,
2808
+ text: `You have a new task assigned to you (ID: ${id}).
2809
+
2810
+ Type: ${taskType || "generic"}
2811
+ ${payload ? `Payload: ${JSON.stringify(payload)}
2812
+ ` : ""}
2813
+ Please check your pending tasks.`
2814
+ }).catch((err) => {
2815
+ console.warn(`[Tasks] Failed to notify ${target.name}:`, err.message);
2816
+ }).finally(() => {
2817
+ notifSender.close();
2818
+ });
2819
+ }
2820
+ res.status(201).json({ id, assignee: target.name, assigneeId: target.id, status: "pending" });
2821
+ } catch (err) {
2822
+ next(err);
2823
+ }
2824
+ });
2825
+ router.get("/tasks/pending", requireAgent, async (req, res, next) => {
2826
+ try {
2827
+ let assigneeId = req.agent.id;
2828
+ const assigneeName = req.query.assignee;
2829
+ if (assigneeName) {
2830
+ const target = await accountManager.getByName(assigneeName);
2831
+ if (target) assigneeId = target.id;
2832
+ }
2833
+ const rows = db.prepare(
2834
+ "SELECT * FROM agent_tasks WHERE assignee_id = ? AND status IN ('pending', 'claimed') ORDER BY created_at ASC"
2835
+ ).all(assigneeId);
2836
+ res.json({ tasks: rows.map(parseTask), count: rows.length });
2837
+ } catch (err) {
2838
+ next(err);
2839
+ }
2840
+ });
2841
+ router.get("/tasks/assigned", requireAuth, async (req, res, next) => {
2842
+ try {
2843
+ const id = req.agent?.id ?? "master";
2844
+ const rows = db.prepare(
2845
+ "SELECT * FROM agent_tasks WHERE assigner_id = ? ORDER BY created_at DESC LIMIT 50"
2846
+ ).all(id);
2847
+ res.json({ tasks: rows.map(parseTask), count: rows.length });
2848
+ } catch (err) {
2849
+ next(err);
2850
+ }
2851
+ });
2852
+ router.post("/tasks/:id/claim", requireAgent, async (req, res, next) => {
2853
+ try {
2854
+ const result = db.prepare(
2855
+ "UPDATE agent_tasks SET status = 'claimed', claimed_at = datetime('now') WHERE id = ? AND status = 'pending'"
2856
+ ).run(req.params.id);
2857
+ if (result.changes === 0) {
2858
+ res.status(404).json({ error: "Task not found or already claimed" });
2859
+ return;
2860
+ }
2861
+ touchActivity(db, req.agent.id);
2862
+ const task = db.prepare("SELECT * FROM agent_tasks WHERE id = ?").get(req.params.id);
2863
+ res.json(parseTask(task));
2864
+ } catch (err) {
2865
+ next(err);
2866
+ }
2867
+ });
2868
+ router.post("/tasks/:id/result", requireAgent, async (req, res, next) => {
2869
+ try {
2870
+ const { result } = req.body || {};
2871
+ const resultJson = JSON.stringify(result ?? null);
2872
+ const dbResult = db.prepare(
2873
+ "UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = datetime('now') WHERE id = ? AND status = 'claimed'"
2874
+ ).run(resultJson, req.params.id);
2875
+ if (dbResult.changes === 0) {
2876
+ res.status(404).json({ error: "Task not found or not in claimed status" });
2877
+ return;
2878
+ }
2879
+ touchActivity(db, req.agent.id);
2880
+ const resolver = rpcResolvers.get(req.params.id);
2881
+ if (resolver) {
2882
+ rpcResolvers.delete(req.params.id);
2883
+ resolver({ status: "completed", result: resultJson });
2884
+ }
2885
+ res.json({ ok: true, taskId: req.params.id, status: "completed" });
2886
+ } catch (err) {
2887
+ next(err);
2888
+ }
2889
+ });
2890
+ router.post("/tasks/:id/complete", requireAgent, async (req, res, next) => {
2891
+ try {
2892
+ const { result } = req.body || {};
2893
+ const resultJson = JSON.stringify(result ?? null);
2894
+ const dbResult = db.prepare(
2895
+ "UPDATE agent_tasks SET status = 'completed', result = ?, claimed_at = datetime('now'), completed_at = datetime('now') WHERE id = ? AND status = 'pending'"
2896
+ ).run(resultJson, req.params.id);
2897
+ if (dbResult.changes === 0) {
2898
+ const retry = db.prepare(
2899
+ "UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = datetime('now') WHERE id = ? AND status = 'claimed'"
2900
+ ).run(resultJson, req.params.id);
2901
+ if (retry.changes === 0) {
2902
+ res.status(404).json({ error: "Task not found or already completed" });
2903
+ return;
2904
+ }
2905
+ }
2906
+ touchActivity(db, req.agent.id);
2907
+ const resolver = rpcResolvers.get(req.params.id);
2908
+ if (resolver) {
2909
+ rpcResolvers.delete(req.params.id);
2910
+ resolver({ status: "completed", result: resultJson });
2911
+ }
2912
+ res.json({ ok: true, taskId: req.params.id, status: "completed" });
2913
+ } catch (err) {
2914
+ next(err);
2915
+ }
2916
+ });
2917
+ router.post("/tasks/:id/fail", requireAgent, async (req, res, next) => {
2918
+ try {
2919
+ const { error } = req.body || {};
2920
+ const errorMsg = error || "Unknown error";
2921
+ const dbResult = db.prepare(
2922
+ "UPDATE agent_tasks SET status = 'failed', error = ?, completed_at = datetime('now') WHERE id = ? AND status = 'claimed'"
2923
+ ).run(errorMsg, req.params.id);
2924
+ if (dbResult.changes === 0) {
2925
+ res.status(404).json({ error: "Task not found or not in claimed status" });
2926
+ return;
2927
+ }
2928
+ touchActivity(db, req.agent.id);
2929
+ const resolver = rpcResolvers.get(req.params.id);
2930
+ if (resolver) {
2931
+ rpcResolvers.delete(req.params.id);
2932
+ resolver({ status: "failed", error: errorMsg });
2933
+ }
2934
+ res.json({ ok: true, taskId: req.params.id, status: "failed" });
2935
+ } catch (err) {
2936
+ next(err);
2937
+ }
2938
+ });
2939
+ router.get("/tasks/:id", requireAuth, async (req, res, next) => {
2940
+ try {
2941
+ const task = db.prepare("SELECT * FROM agent_tasks WHERE id = ?").get(req.params.id);
2942
+ if (!task) {
2943
+ res.status(404).json({ error: "Task not found" });
2944
+ return;
2945
+ }
2946
+ res.json(parseTask(task));
2947
+ } catch (err) {
2948
+ next(err);
2949
+ }
2950
+ });
2951
+ router.post("/tasks/rpc", requireAuth, async (req, res, next) => {
2952
+ try {
2953
+ const { target, task, payload, timeout } = req.body || {};
2954
+ if (!target || !task) {
2955
+ res.status(400).json({ error: "target (agent name) and task are required" });
2956
+ return;
2957
+ }
2958
+ const targetAgent = await accountManager.getByName(target);
2959
+ if (!targetAgent) {
2960
+ res.status(404).json({ error: `Agent "${target}" not found` });
2961
+ return;
2962
+ }
2963
+ const assignerId = req.agent?.id ?? "master";
2964
+ const taskId = uuidv43();
2965
+ const timeoutMs = Math.min(Math.max((timeout || 180) * 1e3, 5e3), 3e5);
2966
+ req.socket.setTimeout(0);
2967
+ res.setHeader("Connection", "keep-alive");
2968
+ db.prepare(
2969
+ "INSERT INTO agent_tasks (id, assigner_id, assignee_id, task_type, payload) VALUES (?, ?, ?, ?, ?)"
2970
+ ).run(taskId, assignerId, targetAgent.id, "rpc", JSON.stringify({ task, ...payload || {} }));
2971
+ const rpcEvent = {
2972
+ type: "task",
2973
+ taskId,
2974
+ taskType: "rpc",
2975
+ task,
2976
+ assignee: targetAgent.name,
2977
+ from: req.agent?.name ?? "system"
2978
+ };
2979
+ if (!pushEventToAgent(targetAgent.id, rpcEvent)) {
2980
+ broadcastEvent(rpcEvent);
2981
+ }
2982
+ if (req.agent) {
2983
+ const notifSender = new MailSender4({
2984
+ host: config.smtp.host,
2985
+ port: config.smtp.port,
2986
+ email: req.agent.email,
2987
+ password: getAgentPassword(req.agent),
2988
+ authUser: req.agent.stalwartPrincipal
2989
+ });
2990
+ notifSender.send({
2991
+ to: targetAgent.email,
2992
+ subject: `[RPC] Task from ${req.agent.name}: ${task}`,
2993
+ text: `You have a pending RPC task (ID: ${taskId}).
2994
+
2995
+ Task: ${task}
2996
+ ${payload ? `Payload: ${JSON.stringify(payload)}
2997
+ ` : ""}
2998
+ Please check your pending tasks, claim this task, process it, and submit the result.`
2999
+ }).catch((err) => {
3000
+ console.warn(`[RPC] Failed to notify ${targetAgent.name}:`, err.message);
3001
+ }).finally(() => {
3002
+ notifSender.close();
3003
+ });
3004
+ }
3005
+ const completionPromise = new Promise((resolve) => {
3006
+ rpcResolvers.set(taskId, resolve);
3007
+ const pollStmt = db.prepare("SELECT status, result, error FROM agent_tasks WHERE id = ?");
3008
+ let pollCount = 0;
3009
+ const pollInterval = setInterval(() => {
3010
+ pollCount++;
3011
+ if (req.destroyed || res.destroyed) {
3012
+ clearInterval(pollInterval);
3013
+ rpcResolvers.delete(taskId);
3014
+ resolve({ status: "disconnected" });
3015
+ return;
3016
+ }
3017
+ const row = pollStmt.get(taskId);
3018
+ if (row?.status === "completed" || row?.status === "failed") {
3019
+ clearInterval(pollInterval);
3020
+ rpcResolvers.delete(taskId);
3021
+ resolve({ status: row.status, result: row.result, error: row.error });
3022
+ }
3023
+ }, 2e3);
3024
+ setTimeout(() => {
3025
+ clearInterval(pollInterval);
3026
+ rpcResolvers.delete(taskId);
3027
+ resolve({ status: "timeout" });
3028
+ }, timeoutMs);
3029
+ });
3030
+ const outcome = await completionPromise;
3031
+ if (outcome.status === "disconnected") return;
3032
+ if (outcome.status === "completed") {
3033
+ let result = null;
3034
+ try {
3035
+ result = JSON.parse(outcome.result);
3036
+ } catch {
3037
+ result = outcome.result;
3038
+ }
3039
+ res.json({ taskId, status: "completed", result });
3040
+ return;
3041
+ }
3042
+ if (outcome.status === "failed") {
3043
+ res.json({ taskId, status: "failed", error: outcome.error });
3044
+ return;
3045
+ }
3046
+ res.json({ taskId, status: "timeout", message: `Task not completed within ${timeout || 180}s. Check with GET /tasks/${taskId}` });
3047
+ } catch (err) {
3048
+ next(err);
3049
+ }
3050
+ });
3051
+ return router;
3052
+ }
3053
+ function parseTask(row) {
3054
+ if (!row) return null;
3055
+ let payload = {};
3056
+ let result = null;
3057
+ try {
3058
+ payload = JSON.parse(row.payload);
3059
+ } catch {
3060
+ payload = row.payload;
3061
+ }
3062
+ try {
3063
+ result = row.result ? JSON.parse(row.result) : null;
3064
+ } catch {
3065
+ result = row.result;
3066
+ }
3067
+ return {
3068
+ id: row.id,
3069
+ assignerId: row.assigner_id,
3070
+ assigneeId: row.assignee_id,
3071
+ taskType: row.task_type,
3072
+ payload,
3073
+ status: row.status,
3074
+ result,
3075
+ error: row.error,
3076
+ createdAt: row.created_at,
3077
+ claimedAt: row.claimed_at,
3078
+ completedAt: row.completed_at,
3079
+ expiresAt: row.expires_at
3080
+ };
3081
+ }
3082
+
3083
+ // src/app.ts
3084
+ function createApp(configOverrides) {
3085
+ const config = resolveConfig(configOverrides);
3086
+ const db = getDatabase(config);
3087
+ const stalwart = new StalwartAdmin({
3088
+ url: config.stalwart.url,
3089
+ adminUser: config.stalwart.adminUser,
3090
+ adminPassword: config.stalwart.adminPassword
3091
+ });
3092
+ const accountManager = new AccountManager(db, stalwart);
3093
+ const domainManager = new DomainManager(db, stalwart);
3094
+ const gatewayManager = new GatewayManager2({
3095
+ db,
3096
+ stalwart,
3097
+ accountManager,
3098
+ localSmtp: {
3099
+ host: config.smtp.host,
3100
+ port: config.smtp.port,
3101
+ user: config.stalwart.adminUser,
3102
+ pass: config.stalwart.adminPassword
3103
+ }
3104
+ });
3105
+ const app2 = express();
3106
+ app2.disable("x-powered-by");
3107
+ app2.use((_req, res, next) => {
3108
+ res.setHeader("X-Powered-By", "AgenticMail");
3109
+ next();
3110
+ });
3111
+ app2.use(cors());
3112
+ app2.use(express.json({ limit: "10mb" }));
3113
+ app2.use(
3114
+ rateLimit({
3115
+ windowMs: 60 * 1e3,
3116
+ max: 100,
3117
+ standardHeaders: true,
3118
+ legacyHeaders: false,
3119
+ message: { error: "Too many requests, please try again later" }
3120
+ })
3121
+ );
3122
+ app2.use("/api/agenticmail", createHealthRoutes(stalwart));
3123
+ app2.use("/api/agenticmail", createInboundRoutes(accountManager, config, gatewayManager));
3124
+ app2.use("/api/agenticmail", createAuthMiddleware(config.masterKey, accountManager, db));
3125
+ app2.use("/api/agenticmail", createAccountRoutes(accountManager, db, config));
3126
+ app2.use("/api/agenticmail", createMailRoutes(accountManager, config, db, gatewayManager));
3127
+ app2.use("/api/agenticmail", createEventRoutes(accountManager, config, db));
3128
+ app2.use("/api/agenticmail", createDomainRoutes(domainManager));
3129
+ app2.use("/api/agenticmail", createGatewayRoutes(gatewayManager));
3130
+ app2.use("/api/agenticmail", createFeatureRoutes(db, accountManager, config, gatewayManager));
3131
+ app2.use("/api/agenticmail", createTaskRoutes(db, accountManager, config));
3132
+ app2.use("/api/agenticmail", (_req, res) => {
3133
+ res.status(404).json({ error: "Not found" });
3134
+ });
3135
+ app2.use(errorHandler);
3136
+ const context2 = { config, db, stalwart, accountManager, domainManager, gatewayManager };
3137
+ return { app: app2, context: context2 };
3138
+ }
3139
+
3140
+ // src/index.ts
3141
+ function getLocalIp() {
3142
+ const nets = networkInterfaces();
3143
+ for (const iface of Object.values(nets)) {
3144
+ if (!iface) continue;
3145
+ for (const info of iface) {
3146
+ if (info.family === "IPv4" && !info.internal) return info.address;
3147
+ }
3148
+ }
3149
+ return "127.0.0.1";
3150
+ }
3151
+ var VERSION = "0.2.26";
3152
+ var { app, context } = createApp();
3153
+ var { port, host } = context.config.api;
3154
+ var scheduledTimer = null;
3155
+ var server = app.listen(port, host, async () => {
3156
+ const displayHost = host === "127.0.0.1" || host === "0.0.0.0" ? getLocalIp() : host;
3157
+ console.log("");
3158
+ console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
3159
+ console.log(" \u2551 \u{1F380} AgenticMail v" + VERSION.padEnd(29) + "\u2551");
3160
+ console.log(" \u2551 Built by Ope Olatunji \u2551");
3161
+ console.log(" \u2551 github.com/agenticmail/agenticmail \u2551");
3162
+ console.log(" \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563");
3163
+ console.log(" \u2551 \u2551");
3164
+ console.log(" \u2551 What \u{1F380} AgenticMail gives your agents: \u2551");
3165
+ console.log(" \u2551 \u2551");
3166
+ console.log(" \u2551 \u{1F4E7} Real Email Send, receive, reply, forward with \u2551");
3167
+ console.log(" \u2551 full DKIM/SPF/DMARC authentication \u2551");
3168
+ console.log(" \u2551 \u2551");
3169
+ console.log(" \u2551 \u{1F91D} Agent Coordination Task queues, synchronous RPC, \u2551");
3170
+ console.log(" \u2551 push notifications, structured \u2551");
3171
+ console.log(" \u2551 results \u2014 replaces fire-and-forget \u2551");
3172
+ console.log(" \u2551 session spawning \u2551");
3173
+ console.log(" \u2551 \u2551");
3174
+ console.log(" \u2551 \u{1F512} Security Outbound PII/credential scanning, \u2551");
3175
+ console.log(" \u2551 inbound spam filtering, human-in- \u2551");
3176
+ console.log(" \u2551 the-loop approval for sensitive \u2551");
3177
+ console.log(" \u2551 content \u2551");
3178
+ console.log(" \u2551 \u2551");
3179
+ console.log(" \u2551 \u26A1 Efficiency ~60% fewer tokens on multi-agent \u2551");
3180
+ console.log(" \u2551 tasks vs session polling. Persistent \u2551");
3181
+ console.log(" \u2551 task state survives crashes. \u2551");
3182
+ console.log(" \u2551 Push-based \u2014 no wasted poll cycles. \u2551");
3183
+ console.log(" \u2551 \u2551");
3184
+ console.log(" \u2551 54 tools \u2022 MIT license \u2022 Contributions welcome \u2551");
3185
+ console.log(" \u2551 \u2551");
3186
+ console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
3187
+ console.log("");
3188
+ console.log(` \u{1F680} API: http://${displayHost}:${port}`);
3189
+ console.log(` \u2764\uFE0F Health: http://${displayHost}:${port}/api/agenticmail/health`);
3190
+ console.log(` \u{1F4D6} About: http://${displayHost}:${port}/api/agenticmail/about`);
3191
+ scheduledTimer = startScheduledSender(context.db, context.accountManager, context.config, context.gatewayManager);
3192
+ try {
3193
+ await context.gatewayManager.resume();
3194
+ const status = context.gatewayManager.getStatus();
3195
+ if (status.mode !== "none") {
3196
+ console.log(` Gateway: ${status.mode} mode resumed${status.relay?.polling ? " (polling)" : ""}`);
3197
+ }
3198
+ } catch (err) {
3199
+ console.error(" Gateway resume failed:", err);
3200
+ }
3201
+ });
3202
+ server.on("error", (err) => {
3203
+ if (err.code === "EADDRINUSE") {
3204
+ console.error(`Port ${port} is already in use`);
3205
+ } else {
3206
+ console.error("Failed to start server:", err);
3207
+ }
3208
+ process.exit(1);
3209
+ });
3210
+ var shuttingDown = false;
3211
+ async function shutdown() {
3212
+ if (shuttingDown) return;
3213
+ shuttingDown = true;
3214
+ console.log("\nShutting down...");
3215
+ if (scheduledTimer) {
3216
+ try {
3217
+ clearInterval(scheduledTimer);
3218
+ } catch {
3219
+ }
3220
+ }
3221
+ try {
3222
+ await closeAllWatchers();
3223
+ } catch {
3224
+ }
3225
+ try {
3226
+ await closeCaches();
3227
+ } catch {
3228
+ }
3229
+ try {
3230
+ await context.gatewayManager.shutdown();
3231
+ } catch {
3232
+ }
3233
+ server.close(() => process.exit(0));
3234
+ setTimeout(() => process.exit(1), 5e3);
3235
+ }
3236
+ process.on("SIGTERM", () => shutdown().catch(() => process.exit(1)));
3237
+ process.on("SIGINT", () => shutdown().catch(() => process.exit(1)));
3238
+ process.on("uncaughtException", (err) => {
3239
+ console.error("[AgenticMail] Uncaught exception (server will continue):", err.message);
3240
+ console.error(err.stack);
3241
+ });
3242
+ process.on("unhandledRejection", (reason) => {
3243
+ const msg = reason instanceof Error ? reason.message : String(reason);
3244
+ console.error("[AgenticMail] Unhandled promise rejection (server will continue):", msg);
3245
+ if (reason instanceof Error && reason.stack) {
3246
+ console.error(reason.stack);
3247
+ }
3248
+ });