diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 78f8b55e..481c5e5d 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -47,7 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; -import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -493,6 +493,24 @@ export function setupIpcHandlers() { triggerGmailSync(); return {}; }, + 'gmail:sendReply': async (_event, args) => { + return sendThreadReply(args); + }, + 'gmail:getConnectionStatus': async () => { + return getGmailConnectionStatus(); + }, + 'gmail:getAccountEmail': async () => { + return { email: await getAccountEmail() }; + }, + 'gmail:archiveThread': async (_event, args) => { + return archiveThread(args.threadId); + }, + 'gmail:trashThread': async (_event, args) => { + return trashThread(args.threadId); + }, + 'gmail:markThreadRead': async (_event, args) => { + return markThreadRead(args.threadId); + }, 'gmail:saveMessageHeight': async (_event, args) => { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 6c6a2e25..28f9539c 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -232,6 +232,10 @@ color: var(--gm-text-faint); } +.gmail-row-shell { + position: relative; +} + .gmail-row { display: grid; grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px; @@ -249,6 +253,51 @@ transition: background 120ms ease; } +.gmail-row-actions { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 2px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.gmail-row-shell:hover .gmail-row-actions { + opacity: 1; + pointer-events: auto; +} + +.gmail-row-shell:hover .gmail-row-date { + visibility: hidden; +} + +.gmail-row-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-row-action:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + +.gmail-row-action-danger:hover { + color: #e8453c; +} + .gmail-row:hover { background: var(--gm-bg-row-hover); box-shadow: none; @@ -694,6 +743,126 @@ font: inherit; } +.gmail-compose-label { + flex: none; + min-width: 28px; + color: var(--gm-text-muted); +} + +.gmail-compose-subject-input { + min-width: 0; + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font: inherit; +} + +/* Recipient (To / Cc / Bcc) rows with editable chips */ +.gmail-recipient-row { + display: flex; + align-items: flex-start; + gap: 8px; + min-height: 34px; + padding: 5px 12px; + border-bottom: 1px solid var(--gm-border); + font-size: 13px; +} + +.gmail-recipient-label { + flex: none; + min-width: 28px; + padding-top: 5px; + color: var(--gm-text-muted); +} + +.gmail-recipient-field { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.gmail-recipient-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + height: 24px; + padding: 0 4px 0 10px; + border-radius: 12px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font-size: 12px; + line-height: 1; +} + +.gmail-recipient-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; +} + +.gmail-recipient-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--gm-text-muted); + font-size: 14px; + line-height: 1; + cursor: pointer; +} + +.gmail-recipient-chip-remove:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text); +} + +.gmail-recipient-input { + flex: 1 1 80px; + min-width: 80px; + height: 24px; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font: inherit; + font-size: 13px; +} + +.gmail-recipient-trailing { + flex: none; + padding-top: 5px; +} + +.gmail-recipient-toggles { + display: flex; + gap: 10px; +} + +.gmail-recipient-toggles button { + border: none; + background: transparent; + color: var(--gm-text-muted); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.gmail-recipient-toggles button:hover { + color: var(--gm-text); + text-decoration: underline; +} + .gmail-compose-toolbar { display: flex; align-items: center; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index d64c3fc1..32f022a3 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, Mail, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react' +import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -12,6 +12,12 @@ import { SettingsDialog } from '@/components/settings-dialog' type GmailThread = blocks.GmailThread type GmailThreadMessage = blocks.GmailThreadMessage +type GmailConnectionStatus = { + connected: boolean + hasRequiredScope: boolean + missingScopes: string[] + email: string | null +} function formatInboxTime(value?: string): string { if (!value) return '' @@ -80,6 +86,112 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined { return thread.messages[thread.messages.length - 1] } +// Split a raw header recipient string (e.g. `"Jo Bloggs" , b@y.com`) into +// individual address tokens, respecting commas inside quotes/angle brackets. +function splitAddresses(raw?: string): string[] { + if (!raw) return [] + const tokens: string[] = [] + let buf = '' + let inQuote = false + let depth = 0 + for (const ch of raw) { + if (ch === '"') inQuote = !inQuote + else if (ch === '<') depth += 1 + else if (ch === '>') depth = Math.max(0, depth - 1) + if ((ch === ',' || ch === ';' || ch === '\n') && !inQuote && depth === 0) { + const token = buf.trim() + if (token) tokens.push(token) + buf = '' + continue + } + buf += ch + } + const last = buf.trim() + if (last) tokens.push(last) + return tokens +} + +// Display label for a recipient chip: the display name if present, else the bare address. +function recipientLabel(token: string): string { + const named = token.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/) + if (named?.[1]?.trim()) return named[1].trim() + return extractAddress(token) +} + +// Dedupe tokens by lowercased email address, dropping any whose address is in `exclude`. +function dedupeRecipients(tokens: string[], exclude: Set): string[] { + const seen = new Set(exclude) + const out: string[] = [] + for (const token of tokens) { + const addr = extractAddress(token).toLowerCase() + if (!addr || seen.has(addr)) continue + seen.add(addr) + out.push(token) + } + return out +} + +// Compute the To / Cc recipients for a reply, reply-all, or forward, excluding "me". +function buildRecipients( + mode: ComposeMode, + thread: GmailThread, + selfEmail: string, +): { to: string[]; cc: string[] } { + if (mode === 'forward') return { to: [], cc: [] } + + const latest = latestMessage(thread) + const self = selfEmail.toLowerCase() + const fromAddr = latest?.from ? extractAddress(latest.from).toLowerCase() : '' + const iAmSender = Boolean(self) && fromAddr === self + + // If my own message is the latest, reply to whoever I sent it to; otherwise reply to the sender. + const rawTo = iAmSender ? splitAddresses(latest?.to) : (latest?.from ? [latest.from] : []) + const ccPool = iAmSender + ? splitAddresses(latest?.cc) + : [...splitAddresses(latest?.to), ...splitAddresses(latest?.cc)] + + const selfSet = new Set(self ? [self] : []) + const to = dedupeRecipients(rawTo, selfSet) + if (iAmSender && to.length === 0 && self && rawTo.some((token) => extractAddress(token).toLowerCase() === self)) { + to.push(self) + } + + if (mode === 'reply') return { to, cc: [] } + + const ccExclude = new Set(selfSet) + for (const token of to) ccExclude.add(extractAddress(token).toLowerCase()) + const cc = dedupeRecipients(ccPool, ccExclude) + return { to, cc } +} + +// Subject line for a reply ("Re: …") or forward ("Fwd: …"), avoiding double prefixes. +function composeSubject(mode: ComposeMode, rawSubject?: string): string { + const raw = (rawSubject || '').trim() + if (mode === 'forward') return /^fwd:/i.test(raw) ? raw : `Fwd: ${raw}`.trim() + return /^re:/i.test(raw) ? raw : `Re: ${raw}`.trim() +} + +function buildForwardedContent(thread: GmailThread): string { + const message = latestMessage(thread) + if (!message) return '' + const rows = [ + '---------- Forwarded message ---------', + message.from ? `From: ${message.from}` : null, + message.date ? `Date: ${formatFullDate(message.date)}` : null, + message.subject || thread.subject ? `Subject: ${message.subject || thread.subject}` : null, + message.to ? `To: ${message.to}` : null, + message.cc ? `Cc: ${message.cc}` : null, + ].filter((line): line is string => Boolean(line)) + const body = (message.body || snippet(message.bodyHtml)).trim() + return [ + '

', + '
', + ...rows.map((line) => `

${escapeHtml(line)}

`), + body ? `

${escapeHtml(body).replace(/\n/g, '
')}

` : '', + '
', + ].join('') +} + const PREFETCH_HOVER_MS = 180 const PREFETCH_MAX_IMAGES_PER_THREAD = 12 @@ -374,7 +486,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable void + autoFocus?: boolean + trailing?: React.ReactNode +}) { + const [draft, setDraft] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + if (autoFocus) inputRef.current?.focus() + }, [autoFocus]) + + const commit = (raw: string) => { + const additions = splitAddresses(raw) + if (additions.length === 0) return + onChange(dedupeRecipients([...value, ...additions], new Set())) + setDraft('') + } + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) { + if (draft.trim()) { + event.preventDefault() + commit(draft) + } + } else if (event.key === 'Backspace' && !draft && value.length > 0) { + onChange(value.slice(0, -1)) + } + } + + return ( +
+ {label} +
+ {value.map((token, index) => ( + + {recipientLabel(token)} + + + ))} + setDraft(event.target.value)} + onKeyDown={onKeyDown} + onBlur={() => { if (draft.trim()) commit(draft) }} + onPaste={(event) => { + const text = event.clipboardData.getData('text') + if (text && /[,;\n]/.test(text)) { + event.preventDefault() + commit(text) + } + }} + /> +
+ {trailing &&
{trailing}
} +
+ ) +} + function ComposeBox({ mode, thread, + selfEmail, onClose, }: { mode: ComposeMode thread: GmailThread + selfEmail: string onClose: () => void }) { const latest = latestMessage(thread) - const to = mode === 'reply' ? extractAddress(latest?.from) : '' + const initialRecipients = useMemo( + () => buildRecipients(mode, thread, selfEmail), + [mode, thread, selfEmail], + ) + + const [toList, setToList] = useState(initialRecipients.to) + const [ccList, setCcList] = useState(initialRecipients.cc) + const [bccList, setBccList] = useState([]) + const [showCc, setShowCc] = useState(initialRecipients.cc.length > 0) + const [showBcc, setShowBcc] = useState(false) + const [subject, setSubject] = useState(() => composeSubject(mode, thread.subject)) + const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply' const initialContent = useMemo(() => { - if (mode !== 'reply') return '' + if (mode === 'forward') return buildForwardedContent(thread) // Gmail-side draft (user's own work) wins over the AI-generated draft. const source = thread.gmail_draft || thread.draft_response if (!source) return '' @@ -496,14 +698,14 @@ function ComposeBox({ .split(/\n{2,}/) .map((para) => `

${escapeHtml(para).replace(/\n/g, '
')}

`) .join('') - }, [mode, thread.gmail_draft, thread.draft_response]) + }, [mode, thread]) const editor = useEditor({ extensions: [ StarterKit.configure({ link: false }), Link.configure({ openOnClick: false, autolink: true }), Placeholder.configure({ - placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…', + placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…', }), ], editorProps: { @@ -555,52 +757,65 @@ function ComposeBox({ if (editor && sel) editor.chain().focus().setTextSelection(sel).run() } + const [sending, setSending] = useState(false) const sendInGmail = async () => { - if (!editor) { - window.open(thread.threadUrl, '_blank') - return - } + if (!editor || sending) return const html = editor.getHTML() const text = editor.getText().trim() - - let copied = false - if (text) { - try { - if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) { - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/html': new Blob([html], { type: 'text/html' }), - 'text/plain': new Blob([text], { type: 'text/plain' }), - }), - ]) - copied = true - } else if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) - copied = true - } - } catch (err) { - console.warn('[Gmail] clipboard write failed:', err) - } + if (!text) { + toast('Draft is empty.', 'error') + return } - window.open(thread.threadUrl, '_blank') - if (copied) { - toast('Draft copied — open the reply in Gmail and paste.', 'info') - } else if (text) { - toast('Could not copy draft. Open Gmail and paste manually.', 'error') + if (toList.length === 0) { + toast('Add at least one recipient.', 'error') + return + } + + // Build References chain from all known message ids (newest last). + const messageIds = thread.messages + .map((m) => m.messageIdHeader) + .filter((v): v is string => Boolean(v)) + const references = messageIds.join(' ') + const inReplyTo = latest?.messageIdHeader + const isForward = mode === 'forward' + + setSending(true) + try { + const result = await window.ipc.invoke('gmail:sendReply', { + threadId: isForward ? undefined : thread.threadId, + to: toList.join(', '), + cc: ccList.length ? ccList.join(', ') : undefined, + bcc: bccList.length ? bccList.join(', ') : undefined, + subject: subject.trim() || composeSubject(mode, thread.subject), + bodyHtml: html, + bodyText: text, + inReplyTo: isForward ? undefined : inReplyTo, + references: isForward ? undefined : references || undefined, + }) + if (result.error) { + toast(`Send failed: ${result.error}`, 'error') + return + } + toast('Sent.', 'success') + onClose() + } catch (err) { + toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error') + } finally { + setSending(false) } } const refineWithCopilot = () => { if (!editor) return const currentDraft = editor.getText().trim() - const subject = thread.subject || '(No subject)' + const threadSubject = thread.subject || '(No subject)' const lines: string[] = [] lines.push(`Help me refine this draft email response. **Please ask me how I want to refine it before making any changes** — wait for my answer, then apply the edits.`) lines.push('') - lines.push(`**Mode:** ${mode === 'reply' ? 'Reply' : 'Forward'}`) - lines.push(`**Subject:** ${subject}`) + lines.push(`**Mode:** ${modeLabel}`) + lines.push(`**Subject:** ${threadSubject}`) lines.push('') lines.push(`## Thread (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`) lines.push('') @@ -625,17 +840,32 @@ function ComposeBox({ return (
- {mode === 'reply' ? 'Reply' : 'Forward'} - -
-
- {mode === 'reply' ? 'To' : 'Recipients'} - + {modeLabel} +
+ + {!showCc && } + {!showBcc && } +
+ } + /> + {showCc && } + {showBcc && } {mode === 'forward' && (
- Subject - + Subject + setSubject(event.target.value)} + placeholder="Subject" + />
)} @@ -666,10 +896,11 @@ function ComposeBox({ type="button" className="gmail-send-button" onClick={() => { void sendInGmail() }} - title="Copy draft and open this thread in Gmail" + disabled={sending} + title="Send this reply via Gmail" > - - Send + {sending ? : } + {sending ? 'Sending…' : 'Send'} + {canReplyAll && ( + + )} + +
+ {isUnread && ( + + )} + + +
+ {isMounted && ( )} - ) : emailConnected === false ? ( + ) : needsEmailConnect || needsEmailReconnect ? (
-

Connect your email to see your inbox here.

+

+ {needsEmailReconnect + ? 'Reconnect your email to enable Gmail sync and actions.' + : 'Connect your email to see your inbox here.'} +

) : ( diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 52bd0ab5..732d56ab 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -75,9 +75,8 @@ const providerConfigs: ProviderConfig = { mode: 'static', }, scopes: [ - 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/calendar.events.readonly', - 'https://www.googleapis.com/auth/drive.readonly', ], }, 'fireflies-ai': { @@ -119,4 +118,3 @@ export async function getProviderConfig(providerName: string): Promise { + const status = await this.getCredentialStatus(requiredScopes); + return status.hasRequiredScopes; + } + + static async getCredentialStatus(requiredScopes: string | string[]): Promise<{ + connected: boolean; + hasRequiredScopes: boolean; + missingScopes: string[]; + }> { const oauthRepo = container.resolve('oauthRepo'); const { tokens } = await oauthRepo.read(this.PROVIDER_NAME); if (!tokens) { - return false; + const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes]; + return { + connected: false, + hasRequiredScopes: false, + missingScopes: scopesArray, + }; } - // Check if required scope(s) are present const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes]; + const granted = new Set(tokens.scopes ?? []); + const missingScopes = scopesArray.filter(scope => !granted.has(scope)); if (!tokens.scopes || tokens.scopes.length === 0) { - return false; + return { + connected: true, + hasRequiredScopes: false, + missingScopes, + }; } - return scopesArray.every(scope => tokens.scopes!.includes(scope)); + return { + connected: true, + hasRequiredScopes: missingScopes.length === 0, + missingScopes, + }; } /** diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 8057df86..1f98736a 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -26,7 +26,7 @@ const CACHE_DIR = path.join(WorkDir, 'inbox_lists'); } })(); const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds -const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; +const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.modify'; const MAX_THREADS_IN_DIGEST = 10; const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000; const nhm = new NodeHtmlMarkdown(); @@ -78,6 +78,76 @@ export function saveMessageBodyHeight(threadId: string, messageId: string, heigh } } +function deleteCachedSnapshot(threadId: string): void { + try { + fs.rmSync(cachePath(threadId), { force: true }); + } catch (err) { + console.warn(`[Gmail cache] delete failed for ${threadId}:`, err); + } +} + +async function getGmailClientOrThrow() { + const auth = await GoogleClientFactory.getClient(); + if (!auth) throw new Error('Gmail is not connected.'); + return google.gmail({ version: 'v1', auth }); +} + +export interface ThreadActionResult { + ok: boolean; + error?: string; +} + +export async function archiveThread(threadId: string): Promise { + try { + const gmailClient = await getGmailClientOrThrow(); + await gmailClient.users.threads.modify({ + userId: 'me', + id: threadId, + requestBody: { removeLabelIds: ['INBOX'] }, + }); + deleteCachedSnapshot(threadId); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function trashThread(threadId: string): Promise { + try { + const gmailClient = await getGmailClientOrThrow(); + await gmailClient.users.threads.trash({ userId: 'me', id: threadId }); + deleteCachedSnapshot(threadId); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function markThreadRead(threadId: string): Promise { + try { + const gmailClient = await getGmailClientOrThrow(); + await gmailClient.users.threads.modify({ + userId: 'me', + id: threadId, + requestBody: { removeLabelIds: ['UNREAD'] }, + }); + // Update local cache: clear unread on all messages in the thread. + const cached = readCachedSnapshot(threadId); + if (cached) { + for (const m of cached.snapshot.messages) m.unread = false; + cached.snapshot.unread = false; + try { + fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8'); + } catch (err) { + console.warn(`[Gmail cache] markRead write failed for ${threadId}:`, err); + } + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + interface SyncedThread { threadId: string; markdown: string; @@ -114,6 +184,7 @@ export interface GmailThreadSnapshot { sizeBytes?: number; savedPath: string; }>; + messageIdHeader?: string; }>; } @@ -1158,6 +1229,162 @@ async function performSync() { } } +// --- Send Reply --- + +export interface SendReplyOptions { + threadId?: string; + to: string; + cc?: string; + bcc?: string; + subject: string; + bodyHtml: string; + bodyText: string; + inReplyTo?: string; + references?: string; +} + +export interface SendReplyResult { + messageId?: string; + error?: string; +} + +export interface GmailConnectionStatus { + connected: boolean; + hasRequiredScope: boolean; + missingScopes: string[]; + email: string | null; +} + +/** The connected Gmail address (cached). Used by the composer to exclude "me" from reply-all. */ +export async function getAccountEmail(): Promise { + const auth = await GoogleClientFactory.getClient(); + if (!auth) return null; + return getUserEmail(auth); +} + +export async function getConnectionStatus(): Promise { + const status = await GoogleClientFactory.getCredentialStatus(REQUIRED_SCOPE); + let email: string | null = null; + if (status.connected) { + try { + email = await getAccountEmail(); + } catch { + email = null; + } + } + return { + connected: status.connected, + hasRequiredScope: status.hasRequiredScopes, + missingScopes: status.missingScopes, + email, + }; +} + +function requireSafeHeaderValue(name: string, value: string): string { + if (/[\r\n]/.test(value)) { + throw new Error(`${name} cannot contain line breaks.`); + } + return value.trim(); +} + +function encodeRfc2047(text: string): string { + requireSafeHeaderValue('Subject', text); + // Only encode if non-ASCII chars present. + // eslint-disable-next-line no-control-regex + if (/^[\x00-\x7F]*$/.test(text)) return text; + return `=?UTF-8?B?${Buffer.from(text).toString('base64')}?=`; +} + +function encodeMimeBase64(text: string): string { + return Buffer.from(text, 'utf8') + .toString('base64') + .match(/.{1,76}/g) + ?.join('\r\n') ?? ''; +} + +export async function sendThreadReply(opts: SendReplyOptions): Promise { + try { + const auth = await GoogleClientFactory.getClient(); + if (!auth) return { error: 'Gmail is not connected.' }; + + const gmailClient = google.gmail({ version: 'v1', auth }); + const userEmail = await getUserEmail(auth); + if (!userEmail) return { error: 'Could not determine your Gmail address.' }; + + const safeTo = requireSafeHeaderValue('To', opts.to); + const safeCc = opts.cc?.trim() ? requireSafeHeaderValue('Cc', opts.cc) : undefined; + const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined; + const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined; + const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined; + + const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + const headers: string[] = []; + headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`); + headers.push(`To: ${safeTo}`); + if (safeCc) headers.push(`Cc: ${safeCc}`); + if (safeBcc) headers.push(`Bcc: ${safeBcc}`); + headers.push(`Subject: ${encodeRfc2047(opts.subject)}`); + if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`); + if (safeReferences) headers.push(`References: ${safeReferences}`); + headers.push('MIME-Version: 1.0'); + headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); + + const parts: string[] = []; + parts.push(`--${boundary}`); + parts.push('Content-Type: text/plain; charset="UTF-8"'); + parts.push('Content-Transfer-Encoding: base64'); + parts.push(''); + parts.push(encodeMimeBase64(opts.bodyText)); + parts.push(''); + parts.push(`--${boundary}`); + parts.push('Content-Type: text/html; charset="UTF-8"'); + parts.push('Content-Transfer-Encoding: base64'); + parts.push(''); + parts.push(encodeMimeBase64(opts.bodyHtml)); + parts.push(''); + parts.push(`--${boundary}--`); + + const message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`; + const raw = Buffer.from(message, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + const requestBody: gmail.Schema$Message = { raw }; + if (opts.threadId) requestBody.threadId = opts.threadId; + + const res = await gmailClient.users.messages.send({ + userId: 'me', + requestBody, + }); + + if (opts.threadId) { + // Clean up any Gmail-side drafts in this thread. + try { + const drafts = await gmailClient.users.drafts.list({ userId: 'me' }); + const matching = (drafts.data.drafts || []).filter( + (d) => d.message?.threadId === opts.threadId && d.id + ); + await Promise.all( + matching.map((d) => + gmailClient.users.drafts.delete({ userId: 'me', id: d.id! }) + ) + ); + } catch (cleanupErr) { + console.warn('[Gmail] Draft cleanup after send failed:', cleanupErr); + } + } + + // Wake the sync loop so the cache picks up the new message. + triggerSync(); + + return { messageId: res.data.id || undefined }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + export async function init() { console.log("Starting Gmail Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index ce8ce43c..69b331c4 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -123,6 +123,7 @@ export const GmailThreadMessageSchema = z.object({ unread: z.boolean().optional(), bodyHeight: z.number().int().positive().optional(), attachments: z.array(GmailAttachmentSchema).optional(), + messageIdHeader: z.string().optional(), }); export type GmailThreadMessage = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 37cf41e7..230d384c 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -148,6 +148,50 @@ const ipcSchemas = { req: z.object({}), res: z.object({}), }, + 'gmail:sendReply': { + req: z.object({ + threadId: z.string().min(1).optional(), + to: z.string().min(1), + cc: z.string().optional(), + bcc: z.string().optional(), + subject: z.string(), + bodyHtml: z.string(), + bodyText: z.string(), + inReplyTo: z.string().optional(), + references: z.string().optional(), + }), + res: z.object({ + messageId: z.string().optional(), + error: z.string().optional(), + }), + }, + 'gmail:getConnectionStatus': { + req: z.object({}), + res: z.object({ + connected: z.boolean(), + hasRequiredScope: z.boolean(), + missingScopes: z.array(z.string()), + email: z.string().nullable(), + }), + }, + 'gmail:getAccountEmail': { + req: z.object({}), + res: z.object({ + email: z.string().nullable(), + }), + }, + 'gmail:archiveThread': { + req: z.object({ threadId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, + 'gmail:trashThread': { + req: z.object({ threadId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, + 'gmail:markThreadRead': { + req: z.object({ threadId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, 'gmail:saveMessageHeight': { req: z.object({ threadId: z.string().min(1),