${attachments
- .map((a, index) => {
- const name = escapeHtml(a.name || t('Attachment'));
- const hasText = typeof a.text === 'string' && a.text.length > 0;
- if (hasText) {
- return ``;
- }
- return `${name}`;
- })
- .join('')}
`
- : '';
wrap.innerHTML = `
${avatarMarkup(sender, 'lg')}
@@ -1439,32 +1441,84 @@ function renderEmail(email, learnerEmail, { showSubject = true, subject = '' } =
-
+
+
${renderMarkdown(email.body)}
- ${attachmentsHtml}
`;
+ const content = wrap.querySelector('.email__content');
+ if (email.forwarded) content.appendChild(renderForwardedBlock(email.forwarded));
+ const attachmentList = renderAttachmentList(attachments);
+ if (attachmentList) content.appendChild(attachmentList);
for (const btn of wrap.querySelectorAll('.email__action')) {
btn.addEventListener('click', (event) => {
event.stopPropagation();
- startReply(btn.dataset.replyMode, email.id);
- });
- }
- for (const btn of wrap.querySelectorAll('.email__attachment--preview')) {
- btn.addEventListener('click', () => {
- const index = Number(btn.dataset.attachmentIndex);
- const attachment = attachments[index];
- if (attachment) openAttachmentPreview(attachment);
+ const mode = btn.dataset.replyMode;
+ if (mode === 'forward') void startForward(email.id);
+ else startReply(mode, email.id);
});
}
return wrap;
}
+function renderAttachmentList(attachments) {
+ if (!attachments?.length) return null;
+ const list = document.createElement('div');
+ list.className = 'email__attachments';
+ for (const attachment of attachments) {
+ const name = attachment.name || t('Attachment');
+ const hasText = typeof attachment.text === 'string' && attachment.text.length > 0;
+ const chip = document.createElement(hasText ? 'button' : 'span');
+ chip.className = 'tag outline email__attachment';
+ chip.textContent = name;
+ if (hasText) {
+ chip.type = 'button';
+ chip.classList.add('email__attachment--preview');
+ chip.setAttribute('aria-label', `${t('Preview attachment')}: ${name}`);
+ chip.addEventListener('click', () => openAttachmentPreview(attachment));
+ }
+ list.appendChild(chip);
+ }
+ return list;
+}
+
+// The original message carried by a forward, quoted under the cover note.
+// The composer preview names its own scrolling container instead.
+function renderForwardedBlock(forwarded, { labelled = true } = {}) {
+ const block = document.createElement('div');
+ block.className = 'email__forwarded';
+ if (labelled) {
+ block.setAttribute('role', 'group');
+ block.setAttribute('aria-label', t('Forwarded message'));
+ }
+ const row = (label, value) => (value
+ ? `
${escapeHtml(t(label))}: ${escapeHtml(value)}
`
+ : '');
+ const fromName = displayName(forwarded.from);
+ const fromEmail = addressEmail(forwarded.from);
+ const from = fromName && fromEmail && fromName !== fromEmail ? `${fromName} <${fromEmail}>` : (fromName || fromEmail);
+ block.innerHTML = `
+
${escapeHtml(t('Forwarded message'))}
+
+ ${row('From', from)}
+ ${row('Date', forwarded.date ? formatDate(forwarded.date) : '')}
+ ${row('Subject', forwarded.subject)}
+ ${row('To', recipientNames(forwarded.to))}
+ ${forwarded.cc?.length ? row('Cc', recipientNames(forwarded.cc)) : ''}
+
+
${renderMarkdown(forwarded.body)}
+ `;
+ const attachmentList = renderAttachmentList(forwarded.attachments);
+ if (attachmentList) block.appendChild(attachmentList);
+ if (forwarded.forwarded) block.appendChild(renderForwardedBlock(forwarded.forwarded));
+ return block;
+}
+
function openAttachmentPreview(attachment) {
const name = attachment?.name || t('Attachment');
const text = typeof attachment?.text === 'string' ? attachment.text : '';
@@ -1531,17 +1585,17 @@ function renderThread(threadId) {
syncCoveredFocus();
}
-// Reply / Reply all sit after the conversation (the toolbar holds Compose).
+// Reply / Reply all / Forward sit after the conversation (the toolbar holds Compose).
function renderThreadActions() {
const actions = document.createElement('div');
actions.className = 'thread-actions';
- for (const [mode, label] of [['reply', t('Reply')], ['replyAll', t('Reply all')]]) {
+ for (const [mode, label] of [['reply', t('Reply')], ['replyAll', t('Reply all')], ['forward', t('Forward')]]) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'button button-secondary button-xsmall';
btn.dataset.replyMode = mode;
btn.textContent = label;
- btn.addEventListener('click', () => startReply(mode));
+ btn.addEventListener('click', () => (mode === 'forward' ? void startForward() : startReply(mode)));
actions.appendChild(btn);
}
return actions;
@@ -1721,6 +1775,7 @@ function startReply(mode, emailId = null) {
rememberComposeOpener();
state.view = 'thread';
state.composingNew = false;
+ state.forwardOf = null;
state.replying = mode;
state.replyToEmailId = emailId;
if (emailId) state.selectedEmailId = emailId;
@@ -1759,12 +1814,14 @@ function startCompose({ blank = true, draft = null } = {}) {
if (state.view === 'compose') state.view = 'list';
if (draft) {
state.activeDraftId = draft.id || newDraftId();
+ state.forwardOf = draft.forwardOf ?? null;
applyRecipientDraft(draft);
els.composeSubject.value = draft.subject || '';
setEditorMarkdown(draft.body || '');
clearAttachments();
} else if (blank) {
state.activeDraftId = newDraftId();
+ state.forwardOf = null;
applyRecipientDraft({ to: [], cc: [] });
els.composeSubject.value = '';
setEditorMarkdown('');
@@ -1772,6 +1829,7 @@ function startCompose({ blank = true, draft = null } = {}) {
} else {
const initial = computeInitialDraft();
state.activeDraftId = initial.id || newDraftId();
+ state.forwardOf = initial.forwardOf ?? null;
applyRecipientDraft(initial);
els.composeSubject.value = initial.subject || '';
setEditorMarkdown(initial.body || '');
@@ -1782,12 +1840,57 @@ function startCompose({ blank = true, draft = null } = {}) {
els.composeToInput?.focus();
}
+// Forward opens a new message whose subject is prefilled and whose original
+// rides along under the cover note. It never reuses an earlier forward draft.
+async function startForward(emailId = null) {
+ const threadId = state.activeThreadId;
+ const thread = (state.session?.threads ?? []).find((th) => th.id === threadId);
+ if (!thread) return;
+ const source = (emailId && thread.emails?.find((email) => email.id === emailId)) || replyTargetEmail(thread);
+ if (!source?.id) return;
+ if (composerIsOpen()) {
+ clearTimeout(state.draftSaveTimer);
+ await saveDraftNow();
+ }
+ startCompose({
+ draft: {
+ id: newDraftId(),
+ to: [],
+ cc: [],
+ subject: forwardSubject(source.subject || thread.subject),
+ body: '',
+ forwardOf: { threadId, emailId: source.id },
+ },
+ });
+}
+
+function forwardedForComposer() {
+ if (!state.composingNew || !state.forwardOf) return null;
+ const found = findThreadEmail(state.session?.threads, state.forwardOf);
+ return found ? forwardedSnapshot(found.email, { subjectFallback: found.thread.subject }) : null;
+}
+
+// Read-only preview of what the forward will carry, under the cover note.
+function renderComposerForwarded() {
+ const host = els.composerForwarded;
+ if (!host) return;
+ const forwarded = forwardedForComposer();
+ const key = forwarded ? `${state.forwardOf.threadId}/${state.forwardOf.emailId}` : '';
+ if (host.dataset.forwardKey === key) return;
+ host.dataset.forwardKey = key;
+ host.setAttribute('aria-label', t('Forwarded message'));
+ host.innerHTML = '';
+ host.hidden = !forwarded;
+ if (forwarded) host.appendChild(renderForwardedBlock(forwarded, { labelled: false }));
+}
+
async function closeComposeOverlay() {
if (!state.composingNew) return;
clearTimeout(state.draftSaveTimer);
await saveDraftNow();
state.composingNew = false;
state.activeDraftId = null;
+ state.forwardOf = null;
applyView();
refreshDraftsUi();
restoreComposeFocus();
@@ -1823,7 +1926,7 @@ async function deleteDraft(draft) {
const hadInserted = sameDraftScope(state.assistant.lastInserted, draft);
const previousInserted = hadInserted ? state.assistant.lastInserted : null;
const restoreCompose = open && state.composingNew
- ? { activeDraftId: state.activeDraftId, attachments: state.attachments.slice() }
+ ? { activeDraftId: state.activeDraftId, forwardOf: state.forwardOf, attachments: state.attachments.slice() }
: null;
const restoreReply = open && state.replying ? state.replying : null;
@@ -1832,6 +1935,7 @@ async function deleteDraft(draft) {
if (state.composingNew) {
state.composingNew = false;
state.activeDraftId = null;
+ state.forwardOf = null;
clearAttachments();
} else if (state.replying) {
state.replying = null;
@@ -1848,6 +1952,7 @@ async function deleteDraft(draft) {
if (restoreCompose) {
state.composingNew = true;
state.activeDraftId = restoreCompose.activeDraftId;
+ state.forwardOf = restoreCompose.forwardOf;
state.attachments = restoreCompose.attachments;
renderAttachmentPreview();
} else if (restoreReply) {
@@ -2039,7 +2144,7 @@ function draftScope() {
function currentDraft() {
const scope = draftScope();
- return {
+ const draft = {
scope: scope?.scope || 'new',
id: scope?.id || state.activeDraftId || null,
threadId: scope?.threadId || null,
@@ -2049,6 +2154,8 @@ function currentDraft() {
body: getEditorMarkdown(),
updated_at: new Date().toISOString(),
};
+ if (state.composingNew && state.forwardOf) draft.forwardOf = { ...state.forwardOf };
+ return draft;
}
function updateSendEnabled() {
@@ -2478,6 +2585,7 @@ async function sendEmail() {
subject: draft.subject,
body: draft.body,
attachments: readyAttachments(),
+ forwardOf: draft.forwardOf,
}),
});
if (!res.ok) throw new Error(`send failed (${res.status})`);
@@ -2504,6 +2612,7 @@ async function sendEmail() {
replaceThread(thread);
state.composingNew = false;
state.activeDraftId = null;
+ state.forwardOf = null;
state.replying = null;
state.view = 'thread';
state.activeThreadId = thread.id;
@@ -2587,7 +2696,7 @@ async function runCharacterReply(character, sentEmail, threadId) {
}),
});
const chat = new OctavusChat({ transport });
- const sentBody = sentEmail?.body || '';
+ const sentBody = bodyWithForwarded(sentEmail, { formatDate });
try {
await chat.send(
'character-reply',
@@ -2624,7 +2733,7 @@ function serializeThreadContext(threadId = state.activeThreadId) {
lines.push(`Attachments: ${email.attachments.map((a) => a.name || 'file').join(', ')}`);
}
lines.push('');
- lines.push(String(email.body ?? ''));
+ lines.push(bodyWithForwarded(email, { formatDate }));
lines.push('\n---\n');
}
return lines.join('\n');
@@ -2640,6 +2749,7 @@ function assistantMailboxContext() {
viewing: {
threadId: state.view === 'thread' ? state.activeThreadId : null,
composingNew: state.composingNew,
+ forwarding: state.composingNew ? state.forwardOf : null,
mailbox: state.activeMailbox,
},
previousHash: state.assistant.lastContextHash,
@@ -3391,6 +3501,7 @@ async function runQuickAction(action, detail = '') {
viewing: {
threadId: sourceThreadId,
composingNew: state.composingNew,
+ forwarding: state.composingNew ? state.forwardOf : null,
mailbox: state.activeMailbox,
},
previousHash: null, // always send full mailbox for one-shot sessions
@@ -3577,11 +3688,13 @@ function composerIsOpen() {
}
// Confirm before overwrite only when the open composer has user content.
-// Reply mode pre-fills To/Subject from the thread, so those alone are not dirty.
+// Reply mode pre-fills To/Subject from the thread, and Forward pre-fills the
+// subject, so those alone are not dirty.
function composerIsDirty() {
if (!composerIsOpen()) return false;
const draft = currentDraft();
if (draft.body.trim()) return true;
+ if (state.composingNew && state.forwardOf) return Boolean(draft.to.length || draft.cc.length);
if (state.composingNew) {
return Boolean(draft.subject.trim() || draft.to.length || draft.cc.length);
}
@@ -4008,6 +4121,7 @@ export function mailAppTestHooks() {
handleComposerFiles,
scrollToEmail,
startReply,
+ startForward,
startCompose,
closeComposer,
cancelComposer,
diff --git a/public/index.html b/public/index.html
index 19f0db3..8246dc7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -147,6 +147,7 @@
New message
Write your message...
diff --git a/server.js b/server.js
index 6744b3f..1ed10bc 100644
--- a/server.js
+++ b/server.js
@@ -17,6 +17,7 @@ import {
} from './lib/character-replies.js';
import { buildCapabilityInstructions } from './lib/assistant.js';
import { removeScopedDraft } from './lib/drafts.js';
+import { findThreadEmail, forwardedSnapshot } from './lib/forwarding.js';
import {
appendSessionEvents,
makeDraftProposedEvent,
@@ -629,10 +630,13 @@ function publicResponder(character) {
// POST /api/email/send — simulate sending: append the learner's email to the
// thread (creating one for compose-new), clear the draft, and list who will
-// write back.
+// write back. `forwardOf: { threadId, emailId }` attaches a snapshot of that
+// email, read from the session rather than trusted from the client.
app.post('/api/email/send', async (req, res) => {
- const { sessionId, threadId, draftId, to, cc, subject, body, attachments } = req.body;
+ const { sessionId, draftId, to, cc, subject, body, attachments, forwardOf } = req.body;
if (!sessionId) return res.status(400).json({ error: 'sessionId is required' });
+ // A forward always starts its own thread.
+ const threadId = forwardOf ? null : req.body.threadId;
try {
const { config } = await getScenario();
const characters = config.characters ?? [];
@@ -648,7 +652,17 @@ app.post('/api/email/send', async (req, res) => {
let email;
let thread;
+ let forwardMissing = false;
const record = await updateSessionById(sessionId, (fresh) => {
+ let forwarded = null;
+ if (forwardOf) {
+ const source = findThreadEmail(fresh.threads, forwardOf);
+ if (!source) {
+ forwardMissing = true;
+ return;
+ }
+ forwarded = forwardedSnapshot(source.email, { subjectFallback: source.thread.subject });
+ }
thread = fresh.threads.find((t) => t.id === threadId);
if (!thread) {
thread = { id: `thread-${randomUUID()}`, subject: subject || '(no subject)', emails: [] };
@@ -669,6 +683,7 @@ app.post('/api/email/send', async (req, res) => {
outbound: true,
attachments: Array.isArray(attachments) ? attachments : [],
};
+ if (forwarded) email.forwarded = forwarded;
thread.emails.push(email);
// Drop only the draft that was sent; keep other scoped drafts.
fresh.drafts = removeScopedDraft(
@@ -677,6 +692,7 @@ app.post('/api/email/send', async (req, res) => {
);
});
if (!record) return res.status(404).json({ error: 'Session not found' });
+ if (forwardMissing) return res.status(400).json({ error: 'Forwarded email not found' });
const responders = selectResponders(email, characters, { doneIds: doneCharacterIds(record) }).map(
publicResponder,
diff --git a/tests/a11y-mail.test.js b/tests/a11y-mail.test.js
index b7c38fe..5f1ddb9 100644
--- a/tests/a11y-mail.test.js
+++ b/tests/a11y-mail.test.js
@@ -156,6 +156,7 @@ async function resetMailbox() {
state.activeThreadId = null;
state.view = 'list';
state.composingNew = false;
+ state.forwardOf = null;
state.replying = null;
state.selectedEmailId = null;
state.replyToEmailId = null;
@@ -2026,3 +2027,147 @@ describe('delete drafts', () => {
vi.unstubAllGlobals();
});
});
+
+describe('Forward', () => {
+ const FORWARD_OF = { threadId: 'thread-quote', emailId: 'email-quote' };
+
+ function forwardButton() {
+ return document.querySelector('[data-email-id="email-quote"] .email__action[data-reply-mode="forward"]');
+ }
+
+ async function settle() {
+ for (let i = 0; i < 5; i += 1) await Promise.resolve();
+ await nextFrame();
+ }
+
+ it('names the message action group and its Forward button', () => {
+ clickThread('thread-quote');
+ const group = document.querySelector('[data-email-id="email-quote"] .email__actions');
+ expect(computeAccessibleName(group)).toBe(strings['Message actions']);
+ expect(computeAccessibleName(forwardButton())).toBe(strings.Forward);
+ expect(document.querySelector('.thread-actions [data-reply-mode="forward"]').textContent).toBe(strings.Forward);
+ });
+
+ it('opens a new message with the subject and a preview of the original', async () => {
+ clickThread('thread-quote');
+ forwardButton().focus();
+ forwardButton().click();
+ await Promise.resolve();
+
+ const { state } = mail;
+ expect(state.composingNew).toBe(true);
+ expect(state.forwardOf).toEqual(FORWARD_OF);
+ expect(state.recipients.to).toEqual([]);
+ expect(document.getElementById('composeSubject').value).toBe('Fwd: Vendor quote');
+ expect(document.getElementById('composerTitle').textContent).toBe(strings.Forward);
+ expect(document.activeElement).toBe(document.getElementById('composeToInput'));
+
+ const preview = document.getElementById('composerForwarded');
+ expect(preview.hidden).toBe(false);
+ expect(computeAccessibleName(preview)).toBe(strings['Forwarded message']);
+ expect(preview.textContent).toContain(QUOTE_BODY);
+ expect(preview.textContent).toContain('Dana Reyes
');
+ });
+
+ it('forwards the thread tip from the footer button', async () => {
+ clickThread('thread-quote');
+ document.querySelector('.thread-actions [data-reply-mode="forward"]').click();
+ await Promise.resolve();
+ expect(mail.state.forwardOf).toEqual(FORWARD_OF);
+ });
+
+ it('keeps the forward on its draft and drops it for a plain new message', async () => {
+ clickThread('thread-quote');
+ await mail.startForward('email-quote');
+ expect(mail.state.session.drafts).toEqual([]);
+ mail.state.session.sessionId = 'session-1';
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({}) })));
+ await mail.saveDraftNow();
+ expect(mail.state.session.drafts).toEqual([
+ expect.objectContaining({ scope: 'new', subject: 'Fwd: Vendor quote', forwardOf: FORWARD_OF }),
+ ]);
+
+ mail.closeComposer();
+ await settle();
+ expect(mail.state.composingNew).toBe(false);
+ mail.startCompose({ blank: true });
+ expect(mail.state.forwardOf).toBeNull();
+ expect(document.getElementById('composerForwarded').hidden).toBe(true);
+
+ mail.startCompose({ draft: mail.state.session.drafts[0] });
+ expect(mail.state.forwardOf).toEqual(FORWARD_OF);
+ expect(document.getElementById('composerForwarded').hidden).toBe(false);
+ });
+
+ it('sends forwardOf and renders the original under the cover note', async () => {
+ clickThread('thread-quote');
+ await mail.startForward('email-quote');
+ mail.state.session.sessionId = 'session-1';
+ mail.state.recipients.to = [SAM];
+ document.getElementById('sendBtn').disabled = false;
+
+ const sent = {
+ id: 'email-fwd',
+ from: { name: 'Alex', email: LEARNER },
+ to: [{ name: 'Sam Okonkwo', email: SAM }],
+ date: '2026-10-06T15:00:00.000Z',
+ subject: 'Fwd: Vendor quote',
+ body: '',
+ outbound: true,
+ forwarded: {
+ emailId: 'email-quote',
+ from: { name: 'Dana Reyes', email: DANA },
+ to: [{ name: 'Alex', email: LEARNER }],
+ cc: [],
+ date: '2026-10-05T15:00:00.000Z',
+ subject: 'Vendor quote',
+ body: QUOTE_BODY,
+ attachments: [{ name: 'quote.txt', text: 'Total: $1,200' }],
+ },
+ };
+ let payload;
+ vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
+ if (String(url).includes('/api/email/send')) {
+ payload = JSON.parse(opts.body);
+ return {
+ ok: true,
+ json: async () => ({
+ email: sent,
+ thread: { id: 'thread-fwd', subject: 'Fwd: Vendor quote', emails: [sent] },
+ responders: [],
+ }),
+ };
+ }
+ return { ok: true, json: async () => ({}) };
+ }));
+
+ await mail.sendEmail();
+
+ expect(payload.forwardOf).toEqual(FORWARD_OF);
+ expect(payload.threadId).toBeNull();
+ expect(mail.state.forwardOf).toBeNull();
+ expect(mail.state.activeThreadId).toBe('thread-fwd');
+ const block = document.querySelector('[data-email-id="email-fwd"] .email__forwarded');
+ expect(computeAccessibleName(block)).toBe(strings['Forwarded message']);
+ expect(block.textContent).toContain(QUOTE_BODY);
+ expect(computeAccessibleName(block.querySelector('.email__attachment--preview')))
+ .toBe(`${strings['Preview attachment']}: quote.txt`);
+ });
+
+ it('returns focus to the per-message Forward button after Close', async () => {
+ clickThread('thread-quote');
+ forwardButton().focus();
+ forwardButton().click();
+ await Promise.resolve();
+
+ mail.closeComposer();
+ await settle();
+
+ expect(document.getElementById('composer').hidden).toBe(true);
+ expect(document.activeElement).toBe(forwardButton());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/tests/api.test.js b/tests/api.test.js
index 739abb1..888c97d 100644
--- a/tests/api.test.js
+++ b/tests/api.test.js
@@ -167,6 +167,57 @@ describe('session lifecycle', () => {
]);
});
+ it('POST /api/email/send forwards a session email into a new thread', async () => {
+ const before = await request(app).get('/api/session').query({ id: sessionId });
+ const source = before.body.threads.find((thread) => thread.id === 'thread-1');
+ const original = source.emails[0];
+ await request(app).post('/api/session/save').send({
+ sessionId,
+ drafts: [
+ { scope: 'new', id: 'draft-fwd', to: ['dana@acme-vendor.com'], cc: [], subject: 'Fwd: x', body: 'FYI', forwardOf: { threadId: 'thread-1', emailId: original.id } },
+ ],
+ });
+
+ const res = await request(app).post('/api/email/send').send({
+ sessionId,
+ // A forward ignores the thread it came from.
+ threadId: 'thread-1',
+ draftId: 'draft-fwd',
+ to: ['dana@acme-vendor.com'],
+ subject: 'Fwd: x',
+ body: 'FYI',
+ forwardOf: { threadId: 'thread-1', emailId: original.id },
+ });
+ expect(res.status).toBe(200);
+ expect(res.body.thread.id).not.toBe('thread-1');
+ expect(res.body.thread.subject).toBe('Fwd: x');
+ expect(res.body.email.body).toBe('FYI');
+ expect(res.body.email.forwarded).toEqual(expect.objectContaining({
+ emailId: original.id,
+ from: original.from,
+ body: original.body,
+ subject: original.subject || source.subject,
+ }));
+
+ const reload = await request(app).get('/api/session').query({ id: sessionId });
+ expect(reload.body.threads.find((thread) => thread.id === 'thread-1').emails).toHaveLength(source.emails.length);
+ expect(reload.body.drafts.some((draft) => draft.id === 'draft-fwd')).toBe(false);
+ });
+
+ it('POST /api/email/send rejects a forward of an unknown email', async () => {
+ const before = await request(app).get('/api/session').query({ id: sessionId });
+ const res = await request(app).post('/api/email/send').send({
+ sessionId,
+ to: ['dana@acme-vendor.com'],
+ subject: 'Fwd: ghost',
+ body: 'FYI',
+ forwardOf: { threadId: 'thread-1', emailId: 'email-missing' },
+ });
+ expect(res.status).toBe(400);
+ const reload = await request(app).get('/api/session').query({ id: sessionId });
+ expect(reload.body.threads).toHaveLength(before.body.threads.length);
+ });
+
it('POST /api/session/save persists drafts and assistant messages', async () => {
const save = await request(app)
.post('/api/session/save')
diff --git a/tests/extract-conversations.test.js b/tests/extract-conversations.test.js
index b77a24b..effa370 100644
--- a/tests/extract-conversations.test.js
+++ b/tests/extract-conversations.test.js
@@ -143,4 +143,77 @@ describe('extract-conversations provenance', () => {
expect(out).toContain('**Quick action** `suggested_replies`');
expect(out).toContain('Happy to discuss pricing next week.');
});
+
+ it('quotes a forwarded original under the cover note in thread mode', () => {
+ writeFileSync(
+ sessionsFile,
+ JSON.stringify({
+ sessions: [
+ {
+ session_id: 's1',
+ updated_at: '2026-01-02T00:00:00.000Z',
+ created_at: '2026-01-01T00:00:00.000Z',
+ threads: [
+ {
+ id: 'thread-fwd',
+ subject: 'Fwd: Proposal',
+ emails: [
+ {
+ id: 'email-fwd',
+ from: { name: 'You', email: 'you@example.com' },
+ to: [{ name: 'Dana', email: 'dana@acme-vendor.com' }],
+ date: '2026-01-02T00:00:00.000Z',
+ subject: 'Fwd: Proposal',
+ body: 'Dana, see below.',
+ outbound: true,
+ forwarded: {
+ emailId: 'email-1',
+ from: { name: 'Sam', email: 'sam@example.com' },
+ to: [{ name: 'You', email: 'you@example.com' }],
+ cc: [],
+ date: '2026-01-01T00:00:00.000Z',
+ subject: 'Proposal',
+ body: 'Original proposal text.\n\nSecond paragraph.',
+ attachments: [{ name: 'terms.txt' }],
+ forwarded: {
+ from: { name: 'Lee', email: 'lee@example.com' },
+ to: [{ name: 'Sam', email: 'sam@example.com' }],
+ date: '2025-12-31T00:00:00.000Z',
+ body: 'Nested line one.\nNested line two.',
+ },
+ },
+ },
+ ],
+ },
+ ],
+ assistant_messages: [],
+ events: [],
+ },
+ ],
+ }),
+ );
+
+ const result = spawnSync(
+ process.execPath,
+ [SCRIPT, '--mode', 'thread', '--output', outputFile],
+ {
+ cwd: ROOT,
+ env: { ...process.env, SESSIONS_FILE: sessionsFile, SCENARIO_FILE: scenarioFile },
+ encoding: 'utf8',
+ },
+ );
+
+ expect(result.status).toBe(0);
+ const out = readFileSync(outputFile, 'utf8');
+ expect(out).toContain('Dana, see below.');
+ expect(out).toContain('> **From:** Sam ');
+ expect(out).toMatch(/> \*\*Date:\*\* .* · _Forwarded_ {2}/);
+ expect(out).toContain('> **Attachments:** terms.txt ');
+ expect(out).toContain('> Original proposal text.\n>\n> Second paragraph.');
+ expect(out).toContain('> > Nested line one.\n> > Nested line two.');
+ const lines = out.split('\n');
+ const start = lines.findIndex((line) => line.startsWith('> **From:** Sam'));
+ const end = lines.findIndex((line) => line === '> > Nested line two.');
+ expect(lines.slice(start, end + 1).every((line) => line.startsWith('>'))).toBe(true);
+ });
});
diff --git a/tests/forwarding.test.js b/tests/forwarding.test.js
new file mode 100644
index 0000000..a5cd26e
--- /dev/null
+++ b/tests/forwarding.test.js
@@ -0,0 +1,143 @@
+import { describe, expect, it } from 'vitest';
+import {
+ FORWARDED_DIVIDER,
+ bodyWithForwarded,
+ findThreadEmail,
+ forwardedLines,
+ forwardedSnapshot,
+ forwardSubject,
+} from '../lib/forwarding.js';
+import { serializeMailbox, buildMailboxContext } from '../lib/assistant.js';
+
+const DANA = { name: 'Dana Reyes', email: 'dana@acme.test' };
+const ALEX = { name: 'Alex', email: 'alex@northwind.test' };
+
+const quote = {
+ id: 'email-quote',
+ from: DANA,
+ to: [ALEX],
+ date: '2026-10-05T15:00:00.000Z',
+ subject: 'Vendor quote',
+ body: 'Please review the Friday shipment.',
+ attachments: [{ name: 'quote.txt', text: 'Total: $1,200' }],
+};
+
+const threads = [{ id: 'thread-quote', subject: 'Vendor quote', emails: [quote] }];
+
+describe('forwardSubject', () => {
+ it('prefixes Fwd: once', () => {
+ expect(forwardSubject('Vendor quote')).toBe('Fwd: Vendor quote');
+ expect(forwardSubject('Fwd: Vendor quote')).toBe('Fwd: Vendor quote');
+ expect(forwardSubject('FW: Vendor quote')).toBe('FW: Vendor quote');
+ expect(forwardSubject('Re: Vendor quote')).toBe('Fwd: Re: Vendor quote');
+ expect(forwardSubject('')).toBe('');
+ });
+});
+
+describe('findThreadEmail', () => {
+ it('finds an email by thread and id', () => {
+ expect(findThreadEmail(threads, { threadId: 'thread-quote', emailId: 'email-quote' })?.email).toBe(quote);
+ });
+
+ it('returns null for a missing or partial reference', () => {
+ expect(findThreadEmail(threads, { threadId: 'thread-quote', emailId: 'nope' })).toBeNull();
+ expect(findThreadEmail(threads, { threadId: 'nope', emailId: 'email-quote' })).toBeNull();
+ expect(findThreadEmail(threads, { emailId: 'email-quote' })).toBeNull();
+ expect(findThreadEmail(threads, undefined)).toBeNull();
+ });
+});
+
+describe('forwardedSnapshot', () => {
+ it('copies the headers, body, and attachments', () => {
+ expect(forwardedSnapshot(quote)).toEqual({
+ emailId: 'email-quote',
+ from: DANA,
+ to: [ALEX],
+ cc: [],
+ date: '2026-10-05T15:00:00.000Z',
+ subject: 'Vendor quote',
+ body: 'Please review the Friday shipment.',
+ attachments: [{ name: 'quote.txt', text: 'Total: $1,200' }],
+ });
+ });
+
+ it('falls back to the thread subject and keeps a nested forward', () => {
+ const inner = forwardedSnapshot(quote);
+ const snapshot = forwardedSnapshot(
+ { id: 'email-fwd', from: ALEX, to: [DANA], body: 'FYI', forwarded: inner },
+ { subjectFallback: 'Fwd: Vendor quote' },
+ );
+ expect(snapshot.subject).toBe('Fwd: Vendor quote');
+ expect(snapshot.forwarded).toBe(inner);
+ });
+});
+
+describe('forwardedLines / bodyWithForwarded', () => {
+ it('quotes the original under the cover note', () => {
+ const email = { body: 'Sam, can you take this?', forwarded: forwardedSnapshot(quote) };
+ expect(bodyWithForwarded(email)).toBe([
+ 'Sam, can you take this?',
+ '',
+ FORWARDED_DIVIDER,
+ 'From: Dana Reyes ',
+ 'Date: 2026-10-05T15:00:00.000Z',
+ 'Subject: Vendor quote',
+ 'To: Alex ',
+ '',
+ 'Please review the Friday shipment.',
+ '',
+ 'Attachments: quote.txt',
+ ].join('\n'));
+ });
+
+ it('returns the plain body when nothing was forwarded', () => {
+ expect(bodyWithForwarded({ body: 'Hello' })).toBe('Hello');
+ expect(forwardedLines(null)).toEqual([]);
+ });
+
+ it('includes nested forwards', () => {
+ const inner = forwardedSnapshot(quote);
+ const outer = forwardedSnapshot({ id: 'e2', from: ALEX, to: [DANA], subject: 'Fwd: Vendor quote', body: 'FYI', forwarded: inner });
+ const text = forwardedLines(outer).join('\n');
+ expect(text.split(FORWARDED_DIVIDER)).toHaveLength(3);
+ expect(text).toContain('FYI');
+ expect(text).toContain('Please review the Friday shipment.');
+ });
+});
+
+describe('Cosmo mailbox context', () => {
+ it('includes the forwarded original and its seeded attachment text', () => {
+ const sent = {
+ id: 'email-fwd',
+ from: ALEX,
+ to: [{ name: 'Sam', email: 'sam@acme.test' }],
+ outbound: true,
+ subject: 'Fwd: Vendor quote',
+ body: 'Sam, can you take this?',
+ forwarded: forwardedSnapshot(quote),
+ };
+ const text = serializeMailbox(
+ [...threads, { id: 'thread-fwd', subject: 'Fwd: Vendor quote', emails: [sent] }],
+ { learnerEmail: ALEX.email },
+ );
+ expect(text).toContain(FORWARDED_DIVIDER);
+ expect(text).toContain('Date: 2026-10-05 15:00 UTC');
+ expect(text).toContain('Total: $1,200');
+ });
+
+ it('says when the composer holds a forward', () => {
+ const { text } = buildMailboxContext({
+ threads,
+ learnerEmail: ALEX.email,
+ viewing: { composingNew: true, forwarding: { threadId: 'thread-quote', emailId: 'email-quote' } },
+ });
+ expect(text).toMatch(/^Currently viewing: a forward in the composer of email email-quote from thread "Vendor quote"/);
+ });
+
+ it('labels a saved forward draft', () => {
+ const text = serializeMailbox(threads, {
+ drafts: [{ scope: 'new', id: 'd1', subject: 'Fwd: Vendor quote', body: 'Note', forwardOf: { threadId: 'thread-quote', emailId: 'email-quote' } }],
+ });
+ expect(text).toContain('(forward of email email-quote in thread thread-quote)');
+ });
+});