uhbarp-gmail-mcp-server
Version:
Gmail MCP Server for managing Gmail through natural language interactions with full OAuth2 authentication support
539 lines (538 loc) • 20.1 kB
JavaScript
import { gmailAuth } from './gmail-auth.js';
import { logger } from './api.js';
import mimeTypes from 'mime-types';
/**
* Gmail operations manager class
*/
export class GmailOperations {
constructor() {
this.gmail = null;
}
/**
* Get authenticated Gmail client
*/
async getGmailClient() {
if (!this.gmail) {
this.gmail = await gmailAuth.getGmailClient();
}
return this.gmail;
}
/**
* Encode string to base64url
*/
encodeBase64Url(str) {
return Buffer.from(str)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Decode base64url string
*/
decodeBase64Url(str) {
// Add padding if needed
const padding = 4 - (str.length % 4);
const paddedStr = str + '='.repeat(padding % 4);
return Buffer.from(paddedStr.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString();
}
/**
* Create email message in RFC 2822 format
*/
createRawMessage(message) {
const lines = [];
// Headers
lines.push(`To: ${message.to.join(', ')}`);
if (message.cc && message.cc.length > 0) {
lines.push(`Cc: ${message.cc.join(', ')}`);
}
if (message.bcc && message.bcc.length > 0) {
lines.push(`Bcc: ${message.bcc.join(', ')}`);
}
if (message.replyTo) {
lines.push(`Reply-To: ${message.replyTo}`);
}
// Subject with proper encoding for international characters
const encodedSubject = message.subject
.replace(/[^\x00-\x7F]/g, (match) => `=?UTF-8?B?${Buffer.from(match).toString('base64')}?=`);
lines.push(`Subject: ${encodedSubject}`);
lines.push('MIME-Version: 1.0');
if (message.attachments && message.attachments.length > 0) {
// Multipart with attachments
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
lines.push('');
// Message body part
lines.push(`--${boundary}`);
if (message.html && message.text) {
// Multipart alternative for HTML and text
const altBoundary = `alt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
lines.push('');
lines.push(`--${altBoundary}`);
lines.push('Content-Type: text/plain; charset=UTF-8');
lines.push('Content-Transfer-Encoding: 8bit');
lines.push('');
lines.push(message.text);
lines.push('');
lines.push(`--${altBoundary}`);
lines.push('Content-Type: text/html; charset=UTF-8');
lines.push('Content-Transfer-Encoding: 8bit');
lines.push('');
lines.push(message.html);
lines.push('');
lines.push(`--${altBoundary}--`);
}
else {
lines.push(`Content-Type: ${message.html ? 'text/html' : 'text/plain'}; charset=UTF-8`);
lines.push('Content-Transfer-Encoding: 8bit');
lines.push('');
lines.push(message.html || message.text || '');
lines.push('');
}
// Attachments
for (const attachment of message.attachments) {
lines.push(`--${boundary}`);
const contentType = attachment.contentType || mimeTypes.lookup(attachment.filename) || 'application/octet-stream';
lines.push(`Content-Type: ${contentType}`);
lines.push('Content-Transfer-Encoding: base64');
lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
lines.push('');
const content = Buffer.isBuffer(attachment.content)
? attachment.content
: Buffer.from(attachment.content, attachment.encoding || 'utf8');
lines.push(content.toString('base64'));
lines.push('');
}
lines.push(`--${boundary}--`);
}
else if (message.html && message.text) {
// Multipart alternative for HTML and text
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
lines.push('');
lines.push(`--${boundary}`);
lines.push('Content-Type: text/plain; charset=UTF-8');
lines.push('Content-Transfer-Encoding: 8bit');
lines.push('');
lines.push(message.text);
lines.push('');
lines.push(`--${boundary}`);
lines.push('Content-Type: text/html; charset=UTF-8');
lines.push('Content-Transfer-Encoding: 8bit');
lines.push('');
lines.push(message.html);
lines.push('');
lines.push(`--${boundary}--`);
}
else {
// Simple message
lines.push(`Content-Type: ${message.html ? 'text/html' : 'text/plain'}; charset=UTF-8`);
lines.push('Content-Transfer-Encoding: 8bit');
lines.push('');
lines.push(message.html || message.text || '');
}
return lines.join('\r\n');
}
/**
* Send an email
*/
async sendEmail(message) {
try {
const gmail = await this.getGmailClient();
const rawMessage = this.createRawMessage(message);
const encodedMessage = this.encodeBase64Url(rawMessage);
logger.log(`Sending email to: ${message.to.join(', ')}`);
logger.log(`Subject: ${message.subject}`);
const response = await gmail.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage
}
});
logger.log(`Email sent successfully: ${response.data.id}`);
return {
id: response.data.id,
threadId: response.data.threadId
};
}
catch (error) {
logger.error('Error sending email:', error);
throw new Error(`Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get email by ID
*/
async getEmail(messageId, format = 'full') {
try {
const gmail = await this.getGmailClient();
logger.log(`Retrieving email: ${messageId}`);
const response = await gmail.users.messages.get({
userId: 'me',
id: messageId,
format
});
return response.data;
}
catch (error) {
logger.error('Error retrieving email:', error);
throw new Error(`Failed to retrieve email: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get email attachment
*/
async getAttachment(messageId, attachmentId) {
try {
const gmail = await this.getGmailClient();
logger.log(`Retrieving attachment: ${attachmentId} from message: ${messageId}`);
const response = await gmail.users.messages.attachments.get({
userId: 'me',
messageId,
id: attachmentId
});
return {
data: response.data.data,
size: response.data.size
};
}
catch (error) {
logger.error('Error retrieving attachment:', error);
throw new Error(`Failed to retrieve attachment: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Search emails
*/
async searchEmails(criteria = {}) {
try {
const gmail = await this.getGmailClient();
// Build search query
const queryParts = [];
if (criteria.query)
queryParts.push(criteria.query);
if (criteria.from)
queryParts.push(`from:${criteria.from}`);
if (criteria.to)
queryParts.push(`to:${criteria.to}`);
if (criteria.subject)
queryParts.push(`subject:${criteria.subject}`);
if (criteria.after)
queryParts.push(`after:${criteria.after}`);
if (criteria.before)
queryParts.push(`before:${criteria.before}`);
if (criteria.hasAttachment)
queryParts.push('has:attachment');
if (criteria.label)
queryParts.push(`label:${criteria.label}`);
if (criteria.isUnread)
queryParts.push('is:unread');
const query = queryParts.join(' ');
logger.log(`Searching emails with query: ${query}`);
const response = await gmail.users.messages.list({
userId: 'me',
q: query,
maxResults: criteria.maxResults || 100
});
if (!response.data.messages) {
return { messages: [] };
}
// Get full message details
const messages = await Promise.all(response.data.messages.map(msg => this.getEmail(msg.id, 'metadata')));
return {
messages,
nextPageToken: response.data.nextPageToken || undefined
};
}
catch (error) {
logger.error('Error searching emails:', error);
throw new Error(`Failed to search emails: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Mark email as read/unread
*/
async markEmail(messageId, read) {
try {
const gmail = await this.getGmailClient();
logger.log(`Marking email ${messageId} as ${read ? 'read' : 'unread'}`);
if (read) {
await gmail.users.messages.modify({
userId: 'me',
id: messageId,
requestBody: {
removeLabelIds: ['UNREAD']
}
});
}
else {
await gmail.users.messages.modify({
userId: 'me',
id: messageId,
requestBody: {
addLabelIds: ['UNREAD']
}
});
}
}
catch (error) {
logger.error('Error marking email:', error);
throw new Error(`Failed to mark email: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Move email to label/folder
*/
async moveToLabel(messageId, labelId, removeLabelIds) {
try {
const gmail = await this.getGmailClient();
logger.log(`Moving email ${messageId} to label ${labelId}`);
await gmail.users.messages.modify({
userId: 'me',
id: messageId,
requestBody: {
addLabelIds: [labelId],
removeLabelIds: removeLabelIds || []
}
});
}
catch (error) {
logger.error('Error moving email:', error);
throw new Error(`Failed to move email: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Delete email
*/
async deleteEmail(messageId) {
try {
const gmail = await this.getGmailClient();
logger.log(`Deleting email: ${messageId}`);
await gmail.users.messages.delete({
userId: 'me',
id: messageId
});
}
catch (error) {
logger.error('Error deleting email:', error);
throw new Error(`Failed to delete email: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* List emails in inbox, sent, or custom label
*/
async listEmails(labelId = 'INBOX', maxResults = 50) {
try {
const gmail = await this.getGmailClient();
logger.log(`Listing emails in label: ${labelId}`);
const response = await gmail.users.messages.list({
userId: 'me',
labelIds: [labelId],
maxResults
});
if (!response.data.messages) {
return { messages: [] };
}
// Get message details
const messages = await Promise.all(response.data.messages.map(msg => this.getEmail(msg.id, 'metadata')));
return {
messages,
nextPageToken: response.data.nextPageToken || undefined
};
}
catch (error) {
logger.error('Error listing emails:', error);
throw new Error(`Failed to list emails: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get all Gmail labels
*/
async getLabels() {
try {
const gmail = await this.getGmailClient();
logger.log('Retrieving Gmail labels');
const response = await gmail.users.labels.list({
userId: 'me'
});
return response.data.labels || [];
}
catch (error) {
logger.error('Error retrieving labels:', error);
throw new Error(`Failed to retrieve labels: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Create a new label
*/
async createLabel(name, visible = true) {
try {
const gmail = await this.getGmailClient();
logger.log(`Creating label: ${name}`);
const response = await gmail.users.labels.create({
userId: 'me',
requestBody: {
name,
labelListVisibility: visible ? 'labelShow' : 'labelHide',
messageListVisibility: 'show'
}
});
return response.data;
}
catch (error) {
logger.error('Error creating label:', error);
throw new Error(`Failed to create label: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Update a label
*/
async updateLabel(labelId, name, visible) {
try {
const gmail = await this.getGmailClient();
logger.log(`Updating label: ${labelId}`);
const requestBody = {};
if (name)
requestBody.name = name;
if (visible !== undefined) {
requestBody.labelListVisibility = visible ? 'labelShow' : 'labelHide';
}
const response = await gmail.users.labels.update({
userId: 'me',
id: labelId,
requestBody
});
return response.data;
}
catch (error) {
logger.error('Error updating label:', error);
throw new Error(`Failed to update label: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Delete a label
*/
async deleteLabel(labelId) {
try {
const gmail = await this.getGmailClient();
logger.log(`Deleting label: ${labelId}`);
await gmail.users.labels.delete({
userId: 'me',
id: labelId
});
}
catch (error) {
logger.error('Error deleting label:', error);
throw new Error(`Failed to delete label: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Batch operations on multiple emails
*/
async batchMarkAsRead(messageIds) {
try {
const gmail = await this.getGmailClient();
logger.log(`Batch marking ${messageIds.length} emails as read`);
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: messageIds,
removeLabelIds: ['UNREAD']
}
});
}
catch (error) {
logger.error('Error batch marking emails as read:', error);
throw new Error(`Failed to batch mark emails as read: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Batch move emails to label
*/
async batchMoveToLabel(messageIds, labelId, removeLabelIds) {
try {
const gmail = await this.getGmailClient();
logger.log(`Batch moving ${messageIds.length} emails to label ${labelId}`);
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: messageIds,
addLabelIds: [labelId],
removeLabelIds: removeLabelIds || []
}
});
}
catch (error) {
logger.error('Error batch moving emails:', error);
throw new Error(`Failed to batch move emails: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Batch delete emails
*/
async batchDelete(messageIds) {
try {
const gmail = await this.getGmailClient();
logger.log(`Batch deleting ${messageIds.length} emails`);
await gmail.users.messages.batchDelete({
userId: 'me',
requestBody: {
ids: messageIds
}
});
}
catch (error) {
logger.error('Error batch deleting emails:', error);
throw new Error(`Failed to batch delete emails: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Extract email content from payload
*/
extractEmailContent(payload) {
const result = { attachments: [] };
const extractParts = (parts) => {
for (const part of parts) {
if (part.parts) {
extractParts(part.parts);
}
else if (part.body && part.body.data) {
const mimeType = part.mimeType;
const data = this.decodeBase64Url(part.body.data);
if (mimeType === 'text/plain') {
result.text = data;
}
else if (mimeType === 'text/html') {
result.html = data;
}
}
// Handle attachments
if (part.filename && part.body && (part.body.attachmentId || part.body.data)) {
result.attachments.push({
filename: part.filename,
mimeType: part.mimeType,
size: part.body.size,
attachmentId: part.body.attachmentId
});
}
}
};
if (payload.parts) {
extractParts(payload.parts);
}
else if (payload.body && payload.body.data) {
const mimeType = payload.mimeType;
const data = this.decodeBase64Url(payload.body.data);
if (mimeType === 'text/plain') {
result.text = data;
}
else if (mimeType === 'text/html') {
result.html = data;
}
}
return result;
}
}
// Export singleton instance
export const gmailOperations = new GmailOperations();