mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-30 20:39:46 +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
|
|
@ -188,18 +188,41 @@ export class GoogleClientFactory {
|
|||
* Check if credentials are available and have required scopes
|
||||
*/
|
||||
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 { 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
|
|
@ -1255,74 +1262,118 @@ export async function getAccountEmail(): Promise<string | null> {
|
|||
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 {
|
||||
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<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}`);
|
||||
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<SendReplyResult> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue