mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-03 20:41:07 +02:00
- Added scope-aware Gmail status via gmail:getConnectionStatus, so the email empty state can
distinguish “not connected” from “connected but missing new Gmail scope.”
- Hardened Gmail send header construction against CR/LF header injection.
- Switched MIME parts from invalid UTF-8 7bit bodies to base64-encoded UTF-8 parts.
- Made forward send as a new message instead of attaching it to the original thread, and included
forwarded message content.
- Changed archive/delete UI behavior to remove the thread only after Gmail confirms success.
This commit is contained in:
parent
3a27c2ebd6
commit
54374fbc4c
5 changed files with 210 additions and 77 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 { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.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 { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||||
import { API_URL } from '@x/core/dist/config/env.js';
|
import { API_URL } from '@x/core/dist/config/env.js';
|
||||||
|
|
@ -496,6 +496,9 @@ export function setupIpcHandlers() {
|
||||||
'gmail:sendReply': async (_event, args) => {
|
'gmail:sendReply': async (_event, args) => {
|
||||||
return sendThreadReply(args);
|
return sendThreadReply(args);
|
||||||
},
|
},
|
||||||
|
'gmail:getConnectionStatus': async () => {
|
||||||
|
return getGmailConnectionStatus();
|
||||||
|
},
|
||||||
'gmail:getAccountEmail': async () => {
|
'gmail:getAccountEmail': async () => {
|
||||||
return { email: await getAccountEmail() };
|
return { email: await getAccountEmail() };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ import { SettingsDialog } from '@/components/settings-dialog'
|
||||||
|
|
||||||
type GmailThread = blocks.GmailThread
|
type GmailThread = blocks.GmailThread
|
||||||
type GmailThreadMessage = blocks.GmailThreadMessage
|
type GmailThreadMessage = blocks.GmailThreadMessage
|
||||||
|
type GmailConnectionStatus = {
|
||||||
|
connected: boolean
|
||||||
|
hasRequiredScope: boolean
|
||||||
|
missingScopes: string[]
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
|
||||||
function formatInboxTime(value?: string): string {
|
function formatInboxTime(value?: string): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
|
|
@ -162,6 +168,27 @@ function composeSubject(mode: ComposeMode, rawSubject?: string): string {
|
||||||
return /^re:/i.test(raw) ? raw : `Re: ${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 [
|
||||||
|
'<p></p>',
|
||||||
|
'<blockquote>',
|
||||||
|
...rows.map((line) => `<p>${escapeHtml(line)}</p>`),
|
||||||
|
body ? `<p>${escapeHtml(body).replace(/\n/g, '<br />')}</p>` : '',
|
||||||
|
'</blockquote>',
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
const PREFETCH_HOVER_MS = 180
|
const PREFETCH_HOVER_MS = 180
|
||||||
const PREFETCH_MAX_IMAGES_PER_THREAD = 12
|
const PREFETCH_MAX_IMAGES_PER_THREAD = 12
|
||||||
|
|
||||||
|
|
@ -660,7 +687,7 @@ function ComposeBox({
|
||||||
const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
|
const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
|
||||||
|
|
||||||
const initialContent = useMemo(() => {
|
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.
|
// Gmail-side draft (user's own work) wins over the AI-generated draft.
|
||||||
const source = thread.gmail_draft || thread.draft_response
|
const source = thread.gmail_draft || thread.draft_response
|
||||||
if (!source) return ''
|
if (!source) return ''
|
||||||
|
|
@ -668,7 +695,7 @@ function ComposeBox({
|
||||||
.split(/\n{2,}/)
|
.split(/\n{2,}/)
|
||||||
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
|
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
|
||||||
.join('')
|
.join('')
|
||||||
}, [mode, thread.gmail_draft, thread.draft_response])
|
}, [mode, thread])
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
|
|
@ -748,19 +775,20 @@ function ComposeBox({
|
||||||
.filter((v): v is string => Boolean(v))
|
.filter((v): v is string => Boolean(v))
|
||||||
const references = messageIds.join(' ')
|
const references = messageIds.join(' ')
|
||||||
const inReplyTo = latest?.messageIdHeader
|
const inReplyTo = latest?.messageIdHeader
|
||||||
|
const isForward = mode === 'forward'
|
||||||
|
|
||||||
setSending(true)
|
setSending(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke('gmail:sendReply', {
|
const result = await window.ipc.invoke('gmail:sendReply', {
|
||||||
threadId: thread.threadId,
|
threadId: isForward ? undefined : thread.threadId,
|
||||||
to: toList.join(', '),
|
to: toList.join(', '),
|
||||||
cc: ccList.length ? ccList.join(', ') : undefined,
|
cc: ccList.length ? ccList.join(', ') : undefined,
|
||||||
bcc: bccList.length ? bccList.join(', ') : undefined,
|
bcc: bccList.length ? bccList.join(', ') : undefined,
|
||||||
subject: subject.trim() || composeSubject(mode, thread.subject),
|
subject: subject.trim() || composeSubject(mode, thread.subject),
|
||||||
bodyHtml: html,
|
bodyHtml: html,
|
||||||
bodyText: text,
|
bodyText: text,
|
||||||
inReplyTo,
|
inReplyTo: isForward ? undefined : inReplyTo,
|
||||||
references: references || undefined,
|
references: isForward ? undefined : references || undefined,
|
||||||
})
|
})
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
toast(`Send failed: ${result.error}`, 'error')
|
toast(`Send failed: ${result.error}`, 'error')
|
||||||
|
|
@ -1067,17 +1095,24 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
// Gmail sync uses the native Google OAuth connection.
|
// Gmail sync uses the native Google OAuth connection.
|
||||||
const [emailConnected, setEmailConnected] = useState<boolean | null>(null)
|
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
try {
|
try {
|
||||||
const oauthState = await window.ipc.invoke('oauth:getState', null)
|
const status = await window.ipc.invoke('gmail:getConnectionStatus', {})
|
||||||
if (!cancelled) setEmailConnected(oauthState.config?.google?.connected ?? false)
|
if (!cancelled) setEmailConnection(status)
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setEmailConnected(false)
|
if (!cancelled) {
|
||||||
|
setEmailConnection({
|
||||||
|
connected: false,
|
||||||
|
hasRequiredScope: false,
|
||||||
|
missingScopes: [],
|
||||||
|
email: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void check()
|
void check()
|
||||||
|
|
@ -1131,20 +1166,26 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
}, [updateThreadInState])
|
}, [updateThreadInState])
|
||||||
|
|
||||||
const archiveThreadAction = useCallback(async (threadId: string) => {
|
const archiveThreadAction = useCallback(async (threadId: string) => {
|
||||||
removeThreadFromState(threadId)
|
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
|
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) {
|
} catch (err) {
|
||||||
toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||||
}
|
}
|
||||||
}, [removeThreadFromState])
|
}, [removeThreadFromState])
|
||||||
|
|
||||||
const trashThreadAction = useCallback(async (threadId: string) => {
|
const trashThreadAction = useCallback(async (threadId: string) => {
|
||||||
removeThreadFromState(threadId)
|
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke('gmail:trashThread', { threadId })
|
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) {
|
} catch (err) {
|
||||||
toast(`Delete failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
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 hasAny = important.threads.length > 0 || other.threads.length > 0
|
||||||
const initialLoading = !hasAny && refreshing
|
const initialLoading = !hasAny && refreshing
|
||||||
|
const needsEmailConnect = emailConnection?.connected === false
|
||||||
|
const needsEmailReconnect = emailConnection?.connected === true && !emailConnection.hasRequiredScope
|
||||||
|
|
||||||
const renderRow = (thread: GmailThread) => {
|
const renderRow = (thread: GmailThread) => {
|
||||||
const latest = latestMessage(thread)
|
const latest = latestMessage(thread)
|
||||||
|
|
@ -1541,17 +1584,21 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : emailConnected === false ? (
|
) : needsEmailConnect || needsEmailReconnect ? (
|
||||||
<div className="gmail-empty-state flex flex-col items-center gap-3 py-16 text-center">
|
<div className="gmail-empty-state flex flex-col items-center gap-3 py-16 text-center">
|
||||||
<Mail size={28} className="opacity-50" />
|
<Mail size={28} className="opacity-50" />
|
||||||
<p>Connect your email to see your inbox here.</p>
|
<p>
|
||||||
|
{needsEmailReconnect
|
||||||
|
? 'Reconnect your email to enable Gmail sync and actions.'
|
||||||
|
: 'Connect your email to see your inbox here.'}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSettingsOpen(true)}
|
onClick={() => setSettingsOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Mail size={15} />
|
<Mail size={15} />
|
||||||
Connect your email
|
{needsEmailReconnect ? 'Reconnect your email' : 'Connect your email'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -188,18 +188,41 @@ export class GoogleClientFactory {
|
||||||
* Check if credentials are available and have required scopes
|
* Check if credentials are available and have required scopes
|
||||||
*/
|
*/
|
||||||
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
|
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
|
||||||
|
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<IOAuthRepo>('oauthRepo');
|
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||||
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||||
if (!tokens) {
|
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 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) {
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1232,7 +1232,7 @@ async function performSync() {
|
||||||
// --- Send Reply ---
|
// --- Send Reply ---
|
||||||
|
|
||||||
export interface SendReplyOptions {
|
export interface SendReplyOptions {
|
||||||
threadId: string;
|
threadId?: string;
|
||||||
to: string;
|
to: string;
|
||||||
cc?: string;
|
cc?: string;
|
||||||
bcc?: string;
|
bcc?: string;
|
||||||
|
|
@ -1248,6 +1248,13 @@ export interface SendReplyResult {
|
||||||
error?: 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. */
|
/** The connected Gmail address (cached). Used by the composer to exclude "me" from reply-all. */
|
||||||
export async function getAccountEmail(): Promise<string | null> {
|
export async function getAccountEmail(): Promise<string | null> {
|
||||||
const auth = await GoogleClientFactory.getClient();
|
const auth = await GoogleClientFactory.getClient();
|
||||||
|
|
@ -1255,74 +1262,118 @@ export async function getAccountEmail(): Promise<string | null> {
|
||||||
return getUserEmail(auth);
|
return getUserEmail(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getConnectionStatus(): Promise<GmailConnectionStatus> {
|
||||||
|
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 {
|
function encodeRfc2047(text: string): string {
|
||||||
|
requireSafeHeaderValue('Subject', text);
|
||||||
// Only encode if non-ASCII chars present.
|
// Only encode if non-ASCII chars present.
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
if (/^[\x00-\x7F]*$/.test(text)) return text;
|
if (/^[\x00-\x7F]*$/.test(text)) return text;
|
||||||
return `=?UTF-8?B?${Buffer.from(text).toString('base64')}?=`;
|
return `=?UTF-8?B?${Buffer.from(text).toString('base64')}?=`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
|
function encodeMimeBase64(text: string): string {
|
||||||
const auth = await GoogleClientFactory.getClient();
|
return Buffer.from(text, 'utf8')
|
||||||
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)
|
|
||||||
.toString('base64')
|
.toString('base64')
|
||||||
.replace(/\+/g, '-')
|
.match(/.{1,76}/g)
|
||||||
.replace(/\//g, '_')
|
?.join('\r\n') ?? '';
|
||||||
.replace(/=+$/, '');
|
}
|
||||||
|
|
||||||
|
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
|
||||||
try {
|
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({
|
const res = await gmailClient.users.messages.send({
|
||||||
userId: 'me',
|
userId: 'me',
|
||||||
requestBody: { raw, threadId: opts.threadId },
|
requestBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up any Gmail-side drafts in this thread.
|
if (opts.threadId) {
|
||||||
try {
|
// Clean up any Gmail-side drafts in this thread.
|
||||||
const drafts = await gmailClient.users.drafts.list({ userId: 'me' });
|
try {
|
||||||
const matching = (drafts.data.drafts || []).filter(
|
const drafts = await gmailClient.users.drafts.list({ userId: 'me' });
|
||||||
(d) => d.message?.threadId === opts.threadId && d.id
|
const matching = (drafts.data.drafts || []).filter(
|
||||||
);
|
(d) => d.message?.threadId === opts.threadId && d.id
|
||||||
await Promise.all(
|
);
|
||||||
matching.map((d) =>
|
await Promise.all(
|
||||||
gmailClient.users.drafts.delete({ userId: 'me', id: d.id! })
|
matching.map((d) =>
|
||||||
)
|
gmailClient.users.drafts.delete({ userId: 'me', id: d.id! })
|
||||||
);
|
)
|
||||||
} catch (cleanupErr) {
|
);
|
||||||
console.warn('[Gmail] Draft cleanup after send failed:', cleanupErr);
|
} catch (cleanupErr) {
|
||||||
|
console.warn('[Gmail] Draft cleanup after send failed:', cleanupErr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wake the sync loop so the cache picks up the new message.
|
// Wake the sync loop so the cache picks up the new message.
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ const ipcSchemas = {
|
||||||
},
|
},
|
||||||
'gmail:sendReply': {
|
'gmail:sendReply': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
threadId: z.string().min(1),
|
threadId: z.string().min(1).optional(),
|
||||||
to: z.string().min(1),
|
to: z.string().min(1),
|
||||||
cc: z.string().optional(),
|
cc: z.string().optional(),
|
||||||
bcc: z.string().optional(),
|
bcc: z.string().optional(),
|
||||||
|
|
@ -165,6 +165,15 @@ const ipcSchemas = {
|
||||||
error: 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': {
|
'gmail:getAccountEmail': {
|
||||||
req: z.object({}),
|
req: z.object({}),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue