@agenticmail/api 0.5.56 → 0.5.59

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 +1643 -1531
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -309,8 +309,8 @@ function createAccountRoutes(accountManager, db, config) {
309
309
  res.status(201).json(sanitizeAgent(agent));
310
310
  } catch (err) {
311
311
  const msg = err.message ?? "";
312
- if (msg.includes("UNIQUE") || msg.includes("unique") || msg.includes("already exists") || msg.includes("duplicate")) {
313
- res.status(409).json({ error: `Agent "${name}" already exists` });
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: "Account already exists", name });
314
314
  return;
315
315
  }
316
316
  next(err);
@@ -488,1901 +488,1973 @@ 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: name2, 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, name2 || 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: name2, text, html, isDefault } = req.body || {};
799
+ if (!name2) {
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, name2, 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: name2, subject, text, html } = req.body || {};
836
+ if (!name2) {
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, name2, 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: name2, color } = req.body || {};
916
+ if (!name2) {
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, name2.trim(), color || "#888888");
922
+ res.json({ ok: true, id, name: name2.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: name2, conditions, actions, priority, enabled } = req.body || {};
1053
+ if (!name2) {
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, name2, priority ?? 0, enabled !== false ? 1 : 0, JSON.stringify(conditions || {}), JSON.stringify(actions || {}));
1061
+ res.status(201).json({ id, name: name2, 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);
1155
+ try {
1156
+ db.prepare("DELETE FROM spam_log WHERE created_at < datetime('now', '-30 days')").run();
1157
+ } catch {
1158
+ }
1159
+ } catch {
1202
1160
  }
1203
- });
1204
- router.post("/mail/messages/:uid/not-spam", 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) {
1205
1174
  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;
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 {
1211
1192
  }
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);
1218
1193
  }
1219
- });
1220
- router.get("/mail/messages/:uid/spam-score", requireAgent, async (req, res, next) => {
1221
- 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;
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 {
1227
1203
  }
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;
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);
1347
- }
1348
- }
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);
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 {
1357
1251
  }
1358
- const { raw: _raw, ...rest } = gatewayResult;
1359
- response = rest;
1360
1252
  }
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
- }
1253
+ };
1254
+ safeWrite(`data: ${JSON.stringify({ type: "connected", agentId: agent.id })}
1396
1255
 
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) => {
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);
1350
+ }
1351
+ }
1352
+ safeWrite(`data: ${JSON.stringify(event)}
1353
+
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
+ `);
1375
+ });
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);
1399
+ });
1400
+ });
1401
+ } catch (err) {
1402
+ next(err);
1403
+ }
1404
+ });
1405
+ return router;
1406
+ }
1407
+
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
+ }
1433
+ }
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
+ }
1440
+ }
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
+ }
1456
+ }
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);
1482
+ }
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);
1491
+ }
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);
1500
+ }
1501
+ }
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) {
1511
+ try {
1512
+ await receiver.disconnect();
1513
+ } catch {
1514
+ }
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) {
1528
+ try {
1529
+ entry.sender.close();
1530
+ } catch {
1531
+ }
1532
+ }
1533
+ senderCache.clear();
1534
+ for (const [, entry] of receiverCache) {
1535
+ try {
1536
+ await entry.receiver.disconnect();
1537
+ } catch {
1538
+ }
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 () => {
1592
+ try {
1593
+ const receiver = await getReceiver(authUser, password, config);
1594
+ await receiver.appendMessage(raw, "Sent Items", ["\\Seen"]);
1595
+ } catch (err) {
1596
+ console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
1597
+ }
1598
+ })();
1599
+ }
1600
+ function createMailRoutes(accountManager, config, db, gatewayManager) {
1601
+ const router = Router5();
1602
+ router.post("/mail/send", requireAgent, async (req, res, next) => {
1603
+ try {
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 } : {} });
1735
+ } catch (err) {
1736
+ next(err);
1737
+ }
1738
+ });
1739
+ router.get("/mail/inbox", requireAgent, async (req, res, next) => {
1740
+ try {
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 });
1749
+ } catch (err) {
1750
+ next(err);
1751
+ }
1752
+ });
1753
+ router.get("/mail/messages/:uid", requireAgent, async (req, res, next) => {
1754
+ try {
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" });
1759
+ return;
1760
+ }
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
+ });
1789
+ } catch (err) {
1790
+ next(err);
1791
+ }
1792
+ });
1793
+ router.get("/mail/messages/:uid/attachments/:index", requireAgent, async (req, res, next) => {
1414
1794
  try {
1415
- const secret = req.headers["x-inbound-secret"];
1416
- if (secret !== INBOUND_SECRET) {
1417
- res.status(401).json({ error: "Invalid inbound secret" });
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" });
1418
1800
  return;
1419
1801
  }
1420
- const { from, to, subject, rawEmail } = req.body;
1421
- if (!to || !rawEmail) {
1422
- res.status(400).json({ error: "to and rawEmail are required" });
1802
+ if (isNaN(index) || index < 0) {
1803
+ res.status(400).json({ error: "Invalid attachment index" });
1423
1804
  return;
1424
1805
  }
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}` });
1806
+ const folder = req.query.folder || "INBOX";
1807
+ const password = getAgentPassword(agent);
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" });
1431
1813
  return;
