support attachments in incoming emails

This commit is contained in:
Arjun 2026-05-18 21:09:56 +05:30
parent 1e90ce1a49
commit 24e9faa5b1
4 changed files with 153 additions and 1 deletions

View file

@ -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;

View file

@ -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
<span></span>
</button>
)}
{message.attachments && message.attachments.length > 0 && (
<MessageAttachments attachments={message.attachments} />
)}
</>
)
}
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<GmailThreadMessage['attachments']> }) {
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 (
<div className="gmail-message-attachments">
{attachments.map((att) => {
const size = formatAttachmentSize(att.sizeBytes)
return (
<button
key={att.savedPath}
type="button"
className="gmail-attachment"
onClick={() => openAttachment(att.savedPath, att.filename)}
title={`Open ${att.filename}`}
>
<Paperclip size={13} />
<span className="gmail-attachment-name">{att.filename}</span>
{size && <span className="gmail-attachment-size">{size}</span>}
</button>
)
})}
</div>
)
}
type ComposeMode = 'reply' | 'forward'
function ComposeToolbarButton({

View file

@ -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,
};
}));

View file

@ -102,6 +102,15 @@ export const EmailBlockSchema = z.object({
export type EmailBlock = z.infer<typeof EmailBlockSchema>;
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<typeof GmailAttachmentSchema>;
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<typeof GmailThreadMessageSchema>;