@agenticmail/api 0.7.21 → 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/README.md CHANGED
@@ -8,7 +8,14 @@ The API server for [AgenticMail](https://github.com/agenticmail/agenticmail) —
8
8
 
9
9
  This package runs a web server that handles everything: sending email and SMS, reading inboxes, managing agents, phone number access, real-time notifications, inter-agent messaging, spam filtering, outbound security scanning, and gateway configuration. Every feature in AgenticMail is accessible through this API.
10
10
 
11
- ## ✨ What's new in 0.7.16
11
+ ## ✨ What's new in 0.9.0
12
+
13
+ - **🧠 Agent-thread memory + thread-id resolver endpoints.** Agents persist their own per-thread judgment so the dispatcher can pre-load it into the next wake's prompt:
14
+ - `GET / POST / DELETE /agents/me/memory/threads/:t` — agent-key scoped; each agent only ever touches its own memory file.
15
+ - `GET /agents/me/thread-id?uid=42&folder=INBOX` — resolves the stable subject-only thread id for a message UID, looking up the canonical root via the dispatcher's ThreadCache when available.
16
+ - **🎯 `wake` default flipped to "To: only".** `POST /mail/send`, `POST /drafts/:id/send`, `POST /templates/:id/send`, and the pending-outbound persistence path all derive the implicit allowlist from local recipients on the `To:` field when `wake` is omitted. CC'd local agents receive the mail without waking. New helper `deriveDefaultWakeList(to)` exported from `routes/mail.ts`. Opt back into the old behaviour with `wake: 'all'`.
17
+
18
+ ## ✨ Earlier — 0.7.16
12
19
 
13
20
  - **📐 Typed task contracts** — `POST /tasks/assign` and the long-poll `POST /tasks/rpc` accept an optional `outputSchema` field (JSON Schema, draft-7 subset). The schema is persisted on the task row via migration `014_task_output_schema.sql` and is rendered into the worker's wake prompt. `POST /tasks/:id/result` validates against the schema before accepting; mismatches return **400** with a flat `schemaErrors: [{ path, message }]` list. Validator lives at `src/lib/schema-validator.ts` (hand-rolled, no `ajv` dep) and supports `type`, `required`, `properties`, `items`, `enum`, `additionalProperties: false`, `minLength`/`maxLength`, `minimum`/`maximum`. Tasks without a schema keep the v0.8.x behaviour — fully back-compat.
14
21
  - **⭐ Star endpoint** — `POST /mail/messages/:uid/star` with `{ starred: boolean, folder?: string }`. Maps to IMAP's `\Flagged` flag — same on-disk bit Gmail's star uses.
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();
@@ -903,7 +916,8 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
903
916
  return;
904
917
  }
905
918
  const agent = req.agent;
906
- const wakeList = normalizeWakeList(req.body?.wake);
919
+ const explicitWake = normalizeWakeList(req.body?.wake);
920
+ const wakeList = req.body?.wake === void 0 ? deriveDefaultWakeList(draft.to_addr) : explicitWake;
907
921
  const customHeaders = wakeHeaders(wakeList);
908
922
  let persistedAttachments;