1432
1814
  }
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" });
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);
1820
+ } catch (err) {
1821
+ next(err);
1822
+ }
1823
+ });
1824
+ router.post("/mail/search", requireAgent, async (req, res, next) => {
1825
+ try {
1826
+ if (!req.body || typeof req.body !== "object") {
1827
+ res.status(400).json({ error: "Request body must be JSON" });
1437
1828
  return;
1438
1829
  }
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 });
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' });
1445
1837
  return;
1446
1838
  }
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
1454
- });
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
- }))
1474
- });
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();
1839
+ if (beforeDate && isNaN(beforeDate.getTime())) {
1840
+ res.status(400).json({ error: 'Invalid "before" date' });
1841
+ return;
1480
1842
  }
1481
- } catch (err) {
1482
- next(err);
1483
- }
1484
- });
1485
- return router;
1486
- }
1487
-
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;
1511
- }
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);
1521
- }
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;
1533
- }
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;
1550
- }
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;
1556
- }
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);
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
+ }
1613
1880
  }
1614
- }
1615
- return isNaN(result.getTime()) ? null : result;
1616
- }
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) => {
1623
- 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 });
1881
+ res.json({ uids, ...relayResults ? { relayResults } : {} });
1626
1882
  } catch (err) {
1627
1883
  next(err);
1628
1884
  }
1629
1885
  });
1630
- router.post("/contacts", requireAgent, async (req, res, next) => {
1886
+ router.post("/mail/import-relay", requireAgent, async (req, res, next) => {
1631
1887
  try {
1632
- const { name: name2, email, notes } = req.body || {};
1633
- if (!email) {
1634
- res.status(400).json({ error: "email 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" });
1635
1891
  return;
1636
1892
  }
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 });
1893
+ if (!gatewayManager) {
1894
+ res.status(400).json({ error: "No gateway configured" });
1895
+ return;
1896
+ }
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." });
1640
1904
  } catch (err) {
1641
1905
  next(err);
1642
1906
  }
1643
1907
  });
1644
- router.delete("/contacts/:id", requireAgent, async (req, res, next) => {
1908
+ router.post("/mail/messages/:uid/seen", requireAgent, async (req, res, next) => {
1645
1909
  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" });
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" });
1649
1914
  return;
1650
1915
  }
1916
+ const password = getAgentPassword(agent);
1917
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1918
+ await receiver.markSeen(uid);
1651
1919
  res.json({ ok: true });
1652
1920
  } catch (err) {
1653
1921
  next(err);
1654
1922
  }
1655
1923
  });
