@agenticmail/api 0.5.58 → 0.5.60

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.
Files changed (2) hide show
  1. package/dist/index.js +1610 -1531
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -264,17 +264,17 @@ function createAccountRoutes(accountManager, db, config) {
264
264
  const router = Router2();
265
265
  const deletionService = new AgentDeletionService(db, accountManager, config);
266
266
  router.post("/accounts", requireMaster, async (req, res, next) => {
267
+ if (!req.body || typeof req.body !== "object") {
268
+ res.status(400).json({ error: "Request body must be JSON" });
269
+ return;
270
+ }
271
+ const { name: accountName, domain, password, metadata, role, persistent } = req.body;
267
272
  try {
268
- if (!req.body || typeof req.body !== "object") {
269
- res.status(400).json({ error: "Request body must be JSON" });
270
- return;
271
- }
272
- const { name: name2, domain, password, metadata, role, persistent } = req.body;
273
- if (!name2 || typeof name2 !== "string") {
273
+ if (!accountName || typeof accountName !== "string") {
274
274
  res.status(400).json({ error: "name is required and must be a string" });
275
275
  return;
276
276
  }
277
- if (name2.length > 64) {
277
+ if (accountName.length > 64) {
278
278
  res.status(400).json({ error: "name must be 64 characters or fewer" });
279
279
  return;
280
280
  }
@@ -293,7 +293,7 @@ function createAccountRoutes(accountManager, db, config) {
293
293
  const cleanMeta = metadata ? Object.fromEntries(
294
294
  Object.entries(metadata).filter(([k]) => !k.startsWith("_"))
295
295
  ) : void 0;
296
- const agent = await accountManager.create({ name: name2, domain, password: password || void 0, metadata: cleanMeta, role });
296
+ const agent = await accountManager.create({ name: accountName, domain, password: password || void 0, metadata: cleanMeta, role });
297
297
  try {
298
298
  db.prepare("UPDATE agents SET last_activity_at = datetime('now') WHERE id = ?").run(agent.id);
299
299
  } catch {
@@ -310,7 +310,7 @@ function createAccountRoutes(accountManager, db, config) {
310
310
  } catch (err) {
311
311
  const msg = err.message ?? "";
312
312
  if (msg.includes("UNIQUE") || msg.includes("unique") || msg.includes("already exists") || msg.includes("duplicate") || msg.includes("fieldAlreadyExists") || msg.toLowerCase().includes("alreadyexists")) {
313
- res.status(409).json({ error: `Agent "${name}" already exists` });
313
+ res.status(409).json({ error: "Account already exists", name: accountName });
314
314
  return;
315
315
  }
316
316
  next(err);
@@ -488,996 +488,916 @@ function createAccountRoutes(accountManager, db, config) {
488
488
  }
489
489
 
490
490
  // src/routes/mail.ts
491
- import { Router as Router3 } from "express";
491
+ import { Router as Router5 } from "express";
492
492
  import crypto from "crypto";
493
493
  import {
494
- MailSender,
494
+ MailSender as MailSender2,
495
+ MailReceiver as MailReceiver2,
496
+ parseEmail as parseEmail2,
497
+ scoreEmail as scoreEmail2,
498
+ sanitizeEmail,
499
+ isInternalEmail as isInternalEmail2,
500
+ scanOutboundEmail
501
+ } from "@agenticmail/core";
502
+
503
+ // src/routes/events.ts
504
+ import { Router as Router4 } from "express";
505
+ import {
506
+ InboxWatcher,
495
507
  MailReceiver,
496
508
  parseEmail,
497
509
  scoreEmail,
498
- sanitizeEmail,
499
510
  isInternalEmail,
500
- scanOutboundEmail
511
+ classifyEmailRoute
501
512
  } from "@agenticmail/core";
502
- var senderCache = /* @__PURE__ */ new Map();
503
- var receiverCache = /* @__PURE__ */ new Map();
504
- var receiverPending = /* @__PURE__ */ new Map();
505
- var CACHE_TTL_MS = 10 * 60 * 1e3;
506
- var MAX_CACHE_SIZE = 100;
507
- var draining = false;
508
- function getAgentPassword(agent) {
509
- return agent.metadata?._password || agent.name;
510
- }
511
- var evictionTimer = null;
512
- function startEvictionTimer() {
513
- if (evictionTimer) return;
514
- evictionTimer = setInterval(evictStaleEntries, 6e4);
515
- }
516
- function evictStaleEntries() {
517
- const now = Date.now();
518
- for (const [key, entry] of senderCache) {
519
- if (now - entry.createdAt > CACHE_TTL_MS) {
520
- try {
521
- entry.sender.close();
522
- } catch {
523
- }
524
- senderCache.delete(key);
525
- }
513
+ import { v4 as uuidv42 } from "uuid";
514
+
515
+ // src/routes/features.ts
516
+ import { Router as Router3 } from "express";
517
+ import { v4 as uuidv4 } from "uuid";
518
+ import {
519
+ MailSender
520
+ } from "@agenticmail/core";
521
+ function parseScheduleTime(input) {
522
+ const trimmed = input.trim();
523
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(trimmed)) {
524
+ const d = new Date(trimmed);
525
+ return isNaN(d.getTime()) ? null : d;
526
526
  }
527
- for (const [key, entry] of receiverCache) {
528
- if (now - entry.createdAt > CACHE_TTL_MS) {
529
- entry.receiver.disconnect().catch(() => {
530
- });
531
- receiverCache.delete(key);
532
- }
527
+ const lower = trimmed.toLowerCase();
528
+ const relativeMatch = lower.match(/^in\s+(\d+)\s+(minute|minutes|min|mins|hour|hours|hr|hrs|day|days)$/);
529
+ if (relativeMatch) {
530
+ const amount = parseInt(relativeMatch[1], 10);
531
+ const unit = relativeMatch[2];
532
+ const now = Date.now();
533
+ if (unit.startsWith("min")) return new Date(now + amount * 6e4);
534
+ if (unit.startsWith("h")) return new Date(now + amount * 36e5);
535
+ if (unit.startsWith("d")) return new Date(now + amount * 864e5);
533
536
  }
534
- }
535
- function getSender(authUser, fromEmail, password, config) {
536
- if (draining) throw new Error("Server is shutting down");
537
- const cacheKey = `${authUser}:${fromEmail}`;
538
- const cached = senderCache.get(cacheKey);
539
- if (cached) return cached.sender;
540
- if (senderCache.size >= MAX_CACHE_SIZE) {
541
- const oldest = senderCache.keys().next().value;
542
- if (oldest) {
543
- try {
544
- senderCache.get(oldest)?.sender.close();
545
- } catch {
537
+ const tomorrowMatch = lower.match(/^tomorrow\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
538
+ if (tomorrowMatch) {
539
+ const tomorrow = /* @__PURE__ */ new Date();
540
+ tomorrow.setDate(tomorrow.getDate() + 1);
541
+ let hour = parseInt(tomorrowMatch[1], 10);
542
+ const min = tomorrowMatch[2] ? parseInt(tomorrowMatch[2], 10) : 0;
543
+ const ampm = tomorrowMatch[3];
544
+ if (ampm === "pm" && hour !== 12) hour += 12;
545
+ if (ampm === "am" && hour === 12) hour = 0;
546
+ tomorrow.setHours(hour, min, 0, 0);
547
+ return tomorrow;
548
+ }
549
+ const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
550
+ const nextDayMatch = lower.match(/^next\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
551
+ if (nextDayMatch) {
552
+ const targetDay = dayNames.indexOf(nextDayMatch[1]);
553
+ let hour = parseInt(nextDayMatch[2], 10);
554
+ const min = nextDayMatch[3] ? parseInt(nextDayMatch[3], 10) : 0;
555
+ const ampm = nextDayMatch[4];
556
+ if (ampm === "pm" && hour !== 12) hour += 12;
557
+ if (ampm === "am" && hour === 12) hour = 0;
558
+ const result = /* @__PURE__ */ new Date();
559
+ const currentDay = result.getDay();
560
+ let daysUntil = targetDay - currentDay;
561
+ if (daysUntil <= 0) daysUntil += 7;
562
+ result.setDate(result.getDate() + daysUntil);
563
+ result.setHours(hour, min, 0, 0);
564
+ return result;
565
+ }
566
+ if (lower === "tonight" || lower === "this evening") {
567
+ const d = /* @__PURE__ */ new Date();
568
+ d.setHours(20, 0, 0, 0);
569
+ if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
570
+ return d;
571
+ }
572
+ const humanMatch = trimmed.match(
573
+ /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\s*(.+)?$/
574
+ );
575
+ if (humanMatch) {
576
+ const [, mStr, dStr, yStr, hStr, minStr, ampmRaw, tzRaw] = humanMatch;
577
+ const month = parseInt(mStr, 10);
578
+ const day = parseInt(dStr, 10);
579
+ const year = parseInt(yStr, 10);
580
+ let hour = parseInt(hStr, 10);
581
+ const min = parseInt(minStr, 10);
582
+ const ampm = ampmRaw.toUpperCase();
583
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour < 1 || hour > 12) return null;
584
+ if (ampm === "PM" && hour !== 12) hour += 12;
585
+ if (ampm === "AM" && hour === 12) hour = 0;
586
+ const result = new Date(year, month - 1, day, hour, min, 0, 0);
587
+ if (tzRaw?.trim()) {
588
+ const TZ_OFFSETS = {
589
+ EST: -5,
590
+ EDT: -4,
591
+ CST: -6,
592
+ CDT: -5,
593
+ MST: -7,
594
+ MDT: -6,
595
+ PST: -8,
596
+ PDT: -7,
597
+ GMT: 0,
598
+ UTC: 0,
599
+ BST: 1,
600
+ CET: 1,
601
+ CEST: 2,
602
+ IST: 5.5,
603
+ JST: 9,
604
+ AEST: 10,
605
+ AEDT: 11,
606
+ NZST: 12,
607
+ NZDT: 13,
608
+ WAT: 1,
609
+ EAT: 3,
610
+ SAST: 2,
611
+ HKT: 8,
612
+ SGT: 8,
613
+ KST: 9,
614
+ HST: -10,
615
+ AKST: -9,
616
+ AKDT: -8,
617
+ AST: -4,
618
+ ADT: -3,
619
+ NST: -3.5,
620
+ NDT: -2.5
621
+ };
622
+ const tz = tzRaw.trim().toUpperCase();
623
+ if (TZ_OFFSETS[tz] !== void 0) {
624
+ const tzOffsetMs = TZ_OFFSETS[tz] * 36e5;
625
+ const serverOffsetMs = result.getTimezoneOffset() * -6e4;
626
+ const diff = serverOffsetMs - tzOffsetMs;
627
+ result.setTime(result.getTime() + diff);
546
628
  }
547
- senderCache.delete(oldest);
548
629
  }
630
+ return isNaN(result.getTime()) ? null : result;
549
631
  }
550
- const sender = new MailSender({
551
- host: config.smtp.host,
552
- port: config.smtp.port,
553
- email: fromEmail,
554
- password,
555
- authUser
556
- });
557
- senderCache.set(cacheKey, { sender, createdAt: Date.now() });
558
- startEvictionTimer();
559
- return sender;
632
+ const fallback = new Date(trimmed);
633
+ return isNaN(fallback.getTime()) ? null : fallback;
560
634
  }
561
- async function getReceiver(authUser, password, config) {
562
- if (draining) throw new Error("Server is shutting down");
563
- const cached = receiverCache.get(authUser);
564
- if (cached) {
635
+ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
636
+ const router = Router3();
637
+ router.get("/contacts", requireAgent, async (req, res, next) => {
565
638
  try {
566
- const client = cached.receiver.getImapClient();
567
- if (client.usable) return cached.receiver;
568
- } catch {
639
+ const rows = db.prepare("SELECT * FROM contacts WHERE agent_id = ? ORDER BY name, email").all(req.agent.id);
640
+ res.json({ contacts: rows });
641
+ } catch (err) {
642
+ next(err);
569
643
  }
644
+ });
645
+ router.post("/contacts", requireAgent, async (req, res, next) => {
570
646
  try {
571
- await cached.receiver.disconnect();
572
- } catch {
573
- }
574
- receiverCache.delete(authUser);
575
- }
576
- const pending = receiverPending.get(authUser);
577
- if (pending) return pending;
578
- const promise = createReceiver(authUser, password, config);
579
- receiverPending.set(authUser, promise);
580
- try {
581
- return await promise;
582
- } finally {
583
- receiverPending.delete(authUser);
584
- }
585
- }
586
- async function createReceiver(authUser, password, config) {
587
- if (receiverCache.size >= MAX_CACHE_SIZE) {
588
- const oldest = receiverCache.keys().next().value;
589
- if (oldest) {
590
- receiverCache.get(oldest)?.receiver.disconnect().catch(() => {
591
- });
592
- receiverCache.delete(oldest);
647
+ const { name, email, notes } = req.body || {};
648
+ if (!email) {
649
+ res.status(400).json({ error: "email is required" });
650
+ return;
651
+ }
652
+ const id = uuidv4();
653
+ db.prepare("INSERT OR REPLACE INTO contacts (id, agent_id, name, email, notes) VALUES (?, ?, ?, ?, ?)").run(id, req.agent.id, name || null, email, notes || null);
654
+ res.json({ ok: true, id, email });
655
+ } catch (err) {
656
+ next(err);
593
657
  }
594
- }
595
- const receiver = new MailReceiver({
596
- host: config.imap.host,
597
- port: config.imap.port,
598
- email: authUser,
599
- password
600
658
  });
601
- try {
602
- await receiver.connect();
603
- } catch (err) {
659
+ router.delete("/contacts/:id", requireAgent, async (req, res, next) => {
604
660
  try {
605
- await receiver.disconnect();
606
- } catch {
661
+ const result = db.prepare("DELETE FROM contacts WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
662
+ if (result.changes === 0) {
663
+ res.status(404).json({ error: "Contact not found" });
664
+ return;
665
+ }
666
+ res.json({ ok: true });
667
+ } catch (err) {
668
+ next(err);
607
669
  }
608
- throw err;
609
- }
610
- receiverCache.set(authUser, { receiver, createdAt: Date.now() });
611
- startEvictionTimer();
612
- return receiver;
613
- }
614
- async function closeCaches() {
615
- draining = true;
616
- if (evictionTimer) {
617
- clearInterval(evictionTimer);
618
- evictionTimer = null;
619
- }
620
- for (const [, entry] of senderCache) {
670
+ });
671
+ router.get("/drafts", requireAgent, async (req, res, next) => {
621
672
  try {
622
- entry.sender.close();
623
- } catch {
673
+ const rows = db.prepare("SELECT * FROM drafts WHERE agent_id = ? ORDER BY updated_at DESC").all(req.agent.id);
674
+ res.json({ drafts: rows });
675
+ } catch (err) {
676
+ next(err);
624
677
  }
625
- }
626
- senderCache.clear();
627
- for (const [, entry] of receiverCache) {
678
+ });
679
+ router.post("/drafts", requireAgent, async (req, res, next) => {
628
680
  try {
629
- await entry.receiver.disconnect();
630
- } catch {
681
+ const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
682
+ const id = uuidv4();
683
+ db.prepare(`INSERT INTO drafts (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, in_reply_to, refs)
684
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
685
+ id,
686
+ req.agent.id,
687
+ to || null,
688
+ subject || null,
689
+ text || null,
690
+ html || null,
691
+ cc || null,
692
+ bcc || null,
693
+ inReplyTo || null,
694
+ references ? JSON.stringify(references) : null
695
+ );
696
+ res.json({ ok: true, id });
697
+ } catch (err) {
698
+ next(err);
631
699
  }
632
- }
633
- receiverCache.clear();
634
- }
635
- function saveSentCopy(authUser, password, config, raw) {
636
- (async () => {
700
+ });
701
+ router.put("/drafts/:id", requireAgent, async (req, res, next) => {
637
702
  try {
638
- const receiver = await getReceiver(authUser, password, config);
639
- await receiver.appendMessage(raw, "Sent Items", ["\\Seen"]);
703
+ const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
704
+ const result = db.prepare(`UPDATE drafts SET to_addr=?, subject=?, text_body=?, html_body=?,
705
+ cc=?, bcc=?, in_reply_to=?, refs=?, updated_at=datetime('now')
706
+ WHERE id=? AND agent_id=?`).run(
707
+ to || null,
708
+ subject || null,
709
+ text || null,
710
+ html || null,
711
+ cc || null,
712
+ bcc || null,
713
+ inReplyTo || null,
714
+ references ? JSON.stringify(references) : null,
715
+ req.params.id,
716
+ req.agent.id
717
+ );
718
+ if (result.changes === 0) {
719
+ res.status(404).json({ error: "Draft not found" });
720
+ return;
721
+ }
722
+ res.json({ ok: true });
640
723
  } catch (err) {
641
- console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
724
+ next(err);
642
725
  }
643
- })();
644
- }
645
- function createMailRoutes(accountManager, config, db, gatewayManager) {
646
- const router = Router3();
647
- router.post("/mail/send", requireAgent, async (req, res, next) => {
726
+ });
727
+ router.delete("/drafts/:id", requireAgent, async (req, res, next) => {
648
728
  try {
649
- if (!req.body || typeof req.body !== "object") {
650
- res.status(400).json({ error: "Request body must be JSON" });
729
+ const result = db.prepare("DELETE FROM drafts WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
730
+ if (result.changes === 0) {
731
+ res.status(404).json({ error: "Draft not found" });
651
732
  return;
652
733
  }
653
- const agent = req.agent;
654
- const { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, allowSensitive } = req.body;
655
- if (!to || !subject) {
656
- res.status(400).json({ error: "to and subject are required" });
734
+ res.json({ ok: true });
735
+ } catch (err) {
736
+ next(err);
737
+ }
738
+ });
739
+ router.post("/drafts/:id/send", requireAgent, async (req, res, next) => {
740
+ try {
741
+ const draft = db.prepare("SELECT * FROM drafts WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
742
+ if (!draft) {
743
+ res.status(404).json({ error: "Draft not found" });
657
744
  return;
658
745
  }
659
- if (typeof to !== "string" && !Array.isArray(to)) {
660
- res.status(400).json({ error: "to must be a string or array of strings" });
746
+ if (!draft.to_addr) {
747
+ res.status(400).json({ error: "Draft has no recipient" });
661
748
  return;
662
749
  }
663
- let outboundWarnings;
664
- let outboundSummary;
665
- if (!(allowSensitive && req.isMaster)) {
666
- const scanResult = scanOutboundEmail({
667
- to: Array.isArray(to) ? to.join(", ") : to,
668
- subject,
669
- text,
670
- html,
671
- attachments: Array.isArray(attachments) ? attachments.map((a) => ({
672
- filename: a.filename || "",
673
- contentType: a.contentType,
674
- content: a.content,
675
- encoding: a.encoding
676
- })) : void 0
677
- });
678
- if (scanResult.blocked) {
679
- const pendingId = crypto.randomUUID();
680
- const ownerName2 = agent.metadata?.ownerName;
681
- const fromName2 = ownerName2 ? `${agent.name} from ${ownerName2}` : agent.name;
682
- const mailOptions = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName: fromName2 };
683
- db.prepare(
684
- `INSERT INTO pending_outbound (id, agent_id, mail_options, warnings, summary) VALUES (?, ?, ?, ?, ?)`
685
- ).run(pendingId, agent.id, JSON.stringify(mailOptions), JSON.stringify(scanResult.warnings), scanResult.summary);
686
- if (gatewayManager) {
687
- const ownerEmail = gatewayManager.getConfig()?.relay?.email;
688
- if (ownerEmail) {
689
- const warningList = scanResult.warnings.map((w) => ` - [${w.severity.toUpperCase()}] ${w.ruleId}: ${w.description}${w.match ? ` (matched: ${w.match})` : ""}`).join("\n");
690
- const recipientLine = Array.isArray(to) ? to.join(", ") : to;
691
- const emailPreview = [
692
- "\u2500".repeat(50),
693
- `From: ${fromName2} <${agent.email}>`,
694
- `To: ${recipientLine}`
695
- ];
696
- if (cc) emailPreview.push(`CC: ${Array.isArray(cc) ? cc.join(", ") : cc}`);
697
- if (bcc) emailPreview.push(`BCC: ${Array.isArray(bcc) ? bcc.join(", ") : bcc}`);
698
- emailPreview.push(`Subject: ${subject}`);
699
- if (Array.isArray(attachments) && attachments.length > 0) {
700
- const attNames = attachments.map((a) => a.filename || "unnamed").join(", ");
701
- emailPreview.push(`Attachments: ${attNames}`);
702
- }
703
- emailPreview.push("\u2500".repeat(50));
704
- if (text) emailPreview.push("", text);
705
- else if (html) emailPreview.push("", "[HTML content \u2014 see original for formatted version]");
706
- else emailPreview.push("", "[No body content]");
707
- emailPreview.push("\u2500".repeat(50));
708
- gatewayManager.routeOutbound(agent.name, {
709
- to: ownerEmail,
710
- subject: `[Approval Required] Blocked email from "${agent.name}" \u2014 "${subject}"`,
711
- text: [
712
- `Your agent "${agent.name}" attempted to send an email that was blocked by the outbound security guard.`,
713
- "",
714
- "SECURITY WARNINGS:",
715
- warningList,
716
- "",
717
- "FULL EMAIL FOR REVIEW:",
718
- ...emailPreview,
719
- "",
720
- `Pending ID: ${pendingId}`,
721
- "",
722
- "ACTION REQUIRED:",
723
- 'Reply "approve" to this email to send it, or "reject" to discard it.',
724
- "If you do not respond, the agent will follow up with you."
725
- ].join("\n"),
726
- fromName: "Agentic Mail"
727
- }).then((result2) => {
728
- if (result2?.messageId) {
729
- db.prepare("UPDATE pending_outbound SET notification_message_id = ? WHERE id = ?").run(result2.messageId, pendingId);
730
- }
731
- }).catch(() => {
732
- });
733
- }
734
- }
735
- res.json({
736
- sent: false,
737
- blocked: true,
738
- pendingId,
739
- warnings: scanResult.warnings,
740
- summary: scanResult.summary
741
- });
742
- return;
743
- }
744
- if (scanResult.warnings.length > 0) {
745
- outboundWarnings = scanResult.warnings;
746
- outboundSummary = scanResult.summary;
747
- }
748
- }
749
- const ownerName = agent.metadata?.ownerName;
750
- const fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
751
- const mailOpts = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName };
752
- const password = getAgentPassword(agent);
750
+ const agent = req.agent;
751
+ const mailOpts = {
752
+ to: draft.to_addr,
753
+ subject: draft.subject || "(no subject)",
754
+ text: draft.text_body || void 0,
755
+ html: draft.html_body || void 0,
756
+ cc: draft.cc || void 0,
757
+ bcc: draft.bcc || void 0,
758
+ inReplyTo: draft.in_reply_to || void 0,
759
+ references: draft.refs ? JSON.parse(draft.refs) : void 0
760
+ };
753
761
  if (gatewayManager) {
754
762
  const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
755
763
  if (gatewayResult) {
756
- if (gatewayResult.raw) {
757
- saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
758
- }
759
- const { raw: _raw2, ...response2 } = gatewayResult;
760
- res.json({ ...response2, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
764
+ db.prepare("DELETE FROM drafts WHERE id = ?").run(draft.id);
765
+ res.json(gatewayResult);
761
766
  return;
762
767
  }
763
768
  }
764
- const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
765
- const result = await sender.send(mailOpts);
766
- saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
767
- const { raw: _raw, ...response } = result;
768
- res.json({ ...response, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
769
+ const password = getAgentPassword(agent);
770
+ const sender = new MailSender({
771
+ host: config.smtp.host,
772
+ port: config.smtp.port,
773
+ email: agent.email,
774
+ password,
775
+ authUser: agent.stalwartPrincipal
776
+ });
777
+ try {
778
+ const result = await sender.send(mailOpts);
779
+ db.prepare("DELETE FROM drafts WHERE id = ?").run(draft.id);
780
+ res.json(result);
781
+ } finally {
782
+ sender.close();
783
+ }
769
784
  } catch (err) {
770
785
  next(err);
771
786
  }
772
787
  });
773
- router.get("/mail/inbox", requireAgent, async (req, res, next) => {
788
+ router.get("/signatures", requireAgent, async (req, res, next) => {
774
789
  try {
775
- const agent = req.agent;
776
- const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
777
- const offset = Math.max(parseInt(req.query.offset) || 0, 0);
778
- const password = getAgentPassword(agent);
779
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
780
- const mailboxInfo = await receiver.getMailboxInfo("INBOX");
781
- const envelopes = await receiver.listEnvelopes("INBOX", { limit, offset });
782
- res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists });
790
+ const rows = db.prepare("SELECT * FROM signatures WHERE agent_id = ? ORDER BY is_default DESC, name").all(req.agent.id);
791
+ res.json({ signatures: rows });
783
792
  } catch (err) {
784
793
  next(err);
785
794
  }
786
795
  });
787
- router.get("/mail/messages/:uid", requireAgent, async (req, res, next) => {
796
+ router.post("/signatures", requireAgent, async (req, res, next) => {
788
797
  try {
789
- const agent = req.agent;
790
- const uid = parseInt(req.params.uid);
791
- if (isNaN(uid) || uid < 1) {
792
- res.status(400).json({ error: "Invalid UID" });
798
+ const { name, text, html, isDefault } = req.body || {};
799
+ if (!name) {
800
+ res.status(400).json({ error: "name is required" });
793
801
  return;
794
802
  }
795
- const folder = req.query.folder || "INBOX";
796
- const password = getAgentPassword(agent);
797
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
798
- const raw = await receiver.fetchMessage(uid, folder);
799
- const parsed = await parseEmail(raw);
800
- if (isInternalEmail(parsed)) {
801
- res.json({
802
- ...parsed,
803
- security: { internal: true, spamScore: 0, isSpam: false, isWarning: false }
804
- });
805
- return;
803
+ const id = uuidv4();
804
+ if (isDefault) {
805
+ db.prepare("UPDATE signatures SET is_default = 0 WHERE agent_id = ?").run(req.agent.id);
806
806
  }
807
- const sanitized = sanitizeEmail(parsed);
808
- const spamScore = scoreEmail(parsed);
809
- res.json({
810
- ...parsed,
811
- text: sanitized.text,
812
- html: sanitized.html,
813
- security: {
814
- spamScore: spamScore.score,
815
- isSpam: spamScore.isSpam,
816
- isWarning: spamScore.isWarning,
817
- topCategory: spamScore.topCategory,
818
- matches: spamScore.matches.map((m) => m.ruleId),
819
- sanitized: sanitized.wasModified,
820
- sanitizeDetections: sanitized.detections
821
- }
822
- });
807
+ db.prepare("INSERT OR REPLACE INTO signatures (id, agent_id, name, text_content, html_content, is_default) VALUES (?, ?, ?, ?, ?, ?)").run(id, req.agent.id, name, text || null, html || null, isDefault ? 1 : 0);
808
+ res.json({ ok: true, id });
823
809
  } catch (err) {
824
810
  next(err);
825
811
  }
826
812
  });
827
- router.get("/mail/messages/:uid/attachments/:index", requireAgent, async (req, res, next) => {
813
+ router.delete("/signatures/:id", requireAgent, async (req, res, next) => {
828
814
  try {
829
- const agent = req.agent;
830
- const uid = parseInt(req.params.uid);
831
- const index = parseInt(req.params.index);
832
- if (isNaN(uid) || uid < 1) {
833
- res.status(400).json({ error: "Invalid UID" });
834
- return;
835
- }
836
- if (isNaN(index) || index < 0) {
837
- res.status(400).json({ error: "Invalid attachment index" });
838
- return;
839
- }
840
- const folder = req.query.folder || "INBOX";
841
- const password = getAgentPassword(agent);
842
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
843
- const raw = await receiver.fetchMessage(uid, folder);
844
- const parsed = await parseEmail(raw);
845
- if (!parsed.attachments || index >= parsed.attachments.length) {
846
- res.status(404).json({ error: "Attachment not found" });
815
+ const result = db.prepare("DELETE FROM signatures WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
816
+ if (result.changes === 0) {
817
+ res.status(404).json({ error: "Signature not found" });
847
818
  return;
848
819
  }
849
- const att = parsed.attachments[index];
850
- res.setHeader("Content-Type", att.contentType || "application/octet-stream");
851
- res.setHeader("Content-Disposition", `attachment; filename="${att.filename.replace(/"/g, '\\"')}"`);
852
- res.setHeader("Content-Length", att.content.length);
853
- res.send(att.content);
820
+ res.json({ ok: true });
854
821
  } catch (err) {
855
822
  next(err);
856
823
  }
857
824
  });
858
- router.post("/mail/search", requireAgent, async (req, res, next) => {
825
+ router.get("/templates", requireAgent, async (req, res, next) => {
859
826
  try {
860
- if (!req.body || typeof req.body !== "object") {
861
- res.status(400).json({ error: "Request body must be JSON" });
862
- return;
863
- }
864
- const agent = req.agent;
865
- const { from, to, subject, since, before, seen, text, searchRelay } = req.body;
866
- const password = getAgentPassword(agent);
867
- const sinceDate = since ? new Date(since) : void 0;
868
- const beforeDate = before ? new Date(before) : void 0;
869
- if (sinceDate && isNaN(sinceDate.getTime())) {
870
- res.status(400).json({ error: 'Invalid "since" date' });
827
+ const rows = db.prepare("SELECT * FROM templates WHERE agent_id = ? ORDER BY name").all(req.agent.id);
828
+ res.json({ templates: rows });
829
+ } catch (err) {
830
+ next(err);
831
+ }
832
+ });
833
+ router.post("/templates", requireAgent, async (req, res, next) => {
834
+ try {
835
+ const { name, subject, text, html } = req.body || {};
836
+ if (!name) {
837
+ res.status(400).json({ error: "name is required" });
871
838
  return;
872
839
  }
873
- if (beforeDate && isNaN(beforeDate.getTime())) {
874
- res.status(400).json({ error: 'Invalid "before" date' });
840
+ const id = uuidv4();
841
+ db.prepare("INSERT OR REPLACE INTO templates (id, agent_id, name, subject, text_body, html_body) VALUES (?, ?, ?, ?, ?, ?)").run(id, req.agent.id, name, subject || null, text || null, html || null);
842
+ res.json({ ok: true, id });
843
+ } catch (err) {
844
+ next(err);
845
+ }
846
+ });
847
+ router.delete("/templates/:id", requireAgent, async (req, res, next) => {
848
+ try {
849
+ const result = db.prepare("DELETE FROM templates WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
850
+ if (result.changes === 0) {
851
+ res.status(404).json({ error: "Template not found" });
875
852
  return;
876
853
  }
877
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
878
- const uids = await receiver.search({
879
- from,
880
- to,
881
- subject,
882
- since: sinceDate,
883
- before: beforeDate,
884
- seen,
885
- text
886
- });
887
- let relayResults;
888
- if (searchRelay === true && gatewayManager) {
889
- try {
890
- const relayHits = await gatewayManager.searchRelay({
891
- from,
892
- to,
893
- subject,
894
- text,
895
- since: sinceDate,
896
- before: beforeDate,
897
- seen
898
- });
899
- if (relayHits.length > 0) {
900
- relayResults = relayHits.map((r) => ({
901
- uid: r.uid,
902
- source: r.source,
903
- account: r.account,
904
- messageId: r.messageId,
905
- subject: r.subject,
906
- from: r.from,
907
- to: r.to,
908
- date: r.date,
909
- flags: r.flags
910
- }));
911
- }
912
- } catch {
913
- }
914
- }
915
- res.json({ uids, ...relayResults ? { relayResults } : {} });
854
+ res.json({ ok: true });
916
855
  } catch (err) {
917
856
  next(err);
918
857
  }
919
858
  });
920
- router.post("/mail/import-relay", requireAgent, async (req, res, next) => {
859
+ router.get("/scheduled", requireAgent, async (req, res, next) => {
921
860
  try {
922
- const { uid } = req.body || {};
923
- if (!uid || typeof uid !== "number" || uid < 1) {
924
- res.status(400).json({ error: "uid (number) is required" });
861
+ const rows = db.prepare("SELECT * FROM scheduled_emails WHERE agent_id = ? ORDER BY send_at ASC").all(req.agent.id);
862
+ res.json({ scheduled: rows });
863
+ } catch (err) {
864
+ next(err);
865
+ }
866
+ });
867
+ router.post("/scheduled", requireAgent, async (req, res, next) => {
868
+ try {
869
+ const { to, subject, text, html, cc, bcc, sendAt } = req.body || {};
870
+ if (!to || !subject || !sendAt) {
871
+ res.status(400).json({ error: "to, subject, and sendAt are required" });
925
872
  return;
926
873
  }
927
- if (!gatewayManager) {
928
- res.status(400).json({ error: "No gateway configured" });
874
+ const sendDate = parseScheduleTime(String(sendAt));
875
+ if (!sendDate || isNaN(sendDate.getTime())) {
876
+ res.status(400).json({
877
+ 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"
878
+ });
929
879
  return;
930
880
  }
931
- const agent = req.agent;
932
- const result = await gatewayManager.importRelayMessage(uid, agent.name);
933
- if (!result.success) {
934
- res.status(400).json({ error: result.error || "Import failed" });
881
+ if (sendDate.getTime() <= Date.now()) {
882
+ res.status(400).json({ error: "sendAt must be in the future" });
935
883
  return;
936
884
  }
937
- res.json({ ok: true, message: "Email imported to local inbox. Use /inbox or list_inbox to see it." });
885
+ const id = uuidv4();
886
+ db.prepare(`INSERT INTO scheduled_emails (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, send_at)
887
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, req.agent.id, to, subject, text || null, html || null, cc || null, bcc || null, sendDate.toISOString());
888
+ res.json({ ok: true, id, sendAt: sendDate.toISOString() });
938
889
  } catch (err) {
939
890
  next(err);
940
891
  }
941
892
  });
942
- router.post("/mail/messages/:uid/seen", requireAgent, async (req, res, next) => {
893
+ router.delete("/scheduled/:id", requireAgent, async (req, res, next) => {
943
894
  try {
944
- const agent = req.agent;
945
- const uid = parseInt(req.params.uid);
946
- if (isNaN(uid) || uid < 1) {
947
- res.status(400).json({ error: "Invalid UID" });
895
+ const result = db.prepare("DELETE FROM scheduled_emails WHERE id = ? AND agent_id = ? AND status = 'pending'").run(req.params.id, req.agent.id);
896
+ if (result.changes === 0) {
897
+ res.status(404).json({ error: "Scheduled email not found or already sent" });
948
898
  return;
949
899
  }
950
- const password = getAgentPassword(agent);
951
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
952
- await receiver.markSeen(uid);
953
900
  res.json({ ok: true });
954
901
  } catch (err) {
955
902
  next(err);
956
903
  }
957
904
  });
958
- router.delete("/mail/messages/:uid", requireAgent, async (req, res, next) => {
905
+ router.get("/tags", requireAgent, async (req, res, next) => {
959
906
  try {
960
- const agent = req.agent;
961
- const uid = parseInt(req.params.uid);
962
- if (isNaN(uid) || uid < 1) {
963
- res.status(400).json({ error: "Invalid UID" });
907
+ const rows = db.prepare("SELECT * FROM tags WHERE agent_id = ? ORDER BY name").all(req.agent.id);
908
+ res.json({ tags: rows });
909
+ } catch (err) {
910
+ next(err);
911
+ }
912
+ });
913
+ router.post("/tags", requireAgent, async (req, res, next) => {
914
+ try {
915
+ const { name, color } = req.body || {};
916
+ if (!name) {
917
+ res.status(400).json({ error: "name is required" });
964
918
  return;
965
919
  }
966
- const password = getAgentPassword(agent);
967
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
968
- await receiver.deleteMessage(uid);
969
- res.status(204).send();
920
+ const id = uuidv4();
921
+ db.prepare("INSERT OR IGNORE INTO tags (id, agent_id, name, color) VALUES (?, ?, ?, ?)").run(id, req.agent.id, name.trim(), color || "#888888");
922
+ res.json({ ok: true, id, name: name.trim(), color: color || "#888888" });
970
923
  } catch (err) {
971
924
  next(err);
972
925
  }
973
926
  });
974
- router.post("/mail/messages/:uid/unseen", requireAgent, async (req, res, next) => {
927
+ router.delete("/tags/:id", requireAgent, async (req, res, next) => {
975
928
  try {
976
- const agent = req.agent;
977
- const uid = parseInt(req.params.uid);
978
- if (isNaN(uid) || uid < 1) {
979
- res.status(400).json({ error: "Invalid UID" });
929
+ const result = db.prepare("DELETE FROM tags WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
930
+ if (result.changes === 0) {
931
+ res.status(404).json({ error: "Tag not found" });
980
932
  return;
981
933
  }
982
- const password = getAgentPassword(agent);
983
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
984
- await receiver.markUnseen(uid);
985
934
  res.json({ ok: true });
986
935
  } catch (err) {
987
936
  next(err);
988
937
  }
989
938
  });
990
- router.post("/mail/messages/:uid/move", requireAgent, async (req, res, next) => {
939
+ router.post("/tags/:id/messages", requireAgent, async (req, res, next) => {
991
940
  try {
992
- const agent = req.agent;
993
- const uid = parseInt(req.params.uid);
994
- if (isNaN(uid) || uid < 1) {
995
- res.status(400).json({ error: "Invalid UID" });
941
+ const { uid, folder } = req.body || {};
942
+ if (!uid) {
943
+ res.status(400).json({ error: "uid is required" });
996
944
  return;
997
945
  }
998
- const { from: fromFolder, to: toFolder } = req.body || {};
999
- if (!toFolder) {
1000
- res.status(400).json({ error: "to (destination folder) is required" });
946
+ const tag = db.prepare("SELECT * FROM tags WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
947
+ if (!tag) {
948
+ res.status(404).json({ error: "Tag not found" });
1001
949
  return;
1002
950
  }
1003
- const password = getAgentPassword(agent);
1004
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1005
- await receiver.moveMessage(uid, fromFolder || "INBOX", toFolder);
951
+ 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");
1006
952
  res.json({ ok: true });
1007
953
  } catch (err) {
1008
954
  next(err);
1009
955
  }
1010
956
  });
1011
- router.get("/mail/folders", requireAgent, async (req, res, next) => {
957
+ router.delete("/tags/:id/messages/:uid", requireAgent, async (req, res, next) => {
1012
958
  try {
1013
- const agent = req.agent;
1014
- const password = getAgentPassword(agent);
1015
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1016
- const folders = await receiver.listFolders();
1017
- res.json({ folders });
959
+ const folder = req.query.folder || "INBOX";
960
+ 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);
961
+ res.json({ ok: true });
1018
962
  } catch (err) {
1019
963
  next(err);
1020
964
  }
1021
965
  });
1022
- router.post("/mail/folders", requireAgent, async (req, res, next) => {
966
+ router.get("/tags/:id/messages", requireAgent, async (req, res, next) => {
1023
967
  try {
1024
- const agent = req.agent;
1025
- const { name: name2 } = req.body || {};
1026
- if (!name2 || typeof name2 !== "string" || !name2.trim()) {
1027
- res.status(400).json({ error: "name is required" });
1028
- return;
1029
- }
1030
- if (name2.length > 200 || /[\\*%]/.test(name2)) {
1031
- res.status(400).json({ error: "Invalid folder name" });
968
+ const tag = db.prepare("SELECT * FROM tags WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
969
+ if (!tag) {
970
+ res.status(404).json({ error: "Tag not found" });
1032
971
  return;
1033
972
  }
1034
- const password = getAgentPassword(agent);
1035
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1036
- await receiver.createFolder(name2);
1037
- res.json({ ok: true, folder: name2 });
973
+ const rows = db.prepare(
974
+ "SELECT message_uid, folder FROM message_tags WHERE agent_id = ? AND tag_id = ? ORDER BY created_at DESC"
975
+ ).all(req.agent.id, req.params.id);
976
+ res.json({ tag, messages: rows.map((r) => ({ uid: r.message_uid, folder: r.folder })) });
1038
977
  } catch (err) {
1039
978
  next(err);
1040
979
  }
1041
980
  });
1042
- router.get("/mail/folders/:folder", requireAgent, async (req, res, next) => {
981
+ router.get("/messages/:uid/tags", requireAgent, async (req, res, next) => {
1043
982
  try {
1044
- const agent = req.agent;
1045
- const folder = decodeURIComponent(req.params.folder);
1046
- const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
1047
- const offset = Math.max(parseInt(req.query.offset) || 0, 0);
1048
- const password = getAgentPassword(agent);
1049
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1050
- let mailboxInfo;
1051
- try {
1052
- mailboxInfo = await receiver.getMailboxInfo(folder);
1053
- } catch {
1054
- res.json({ messages: [], count: 0, total: 0, folder });
1055
- return;
1056
- }
1057
- const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
1058
- res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder });
983
+ const rows = db.prepare(`
984
+ SELECT t.* FROM tags t
985
+ JOIN message_tags mt ON mt.tag_id = t.id
986
+ WHERE mt.agent_id = ? AND mt.message_uid = ?
987
+ ORDER BY t.name
988
+ `).all(req.agent.id, parseInt(String(req.params.uid)));
989
+ res.json({ tags: rows });
1059
990
  } catch (err) {
1060
991
  next(err);
1061
992
  }
1062
993
  });
1063
- function validateUids(raw) {
1064
- if (!Array.isArray(raw) || raw.length === 0) return null;
1065
- if (raw.length > 1e3) return null;
1066
- const nums = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0);
1067
- return nums.length > 0 ? nums : null;
1068
- }
1069
- router.post("/mail/batch/delete", requireAgent, async (req, res, next) => {
994
+ router.post("/templates/:id/send", requireAgent, async (req, res, next) => {
1070
995
  try {
1071
- const agent = req.agent;
1072
- const { uids: rawUids, folder } = req.body || {};
1073
- const uids = validateUids(rawUids);
1074
- if (!uids) {
1075
- res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
996
+ const template = db.prepare("SELECT * FROM templates WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
997
+ if (!template) {
998
+ res.status(404).json({ error: "Template not found" });
1076
999
  return;
1077
1000
  }
1078
- const password = getAgentPassword(agent);
1079
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1080
- await receiver.batchDelete(uids, folder || "INBOX");
1081
- res.json({ ok: true, deleted: uids.length });
1082
- } catch (err) {
1083
- next(err);
1084
- }
1085
- });
1086
- router.post("/mail/batch/seen", requireAgent, async (req, res, next) => {
1087
- try {
1088
- const agent = req.agent;
1089
- const { uids: rawUids, folder } = req.body || {};
1090
- const uids = validateUids(rawUids);
1091
- if (!uids) {
1092
- res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1001
+ const { to, variables, cc, bcc } = req.body || {};
1002
+ if (!to) {
1003
+ res.status(400).json({ error: "to is required" });
1093
1004
  return;
1094
1005
  }
1006
+ const applyVars = (text, vars2) => text.replace(/\{\{(\w+)\}\}/g, (m, key) => vars2[key] ?? m);
1007
+ const vars = variables && typeof variables === "object" ? variables : {};
1008
+ const mailOpts = {
1009
+ to,
1010
+ subject: applyVars(template.subject || "(no subject)", vars),
1011
+ text: template.text_body ? applyVars(template.text_body, vars) : void 0,
1012
+ html: template.html_body ? applyVars(template.html_body, vars) : void 0,
1013
+ cc: cc || void 0,
1014
+ bcc: bcc || void 0
1015
+ };
1016
+ const agent = req.agent;
1017
+ if (gatewayManager) {
1018
+ const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
1019
+ if (gatewayResult) {
1020
+ res.json(gatewayResult);
1021
+ return;
1022
+ }
1023
+ }
1095
1024
  const password = getAgentPassword(agent);
1096
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1097
- await receiver.batchMarkSeen(uids, folder || "INBOX");
1098
- res.json({ ok: true, marked: uids.length });
1025
+ const sender = new MailSender({
1026
+ host: config.smtp.host,
1027
+ port: config.smtp.port,
1028
+ email: agent.email,
1029
+ password,
1030
+ authUser: agent.stalwartPrincipal
1031
+ });
1032
+ try {
1033
+ const result = await sender.send(mailOpts);
1034
+ res.json(result);
1035
+ } finally {
1036
+ sender.close();
1037
+ }
1099
1038
  } catch (err) {
1100
1039
  next(err);
1101
1040
  }
1102
1041
  });
1103
- router.post("/mail/batch/unseen", requireAgent, async (req, res, next) => {
1042
+ router.get("/rules", requireAgent, async (req, res, next) => {
1104
1043
  try {
1105
- const agent = req.agent;
1106
- const { uids: rawUids, folder } = req.body || {};
1107
- const uids = validateUids(rawUids);
1108
- if (!uids) {
1109
- res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1110
- return;
1111
- }
1112
- const password = getAgentPassword(agent);
1113
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1114
- await receiver.batchMarkUnseen(uids, folder || "INBOX");
1115
- res.json({ ok: true, marked: uids.length });
1044
+ const rows = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? ORDER BY priority DESC, created_at").all(req.agent.id);
1045
+ res.json({ rules: rows.map((r) => ({ ...r, conditions: JSON.parse(r.conditions), actions: JSON.parse(r.actions) })) });
1116
1046
  } catch (err) {
1117
1047
  next(err);
1118
1048
  }
1119
1049
  });
1120
- router.post("/mail/batch/move", requireAgent, async (req, res, next) => {
1050
+ router.post("/rules", requireAgent, async (req, res, next) => {
1121
1051
  try {
1122
- const agent = req.agent;
1123
- const { uids: rawUids, from: fromFolder, to: toFolder } = req.body || {};
1124
- const uids = validateUids(rawUids);
1125
- if (!uids) {
1126
- res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1127
- return;
1128
- }
1129
- if (!toFolder) {
1130
- res.status(400).json({ error: "to (destination folder) is required" });
1052
+ const { name, conditions, actions, priority, enabled } = req.body || {};
1053
+ if (!name) {
1054
+ res.status(400).json({ error: "name is required" });
1131
1055
  return;
1132
1056
  }
1133
- const password = getAgentPassword(agent);
1134
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1135
- await receiver.batchMove(uids, fromFolder || "INBOX", toFolder);
1136
- res.json({ ok: true, moved: uids.length });
1057
+ const id = uuidv4();
1058
+ db.prepare(
1059
+ "INSERT INTO email_rules (id, agent_id, name, priority, enabled, conditions, actions) VALUES (?, ?, ?, ?, ?, ?, ?)"
1060
+ ).run(id, req.agent.id, name, priority ?? 0, enabled !== false ? 1 : 0, JSON.stringify(conditions || {}), JSON.stringify(actions || {}));
1061
+ res.status(201).json({ id, name, conditions: conditions || {}, actions: actions || {}, priority: priority ?? 0, enabled: enabled !== false });
1137
1062
  } catch (err) {
1138
1063
  next(err);
1139
1064
  }
1140
1065
  });
1141
- router.post("/mail/batch/read", requireAgent, async (req, res, next) => {
1066
+ router.delete("/rules/:id", requireAgent, async (req, res, next) => {
1142
1067
  try {
1143
- const agent = req.agent;
1144
- const { uids: rawUids, folder } = req.body || {};
1145
- const uids = validateUids(rawUids);
1146
- if (!uids) {
1147
- res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1068
+ const result = db.prepare("DELETE FROM email_rules WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1069
+ if (result.changes === 0) {
1070
+ res.status(404).json({ error: "Rule not found" });
1148
1071
  return;
1149
1072
  }
1150
- const password = getAgentPassword(agent);
1151
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1152
- const rawMap = await receiver.batchFetch(uids, folder || "INBOX");
1153
- const messages = [];
1154
- for (const [uid, raw] of rawMap) {
1155
- const parsed = await parseEmail(raw);
1156
- messages.push({ uid, ...parsed });
1157
- }
1158
- res.json({ messages, count: messages.length });
1073
+ res.json({ ok: true });
1159
1074
  } catch (err) {
1160
1075
  next(err);
1161
1076
  }
1162
1077
  });
1163
- router.get("/mail/spam", requireAgent, async (req, res, next) => {
1164
- try {
1165
- const agent = req.agent;
1166
- const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
1167
- const offset = Math.max(parseInt(req.query.offset) || 0, 0);
1168
- const password = getAgentPassword(agent);
1169
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1170
- let mailboxInfo;
1078
+ return router;
1079
+ }
1080
+ function evaluateRules(db, agentId, email) {
1081
+ const rules = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? AND enabled = 1 ORDER BY priority DESC").all(agentId);
1082
+ for (const rule of rules) {
1083
+ const cond = JSON.parse(rule.conditions);
1084
+ let match = true;
1085
+ const fromAddr = (email.from?.[0]?.address ?? "").toLowerCase();
1086
+ const toAddr = (email.to?.[0]?.address ?? "").toLowerCase();
1087
+ const subject = (email.subject ?? "").toLowerCase();
1088
+ if (cond.from_contains && !fromAddr.includes(cond.from_contains.toLowerCase())) match = false;
1089
+ if (cond.from_exact && fromAddr !== cond.from_exact.toLowerCase()) match = false;
1090
+ if (cond.subject_contains && !subject.includes(cond.subject_contains.toLowerCase())) match = false;
1091
+ if (cond.subject_regex) {
1171
1092
  try {
1172
- mailboxInfo = await receiver.getMailboxInfo("Spam");
1093
+ if (!new RegExp(cond.subject_regex, "i").test(email.subject ?? "")) match = false;
1173
1094
  } catch {
1174
- res.json({ messages: [], count: 0, total: 0, folder: "Spam" });
1175
- return;
1095
+ match = false;
1176
1096
  }
1177
- const envelopes = await receiver.listEnvelopes("Spam", { limit, offset });
1178
- res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder: "Spam" });
1179
- } catch (err) {
1180
- next(err);
1181
1097
  }
1182
- });
1183
- router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
1098
+ if (cond.to_contains && !toAddr.includes(cond.to_contains.toLowerCase())) match = false;
1099
+ if (cond.has_attachment === true && (!email.attachments || email.attachments.length === 0)) match = false;
1100
+ if (match) return { ruleId: rule.id, actions: JSON.parse(rule.actions) };
1101
+ }
1102
+ return null;
1103
+ }
1104
+ function startScheduledSender(db, accountManager, config, gatewayManager) {
1105
+ return setInterval(async () => {
1184
1106
  try {
1185
- const agent = req.agent;
1186
- const uid = parseInt(req.params.uid);
1187
- if (isNaN(uid) || uid < 1) {
1188
- res.status(400).json({ error: "Invalid UID" });
1189
- return;
1107
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1108
+ const pending = db.prepare(
1109
+ "SELECT * FROM scheduled_emails WHERE status = 'pending' AND send_at <= ?"
1110
+ ).all(now);
1111
+ for (const row of pending) {
1112
+ try {
1113
+ const agent = await accountManager.getById(row.agent_id);
1114
+ if (!agent) {
1115
+ db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run("Agent not found", row.id);
1116
+ continue;
1117
+ }
1118
+ const mailOpts = {
1119
+ to: row.to_addr,
1120
+ subject: row.subject,
1121
+ text: row.text_body || void 0,
1122
+ html: row.html_body || void 0,
1123
+ cc: row.cc || void 0,
1124
+ bcc: row.bcc || void 0
1125
+ };
1126
+ if (gatewayManager) {
1127
+ const gResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
1128
+ if (gResult) {
1129
+ db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
1130
+ continue;
1131
+ }
1132
+ }
1133
+ const password = agent.metadata?._password || agent.name;
1134
+ const sender = new MailSender({
1135
+ host: config.smtp.host,
1136
+ port: config.smtp.port,
1137
+ email: agent.email,
1138
+ password,
1139
+ authUser: agent.stalwartPrincipal
1140
+ });
1141
+ try {
1142
+ await sender.send(mailOpts);
1143
+ db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
1144
+ } finally {
1145
+ sender.close();
1146
+ }
1147
+ } catch (err) {
1148
+ db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run(err.message, row.id);
1149
+ }
1190
1150
  }
1191
- const folder = req.body?.folder || "INBOX";
1192
- const password = getAgentPassword(agent);
1193
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1194
1151
  try {
1195
- await receiver.createFolder("Spam");
1152
+ db.prepare("DELETE FROM delivered_messages WHERE delivered_at < datetime('now', '-30 days')").run();
1196
1153
  } catch {
1197
1154
  }
1198
- await receiver.moveMessage(uid, folder, "Spam");
1199
- res.json({ ok: true, movedToSpam: true });
1200
- } catch (err) {
1201
- next(err);
1202
- }
1203
- });
1204
- router.post("/mail/messages/:uid/not-spam", requireAgent, async (req, res, next) => {
1205
- try {
1206
- const agent = req.agent;
1207
- const uid = parseInt(req.params.uid);
1208
- if (isNaN(uid) || uid < 1) {
1209
- res.status(400).json({ error: "Invalid UID" });
1210
- return;
1155
+ try {
1156
+ db.prepare("DELETE FROM spam_log WHERE created_at < datetime('now', '-30 days')").run();
1157
+ } catch {
1211
1158
  }
1212
- const password = getAgentPassword(agent);
1213
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1214
- await receiver.moveMessage(uid, "Spam", "INBOX");
1215
- res.json({ ok: true, movedToInbox: true });
1216
- } catch (err) {
1217
- next(err);
1159
+ } catch {
1218
1160
  }
1219
- });
1220
- router.get("/mail/messages/:uid/spam-score", requireAgent, async (req, res, next) => {
1161
+ }, 3e4);
1162
+ }
1163
+
1164
+ // src/routes/events.ts
1165
+ var MAX_SSE_PER_AGENT = 5;
1166
+ var activeWatchers = /* @__PURE__ */ new Map();
1167
+ function pushEventToAgent(agentId, event) {
1168
+ const watchers = activeWatchers.get(agentId);
1169
+ if (!watchers || watchers.size === 0) return false;
1170
+ const data = `data: ${JSON.stringify(event)}
1171
+
1172
+ `;
1173
+ for (const entry of watchers) {
1221
1174
  try {
1222
- const agent = req.agent;
1223
- const uid = parseInt(req.params.uid);
1224
- if (isNaN(uid) || uid < 1) {
1225
- res.status(400).json({ error: "Invalid UID" });
1226
- return;
1175
+ entry.res.write(data);
1176
+ } catch {
1177
+ }
1178
+ }
1179
+ return true;
1180
+ }
1181
+ function broadcastEvent(event) {
1182
+ const data = `data: ${JSON.stringify(event)}
1183
+
1184
+ `;
1185
+ let count = 0;
1186
+ for (const [, watchers] of activeWatchers) {
1187
+ for (const entry of watchers) {
1188
+ try {
1189
+ entry.res.write(data);
1190
+ count++;
1191
+ } catch {
1227
1192
  }
1228
- const folder = req.query.folder || "INBOX";
1229
- const password = getAgentPassword(agent);
1230
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1231
- const raw = await receiver.fetchMessage(uid, folder);
1232
- const parsed = await parseEmail(raw);
1233
- if (isInternalEmail(parsed)) {
1234
- res.json({ score: 0, isSpam: false, isWarning: false, matches: [], topCategory: null, internal: true });
1235
- return;
1193
+ }
1194
+ }
1195
+ return count;
1196
+ }
1197
+ async function closeAllWatchers() {
1198
+ for (const [, watchers] of activeWatchers) {
1199
+ for (const entry of watchers) {
1200
+ try {
1201
+ await entry.watcher.stop();
1202
+ } catch {
1203
+ }
1204
+ try {
1205
+ entry.res.end();
1206
+ } catch {
1236
1207
  }
1237
- const result = scoreEmail(parsed);
1238
- res.json(result);
1239
- } catch (err) {
1240
- next(err);
1241
1208
  }
1242
- });
1243
- router.get("/mail/digest", requireAgent, async (req, res, next) => {
1209
+ }
1210
+ activeWatchers.clear();
1211
+ }
1212
+ function createEventRoutes(accountManager, config, db) {
1213
+ const router = Router4();
1214
+ router.get("/events", requireAgent, async (req, res, next) => {
1244
1215
  try {
1245
1216
  const agent = req.agent;
1246
- const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 50);
1247
- const offset = Math.max(parseInt(req.query.offset) || 0, 0);
1248
- const previewLen = Math.min(Math.max(parseInt(req.query.previewLength) || 200, 50), 500);
1249
- const folder = req.query.folder || "INBOX";
1250
1217
  const password = getAgentPassword(agent);
1251
- const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1252
- const mailboxInfo = await receiver.getMailboxInfo(folder);
1253
- const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
1254
- const uids = envelopes.map((e) => e.uid);
1255
- const rawMap = uids.length > 0 ? await receiver.batchFetch(uids, folder) : /* @__PURE__ */ new Map();
1256
- const messages = [];
1257
- for (const env of envelopes) {
1258
- let preview = "";
1259
- const raw = rawMap.get(env.uid);
1260
- if (raw) {
1261
- const parsed = await parseEmail(raw);
1262
- preview = (parsed.text || "").slice(0, previewLen);
1263
- }
1264
- messages.push({
1265
- uid: env.uid,
1266
- subject: env.subject,
1267
- from: env.from,
1268
- to: env.to,
1269
- date: env.date,
1270
- flags: [...env.flags],
1271
- size: env.size,
1272
- preview
1273
- });
1274
- }
1275
- res.json({ messages, count: messages.length, total: mailboxInfo.exists });
1276
- } catch (err) {
1277
- next(err);
1278
- }
1279
- });
1280
- router.get("/mail/pending", requireAuth, async (req, res) => {
1281
- const rows = req.isMaster ? db.prepare(
1282
- `SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
1283
- FROM pending_outbound ORDER BY created_at DESC LIMIT 50`
1284
- ).all() : db.prepare(
1285
- `SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
1286
- FROM pending_outbound WHERE agent_id = ? ORDER BY created_at DESC LIMIT 50`
1287
- ).all(req.agent.id);
1288
- const pending = rows.map((r) => {
1289
- const opts = JSON.parse(r.mail_options);
1290
- return {
1291
- id: r.id,
1292
- agentId: r.agent_id,
1293
- to: opts.to,
1294
- subject: opts.subject,
1295
- warnings: JSON.parse(r.warnings),
1296
- summary: r.summary,
1297
- status: r.status,
1298
- createdAt: r.created_at,
1299
- resolvedAt: r.resolved_at,
1300
- resolvedBy: r.resolved_by
1301
- };
1302
- });
1303
- res.json({ pending, count: pending.length });
1304
- });
1305
- router.get("/mail/pending/:id", requireAuth, async (req, res) => {
1306
- 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);
1307
- if (!row) {
1308
- res.status(404).json({ error: "Pending email not found" });
1309
- return;
1310
- }
1311
- res.json({
1312
- id: row.id,
1313
- mailOptions: JSON.parse(row.mail_options),
1314
- warnings: JSON.parse(row.warnings),
1315
- summary: row.summary,
1316
- status: row.status,
1317
- createdAt: row.created_at,
1318
- resolvedAt: row.resolved_at,
1319
- resolvedBy: row.resolved_by
1320
- });
1321
- });
1322
- router.post("/mail/pending/:id/approve", requireMaster, async (req, res, next) => {
1323
- try {
1324
- const row = db.prepare(
1325
- `SELECT * FROM pending_outbound WHERE id = ?`
1326
- ).get(req.params.id);
1327
- if (!row) {
1328
- res.status(404).json({ error: "Pending email not found" });
1329
- return;
1330
- }
1331
- if (row.status !== "pending") {
1332
- res.status(400).json({ error: `Email already ${row.status}` });
1218
+ const agentWatchers = activeWatchers.get(agent.id) ?? /* @__PURE__ */ new Set();
1219
+ if (agentWatchers.size >= MAX_SSE_PER_AGENT) {
1220
+ res.status(429).json({ error: `Maximum ${MAX_SSE_PER_AGENT} concurrent SSE connections per agent` });
1333
1221
  return;
1334
1222
  }
1335
- const agent = await accountManager.getById(row.agent_id);
1336
- if (!agent) {
1337
- res.status(404).json({ error: "Agent account no longer exists" });
1223
+ const watcher = new InboxWatcher({
1224
+ host: config.imap.host,
1225
+ port: config.imap.port,
1226
+ email: agent.stalwartPrincipal,
1227
+ password,
1228
+ autoReconnect: true,
1229
+ maxReconnectAttempts: 20
1230
+ });
1231
+ try {
1232
+ await watcher.start();
1233
+ } catch (err) {
1234
+ res.status(500).json({ error: "Failed to start event stream: " + (err instanceof Error ? err.message : String(err)) });
1338
1235
  return;
1339
1236
  }
1340
- const mailOpts = JSON.parse(row.mail_options);
1341
- const ownerName = agent.metadata?.ownerName;
1342
- mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
1343
- if (Array.isArray(mailOpts.attachments)) {
1344
- for (const att of mailOpts.attachments) {
1345
- if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
1346
- att.content = Buffer.from(att.content.data);
1237
+ res.setHeader("Content-Type", "text/event-stream");
1238
+ res.setHeader("Cache-Control", "no-cache");
1239
+ res.setHeader("Connection", "keep-alive");
1240
+ res.setHeader("X-Accel-Buffering", "no");
1241
+ res.flushHeaders();
1242
+ let closed = false;
1243
+ const entry = { watcher, res };
1244
+ activeWatchers.set(agent.id, agentWatchers);
1245
+ agentWatchers.add(entry);
1246
+ const safeWrite = (data) => {
1247
+ if (!closed) {
1248
+ try {
1249
+ res.write(data);
1250
+ } catch {
1347
1251
  }
1348
1252
  }
1349
- }
1350
- const password = getAgentPassword(agent);
1351
- let response;
1352
- if (gatewayManager) {
1353
- const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
1354
- if (gatewayResult) {
1355
- if (gatewayResult.raw) {
1356
- saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
1253
+ };
1254
+ safeWrite(`data: ${JSON.stringify({ type: "connected", agentId: agent.id })}
1255
+
1256
+ `);
1257
+ watcher.on("new", async (event) => {
1258
+ if (db) touchActivity(db, agent.id);
1259
+ if (db && event.uid) {
1260
+ try {
1261
+ const receiver = new MailReceiver({
1262
+ host: config.imap.host,
1263
+ port: config.imap.port,
1264
+ email: agent.stalwartPrincipal,
1265
+ password,
1266
+ secure: false
1267
+ });
1268
+ await receiver.connect();
1269
+ try {
1270
+ const raw = await receiver.fetchMessage(event.uid);
1271
+ const parsed = await parseEmail(raw);
1272
+ const policyMetadata = agent.metadata && typeof agent.metadata === "object" ? {
1273
+ emailRoutePolicy: agent.metadata.emailRoutePolicy,
1274
+ routePolicy: agent.metadata.routePolicy,
1275
+ mailboxPolicy: agent.metadata.mailboxPolicy
1276
+ } : void 0;
1277
+ const accountRouteContext = {
1278
+ name: agent.name,
1279
+ email: agent.email,
1280
+ role: agent.role,
1281
+ metadata: policyMetadata
1282
+ };
1283
+ const isRelay = !!parsed.headers.get("x-agenticmail-relay");
1284
+ const internal = !isRelay && isInternalEmail(parsed);
1285
+ if (internal) {
1286
+ event.route = classifyEmailRoute({ email: parsed, account: accountRouteContext });
1287
+ const ruleResult2 = evaluateRules(db, agent.id, parsed);
1288
+ if (ruleResult2) {
1289
+ const actions = ruleResult2.actions;
1290
+ if (actions.mark_read) await receiver.markSeen(event.uid);
1291
+ if (actions.delete) {
1292
+ await receiver.deleteMessage(event.uid);
1293
+ return;
1294
+ }
1295
+ if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
1296
+ event.ruleApplied = { ruleId: ruleResult2.ruleId, actions };
1297
+ }
1298
+ safeWrite(`data: ${JSON.stringify(event)}
1299
+
1300
+ `);
1301
+ return;
1302
+ }
1303
+ const spamResult = scoreEmail(parsed);
1304
+ event.route = classifyEmailRoute({ email: parsed, spam: spamResult, account: accountRouteContext });
1305
+ try {
1306
+ db.prepare(
1307
+ "INSERT INTO spam_log (id, agent_id, message_uid, score, flags, category, is_spam) VALUES (?, ?, ?, ?, ?, ?, ?)"
1308
+ ).run(
1309
+ uuidv42(),
1310
+ agent.id,
1311
+ event.uid,
1312
+ spamResult.score,
1313
+ JSON.stringify(spamResult.matches.map((m) => m.ruleId)),
1314
+ spamResult.topCategory,
1315
+ spamResult.isSpam ? 1 : 0
1316
+ );
1317
+ } catch {
1318
+ }
1319
+ if (spamResult.isSpam) {
1320
+ try {
1321
+ await receiver.createFolder("Spam");
1322
+ } catch {
1323
+ }
1324
+ await receiver.moveMessage(event.uid, "INBOX", "Spam");
1325
+ event.spam = { score: spamResult.score, category: spamResult.topCategory, movedToSpam: true };
1326
+ safeWrite(`data: ${JSON.stringify(event)}
1327
+
1328
+ `);
1329
+ return;
1330
+ }
1331
+ if (spamResult.isWarning) {
1332
+ event.spamWarning = { score: spamResult.score, category: spamResult.topCategory, matches: spamResult.matches.map((m) => m.ruleId) };
1333
+ }
1334
+ const ruleResult = evaluateRules(db, agent.id, parsed);
1335
+ if (ruleResult) {
1336
+ const actions = ruleResult.actions;
1337
+ if (actions.mark_read) await receiver.markSeen(event.uid);
1338
+ if (actions.delete) {
1339
+ await receiver.deleteMessage(event.uid);
1340
+ return;
1341
+ }
1342
+ if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
1343
+ event.ruleApplied = { ruleId: ruleResult.ruleId, actions };
1344
+ }
1345
+ } finally {
1346
+ await receiver.disconnect();
1347
+ }
1348
+ } catch (err) {
1349
+ console.error("[SSE] Spam/rule evaluation error:", err.message);
1357
1350
  }
1358
- const { raw: _raw, ...rest } = gatewayResult;
1359
- response = rest;
1360
1351
  }
1361
- }
1362
- if (!response) {
1363
- const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
1364
- const result = await sender.send(mailOpts);
1365
- saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
1366
- const { raw: _raw, ...rest } = result;
1367
- response = rest;
1368
- }
1369
- db.prepare(
1370
- `UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
1371
- ).run("master", row.id);
1372
- res.json({ ...response, approved: true, pendingId: row.id });
1373
- } catch (err) {
1374
- next(err);
1375
- }
1376
- });
1377
- router.post("/mail/pending/:id/reject", requireMaster, async (req, res) => {
1378
- const row = db.prepare(
1379
- `SELECT * FROM pending_outbound WHERE id = ?`
1380
- ).get(req.params.id);
1381
- if (!row) {
1382
- res.status(404).json({ error: "Pending email not found" });
1383
- return;
1384
- }
1385
- if (row.status !== "pending") {
1386
- res.status(400).json({ error: `Email already ${row.status}` });
1387
- return;
1388
- }
1389
- db.prepare(
1390
- `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
1391
- ).run("master", row.id);
1392
- res.json({ ok: true, rejected: true, pendingId: row.id });
1393
- });
1394
- return router;
1395
- }
1352
+ safeWrite(`data: ${JSON.stringify(event)}
1396
1353
 
1397
- // src/routes/inbound.ts
1398
- import { Router as Router4 } from "express";
1399
- import { randomUUID } from "crypto";
1400
- import {
1401
- parseEmail as parseEmail2,
1402
- MailSender as MailSender2
1403
- } from "@agenticmail/core";
1404
- var INBOUND_SECRET = process.env.AGENTICMAIL_INBOUND_SECRET || (() => {
1405
- const generated = randomUUID();
1406
- console.warn("[Inbound] WARNING: AGENTICMAIL_INBOUND_SECRET is not set. Generated a random secret for this session.");
1407
- console.warn(`[Inbound] Set AGENTICMAIL_INBOUND_SECRET="${generated}" in your environment to persist it across restarts.`);
1408
- return generated;
1409
- })();
1410
- var DEBUG = () => !!process.env.AGENTICMAIL_DEBUG;
1411
- function createInboundRoutes(accountManager, config, gatewayManager) {
1412
- const router = Router4();
1413
- router.post("/mail/inbound", async (req, res, next) => {
1414
- try {
1415
- const secret = req.headers["x-inbound-secret"];
1416
- if (secret !== INBOUND_SECRET) {
1417
- res.status(401).json({ error: "Invalid inbound secret" });
1418
- return;
1419
- }
1420
- const { from, to, subject, rawEmail } = req.body;
1421
- if (!to || !rawEmail) {
1422
- res.status(400).json({ error: "to and rawEmail are required" });
1423
- return;
1424
- }
1425
- const recipientEmail = typeof to === "string" ? to : to[0];
1426
- const localPart = recipientEmail.split("@")[0];
1427
- const agent = await accountManager.getByName(localPart);
1428
- if (!agent) {
1429
- console.warn(`[Inbound] No agent found for "${localPart}" (${recipientEmail})`);
1430
- res.status(404).json({ error: `No agent found for ${recipientEmail}` });
1431
- return;
1432
- }
1433
- const agentPassword = agent.metadata?._password;
1434
- if (!agentPassword) {
1435
- console.warn(`[Inbound] No password for agent "${agent.name}"`);
1436
- res.status(500).json({ error: "Agent has no password configured" });
1437
- return;
1438
- }
1439
- const rawBuffer = Buffer.from(rawEmail, "base64");
1440
- const parsed = await parseEmail2(rawBuffer);
1441
- const originalMessageId = parsed.messageId;
1442
- if (originalMessageId && gatewayManager?.isAlreadyDelivered(originalMessageId, agent.name)) {
1443
- if (DEBUG()) console.log(`[Inbound] Skipping duplicate: ${originalMessageId} \u2192 ${agent.name}`);
1444
- res.json({ ok: true, delivered: agent.email, duplicate: true });
1445
- return;
1446
- }
1447
- if (DEBUG()) console.log(`[Inbound] Delivering email to ${agent.email} from ${from} (subject: ${subject || parsed.subject})`);
1448
- const sender = new MailSender2({
1449
- host: config.smtp.host,
1450
- port: config.smtp.port,
1451
- email: agent.email,
1452
- password: agentPassword,
1453
- authUser: agent.stalwartPrincipal
1354
+ `);
1355
+ });
1356
+ watcher.on("expunge", (event) => {
1357
+ safeWrite(`data: ${JSON.stringify(event)}
1358
+
1359
+ `);
1360
+ });
1361
+ watcher.on("flags", (event) => {
1362
+ safeWrite(`data: ${JSON.stringify(event)}
1363
+
1364
+ `);
1365
+ });
1366
+ watcher.on("error", (err) => {
1367
+ safeWrite(`data: ${JSON.stringify({ type: "error", message: err.message })}
1368
+
1369
+ `);
1370
+ });
1371
+ watcher.on("reconnecting", (info) => {
1372
+ safeWrite(`data: ${JSON.stringify({ type: "reconnecting", attempt: info.attempt, delayMs: info.delayMs })}
1373
+
1374
+ `);
1454
1375
  });
1455
- try {
1456
- await sender.send({
1457
- to: agent.email,
1458
- subject: parsed.subject || subject || "(no subject)",
1459
- text: parsed.text || void 0,
1460
- html: parsed.html || void 0,
1461
- replyTo: from || parsed.from?.[0]?.address,
1462
- inReplyTo: parsed.inReplyTo,
1463
- references: parsed.references,
1464
- headers: {
1465
- "X-AgenticMail-Inbound": "cloudflare-worker",
1466
- "X-Original-From": from || parsed.from?.[0]?.address || "",
1467
- ...parsed.messageId ? { "X-Original-Message-Id": parsed.messageId } : {}
1468
- },
1469
- attachments: parsed.attachments?.map((a) => ({
1470
- filename: a.filename,
1471
- content: a.content,
1472
- contentType: a.contentType
1473
- }))
1376
+ watcher.on("reconnected", (info) => {
1377
+ safeWrite(`data: ${JSON.stringify({ type: "reconnected", attempt: info.attempt })}
1378
+
1379
+ `);
1380
+ });
1381
+ watcher.on("reconnect_failed", (info) => {
1382
+ safeWrite(`data: ${JSON.stringify({ type: "reconnect_failed", attempts: info.attempts })}
1383
+
1384
+ `);
1385
+ });
1386
+ const pingInterval = setInterval(() => {
1387
+ safeWrite(`: ping
1388
+
1389
+ `);
1390
+ }, 3e4);
1391
+ req.on("close", () => {
1392
+ closed = true;
1393
+ clearInterval(pingInterval);
1394
+ agentWatchers.delete(entry);
1395
+ if (agentWatchers.size === 0) activeWatchers.delete(agent.id);
1396
+ watcher.removeAllListeners();
1397
+ watcher.stop().catch((err) => {
1398
+ console.error("[SSE] Watcher cleanup error:", err);
1474
1399
  });
1475
- if (originalMessageId) gatewayManager?.recordDelivery(originalMessageId, agent.name);
1476
- if (DEBUG()) console.log(`[Inbound] Delivered to ${agent.email}`);
1477
- res.json({ ok: true, delivered: agent.email });
1478
- } finally {
1479
- sender.close();
1480
- }
1400
+ });
1481
1401
  } catch (err) {
1482
1402
  next(err);
1483
1403
  }
@@ -1485,904 +1405,1056 @@ function createInboundRoutes(accountManager, config, gatewayManager) {
1485
1405
  return router;
1486
1406
  }
1487
1407
 
1488
- // src/routes/events.ts
1489
- import { Router as Router6 } from "express";
1490
- import {
1491
- InboxWatcher,
1492
- MailReceiver as MailReceiver2,
1493
- parseEmail as parseEmail3,
1494
- scoreEmail as scoreEmail2,
1495
- isInternalEmail as isInternalEmail2,
1496
- classifyEmailRoute
1497
- } from "@agenticmail/core";
1498
- import { v4 as uuidv42 } from "uuid";
1499
-
1500
- // src/routes/features.ts
1501
- import { Router as Router5 } from "express";
1502
- import { v4 as uuidv4 } from "uuid";
1503
- import {
1504
- MailSender as MailSender3
1505
- } from "@agenticmail/core";
1506
- function parseScheduleTime(input) {
1507
- const trimmed = input.trim();
1508
- if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(trimmed)) {
1509
- const d = new Date(trimmed);
1510
- return isNaN(d.getTime()) ? null : d;
1408
+ // src/routes/mail.ts
1409
+ var senderCache = /* @__PURE__ */ new Map();
1410
+ var receiverCache = /* @__PURE__ */ new Map();
1411
+ var receiverPending = /* @__PURE__ */ new Map();
1412
+ var CACHE_TTL_MS = 10 * 60 * 1e3;
1413
+ var MAX_CACHE_SIZE = 100;
1414
+ var draining = false;
1415
+ function getAgentPassword(agent) {
1416
+ return agent.metadata?._password || agent.name;
1417
+ }
1418
+ var evictionTimer = null;
1419
+ function startEvictionTimer() {
1420
+ if (evictionTimer) return;
1421
+ evictionTimer = setInterval(evictStaleEntries, 6e4);
1422
+ }
1423
+ function evictStaleEntries() {
1424
+ const now = Date.now();
1425
+ for (const [key, entry] of senderCache) {
1426
+ if (now - entry.createdAt > CACHE_TTL_MS) {
1427
+ try {
1428
+ entry.sender.close();
1429
+ } catch {
1430
+ }
1431
+ senderCache.delete(key);
1432
+ }
1511
1433
  }
1512
- const lower = trimmed.toLowerCase();
1513
- const relativeMatch = lower.match(/^in\s+(\d+)\s+(minute|minutes|min|mins|hour|hours|hr|hrs|day|days)$/);
1514
- if (relativeMatch) {
1515
- const amount = parseInt(relativeMatch[1], 10);
1516
- const unit = relativeMatch[2];
1517
- const now = Date.now();
1518
- if (unit.startsWith("min")) return new Date(now + amount * 6e4);
1519
- if (unit.startsWith("h")) return new Date(now + amount * 36e5);
1520
- if (unit.startsWith("d")) return new Date(now + amount * 864e5);
1434
+ for (const [key, entry] of receiverCache) {
1435
+ if (now - entry.createdAt > CACHE_TTL_MS) {
1436
+ entry.receiver.disconnect().catch(() => {
1437
+ });
1438
+ receiverCache.delete(key);
1439
+ }
1521
1440
  }
1522
- const tomorrowMatch = lower.match(/^tomorrow\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
1523
- if (tomorrowMatch) {
1524
- const tomorrow = /* @__PURE__ */ new Date();
1525
- tomorrow.setDate(tomorrow.getDate() + 1);
1526
- let hour = parseInt(tomorrowMatch[1], 10);
1527
- const min = tomorrowMatch[2] ? parseInt(tomorrowMatch[2], 10) : 0;
1528
- const ampm = tomorrowMatch[3];
1529
- if (ampm === "pm" && hour !== 12) hour += 12;
1530
- if (ampm === "am" && hour === 12) hour = 0;
1531
- tomorrow.setHours(hour, min, 0, 0);
1532
- return tomorrow;
1441
+ }
1442
+ function getSender(authUser, fromEmail, password, config) {
1443
+ if (draining) throw new Error("Server is shutting down");
1444
+ const cacheKey = `${authUser}:${fromEmail}`;
1445
+ const cached = senderCache.get(cacheKey);
1446
+ if (cached) return cached.sender;
1447
+ if (senderCache.size >= MAX_CACHE_SIZE) {
1448
+ const oldest = senderCache.keys().next().value;
1449
+ if (oldest) {
1450
+ try {
1451
+ senderCache.get(oldest)?.sender.close();
1452
+ } catch {
1453
+ }
1454
+ senderCache.delete(oldest);
1455
+ }
1533
1456
  }
1534
- const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
1535
- const nextDayMatch = lower.match(/^next\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
1536
- if (nextDayMatch) {
1537
- const targetDay = dayNames.indexOf(nextDayMatch[1]);
1538
- let hour = parseInt(nextDayMatch[2], 10);
1539
- const min = nextDayMatch[3] ? parseInt(nextDayMatch[3], 10) : 0;
1540
- const ampm = nextDayMatch[4];
1541
- if (ampm === "pm" && hour !== 12) hour += 12;
1542
- if (ampm === "am" && hour === 12) hour = 0;
1543
- const result = /* @__PURE__ */ new Date();
1544
- const currentDay = result.getDay();
1545
- let daysUntil = targetDay - currentDay;
1546
- if (daysUntil <= 0) daysUntil += 7;
1547
- result.setDate(result.getDate() + daysUntil);
1548
- result.setHours(hour, min, 0, 0);
1549
- return result;
1457
+ const sender = new MailSender2({
1458
+ host: config.smtp.host,
1459
+ port: config.smtp.port,
1460
+ email: fromEmail,
1461
+ password,
1462
+ authUser
1463
+ });
1464
+ senderCache.set(cacheKey, { sender, createdAt: Date.now() });
1465
+ startEvictionTimer();
1466
+ return sender;
1467
+ }
1468
+ async function getReceiver(authUser, password, config) {
1469
+ if (draining) throw new Error("Server is shutting down");
1470
+ const cached = receiverCache.get(authUser);
1471
+ if (cached) {
1472
+ try {
1473
+ const client = cached.receiver.getImapClient();
1474
+ if (client.usable) return cached.receiver;
1475
+ } catch {
1476
+ }
1477
+ try {
1478
+ await cached.receiver.disconnect();
1479
+ } catch {
1480
+ }
1481
+ receiverCache.delete(authUser);
1550
1482
  }
1551
- if (lower === "tonight" || lower === "this evening") {
1552
- const d = /* @__PURE__ */ new Date();
1553
- d.setHours(20, 0, 0, 0);
1554
- if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
1555
- return d;
1483
+ const pending = receiverPending.get(authUser);
1484
+ if (pending) return pending;
1485
+ const promise = createReceiver(authUser, password, config);
1486
+ receiverPending.set(authUser, promise);
1487
+ try {
1488
+ return await promise;
1489
+ } finally {
1490
+ receiverPending.delete(authUser);
1556
1491
  }
1557
- const humanMatch = trimmed.match(
1558
- /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\s*(.+)?$/
1559
- );
1560
- if (humanMatch) {
1561
- const [, mStr, dStr, yStr, hStr, minStr, ampmRaw, tzRaw] = humanMatch;
1562
- const month = parseInt(mStr, 10);
1563
- const day = parseInt(dStr, 10);
1564
- const year = parseInt(yStr, 10);
1565
- let hour = parseInt(hStr, 10);
1566
- const min = parseInt(minStr, 10);
1567
- const ampm = ampmRaw.toUpperCase();
1568
- if (month < 1 || month > 12 || day < 1 || day > 31 || hour < 1 || hour > 12) return null;
1569
- if (ampm === "PM" && hour !== 12) hour += 12;
1570
- if (ampm === "AM" && hour === 12) hour = 0;
1571
- const result = new Date(year, month - 1, day, hour, min, 0, 0);
1572
- if (tzRaw?.trim()) {
1573
- const TZ_OFFSETS = {
1574
- EST: -5,
1575
- EDT: -4,
1576
- CST: -6,
1577
- CDT: -5,
1578
- MST: -7,
1579
- MDT: -6,
1580
- PST: -8,
1581
- PDT: -7,
1582
- GMT: 0,
1583
- UTC: 0,
1584
- BST: 1,
1585
- CET: 1,
1586
- CEST: 2,
1587
- IST: 5.5,
1588
- JST: 9,
1589
- AEST: 10,
1590
- AEDT: 11,
1591
- NZST: 12,
1592
- NZDT: 13,
1593
- WAT: 1,
1594
- EAT: 3,
1595
- SAST: 2,
1596
- HKT: 8,
1597
- SGT: 8,
1598
- KST: 9,
1599
- HST: -10,
1600
- AKST: -9,
1601
- AKDT: -8,
1602
- AST: -4,
1603
- ADT: -3,
1604
- NST: -3.5,
1605
- NDT: -2.5
1606
- };
1607
- const tz = tzRaw.trim().toUpperCase();
1608
- if (TZ_OFFSETS[tz] !== void 0) {
1609
- const tzOffsetMs = TZ_OFFSETS[tz] * 36e5;
1610
- const serverOffsetMs = result.getTimezoneOffset() * -6e4;
1611
- const diff = serverOffsetMs - tzOffsetMs;
1612
- result.setTime(result.getTime() + diff);
1613
- }
1492
+ }
1493
+ async function createReceiver(authUser, password, config) {
1494
+ if (receiverCache.size >= MAX_CACHE_SIZE) {
1495
+ const oldest = receiverCache.keys().next().value;
1496
+ if (oldest) {
1497
+ receiverCache.get(oldest)?.receiver.disconnect().catch(() => {
1498
+ });
1499
+ receiverCache.delete(oldest);
1614
1500
  }
1615
- return isNaN(result.getTime()) ? null : result;
1616
1501
  }
1617
- const fallback = new Date(trimmed);
1618
- return isNaN(fallback.getTime()) ? null : fallback;
1619
- }
1620
- function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
1621
- const router = Router5();
1622
- router.get("/contacts", requireAgent, async (req, res, next) => {
1502
+ const receiver = new MailReceiver2({
1503
+ host: config.imap.host,
1504
+ port: config.imap.port,
1505
+ email: authUser,
1506
+ password
1507
+ });
1508
+ try {
1509
+ await receiver.connect();
1510
+ } catch (err) {
1623
1511
  try {
1624
- const rows = db.prepare("SELECT * FROM contacts WHERE agent_id = ? ORDER BY name, email").all(req.agent.id);
1625
- res.json({ contacts: rows });
1626
- } catch (err) {
1627
- next(err);
1512
+ await receiver.disconnect();
1513
+ } catch {
1628
1514
  }
1629
- });
1630
- router.post("/contacts", requireAgent, async (req, res, next) => {
1515
+ throw err;
1516
+ }
1517
+ receiverCache.set(authUser, { receiver, createdAt: Date.now() });
1518
+ startEvictionTimer();
1519
+ return receiver;
1520
+ }
1521
+ async function closeCaches() {
1522
+ draining = true;
1523
+ if (evictionTimer) {
1524
+ clearInterval(evictionTimer);
1525
+ evictionTimer = null;
1526
+ }
1527
+ for (const [, entry] of senderCache) {
1631
1528
  try {
1632
- const { name: name2, email, notes } = req.body || {};
1633
- if (!email) {
1634
- res.status(400).json({ error: "email is required" });
1635
- return;
1636
- }
1637
- const id = uuidv4();
1638
- db.prepare("INSERT OR REPLACE INTO contacts (id, agent_id, name, email, notes) VALUES (?, ?, ?, ?, ?)").run(id, req.agent.id, name2 || null, email, notes || null);
1639
- res.json({ ok: true, id, email });
1640
- } catch (err) {
1641
- next(err);
1529
+ entry.sender.close();
1530
+ } catch {
1642
1531
  }
1643
- });
1644
- router.delete("/contacts/:id", requireAgent, async (req, res, next) => {
1532
+ }
1533
+ senderCache.clear();
1534
+ for (const [, entry] of receiverCache) {
1645
1535
  try {
1646
- const result = db.prepare("DELETE FROM contacts WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1647
- if (result.changes === 0) {
1648
- res.status(404).json({ error: "Contact not found" });
1649
- return;
1650
- }
1651
- res.json({ ok: true });
1652
- } catch (err) {
1653
- next(err);
1536
+ await entry.receiver.disconnect();
1537
+ } catch {
1654
1538
  }
1655
- });
1656
- router.get("/drafts", requireAgent, async (req, res, next) => {
1539
+ }
1540
+ receiverCache.clear();
1541
+ }
1542
+ async function notifyLocalRecipientsOfNewMail(accountManager, toField, ccField, bccField, fromAgent, subject, messageId) {
1543
+ const collected = [];
1544
+ const push = (v) => {
1545
+ if (!v) return;
1546
+ if (Array.isArray(v)) collected.push(...v);
1547
+ else collected.push(v);
1548
+ };
1549
+ push(toField);
1550
+ push(ccField);
1551
+ push(bccField);
1552
+ const addrRe = /<([^>]+)>|([^\s,;<>]+@[^\s,;<>]+)/g;
1553
+ const addresses = /* @__PURE__ */ new Set();
1554
+ for (const entry of collected) {
1555
+ let match;
1556
+ addrRe.lastIndex = 0;
1557
+ while ((match = addrRe.exec(entry)) !== null) {
1558
+ const a = (match[1] || match[2] || "").trim().toLowerCase();
1559
+ if (a) addresses.add(a);
1560
+ }
1561
+ }
1562
+ const notified = /* @__PURE__ */ new Set();
1563
+ for (const addr of addresses) {
1564
+ const at = addr.indexOf("@");
1565
+ if (at < 0) continue;
1566
+ const localPart = addr.slice(0, at);
1567
+ const domain = addr.slice(at + 1);
1568
+ if (domain !== "localhost") continue;
1569
+ if (addr === fromAgent.email.toLowerCase()) continue;
1570
+ let recipient = null;
1571
+ try {
1572
+ recipient = await accountManager.getByName(localPart);
1573
+ } catch {
1574
+ }
1575
+ if (!recipient || notified.has(recipient.id)) continue;
1576
+ notified.add(recipient.id);
1577
+ pushEventToAgent(recipient.id, {
1578
+ type: "new",
1579
+ // uid is unknown without an IMAP fetch; use 0 as a sentinel —
1580
+ // this matches the watcher's autoFetch=false path. SSE consumers
1581
+ // that want full message detail can call /mail/inbox.
1582
+ uid: 0,
1583
+ internal: true,
1584
+ from: { name: fromAgent.name, address: fromAgent.email },
1585
+ subject,
1586
+ messageId
1587
+ });
1588
+ }
1589
+ }
1590
+ function saveSentCopy(authUser, password, config, raw) {
1591
+ (async () => {
1657
1592
  try {
1658
- const rows = db.prepare("SELECT * FROM drafts WHERE agent_id = ? ORDER BY updated_at DESC").all(req.agent.id);
1659
- res.json({ drafts: rows });
1593
+ const receiver = await getReceiver(authUser, password, config);
1594
+ await receiver.appendMessage(raw, "Sent Items", ["\\Seen"]);
1660
1595
  } catch (err) {
1661
- next(err);
1596
+ console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
1662
1597
  }
1663
- });
1664
- router.post("/drafts", requireAgent, async (req, res, next) => {
1598
+ })();
1599
+ }
1600
+ function createMailRoutes(accountManager, config, db, gatewayManager) {
1601
+ const router = Router5();
1602
+ router.post("/mail/send", requireAgent, async (req, res, next) => {
1665
1603
  try {
1666
- const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
1667
- const id = uuidv4();
1668
- db.prepare(`INSERT INTO drafts (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, in_reply_to, refs)
1669
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
1670
- id,
1671
- req.agent.id,
1672
- to || null,
1673
- subject || null,
1674
- text || null,
1675
- html || null,
1676
- cc || null,
1677
- bcc || null,
1678
- inReplyTo || null,
1679
- references ? JSON.stringify(references) : null
1680
- );
1681
- res.json({ ok: true, id });
1604
+ if (!req.body || typeof req.body !== "object") {
1605
+ res.status(400).json({ error: "Request body must be JSON" });
1606
+ return;
1607
+ }
1608
+ const agent = req.agent;
1609
+ const { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, allowSensitive } = req.body;
1610
+ if (!to || !subject) {
1611
+ res.status(400).json({ error: "to and subject are required" });
1612
+ return;
1613
+ }
1614
+ if (typeof to !== "string" && !Array.isArray(to)) {
1615
+ res.status(400).json({ error: "to must be a string or array of strings" });
1616
+ return;
1617
+ }
1618
+ let outboundWarnings;
1619
+ let outboundSummary;
1620
+ if (!(allowSensitive && req.isMaster)) {
1621
+ const scanResult = scanOutboundEmail({
1622
+ to: Array.isArray(to) ? to.join(", ") : to,
1623
+ subject,
1624
+ text,
1625
+ html,
1626
+ attachments: Array.isArray(attachments) ? attachments.map((a) => ({
1627
+ filename: a.filename || "",
1628
+ contentType: a.contentType,
1629
+ content: a.content,
1630
+ encoding: a.encoding
1631
+ })) : void 0
1632
+ });
1633
+ if (scanResult.blocked) {
1634
+ const pendingId = crypto.randomUUID();
1635
+ const ownerName2 = agent.metadata?.ownerName;
1636
+ const fromName2 = ownerName2 ? `${agent.name} from ${ownerName2}` : agent.name;
1637
+ const mailOptions = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName: fromName2 };
1638
+ db.prepare(
1639
+ `INSERT INTO pending_outbound (id, agent_id, mail_options, warnings, summary) VALUES (?, ?, ?, ?, ?)`
1640
+ ).run(pendingId, agent.id, JSON.stringify(mailOptions), JSON.stringify(scanResult.warnings), scanResult.summary);
1641
+ if (gatewayManager) {
1642
+ const ownerEmail = gatewayManager.getConfig()?.relay?.email;
1643
+ if (ownerEmail) {
1644
+ const warningList = scanResult.warnings.map((w) => ` - [${w.severity.toUpperCase()}] ${w.ruleId}: ${w.description}${w.match ? ` (matched: ${w.match})` : ""}`).join("\n");
1645
+ const recipientLine = Array.isArray(to) ? to.join(", ") : to;
1646
+ const emailPreview = [
1647
+ "\u2500".repeat(50),
1648
+ `From: ${fromName2} <${agent.email}>`,
1649
+ `To: ${recipientLine}`
1650
+ ];
1651
+ if (cc) emailPreview.push(`CC: ${Array.isArray(cc) ? cc.join(", ") : cc}`);
1652
+ if (bcc) emailPreview.push(`BCC: ${Array.isArray(bcc) ? bcc.join(", ") : bcc}`);
1653
+ emailPreview.push(`Subject: ${subject}`);
1654
+ if (Array.isArray(attachments) && attachments.length > 0) {
1655
+ const attNames = attachments.map((a) => a.filename || "unnamed").join(", ");
1656
+ emailPreview.push(`Attachments: ${attNames}`);
1657
+ }
1658
+ emailPreview.push("\u2500".repeat(50));
1659
+ if (text) emailPreview.push("", text);
1660
+ else if (html) emailPreview.push("", "[HTML content \u2014 see original for formatted version]");
1661
+ else emailPreview.push("", "[No body content]");
1662
+ emailPreview.push("\u2500".repeat(50));
1663
+ gatewayManager.routeOutbound(agent.name, {
1664
+ to: ownerEmail,
1665
+ subject: `[Approval Required] Blocked email from "${agent.name}" \u2014 "${subject}"`,
1666
+ text: [
1667
+ `Your agent "${agent.name}" attempted to send an email that was blocked by the outbound security guard.`,
1668
+ "",
1669
+ "SECURITY WARNINGS:",
1670
+ warningList,
1671
+ "",
1672
+ "FULL EMAIL FOR REVIEW:",
1673
+ ...emailPreview,
1674
+ "",
1675
+ `Pending ID: ${pendingId}`,
1676
+ "",
1677
+ "ACTION REQUIRED:",
1678
+ 'Reply "approve" to this email to send it, or "reject" to discard it.',
1679
+ "If you do not respond, the agent will follow up with you."
1680
+ ].join("\n"),
1681
+ fromName: "Agentic Mail"
1682
+ }).then((result2) => {
1683
+ if (result2?.messageId) {
1684
+ db.prepare("UPDATE pending_outbound SET notification_message_id = ? WHERE id = ?").run(result2.messageId, pendingId);
1685
+ }
1686
+ }).catch(() => {
1687
+ });
1688
+ }
1689
+ }
1690
+ res.json({
1691
+ sent: false,
1692
+ blocked: true,
1693
+ pendingId,
1694
+ warnings: scanResult.warnings,
1695
+ summary: scanResult.summary
1696
+ });
1697
+ return;
1698
+ }
1699
+ if (scanResult.warnings.length > 0) {
1700
+ outboundWarnings = scanResult.warnings;
1701
+ outboundSummary = scanResult.summary;
1702
+ }
1703
+ }
1704
+ const ownerName = agent.metadata?.ownerName;
1705
+ const fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
1706
+ const mailOpts = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName };
1707
+ const password = getAgentPassword(agent);
1708
+ if (gatewayManager) {
1709
+ const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
1710
+ if (gatewayResult) {
1711
+ if (gatewayResult.raw) {
1712
+ saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
1713
+ }
1714
+ const { raw: _raw2, ...response2 } = gatewayResult;
1715
+ res.json({ ...response2, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
1716
+ return;
1717
+ }
1718
+ }
1719
+ const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
1720
+ const result = await sender.send(mailOpts);
1721
+ saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
1722
+ notifyLocalRecipientsOfNewMail(
1723
+ accountManager,
1724
+ to,
1725
+ cc,
1726
+ bcc,
1727
+ agent,
1728
+ subject,
1729
+ result.messageId
1730
+ ).catch((err) => {
1731
+ console.warn(`[mail] Internal SSE notify failed: ${err.message}`);
1732
+ });
1733
+ const { raw: _raw, ...response } = result;
1734
+ res.json({ ...response, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
1682
1735
  } catch (err) {
1683
1736
  next(err);
1684
1737
  }
1685
1738
  });
1686
- router.put("/drafts/:id", requireAgent, async (req, res, next) => {
1739
+ router.get("/mail/inbox", requireAgent, async (req, res, next) => {
1687
1740
  try {
1688
- const { to, subject, text, html, cc, bcc, inReplyTo, references } = req.body || {};
1689
- const result = db.prepare(`UPDATE drafts SET to_addr=?, subject=?, text_body=?, html_body=?,
1690
- cc=?, bcc=?, in_reply_to=?, refs=?, updated_at=datetime('now')
1691
- WHERE id=? AND agent_id=?`).run(
1692
- to || null,
1693
- subject || null,
1694
- text || null,
1695
- html || null,
1696
- cc || null,
1697
- bcc || null,
1698
- inReplyTo || null,
1699
- references ? JSON.stringify(references) : null,
1700
- req.params.id,
1701
- req.agent.id
1702
- );
1703
- if (result.changes === 0) {
1704
- res.status(404).json({ error: "Draft not found" });
1705
- return;
1706
- }
1707
- res.json({ ok: true });
1741
+ const agent = req.agent;
1742
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
1743
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
1744
+ const password = getAgentPassword(agent);
1745
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1746
+ const mailboxInfo = await receiver.getMailboxInfo("INBOX");
1747
+ const envelopes = await receiver.listEnvelopes("INBOX", { limit, offset });
1748
+ res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists });
1708
1749
  } catch (err) {
1709
1750
  next(err);
1710
1751
  }
1711
1752
  });
1712
- router.delete("/drafts/:id", requireAgent, async (req, res, next) => {
1753
+ router.get("/mail/messages/:uid", requireAgent, async (req, res, next) => {
1713
1754
  try {
1714
- const result = db.prepare("DELETE FROM drafts WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1715
- if (result.changes === 0) {
1716
- res.status(404).json({ error: "Draft not found" });
1755
+ const agent = req.agent;
1756
+ const uid = parseInt(req.params.uid);
1757
+ if (isNaN(uid) || uid < 1) {
1758
+ res.status(400).json({ error: "Invalid UID" });
1717
1759
  return;
1718
1760
  }
1719
- res.json({ ok: true });
1761
+ const folder = req.query.folder || "INBOX";
1762
+ const password = getAgentPassword(agent);
1763
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1764
+ const raw = await receiver.fetchMessage(uid, folder);
1765
+ const parsed = await parseEmail2(raw);
1766
+ if (isInternalEmail2(parsed)) {
1767
+ res.json({
1768
+ ...parsed,
1769
+ security: { internal: true, spamScore: 0, isSpam: false, isWarning: false }
1770
+ });
1771
+ return;
1772
+ }
1773
+ const sanitized = sanitizeEmail(parsed);
1774
+ const spamScore = scoreEmail2(parsed);
1775
+ res.json({
1776
+ ...parsed,
1777
+ text: sanitized.text,
1778
+ html: sanitized.html,
1779
+ security: {
1780
+ spamScore: spamScore.score,
1781
+ isSpam: spamScore.isSpam,
1782
+ isWarning: spamScore.isWarning,
1783
+ topCategory: spamScore.topCategory,
1784
+ matches: spamScore.matches.map((m) => m.ruleId),
1785
+ sanitized: sanitized.wasModified,
1786
+ sanitizeDetections: sanitized.detections
1787
+ }
1788
+ });
1720
1789
  } catch (err) {
1721
1790
  next(err);
1722
1791
  }
1723
1792
  });
1724
- router.post("/drafts/:id/send", requireAgent, async (req, res, next) => {
1793
+ router.get("/mail/messages/:uid/attachments/:index", requireAgent, async (req, res, next) => {
1725
1794
  try {
1726
- const draft = db.prepare("SELECT * FROM drafts WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
1727
- if (!draft) {
1728
- res.status(404).json({ error: "Draft not found" });
1795
+ const agent = req.agent;
1796
+ const uid = parseInt(req.params.uid);
1797
+ const index = parseInt(req.params.index);
1798
+ if (isNaN(uid) || uid < 1) {
1799
+ res.status(400).json({ error: "Invalid UID" });
1729
1800
  return;
1730
1801
  }
1731
- if (!draft.to_addr) {
1732
- res.status(400).json({ error: "Draft has no recipient" });
1802
+ if (isNaN(index) || index < 0) {
1803
+ res.status(400).json({ error: "Invalid attachment index" });
1733
1804
  return;
1734
1805
  }
1735
- const agent = req.agent;
1736
- const mailOpts = {
1737
- to: draft.to_addr,
1738
- subject: draft.subject || "(no subject)",
1739
- text: draft.text_body || void 0,
1740
- html: draft.html_body || void 0,
1741
- cc: draft.cc || void 0,
1742
- bcc: draft.bcc || void 0,
1743
- inReplyTo: draft.in_reply_to || void 0,
1744
- references: draft.refs ? JSON.parse(draft.refs) : void 0
1745
- };
1746
- if (gatewayManager) {
1747
- const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
1748
- if (gatewayResult) {
1749
- db.prepare("DELETE FROM drafts WHERE id = ?").run(draft.id);
1750
- res.json(gatewayResult);
1751
- return;
1752
- }
1753
- }
1806
+ const folder = req.query.folder || "INBOX";
1754
1807
  const password = getAgentPassword(agent);
1755
- const sender = new MailSender3({
1756
- host: config.smtp.host,
1757
- port: config.smtp.port,
1758
- email: agent.email,
1759
- password,
1760
- authUser: agent.stalwartPrincipal
1761
- });
1762
- try {
1763
- const result = await sender.send(mailOpts);
1764
- db.prepare("DELETE FROM drafts WHERE id = ?").run(draft.id);
1765
- res.json(result);
1766
- } finally {
1767
- sender.close();
1808
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1809
+ const raw = await receiver.fetchMessage(uid, folder);
1810
+ const parsed = await parseEmail2(raw);
1811
+ if (!parsed.attachments || index >= parsed.attachments.length) {
1812
+ res.status(404).json({ error: "Attachment not found" });
1813
+ return;
1768
1814
  }
1815
+ const att = parsed.attachments[index];
1816
+ res.setHeader("Content-Type", att.contentType || "application/octet-stream");
1817
+ res.setHeader("Content-Disposition", `attachment; filename="${att.filename.replace(/"/g, '\\"')}"`);
1818
+ res.setHeader("Content-Length", att.content.length);
1819
+ res.send(att.content);
1769
1820
  } catch (err) {
1770
1821
  next(err);
1771
1822
  }
1772
1823
  });
1773
- router.get("/signatures", requireAgent, async (req, res, next) => {
1824
+ router.post("/mail/search", requireAgent, async (req, res, next) => {
1774
1825
  try {
1775
- const rows = db.prepare("SELECT * FROM signatures WHERE agent_id = ? ORDER BY is_default DESC, name").all(req.agent.id);
1776
- res.json({ signatures: rows });
1826
+ if (!req.body || typeof req.body !== "object") {
1827
+ res.status(400).json({ error: "Request body must be JSON" });
1828
+ return;
1829
+ }
1830
+ const agent = req.agent;
1831
+ const { from, to, subject, since, before, seen, text, searchRelay } = req.body;
1832
+ const password = getAgentPassword(agent);
1833
+ const sinceDate = since ? new Date(since) : void 0;
1834
+ const beforeDate = before ? new Date(before) : void 0;
1835
+ if (sinceDate && isNaN(sinceDate.getTime())) {
1836
+ res.status(400).json({ error: 'Invalid "since" date' });
1837
+ return;
1838
+ }
1839
+ if (beforeDate && isNaN(beforeDate.getTime())) {
1840
+ res.status(400).json({ error: 'Invalid "before" date' });
1841
+ return;
1842
+ }
1843
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1844
+ const uids = await receiver.search({
1845
+ from,
1846
+ to,
1847
+ subject,
1848
+ since: sinceDate,
1849
+ before: beforeDate,
1850
+ seen,
1851
+ text
1852
+ });
1853
+ let relayResults;
1854
+ if (searchRelay === true && gatewayManager) {
1855
+ try {
1856
+ const relayHits = await gatewayManager.searchRelay({
1857
+ from,
1858
+ to,
1859
+ subject,
1860
+ text,
1861
+ since: sinceDate,
1862
+ before: beforeDate,
1863
+ seen
1864
+ });
1865
+ if (relayHits.length > 0) {
1866
+ relayResults = relayHits.map((r) => ({
1867
+ uid: r.uid,
1868
+ source: r.source,
1869
+ account: r.account,
1870
+ messageId: r.messageId,
1871
+ subject: r.subject,
1872
+ from: r.from,
1873
+ to: r.to,
1874
+ date: r.date,
1875
+ flags: r.flags
1876
+ }));
1877
+ }
1878
+ } catch {
1879
+ }
1880
+ }
1881
+ res.json({ uids, ...relayResults ? { relayResults } : {} });
1777
1882
  } catch (err) {
1778
1883
  next(err);
1779
1884
  }
1780
1885
  });
1781
- router.post("/signatures", requireAgent, async (req, res, next) => {
1886
+ router.post("/mail/import-relay", requireAgent, async (req, res, next) => {
1782
1887
  try {
1783
- const { name: name2, text, html, isDefault } = req.body || {};
1784
- if (!name2) {
1785
- res.status(400).json({ error: "name is required" });
1888
+ const { uid } = req.body || {};
1889
+ if (!uid || typeof uid !== "number" || uid < 1) {
1890
+ res.status(400).json({ error: "uid (number) is required" });
1786
1891
  return;
1787
1892
  }
1788
- const id = uuidv4();
1789
- if (isDefault) {
1790
- db.prepare("UPDATE signatures SET is_default = 0 WHERE agent_id = ?").run(req.agent.id);
1893
+ if (!gatewayManager) {
1894
+ res.status(400).json({ error: "No gateway configured" });
1895
+ return;
1791
1896
  }
1792
- 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);
1793
- res.json({ ok: true, id });
1897
+ const agent = req.agent;
1898
+ const result = await gatewayManager.importRelayMessage(uid, agent.name);
1899
+ if (!result.success) {
1900
+ res.status(400).json({ error: result.error || "Import failed" });
1901
+ return;
1902
+ }
1903
+ res.json({ ok: true, message: "Email imported to local inbox. Use /inbox or list_inbox to see it." });
1794
1904
  } catch (err) {
1795
1905
  next(err);
1796
1906
  }
1797
1907
  });
1798
- router.delete("/signatures/:id", requireAgent, async (req, res, next) => {
1908
+ router.post("/mail/messages/:uid/seen", requireAgent, async (req, res, next) => {
1799
1909
  try {
1800
- const result = db.prepare("DELETE FROM signatures WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1801
- if (result.changes === 0) {
1802
- res.status(404).json({ error: "Signature not found" });
1910
+ const agent = req.agent;
1911
+ const uid = parseInt(req.params.uid);
1912
+ if (isNaN(uid) || uid < 1) {
1913
+ res.status(400).json({ error: "Invalid UID" });
1803
1914
  return;
1804
1915
  }
1916
+ const password = getAgentPassword(agent);
1917
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1918
+ await receiver.markSeen(uid);
1805
1919
  res.json({ ok: true });
1806
1920
  } catch (err) {
1807
1921
  next(err);
1808
1922
  }
1809
1923
  });
1810
- router.get("/templates", requireAgent, async (req, res, next) => {
1924
+ router.delete("/mail/messages/:uid", requireAgent, async (req, res, next) => {
1811
1925
  try {
1812
- const rows = db.prepare("SELECT * FROM templates WHERE agent_id = ? ORDER BY name").all(req.agent.id);
1813
- res.json({ templates: rows });
1926
+ const agent = req.agent;
1927
+ const uid = parseInt(req.params.uid);
1928
+ if (isNaN(uid) || uid < 1) {
1929
+ res.status(400).json({ error: "Invalid UID" });
1930
+ return;
1931
+ }
1932
+ const password = getAgentPassword(agent);
1933
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1934
+ await receiver.deleteMessage(uid);
1935
+ res.status(204).send();
1814
1936
  } catch (err) {
1815
1937
  next(err);
1816
1938
  }
1817
1939
  });
1818
- router.post("/templates", requireAgent, async (req, res, next) => {
1940
+ router.post("/mail/messages/:uid/unseen", requireAgent, async (req, res, next) => {
1819
1941
  try {
1820
- const { name: name2, subject, text, html } = req.body || {};
1821
- if (!name2) {
1822
- res.status(400).json({ error: "name is required" });
1942
+ const agent = req.agent;
1943
+ const uid = parseInt(req.params.uid);
1944
+ if (isNaN(uid) || uid < 1) {
1945
+ res.status(400).json({ error: "Invalid UID" });
1823
1946
  return;
1824
1947
  }
1825
- const id = uuidv4();
1826
- 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);
1827
- res.json({ ok: true, id });
1948
+ const password = getAgentPassword(agent);
1949
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1950
+ await receiver.markUnseen(uid);
1951
+ res.json({ ok: true });
1828
1952
  } catch (err) {
1829
1953
  next(err);
1830
1954
  }
1831
1955
  });
1832
- router.delete("/templates/:id", requireAgent, async (req, res, next) => {
1956
+ router.post("/mail/messages/:uid/move", requireAgent, async (req, res, next) => {
1833
1957
  try {
1834
- const result = db.prepare("DELETE FROM templates WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1835
- if (result.changes === 0) {
1836
- res.status(404).json({ error: "Template not found" });
1958
+ const agent = req.agent;
1959
+ const uid = parseInt(req.params.uid);
1960
+ if (isNaN(uid) || uid < 1) {
1961
+ res.status(400).json({ error: "Invalid UID" });
1962
+ return;
1963
+ }
1964
+ const { from: fromFolder, to: toFolder } = req.body || {};
1965
+ if (!toFolder) {
1966
+ res.status(400).json({ error: "to (destination folder) is required" });
1837
1967
  return;
1838
1968
  }
1969
+ const password = getAgentPassword(agent);
1970
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1971
+ await receiver.moveMessage(uid, fromFolder || "INBOX", toFolder);
1839
1972
  res.json({ ok: true });
1840
1973
  } catch (err) {
1841
1974
  next(err);
1842
1975
  }
1843
1976
  });
1844
- router.get("/scheduled", requireAgent, async (req, res, next) => {
1977
+ router.get("/mail/folders", requireAgent, async (req, res, next) => {
1845
1978
  try {
1846
- const rows = db.prepare("SELECT * FROM scheduled_emails WHERE agent_id = ? ORDER BY send_at ASC").all(req.agent.id);
1847
- res.json({ scheduled: rows });
1979
+ const agent = req.agent;
1980
+ const password = getAgentPassword(agent);
1981
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1982
+ const folders = await receiver.listFolders();
1983
+ res.json({ folders });
1984
+ } catch (err) {
1985
+ next(err);
1986
+ }
1987
+ });
1988
+ router.post("/mail/folders", requireAgent, async (req, res, next) => {
1989
+ try {
1990
+ const agent = req.agent;
1991
+ const { name } = req.body || {};
1992
+ if (!name || typeof name !== "string" || !name.trim()) {
1993
+ res.status(400).json({ error: "name is required" });
1994
+ return;
1995
+ }
1996
+ if (name.length > 200 || /[\\*%]/.test(name)) {
1997
+ res.status(400).json({ error: "Invalid folder name" });
1998
+ return;
1999
+ }
2000
+ const password = getAgentPassword(agent);
2001
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2002
+ await receiver.createFolder(name);
2003
+ res.json({ ok: true, folder: name });
1848
2004
  } catch (err) {
1849
2005
  next(err);
1850
2006
  }
1851
2007
  });
1852
- router.post("/scheduled", requireAgent, async (req, res, next) => {
2008
+ router.get("/mail/folders/:folder", requireAgent, async (req, res, next) => {
1853
2009
  try {
1854
- const { to, subject, text, html, cc, bcc, sendAt } = req.body || {};
1855
- if (!to || !subject || !sendAt) {
1856
- res.status(400).json({ error: "to, subject, and sendAt are required" });
1857
- return;
1858
- }
1859
- const sendDate = parseScheduleTime(String(sendAt));
1860
- if (!sendDate || isNaN(sendDate.getTime())) {
1861
- res.status(400).json({
1862
- 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"
1863
- });
1864
- return;
1865
- }
1866
- if (sendDate.getTime() <= Date.now()) {
1867
- res.status(400).json({ error: "sendAt must be in the future" });
2010
+ const agent = req.agent;
2011
+ const folder = decodeURIComponent(req.params.folder);
2012
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
2013
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
2014
+ const password = getAgentPassword(agent);
2015
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2016
+ let mailboxInfo;
2017
+ try {
2018
+ mailboxInfo = await receiver.getMailboxInfo(folder);
2019
+ } catch {
2020
+ res.json({ messages: [], count: 0, total: 0, folder });
1868
2021
  return;
1869
2022
  }
1870
- const id = uuidv4();
1871
- db.prepare(`INSERT INTO scheduled_emails (id, agent_id, to_addr, subject, text_body, html_body, cc, bcc, send_at)
1872
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, req.agent.id, to, subject, text || null, html || null, cc || null, bcc || null, sendDate.toISOString());
1873
- res.json({ ok: true, id, sendAt: sendDate.toISOString() });
2023
+ const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
2024
+ res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder });
1874
2025
  } catch (err) {
1875
2026
  next(err);
1876
2027
  }
1877
2028
  });
1878
- router.delete("/scheduled/:id", requireAgent, async (req, res, next) => {
2029
+ function validateUids(raw) {
2030
+ if (!Array.isArray(raw) || raw.length === 0) return null;
2031
+ if (raw.length > 1e3) return null;
2032
+ const nums = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0);
2033
+ return nums.length > 0 ? nums : null;
2034
+ }
2035
+ router.post("/mail/batch/delete", requireAgent, async (req, res, next) => {
1879
2036
  try {
1880
- const result = db.prepare("DELETE FROM scheduled_emails WHERE id = ? AND agent_id = ? AND status = 'pending'").run(req.params.id, req.agent.id);
1881
- if (result.changes === 0) {
1882
- res.status(404).json({ error: "Scheduled email not found or already sent" });
2037
+ const agent = req.agent;
2038
+ const { uids: rawUids, folder } = req.body || {};
2039
+ const uids = validateUids(rawUids);
2040
+ if (!uids) {
2041
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1883
2042
  return;
1884
2043
  }
1885
- res.json({ ok: true });
2044
+ const password = getAgentPassword(agent);
2045
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2046
+ await receiver.batchDelete(uids, folder || "INBOX");
2047
+ res.json({ ok: true, deleted: uids.length });
1886
2048
  } catch (err) {
1887
2049
  next(err);
1888
2050
  }
1889
2051
  });
1890
- router.get("/tags", requireAgent, async (req, res, next) => {
2052
+ router.post("/mail/batch/seen", requireAgent, async (req, res, next) => {
1891
2053
  try {
1892
- const rows = db.prepare("SELECT * FROM tags WHERE agent_id = ? ORDER BY name").all(req.agent.id);
1893
- res.json({ tags: rows });
2054
+ const agent = req.agent;
2055
+ const { uids: rawUids, folder } = req.body || {};
2056
+ const uids = validateUids(rawUids);
2057
+ if (!uids) {
2058
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
2059
+ return;
2060
+ }
2061
+ const password = getAgentPassword(agent);
2062
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2063
+ await receiver.batchMarkSeen(uids, folder || "INBOX");
2064
+ res.json({ ok: true, marked: uids.length });
1894
2065
  } catch (err) {
1895
2066
  next(err);
1896
2067
  }
1897
2068
  });
1898
- router.post("/tags", requireAgent, async (req, res, next) => {
2069
+ router.post("/mail/batch/unseen", requireAgent, async (req, res, next) => {
1899
2070
  try {
1900
- const { name: name2, color } = req.body || {};
1901
- if (!name2) {
1902
- res.status(400).json({ error: "name is required" });
2071
+ const agent = req.agent;
2072
+ const { uids: rawUids, folder } = req.body || {};
2073
+ const uids = validateUids(rawUids);
2074
+ if (!uids) {
2075
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1903
2076
  return;
1904
2077
  }
1905
- const id = uuidv4();
1906
- db.prepare("INSERT OR IGNORE INTO tags (id, agent_id, name, color) VALUES (?, ?, ?, ?)").run(id, req.agent.id, name2.trim(), color || "#888888");
1907
- res.json({ ok: true, id, name: name2.trim(), color: color || "#888888" });
2078
+ const password = getAgentPassword(agent);
2079
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2080
+ await receiver.batchMarkUnseen(uids, folder || "INBOX");
2081
+ res.json({ ok: true, marked: uids.length });
1908
2082
  } catch (err) {
1909
2083
  next(err);
1910
2084
  }
1911
2085
  });
1912
- router.delete("/tags/:id", requireAgent, async (req, res, next) => {
2086
+ router.post("/mail/batch/move", requireAgent, async (req, res, next) => {
1913
2087
  try {
1914
- const result = db.prepare("DELETE FROM tags WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
1915
- if (result.changes === 0) {
1916
- res.status(404).json({ error: "Tag not found" });
2088
+ const agent = req.agent;
2089
+ const { uids: rawUids, from: fromFolder, to: toFolder } = req.body || {};
2090
+ const uids = validateUids(rawUids);
2091
+ if (!uids) {
2092
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1917
2093
  return;
1918
2094
  }
1919
- res.json({ ok: true });
2095
+ if (!toFolder) {
2096
+ res.status(400).json({ error: "to (destination folder) is required" });
2097
+ return;
2098
+ }
2099
+ const password = getAgentPassword(agent);
2100
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2101
+ await receiver.batchMove(uids, fromFolder || "INBOX", toFolder);
2102
+ res.json({ ok: true, moved: uids.length });
1920
2103
  } catch (err) {
1921
2104
  next(err);
1922
2105
  }
1923
2106
  });
1924
- router.post("/tags/:id/messages", requireAgent, async (req, res, next) => {
2107
+ router.post("/mail/batch/read", requireAgent, async (req, res, next) => {
1925
2108
  try {
1926
- const { uid, folder } = req.body || {};
1927
- if (!uid) {
1928
- res.status(400).json({ error: "uid is required" });
2109
+ const agent = req.agent;
2110
+ const { uids: rawUids, folder } = req.body || {};
2111
+ const uids = validateUids(rawUids);
2112
+ if (!uids) {
2113
+ res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
1929
2114
  return;
1930
2115
  }
1931
- const tag = db.prepare("SELECT * FROM tags WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
1932
- if (!tag) {
1933
- res.status(404).json({ error: "Tag not found" });
1934
- return;
2116
+ const password = getAgentPassword(agent);
2117
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2118
+ const rawMap = await receiver.batchFetch(uids, folder || "INBOX");
2119
+ const messages = [];
2120
+ for (const [uid, raw] of rawMap) {
2121
+ const parsed = await parseEmail2(raw);
2122
+ messages.push({ uid, ...parsed });
1935
2123
  }
1936
- 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");
1937
- res.json({ ok: true });
2124
+ res.json({ messages, count: messages.length });
1938
2125
  } catch (err) {
1939
2126
  next(err);
1940
2127
  }
1941
2128
  });
1942
- router.delete("/tags/:id/messages/:uid", requireAgent, async (req, res, next) => {
2129
+ router.get("/mail/spam", requireAgent, async (req, res, next) => {
1943
2130
  try {
1944
- const folder = req.query.folder || "INBOX";
1945
- 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);
1946
- res.json({ ok: true });
2131
+ const agent = req.agent;
2132
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
2133
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
2134
+ const password = getAgentPassword(agent);
2135
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2136
+ let mailboxInfo;
2137
+ try {
2138
+ mailboxInfo = await receiver.getMailboxInfo("Spam");
2139
+ } catch {
2140
+ res.json({ messages: [], count: 0, total: 0, folder: "Spam" });
2141
+ return;
2142
+ }
2143
+ const envelopes = await receiver.listEnvelopes("Spam", { limit, offset });
2144
+ res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder: "Spam" });
1947
2145
  } catch (err) {
1948
2146
  next(err);
1949
2147
  }
1950
2148
  });
1951
- router.get("/tags/:id/messages", requireAgent, async (req, res, next) => {
2149
+ router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
1952
2150
  try {
1953
- const tag = db.prepare("SELECT * FROM tags WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
1954
- if (!tag) {
1955
- res.status(404).json({ error: "Tag not found" });
2151
+ const agent = req.agent;
2152
+ const uid = parseInt(req.params.uid);
2153
+ if (isNaN(uid) || uid < 1) {
2154
+ res.status(400).json({ error: "Invalid UID" });
1956
2155
  return;
1957
2156
  }
1958
- const rows = db.prepare(
1959
- "SELECT message_uid, folder FROM message_tags WHERE agent_id = ? AND tag_id = ? ORDER BY created_at DESC"
1960
- ).all(req.agent.id, req.params.id);
1961
- res.json({ tag, messages: rows.map((r) => ({ uid: r.message_uid, folder: r.folder })) });
2157
+ const folder = req.body?.folder || "INBOX";
2158
+ const password = getAgentPassword(agent);
2159
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2160
+ try {
2161
+ await receiver.createFolder("Spam");
2162
+ } catch {
2163
+ }
2164
+ await receiver.moveMessage(uid, folder, "Spam");
2165
+ res.json({ ok: true, movedToSpam: true });
1962
2166
  } catch (err) {
1963
2167
  next(err);
1964
2168
  }
1965
2169
  });
1966
- router.get("/messages/:uid/tags", requireAgent, async (req, res, next) => {
2170
+ router.post("/mail/messages/:uid/not-spam", requireAgent, async (req, res, next) => {
1967
2171
  try {
1968
- const rows = db.prepare(`
1969
- SELECT t.* FROM tags t
1970
- JOIN message_tags mt ON mt.tag_id = t.id
1971
- WHERE mt.agent_id = ? AND mt.message_uid = ?
1972
- ORDER BY t.name
1973
- `).all(req.agent.id, parseInt(String(req.params.uid)));
1974
- res.json({ tags: rows });
2172
+ const agent = req.agent;
2173
+ const uid = parseInt(req.params.uid);
2174
+ if (isNaN(uid) || uid < 1) {
2175
+ res.status(400).json({ error: "Invalid UID" });
2176
+ return;
2177
+ }
2178
+ const password = getAgentPassword(agent);
2179
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2180
+ await receiver.moveMessage(uid, "Spam", "INBOX");
2181
+ res.json({ ok: true, movedToInbox: true });
1975
2182
  } catch (err) {
1976
2183
  next(err);
1977
2184
  }
1978
2185
  });
1979
- router.post("/templates/:id/send", requireAgent, async (req, res, next) => {
2186
+ router.get("/mail/messages/:uid/spam-score", requireAgent, async (req, res, next) => {
1980
2187
  try {
1981
- const template = db.prepare("SELECT * FROM templates WHERE id = ? AND agent_id = ?").get(req.params.id, req.agent.id);
1982
- if (!template) {
1983
- res.status(404).json({ error: "Template not found" });
2188
+ const agent = req.agent;
2189
+ const uid = parseInt(req.params.uid);
2190
+ if (isNaN(uid) || uid < 1) {
2191
+ res.status(400).json({ error: "Invalid UID" });
1984
2192
  return;
1985
2193
  }
1986
- const { to, variables, cc, bcc } = req.body || {};
1987
- if (!to) {
1988
- res.status(400).json({ error: "to is required" });
2194
+ const folder = req.query.folder || "INBOX";
2195
+ const password = getAgentPassword(agent);
2196
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2197
+ const raw = await receiver.fetchMessage(uid, folder);
2198
+ const parsed = await parseEmail2(raw);
2199
+ if (isInternalEmail2(parsed)) {
2200
+ res.json({ score: 0, isSpam: false, isWarning: false, matches: [], topCategory: null, internal: true });
1989
2201
  return;
1990
2202
  }
1991
- const applyVars = (text, vars2) => text.replace(/\{\{(\w+)\}\}/g, (m, key) => vars2[key] ?? m);
1992
- const vars = variables && typeof variables === "object" ? variables : {};
1993
- const mailOpts = {
1994
- to,
1995
- subject: applyVars(template.subject || "(no subject)", vars),
1996
- text: template.text_body ? applyVars(template.text_body, vars) : void 0,
1997
- html: template.html_body ? applyVars(template.html_body, vars) : void 0,
1998
- cc: cc || void 0,
1999
- bcc: bcc || void 0
2000
- };
2203
+ const result = scoreEmail2(parsed);
2204
+ res.json(result);
2205
+ } catch (err) {
2206
+ next(err);
2207
+ }
2208
+ });
2209
+ router.get("/mail/digest", requireAgent, async (req, res, next) => {
2210
+ try {
2001
2211
  const agent = req.agent;
2002
- if (gatewayManager) {
2003
- const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
2004
- if (gatewayResult) {
2005
- res.json(gatewayResult);
2006
- return;
2007
- }
2008
- }
2212
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 50);
2213
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
2214
+ const previewLen = Math.min(Math.max(parseInt(req.query.previewLength) || 200, 50), 500);
2215
+ const folder = req.query.folder || "INBOX";
2009
2216
  const password = getAgentPassword(agent);
2010
- const sender = new MailSender3({
2011
- host: config.smtp.host,
2012
- port: config.smtp.port,
2013
- email: agent.email,
2014
- password,
2015
- authUser: agent.stalwartPrincipal
2016
- });
2017
- try {
2018
- const result = await sender.send(mailOpts);
2019
- res.json(result);
2020
- } finally {
2021
- sender.close();
2217
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2218
+ const mailboxInfo = await receiver.getMailboxInfo(folder);
2219
+ const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
2220
+ const uids = envelopes.map((e) => e.uid);
2221
+ const rawMap = uids.length > 0 ? await receiver.batchFetch(uids, folder) : /* @__PURE__ */ new Map();
2222
+ const messages = [];
2223
+ for (const env of envelopes) {
2224
+ let preview = "";
2225
+ const raw = rawMap.get(env.uid);
2226
+ if (raw) {
2227
+ const parsed = await parseEmail2(raw);
2228
+ preview = (parsed.text || "").slice(0, previewLen);
2229
+ }
2230
+ messages.push({
2231
+ uid: env.uid,
2232
+ subject: env.subject,
2233
+ from: env.from,
2234
+ to: env.to,
2235
+ date: env.date,
2236
+ flags: [...env.flags],
2237
+ size: env.size,
2238
+ preview
2239
+ });
2022
2240
  }
2241
+ res.json({ messages, count: messages.length, total: mailboxInfo.exists });
2023
2242
  } catch (err) {
2024
2243
  next(err);
2025
2244
  }
2026
2245
  });
2027
- router.get("/rules", requireAgent, async (req, res, next) => {
2028
- try {
2029
- const rows = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? ORDER BY priority DESC, created_at").all(req.agent.id);
2030
- res.json({ rules: rows.map((r) => ({ ...r, conditions: JSON.parse(r.conditions), actions: JSON.parse(r.actions) })) });
2031
- } catch (err) {
2032
- next(err);
2246
+ router.get("/mail/pending", requireAuth, async (req, res) => {
2247
+ const rows = req.isMaster ? db.prepare(
2248
+ `SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
2249
+ FROM pending_outbound ORDER BY created_at DESC LIMIT 50`
2250
+ ).all() : db.prepare(
2251
+ `SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
2252
+ FROM pending_outbound WHERE agent_id = ? ORDER BY created_at DESC LIMIT 50`
2253
+ ).all(req.agent.id);
2254
+ const pending = rows.map((r) => {
2255
+ const opts = JSON.parse(r.mail_options);
2256
+ return {
2257
+ id: r.id,
2258
+ agentId: r.agent_id,
2259
+ to: opts.to,
2260
+ subject: opts.subject,
2261
+ warnings: JSON.parse(r.warnings),
2262
+ summary: r.summary,
2263
+ status: r.status,
2264
+ createdAt: r.created_at,
2265
+ resolvedAt: r.resolved_at,
2266
+ resolvedBy: r.resolved_by
2267
+ };
2268
+ });
2269
+ res.json({ pending, count: pending.length });
2270
+ });
2271
+ router.get("/mail/pending/:id", requireAuth, async (req, res) => {
2272
+ 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);
2273
+ if (!row) {
2274
+ res.status(404).json({ error: "Pending email not found" });
2275
+ return;
2033
2276
  }
2277
+ res.json({
2278
+ id: row.id,
2279
+ mailOptions: JSON.parse(row.mail_options),
2280
+ warnings: JSON.parse(row.warnings),
2281
+ summary: row.summary,
2282
+ status: row.status,
2283
+ createdAt: row.created_at,
2284
+ resolvedAt: row.resolved_at,
2285
+ resolvedBy: row.resolved_by
2286
+ });
2034
2287
  });
2035
- router.post("/rules", requireAgent, async (req, res, next) => {
2288
+ router.post("/mail/pending/:id/approve", requireMaster, async (req, res, next) => {
2036
2289
  try {
2037
- const { name: name2, conditions, actions, priority, enabled } = req.body || {};
2038
- if (!name2) {
2039
- res.status(400).json({ error: "name is required" });
2290
+ const row = db.prepare(
2291
+ `SELECT * FROM pending_outbound WHERE id = ?`
2292
+ ).get(req.params.id);
2293
+ if (!row) {
2294
+ res.status(404).json({ error: "Pending email not found" });
2040
2295
  return;
2041
2296
  }
2042
- const id = uuidv4();
2043
- db.prepare(
2044
- "INSERT INTO email_rules (id, agent_id, name, priority, enabled, conditions, actions) VALUES (?, ?, ?, ?, ?, ?, ?)"
2045
- ).run(id, req.agent.id, name2, priority ?? 0, enabled !== false ? 1 : 0, JSON.stringify(conditions || {}), JSON.stringify(actions || {}));
2046
- res.status(201).json({ id, name: name2, conditions: conditions || {}, actions: actions || {}, priority: priority ?? 0, enabled: enabled !== false });
2047
- } catch (err) {
2048
- next(err);
2049
- }
2050
- });
2051
- router.delete("/rules/:id", requireAgent, async (req, res, next) => {
2052
- try {
2053
- const result = db.prepare("DELETE FROM email_rules WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
2054
- if (result.changes === 0) {
2055
- res.status(404).json({ error: "Rule not found" });
2297
+ if (row.status !== "pending") {
2298
+ res.status(400).json({ error: `Email already ${row.status}` });
2056
2299
  return;
2057
2300
  }
2058
- res.json({ ok: true });
2059
- } catch (err) {
2060
- next(err);
2061
- }
2062
- });
2063
- return router;
2064
- }
2065
- function evaluateRules(db, agentId, email) {
2066
- const rules = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? AND enabled = 1 ORDER BY priority DESC").all(agentId);
2067
- for (const rule of rules) {
2068
- const cond = JSON.parse(rule.conditions);
2069
- let match = true;
2070
- const fromAddr = (email.from?.[0]?.address ?? "").toLowerCase();
2071
- const toAddr = (email.to?.[0]?.address ?? "").toLowerCase();
2072
- const subject = (email.subject ?? "").toLowerCase();
2073
- if (cond.from_contains && !fromAddr.includes(cond.from_contains.toLowerCase())) match = false;
2074
- if (cond.from_exact && fromAddr !== cond.from_exact.toLowerCase()) match = false;
2075
- if (cond.subject_contains && !subject.includes(cond.subject_contains.toLowerCase())) match = false;
2076
- if (cond.subject_regex) {
2077
- try {
2078
- if (!new RegExp(cond.subject_regex, "i").test(email.subject ?? "")) match = false;
2079
- } catch {
2080
- match = false;
2301
+ const agent = await accountManager.getById(row.agent_id);
2302
+ if (!agent) {
2303
+ res.status(404).json({ error: "Agent account no longer exists" });
2304
+ return;
2081
2305
  }
2082
- }
2083
- if (cond.to_contains && !toAddr.includes(cond.to_contains.toLowerCase())) match = false;
2084
- if (cond.has_attachment === true && (!email.attachments || email.attachments.length === 0)) match = false;
2085
- if (match) return { ruleId: rule.id, actions: JSON.parse(rule.actions) };
2086
- }
2087
- return null;
2088
- }
2089
- function startScheduledSender(db, accountManager, config, gatewayManager) {
2090
- return setInterval(async () => {
2091
- try {
2092
- const now = (/* @__PURE__ */ new Date()).toISOString();
2093
- const pending = db.prepare(
2094
- "SELECT * FROM scheduled_emails WHERE status = 'pending' AND send_at <= ?"
2095
- ).all(now);
2096
- for (const row of pending) {
2097
- try {
2098
- const agent = await accountManager.getById(row.agent_id);
2099
- if (!agent) {
2100
- db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run("Agent not found", row.id);
2101
- continue;
2102
- }
2103
- const mailOpts = {
2104
- to: row.to_addr,
2105
- subject: row.subject,
2106
- text: row.text_body || void 0,
2107
- html: row.html_body || void 0,
2108
- cc: row.cc || void 0,
2109
- bcc: row.bcc || void 0
2110
- };
2111
- if (gatewayManager) {
2112
- const gResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
2113
- if (gResult) {
2114
- db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
2115
- continue;
2116
- }
2117
- }
2118
- const password = agent.metadata?._password || agent.name;
2119
- const sender = new MailSender3({
2120
- host: config.smtp.host,
2121
- port: config.smtp.port,
2122
- email: agent.email,
2123
- password,
2124
- authUser: agent.stalwartPrincipal
2125
- });
2126
- try {
2127
- await sender.send(mailOpts);
2128
- db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
2129
- } finally {
2130
- sender.close();
2306
+ const mailOpts = JSON.parse(row.mail_options);
2307
+ const ownerName = agent.metadata?.ownerName;
2308
+ mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
2309
+ if (Array.isArray(mailOpts.attachments)) {
2310
+ for (const att of mailOpts.attachments) {
2311
+ if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
2312
+ att.content = Buffer.from(att.content.data);
2131
2313
  }
2132
- } catch (err) {
2133
- db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run(err.message, row.id);
2134
2314
  }
2135
2315
  }
2136
- try {
2137
- db.prepare("DELETE FROM delivered_messages WHERE delivered_at < datetime('now', '-30 days')").run();
2138
- } catch {
2316
+ const password = getAgentPassword(agent);
2317
+ let response;
2318
+ if (gatewayManager) {
2319
+ const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
2320
+ if (gatewayResult) {
2321
+ if (gatewayResult.raw) {
2322
+ saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
2323
+ }
2324
+ const { raw: _raw, ...rest } = gatewayResult;
2325
+ response = rest;
2326
+ }
2139
2327
  }
2140
- try {
2141
- db.prepare("DELETE FROM spam_log WHERE created_at < datetime('now', '-30 days')").run();
2142
- } catch {
2328
+ if (!response) {
2329
+ const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
2330
+ const result = await sender.send(mailOpts);
2331
+ saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
2332
+ notifyLocalRecipientsOfNewMail(
2333
+ accountManager,
2334
+ mailOpts.to,
2335
+ mailOpts.cc,
2336
+ mailOpts.bcc,
2337
+ agent,
2338
+ mailOpts.subject,
2339
+ result.messageId
2340
+ ).catch((err) => {
2341
+ console.warn(`[mail] Internal SSE notify (approve) failed: ${err.message}`);
2342
+ });
2343
+ const { raw: _raw, ...rest } = result;
2344
+ response = rest;
2143
2345
  }
2144
- } catch {
2145
- }
2146
- }, 3e4);
2147
- }
2148
-
2149
- // src/routes/events.ts
2150
- var MAX_SSE_PER_AGENT = 5;
2151
- var activeWatchers = /* @__PURE__ */ new Map();
2152
- function pushEventToAgent(agentId, event) {
2153
- const watchers = activeWatchers.get(agentId);
2154
- if (!watchers || watchers.size === 0) return false;
2155
- const data = `data: ${JSON.stringify(event)}
2156
-
2157
- `;
2158
- for (const entry of watchers) {
2159
- try {
2160
- entry.res.write(data);
2161
- } catch {
2346
+ db.prepare(
2347
+ `UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
2348
+ ).run("master", row.id);
2349
+ res.json({ ...response, approved: true, pendingId: row.id });
2350
+ } catch (err) {
2351
+ next(err);
2162
2352
  }
2163
- }
2164
- return true;
2165
- }
2166
- function broadcastEvent(event) {
2167
- const data = `data: ${JSON.stringify(event)}
2168
-
2169
- `;
2170
- let count = 0;
2171
- for (const [, watchers] of activeWatchers) {
2172
- for (const entry of watchers) {
2173
- try {
2174
- entry.res.write(data);
2175
- count++;
2176
- } catch {
2177
- }
2353
+ });
2354
+ router.post("/mail/pending/:id/reject", requireMaster, async (req, res) => {
2355
+ const row = db.prepare(
2356
+ `SELECT * FROM pending_outbound WHERE id = ?`
2357
+ ).get(req.params.id);
2358
+ if (!row) {
2359
+ res.status(404).json({ error: "Pending email not found" });
2360
+ return;
2178
2361
  }
2179
- }
2180
- return count;
2181
- }
2182
- async function closeAllWatchers() {
2183
- for (const [, watchers] of activeWatchers) {
2184
- for (const entry of watchers) {
2185
- try {
2186
- await entry.watcher.stop();
2187
- } catch {
2188
- }
2189
- try {
2190
- entry.res.end();
2191
- } catch {
2192
- }
2362
+ if (row.status !== "pending") {
2363
+ res.status(400).json({ error: `Email already ${row.status}` });
2364
+ return;
2193
2365
  }
2194
- }
2195
- activeWatchers.clear();
2366
+ db.prepare(
2367
+ `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
2368
+ ).run("master", row.id);
2369
+ res.json({ ok: true, rejected: true, pendingId: row.id });
2370
+ });
2371
+ return router;
2196
2372
  }
2197
- function createEventRoutes(accountManager, config, db) {
2373
+
2374
+ // src/routes/inbound.ts
2375
+ import { Router as Router6 } from "express";
2376
+ import { randomUUID } from "crypto";
2377
+ import {
2378
+ parseEmail as parseEmail3,
2379
+ MailSender as MailSender3
2380
+ } from "@agenticmail/core";
2381
+ var INBOUND_SECRET = process.env.AGENTICMAIL_INBOUND_SECRET || (() => {
2382
+ const generated = randomUUID();
2383
+ console.warn("[Inbound] WARNING: AGENTICMAIL_INBOUND_SECRET is not set. Generated a random secret for this session.");
2384
+ console.warn(`[Inbound] Set AGENTICMAIL_INBOUND_SECRET="${generated}" in your environment to persist it across restarts.`);
2385
+ return generated;
2386
+ })();
2387
+ var DEBUG = () => !!process.env.AGENTICMAIL_DEBUG;
2388
+ function createInboundRoutes(accountManager, config, gatewayManager) {
2198
2389
  const router = Router6();
2199
- router.get("/events", requireAgent, async (req, res, next) => {
2390
+ router.post("/mail/inbound", async (req, res, next) => {
2200
2391
  try {
2201
- const agent = req.agent;
2202
- const password = getAgentPassword(agent);
2203
- const agentWatchers = activeWatchers.get(agent.id) ?? /* @__PURE__ */ new Set();
2204
- if (agentWatchers.size >= MAX_SSE_PER_AGENT) {
2205
- res.status(429).json({ error: `Maximum ${MAX_SSE_PER_AGENT} concurrent SSE connections per agent` });
2392
+ const secret = req.headers["x-inbound-secret"];
2393
+ if (secret !== INBOUND_SECRET) {
2394
+ res.status(401).json({ error: "Invalid inbound secret" });
2206
2395
  return;
2207
2396
  }
2208
- const watcher = new InboxWatcher({
2209
- host: config.imap.host,
2210
- port: config.imap.port,
2211
- email: agent.stalwartPrincipal,
2212
- password,
2213
- autoReconnect: true,
2214
- maxReconnectAttempts: 20
2215
- });
2216
- try {
2217
- await watcher.start();
2218
- } catch (err) {
2219
- res.status(500).json({ error: "Failed to start event stream: " + (err instanceof Error ? err.message : String(err)) });
2397
+ const { from, to, subject, rawEmail } = req.body;
2398
+ if (!to || !rawEmail) {
2399
+ res.status(400).json({ error: "to and rawEmail are required" });
2220
2400
  return;
2221
2401
  }
2222
- res.setHeader("Content-Type", "text/event-stream");
2223
- res.setHeader("Cache-Control", "no-cache");
2224
- res.setHeader("Connection", "keep-alive");
2225
- res.setHeader("X-Accel-Buffering", "no");
2226
- res.flushHeaders();
2227
- let closed = false;
2228
- const entry = { watcher, res };
2229
- activeWatchers.set(agent.id, agentWatchers);
2230
- agentWatchers.add(entry);
2231
- const safeWrite = (data) => {
2232
- if (!closed) {
2233
- try {
2234
- res.write(data);
2235
- } catch {
2236
- }
2237
- }
2238
- };
2239
- safeWrite(`data: ${JSON.stringify({ type: "connected", agentId: agent.id })}
2240
-
2241
- `);
2242
- watcher.on("new", async (event) => {
2243
- if (db) touchActivity(db, agent.id);
2244
- if (db && event.uid) {
2245
- try {
2246
- const receiver = new MailReceiver2({
2247
- host: config.imap.host,
2248
- port: config.imap.port,
2249
- email: agent.stalwartPrincipal,
2250
- password,
2251
- secure: false
2252
- });
2253
- await receiver.connect();
2254
- try {
2255
- const raw = await receiver.fetchMessage(event.uid);
2256
- const parsed = await parseEmail3(raw);
2257
- const policyMetadata = agent.metadata && typeof agent.metadata === "object" ? {
2258
- emailRoutePolicy: agent.metadata.emailRoutePolicy,
2259
- routePolicy: agent.metadata.routePolicy,
2260
- mailboxPolicy: agent.metadata.mailboxPolicy
2261
- } : void 0;
2262
- const accountRouteContext = {
2263
- name: agent.name,
2264
- email: agent.email,
2265
- role: agent.role,
2266
- metadata: policyMetadata
2267
- };
2268
- const isRelay = !!parsed.headers.get("x-agenticmail-relay");
2269
- const internal = !isRelay && isInternalEmail2(parsed);
2270
- if (internal) {
2271
- event.route = classifyEmailRoute({ email: parsed, account: accountRouteContext });
2272
- const ruleResult2 = evaluateRules(db, agent.id, parsed);
2273
- if (ruleResult2) {
2274
- const actions = ruleResult2.actions;
2275
- if (actions.mark_read) await receiver.markSeen(event.uid);
2276
- if (actions.delete) {
2277
- await receiver.deleteMessage(event.uid);
2278
- return;
2279
- }
2280
- if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
2281
- event.ruleApplied = { ruleId: ruleResult2.ruleId, actions };
2282
- }
2283
- safeWrite(`data: ${JSON.stringify(event)}
2284
-
2285
- `);
2286
- return;
2287
- }
2288
- const spamResult = scoreEmail2(parsed);
2289
- event.route = classifyEmailRoute({ email: parsed, spam: spamResult, account: accountRouteContext });
2290
- try {
2291
- db.prepare(
2292
- "INSERT INTO spam_log (id, agent_id, message_uid, score, flags, category, is_spam) VALUES (?, ?, ?, ?, ?, ?, ?)"
2293
- ).run(
2294
- uuidv42(),
2295
- agent.id,
2296
- event.uid,
2297
- spamResult.score,
2298
- JSON.stringify(spamResult.matches.map((m) => m.ruleId)),
2299
- spamResult.topCategory,
2300
- spamResult.isSpam ? 1 : 0
2301
- );
2302
- } catch {
2303
- }
2304
- if (spamResult.isSpam) {
2305
- try {
2306
- await receiver.createFolder("Spam");
2307
- } catch {
2308
- }
2309
- await receiver.moveMessage(event.uid, "INBOX", "Spam");
2310
- event.spam = { score: spamResult.score, category: spamResult.topCategory, movedToSpam: true };
2311
- safeWrite(`data: ${JSON.stringify(event)}
2312
-
2313
- `);
2314
- return;
2315
- }
2316
- if (spamResult.isWarning) {
2317
- event.spamWarning = { score: spamResult.score, category: spamResult.topCategory, matches: spamResult.matches.map((m) => m.ruleId) };
2318
- }
2319
- const ruleResult = evaluateRules(db, agent.id, parsed);
2320
- if (ruleResult) {
2321
- const actions = ruleResult.actions;
2322
- if (actions.mark_read) await receiver.markSeen(event.uid);
2323
- if (actions.delete) {
2324
- await receiver.deleteMessage(event.uid);
2325
- return;
2326
- }
2327
- if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
2328
- event.ruleApplied = { ruleId: ruleResult.ruleId, actions };
2329
- }
2330
- } finally {
2331
- await receiver.disconnect();
2332
- }
2333
- } catch (err) {
2334
- console.error("[SSE] Spam/rule evaluation error:", err.message);
2335
- }
2336
- }
2337
- safeWrite(`data: ${JSON.stringify(event)}
2338
-
2339
- `);
2340
- });
2341
- watcher.on("expunge", (event) => {
2342
- safeWrite(`data: ${JSON.stringify(event)}
2343
-
2344
- `);
2345
- });
2346
- watcher.on("flags", (event) => {
2347
- safeWrite(`data: ${JSON.stringify(event)}
2348
-
2349
- `);
2350
- });
2351
- watcher.on("error", (err) => {
2352
- safeWrite(`data: ${JSON.stringify({ type: "error", message: err.message })}
2353
-
2354
- `);
2355
- });
2356
- watcher.on("reconnecting", (info) => {
2357
- safeWrite(`data: ${JSON.stringify({ type: "reconnecting", attempt: info.attempt, delayMs: info.delayMs })}
2358
-
2359
- `);
2360
- });
2361
- watcher.on("reconnected", (info) => {
2362
- safeWrite(`data: ${JSON.stringify({ type: "reconnected", attempt: info.attempt })}
2363
-
2364
- `);
2365
- });
2366
- watcher.on("reconnect_failed", (info) => {
2367
- safeWrite(`data: ${JSON.stringify({ type: "reconnect_failed", attempts: info.attempts })}
2368
-
2369
- `);
2402
+ const recipientEmail = typeof to === "string" ? to : to[0];
2403
+ const localPart = recipientEmail.split("@")[0];
2404
+ const agent = await accountManager.getByName(localPart);
2405
+ if (!agent) {
2406
+ console.warn(`[Inbound] No agent found for "${localPart}" (${recipientEmail})`);
2407
+ res.status(404).json({ error: `No agent found for ${recipientEmail}` });
2408
+ return;
2409
+ }
2410
+ const agentPassword = agent.metadata?._password;
2411
+ if (!agentPassword) {
2412
+ console.warn(`[Inbound] No password for agent "${agent.name}"`);
2413
+ res.status(500).json({ error: "Agent has no password configured" });
2414
+ return;
2415
+ }
2416
+ const rawBuffer = Buffer.from(rawEmail, "base64");
2417
+ const parsed = await parseEmail3(rawBuffer);
2418
+ const originalMessageId = parsed.messageId;
2419
+ if (originalMessageId && gatewayManager?.isAlreadyDelivered(originalMessageId, agent.name)) {
2420
+ if (DEBUG()) console.log(`[Inbound] Skipping duplicate: ${originalMessageId} \u2192 ${agent.name}`);
2421
+ res.json({ ok: true, delivered: agent.email, duplicate: true });
2422
+ return;
2423
+ }
2424
+ if (DEBUG()) console.log(`[Inbound] Delivering email to ${agent.email} from ${from} (subject: ${subject || parsed.subject})`);
2425
+ const sender = new MailSender3({
2426
+ host: config.smtp.host,
2427
+ port: config.smtp.port,
2428
+ email: agent.email,
2429
+ password: agentPassword,
2430
+ authUser: agent.stalwartPrincipal
2370
2431
  });
2371
- const pingInterval = setInterval(() => {
2372
- safeWrite(`: ping
2373
-
2374
- `);
2375
- }, 3e4);
2376
- req.on("close", () => {
2377
- closed = true;
2378
- clearInterval(pingInterval);
2379
- agentWatchers.delete(entry);
2380
- if (agentWatchers.size === 0) activeWatchers.delete(agent.id);
2381
- watcher.removeAllListeners();
2382
- watcher.stop().catch((err) => {
2383
- console.error("[SSE] Watcher cleanup error:", err);
2432
+ try {
2433
+ await sender.send({
2434
+ to: agent.email,
2435
+ subject: parsed.subject || subject || "(no subject)",
2436
+ text: parsed.text || void 0,
2437
+ html: parsed.html || void 0,
2438
+ replyTo: from || parsed.from?.[0]?.address,
2439
+ inReplyTo: parsed.inReplyTo,
2440
+ references: parsed.references,
2441
+ headers: {
2442
+ "X-AgenticMail-Inbound": "cloudflare-worker",
2443
+ "X-Original-From": from || parsed.from?.[0]?.address || "",
2444
+ ...parsed.messageId ? { "X-Original-Message-Id": parsed.messageId } : {}
2445
+ },
2446
+ attachments: parsed.attachments?.map((a) => ({
2447
+ filename: a.filename,
2448
+ content: a.content,
2449
+ contentType: a.contentType
2450
+ }))
2384
2451
  });
2385
- });
2452
+ if (originalMessageId) gatewayManager?.recordDelivery(originalMessageId, agent.name);
2453
+ if (DEBUG()) console.log(`[Inbound] Delivered to ${agent.email}`);
2454
+ res.json({ ok: true, delivered: agent.email });
2455
+ } finally {
2456
+ sender.close();
2457
+ }
2386
2458
  } catch (err) {
2387
2459
  next(err);
2388
2460
  }
@@ -3402,7 +3474,14 @@ function buildColumnDDL(col, dialect) {
3402
3474
  if (col.required && !col.primaryKey) ddl += " NOT NULL";
3403
3475
  if (col.unique && !col.primaryKey) ddl += " UNIQUE";
3404
3476
  if (col.default !== void 0) {
3405
- const val = typeof col.default === "string" ? `'${col.default}'` : col.default;
3477
+ let val;
3478
+ if (typeof col.default === "string") {
3479
+ const trimmed = col.default.trim();
3480
+ const isSqlExpr = /\(.*\)/.test(trimmed) || /^CURRENT_(?:TIMESTAMP|DATE|TIME)$/i.test(trimmed);
3481
+ val = isSqlExpr ? `(${trimmed})` : `'${col.default.replace(/'/g, "''")}'`;
3482
+ } else {
3483
+ val = col.default;
3484
+ }
3406
3485
  ddl += ` DEFAULT ${val}`;
3407
3486
  }
3408
3487
  if (col.check) ddl += ` CHECK (${col.check})`;
@@ -3412,15 +3491,15 @@ function buildColumnDDL(col, dialect) {
3412
3491
  }
3413
3492
  return ddl;
3414
3493
  }
3415
- function safeTableName(agentId, name2, shared) {
3416
- const clean = name2.replace(/[^a-zA-Z0-9_]/g, "").substring(0, 64);
3494
+ function safeTableName(agentId, name, shared) {
3495
+ const clean = name.replace(/[^a-zA-Z0-9_]/g, "").substring(0, 64);
3417
3496
  if (!clean) throw new Error("Invalid table name");
3418
3497
  const prefix = shared ? "shared" : `agt_${agentId.replace(/[^a-zA-Z0-9]/g, "").substring(0, 16)}`;
3419
3498
  return `${prefix}_${clean}`;
3420
3499
  }
3421
- function resolveTable(agentId, name2) {
3422
- if (name2.startsWith("agt_") || name2.startsWith("shared_")) return name2;
3423
- return safeTableName(agentId, name2, false);
3500
+ function resolveTable(agentId, name) {
3501
+ if (name.startsWith("agt_") || name.startsWith("shared_")) return name;
3502
+ return safeTableName(agentId, name, false);
3424
3503
  }
3425
3504
  function isSafeTable(tableName) {
3426
3505
  return tableName.startsWith("agt_") || tableName.startsWith("shared_");
@@ -3578,8 +3657,8 @@ function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite")
3578
3657
  if (!agent) return;
3579
3658
  await ensureMetaTable();
3580
3659
  try {
3581
- const { name: name2, columns, indexes, shared, description, timestamps } = req.body;
3582
- if (!name2 || !columns?.length) return res.status(400).json({ error: "name and columns are required" });
3660
+ const { name, columns, indexes, shared, description, timestamps } = req.body;
3661
+ if (!name || !columns?.length) return res.status(400).json({ error: "name and columns are required" });
3583
3662
  const hasPK = columns.some((c) => c.primaryKey);
3584
3663
  const allCols = [...hasPK ? [] : [{ name: "id", type: "text", primaryKey: true }], ...columns];
3585
3664
  if (timestamps !== false) {
@@ -3590,9 +3669,9 @@ function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite")
3590
3669
  allCols.push({ name: "updated_at", type: "timestamp", default: nowExpr(dialect) });
3591
3670
  }
3592
3671
  }
3593
- const tableName = safeTableName(agent.id, name2, !!shared);
3672
+ const tableName = safeTableName(agent.id, name, !!shared);
3594
3673
  const existing = await db.get("SELECT table_name FROM agenticmail_storage_meta WHERE table_name = ?", [tableName]);
3595
- if (existing) return res.status(409).json({ error: `Table "${name2}" already exists`, table: tableName });
3674
+ if (existing) return res.status(409).json({ error: `Table "${name}" already exists`, table: tableName });
3596
3675
  const colDefs = allCols.map((c) => buildColumnDDL(c, dialect)).join(",\n ");
3597
3676
  await db.run(`CREATE TABLE IF NOT EXISTS ${tableName} (
3598
3677
  ${colDefs}
@@ -3613,7 +3692,7 @@ function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite")
3613
3692
  }
3614
3693
  await db.run(
3615
3694
  "INSERT INTO agenticmail_storage_meta (table_name, agent_id, display_name, description, shared, columns, indexes) VALUES (?, ?, ?, ?, ?, ?, ?)",
3616
- [tableName, agent.id, name2, description || "", shared ? 1 : 0, JSON.stringify(allCols), JSON.stringify(idxMeta)]
3695
+ [tableName, agent.id, name, description || "", shared ? 1 : 0, JSON.stringify(allCols), JSON.stringify(idxMeta)]
3617
3696
  );
3618
3697
  res.json({ ok: true, table: tableName, columns: allCols, indexes: idxMeta });
3619
3698
  } catch (err) {