909
923
  if (draft.attachments) {
@@ -1189,7 +1203,8 @@ function createFeatureRoutes(db, _accountManager, config, gatewayManager) {
1189
1203
  return;
1190
1204
  }
1191
1205
  const applyVars = (text, vars2) => text.replace(/\{\{(\w+)\}\}/g, (m, key) => vars2[key] ?? m);
1192
- const wakeList = normalizeWakeList(wake);
1206
+ const explicitWake = normalizeWakeList(wake);
1207
+ const wakeList = wake === void 0 ? deriveDefaultWakeList(to) : explicitWake;
1193
1208
  const customHeaders = wakeHeaders(wakeList);
1194
1209
  const vars = variables && typeof variables === "object" ? variables : {};
1195
1210
  const renderedSubject = applyVars(template.subject || "(no subject)", vars);
@@ -1799,13 +1814,29 @@ function normalizeMessageId(id) {
1799
1814
  if (!id) return "";
1800
1815
  return id.trim().replace(/^<+|>+$/g, "").toLowerCase();
1801
1816
  }
1817
+ var WAKE_ALL_SENTINEL = "__wake_all__";
1802
1818
  function normalizeWakeList(value) {
1803
1819
  if (value === void 0 || value === null) return void 0;
1820
+ if (value === "all" || value === WAKE_ALL_SENTINEL) return void 0;
1804
1821
  const strip = (s) => s.trim().replace(/@localhost$/i, "").toLowerCase();
1805
1822
  if (Array.isArray(value)) return value.map((v) => strip(String(v))).filter(Boolean);
1806
1823
  if (typeof value === "string") return value.split(",").map(strip).filter(Boolean);
1807
1824
  return void 0;
1808
1825
  }
1826
+ function deriveDefaultWakeList(toField) {
1827
+ if (!toField) return void 0;
1828
+ const arr = Array.isArray(toField) ? toField : String(toField).split(",");
1829
+ const localNames = [];
1830
+ for (const raw of arr) {
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;
1835
+ const name = bare.replace(/@localhost$/i, "");
1836
+ if (name) localNames.push(name);
1837
+ }
1838
+ return localNames.length > 0 ? localNames : void 0;
1839
+ }
1809
1840
  function wakeHeaders(wakeList) {
1810
1841
  if (wakeList === void 0) return {};
1811
1842
  return { "X-AgenticMail-Wake": wakeList.join(", ") };
@@ -1821,15 +1852,25 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
1821
1852
  push(ccField);
1822
1853
  push(bccField);
1823
1854
  const addrRe = /<([^>]+)>|([^\s,;<>]+@[^\s,;<>]+)/g;
1824
- const addresses = /* @__PURE__ */ new Set();
1825
- for (const entry of collected) {
1826
- let match;
1827
- addrRe.lastIndex = 0;
1828
- while ((match = addrRe.exec(entry)) !== null) {
1829
- const a = (match[1] || match[2] || "").trim().toLowerCase();
1830
- if (a) addresses.add(a);
1831
- }
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);
1832
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, "")));
1833
1874
  const notified = /* @__PURE__ */ new Set();
1834
1875
  for (const addr of addresses) {
1835
1876
  const at = addr.indexOf("@");
@@ -1861,6 +1902,7 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
1861
1902
  lookup = "failed";
1862
1903
  }
1863
1904
  }
1905
+ const wasOnTo = toLocalNames.has(recipient.name.toLowerCase());
1864
1906
  pushEventToAgent(recipient.id, {
1865
1907
  type: "new",
1866
1908
  uid,
@@ -1876,7 +1918,11 @@ async function notifyLocalRecipientsOfNewMail(accountManager2, toField, ccField,
1876
1918
  // dispatcher reads this and spawns a Claude worker only for
1877
1919
  // recipients whose name is on the list (or for everyone if the
1878
1920
  // field is absent, preserving the v0.8.x default).
1879
- ...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
1880
1926
  });
1881
1927
  }
1882
1928
  }
@@ -1933,7 +1979,8 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
1933
1979
  const pendingId = crypto.randomUUID();
1934
1980
  const ownerName2 = agent.metadata?.ownerName;
1935
1981
  const fromName2 = ownerName2 ? `${agent.name} from ${ownerName2}` : agent.name;
