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({