mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
added send
This commit is contained in:
parent
c157e9e50d
commit
6f80576f72
7 changed files with 168 additions and 36 deletions
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue