@agenticmail/api 0.9.6 → 0.9.7
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 +149 -65
- package/package.json +2 -2
- package/public/js/activity-badges.js +12 -30
- package/public/js/app.js +9 -6
- package/public/js/sse.js +27 -52
- package/public/js/state.js +0 -1
- package/public/js/system-stream.js +109 -0
package/dist/index.js
CHANGED
|
@@ -38,7 +38,7 @@ function safeEqual(a, b) {
|
|
|
38
38
|
const hb = createHash("sha256").update(b).digest();
|
|
39
39
|
return timingSafeEqual(ha, hb);
|
|
40
40
|
}
|
|
41
|
-
function createAuthMiddleware(masterKey,
|
|
41
|
+
function createAuthMiddleware(masterKey, accountManager, db) {
|
|
42
42
|
return async (req, res, next) => {
|
|
43
43
|
const authHeader = req.headers.authorization;
|
|
44
44
|
if (!authHeader?.startsWith("Bearer ")) {
|
|
@@ -56,7 +56,7 @@ function createAuthMiddleware(masterKey, accountManager2, db) {
|
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
58
|
try {
|
|
59
|
-
const agent = await
|
|
59
|
+
const agent = await accountManager.getByApiKey(token);
|
|
60
60
|
if (agent) {
|
|
61
61
|
req.agent = agent;
|
|
62
62
|
if (db) {
|
|
@@ -327,9 +327,9 @@ function sanitizeAgent(agent) {
|
|
|
327
327
|
}
|
|
328
328
|
return agent;
|
|
329
329
|
}
|
|
330
|
-
function createAccountRoutes(
|
|
330
|
+
function createAccountRoutes(accountManager, db, config) {
|
|
331
331
|
const router = Router3();
|
|
332
|
-
const deletionService = new AgentDeletionService(db,
|
|
332
|
+
const deletionService = new AgentDeletionService(db, accountManager, config);
|
|
333
333
|
router.post("/accounts", requireMaster, async (req, res, next) => {
|
|
334
334
|
if (!req.body || typeof req.body !== "object") {
|
|
335
335
|
res.status(400).json({ error: "Request body must be JSON" });
|
|
@@ -360,7 +360,7 @@ function createAccountRoutes(accountManager2, db, config) {
|
|
|
360
360
|
const cleanMeta = metadata ? Object.fromEntries(
|
|
361
361
|
Object.entries(metadata).filter(([k]) => !k.startsWith("_"))
|
|
362
362
|
) : void 0;
|
|
363
|
-
const agent = await
|
|
363
|
+
const agent = await accountManager.create({ name: accountName, domain, password: password || void 0, metadata: cleanMeta, role });
|
|
364
364
|
try {
|
|
365
365
|
db.prepare("UPDATE agents SET last_activity_at = datetime('now') WHERE id = ?").run(agent.id);
|
|
366
366
|
} catch {
|
|
@@ -392,7 +392,7 @@ function createAccountRoutes(accountManager2, db, config) {
|
|
|
392
392
|
});
|
|
393
393
|
router.get("/accounts", requireMaster, async (_req, res, next) => {
|
|
394
394
|
try {
|
|
395
|
-
const agents = await
|
|
395
|
+
const agents = await accountManager.list();
|
|
396
396
|
res.json({ agents: agents.map(sanitizeAgent) });
|
|
397
397
|
} catch (err) {
|
|
398
398
|
next(err);
|
|
@@ -400,7 +400,7 @@ function createAccountRoutes(accountManager2, db, config) {
|
|
|
400
400
|
});
|
|
401
401
|
router.get("/accounts/directory", requireAuth, async (_req, res, next) => {
|
|
402
402
|
try {
|
|
403
|
-
const agents = await
|
|
403
|
+
const agents = await accountManager.list();
|
|
404
404
|
const directory = agents.map((a) => ({ name: a.name, email: a.email, role: a.role }));
|
|
405
405
|
res.json({ agents: directory });
|
|
406
406
|
} catch (err) {
|
|
@@ -409,7 +409,7 @@ function createAccountRoutes(accountManager2, db, config) {
|
|
|
409
409
|
});
|
|
410
410
|
router.get("/accounts/directory/:name", requireAuth, async (req, res, next) => {
|
|
411
411
|
try {
|
|
412
|
-
const agent = await
|
|
412
|
+
const agent = await accountManager.getByName(req.params.name);
|
|
413
413
|
if (!agent) {
|
|
414
414
|
res.status(404).json({ error: "Agent not found" });
|
|
415
415
|
return;
|
|
@@ -474,7 +474,7 @@ function createAccountRoutes(accountManager2, db, config) {
|
|
|
474
474
|
const deleted = [];
|
|
475
475
|
for (const row of rows) {
|
|
476
476
|
try {
|
|
477
|
-
await
|
|
477
|
+
await accountManager.delete(row.id);
|
|
478
478
|
deleted.push(row.name);
|
|
479
479
|
} catch {
|
|
480
480
|
}
|
|
@@ -486,7 +486,7 @@ function createAccountRoutes(accountManager2, db, config) {
|
|
|
486
486
|
});
|
|
487
487
|
router.get("/accounts/:id", requireMaster, async (req, res, next) => {
|
|
488
488
|
try {
|
|
489
|
-
const agent = await
|
|
489
|
+
const agent = await accountManager.getById(req.params.id);
|
|
490
490
|
if (!agent) {
|
|
491
491
|
res.status(404).json({ error: "Agent not found" });
|
|
492
492
|
return;
|
|
@@ -504,7 +504,7 @@ function createAccountRoutes(accountManager2, db, config) {
|
|
|
504
504
|
res.status(400).json({ error: "metadata must be an object" });
|
|
505
505
|
return;
|
|
506
506
|
}
|
|
507
|
-
const updated = await
|
|
507
|
+
const updated = await accountManager.updateMetadata(agent.id, metadata);
|
|
508
508
|
if (!updated) {
|
|
509
509
|
res.status(404).json({ error: "Agent not found" });
|
|
510
510
|
return;
|
|
@@ -542,7 +542,7 @@ function createAccountRoutes(accountManager2, db, config) {
|
|
|
542
542
|
});
|
|
543
543
|
router.delete("/accounts/:id", requireMaster, async (req, res, next) => {
|
|
544
544
|
try {
|
|
545
|
-
const allAgents = await
|
|
545
|
+
const allAgents = await accountManager.list();
|
|
546
546
|
if (allAgents.length <= 1) {
|
|
547
547
|
res.status(400).json({ error: "Cannot delete the last agent. At least one agent must remain." });
|
|
548
548
|
return;
|
|
@@ -560,7 +560,7 @@ function createAccountRoutes(accountManager2, db, config) {
|
|
|
560
560
|
}
|
|
561
561
|
res.json(summary);
|
|
562
562
|
} else {
|
|
563
|
-
const deleted = await
|
|
563
|
+
const deleted = await accountManager.delete(req.params.id);
|
|
564
564
|
if (!deleted) {
|
|
565
565
|
res.status(404).json({ error: "Agent not found" });
|
|
566
566
|
return;
|
|
@@ -728,7 +728,7 @@ function parseScheduleTime(input) {
|
|
|
728
728
|
const fallback = new Date(trimmed);
|
|
729
729
|
return isNaN(fallback.getTime()) ? null : fallback;
|
|
730
730
|
}
|
|
731
|
-
function createFeatureRoutes(db,
|
|
731
|
+
function createFeatureRoutes(db, accountManager, config, gatewayManager) {
|
|
732
732
|
const router = Router4();
|
|
733
733
|
router.get("/contacts", requireAgent, async (req, res, next) => {
|
|
734
734
|
try {
|
|
@@ -1318,7 +1318,7 @@ function evaluateRules(db, agentId, email) {
|
|
|
1318
1318
|
}
|
|
1319
1319
|
return null;
|
|
1320
1320
|
}
|
|
1321
|
-
function startScheduledSender(db,
|
|
1321
|
+
function startScheduledSender(db, accountManager, config, gatewayManager) {
|
|
1322
1322
|
return setInterval(async () => {
|
|
1323
1323
|
try {
|
|
1324
1324
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1327,7 +1327,7 @@ function startScheduledSender(db, accountManager2, config, gatewayManager) {
|
|
|
1327
1327
|
).all(now);
|
|
1328
1328
|
for (const row of pending) {
|
|
1329
1329
|
try {
|
|
1330
|
-
const agent = await
|
|
1330
|
+
const agent = await accountManager.getById(row.agent_id);
|
|
1331
1331
|
if (!agent) {
|
|
1332
1332
|
db.prepare("UPDATE scheduled_emails SET status = 'failed', error = ? WHERE id = ?").run("Agent not found", row.id);
|
|
1333
1333
|
continue;
|
|
@@ -1426,7 +1426,7 @@ async function closeAllWatchers() {
|
|
|
1426
1426
|
}
|
|
1427
1427
|
activeWatchers.clear();
|
|
1428
1428
|
}
|
|
1429
|
-
function createEventRoutes(
|
|
1429
|
+
function createEventRoutes(accountManager, config, db) {
|
|
1430
1430
|
const router = Router5();
|
|
1431
1431
|
router.get("/events", requireAgent, async (req, res, next) => {
|
|
1432
1432
|
try {
|
|
@@ -1569,6 +1569,15 @@ function createEventRoutes(accountManager2, config, db) {
|
|
|
1569
1569
|
safeWrite(`data: ${JSON.stringify(event)}
|
|
1570
1570
|
|
|
1571
1571
|
`);
|
|
1572
|
+
try {
|
|
1573
|
+
pushSystemEvent({
|
|
1574
|
+
type: "new_mail",
|
|
1575
|
+
agentId: agent.id,
|
|
1576
|
+
agentName: agent.name,
|
|
1577
|
+
event
|
|
1578
|
+
});
|
|
1579
|
+
} catch {
|
|
1580
|
+
}
|
|
1572
1581
|
});
|
|
1573
1582
|
watcher.on("expunge", (event) => {
|
|
1574
1583
|
safeWrite(`data: ${JSON.stringify(event)}
|
|
@@ -1629,6 +1638,38 @@ var receiverPending = /* @__PURE__ */ new Map();
|
|
|
1629
1638
|
var CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
1630
1639
|
var MAX_CACHE_SIZE = 100;
|
|
1631
1640
|
var draining = false;
|
|
1641
|
+
var parsedMessageCache = /* @__PURE__ */ new Map();
|
|
1642
|
+
var PARSED_MESSAGE_TTL_MS = 6e4;
|
|
1643
|
+
var PARSED_MESSAGE_MAX = 200;
|
|
1644
|
+
function parsedMessageCacheKey(agentId, folder, uid) {
|
|
1645
|
+
return `${agentId}::${folder}::${uid}`;
|
|
1646
|
+
}
|
|
1647
|
+
function getParsedMessageFromCache(agentId, folder, uid) {
|
|
1648
|
+
const key = parsedMessageCacheKey(agentId, folder, uid);
|
|
1649
|
+
const entry = parsedMessageCache.get(key);
|
|
1650
|
+
if (!entry) return null;
|
|
1651
|
+
if (Date.now() - entry.cachedAt > PARSED_MESSAGE_TTL_MS) {
|
|
1652
|
+
parsedMessageCache.delete(key);
|
|
1653
|
+
return null;
|
|
1654
|
+
}
|
|
1655
|
+
parsedMessageCache.delete(key);
|
|
1656
|
+
parsedMessageCache.set(key, entry);
|
|
1657
|
+
return entry.data;
|
|
1658
|
+
}
|
|
1659
|
+
function setParsedMessageInCache(agentId, folder, uid, data) {
|
|
1660
|
+
if (parsedMessageCache.size >= PARSED_MESSAGE_MAX) {
|
|
1661
|
+
const oldest = parsedMessageCache.keys().next().value;
|
|
1662
|
+
if (oldest) parsedMessageCache.delete(oldest);
|
|
1663
|
+
}
|
|
1664
|
+
parsedMessageCache.set(parsedMessageCacheKey(agentId, folder, uid), { data, cachedAt: Date.now() });
|
|
1665
|
+
}
|
|
1666
|
+
function invalidateParsedMessage(agentId, uid) {
|
|
1667
|
+
const prefix = `${agentId}::`;
|
|
1668
|
+
const suffix = `::${uid}`;
|
|
1669
|
+
for (const key of parsedMessageCache.keys()) {
|
|
1670
|
+
if (key.startsWith(prefix) && key.endsWith(suffix)) parsedMessageCache.delete(key);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1632
1673
|
function getAgentPassword(agent) {
|
|
1633
1674
|
return agent.metadata?._password || agent.name;
|
|
1634
1675
|
}
|
|
@@ -1763,7 +1804,7 @@ async function findUidByMessageId(receiver, messageId, maxAttempts = 8) {
|
|
|
1763
1804
|
const lock = await client.getMailboxLock("INBOX");
|
|
1764
1805
|
try {
|
|
1765
1806
|
const results = await client.search(
|
|
1766
|
-
{ header:
|
|
1807
|
+
{ header: { "Message-ID": messageId } },
|
|
1767
1808
|
{ uid: true }
|
|
1768
1809
|
);
|
|
1769
1810
|
if (Array.isArray(results) && results.length > 0) {
|
|
@@ -1841,7 +1882,7 @@ function wakeHeaders(wakeList) {
|
|
|
1841
1882
|
if (wakeList === void 0) return {};
|
|
1842
1883
|
return { "X-AgenticMail-Wake": wakeList.join(", ") };
|
|
1843
1884
|
}
|
|
1844
|
-
async function notifyLocalRecipientsOfNewMail(
|
|
1885
|
+
async function notifyLocalRecipientsOfNewMail(accountManager, toField, ccField, bccField, fromAgent, subject, messageId, config, wakeList) {
|
|
1845
1886
|
const collected = [];
|
|
1846
1887
|
const push = (v) => {
|
|
1847
1888
|
if (!v) return;
|
|
@@ -1881,7 +1922,7 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
|
|
|
1881
1922
|
if (addr === fromAgent.email.toLowerCase()) continue;
|
|
1882
1923
|
let recipient = null;
|
|
1883
1924
|
try {
|
|
1884
|
-
recipient = await
|
|
1925
|
+
recipient = await accountManager.getByName(localPart);
|
|
1885
1926
|
} catch {
|
|
1886
1927
|
}
|
|
1887
1928
|
if (!recipient || notified.has(recipient.id)) continue;
|
|
@@ -1942,7 +1983,7 @@ async function saveSentCopy(authUser, password, config, raw) {
|
|
|
1942
1983
|
console.warn(`[mail] Failed to save Sent copy for ${authUser}: ${err.message}`);
|
|
1943
1984
|
}
|
|
1944
1985
|
}
|
|
1945
|
-
function createMailRoutes(
|
|
1986
|
+
function createMailRoutes(accountManager, config, db, gatewayManager) {
|
|
1946
1987
|
const router = Router6();
|
|
1947
1988
|
router.post("/mail/send", requireAgent, async (req, res, next) => {
|
|
1948
1989
|
try {
|
|
@@ -2096,7 +2137,7 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2096
2137
|
const result = await sender.send(mailOpts);
|
|
2097
2138
|
saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
|
|
2098
2139
|
notifyLocalRecipientsOfNewMail(
|
|
2099
|
-
|
|
2140
|
+
accountManager,
|
|
2100
2141
|
to,
|
|
2101
2142
|
cc,
|
|
2102
2143
|
bcc,
|
|
@@ -2137,33 +2178,52 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2137
2178
|
return;
|
|
2138
2179
|
}
|
|
2139
2180
|
const folder = req.query.folder || "INBOX";
|
|
2181
|
+
const cached = getParsedMessageFromCache(agent.id, folder, uid);
|
|
2182
|
+
if (cached) {
|
|
2183
|
+
res.json(cached);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2140
2186
|
const password = getAgentPassword(agent);
|
|
2141
2187
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2142
2188
|
const raw = await receiver.fetchMessage(uid, folder);
|
|
2143
2189
|
const parsed = await parseEmail2(raw);
|
|
2190
|
+
const attachments = Array.isArray(parsed.attachments) ? parsed.attachments.map((a, index) => ({
|
|
2191
|
+
index,
|
|
2192
|
+
filename: a.filename,
|
|
2193
|
+
contentType: a.contentType,
|
|
2194
|
+
size: typeof a.size === "number" ? a.size : a.content?.length ?? 0,
|
|
2195
|
+
contentDisposition: a.contentDisposition,
|
|
2196
|
+
cid: a.cid,
|
|
2197
|
+
related: a.related
|
|
2198
|
+
})) : [];
|
|
2199
|
+
let payload;
|
|
2144
2200
|
if (isInternalEmail2(parsed)) {
|
|
2145
|
-
|
|
2201
|
+
payload = {
|
|
2146
2202
|
...parsed,
|
|
2203
|
+
attachments,
|
|
2147
2204
|
security: { internal: true, spamScore: 0, isSpam: false, isWarning: false }
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2205
|
+
};
|
|
2206
|
+
} else {
|
|
2207
|
+
const sanitized = sanitizeEmail(parsed);
|
|
2208
|
+
const spamScore = scoreEmail2(parsed);
|
|
2209
|
+
payload = {
|
|
2210
|
+
...parsed,
|
|
2211
|
+
attachments,
|
|
2212
|
+
text: sanitized.text,
|
|
2213
|
+
html: sanitized.html,
|
|
2214
|
+
security: {
|
|
2215
|
+
spamScore: spamScore.score,
|
|
2216
|
+
isSpam: spamScore.isSpam,
|
|
2217
|
+
isWarning: spamScore.isWarning,
|
|
2218
|
+
topCategory: spamScore.topCategory,
|
|
2219
|
+
matches: spamScore.matches.map((m) => m.ruleId),
|
|
2220
|
+
sanitized: sanitized.wasModified,
|
|
2221
|
+
sanitizeDetections: sanitized.detections
|
|
2222
|
+
}
|
|
2223
|
+
};
|
|
2150
2224
|
}
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
res.json({
|
|
2154
|
-
...parsed,
|
|
2155
|
-
text: sanitized.text,
|
|
2156
|
-
html: sanitized.html,
|
|
2157
|
-
security: {
|
|
2158
|
-
spamScore: spamScore.score,
|
|
2159
|
-
isSpam: spamScore.isSpam,
|
|
2160
|
-
isWarning: spamScore.isWarning,
|
|
2161
|
-
topCategory: spamScore.topCategory,
|
|
2162
|
-
matches: spamScore.matches.map((m) => m.ruleId),
|
|
2163
|
-
sanitized: sanitized.wasModified,
|
|
2164
|
-
sanitizeDetections: sanitized.detections
|
|
2165
|
-
}
|
|
2166
|
-
});
|
|
2225
|
+
setParsedMessageInCache(agent.id, folder, uid, payload);
|
|
2226
|
+
res.json(payload);
|
|
2167
2227
|
} catch (err) {
|
|
2168
2228
|
next(err);
|
|
2169
2229
|
}
|
|
@@ -2294,6 +2354,7 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2294
2354
|
const password = getAgentPassword(agent);
|
|
2295
2355
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2296
2356
|
await receiver.markSeen(uid);
|
|
2357
|
+
invalidateParsedMessage(agent.id, uid);
|
|
2297
2358
|
res.json({ ok: true });
|
|
2298
2359
|
} catch (err) {
|
|
2299
2360
|
next(err);
|
|
@@ -2340,6 +2401,7 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2340
2401
|
const password = getAgentPassword(agent);
|
|
2341
2402
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2342
2403
|
await receiver.markUnseen(uid);
|
|
2404
|
+
invalidateParsedMessage(agent.id, uid);
|
|
2343
2405
|
res.json({ ok: true });
|
|
2344
2406
|
} catch (err) {
|
|
2345
2407
|
next(err);
|
|
@@ -2358,6 +2420,7 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2358
2420
|
const password = getAgentPassword(agent);
|
|
2359
2421
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2360
2422
|
await receiver.setStarred(uid, starred, folder);
|
|
2423
|
+
invalidateParsedMessage(agent.id, uid);
|
|
2361
2424
|
res.json({ ok: true, starred });
|
|
2362
2425
|
} catch (err) {
|
|
2363
2426
|
next(err);
|
|
@@ -2379,6 +2442,7 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2379
2442
|
const password = getAgentPassword(agent);
|
|
2380
2443
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2381
2444
|
await receiver.moveMessage(uid, fromFolder || "INBOX", toFolder);
|
|
2445
|
+
invalidateParsedMessage(agent.id, uid);
|
|
2382
2446
|
res.json({ ok: true });
|
|
2383
2447
|
} catch (err) {
|
|
2384
2448
|
next(err);
|
|
@@ -2842,7 +2906,7 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2842
2906
|
res.status(400).json({ error: `Email already ${row.status}` });
|
|
2843
2907
|
return;
|
|
2844
2908
|
}
|
|
2845
|
-
const agent = await
|
|
2909
|
+
const agent = await accountManager.getById(row.agent_id);
|
|
2846
2910
|
if (!agent) {
|
|
2847
2911
|
res.status(404).json({ error: "Agent account no longer exists" });
|
|
2848
2912
|
return;
|
|
@@ -2879,7 +2943,7 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2879
2943
|
const result = await sender.send(mailOpts);
|
|
2880
2944
|
saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
|
|
2881
2945
|
notifyLocalRecipientsOfNewMail(
|
|
2882
|
-
|
|
2946
|
+
accountManager,
|
|
2883
2947
|
mailOpts.to,
|
|
2884
2948
|
mailOpts.cc,
|
|
2885
2949
|
mailOpts.bcc,
|
|
@@ -2936,7 +3000,7 @@ var INBOUND_SECRET = process.env.AGENTICMAIL_INBOUND_SECRET || (() => {
|
|
|
2936
3000
|
return generated;
|
|
2937
3001
|
})();
|
|
2938
3002
|
var DEBUG = () => !!process.env.AGENTICMAIL_DEBUG;
|
|
2939
|
-
function createInboundRoutes(
|
|
3003
|
+
function createInboundRoutes(accountManager, config, gatewayManager) {
|
|
2940
3004
|
const router = Router7();
|
|
2941
3005
|
router.post("/mail/inbound", async (req, res, next) => {
|
|
2942
3006
|
try {
|
|
@@ -2952,7 +3016,7 @@ function createInboundRoutes(accountManager2, config, gatewayManager) {
|
|
|
2952
3016
|
}
|
|
2953
3017
|
const recipientEmail = typeof to === "string" ? to : to[0];
|
|
2954
3018
|
const localPart = recipientEmail.split("@")[0];
|
|
2955
|
-
const agent = await
|
|
3019
|
+
const agent = await accountManager.getByName(localPart);
|
|
2956
3020
|
if (!agent) {
|
|
2957
3021
|
console.warn(`[Inbound] No agent found for "${localPart}" (${recipientEmail})`);
|
|
2958
3022
|
res.status(404).json({ error: `No agent found for ${recipientEmail}` });
|
|
@@ -3544,7 +3608,7 @@ function deepEqual(a, b) {
|
|
|
3544
3608
|
|
|
3545
3609
|
// src/routes/tasks.ts
|
|
3546
3610
|
var rpcResolvers = /* @__PURE__ */ new Map();
|
|
3547
|
-
function createTaskRoutes(db,
|
|
3611
|
+
function createTaskRoutes(db, accountManager, config) {
|
|
3548
3612
|
const router = Router10();
|
|
3549
3613
|
router.post("/tasks/assign", requireAuth, async (req, res, next) => {
|
|
3550
3614
|
try {
|
|
@@ -3553,7 +3617,7 @@ function createTaskRoutes(db, accountManager2, config) {
|
|
|
3553
3617
|
res.status(400).json({ error: "assignee (agent name) is required" });
|
|
3554
3618
|
return;
|
|
3555
3619
|
}
|
|
3556
|
-
const target = await
|
|
3620
|
+
const target = await accountManager.getByName(assignee);
|
|
3557
3621
|
if (!target) {
|
|
3558
3622
|
res.status(404).json({ error: `Agent "${assignee}" not found` });
|
|
3559
3623
|
return;
|
|
@@ -3631,7 +3695,7 @@ Please check your pending tasks.`
|
|
|
3631
3695
|
let assigneeId = req.agent.id;
|
|
3632
3696
|
const assigneeName = req.query.assignee;
|
|
3633
3697
|
if (assigneeName) {
|
|
3634
|
-
const target = await
|
|
3698
|
+
const target = await accountManager.getByName(assigneeName);
|
|
3635
3699
|
if (target) assigneeId = target.id;
|
|
3636
3700
|
}
|
|
3637
3701
|
const rows = db.prepare(
|
|
@@ -3778,7 +3842,7 @@ Please check your pending tasks.`
|
|
|
3778
3842
|
res.status(400).json({ error: "target (agent name) and task are required" });
|
|
3779
3843
|
return;
|
|
3780
3844
|
}
|
|
3781
|
-
const targetAgent = await
|
|
3845
|
+
const targetAgent = await accountManager.getByName(target);
|
|
3782
3846
|
if (!targetAgent) {
|
|
3783
3847
|
res.status(404).json({ error: `Agent "${target}" not found` });
|
|
3784
3848
|
return;
|
|
@@ -3919,7 +3983,7 @@ import {
|
|
|
3919
3983
|
normalizePhoneNumber,
|
|
3920
3984
|
isValidPhoneNumber
|
|
3921
3985
|
} from "@agenticmail/core";
|
|
3922
|
-
function createSmsRoutes(db,
|
|
3986
|
+
function createSmsRoutes(db, accountManager, config, gatewayManager) {
|
|
3923
3987
|
const router = Router11();
|
|
3924
3988
|
const smsManager = new SmsManager(db);
|
|
3925
3989
|
function getAgent(req, res) {
|
|
@@ -4307,7 +4371,7 @@ function adaptBetterSqlite(raw) {
|
|
|
4307
4371
|
}
|
|
4308
4372
|
};
|
|
4309
4373
|
}
|
|
4310
|
-
function createStorageRoutes(rawDb,
|
|
4374
|
+
function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite") {
|
|
4311
4375
|
const db = adaptBetterSqlite(rawDb);
|
|
4312
4376
|
const router = Router12();
|
|
4313
4377
|
function getAgent(req, res) {
|
|
@@ -5485,12 +5549,12 @@ function createApp(configOverrides) {
|
|
|
5485
5549
|
adminUser: config.stalwart.adminUser,
|
|
5486
5550
|
adminPassword: config.stalwart.adminPassword
|
|
5487
5551
|
});
|
|
5488
|
-
const
|
|
5552
|
+
const accountManager = new AccountManager(db, stalwart);
|
|
5489
5553
|
const domainManager = new DomainManager(db, stalwart);
|
|
5490
5554
|
const gatewayManager = new GatewayManager2({
|
|
5491
5555
|
db,
|
|
5492
5556
|
stalwart,
|
|
5493
|
-
accountManager
|
|
5557
|
+
accountManager,
|
|
5494
5558
|
localSmtp: {
|
|
5495
5559
|
host: config.smtp.host,
|
|
5496
5560
|
port: config.smtp.port,
|
|
@@ -5510,7 +5574,7 @@ function createApp(configOverrides) {
|
|
|
5510
5574
|
app2.use(
|
|
5511
5575
|
rateLimit({
|
|
5512
5576
|
windowMs: 60 * 1e3,
|
|
5513
|
-
|
|
5577
|
+
limit: 1e4,
|
|
5514
5578
|
standardHeaders: true,
|
|
5515
5579
|
legacyHeaders: false,
|
|
5516
5580
|
message: { error: "Too many requests, please try again later" }
|
|
@@ -5526,25 +5590,45 @@ function createApp(configOverrides) {
|
|
|
5526
5590
|
return null;
|
|
5527
5591
|
})();
|
|
5528
5592
|
if (staticDir) {
|
|
5529
|
-
app2.use("/", express.static(staticDir, {
|
|
5593
|
+
app2.use("/", express.static(staticDir, {
|
|
5594
|
+
index: "index.html",
|
|
5595
|
+
extensions: ["html"],
|
|
5596
|
+
// Browser caching for static assets. Without these every page
|
|
5597
|
+
// refresh re-downloads every .js / .css / branding image, which
|
|
5598
|
+
// ate ~30 round-trips per refresh and made the page feel slow.
|
|
5599
|
+
//
|
|
5600
|
+
// We DON'T cache index.html — it stays fresh so a deploy is
|
|
5601
|
+
// visible on the next refresh. Everything else gets a short
|
|
5602
|
+
// max-age + must-revalidate so the browser will keep using its
|
|
5603
|
+
// cached copy across refreshes while still picking up new builds
|
|
5604
|
+
// (the dist contents are deterministic — same code → same etag
|
|
5605
|
+
// → 304 Not Modified on revalidate).
|
|
5606
|
+
setHeaders: (res, filePath) => {
|
|
5607
|
+
if (filePath.endsWith("index.html") || filePath.endsWith(".html")) {
|
|
5608
|
+
res.setHeader("Cache-Control", "no-cache, must-revalidate");
|
|
5609
|
+
} else {
|
|
5610
|
+
res.setHeader("Cache-Control", "public, max-age=300, must-revalidate");
|
|
5611
|
+
}
|
|
5612
|
+
}
|
|
5613
|
+
}));
|
|
5530
5614
|
app2.get("/ui", (_req, res) => res.sendFile(join3(staticDir, "index.html")));
|
|
5531
5615
|
}
|
|
5532
5616
|
app2.use("/api/agenticmail", createHealthRoutes(stalwart));
|
|
5533
|
-
app2.use("/api/agenticmail", createInboundRoutes(
|
|
5617
|
+
app2.use("/api/agenticmail", createInboundRoutes(accountManager, config, gatewayManager));
|
|
5534
5618
|
const integrationFactory = readResolvedFactory(integrationRouteFactoryPromise);
|
|
5535
5619
|
if (integrationFactory) {
|
|
5536
5620
|
app2.use("/api/agenticmail", integrationFactory());
|
|
5537
5621
|
}
|
|
5538
|
-
app2.use("/api/agenticmail", createAuthMiddleware(config.masterKey,
|
|
5539
|
-
app2.use("/api/agenticmail", createAccountRoutes(
|
|
5540
|
-
app2.use("/api/agenticmail", createMailRoutes(
|
|
5541
|
-
app2.use("/api/agenticmail", createEventRoutes(
|
|
5622
|
+
app2.use("/api/agenticmail", createAuthMiddleware(config.masterKey, accountManager, db));
|
|
5623
|
+
app2.use("/api/agenticmail", createAccountRoutes(accountManager, db, config));
|
|
5624
|
+
app2.use("/api/agenticmail", createMailRoutes(accountManager, config, db, gatewayManager));
|
|
5625
|
+
app2.use("/api/agenticmail", createEventRoutes(accountManager, config, db));
|
|
5542
5626
|
app2.use("/api/agenticmail", createDomainRoutes(domainManager));
|
|
5543
5627
|
app2.use("/api/agenticmail", createGatewayRoutes(gatewayManager));
|
|
5544
|
-
app2.use("/api/agenticmail", createFeatureRoutes(db,
|
|
5545
|
-
app2.use("/api/agenticmail", createTaskRoutes(db,
|
|
5546
|
-
app2.use("/api/agenticmail", createSmsRoutes(db,
|
|
5547
|
-
app2.use("/api/agenticmail", createStorageRoutes(db,
|
|
5628
|
+
app2.use("/api/agenticmail", createFeatureRoutes(db, accountManager, config, gatewayManager));
|
|
5629
|
+
app2.use("/api/agenticmail", createTaskRoutes(db, accountManager, config));
|
|
5630
|
+
app2.use("/api/agenticmail", createSmsRoutes(db, accountManager, config, gatewayManager));
|
|
5631
|
+
app2.use("/api/agenticmail", createStorageRoutes(db, accountManager, config));
|
|
5548
5632
|
app2.use("/api/agenticmail", createSystemEventRoutes());
|
|
5549
5633
|
app2.use("/api/agenticmail", createDispatcherActivityRoutes());
|
|
5550
5634
|
app2.use("/api/agenticmail", createAgentMemoryRoutes(config));
|
|
@@ -5552,7 +5636,7 @@ function createApp(configOverrides) {
|
|
|
5552
5636
|
res.status(404).json({ error: "Not found" });
|
|
5553
5637
|
});
|
|
5554
5638
|
app2.use(errorHandler);
|
|
5555
|
-
const context2 = { config, db, stalwart, accountManager
|
|
5639
|
+
const context2 = { config, db, stalwart, accountManager, domainManager, gatewayManager };
|
|
5556
5640
|
return { app: app2, context: context2 };
|
|
5557
5641
|
}
|
|
5558
5642
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agenticmail/api",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/cors": "^2.8.17",
|
|
43
|
-
"@types/express": "^
|
|
43
|
+
"@types/express": "^4.17.0",
|
|
44
44
|
"@types/uuid": "^10.0.0",
|
|
45
45
|
"tsup": "^8.4.0",
|
|
46
46
|
"tsx": "^4.19.0",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// arrive at the heartbeat cadence (30 s) so the badge text
|
|
12
12
|
// reflects what the agent is doing right now.
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { onSystemEvent } from './system-stream.js';
|
|
15
15
|
|
|
16
16
|
const BADGE_CONTAINER_ID = 'activity-badges';
|
|
17
17
|
|
|
@@ -21,7 +21,9 @@ const BADGE_CONTAINER_ID = 'activity-badges';
|
|
|
21
21
|
* the badge container on every event.
|
|
22
22
|
*/
|
|
23
23
|
const workers = new Map();
|
|
24
|
-
let
|
|
24
|
+
let unsubWorkerStarted = null;
|
|
25
|
+
let unsubWorkerHeartbeat = null;
|
|
26
|
+
let unsubWorkerFinished = null;
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Map an SDK tool name (or the truncated head we capture in
|
|
@@ -91,36 +93,16 @@ function handleEvent(event) {
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/**
|
|
94
|
-
* Subscribe to
|
|
95
|
-
*
|
|
96
|
-
* sign-in). Re-subscribes idempotently — safe to call after
|
|
97
|
-
* agent-list refresh.
|
|
96
|
+
* Subscribe to worker_* events on the shared /system/events stream.
|
|
97
|
+
* Idempotent — safe to call after agent-list refresh.
|
|
98
98
|
*/
|
|
99
99
|
export function subscribeToActivity() {
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (!res.ok || !res.body) return;
|
|
107
|
-
const reader = res.body.getReader();
|
|
108
|
-
const dec = new TextDecoder();
|
|
109
|
-
let buf = '';
|
|
110
|
-
while (!sseController.signal.aborted) {
|
|
111
|
-
const { done, value } = await reader.read();
|
|
112
|
-
if (done) break;
|
|
113
|
-
buf += dec.decode(value, { stream: true });
|
|
114
|
-
let i;
|
|
115
|
-
while ((i = buf.indexOf('\n\n')) !== -1) {
|
|
116
|
-
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
117
|
-
for (const line of frame.split('\n')) {
|
|
118
|
-
if (!line.startsWith('data: ')) continue;
|
|
119
|
-
try { handleEvent(JSON.parse(line.slice(6))); } catch {}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}).catch(() => { /* dropped — user can refresh to reconnect */ });
|
|
100
|
+
if (unsubWorkerStarted) { try { unsubWorkerStarted(); } catch {} }
|
|
101
|
+
if (unsubWorkerHeartbeat) { try { unsubWorkerHeartbeat(); } catch {} }
|
|
102
|
+
if (unsubWorkerFinished) { try { unsubWorkerFinished(); } catch {} }
|
|
103
|
+
unsubWorkerStarted = onSystemEvent('worker_started', handleEvent);
|
|
104
|
+
unsubWorkerHeartbeat = onSystemEvent('worker_heartbeat', handleEvent);
|
|
105
|
+
unsubWorkerFinished = onSystemEvent('worker_finished', handleEvent);
|
|
124
106
|
}
|
|
125
107
|
|
|
126
108
|
// Tiny HTML escapers (kept local to avoid an import cycle).
|
package/public/js/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { loadList, renderList, clearSearch, ensureFolderCache } from './list-vie
|
|
|
14
14
|
import { openMessage } from './message-view.js';
|
|
15
15
|
import { populateComposeFrom, openCompose, openDraft, closeCompose, discardCompose, sendCompose } from './compose.js';
|
|
16
16
|
import { subscribeToAllAgents, maybeRequestNotificationPermission } from './sse.js';
|
|
17
|
+
import { connectSystemStream } from './system-stream.js';
|
|
17
18
|
import { subscribeToActivity } from './activity-badges.js';
|
|
18
19
|
import { icon } from './icons.js';
|
|
19
20
|
import { isSoundEnabled, setSoundEnabled, playNotificationSound } from './sound.js';
|
|
@@ -111,12 +112,14 @@ async function bootstrap() {
|
|
|
111
112
|
if (initial) await selectAgent(initial);
|
|
112
113
|
renderProfile();
|
|
113
114
|
populateComposeFrom();
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
115
|
+
// ONE shared SSE connection on /system/events for the whole UI.
|
|
116
|
+
// Used to be N+1 (one per agent for new mail + one for activity
|
|
117
|
+
// badges), which saturated the browser's 6-connections-per-origin
|
|
118
|
+
// cap with 5 agents and blocked page navigation. Now everything
|
|
119
|
+
// multiplexes through this single stream — see system-stream.js.
|
|
120
|
+
connectSystemStream();
|
|
121
|
+
subscribeToAllAgents(); // new_mail handlers
|
|
122
|
+
subscribeToActivity(); // worker_* handlers
|
|
120
123
|
maybeRequestNotificationPermission();
|
|
121
124
|
// If the URL points at a message (not a folder), open it now —
|
|
122
125
|
// the folder list selectAgent already loaded stays in the
|
package/public/js/sse.js
CHANGED
|
@@ -1,47 +1,35 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
1
|
+
// New-mail notifications for the web UI.
|
|
2
|
+
//
|
|
3
|
+
// Listens for `new_mail` events on the shared /system/events stream
|
|
4
|
+
// (one connection for the whole UI; see system-stream.js for why).
|
|
5
|
+
// Fans the event out to:
|
|
6
|
+
// 1. List view — silent in-place refresh (no flicker / scroll jump)
|
|
7
|
+
// if it's the active inbox.
|
|
8
|
+
// 2. Profile dropdown — bump the per-agent unread counter.
|
|
9
|
+
// 3. Browser notification when tab isn't focused.
|
|
10
|
+
// 4. Soft chime (toggleable) when sound is enabled.
|
|
11
|
+
|
|
12
|
+
import { state } from './state.js';
|
|
10
13
|
import { toast } from './utils.js';
|
|
11
14
|
import { renderProfile } from './profile.js';
|
|
12
15
|
import { silentRefresh } from './list-view.js';
|
|
13
16
|
import { playNotificationSound } from './sound.js';
|
|
17
|
+
import { onSystemEvent } from './system-stream.js';
|
|
18
|
+
|
|
19
|
+
let unsubscribe = null;
|
|
14
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Wire the new-mail listener onto the shared system stream.
|
|
23
|
+
* Idempotent — safe to call after agent-list refreshes.
|
|
24
|
+
*/
|
|
15
25
|
export function subscribeToAllAgents() {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
headers: { Authorization: `Bearer ${agent.apiKey}`, Accept: 'text/event-stream' },
|
|
24
|
-
signal: ctrl.signal,
|
|
25
|
-
}).then(async res => {
|
|
26
|
-
if (!res.ok || !res.body) return;
|
|
27
|
-
const reader = res.body.getReader();
|
|
28
|
-
const dec = new TextDecoder();
|
|
29
|
-
let buf = '';
|
|
30
|
-
while (!ctrl.signal.aborted) {
|
|
31
|
-
const { done, value } = await reader.read();
|
|
32
|
-
if (done) break;
|
|
33
|
-
buf += dec.decode(value, { stream: true });
|
|
34
|
-
let i;
|
|
35
|
-
while ((i = buf.indexOf('\n\n')) !== -1) {
|
|
36
|
-
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
37
|
-
for (const line of frame.split('\n')) {
|
|
38
|
-
if (!line.startsWith('data: ')) continue;
|
|
39
|
-
try { handleSseEvent(agent, JSON.parse(line.slice(6))); } catch {}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}).catch(() => {});
|
|
44
|
-
}
|
|
26
|
+
if (unsubscribe) { try { unsubscribe(); } catch {} }
|
|
27
|
+
unsubscribe = onSystemEvent('new_mail', payload => {
|
|
28
|
+
// payload shape: { type: 'new_mail', agentId, agentName, event }
|
|
29
|
+
const agent = state.agents.find(a => a.id === payload.agentId);
|
|
30
|
+
if (!agent) return; // unknown agent (account_deleted race)
|
|
31
|
+
handleSseEvent(agent, payload.event);
|
|
32
|
+
});
|
|
45
33
|
}
|
|
46
34
|
|
|
47
35
|
async function handleSseEvent(agent, event) {
|
|
@@ -52,23 +40,12 @@ async function handleSseEvent(agent, event) {
|
|
|
52
40
|
|
|
53
41
|
const isOpen = state.selectedAgent?.id === agent.id;
|
|
54
42
|
if (isOpen) {
|
|
55
|
-
// Silent in-place refresh — re-fetches the list digest and
|
|
56
|
-
// re-renders ONLY the rows div. Toolbar (select-all, refresh,
|
|
57
|
-
// bulk-actions) is untouched; existing row checkboxes survive;
|
|
58
|
-
// scroll position is preserved by the browser since we replace
|
|
59
|
-
// only the inner content. No "Loading…" flicker.
|
|
60
43
|
await silentRefresh(agent, state.selectedFolder);
|
|
61
|
-
state.unread[agent.id] = 0;
|
|
44
|
+
state.unread[agent.id] = 0;
|
|
62
45
|
renderProfile();
|
|
63
46
|
}
|
|
64
47
|
|
|
65
|
-
// Soft chime — respects the user's sound toggle. Plays for every
|
|
66
|
-
// arrival regardless of whether the tab is focused, because that
|
|
67
|
-
// is the whole point of the chime (a foregrounded tab still
|
|
68
|
-
// benefits from the audible ping when the user's attention is
|
|
69
|
-
// elsewhere on screen).
|
|
70
48
|
playNotificationSound();
|
|
71
|
-
|
|
72
49
|
fireBrowserNotification(agent, event, isOpen);
|
|
73
50
|
|
|
74
51
|
if (!isOpen) {
|
|
@@ -103,8 +80,6 @@ function fireBrowserNotification(agent, event, isOpen) {
|
|
|
103
80
|
});
|
|
104
81
|
n.onclick = () => {
|
|
105
82
|
window.focus();
|
|
106
|
-
// Switching agent here requires the router; let the user click
|
|
107
|
-
// through manually so we don't tightly couple sse → router.
|
|
108
83
|
if (event.uid) location.hash = `#/m/${event.uid}`;
|
|
109
84
|
n.close();
|
|
110
85
|
};
|
package/public/js/state.js
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Single shared SSE connection to /system/events.
|
|
2
|
+
//
|
|
3
|
+
// # Why this exists
|
|
4
|
+
//
|
|
5
|
+
// Browsers cap HTTP connections at 6 per origin. The old web UI opened
|
|
6
|
+
// ONE per-agent /events SSE plus ONE /system/events SSE — so with 5
|
|
7
|
+
// agents, that's 6 long-lived connections, exhausting the cap. Every
|
|
8
|
+
// other request (page refresh, message fetch, attachment download)
|
|
9
|
+
// had to wait for an SSE slot to free up, which never happened
|
|
10
|
+
// because they're persistent.
|
|
11
|
+
//
|
|
12
|
+
// Fix: every per-agent new-mail event is now also pushed to
|
|
13
|
+
// /system/events by the API. The UI subscribes ONCE here, and modules
|
|
14
|
+
// register handlers via `onSystemEvent(type, handler)`. Net effect:
|
|
15
|
+
// 6 SSE connections → 1, freeing 5 slots for actual HTTP traffic.
|
|
16
|
+
//
|
|
17
|
+
// # API
|
|
18
|
+
//
|
|
19
|
+
// import { connectSystemStream, onSystemEvent } from './system-stream.js';
|
|
20
|
+
// connectSystemStream(); // wire it up once after sign-in
|
|
21
|
+
// onSystemEvent('new_mail', (e) => { ... }); // subscribe to event type
|
|
22
|
+
// onSystemEvent('worker_started', (e) => {}); // ANY type the server emits
|
|
23
|
+
//
|
|
24
|
+
// Multiple subscribers per type are supported. Each handler runs in
|
|
25
|
+
// try/catch so one buggy handler can't kill the others.
|
|
26
|
+
|
|
27
|
+
import { state, API_URL } from './state.js';
|
|
28
|
+
|
|
29
|
+
let controller = null;
|
|
30
|
+
let connected = false;
|
|
31
|
+
const handlers = new Map(); // type → Set<handler>
|
|
32
|
+
|
|
33
|
+
export function onSystemEvent(type, handler) {
|
|
34
|
+
if (!handlers.has(type)) handlers.set(type, new Set());
|
|
35
|
+
handlers.get(type).add(handler);
|
|
36
|
+
return () => handlers.get(type)?.delete(handler);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function dispatch(event) {
|
|
40
|
+
if (!event || typeof event !== 'object') return;
|
|
41
|
+
const set = handlers.get(event.type);
|
|
42
|
+
if (!set) return;
|
|
43
|
+
for (const h of set) {
|
|
44
|
+
try { h(event); } catch (err) { console.error('[system-stream] handler error', err); }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function connectSystemStream() {
|
|
49
|
+
if (controller) { try { controller.abort(); } catch {} }
|
|
50
|
+
controller = new AbortController();
|
|
51
|
+
connected = false;
|
|
52
|
+
const sig = controller.signal;
|
|
53
|
+
|
|
54
|
+
// Auto-reconnect with exponential backoff. Capped at 30s — keeping
|
|
55
|
+
// a UI live during a long server outage shouldn't slam the API
|
|
56
|
+
// every two seconds.
|
|
57
|
+
let backoff = 1000;
|
|
58
|
+
const loop = async () => {
|
|
59
|
+
while (!sig.aborted) {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(`${API_URL}/api/agenticmail/system/events`, {
|
|
62
|
+
headers: { Authorization: `Bearer ${state.masterKey}`, Accept: 'text/event-stream' },
|
|
63
|
+
signal: sig,
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok || !res.body) {
|
|
66
|
+
// Hard 4xx (auth) → stop trying; user has to refresh / sign in again.
|
|
67
|
+
if (res.status === 401 || res.status === 403) return;
|
|
68
|
+
throw new Error(`/system/events HTTP ${res.status}`);
|
|
69
|
+
}
|
|
70
|
+
connected = true;
|
|
71
|
+
backoff = 1000; // healthy connection — reset
|
|
72
|
+
const reader = res.body.getReader();
|
|
73
|
+
const dec = new TextDecoder();
|
|
74
|
+
let buf = '';
|
|
75
|
+
while (!sig.aborted) {
|
|
76
|
+
const { done, value } = await reader.read();
|
|
77
|
+
if (done) break;
|
|
78
|
+
buf += dec.decode(value, { stream: true });
|
|
79
|
+
let i;
|
|
80
|
+
while ((i = buf.indexOf('\n\n')) !== -1) {
|
|
81
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
82
|
+
for (const line of frame.split('\n')) {
|
|
83
|
+
if (!line.startsWith('data: ')) continue;
|
|
84
|
+
try { dispatch(JSON.parse(line.slice(6))); } catch {}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (sig.aborted) return;
|
|
90
|
+
// Stream dropped — wait + reconnect.
|
|
91
|
+
}
|
|
92
|
+
connected = false;
|
|
93
|
+
if (sig.aborted) return;
|
|
94
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
95
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
loop();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isSystemStreamConnected() {
|
|
102
|
+
return connected;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function disconnectSystemStream() {
|
|
106
|
+
if (controller) { try { controller.abort(); } catch {} }
|
|
107
|
+
controller = null;
|
|
108
|
+
connected = false;
|
|
109
|
+
}
|