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:
@@ -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<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();
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user