@agenticmail/api 0.5.56 → 0.5.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1643 -1531
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -309,8 +309,8 @@ function createAccountRoutes(accountManager, db, config) {
|
|
|
309
309
|
res.status(201).json(sanitizeAgent(agent));
|
|
310
310
|
} catch (err) {
|
|
311
311
|
const msg = err.message ?? "";
|
|
312
|
-
if (msg.includes("UNIQUE") || msg.includes("unique") || msg.includes("already exists") || msg.includes("duplicate")) {
|
|
313
|
-
res.status(409).json({ error:
|
|
312
|
+
if (msg.includes("UNIQUE") || msg.includes("unique") || msg.includes("already exists") || msg.includes("duplicate") || msg.includes("fieldAlreadyExists") || msg.toLowerCase().includes("alreadyexists")) {
|
|
313
|
+
res.status(409).json({ error: "Account already exists", name });
|
|
314
314
|
return;
|
|
315
315
|
}
|
|
316
316
|
next(err);
|
|
@@ -488,1901 +488,1973 @@ function createAccountRoutes(accountManager, db, config) {
|
|
|
488
488
|
}
|
|
489
489
|
|
|
490
490
|
// src/routes/mail.ts
|
|
491
|
-
import { Router as
|
|
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
|
-
|
|
511
|
+
classifyEmailRoute
|
|
501
512
|
} from "@agenticmail/core";
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
551
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
567
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
602
|
-
await receiver.connect();
|
|
603
|
-
} catch (err) {
|
|
659
|
+
router.delete("/contacts/:id", requireAgent, async (req, res, next) => {
|
|
604
660
|
try {
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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
|
-
|
|
627
|
-
for (const [, entry] of receiverCache) {
|
|
678
|
+
});
|
|
679
|
+
router.post("/drafts", requireAgent, async (req, res, next) => {
|
|
628
680
|
try {
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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
|
|
639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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 (
|
|
660
|
-
res.status(400).json({ error: "
|
|
746
|
+
if (!draft.to_addr) {
|
|
747
|
+
res.status(400).json({ error: "Draft has no recipient" });
|
|
661
748
|
return;
|
|
662
749
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
757
|
-
|
|
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
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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("/
|
|
788
|
+
router.get("/signatures", requireAgent, async (req, res, next) => {
|
|
774
789
|
try {
|
|
775
|
-
const
|
|
776
|
-
|
|
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.
|
|
796
|
+
router.post("/signatures", requireAgent, async (req, res, next) => {
|
|
788
797
|
try {
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
808
|
-
|
|
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.
|
|
813
|
+
router.delete("/signatures/:id", requireAgent, async (req, res, next) => {
|
|
828
814
|
try {
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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.
|
|
825
|
+
router.get("/templates", requireAgent, async (req, res, next) => {
|
|
859
826
|
try {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const
|
|
869
|
-
if (
|
|
870
|
-
res.status(400).json({ error:
|
|
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
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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.
|
|
859
|
+
router.get("/scheduled", requireAgent, async (req, res, next) => {
|
|
921
860
|
try {
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
928
|
-
|
|
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
|
-
|
|
932
|
-
|
|
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
|
-
|
|
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.
|
|
893
|
+
router.delete("/scheduled/:id", requireAgent, async (req, res, next) => {
|
|
943
894
|
try {
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
|
|
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.
|
|
905
|
+
router.get("/tags", requireAgent, async (req, res, next) => {
|
|
959
906
|
try {
|
|
960
|
-
const
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
|
967
|
-
|
|
968
|
-
|
|
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.
|
|
927
|
+
router.delete("/tags/:id", requireAgent, async (req, res, next) => {
|
|
975
928
|
try {
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
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("/
|
|
939
|
+
router.post("/tags/:id/messages", requireAgent, async (req, res, next) => {
|
|
991
940
|
try {
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
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
|
|
999
|
-
if (!
|
|
1000
|
-
res.status(
|
|
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
|
-
|
|
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.
|
|
957
|
+
router.delete("/tags/:id/messages/:uid", requireAgent, async (req, res, next) => {
|
|
1012
958
|
try {
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
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.
|
|
966
|
+
router.get("/tags/:id/messages", requireAgent, async (req, res, next) => {
|
|
1023
967
|
try {
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
res.json({
|
|
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("/
|
|
981
|
+
router.get("/messages/:uid/tags", requireAgent, async (req, res, next) => {
|
|
1043
982
|
try {
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
|
1097
|
-
|
|
1098
|
-
|
|
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.
|
|
1042
|
+
router.get("/rules", requireAgent, async (req, res, next) => {
|
|
1104
1043
|
try {
|
|
1105
|
-
const
|
|
1106
|
-
|
|
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("/
|
|
1050
|
+
router.post("/rules", requireAgent, async (req, res, next) => {
|
|
1121
1051
|
try {
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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.
|
|
1066
|
+
router.delete("/rules/:id", requireAgent, async (req, res, next) => {
|
|
1142
1067
|
try {
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
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
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1093
|
+
if (!new RegExp(cond.subject_regex, "i").test(email.subject ?? "")) match = false;
|
|
1173
1094
|
} catch {
|
|
1174
|
-
|
|
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
|
-
|
|
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
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|
-
|
|
1152
|
+
db.prepare("DELETE FROM delivered_messages WHERE delivered_at < datetime('now', '-30 days')").run();
|
|
1196
1153
|
} catch {
|
|
1197
1154
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
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
|
|
1252
|
-
|
|
1253
|
-
|
|
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
|
|
1336
|
-
|
|
1337
|
-
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1256
|
+
`);
|
|
1257
|
+
watcher.on("new", async (event) => {
|
|
1258
|
+
if (db) touchActivity(db, agent.id);
|
|
1259
|
+
if (db && event.uid) {
|
|
1260
|
+
try {
|
|
1261
|
+
const receiver = new MailReceiver({
|
|
1262
|
+
host: config.imap.host,
|
|
1263
|
+
port: config.imap.port,
|
|
1264
|
+
email: agent.stalwartPrincipal,
|
|
1265
|
+
password,
|
|
1266
|
+
secure: false
|
|
1267
|
+
});
|
|
1268
|
+
await receiver.connect();
|
|
1269
|
+
try {
|
|
1270
|
+
const raw = await receiver.fetchMessage(event.uid);
|
|
1271
|
+
const parsed = await parseEmail(raw);
|
|
1272
|
+
const policyMetadata = agent.metadata && typeof agent.metadata === "object" ? {
|
|
1273
|
+
emailRoutePolicy: agent.metadata.emailRoutePolicy,
|
|
1274
|
+
routePolicy: agent.metadata.routePolicy,
|
|
1275
|
+
mailboxPolicy: agent.metadata.mailboxPolicy
|
|
1276
|
+
} : void 0;
|
|
1277
|
+
const accountRouteContext = {
|
|
1278
|
+
name: agent.name,
|
|
1279
|
+
email: agent.email,
|
|
1280
|
+
role: agent.role,
|
|
1281
|
+
metadata: policyMetadata
|
|
1282
|
+
};
|
|
1283
|
+
const isRelay = !!parsed.headers.get("x-agenticmail-relay");
|
|
1284
|
+
const internal = !isRelay && isInternalEmail(parsed);
|
|
1285
|
+
if (internal) {
|
|
1286
|
+
event.route = classifyEmailRoute({ email: parsed, account: accountRouteContext });
|
|
1287
|
+
const ruleResult2 = evaluateRules(db, agent.id, parsed);
|
|
1288
|
+
if (ruleResult2) {
|
|
1289
|
+
const actions = ruleResult2.actions;
|
|
1290
|
+
if (actions.mark_read) await receiver.markSeen(event.uid);
|
|
1291
|
+
if (actions.delete) {
|
|
1292
|
+
await receiver.deleteMessage(event.uid);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
|
|
1296
|
+
event.ruleApplied = { ruleId: ruleResult2.ruleId, actions };
|
|
1297
|
+
}
|
|
1298
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1299
|
+
|
|
1300
|
+
`);
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
const spamResult = scoreEmail(parsed);
|
|
1304
|
+
event.route = classifyEmailRoute({ email: parsed, spam: spamResult, account: accountRouteContext });
|
|
1305
|
+
try {
|
|
1306
|
+
db.prepare(
|
|
1307
|
+
"INSERT INTO spam_log (id, agent_id, message_uid, score, flags, category, is_spam) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
1308
|
+
).run(
|
|
1309
|
+
uuidv42(),
|
|
1310
|
+
agent.id,
|
|
1311
|
+
event.uid,
|
|
1312
|
+
spamResult.score,
|
|
1313
|
+
JSON.stringify(spamResult.matches.map((m) => m.ruleId)),
|
|
1314
|
+
spamResult.topCategory,
|
|
1315
|
+
spamResult.isSpam ? 1 : 0
|
|
1316
|
+
);
|
|
1317
|
+
} catch {
|
|
1318
|
+
}
|
|
1319
|
+
if (spamResult.isSpam) {
|
|
1320
|
+
try {
|
|
1321
|
+
await receiver.createFolder("Spam");
|
|
1322
|
+
} catch {
|
|
1323
|
+
}
|
|
1324
|
+
await receiver.moveMessage(event.uid, "INBOX", "Spam");
|
|
1325
|
+
event.spam = { score: spamResult.score, category: spamResult.topCategory, movedToSpam: true };
|
|
1326
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1327
|
+
|
|
1328
|
+
`);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (spamResult.isWarning) {
|
|
1332
|
+
event.spamWarning = { score: spamResult.score, category: spamResult.topCategory, matches: spamResult.matches.map((m) => m.ruleId) };
|
|
1333
|
+
}
|
|
1334
|
+
const ruleResult = evaluateRules(db, agent.id, parsed);
|
|
1335
|
+
if (ruleResult) {
|
|
1336
|
+
const actions = ruleResult.actions;
|
|
1337
|
+
if (actions.mark_read) await receiver.markSeen(event.uid);
|
|
1338
|
+
if (actions.delete) {
|
|
1339
|
+
await receiver.deleteMessage(event.uid);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
|
|
1343
|
+
event.ruleApplied = { ruleId: ruleResult.ruleId, actions };
|
|
1344
|
+
}
|
|
1345
|
+
} finally {
|
|
1346
|
+
await receiver.disconnect();
|
|
1347
|
+
}
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
console.error("[SSE] Spam/rule evaluation error:", err.message);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1353
|
+
|
|
1354
|
+
`);
|
|
1355
|
+
});
|
|
1356
|
+
watcher.on("expunge", (event) => {
|
|
1357
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1358
|
+
|
|
1359
|
+
`);
|
|
1360
|
+
});
|
|
1361
|
+
watcher.on("flags", (event) => {
|
|
1362
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1363
|
+
|
|
1364
|
+
`);
|
|
1365
|
+
});
|
|
1366
|
+
watcher.on("error", (err) => {
|
|
1367
|
+
safeWrite(`data: ${JSON.stringify({ type: "error", message: err.message })}
|
|
1368
|
+
|
|
1369
|
+
`);
|
|
1370
|
+
});
|
|
1371
|
+
watcher.on("reconnecting", (info) => {
|
|
1372
|
+
safeWrite(`data: ${JSON.stringify({ type: "reconnecting", attempt: info.attempt, delayMs: info.delayMs })}
|
|
1373
|
+
|
|
1374
|
+
`);
|
|
1375
|
+
});
|
|
1376
|
+
watcher.on("reconnected", (info) => {
|
|
1377
|
+
safeWrite(`data: ${JSON.stringify({ type: "reconnected", attempt: info.attempt })}
|
|
1378
|
+
|
|
1379
|
+
`);
|
|
1380
|
+
});
|
|
1381
|
+
watcher.on("reconnect_failed", (info) => {
|
|
1382
|
+
safeWrite(`data: ${JSON.stringify({ type: "reconnect_failed", attempts: info.attempts })}
|
|
1383
|
+
|
|
1384
|
+
`);
|
|
1385
|
+
});
|
|
1386
|
+
const pingInterval = setInterval(() => {
|
|
1387
|
+
safeWrite(`: ping
|
|
1388
|
+
|
|
1389
|
+
`);
|
|
1390
|
+
}, 3e4);
|
|
1391
|
+
req.on("close", () => {
|
|
1392
|
+
closed = true;
|
|
1393
|
+
clearInterval(pingInterval);
|
|
1394
|
+
agentWatchers.delete(entry);
|
|
1395
|
+
if (agentWatchers.size === 0) activeWatchers.delete(agent.id);
|
|
1396
|
+
watcher.removeAllListeners();
|
|
1397
|
+
watcher.stop().catch((err) => {
|
|
1398
|
+
console.error("[SSE] Watcher cleanup error:", err);
|
|
1399
|
+
});
|
|
1400
|
+
});
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
next(err);
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
return router;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// src/routes/mail.ts
|
|
1409
|
+
var senderCache = /* @__PURE__ */ new Map();
|
|
1410
|
+
var receiverCache = /* @__PURE__ */ new Map();
|
|
1411
|
+
var receiverPending = /* @__PURE__ */ new Map();
|
|
1412
|
+
var CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
1413
|
+
var MAX_CACHE_SIZE = 100;
|
|
1414
|
+
var draining = false;
|
|
1415
|
+
function getAgentPassword(agent) {
|
|
1416
|
+
return agent.metadata?._password || agent.name;
|
|
1417
|
+
}
|
|
1418
|
+
var evictionTimer = null;
|
|
1419
|
+
function startEvictionTimer() {
|
|
1420
|
+
if (evictionTimer) return;
|
|
1421
|
+
evictionTimer = setInterval(evictStaleEntries, 6e4);
|
|
1422
|
+
}
|
|
1423
|
+
function evictStaleEntries() {
|
|
1424
|
+
const now = Date.now();
|
|
1425
|
+
for (const [key, entry] of senderCache) {
|
|
1426
|
+
if (now - entry.createdAt > CACHE_TTL_MS) {
|
|
1427
|
+
try {
|
|
1428
|
+
entry.sender.close();
|
|
1429
|
+
} catch {
|
|
1430
|
+
}
|
|
1431
|
+
senderCache.delete(key);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
for (const [key, entry] of receiverCache) {
|
|
1435
|
+
if (now - entry.createdAt > CACHE_TTL_MS) {
|
|
1436
|
+
entry.receiver.disconnect().catch(() => {
|
|
1437
|
+
});
|
|
1438
|
+
receiverCache.delete(key);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
function getSender(authUser, fromEmail, password, config) {
|
|
1443
|
+
if (draining) throw new Error("Server is shutting down");
|
|
1444
|
+
const cacheKey = `${authUser}:${fromEmail}`;
|
|
1445
|
+
const cached = senderCache.get(cacheKey);
|
|
1446
|
+
if (cached) return cached.sender;
|
|
1447
|
+
if (senderCache.size >= MAX_CACHE_SIZE) {
|
|
1448
|
+
const oldest = senderCache.keys().next().value;
|
|
1449
|
+
if (oldest) {
|
|
1450
|
+
try {
|
|
1451
|
+
senderCache.get(oldest)?.sender.close();
|
|
1452
|
+
} catch {
|
|
1453
|
+
}
|
|
1454
|
+
senderCache.delete(oldest);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
const sender = new MailSender2({
|
|
1458
|
+
host: config.smtp.host,
|
|
1459
|
+
port: config.smtp.port,
|
|
1460
|
+
email: fromEmail,
|
|
1461
|
+
password,
|
|
1462
|
+
authUser
|
|
1463
|
+
});
|
|
1464
|
+
senderCache.set(cacheKey, { sender, createdAt: Date.now() });
|
|
1465
|
+
startEvictionTimer();
|
|
1466
|
+
return sender;
|
|
1467
|
+
}
|
|
1468
|
+
async function getReceiver(authUser, password, config) {
|
|
1469
|
+
if (draining) throw new Error("Server is shutting down");
|
|
1470
|
+
const cached = receiverCache.get(authUser);
|
|
1471
|
+
if (cached) {
|
|
1472
|
+
try {
|
|
1473
|
+
const client = cached.receiver.getImapClient();
|
|
1474
|
+
if (client.usable) return cached.receiver;
|
|
1475
|
+
} catch {
|
|
1476
|
+
}
|
|
1477
|
+
try {
|
|
1478
|
+
await cached.receiver.disconnect();
|
|
1479
|
+
} catch {
|
|
1480
|
+
}
|
|
1481
|
+
receiverCache.delete(authUser);
|
|
1482
|
+
}
|
|
1483
|
+
const pending = receiverPending.get(authUser);
|
|
1484
|
+
if (pending) return pending;
|
|
1485
|
+
const promise = createReceiver(authUser, password, config);
|
|
1486
|
+
receiverPending.set(authUser, promise);
|
|
1487
|
+
try {
|
|
1488
|
+
return await promise;
|
|
1489
|
+
} finally {
|
|
1490
|
+
receiverPending.delete(authUser);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
async function createReceiver(authUser, password, config) {
|
|
1494
|
+
if (receiverCache.size >= MAX_CACHE_SIZE) {
|
|
1495
|
+
const oldest = receiverCache.keys().next().value;
|
|
1496
|
+
if (oldest) {
|
|
1497
|
+
receiverCache.get(oldest)?.receiver.disconnect().catch(() => {
|
|
1498
|
+
});
|
|
1499
|
+
receiverCache.delete(oldest);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const receiver = new MailReceiver2({
|
|
1503
|
+
host: config.imap.host,
|
|
1504
|
+
port: config.imap.port,
|
|
1505
|
+
email: authUser,
|
|
1506
|
+
password
|
|
1507
|
+
});
|
|
1508
|
+
try {
|
|
1509
|
+
await receiver.connect();
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
try {
|
|
1512
|
+
await receiver.disconnect();
|
|
1513
|
+
} catch {
|
|
1514
|
+
}
|
|
1515
|
+
throw err;
|
|
1516
|
+
}
|
|
1517
|
+
receiverCache.set(authUser, { receiver, createdAt: Date.now() });
|
|
1518
|
+
startEvictionTimer();
|
|
1519
|
+
return receiver;
|
|
1520
|
+
}
|
|
1521
|
+
async function closeCaches() {
|
|
1522
|
+
draining = true;
|
|
1523
|
+
if (evictionTimer) {
|
|
1524
|
+
clearInterval(evictionTimer);
|
|
1525
|
+
evictionTimer = null;
|
|
1526
|
+
}
|
|
1527
|
+
for (const [, entry] of senderCache) {
|
|
1528
|
+
try {
|
|
1529
|
+
entry.sender.close();
|
|
1530
|
+
} catch {
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
senderCache.clear();
|
|
1534
|
+
for (const [, entry] of receiverCache) {
|
|
1535
|
+
try {
|
|
1536
|
+
await entry.receiver.disconnect();
|
|
1537
|
+
} catch {
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
receiverCache.clear();
|
|
1541
|
+
}
|
|
1542
|
+
async function notifyLocalRecipientsOfNewMail(accountManager, toField, ccField, bccField, fromAgent, subject, messageId) {
|
|
1543
|
+
const collected = [];
|
|
1544
|
+
const push = (v) => {
|
|
1545
|
+
if (!v) return;
|
|
1546
|
+
if (Array.isArray(v)) collected.push(...v);
|
|
1547
|
+
else collected.push(v);
|
|
1548
|
+
};
|
|
1549
|
+
push(toField);
|
|
1550
|
+
push(ccField);
|
|
1551
|
+
push(bccField);
|
|
1552
|
+
const addrRe = /<([^>]+)>|([^\s,;<>]+@[^\s,;<>]+)/g;
|
|
1553
|
+
const addresses = /* @__PURE__ */ new Set();
|
|
1554
|
+
for (const entry of collected) {
|
|
1555
|
+
let match;
|
|
1556
|
+
addrRe.lastIndex = 0;
|
|
1557
|
+
while ((match = addrRe.exec(entry)) !== null) {
|
|
1558
|
+
const a = (match[1] || match[2] || "").trim().toLowerCase();
|
|
1559
|
+
if (a) addresses.add(a);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
const notified = /* @__PURE__ */ new Set();
|
|
1563
|
+
for (const addr of addresses) {
|
|
1564
|
+
const at = addr.indexOf("@");
|
|
1565
|
+
if (at < 0) continue;
|
|
1566
|
+
const localPart = addr.slice(0, at);
|
|
1567
|
+
const domain = addr.slice(at + 1);
|
|
1568
|
+
if (domain !== "localhost") continue;
|
|
1569
|
+
if (addr === fromAgent.email.toLowerCase()) continue;
|
|
1570
|
+
let recipient = null;
|
|
1571
|
+
try {
|
|
1572
|
+
recipient = await accountManager.getByName(localPart);
|
|
1573
|
+
} catch {
|
|
1574
|
+
}
|
|
1575
|
+
if (!recipient || notified.has(recipient.id)) continue;
|
|
1576
|
+
notified.add(recipient.id);
|
|
1577
|
+
pushEventToAgent(recipient.id, {
|
|
1578
|
+
type: "new",
|
|
1579
|
+
// uid is unknown without an IMAP fetch; use 0 as a sentinel —
|
|
1580
|
+
// this matches the watcher's autoFetch=false path. SSE consumers
|
|
1581
|
+
// that want full message detail can call /mail/inbox.
|
|
1582
|
+
uid: 0,
|
|
1583
|
+
internal: true,
|
|
1584
|
+
from: { name: fromAgent.name, address: fromAgent.email },
|
|
1585
|
+
subject,
|
|
1586
|
+
messageId
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function saveSentCopy(authUser, password, config, raw) {
|
|
1591
|
+
(async () => {
|
|
1592
|
+
try {
|
|
1593
|
+
const receiver = await getReceiver(authUser, password, config);
|
|
1594
|
+
await receiver.appendMessage(raw, "Sent Items", ["\\Seen"]);
|
|
1595
|
+
} catch (err) {
|
|
1596
|
+
console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
|
|
1597
|
+
}
|
|
1598
|
+
})();
|
|
1599
|
+
}
|
|
1600
|
+
function createMailRoutes(accountManager, config, db, gatewayManager) {
|
|
1601
|
+
const router = Router5();
|
|
1602
|
+
router.post("/mail/send", requireAgent, async (req, res, next) => {
|
|
1603
|
+
try {
|
|
1604
|
+
if (!req.body || typeof req.body !== "object") {
|
|
1605
|
+
res.status(400).json({ error: "Request body must be JSON" });
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
const agent = req.agent;
|
|
1609
|
+
const { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, allowSensitive } = req.body;
|
|
1610
|
+
if (!to || !subject) {
|
|
1611
|
+
res.status(400).json({ error: "to and subject are required" });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (typeof to !== "string" && !Array.isArray(to)) {
|
|
1615
|
+
res.status(400).json({ error: "to must be a string or array of strings" });
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
let outboundWarnings;
|
|
1619
|
+
let outboundSummary;
|
|
1620
|
+
if (!(allowSensitive && req.isMaster)) {
|
|
1621
|
+
const scanResult = scanOutboundEmail({
|
|
1622
|
+
to: Array.isArray(to) ? to.join(", ") : to,
|
|
1623
|
+
subject,
|
|
1624
|
+
text,
|
|
1625
|
+
html,
|
|
1626
|
+
attachments: Array.isArray(attachments) ? attachments.map((a) => ({
|
|
1627
|
+
filename: a.filename || "",
|
|
1628
|
+
contentType: a.contentType,
|
|
1629
|
+
content: a.content,
|
|
1630
|
+
encoding: a.encoding
|
|
1631
|
+
})) : void 0
|
|
1632
|
+
});
|
|
1633
|
+
if (scanResult.blocked) {
|
|
1634
|
+
const pendingId = crypto.randomUUID();
|
|
1635
|
+
const ownerName2 = agent.metadata?.ownerName;
|
|
1636
|
+
const fromName2 = ownerName2 ? `${agent.name} from ${ownerName2}` : agent.name;
|
|
1637
|
+
const mailOptions = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName: fromName2 };
|
|
1638
|
+
db.prepare(
|
|
1639
|
+
`INSERT INTO pending_outbound (id, agent_id, mail_options, warnings, summary) VALUES (?, ?, ?, ?, ?)`
|
|
1640
|
+
).run(pendingId, agent.id, JSON.stringify(mailOptions), JSON.stringify(scanResult.warnings), scanResult.summary);
|
|
1641
|
+
if (gatewayManager) {
|
|
1642
|
+
const ownerEmail = gatewayManager.getConfig()?.relay?.email;
|
|
1643
|
+
if (ownerEmail) {
|
|
1644
|
+
const warningList = scanResult.warnings.map((w) => ` - [${w.severity.toUpperCase()}] ${w.ruleId}: ${w.description}${w.match ? ` (matched: ${w.match})` : ""}`).join("\n");
|
|
1645
|
+
const recipientLine = Array.isArray(to) ? to.join(", ") : to;
|
|
1646
|
+
const emailPreview = [
|
|
1647
|
+
"\u2500".repeat(50),
|
|
1648
|
+
`From: ${fromName2} <${agent.email}>`,
|
|
1649
|
+
`To: ${recipientLine}`
|
|
1650
|
+
];
|
|
1651
|
+
if (cc) emailPreview.push(`CC: ${Array.isArray(cc) ? cc.join(", ") : cc}`);
|
|
1652
|
+
if (bcc) emailPreview.push(`BCC: ${Array.isArray(bcc) ? bcc.join(", ") : bcc}`);
|
|
1653
|
+
emailPreview.push(`Subject: ${subject}`);
|
|
1654
|
+
if (Array.isArray(attachments) && attachments.length > 0) {
|
|
1655
|
+
const attNames = attachments.map((a) => a.filename || "unnamed").join(", ");
|
|
1656
|
+
emailPreview.push(`Attachments: ${attNames}`);
|
|
1657
|
+
}
|
|
1658
|
+
emailPreview.push("\u2500".repeat(50));
|
|
1659
|
+
if (text) emailPreview.push("", text);
|
|
1660
|
+
else if (html) emailPreview.push("", "[HTML content \u2014 see original for formatted version]");
|
|
1661
|
+
else emailPreview.push("", "[No body content]");
|
|
1662
|
+
emailPreview.push("\u2500".repeat(50));
|
|
1663
|
+
gatewayManager.routeOutbound(agent.name, {
|
|
1664
|
+
to: ownerEmail,
|
|
1665
|
+
subject: `[Approval Required] Blocked email from "${agent.name}" \u2014 "${subject}"`,
|
|
1666
|
+
text: [
|
|
1667
|
+
`Your agent "${agent.name}" attempted to send an email that was blocked by the outbound security guard.`,
|
|
1668
|
+
"",
|
|
1669
|
+
"SECURITY WARNINGS:",
|
|
1670
|
+
warningList,
|
|
1671
|
+
"",
|
|
1672
|
+
"FULL EMAIL FOR REVIEW:",
|
|
1673
|
+
...emailPreview,
|
|
1674
|
+
"",
|
|
1675
|
+
`Pending ID: ${pendingId}`,
|
|
1676
|
+
"",
|
|
1677
|
+
"ACTION REQUIRED:",
|
|
1678
|
+
'Reply "approve" to this email to send it, or "reject" to discard it.',
|
|
1679
|
+
"If you do not respond, the agent will follow up with you."
|
|
1680
|
+
].join("\n"),
|
|
1681
|
+
fromName: "Agentic Mail"
|
|
1682
|
+
}).then((result2) => {
|
|
1683
|
+
if (result2?.messageId) {
|
|
1684
|
+
db.prepare("UPDATE pending_outbound SET notification_message_id = ? WHERE id = ?").run(result2.messageId, pendingId);
|
|
1685
|
+
}
|
|
1686
|
+
}).catch(() => {
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
res.json({
|
|
1691
|
+
sent: false,
|
|
1692
|
+
blocked: true,
|
|
1693
|
+
pendingId,
|
|
1694
|
+
warnings: scanResult.warnings,
|
|
1695
|
+
summary: scanResult.summary
|
|
1696
|
+
});
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (scanResult.warnings.length > 0) {
|
|
1700
|
+
outboundWarnings = scanResult.warnings;
|
|
1701
|
+
outboundSummary = scanResult.summary;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
const ownerName = agent.metadata?.ownerName;
|
|
1705
|
+
const fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
|
|
1706
|
+
const mailOpts = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName };
|
|
1707
|
+
const password = getAgentPassword(agent);
|
|
1708
|
+
if (gatewayManager) {
|
|
1709
|
+
const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
|
|
1710
|
+
if (gatewayResult) {
|
|
1711
|
+
if (gatewayResult.raw) {
|
|
1712
|
+
saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
|
|
1713
|
+
}
|
|
1714
|
+
const { raw: _raw2, ...response2 } = gatewayResult;
|
|
1715
|
+
res.json({ ...response2, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
|
|
1720
|
+
const result = await sender.send(mailOpts);
|
|
1721
|
+
saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
|
|
1722
|
+
notifyLocalRecipientsOfNewMail(
|
|
1723
|
+
accountManager,
|
|
1724
|
+
to,
|
|
1725
|
+
cc,
|
|
1726
|
+
bcc,
|
|
1727
|
+
agent,
|
|
1728
|
+
subject,
|
|
1729
|
+
result.messageId
|
|
1730
|
+
).catch((err) => {
|
|
1731
|
+
console.warn(`[mail] Internal SSE notify failed: ${err.message}`);
|
|
1732
|
+
});
|
|
1733
|
+
const { raw: _raw, ...response } = result;
|
|
1734
|
+
res.json({ ...response, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
next(err);
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
router.get("/mail/inbox", requireAgent, async (req, res, next) => {
|
|
1740
|
+
try {
|
|
1741
|
+
const agent = req.agent;
|
|
1742
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
|
|
1743
|
+
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
1744
|
+
const password = getAgentPassword(agent);
|
|
1745
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1746
|
+
const mailboxInfo = await receiver.getMailboxInfo("INBOX");
|
|
1747
|
+
const envelopes = await receiver.listEnvelopes("INBOX", { limit, offset });
|
|
1748
|
+
res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists });
|
|
1749
|
+
} catch (err) {
|
|
1750
|
+
next(err);
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1753
|
+
router.get("/mail/messages/:uid", requireAgent, async (req, res, next) => {
|
|
1754
|
+
try {
|
|
1755
|
+
const agent = req.agent;
|
|
1756
|
+
const uid = parseInt(req.params.uid);
|
|
1757
|
+
if (isNaN(uid) || uid < 1) {
|
|
1758
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const folder = req.query.folder || "INBOX";
|
|
1762
|
+
const password = getAgentPassword(agent);
|
|
1763
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1764
|
+
const raw = await receiver.fetchMessage(uid, folder);
|
|
1765
|
+
const parsed = await parseEmail2(raw);
|
|
1766
|
+
if (isInternalEmail2(parsed)) {
|
|
1767
|
+
res.json({
|
|
1768
|
+
...parsed,
|
|
1769
|
+
security: { internal: true, spamScore: 0, isSpam: false, isWarning: false }
|
|
1770
|
+
});
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const sanitized = sanitizeEmail(parsed);
|
|
1774
|
+
const spamScore = scoreEmail2(parsed);
|
|
1775
|
+
res.json({
|
|
1776
|
+
...parsed,
|
|
1777
|
+
text: sanitized.text,
|
|
1778
|
+
html: sanitized.html,
|
|
1779
|
+
security: {
|
|
1780
|
+
spamScore: spamScore.score,
|
|
1781
|
+
isSpam: spamScore.isSpam,
|
|
1782
|
+
isWarning: spamScore.isWarning,
|
|
1783
|
+
topCategory: spamScore.topCategory,
|
|
1784
|
+
matches: spamScore.matches.map((m) => m.ruleId),
|
|
1785
|
+
sanitized: sanitized.wasModified,
|
|
1786
|
+
sanitizeDetections: sanitized.detections
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
} catch (err) {
|
|
1790
|
+
next(err);
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
router.get("/mail/messages/:uid/attachments/:index", requireAgent, async (req, res, next) => {
|
|
1414
1794
|
try {
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1417
|
-
|
|
1795
|
+
const agent = req.agent;
|
|
1796
|
+
const uid = parseInt(req.params.uid);
|
|
1797
|
+
const index = parseInt(req.params.index);
|
|
1798
|
+
if (isNaN(uid) || uid < 1) {
|
|
1799
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1418
1800
|
return;
|
|
1419
1801
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
res.status(400).json({ error: "to and rawEmail are required" });
|
|
1802
|
+
if (isNaN(index) || index < 0) {
|
|
1803
|
+
res.status(400).json({ error: "Invalid attachment index" });
|
|
1423
1804
|
return;
|
|
1424
1805
|
}
|
|
1425
|
-
const
|
|
1426
|
-
const
|
|
1427
|
-
const
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1806
|
+
const folder = req.query.folder || "INBOX";
|
|
1807
|
+
const password = getAgentPassword(agent);
|
|
1808
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1809
|
+
const raw = await receiver.fetchMessage(uid, folder);
|
|
1810
|
+
const parsed = await parseEmail2(raw);
|
|
1811
|
+
if (!parsed.attachments || index >= parsed.attachments.length) {
|
|
1812
|
+
res.status(404).json({ error: "Attachment not found" });
|
|
1431
1813
|
return;
|
|
1432
1814
|
}
|
|
1433
|
-
const
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1815
|
+
const att = parsed.attachments[index];
|
|
1816
|
+
res.setHeader("Content-Type", att.contentType || "application/octet-stream");
|
|
1817
|
+
res.setHeader("Content-Disposition", `attachment; filename="${att.filename.replace(/"/g, '\\"')}"`);
|
|
1818
|
+
res.setHeader("Content-Length", att.content.length);
|
|
1819
|
+
res.send(att.content);
|
|
1820
|
+
} catch (err) {
|
|
1821
|
+
next(err);
|
|
1822
|
+
}
|
|
1823
|
+
});
|
|
1824
|
+
router.post("/mail/search", requireAgent, async (req, res, next) => {
|
|
1825
|
+
try {
|
|
1826
|
+
if (!req.body || typeof req.body !== "object") {
|
|
1827
|
+
res.status(400).json({ error: "Request body must be JSON" });
|
|
1437
1828
|
return;
|
|
1438
1829
|
}
|
|
1439
|
-
const
|
|
1440
|
-
const
|
|
1441
|
-
const
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1830
|
+
const agent = req.agent;
|
|
1831
|
+
const { from, to, subject, since, before, seen, text, searchRelay } = req.body;
|
|
1832
|
+
const password = getAgentPassword(agent);
|
|
1833
|
+
const sinceDate = since ? new Date(since) : void 0;
|
|
1834
|
+
const beforeDate = before ? new Date(before) : void 0;
|
|
1835
|
+
if (sinceDate && isNaN(sinceDate.getTime())) {
|
|
1836
|
+
res.status(400).json({ error: 'Invalid "since" date' });
|
|
1445
1837
|
return;
|
|
1446
1838
|
}
|
|
1447
|
-
if (
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
port: config.smtp.port,
|
|
1451
|
-
email: agent.email,
|
|
1452
|
-
password: agentPassword,
|
|
1453
|
-
authUser: agent.stalwartPrincipal
|
|
1454
|
-
});
|
|
1455
|
-
try {
|
|
1456
|
-
await sender.send({
|
|
1457
|
-
to: agent.email,
|
|
1458
|
-
subject: parsed.subject || subject || "(no subject)",
|
|
1459
|
-
text: parsed.text || void 0,
|
|
1460
|
-
html: parsed.html || void 0,
|
|
1461
|
-
replyTo: from || parsed.from?.[0]?.address,
|
|
1462
|
-
inReplyTo: parsed.inReplyTo,
|
|
1463
|
-
references: parsed.references,
|
|
1464
|
-
headers: {
|
|
1465
|
-
"X-AgenticMail-Inbound": "cloudflare-worker",
|
|
1466
|
-
"X-Original-From": from || parsed.from?.[0]?.address || "",
|
|
1467
|
-
...parsed.messageId ? { "X-Original-Message-Id": parsed.messageId } : {}
|
|
1468
|
-
},
|
|
1469
|
-
attachments: parsed.attachments?.map((a) => ({
|
|
1470
|
-
filename: a.filename,
|
|
1471
|
-
content: a.content,
|
|
1472
|
-
contentType: a.contentType
|
|
1473
|
-
}))
|
|
1474
|
-
});
|
|
1475
|
-
if (originalMessageId) gatewayManager?.recordDelivery(originalMessageId, agent.name);
|
|
1476
|
-
if (DEBUG()) console.log(`[Inbound] Delivered to ${agent.email}`);
|
|
1477
|
-
res.json({ ok: true, delivered: agent.email });
|
|
1478
|
-
} finally {
|
|
1479
|
-
sender.close();
|
|
1839
|
+
if (beforeDate && isNaN(beforeDate.getTime())) {
|
|
1840
|
+
res.status(400).json({ error: 'Invalid "before" date' });
|
|
1841
|
+
return;
|
|
1480
1842
|
}
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
if (unit.startsWith("min")) return new Date(now + amount * 6e4);
|
|
1519
|
-
if (unit.startsWith("h")) return new Date(now + amount * 36e5);
|
|
1520
|
-
if (unit.startsWith("d")) return new Date(now + amount * 864e5);
|
|
1521
|
-
}
|
|
1522
|
-
const tomorrowMatch = lower.match(/^tomorrow\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
|
|
1523
|
-
if (tomorrowMatch) {
|
|
1524
|
-
const tomorrow = /* @__PURE__ */ new Date();
|
|
1525
|
-
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
1526
|
-
let hour = parseInt(tomorrowMatch[1], 10);
|
|
1527
|
-
const min = tomorrowMatch[2] ? parseInt(tomorrowMatch[2], 10) : 0;
|
|
1528
|
-
const ampm = tomorrowMatch[3];
|
|
1529
|
-
if (ampm === "pm" && hour !== 12) hour += 12;
|
|
1530
|
-
if (ampm === "am" && hour === 12) hour = 0;
|
|
1531
|
-
tomorrow.setHours(hour, min, 0, 0);
|
|
1532
|
-
return tomorrow;
|
|
1533
|
-
}
|
|
1534
|
-
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
1535
|
-
const nextDayMatch = lower.match(/^next\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
|
|
1536
|
-
if (nextDayMatch) {
|
|
1537
|
-
const targetDay = dayNames.indexOf(nextDayMatch[1]);
|
|
1538
|
-
let hour = parseInt(nextDayMatch[2], 10);
|
|
1539
|
-
const min = nextDayMatch[3] ? parseInt(nextDayMatch[3], 10) : 0;
|
|
1540
|
-
const ampm = nextDayMatch[4];
|
|
1541
|
-
if (ampm === "pm" && hour !== 12) hour += 12;
|
|
1542
|
-
if (ampm === "am" && hour === 12) hour = 0;
|
|
1543
|
-
const result = /* @__PURE__ */ new Date();
|
|
1544
|
-
const currentDay = result.getDay();
|
|
1545
|
-
let daysUntil = targetDay - currentDay;
|
|
1546
|
-
if (daysUntil <= 0) daysUntil += 7;
|
|
1547
|
-
result.setDate(result.getDate() + daysUntil);
|
|
1548
|
-
result.setHours(hour, min, 0, 0);
|
|
1549
|
-
return result;
|
|
1550
|
-
}
|
|
1551
|
-
if (lower === "tonight" || lower === "this evening") {
|
|
1552
|
-
const d = /* @__PURE__ */ new Date();
|
|
1553
|
-
d.setHours(20, 0, 0, 0);
|
|
1554
|
-
if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
|
|
1555
|
-
return d;
|
|
1556
|
-
}
|
|
1557
|
-
const humanMatch = trimmed.match(
|
|
1558
|
-
/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)\s*(.+)?$/
|
|
1559
|
-
);
|
|
1560
|
-
if (humanMatch) {
|
|
1561
|
-
const [, mStr, dStr, yStr, hStr, minStr, ampmRaw, tzRaw] = humanMatch;
|
|
1562
|
-
const month = parseInt(mStr, 10);
|
|
1563
|
-
const day = parseInt(dStr, 10);
|
|
1564
|
-
const year = parseInt(yStr, 10);
|
|
1565
|
-
let hour = parseInt(hStr, 10);
|
|
1566
|
-
const min = parseInt(minStr, 10);
|
|
1567
|
-
const ampm = ampmRaw.toUpperCase();
|
|
1568
|
-
if (month < 1 || month > 12 || day < 1 || day > 31 || hour < 1 || hour > 12) return null;
|
|
1569
|
-
if (ampm === "PM" && hour !== 12) hour += 12;
|
|
1570
|
-
if (ampm === "AM" && hour === 12) hour = 0;
|
|
1571
|
-
const result = new Date(year, month - 1, day, hour, min, 0, 0);
|
|
1572
|
-
if (tzRaw?.trim()) {
|
|
1573
|
-
const TZ_OFFSETS = {
|
|
1574
|
-
EST: -5,
|
|
1575
|
-
EDT: -4,
|
|
1576
|
-
CST: -6,
|
|
1577
|
-
CDT: -5,
|
|
1578
|
-
MST: -7,
|
|
1579
|
-
MDT: -6,
|
|
1580
|
-
PST: -8,
|
|
1581
|
-
PDT: -7,
|
|
1582
|
-
GMT: 0,
|
|
1583
|
-
UTC: 0,
|
|
1584
|
-
BST: 1,
|
|
1585
|
-
CET: 1,
|
|
1586
|
-
CEST: 2,
|
|
1587
|
-
IST: 5.5,
|
|
1588
|
-
JST: 9,
|
|
1589
|
-
AEST: 10,
|
|
1590
|
-
AEDT: 11,
|
|
1591
|
-
NZST: 12,
|
|
1592
|
-
NZDT: 13,
|
|
1593
|
-
WAT: 1,
|
|
1594
|
-
EAT: 3,
|
|
1595
|
-
SAST: 2,
|
|
1596
|
-
HKT: 8,
|
|
1597
|
-
SGT: 8,
|
|
1598
|
-
KST: 9,
|
|
1599
|
-
HST: -10,
|
|
1600
|
-
AKST: -9,
|
|
1601
|
-
AKDT: -8,
|
|
1602
|
-
AST: -4,
|
|
1603
|
-
ADT: -3,
|
|
1604
|
-
NST: -3.5,
|
|
1605
|
-
NDT: -2.5
|
|
1606
|
-
};
|
|
1607
|
-
const tz = tzRaw.trim().toUpperCase();
|
|
1608
|
-
if (TZ_OFFSETS[tz] !== void 0) {
|
|
1609
|
-
const tzOffsetMs = TZ_OFFSETS[tz] * 36e5;
|
|
1610
|
-
const serverOffsetMs = result.getTimezoneOffset() * -6e4;
|
|
1611
|
-
const diff = serverOffsetMs - tzOffsetMs;
|
|
1612
|
-
result.setTime(result.getTime() + diff);
|
|
1843
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1844
|
+
const uids = await receiver.search({
|
|
1845
|
+
from,
|
|
1846
|
+
to,
|
|
1847
|
+
subject,
|
|
1848
|
+
since: sinceDate,
|
|
1849
|
+
before: beforeDate,
|
|
1850
|
+
seen,
|
|
1851
|
+
text
|
|
1852
|
+
});
|
|
1853
|
+
let relayResults;
|
|
1854
|
+
if (searchRelay === true && gatewayManager) {
|
|
1855
|
+
try {
|
|
1856
|
+
const relayHits = await gatewayManager.searchRelay({
|
|
1857
|
+
from,
|
|
1858
|
+
to,
|
|
1859
|
+
subject,
|
|
1860
|
+
text,
|
|
1861
|
+
since: sinceDate,
|
|
1862
|
+
before: beforeDate,
|
|
1863
|
+
seen
|
|
1864
|
+
});
|
|
1865
|
+
if (relayHits.length > 0) {
|
|
1866
|
+
relayResults = relayHits.map((r) => ({
|
|
1867
|
+
uid: r.uid,
|
|
1868
|
+
source: r.source,
|
|
1869
|
+
account: r.account,
|
|
1870
|
+
messageId: r.messageId,
|
|
1871
|
+
subject: r.subject,
|
|
1872
|
+
from: r.from,
|
|
1873
|
+
to: r.to,
|
|
1874
|
+
date: r.date,
|
|
1875
|
+
flags: r.flags
|
|
1876
|
+
}));
|
|
1877
|
+
}
|
|
1878
|
+
} catch {
|
|
1879
|
+
}
|
|
1613
1880
|
}
|
|
1614
|
-
|
|
1615
|
-
return isNaN(result.getTime()) ? null : result;
|
|
1616
|
-
}
|
|
1617
|
-
const fallback = new Date(trimmed);
|
|
1618
|
-
return isNaN(fallback.getTime()) ? null : fallback;
|
|
1619
|
-
}
|
|
1620
|
-
function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
|
|
1621
|
-
const router = Router5();
|
|
1622
|
-
router.get("/contacts", requireAgent, async (req, res, next) => {
|
|
1623
|
-
try {
|
|
1624
|
-
const rows = db.prepare("SELECT * FROM contacts WHERE agent_id = ? ORDER BY name, email").all(req.agent.id);
|
|
1625
|
-
res.json({ contacts: rows });
|
|
1881
|
+
res.json({ uids, ...relayResults ? { relayResults } : {} });
|
|
1626
1882
|
} catch (err) {
|
|
1627
1883
|
next(err);
|
|
1628
1884
|
}
|
|
1629
1885
|
});
|
|
1630
|
-
router.post("/
|
|
1886
|
+
router.post("/mail/import-relay", requireAgent, async (req, res, next) => {
|
|
1631
1887
|
try {
|
|
1632
|
-
const {
|
|
1633
|
-
if (!
|
|
1634
|
-
res.status(400).json({ error: "
|
|
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
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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.
|
|
1908
|
+
router.post("/mail/messages/:uid/seen", requireAgent, async (req, res, next) => {
|
|
1645
1909
|
try {
|
|
1646
|
-
const
|
|
1647
|
-
|
|
1648
|
-
|
|
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.
|
|
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
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
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.
|
|
1940
|
+
router.post("/mail/messages/:uid/unseen", requireAgent, async (req, res, next) => {
|
|
1713
1941
|
try {
|
|
1714
|
-
const
|
|
1715
|
-
|
|
1716
|
-
|
|
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("/
|
|
1956
|
+
router.post("/mail/messages/:uid/move", requireAgent, async (req, res, next) => {
|
|
1725
1957
|
try {
|
|
1726
|
-
const
|
|
1727
|
-
|
|
1728
|
-
|
|
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
|
-
|
|
1732
|
-
|
|
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
|
|
1756
|
-
|
|
1757
|
-
|
|
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("/
|
|
1977
|
+
router.get("/mail/folders", requireAgent, async (req, res, next) => {
|
|
1774
1978
|
try {
|
|
1775
|
-
const
|
|
1776
|
-
|
|
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("/
|
|
1988
|
+
router.post("/mail/folders", requireAgent, async (req, res, next) => {
|
|
1782
1989
|
try {
|
|
1783
|
-
const
|
|
1784
|
-
|
|
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
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1996
|
+
if (name2.length > 200 || /[\\*%]/.test(name2)) {
|
|
1997
|
+
res.status(400).json({ error: "Invalid folder name" });
|
|
1998
|
+
return;
|
|
1791
1999
|
}
|
|
1792
|
-
|
|
1793
|
-
|
|
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.
|
|
2008
|
+
router.get("/mail/folders/:folder", requireAgent, async (req, res, next) => {
|
|
1799
2009
|
try {
|
|
1800
|
-
const
|
|
1801
|
-
|
|
1802
|
-
|
|
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
|
-
|
|
1806
|
-
|
|
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
|
-
|
|
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
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
|
1826
|
-
|
|
1827
|
-
|
|
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.
|
|
2052
|
+
router.post("/mail/batch/seen", requireAgent, async (req, res, next) => {
|
|
1833
2053
|
try {
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
|
|
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
|
-
|
|
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.
|
|
2069
|
+
router.post("/mail/batch/unseen", requireAgent, async (req, res, next) => {
|
|
1845
2070
|
try {
|
|
1846
|
-
const
|
|
1847
|
-
|
|
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("/
|
|
2086
|
+
router.post("/mail/batch/move", requireAgent, async (req, res, next) => {
|
|
1853
2087
|
try {
|
|
1854
|
-
const
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
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 (
|
|
1867
|
-
res.status(400).json({ error: "
|
|
2095
|
+
if (!toFolder) {
|
|
2096
|
+
res.status(400).json({ error: "to (destination folder) is required" });
|
|
1868
2097
|
return;
|
|
1869
2098
|
}
|
|
1870
|
-
const
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
res.json({ ok: true,
|
|
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.
|
|
2107
|
+
router.post("/mail/batch/read", requireAgent, async (req, res, next) => {
|
|
1879
2108
|
try {
|
|
1880
|
-
const
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
-
|
|
2116
|
+
const password = getAgentPassword(agent);
|
|
2117
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2118
|
+
const rawMap = await receiver.batchFetch(uids, folder || "INBOX");
|
|
2119
|
+
const messages = [];
|
|
2120
|
+
for (const [uid, raw] of rawMap) {
|
|
2121
|
+
const parsed = await parseEmail2(raw);
|
|
2122
|
+
messages.push({ uid, ...parsed });
|
|
2123
|
+
}
|
|
2124
|
+
res.json({ messages, count: messages.length });
|
|
1886
2125
|
} catch (err) {
|
|
1887
2126
|
next(err);
|
|
1888
2127
|
}
|
|
1889
2128
|
});
|
|
1890
|
-
router.get("/
|
|
2129
|
+
router.get("/mail/spam", requireAgent, async (req, res, next) => {
|
|
1891
2130
|
try {
|
|
1892
|
-
const
|
|
1893
|
-
|
|
2131
|
+
const agent = req.agent;
|
|
2132
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
|
|
2133
|
+
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
2134
|
+
const password = getAgentPassword(agent);
|
|
2135
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2136
|
+
let mailboxInfo;
|
|
2137
|
+
try {
|
|
2138
|
+
mailboxInfo = await receiver.getMailboxInfo("Spam");
|
|
2139
|
+
} catch {
|
|
2140
|
+
res.json({ messages: [], count: 0, total: 0, folder: "Spam" });
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const envelopes = await receiver.listEnvelopes("Spam", { limit, offset });
|
|
2144
|
+
res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder: "Spam" });
|
|
1894
2145
|
} catch (err) {
|
|
1895
2146
|
next(err);
|
|
1896
2147
|
}
|
|
1897
2148
|
});
|
|
1898
|
-
router.post("/
|
|
2149
|
+
router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
|
|
1899
2150
|
try {
|
|
1900
|
-
const
|
|
1901
|
-
|
|
1902
|
-
|
|
2151
|
+
const agent = req.agent;
|
|
2152
|
+
const uid = parseInt(req.params.uid);
|
|
2153
|
+
if (isNaN(uid) || uid < 1) {
|
|
2154
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1903
2155
|
return;
|
|
1904
2156
|
}
|
|
1905
|
-
const
|
|
1906
|
-
|
|
1907
|
-
|
|
2157
|
+
const folder = req.body?.folder || "INBOX";
|
|
2158
|
+
const password = getAgentPassword(agent);
|
|
2159
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2160
|
+
try {
|
|
2161
|
+
await receiver.createFolder("Spam");
|
|
2162
|
+
} catch {
|
|
2163
|
+
}
|
|
2164
|
+
await receiver.moveMessage(uid, folder, "Spam");
|
|
2165
|
+
res.json({ ok: true, movedToSpam: true });
|
|
1908
2166
|
} catch (err) {
|
|
1909
2167
|
next(err);
|
|
1910
2168
|
}
|
|
1911
2169
|
});
|
|
1912
|
-
router.
|
|
2170
|
+
router.post("/mail/messages/:uid/not-spam", requireAgent, async (req, res, next) => {
|
|
1913
2171
|
try {
|
|
1914
|
-
const
|
|
1915
|
-
|
|
1916
|
-
|
|
2172
|
+
const agent = req.agent;
|
|
2173
|
+
const uid = parseInt(req.params.uid);
|
|
2174
|
+
if (isNaN(uid) || uid < 1) {
|
|
2175
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1917
2176
|
return;
|
|
1918
2177
|
}
|
|
1919
|
-
|
|
2178
|
+
const password = getAgentPassword(agent);
|
|
2179
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2180
|
+
await receiver.moveMessage(uid, "Spam", "INBOX");
|
|
2181
|
+
res.json({ ok: true, movedToInbox: true });
|
|
1920
2182
|
} catch (err) {
|
|
1921
2183
|
next(err);
|
|
1922
2184
|
}
|
|
1923
2185
|
});
|
|
1924
|
-
router.
|
|
2186
|
+
router.get("/mail/messages/:uid/spam-score", requireAgent, async (req, res, next) => {
|
|
1925
2187
|
try {
|
|
1926
|
-
const
|
|
1927
|
-
|
|
1928
|
-
|
|
2188
|
+
const agent = req.agent;
|
|
2189
|
+
const uid = parseInt(req.params.uid);
|
|
2190
|
+
if (isNaN(uid) || uid < 1) {
|
|
2191
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1929
2192
|
return;
|
|
1930
2193
|
}
|
|
1931
|
-
const
|
|
1932
|
-
|
|
1933
|
-
|
|
2194
|
+
const folder = req.query.folder || "INBOX";
|
|
2195
|
+
const password = getAgentPassword(agent);
|
|
2196
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2197
|
+
const raw = await receiver.fetchMessage(uid, folder);
|
|
2198
|
+
const parsed = await parseEmail2(raw);
|
|
2199
|
+
if (isInternalEmail2(parsed)) {
|
|
2200
|
+
res.json({ score: 0, isSpam: false, isWarning: false, matches: [], topCategory: null, internal: true });
|
|
1934
2201
|
return;
|
|
1935
2202
|
}
|
|
1936
|
-
|
|
1937
|
-
res.json(
|
|
2203
|
+
const result = scoreEmail2(parsed);
|
|
2204
|
+
res.json(result);
|
|
1938
2205
|
} catch (err) {
|
|
1939
2206
|
next(err);
|
|
1940
2207
|
}
|
|
1941
2208
|
});
|
|
1942
|
-
router.
|
|
2209
|
+
router.get("/mail/digest", requireAgent, async (req, res, next) => {
|
|
1943
2210
|
try {
|
|
2211
|
+
const agent = req.agent;
|
|
2212
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 50);
|
|
2213
|
+
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
2214
|
+
const previewLen = Math.min(Math.max(parseInt(req.query.previewLength) || 200, 50), 500);
|
|
1944
2215
|
const folder = req.query.folder || "INBOX";
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2216
|
+
const password = getAgentPassword(agent);
|
|
2217
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2218
|
+
const mailboxInfo = await receiver.getMailboxInfo(folder);
|
|
2219
|
+
const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
|
|
2220
|
+
const uids = envelopes.map((e) => e.uid);
|
|
2221
|
+
const rawMap = uids.length > 0 ? await receiver.batchFetch(uids, folder) : /* @__PURE__ */ new Map();
|
|
2222
|
+
const messages = [];
|
|
2223
|
+
for (const env of envelopes) {
|
|
2224
|
+
let preview = "";
|
|
2225
|
+
const raw = rawMap.get(env.uid);
|
|
2226
|
+
if (raw) {
|
|
2227
|
+
const parsed = await parseEmail2(raw);
|
|
2228
|
+
preview = (parsed.text || "").slice(0, previewLen);
|
|
2229
|
+
}
|
|
2230
|
+
messages.push({
|
|
2231
|
+
uid: env.uid,
|
|
2232
|
+
subject: env.subject,
|
|
2233
|
+
from: env.from,
|
|
2234
|
+
to: env.to,
|
|
2235
|
+
date: env.date,
|
|
2236
|
+
flags: [...env.flags],
|
|
2237
|
+
size: env.size,
|
|
2238
|
+
preview
|
|
2239
|
+
});
|
|
1957
2240
|
}
|
|
1958
|
-
|
|
1959
|
-
"SELECT message_uid, folder FROM message_tags WHERE agent_id = ? AND tag_id = ? ORDER BY created_at DESC"
|
|
1960
|
-
).all(req.agent.id, req.params.id);
|
|
1961
|
-
res.json({ tag, messages: rows.map((r) => ({ uid: r.message_uid, folder: r.folder })) });
|
|
2241
|
+
res.json({ messages, count: messages.length, total: mailboxInfo.exists });
|
|
1962
2242
|
} catch (err) {
|
|
1963
2243
|
next(err);
|
|
1964
2244
|
}
|
|
1965
2245
|
});
|
|
1966
|
-
router.get("/
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2246
|
+
router.get("/mail/pending", requireAuth, async (req, res) => {
|
|
2247
|
+
const rows = req.isMaster ? db.prepare(
|
|
2248
|
+
`SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
|
|
2249
|
+
FROM pending_outbound ORDER BY created_at DESC LIMIT 50`
|
|
2250
|
+
).all() : db.prepare(
|
|
2251
|
+
`SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
|
|
2252
|
+
FROM pending_outbound WHERE agent_id = ? ORDER BY created_at DESC LIMIT 50`
|
|
2253
|
+
).all(req.agent.id);
|
|
2254
|
+
const pending = rows.map((r) => {
|
|
2255
|
+
const opts = JSON.parse(r.mail_options);
|
|
2256
|
+
return {
|
|
2257
|
+
id: r.id,
|
|
2258
|
+
agentId: r.agent_id,
|
|
2259
|
+
to: opts.to,
|
|
2260
|
+
subject: opts.subject,
|
|
2261
|
+
warnings: JSON.parse(r.warnings),
|
|
2262
|
+
summary: r.summary,
|
|
2263
|
+
status: r.status,
|
|
2264
|
+
createdAt: r.created_at,
|
|
2265
|
+
resolvedAt: r.resolved_at,
|
|
2266
|
+
resolvedBy: r.resolved_by
|
|
2267
|
+
};
|
|
2268
|
+
});
|
|
2269
|
+
res.json({ pending, count: pending.length });
|
|
2270
|
+
});
|
|
2271
|
+
router.get("/mail/pending/:id", requireAuth, async (req, res) => {
|
|
2272
|
+
const row = req.isMaster ? db.prepare(`SELECT * FROM pending_outbound WHERE id = ?`).get(req.params.id) : db.prepare(`SELECT * FROM pending_outbound WHERE id = ? AND agent_id = ?`).get(req.params.id, req.agent.id);
|
|
2273
|
+
if (!row) {
|
|
2274
|
+
res.status(404).json({ error: "Pending email not found" });
|
|
2275
|
+
return;
|
|
1977
2276
|
}
|
|
2277
|
+
res.json({
|
|
2278
|
+
id: row.id,
|
|
2279
|
+
mailOptions: JSON.parse(row.mail_options),
|
|
2280
|
+
warnings: JSON.parse(row.warnings),
|
|
2281
|
+
summary: row.summary,
|
|
2282
|
+
status: row.status,
|
|
2283
|
+
createdAt: row.created_at,
|
|
2284
|
+
resolvedAt: row.resolved_at,
|
|
2285
|
+
resolvedBy: row.resolved_by
|
|
2286
|
+
});
|
|
1978
2287
|
});
|
|
1979
|
-
router.post("/
|
|
1980
|
-
try {
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
|
|
2288
|
+
router.post("/mail/pending/:id/approve", requireMaster, async (req, res, next) => {
|
|
2289
|
+
try {
|
|
2290
|
+
const row = db.prepare(
|
|
2291
|
+
`SELECT * FROM pending_outbound WHERE id = ?`
|
|
2292
|
+
).get(req.params.id);
|
|
2293
|
+
if (!row) {
|
|
2294
|
+
res.status(404).json({ error: "Pending email not found" });
|
|
1984
2295
|
return;
|
|
1985
2296
|
}
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
res.status(400).json({ error: "to is required" });
|
|
2297
|
+
if (row.status !== "pending") {
|
|
2298
|
+
res.status(400).json({ error: `Email already ${row.status}` });
|
|
1989
2299
|
return;
|
|
1990
2300
|
}
|
|
1991
|
-
const
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2301
|
+
const agent = await accountManager.getById(row.agent_id);
|
|
2302
|
+
if (!agent) {
|
|
2303
|
+
res.status(404).json({ error: "Agent account no longer exists" });
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
const mailOpts = JSON.parse(row.mail_options);
|
|
2307
|
+
const ownerName = agent.metadata?.ownerName;
|
|
2308
|
+
mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
|
|
2309
|
+
if (Array.isArray(mailOpts.attachments)) {
|
|
2310
|
+
for (const att of mailOpts.attachments) {
|
|
2311
|
+
if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
|
|
2312
|
+
att.content = Buffer.from(att.content.data);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
const password = getAgentPassword(agent);
|
|
2317
|
+
let response;
|
|
2002
2318
|
if (gatewayManager) {
|
|
2003
2319
|
const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
|
|
2004
2320
|
if (gatewayResult) {
|
|
2005
|
-
|
|
2006
|
-
|
|
2321
|
+
if (gatewayResult.raw) {
|
|
2322
|
+
saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
|
|
2323
|
+
}
|
|
2324
|
+
const { raw: _raw, ...rest } = gatewayResult;
|
|
2325
|
+
response = rest;
|
|
2007
2326
|
}
|
|
2008
2327
|
}
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
host: config.smtp.host,
|
|
2012
|
-
port: config.smtp.port,
|
|
2013
|
-
email: agent.email,
|
|
2014
|
-
password,
|
|
2015
|
-
authUser: agent.stalwartPrincipal
|
|
2016
|
-
});
|
|
2017
|
-
try {
|
|
2328
|
+
if (!response) {
|
|
2329
|
+
const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
|
|
2018
2330
|
const result = await sender.send(mailOpts);
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
}
|
|
2034
|
-
});
|
|
2035
|
-
router.post("/rules", requireAgent, async (req, res, next) => {
|
|
2036
|
-
try {
|
|
2037
|
-
const { name: name2, conditions, actions, priority, enabled } = req.body || {};
|
|
2038
|
-
if (!name2) {
|
|
2039
|
-
res.status(400).json({ error: "name is required" });
|
|
2040
|
-
return;
|
|
2331
|
+
saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
|
|
2332
|
+
notifyLocalRecipientsOfNewMail(
|
|
2333
|
+
accountManager,
|
|
2334
|
+
mailOpts.to,
|
|
2335
|
+
mailOpts.cc,
|
|
2336
|
+
mailOpts.bcc,
|
|
2337
|
+
agent,
|
|
2338
|
+
mailOpts.subject,
|
|
2339
|
+
result.messageId
|
|
2340
|
+
).catch((err) => {
|
|
2341
|
+
console.warn(`[mail] Internal SSE notify (approve) failed: ${err.message}`);
|
|
2342
|
+
});
|
|
2343
|
+
const { raw: _raw, ...rest } = result;
|
|
2344
|
+
response = rest;
|
|
2041
2345
|
}
|
|
2042
|
-
const id = uuidv4();
|
|
2043
2346
|
db.prepare(
|
|
2044
|
-
|
|
2045
|
-
).run(
|
|
2046
|
-
res.
|
|
2347
|
+
`UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
|
|
2348
|
+
).run("master", row.id);
|
|
2349
|
+
res.json({ ...response, approved: true, pendingId: row.id });
|
|
2047
2350
|
} catch (err) {
|
|
2048
2351
|
next(err);
|
|
2049
2352
|
}
|
|
2050
2353
|
});
|
|
2051
|
-
router.
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2354
|
+
router.post("/mail/pending/:id/reject", requireMaster, async (req, res) => {
|
|
2355
|
+
const row = db.prepare(
|
|
2356
|
+
`SELECT * FROM pending_outbound WHERE id = ?`
|
|
2357
|
+
).get(req.params.id);
|
|
2358
|
+
if (!row) {
|
|
2359
|
+
res.status(404).json({ error: "Pending email not found" });
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
if (row.status !== "pending") {
|
|
2363
|
+
res.status(400).json({ error: `Email already ${row.status}` });
|
|
2364
|
+
return;
|
|
2061
2365
|
}
|
|
2366
|
+
db.prepare(
|
|
2367
|
+
`UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
|
|
2368
|
+
).run("master", row.id);
|
|
2369
|
+
res.json({ ok: true, rejected: true, pendingId: row.id });
|
|
2062
2370
|
});
|
|
2063
2371
|
return router;
|
|
2064
2372
|
}
|
|
2065
|
-
function evaluateRules(db, agentId, email) {
|
|
2066
|
-
const rules = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? AND enabled = 1 ORDER BY priority DESC").all(agentId);
|
|
2067
|
-
for (const rule of rules) {
|
|
2068
|
-
const cond = JSON.parse(rule.conditions);
|
|
2069
|
-
let match = true;
|
|
2070
|
-
const fromAddr = (email.from?.[0]?.address ?? "").toLowerCase();
|
|
2071
|
-
const toAddr = (email.to?.[0]?.address ?? "").toLowerCase();
|
|
2072
|
-
const subject = (email.subject ?? "").toLowerCase();
|
|
2073
|
-
if (cond.from_contains && !fromAddr.includes(cond.from_contains.toLowerCase())) match = false;
|
|
2074
|
-
if (cond.from_exact && fromAddr !== cond.from_exact.toLowerCase()) match = false;
|
|
2075
|
-
if (cond.subject_contains && !subject.includes(cond.subject_contains.toLowerCase())) match = false;
|
|
2076
|
-
if (cond.subject_regex) {
|
|
2077
|
-
try {
|
|
2078
|
-
if (!new RegExp(cond.subject_regex, "i").test(email.subject ?? "")) match = false;
|
|
2079
|
-
} catch {
|
|
2080
|
-
match = false;
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
if (cond.to_contains && !toAddr.includes(cond.to_contains.toLowerCase())) match = false;
|
|
2084
|
-
if (cond.has_attachment === true && (!email.attachments || email.attachments.length === 0)) match = false;
|
|
2085
|
-
if (match) return { ruleId: rule.id, actions: JSON.parse(rule.actions) };
|
|
2086
|
-
}
|
|
2087
|
-
return null;
|
|
2088
|
-
}
|
|
2089
|
-
function startScheduledSender(db, accountManager, config, gatewayManager) {
|
|
2090
|
-
return setInterval(async () => {
|
|
2091
|
-
try {
|
|
2092
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2093
|
-
const pending = db.prepare(
|
|
2094
|
-
"SELECT * FROM scheduled_emails WHERE status = 'pending' AND send_at <= ?"
|
|
2095
|
-
).all(now);
|
|
2096
|
-
for (const row of pending) {
|
|
2097
|
-
try {
|
|
2098
|
-
const agent = await accountManager.getById(row.agent_id);
|
|
2099
|
-
if (!agent) {
|
|
2100
|
-
db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run("Agent not found", row.id);
|
|
2101
|
-
continue;
|
|
2102
|
-
}
|
|
2103
|
-
const mailOpts = {
|
|
2104
|
-
to: row.to_addr,
|
|
2105
|
-
subject: row.subject,
|
|
2106
|
-
text: row.text_body || void 0,
|
|
2107
|
-
html: row.html_body || void 0,
|
|
2108
|
-
cc: row.cc || void 0,
|
|
2109
|
-
bcc: row.bcc || void 0
|
|
2110
|
-
};
|
|
2111
|
-
if (gatewayManager) {
|
|
2112
|
-
const gResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
|
|
2113
|
-
if (gResult) {
|
|
2114
|
-
db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
|
|
2115
|
-
continue;
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
const password = agent.metadata?._password || agent.name;
|
|
2119
|
-
const sender = new MailSender3({
|
|
2120
|
-
host: config.smtp.host,
|
|
2121
|
-
port: config.smtp.port,
|
|
2122
|
-
email: agent.email,
|
|
2123
|
-
password,
|
|
2124
|
-
authUser: agent.stalwartPrincipal
|
|
2125
|
-
});
|
|
2126
|
-
try {
|
|
2127
|
-
await sender.send(mailOpts);
|
|
2128
|
-
db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
|
|
2129
|
-
} finally {
|
|
2130
|
-
sender.close();
|
|
2131
|
-
}
|
|
2132
|
-
} catch (err) {
|
|
2133
|
-
db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run(err.message, row.id);
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2136
|
-
try {
|
|
2137
|
-
db.prepare("DELETE FROM delivered_messages WHERE delivered_at < datetime('now', '-30 days')").run();
|
|
2138
|
-
} catch {
|
|
2139
|
-
}
|
|
2140
|
-
try {
|
|
2141
|
-
db.prepare("DELETE FROM spam_log WHERE created_at < datetime('now', '-30 days')").run();
|
|
2142
|
-
} catch {
|
|
2143
|
-
}
|
|
2144
|
-
} catch {
|
|
2145
|
-
}
|
|
2146
|
-
}, 3e4);
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
// src/routes/events.ts
|
|
2150
|
-
var MAX_SSE_PER_AGENT = 5;
|
|
2151
|
-
var activeWatchers = /* @__PURE__ */ new Map();
|
|
2152
|
-
function pushEventToAgent(agentId, event) {
|
|
2153
|
-
const watchers = activeWatchers.get(agentId);
|
|
2154
|
-
if (!watchers || watchers.size === 0) return false;
|
|
2155
|
-
const data = `data: ${JSON.stringify(event)}
|
|
2156
|
-
|
|
2157
|
-
`;
|
|
2158
|
-
for (const entry of watchers) {
|
|
2159
|
-
try {
|
|
2160
|
-
entry.res.write(data);
|
|
2161
|
-
} catch {
|
|
2162
|
-
}
|
|
2163
|
-
}
|
|
2164
|
-
return true;
|
|
2165
|
-
}
|
|
2166
|
-
function broadcastEvent(event) {
|
|
2167
|
-
const data = `data: ${JSON.stringify(event)}
|
|
2168
2373
|
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
}
|
|
2180
|
-
return
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
for (const entry of watchers) {
|
|
2185
|
-
try {
|
|
2186
|
-
await entry.watcher.stop();
|
|
2187
|
-
} catch {
|
|
2188
|
-
}
|
|
2189
|
-
try {
|
|
2190
|
-
entry.res.end();
|
|
2191
|
-
} catch {
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
activeWatchers.clear();
|
|
2196
|
-
}
|
|
2197
|
-
function createEventRoutes(accountManager, config, db) {
|
|
2374
|
+
// src/routes/inbound.ts
|
|
2375
|
+
import { Router as Router6 } from "express";
|
|
2376
|
+
import { randomUUID } from "crypto";
|
|
2377
|
+
import {
|
|
2378
|
+
parseEmail as parseEmail3,
|
|
2379
|
+
MailSender as MailSender3
|
|
2380
|
+
} from "@agenticmail/core";
|
|
2381
|
+
var INBOUND_SECRET = process.env.AGENTICMAIL_INBOUND_SECRET || (() => {
|
|
2382
|
+
const generated = randomUUID();
|
|
2383
|
+
console.warn("[Inbound] WARNING: AGENTICMAIL_INBOUND_SECRET is not set. Generated a random secret for this session.");
|
|
2384
|
+
console.warn(`[Inbound] Set AGENTICMAIL_INBOUND_SECRET="${generated}" in your environment to persist it across restarts.`);
|
|
2385
|
+
return generated;
|
|
2386
|
+
})();
|
|
2387
|
+
var DEBUG = () => !!process.env.AGENTICMAIL_DEBUG;
|
|
2388
|
+
function createInboundRoutes(accountManager, config, gatewayManager) {
|
|
2198
2389
|
const router = Router6();
|
|
2199
|
-
router.
|
|
2390
|
+
router.post("/mail/inbound", async (req, res, next) => {
|
|
2200
2391
|
try {
|
|
2201
|
-
const
|
|
2202
|
-
|
|
2203
|
-
|
|
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
|
|
2209
|
-
|
|
2210
|
-
|
|
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
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
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
|
-
|
|
3477
|
+
let val;
|
|
3478
|
+
if (typeof col.default === "string") {
|
|
3479
|
+
const trimmed = col.default.trim();
|
|
3480
|
+
const isSqlExpr = /\(.*\)/.test(trimmed) || /^CURRENT_(?:TIMESTAMP|DATE|TIME)$/i.test(trimmed);
|
|
3481
|
+
val = isSqlExpr ? trimmed : `'${col.default.replace(/'/g, "''")}'`;
|
|
3482
|
+
} else {
|
|
3483
|
+
val = col.default;
|
|
3484
|
+
}
|
|
3406
3485
|
ddl += ` DEFAULT ${val}`;
|
|
3407
3486
|
}
|
|
3408
3487
|
if (col.check) ddl += ` CHECK (${col.check})`;
|
|
@@ -3492,7 +3571,40 @@ function buildWhereClause(where) {
|
|
|
3492
3571
|
function nowExpr(dialect) {
|
|
3493
3572
|
return dialect === "postgres" ? "NOW()" : "datetime('now')";
|
|
3494
3573
|
}
|
|
3495
|
-
function
|
|
3574
|
+
function adaptBetterSqlite(raw) {
|
|
3575
|
+
if (raw && typeof raw.run === "function" && typeof raw.get === "function" && typeof raw.all === "function") {
|
|
3576
|
+
return raw;
|
|
3577
|
+
}
|
|
3578
|
+
const exec = (sql, params) => {
|
|
3579
|
+
if (!params || params.length === 0) {
|
|
3580
|
+
raw.exec(sql);
|
|
3581
|
+
return;
|
|
3582
|
+
}
|
|
3583
|
+
raw.prepare(sql).run(...params);
|
|
3584
|
+
};
|
|
3585
|
+
return {
|
|
3586
|
+
run(sql, params) {
|
|
3587
|
+
const trimmed = sql.trim().toUpperCase();
|
|
3588
|
+
const isDDL = trimmed.startsWith("CREATE") || trimmed.startsWith("ALTER") || trimmed.startsWith("DROP") || trimmed.startsWith("PRAGMA");
|
|
3589
|
+
if (isDDL && (!params || params.length === 0)) {
|
|
3590
|
+
raw.exec(sql);
|
|
3591
|
+
return;
|
|
3592
|
+
}
|
|
3593
|
+
exec(sql, params);
|
|
3594
|
+
},
|
|
3595
|
+
get(sql, params) {
|
|
3596
|
+
const stmt = raw.prepare(sql);
|
|
3597
|
+
return params && params.length > 0 ? stmt.get(...params) : stmt.get();
|
|
3598
|
+
},
|
|
3599
|
+
all(sql, params) {
|
|
3600
|
+
const stmt = raw.prepare(sql);
|
|
3601
|
+
const rows = params && params.length > 0 ? stmt.all(...params) : stmt.all();
|
|
3602
|
+
return rows;
|
|
3603
|
+
}
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite") {
|
|
3607
|
+
const db = adaptBetterSqlite(rawDb);
|
|
3496
3608
|
const router = Router11();
|
|
3497
3609
|
function getAgent(req, res) {
|
|
3498
3610
|
const agent = req.agent;
|