Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions extract-conversations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)} `,
Expand All @@ -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;
}

Expand Down
6 changes: 5 additions & 1 deletion i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 17 additions & 2 deletions lib/assistant.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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');
}
Expand All @@ -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})`,
'',
Expand All @@ -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);
Expand Down
87 changes: 87 additions & 0 deletions lib/forwarding.js
Original file line number Diff line number Diff line change
@@ -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');
}
46 changes: 46 additions & 0 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading