@agenticmail/api 0.9.0 → 0.9.1

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
@@ -527,6 +527,19 @@ function createAccountRoutes(accountManager2, db, config) {
527
527
  next(err);
528
528
  }
529
529
  });
530
+ router.patch("/accounts/:id/wake-on-cc", requireMaster, async (req, res, next) => {
531
+ try {
532
+ const wakeOnCc = req.body?.wakeOnCc === false ? 0 : 1;
533
+ const result = db.prepare("UPDATE agents SET wake_on_cc = ? WHERE id = ?").run(wakeOnCc, req.params.id);
534
+ if (result.changes === 0) {
535
+ res.status(404).json({ error: "Agent not found" });
536
+ return;
537
+ }
538
+ res.json({ ok: true, wakeOnCc: wakeOnCc === 1 });
539
+ } catch (err) {
540
+ next(err);
541
+ }
542
+ });
530
543
  router.delete("/accounts/:id", requireMaster, async (req, res, next) => {
531
544
  try {
532
545
  const allAgents = await accountManager2.list();
@@ -1815,10 +1828,10 @@ function deriveDefaultWakeList(toField) {
1815
1828
  const arr = Array.isArray(toField) ? toField : String(toField).split(",");
1816
1829
  const localNames = [];
1817
1830
  for (const raw of arr) {
1818
- const addr = String(raw).trim().toLowerCase();
1819
- if (!addr.endsWith("@localhost")) continue;
1820
- const m = addr.match(/<([^>]+)>/);
1821
- const bare = m ? m[1].trim() : addr;
1831
+ const trimmed = String(raw).trim().toLowerCase();
1832
+ const m = trimmed.match(/<([^>]+)>/);
1833
+ const bare = (m ? m[1] : trimmed).trim();
1834
+ if (!bare.endsWith("@localhost")) continue;
1822
1835
  const name = bare.replace(/@localhost$/i, "");
1823
1836
  if (name) localNames.push(name);
1824
1837
  }
@@ -1839,15 +1852,25 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
1839
1852
  push(ccField);
1840
1853
  push(bccField);
1841
1854
  const addrRe = /<([^>]+)>|([^\s,;<>]+@[^\s,;<>]+)/g;
1842
- const addresses = /* @__PURE__ */ new Set();
1843
- for (const entry of collected) {
1844
- let match;
1845
- addrRe.lastIndex = 0;
1846
- while ((match = addrRe.exec(entry)) !== null) {
1847
- const a = (match[1] || match[2] || "").trim().toLowerCase();
1848
- if (a) addresses.add(a);
1849
- }
1855
+ function extractAddrs(v) {
1856
+ if (!v) return [];
1857
+ const items = Array.isArray(v) ? v : [v];
1858
+ const out = /* @__PURE__ */ new Set();
1859
+ for (const entry of items) {
1860
+ let match;
1861
+ addrRe.lastIndex = 0;
1862
+ while ((match = addrRe.exec(entry)) !== null) {
1863
+ const a = (match[1] || match[2] || "").trim().toLowerCase();
1864
+ if (a) out.add(a);
1865
+ }
1866
+ }
1867
+ return Array.from(out);
1850
1868
  }
1869
+ const toAddrs = extractAddrs(toField);
1870
+ const ccAddrs = extractAddrs(ccField);
1871
+ const bccAddrs = extractAddrs(bccField);
1872
+ const addresses = /* @__PURE__ */ new Set([...toAddrs, ...ccAddrs, ...bccAddrs]);
1873
+ const toLocalNames = new Set(toAddrs.filter((a) => a.endsWith("@localhost")).map((a) => a.replace(/@localhost$/i, "")));
1851
1874
  const notified = /* @__PURE__ */ new Set();
1852
1875
  for (const addr of addresses) {
1853
1876
  const at = addr.indexOf("@");
@@ -1879,6 +1902,7 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
1879
1902
  lookup = "failed";
1880
1903
  }
1881
1904
  }
1905
+ const wasOnTo = toLocalNames.has(recipient.name.toLowerCase());
1882
1906
  pushEventToAgent(recipient.id, {
1883
1907
  type: "new",
1884
1908
  uid,
@@ -1894,7 +1918,11 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
1894
1918
  // dispatcher reads this and spawns a Claude worker only for
1895
1919
  // recipients whose name is on the list (or for everyone if the
1896
1920
  // field is absent, preserving the v0.8.x default).
1897
- ...wakeList !== void 0 ? { wakeAllowlist: wakeList } : {}
1921
+ ...wakeList !== void 0 ? { wakeAllowlist: wakeList } : {},
1922
+ // Per-recipient "was I on To?" flag for wake_on_cc honoring.
1923
+ // The dispatcher uses this combined with account.wakeOnCc to
1924
+ // decide whether to skip a CC-only delivery.
1925
+ wasOnTo
1898
1926
  });
1899
1927
  }
1900
1928
  }
@@ -4902,7 +4930,7 @@ function createStorageRoutes(rawDb, accountManager2, config, dialect = "sqlite")
4902
4930
  const meta = await verifyAccess(agent, tableName, res);
4903
4931
  if (!meta) return;
4904
4932
  let imported = 0;
4905
- let skipped = 0;
4933
+ let skipped2 = 0;
4906
4934
  for (const row of rows) {
4907
4935
  const keys = Object.keys(row);
4908
4936
  const vals = Object.values(row).map((v) => typeof v === "object" && v !== null ? JSON.stringify(v) : v);
@@ -4928,7 +4956,7 @@ function createStorageRoutes(rawDb, accountManager2, config, dialect = "sqlite")
4928
4956
  imported++;
4929
4957
  } catch (e) {
4930
4958
  if (onConflict === "skip") {
4931
- skipped++;
4959
+ skipped2++;
4932
4960
  continue;
4933
4961
  }
4934
4962
  throw e;
@@ -4936,7 +4964,7 @@ function createStorageRoutes(rawDb, accountManager2, config, dialect = "sqlite")
4936
4964
  }
4937
4965
  const countResult = await db.get(`SELECT COUNT(*) as cnt FROM ${tableName}`);
4938
4966
  await db.run("UPDATE agenticmail_storage_meta SET row_count = ?, updated_at = " + nowExpr(dialect) + " WHERE table_name = ?", [countResult?.cnt || 0, tableName]);
4939
- res.json({ ok: true, imported, skipped, totalRows: countResult?.cnt || 0 });
4967
+ res.json({ ok: true, imported, skipped: skipped2, totalRows: countResult?.cnt || 0 });
4940
4968
  } catch (err) {
4941
4969
  res.status(500).json({ error: err.message });
4942
4970
  }
@@ -5090,6 +5118,10 @@ function prune(nowMs) {
5090
5118
  recent.delete(first);
5091
5119
  }
5092
5120
  }
