@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 +1 -1
- package/public/js/compose.js +16 -1
- package/public/js/message-view.js +123 -5
- package/public/styles.css +24 -0
package/package.json
CHANGED
package/public/js/compose.js
CHANGED
|
@@ -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\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|