diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css
index d14a85cb..2babd40d 100644
--- a/apps/x/apps/renderer/src/App.css
+++ b/apps/x/apps/renderer/src/App.css
@@ -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;
diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx
index af978bfe..9c4fee0d 100644
--- a/apps/x/apps/renderer/src/components/email-view.tsx
+++ b/apps/x/apps/renderer/src/components/email-view.tsx
@@ -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, ''')
+}
+
+function buildEmailDocument(html: string): string {
+ return `
+
+
+
+
+${html}`
+}
+
+function MessageBody({ message }: { message: GmailThreadMessage }) {
+ const iframeRef = useRef(null)
+ const observerRef = useRef(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(`${escapeHtml(text)}`)
+ }, [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 (
+
+ )
+}
+
async function mapWithConcurrency(
items: T[],
limit: number,
@@ -207,7 +287,7 @@ function ThreadDetail({
{formatFullDate(message.date)}
to {message.to || 'me'}
- {message.body || '(No message body)'}
+
)
diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts
index b1d5ee59..36935277 100644
--- a/apps/x/packages/core/src/knowledge/sync_gmail.ts
+++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts
@@ -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 {
+ 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();
+ 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 {
+ 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;