import { getGoogleAPIs } from '../auth/google-auth.js';
import {
getLogger,
validateInput,
GmailSearchSchema,
GmailGetMessageSchema,
GmailModifyLabelsSchema,
GmailSendMessageSchema,
GmailReplyMessageSchema,
GmailDeleteMessageSchema,
GmailCreateDraftSchema,
GmailGetAttachmentSchema,
isOperationAllowed,
OperationNotAllowedError,
GoogleAPIError,
withErrorHandling,
type GmailSearch,
type GmailGetMessage,
type GmailModifyLabels,
type GmailSendMessage,
type GmailReplyMessage,
type GmailDeleteMessage,
type GmailCreateDraft,
type GmailGetAttachment,
} from '@company-mcp/core';
const logger = getLogger();
// Types
export interface GmailMessage {
id: string;
threadId: string;
snippet: string;
internalDate: string;
}
export interface GmailMessageFull extends GmailMessage {
subject: string;
from: string;
to: string;
date: string;
bodyText?: string;
}
export interface GmailLabel {
id: string;
name: string;
}
export interface GmailSearchResult {
messages: GmailMessage[];
nextPageToken?: string;
}
// Helper to extract header value
function getHeader(
headers: Array<{ name?: string | null; value?: string | null }> | undefined,
name: string
): string {
const header = headers?.find(
(h) => h.name?.toLowerCase() === name.toLowerCase()
);
return header?.value || '';
}
// Helper to decode body
function decodeBody(data?: string): string {
if (!data) return '';
try {
return Buffer.from(data, 'base64').toString('utf-8');
} catch {
return '';
}
}
// Helper to extract text from message parts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractText(payload: any): string {
if (!payload) return '';
// Direct body
if (payload.body?.data) {
return decodeBody(payload.body.data);
}
// Multi-part message
if (payload.parts) {
for (const part of payload.parts) {
if (part.mimeType === 'text/plain' && part.body?.data) {
return decodeBody(part.body.data);
}
}
// Fallback to first text part
for (const part of payload.parts) {
if (part.mimeType?.startsWith('text/') && part.body?.data) {
return decodeBody(part.body.data);
}
}
}
return '';
}
// Search messages
export async function gmailSearchMessages(
input: unknown
): Promise<GmailSearchResult> {
return withErrorHandling('gmail_search_messages', async () => {
const validation = validateInput(GmailSearchSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as GmailSearch;
const startTime = Date.now();
const { gmail } = getGoogleAPIs();
const response = await gmail.users.messages.list({
userId: 'me',
q: params.query,
maxResults: params.maxResults,
pageToken: params.pageToken,
});
const messages: GmailMessage[] = [];
if (response.data.messages) {
// Get details for each message
for (const msg of response.data.messages) {
const detail = await gmail.users.messages.get({
userId: 'me',
id: msg.id!,
format: 'metadata',
metadataHeaders: ['Subject', 'From', 'To', 'Date'],
});
messages.push({
id: detail.data.id!,
threadId: detail.data.threadId!,
snippet: detail.data.snippet || '',
internalDate: detail.data.internalDate || '',
});
}
}
logger.audit('gmail_search_messages', 'search', {
args: { query: params.query, maxResults: params.maxResults },
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
messages,
nextPageToken: response.data.nextPageToken || undefined,
};
});
}
// Get message details
export async function gmailGetMessage(
input: unknown
): Promise<GmailMessageFull> {
return withErrorHandling('gmail_get_message', async () => {
const validation = validateInput(GmailGetMessageSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as GmailGetMessage;
const startTime = Date.now();
const { gmail } = getGoogleAPIs();
const response = await gmail.users.messages.get({
userId: 'me',
id: params.messageId,
format: params.format,
});
const headers = response.data.payload?.headers;
const message: GmailMessageFull = {
id: response.data.id!,
threadId: response.data.threadId!,
snippet: response.data.snippet || '',
internalDate: response.data.internalDate || '',
subject: getHeader(headers, 'Subject'),
from: getHeader(headers, 'From'),
to: getHeader(headers, 'To'),
date: getHeader(headers, 'Date'),
};
// Include body if full format
if (params.format === 'full') {
message.bodyText = extractText(response.data.payload);
}
logger.audit('gmail_get_message', 'get', {
args: { messageId: params.messageId, format: params.format },
result: 'success',
duration_ms: Date.now() - startTime,
});
return message;
});
}
// List labels
export async function gmailListLabels(): Promise<{ labels: GmailLabel[] }> {
return withErrorHandling('gmail_list_labels', async () => {
const startTime = Date.now();
const { gmail } = getGoogleAPIs();
const response = await gmail.users.labels.list({
userId: 'me',
});
const labels: GmailLabel[] = (response.data.labels || []).map((label) => ({
id: label.id!,
name: label.name!,
}));
logger.audit('gmail_list_labels', 'list', {
result: 'success',
duration_ms: Date.now() - startTime,
});
return { labels };
});
}
// Modify labels (requires explicit enable)
export async function gmailModifyLabels(
input: unknown
): Promise<{ ok: true }> {
return withErrorHandling('gmail_modify_labels', async () => {
// Check if operation is allowed
if (!isOperationAllowed('gmail_modify_labels')) {
throw new OperationNotAllowedError('gmail_modify_labels');
}
const validation = validateInput(GmailModifyLabelsSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as GmailModifyLabels;
const startTime = Date.now();
const { gmail } = getGoogleAPIs();
await gmail.users.messages.modify({
userId: 'me',
id: params.messageId,
requestBody: {
addLabelIds: params.addLabelIds,
removeLabelIds: params.removeLabelIds,
},
});
logger.audit('gmail_modify_labels', 'modify', {
args: {
messageId: params.messageId,
addLabelIds: params.addLabelIds,
removeLabelIds: params.removeLabelIds,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { ok: true };
});
}
// Helper to encode subject for non-ASCII characters (RFC 2047)
function encodeSubject(subject: string): string {
// Check if subject contains non-ASCII characters
// eslint-disable-next-line no-control-regex
if (/[^\x00-\x7F]/.test(subject)) {
// Encode using RFC 2047 MIME encoded-word (UTF-8 + Base64)
const encoded = Buffer.from(subject, 'utf-8').toString('base64');
return `=?UTF-8?B?${encoded}?=`;
}
return subject;
}
// Helper to create MIME message
function createMimeMessage(options: {
to: string[];
subject: string;
body: string;
cc?: string[];
bcc?: string[];
inReplyTo?: string;
references?: string;
}): string {
const lines: string[] = [];
lines.push(`To: ${options.to.join(', ')}`);
if (options.cc?.length) {
lines.push(`Cc: ${options.cc.join(', ')}`);
}
if (options.bcc?.length) {
lines.push(`Bcc: ${options.bcc.join(', ')}`);
}
lines.push(`Subject: ${encodeSubject(options.subject)}`);
if (options.inReplyTo) {
lines.push(`In-Reply-To: ${options.inReplyTo}`);
}
if (options.references) {
lines.push(`References: ${options.references}`);
}
lines.push('MIME-Version: 1.0');
lines.push('Content-Type: text/plain; charset="UTF-8"');
lines.push('Content-Transfer-Encoding: base64');
lines.push('');
// Body also needs to be base64 encoded for UTF-8
lines.push(Buffer.from(options.body, 'utf-8').toString('base64'));
return Buffer.from(lines.join('\r\n')).toString('base64url');
}
// Send a new message
export async function gmailSendMessage(
input: unknown
): Promise<{ id: string; threadId: string }> {
return withErrorHandling('gmail_send_message', async () => {
// Check if operation is allowed
if (!isOperationAllowed('gmail_send')) {
throw new OperationNotAllowedError('gmail_send');
}
const validation = validateInput(GmailSendMessageSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as GmailSendMessage;
const startTime = Date.now();
const { gmail } = getGoogleAPIs();
const raw = createMimeMessage({
to: params.to,
subject: params.subject,
body: params.body,
cc: params.cc,
bcc: params.bcc,
});
const response = await gmail.users.messages.send({
userId: 'me',
requestBody: {
raw,
},
});
logger.audit('gmail_send_message', 'send', {
args: {
to: params.to,
subject: params.subject,
cc: params.cc,
bcc: params.bcc,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
id: response.data.id!,
threadId: response.data.threadId!,
};
});
}
// Reply to a message (creates a draft, does not send immediately)
export async function gmailReplyMessage(
input: unknown
): Promise<{ id: string; threadId: string; draftId: string }> {
return withErrorHandling('gmail_reply_message', async () => {
// Check if operation is allowed (uses gmail_draft permission)
if (!isOperationAllowed('gmail_draft')) {
throw new OperationNotAllowedError('gmail_draft');
}
const validation = validateInput(GmailReplyMessageSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as GmailReplyMessage;
const startTime = Date.now();
const { gmail } = getGoogleAPIs();
// Get the original message to extract reply info
const originalMessage = await gmail.users.messages.get({
userId: 'me',
id: params.messageId,
format: 'metadata',
metadataHeaders: ['Subject', 'From', 'To', 'Cc', 'Message-ID', 'References'],
});
const headers = originalMessage.data.payload?.headers || [];
const getHeaderValue = (name: string) =>
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
const originalSubject = getHeaderValue('Subject');
const originalFrom = getHeaderValue('From');
const originalTo = getHeaderValue('To');
const originalCc = getHeaderValue('Cc');
const originalMessageId = getHeaderValue('Message-ID');
const originalReferences = getHeaderValue('References');
// Build reply recipients
const replyTo = [originalFrom];
let replyCc: string[] = [];
if (params.replyAll) {
// Include original To (excluding self) and Cc
const toAddresses = originalTo.split(',').map((e) => e.trim()).filter((e) => e);
const ccAddresses = originalCc.split(',').map((e) => e.trim()).filter((e) => e);
replyCc = [...toAddresses, ...ccAddresses];
}
// Build subject with Re: prefix if not already present
const replySubject = originalSubject.toLowerCase().startsWith('re:')
? originalSubject
: `Re: ${originalSubject}`;
// Build references header
const references = originalReferences
? `${originalReferences} ${originalMessageId}`
: originalMessageId;
const raw = createMimeMessage({
to: replyTo,
subject: replySubject,
body: params.body,
cc: replyCc.length > 0 ? replyCc : undefined,
inReplyTo: originalMessageId,
references,
});
// Create draft instead of sending immediately
const response = await gmail.users.drafts.create({
userId: 'me',
requestBody: {
message: {
raw,
threadId: originalMessage.data.threadId!,
},
},
});
logger.audit('gmail_reply_message', 'create_reply_draft', {
args: {
originalMessageId: params.messageId,
replyAll: params.replyAll,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
id: response.data.message?.id || '',
threadId: originalMessage.data.threadId!,
draftId: response.data.id!,
};
});
}
// Delete/trash a message
export async function gmailDeleteMessage(
input: unknown
): Promise<{ ok: true }> {
return withErrorHandling('gmail_delete_message', async () => {
// Check if operation is allowed
if (!isOperationAllowed('gmail_delete')) {
throw new OperationNotAllowedError('gmail_delete');
}
const validation = validateInput(GmailDeleteMessageSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as GmailDeleteMessage;
const startTime = Date.now();
const { gmail } = getGoogleAPIs();
if (params.permanent) {
// Permanently delete the message
await gmail.users.messages.delete({
userId: 'me',
id: params.messageId,
});
} else {
// Move to trash
await gmail.users.messages.trash({
userId: 'me',
id: params.messageId,
});
}
logger.audit('gmail_delete_message', params.permanent ? 'delete' : 'trash', {
args: {
messageId: params.messageId,
permanent: params.permanent,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { ok: true };
});
}
// Create a draft
export async function gmailCreateDraft(
input: unknown
): Promise<{ id: string; messageId: string }> {
return withErrorHandling('gmail_create_draft', async () => {
// Check if operation is allowed (uses gmail_draft permission, separate from send)
if (!isOperationAllowed('gmail_draft')) {
throw new OperationNotAllowedError('gmail_draft');
}
const validation = validateInput(GmailCreateDraftSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as GmailCreateDraft;
const startTime = Date.now();
const { gmail } = getGoogleAPIs();
const raw = createMimeMessage({
to: params.to,
subject: params.subject,
body: params.body,
cc: params.cc,
bcc: params.bcc,
});
const response = await gmail.users.drafts.create({
userId: 'me',
requestBody: {
message: {
raw,
},
},
});
logger.audit('gmail_create_draft', 'create', {
args: {
to: params.to,
subject: params.subject,
cc: params.cc,
bcc: params.bcc,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
id: response.data.id!,
messageId: response.data.message?.id || '',
};
});
}
// Get attachment from a message
export async function gmailGetAttachment(
input: unknown
): Promise<{ data: string; size: number }> {
return withErrorHandling('gmail_get_attachment', async () => {
const validation = validateInput(GmailGetAttachmentSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as GmailGetAttachment;
const startTime = Date.now();
const { gmail } = getGoogleAPIs();
const response = await gmail.users.messages.attachments.get({
userId: 'me',
messageId: params.messageId,
id: params.attachmentId,
});
logger.audit('gmail_get_attachment', 'get', {
args: {
messageId: params.messageId,
attachmentId: params.attachmentId,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
data: response.data.data || '',
size: response.data.size || 0,
};
});
}
// Tool definitions for MCP
export const gmailTools = [
{
name: 'gmail_search_messages',
description:
'Search Gmail messages using Gmail search syntax. Returns message IDs, snippets, and metadata.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'Gmail search query (e.g., "from:user@example.com subject:meeting")',
},
maxResults: {
type: 'number',
description: 'Maximum number of results (1-500, default: 10)',
default: 10,
},
pageToken: {
type: 'string',
description: 'Page token for pagination',
},
},
required: ['query'],
},
},
{
name: 'gmail_get_message',
description:
'Get detailed information about a specific Gmail message including subject, sender, and body.',
inputSchema: {
type: 'object',
properties: {
messageId: {
type: 'string',
description: 'The ID of the message to retrieve',
},
format: {
type: 'string',
enum: ['metadata', 'full'],
description: 'Response format (metadata: headers only, full: include body)',
default: 'metadata',
},
},
required: ['messageId'],
},
},
{
name: 'gmail_list_labels',
description: 'List all Gmail labels in the mailbox.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'gmail_modify_labels',
description:
'Add or remove labels from a message. Requires GMAIL_MODIFY_LABELS_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
messageId: {
type: 'string',
description: 'The ID of the message to modify',
},
addLabelIds: {
type: 'array',
items: { type: 'string' },
description: 'Label IDs to add',
},
removeLabelIds: {
type: 'array',
items: { type: 'string' },
description: 'Label IDs to remove',
},
},
required: ['messageId'],
},
},
{
name: 'gmail_send_message',
description:
'Send a new email message. Requires GMAIL_SEND_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
to: {
type: 'array',
items: { type: 'string' },
description: 'List of recipient email addresses',
},
subject: {
type: 'string',
description: 'Email subject',
},
body: {
type: 'string',
description: 'Email body (plain text)',
},
cc: {
type: 'array',
items: { type: 'string' },
description: 'List of CC email addresses',
},
bcc: {
type: 'array',
items: { type: 'string' },
description: 'List of BCC email addresses',
},
},
required: ['to', 'subject', 'body'],
},
},
{
name: 'gmail_reply_message',
description:
'Reply to an existing email message. Requires GMAIL_SEND_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
messageId: {
type: 'string',
description: 'The ID of the message to reply to',
},
body: {
type: 'string',
description: 'Reply body (plain text)',
},
replyAll: {
type: 'boolean',
description: 'Whether to reply to all recipients (default: false)',
default: false,
},
},
required: ['messageId', 'body'],
},
},
{
name: 'gmail_delete_message',
description:
'Delete or trash a Gmail message. Requires GMAIL_DELETE_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
messageId: {
type: 'string',
description: 'The ID of the message to delete',
},
permanent: {
type: 'boolean',
description: 'If true, permanently delete the message. If false, move to trash (default: false)',
default: false,
},
},
required: ['messageId'],
},
},
{
name: 'gmail_create_draft',
description:
'Create a draft email message. Requires GMAIL_SEND_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
to: {
type: 'array',
items: { type: 'string' },
description: 'List of recipient email addresses',
},
subject: {
type: 'string',
description: 'Email subject',
},
body: {
type: 'string',
description: 'Email body (plain text)',
},
cc: {
type: 'array',
items: { type: 'string' },
description: 'List of CC email addresses',
},
bcc: {
type: 'array',
items: { type: 'string' },
description: 'List of BCC email addresses',
},
},
required: ['to', 'subject', 'body'],
},
},
{
name: 'gmail_get_attachment',
description:
'Get an attachment from a Gmail message. Returns the attachment data as base64.',
inputSchema: {
type: 'object',
properties: {
messageId: {
type: 'string',
description: 'The ID of the message containing the attachment',
},
attachmentId: {
type: 'string',
description: 'The ID of the attachment to retrieve',
},
},
required: ['messageId', 'attachmentId'],
},
},
];
// Tool handlers
export const gmailHandlers: Record<
string,
(input: unknown) => Promise<unknown>
> = {
gmail_search_messages: gmailSearchMessages,
gmail_get_message: gmailGetMessage,
gmail_list_labels: gmailListLabels,
gmail_modify_labels: gmailModifyLabels,
gmail_send_message: gmailSendMessage,
gmail_reply_message: gmailReplyMessage,
gmail_delete_message: gmailDeleteMessage,
gmail_create_draft: gmailCreateDraft,
gmail_get_attachment: gmailGetAttachment,
};