@agenticmail/api 0.5.58 → 0.5.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1610 -1531
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -264,17 +264,17 @@ function createAccountRoutes(accountManager, db, config) {
|
|
|
264
264
|
const router = Router2();
|
|
265
265
|
const deletionService = new AgentDeletionService(db, accountManager, config);
|
|
266
266
|
router.post("/accounts", requireMaster, async (req, res, next) => {
|
|
267
|
+
if (!req.body || typeof req.body !== "object") {
|
|
268
|
+
res.status(400).json({ error: "Request body must be JSON" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const { name: accountName, domain, password, metadata, role, persistent } = req.body;
|
|
267
272
|
try {
|
|
268
|
-
if (!
|
|
269
|
-
res.status(400).json({ error: "Request body must be JSON" });
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
const { name: name2, domain, password, metadata, role, persistent } = req.body;
|
|
273
|
-
if (!name2 || typeof name2 !== "string") {
|
|
273
|
+
if (!accountName || typeof accountName !== "string") {
|
|
274
274
|
res.status(400).json({ error: "name is required and must be a string" });
|
|
275
275
|
return;
|
|
276
276
|
}
|
|
277
|
-
if (
|
|
277
|
+
if (accountName.length > 64) {
|
|
278
278
|
res.status(400).json({ error: "name must be 64 characters or fewer" });
|
|
279
279
|
return;
|
|
280
280
|
}
|
|
@@ -293,7 +293,7 @@ function createAccountRoutes(accountManager, db, config) {
|
|
|
293
293
|
const cleanMeta = metadata ? Object.fromEntries(
|
|
294
294
|
Object.entries(metadata).filter(([k]) => !k.startsWith("_"))
|
|
295
295
|
) : void 0;
|
|
296
|
-
const agent = await accountManager.create({ name:
|
|
296
|
+
const agent = await accountManager.create({ name: accountName, domain, password: password || void 0, metadata: cleanMeta, role });
|
|
297
297
|
try {
|
|
298
298
|
db.prepare("UPDATE agents SET last_activity_at = datetime('now') WHERE id = ?").run(agent.id);
|
|
299
299
|
} catch {
|
|
@@ -310,7 +310,7 @@ function createAccountRoutes(accountManager, db, config) {
|
|
|
310
310
|
} catch (err) {
|
|
311
311
|
const msg = err.message ?? "";
|
|
312
312
|
if (msg.includes("UNIQUE") || msg.includes("unique") || msg.includes("already exists") || msg.includes("duplicate") || msg.includes("fieldAlreadyExists") || msg.toLowerCase().includes("alreadyexists")) {
|
|
313
|
-
res.status(409).json({ error:
|
|
313
|
+
res.status(409).json({ error: "Account already exists", name: accountName });
|
|
314
314
|
return;
|
|
315
315
|
}
|
|
316
316
|
next(err);
|
|
@@ -488,996 +488,916 @@ function createAccountRoutes(accountManager, db, config) {
|
|
|
488
488
|
}
|
|
489
489
|
|
|
490
490
|
// src/routes/mail.ts
|
|
491
|
-
import { Router as
|
|
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, email, notes } = req.body || {};
|
|
648
|
+
if (!email) {
|
|
649
|
+
res.status(400).json({ error: "email is required" });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const id = uuidv4();
|
|
653
|
+
db.prepare("INSERT OR REPLACE INTO contacts (id, agent_id, name, email, notes) VALUES (?, ?, ?, ?, ?)").run(id, req.agent.id, name || null, email, notes || null);
|
|
654
|
+
res.json({ ok: true, id, email });
|
|
655
|
+
} catch (err) {
|
|
656
|
+
next(err);
|
|
593
657
|
}
|
|
594
|
-
}
|
|
595
|
-
const receiver = new MailReceiver({
|
|
596
|
-
host: config.imap.host,
|
|
597
|
-
port: config.imap.port,
|
|
598
|
-
email: authUser,
|
|
599
|
-
password
|
|
600
658
|
});
|
|
601
|
-
|
|
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, text, html, isDefault } = req.body || {};
|
|
799
|
+
if (!name) {
|
|
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, name, text || null, html || null, isDefault ? 1 : 0);
|
|
808
|
+
res.json({ ok: true, id });
|
|
823
809
|
} catch (err) {
|
|
824
810
|
next(err);
|
|
825
811
|
}
|
|
826
812
|
});
|
|
827
|
-
router.
|
|
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, subject, text, html } = req.body || {};
|
|
836
|
+
if (!name) {
|
|
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, name, subject || null, text || null, html || null);
|
|
842
|
+
res.json({ ok: true, id });
|
|
843
|
+
} catch (err) {
|
|
844
|
+
next(err);
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
router.delete("/templates/:id", requireAgent, async (req, res, next) => {
|
|
848
|
+
try {
|
|
849
|
+
const result = db.prepare("DELETE FROM templates WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
|
|
850
|
+
if (result.changes === 0) {
|
|
851
|
+
res.status(404).json({ error: "Template not found" });
|
|
875
852
|
return;
|
|
876
853
|
}
|
|
877
|
-
|
|
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, color } = req.body || {};
|
|
916
|
+
if (!name) {
|
|
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, name.trim(), color || "#888888");
|
|
922
|
+
res.json({ ok: true, id, name: name.trim(), color: color || "#888888" });
|
|
970
923
|
} catch (err) {
|
|
971
924
|
next(err);
|
|
972
925
|
}
|
|
973
926
|
});
|
|
974
|
-
router.
|
|
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, conditions, actions, priority, enabled } = req.body || {};
|
|
1053
|
+
if (!name) {
|
|
1054
|
+
res.status(400).json({ error: "name is required" });
|
|
1131
1055
|
return;
|
|
1132
1056
|
}
|
|
1133
|
-
const
|
|
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, name, priority ?? 0, enabled !== false ? 1 : 0, JSON.stringify(conditions || {}), JSON.stringify(actions || {}));
|
|
1061
|
+
res.status(201).json({ id, name, conditions: conditions || {}, actions: actions || {}, priority: priority ?? 0, enabled: enabled !== false });
|
|
1137
1062
|
} catch (err) {
|
|
1138
1063
|
next(err);
|
|
1139
1064
|
}
|
|
1140
1065
|
});
|
|
1141
|
-
router.
|
|
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
|
-
next(err);
|
|
1202
|
-
}
|
|
1203
|
-
});
|
|
1204
|
-
router.post("/mail/messages/:uid/not-spam", requireAgent, async (req, res, next) => {
|
|
1205
|
-
try {
|
|
1206
|
-
const agent = req.agent;
|
|
1207
|
-
const uid = parseInt(req.params.uid);
|
|
1208
|
-
if (isNaN(uid) || uid < 1) {
|
|
1209
|
-
res.status(400).json({ error: "Invalid UID" });
|
|
1210
|
-
return;
|
|
1155
|
+
try {
|
|
1156
|
+
db.prepare("DELETE FROM spam_log WHERE created_at < datetime('now', '-30 days')").run();
|
|
1157
|
+
} catch {
|
|
1211
1158
|
}
|
|
1212
|
-
|
|
1213
|
-
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1214
|
-
await receiver.moveMessage(uid, "Spam", "INBOX");
|
|
1215
|
-
res.json({ ok: true, movedToInbox: true });
|
|
1216
|
-
} catch (err) {
|
|
1217
|
-
next(err);
|
|
1159
|
+
} catch {
|
|
1218
1160
|
}
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1161
|
+
}, 3e4);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// src/routes/events.ts
|
|
1165
|
+
var MAX_SSE_PER_AGENT = 5;
|
|
1166
|
+
var activeWatchers = /* @__PURE__ */ new Map();
|
|
1167
|
+
function pushEventToAgent(agentId, event) {
|
|
1168
|
+
const watchers = activeWatchers.get(agentId);
|
|
1169
|
+
if (!watchers || watchers.size === 0) return false;
|
|
1170
|
+
const data = `data: ${JSON.stringify(event)}
|
|
1171
|
+
|
|
1172
|
+
`;
|
|
1173
|
+
for (const entry of watchers) {
|
|
1221
1174
|
try {
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1175
|
+
entry.res.write(data);
|
|
1176
|
+
} catch {
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
function broadcastEvent(event) {
|
|
1182
|
+
const data = `data: ${JSON.stringify(event)}
|
|
1183
|
+
|
|
1184
|
+
`;
|
|
1185
|
+
let count = 0;
|
|
1186
|
+
for (const [, watchers] of activeWatchers) {
|
|
1187
|
+
for (const entry of watchers) {
|
|
1188
|
+
try {
|
|
1189
|
+
entry.res.write(data);
|
|
1190
|
+
count++;
|
|
1191
|
+
} catch {
|
|
1227
1192
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return count;
|
|
1196
|
+
}
|
|
1197
|
+
async function closeAllWatchers() {
|
|
1198
|
+
for (const [, watchers] of activeWatchers) {
|
|
1199
|
+
for (const entry of watchers) {
|
|
1200
|
+
try {
|
|
1201
|
+
await entry.watcher.stop();
|
|
1202
|
+
} catch {
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
entry.res.end();
|
|
1206
|
+
} catch {
|
|
1236
1207
|
}
|
|
1237
|
-
const result = scoreEmail(parsed);
|
|
1238
|
-
res.json(result);
|
|
1239
|
-
} catch (err) {
|
|
1240
|
-
next(err);
|
|
1241
1208
|
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
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
|
-
|
|
1237
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1238
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1239
|
+
res.setHeader("Connection", "keep-alive");
|
|
1240
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
1241
|
+
res.flushHeaders();
|
|
1242
|
+
let closed = false;
|
|
1243
|
+
const entry = { watcher, res };
|
|
1244
|
+
activeWatchers.set(agent.id, agentWatchers);
|
|
1245
|
+
agentWatchers.add(entry);
|
|
1246
|
+
const safeWrite = (data) => {
|
|
1247
|
+
if (!closed) {
|
|
1248
|
+
try {
|
|
1249
|
+
res.write(data);
|
|
1250
|
+
} catch {
|
|
1347
1251
|
}
|
|
1348
1252
|
}
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
if (
|
|
1355
|
-
|
|
1356
|
-
|
|
1253
|
+
};
|
|
1254
|
+
safeWrite(`data: ${JSON.stringify({ type: "connected", agentId: agent.id })}
|
|
1255
|
+
|
|
1256
|
+
`);
|
|
1257
|
+
watcher.on("new", async (event) => {
|
|
1258
|
+
if (db) touchActivity(db, agent.id);
|
|
1259
|
+
if (db && event.uid) {
|
|
1260
|
+
try {
|
|
1261
|
+
const receiver = new MailReceiver({
|
|
1262
|
+
host: config.imap.host,
|
|
1263
|
+
port: config.imap.port,
|
|
1264
|
+
email: agent.stalwartPrincipal,
|
|
1265
|
+
password,
|
|
1266
|
+
secure: false
|
|
1267
|
+
});
|
|
1268
|
+
await receiver.connect();
|
|
1269
|
+
try {
|
|
1270
|
+
const raw = await receiver.fetchMessage(event.uid);
|
|
1271
|
+
const parsed = await parseEmail(raw);
|
|
1272
|
+
const policyMetadata = agent.metadata && typeof agent.metadata === "object" ? {
|
|
1273
|
+
emailRoutePolicy: agent.metadata.emailRoutePolicy,
|
|
1274
|
+
routePolicy: agent.metadata.routePolicy,
|
|
1275
|
+
mailboxPolicy: agent.metadata.mailboxPolicy
|
|
1276
|
+
} : void 0;
|
|
1277
|
+
const accountRouteContext = {
|
|
1278
|
+
name: agent.name,
|
|
1279
|
+
email: agent.email,
|
|
1280
|
+
role: agent.role,
|
|
1281
|
+
metadata: policyMetadata
|
|
1282
|
+
};
|
|
1283
|
+
const isRelay = !!parsed.headers.get("x-agenticmail-relay");
|
|
1284
|
+
const internal = !isRelay && isInternalEmail(parsed);
|
|
1285
|
+
if (internal) {
|
|
1286
|
+
event.route = classifyEmailRoute({ email: parsed, account: accountRouteContext });
|
|
1287
|
+
const ruleResult2 = evaluateRules(db, agent.id, parsed);
|
|
1288
|
+
if (ruleResult2) {
|
|
1289
|
+
const actions = ruleResult2.actions;
|
|
1290
|
+
if (actions.mark_read) await receiver.markSeen(event.uid);
|
|
1291
|
+
if (actions.delete) {
|
|
1292
|
+
await receiver.deleteMessage(event.uid);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
|
|
1296
|
+
event.ruleApplied = { ruleId: ruleResult2.ruleId, actions };
|
|
1297
|
+
}
|
|
1298
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1299
|
+
|
|
1300
|
+
`);
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
const spamResult = scoreEmail(parsed);
|
|
1304
|
+
event.route = classifyEmailRoute({ email: parsed, spam: spamResult, account: accountRouteContext });
|
|
1305
|
+
try {
|
|
1306
|
+
db.prepare(
|
|
1307
|
+
"INSERT INTO spam_log (id, agent_id, message_uid, score, flags, category, is_spam) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
1308
|
+
).run(
|
|
1309
|
+
uuidv42(),
|
|
1310
|
+
agent.id,
|
|
1311
|
+
event.uid,
|
|
1312
|
+
spamResult.score,
|
|
1313
|
+
JSON.stringify(spamResult.matches.map((m) => m.ruleId)),
|
|
1314
|
+
spamResult.topCategory,
|
|
1315
|
+
spamResult.isSpam ? 1 : 0
|
|
1316
|
+
);
|
|
1317
|
+
} catch {
|
|
1318
|
+
}
|
|
1319
|
+
if (spamResult.isSpam) {
|
|
1320
|
+
try {
|
|
1321
|
+
await receiver.createFolder("Spam");
|
|
1322
|
+
} catch {
|
|
1323
|
+
}
|
|
1324
|
+
await receiver.moveMessage(event.uid, "INBOX", "Spam");
|
|
1325
|
+
event.spam = { score: spamResult.score, category: spamResult.topCategory, movedToSpam: true };
|
|
1326
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1327
|
+
|
|
1328
|
+
`);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (spamResult.isWarning) {
|
|
1332
|
+
event.spamWarning = { score: spamResult.score, category: spamResult.topCategory, matches: spamResult.matches.map((m) => m.ruleId) };
|
|
1333
|
+
}
|
|
1334
|
+
const ruleResult = evaluateRules(db, agent.id, parsed);
|
|
1335
|
+
if (ruleResult) {
|
|
1336
|
+
const actions = ruleResult.actions;
|
|
1337
|
+
if (actions.mark_read) await receiver.markSeen(event.uid);
|
|
1338
|
+
if (actions.delete) {
|
|
1339
|
+
await receiver.deleteMessage(event.uid);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (actions.move_to) await receiver.moveMessage(event.uid, "INBOX", actions.move_to);
|
|
1343
|
+
event.ruleApplied = { ruleId: ruleResult.ruleId, actions };
|
|
1344
|
+
}
|
|
1345
|
+
} finally {
|
|
1346
|
+
await receiver.disconnect();
|
|
1347
|
+
}
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
console.error("[SSE] Spam/rule evaluation error:", err.message);
|
|
1357
1350
|
}
|
|
1358
|
-
const { raw: _raw, ...rest } = gatewayResult;
|
|
1359
|
-
response = rest;
|
|
1360
1351
|
}
|
|
1361
|
-
|
|
1362
|
-
if (!response) {
|
|
1363
|
-
const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
|
|
1364
|
-
const result = await sender.send(mailOpts);
|
|
1365
|
-
saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
|
|
1366
|
-
const { raw: _raw, ...rest } = result;
|
|
1367
|
-
response = rest;
|
|
1368
|
-
}
|
|
1369
|
-
db.prepare(
|
|
1370
|
-
`UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
|
|
1371
|
-
).run("master", row.id);
|
|
1372
|
-
res.json({ ...response, approved: true, pendingId: row.id });
|
|
1373
|
-
} catch (err) {
|
|
1374
|
-
next(err);
|
|
1375
|
-
}
|
|
1376
|
-
});
|
|
1377
|
-
router.post("/mail/pending/:id/reject", requireMaster, async (req, res) => {
|
|
1378
|
-
const row = db.prepare(
|
|
1379
|
-
`SELECT * FROM pending_outbound WHERE id = ?`
|
|
1380
|
-
).get(req.params.id);
|
|
1381
|
-
if (!row) {
|
|
1382
|
-
res.status(404).json({ error: "Pending email not found" });
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
if (row.status !== "pending") {
|
|
1386
|
-
res.status(400).json({ error: `Email already ${row.status}` });
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
db.prepare(
|
|
1390
|
-
`UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
|
|
1391
|
-
).run("master", row.id);
|
|
1392
|
-
res.json({ ok: true, rejected: true, pendingId: row.id });
|
|
1393
|
-
});
|
|
1394
|
-
return router;
|
|
1395
|
-
}
|
|
1352
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1396
1353
|
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
return;
|
|
1419
|
-
}
|
|
1420
|
-
const { from, to, subject, rawEmail } = req.body;
|
|
1421
|
-
if (!to || !rawEmail) {
|
|
1422
|
-
res.status(400).json({ error: "to and rawEmail are required" });
|
|
1423
|
-
return;
|
|
1424
|
-
}
|
|
1425
|
-
const recipientEmail = typeof to === "string" ? to : to[0];
|
|
1426
|
-
const localPart = recipientEmail.split("@")[0];
|
|
1427
|
-
const agent = await accountManager.getByName(localPart);
|
|
1428
|
-
if (!agent) {
|
|
1429
|
-
console.warn(`[Inbound] No agent found for "${localPart}" (${recipientEmail})`);
|
|
1430
|
-
res.status(404).json({ error: `No agent found for ${recipientEmail}` });
|
|
1431
|
-
return;
|
|
1432
|
-
}
|
|
1433
|
-
const agentPassword = agent.metadata?._password;
|
|
1434
|
-
if (!agentPassword) {
|
|
1435
|
-
console.warn(`[Inbound] No password for agent "${agent.name}"`);
|
|
1436
|
-
res.status(500).json({ error: "Agent has no password configured" });
|
|
1437
|
-
return;
|
|
1438
|
-
}
|
|
1439
|
-
const rawBuffer = Buffer.from(rawEmail, "base64");
|
|
1440
|
-
const parsed = await parseEmail2(rawBuffer);
|
|
1441
|
-
const originalMessageId = parsed.messageId;
|
|
1442
|
-
if (originalMessageId && gatewayManager?.isAlreadyDelivered(originalMessageId, agent.name)) {
|
|
1443
|
-
if (DEBUG()) console.log(`[Inbound] Skipping duplicate: ${originalMessageId} \u2192 ${agent.name}`);
|
|
1444
|
-
res.json({ ok: true, delivered: agent.email, duplicate: true });
|
|
1445
|
-
return;
|
|
1446
|
-
}
|
|
1447
|
-
if (DEBUG()) console.log(`[Inbound] Delivering email to ${agent.email} from ${from} (subject: ${subject || parsed.subject})`);
|
|
1448
|
-
const sender = new MailSender2({
|
|
1449
|
-
host: config.smtp.host,
|
|
1450
|
-
port: config.smtp.port,
|
|
1451
|
-
email: agent.email,
|
|
1452
|
-
password: agentPassword,
|
|
1453
|
-
authUser: agent.stalwartPrincipal
|
|
1354
|
+
`);
|
|
1355
|
+
});
|
|
1356
|
+
watcher.on("expunge", (event) => {
|
|
1357
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1358
|
+
|
|
1359
|
+
`);
|
|
1360
|
+
});
|
|
1361
|
+
watcher.on("flags", (event) => {
|
|
1362
|
+
safeWrite(`data: ${JSON.stringify(event)}
|
|
1363
|
+
|
|
1364
|
+
`);
|
|
1365
|
+
});
|
|
1366
|
+
watcher.on("error", (err) => {
|
|
1367
|
+
safeWrite(`data: ${JSON.stringify({ type: "error", message: err.message })}
|
|
1368
|
+
|
|
1369
|
+
`);
|
|
1370
|
+
});
|
|
1371
|
+
watcher.on("reconnecting", (info) => {
|
|
1372
|
+
safeWrite(`data: ${JSON.stringify({ type: "reconnecting", attempt: info.attempt, delayMs: info.delayMs })}
|
|
1373
|
+
|
|
1374
|
+
`);
|
|
1454
1375
|
});
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1376
|
+
watcher.on("reconnected", (info) => {
|
|
1377
|
+
safeWrite(`data: ${JSON.stringify({ type: "reconnected", attempt: info.attempt })}
|
|
1378
|
+
|
|
1379
|
+
`);
|
|
1380
|
+
});
|
|
1381
|
+
watcher.on("reconnect_failed", (info) => {
|
|
1382
|
+
safeWrite(`data: ${JSON.stringify({ type: "reconnect_failed", attempts: info.attempts })}
|
|
1383
|
+
|
|
1384
|
+
`);
|
|
1385
|
+
});
|
|
1386
|
+
const pingInterval = setInterval(() => {
|
|
1387
|
+
safeWrite(`: ping
|
|
1388
|
+
|
|
1389
|
+
`);
|
|
1390
|
+
}, 3e4);
|
|
1391
|
+
req.on("close", () => {
|
|
1392
|
+
closed = true;
|
|
1393
|
+
clearInterval(pingInterval);
|
|
1394
|
+
agentWatchers.delete(entry);
|
|
1395
|
+
if (agentWatchers.size === 0) activeWatchers.delete(agent.id);
|
|
1396
|
+
watcher.removeAllListeners();
|
|
1397
|
+
watcher.stop().catch((err) => {
|
|
1398
|
+
console.error("[SSE] Watcher cleanup error:", err);
|
|
1474
1399
|
});
|
|
1475
|
-
|
|
1476
|
-
if (DEBUG()) console.log(`[Inbound] Delivered to ${agent.email}`);
|
|
1477
|
-
res.json({ ok: true, delivered: agent.email });
|
|
1478
|
-
} finally {
|
|
1479
|
-
sender.close();
|
|
1480
|
-
}
|
|
1400
|
+
});
|
|
1481
1401
|
} catch (err) {
|
|
1482
1402
|
next(err);
|
|
1483
1403
|
}
|
|
@@ -1485,904 +1405,1056 @@ function createInboundRoutes(accountManager, config, gatewayManager) {
|
|
|
1485
1405
|
return router;
|
|
1486
1406
|
}
|
|
1487
1407
|
|
|
1488
|
-
// src/routes/
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1408
|
+
// src/routes/mail.ts
|
|
1409
|
+
var senderCache = /* @__PURE__ */ new Map();
|
|
1410
|
+
var receiverCache = /* @__PURE__ */ new Map();
|
|
1411
|
+
var receiverPending = /* @__PURE__ */ new Map();
|
|
1412
|
+
var CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
1413
|
+
var MAX_CACHE_SIZE = 100;
|
|
1414
|
+
var draining = false;
|
|
1415
|
+
function getAgentPassword(agent) {
|
|
1416
|
+
return agent.metadata?._password || agent.name;
|
|
1417
|
+
}
|
|
1418
|
+
var evictionTimer = null;
|
|
1419
|
+
function startEvictionTimer() {
|
|
1420
|
+
if (evictionTimer) return;
|
|
1421
|
+
evictionTimer = setInterval(evictStaleEntries, 6e4);
|
|
1422
|
+
}
|
|
1423
|
+
function evictStaleEntries() {
|
|
1424
|
+
const now = Date.now();
|
|
1425
|
+
for (const [key, entry] of senderCache) {
|
|
1426
|
+
if (now - entry.createdAt > CACHE_TTL_MS) {
|
|
1427
|
+
try {
|
|
1428
|
+
entry.sender.close();
|
|
1429
|
+
} catch {
|
|
1430
|
+
}
|
|
1431
|
+
senderCache.delete(key);
|
|
1432
|
+
}
|
|
1511
1433
|
}
|
|
1512
|
-
const
|
|
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);
|
|
1434
|
+
for (const [key, entry] of receiverCache) {
|
|
1435
|
+
if (now - entry.createdAt > CACHE_TTL_MS) {
|
|
1436
|
+
entry.receiver.disconnect().catch(() => {
|
|
1437
|
+
});
|
|
1438
|
+
receiverCache.delete(key);
|
|
1439
|
+
}
|
|
1521
1440
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
if (
|
|
1531
|
-
|
|
1532
|
-
|
|
1441
|
+
}
|
|
1442
|
+
function getSender(authUser, fromEmail, password, config) {
|
|
1443
|
+
if (draining) throw new Error("Server is shutting down");
|
|
1444
|
+
const cacheKey = `${authUser}:${fromEmail}`;
|
|
1445
|
+
const cached = senderCache.get(cacheKey);
|
|
1446
|
+
if (cached) return cached.sender;
|
|
1447
|
+
if (senderCache.size >= MAX_CACHE_SIZE) {
|
|
1448
|
+
const oldest = senderCache.keys().next().value;
|
|
1449
|
+
if (oldest) {
|
|
1450
|
+
try {
|
|
1451
|
+
senderCache.get(oldest)?.sender.close();
|
|
1452
|
+
} catch {
|
|
1453
|
+
}
|
|
1454
|
+
senderCache.delete(oldest);
|
|
1455
|
+
}
|
|
1533
1456
|
}
|
|
1534
|
-
const
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1457
|
+
const sender = new MailSender2({
|
|
1458
|
+
host: config.smtp.host,
|
|
1459
|
+
port: config.smtp.port,
|
|
1460
|
+
email: fromEmail,
|
|
1461
|
+
password,
|
|
1462
|
+
authUser
|
|
1463
|
+
});
|
|
1464
|
+
senderCache.set(cacheKey, { sender, createdAt: Date.now() });
|
|
1465
|
+
startEvictionTimer();
|
|
1466
|
+
return sender;
|
|
1467
|
+
}
|
|
1468
|
+
async function getReceiver(authUser, password, config) {
|
|
1469
|
+
if (draining) throw new Error("Server is shutting down");
|
|
1470
|
+
const cached = receiverCache.get(authUser);
|
|
1471
|
+
if (cached) {
|
|
1472
|
+
try {
|
|
1473
|
+
const client = cached.receiver.getImapClient();
|
|
1474
|
+
if (client.usable) return cached.receiver;
|
|
1475
|
+
} catch {
|
|
1476
|
+
}
|
|
1477
|
+
try {
|
|
1478
|
+
await cached.receiver.disconnect();
|
|
1479
|
+
} catch {
|
|
1480
|
+
}
|
|
1481
|
+
receiverCache.delete(authUser);
|
|
1550
1482
|
}
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1483
|
+
const pending = receiverPending.get(authUser);
|
|
1484
|
+
if (pending) return pending;
|
|
1485
|
+
const promise = createReceiver(authUser, password, config);
|
|
1486
|
+
receiverPending.set(authUser, promise);
|
|
1487
|
+
try {
|
|
1488
|
+
return await promise;
|
|
1489
|
+
} finally {
|
|
1490
|
+
receiverPending.delete(authUser);
|
|
1556
1491
|
}
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
)
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
let hour = parseInt(hStr, 10);
|
|
1566
|
-
const min = parseInt(minStr, 10);
|
|
1567
|
-
const ampm = ampmRaw.toUpperCase();
|
|
1568
|
-
if (month < 1 || month > 12 || day < 1 || day > 31 || hour < 1 || hour > 12) return null;
|
|
1569
|
-
if (ampm === "PM" && hour !== 12) hour += 12;
|
|
1570
|
-
if (ampm === "AM" && hour === 12) hour = 0;
|
|
1571
|
-
const result = new Date(year, month - 1, day, hour, min, 0, 0);
|
|
1572
|
-
if (tzRaw?.trim()) {
|
|
1573
|
-
const TZ_OFFSETS = {
|
|
1574
|
-
EST: -5,
|
|
1575
|
-
EDT: -4,
|
|
1576
|
-
CST: -6,
|
|
1577
|
-
CDT: -5,
|
|
1578
|
-
MST: -7,
|
|
1579
|
-
MDT: -6,
|
|
1580
|
-
PST: -8,
|
|
1581
|
-
PDT: -7,
|
|
1582
|
-
GMT: 0,
|
|
1583
|
-
UTC: 0,
|
|
1584
|
-
BST: 1,
|
|
1585
|
-
CET: 1,
|
|
1586
|
-
CEST: 2,
|
|
1587
|
-
IST: 5.5,
|
|
1588
|
-
JST: 9,
|
|
1589
|
-
AEST: 10,
|
|
1590
|
-
AEDT: 11,
|
|
1591
|
-
NZST: 12,
|
|
1592
|
-
NZDT: 13,
|
|
1593
|
-
WAT: 1,
|
|
1594
|
-
EAT: 3,
|
|
1595
|
-
SAST: 2,
|
|
1596
|
-
HKT: 8,
|
|
1597
|
-
SGT: 8,
|
|
1598
|
-
KST: 9,
|
|
1599
|
-
HST: -10,
|
|
1600
|
-
AKST: -9,
|
|
1601
|
-
AKDT: -8,
|
|
1602
|
-
AST: -4,
|
|
1603
|
-
ADT: -3,
|
|
1604
|
-
NST: -3.5,
|
|
1605
|
-
NDT: -2.5
|
|
1606
|
-
};
|
|
1607
|
-
const tz = tzRaw.trim().toUpperCase();
|
|
1608
|
-
if (TZ_OFFSETS[tz] !== void 0) {
|
|
1609
|
-
const tzOffsetMs = TZ_OFFSETS[tz] * 36e5;
|
|
1610
|
-
const serverOffsetMs = result.getTimezoneOffset() * -6e4;
|
|
1611
|
-
const diff = serverOffsetMs - tzOffsetMs;
|
|
1612
|
-
result.setTime(result.getTime() + diff);
|
|
1613
|
-
}
|
|
1492
|
+
}
|
|
1493
|
+
async function createReceiver(authUser, password, config) {
|
|
1494
|
+
if (receiverCache.size >= MAX_CACHE_SIZE) {
|
|
1495
|
+
const oldest = receiverCache.keys().next().value;
|
|
1496
|
+
if (oldest) {
|
|
1497
|
+
receiverCache.get(oldest)?.receiver.disconnect().catch(() => {
|
|
1498
|
+
});
|
|
1499
|
+
receiverCache.delete(oldest);
|
|
1614
1500
|
}
|
|
1615
|
-
return isNaN(result.getTime()) ? null : result;
|
|
1616
1501
|
}
|
|
1617
|
-
const
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1502
|
+
const receiver = new MailReceiver2({
|
|
1503
|
+
host: config.imap.host,
|
|
1504
|
+
port: config.imap.port,
|
|
1505
|
+
email: authUser,
|
|
1506
|
+
password
|
|
1507
|
+
});
|
|
1508
|
+
try {
|
|
1509
|
+
await receiver.connect();
|
|
1510
|
+
} catch (err) {
|
|
1623
1511
|
try {
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
} catch (err) {
|
|
1627
|
-
next(err);
|
|
1512
|
+
await receiver.disconnect();
|
|
1513
|
+
} catch {
|
|
1628
1514
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1515
|
+
throw err;
|
|
1516
|
+
}
|
|
1517
|
+
receiverCache.set(authUser, { receiver, createdAt: Date.now() });
|
|
1518
|
+
startEvictionTimer();
|
|
1519
|
+
return receiver;
|
|
1520
|
+
}
|
|
1521
|
+
async function closeCaches() {
|
|
1522
|
+
draining = true;
|
|
1523
|
+
if (evictionTimer) {
|
|
1524
|
+
clearInterval(evictionTimer);
|
|
1525
|
+
evictionTimer = null;
|
|
1526
|
+
}
|
|
1527
|
+
for (const [, entry] of senderCache) {
|
|
1631
1528
|
try {
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
res.status(400).json({ error: "email is required" });
|
|
1635
|
-
return;
|
|
1636
|
-
}
|
|
1637
|
-
const id = uuidv4();
|
|
1638
|
-
db.prepare("INSERT OR REPLACE INTO contacts (id, agent_id, name, email, notes) VALUES (?, ?, ?, ?, ?)").run(id, req.agent.id, name2 || null, email, notes || null);
|
|
1639
|
-
res.json({ ok: true, id, email });
|
|
1640
|
-
} catch (err) {
|
|
1641
|
-
next(err);
|
|
1529
|
+
entry.sender.close();
|
|
1530
|
+
} catch {
|
|
1642
1531
|
}
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1532
|
+
}
|
|
1533
|
+
senderCache.clear();
|
|
1534
|
+
for (const [, entry] of receiverCache) {
|
|
1645
1535
|
try {
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
res.status(404).json({ error: "Contact not found" });
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
|
-
res.json({ ok: true });
|
|
1652
|
-
} catch (err) {
|
|
1653
|
-
next(err);
|
|
1536
|
+
await entry.receiver.disconnect();
|
|
1537
|
+
} catch {
|
|
1654
1538
|
}
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1539
|
+
}
|
|
1540
|
+
receiverCache.clear();
|
|
1541
|
+
}
|
|
1542
|
+
async function notifyLocalRecipientsOfNewMail(accountManager, toField, ccField, bccField, fromAgent, subject, messageId) {
|
|
1543
|
+
const collected = [];
|
|
1544
|
+
const push = (v) => {
|
|
1545
|
+
if (!v) return;
|
|
1546
|
+
if (Array.isArray(v)) collected.push(...v);
|
|
1547
|
+
else collected.push(v);
|
|
1548
|
+
};
|
|
1549
|
+
push(toField);
|
|
1550
|
+
push(ccField);
|
|
1551
|
+
push(bccField);
|
|
1552
|
+
const addrRe = /<([^>]+)>|([^\s,;<>]+@[^\s,;<>]+)/g;
|
|
1553
|
+
const addresses = /* @__PURE__ */ new Set();
|
|
1554
|
+
for (const entry of collected) {
|
|
1555
|
+
let match;
|
|
1556
|
+
addrRe.lastIndex = 0;
|
|
1557
|
+
while ((match = addrRe.exec(entry)) !== null) {
|
|
1558
|
+
const a = (match[1] || match[2] || "").trim().toLowerCase();
|
|
1559
|
+
if (a) addresses.add(a);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
const notified = /* @__PURE__ */ new Set();
|
|
1563
|
+
for (const addr of addresses) {
|
|
1564
|
+
const at = addr.indexOf("@");
|
|
1565
|
+
if (at < 0) continue;
|
|
1566
|
+
const localPart = addr.slice(0, at);
|
|
1567
|
+
const domain = addr.slice(at + 1);
|
|
1568
|
+
if (domain !== "localhost") continue;
|
|
1569
|
+
if (addr === fromAgent.email.toLowerCase()) continue;
|
|
1570
|
+
let recipient = null;
|
|
1571
|
+
try {
|
|
1572
|
+
recipient = await accountManager.getByName(localPart);
|
|
1573
|
+
} catch {
|
|
1574
|
+
}
|
|
1575
|
+
if (!recipient || notified.has(recipient.id)) continue;
|
|
1576
|
+
notified.add(recipient.id);
|
|
1577
|
+
pushEventToAgent(recipient.id, {
|
|
1578
|
+
type: "new",
|
|
1579
|
+
// uid is unknown without an IMAP fetch; use 0 as a sentinel —
|
|
1580
|
+
// this matches the watcher's autoFetch=false path. SSE consumers
|
|
1581
|
+
// that want full message detail can call /mail/inbox.
|
|
1582
|
+
uid: 0,
|
|
1583
|
+
internal: true,
|
|
1584
|
+
from: { name: fromAgent.name, address: fromAgent.email },
|
|
1585
|
+
subject,
|
|
1586
|
+
messageId
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function saveSentCopy(authUser, password, config, raw) {
|
|
1591
|
+
(async () => {
|
|
1657
1592
|
try {
|
|
1658
|
-
const
|
|
1659
|
-
|
|
1593
|
+
const receiver = await getReceiver(authUser, password, config);
|
|
1594
|
+
await receiver.appendMessage(raw, "Sent Items", ["\\Seen"]);
|
|
1660
1595
|
} catch (err) {
|
|
1661
|
-
|
|
1596
|
+
console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
|
|
1662
1597
|
}
|
|
1663
|
-
});
|
|
1664
|
-
|
|
1598
|
+
})();
|
|
1599
|
+
}
|
|
1600
|
+
function createMailRoutes(accountManager, config, db, gatewayManager) {
|
|
1601
|
+
const router = Router5();
|
|
1602
|
+
router.post("/mail/send", requireAgent, async (req, res, next) => {
|
|
1665
1603
|
try {
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
subject
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1604
|
+
if (!req.body || typeof req.body !== "object") {
|
|
1605
|
+
res.status(400).json({ error: "Request body must be JSON" });
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
const agent = req.agent;
|
|
1609
|
+
const { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, allowSensitive } = req.body;
|
|
1610
|
+
if (!to || !subject) {
|
|
1611
|
+
res.status(400).json({ error: "to and subject are required" });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (typeof to !== "string" && !Array.isArray(to)) {
|
|
1615
|
+
res.status(400).json({ error: "to must be a string or array of strings" });
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
let outboundWarnings;
|
|
1619
|
+
let outboundSummary;
|
|
1620
|
+
if (!(allowSensitive && req.isMaster)) {
|
|
1621
|
+
const scanResult = scanOutboundEmail({
|
|
1622
|
+
to: Array.isArray(to) ? to.join(", ") : to,
|
|
1623
|
+
subject,
|
|
1624
|
+
text,
|
|
1625
|
+
html,
|
|
1626
|
+
attachments: Array.isArray(attachments) ? attachments.map((a) => ({
|
|
1627
|
+
filename: a.filename || "",
|
|
1628
|
+
contentType: a.contentType,
|
|
1629
|
+
content: a.content,
|
|
1630
|
+
encoding: a.encoding
|
|
1631
|
+
})) : void 0
|
|
1632
|
+
});
|
|
1633
|
+
if (scanResult.blocked) {
|
|
1634
|
+
const pendingId = crypto.randomUUID();
|
|
1635
|
+
const ownerName2 = agent.metadata?.ownerName;
|
|
1636
|
+
const fromName2 = ownerName2 ? `${agent.name} from ${ownerName2}` : agent.name;
|
|
1637
|
+
const mailOptions = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName: fromName2 };
|
|
1638
|
+
db.prepare(
|
|
1639
|
+
`INSERT INTO pending_outbound (id, agent_id, mail_options, warnings, summary) VALUES (?, ?, ?, ?, ?)`
|
|
1640
|
+
).run(pendingId, agent.id, JSON.stringify(mailOptions), JSON.stringify(scanResult.warnings), scanResult.summary);
|
|
1641
|
+
if (gatewayManager) {
|
|
1642
|
+
const ownerEmail = gatewayManager.getConfig()?.relay?.email;
|
|
1643
|
+
if (ownerEmail) {
|
|
1644
|
+
const warningList = scanResult.warnings.map((w) => ` - [${w.severity.toUpperCase()}] ${w.ruleId}: ${w.description}${w.match ? ` (matched: ${w.match})` : ""}`).join("\n");
|
|
1645
|
+
const recipientLine = Array.isArray(to) ? to.join(", ") : to;
|
|
1646
|
+
const emailPreview = [
|
|
1647
|
+
"\u2500".repeat(50),
|
|
1648
|
+
`From: ${fromName2} <${agent.email}>`,
|
|
1649
|
+
`To: ${recipientLine}`
|
|
1650
|
+
];
|
|
1651
|
+
if (cc) emailPreview.push(`CC: ${Array.isArray(cc) ? cc.join(", ") : cc}`);
|
|
1652
|
+
if (bcc) emailPreview.push(`BCC: ${Array.isArray(bcc) ? bcc.join(", ") : bcc}`);
|
|
1653
|
+
emailPreview.push(`Subject: ${subject}`);
|
|
1654
|
+
if (Array.isArray(attachments) && attachments.length > 0) {
|
|
1655
|
+
const attNames = attachments.map((a) => a.filename || "unnamed").join(", ");
|
|
1656
|
+
emailPreview.push(`Attachments: ${attNames}`);
|
|
1657
|
+
}
|
|
1658
|
+
emailPreview.push("\u2500".repeat(50));
|
|
1659
|
+
if (text) emailPreview.push("", text);
|
|
1660
|
+
else if (html) emailPreview.push("", "[HTML content \u2014 see original for formatted version]");
|
|
1661
|
+
else emailPreview.push("", "[No body content]");
|
|
1662
|
+
emailPreview.push("\u2500".repeat(50));
|
|
1663
|
+
gatewayManager.routeOutbound(agent.name, {
|
|
1664
|
+
to: ownerEmail,
|
|
1665
|
+
subject: `[Approval Required] Blocked email from "${agent.name}" \u2014 "${subject}"`,
|
|
1666
|
+
text: [
|
|
1667
|
+
`Your agent "${agent.name}" attempted to send an email that was blocked by the outbound security guard.`,
|
|
1668
|
+
"",
|
|
1669
|
+
"SECURITY WARNINGS:",
|
|
1670
|
+
warningList,
|
|
1671
|
+
"",
|
|
1672
|
+
"FULL EMAIL FOR REVIEW:",
|
|
1673
|
+
...emailPreview,
|
|
1674
|
+
"",
|
|
1675
|
+
`Pending ID: ${pendingId}`,
|
|
1676
|
+
"",
|
|
1677
|
+
"ACTION REQUIRED:",
|
|
1678
|
+
'Reply "approve" to this email to send it, or "reject" to discard it.',
|
|
1679
|
+
"If you do not respond, the agent will follow up with you."
|
|
1680
|
+
].join("\n"),
|
|
1681
|
+
fromName: "Agentic Mail"
|
|
1682
|
+
}).then((result2) => {
|
|
1683
|
+
if (result2?.messageId) {
|
|
1684
|
+
db.prepare("UPDATE pending_outbound SET notification_message_id = ? WHERE id = ?").run(result2.messageId, pendingId);
|
|
1685
|
+
}
|
|
1686
|
+
}).catch(() => {
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
res.json({
|
|
1691
|
+
sent: false,
|
|
1692
|
+
blocked: true,
|
|
1693
|
+
pendingId,
|
|
1694
|
+
warnings: scanResult.warnings,
|
|
1695
|
+
summary: scanResult.summary
|
|
1696
|
+
});
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (scanResult.warnings.length > 0) {
|
|
1700
|
+
outboundWarnings = scanResult.warnings;
|
|
1701
|
+
outboundSummary = scanResult.summary;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
const ownerName = agent.metadata?.ownerName;
|
|
1705
|
+
const fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
|
|
1706
|
+
const mailOpts = { to, subject, text, html, cc, bcc, replyTo, inReplyTo, references, attachments, fromName };
|
|
1707
|
+
const password = getAgentPassword(agent);
|
|
1708
|
+
if (gatewayManager) {
|
|
1709
|
+
const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
|
|
1710
|
+
if (gatewayResult) {
|
|
1711
|
+
if (gatewayResult.raw) {
|
|
1712
|
+
saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
|
|
1713
|
+
}
|
|
1714
|
+
const { raw: _raw2, ...response2 } = gatewayResult;
|
|
1715
|
+
res.json({ ...response2, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
|
|
1720
|
+
const result = await sender.send(mailOpts);
|
|
1721
|
+
saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
|
|
1722
|
+
notifyLocalRecipientsOfNewMail(
|
|
1723
|
+
accountManager,
|
|
1724
|
+
to,
|
|
1725
|
+
cc,
|
|
1726
|
+
bcc,
|
|
1727
|
+
agent,
|
|
1728
|
+
subject,
|
|
1729
|
+
result.messageId
|
|
1730
|
+
).catch((err) => {
|
|
1731
|
+
console.warn(`[mail] Internal SSE notify failed: ${err.message}`);
|
|
1732
|
+
});
|
|
1733
|
+
const { raw: _raw, ...response } = result;
|
|
1734
|
+
res.json({ ...response, ...outboundWarnings ? { outboundWarnings, outboundSummary } : {} });
|
|
1682
1735
|
} catch (err) {
|
|
1683
1736
|
next(err);
|
|
1684
1737
|
}
|
|
1685
1738
|
});
|
|
1686
|
-
router.
|
|
1739
|
+
router.get("/mail/inbox", requireAgent, async (req, res, next) => {
|
|
1687
1740
|
try {
|
|
1688
|
-
const
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
cc || null,
|
|
1697
|
-
bcc || null,
|
|
1698
|
-
inReplyTo || null,
|
|
1699
|
-
references ? JSON.stringify(references) : null,
|
|
1700
|
-
req.params.id,
|
|
1701
|
-
req.agent.id
|
|
1702
|
-
);
|
|
1703
|
-
if (result.changes === 0) {
|
|
1704
|
-
res.status(404).json({ error: "Draft not found" });
|
|
1705
|
-
return;
|
|
1706
|
-
}
|
|
1707
|
-
res.json({ ok: true });
|
|
1741
|
+
const agent = req.agent;
|
|
1742
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
|
|
1743
|
+
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
1744
|
+
const password = getAgentPassword(agent);
|
|
1745
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1746
|
+
const mailboxInfo = await receiver.getMailboxInfo("INBOX");
|
|
1747
|
+
const envelopes = await receiver.listEnvelopes("INBOX", { limit, offset });
|
|
1748
|
+
res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists });
|
|
1708
1749
|
} catch (err) {
|
|
1709
1750
|
next(err);
|
|
1710
1751
|
}
|
|
1711
1752
|
});
|
|
1712
|
-
router.
|
|
1753
|
+
router.get("/mail/messages/:uid", requireAgent, async (req, res, next) => {
|
|
1713
1754
|
try {
|
|
1714
|
-
const
|
|
1715
|
-
|
|
1716
|
-
|
|
1755
|
+
const agent = req.agent;
|
|
1756
|
+
const uid = parseInt(req.params.uid);
|
|
1757
|
+
if (isNaN(uid) || uid < 1) {
|
|
1758
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1717
1759
|
return;
|
|
1718
1760
|
}
|
|
1719
|
-
|
|
1761
|
+
const folder = req.query.folder || "INBOX";
|
|
1762
|
+
const password = getAgentPassword(agent);
|
|
1763
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1764
|
+
const raw = await receiver.fetchMessage(uid, folder);
|
|
1765
|
+
const parsed = await parseEmail2(raw);
|
|
1766
|
+
if (isInternalEmail2(parsed)) {
|
|
1767
|
+
res.json({
|
|
1768
|
+
...parsed,
|
|
1769
|
+
security: { internal: true, spamScore: 0, isSpam: false, isWarning: false }
|
|
1770
|
+
});
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const sanitized = sanitizeEmail(parsed);
|
|
1774
|
+
const spamScore = scoreEmail2(parsed);
|
|
1775
|
+
res.json({
|
|
1776
|
+
...parsed,
|
|
1777
|
+
text: sanitized.text,
|
|
1778
|
+
html: sanitized.html,
|
|
1779
|
+
security: {
|
|
1780
|
+
spamScore: spamScore.score,
|
|
1781
|
+
isSpam: spamScore.isSpam,
|
|
1782
|
+
isWarning: spamScore.isWarning,
|
|
1783
|
+
topCategory: spamScore.topCategory,
|
|
1784
|
+
matches: spamScore.matches.map((m) => m.ruleId),
|
|
1785
|
+
sanitized: sanitized.wasModified,
|
|
1786
|
+
sanitizeDetections: sanitized.detections
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1720
1789
|
} catch (err) {
|
|
1721
1790
|
next(err);
|
|
1722
1791
|
}
|
|
1723
1792
|
});
|
|
1724
|
-
router.
|
|
1793
|
+
router.get("/mail/messages/:uid/attachments/:index", requireAgent, async (req, res, next) => {
|
|
1725
1794
|
try {
|
|
1726
|
-
const
|
|
1727
|
-
|
|
1728
|
-
|
|
1795
|
+
const agent = req.agent;
|
|
1796
|
+
const uid = parseInt(req.params.uid);
|
|
1797
|
+
const index = parseInt(req.params.index);
|
|
1798
|
+
if (isNaN(uid) || uid < 1) {
|
|
1799
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1729
1800
|
return;
|
|
1730
1801
|
}
|
|
1731
|
-
if (
|
|
1732
|
-
res.status(400).json({ error: "
|
|
1802
|
+
if (isNaN(index) || index < 0) {
|
|
1803
|
+
res.status(400).json({ error: "Invalid attachment index" });
|
|
1733
1804
|
return;
|
|
1734
1805
|
}
|
|
1735
|
-
const
|
|
1736
|
-
const mailOpts = {
|
|
1737
|
-
to: draft.to_addr,
|
|
1738
|
-
subject: draft.subject || "(no subject)",
|
|
1739
|
-
text: draft.text_body || void 0,
|
|
1740
|
-
html: draft.html_body || void 0,
|
|
1741
|
-
cc: draft.cc || void 0,
|
|
1742
|
-
bcc: draft.bcc || void 0,
|
|
1743
|
-
inReplyTo: draft.in_reply_to || void 0,
|
|
1744
|
-
references: draft.refs ? JSON.parse(draft.refs) : void 0
|
|
1745
|
-
};
|
|
1746
|
-
if (gatewayManager) {
|
|
1747
|
-
const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
|
|
1748
|
-
if (gatewayResult) {
|
|
1749
|
-
db.prepare("DELETE FROM drafts WHERE id = ?").run(draft.id);
|
|
1750
|
-
res.json(gatewayResult);
|
|
1751
|
-
return;
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1806
|
+
const folder = req.query.folder || "INBOX";
|
|
1754
1807
|
const password = getAgentPassword(agent);
|
|
1755
|
-
const
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
});
|
|
1762
|
-
try {
|
|
1763
|
-
const result = await sender.send(mailOpts);
|
|
1764
|
-
db.prepare("DELETE FROM drafts WHERE id = ?").run(draft.id);
|
|
1765
|
-
res.json(result);
|
|
1766
|
-
} finally {
|
|
1767
|
-
sender.close();
|
|
1808
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1809
|
+
const raw = await receiver.fetchMessage(uid, folder);
|
|
1810
|
+
const parsed = await parseEmail2(raw);
|
|
1811
|
+
if (!parsed.attachments || index >= parsed.attachments.length) {
|
|
1812
|
+
res.status(404).json({ error: "Attachment not found" });
|
|
1813
|
+
return;
|
|
1768
1814
|
}
|
|
1815
|
+
const att = parsed.attachments[index];
|
|
1816
|
+
res.setHeader("Content-Type", att.contentType || "application/octet-stream");
|
|
1817
|
+
res.setHeader("Content-Disposition", `attachment; filename="${att.filename.replace(/"/g, '\\"')}"`);
|
|
1818
|
+
res.setHeader("Content-Length", att.content.length);
|
|
1819
|
+
res.send(att.content);
|
|
1769
1820
|
} catch (err) {
|
|
1770
1821
|
next(err);
|
|
1771
1822
|
}
|
|
1772
1823
|
});
|
|
1773
|
-
router.
|
|
1824
|
+
router.post("/mail/search", requireAgent, async (req, res, next) => {
|
|
1774
1825
|
try {
|
|
1775
|
-
|
|
1776
|
-
|
|
1826
|
+
if (!req.body || typeof req.body !== "object") {
|
|
1827
|
+
res.status(400).json({ error: "Request body must be JSON" });
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
const agent = req.agent;
|
|
1831
|
+
const { from, to, subject, since, before, seen, text, searchRelay } = req.body;
|
|
1832
|
+
const password = getAgentPassword(agent);
|
|
1833
|
+
const sinceDate = since ? new Date(since) : void 0;
|
|
1834
|
+
const beforeDate = before ? new Date(before) : void 0;
|
|
1835
|
+
if (sinceDate && isNaN(sinceDate.getTime())) {
|
|
1836
|
+
res.status(400).json({ error: 'Invalid "since" date' });
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
if (beforeDate && isNaN(beforeDate.getTime())) {
|
|
1840
|
+
res.status(400).json({ error: 'Invalid "before" date' });
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1844
|
+
const uids = await receiver.search({
|
|
1845
|
+
from,
|
|
1846
|
+
to,
|
|
1847
|
+
subject,
|
|
1848
|
+
since: sinceDate,
|
|
1849
|
+
before: beforeDate,
|
|
1850
|
+
seen,
|
|
1851
|
+
text
|
|
1852
|
+
});
|
|
1853
|
+
let relayResults;
|
|
1854
|
+
if (searchRelay === true && gatewayManager) {
|
|
1855
|
+
try {
|
|
1856
|
+
const relayHits = await gatewayManager.searchRelay({
|
|
1857
|
+
from,
|
|
1858
|
+
to,
|
|
1859
|
+
subject,
|
|
1860
|
+
text,
|
|
1861
|
+
since: sinceDate,
|
|
1862
|
+
before: beforeDate,
|
|
1863
|
+
seen
|
|
1864
|
+
});
|
|
1865
|
+
if (relayHits.length > 0) {
|
|
1866
|
+
relayResults = relayHits.map((r) => ({
|
|
1867
|
+
uid: r.uid,
|
|
1868
|
+
source: r.source,
|
|
1869
|
+
account: r.account,
|
|
1870
|
+
messageId: r.messageId,
|
|
1871
|
+
subject: r.subject,
|
|
1872
|
+
from: r.from,
|
|
1873
|
+
to: r.to,
|
|
1874
|
+
date: r.date,
|
|
1875
|
+
flags: r.flags
|
|
1876
|
+
}));
|
|
1877
|
+
}
|
|
1878
|
+
} catch {
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
res.json({ uids, ...relayResults ? { relayResults } : {} });
|
|
1777
1882
|
} catch (err) {
|
|
1778
1883
|
next(err);
|
|
1779
1884
|
}
|
|
1780
1885
|
});
|
|
1781
|
-
router.post("/
|
|
1886
|
+
router.post("/mail/import-relay", requireAgent, async (req, res, next) => {
|
|
1782
1887
|
try {
|
|
1783
|
-
const {
|
|
1784
|
-
if (!
|
|
1785
|
-
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" });
|
|
1786
1891
|
return;
|
|
1787
1892
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1893
|
+
if (!gatewayManager) {
|
|
1894
|
+
res.status(400).json({ error: "No gateway configured" });
|
|
1895
|
+
return;
|
|
1791
1896
|
}
|
|
1792
|
-
|
|
1793
|
-
|
|
1897
|
+
const agent = req.agent;
|
|
1898
|
+
const result = await gatewayManager.importRelayMessage(uid, agent.name);
|
|
1899
|
+
if (!result.success) {
|
|
1900
|
+
res.status(400).json({ error: result.error || "Import failed" });
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
res.json({ ok: true, message: "Email imported to local inbox. Use /inbox or list_inbox to see it." });
|
|
1794
1904
|
} catch (err) {
|
|
1795
1905
|
next(err);
|
|
1796
1906
|
}
|
|
1797
1907
|
});
|
|
1798
|
-
router.
|
|
1908
|
+
router.post("/mail/messages/:uid/seen", requireAgent, async (req, res, next) => {
|
|
1799
1909
|
try {
|
|
1800
|
-
const
|
|
1801
|
-
|
|
1802
|
-
|
|
1910
|
+
const agent = req.agent;
|
|
1911
|
+
const uid = parseInt(req.params.uid);
|
|
1912
|
+
if (isNaN(uid) || uid < 1) {
|
|
1913
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1803
1914
|
return;
|
|
1804
1915
|
}
|
|
1916
|
+
const password = getAgentPassword(agent);
|
|
1917
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1918
|
+
await receiver.markSeen(uid);
|
|
1805
1919
|
res.json({ ok: true });
|
|
1806
1920
|
} catch (err) {
|
|
1807
1921
|
next(err);
|
|
1808
1922
|
}
|
|
1809
1923
|
});
|
|
1810
|
-
router.
|
|
1924
|
+
router.delete("/mail/messages/:uid", requireAgent, async (req, res, next) => {
|
|
1811
1925
|
try {
|
|
1812
|
-
const
|
|
1813
|
-
|
|
1926
|
+
const agent = req.agent;
|
|
1927
|
+
const uid = parseInt(req.params.uid);
|
|
1928
|
+
if (isNaN(uid) || uid < 1) {
|
|
1929
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
const password = getAgentPassword(agent);
|
|
1933
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1934
|
+
await receiver.deleteMessage(uid);
|
|
1935
|
+
res.status(204).send();
|
|
1814
1936
|
} catch (err) {
|
|
1815
1937
|
next(err);
|
|
1816
1938
|
}
|
|
1817
1939
|
});
|
|
1818
|
-
router.post("/
|
|
1940
|
+
router.post("/mail/messages/:uid/unseen", requireAgent, async (req, res, next) => {
|
|
1819
1941
|
try {
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
|
|
1942
|
+
const agent = req.agent;
|
|
1943
|
+
const uid = parseInt(req.params.uid);
|
|
1944
|
+
if (isNaN(uid) || uid < 1) {
|
|
1945
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1823
1946
|
return;
|
|
1824
1947
|
}
|
|
1825
|
-
const
|
|
1826
|
-
|
|
1827
|
-
|
|
1948
|
+
const password = getAgentPassword(agent);
|
|
1949
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1950
|
+
await receiver.markUnseen(uid);
|
|
1951
|
+
res.json({ ok: true });
|
|
1828
1952
|
} catch (err) {
|
|
1829
1953
|
next(err);
|
|
1830
1954
|
}
|
|
1831
1955
|
});
|
|
1832
|
-
router.
|
|
1956
|
+
router.post("/mail/messages/:uid/move", requireAgent, async (req, res, next) => {
|
|
1833
1957
|
try {
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
|
|
1958
|
+
const agent = req.agent;
|
|
1959
|
+
const uid = parseInt(req.params.uid);
|
|
1960
|
+
if (isNaN(uid) || uid < 1) {
|
|
1961
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
const { from: fromFolder, to: toFolder } = req.body || {};
|
|
1965
|
+
if (!toFolder) {
|
|
1966
|
+
res.status(400).json({ error: "to (destination folder) is required" });
|
|
1837
1967
|
return;
|
|
1838
1968
|
}
|
|
1969
|
+
const password = getAgentPassword(agent);
|
|
1970
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1971
|
+
await receiver.moveMessage(uid, fromFolder || "INBOX", toFolder);
|
|
1839
1972
|
res.json({ ok: true });
|
|
1840
1973
|
} catch (err) {
|
|
1841
1974
|
next(err);
|
|
1842
1975
|
}
|
|
1843
1976
|
});
|
|
1844
|
-
router.get("/
|
|
1977
|
+
router.get("/mail/folders", requireAgent, async (req, res, next) => {
|
|
1845
1978
|
try {
|
|
1846
|
-
const
|
|
1847
|
-
|
|
1979
|
+
const agent = req.agent;
|
|
1980
|
+
const password = getAgentPassword(agent);
|
|
1981
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
1982
|
+
const folders = await receiver.listFolders();
|
|
1983
|
+
res.json({ folders });
|
|
1984
|
+
} catch (err) {
|
|
1985
|
+
next(err);
|
|
1986
|
+
}
|
|
1987
|
+
});
|
|
1988
|
+
router.post("/mail/folders", requireAgent, async (req, res, next) => {
|
|
1989
|
+
try {
|
|
1990
|
+
const agent = req.agent;
|
|
1991
|
+
const { name } = req.body || {};
|
|
1992
|
+
if (!name || typeof name !== "string" || !name.trim()) {
|
|
1993
|
+
res.status(400).json({ error: "name is required" });
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
if (name.length > 200 || /[\\*%]/.test(name)) {
|
|
1997
|
+
res.status(400).json({ error: "Invalid folder name" });
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
const password = getAgentPassword(agent);
|
|
2001
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2002
|
+
await receiver.createFolder(name);
|
|
2003
|
+
res.json({ ok: true, folder: name });
|
|
1848
2004
|
} catch (err) {
|
|
1849
2005
|
next(err);
|
|
1850
2006
|
}
|
|
1851
2007
|
});
|
|
1852
|
-
router.
|
|
2008
|
+
router.get("/mail/folders/:folder", requireAgent, async (req, res, next) => {
|
|
1853
2009
|
try {
|
|
1854
|
-
const
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
const
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
}
|
|
1866
|
-
if (sendDate.getTime() <= Date.now()) {
|
|
1867
|
-
res.status(400).json({ error: "sendAt must be in the future" });
|
|
2010
|
+
const agent = req.agent;
|
|
2011
|
+
const folder = decodeURIComponent(req.params.folder);
|
|
2012
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
|
|
2013
|
+
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
2014
|
+
const password = getAgentPassword(agent);
|
|
2015
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2016
|
+
let mailboxInfo;
|
|
2017
|
+
try {
|
|
2018
|
+
mailboxInfo = await receiver.getMailboxInfo(folder);
|
|
2019
|
+
} catch {
|
|
2020
|
+
res.json({ messages: [], count: 0, total: 0, folder });
|
|
1868
2021
|
return;
|
|
1869
2022
|
}
|
|
1870
|
-
const
|
|
1871
|
-
|
|
1872
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, req.agent.id, to, subject, text || null, html || null, cc || null, bcc || null, sendDate.toISOString());
|
|
1873
|
-
res.json({ ok: true, id, sendAt: sendDate.toISOString() });
|
|
2023
|
+
const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
|
|
2024
|
+
res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder });
|
|
1874
2025
|
} catch (err) {
|
|
1875
2026
|
next(err);
|
|
1876
2027
|
}
|
|
1877
2028
|
});
|
|
1878
|
-
|
|
2029
|
+
function validateUids(raw) {
|
|
2030
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
2031
|
+
if (raw.length > 1e3) return null;
|
|
2032
|
+
const nums = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0);
|
|
2033
|
+
return nums.length > 0 ? nums : null;
|
|
2034
|
+
}
|
|
2035
|
+
router.post("/mail/batch/delete", requireAgent, async (req, res, next) => {
|
|
1879
2036
|
try {
|
|
1880
|
-
const
|
|
1881
|
-
|
|
1882
|
-
|
|
2037
|
+
const agent = req.agent;
|
|
2038
|
+
const { uids: rawUids, folder } = req.body || {};
|
|
2039
|
+
const uids = validateUids(rawUids);
|
|
2040
|
+
if (!uids) {
|
|
2041
|
+
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
1883
2042
|
return;
|
|
1884
2043
|
}
|
|
1885
|
-
|
|
2044
|
+
const password = getAgentPassword(agent);
|
|
2045
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2046
|
+
await receiver.batchDelete(uids, folder || "INBOX");
|
|
2047
|
+
res.json({ ok: true, deleted: uids.length });
|
|
1886
2048
|
} catch (err) {
|
|
1887
2049
|
next(err);
|
|
1888
2050
|
}
|
|
1889
2051
|
});
|
|
1890
|
-
router.
|
|
2052
|
+
router.post("/mail/batch/seen", requireAgent, async (req, res, next) => {
|
|
1891
2053
|
try {
|
|
1892
|
-
const
|
|
1893
|
-
|
|
2054
|
+
const agent = req.agent;
|
|
2055
|
+
const { uids: rawUids, folder } = req.body || {};
|
|
2056
|
+
const uids = validateUids(rawUids);
|
|
2057
|
+
if (!uids) {
|
|
2058
|
+
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
const password = getAgentPassword(agent);
|
|
2062
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2063
|
+
await receiver.batchMarkSeen(uids, folder || "INBOX");
|
|
2064
|
+
res.json({ ok: true, marked: uids.length });
|
|
1894
2065
|
} catch (err) {
|
|
1895
2066
|
next(err);
|
|
1896
2067
|
}
|
|
1897
2068
|
});
|
|
1898
|
-
router.post("/
|
|
2069
|
+
router.post("/mail/batch/unseen", requireAgent, async (req, res, next) => {
|
|
1899
2070
|
try {
|
|
1900
|
-
const
|
|
1901
|
-
|
|
1902
|
-
|
|
2071
|
+
const agent = req.agent;
|
|
2072
|
+
const { uids: rawUids, folder } = req.body || {};
|
|
2073
|
+
const uids = validateUids(rawUids);
|
|
2074
|
+
if (!uids) {
|
|
2075
|
+
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
1903
2076
|
return;
|
|
1904
2077
|
}
|
|
1905
|
-
const
|
|
1906
|
-
|
|
1907
|
-
|
|
2078
|
+
const password = getAgentPassword(agent);
|
|
2079
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2080
|
+
await receiver.batchMarkUnseen(uids, folder || "INBOX");
|
|
2081
|
+
res.json({ ok: true, marked: uids.length });
|
|
1908
2082
|
} catch (err) {
|
|
1909
2083
|
next(err);
|
|
1910
2084
|
}
|
|
1911
2085
|
});
|
|
1912
|
-
router.
|
|
2086
|
+
router.post("/mail/batch/move", requireAgent, async (req, res, next) => {
|
|
1913
2087
|
try {
|
|
1914
|
-
const
|
|
1915
|
-
|
|
1916
|
-
|
|
2088
|
+
const agent = req.agent;
|
|
2089
|
+
const { uids: rawUids, from: fromFolder, to: toFolder } = req.body || {};
|
|
2090
|
+
const uids = validateUids(rawUids);
|
|
2091
|
+
if (!uids) {
|
|
2092
|
+
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
1917
2093
|
return;
|
|
1918
2094
|
}
|
|
1919
|
-
|
|
2095
|
+
if (!toFolder) {
|
|
2096
|
+
res.status(400).json({ error: "to (destination folder) is required" });
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
const password = getAgentPassword(agent);
|
|
2100
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2101
|
+
await receiver.batchMove(uids, fromFolder || "INBOX", toFolder);
|
|
2102
|
+
res.json({ ok: true, moved: uids.length });
|
|
1920
2103
|
} catch (err) {
|
|
1921
2104
|
next(err);
|
|
1922
2105
|
}
|
|
1923
2106
|
});
|
|
1924
|
-
router.post("/
|
|
2107
|
+
router.post("/mail/batch/read", requireAgent, async (req, res, next) => {
|
|
1925
2108
|
try {
|
|
1926
|
-
const
|
|
1927
|
-
|
|
1928
|
-
|
|
2109
|
+
const agent = req.agent;
|
|
2110
|
+
const { uids: rawUids, folder } = req.body || {};
|
|
2111
|
+
const uids = validateUids(rawUids);
|
|
2112
|
+
if (!uids) {
|
|
2113
|
+
res.status(400).json({ error: "uids must be a non-empty array of positive integers (max 1000)" });
|
|
1929
2114
|
return;
|
|
1930
2115
|
}
|
|
1931
|
-
const
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2116
|
+
const password = getAgentPassword(agent);
|
|
2117
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2118
|
+
const rawMap = await receiver.batchFetch(uids, folder || "INBOX");
|
|
2119
|
+
const messages = [];
|
|
2120
|
+
for (const [uid, raw] of rawMap) {
|
|
2121
|
+
const parsed = await parseEmail2(raw);
|
|
2122
|
+
messages.push({ uid, ...parsed });
|
|
1935
2123
|
}
|
|
1936
|
-
|
|
1937
|
-
res.json({ ok: true });
|
|
2124
|
+
res.json({ messages, count: messages.length });
|
|
1938
2125
|
} catch (err) {
|
|
1939
2126
|
next(err);
|
|
1940
2127
|
}
|
|
1941
2128
|
});
|
|
1942
|
-
router.
|
|
2129
|
+
router.get("/mail/spam", requireAgent, async (req, res, next) => {
|
|
1943
2130
|
try {
|
|
1944
|
-
const
|
|
1945
|
-
|
|
1946
|
-
|
|
2131
|
+
const agent = req.agent;
|
|
2132
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 200);
|
|
2133
|
+
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
2134
|
+
const password = getAgentPassword(agent);
|
|
2135
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2136
|
+
let mailboxInfo;
|
|
2137
|
+
try {
|
|
2138
|
+
mailboxInfo = await receiver.getMailboxInfo("Spam");
|
|
2139
|
+
} catch {
|
|
2140
|
+
res.json({ messages: [], count: 0, total: 0, folder: "Spam" });
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const envelopes = await receiver.listEnvelopes("Spam", { limit, offset });
|
|
2144
|
+
res.json({ messages: envelopes, count: envelopes.length, total: mailboxInfo.exists, folder: "Spam" });
|
|
1947
2145
|
} catch (err) {
|
|
1948
2146
|
next(err);
|
|
1949
2147
|
}
|
|
1950
2148
|
});
|
|
1951
|
-
router.
|
|
2149
|
+
router.post("/mail/messages/:uid/spam", requireAgent, async (req, res, next) => {
|
|
1952
2150
|
try {
|
|
1953
|
-
const
|
|
1954
|
-
|
|
1955
|
-
|
|
2151
|
+
const agent = req.agent;
|
|
2152
|
+
const uid = parseInt(req.params.uid);
|
|
2153
|
+
if (isNaN(uid) || uid < 1) {
|
|
2154
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1956
2155
|
return;
|
|
1957
2156
|
}
|
|
1958
|
-
const
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2157
|
+
const folder = req.body?.folder || "INBOX";
|
|
2158
|
+
const password = getAgentPassword(agent);
|
|
2159
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2160
|
+
try {
|
|
2161
|
+
await receiver.createFolder("Spam");
|
|
2162
|
+
} catch {
|
|
2163
|
+
}
|
|
2164
|
+
await receiver.moveMessage(uid, folder, "Spam");
|
|
2165
|
+
res.json({ ok: true, movedToSpam: true });
|
|
1962
2166
|
} catch (err) {
|
|
1963
2167
|
next(err);
|
|
1964
2168
|
}
|
|
1965
2169
|
});
|
|
1966
|
-
router.
|
|
2170
|
+
router.post("/mail/messages/:uid/not-spam", requireAgent, async (req, res, next) => {
|
|
1967
2171
|
try {
|
|
1968
|
-
const
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2172
|
+
const agent = req.agent;
|
|
2173
|
+
const uid = parseInt(req.params.uid);
|
|
2174
|
+
if (isNaN(uid) || uid < 1) {
|
|
2175
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
const password = getAgentPassword(agent);
|
|
2179
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2180
|
+
await receiver.moveMessage(uid, "Spam", "INBOX");
|
|
2181
|
+
res.json({ ok: true, movedToInbox: true });
|
|
1975
2182
|
} catch (err) {
|
|
1976
2183
|
next(err);
|
|
1977
2184
|
}
|
|
1978
2185
|
});
|
|
1979
|
-
router.
|
|
2186
|
+
router.get("/mail/messages/:uid/spam-score", requireAgent, async (req, res, next) => {
|
|
1980
2187
|
try {
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
|
|
2188
|
+
const agent = req.agent;
|
|
2189
|
+
const uid = parseInt(req.params.uid);
|
|
2190
|
+
if (isNaN(uid) || uid < 1) {
|
|
2191
|
+
res.status(400).json({ error: "Invalid UID" });
|
|
1984
2192
|
return;
|
|
1985
2193
|
}
|
|
1986
|
-
const
|
|
1987
|
-
|
|
1988
|
-
|
|
2194
|
+
const folder = req.query.folder || "INBOX";
|
|
2195
|
+
const password = getAgentPassword(agent);
|
|
2196
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2197
|
+
const raw = await receiver.fetchMessage(uid, folder);
|
|
2198
|
+
const parsed = await parseEmail2(raw);
|
|
2199
|
+
if (isInternalEmail2(parsed)) {
|
|
2200
|
+
res.json({ score: 0, isSpam: false, isWarning: false, matches: [], topCategory: null, internal: true });
|
|
1989
2201
|
return;
|
|
1990
2202
|
}
|
|
1991
|
-
const
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
bcc: bcc || void 0
|
|
2000
|
-
};
|
|
2203
|
+
const result = scoreEmail2(parsed);
|
|
2204
|
+
res.json(result);
|
|
2205
|
+
} catch (err) {
|
|
2206
|
+
next(err);
|
|
2207
|
+
}
|
|
2208
|
+
});
|
|
2209
|
+
router.get("/mail/digest", requireAgent, async (req, res, next) => {
|
|
2210
|
+
try {
|
|
2001
2211
|
const agent = req.agent;
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
return;
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2212
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 50);
|
|
2213
|
+
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
2214
|
+
const previewLen = Math.min(Math.max(parseInt(req.query.previewLength) || 200, 50), 500);
|
|
2215
|
+
const folder = req.query.folder || "INBOX";
|
|
2009
2216
|
const password = getAgentPassword(agent);
|
|
2010
|
-
const
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
const
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2217
|
+
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2218
|
+
const mailboxInfo = await receiver.getMailboxInfo(folder);
|
|
2219
|
+
const envelopes = await receiver.listEnvelopes(folder, { limit, offset });
|
|
2220
|
+
const uids = envelopes.map((e) => e.uid);
|
|
2221
|
+
const rawMap = uids.length > 0 ? await receiver.batchFetch(uids, folder) : /* @__PURE__ */ new Map();
|
|
2222
|
+
const messages = [];
|
|
2223
|
+
for (const env of envelopes) {
|
|
2224
|
+
let preview = "";
|
|
2225
|
+
const raw = rawMap.get(env.uid);
|
|
2226
|
+
if (raw) {
|
|
2227
|
+
const parsed = await parseEmail2(raw);
|
|
2228
|
+
preview = (parsed.text || "").slice(0, previewLen);
|
|
2229
|
+
}
|
|
2230
|
+
messages.push({
|
|
2231
|
+
uid: env.uid,
|
|
2232
|
+
subject: env.subject,
|
|
2233
|
+
from: env.from,
|
|
2234
|
+
to: env.to,
|
|
2235
|
+
date: env.date,
|
|
2236
|
+
flags: [...env.flags],
|
|
2237
|
+
size: env.size,
|
|
2238
|
+
preview
|
|
2239
|
+
});
|
|
2022
2240
|
}
|
|
2241
|
+
res.json({ messages, count: messages.length, total: mailboxInfo.exists });
|
|
2023
2242
|
} catch (err) {
|
|
2024
2243
|
next(err);
|
|
2025
2244
|
}
|
|
2026
2245
|
});
|
|
2027
|
-
router.get("/
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2246
|
+
router.get("/mail/pending", requireAuth, async (req, res) => {
|
|
2247
|
+
const rows = req.isMaster ? db.prepare(
|
|
2248
|
+
`SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
|
|
2249
|
+
FROM pending_outbound ORDER BY created_at DESC LIMIT 50`
|
|
2250
|
+
).all() : db.prepare(
|
|
2251
|
+
`SELECT id, agent_id, mail_options, warnings, summary, status, created_at, resolved_at, resolved_by
|
|
2252
|
+
FROM pending_outbound WHERE agent_id = ? ORDER BY created_at DESC LIMIT 50`
|
|
2253
|
+
).all(req.agent.id);
|
|
2254
|
+
const pending = rows.map((r) => {
|
|
2255
|
+
const opts = JSON.parse(r.mail_options);
|
|
2256
|
+
return {
|
|
2257
|
+
id: r.id,
|
|
2258
|
+
agentId: r.agent_id,
|
|
2259
|
+
to: opts.to,
|
|
2260
|
+
subject: opts.subject,
|
|
2261
|
+
warnings: JSON.parse(r.warnings),
|
|
2262
|
+
summary: r.summary,
|
|
2263
|
+
status: r.status,
|
|
2264
|
+
createdAt: r.created_at,
|
|
2265
|
+
resolvedAt: r.resolved_at,
|
|
2266
|
+
resolvedBy: r.resolved_by
|
|
2267
|
+
};
|
|
2268
|
+
});
|
|
2269
|
+
res.json({ pending, count: pending.length });
|
|
2270
|
+
});
|
|
2271
|
+
router.get("/mail/pending/:id", requireAuth, async (req, res) => {
|
|
2272
|
+
const row = req.isMaster ? db.prepare(`SELECT * FROM pending_outbound WHERE id = ?`).get(req.params.id) : db.prepare(`SELECT * FROM pending_outbound WHERE id = ? AND agent_id = ?`).get(req.params.id, req.agent.id);
|
|
2273
|
+
if (!row) {
|
|
2274
|
+
res.status(404).json({ error: "Pending email not found" });
|
|
2275
|
+
return;
|
|
2033
2276
|
}
|
|
2277
|
+
res.json({
|
|
2278
|
+
id: row.id,
|
|
2279
|
+
mailOptions: JSON.parse(row.mail_options),
|
|
2280
|
+
warnings: JSON.parse(row.warnings),
|
|
2281
|
+
summary: row.summary,
|
|
2282
|
+
status: row.status,
|
|
2283
|
+
createdAt: row.created_at,
|
|
2284
|
+
resolvedAt: row.resolved_at,
|
|
2285
|
+
resolvedBy: row.resolved_by
|
|
2286
|
+
});
|
|
2034
2287
|
});
|
|
2035
|
-
router.post("/
|
|
2288
|
+
router.post("/mail/pending/:id/approve", requireMaster, async (req, res, next) => {
|
|
2036
2289
|
try {
|
|
2037
|
-
const
|
|
2038
|
-
|
|
2039
|
-
|
|
2290
|
+
const row = db.prepare(
|
|
2291
|
+
`SELECT * FROM pending_outbound WHERE id = ?`
|
|
2292
|
+
).get(req.params.id);
|
|
2293
|
+
if (!row) {
|
|
2294
|
+
res.status(404).json({ error: "Pending email not found" });
|
|
2040
2295
|
return;
|
|
2041
2296
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
"INSERT INTO email_rules (id, agent_id, name, priority, enabled, conditions, actions) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
2045
|
-
).run(id, req.agent.id, name2, priority ?? 0, enabled !== false ? 1 : 0, JSON.stringify(conditions || {}), JSON.stringify(actions || {}));
|
|
2046
|
-
res.status(201).json({ id, name: name2, conditions: conditions || {}, actions: actions || {}, priority: priority ?? 0, enabled: enabled !== false });
|
|
2047
|
-
} catch (err) {
|
|
2048
|
-
next(err);
|
|
2049
|
-
}
|
|
2050
|
-
});
|
|
2051
|
-
router.delete("/rules/:id", requireAgent, async (req, res, next) => {
|
|
2052
|
-
try {
|
|
2053
|
-
const result = db.prepare("DELETE FROM email_rules WHERE id = ? AND agent_id = ?").run(req.params.id, req.agent.id);
|
|
2054
|
-
if (result.changes === 0) {
|
|
2055
|
-
res.status(404).json({ error: "Rule not found" });
|
|
2297
|
+
if (row.status !== "pending") {
|
|
2298
|
+
res.status(400).json({ error: `Email already ${row.status}` });
|
|
2056
2299
|
return;
|
|
2057
2300
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
});
|
|
2063
|
-
return router;
|
|
2064
|
-
}
|
|
2065
|
-
function evaluateRules(db, agentId, email) {
|
|
2066
|
-
const rules = db.prepare("SELECT * FROM email_rules WHERE agent_id = ? AND enabled = 1 ORDER BY priority DESC").all(agentId);
|
|
2067
|
-
for (const rule of rules) {
|
|
2068
|
-
const cond = JSON.parse(rule.conditions);
|
|
2069
|
-
let match = true;
|
|
2070
|
-
const fromAddr = (email.from?.[0]?.address ?? "").toLowerCase();
|
|
2071
|
-
const toAddr = (email.to?.[0]?.address ?? "").toLowerCase();
|
|
2072
|
-
const subject = (email.subject ?? "").toLowerCase();
|
|
2073
|
-
if (cond.from_contains && !fromAddr.includes(cond.from_contains.toLowerCase())) match = false;
|
|
2074
|
-
if (cond.from_exact && fromAddr !== cond.from_exact.toLowerCase()) match = false;
|
|
2075
|
-
if (cond.subject_contains && !subject.includes(cond.subject_contains.toLowerCase())) match = false;
|
|
2076
|
-
if (cond.subject_regex) {
|
|
2077
|
-
try {
|
|
2078
|
-
if (!new RegExp(cond.subject_regex, "i").test(email.subject ?? "")) match = false;
|
|
2079
|
-
} catch {
|
|
2080
|
-
match = false;
|
|
2301
|
+
const agent = await accountManager.getById(row.agent_id);
|
|
2302
|
+
if (!agent) {
|
|
2303
|
+
res.status(404).json({ error: "Agent account no longer exists" });
|
|
2304
|
+
return;
|
|
2081
2305
|
}
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
function startScheduledSender(db, accountManager, config, gatewayManager) {
|
|
2090
|
-
return setInterval(async () => {
|
|
2091
|
-
try {
|
|
2092
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2093
|
-
const pending = db.prepare(
|
|
2094
|
-
"SELECT * FROM scheduled_emails WHERE status = 'pending' AND send_at <= ?"
|
|
2095
|
-
).all(now);
|
|
2096
|
-
for (const row of pending) {
|
|
2097
|
-
try {
|
|
2098
|
-
const agent = await accountManager.getById(row.agent_id);
|
|
2099
|
-
if (!agent) {
|
|
2100
|
-
db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run("Agent not found", row.id);
|
|
2101
|
-
continue;
|
|
2102
|
-
}
|
|
2103
|
-
const mailOpts = {
|
|
2104
|
-
to: row.to_addr,
|
|
2105
|
-
subject: row.subject,
|
|
2106
|
-
text: row.text_body || void 0,
|
|
2107
|
-
html: row.html_body || void 0,
|
|
2108
|
-
cc: row.cc || void 0,
|
|
2109
|
-
bcc: row.bcc || void 0
|
|
2110
|
-
};
|
|
2111
|
-
if (gatewayManager) {
|
|
2112
|
-
const gResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
|
|
2113
|
-
if (gResult) {
|
|
2114
|
-
db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
|
|
2115
|
-
continue;
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
const password = agent.metadata?._password || agent.name;
|
|
2119
|
-
const sender = new MailSender3({
|
|
2120
|
-
host: config.smtp.host,
|
|
2121
|
-
port: config.smtp.port,
|
|
2122
|
-
email: agent.email,
|
|
2123
|
-
password,
|
|
2124
|
-
authUser: agent.stalwartPrincipal
|
|
2125
|
-
});
|
|
2126
|
-
try {
|
|
2127
|
-
await sender.send(mailOpts);
|
|
2128
|
-
db.prepare("UPDATE scheduled_emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?").run(row.id);
|
|
2129
|
-
} finally {
|
|
2130
|
-
sender.close();
|
|
2306
|
+
const mailOpts = JSON.parse(row.mail_options);
|
|
2307
|
+
const ownerName = agent.metadata?.ownerName;
|
|
2308
|
+
mailOpts.fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
|
|
2309
|
+
if (Array.isArray(mailOpts.attachments)) {
|
|
2310
|
+
for (const att of mailOpts.attachments) {
|
|
2311
|
+
if (att.content && typeof att.content === "object" && att.content.type === "Buffer" && Array.isArray(att.content.data)) {
|
|
2312
|
+
att.content = Buffer.from(att.content.data);
|
|
2131
2313
|
}
|
|
2132
|
-
} catch (err) {
|
|
2133
|
-
db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run(err.message, row.id);
|
|
2134
2314
|
}
|
|
2135
2315
|
}
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2316
|
+
const password = getAgentPassword(agent);
|
|
2317
|
+
let response;
|
|
2318
|
+
if (gatewayManager) {
|
|
2319
|
+
const gatewayResult = await gatewayManager.routeOutbound(agent.name, mailOpts);
|
|
2320
|
+
if (gatewayResult) {
|
|
2321
|
+
if (gatewayResult.raw) {
|
|
2322
|
+
saveSentCopy(agent.stalwartPrincipal, password, config, gatewayResult.raw);
|
|
2323
|
+
}
|
|
2324
|
+
const { raw: _raw, ...rest } = gatewayResult;
|
|
2325
|
+
response = rest;
|
|
2326
|
+
}
|
|
2139
2327
|
}
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2328
|
+
if (!response) {
|
|
2329
|
+
const sender = getSender(agent.stalwartPrincipal, agent.email, password, config);
|
|
2330
|
+
const result = await sender.send(mailOpts);
|
|
2331
|
+
saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
|
|
2332
|
+
notifyLocalRecipientsOfNewMail(
|
|
2333
|
+
accountManager,
|
|
2334
|
+
mailOpts.to,
|
|
2335
|
+
mailOpts.cc,
|
|
2336
|
+
mailOpts.bcc,
|
|
2337
|
+
agent,
|
|
2338
|
+
mailOpts.subject,
|
|
2339
|
+
result.messageId
|
|
2340
|
+
).catch((err) => {
|
|
2341
|
+
console.warn(`[mail] Internal SSE notify (approve) failed: ${err.message}`);
|
|
2342
|
+
});
|
|
2343
|
+
const { raw: _raw, ...rest } = result;
|
|
2344
|
+
response = rest;
|
|
2143
2345
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
var MAX_SSE_PER_AGENT = 5;
|
|
2151
|
-
var activeWatchers = /* @__PURE__ */ new Map();
|
|
2152
|
-
function pushEventToAgent(agentId, event) {
|
|
2153
|
-
const watchers = activeWatchers.get(agentId);
|
|
2154
|
-
if (!watchers || watchers.size === 0) return false;
|
|
2155
|
-
const data = `data: ${JSON.stringify(event)}
|
|
2156
|
-
|
|
2157
|
-
`;
|
|
2158
|
-
for (const entry of watchers) {
|
|
2159
|
-
try {
|
|
2160
|
-
entry.res.write(data);
|
|
2161
|
-
} catch {
|
|
2346
|
+
db.prepare(
|
|
2347
|
+
`UPDATE pending_outbound SET status = 'approved', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
|
|
2348
|
+
).run("master", row.id);
|
|
2349
|
+
res.json({ ...response, approved: true, pendingId: row.id });
|
|
2350
|
+
} catch (err) {
|
|
2351
|
+
next(err);
|
|
2162
2352
|
}
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
for (const [, watchers] of activeWatchers) {
|
|
2172
|
-
for (const entry of watchers) {
|
|
2173
|
-
try {
|
|
2174
|
-
entry.res.write(data);
|
|
2175
|
-
count++;
|
|
2176
|
-
} catch {
|
|
2177
|
-
}
|
|
2353
|
+
});
|
|
2354
|
+
router.post("/mail/pending/:id/reject", requireMaster, async (req, res) => {
|
|
2355
|
+
const row = db.prepare(
|
|
2356
|
+
`SELECT * FROM pending_outbound WHERE id = ?`
|
|
2357
|
+
).get(req.params.id);
|
|
2358
|
+
if (!row) {
|
|
2359
|
+
res.status(404).json({ error: "Pending email not found" });
|
|
2360
|
+
return;
|
|
2178
2361
|
}
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
async function closeAllWatchers() {
|
|
2183
|
-
for (const [, watchers] of activeWatchers) {
|
|
2184
|
-
for (const entry of watchers) {
|
|
2185
|
-
try {
|
|
2186
|
-
await entry.watcher.stop();
|
|
2187
|
-
} catch {
|
|
2188
|
-
}
|
|
2189
|
-
try {
|
|
2190
|
-
entry.res.end();
|
|
2191
|
-
} catch {
|
|
2192
|
-
}
|
|
2362
|
+
if (row.status !== "pending") {
|
|
2363
|
+
res.status(400).json({ error: `Email already ${row.status}` });
|
|
2364
|
+
return;
|
|
2193
2365
|
}
|
|
2194
|
-
|
|
2195
|
-
|
|
2366
|
+
db.prepare(
|
|
2367
|
+
`UPDATE pending_outbound SET status = 'rejected', resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`
|
|
2368
|
+
).run("master", row.id);
|
|
2369
|
+
res.json({ ok: true, rejected: true, pendingId: row.id });
|
|
2370
|
+
});
|
|
2371
|
+
return router;
|
|
2196
2372
|
}
|
|
2197
|
-
|
|
2373
|
+
|
|
2374
|
+
// src/routes/inbound.ts
|
|
2375
|
+
import { Router as Router6 } from "express";
|
|
2376
|
+
import { randomUUID } from "crypto";
|
|
2377
|
+
import {
|
|
2378
|
+
parseEmail as parseEmail3,
|
|
2379
|
+
MailSender as MailSender3
|
|
2380
|
+
} from "@agenticmail/core";
|
|
2381
|
+
var INBOUND_SECRET = process.env.AGENTICMAIL_INBOUND_SECRET || (() => {
|
|
2382
|
+
const generated = randomUUID();
|
|
2383
|
+
console.warn("[Inbound] WARNING: AGENTICMAIL_INBOUND_SECRET is not set. Generated a random secret for this session.");
|
|
2384
|
+
console.warn(`[Inbound] Set AGENTICMAIL_INBOUND_SECRET="${generated}" in your environment to persist it across restarts.`);
|
|
2385
|
+
return generated;
|
|
2386
|
+
})();
|
|
2387
|
+
var DEBUG = () => !!process.env.AGENTICMAIL_DEBUG;
|
|
2388
|
+
function createInboundRoutes(accountManager, config, gatewayManager) {
|
|
2198
2389
|
const router = Router6();
|
|
2199
|
-
router.
|
|
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})`;
|
|
@@ -3412,15 +3491,15 @@ function buildColumnDDL(col, dialect) {
|
|
|
3412
3491
|
}
|
|
3413
3492
|
return ddl;
|
|
3414
3493
|
}
|
|
3415
|
-
function safeTableName(agentId,
|
|
3416
|
-
const clean =
|
|
3494
|
+
function safeTableName(agentId, name, shared) {
|
|
3495
|
+
const clean = name.replace(/[^a-zA-Z0-9_]/g, "").substring(0, 64);
|
|
3417
3496
|
if (!clean) throw new Error("Invalid table name");
|
|
3418
3497
|
const prefix = shared ? "shared" : `agt_${agentId.replace(/[^a-zA-Z0-9]/g, "").substring(0, 16)}`;
|
|
3419
3498
|
return `${prefix}_${clean}`;
|
|
3420
3499
|
}
|
|
3421
|
-
function resolveTable(agentId,
|
|
3422
|
-
if (
|
|
3423
|
-
return safeTableName(agentId,
|
|
3500
|
+
function resolveTable(agentId, name) {
|
|
3501
|
+
if (name.startsWith("agt_") || name.startsWith("shared_")) return name;
|
|
3502
|
+
return safeTableName(agentId, name, false);
|
|
3424
3503
|
}
|
|
3425
3504
|
function isSafeTable(tableName) {
|
|
3426
3505
|
return tableName.startsWith("agt_") || tableName.startsWith("shared_");
|
|
@@ -3578,8 +3657,8 @@ function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite")
|
|
|
3578
3657
|
if (!agent) return;
|
|
3579
3658
|
await ensureMetaTable();
|
|
3580
3659
|
try {
|
|
3581
|
-
const { name
|
|
3582
|
-
if (!
|
|
3660
|
+
const { name, columns, indexes, shared, description, timestamps } = req.body;
|
|
3661
|
+
if (!name || !columns?.length) return res.status(400).json({ error: "name and columns are required" });
|
|
3583
3662
|
const hasPK = columns.some((c) => c.primaryKey);
|
|
3584
3663
|
const allCols = [...hasPK ? [] : [{ name: "id", type: "text", primaryKey: true }], ...columns];
|
|
3585
3664
|
if (timestamps !== false) {
|
|
@@ -3590,9 +3669,9 @@ function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite")
|
|
|
3590
3669
|
allCols.push({ name: "updated_at", type: "timestamp", default: nowExpr(dialect) });
|
|
3591
3670
|
}
|
|
3592
3671
|
}
|
|
3593
|
-
const tableName = safeTableName(agent.id,
|
|
3672
|
+
const tableName = safeTableName(agent.id, name, !!shared);
|
|
3594
3673
|
const existing = await db.get("SELECT table_name FROM agenticmail_storage_meta WHERE table_name = ?", [tableName]);
|
|
3595
|
-
if (existing) return res.status(409).json({ error: `Table "${
|
|
3674
|
+
if (existing) return res.status(409).json({ error: `Table "${name}" already exists`, table: tableName });
|
|
3596
3675
|
const colDefs = allCols.map((c) => buildColumnDDL(c, dialect)).join(",\n ");
|
|
3597
3676
|
await db.run(`CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
3598
3677
|
${colDefs}
|
|
@@ -3613,7 +3692,7 @@ function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite")
|
|
|
3613
3692
|
}
|
|
3614
3693
|
await db.run(
|
|
3615
3694
|
"INSERT INTO agenticmail_storage_meta (table_name, agent_id, display_name, description, shared, columns, indexes) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
3616
|
-
[tableName, agent.id,
|
|
3695
|
+
[tableName, agent.id, name, description || "", shared ? 1 : 0, JSON.stringify(allCols), JSON.stringify(idxMeta)]
|
|
3617
3696
|
);
|
|
3618
3697
|
res.json({ ok: true, table: tableName, columns: allCols, indexes: idxMeta });
|
|
3619
3698
|
} catch (err) {
|