mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
render html emails
This commit is contained in:
parent
78351da01b
commit
8a98f3501e
4 changed files with 186 additions and 31 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue