systemprompt-mcp-gmail
Version:
A specialized Model Context Protocol (MCP) server that enables you to search, read, delete and send emails from your Gmail account, leveraging an AI Agent to help with each operation.
491 lines • 18.6 kB
JavaScript
import { google } from "googleapis";
import { GoogleBaseService } from "./google-base-service.js";
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function validateEmail(email) {
return EMAIL_REGEX.test(email.trim());
}
function validateEmailList(emails) {
if (!emails)
return;
const emailList = Array.isArray(emails) ? emails : emails.split(",").map((e) => e.trim());
for (const email of emailList) {
if (!validateEmail(email)) {
throw new Error(`Invalid email address: ${email}`);
}
}
}
export class GmailService extends GoogleBaseService {
gmail;
labelCache = new Map();
gmailInitPromise;
constructor() {
super();
this.gmailInitPromise = this.initializeGmailClient();
}
async initializeGmailClient() {
await this.waitForInit();
this.gmail = google.gmail({ version: "v1", auth: this.auth.getAuth() });
}
// Helper method to ensure initialization is complete
async ensureInitialized() {
await this.gmailInitPromise;
}
async loadLabels() {
await this.ensureInitialized();
if (this.labelCache.size === 0) {
const labels = await this.getLabels();
labels.forEach((label) => {
this.labelCache.set(label.id, label);
});
}
}
parseEmailAddress(address) {
const match = address.match(/(?:"?([^"]*)"?\s)?(?:<)?(.+@[^>]+)(?:>)?/);
if (match) {
return {
name: match[1]?.trim(),
email: match[2].trim(),
};
}
return { email: address.trim() };
}
async getMessageMetadata(messageId) {
await this.ensureInitialized();
try {
const response = await this.gmail.users.messages.get({
userId: "me",
id: messageId,
format: "metadata",
metadataHeaders: [
"From",
"To",
"Cc",
"Bcc",
"Subject",
"Date",
"Reply-To",
"Message-ID",
"References",
"Content-Type",
],
});
return response.data;
}
catch (error) {
console.error(`Failed to get message metadata for ${messageId}:`, error);
throw error;
}
}
async extractEmailMetadata(message) {
await this.loadLabels();
const headers = message.payload?.headers || [];
const fromHeader = headers.find((h) => h.name === "From")?.value || "";
const toHeader = headers.find((h) => h.name === "To")?.value || "";
const dateStr = headers.find((h) => h.name === "Date")?.value;
const labels = (message.labelIds || [])
.map((id) => {
const label = this.labelCache.get(id);
return label ? { id, name: label.name || id } : null;
})
.filter((label) => label !== null);
return {
id: message.id,
threadId: message.threadId,
snippet: message.snippet?.replace(/'/g, "'").replace(/"/g, '"') || "",
from: this.parseEmailAddress(fromHeader),
to: toHeader.split(",").map((addr) => this.parseEmailAddress(addr.trim())),
subject: headers.find((h) => h.name === "Subject")?.value || "(no subject)",
date: dateStr ? new Date(dateStr) : new Date(),
labels,
hasAttachments: Boolean(message.payload?.parts?.some((part) => part.filename && part.filename.length > 0)),
isUnread: message.labelIds?.includes("UNREAD") || false,
isImportant: message.labelIds?.includes("IMPORTANT") || false,
};
}
async listMessages(maxResults = 100) {
await this.ensureInitialized();
try {
const response = await this.gmail.users.messages.list({
userId: "me",
maxResults,
});
const messages = response.data.messages || [];
const messageDetails = await Promise.all(messages.map((msg) => this.getMessageMetadata(msg.id)));
return await Promise.all(messageDetails.map((msg) => this.extractEmailMetadata(msg)));
}
catch (error) {
console.error("Failed to list Gmail messages:", error);
throw error;
}
}
async getMessage(messageId) {
await this.ensureInitialized();
try {
const response = await this.gmail.users.messages.get({
userId: "me",
id: messageId,
format: "full",
});
const metadata = await this.extractEmailMetadata(response.data);
let body = "";
// Extract message body
const message = response.data;
if (message.payload) {
if (message.payload.body?.data) {
body = Buffer.from(message.payload.body.data, "base64").toString("utf8");
}
else if (message.payload.parts) {
const textPart = message.payload.parts.find((part) => part.mimeType === "text/plain" || part.mimeType === "text/html");
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, "base64").toString("utf8");
}
}
}
return {
...metadata,
body,
};
}
catch (error) {
console.error("Failed to get Gmail message:", error);
throw error;
}
}
async searchMessages(query, maxResults = 10) {
await this.ensureInitialized();
try {
const response = await this.gmail.users.messages.list({
userId: "me",
q: query,
maxResults,
});
const messages = response.data.messages || [];
const messageDetails = await Promise.all(messages.map((msg) => this.getMessageMetadata(msg.id)));
return await Promise.all(messageDetails.map((msg) => this.extractEmailMetadata(msg)));
}
catch (error) {
console.error("Failed to search Gmail messages:", error);
throw error;
}
}
async getLabels() {
await this.ensureInitialized();
try {
const response = await this.gmail.users.labels.list({
userId: "me",
});
return response.data.labels || [];
}
catch (error) {
console.error("Failed to get Gmail labels:", error);
throw error;
}
}
createEmailRaw(options) {
const boundary = "boundary" + Date.now().toString();
const toList = Array.isArray(options.to) ? options.to : [options.to];
const ccList = options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : [];
const bccList = options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : [];
let email = [
`Content-Type: multipart/mixed; boundary="${boundary}"`,
"MIME-Version: 1.0",
`To: ${toList.join(", ")}`,
`Subject: ${options.subject}`,
];
if (ccList.length > 0)
email.push(`Cc: ${ccList.join(", ")}`);
if (bccList.length > 0)
email.push(`Bcc: ${bccList.join(", ")}`);
if (options.replyTo)
email.push(`Reply-To: ${options.replyTo}`);
email.push("", `--${boundary}`);
// Add the email body
email.push(`Content-Type: ${options.isHtml ? "text/html" : "text/plain"}; charset="UTF-8"`, "MIME-Version: 1.0", "Content-Transfer-Encoding: 7bit", "", options.body, "");
// Add attachments if any
if (options.attachments?.length) {
for (const attachment of options.attachments) {
const content = Buffer.isBuffer(attachment.content)
? attachment.content.toString("base64")
: Buffer.from(attachment.content).toString("base64");
email.push(`--${boundary}`, "Content-Type: " + (attachment.contentType || "application/octet-stream"), "MIME-Version: 1.0", "Content-Transfer-Encoding: base64", `Content-Disposition: attachment; filename="${attachment.filename}"`, "", content.replace(/(.{76})/g, "$1\n"), "");
}
}
email.push(`--${boundary}--`);
return Buffer.from(email.join("\r\n")).toString("base64url");
}
async sendEmail(options) {
await this.ensureInitialized();
try {
// Validate email addresses
validateEmailList(options.to);
validateEmailList(options.cc);
validateEmailList(options.bcc);
const raw = this.createEmailRaw(options);
const response = await this.gmail.users.messages.send({
userId: "me",
requestBody: { raw },
});
return response.data.id;
}
catch (error) {
console.error("Failed to send email:", error);
throw error;
}
}
async createDraft(options) {
await this.ensureInitialized();
try {
// Validate email addresses
validateEmailList(options.to);
validateEmailList(options.cc);
validateEmailList(options.bcc);
const raw = this.createEmailRaw(options);
const response = await this.gmail.users.drafts.create({
userId: "me",
requestBody: {
message: { raw },
},
});
return response.data.id;
}
catch (error) {
console.error("Failed to create draft:", error);
throw error;
}
}
async updateDraft(options) {
if (!options.id) {
throw new Error("Draft ID is required for updating");
}
await this.ensureInitialized();
try {
// Validate email addresses
validateEmailList(options.to);
validateEmailList(options.cc);
validateEmailList(options.bcc);
const raw = this.createEmailRaw(options);
const response = await this.gmail.users.drafts.update({
userId: "me",
id: options.id,
requestBody: {
message: { raw },
},
});
return response.data.id;
}
catch (error) {
console.error("Failed to update draft:", error);
throw error;
}
}
async listDrafts(maxResults = 10) {
await this.ensureInitialized();
try {
const response = await this.gmail.users.drafts.list({
userId: "me",
maxResults,
});
const drafts = response.data.drafts || [];
const messageDetails = await Promise.all(drafts.map((draft) => this.getMessageMetadata(draft.message.id)));
return await Promise.all(messageDetails.map((msg) => this.extractEmailMetadata(msg)));
}
catch (error) {
console.error("Failed to list drafts:", error);
throw error;
}
}
async deleteDraft(draftId) {
await this.ensureInitialized();
try {
await this.gmail.users.drafts.delete({
userId: "me",
id: draftId,
});
}
catch (error) {
console.error("Failed to delete draft:", error);
throw error;
}
}
async getDraft(draftId) {
await this.ensureInitialized();
try {
const response = await this.gmail.users.drafts.get({
userId: "me",
id: draftId,
format: "full",
});
if (!response.data.message) {
throw new Error("Draft message not found");
}
const metadata = await this.extractEmailMetadata(response.data.message);
let body = "";
// Extract message body
const message = response.data.message;
if (message.payload) {
if (message.payload.body?.data) {
body = Buffer.from(message.payload.body.data, "base64").toString("utf8");
}
else if (message.payload.parts) {
const textPart = message.payload.parts.find((part) => part.mimeType === "text/plain" || part.mimeType === "text/html");
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, "base64").toString("utf8");
}
}
}
return {
...metadata,
body,
};
}
catch (error) {
console.error("Failed to get draft:", error);
throw error;
}
}
async modifyMessage(messageId, options) {
await this.ensureInitialized();
try {
const response = await this.gmail.users.messages.modify({
userId: "me",
id: messageId,
requestBody: options,
});
return this.extractEmailMetadata(response.data);
}
catch (error) {
console.error("Failed to modify message:", error);
throw error;
}
}
async trashMessage(messageId) {
await this.ensureInitialized();
try {
await this.gmail.users.messages.trash({
userId: "me",
id: messageId,
});
}
catch (error) {
console.error("Failed to trash message:", error);
throw error;
}
}
async untrashMessage(messageId) {
await this.ensureInitialized();
try {
await this.gmail.users.messages.untrash({
userId: "me",
id: messageId,
});
}
catch (error) {
console.error("Failed to untrash message:", error);
throw error;
}
}
async deleteMessage(messageId) {
await this.ensureInitialized();
try {
await this.gmail.users.messages.delete({
userId: "me",
id: messageId,
});
}
catch (error) {
console.error("Failed to delete message:", error);
throw error;
}
}
async createLabel(name, options = {}) {
await this.ensureInitialized();
try {
const response = await this.gmail.users.labels.create({
userId: "me",
requestBody: {
name,
messageListVisibility: options.messageListVisibility,
labelListVisibility: options.labelListVisibility,
color: options.textColor || options.backgroundColor
? {
textColor: options.textColor,
backgroundColor: options.backgroundColor,
}
: undefined,
},
});
// Update label cache
this.labelCache.set(response.data.id, response.data);
return response.data;
}
catch (error) {
console.error("Failed to create label:", error);
throw error;
}
}
async deleteLabel(labelId) {
await this.ensureInitialized();
try {
await this.gmail.users.labels.delete({
userId: "me",
id: labelId,
});
// Remove from cache
this.labelCache.delete(labelId);
}
catch (error) {
console.error("Failed to delete label:", error);
throw error;
}
}
async replyEmail(messageId, body, isHtml = false) {
await this.ensureInitialized();
try {
// Get the original message to extract threading information
const originalMessage = (await this.gmail.users.messages.get({
userId: "me",
id: messageId,
format: "metadata",
metadataHeaders: ["Subject", "Message-ID", "References", "From", "To"],
})).data;
const headers = originalMessage.payload?.headers || [];
const subjectHeader = headers.find((h) => h.name === "Subject");
const messageIdHeader = headers.find((h) => h.name === "Message-ID");
const referencesHeader = headers.find((h) => h.name === "References");
const fromHeader = headers.find((h) => h.name === "From");
const toHeader = headers.find((h) => h.name === "To");
const subject = subjectHeader?.value || "";
const originalMessageId = messageIdHeader?.value || "";
const references = referencesHeader?.value || "";
const from = fromHeader?.value || "";
const to = toHeader?.value || "";
// Build References header for proper threading
const newReferences = references ? `${references} ${originalMessageId}` : originalMessageId;
// Create email with proper threading headers
const email = [
`Content-Type: ${isHtml ? "text/html" : "text/plain"}; charset="UTF-8"`,
"MIME-Version: 1.0",
`Subject: ${subject.startsWith("Re:") ? subject : `Re: ${subject}`}`,
`To: ${from}`,
`References: ${newReferences}`,
`In-Reply-To: ${originalMessageId}`,
"",
body,
].join("\r\n");
const raw = Buffer.from(email).toString("base64url");
const response = await this.gmail.users.messages.send({
userId: "me",
requestBody: {
raw,
threadId: originalMessage.threadId,
},
});
return response.data.id;
}
catch (error) {
console.error("Failed to reply to email:", error);
throw error;
}
}
}
//# sourceMappingURL=gmail-service.js.map