From de0ce07640268fb6e59529b4124965468aebaf4d Mon Sep 17 00:00:00 2001 From: Brian Genisio Date: Tue, 13 Oct 2026 06:44:11 -0400 Subject: [PATCH 1/2] Add Forward to messages and threads. Co-authored-by: Cursor --- extract-conversations.js | 8 +- i18n/en.json | 6 +- i18n/es.json | 6 +- lib/assistant.js | 19 ++- lib/forwarding.js | 87 ++++++++++++++ public/app.css | 46 ++++++++ public/app.js | 176 +++++++++++++++++++++++----- public/index.html | 1 + server.js | 20 +++- tests/a11y-mail.test.js | 145 +++++++++++++++++++++++ tests/api.test.js | 51 ++++++++ tests/extract-conversations.test.js | 62 ++++++++++ tests/forwarding.test.js | 143 ++++++++++++++++++++++ 13 files changed, 731 insertions(+), 39 deletions(-) create mode 100644 lib/forwarding.js create mode 100644 tests/forwarding.test.js diff --git a/extract-conversations.js b/extract-conversations.js index 03fdb60..0d0a2b9 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 }); + 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('