diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e1497e16..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, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail } 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'; @@ -496,6 +496,9 @@ export function setupIpcHandlers() { 'gmail:sendReply': async (_event, args) => { return sendThreadReply(args); }, + 'gmail:getConnectionStatus': async () => { + return getGmailConnectionStatus(); + }, 'gmail:getAccountEmail': async () => { return { email: await getAccountEmail() }; }, diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 1848b2f2..600ebd96 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -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 '' @@ -162,6 +168,27 @@ function composeSubject(mode: ComposeMode, rawSubject?: string): string { 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 @@ -660,7 +687,7 @@ function ComposeBox({ const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply' const initialContent = useMemo(() => { - if (mode === 'forward') 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 '' @@ -668,7 +695,7 @@ 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: [ @@ -748,19 +775,20 @@ function ComposeBox({ .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: thread.threadId, + 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, - references: references || undefined, + inReplyTo: isForward ? undefined : inReplyTo, + references: isForward ? undefined : references || undefined, }) if (result.error) { toast(`Send failed: ${result.error}`, 'error') @@ -1067,17 +1095,24 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [error, setError] = useState(null) const [query, setQuery] = useState('') // Gmail sync uses the native Google OAuth connection. - const [emailConnected, setEmailConnected] = useState(null) + const [emailConnection, setEmailConnection] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) useEffect(() => { let cancelled = false const check = async () => { try { - const oauthState = await window.ipc.invoke('oauth:getState', null) - if (!cancelled) setEmailConnected(oauthState.config?.google?.connected ?? false) + const status = await window.ipc.invoke('gmail:getConnectionStatus', {}) + if (!cancelled) setEmailConnection(status) } catch { - if (!cancelled) setEmailConnected(false) + if (!cancelled) { + setEmailConnection({ + connected: false, + hasRequiredScope: false, + missingScopes: [], + email: null, + }) + } } } void check() @@ -1131,20 +1166,26 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = }, [updateThreadInState]) const archiveThreadAction = useCallback(async (threadId: string) => { - removeThreadFromState(threadId) try { const result = await window.ipc.invoke('gmail:archiveThread', { threadId }) - if (!result.ok && result.error) toast(`Archive failed: ${result.error}`, 'error') + if (result.ok) { + removeThreadFromState(threadId) + } else if (result.error) { + toast(`Archive failed: ${result.error}`, 'error') + } } catch (err) { toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error') } }, [removeThreadFromState]) const trashThreadAction = useCallback(async (threadId: string) => { - removeThreadFromState(threadId) try { const result = await window.ipc.invoke('gmail:trashThread', { threadId }) - if (!result.ok && result.error) toast(`Delete failed: ${result.error}`, 'error') + if (result.ok) { + removeThreadFromState(threadId) + } else if (result.error) { + toast(`Delete failed: ${result.error}`, 'error') + } } catch (err) { toast(`Delete failed: ${err instanceof Error ? err.message : String(err)}`, 'error') } @@ -1411,6 +1452,8 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const hasAny = important.threads.length > 0 || other.threads.length > 0 const initialLoading = !hasAny && refreshing + const needsEmailConnect = emailConnection?.connected === false + const needsEmailReconnect = emailConnection?.connected === true && !emailConnection.hasRequiredScope const renderRow = (thread: GmailThread) => { const latest = latestMessage(thread) @@ -1541,17 +1584,21 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = )} - ) : 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/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts index aa47dbc2..db5da7c6 100644 --- a/apps/x/packages/core/src/knowledge/google-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts @@ -188,18 +188,41 @@ export class GoogleClientFactory { * Check if credentials are available and have required scopes */ static async hasValidCredentials(requiredScopes: string | 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 c0aa82e5..1f98736a 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -1232,7 +1232,7 @@ async function performSync() { // --- Send Reply --- export interface SendReplyOptions { - threadId: string; + threadId?: string; to: string; cc?: string; bcc?: string; @@ -1248,6 +1248,13 @@ export interface SendReplyResult { 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(); @@ -1255,74 +1262,118 @@ export async function getAccountEmail(): Promise { 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')}?=`; } -export async function sendThreadReply(opts: SendReplyOptions): Promise { - 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 boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; - const headers: string[] = []; - headers.push(`From: ${userEmail}`); - headers.push(`To: ${opts.to}`); - if (opts.cc?.trim()) headers.push(`Cc: ${opts.cc.trim()}`); - if (opts.bcc?.trim()) headers.push(`Bcc: ${opts.bcc.trim()}`); - headers.push(`Subject: ${encodeRfc2047(opts.subject)}`); - if (opts.inReplyTo) headers.push(`In-Reply-To: ${opts.inReplyTo}`); - if (opts.references) headers.push(`References: ${opts.references}`); - 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: 7bit'); - parts.push(''); - parts.push(opts.bodyText); - parts.push(''); - parts.push(`--${boundary}`); - parts.push('Content-Type: text/html; charset="UTF-8"'); - parts.push('Content-Transfer-Encoding: 7bit'); - parts.push(''); - parts.push(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) +function encodeMimeBase64(text: string): string { + return Buffer.from(text, 'utf8') .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + .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: { raw, threadId: opts.threadId }, + requestBody, }); - // 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); + 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. diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 4e00dafb..230d384c 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -150,7 +150,7 @@ const ipcSchemas = { }, 'gmail:sendReply': { req: z.object({ - threadId: z.string().min(1), + threadId: z.string().min(1).optional(), to: z.string().min(1), cc: z.string().optional(), bcc: z.string().optional(), @@ -165,6 +165,15 @@ const ipcSchemas = { 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({