1656
- router.get("/drafts", requireAgent, async (req, res, next) => {
1657
- 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 });
1660
- } catch (err) {
1661
- next(err);
1662
- }
1663
- });
1664
- router.post("/drafts", requireAgent, async (req, res, next) => {
1665
- 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 });
1682
- } catch (err) {
1683
- next(err);
1684
- }
1685
- });
1686
- router.put("/drafts/:id", requireAgent, async (req, res, next) => {
1924
+ router.delete("/mail/messages/:uid", requireAgent, async (req, res, next) => {
1687
1925
  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" });
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" });
1705
1930
  return;
1706
1931
  }
1707
- res.json({ ok: true });
1932
+ const password = getAgentPassword(agent);
1933
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1934
+ await receiver.deleteMessage(uid);
1935
+ res.status(204).send();
1708
1936
  } catch (err) {
1709
1937
  next(err);
1710
1938
  }
1711
1939
  });
1712
- router.delete("/drafts/:id", requireAgent, async (req, res, next) => {
1940
+ router.post("/mail/messages/:uid/unseen", requireAgent, async (req, res, next) => {
1713
1941
  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" });
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" });
1717
1946
  return;
1718
1947
  }
1948
+ const password = getAgentPassword(agent);
1949
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1950
+ await receiver.markUnseen(uid);
1719
1951
  res.json({ ok: true });
1720
1952
  } catch (err) {
1721
1953
  next(err);
1722
1954
  }
1723
1955
  });
1724
- router.post("/drafts/:id/send", requireAgent, async (req, res, next) => {
1956
+ router.post("/mail/messages/:uid/move", requireAgent, async (req, res, next) => {
1725
1957
  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" });
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" });
1729
1962
  return;
1730
1963
  }
1731
- if (!draft.to_addr) {
1732
- res.status(400).json({ error: "Draft has no recipient" });
1964
+ const { from: fromFolder, to: toFolder } = req.body || {};
1965
+ if (!toFolder) {
1966
+ res.status(400).json({ error: "to (destination folder) is required" });
1733
1967
  return;
1734
1968
  }
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
- }
1754
1969
  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();
1768
- }
1970
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1971
+ await receiver.moveMessage(uid, fromFolder || "INBOX", toFolder);
1972
+ res.json({ ok: true });
1769
1973
  } catch (err) {
1770
1974
  next(err);
1771
1975
  }
1772
1976
  });
1773
- router.get("/signatures", requireAgent, async (req, res, next) => {
1977
+ router.get("/mail/folders", requireAgent, async (req, res, next) => {
1774
1978
  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 });
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 });
1777
1984
  } catch (err) {
1778
1985
  next(err);
1779
1986
  }
1780
1987
  });
1781
- router.post("/signatures", requireAgent, async (req, res, next) => {
1988
+ router.post("/mail/folders", requireAgent, async (req, res, next) => {
1782
1989
  try {
1783
- const { name: name2, text, html, isDefault } = req.body || {};
1784
- if (!name2) {
1990
+ const agent = req.agent;
1991
+ const { name: name2 } = req.body || {};
1992
+ if (!name2 || typeof name2 !== "string" || !name2.trim()) {
1785
1993
  res.status(400).json({ error: "name is required" });
1786
1994
  return;
1787
1995
  }
1788
- const id = uuidv4();
1789
- if (isDefault) {
1790
- db.prepare("UPDATE signatures SET is_default = 0 WHERE agent_id = ?").run(req.agent.id);
1996
+ if (name2.length > 200 || /[\\*%]/.test(name2)) {
1997
+ res.status(400).json({ error: "Invalid folder name" });
1998
+ return;
1791
1999
  }
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 });
2000
+ const password = getAgentPassword(agent);
2001
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2002
+ await receiver.createFolder(name2);
2003
+ res.json({ ok: true, folder: name2 });
1794
2004
  } catch (err) {
1795
2005
  next(err);
1796
2006
  }
1797
2007
  });
1798
- router.delete("/signatures/:id", requireAgent, async (req, res, next) => {
2008
+ router.get("/mail/folders/:folder", requireAgent, async (req, res, next) => {
1799
2009
  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" });
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 });
1803
2021
  return;
1804
2022
  }
1805
- res.json({ ok: true });
1806
- } catch (err) {
1807
- next(err);
1808
- }
1809
- });
1810
- router.get("/templates", requireAgent, async (req, res, next) => {
1811
- try {
1812
- const rows = db.prepare("SELECT * FROM templates WHERE agent_id = ? ORDER BY name").all(req.agent.id);
1813
- res.json({ templates: rows });
2023
+ const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
2024
+ res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder });
1814
2025
  } catch (err) {
1815
2026
  next(err);
1816
2027
  }
