improvement(reply-gmail): added reply to gmail (#1809)

* added reply to thread/message

* cleanup, extract header helper for threaded replies

* more helpers
This commit is contained in:
Adam Gough
2025-11-04 20:23:10 -08:00
committed by GitHub
parent f65d62ea3d
commit b0fa3e8a26
9 changed files with 239 additions and 52 deletions

View File

@@ -28,7 +28,7 @@ export const gmailDraftTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
},
subject: {
type: 'string',
required: true,
required: false,
visibility: 'user-or-llm',
description: 'Email subject',
},
@@ -38,6 +38,19 @@ export const gmailDraftTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
visibility: 'user-or-llm',
description: 'Email body content',
},
threadId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Thread ID to reply to (for threading)',
},
replyToMessageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Gmail message ID to reply to - use the "id" field from Gmail Read results (not the RFC "messageId")',
},
cc: {
type: 'string',
required: false,
@@ -69,6 +82,8 @@ export const gmailDraftTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
to: params.to,
subject: params.subject,
body: params.body,
threadId: params.threadId,
replyToMessageId: params.replyToMessageId,
cc: params.cc,
bcc: params.bcc,
attachments: params.attachments,

View File

@@ -225,6 +225,7 @@ export const gmailReadTool: ToolConfig<GmailReadParams, GmailToolResponse> = {
threadId: msg.threadId,
subject: msg.subject,
from: msg.from,
to: msg.to,
date: msg.date,
})),
},

View File

