added send

This commit is contained in:
Arjun 2026-05-14 21:09:54 +05:30
parent c157e9e50d
commit 6f80576f72
7 changed files with 168 additions and 36 deletions

View file

@ -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 { fetchThreadSnapshot, listRecentThreadIds, listInboxPage, saveMessageBodyHeight } from '@x/core/dist/knowledge/sync_gmail.js';
import { fetchThreadSnapshot, listRecentThreadIds, listInboxPage, saveMessageBodyHeight, sendThreadReply } 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';
@ -510,6 +510,9 @@ export function setupIpcHandlers() {
limit: args.limit,
});
},
'gmail:sendReply': async (_event, args) => {
return sendThreadReply(args);
},
'gmail:saveMessageHeight': async (_event, args) => {
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
return {};

View file

@ -466,39 +466,55 @@ 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')
const recipient = mode === 'reply' ? extractAddress(latest?.from) : ''
if (!recipient) {
toast('No recipient found for this thread.', 'error')
return
}
const rawSubject = thread.subject || ''
const subject = mode === 'reply'
? (/^re:/i.test(rawSubject) ? rawSubject : `Re: ${rawSubject}`.trim())
: (/^fwd:/i.test(rawSubject) ? rawSubject : `Fwd: ${rawSubject}`.trim())
// 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
setSending(true)
try {
const result = await window.ipc.invoke('gmail:sendReply', {
threadId: thread.threadId,
to: recipient,
subject,
bodyHtml: html,
bodyText: text,
inReplyTo,
references: 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)
}
}
@ -577,10 +593,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 size={15} />
Send
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
{sending ? 'Sending…' : 'Send'}
</button>
<button
type="button"

View file

@ -75,7 +75,7 @@ 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',
],

View file

@ -134,7 +134,7 @@ async function publishCalendarSyncEvent(
// Configuration
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const LOOKBACK_DAYS = 7;
const REQUIRED_SCOPES = [
'https://www.googleapis.com/auth/calendar.events.readonly',

View file

@ -25,8 +25,8 @@ const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
console.warn('[Gmail] Cache directory migration failed:', err);
}
})();
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.modify';
const MAX_THREADS_IN_DIGEST = 10;
const nhm = new NodeHtmlMarkdown();
@ -107,6 +107,7 @@ export interface GmailThreadSnapshot {
bodyHtml?: string;
unread?: boolean;
bodyHeight?: number;
messageIdHeader?: string;
}>;
}
@ -465,6 +466,7 @@ export async function fetchThreadSnapshot(threadId: string, expectedHistoryId?:
bodyHtml,
unread: msg.labelIds?.includes('UNREAD') ?? false,
bodyHeight: msg.id ? heightCarryover.get(msg.id) : undefined,
messageIdHeader: headerValue(headers, 'Message-ID') || headerValue(headers, 'Message-Id') || undefined,
isDraft,
};
}));
@ -911,6 +913,100 @@ async function performSync() {
}
}
// --- Send Reply ---
export interface SendReplyOptions {
threadId: string;
to: string;
subject: string;
bodyHtml: string;
bodyText: string;
inReplyTo?: string;
references?: string;
}
export interface SendReplyResult {
messageId?: string;
error?: string;
}
function encodeRfc2047(text: string): string {
// 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<SendReplyResult> {
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}`);
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)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
try {
const res = await gmailClient.users.messages.send({
userId: 'me',
requestBody: { raw, threadId: 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.`);

View file

@ -113,6 +113,7 @@ export const GmailThreadMessageSchema = z.object({
bodyHtml: z.string().optional(),
unread: z.boolean().optional(),
bodyHeight: z.number().int().positive().optional(),
messageIdHeader: z.string().optional(),
});
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;

View file

@ -157,6 +157,21 @@ const ipcSchemas = {
nextCursor: z.string().nullable(),
}),
},
'gmail:sendReply': {
req: z.object({
threadId: z.string().min(1),
to: z.string().min(1),
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:saveMessageHeight': {
req: z.object({
threadId: z.string().min(1),