@agenticmail/api 0.9.23 → 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.23",
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",
@@ -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 = [];
@@ -292,13 +361,25 @@ function renderBodyWithThreading(src) {
292
361
  }
293
362
  break;
294
363
  }
295
- out.push(renderThreadQuote(dateRaw, sender, quoted.join('\n'), { to: toAddrs, cc: ccAddrs, bcc: bccAddrs }));
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));
296
377
  }
297
378
  flushProse();
298
379
  return out.join('');
299
380
  }
300
381
 
301
- function renderThreadQuote(dateRaw, sender, quotedBody, audience = {}) {
382
+ function renderThreadQuote(dateRaw, sender, quotedBody, audience = {}, audienceLookup = null) {
302
383
  // Try to format the ISO date through the same helper the
303
384
  // message header uses; fall back to the raw string on parse fail.
304
385
  const friendlyDate = (() => {
@@ -306,7 +387,7 @@ function renderThreadQuote(dateRaw, sender, quotedBody, audience = {}) {
306
387
  if (!Number.isNaN(d.getTime())) return formatDateFull(d.toISOString());
307
388
  return dateRaw;
308
389
  })();
309
- const sub = renderBodyWithThreading(quotedBody); // recurse for nested threads
390
+ const sub = renderBodyWithThreading(quotedBody, audienceLookup); // recurse for nested threads
310
391
  // Render the optional audience lines (To/Cc/Bcc) inside the
311
392
  // thread-quote header so the reader can see who was on the
312
393
  // previous round. Missing values are simply omitted — a quote