1817
2028
  });
1818
- router.post("/templates", 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) => {
1819
2036
  try {
1820
- const { name: name2, subject, text, html } = req.body || {};
1821
- if (!name2) {
1822
- res.status(400).json({ error: "name is required" });
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)" });
1823
2042
  return;
1824
2043
  }
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 });
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 });
1828
2048
  } catch (err) {
1829
2049
  next(err);
1830
2050
  }
1831
2051
  });
1832
- router.delete("/templates/:id", requireAgent, async (req, res, next) => {
2052
+ router.post("/mail/batch/seen", requireAgent, async (req, res, next) => {
1833
2053
  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" });
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)" });
1837
2059
  return;
1838
2060
  }
1839
- res.json({ ok: true });
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 });
1840
2065
  } catch (err) {
1841
2066
  next(err);
1842
2067
  }
1843
2068
  });
1844
- router.get("/scheduled", requireAgent, async (req, res, next) => {
2069
+ router.post("/mail/batch/unseen", requireAgent, async (req, res, next) => {
1845
2070
  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 });
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)" });
2076
+ return;
2077
+ }
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 });
1848
2082
  } catch (err) {
1849
2083
  next(err);
1850
2084
  }
1851
2085
  });
1852
- router.post("/scheduled", requireAgent, async (req, res, next) => {
2086
+ router.post("/mail/batch/move", requireAgent, async (req, res, next) => {
1853
2087
  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
- });
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)" });
1864
2093
  return;
1865
2094
  }
1866
- if (sendDate.getTime() <= Date.now()) {
1867
- res.status(400).json({ error: "sendAt must be in the future" });
2095
+ if (!toFolder) {
2096
+ res.status(400).json({ error: "to (destination folder) is required" });
1868
2097
  return;
1869
2098
  }
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() });
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 });
1874
2103
  } catch (err) {
1875
2104
  next(err);
1876
2105
  }
1877
2106
  });
1878
- router.delete("/scheduled/:id", requireAgent, async (req, res, next) => {
2107
+ router.post("/mail/batch/read", requireAgent, async (req, res, next) => {
1879
2108
  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" });
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)" });
1883
2114
  return;
1884
2115
  }
1885
- res.json({ ok: true });
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 });
2123
+ }
2124
+ res.json({ messages, count: messages.length });
1886
2125
  } catch (err) {
1887
2126
  next(err);
1888
2127
  }
1889
2128
  });
1890
- router.get("/tags", requireAgent, async (req, res, next) => {
2129
+ router.get("/mail/spam", requireAgent, async (req, res, next) => {
1891
2130
  try {
1892
- const rows = db.prepare("SELECT * FROM tags WHERE agent_id = ? ORDER BY name").all(req.agent.id);
1893
- res.json({ tags: rows });
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" });
1894
2145
  } catch (err) {
1895
2146
  next(err);
1896
2147
  }
1897
2148
  });
1898
- router.post("/tags", requireAgent, async (req, res, next) => {
2149
+ router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
1899
2150
  try {
1900
- const { name: name2, color } = req.body || {};
1901
- if (!name2) {
1902
- res.status(400).json({ error: "name is required" });
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" });
1903
2155
  return;
1904
2156
  }
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" });
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 });
1908
2166
  } catch (err) {
1909
2167
  next(err);
1910
2168
  }
1911
2169
  });
