diff --git a/extract-conversations.js b/extract-conversations.js index 03fdb60..3ff30e1 100644 --- a/extract-conversations.js +++ b/extract-conversations.js @@ -209,8 +209,8 @@ function overviewLines() { return lines; } -function emailLines(email) { - const dir = email.outbound ? 'Outbound (participant)' : 'Inbound'; +function emailLines(email, { forwarded = false } = {}) { + const dir = forwarded ? 'Forwarded' : email.outbound ? 'Outbound (participant)' : 'Inbound'; const lines = [ `**From:** ${addr(email.from)} `, `**To:** ${addrList(email.to)} `, @@ -224,6 +224,10 @@ function emailLines(email) { lines.push(''); lines.push(String(email.body ?? '')); lines.push(''); + if (email.forwarded) { + const quoted = emailLines(email.forwarded, { forwarded: true }).flatMap((entry) => entry.split('\n')); + lines.push(...quoted.map((line) => (line ? `> ${line}` : '>')), ''); + } return lines; } diff --git a/i18n/en.json b/i18n/en.json index ca3496f..da334d5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -82,7 +82,11 @@ "(no subject)": "(no subject)", "No recipients": "No recipients", "New messages": "New messages", - "Reply options": "Reply options", + "Message actions": "Message actions", + "Forward": "Forward", + "Forwarded message": "Forwarded message", + "From": "From", + "Date": "Date", "Thought for {seconds}s": "Thought for {seconds}s", "{current} of {total}": "{current} of {total}", "Previous suggestion": "Previous suggestion", diff --git a/i18n/es.json b/i18n/es.json index fc85368..0ad2663 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -83,7 +83,11 @@ "(no subject)": "(sin asunto)", "No recipients": "Sin destinatarios", "New messages": "Mensajes nuevos", - "Reply options": "Opciones de respuesta", + "Message actions": "Acciones del mensaje", + "Forward": "Reenviar", + "Forwarded message": "Mensaje reenviado", + "From": "De", + "Date": "Fecha", "Thought for {seconds}s": "Pensó durante {seconds}s", "{current} of {total}": "{current} de {total}", "Previous suggestion": "Sugerencia anterior", diff --git a/lib/assistant.js b/lib/assistant.js index 2310074..c8df18b 100644 --- a/lib/assistant.js +++ b/lib/assistant.js @@ -1,5 +1,6 @@ import { mailboxForThread, emailIsOutbound } from './mailboxes.js'; import { visibleDrafts } from './drafts.js'; +import { bodyWithForwarded } from './forwarding.js'; /** * Cosmo (assistant) helpers shared by the server and the browser bundle. @@ -220,7 +221,11 @@ function serializeThread(thread, index, learnerEmail) { // Inbound seeds only: learner outbound attachments stay name-only. lines.push(...serializeAttachments(email.attachments, { includeText: !fromLearner })); } - lines.push('', String(email.body ?? ''), '', '---', ''); + const body = bodyWithForwarded(email, { + formatDate, + attachmentLines: (attachments) => serializeAttachments(attachments), + }); + lines.push('', body, '', '---', ''); } return lines.join('\n'); } @@ -231,7 +236,11 @@ function formatDraftAddresses(list) { } function serializeDraft(draft, index) { - const kind = draft.scope === 'reply' ? `reply to thread ${draft.threadId}` : 'new message'; + const kind = draft.scope === 'reply' + ? `reply to thread ${draft.threadId}` + : draft.forwardOf + ? `forward of email ${draft.forwardOf.emailId} in thread ${draft.forwardOf.threadId}` + : 'new message'; const lines = [ `#### Draft ${index + 1}: ${draft.subject || '(no subject)'} [Drafts] (${kind})`, '', @@ -257,6 +266,12 @@ export function serializeMailbox(threads, { learnerEmail = '', drafts = [] } = { } function describeViewing(threads, viewing = {}) { + const forward = viewing.forwarding; + if (viewing.composingNew && forward) { + const thread = (threads ?? []).find((th) => th.id === forward.threadId); + const subject = thread?.subject || '(no subject)'; + return `a forward in the composer of email ${forward.emailId} from thread "${subject}" (id: ${forward.threadId}). The draft is only the cover note; the original email is included below it when sent.`; + } if (viewing.composingNew) return 'a new message in the composer'; if (viewing.threadId) { const thread = (threads ?? []).find((th) => th.id === viewing.threadId); diff --git a/lib/forwarding.js b/lib/forwarding.js new file mode 100644 index 0000000..9f7ec38 --- /dev/null +++ b/lib/forwarding.js @@ -0,0 +1,87 @@ +/** + * Forwarding, shared by the server and the browser bundle. + * + * A forward keeps the original message as a structured snapshot on the + * outbound email (`email.forwarded`) instead of pasting it into the body. The + * learner's cover note stays the editable body, and the reading pane, + * characters, Cosmo, and the session export all show the same original. + * + * A forward always starts a new thread, so a recipient only sees what was + * forwarded, never the rest of the source conversation. + */ + +export const FORWARDED_DIVIDER = '---------- Forwarded message ----------'; + +export function forwardSubject(subject) { + const subj = String(subject || '').trim(); + if (!subj) return ''; + return /^fwd?:/i.test(subj) ? subj : `Fwd: ${subj}`; +} + +export function findThreadEmail(threads, { threadId, emailId } = {}) { + if (!threadId || !emailId) return null; + const thread = (threads ?? []).find((th) => th.id === threadId); + const email = thread?.emails?.find((item) => item.id === emailId); + return email ? { thread, email } : null; +} + +/** The copy of `email` that travels with a forward (nested forwards included). */ +export function forwardedSnapshot(email, { subjectFallback = '' } = {}) { + if (!email) return null; + const snapshot = { + emailId: email.id ?? null, + from: email.from ?? null, + to: Array.isArray(email.to) ? email.to : (email.to ? [email.to] : []), + cc: Array.isArray(email.cc) ? email.cc : [], + date: email.date ?? null, + subject: email.subject || subjectFallback || '', + body: String(email.body ?? ''), + attachments: Array.isArray(email.attachments) ? email.attachments : [], + }; + if (email.forwarded) snapshot.forwarded = email.forwarded; + return snapshot; +} + +function formatAddress(addr) { + if (!addr) return ''; + if (typeof addr === 'string') return addr; + return addr.name ? `${addr.name} <${addr.email}>` : addr.email || ''; +} + +function formatAddressList(list) { + if (!list) return ''; + return (Array.isArray(list) ? list : [list]).map(formatAddress).filter(Boolean).join(', '); +} + +function attachmentNames(attachments) { + if (!attachments?.length) return []; + return [`Attachments: ${attachments.map((a) => a?.name || 'file').join(', ')}`]; +} + +/** + * Plain-text lines for a forwarded block, as a mail client would quote it. + * `attachmentLines` lets Cosmo's context include seeded attachment text. + */ +export function forwardedLines(forwarded, { + formatDate = (value) => String(value ?? ''), + attachmentLines = attachmentNames, +} = {}) { + if (!forwarded) return []; + const lines = ['', FORWARDED_DIVIDER, `From: ${formatAddress(forwarded.from)}`]; + if (forwarded.date) lines.push(`Date: ${formatDate(forwarded.date)}`); + if (forwarded.subject) lines.push(`Subject: ${forwarded.subject}`); + lines.push(`To: ${formatAddressList(forwarded.to)}`); + if (forwarded.cc?.length) lines.push(`Cc: ${formatAddressList(forwarded.cc)}`); + lines.push('', String(forwarded.body ?? '')); + const attachments = attachmentLines(forwarded.attachments ?? []); + if (attachments.length) lines.push('', ...attachments); + lines.push(...forwardedLines(forwarded.forwarded, { formatDate, attachmentLines })); + return lines; +} + +/** The email body followed by any forwarded message, as plain text. */ +export function bodyWithForwarded(email, options) { + const body = String(email?.body ?? ''); + if (!email?.forwarded) return body; + return [body, ...forwardedLines(email.forwarded, options)].join('\n'); +} diff --git a/public/app.css b/public/app.css index 8115ff7..b4e014c 100644 --- a/public/app.css +++ b/public/app.css @@ -1527,6 +1527,52 @@ body { border-top: 1px solid var(--Colors-Border-Subtle); } +/* The original a forward carries, quoted under the cover note. */ +.email__forwarded { + display: flex; + flex-direction: column; + gap: var(--UI-Spacing-spacing-s); + margin-top: var(--UI-Spacing-spacing-ms); + padding-left: var(--UI-Spacing-spacing-m); + border-left: 3px solid var(--Colors-Border-Subtle); +} + +.email__forwarded-label { + margin: 0; + color: var(--Colors-Text-Body-Medium); + font-size: var(--Fonts-Body-Default-xs); + font-weight: 600; +} + +.email__forwarded-meta { + display: flex; + flex-direction: column; + gap: var(--UI-Spacing-spacing-xxs); +} + +.email__forwarded .email__attachments { + margin-top: 0; +} + +.composer__forwarded { + max-height: 160px; + overflow-y: auto; + padding: var(--UI-Spacing-spacing-mxs); + border: 1.5px solid var(--Colors-Border-Subtle); + border-radius: var(--UI-Radius-radius-s); + scrollbar-width: thin; + scrollbar-color: var(--Colors-Control-Neutral-Strong) transparent; +} + +.composer__forwarded:focus-visible { + outline: 2px solid var(--Colors-Stroke-Primary); + outline-offset: 0; +} + +.composer__forwarded > .email__forwarded { + margin-top: 0; +} + /* ── Assistant panel (Figma "Assistant Sidebar") ── */ .assistant { display: flex; diff --git a/public/app.js b/public/app.js index 231f6c6..7a1e473 100644 --- a/public/app.js +++ b/public/app.js @@ -36,6 +36,7 @@ import { } from '../lib/characters.js'; import { buildMailboxContext } from '../lib/assistant.js'; import { draftForScope, draftHasPersistableContent, parseInsertedDraft, removeScopedDraft, sameDraftScope, upsertScopedDraft, visibleDrafts } from '../lib/drafts.js'; +import { bodyWithForwarded, findThreadEmail, forwardedSnapshot, forwardSubject } from '../lib/forwarding.js'; import { appendSessionEvents, draftFieldsToMarkdown, @@ -117,6 +118,7 @@ const state = { view: 'list', // list | thread composingNew: false, activeDraftId: null, + forwardOf: null, // { threadId, emailId } when the new message is a forward replying: null, // null | 'reply' | 'replyAll' selectedEmailId: null, // highlighted message in a multi-message thread replyToEmailId: null, // email a per-message reply button targeted @@ -212,6 +214,7 @@ const els = { attachBtn: document.getElementById('attachBtn'), fileInput: document.getElementById('fileInput'), attachmentPreview: document.getElementById('attachmentPreview'), + composerForwarded: document.getElementById('composerForwarded'), assistantPanel: document.getElementById('assistantPanel'), assistantContent: document.getElementById('assistantContent'), assistantMessages: document.getElementById('assistantMessages'), @@ -791,6 +794,8 @@ const MAILBOX_ICONS = { // weight can be set: 1.25px. const EMAIL_REPLY_ICON = ''; const EMAIL_REPLY_ALL_ICON = ''; +// The reply arrow, mirrored. +const EMAIL_FORWARD_ICON = ''; // Figma untitled-ui mail-01 with lines (thread header). const THREAD_ICON = ''; @@ -813,10 +818,18 @@ function renderShell() { const COMPOSER_REPLY_ICON = ''; const COMPOSER_NEW_ICON = ''; +const COMPOSER_FORWARD_ICON = COMPOSER_REPLY_ICON.replace('${escapeHtml(t('Cc'))}: ${escapeHtml(recipientNames(email.cc))}` : ''; const attachments = Array.isArray(email.attachments) ? email.attachments : []; - const attachmentsHtml = attachments.length - ? `
${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 ``; - }) - .join('')}
` - : ''; wrap.innerHTML = `
${avatarMarkup(sender, 'lg')} @@ -1439,32 +1441,84 @@ function renderEmail(email, learnerEmail, { showSubject = true, subject = '' } =
- `; + 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 + ? `` + : ''); + const fromName = displayName(forwarded.from); + const fromEmail = addressEmail(forwarded.from); + const from = fromName && fromEmail && fromName !== fromEmail ? `${fromName} <${fromEmail}>` : (fromName || fromEmail); + block.innerHTML = ` + + + + `; + 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)'); + }); +});