@agenticmail/api 0.9.22 → 0.9.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.22",
3
+ "version": "0.9.24",
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",
@@ -85,8 +85,23 @@ export function openReply(replyAll) {
85
85
  document.getElementById('compose-wake').value = '';
86
86
  document.getElementById('compose-subject').value =
87
87
  (msg.subject ?? '').startsWith('Re:') ? msg.subject : `Re: ${msg.subject ?? ''}`;
88
+ // Quote header — extend the standard "On <date>, <addr> wrote:"
89
+ // line with To: / Cc: lines pulled from the original message so
90
+ // the recipient of THIS reply can see who was on the previous
91
+ // round of the thread. The renderer in message-view.js parses
92
+ // these optional lines and surfaces them in the thread-quote
93
+ // header alongside the sender name.
94
+ const fmtAddrList = (arr) => (arr || [])
95
+ .map(a => (typeof a === 'string' ? a : (a?.address ?? '')))
96
+ .filter(Boolean)
97
+ .join(', ');
98
+ const origTo = fmtAddrList(msg.to);
99
+ const origCc = fmtAddrList(msg.cc);
100
+ const headerLines = [`On ${msg.date}, ${fromAddr} wrote:`];
101
+ if (origTo) headerLines.push(`To: ${origTo}`);
102
+ if (origCc) headerLines.push(`Cc: ${origCc}`);
88
103
  const quoted = (msg.text ?? '').split('\n').map(l => `> ${l}`).join('\n');
89
- const stub = `\n\nOn ${msg.date}, ${fromAddr} wrote:\n${quoted}`;
104
+ const stub = `\n\n${headerLines.join('\n')}\n${quoted}`;
90
105
  document.getElementById('compose-body').value = stub;
91
106
  pendingAttachments = [];
92
107
  renderAttachmentChips();
@@ -171,7 +171,7 @@ function renderMessage(msg) {
171
171
  <div class="message-date">${escapeHtml(formatDateFull(msg.date))}</div>
172
172
  </div>
173
173
  </div>
174
- <div class="message-body">${renderBodyWithThreading(bodyText)}</div>
174
+ <div class="message-body">${renderBodyWithThreading(bodyText, buildAudienceLookup())}</div>
175
175
  ${attachmentsHtml}
176
176
  `;
177
177
 
@@ -222,7 +222,76 @@ function formatBytes(bytes) {
222
222
  *
223
223
  * Non-matching prose flows through untouched via `renderMarkdown`.
224
224
  */
225
- function renderBodyWithThreading(src) {
225
+ /**
226
+ * Build a lookup map from "sender + date" to {to, cc, bcc} so the
227
+ * thread-quote renderer can backfill audience info on quotes that
228
+ * pre-date the 0.9.32 reply-builder change (those quotes only have
229
+ * `On <date>, <addr> wrote:` — no `To:`/`Cc:` follow-up lines).
230
+ *
231
+ * Source of truth: `state.messages` — the inbox list view the
232
+ * operator is currently looking at. Every entry in that list has
233
+ * `from`, `to`, `cc`, `date`. We hash by lowercase sender address +
234
+ * date and dedupe via the messageId.
235
+ *
236
+ * Returns a function: `(senderAddr, dateStr) → {to, cc, bcc} | null`.
237
+ * Date matching is fuzzy: we accept any sibling whose date.getTime()
238
+ * is within 2 seconds of the parsed quote date, since the quote
239
+ * header serialises the date with second precision and stripped
240
+ * timezone info.
241
+ */
242
+ function buildAudienceLookup() {
243
+ // Defensive: state.messages may be empty if the operator navigated
244
+ // straight to /#/m/N. Lookup degrades to "no match" — the renderer
245
+ // falls back to sender-only.
246
+ const list = Array.isArray(state.messages) ? state.messages : [];
247
+ if (list.length === 0) return () => null;
248
+ // Flatten to a normalised array we can scan. Each entry carries
249
+ // the same shape the renderer expects.
250
+ const entries = list.map(m => {
251
+ const fromAddr = (m.from?.[0]?.address ?? '').toLowerCase();
252
+ const fmtAddrs = (arr) => (Array.isArray(arr) ? arr : [])
253
+ .map((a) => (typeof a === 'string' ? a : (a?.address ?? '')))
254
+ .filter(Boolean)
255
+ .join(', ');
256
+ return {
257
+ from: fromAddr,
258
+ timeMs: m.date ? new Date(m.date).getTime() : NaN,
259
+ to: fmtAddrs(m.to),
260
+ cc: fmtAddrs(m.cc),
261
+ bcc: fmtAddrs(m.bcc),
262
+ };
263
+ }).filter(e => e.from && Number.isFinite(e.timeMs));
264
+ return (senderAddr, dateStr) => {
265
+ if (!senderAddr || !dateStr) return null;
266
+ const senderL = senderAddr.toLowerCase();
267
+ const t = new Date(dateStr).getTime();
268
+ if (!Number.isFinite(t)) return null;
269
+ // Match by sender + nearest date within 2 s. Quote headers
270
+ // typically lose timezone info during render so wall-clock can
271
+ // shift by hours; the inbox-list date is the canonical UTC.
272
+ // We prefer the closest match by absolute delta.
273
+ let best = null;
274
+ let bestDelta = 2000; // 2-second tolerance for same-second matches
275
+ for (const e of entries) {
276
+ if (e.from !== senderL) continue;
277
+ const delta = Math.abs(e.timeMs - t);
278
+ if (delta < bestDelta) { best = e; bestDelta = delta; }
279
+ }
280
+ if (!best) {
281
+ // Fallback: if the date didn't parse close to anything, just
282
+ // take the most recent message from that sender. Better than
283
+ // nothing for the common case of a reply to the most recent
284
+ // prior reply.
285
+ for (const e of entries) {
286
+ if (e.from !== senderL) continue;
287
+ if (!best || e.timeMs > best.timeMs) best = e;
288
+ }
289
+ }
290
+ return best ? { to: best.to, cc: best.cc, bcc: best.bcc } : null;
291
+ };
292
+ }
293
+
294
+ function renderBodyWithThreading(src, audienceLookup = null) {
226
295
  if (!src) return '<div class="empty">(no body)</div>';
227
296
  const lines = src.split('\n');
228
297
  const out = [];
@@ -239,6 +308,14 @@ function renderBodyWithThreading(src) {
239
308
  // angle-bracket form `<addr@host>`. Date is anything up to the
240
309
  // comma; addr is anything not whitespace + an @.
241
310
  const headerRe = /^On (.+?), <?([^\s<>]+@[^\s<>]+)>? wrote:\s*$/;
311
+ // Optional follow-up address lines emitted by AgenticMail's
312
+ // reply builders: `To: a@x, b@y` / `Cc: …` / `Bcc: …`. These
313
+ // sit between the `wrote:` line and the first `> ` quoted body
314
+ // line; the parser collects them so the rendered thread-quote
315
+ // header can show the previous round's full audience, not just
316
+ // the sender. Optional — older replies (pre-0.9.32) won't have
317
+ // them and degrade to sender-only.
318
+ const audienceRe = /^(To|Cc|Bcc):\s*(.+)$/i;
242
319
 
243
320
  while (i < lines.length) {
244
321
  const m = lines[i].match(headerRe);
@@ -251,6 +328,23 @@ function renderBodyWithThreading(src) {
251
328
  const dateRaw = m[1];
252
329
  const sender = m[2];
253
330
  i++;
331
+ // Collect optional audience lines (To/Cc/Bcc) immediately
332
+ // after the wrote: header. Stop at the first line that
333
+ // doesn't match; non-matching content falls through to the
334
+ // body-quote collection loop below.
335
+ let toAddrs = '';
336
+ let ccAddrs = '';
337
+ let bccAddrs = '';
338
+ while (i < lines.length) {
339
+ const a = lines[i].match(audienceRe);
340
+ if (!a) break;
341
+ const field = a[1].toLowerCase();
342
+ const value = a[2].trim();
343
+ if (field === 'to') toAddrs = value;
344
+ else if (field === 'cc') ccAddrs = value;
345
+ else if (field === 'bcc') bccAddrs = value;
346
+ i++;
347
+ }
254
348
  // Collect contiguous `>` lines (with possible blank-line gaps
255
349
  // inside the quote, which most clients tolerate). Stop at the
256
350
  // first non-quote, non-blank line.
@@ -267,13 +361,25 @@ function renderBodyWithThreading(src) {
267
361
  }
268
362
  break;
269
363
  }
270
- out.push(renderThreadQuote(dateRaw, sender, quoted.join('\n')));
364
+ // Fall back to cross-message lookup if the body didn't carry
365
+ // audience lines (older replies pre-0.9.32). The lookup uses
366
+ // the surrounding inbox-list messages to find the original
367
+ // message by sender + date and lift its To/Cc/Bcc.
368
+ if (!toAddrs && !ccAddrs && !bccAddrs && typeof audienceLookup === 'function') {
369
+ const fromLookup = audienceLookup(sender, dateRaw);
370
+ if (fromLookup) {
371
+ toAddrs = fromLookup.to;
372
+ ccAddrs = fromLookup.cc;
373
+ bccAddrs = fromLookup.bcc;
374
+ }
375
+ }
376
+ out.push(renderThreadQuote(dateRaw, sender, quoted.join('\n'), { to: toAddrs, cc: ccAddrs, bcc: bccAddrs }, audienceLookup));
271
377
  }
272
378
  flushProse();
273
379
  return out.join('');
274
380
  }
275
381
 
276
- function renderThreadQuote(dateRaw, sender, quotedBody) {
382
+ function renderThreadQuote(dateRaw, sender, quotedBody, audience = {}, audienceLookup = null) {
277
383
  // Try to format the ISO date through the same helper the
278
384
  // message header uses; fall back to the raw string on parse fail.
279
385
  const friendlyDate = (() => {
@@ -281,7 +387,18 @@ function renderThreadQuote(dateRaw, sender, quotedBody) {
281
387
  if (!Number.isNaN(d.getTime())) return formatDateFull(d.toISOString());
282
388
  return dateRaw;
283
389
  })();
284
- const sub = renderBodyWithThreading(quotedBody); // recurse for nested threads
390
+ const sub = renderBodyWithThreading(quotedBody, audienceLookup); // recurse for nested threads
391
+ // Render the optional audience lines (To/Cc/Bcc) inside the
392
+ // thread-quote header so the reader can see who was on the
393
+ // previous round. Missing values are simply omitted — a quote
394
+ // from an older email that didn't include them degrades cleanly
395
+ // to the sender + date line.
396
+ const audienceRow = (label, value) => value
397
+ ? `<div class="thread-quote-audience-row"><span class="thread-quote-audience-label">${label}:</span> <span class="thread-quote-audience-value">${escapeHtml(value)}</span></div>`
398
+ : '';
399
+ const audienceBlock = (audience.to || audience.cc || audience.bcc)
400
+ ? `<div class="thread-quote-audience">${audienceRow('To', audience.to)}${audienceRow('Cc', audience.cc)}${audienceRow('Bcc', audience.bcc)}</div>`
401
+ : '';
285
402
  return `
286
403
  <div class="thread-quote">
287
404
  <div class="thread-quote-head">
@@ -290,6 +407,7 @@ function renderThreadQuote(dateRaw, sender, quotedBody) {
290
407
  <span class="thread-quote-dot">·</span>
291
408
  <span class="thread-quote-date">${escapeHtml(friendlyDate)}</span>
292
409
  </div>
410
+ ${audienceBlock}
293
411
  <div class="thread-quote-body">${sub}</div>
294
412
  </div>
295
413
  `;
package/public/styles.css CHANGED
@@ -938,6 +938,30 @@ mark.search-hl {
938
938
  .thread-quote-from { font-weight: 500; color: var(--ink-soft); }
939
939
  .thread-quote-dot { opacity: .5; }
940
940
  .thread-quote-date { color: var(--muted); }
941
+ /* Audience block (To / Cc / Bcc) below the quote header — surfaces
942
+ * who was on the previous round of the thread, not just the sender.
943
+ * Optional: missing values are simply omitted, no row rendered. */
944
+ .thread-quote-audience {
945
+ margin: -4px 0 8px;
946
+ padding-left: 30px; /* line up with sender name (past avatar) */
947
+ font-size: 12px;
948
+ color: var(--muted);
949
+ line-height: 1.5;
950
+ }
951
+ .thread-quote-audience-row { display: block; }
952
+ .thread-quote-audience-label {
953
+ display: inline-block;
954
+ min-width: 28px;
955
+ color: var(--muted);
956
+ font-weight: 500;
957
+ text-transform: uppercase;
958
+ letter-spacing: 0.04em;
959
+ font-size: 10.5px;
960
+ }
961
+ .thread-quote-audience-value {
962
+ color: var(--ink-soft);
963
+ word-break: break-word;
964
+ }
941
965
  .thread-quote-body {
942
966
  color: var(--ink-soft);
943
967
  }