@agenticmail/api 0.9.5 → 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 +208 -79
- package/package.json +2 -2
- package/public/js/activity-badges.js +12 -30
- package/public/js/app.js +42 -18
- package/public/js/list-view.js +9 -1
- 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);
|
|
@@ -2714,30 +2778,75 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2714
2778
|
const folder = req.query.folder || "INBOX";
|
|
2715
2779
|
const password = getAgentPassword(agent);
|
|
2716
2780
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2717
|
-
const
|
|
2718
|
-
const
|
|
2719
|
-
const
|
|
2720
|
-
const
|
|
2721
|
-
const
|
|
2722
|
-
|
|
2723
|
-
|
|
2781
|
+
const PREVIEW_MAX_BYTES = 8192;
|
|
2782
|
+
const client = receiver.getImapClient();
|
|
2783
|
+
const lock = await client.getMailboxLock(folder);
|
|
2784
|
+
const envelopes = [];
|
|
2785
|
+
const rawMap = /* @__PURE__ */ new Map();
|
|
2786
|
+
let total = 0;
|
|
2787
|
+
try {
|
|
2788
|
+
const searchResult = await client.search({ all: true }, { uid: true });
|
|
2789
|
+
const allUids = Array.isArray(searchResult) ? searchResult : [];
|
|
2790
|
+
total = allUids.length;
|
|
2791
|
+
const sorted = allUids.slice().sort((a, b) => b - a);
|
|
2792
|
+
const pageUids = sorted.slice(offset, offset + limit);
|
|
2793
|
+
if (pageUids.length > 0) {
|
|
2794
|
+
for await (const msg of client.fetch(pageUids.join(","), {
|
|
2795
|
+
uid: true,
|
|
2796
|
+
envelope: true,
|
|
2797
|
+
flags: true,
|
|
2798
|
+
size: true
|
|
2799
|
+
})) {
|
|
2800
|
+
const env = msg.envelope;
|
|
2801
|
+
if (!env) continue;
|
|
2802
|
+
envelopes.push({
|
|
2803
|
+
uid: msg.uid,
|
|
2804
|
+
subject: env.subject ?? "",
|
|
2805
|
+
from: (env.from ?? []).map((a) => ({ name: a.name, address: a.address ?? "" })),
|
|
2806
|
+
to: (env.to ?? []).map((a) => ({ name: a.name, address: a.address ?? "" })),
|
|
2807
|
+
date: env.date ?? /* @__PURE__ */ new Date(),
|
|
2808
|
+
flags: msg.flags ? [...msg.flags] : [],
|
|
2809
|
+
size: msg.size ?? 0
|
|
2810
|
+
});
|
|
2811
|
+
}
|
|
2812
|
+
envelopes.sort((a, b) => b.uid - a.uid);
|
|
2813
|
+
for await (const msg of client.fetch(pageUids.join(","), {
|
|
2814
|
+
uid: true,
|
|
2815
|
+
source: { start: 0, maxLength: PREVIEW_MAX_BYTES }
|
|
2816
|
+
})) {
|
|
2817
|
+
if (msg.source) {
|
|
2818
|
+
rawMap.set(
|
|
2819
|
+
msg.uid,
|
|
2820
|
+
Buffer.isBuffer(msg.source) ? msg.source : Buffer.from(msg.source)
|
|
2821
|
+
);
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
} finally {
|
|
2826
|
+
lock.release();
|
|
2827
|
+
}
|
|
2828
|
+
const messages = await Promise.all(envelopes.map(async (env) => {
|
|
2724
2829
|
const raw = rawMap.get(env.uid);
|
|
2830
|
+
let preview = "";
|
|
2725
2831
|
if (raw) {
|
|
2726
|
-
|
|
2727
|
-
|
|
2832
|
+
try {
|
|
2833
|
+
const parsed = await parseEmail2(raw);
|
|
2834
|
+
preview = (parsed.text || "").slice(0, previewLen);
|
|
2835
|
+
} catch {
|
|
2836
|
+
}
|
|
2728
2837
|
}
|
|
2729
|
-
|
|
2838
|
+
return {
|
|
2730
2839
|
uid: env.uid,
|
|
2731
2840
|
subject: env.subject,
|
|
2732
2841
|
from: env.from,
|
|
2733
2842
|
to: env.to,
|
|
2734
2843
|
date: env.date,
|
|
2735
|
-
flags:
|
|
2844
|
+
flags: env.flags,
|
|
2736
2845
|
size: env.size,
|
|
2737
2846
|
preview
|
|
2738
|
-
}
|
|
2739
|
-
}
|
|
2740
|
-
res.json({ messages, count: messages.length, total
|
|
2847
|
+
};
|
|
2848
|
+
}));
|
|
2849
|
+
res.json({ messages, count: messages.length, total });
|
|
2741
2850
|
} catch (err) {
|
|
2742
2851
|
next(err);
|
|
2743
2852
|
}
|
|
@@ -2797,7 +2906,7 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2797
2906
|
res.status(400).json({ error: `Email already ${row.status}` });
|
|
2798
2907
|
return;
|
|
2799
2908
|
}
|
|
2800
|
-
const agent = await
|
|
2909
|
+
const agent = await accountManager.getById(row.agent_id);
|
|
2801
2910
|
if (!agent) {
|
|
2802
2911
|
res.status(404).json({ error: "Agent account no longer exists" });
|
|
2803
2912
|
return;
|
|
@@ -2834,7 +2943,7 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
|
|
|
2834
2943
|
const result = await sender.send(mailOpts);
|
|
2835
2944
|
saveSentCopy(agent.stalwartPrincipal, password, config, result.raw);
|
|
2836
2945
|
notifyLocalRecipientsOfNewMail(
|
|
2837
|
-
|
|
2946
|
+
accountManager,
|
|
2838
2947
|
mailOpts.to,
|
|
2839
2948
|
mailOpts.cc,
|
|
2840
2949
|
mailOpts.bcc,
|
|
@@ -2891,7 +3000,7 @@ var INBOUND_SECRET = process.env.AGENTICMAIL_INBOUND_SECRET || (() => {
|
|
|
2891
3000
|
return generated;
|
|
2892
3001
|
})();
|
|
2893
3002
|
var DEBUG = () => !!process.env.AGENTICMAIL_DEBUG;
|
|
2894
|
-
function createInboundRoutes(
|
|
3003
|
+
function createInboundRoutes(accountManager, config, gatewayManager) {
|
|
2895
3004
|
const router = Router7();
|
|
2896
3005
|
router.post("/mail/inbound", async (req, res, next) => {
|
|
2897
3006
|
try {
|
|
@@ -2907,7 +3016,7 @@ function createInboundRoutes(accountManager2, config, gatewayManager) {
|
|
|
2907
3016
|
}
|
|
2908
3017
|
const recipientEmail = typeof to === "string" ? to : to[0];
|
|
2909
3018
|
const localPart = recipientEmail.split("@")[0];
|
|
2910
|
-
const agent = await
|
|
3019
|
+
const agent = await accountManager.getByName(localPart);
|
|
2911
3020
|
if (!agent) {
|
|
2912
3021
|
console.warn(`[Inbound] No agent found for "${localPart}" (${recipientEmail})`);
|
|
2913
3022
|
res.status(404).json({ error: `No agent found for ${recipientEmail}` });
|
|
@@ -3499,7 +3608,7 @@ function deepEqual(a, b) {
|
|
|
3499
3608
|
|
|
3500
3609
|
// src/routes/tasks.ts
|
|
3501
3610
|
var rpcResolvers = /* @__PURE__ */ new Map();
|
|
3502
|
-
function createTaskRoutes(db,
|
|
3611
|
+
function createTaskRoutes(db, accountManager, config) {
|
|
3503
3612
|
const router = Router10();
|
|
3504
3613
|
router.post("/tasks/assign", requireAuth, async (req, res, next) => {
|
|
3505
3614
|
try {
|
|
@@ -3508,7 +3617,7 @@ function createTaskRoutes(db, accountManager2, config) {
|
|
|
3508
3617
|
res.status(400).json({ error: "assignee (agent name) is required" });
|
|
3509
3618
|
return;
|
|
3510
3619
|
}
|
|
3511
|
-
const target = await
|
|
3620
|
+
const target = await accountManager.getByName(assignee);
|
|
3512
3621
|
if (!target) {
|
|
3513
3622
|
res.status(404).json({ error: `Agent "${assignee}" not found` });
|
|
3514
3623
|
return;
|
|
@@ -3586,7 +3695,7 @@ Please check your pending tasks.`
|
|
|
3586
3695
|
let assigneeId = req.agent.id;
|
|
3587
3696
|
const assigneeName = req.query.assignee;
|
|
3588
3697
|
if (assigneeName) {
|
|
3589
|
-
const target = await
|
|
3698
|
+
const target = await accountManager.getByName(assigneeName);
|
|
3590
3699
|
if (target) assigneeId = target.id;
|
|
3591
3700
|
}
|
|
3592
3701
|
const rows = db.prepare(
|
|
@@ -3733,7 +3842,7 @@ Please check your pending tasks.`
|
|
|
3733
3842
|
res.status(400).json({ error: "target (agent name) and task are required" });
|
|
3734
3843
|
return;
|
|
3735
3844
|
}
|
|
3736
|
-
const targetAgent = await
|
|
3845
|
+
const targetAgent = await accountManager.getByName(target);
|
|
3737
3846
|
if (!targetAgent) {
|
|
3738
3847
|
res.status(404).json({ error: `Agent "${target}" not found` });
|
|
3739
3848
|
return;
|
|
@@ -3874,7 +3983,7 @@ import {
|
|
|
3874
3983
|
normalizePhoneNumber,
|
|
3875
3984
|
isValidPhoneNumber
|
|
3876
3985
|
} from "@agenticmail/core";
|
|
3877
|
-
function createSmsRoutes(db,
|
|
3986
|
+
function createSmsRoutes(db, accountManager, config, gatewayManager) {
|
|
3878
3987
|
const router = Router11();
|
|
3879
3988
|
const smsManager = new SmsManager(db);
|
|
3880
3989
|
function getAgent(req, res) {
|
|
@@ -4262,7 +4371,7 @@ function adaptBetterSqlite(raw) {
|
|
|
4262
4371
|
}
|
|
4263
4372
|
};
|
|
4264
4373
|
}
|
|
4265
|
-
function createStorageRoutes(rawDb,
|
|
4374
|
+
function createStorageRoutes(rawDb, accountManager, config, dialect = "sqlite") {
|
|
4266
4375
|
const db = adaptBetterSqlite(rawDb);
|
|
4267
4376
|
const router = Router12();
|
|
4268
4377
|
function getAgent(req, res) {
|
|
@@ -5440,12 +5549,12 @@ function createApp(configOverrides) {
|
|
|
5440
5549
|
adminUser: config.stalwart.adminUser,
|
|
5441
5550
|
adminPassword: config.stalwart.adminPassword
|
|
5442
5551
|
});
|
|
5443
|
-
const
|
|
5552
|
+
const accountManager = new AccountManager(db, stalwart);
|
|
5444
5553
|
const domainManager = new DomainManager(db, stalwart);
|
|
5445
5554
|
const gatewayManager = new GatewayManager2({
|
|
5446
5555
|
db,
|
|
5447
5556
|
stalwart,
|
|
5448
|
-
accountManager
|
|
5557
|
+
accountManager,
|
|
5449
5558
|
localSmtp: {
|
|
5450
5559
|
host: config.smtp.host,
|
|
5451
5560
|
port: config.smtp.port,
|
|
@@ -5465,7 +5574,7 @@ function createApp(configOverrides) {
|
|
|
5465
5574
|
app2.use(
|
|
5466
5575
|
rateLimit({
|
|
5467
5576
|
windowMs: 60 * 1e3,
|
|
5468
|
-
|
|
5577
|
+
limit: 1e4,
|
|
5469
5578
|
standardHeaders: true,
|
|
5470
5579
|
legacyHeaders: false,
|
|
5471
5580
|
message: { error: "Too many requests, please try again later" }
|
|
@@ -5481,25 +5590,45 @@ function createApp(configOverrides) {
|
|
|
5481
5590
|
return null;
|
|
5482
5591
|
})();
|
|
5483
5592
|
if (staticDir) {
|
|
5484
|
-
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
|
+
}));
|
|
5485
5614
|
app2.get("/ui", (_req, res) => res.sendFile(join3(staticDir, "index.html")));
|
|
5486
5615
|
}
|
|
5487
5616
|
app2.use("/api/agenticmail", createHealthRoutes(stalwart));
|
|
5488
|
-
app2.use("/api/agenticmail", createInboundRoutes(
|
|
5617
|
+
app2.use("/api/agenticmail", createInboundRoutes(accountManager, config, gatewayManager));
|
|
5489
5618
|
const integrationFactory = readResolvedFactory(integrationRouteFactoryPromise);
|
|
5490
5619
|
if (integrationFactory) {
|
|
5491
5620
|
app2.use("/api/agenticmail", integrationFactory());
|
|
5492
5621
|
}
|
|
5493
|
-
app2.use("/api/agenticmail", createAuthMiddleware(config.masterKey,
|
|
5494
|
-
app2.use("/api/agenticmail", createAccountRoutes(
|
|
5495
|
-
app2.use("/api/agenticmail", createMailRoutes(
|
|
5496
|
-
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));
|
|
5497
5626
|
app2.use("/api/agenticmail", createDomainRoutes(domainManager));
|
|
5498
5627
|
app2.use("/api/agenticmail", createGatewayRoutes(gatewayManager));
|
|
5499
|
-
app2.use("/api/agenticmail", createFeatureRoutes(db,
|
|
5500
|
-
app2.use("/api/agenticmail", createTaskRoutes(db,
|
|
5501
|
-
app2.use("/api/agenticmail", createSmsRoutes(db,
|
|
5502
|
-
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));
|
|
5503
5632
|
app2.use("/api/agenticmail", createSystemEventRoutes());
|
|
5504
5633
|
app2.use("/api/agenticmail", createDispatcherActivityRoutes());
|
|
5505
5634
|
app2.use("/api/agenticmail", createAgentMemoryRoutes(config));
|
|
@@ -5507,7 +5636,7 @@ function createApp(configOverrides) {
|
|
|
5507
5636
|
res.status(404).json({ error: "Not found" });
|
|
5508
5637
|
});
|
|
5509
5638
|
app2.use(errorHandler);
|
|
5510
|
-
const context2 = { config, db, stalwart, accountManager
|
|
5639
|
+
const context2 = { config, db, stalwart, accountManager, domainManager, gatewayManager };
|
|
5511
5640
|
return { app: app2, context: context2 };
|
|
5512
5641
|
}
|
|
5513
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';
|
|
@@ -95,20 +96,39 @@ async function bootstrap() {
|
|
|
95
96
|
const initial = (lastId && state.agents.find(a => a.id === lastId))
|
|
96
97
|
?? state.agents.find(isBridgeAgent)
|
|
97
98
|
?? state.agents[0];
|
|
99
|
+
// Seed the URL hash BEFORE selectAgent so selectAgent's loadList
|
|
100
|
+
// call lands on the right folder. We use history.replaceState
|
|
101
|
+
// (NOT `location.hash = ...`) so this does NOT fire a hashchange
|
|
102
|
+
// event — that would trigger a second route() → loadList() in
|
|
103
|
+
// parallel with selectAgent's, doubling the work on every
|
|
104
|
+
// bootstrap. Read the hash first so a deep-link refresh
|
|
105
|
+
// (e.g. /#/folder/sent) still wins.
|
|
106
|
+
const folderMatch = location.hash.match(/^#\/folder\/([a-z]+)$/);
|
|
107
|
+
if (folderMatch) {
|
|
108
|
+
state.selectedFolder = folderMatch[1];
|
|
109
|
+
} else if (!location.hash) {
|
|
110
|
+
history.replaceState(null, '', `${location.pathname}${location.search}#/folder/inbox`);
|
|
111
|
+
}
|
|
98
112
|
if (initial) await selectAgent(initial);
|
|
99
113
|
renderProfile();
|
|
100
114
|
populateComposeFrom();
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
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
|
|
107
123
|
maybeRequestNotificationPermission();
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
// If the URL points at a message (not a folder), open it now —
|
|
125
|
+
// the folder list selectAgent already loaded stays in the
|
|
126
|
+
// background. Folder hashes need no extra work; selectAgent's
|
|
127
|
+
// loadList already handled them above.
|
|
128
|
+
const hash = location.hash;
|
|
129
|
+
const msgMatch = hash.match(/^#\/m\/(\d+)$/);
|
|
130
|
+
const draftMatch = hash.match(/^#\/d\/([a-zA-Z0-9-]+)$/);
|
|
131
|
+
if (msgMatch || draftMatch) route();
|
|
112
132
|
} catch (err) {
|
|
113
133
|
toast(`Failed to load agents: ${err.message}`, true);
|
|
114
134
|
}
|
|
@@ -176,14 +196,18 @@ function route() {
|
|
|
176
196
|
}
|
|
177
197
|
const folderMatch = hash.match(/^#\/folder\/([a-z]+)$/);
|
|
178
198
|
const folder = folderMatch ? folderMatch[1] : 'inbox';
|
|
179
|
-
if
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
// Only do work if the folder actually changed. Re-firing loadList
|
|
200
|
+
// for the SAME folder on every hashchange (e.g. closing a message
|
|
201
|
+
// detail back to the list) makes the UI feel sluggish — the list
|
|
202
|
+
// is already rendered, and a second digest fetch just churns
|
|
203
|
+
// through 50 messages on the IMAP server for no visible change.
|
|
204
|
+
if (state.selectedFolder === folder) return;
|
|
205
|
+
state.selectedFolder = folder;
|
|
206
|
+
// Reset pagination on every folder switch — a fresh folder
|
|
207
|
+
// starts at page 1. Preserved across silent SSE refreshes so
|
|
208
|
+
// a new arrival doesn't yank the user back from page 3.
|
|
209
|
+
state.pagination = { offset: 0, limit: 50, total: 0 };
|
|
210
|
+
renderSidebar(onFolderSelect);
|
|
187
211
|
if (state.selectedAgent) loadList(state.selectedAgent, folder);
|
|
188
212
|
}
|
|
189
213
|
window.addEventListener('hashchange', route);
|
package/public/js/list-view.js
CHANGED
|
@@ -359,7 +359,15 @@ export function renderList() {
|
|
|
359
359
|
const prevBtn = document.getElementById('pager-prev');
|
|
360
360
|
const nextBtn = document.getElementById('pager-next');
|
|
361
361
|
if (prevBtn) prevBtn.disabled = offset <= 0;
|
|
362
|
-
|
|
362
|
+
// Drive Next purely from the server-reported total. The previous
|
|
363
|
+
// `state.messages.length < limit` clause was meant as a "we hit the
|
|
364
|
+
// end" heuristic for the no-total fallback case, but it backfired
|
|
365
|
+
// on every folder that legitimately had fewer-than-`limit` items on
|
|
366
|
+
// a page (e.g. trailing partial page after deletions, or a folder
|
|
367
|
+
// whose IMAP STATUS returned a stale low count) — Next stayed
|
|
368
|
+
// permanently disabled. The new digest endpoint always returns an
|
|
369
|
+
// authoritative SEARCH-derived total, so a single check is enough.
|
|
370
|
+
if (nextBtn) nextBtn.disabled = pageEnd >= total;
|
|
363
371
|
|
|
364
372
|
if (filtered.length === 0) {
|
|
365
373
|
root.innerHTML = q
|
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
|
+
}
|