diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index be2926d2..67616c32 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -541,6 +541,50 @@ border-color: var(--gm-accent); } +.gmail-message-attachments { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; + max-width: 820px; +} + +.gmail-attachment { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 320px; + padding: 6px 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 6px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font: inherit; + font-size: 12px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.gmail-attachment:hover { + background: var(--gm-bg-pill-hover); + border-color: var(--gm-accent); + color: var(--gm-accent); +} + +.gmail-attachment-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 500; +} + +.gmail-attachment-size { + flex-shrink: 0; + color: var(--gm-text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + .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 79df6f49..d5d8248f 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react' +import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -268,10 +268,55 @@ function MessageBody({ message, threadId }: { message: GmailThreadMessage; threa ••• )} + {message.attachments && message.attachments.length > 0 && ( + + )} ) } +function formatAttachmentSize(bytes?: number): string { + if (!bytes || bytes <= 0) return '' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +function MessageAttachments({ attachments }: { attachments: NonNullable }) { + const openAttachment = (path: string, filename: string) => { + void window.ipc + .invoke('shell:openPath', { path }) + .then((result) => { + if (result?.error) toast(`Could not open ${filename}: ${result.error}`, 'error') + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err) + toast(`Could not open ${filename}: ${message}`, 'error') + }) + } + + return ( +
+ {attachments.map((att) => { + const size = formatAttachmentSize(att.sizeBytes) + return ( + + ) + })} +
+ ) +} + type ComposeMode = 'reply' | 'forward' function ComposeToolbarButton({ diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 2c8ebd1f..69a0a58e 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -107,6 +107,12 @@ export interface GmailThreadSnapshot { bodyHtml?: string; unread?: boolean; bodyHeight?: number; + attachments?: Array<{ + filename: string; + mimeType?: string; + sizeBytes?: number; + savedPath: string; + }>; }>; } @@ -214,6 +220,51 @@ function getBody(payload: gmail.Schema$MessagePart): string { return ''; } +interface ExtractedAttachment { + filename: string; + mimeType?: string; + sizeBytes?: number; + savedPath: string; +} + +/** + * Walk a message MIME tree and collect "real" attachments — parts with a + * filename + attachmentId, excluding cid-referenced inline images (those + * already get baked into bodyHtml as data URLs). + * + * Returns workspace-relative paths matching the convention used by + * saveAttachment / processThread, so the renderer can hand them to + * shell.openPath via the existing IPC. + */ +function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart): ExtractedAttachment[] { + const out: ExtractedAttachment[] = []; + const walk = (part: gmail.Schema$MessagePart): void => { + const filename = part.filename; + const attId = part.body?.attachmentId; + if (filename && attId) { + // Exclude only true inline images (image/* with a Content-ID, which + // get baked into bodyHtml as data URLs by inlineCidImages). Other + // parts with Content-ID — PDFs, .log files, .ics, etc. — are real + // attachments; Gmail just stamps Content-ID on most parts. + const cid = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value; + const mime = part.mimeType || ''; + const isInlineImage = !!cid && mime.startsWith('image/'); + if (!isInlineImage) { + const safeName = `${msgId}_${cleanFilename(filename)}`; + out.push({ + filename, + mimeType: part.mimeType ?? undefined, + sizeBytes: typeof part.body?.size === 'number' ? part.body.size : undefined, + savedPath: `gmail_sync/attachments/${safeName}`, + }); + } + } + if (part.parts) for (const sub of part.parts) walk(sub); + }; + walk(payload); + return out; +} + async function inlineCidImages( gmailClient: gmail.Gmail, messageId: string, @@ -479,6 +530,7 @@ async function buildAndCacheSnapshot( } } const isDraft = msg.labelIds?.includes('DRAFT') ?? false; + const attachments = msg.payload && msg.id ? extractAttachments(msg.id, msg.payload) : []; return { id: msg.id || undefined, from: headerValue(headers, 'From') || 'Unknown', @@ -491,6 +543,7 @@ async function buildAndCacheSnapshot( unread: msg.labelIds?.includes('UNREAD') ?? false, bodyHeight: msg.id ? heightCarryover.get(msg.id) : undefined, messageIdHeader: headerValue(headers, 'Message-ID') || headerValue(headers, 'Message-Id') || undefined, + attachments: attachments.length > 0 ? attachments : undefined, isDraft, }; })); diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index b5552c28..ce8ce43c 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -102,6 +102,15 @@ export const EmailBlockSchema = z.object({ export type EmailBlock = z.infer; +export const GmailAttachmentSchema = z.object({ + filename: z.string(), + mimeType: z.string().optional(), + sizeBytes: z.number().int().nonnegative().optional(), + savedPath: z.string(), +}); + +export type GmailAttachment = z.infer; + export const GmailThreadMessageSchema = z.object({ id: z.string().optional(), from: z.string().optional(), @@ -113,6 +122,7 @@ export const GmailThreadMessageSchema = z.object({ bodyHtml: z.string().optional(), unread: z.boolean().optional(), bodyHeight: z.number().int().positive().optional(), + attachments: z.array(GmailAttachmentSchema).optional(), }); export type GmailThreadMessage = z.infer;