1912
- router.delete("/tags/:id", requireAgent, async (req, res, next) => {
2170
+ router.post("/mail/messages/:uid/not-spam", requireAgent, async (req, res, next) => {
1913
2171
  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" });
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" });
1917
2176
  return;
1918
2177
  }
1919
- res.json({ ok: true });
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 });
1920
2182
  } catch (err) {
1921
2183
  next(err);
1922
2184
  }
1923
2185
  });
1924
- router.post("/tags/:id/messages", requireAgent, async (req, res, next) => {
2186
+ router.get("/mail/messages/:uid/spam-score", requireAgent, async (req, res, next) => {
1925
2187
  try {
1926
- const { uid, folder } = req.body || {};
1927
- if (!uid) {
1928
- res.status(400).json({ error: "uid is required" });
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" });
1929
2192
  return;
1930
2193
  }
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" });
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 });
1934
2201
  return;
1935
2202
  }
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 });
2203
+ const result = scoreEmail2(parsed);
2204
+ res.json(result);
1938
2205
  } catch (err) {
1939
2206
  next(err);
1940
2207
  }
1941
2208
  });
1942
- router.delete("/tags/:id/messages/:uid", requireAgent, async (req, res, next) => {
2209
+ router.get("/mail/digest", requireAgent, async (req, res, next) => {
1943
2210
  try {
2211
+ const agent = req.agent;
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);
1944
2215
  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 });
1947
- } catch (err) {
1948
- next(err);
1949
- }
1950
- });
1951
- router.get("/tags/:id/messages", requireAgent, async (req, res, next) => {
1952
- 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" });
1956
- return;
2216
+ const password = getAgentPassword(agent);
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
+ });
1957
2240
  }
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 })) });
2241
+ res.json({ messages, count: messages.length, total: mailboxInfo.exists });
1962
2242
  } catch (err) {
1963
2243
  next(err);
1964
2244
  }
1965
2245
  });
1966
- router.get("/messages/:uid/tags", requireAgent, async (req, res, next) => {
1967
- 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 });
1975
- } catch (err) {
1976
- 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;
1977
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
+ });
1978
2287
  });
1979
- router.post("/templates/:id/send", requireAgent, async (req, res, next) => {
1980
- 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" });
2288
+ router.post("/mail/pending/:id/approve", requireMaster, async (req, res, next) => {
2289
+ try {
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" });
1984
2295
  return;
1985
2296
  }
1986
- const { to, variables, cc, bcc } = req.body || {};
1987
- if (!to) {
1988
- res.status(400).json({ error: "to is required" });
2297
+ if (row.status !== "pending") {
2298
+ res.status(400).json({ error: `Email already ${row.status}` });
1989
2299
  return;
1990
2300
  }
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
- };
2001
- const agent = req.agent;
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;
2305
+ }
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);
2313
+ }
2314
+ }
2315
+ }
2316
+ const password = getAgentPassword(agent);
2317
+ let response;
2002
2318
  if (gatewayManager) {
2003
2319
  const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
2004
2320
  if (gatewayResult) {
2005
- res.json(gatewayResult);
2006
- return;
2321
+ if (gatewayResult.raw) {
2322
+ saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
2323
+ }
2324
+ const { raw: _raw, ...rest } = gatewayResult;
2325
+ response = rest;
2007
2326
  }
2008
2327
  }
