@agenticmail/api 0.5.58 → 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 +1609 -1530
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -310,7 +310,7 @@ function createAccountRoutes(accountManager, db, config) {
310
310
  } catch (err) {
311
311
  const msg = err.message ?? "";
312
312
  if (msg.includes("UNIQUE") || msg.includes("unique") || msg.includes("already exists") || msg.includes("duplicate") || msg.includes("fieldAlreadyExists") || msg.toLowerCase().includes("alreadyexists")) {
313
- res.status(409).json({ error: `Agent "${name}" already exists` });
313
+ res.status(409).json({ error: "Account already exists", name });
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) {
1414
1472
  try {
1415
- const secret = req.headers["x-inbound-secret"];
1416
- if (secret !== INBOUND_SECRET) {
1417
- res.status(401).json({ error: "Invalid inbound secret" });
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" });
1418
1606
  return;
1419
1607
  }
1420
- const { from, to, subject, rawEmail } = req.body;
1421
- if (!to || !rawEmail) {
1422
- res.status(400).json({ error: "to and rawEmail are required" });
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
+ });
1423
1771
  return;
1424
1772
  }
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}` });
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) => {
1794
+ try {
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" });
1431
1800
  return;
1432
1801
  }
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" });
1802
+ if (isNaN(index) || index < 0) {
1803
+ res.status(400).json({ error: "Invalid attachment index" });
1437
1804
  return;
1438
1805
  }
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 });
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" });
1445
1813
  return;
1446
1814
  }
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();
1480
- }
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);
1481
1820
  } catch (err) {
1482
1821
  next(err);
1483
1822
  }
1484
1823
  });
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);
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" });
1828
+ return;
1829
+ }
1830
+ const agent = req.agent;
1831
+ const { from, to, subject, since, before, seen, text, searchRelay } = req.body;
1832
+ const password = getAgentPassword(agent);
1833
+ const sinceDate = since ? new Date(since) : void 0;
1834
+ const beforeDate = before ? new Date(before) : void 0;
1835
+ if (sinceDate && isNaN(sinceDate.getTime())) {
1836
+ res.status(400).json({ error: 'Invalid "since" date' });
1837
+ return;
1838
+ }
1839
+ if (beforeDate && isNaN(beforeDate.getTime())) {
1840
+ res.status(400).json({ error: 'Invalid "before" date' });
1841
+ return;
1842
+ }
1843
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
1844
+ const uids = await receiver.search({
1845
+ from,
1846
+ to,
1847
+ subject,
1848
+ since: sinceDate,
1849
+ before: beforeDate,
1850
+ seen,
1851
+ text
1852
+ });
1853
+ let relayResults;
1854
+ if (searchRelay === true && gatewayManager) {
1855
+ try {
1856
+ const relayHits = await gatewayManager.searchRelay({
1857
+ from,
1858
+ to,
1859
+ subject,
1860
+ text,
1861
+ since: sinceDate,
1862
+ before: beforeDate,
1863
+ seen
1864
+ });
1865
+ if (relayHits.length > 0) {
1866
+ relayResults = relayHits.map((r) => ({
1867
+ uid: r.uid,
1868
+ source: r.source,
1869
+ account: r.account,
1870
+ messageId: r.messageId,
1871
+ subject: r.subject,
1872
+ from: r.from,
1873
+ to: r.to,
1874
+ date: r.date,
1875
+ flags: r.flags
1876
+ }));
1877
+ }
1878
+ } catch {
1879
+ }
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 });
1886
- } catch (err) {
1887
- next(err);
1888
- }
1889
- });
1890
- router.get("/tags", requireAgent, async (req, res, next) => {
1891
- try {
1892
- const rows = db.prepare("SELECT * FROM tags WHERE agent_id = ? ORDER BY name").all(req.agent.id);
1893
- res.json({ tags: rows });
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 });
1894
2125
  } catch (err) {
1895
2126
  next(err);
1896
2127
  }
1897
2128
  });
1898
- router.post("/tags", requireAgent, async (req, res, next) => {
2129
+ router.get("/mail/spam", requireAgent, async (req, res, next) => {
1899
2130
  try {
1900
- const { name: name2, color } = req.body || {};
1901
- if (!name2) {
1902
- res.status(400).json({ error: "name is required" });
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" });
1903
2141
  return;
1904
2142
  }
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" });
2143
+ const envelopes = await receiver.listEnvelopes("Spam", { limit, offset });
2144
+ res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder: "Spam" });
1908
2145
  } catch (err) {
1909
2146
  next(err);
1910
2147
  }
1911
2148
  });
1912
- router.delete("/tags/:id", requireAgent, async (req, res, next) => {
2149
+ router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
1913
2150
  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" });
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" });
1917
2155
  return;
1918
2156
  }
1919
- res.json({ ok: true });
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 });
1920
2166
  } catch (err) {
1921
2167
  next(err);
1922
2168
  }
1923
2169
  });
1924
- router.post("/tags/:id/messages", requireAgent, async (req, res, next) => {
2170
+ router.post("/mail/messages/:uid/not-spam", requireAgent, async (req, res, next) => {
1925
2171
  try {
1926
- const { uid, folder } = req.body || {};
1927
- if (!uid) {
1928
- res.status(400).json({ error: "uid is required" });
1929
- return;
1930
- }
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" });
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" });
1934
2176
  return;
1935
2177
  }
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 });
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 });
1938
2182
  } catch (err) {
1939
2183
  next(err);
1940
2184
  }
1941
2185
  });
1942
- router.delete("/tags/:id/messages/:uid", requireAgent, async (req, res, next) => {
2186
+ router.get("/mail/messages/:uid/spam-score", requireAgent, async (req, res, next) => {
1943
2187
  try {
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" });
2192
+ return;
2193
+ }
1944
2194
  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" });
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 });
1956
2201
  return;
1957
2202
  }
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 })) });
1962
- } catch (err) {
1963
- next(err);
1964
- }
1965
- });
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 });
2203
+ const result = scoreEmail2(parsed);
2204
+ res.json(result);
1975
2205
  } catch (err) {
1976
2206
  next(err);
1977
2207
  }
1978
2208
  });
1979
- router.post("/templates/:id/send", requireAgent, async (req, res, next) => {
2209
+ router.get("/mail/digest", requireAgent, async (req, res, next) => {
1980
2210
  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" });
1984
- return;
1985
- }
1986
- const { to, variables, cc, bcc } = req.body || {};
1987
- if (!to) {
1988
- res.status(400).json({ error: "to is required" });
1989
- return;
1990
- }
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
2211
  const agent = req.agent;
2002
- if (gatewayManager) {
2003
- const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
2004
- if (gatewayResult) {
2005
- res.json(gatewayResult);
2006
- return;
2007
- }
2008
- }
2212
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 50);
2213
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
2214
+ const previewLen = Math.min(Math.max(parseInt(req.query.previewLength) || 200, 50), 500);
2215
+ const folder = req.query.folder || "INBOX";
2009
2216
  const password = getAgentPassword(agent);
2010
- const sender = new MailSender3({
2011
- host: config.smtp.host,
2012
- port: config.smtp.port,
2013
- email: agent.email,
2014
- password,
2015
- authUser: agent.stalwartPrincipal
2016
- });
2017
- try {
2018
- const result = await sender.send(mailOpts);
2019
- res.json(result);
2020
- } finally {
2021
- sender.close();
2217
+ const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
2218
+ const mailboxInfo = await receiver.getMailboxInfo(folder);
2219
+ const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
2220
+ const uids = envelopes.map((e) => e.uid);
2221
+ const rawMap = uids.length > 0 ? await receiver.batchFetch(uids, folder) : /* @__PURE__ */ new Map();
2222
+ const messages = [];
2223
+ for (const env of envelopes) {
2224
+ let preview = "";
2225
+ const raw = rawMap.get(env.uid);
2226
+ if (raw) {
2227
+ const parsed = await parseEmail2(raw);
2228
+ preview = (parsed.text || "").slice(0, previewLen);
2229
+ }
2230
+ messages.push({
2231
+ uid: env.uid,
2232
+ subject: env.subject,
2233
+ from: env.from,
2234
+ to: env.to,
2235
+ date: env.date,
2236
+ flags: [...env.flags],
2237
+ size: env.size,
2238
+ preview
2239
+ });
2022
2240
  }
2241
+ res.json({ messages, count: messages.length, total: mailboxInfo.exists });
2023
2242
  } catch (err) {
2024
2243
  next(err);
2025
2244
  }
2026
2245
  });
2027
- router.get("/rules", requireAgent, async (req, res, next) => {
2028
- try {
2029
- const rows = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? ORDER BY priority DESC, created_at").all(req.agent.id);
2030
- res.json({ rules: rows.map((r) => ({ ...r, conditions: JSON.parse(r.conditions), actions: JSON.parse(r.actions) })) });
2031
- } catch (err) {
2032
- next(err);
2246
+ router.get("/mail/pending", requireAuth, async (req, res) => {
2247
+ const rows = req.isMaster ? db.prepare(
2248
+ `SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
2249
+ FROM pending_outbound ORDER BY created_at DESC LIMIT 50`
2250
+ ).all() : db.prepare(
2251
+ `SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
2252
+ FROM pending_outbound WHERE agent_id = ? ORDER BY created_at DESC LIMIT 50`
2253
+ ).all(req.agent.id);
2254
+ const pending = rows.map((r) => {
2255
+ const opts = JSON.parse(r.mail_options);
2256
+ return {
2257
+ id: r.id,
2258
+ agentId: r.agent_id,
2259
+ to: opts.to,
2260
+ subject: opts.subject,
2261
+ warnings: JSON.parse(r.warnings),
2262
+ summary: r.summary,
2263
+ status: r.status,
2264
+ createdAt: r.created_at,
2265
+ resolvedAt: r.resolved_at,
2266
+ resolvedBy: r.resolved_by
2267
+ };
2268
+ });
2269
+ res.json({ pending, count: pending.length });
2270
+ });
2271
+ router.get("/mail/pending/:id", requireAuth, async (req, res) => {
2272
+ const row = req.isMaster ? db.prepare(`SELECT * FROM pending_outbound WHERE id = ?`).get(req.params.id) : db.prepare(`SELECT * FROM pending_outbound WHERE id = ? AND agent_id = ?`).get(req.params.id, req.agent.id);
2273
+ if (!row) {
2274
+ res.status(404).json({ error: "Pending email not found" });
2275
+ return;
2033
2276
  }
2277
+ res.json({
2278
+ id: row.id,
2279
+ mailOptions: JSON.parse(row.mail_options),
2280
+ warnings: JSON.parse(row.warnings),
2281
+ summary: row.summary,
2282
+ status: row.status,
2283
+ createdAt: row.created_at,
2284
+ resolvedAt: row.resolved_at,
2285
+ resolvedBy: row.resolved_by
2286
+ });
2034
2287
  });
2035
- router.post("/rules", requireAgent, async (req, res, next) => {
2288
+ router.post("/mail/pending/:id/approve", requireMaster, async (req, res, next) => {
2036
2289
  try {
2037
- const { name: name2, conditions, actions, priority, enabled } = req.body || {};
2038
- if (!name2) {
2039
- res.status(400).json({ error: "name is required" });
2290
+ const row = db.prepare(
2291
+ `SELECT * FROM pending_outbound WHERE id = ?`
2292
+ ).get(req.params.id);
2293
+ if (!row) {
2294
+ res.status(404).json({ error: "Pending email not found" });
2040
2295
  return;
2041
2296
  }
2042
- const id = uuidv4();
2043
- db.prepare(
2044
- "INSERT INTO email_rules (id, agent_id, name, priority, enabled, conditions, actions) VALUES (?, ?, ?, ?, ?, ?, ?)"
2045
- ).run(id, req.agent.id, name2, priority ?? 0, enabled !== false ? 1 : 0, JSON.stringify(conditions || {}), JSON.stringify(actions || {}));
2046
- res.status(201).json({ id, name: name2, conditions: conditions || {}, actions: actions || {}, priority: priority ?? 0, enabled: enabled !== false });
2047
- } catch (err) {
2048
- next(err);
2049
- }
2050
- });
2051
- router.delete("/rules/:id", requireAgent, async (req, res, next) => {
2052
- try {
2053
- const result = db.prepare("DELETE FROM email_rules WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
2054
- if (result.changes === 0) {
2055
- res.status(404).json({ error: "Rule not found" });
2297
+ if (row.status !== "pending") {
2298
+ res.status(400).json({ error: `Email already ${row.status}` });
2056
2299
  return;
2057
2300
  }
2058
- res.json({ ok: true });
2059
- } catch (err) {
2060
- next(err);
2061
- }
2062
- });
2063
- return router;
2064
- }
2065
- function evaluateRules(db, agentId, email) {
2066
- const rules = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? AND enabled = 1 ORDER BY priority DESC").all(agentId);
2067
- for (const rule of rules) {
2068
- const cond = JSON.parse(rule.conditions);
2069
- let match = true;
2070
- const fromAddr = (email.from?.[0]?.address ?? "").toLowerCase();
2071
- const toAddr = (email.to?.[0]?.address ?? "").toLowerCase();
2072
- const subject = (email.subject ?? "").toLowerCase();
2073
- if (cond.from_contains && !fromAddr.includes(cond.from_contains.toLowerCase())) match = false;
2074
- if (cond.from_exact && fromAddr !== cond.from_exact.toLowerCase()) match = false;
2075
- if (cond.subject_contains && !subject.includes(cond.subject_contains.toLowerCase())) match = false;
2076
- if (cond.subject_regex) {
2077
- try {
2078
- if (!new RegExp(cond.subject_regex, "i").test(email.subject ?? "")) match = false;
2079
- } catch {
2080
- match = false;
2301
+ const agent = await accountManager.getById(row.agent_id);
2302
+ if (!agent) {
2303
+ res.status(404).json({ error: "Agent account no longer exists" });
2304
+ return;
2081
2305
  }
2082
- }
2083
- if (cond.to_contains && !toAddr.includes(cond.to_contains.toLowerCase())) match = false;
2084
- if (cond.has_attachment === true && (!email.attachments || email.attachments.length === 0)) match = false;
2085
- if (match) return { ruleId: rule.id, actions: JSON.parse(rule.actions) };
2086
- }
2087
- return null;
2088
- }
2089
- function startScheduledSender(db, accountManager, config, gatewayManager) {
2090
- return setInterval(async () => {
2091
- try {
2092
- const now = (/* @__PURE__ */ new Date()).toISOString();
2093
- const pending = db.prepare(
2094
- "SELECT * FROM scheduled_emails WHERE status = 'pending' AND send_at <= ?"
2095
- ).all(now);
2096
- for (const row of pending) {
2097
- try {
2098
- const agent = await accountManager.getById(row.agent_id);
2099
- if (!agent) {
2100
- db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run("Agent not found", row.id);
2101
- continue;
2102
- }
2103
- const mailOpts = {
2104
- to: row.to_addr,
2105
- subject: row.subject,
2106
- text: row.text_body || void 0,
2107
- html: row.html_body || void 0,
2108
- cc: row.cc || void 0,
2109
- bcc: row.bcc || void 0
2110
- };
2111
- if (gatewayManager) {
2112
- const gResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
2113
- if (gResult) {
2114
- db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
2115
- continue;
2116
- }
2117
- }
2118
- const password = agent.metadata?._password || agent.name;
2119
- const sender = new MailSender3({
2120
- host: config.smtp.host,
2121
- port: config.smtp.port,
2122
- email: agent.email,
2123
- password,
2124
- authUser: agent.stalwartPrincipal
2125
- });
2126
- try {
2127
- await sender.send(mailOpts);
2128
- db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
2129
- } finally {
2130
- sender.close();
2306
+ const mailOpts = JSON.parse(row.mail_options);
2307
+ const ownerName = agent.metadata?.ownerName;
2308
+ mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
2309
+ if (Array.isArray(mailOpts.attachments)) {
2310
+ for (const att of mailOpts.attachments) {
2311
+ if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
2312
+ att.content = Buffer.from(att.content.data);
2131
2313
  }
2132
- } catch (err) {
2133
- db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run(err.message, row.id);
2134
2314
  }
2135
2315
  }
2136
- try {
2137
- db.prepare("DELETE FROM delivered_messages WHERE delivered_at < datetime('now', '-30 days')").run();
2138
- } catch {
2316
+ const password = getAgentPassword(agent);
2317
+ let response;
2318
+ if (gatewayManager) {
2319
+ const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
2320
+ if (gatewayResult) {
2321
+ if (gatewayResult.raw) {
2322
+ saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
2323
+ }
2324
+ const { raw: _raw, ...rest } = gatewayResult;
2325
+ response = rest;
2326
+ }
2139
2327
  }
2140
- try {
2141
- db.prepare("DELETE FROM spam_log WHERE created_at < datetime('now', '-30 days')").run();
2142
- } catch {
2328
+ if (!response) {
2329
+ const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
2330
+ const result = await sender.send(mailOpts);
2331
+ saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
2332
+ notifyLocalRecipientsOfNewMail(
2333
+ accountManager,
2334
+ mailOpts.to,
2335
+ mailOpts.cc,
2336
+ mailOpts.bcc,
2337
+ agent,
2338
+ mailOpts.subject,
2339
+ result.messageId
2340
+ ).catch((err) => {
2341
+ console.warn(`[mail] Internal SSE notify (approve) failed: ${err.message}`);
2342
+ });
2343
+ const { raw: _raw, ...rest } = result;
2344
+ response = rest;
2143
2345
  }
2144
- } catch {
2145
- }
2146
- }, 3e4);
2147
- }
2148
-
2149
- // src/routes/events.ts
2150
- var MAX_SSE_PER_AGENT = 5;
2151
- var activeWatchers = /* @__PURE__ */ new Map();
2152
- function pushEventToAgent(agentId, event) {
2153
- const watchers = activeWatchers.get(agentId);
2154
- if (!watchers || watchers.size === 0) return false;
2155
- const data = `data: ${JSON.stringify(event)}
2156
-
2157
- `;
2158
- for (const entry of watchers) {
2159
- try {
2160
- entry.res.write(data);
2161
- } catch {
2346
+ db.prepare(
2347
+ `UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
2348
+ ).run("master", row.id);
2349
+ res.json({ ...response, approved: true, pendingId: row.id });
2350
+ } catch (err) {
2351
+ next(err);
2162
2352
  }