1936
- const wakeListForPersist = normalizeWakeList(wake);
1982
+ const explicitWakeForPersist = normalizeWakeList(wake);
1983
+ const wakeListForPersist = wake === void 0 ? deriveDefaultWakeList(to) : explicitWakeForPersist;
1937
1984
  const mailOptions = {
1938
1985
  to,
1939
1986
  subject,
@@ -2016,7 +2063,8 @@ function createMailRoutes(accountManager2, config, db, gatewayManager) {
2016
2063
  }
2017
2064
  const ownerName = agent.metadata?.ownerName;
2018
2065
  const fromName = ownerName ? `${agent.name} from ${ownerName}` : agent.name;
2019
- const wakeList = normalizeWakeList(wake);
2066
+ const explicitWake = normalizeWakeList(wake);
2067
+ const wakeList = wake === void 0 ? deriveDefaultWakeList(to) : explicitWake;
2020
2068
  const customHeaders = wakeHeaders(wakeList);
2021
2069
  const mailOpts = {
2022
2070
  to,
@@ -4882,7 +4930,7 @@ function createStorageRoutes(rawDb, accountManager2, config, dialect = "sqlite")
4882
4930
  const meta = await verifyAccess(agent, tableName, res);
4883
4931
  if (!meta) return;
4884
4932
  let imported = 0;
4885
- let skipped = 0;
4933
+ let skipped2 = 0;
4886
4934
  for (const row of rows) {
4887
4935
  const keys = Object.keys(row);
4888
4936
  const vals = Object.values(row).map((v) => typeof v === "object" && v !== null ? JSON.stringify(v) : v);
@@ -4908,7 +4956,7 @@ function createStorageRoutes(rawDb, accountManager2, config, dialect = "sqlite")
4908
4956
  imported++;
4909
4957
  } catch (e) {
4910
4958
  if (onConflict === "skip") {
4911
- skipped++;
4959
+ skipped2++;
4912
4960
  continue;
4913
4961
  }
4914
4962
  throw e;
@@ -4916,7 +4964,7 @@ function createStorageRoutes(rawDb, accountManager2, config, dialect = "sqlite")
4916
4964
  }
4917
4965
  const countResult = await db.get(`SELECT COUNT(*) as cnt FROM ${tableName}`);
4918
4966
  await db.run("UPDATE agenticmail_storage_meta SET row_count = ?, updated_at = " + nowExpr(dialect) + " WHERE table_name = ?", [countResult?.cnt || 0, tableName]);
4919
- res.json({ ok: true, imported, skipped, totalRows: countResult?.cnt || 0 });
4967
+ res.json({ ok: true, imported, skipped: skipped2, totalRows: countResult?.cnt || 0 });
4920
4968
  } catch (err) {
4921
4969
  res.status(500).json({ error: err.message });
4922
4970
  }
@@ -5070,6 +5118,10 @@ function prune(nowMs) {
5070
5118
  recent.delete(first);
5071
5119
  }
5072
5120
  }
