From a19cbd162b720989acc07189e063a287db8963ae Mon Sep 17 00:00:00 2001 From: Phillip Carter Date: Mon, 27 Jul 2026 19:20:40 -0700 Subject: [PATCH] Audit implementation --- feature-list.md | 18 - src/app/(inbox)/actions.ts | 69 +++- src/app/(inbox)/archive/page.tsx | 7 + src/app/(inbox)/email/[id]/page.tsx | 11 +- src/app/(inbox)/layout.tsx | 28 ++ src/app/(inbox)/thread/[threadId]/page.tsx | 31 +- src/app/(inbox)/trash/page.tsx | 7 + src/app/compose/actions.ts | 91 ++++- src/app/compose/page.tsx | 77 +++- src/app/smoke-tests/SmokeHarness.tsx | 13 +- src/app/smoke-tests/page.tsx | 1 + src/components/Composer.tsx | 107 +++-- src/components/DesktopNav.tsx | 12 +- src/components/EmailBody.tsx | 60 ++- src/components/EmailDetailView.tsx | 7 +- src/components/EmailListPanel.tsx | 433 +++++++++++++++++---- src/components/InboxPanelLayout.tsx | 3 +- src/components/MessageActionBar.tsx | 147 ++++++- src/components/MobileNav.tsx | 12 +- src/components/ThreadView.tsx | 19 +- src/lib/__tests__/composeHtml.test.ts | 16 + src/lib/__tests__/emailHtml.test.ts | 74 ++++ src/lib/__tests__/emailList.test.ts | 2 + src/lib/__tests__/jmap.test.ts | 126 +++++- src/lib/__tests__/printHtml.test.ts | 31 +- src/lib/composeHtml.ts | 25 ++ src/lib/emailHtml.ts | 79 +++- src/lib/jmap.ts | 171 +++++++- src/lib/printHtml.ts | 78 +++- src/lib/types.ts | 1 + tests/smoke/mail.spec.ts | 31 ++ 31 files changed, 1599 insertions(+), 188 deletions(-) delete mode 100644 feature-list.md create mode 100644 src/app/(inbox)/archive/page.tsx create mode 100644 src/app/(inbox)/trash/page.tsx diff --git a/feature-list.md b/feature-list.md deleted file mode 100644 index 0af843e..0000000 --- a/feature-list.md +++ /dev/null @@ -1,18 +0,0 @@ -# Feature List - -Features to implement over time, roughly in priority order. - -and - -- rules for formatting - - markdown for composition - - do not just have the raw-ass html in the reply - -## Ideas (not yet prioritized) - -- Folder / label navigation in the sidebar -- Keyboard shortcuts (j/k navigation, r to reply, c to compose, etc.) -- Multiple account support (Gmail, Outlook via IMAP bridge or OAuth) -- Unread count badge in sidebar -- Archive and delete actions -- Dark mode diff --git a/src/app/(inbox)/actions.ts b/src/app/(inbox)/actions.ts index 6941bab..d6de78c 100644 --- a/src/app/(inbox)/actions.ts +++ b/src/app/(inbox)/actions.ts @@ -1,7 +1,7 @@ "use server"; import { auth } from "@/auth"; -import { getSession, getAccountId, getMailboxes, listEmails, loadMoreEmailsFiltered, searchEmails, setPin, setKeywordsOnMany, moveEmailsToMailbox, getInboxSnapshot } from "@/lib/jmap"; +import { getSession, getAccountId, getMailboxes, listEmails, loadMoreEmailsFiltered, searchEmails, setPin, setKeywordsOnMany, moveEmailsToMailbox, getInboxSnapshot, destroyAllEmailsInMailbox, destroyEmails, getEmailMailboxIds } from "@/lib/jmap"; import { parseSearchQuery, buildJmapFilter } from "@/lib/search"; import { log } from "@/lib/logger"; import { Email } from "@/lib/types"; @@ -111,3 +111,70 @@ export async function bulkMoveToMailbox( await moveEmailsToMailbox(session.apiUrl, accountId, emails, targetMailboxId); log.info({ count: emails.length, target_mailbox_id: targetMailboxId, duration_ms: Date.now() - t }, "action.move_emails"); } + +async function requireTrashMailbox( + apiUrl: string, + accountId: string, + trashMailboxId: string, +) { + const mailboxes = await getMailboxes(apiUrl, accountId); + const trash = mailboxes.find( + (mailbox) => + mailbox.id === trashMailboxId && mailbox.role === "trash", + ); + if (!trash) throw new Error("Invalid trash mailbox"); + return trash; +} + +export async function permanentlyDeleteEmailsAction( + emailIds: string[], + trashMailboxId: string, +): Promise { + const t = Date.now(); + const { session, accountId } = await requireAuthedJmap(); + if (!Array.isArray(emailIds) || typeof trashMailboxId !== "string") { + throw new Error("Invalid permanent delete request"); + } + const ids = [ + ...new Set( + emailIds + .slice(0, 500) + .filter((emailId): emailId is string => typeof emailId === "string" && !!emailId), + ), + ]; + if (!ids.length) return; + await requireTrashMailbox(session.apiUrl, accountId, trashMailboxId); + const emails = await getEmailMailboxIds(session.apiUrl, accountId, ids); + if ( + emails.length !== ids.length || + emails.some((email) => !email.mailboxIds[trashMailboxId]) + ) { + throw new Error("Only messages in Trash can be permanently deleted"); + } + await destroyEmails(session.apiUrl, accountId, ids); + log.info( + { count: ids.length, duration_ms: Date.now() - t }, + "action.destroy_emails", + ); +} + +export async function emptyTrashAction( + trashMailboxId: string, +): Promise<{ destroyed: number }> { + const t = Date.now(); + const { session, accountId } = await requireAuthedJmap(); + if (typeof trashMailboxId !== "string" || !trashMailboxId) { + throw new Error("Invalid trash mailbox"); + } + await requireTrashMailbox(session.apiUrl, accountId, trashMailboxId); + const destroyed = await destroyAllEmailsInMailbox( + session.apiUrl, + accountId, + trashMailboxId, + ); + log.info( + { count: destroyed, duration_ms: Date.now() - t }, + "action.empty_trash", + ); + return { destroyed }; +} diff --git a/src/app/(inbox)/archive/page.tsx b/src/app/(inbox)/archive/page.tsx new file mode 100644 index 0000000..18fa906 --- /dev/null +++ b/src/app/(inbox)/archive/page.tsx @@ -0,0 +1,7 @@ +export default function ArchivePage() { + return ( +
+ Select an archived conversation to read it +
+ ); +} diff --git a/src/app/(inbox)/email/[id]/page.tsx b/src/app/(inbox)/email/[id]/page.tsx index 6f78a2a..89f1416 100644 --- a/src/app/(inbox)/email/[id]/page.tsx +++ b/src/app/(inbox)/email/[id]/page.tsx @@ -13,7 +13,16 @@ export default async function EmailPage({ params, searchParams }: Props) { const { id } = await params; const resolvedSearchParams = searchParams ? await searchParams : {}; const from = resolvedSearchParams.from; - const backLabel = from === "spam" ? "Spam" : from === "sent" ? "Sent" : "Inbox"; + const backLabel = + from === "spam" + ? "Spam" + : from === "sent" + ? "Sent" + : from === "archive" + ? "Archive" + : from === "trash" + ? "Trash" + : "Inbox"; const { session, accountId } = await getJmapContext(); const email = await getEmail(session.apiUrl, accountId, id); diff --git a/src/app/(inbox)/layout.tsx b/src/app/(inbox)/layout.tsx index 95690e5..db119bd 100644 --- a/src/app/(inbox)/layout.tsx +++ b/src/app/(inbox)/layout.tsx @@ -18,6 +18,14 @@ const EMPTY_DEFERRED_DATA: DeferredMailPanelData = { spamUnreadTotal: 0, spamReads: [], spamReadTotal: 0, + archiveUnreads: [], + archiveUnreadTotal: 0, + archiveReads: [], + archiveReadTotal: 0, + trashUnreads: [], + trashUnreadTotal: 0, + trashReads: [], + trashReadTotal: 0, }; async function DeferredPanelData({ @@ -72,6 +80,8 @@ async function MailPanelData() { drafts: draftsMailbox?.id, sent: sentMailbox?.id, spam: spamMailbox?.id, + archive: archiveMailbox?.id, + trash: trashMailbox?.id, }, true, ), @@ -85,6 +95,14 @@ async function MailPanelData() { spamUnreadTotal: data.spam.unreadTotal, spamReads: data.spam.reads, spamReadTotal: data.spam.readTotal, + archiveUnreads: data.archive.unreads, + archiveUnreadTotal: data.archive.unreadTotal, + archiveReads: data.archive.reads, + archiveReadTotal: data.archive.readTotal, + trashUnreads: data.trash.unreads, + trashUnreadTotal: data.trash.unreadTotal, + trashReads: data.trash.reads, + trashReadTotal: data.trash.readTotal, }), ) .catch((err) => { @@ -106,6 +124,8 @@ async function MailPanelData() { pinned: [], sent: { emails: [], total: 0 }, spam: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, + archive: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, + trash: { unreads: [], unreadTotal: 0, reads: [], readTotal: 0 }, }; } @@ -133,6 +153,14 @@ async function MailPanelData() { archiveMailboxId={archiveMailbox?.id} trashMailboxId={trashMailbox?.id} spamMailboxId={spamMailbox?.id} + archiveUnreads={[]} + archiveUnreadTotal={0} + archiveReads={[]} + archiveReadTotal={0} + trashUnreads={[]} + trashUnreadTotal={0} + trashReads={[]} + trashReadTotal={0} deferredContent={ diff --git a/src/app/(inbox)/thread/[threadId]/page.tsx b/src/app/(inbox)/thread/[threadId]/page.tsx index 43ba759..f406bbf 100644 --- a/src/app/(inbox)/thread/[threadId]/page.tsx +++ b/src/app/(inbox)/thread/[threadId]/page.tsx @@ -10,6 +10,7 @@ import { getJmapContext, getJmapMailboxContext, } from "@/lib/jmapServer"; +import { sanitizeReaderHtml } from "@/lib/printHtml"; interface Props { params: Promise<{ threadId: string }>; @@ -20,10 +21,32 @@ export default async function ThreadPage({ params, searchParams }: Props) { const { threadId } = await params; const resolvedSearchParams = searchParams ? await searchParams : {}; const from = resolvedSearchParams.from; - const backLabel = from === "spam" ? "Spam" : from === "sent" ? "Sent" : "Inbox"; + const backLabel = + from === "spam" + ? "Spam" + : from === "sent" + ? "Sent" + : from === "archive" + ? "Archive" + : from === "trash" + ? "Trash" + : "Inbox"; const { session, accountId } = await getJmapContext(); - const emails = await getThreadEmails(session.apiUrl, accountId, threadId); + const emails = (await getThreadEmails(session.apiUrl, accountId, threadId)).map( + (email) => { + const bodyValues = { ...email.bodyValues }; + for (const part of email.htmlBody ?? []) { + if (part.partId && bodyValues[part.partId]) { + bodyValues[part.partId] = { + ...bodyValues[part.partId], + value: sanitizeReaderHtml(bodyValues[part.partId].value), + }; + } + } + return { ...email, bodyValues }; + }, + ); if (!emails.length) return notFound(); @@ -57,6 +80,8 @@ export default async function ThreadPage({ params, searchParams }: Props) { ]); const spamMailbox = mailboxes.find((m) => m.role === "junk" || m.name.toLowerCase() === "spam" || m.name.toLowerCase() === "junk"); const inboxMailbox = mailboxes.find((m) => m.role === "inbox"); + const archiveMailbox = mailboxes.find((m) => m.role === "archive"); + const trashMailbox = mailboxes.find((m) => m.role === "trash"); return (
@@ -82,6 +107,8 @@ export default async function ThreadPage({ params, searchParams }: Props) { calendarEvents={calendarEvents} spamMailboxId={spamMailbox?.id} inboxMailboxId={inboxMailbox?.id} + archiveMailboxId={archiveMailbox?.id} + trashMailboxId={trashMailbox?.id} />
diff --git a/src/app/(inbox)/trash/page.tsx b/src/app/(inbox)/trash/page.tsx new file mode 100644 index 0000000..00bff11 --- /dev/null +++ b/src/app/(inbox)/trash/page.tsx @@ -0,0 +1,7 @@ +export default function TrashPage() { + return ( +
+ Select a conversation to review or restore it +
+ ); +} diff --git a/src/app/compose/actions.ts b/src/app/compose/actions.ts index 77c87c3..06ebc51 100644 --- a/src/app/compose/actions.ts +++ b/src/app/compose/actions.ts @@ -21,22 +21,80 @@ function splitRaw(raw: string) { export interface DraftSaveInput { draftId: string | null; - fromName: string; - fromEmail: string; + identityId: string; to: string; cc: string; bcc: string; subject: string; body: string; + htmlBody: string; + inlineImages: { id: string; blobId: string; type: string }[]; + attachments: { blobId: string; name: string; type: string }[]; inReplyToId?: string; } +function requireString(value: unknown, field: string): string { + if (typeof value !== "string") throw new Error(`Invalid ${field}`); + return value; +} + +function normalizeInlineImages( + value: unknown, +): DraftSaveInput["inlineImages"] { + if (!Array.isArray(value)) return []; + return value.slice(0, 100).map((image) => { + if (!image || typeof image !== "object") { + throw new Error("Invalid inline image"); + } + const candidate = image as Record; + return { + id: requireString(candidate.id, "inline image id"), + blobId: requireString(candidate.blobId, "inline image blob"), + type: requireString(candidate.type, "inline image type"), + }; + }); +} + +function normalizeAttachments( + value: unknown, +): DraftSaveInput["attachments"] { + if (!Array.isArray(value)) return []; + return value.slice(0, 100).map((attachment) => { + if (!attachment || typeof attachment !== "object") { + throw new Error("Invalid attachment"); + } + const candidate = attachment as Record; + return { + blobId: requireString(candidate.blobId, "attachment blob"), + name: requireString(candidate.name, "attachment name"), + type: requireString(candidate.type, "attachment type"), + }; + }); +} + export async function saveDraftAction( input: DraftSaveInput ): Promise<{ draftId: string }> { const t = Date.now(); const sessionData = await auth(); if (!sessionData?.user) throw new Error("Unauthorized"); + const identityId = requireString(input?.identityId, "identity"); + const to = requireString(input?.to, "to"); + const cc = requireString(input?.cc, "cc"); + const bcc = requireString(input?.bcc, "bcc"); + const subject = requireString(input?.subject, "subject"); + const body = requireString(input?.body, "body"); + const htmlBody = requireString(input?.htmlBody, "HTML body"); + const draftIdInput = + input?.draftId === null + ? null + : requireString(input?.draftId, "draft id"); + const inReplyToId = + input?.inReplyToId === undefined + ? undefined + : requireString(input.inReplyToId, "reply target"); + const inlineImages = normalizeInlineImages(input?.inlineImages); + const attachments = normalizeAttachments(input?.attachments); const session = await getSession(); const accountId = getAccountId(session); const [mailboxes, identities] = await Promise.all([ @@ -45,12 +103,12 @@ export async function saveDraftAction( ]); const draftsMailbox = mailboxes.find((m) => m.role === "drafts"); if (!draftsMailbox) throw new Error("No drafts mailbox found"); - const identity = identities.find((candidate) => candidate.email === input.fromEmail); + const identity = identities.find((candidate) => candidate.id === identityId); if (!identity) throw new Error("Invalid from address"); - const toAddrs = parseAddresses(splitRaw(input.to), { strict: false }); - const ccAddrs = parseAddresses(splitRaw(input.cc), { strict: false }); - const bccAddrs = parseAddresses(splitRaw(input.bcc), { strict: false }); + const toAddrs = parseAddresses(splitRaw(to), { strict: false }); + const ccAddrs = parseAddresses(splitRaw(cc), { strict: false }); + const bccAddrs = parseAddresses(splitRaw(bcc), { strict: false }); const draftId = await saveDraft( session.apiUrl, @@ -61,22 +119,27 @@ export async function saveDraftAction( to: toAddrs, cc: ccAddrs, bcc: bccAddrs, - subject: input.subject, - body: input.body, - inReplyToId: input.inReplyToId, + subject, + body, + htmlBody, + inlineImages, + attachments, + inReplyToId, }, - input.draftId + draftIdInput ); log.info({ - is_update: !!input.draftId, - prev_draft_id: input.draftId ?? undefined, + is_update: !!draftIdInput, + prev_draft_id: draftIdInput ?? undefined, new_draft_id: draftId, to_count: toAddrs.length, cc_count: ccAddrs.length, bcc_count: bccAddrs.length, - subject_len: input.subject.length, - body_len: input.body.length, + subject_len: subject.length, + body_len: body.length, + inline_image_count: inlineImages.length, + attachment_count: attachments.length, duration_ms: Date.now() - t, }, "action.save_draft"); diff --git a/src/app/compose/page.tsx b/src/app/compose/page.tsx index 299fef9..cac73d2 100644 --- a/src/app/compose/page.tsx +++ b/src/app/compose/page.tsx @@ -12,6 +12,12 @@ import { import Composer from "@/components/Composer"; import MobileBackButton from "@/components/MobileBackButton"; import { getJmapContext } from "@/lib/jmapServer"; +import { visibleAttachments } from "@/lib/attachments"; +import { + extractForwardedHtml, +} from "@/lib/composeHtml"; +import { sanitizeReaderHtml } from "@/lib/printHtml"; +import type { EmailBodyPart } from "@/lib/types"; interface Props { searchParams: Promise<{ mode?: string; id?: string; draftId?: string }>; @@ -65,6 +71,20 @@ export default async function ComposePage({ searchParams }: Props) { let title = "New Message"; let initialDraftId: string | undefined; let forwardedHtml: string | undefined; + let initialIdentityId: string | undefined; + let initialInlineImages: { + id: string; + blobId: string; + dataUrl: string; + type: string; + }[] = []; + let initialAttachments: { + id: string; + name: string; + size: number; + type: string; + blobId: string; + }[] = []; // Resume a saved draft if (draftId) { @@ -73,10 +93,13 @@ export default async function ComposePage({ searchParams }: Props) { initialDraftId = draftId; initialTo = draft.to?.map(formatAddressRFC).join(", ") ?? ""; initialCc = draft.cc?.map(formatAddressRFC).join(", ") ?? ""; - // bcc is visible on drafts since they live in the sender's mailbox - initialBcc = (draft as { bcc?: typeof draft.to })?.bcc + initialBcc = draft.bcc ?.map(formatAddressRFC) .join(", ") ?? ""; + initialIdentityId = sorted.find( + (identity) => + identity.email.toLowerCase() === draft.from?.[0]?.email.toLowerCase(), + )?.id; initialSubject = draft.subject ?? ""; if (draft.textBody?.length > 0) { const part = draft.textBody[0]; @@ -84,6 +107,39 @@ export default async function ComposePage({ searchParams }: Props) { initialBody = draft.bodyValues[part.partId].value; } } + if (draft.htmlBody?.length > 0) { + const htmlPart = draft.htmlBody[0]; + if (htmlPart.partId && draft.bodyValues?.[htmlPart.partId]) { + forwardedHtml = extractForwardedHtml( + draft.bodyValues[htmlPart.partId].value, + ); + } + } + const inlineParts = (draft.attachments ?? []).filter( + (part) => part.blobId && part.disposition?.toLowerCase() === "inline", + ); + initialInlineImages = inlineParts.flatMap((part) => { + const id = part.cid?.replace(/@mail$/i, ""); + if (!id || !part.blobId) return []; + return [{ + id, + blobId: part.blobId, + type: part.type, + dataUrl: inlinePartUrl(part), + }]; + }); + initialAttachments = visibleAttachments(draft.attachments).flatMap( + (part) => + part.blobId + ? [{ + id: `draft-${part.blobId}`, + name: part.name ?? "attachment", + size: part.size, + type: part.type, + blobId: part.blobId, + }] + : [], + ); title = "Draft"; if (draft.inReplyTo?.[0]) { inReplyToId = draft.inReplyTo[0]; @@ -146,7 +202,9 @@ export default async function ComposePage({ searchParams }: Props) { if (email.htmlBody?.length > 0) { const part = email.htmlBody[0]; if (part.partId && email.bodyValues?.[part.partId]) { - forwardedHtml = email.bodyValues[part.partId].value; + forwardedHtml = sanitizeReaderHtml( + email.bodyValues[part.partId].value, + ); } } // The markdown body carries the plain-text fallback (text/plain part @@ -182,8 +240,21 @@ export default async function ComposePage({ searchParams }: Props) { replyThreadId={replyThreadId} initialDraftId={initialDraftId} forwardedHtml={forwardedHtml} + initialIdentityId={initialIdentityId} + initialInlineImages={initialInlineImages} + initialAttachments={initialAttachments} /> ); } + +function inlinePartUrl(part: EmailBodyPart): string { + const params = new URLSearchParams({ + blobId: part.blobId ?? "", + name: part.name ?? "inline-image", + type: part.type, + inline: "true", + }); + return `/api/download?${params.toString()}`; +} diff --git a/src/app/smoke-tests/SmokeHarness.tsx b/src/app/smoke-tests/SmokeHarness.tsx index d72024a..b3632ef 100644 --- a/src/app/smoke-tests/SmokeHarness.tsx +++ b/src/app/smoke-tests/SmokeHarness.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useCallback, useState } from "react"; import AttachmentList from "@/components/AttachmentList"; import Composer from "@/components/Composer"; +import EmailBody from "@/components/EmailBody"; import EmailListPanel from "@/components/EmailListPanel"; import MessageActionBar from "@/components/MessageActionBar"; import MobileNav from "@/components/MobileNav"; @@ -26,7 +27,8 @@ export type SmokePanel = | "dark-rendering" | "tab-indicator" | "message-actions" - | "mobile-viewport"; + | "mobile-viewport" + | "reader-privacy"; const fixtureEmails: Email[] = [ { @@ -277,6 +279,15 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) { /> )} + {panel === "reader-privacy" && ( +
+ +
+ )} + {panel === "reply" && ( ([ "tab-indicator", "message-actions", "mobile-viewport", + "reader-privacy", ]); export default async function SmokePage({ searchParams }: Props) { diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 3988847..9970523 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -7,6 +7,7 @@ import { saveDraftAction, deleteDraftAction } from "@/app/compose/actions"; import { useToast } from "@/components/ToastProvider"; import { useNavigationGuard } from "@/components/NavigationGuardProvider"; import { + appendForwardedHtml, markQuotedReplyHtml, wrapComposePreviewHtml, wrapEmailHtml, @@ -197,6 +198,9 @@ interface Props { replyThreadId?: string; initialDraftId?: string; forwardedHtml?: string; + initialIdentityId?: string; + initialInlineImages?: InlineImage[]; + initialAttachments?: Attachment[]; } marked.setOptions({ gfm: true, breaks: true }); @@ -223,6 +227,8 @@ function draftFingerprint({ bcc, subject, markdown, + inlineImages, + attachments, }: { identityId: string; to: string; @@ -230,8 +236,19 @@ function draftFingerprint({ bcc: string; subject: string; markdown: string; + inlineImages: Pick[]; + attachments: Pick[]; }) { - return JSON.stringify([identityId, to, cc, bcc, subject, markdown]); + return JSON.stringify([ + identityId, + to, + cc, + bcc, + subject, + markdown, + inlineImages.map(({ id, blobId, type }) => [id, blobId, type]), + attachments.map(({ blobId, name, type }) => [blobId, name, type]), + ]); } export default function Composer({ @@ -245,10 +262,17 @@ export default function Composer({ replyThreadId, initialDraftId, forwardedHtml, + initialIdentityId, + initialInlineImages = [], + initialAttachments = [], }: Props) { const router = useRouter(); const showToast = useToast(); - const [identityId, setIdentityId] = useState(identities[0]?.id ?? ""); + const startingIdentityId = + identities.some((identity) => identity.id === initialIdentityId) + ? initialIdentityId! + : identities[0]?.id ?? ""; + const [identityId, setIdentityId] = useState(startingIdentityId); const [to, setTo] = useState(initialTo); const [cc, setCc] = useState(initialCc); const [bcc, setBcc] = useState(initialBcc); @@ -262,8 +286,10 @@ export default function Composer({ const [dragActive, setDragActive] = useState(false); const [sending, setSending] = useState(false); const [error, setError] = useState(null); - const [inlineImages, setInlineImages] = useState([]); - const [attachments, setAttachments] = useState([]); + const [inlineImages, setInlineImages] = + useState(initialInlineImages); + const [attachments, setAttachments] = + useState(initialAttachments); const [uploading, setUploading] = useState(0); // Draft state @@ -272,12 +298,14 @@ export default function Composer({ const [lastSaved, setLastSaved] = useState(null); const [savedFingerprint, setSavedFingerprint] = useState(() => draftFingerprint({ - identityId: identities[0]?.id ?? "", + identityId: startingIdentityId, to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject, markdown: initialBody, + inlineImages: initialInlineImages, + attachments: initialAttachments, }), ); const draftIdRef = useRef(initialDraftId ?? null); @@ -305,12 +333,11 @@ export default function Composer({ bcc: showBcc ? bcc : "", subject, markdown, + inlineImages, + attachments, }); const hasUnsavedDraftChanges = - currentFingerprint !== savedFingerprint || - attachments.length > 0 || - inlineImages.length > 0 || - uploading > 0; + currentFingerprint !== savedFingerprint || uploading > 0; useNavigationGuard( hasUnsavedDraftChanges && !sending, "Leave this message? Recent changes may not be saved.", @@ -367,9 +394,15 @@ export default function Composer({ isInitialRender.current = false; return; } - if (sending || suppressDraftSideEffectsRef.current) return; + if (sending || uploading > 0 || suppressDraftSideEffectsRef.current) return; // Nothing worth saving yet - if (!to && !subject && !markdown) return; + if ( + !to && + !subject && + !markdown && + attachments.length === 0 && + inlineImages.length === 0 + ) return; const timer = setTimeout(async () => { if (savingRef.current || suppressDraftSideEffectsRef.current) return; @@ -380,15 +413,36 @@ export default function Composer({ const existingDraftId = draftIdRef.current; const isFirstSave = !existingDraftId; try { + const rawHtml = await marked.parse(normalizeComposeMarkdown(markdown)); + const htmlWithCids = replacePlaceholders( + rawHtml, + (id) => `cid:${id}@mail`, + ); + const renderedBody = forwardedHtml + ? htmlWithCids + appendForwardedHtml(forwardedHtml) + : htmlWithCids; + const composedBody = inReplyToId + ? markQuotedReplyHtml(renderedBody) + : renderedBody; const result = await saveDraftAction({ draftId: existingDraftId, - fromName: identity?.name ?? "", - fromEmail: identity?.email ?? "", + identityId: identity?.id ?? "", to, cc: showCc ? cc : "", bcc: showBcc ? bcc : "", subject, body: markdown, + htmlBody: wrapEmailHtml(composedBody), + inlineImages: inlineImages.map(({ id, blobId, type }) => ({ + id, + blobId, + type, + })), + attachments: attachments.map(({ blobId, name, type }) => ({ + blobId, + name, + type, + })), inReplyToId, }); @@ -422,6 +476,8 @@ export default function Composer({ bcc: showBcc ? bcc : "", subject, markdown, + inlineImages, + attachments, }), ); setLastSaved(new Date()); @@ -434,7 +490,7 @@ export default function Composer({ }, 2000); return () => clearTimeout(timer); - }, [to, cc, bcc, showCc, showBcc, subject, markdown, identityId, inReplyToId, sending]); // eslint-disable-line react-hooks/exhaustive-deps + }, [to, cc, bcc, showCc, showBcc, subject, markdown, identityId, inReplyToId, sending, uploading, inlineImages, attachments, forwardedHtml]); // eslint-disable-line react-hooks/exhaustive-deps const handlePaste = useCallback( async (e: React.ClipboardEvent) => { @@ -525,7 +581,8 @@ export default function Composer({ !!bcc.trim() || !!subject.trim() || !!markdown.trim() || - attachmentsRef.current.length > 0; + attachmentsRef.current.length > 0 || + inlineImagesRef.current.length > 0; if (hasContent && !window.confirm("Discard this draft?")) return; suppressDraftSideEffectsRef.current = true; cleanupPendingDraftsRef.current = true; @@ -1013,23 +1070,3 @@ export default function Composer({ ); } - -// Extract the content from a full HTML document, or return the input -// as-is if no tag is found (e.g. HTML fragments). -function extractBodyContent(html: string): string { - const m = html.match(/]*>([\s\S]*?)<\/body>/i); - if (m) return m[1]; - // Strip doctype / html / head wrappers and return the rest - return html - .replace(/]*>/gi, "") - .replace(/<\/?html[^>]*>/gi, "") - .replace(//gi, "") - .trim(); -} - -// Returns an HTML snippet to append after the composed content when forwarding. -// The original email is rendered in a visually separated block. -function appendForwardedHtml(originalHtml: string): string { - const content = extractBodyContent(originalHtml); - return `
${content}
`; -} diff --git a/src/components/DesktopNav.tsx b/src/components/DesktopNav.tsx index a439e62..a78f293 100644 --- a/src/components/DesktopNav.tsx +++ b/src/components/DesktopNav.tsx @@ -16,6 +16,8 @@ const mailboxItems: NavItem[] = [ { href: "/", label: "Inbox", icon: "inbox", badge: "inbox" }, { href: "/drafts", label: "Drafts", icon: "drafts", badge: "drafts" }, { href: "/sent", label: "Sent", icon: "sent" }, + { href: "/archive", label: "Archive", icon: "archive" }, + { href: "/trash", label: "Trash", icon: "trash" }, { href: "/spam", label: "Spam", icon: "spam", badge: "spam" }, { href: "/calendar", label: "Calendar", icon: "calendar" }, ]; @@ -32,7 +34,9 @@ function isActive( pathname.startsWith("/thread/") || pathname.startsWith("/attachment/")) && from !== "spam" && - from !== "sent" + from !== "sent" && + from !== "archive" && + from !== "trash" ); } if (item.href === "/sent") { @@ -41,6 +45,12 @@ function isActive( if (item.href === "/spam") { return pathname.startsWith("/spam") || from === "spam"; } + if (item.href === "/archive") { + return pathname.startsWith("/archive") || from === "archive"; + } + if (item.href === "/trash") { + return pathname.startsWith("/trash") || from === "trash"; + } return pathname.startsWith(item.href); } diff --git a/src/components/EmailBody.tsx b/src/components/EmailBody.tsx index 372ed57..a624c6d 100644 --- a/src/components/EmailBody.tsx +++ b/src/components/EmailBody.tsx @@ -1,10 +1,10 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useBodyClass from "@/components/useBodyClass"; import { useAppearance } from "@/components/AppearanceProvider"; import { lockEmailContentWidth } from "@/lib/emailFrameLayout"; -import { prepareHtml, prepareTextBody } from "@/lib/emailHtml"; +import { hasRemoteContent, prepareHtml, prepareTextBody } from "@/lib/emailHtml"; import type { EmailBodyPart } from "@/lib/types"; const EMPTY_EMBEDDED_PARTS: EmailBodyPart[] = []; @@ -26,7 +26,12 @@ export default function EmailBody({ const iframeRef = useRef(null); const lastDimsRef = useRef({ h: 0, w: 0, availableWidth: 0 }); const lockedContentWidthRef = useRef(null); + const [remoteContentAllowedFor, setRemoteContentAllowedFor] = useState< + string | null + >(null); const { preferences } = useAppearance(); + const remoteContentAvailable = type === "html" && hasRemoteContent(body); + const allowRemoteContent = remoteContentAllowedFor === body; useBodyClass("rich-content-open"); @@ -134,29 +139,50 @@ export default function EmailBody({ stripQuotes, embeddedParts, colorMode: preferences.theme, + allowRemoteContent, }) : prepareTextBody(body, { stripQuotes, colorMode: preferences.theme, }), - [body, embeddedParts, preferences.theme, stripQuotes, type], + [ + allowRemoteContent, + body, + embeddedParts, + preferences.theme, + stripQuotes, + type, + ], ); return ( -
-