);
};
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');
}