render html emails

This commit is contained in:
Arjun 2026-05-13 14:08:50 +05:30
parent 78351da01b
commit 8a98f3501e
4 changed files with 186 additions and 31 deletions

View file

@ -349,6 +349,15 @@
white-space: pre-wrap;
}
.gmail-message-iframe {
display: block;
width: 100%;
max-width: 820px;
margin-top: 14px;
border: 0;
background: #fff;
}
.gmail-thread-actions {
display: flex;
gap: 8px;

View file

@ -90,6 +90,86 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined {
return thread.messages[thread.messages.length - 1]
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function buildEmailDocument(html: string): string {
return `<!doctype html>
<html><head>
<meta charset="utf-8">
<base target="_blank">
<style>
html, body { margin: 0; padding: 0; }
body {
font: 14px/1.6 Arial, sans-serif;
color: #202124;
overflow-x: auto;
overflow-y: hidden;
word-wrap: break-word;
}
img { max-width: 100%; height: auto; }
table { max-width: 100%; }
a { color: #1a73e8; }
blockquote {
margin: 0 0 0 6px;
padding-left: 12px;
border-left: 2px solid #dadce0;
color: #5f6368;
}
</style>
</head><body>${html}</body></html>`
}
function MessageBody({ message }: { message: GmailThreadMessage }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const observerRef = useRef<ResizeObserver | null>(null)
const [height, setHeight] = useState(80)
const srcDoc = useMemo(() => {
if (message.bodyHtml && message.bodyHtml.trim()) {
return buildEmailDocument(message.bodyHtml)
}
const text = (message.body || '(No message body)').trim()
return buildEmailDocument(`<pre style="white-space: pre-wrap; font: inherit; margin: 0;">${escapeHtml(text)}</pre>`)
}, [message.bodyHtml, message.body])
const handleLoad = useCallback(() => {
const iframe = iframeRef.current
const doc = iframe?.contentDocument
if (!doc?.body) return
const measure = () => {
const next = Math.max(40, doc.documentElement.scrollHeight)
setHeight((current) => (current === next ? current : next))
}
measure()
observerRef.current?.disconnect()
if (typeof ResizeObserver !== 'undefined') {
observerRef.current = new ResizeObserver(measure)
observerRef.current.observe(doc.body)
}
}, [])
useEffect(() => () => observerRef.current?.disconnect(), [])
return (
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
className="gmail-message-iframe"
style={{ height }}
onLoad={handleLoad}
/>
)
}
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
@ -207,7 +287,7 @@ function ThreadDetail({
<div className="gmail-message-date">{formatFullDate(message.date)}</div>
</div>
<div className="gmail-message-to">to {message.to || 'me'}</div>
<div className="gmail-message-body">{message.body || '(No message body)'}</div>
<MessageBody message={message} />
</div>
</div>
)

View file

@ -39,6 +39,7 @@ export interface GmailThreadSnapshot {
date?: string;
subject?: string;
body?: string;
bodyHtml?: string;
}>;
}
@ -114,35 +115,87 @@ function decodeBase64(data: string): string {
return Buffer.from(data, 'base64').toString('utf-8');
}
function extractBodyParts(payload: gmail.Schema$MessagePart): { text: string; html: string } {
const out = { text: '', html: '' };
const walk = (part: gmail.Schema$MessagePart): void => {
const mime = part.mimeType || '';
if (mime === 'text/html' && part.body?.data) {
if (!out.html) out.html = decodeBase64(part.body.data);
return;
}
if (mime === 'text/plain' && part.body?.data) {
if (!out.text) out.text = decodeBase64(part.body.data);
return;
}
if (part.parts) {
for (const sub of part.parts) walk(sub);
}
};
walk(payload);
return out;
}
function getBody(payload: gmail.Schema$MessagePart): string {
let body = "";
if (payload.parts) {
for (const part of payload.parts) {
if (part.mimeType === 'text/plain' && part.body && part.body.data) {
const text = decodeBase64(part.body.data);
// Strip quoted lines
const cleanLines = text.split('\n').filter((line: string) => !line.trim().startsWith('>'));
body += cleanLines.join('\n');
} else if (part.mimeType === 'text/html' && part.body && part.body.data) {
const html = decodeBase64(part.body.data);
const md = nhm.translate(html);
// Simple quote stripping for MD
const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>'));
body += cleanLines.join('\n');
} else if (part.parts) {
body += getBody(part);
}
}
} else if (payload.body && payload.body.data) {
const data = decodeBase64(payload.body.data);
if (payload.mimeType === 'text/html') {
const md = nhm.translate(data);
body += md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
} else {
body += data.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
}
const { text, html } = extractBodyParts(payload);
if (html) {
const md = nhm.translate(html);
return md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
}
return body;
if (text) {
return text.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
}
return '';
}
async function inlineCidImages(
gmailClient: gmail.Gmail,
messageId: string,
payload: gmail.Schema$MessagePart,
html: string,
): Promise<string> {
if (!/src\s*=\s*["']?cid:/i.test(html)) return html;
const inlineParts: Array<{ contentId: string; mimeType: string; attachmentId: string }> = [];
const collect = (part: gmail.Schema$MessagePart): void => {
const cidHeader = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value;
const attachmentId = part.body?.attachmentId;
const mime = part.mimeType || '';
if (cidHeader && attachmentId && mime.startsWith('image/')) {
inlineParts.push({
contentId: cidHeader.replace(/^<|>$/g, '').trim(),
mimeType: mime,
attachmentId,
});
}
if (part.parts) for (const sub of part.parts) collect(sub);
};
collect(payload);
if (inlineParts.length === 0) return html;
const dataUrls = new Map<string, string>();
await Promise.all(inlineParts.map(async (part) => {
try {
const res = await gmailClient.users.messages.attachments.get({
userId: 'me',
messageId,
id: part.attachmentId,
});
const b64 = res.data.data;
if (!b64) return;
// Gmail returns base64url; data URLs need standard base64
const normalized = b64.replace(/-/g, '+').replace(/_/g, '/');
dataUrls.set(part.contentId, `data:${part.mimeType};base64,${normalized}`);
} catch (err) {
console.warn(`[Gmail] inline image fetch failed for ${part.contentId}:`, err);
}
}));
let rewritten = html;
for (const [cid, url] of dataUrls) {
const escaped = cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
rewritten = rewritten.replace(new RegExp(`cid:${escaped}`, 'gi'), url);
}
return rewritten;
}
function normalizeBody(body: string): string {
@ -164,8 +217,19 @@ export async function fetchThreadSnapshot(threadId: string): Promise<GmailThread
const messages = res.data.messages;
if (!messages || messages.length === 0) return null;
const parsed = messages.map((msg) => {
const parsed = await Promise.all(messages.map(async (msg) => {
const headers = msg.payload?.headers || [];
const parts = msg.payload ? extractBodyParts(msg.payload) : { text: '', html: '' };
const body = msg.payload ? normalizeBody(getBody(msg.payload)) : '';
let bodyHtml: string | undefined;
if (parts.html && msg.payload && msg.id) {
try {
bodyHtml = await inlineCidImages(gmailClient, msg.id, msg.payload, parts.html);
} catch (err) {
console.warn(`[Gmail] inline image embed failed for message ${msg.id}:`, err);
bodyHtml = parts.html;
}
}
return {
id: msg.id || undefined,
from: headerValue(headers, 'From') || 'Unknown',
@ -173,9 +237,10 @@ export async function fetchThreadSnapshot(threadId: string): Promise<GmailThread
cc: headerValue(headers, 'Cc'),
date: headerValue(headers, 'Date'),
subject: headerValue(headers, 'Subject') || '(No Subject)',
body: msg.payload ? normalizeBody(getBody(msg.payload)) : '',
body,
bodyHtml,
};
});
}));
const latest = parsed[parsed.length - 1]!;
const earlier = parsed.slice(0, -1);

View file

@ -110,6 +110,7 @@ export const GmailThreadMessageSchema = z.object({
date: z.string().optional(),
subject: z.string().optional(),
body: z.string().optional(),
bodyHtml: z.string().optional(),
});
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;