diff --git a/apps/email/client/components/mail/reply-composer.tsx b/apps/email/client/components/mail/reply-composer.tsx index b24738b36..bf7469950 100644 --- a/apps/email/client/components/mail/reply-composer.tsx +++ b/apps/email/client/components/mail/reply-composer.tsx @@ -198,6 +198,7 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { threadId: replyToMessage?.threadId, isForward: mode === 'forward', originalMessage: replyToMessage.decodedBody, + originalMessageId: replyToMessage?.id, scheduleAt: data.scheduleAt, }); @@ -273,7 +274,12 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { initialTo={ensureEmailArray(draft?.to)} initialCc={ensureEmailArray(draft?.cc)} initialBcc={ensureEmailArray(draft?.bcc)} - initialSubject={draft?.subject} + initialSubject={ + draft?.subject ?? + (mode === 'forward' && replyToMessage?.subject + ? `Fwd: ${replyToMessage.subject}` + : undefined) + } autofocus={true} settingsLoading={settingsLoading} replyingTo={replyToMessage?.sender.email} diff --git a/apps/email/server/src/lib/imap-driver.ts b/apps/email/server/src/lib/imap-driver.ts index ef6e6c23a..a15620719 100644 --- a/apps/email/server/src/lib/imap-driver.ts +++ b/apps/email/server/src/lib/imap-driver.ts @@ -810,7 +810,11 @@ export async function getThread( mimeType: ct, size: a.size ?? 0, inline: a.contentDisposition === 'inline', - body: '', + // simpleParser already decoded the full message (including this + // attachment's bytes) to build `parsed.text`/`parsed.html` above - + // `a.content` is sitting in memory either way, so shipping it + // base64-encoded here costs no extra IMAP round trip. + body: a.content ? a.content.toString('base64') : '', attachmentId: attId, headers: [], }; diff --git a/apps/email/server/src/trpc/routes/mail.ts b/apps/email/server/src/trpc/routes/mail.ts index 41e4d745a..6758a398b 100644 --- a/apps/email/server/src/trpc/routes/mail.ts +++ b/apps/email/server/src/trpc/routes/mail.ts @@ -156,24 +156,51 @@ export const mailRouter = router({ threadId: z.string().nullable().optional(), isForward: z.boolean().optional(), originalMessage: z.string().optional(), + originalMessageId: z.string().optional(), scheduleAt: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), inReplyTo: z.string().optional(), references: z.array(z.string()).optional(), }), ) - .mutation(({ ctx, input }) => { + .mutation(async ({ ctx, input }) => { const refsHeader = input.headers?.References; const inReplyToHeader = input.headers?.['In-Reply-To']; const fromAddress = formatFromAddress(input.fromEmail, ctx.session.email, ctx.session.name); + + // Forward: the client's own attachments field is always empty (the + // "attachment chip" the user sees while forwarding is just the + // read-pane's display of the ORIGINAL message, never wired into the + // composer's own file list) - so for a forward, fetch the original + // message's attachments straight from IMAP server-side instead of + // trusting the client payload. Likewise the client's composed HTML + // ends at the "---------- Forwarded message ----------" header block; + // the original content only travels in `originalMessage` (already + // HTML-preferring, see getThread's `decodedBody`) and must be + // appended here or the recipient gets a bodyless forward. + let forwardAttachments: typeof input.attachments; + let outgoingHtml = input.message ?? input.html ?? input.body ?? undefined; + if (input.isForward && input.originalMessageId) { + const original = await getThread(ctx.imap, input.originalMessageId).catch(() => null); + const originalAttachments = original?.latest.attachments ?? []; + if (originalAttachments.length > 0) { + forwardAttachments = originalAttachments + .filter((a) => a.body) + .map((a) => ({ name: a.filename, type: a.contentType, base64: a.body })); + } + } + if (input.isForward && input.originalMessage) { + outgoingHtml = `${outgoingHtml ?? ''}${input.originalMessage}`; + } + return driverSend(ctx.smtp, ctx.imap, fromAddress, { to: input.to.map(senderToAddress), cc: input.cc?.map(senderToAddress), bcc: input.bcc?.map(senderToAddress), subject: input.subject, - html: input.message ?? input.html ?? input.body ?? undefined, + html: outgoingHtml, text: input.text, - attachments: input.attachments, + attachments: forwardAttachments ?? input.attachments, inReplyTo: input.inReplyTo ?? inReplyToHeader ?? undefined, references: input.references ??