@@ -28,7 +28,7 @@ export const gmailSendTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
},
subject: {
type: 'string',
required: true,
required: false,
visibility: 'user-or-llm',
description: 'Email subject',
},
@@ -38,6 +38,19 @@ export const gmailSendTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
visibility: 'user-or-llm',
description: 'Email body content',
},
threadId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Thread ID to reply to (for threading)',
},
replyToMessageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Gmail message ID to reply to - use the "id" field from Gmail Read results (not the RFC "messageId")',
},
cc: {
type: 'string',
required: false,
@@ -69,6 +82,8 @@ export const gmailSendTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
to: params.to,
subject: params.subject,
body: params.body,
threadId: params.threadId,
replyToMessageId: params.replyToMessageId,
cc: params.cc,
bcc: params.bcc,
attachments: params.attachments,

View File

@@ -11,8 +11,10 @@ export interface GmailSendParams extends BaseGmailParams {
to: string
cc?: string
bcc?: string
subject: string
subject?: string
body: string
threadId?: string
replyToMessageId?: string
attachments?: UserFile[]
}

View File

@@ -7,6 +7,47 @@ import type {
export const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'
/**
* Fetch original message headers for threading
* @param messageId Gmail message ID to fetch headers from
* @param accessToken Gmail access token
* @returns Object containing threading headers (messageId, references, subject)
*/
export async function fetchThreadingHeaders(
messageId: string,
accessToken: string
): Promise<{
messageId?: string
references?: string
subject?: string
}> {
try {
const messageResponse = await fetch(
`${GMAIL_API_BASE}/messages/${messageId}?format=metadata&metadataHeaders=Message-ID&metadataHeaders=References&metadataHeaders=Subject`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (messageResponse.ok) {
const messageData = await messageResponse.json()
const headers = messageData.payload?.headers || []
return {
messageId: headers.find((h: any) => h.name.toLowerCase() === 'message-id')?.value,
references: headers.find((h: any) => h.name.toLowerCase() === 'references')?.value,
subject: headers.find((h: any) => h.name.toLowerCase() === 'subject')?.value,
}
}
} catch (error) {
// Continue without threading headers rather than failing
}
return {}
}
// Helper function to process a Gmail message
export async function processMessage(
message: GmailMessage,
@@ -81,6 +122,7 @@ export function processMessageForSummary(message: GmailMessage): any {
threadId: message?.threadId || '',
subject: 'Unknown Subject',
from: 'Unknown Sender',
to: '',
date: '',
snippet: message?.snippet || '',
}
@@ -89,6 +131,7 @@ export function processMessageForSummary(message: GmailMessage): any {
const headers = message.payload.headers || []
const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value || 'No Subject'
const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value || 'Unknown Sender'
const to = headers.find((h) => h.name.toLowerCase() === 'to')?.value || ''
const date = headers.find((h) => h.name.toLowerCase() === 'date')?.value || ''
return {
@@ -96,6 +139,7 @@ export function processMessageForSummary(message: GmailMessage): any {
threadId: message.threadId,
subject,
from,
to,
date,
snippet: message.snippet || '',
}
@@ -230,7 +274,10 @@ export function createMessagesSummary(messages: any[]): string {
messages.forEach((msg, index) => {
summary += `${index + 1}. Subject: ${msg.subject}\n`
summary += ` From: ${msg.from}\n`
summary += ` To: ${msg.to}\n`
summary += ` Date: ${msg.date}\n`
summary += ` ID: ${msg.id}\n`
summary += ` Thread ID: ${msg.threadId}\n`
summary += ` Preview: ${msg.snippet}\n\n`
})
@@ -255,6 +302,47 @@ export function base64UrlEncode(data: string | Buffer): string {
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Build a simple text email message (without attachments)
* @param params Email parameters including recipients, subject, body, and threading info
* @returns Base64url encoded raw message
*/
export function buildSimpleEmailMessage(params: {
to: string
cc?: string | null
bcc?: string | null
subject?: string | null
body: string
inReplyTo?: string
references?: string
}): string {
const { to, cc, bcc, subject, body, inReplyTo, references } = params
const emailHeaders = [
'Content-Type: text/plain; charset="UTF-8"',
'MIME-Version: 1.0',
`To: ${to}`,
]
if (cc) {
emailHeaders.push(`Cc: ${cc}`)
}
if (bcc) {
emailHeaders.push(`Bcc: ${bcc}`)
}
emailHeaders.push(`Subject: ${subject || ''}`)
if (inReplyTo) {
emailHeaders.push(`In-Reply-To: ${inReplyTo}`)
const referencesChain = references ? `${references} ${inReplyTo}` : inReplyTo
emailHeaders.push(`References: ${referencesChain}`)
}
emailHeaders.push('', body)
const email = emailHeaders.join('\n')
return Buffer.from(email).toString('base64url')
}
/**
* Build a MIME multipart message with optional attachments
* @param params Message parameters including recipients, subject, body, and attachments
@@ -264,8 +352,10 @@ export interface BuildMimeMessageParams {
to: string
cc?: string
bcc?: string
subject: string
subject?: string
body: string
inReplyTo?: string
references?: string
attachments?: Array<{
filename: string
mimeType: string
@@ -274,11 +364,10 @@ export interface BuildMimeMessageParams {
}
export function buildMimeMessage(params: BuildMimeMessageParams): string {
const { to, cc, bcc, subject, body, attachments } = params
const { to, cc, bcc, subject, body, inReplyTo, references, attachments } = params
const boundary = generateBoundary()
const messageParts: string[] = []
// Add headers
messageParts.push(`To: ${to}`)
if (cc) {
messageParts.push(`Cc: ${cc}`)
@@ -286,11 +375,21 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string {
if (bcc) {
messageParts.push(`Bcc: ${bcc}`)
}
messageParts.push(`Subject: ${subject}`)
messageParts.push(`Subject: ${subject || ''}`)
if (inReplyTo) {
messageParts.push(`In-Reply-To: ${inReplyTo}`)
}
if (references) {
const referencesChain = inReplyTo ? `${references} ${inReplyTo}` : references
messageParts.push(`References: ${referencesChain}`)
} else if (inReplyTo) {
messageParts.push(`References: ${inReplyTo}`)
}
messageParts.push('MIME-Version: 1.0')
if (attachments && attachments.length > 0) {
// Multipart message with attachments
messageParts.push(`Content-Type: multipart/mixed; boundary="${boundary}"`)
messageParts.push('')
messageParts.push(`--${boundary}`)
@@ -300,7 +399,6 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string {
messageParts.push(body)
messageParts.push('')
// Add each attachment
for (const attachment of attachments) {
messageParts.push(`--${boundary}`)
messageParts.push(`Content-Type: ${attachment.mimeType}`)
@@ -308,7 +406,6 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string {
messageParts.push('Content-Transfer-Encoding: base64')
messageParts.push('')
// Split base64 content into 76-character lines (MIME standard)
const base64Content = attachment.content.toString('base64')
const lines = base64Content.match(/.{1,76}/g) || []
messageParts.push(...lines)
@@ -317,7 +414,6 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string {
messageParts.push(`--${boundary}--`)
} else {
// Simple text message without attachments
messageParts.push('Content-Type: text/plain; charset="UTF-8"')
messageParts.push('MIME-Version: 1.0')
messageParts.push('')