@agenticmail/cli 0.9.42 → 0.9.44

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;
@@ -1019,19 +1019,20 @@ 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. Two issues
1023
- * users flagged on the rendered view before:
1022
+ /* Thread-quote: the boxed-up older email a reply quotes. Issues we
1023
+ * sequentially squashed:
1024
1024
  *
1025
- * 1. Huge vertical gap before the quote — was 16px margin-top here
1026
- * PLUS empty <p> blocks from trailing newlines in the parent
1027
- * message body (now also fixed in message-view.js by trimming
1028
- * trailing whitespace before the quote). Dropped to a tight 4px
1029
- * since the horizontal divider below carries the separation.
1030
- * 2. The pink left rule looked like a stub. We add a full-width
1031
- * hairline divider on ::before so the visual break between the
1032
- * parent message and the quote is unambiguous, and pin the rule
1033
- * so it reads as a thread spine continuing down through the
1034
- * whole quoted block.
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.
1035
1036
  *
1036
1037
  * Nested quotes (replies-of-replies) get progressively warmer rule
1037
1038
  * colours (pink → purple → amber) so the depth is visible at a
@@ -1051,6 +1052,29 @@ mark.search-hl {
1051
1052
  height: 1px;
1052
1053
  background: var(--line, rgba(255,255,255,0.08));
1053
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;
1077
+ }
1054
1078
  .thread-quote .thread-quote {
1055
1079
  border-left-color: #c084fc;
1056
1080
  margin: 4px 0 8px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/cli",
3
- "version": "0.9.42",
3
+ "version": "0.9.44",
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,13 +31,13 @@
31
31
  "prepublishOnly": "npm run build"
32
32
  },
33
33
  "dependencies": {
34
- "@agenticmail/api": "^0.9.29",
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": {
39
39
  "@agenticmail/claudecode": "^0.2.22",
40
- "@agenticmail/codex": "^0.1.17"
40
+ "@agenticmail/codex": "^0.1.18"
41
41
  },
42
42
  "devDependencies": {
43
43
  "tsup": "^8.4.0",