2163
- }
2164
- return true;
2165
- }
2166
- function broadcastEvent(event) {
2167
- const data = `data: ${JSON.stringify(event)}
2168
-
2169
- `;
2170
- let count = 0;
2171
- for (const [, watchers] of activeWatchers) {
2172
- for (const entry of watchers) {
2173
- try {
2174
- entry.res.write(data);
2175
- count++;
2176
- } catch {
2177
- }
2353
+ });
2354
+ router.post("/mail/pending/:id/reject", requireMaster, async (req, res) => {
2355
+ const row = db.prepare(
2356
+ `SELECT * FROM pending_outbound WHERE id = ?`
2357
+ ).get(req.params.id);
2358
+ if (!row) {
2359
+ res.status(404).json({ error: "Pending email not found" });
2360
+ return;
2178
2361
  }
2179
- }
2180
- return count;
2181
- }
2182
- async function closeAllWatchers() {
2183
- for (const [, watchers] of activeWatchers) {
2184
- for (const entry of watchers) {
2185
- try {
2186
- await entry.watcher.stop();
2187
- } catch {
2188
- }
2189
- try {
2190
- entry.res.end();
2191
- } catch {
2192
- }
2362
+ if (row.status !== "pending") {
2363
+ res.status(400).json({ error: `Email already ${row.status}` });
2364
+ return;
2193
2365
  }
2194
- }
2195
- activeWatchers.clear();
2366
+ db.prepare(
2367
+ `UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
2368
+ ).run("master", row.id);
2369
+ res.json({ ok: true, rejected: true, pendingId: row.id });
2370
+ });
2371
+ return router;
2196
2372
  }
2197
- function createEventRoutes(accountManager, config, db) {
2373
+
2374
+ // src/routes/inbound.ts
2375
+ import { Router as Router6 } from "express";
2376
+ import { randomUUID } from "crypto";
2377
+ import {
2378
+ parseEmail as parseEmail3,
2379
+ MailSender as MailSender3
2380
+ } from "@agenticmail/core";
2381
+ var INBOUND_SECRET = process.env.AGENTICMAIL_INBOUND_SECRET || (() => {
2382
+ const generated = randomUUID();
2383
+ console.warn("[Inbound] WARNING: AGENTICMAIL_INBOUND_SECRET is not set. Generated a random secret for this session.");
2384
+ console.warn(`[Inbound] Set AGENTICMAIL_INBOUND_SECRET="${generated}" in your environment to persist it across restarts.`);
2385
+ return generated;
2386
+ })();
2387
+ var DEBUG = () => !!process.env.AGENTICMAIL_DEBUG;
2388
+ function createInboundRoutes(accountManager, config, gatewayManager) {
2198
2389
  const router = Router6();
2199
- router.get("/events", requireAgent, async (req, res, next) => {
2390
+ router.post("/mail/inbound", async (req, res, next) => {
2200
2391
  try {
2201
- const agent = req.agent;
2202
- const password = getAgentPassword(agent);
2203
- const agentWatchers = activeWatchers.get(agent.id) ?? /* @__PURE__ */ new Set();
2204
- if (agentWatchers.size >= MAX_SSE_PER_AGENT) {
2205
- res.status(429).json({ error: `Maximum ${MAX_SSE_PER_AGENT} concurrent SSE connections per agent` });
2392
+ const secret = req.headers["x-inbound-secret"];
2393
+ if (secret !== INBOUND_SECRET) {
2394
+ res.status(401).json({ error: "Invalid inbound secret" });
2206
2395
  return;
2207
2396
  }
2208
- const watcher = new InboxWatcher({
2209
- host: config.imap.host,
2210
- port: config.imap.port,
2211
- email: agent.stalwartPrincipal,
2212
- password,
2213
- autoReconnect: true,
2214
- maxReconnectAttempts: 20
2215
- });
2216
- try {
2217
- await watcher.start();
2218
- } catch (err) {
2219
- res.status(500).json({ error: "Failed to start event stream: " + (err instanceof Error ? err.message : String(err)) });
2397
+ const { from, to, subject, rawEmail } = req.body;
2398
+ if (!to || !rawEmail) {
2399
+ res.status(400).json({ error: "to and rawEmail are required" });
2220
2400
  return;
2221
2401
  }
2222
- res.setHeader("Content-Type", "text/event-stream");
2223
- res.setHeader("Cache-Control", "no-cache");
2224
- res.setHeader("Connection", "keep-alive");
2225
- res.setHeader("X-Accel-Buffering", "no");
2226
- res.flushHeaders();
2227
- let closed = false;
2228
- const entry = { watcher, res };
2229
- activeWatchers.set(agent.id, agentWatchers);
2230
- agentWatchers.add(entry);
2231
- const safeWrite = (data) => {
2232
- if (!closed) {
2233
- try {
2234
- res.write(data);
2235
- } catch {
2236
- }
2237
- }
2238
- };
2239
- safeWrite(`data: ${JSON.stringify({ type: "connected", agentId: agent.id })}
2240
-
2241
- `);
2242
- watcher.on("new", async (event) => {
2243
- if (db) touchActivity(db, agent.id);
2244
- if (db && event.uid) {
2245
- try {
2246
- const receiver = new MailReceiver2({
2247
- host: config.imap.host,
2248
- port: config.imap.port,
2249
- email: agent.stalwartPrincipal,
2250
- password,
2251
- secure: false
2252
- });
2253
- await receiver.connect();
2254
- try {
2255
- const raw = await receiver.fetchMessage(event.uid);
2256
- const parsed = await parseEmail3(raw);
2257
- const policyMetadata = agent.metadata && typeof agent.metadata === "object" ? {
2258
- emailRoutePolicy: agent.metadata.emailRoutePolicy,
2259
- routePolicy: agent.metadata.routePolicy,
2260
- mailboxPolicy: agent.metadata.mailboxPolicy
2261
- } : void 0;
2262
- const accountRouteContext = {
2263
- name: agent.name,
2264
- email: agent.email,
2265
- role: agent.role,
2266
- metadata: policyMetadata
2267
- };
2268
- const isRelay = !!parsed.headers.get("x-agenticmail-relay");
2269
- const internal = !isRelay && isInternalEmail2(parsed);
2270
- if (internal) {
2271
- event.route = classifyEmailRoute({ email: parsed, account: accountRouteContext });
2272
- const ruleResult2 = evaluateRules(db, agent.id, parsed);
2273
- if (ruleResult2) {
2274
- const actions = ruleResult2.actions;
2275
- if (actions.mark_read) await receiver.markSeen(event.uid);
2276
- if (actions.delete) {
2277
- await receiver.deleteMessage(event.uid);
2278
- return;
2279
- }
2280
- if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
2281
- event.ruleApplied = { ruleId: ruleResult2.ruleId, actions };
2282
- }
2283
- safeWrite(`data: ${JSON.stringify(event)}
2284
-
2285
- `);
2286
- return;
2287
- }
2288
- const spamResult = scoreEmail2(parsed);
2289
- event.route = classifyEmailRoute({ email: parsed, spam: spamResult, account: accountRouteContext });
2290
- try {
2291
- db.prepare(
2292
- "INSERT INTO spam_log (id, agent_id, message_uid, score, flags, category, is_spam) VALUES (?, ?, ?, ?, ?, ?, ?)"
2293
- ).run(
2294
- uuidv42(),
2295
- agent.id,
2296
- event.uid,
2297
- spamResult.score,
2298
- JSON.stringify(spamResult.matches.map((m) => m.ruleId)),
2299
- spamResult.topCategory,
2300
- spamResult.isSpam ? 1 : 0
2301
- );
2302
- } catch {
2303
- }
2304
- if (spamResult.isSpam) {
2305
- try {
2306
- await receiver.createFolder("Spam");
2307
- } catch {
2308
- }
2309
- await receiver.moveMessage(event.uid, "INBOX", "Spam");
2310
- event.spam = { score: spamResult.score, category: spamResult.topCategory, movedToSpam: true };
2311
- safeWrite(`data: ${JSON.stringify(event)}
2312
-
2313
- `);
2314
- return;
2315
- }
2316
- if (spamResult.isWarning) {
2317
- event.spamWarning = { score: spamResult.score, category: spamResult.topCategory, matches: spamResult.matches.map((m) => m.ruleId) };
2318
- }
2319
- const ruleResult = evaluateRules(db, agent.id, parsed);
2320
- if (ruleResult) {
2321
- const actions = ruleResult.actions;
2322
- if (actions.mark_read) await receiver.markSeen(event.uid);
2323
- if (actions.delete) {
2324
- await receiver.deleteMessage(event.uid);
2325
- return;
2326
- }
2327
- if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
2328
- event.ruleApplied = { ruleId: ruleResult.ruleId, actions };
2329
- }
2330
- } finally {
2331
- await receiver.disconnect();
2332
- }
2333
- } catch (err) {
2334
- console.error("[SSE] Spam/rule evaluation error:", err.message);
2335
- }
2336
- }
2337
- safeWrite(`data: ${JSON.stringify(event)}
2338
-
2339
- `);
2340
- });
2341
- watcher.on("expunge", (event) => {
2342
- safeWrite(`data: ${JSON.stringify(event)}
2343
-
2344
- `);
2345
- });
2346
- watcher.on("flags", (event) => {
2347
- safeWrite(`data: ${JSON.stringify(event)}
2348
-
2349
- `);
2350
- });
2351
- watcher.on("error", (err) => {
2352
- safeWrite(`data: ${JSON.stringify({ type: "error", message: err.message })}
2353
-
2354
- `);
2355
- });
2356
- watcher.on("reconnecting", (info) => {
2357
- safeWrite(`data: ${JSON.stringify({ type: "reconnecting", attempt: info.attempt, delayMs: info.delayMs })}
2358
-
2359
- `);
2360
- });
2361
- watcher.on("reconnected", (info) => {
2362
- safeWrite(`data: ${JSON.stringify({ type: "reconnected", attempt: info.attempt })}
2363
-
2364
- `);
2365
- });
2366
- watcher.on("reconnect_failed", (info) => {
2367
- safeWrite(`data: ${JSON.stringify({ type: "reconnect_failed", attempts: info.attempts })}
2368
-
2369
- `);
2402
+ const recipientEmail = typeof to === "string" ? to : to[0];
2403
+ const localPart = recipientEmail.split("@")[0];
2404
+ const agent = await accountManager.getByName(localPart);
2405
+ if (!agent) {
2406
+ console.warn(`[Inbound] No agent found for "${localPart}" (${recipientEmail})`);
2407
+ res.status(404).json({ error: `No agent found for ${recipientEmail}` });
2408
+ return;
2409
+ }
2410
+ const agentPassword = agent.metadata?._password;
2411
+ if (!agentPassword) {
2412
+ console.warn(`[Inbound] No password for agent "${agent.name}"`);
2413
+ res.status(500).json({ error: "Agent has no password configured" });
2414
+ return;
2415
+ }
2416
+ const rawBuffer = Buffer.from(rawEmail, "base64");
2417
+ const parsed = await parseEmail3(rawBuffer);
2418
+ const originalMessageId = parsed.messageId;
2419
+ if (originalMessageId && gatewayManager?.isAlreadyDelivered(originalMessageId, agent.name)) {
2420
+ if (DEBUG()) console.log(`[Inbound] Skipping duplicate: ${originalMessageId} \u2192 ${agent.name}`);
2421
+ res.json({ ok: true, delivered: agent.email, duplicate: true });
2422
+ return;
2423
+ }
2424
+ if (DEBUG()) console.log(`[Inbound] Delivering email to ${agent.email} from ${from} (subject: ${subject || parsed.subject})`);
2425
+ const sender = new MailSender3({
2426
+ host: config.smtp.host,
2427
+ port: config.smtp.port,
2428
+ email: agent.email,
2429
+ password: agentPassword,
2430
+ authUser: agent.stalwartPrincipal
2370
2431
  });
2371
- const pingInterval = setInterval(() => {
2372
- safeWrite(`: ping
2373
-
2374
- `);
2375
- }, 3e4);
2376
- req.on("close", () => {
2377
- closed = true;
2378
- clearInterval(pingInterval);
2379
- agentWatchers.delete(entry);
2380
- if (agentWatchers.size === 0) activeWatchers.delete(agent.id);
2381
- watcher.removeAllListeners();
2382
- watcher.stop().catch((err) => {
2383
- console.error("[SSE] Watcher cleanup error:", err);
2432
+ try {
2433
+ await sender.send({
2434
+ to: agent.email,
2435
+ subject: parsed.subject || subject || "(no subject)",
2436
+ text: parsed.text || void 0,
2437
+ html: parsed.html || void 0,
2438
+ replyTo: from || parsed.from?.[0]?.address,
2439
+ inReplyTo: parsed.inReplyTo,
2440
+ references: parsed.references,
2441
+ headers: {
2442
+ "X-AgenticMail-Inbound": "cloudflare-worker",
2443
+ "X-Original-From": from || parsed.from?.[0]?.address || "",
2444
+ ...parsed.messageId ? { "X-Original-Message-Id": parsed.messageId } : {}
2445
+ },
2446
+ attachments: parsed.attachments?.map((a) => ({
2447
+ filename: a.filename,
2448
+ content: a.content,
2449
+ contentType: a.contentType
2450
+ }))
2384
2451
  });
2385
- });
2452
+ if (originalMessageId) gatewayManager?.recordDelivery(originalMessageId, agent.name);
2453
+ if (DEBUG()) console.log(`[Inbound] Delivered to ${agent.email}`);
2454
+ res.json({ ok: true, delivered: agent.email });
2455
+ } finally {
2456
+ sender.close();
2457
+ }
2386
2458
  } catch (err) {
2387
2459
  next(err);
2388
2460
  }
@@ -3402,7 +3474,14 @@ function buildColumnDDL(col, dialect) {
3402
3474
  if (col.required && !col.primaryKey) ddl += " NOT NULL";
3403
3475
  if (col.unique && !col.primaryKey) ddl += " UNIQUE";
3404
3476
  if (col.default !== void 0) {
3405
- const val = typeof col.default === "string" ? `'${col.default}'` : col.default;
3477
+ let val;
3478
+ if (typeof col.default === "string") {
3479
+ const trimmed = col.default.trim();
3480
+ const isSqlExpr = /\(.*\)/.test(trimmed) || /^CURRENT_(?:TIMESTAMP|DATE|TIME)$/i.test(trimmed);
3481
+ val = isSqlExpr ? trimmed : `'${col.default.replace(/'/g, "''")}'`;
3482
+ } else {
3483
+ val = col.default;
3484
+ }
3406
3485
  ddl += ` DEFAULT ${val}`;
3407
3486
  }
3408
3487
  if (col.check) ddl += ` CHECK (${col.check})`;