5121
+ var skipped = [];
5122
+ var SKIPPED_CAP = 100;
5123
+ var SKIPPED_TTL_MS = 5 * 60 * 1e3;
5124
+ var processState = null;
5073
5125
  function createDispatcherActivityRoutes() {
5074
5126
  const router = Router13();
5075
5127
  router.post("/dispatcher/worker-started", requireMaster, (req, res) => {
@@ -5117,7 +5169,8 @@ function createDispatcherActivityRoutes() {
5117
5169
  endedAtMs: nowMs,
5118
5170
  ok: body.ok === false ? false : true,
5119
5171
  resultPreview: typeof body.resultPreview === "string" ? body.resultPreview.slice(0, 240) : void 0,
5120
- 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
5121
5174
  };
5122
5175
  active.delete(body.workerId);
5123
5176
  recent.set(body.workerId, info);
@@ -5150,8 +5203,26 @@ function createDispatcherActivityRoutes() {
5150
5203
  router.get("/dispatcher/activity", requireMaster, (_req, res) => {
5151
5204
  const nowMs = Date.now();
5152
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
+ })();
5153
5223
  res.json({
5154
5224
  now: nowMs,
5225
+ dispatcher: processHealth,
5155
5226
  active: Array.from(active.values()).map((w) => ({
5156
5227
  ...w,
5157
5228
  durationMs: nowMs - w.startedAtMs,
@@ -5161,8 +5232,51 @@ function createDispatcherActivityRoutes() {
5161
5232
  recent: Array.from(recent.values()).map((w) => ({
5162
5233
  ...w,
5163
5234
  durationMs: (w.endedAtMs ?? nowMs) - w.startedAtMs
5164
- }))
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 }))
5241
+ });
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()
5165
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 });
5166
5280
  });
5167
5281
  router.get("/dispatcher/worker-log/:workerId", requireMaster, (req, res) => {
5168
5282
  const rawId = String(req.params.workerId ?? "");
@@ -5196,6 +5310,91 @@ function createDispatcherActivityRoutes() {
5196
5310
  return router;
5197
5311
  }
5198
5312
 
5313
+ // src/routes/agent-memory.ts
5314
+ import { Router as Router14 } from "express";
5315
+ import {
5316
+ AgentMemoryStore,
5317
+ threadIdFor,
5318
+ ThreadCache
5319
+ } from "@agenticmail/core";
5320
+ function createAgentMemoryRoutes(config) {
5321
+ const router = Router14();
5322
+ const memoryStore = new AgentMemoryStore();
5323
+ const threadCache = new ThreadCache();
5324
+ router.get("/agents/me/memory/threads/:t", requireAgent, async (req, res, next) => {
5325
+ try {
5326
+ const t = String(req.params.t);
5327
+ const memory = memoryStore.read(req.agent.id, t);
5328
+ if (!memory) {
5329
+ res.status(404).json({ error: "No memory for this thread" });
5330
+ return;
5331
+ }
5332
+ res.json(memory);
5333
+ } catch (err) {
5334
+ next(err);
5335
+ }
5336
+ });
5337
+ router.post("/agents/me/memory/threads/:t", requireAgent, async (req, res, next) => {
5338
+ try {
5339
+ const t = String(req.params.t);
5340
+ const { summary, commitments, openQuestions, lastAction, lastUid } = req.body ?? {};
5341
+ if (!summary && !commitments && !openQuestions && !lastAction && lastUid === void 0) {
5342
+ res.status(400).json({ error: "At least one field is required (summary, commitments, openQuestions, lastAction, or lastUid)" });
5343
+ return;
5344
+ }
5345
+ memoryStore.write(req.agent.id, t, {
5346
+ summary: typeof summary === "string" ? summary : void 0,
5347
+ commitments: Array.isArray(commitments) ? commitments.map(String) : void 0,
5348
+ openQuestions: Array.isArray(openQuestions) ? openQuestions.map(String) : void 0,
5349
+ lastAction: typeof lastAction === "string" ? lastAction : void 0,
5350
+ lastUid: typeof lastUid === "number" ? lastUid : void 0
5351
+ });
5352
+ res.json({ ok: true, threadId: t });
5353
+ } catch (err) {
5354
+ next(err);
5355
+ }
5356
+ });
5357
+ router.delete("/agents/me/memory/threads/:t", requireAgent, async (req, res, next) => {
5358
+ try {
5359
+ memoryStore.delete(req.agent.id, String(req.params.t));
5360
+ res.json({ ok: true });
5361
+ } catch (err) {
5362
+ next(err);
5363
+ }
5364
+ });
5365
+ router.get("/agents/me/thread-id", requireAgent, async (req, res, next) => {
5366
+ try {
5367
+ const uid = parseInt(String(req.query.uid ?? ""), 10);
5368
+ if (isNaN(uid) || uid < 1) {
5369
+ res.status(400).json({ error: "uid query param is required" });
5370
+ return;
5371
+ }
5372
+ const folder = req.query.folder || "INBOX";
5373
+ const password = getAgentPassword(req.agent);
5374
+ const receiver = await getReceiver(req.agent.stalwartPrincipal, password, config);
5375
+ const envs = await receiver.listEnvelopes(folder, { limit: 1, offset: 0 });
5376
+ const envelope = envs.find((e) => e.uid === uid) ?? (await receiver.listEnvelopes(folder, { limit: 200, offset: 0 })).find((e) => e.uid === uid);
5377
+ if (!envelope) {
5378
+ res.status(404).json({ error: `No message with UID ${uid} in folder ${folder}` });
5379
+ return;
5380
+ }
5381
+ const subject = envelope.subject ?? "";
5382
+ const senderAddr = envelope.from?.[0]?.address ?? "";
5383
+ const provisional = threadIdFor({ subject, rootFromAddr: senderAddr });
5384
+ const existing = threadCache.read(provisional);
5385
+ if (existing) {
5386
+ const canonical = threadIdFor({ subject, rootFromAddr: existing.rootFromAddr });
5387
+ res.json({ threadId: canonical, rootFromAddr: existing.rootFromAddr, subject: existing.subject });
5388
+ return;
5389
+ }
5390
+ res.json({ threadId: provisional, rootFromAddr: senderAddr, subject });
5391
+ } catch (err) {
5392
+ next(err);
5393
+ }
5394
+ });
5395
+ return router;
5396
+ }
5397
+
5199
5398
  // src/app.ts
5200
5399
  var integrationRouteFactoryPromise = (async () => {
5201
5400
  try {
@@ -5296,6 +5495,7 @@ function createApp(configOverrides) {
5296
5495
  app2.use("/api/agenticmail", createStorageRoutes(db, accountManager2, config));
5297
5496
  app2.use("/api/agenticmail", createSystemEventRoutes());
5298
5497
  app2.use("/api/agenticmail", createDispatcherActivityRoutes());
5498
+ app2.use("/api/agenticmail", createAgentMemoryRoutes(config));
5299
5499
  app2.use("/api/agenticmail", (_req, res) => {
5300
5500
  res.status(404).json({ error: "Not found" });
5301
5501
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.7.21",
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.7.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.1.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;