5121
+ var skipped = [];
5122
+ var SKIPPED_CAP = 100;
5123
+ var SKIPPED_TTL_MS = 5 * 60 * 1e3;
5124
+ var processState = null;
5093
5125
  function createDispatcherActivityRoutes() {
5094
5126
  const router = Router13();
5095
5127
  router.post("/dispatcher/worker-started", requireMaster, (req, res) => {
@@ -5137,7 +5169,8 @@ function createDispatcherActivityRoutes() {
5137
5169
  endedAtMs: nowMs,
5138
5170
  ok: body.ok === false ? false : true,
5139
5171
  resultPreview: typeof body.resultPreview === "string" ? body.resultPreview.slice(0, 240) : void 0,
5140
- turnCount: typeof body.turnCount === "number" ? body.turnCount : existing?.turnCount
5172
+ turnCount: typeof body.turnCount === "number" ? body.turnCount : existing?.turnCount,
5173
+ usage: typeof body.usage === "string" ? body.usage : existing?.usage
5141
5174
  };
5142
5175
  active.delete(body.workerId);
5143
5176
  recent.set(body.workerId, info);
@@ -5170,8 +5203,26 @@ function createDispatcherActivityRoutes() {
5170
5203
  router.get("/dispatcher/activity", requireMaster, (_req, res) => {
5171
5204
  const nowMs = Date.now();
5172
5205
  prune(nowMs);
5206
+ while (skipped.length > 0 && nowMs - skipped[0].atMs > SKIPPED_TTL_MS) skipped.shift();
5207
+ while (skipped.length > SKIPPED_CAP) skipped.shift();
5208
+ const processHealth = (() => {
5209
+ if (!processState) return { state: "missing" };
5210
+ const age = nowMs - processState.atMs;
5211
+ const isAlive = age <= 9e4;
5212
+ return {
5213
+ state: isAlive ? "alive" : "unhealthy",
5214
+ startedAtMs: processState.startedAtMs,
5215
+ uptimeMs: nowMs - processState.startedAtMs,
5216
+ lastHeartbeatAgeMs: age,
5217
+ channels: processState.channels,
5218
+ coalesceQueueSize: processState.coalesceQueueSize,
5219
+ running: processState.running,
5220
+ maxConcurrent: processState.maxConcurrent
5221
+ };
5222
+ })();
5173
5223
  res.json({
5174
5224
  now: nowMs,
5225
+ dispatcher: processHealth,
5175
5226
  active: Array.from(active.values()).map((w) => ({
5176
5227
  ...w,
5177
5228
  durationMs: nowMs - w.startedAtMs,
@@ -5181,9 +5232,52 @@ function createDispatcherActivityRoutes() {
5181
5232
  recent: Array.from(recent.values()).map((w) => ({
5182
5233
  ...w,
5183
5234
  durationMs: (w.endedAtMs ?? nowMs) - w.startedAtMs
5184
- }))
5235
+ })),
5236
+ // Recent skipped wakes — every filter decision the dispatcher
5237
+ // made that DROPPED a wake. Surfaced so the host can see "the
5238
+ // mail landed, the dispatcher saw it, here's why it skipped"
5239
+ // instead of staring at silence.
5240
+ skipped: skipped.map((s) => ({ ...s, ageMs: nowMs - s.atMs }))
5185
5241
  });
5186
5242
  });
5243
+ router.post("/dispatcher/process-heartbeat", requireMaster, (req, res) => {
5244
+ const body = req.body ?? {};
5245
+ if (typeof body.startedAtMs !== "number") {
5246
+ res.status(400).json({ error: "startedAtMs is required" });
5247
+ return;
5248
+ }
5249
+ processState = {
5250
+ startedAtMs: body.startedAtMs,
5251
+ channels: typeof body.channels === "number" ? body.channels : 0,
5252
+ coalesceQueueSize: typeof body.coalesceQueueSize === "number" ? body.coalesceQueueSize : 0,
5253
+ running: typeof body.running === "number" ? body.running : 0,
5254
+ maxConcurrent: typeof body.maxConcurrent === "number" ? body.maxConcurrent : 0,
5255
+ atMs: Date.now()
5256
+ };
5257
+ res.json({ ok: true });
5258
+ });
5259
+ router.post("/dispatcher/worker-skipped", requireMaster, (req, res) => {
5260
+ const body = req.body ?? {};
5261
+ if (typeof body.agentName !== "string" || typeof body.reason !== "string") {
5262
+ res.status(400).json({ error: "agentName and reason are required" });
5263
+ return;
5264
+ }
5265
+ skipped.push({
5266
+ agentId: typeof body.agentId === "string" ? body.agentId : void 0,
5267
+ agentName: body.agentName,
5268
+ uid: typeof body.uid === "number" ? body.uid : void 0,
5269
+ subject: typeof body.subject === "string" ? body.subject : void 0,
5270
+ from: typeof body.from === "string" ? body.from : void 0,
5271
+ reason: body.reason,
5272
+ detail: typeof body.detail === "string" ? body.detail : void 0,
5273
+ atMs: Date.now()
5274
+ });
5275
+ while (skipped.length > SKIPPED_CAP) skipped.shift();
5276
+ res.json({ ok: true });
5277
+ });
5278
+ router.post("/dispatcher/worker-queued", requireMaster, (req, res) => {
5279
+ res.json({ ok: true, recorded: req.body ?? null });
5280
+ });
5187
5281
  router.get("/dispatcher/worker-log/:workerId", requireMaster, (req, res) => {
5188
5282
  const rawId = String(req.params.workerId ?? "");
5189
5283
  if (!rawId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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",
@@ -28,7 +28,7 @@
28
28
  "prepublishOnly": "npm run build"
29
29
  },
30
30
  "dependencies": {
31
- "@agenticmail/core": "^0.9.0",
31
+ "@agenticmail/core": "^0.9.1",
32
32
  "cors": "^2.8.5",
33
33
  "dotenv": "^16.4.7",
34
34
  "express": "^4.21.0",
@@ -36,7 +36,7 @@
36
36
  "uuid": "^11.1.0"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@agenticmail/claudecode": "^0.2.0"
39
+ "@agenticmail/claudecode": "^0.2.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/cors": "^2.8.17",
@@ -50,11 +50,22 @@ function renderMessage(msg) {
50
50
  if (!view) return;
51
51
  const fromAddr = msg.from?.[0]?.address ?? '?';
52
52
  const fromName = msg.from?.[0]?.name || fromAddr;
53
- const toStr = (msg.to ?? []).map(a => a.name ? `${a.name} <${a.address}>` : a.address).join(', ') || '?';
54
- const ccStr = (msg.cc ?? []).map(a => a.address).join(', ');
55
53
  const senderPseudo = { name: fromName }; // for avatar generation
56
54
  const bodyText = msg.text ?? stripHtml(msg.html ?? '');
57
55
 
56
+ // Build separate To / Cc / Bcc lines so the user can actually
57
+ // tell who was on the action list vs CC'd for awareness. The
58
+ // previous renderer concatenated everything onto one "to" line.
59
+ const formatAddr = (a) => a?.name && a.name !== a.address
60
+ ? `${a.name} <${a.address}>`
61
+ : (a?.address ?? '');
62
+ const renderAddrRow = (label, list, cls) => {
63
+ if (!Array.isArray(list) || list.length === 0) return '';
64
+ const rendered = list.map(formatAddr).filter(Boolean).map(escapeHtml).join(', ');
65
+ if (!rendered) return '';
66
+ return `<div class="message-recipient-row ${cls}"><span class="message-recipient-label">${label}</span><span class="message-recipient-list">${rendered}</span></div>`;
67
+ };
68
+
58
69
  const attachmentsHtml = (msg.attachments ?? []).length > 0
59
70
  ? `<div class="message-attachments">${msg.attachments.map((a, i) =>
60
71
  `<button class="message-attachment" data-att-index="${i}" data-att-filename="${escapeHtml(a.filename ?? 'attachment')}" title="Click to download">
@@ -75,7 +86,9 @@ function renderMessage(msg) {
75
86
  <span class="name">${escapeHtml(fromName)}</span>
76
87
  <span class="addr">&lt;${escapeHtml(fromAddr)}&gt;</span>
77
88
  </div>
78
- <div class="message-to">to ${escapeHtml(toStr)}${ccStr ? `, cc ${escapeHtml(ccStr)}` : ''}</div>
89
+ ${renderAddrRow('To', msg.to, 'message-to')}
90
+ ${renderAddrRow('Cc', msg.cc, 'message-cc')}
91
+ ${renderAddrRow('Bcc', msg.bcc, 'message-bcc')}
79
92
  </div>
80
93
  <div class="message-date">${escapeHtml(formatDateFull(msg.date))}</div>
81
94
  </div>
package/public/styles.css CHANGED
@@ -583,7 +583,28 @@ mark.search-hl {
583
583
  }
584
584
  .message-from .name { font-weight: 500; }
585
585
  .message-from .addr { color: var(--muted); margin-left: 4px; }
586
- .message-to { font-size: 12px; color: var(--muted); margin-top: 4px; }
586
+ /* Recipient rows one per field (To / Cc / Bcc). Renders only
587
+ when the field is non-empty. The label is a fixed-width tag
588
+ so the addresses align cleanly across the three rows. */
589
+ .message-recipient-row {
590
+ display: flex; gap: 6px;
591
+ font-size: 12px; color: var(--muted);
592
+ margin-top: 4px;
593
+ line-height: 1.5;
594
+ }
595
+ .message-recipient-label {
596
+ flex: 0 0 28px;
597
+ font-weight: 600;
598
+ color: var(--ink-soft);
599
+ text-transform: none;
600
+ }
601
+ .message-recipient-list {
602
+ flex: 1 1 auto;
603
+ min-width: 0;
604
+ word-break: break-word;
605
+ }
606
+ .message-cc .message-recipient-label,
607
+ .message-bcc .message-recipient-label { color: var(--muted); }
587
608
  .message-date {
588
609
  font-size: 12px; color: var(--muted);
589
610
  white-space: nowrap;