2009
- 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 {
2328
+ if (!response) {
2329
+ const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
2018
2330
  const result = await sender.send(mailOpts);
2019
- res.json(result);
2020
- } finally {
2021
- sender.close();
2022
- }
2023
- } catch (err) {
2024
- next(err);
2025
- }
2026
- });
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);
2033
- }
2034
- });
2035
- router.post("/rules", requireAgent, async (req, res, next) => {
2036
- try {
2037
- const { name: name2, conditions, actions, priority, enabled } = req.body || {};
2038
- if (!name2) {
2039
- res.status(400).json({ error: "name is required" });
2040
- return;
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;
2041
2345
  }
2042
- const id = uuidv4();
2043
2346
  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 });
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 });
2047
2350
  } catch (err) {
2048
2351
  next(err);
2049
2352
  }
2050
2353
  });
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" });
2056
- return;
2057
- }
2058
- res.json({ ok: true });
2059
- } catch (err) {
2060
- next(err);
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;
2361
+ }
2362
+ if (row.status !== "pending") {
2363
+ res.status(400).json({ error: `Email already ${row.status}` });
2364
+ return;
2061
2365
  }
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 });
2062
2370
  });
2063
2371
  return router;
2064
2372
  }
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;
2081
- }
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();
2131
- }
2132
- } catch (err) {
2133
- db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run(err.message, row.id);
2134
- }
2135
- }
2136
- try {
2137
- db.prepare("DELETE FROM delivered_messages WHERE delivered_at < datetime('now', '-30 days')").run();
2138
- } catch {
2139
- }
2140
- try {
2141
- db.prepare("DELETE FROM spam_log WHERE created_at < datetime('now', '-30 days')").run();
2142
- } catch {
2143
- }
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 {
2162
- }
2163
- }
2164
- return true;
2165
- }
2166
- function broadcastEvent(event) {
2167
- const data = `data: ${JSON.stringify(event)}
2168
2373
 
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
- }
2178
- }
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
- }
2193
- }
2194
- }
2195
- activeWatchers.clear();
2196
- }
2197
- function createEventRoutes(accountManager, config, db) {
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})`;
@@ -3492,7 +3571,40 @@ function buildWhereClause(where) {
3492
3571
  function nowExpr(dialect) {
3493
3572
  return dialect === "postgres" ? "NOW()" : "datetime('now')";
3494
3573
  }
3495
- function createStorageRoutes(db, accountManager, config, dialect = "sqlite") {
3574
+ function adaptBetterSqlite(raw) {
3575
+ if (raw && typeof raw.run === "function" && typeof raw.get === "function" && typeof raw.all === "function") {
3576
+ return raw;
3577
+ }
3578
+ const exec = (sql, params) => {
3579
+ if (!params || params.length === 0) {
3580
+ raw.exec(sql);
3581
+ return;
3582
+ }
3583
+ raw.prepare(sql).run(...params);
3584
+ };
3585
+ return {
3586
+ run(sql, params) {
3587
+ const trimmed = sql.trim().toUpperCase();
3588
+ const isDDL = trimmed.startsWith("CREATE") || trimmed.startsWith("ALTER") || trimmed.startsWith("DROP") || trimmed.startsWith("PRAGMA");
3589
+ if (isDDL && (!params || params.length === 0)) {
3590
+ raw.exec(sql);
3591
+ return;
3592
+ }
3593
+ exec(sql, params);
3594
+ },
3595
+ get(sql, params) {
3596
+ const stmt = raw.prepare(sql);
3597
+ return params && params.length > 0 ? stmt.get(...params) : stmt.get();
3598
+ },
3599
+ all(sql, params) {
3600
+ const stmt = raw.prepare(sql);
3601
+ const rows = params && params.length > 0 ? stmt.all(...params) : stmt.all();
3602
+ return rows;
3603
+ }
3604
+ };
3605
+ }
3606
+ function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite") {
3607
+ const db = adaptBetterSqlite(rawDb);
3496
3608
  const router = Router11();
3497
3609
  function getAgent(req, res) {
3498
3610
  const agent = req.agent;