From b963d9b7a7f489067ce13f561a56d5d568401793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 1 Mar 2026 04:58:32 +0000 Subject: [PATCH] email: charset decoding, inline attachments, UI fixes - respect charset from Content-Type when decoding body and headers - detect inline attachments with filenames (not just disposition: attachment) - fix broken decodeQuotedPrintable reference in parseAttachments - white background for email iframe (emails designed for light bg) - sticky header in email list, scrollable message area - arrow key navigation between emails with scroll-into-view - /email/:id routing for deep linking to specific emails - empty folder shows "No emails in this folder" instead of losing header - fix attachment viewer header overlap with dialog close button Co-Authored-By: Claude Opus 4.6 --- src/apps/officer-web/App.tsx | 1 + .../Screens/Dashboard/Email/EmailList.tsx | 41 ++++++++-- .../Screens/Dashboard/Email/EmailReader.tsx | 6 +- .../Screens/Dashboard/Email/EmailScreen.tsx | 19 ++++- src/servers/api/email/email-db.ts | 77 +++++++++++++++---- 5 files changed, 119 insertions(+), 25 deletions(-) diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 98a1077f..ce524e96 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -60,6 +60,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx index 9a1aaa41..328d1ad1 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx @@ -96,6 +96,30 @@ export const EmailList = () => { const total = data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / LIMIT)); + // Arrow key navigation + useEffect(() => { + const handleKeyDown = (ev: KeyboardEvent) => { + if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') return; + if (messages.length === 0) return; + ev.preventDefault(); + const currentIndex = selectedId ? messages.findIndex((m) => m.id === selectedId) : -1; + let nextId: string | undefined; + if (ev.key === 'ArrowDown') { + const next = currentIndex + 1; + if (next < messages.length) nextId = messages[next]!.id; + } else { + const prev = currentIndex - 1; + if (prev >= 0) nextId = messages[prev]!.id; + } + if (nextId) { + setSelectedId(nextId); + document.querySelector(`[data-email-id="${nextId}"]`)?.scrollIntoView({ block: 'nearest' }); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [messages, selectedId]); + if (isLoading) { return (
@@ -104,7 +128,9 @@ export const EmailList = () => { ); } - if (messages.length === 0 && page === 1) { + // Show onboarding empty state only when no emails exist at all + const hasNoEmails = total === 0 && folder === 'inbox' && !allCount?.total; + if (hasNoEmails && !isLoading && page === 1) { return (
@@ -131,8 +157,8 @@ export const EmailList = () => { } return ( -
-
+
+
{FOLDERS.map((f) => { const Icon = f.icon; @@ -187,12 +213,16 @@ export const EmailList = () => {
)}
-
+ {messages.length === 0 ? ( +
No emails in this folder
+ ) : ( +
{messages.map((msg: EmailSummary) => ( ))}
+ )}
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx index 2d0b8602..d67e42fa 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx @@ -27,8 +27,8 @@ const HtmlBody = ({ html }: { html: string }) => { @@ -126,7 +126,7 @@ export const EmailReader = () => { fileName={openAttachment.fileName} root={openAttachment.root} > -
+
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx index 4af3f484..7d137a77 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx @@ -1,4 +1,5 @@ -import { useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; @@ -12,7 +13,23 @@ const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite e export const EmailScreen = () => { const isMobile = useIsMobile(); + const { emailId } = useParams<{ emailId?: string }>(); + const navigate = useNavigate(); const [selectedId, setSelectedId] = useGlobal('EMAIL_SELECTED', null); + + // Sync URL param → global state + useEffect(() => { + if (emailId && emailId !== selectedId) setSelectedId(emailId); + }, [emailId]); + + // Sync global state → URL + useEffect(() => { + if (selectedId && selectedId !== emailId) { + navigate(`/email/${selectedId}`, { replace: true }); + } else if (!selectedId && emailId) { + navigate('/email', { replace: true }); + } + }, [selectedId]); const workspace = useDashboardState('screens/email', defaultLayout); const components: PanelComponents = useMemo( diff --git a/src/servers/api/email/email-db.ts b/src/servers/api/email/email-db.ts index 09f5bf03..bbc09252 100644 --- a/src/servers/api/email/email-db.ts +++ b/src/servers/api/email/email-db.ts @@ -251,10 +251,12 @@ export function updateEmailLabels(db: Database, id: string, labels: string[]): v // ── Header parsing helpers (same logic as gmail-sync) ── function decodeMimeWords(text: string): string { - return text.replace(/=\?([^?]+)\?(B|Q)\?([^?]+)\?=/gi, (_, _charset, encoding, encoded) => { + return text.replace(/=\?([^?]+)\?(B|Q)\?([^?]+)\?=/gi, (_, charset, encoding, encoded) => { try { + const normalizedCs = normalizeCharset(charset.toLowerCase()); if (encoding.toUpperCase() === 'B') { - return Buffer.from(encoded, 'base64').toString('utf-8'); + const buf = Buffer.from(encoded, 'base64'); + return new TextDecoder(normalizedCs, { fatal: false }).decode(buf); } const bytes: number[] = []; for (let i = 0; i < encoded.length; i++) { @@ -267,7 +269,7 @@ function decodeMimeWords(text: string): string { bytes.push(encoded.charCodeAt(i)); } } - return Buffer.from(bytes).toString('utf-8'); + return new TextDecoder(normalizedCs, { fatal: false }).decode(Buffer.from(bytes)); } catch { return encoded; } @@ -325,16 +327,51 @@ function extractSnippet(raw: string): string { return body.replace(/\s+/g, ' ').trim().slice(0, 120); } -function decodeQuotedPrintable(text: string): string { - return text - .replace(/=\r?\n/g, '') - .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); +function decodeQuotedPrintableBytes(text: string): Buffer { + const cleaned = text.replace(/=\r?\n/g, ''); + const bytes: number[] = []; + for (let i = 0; i < cleaned.length; i++) { + if (cleaned[i] === '=' && i + 2 < cleaned.length) { + const hex = cleaned.slice(i + 1, i + 3); + const val = parseInt(hex, 16); + if (!isNaN(val)) { + bytes.push(val); + i += 2; + continue; + } + } + bytes.push(cleaned.charCodeAt(i)); + } + return Buffer.from(bytes); } -function decodePartBody(body: string, encoding: string): string { +function extractCharset(contentType: string): string { + const match = contentType.match(/charset=["']?([^"';\s]+)/i); + return match?.[1]?.toLowerCase() ?? 'utf-8'; +} + +function normalizeCharset(charset: string): string { + const map: Record = { + 'iso-8859-1': 'latin1', + 'iso_8859-1': 'latin1', + 'windows-1252': 'latin1', + 'us-ascii': 'ascii', + 'ascii': 'ascii', + }; + return map[charset] ?? charset; +} + +function decodePartBody(body: string, encoding: string, charset = 'utf-8'): string { const enc = encoding.toLowerCase(); - if (enc === 'base64') return Buffer.from(body.replace(/\s/g, ''), 'base64').toString('utf-8'); - if (enc === 'quoted-printable') return decodeQuotedPrintable(body); + const normalizedCharset = normalizeCharset(charset); + if (enc === 'base64') { + const buf = Buffer.from(body.replace(/\s/g, ''), 'base64'); + return new TextDecoder(normalizedCharset, { fatal: false }).decode(buf); + } + if (enc === 'quoted-printable') { + const buf = decodeQuotedPrintableBytes(body); + return new TextDecoder(normalizedCharset, { fatal: false }).decode(buf); + } return body; } @@ -349,7 +386,8 @@ function extractBody(raw: string): { html: string | null; text: string | null } // Non-multipart: single body if (!topCt.includes('multipart')) { const body = raw.slice(headerEnd); - const decoded = decodePartBody(body, topEncoding); + const charset = extractCharset(topCtRaw); + const decoded = decodePartBody(body, topEncoding, charset); if (topCt.includes('text/html')) return { html: decoded, text: null }; return { html: null, text: decoded }; } @@ -368,8 +406,10 @@ function extractBody(raw: string): { html: string | null; text: string | null } const partHeaderEnd = findHeaderEnd(part); if (partHeaderEnd === -1) continue; - const partCt = extractFullHeader(part, 'Content-Type').toLowerCase(); + const partCtRaw = extractFullHeader(part, 'Content-Type'); + const partCt = partCtRaw.toLowerCase(); const partEnc = extractFullHeader(part, 'Content-Transfer-Encoding'); + const partCharset = extractCharset(partCtRaw); const partBody = part.slice(partHeaderEnd); // Recurse into nested multipart (e.g. multipart/alternative inside multipart/mixed) @@ -381,9 +421,9 @@ function extractBody(raw: string): { html: string | null; text: string | null } } if (partCt.includes('text/html') && !html) { - html = decodePartBody(partBody, partEnc); + html = decodePartBody(partBody, partEnc, partCharset); } else if (partCt.includes('text/plain') && !text) { - text = decodePartBody(partBody, partEnc); + text = decodePartBody(partBody, partEnc, partCharset); } } @@ -394,7 +434,8 @@ type AttachmentMeta = { filename: string; size: number; contentType: string; con function parseAttachments(raw: string): AttachmentMeta[] { const results: AttachmentMeta[] = []; - const regex = /^Content-Disposition:\s*attachment[^\n]*/gim; + // Match both "attachment" and "inline" dispositions + const regex = /^Content-Disposition:\s*(?:attachment|inline)[^\n]*/gim; let match: RegExpExecArray | null; while ((match = regex.exec(raw)) !== null) { @@ -404,6 +445,10 @@ function parseAttachments(raw: string): AttachmentMeta[] { const partStart = raw.lastIndexOf('\n--', pos); const headerBlock = partStart !== -1 ? raw.slice(partStart, pos + 500) : raw.slice(Math.max(0, pos - 500), pos + 500); + // Skip inline parts without a filename (e.g. inline text/plain body parts) + const hasFilename = /filename/i.test(headerBlock); + if (!hasFilename) continue; + // Extract filename from Content-Disposition or Content-Type const fnMatch = headerBlock.match(/filename\*?=(?:"([^"]+)"|([^\s;]+))/i); const filename = fnMatch ? (fnMatch[1] ?? fnMatch[2] ?? 'unknown') : 'unknown'; @@ -430,7 +475,7 @@ function parseAttachments(raw: string): AttachmentMeta[] { } else { // For quoted-printable or 7bit/8bit, re-encode to base64 const buf = encoding === 'quoted-printable' - ? Buffer.from(decodeQuotedPrintable(bodyRaw)) + ? decodeQuotedPrintableBytes(bodyRaw) : Buffer.from(bodyRaw); content = buf.toString('base64'); }