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 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent de1baa1588
commit b963d9b7a7
5 changed files with 119 additions and 25 deletions
+1
View File
@@ -60,6 +60,7 @@ export function App() {
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} /> <Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} /> <Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
<Route path="/email" element={<Dashboard.EmailScreen />} /> <Route path="/email" element={<Dashboard.EmailScreen />} />
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
<Route path="/browser" element={<Dashboard.BrowserScreen />} /> <Route path="/browser" element={<Dashboard.BrowserScreen />} />
<Route path="/terminal" element={<Dashboard.TerminalScreen />} /> <Route path="/terminal" element={<Dashboard.TerminalScreen />} />
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} /> <Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
@@ -96,6 +96,30 @@ export const EmailList = () => {
const total = data?.total ?? 0; const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / LIMIT)); 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) { if (isLoading) {
return ( return (
<div className="flex h-full items-center justify-center text-sm opacity-50"> <div className="flex h-full items-center justify-center text-sm opacity-50">
@@ -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 ( return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50"> <div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
<Mail className="h-8 w-8" /> <Mail className="h-8 w-8" />
@@ -131,8 +157,8 @@ export const EmailList = () => {
} }
return ( return (
<div className="flex h-full flex-col overflow-y-auto"> <div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-3 py-2"> <div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{FOLDERS.map((f) => { {FOLDERS.map((f) => {
const Icon = f.icon; const Icon = f.icon;
@@ -187,12 +213,16 @@ export const EmailList = () => {
</div> </div>
)} )}
</div> </div>
<div className="flex flex-1 flex-col divide-y divide-white/10"> {messages.length === 0 ? (
<div className="flex flex-1 items-center justify-center text-sm opacity-40">No emails in this folder</div>
) : (
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
{messages.map((msg: EmailSummary) => ( {messages.map((msg: EmailSummary) => (
<button <button
key={msg.id} key={msg.id}
data-email-id={msg.id}
onClick={() => setSelectedId(msg.id)} onClick={() => setSelectedId(msg.id)}
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer ${ className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50' selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
}`} }`}
> >
@@ -213,6 +243,7 @@ export const EmailList = () => {
</button> </button>
))} ))}
</div> </div>
)}
</div> </div>
); );
}; };
@@ -27,8 +27,8 @@ const HtmlBody = ({ html }: { html: string }) => {
<html> <html>
<head> <head>
<style> <style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; margin: 0; padding: 16px; color: #e0e0e0; background: transparent; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; margin: 0; padding: 16px; color: #1a1a1a; background: #ffffff; }
a { color: #60a5fa; } a { color: #1a73e8; }
img { max-width: 100%; height: auto; } img { max-width: 100%; height: auto; }
</style> </style>
</head> </head>
@@ -126,7 +126,7 @@ export const EmailReader = () => {
fileName={openAttachment.fileName} fileName={openAttachment.fileName}
root={openAttachment.root} root={openAttachment.root}
> >
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-1.5"> <div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
<FileViewerHeader /> <FileViewerHeader />
</div> </div>
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
@@ -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 type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceView } from 'officerdev'; import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState'; 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 = () => { export const EmailScreen = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { emailId } = useParams<{ emailId?: string }>();
const navigate = useNavigate();
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null); const [selectedId, setSelectedId] = useGlobal<string | null>('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<LayoutNode>('screens/email', defaultLayout); const workspace = useDashboardState<LayoutNode>('screens/email', defaultLayout);
const components: PanelComponents = useMemo( const components: PanelComponents = useMemo(
+61 -16
View File
@@ -251,10 +251,12 @@ export function updateEmailLabels(db: Database, id: string, labels: string[]): v
// ── Header parsing helpers (same logic as gmail-sync) ── // ── Header parsing helpers (same logic as gmail-sync) ──
function decodeMimeWords(text: string): string { function decodeMimeWords(text: string): string {
return text.replace(/=\?([^?]+)\?(B|Q)\?([^?]+)\?=/gi, (_, _charset, encoding, encoded) => { return text.replace(/=\?([^?]+)\?(B|Q)\?([^?]+)\?=/gi, (_, charset, encoding, encoded) => {
try { try {
const normalizedCs = normalizeCharset(charset.toLowerCase());
if (encoding.toUpperCase() === 'B') { 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[] = []; const bytes: number[] = [];
for (let i = 0; i < encoded.length; i++) { for (let i = 0; i < encoded.length; i++) {
@@ -267,7 +269,7 @@ function decodeMimeWords(text: string): string {
bytes.push(encoded.charCodeAt(i)); bytes.push(encoded.charCodeAt(i));
} }
} }
return Buffer.from(bytes).toString('utf-8'); return new TextDecoder(normalizedCs, { fatal: false }).decode(Buffer.from(bytes));
} catch { } catch {
return encoded; return encoded;
} }
@@ -325,16 +327,51 @@ function extractSnippet(raw: string): string {
return body.replace(/\s+/g, ' ').trim().slice(0, 120); return body.replace(/\s+/g, ' ').trim().slice(0, 120);
} }
function decodeQuotedPrintable(text: string): string { function decodeQuotedPrintableBytes(text: string): Buffer {
return text const cleaned = text.replace(/=\r?\n/g, '');
.replace(/=\r?\n/g, '') const bytes: number[] = [];
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); 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<string, string> = {
'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(); const enc = encoding.toLowerCase();
if (enc === 'base64') return Buffer.from(body.replace(/\s/g, ''), 'base64').toString('utf-8'); const normalizedCharset = normalizeCharset(charset);
if (enc === 'quoted-printable') return decodeQuotedPrintable(body); 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; return body;
} }
@@ -349,7 +386,8 @@ function extractBody(raw: string): { html: string | null; text: string | null }
// Non-multipart: single body // Non-multipart: single body
if (!topCt.includes('multipart')) { if (!topCt.includes('multipart')) {
const body = raw.slice(headerEnd); 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 }; if (topCt.includes('text/html')) return { html: decoded, text: null };
return { html: null, text: decoded }; return { html: null, text: decoded };
} }
@@ -368,8 +406,10 @@ function extractBody(raw: string): { html: string | null; text: string | null }
const partHeaderEnd = findHeaderEnd(part); const partHeaderEnd = findHeaderEnd(part);
if (partHeaderEnd === -1) continue; 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 partEnc = extractFullHeader(part, 'Content-Transfer-Encoding');
const partCharset = extractCharset(partCtRaw);
const partBody = part.slice(partHeaderEnd); const partBody = part.slice(partHeaderEnd);
// Recurse into nested multipart (e.g. multipart/alternative inside multipart/mixed) // 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) { if (partCt.includes('text/html') && !html) {
html = decodePartBody(partBody, partEnc); html = decodePartBody(partBody, partEnc, partCharset);
} else if (partCt.includes('text/plain') && !text) { } 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[] { function parseAttachments(raw: string): AttachmentMeta[] {
const results: 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; let match: RegExpExecArray | null;
while ((match = regex.exec(raw)) !== null) { while ((match = regex.exec(raw)) !== null) {
@@ -404,6 +445,10 @@ function parseAttachments(raw: string): AttachmentMeta[] {
const partStart = raw.lastIndexOf('\n--', pos); const partStart = raw.lastIndexOf('\n--', pos);
const headerBlock = partStart !== -1 ? raw.slice(partStart, pos + 500) : raw.slice(Math.max(0, pos - 500), pos + 500); 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 // Extract filename from Content-Disposition or Content-Type
const fnMatch = headerBlock.match(/filename\*?=(?:"([^"]+)"|([^\s;]+))/i); const fnMatch = headerBlock.match(/filename\*?=(?:"([^"]+)"|([^\s;]+))/i);
const filename = fnMatch ? (fnMatch[1] ?? fnMatch[2] ?? 'unknown') : 'unknown'; const filename = fnMatch ? (fnMatch[1] ?? fnMatch[2] ?? 'unknown') : 'unknown';
@@ -430,7 +475,7 @@ function parseAttachments(raw: string): AttachmentMeta[] {
} else { } else {
// For quoted-printable or 7bit/8bit, re-encode to base64 // For quoted-printable or 7bit/8bit, re-encode to base64
const buf = encoding === 'quoted-printable' const buf = encoding === 'quoted-printable'
? Buffer.from(decodeQuotedPrintable(bodyRaw)) ? decodeQuotedPrintableBytes(bodyRaw)
: Buffer.from(bodyRaw); : Buffer.from(bodyRaw);
content = buf.toString('base64'); content = buf.toString('base64');
} }