mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
support attachments in incoming emails
This commit is contained in:
parent
1e90ce1a49
commit
24e9faa5b1
4 changed files with 153 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue