Gmail send, archive and delete (#573)

* added send, archive and delete

* fix scopes

* added replyall, cc, bcc etc

* - 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.

---------

Co-authored-by: Ramnique Singh <30795890+ramnique@users.noreply.github.com>
This commit is contained in:
arkml 2026-05-25 09:47:08 +05:30 committed by GitHub
parent a59c42e22b
commit 84aa980894
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 927 additions and 79 deletions

View file

@ -75,9 +75,8 @@ 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',
],
},
'fireflies-ai': {
@ -119,4 +118,3 @@ export async function getProviderConfig(providerName: string): Promise<ProviderC
export function getAvailableProviders(): string[] {
return Object.keys(providerConfigs);
}

View file

@ -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,
};
}
/**

View file

@ -26,7 +26,7 @@ const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
}
})();
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.modify';
const MAX_THREADS_IN_DIGEST = 10;
const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000;
const nhm = new NodeHtmlMarkdown();
@ -78,6 +78,76 @@ export function saveMessageBodyHeight(threadId: string, messageId: string, heigh
}
}
function deleteCachedSnapshot(threadId: string): void {
try {
fs.rmSync(cachePath(threadId), { force: true });
} catch (err) {
console.warn(`[Gmail cache] delete failed for ${threadId}:`, err);
}
}
async function getGmailClientOrThrow() {
const auth = await GoogleClientFactory.getClient();
if (!auth) throw new Error('Gmail is not connected.');
return google.gmail({ version: 'v1', auth });
}
export interface ThreadActionResult {
ok: boolean;
error?: string;
}
export async function archiveThread(threadId: string): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: { removeLabelIds: ['INBOX'] },
});
deleteCachedSnapshot(threadId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function trashThread(threadId: string): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.trash({ userId: 'me', id: threadId });
deleteCachedSnapshot(threadId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function markThreadRead(threadId: string): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: { removeLabelIds: ['UNREAD'] },
});
// Update local cache: clear unread on all messages in the thread.
const cached = readCachedSnapshot(threadId);
if (cached) {
for (const m of cached.snapshot.messages) m.unread = false;
cached.snapshot.unread = false;
try {
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
} catch (err) {
console.warn(`[Gmail cache] markRead write failed for ${threadId}:`, err);
}
}
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
interface SyncedThread {
threadId: string;
markdown: string;
@ -114,6 +184,7 @@ export interface GmailThreadSnapshot {
sizeBytes?: number;
savedPath: string;
}>;
messageIdHeader?: string;
}>;
}
@ -1158,6 +1229,162 @@ async function performSync() {
}
}
// --- Send Reply ---
export interface SendReplyOptions {
threadId?: string;
to: string;
cc?: string;
bcc?: string;
subject: string;
bodyHtml: string;
bodyText: string;
inReplyTo?: string;
references?: string;
}
export interface SendReplyResult {
messageId?: 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. */
export async function getAccountEmail(): Promise<string | null> {
const auth = await GoogleClientFactory.getClient();
if (!auth) return 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')}?=`;
}
function encodeMimeBase64(text: string): string {
return Buffer.from(text, 'utf8')
.toString('base64')
.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,
});
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.
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

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

View file

@ -148,6 +148,50 @@ const ipcSchemas = {
req: z.object({}),
res: z.object({}),
},
'gmail:sendReply': {
req: z.object({
threadId: z.string().min(1).optional(),
to: z.string().min(1),
cc: z.string().optional(),
bcc: z.string().optional(),
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: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({
email: z.string().nullable(),
}),
},
'gmail:archiveThread': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:trashThread': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:markThreadRead': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:saveMessageHeight': {
req: z.object({
threadId: z.string().min(1),