@agenticmail/cli 0.9.41 → 0.9.43

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.
@@ -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, buildAudienceLookup())}</div>
174
+ <div class="message-body">${renderBodyWithThreading(bodyText, buildAudienceLookup(msg))}</div>
175
175
  ${attachmentsHtml}
176
176
  `;
177
177
 
@@ -239,12 +239,43 @@ function formatBytes(bytes) {
239
239
  * header serialises the date with second precision and stripped
240
240
  * timezone info.
241
241
  */
242
- function buildAudienceLookup() {
242
+ function buildAudienceLookup(currentMessage = null) {
243
+ // Two layered sources, server-authoritative wins:
244
+ //
245
+ // 1. currentMessage.quotedAudiences — populated by the API when
246
+ // it parses the body, scans for `On <date>, <addr> wrote:`
247
+ // headers, and matches them against the IMAP envelopes
248
+ // (which carry Cc/Bcc on the wire — we just weren't surfacing
249
+ // them before 0.9.30). This works even on direct deep links
250
+ // where state.messages is empty.
251
+ //
252
+ // 2. state.messages — the inbox list view the operator is
253
+ // currently looking at. Fallback for older API responses
254
+ // that don't yet include the server-side resolution.
255
+ //
256
+ // Layer 1 takes precedence because the server actually has access
257
+ // to the full mailbox envelopes; the client only sees what was
258
+ // paginated into state.messages.
259
+ const serverIndex = new Map();
260
+ if (currentMessage && Array.isArray(currentMessage.quotedAudiences)) {
261
+ for (const a of currentMessage.quotedAudiences) {
262
+ if (!a || !a.sender) continue;
263
+ // Index by sender alone AND by sender::dateIso for fuzzy date match
264
+ const senderKey = String(a.sender).toLowerCase();
265
+ const t = a.dateIso ? new Date(a.dateIso).getTime() : NaN;
266
+ serverIndex.set(senderKey + '::' + (Number.isFinite(t) ? t : ''), { to: a.to, cc: a.cc, bcc: a.bcc, timeMs: t });
267
+ // Also keep a "most recent for sender" entry as last-resort fallback
268
+ const allKey = senderKey + '::*';
269
+ const prev = serverIndex.get(allKey);
270
+ if (!prev || (Number.isFinite(t) && t > prev.timeMs)) {
271
+ serverIndex.set(allKey, { to: a.to, cc: a.cc, bcc: a.bcc, timeMs: t });
272
+ }
273
+ }
274
+ }
243
275
  // 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.
276
+ // straight to /#/m/N. Lookup degrades to layer 1 only.
246
277
  const list = Array.isArray(state.messages) ? state.messages : [];
247
- if (list.length === 0) return () => null;
278
+ if (list.length === 0 && serverIndex.size === 0) return () => null;
248
279
  // Flatten to a normalised array we can scan. Each entry carries
249
280
  // the same shape the renderer expects.
250
281
  const entries = list.map(m => {
@@ -265,23 +296,29 @@ function buildAudienceLookup() {
265
296
  if (!senderAddr || !dateStr) return null;
266
297
  const senderL = senderAddr.toLowerCase();
267
298
  const t = new Date(dateStr).getTime();
299
+
300
+ // Layer 1: server-resolved index. Exact date match first, then
301
+ // sender-only fallback. The server already matched against IMAP
302
+ // envelopes so this is the authoritative source.
303
+ if (serverIndex.size > 0) {
304
+ const exactKey = senderL + '::' + (Number.isFinite(t) ? t : '');
305
+ const exact = serverIndex.get(exactKey);
306
+ if (exact) return { to: exact.to, cc: exact.cc, bcc: exact.bcc };
307
+ const senderOnly = serverIndex.get(senderL + '::*');
308
+ if (senderOnly) return { to: senderOnly.to, cc: senderOnly.cc, bcc: senderOnly.bcc };
309
+ }
310
+
311
+ // Layer 2: state.messages — surrounding inbox list. Bounded by
312
+ // what the operator's folder view happens to have loaded.
268
313
  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
314
  let best = null;
274
- let bestDelta = 2000; // 2-second tolerance for same-second matches
315
+ let bestDelta = 2000;
275
316
  for (const e of entries) {
276
317
  if (e.from !== senderL) continue;
277
318
  const delta = Math.abs(e.timeMs - t);
278
319
  if (delta < bestDelta) { best = e; bestDelta = delta; }
279
320
  }
280
321
  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
322
  for (const e of entries) {
286
323
  if (e.from !== senderL) continue;
287
324
  if (!best || e.timeMs > best.timeMs) best = e;
@@ -324,6 +361,17 @@ function renderBodyWithThreading(src, audienceLookup = null) {
324
361
  i++;
325
362
  continue;
326
363
  }
364
+ // Strip any trailing blank lines from the prose buffer before
365
+ // flushing. Reply builders typically pad the new content with
366
+ // `\n\n` before the `On <date>, <addr> wrote:` header so the
367
+ // quote stands apart in plaintext clients. In the web view those
368
+ // blank lines render as empty <p> blocks with their own margins,
369
+ // producing the huge gap operators flagged between the latest
370
+ // message and the quoted thread. Trimming here keeps plain-text
371
+ // readers happy and tightens the rendered HTML at the same time.
372
+ while (prose.length > 0 && prose[prose.length - 1].trim() === '') {
373
+ prose.pop();
374
+ }
327
375
  flushProse();
328
376
  const dateRaw = m[1];
329
377
  const sender = m[2];
@@ -361,16 +409,27 @@ function renderBodyWithThreading(src, audienceLookup = null) {
361
409
  }
362
410
  break;
363
411
  }
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') {
412
+ // Fall back to cross-message lookup to fill ANY missing audience
413
+ // field not just the all-empty case.
414
+ //
415
+ // The previous gate (`!toAddrs && !ccAddrs && !bccAddrs`) was wrong:
416
+ // it treated a body that had a `To:` line but no `Cc:` line as
417
+ // "audience encoded, lookup not needed", silently dropping the Cc
418
+ // info that state.messages had. This surfaced as quoted blocks
419
+ // showing only `TO:` even when the original message had Cc'd
420
+ // multiple agents — operators correctly flagged this as "the
421
+ // earlier email's CCs aren't being shown".
422
+ //
423
+ // Now: run the lookup whenever any field is missing, and only
424
+ // backfill the fields that weren't already in the body. The
425
+ // body-encoded value always wins (it's authoritative); the
426
+ // lookup is purely a backfill.
427
+ if ((!toAddrs || !ccAddrs || !bccAddrs) && typeof audienceLookup === 'function') {
369
428
  const fromLookup = audienceLookup(sender, dateRaw);
370
429
  if (fromLookup) {
371
- toAddrs = fromLookup.to;
372
- ccAddrs = fromLookup.cc;
373
- bccAddrs = fromLookup.bcc;
430
+ if (!toAddrs) toAddrs = fromLookup.to || '';
431
+ if (!ccAddrs) ccAddrs = fromLookup.cc || '';
432
+ if (!bccAddrs) bccAddrs = fromLookup.bcc || '';
374
433
  }
375
434
  }
376
435
  out.push(renderThreadQuote(dateRaw, sender, quoted.join('\n'), { to: toAddrs, cc: ccAddrs, bcc: bccAddrs }, audienceLookup));
@@ -1019,14 +1019,65 @@ mark.search-hl {
1019
1019
  styled card with proper avatar + name + friendly date instead
1020
1020
  of leaving the raw ISO timestamp visible. Nested replies recurse
1021
1021
  so a 3-deep thread gets 3 nested cards. */
1022
+ /* Thread-quote: the boxed-up older email a reply quotes. Issues we
1023
+ * sequentially squashed:
1024
+ *
1025
+ * 1. Huge vertical gap before the quote — initially 16px margin-top
1026
+ * on .thread-quote PLUS empty <p>/<ul> blocks from the markdown
1027
+ * renderer treating reply sig-lines like `- marlow` as bullet
1028
+ * lists. message-view.js now trims trailing blank lines from
1029
+ * the prose buffer; we tighten the rendered block margins below
1030
+ * so the last <p>/<ul>/<ol> before a quote collapses its
1031
+ * bottom margin and the gap reads as a single hairline.
1032
+ * 2. The pink left rule looked like a stub. ::before lays a 1px
1033
+ * full-width divider tucked behind the border so the visual
1034
+ * break is unambiguous and the rule reads as a continuous
1035
+ * thread spine through the quoted block.
1036
+ *
1037
+ * Nested quotes (replies-of-replies) get progressively warmer rule
1038
+ * colours (pink → purple → amber) so the depth is visible at a
1039
+ * glance without staring at indentation. */
1022
1040
  .thread-quote {
1023
- margin: 16px 0;
1041
+ position: relative;
1042
+ margin: 4px 0 8px;
1043
+ padding: 14px 0 4px 16px;
1024
1044
  border-left: 3px solid var(--pink-rule);
1025
- padding: 0 0 0 16px;
1045
+ }
1046
+ .thread-quote::before {
1047
+ content: '';
1048
+ position: absolute;
1049
+ top: 0;
1050
+ left: -3px;
1051
+ right: 0;
1052
+ height: 1px;
1053
+ background: var(--line, rgba(255,255,255,0.08));
1054
+ }
1055
+ /* Collapse the margin-bottom on whichever block lands immediately
1056
+ * before a thread-quote. Markdown's default 1em paragraph/list
1057
+ * margins were stacking with .thread-quote's margin-top, producing
1058
+ * a ~50px dead zone above quoted replies. With :has() we target
1059
+ * only the preceding sibling (other blocks elsewhere keep their
1060
+ * default margins). Modern browsers all support :has() now. */
1061
+ .message-body p:has(+ .thread-quote),
1062
+ .message-body ul:has(+ .thread-quote),
1063
+ .message-body ol:has(+ .thread-quote),
1064
+ .message-body h1:has(+ .thread-quote),
1065
+ .message-body h2:has(+ .thread-quote),
1066
+ .message-body h3:has(+ .thread-quote),
1067
+ .message-body pre:has(+ .thread-quote),
1068
+ .message-body blockquote:has(+ .thread-quote) {
1069
+ margin-bottom: 0;
1070
+ }
1071
+ /* Same tightening on the LAST child of a thread-quote-body — keeps
1072
+ * nested quote ladders from accumulating spacing as they descend. */
1073
+ .thread-quote-body > p:last-child,
1074
+ .thread-quote-body > ul:last-child,
1075
+ .thread-quote-body > ol:last-child {
1076
+ margin-bottom: 0;
1026
1077
  }
1027
1078
  .thread-quote .thread-quote {
1028
1079
  border-left-color: #c084fc;
1029
- margin: 12px 0;
1080
+ margin: 4px 0 8px;
1030
1081
  }
1031
1082
  .thread-quote .thread-quote .thread-quote { border-left-color: #f59e0b; }
1032
1083
  .thread-quote-head {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/cli",
3
- "version": "0.9.41",
3
+ "version": "0.9.43",
4
4
  "description": "Email and SMS infrastructure for AI agents — the first platform to give agents real email addresses and phone numbers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,8 +31,8 @@
31
31
  "prepublishOnly": "npm run build"
32
32
  },
33
33
  "dependencies": {
34
- "@agenticmail/api": "^0.9.28",
35
- "@agenticmail/core": "^0.9.7",
34
+ "@agenticmail/api": "^0.9.30",
35
+ "@agenticmail/core": "^0.9.8",
36
36
  "json5": "^2.2.3"
37
37
  },
38
38
  "optionalDependencies": {