@agenticmail/api 0.9.6 → 0.9.8

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 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, accountManager2, db) {
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 accountManager2.getByApiKey(token);
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(accountManager2, db, config) {
330
+ function createAccountRoutes(accountManager, db, config) {
331
331
  const router = Router3();
332
- const deletionService = new AgentDeletionService(db, accountManager2, config);
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 accountManager2.create({ name: accountName, domain, password: password || void 0, metadata: cleanMeta, role });
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 accountManager2.list();
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 accountManager2.list();
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 accountManager2.getByName(req.params.name);
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 accountManager2.delete(row.id);
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 accountManager2.getById(req.params.id);
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 accountManager2.updateMetadata(agent.id, metadata);
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 accountManager2.list();
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 accountManager2.delete(req.params.id);
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, _accountManager, config, gatewayManager) {
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, accountManager2, config, gatewayManager) {
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 accountManager2.getById(row.agent_id);
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(accountManager2, config, db) {
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: ["Message-ID", messageId] },
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(accountManager2, toField, ccField, bccField, fromAgent, subject, messageId, config, wakeList) {
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 accountManager2.getByName(localPart);
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(accountManager2, config, db, gatewayManager) {
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
- accountManager2,
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
- res.json({
2201
+ payload = {
2146
2202
  ...parsed,
2203
+ attachments,
2147
2204
  security: { internal: true, spamScore: 0, isSpam: false, isWarning: false }
2148
- });
2149
- return;
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
- const sanitized = sanitizeEmail(parsed);
2152
- const spamScore = scoreEmail2(parsed);
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 accountManager2.getById(row.agent_id);
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
- accountManager2,
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(accountManager2, config, gatewayManager) {
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 accountManager2.getByName(localPart);
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, accountManager2, config) {
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 accountManager2.getByName(assignee);
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 accountManager2.getByName(assigneeName);
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 accountManager2.getByName(target);
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, accountManager2, config, gatewayManager) {
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, accountManager2, config, dialect = "sqlite") {
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 accountManager2 = new AccountManager(db, stalwart);
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: accountManager2,
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
- max: 1e4,
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, { index: "index.html", extensions: ["html"] }));
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(accountManager2, config, gatewayManager));
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, accountManager2, db));
5539
- app2.use("/api/agenticmail", createAccountRoutes(accountManager2, db, config));
5540
- app2.use("/api/agenticmail", createMailRoutes(accountManager2, config, db, gatewayManager));
5541
- app2.use("/api/agenticmail", createEventRoutes(accountManager2, config, db));
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, accountManager2, config, gatewayManager));
5545
- app2.use("/api/agenticmail", createTaskRoutes(db, accountManager2, config));
5546
- app2.use("/api/agenticmail", createSmsRoutes(db, accountManager2, config, gatewayManager));
5547
- app2.use("/api/agenticmail", createStorageRoutes(db, accountManager2, config));
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: accountManager2, domainManager, gatewayManager };
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.6",
3
+ "version": "0.9.8",
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": "^5.0.0",
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 { state, API_URL } from './state.js';
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 sseController = null;
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 /system/events with the master key. The web UI
95
- * already holds the master key in state.masterKey (set on
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 (sseController) { try { sseController.abort(); } catch {} }
101
- sseController = new AbortController();
102
- fetch(`${API_URL}/api/agenticmail/system/events`, {
103
- headers: { Authorization: `Bearer ${state.masterKey}`, Accept: 'text/event-stream' },
104
- signal: sseController.signal,
105
- }).then(async res => {
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
- subscribeToAllAgents();
115
- // Real-time worker activity badges. Master-key-scoped SSE on
116
- // /system/events; the dispatcher's worker_started /
117
- // worker_heartbeat / worker_finished events drive the badge
118
- // rendering. Idempotent safe to call after bootstrap reruns.
119
- subscribeToActivity();
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
@@ -176,10 +179,20 @@ function onFolderSelect(folder) {
176
179
  // Folder switches go through here too so the URL is the source of truth
177
180
  // for "what's on screen". If you bookmark or copy-paste a URL like
178
181
  // http://127.0.0.1:3829/#/folder/sent, opening it lands you on Sent.
182
+ // Track which view shape is currently on screen so the router knows
183
+ // whether navigating back to #/folder/<x> for the SAME folder should
184
+ // re-render the list. Without this, hitting Back from #/m/54 to
185
+ // #/folder/inbox would early-return because state.selectedFolder is
186
+ // still 'inbox' (it never changed when the message opened) — leaving
187
+ // the message-detail view stuck on screen even though the URL bar
188
+ // flipped back to the folder.
189
+ let currentView = 'folder'; // 'folder' | 'message' | 'draft'
190
+
179
191
  function route() {
180
192
  const hash = location.hash || '#/inbox';
181
193
  const msgMatch = hash.match(/^#\/m\/(\d+)$/);
182
194
  if (msgMatch) {
195
+ currentView = 'message';
183
196
  openMessage(Number(msgMatch[1]));
184
197
  return;
185
198
  }
@@ -188,23 +201,27 @@ function route() {
188
201
  // row click handler emits #/d/<uuid> for draft rows.
189
202
  const draftMatch = hash.match(/^#\/d\/([a-zA-Z0-9-]+)$/);
190
203
  if (draftMatch) {
204
+ currentView = 'draft';
191
205
  openDraft(draftMatch[1]);
192
206
  return;
193
207
  }
194
208
  const folderMatch = hash.match(/^#\/folder\/([a-z]+)$/);
195
209
  const folder = folderMatch ? folderMatch[1] : 'inbox';
196
- // Only do work if the folder actually changed. Re-firing loadList
197
- // for the SAME folder on every hashchange (e.g. closing a message
198
- // detail back to the list) makes the UI feel sluggish — the list
199
- // is already rendered, and a second digest fetch just churns
200
- // through 50 messages on the IMAP server for no visible change.
201
- if (state.selectedFolder === folder) return;
210
+ // Skip the reload ONLY when we're already showing this folder's
211
+ // list view. Coming back from a message / draft folder must
212
+ // always re-render the list, even if state.selectedFolder hasn't
213
+ // changed since the message was opened.
214
+ if (currentView === 'folder' && state.selectedFolder === folder) return;
215
+ const folderChanged = state.selectedFolder !== folder;
202
216
  state.selectedFolder = folder;
203
- // Reset pagination on every folder switch — a fresh folder
204
- // starts at page 1. Preserved across silent SSE refreshes so
205
- // a new arrival doesn't yank the user back from page 3.
206
- state.pagination = { offset: 0, limit: 50, total: 0 };
207
- renderSidebar(onFolderSelect);
217
+ currentView = 'folder';
218
+ if (folderChanged) {
219
+ // Fresh folder page 1. Preserved across silent SSE refreshes so
220
+ // a new arrival doesn't yank the user back from page 3. We also
221
+ // re-render the sidebar so the active-folder highlight updates.
222
+ state.pagination = { offset: 0, limit: 50, total: 0 };
223
+ renderSidebar(onFolderSelect);
224
+ }
208
225
  if (state.selectedAgent) loadList(state.selectedAgent, folder);
209
226
  }
210
227
  window.addEventListener('hashchange', route);
package/public/js/sse.js CHANGED
@@ -1,47 +1,35 @@
1
- // Real-time mail delivery via Server-Sent Events. Every agent gets
2
- // its own subscription; the dispatcher pushes a `new` event per
3
- // arrived message. We fan that out to:
4
- // 1. List view silent in-place refresh (no flicker, no scroll
5
- // jump, no bulk-selection wipe) if it's the active inbox
6
- // 2. Profile dropdownbump the per-agent unread counter
7
- // 3. Browser notification — system ping when the tab is in the background
8
- // 4. Soft chime (toggleable) when sound is enabled
9
- import { state, API_URL } from './state.js';
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 viewsilent 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
- // Tear down previous controllers (called on agent-list refresh).
17
- for (const c of state.sseControllers) { try { c.abort(); } catch {} }
18
- state.sseControllers = [];
19
- for (const agent of state.agents) {
20
- const ctrl = new AbortController();
21
- state.sseControllers.push(ctrl);
22
- fetch(`${API_URL}/api/agenticmail/events`, {
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; // user is looking — clear badge
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
  };
@@ -10,7 +10,6 @@ export const state = {
10
10
  currentMessage: null,
11
11
  composeReplyContext: null,
12
12
  searchQuery: '',
13
- sseControllers: [],
14
13
  unread: {}, // { [agentId]: count }
15
14
  /**
16
15
  * Mapping from sidebar folder id ('sent', 'drafts', 'spam', etc.)
@@ -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
+ }