mcp-memory-libsql
by spences10
Verified
import { google } from 'googleapis';
import { BaseGoogleService } from '../base/BaseGoogleService.js';
import {
GetEmailsParams,
SendEmailParams,
EmailResponse,
SendEmailResponse,
GetGmailSettingsParams,
GetGmailSettingsResponse,
SearchCriteria,
GetEmailsResponse,
ThreadInfo
} from '../../modules/gmail/types.js';
/**
* Gmail service implementation extending BaseGoogleService.
* Handles Gmail-specific operations while leveraging common Google API functionality.
*/
export class GmailService extends BaseGoogleService<ReturnType<typeof google.gmail>> {
constructor() {
super({
serviceName: 'gmail',
version: 'v1'
});
}
/**
* Gets an authenticated Gmail client
*/
private async getGmailClient(email: string) {
return this.getAuthenticatedClient(
email,
(auth) => google.gmail({ version: 'v1', auth })
);
}
/**
* Extracts all headers into a key-value map
*/
private extractHeaders(headers: { name: string; value: string }[]): { [key: string]: string } {
return headers.reduce((acc, header) => {
acc[header.name] = header.value;
return acc;
}, {} as { [key: string]: string });
}
/**
* Groups emails by thread ID and extracts thread information
*/
private groupEmailsByThread(emails: EmailResponse[]): { [threadId: string]: ThreadInfo } {
return emails.reduce((threads, email) => {
if (!threads[email.threadId]) {
threads[email.threadId] = {
messages: [],
participants: [],
subject: email.subject,
lastUpdated: email.date
};
}
const thread = threads[email.threadId];
thread.messages.push(email.id);
if (!thread.participants.includes(email.from)) {
thread.participants.push(email.from);
}
if (email.to && !thread.participants.includes(email.to)) {
thread.participants.push(email.to);
}
const emailDate = new Date(email.date);
const threadDate = new Date(thread.lastUpdated);
if (emailDate > threadDate) {
thread.lastUpdated = email.date;
}
return threads;
}, {} as { [threadId: string]: ThreadInfo });
}
/**
* Builds a Gmail search query string from SearchCriteria
*/
private buildSearchQuery(criteria: SearchCriteria = {}): string {
const queryParts: string[] = [];
if (criteria.from) {
const fromAddresses = Array.isArray(criteria.from) ? criteria.from : [criteria.from];
if (fromAddresses.length === 1) {
queryParts.push(`from:${fromAddresses[0]}`);
} else {
queryParts.push(`{${fromAddresses.map(f => `from:${f}`).join(' OR ')}}`);
}
}
if (criteria.to) {
const toAddresses = Array.isArray(criteria.to) ? criteria.to : [criteria.to];
if (toAddresses.length === 1) {
queryParts.push(`to:${toAddresses[0]}`);
} else {
queryParts.push(`{${toAddresses.map(t => `to:${t}`).join(' OR ')}}`);
}
}
if (criteria.subject) {
const escapedSubject = criteria.subject.replace(/["\\]/g, '\\$&');
queryParts.push(`subject:"${escapedSubject}"`);
}
if (criteria.content) {
const escapedContent = criteria.content.replace(/["\\]/g, '\\$&');
queryParts.push(`"${escapedContent}"`);
}
if (criteria.after) {
const afterDate = new Date(criteria.after);
const afterStr = `${afterDate.getFullYear()}/${(afterDate.getMonth() + 1).toString().padStart(2, '0')}/${afterDate.getDate().toString().padStart(2, '0')}`;
queryParts.push(`after:${afterStr}`);
}
if (criteria.before) {
const beforeDate = new Date(criteria.before);
const beforeStr = `${beforeDate.getFullYear()}/${(beforeDate.getMonth() + 1).toString().padStart(2, '0')}/${beforeDate.getDate().toString().padStart(2, '0')}`;
queryParts.push(`before:${beforeStr}`);
}
if (criteria.hasAttachment) {
queryParts.push('has:attachment');
}
if (criteria.labels && criteria.labels.length > 0) {
criteria.labels.forEach(label => {
queryParts.push(`label:${label}`);
});
}
if (criteria.excludeLabels && criteria.excludeLabels.length > 0) {
criteria.excludeLabels.forEach(label => {
queryParts.push(`-label:${label}`);
});
}
if (criteria.includeSpam) {
queryParts.push('in:anywhere');
}
if (criteria.isUnread !== undefined) {
queryParts.push(criteria.isUnread ? 'is:unread' : 'is:read');
}
return queryParts.join(' ');
}
/**
* Gets emails with proper scope handling for search and content access.
*/
async getEmails({ email, search = {}, options = {}, messageIds }: GetEmailsParams): Promise<GetEmailsResponse> {
try {
const gmail = await this.getGmailClient(email);
const maxResults = options.maxResults || 10;
let messages;
let nextPageToken: string | undefined;
if (messageIds && messageIds.length > 0) {
messages = { messages: messageIds.map(id => ({ id })) };
} else {
const query = this.buildSearchQuery(search);
const { data } = await gmail.users.messages.list({
userId: 'me',
q: query,
maxResults,
pageToken: options.pageToken,
});
messages = data;
nextPageToken = data.nextPageToken || undefined;
}
if (!messages.messages || messages.messages.length === 0) {
return {
emails: [],
resultSummary: {
total: 0,
returned: 0,
hasMore: false,
searchCriteria: search
}
};
}
const emails = await Promise.all(
messages.messages.map(async (message) => {
const { data: email } = await gmail.users.messages.get({
userId: 'me',
id: message.id!,
format: options.format || 'full',
});
const headers = (email.payload?.headers || []).map(h => ({
name: h.name || '',
value: h.value || ''
}));
const subject = headers.find(h => h.name === 'Subject')?.value || '';
const from = headers.find(h => h.name === 'From')?.value || '';
const to = headers.find(h => h.name === 'To')?.value || '';
const date = headers.find(h => h.name === 'Date')?.value || '';
let body = '';
if (email.payload?.body?.data) {
body = Buffer.from(email.payload.body.data, 'base64').toString();
} else if (email.payload?.parts) {
const textPart = email.payload.parts.find(part => part.mimeType === 'text/plain');
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString();
}
}
const response: EmailResponse = {
id: email.id!,
threadId: email.threadId!,
labelIds: email.labelIds || undefined,
snippet: email.snippet || undefined,
subject,
from,
to,
date,
body,
headers: options.includeHeaders ? this.extractHeaders(headers) : undefined,
isUnread: email.labelIds?.includes('UNREAD') || false,
hasAttachment: email.payload?.parts?.some(part => part.filename && part.filename.length > 0) || false
};
return response;
})
);
const threads = options.threadedView ? this.groupEmailsByThread(emails) : undefined;
if (options.sortOrder) {
emails.sort((a, b) => {
const dateA = new Date(a.date).getTime();
const dateB = new Date(b.date).getTime();
return options.sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
});
}
return {
emails,
nextPageToken,
resultSummary: {
total: messages.resultSizeEstimate || emails.length,
returned: emails.length,
hasMore: Boolean(nextPageToken),
searchCriteria: search
},
threads
};
} catch (error) {
throw this.handleError(error, 'Failed to get emails');
}
}
/**
* Sends an email from the specified account
*/
async sendEmail({ email, to, subject, body, cc = [], bcc = [] }: SendEmailParams): Promise<SendEmailResponse> {
try {
const gmail = await this.getGmailClient(email);
const message = [
'Content-Type: text/plain; charset="UTF-8"\n',
'MIME-Version: 1.0\n',
'Content-Transfer-Encoding: 7bit\n',
`To: ${to.join(', ')}\n`,
cc.length > 0 ? `Cc: ${cc.join(', ')}\n` : '',
bcc.length > 0 ? `Bcc: ${bcc.join(', ')}\n` : '',
`Subject: ${subject}\n\n`,
body,
].join('');
const encodedMessage = Buffer.from(message)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const { data } = await gmail.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
},
});
return {
messageId: data.id!,
threadId: data.threadId!,
labelIds: data.labelIds || undefined,
};
} catch (error) {
throw this.handleError(error, 'Failed to send email');
}
}
/**
* Gets Gmail settings and profile information
*/
async getWorkspaceGmailSettings({ email }: GetGmailSettingsParams): Promise<GetGmailSettingsResponse> {
try {
const gmail = await this.getGmailClient(email);
const { data: profile } = await gmail.users.getProfile({
userId: 'me'
});
const [
{ data: autoForwarding },
{ data: imap },
{ data: language },
{ data: pop },
{ data: vacation }
] = await Promise.all([
gmail.users.settings.getAutoForwarding({ userId: 'me' }),
gmail.users.settings.getImap({ userId: 'me' }),
gmail.users.settings.getLanguage({ userId: 'me' }),
gmail.users.settings.getPop({ userId: 'me' }),
gmail.users.settings.getVacation({ userId: 'me' })
]);
const nullSafeString = (value: string | null | undefined): string | undefined =>
value === null ? undefined : value;
return {
profile: {
emailAddress: profile.emailAddress ?? '',
messagesTotal: typeof profile.messagesTotal === 'number' ? profile.messagesTotal : 0,
threadsTotal: typeof profile.threadsTotal === 'number' ? profile.threadsTotal : 0,
historyId: profile.historyId ?? ''
},
settings: {
...(language?.displayLanguage && {
language: {
displayLanguage: language.displayLanguage
}
}),
...(autoForwarding && {
autoForwarding: {
enabled: Boolean(autoForwarding.enabled),
...(autoForwarding.emailAddress && {
emailAddress: autoForwarding.emailAddress
})
}
}),
...(imap && {
imap: {
enabled: Boolean(imap.enabled),
...(typeof imap.autoExpunge === 'boolean' && {
autoExpunge: imap.autoExpunge
}),
...(imap.expungeBehavior && {
expungeBehavior: imap.expungeBehavior
})
}
}),
...(pop && {
pop: {
enabled: Boolean(pop.accessWindow),
...(pop.accessWindow && {
accessWindow: pop.accessWindow
})
}
}),
...(vacation && {
vacationResponder: {
enabled: Boolean(vacation.enableAutoReply),
...(vacation.startTime && {
startTime: vacation.startTime
}),
...(vacation.endTime && {
endTime: vacation.endTime
}),
...(vacation.responseSubject && {
responseSubject: vacation.responseSubject
}),
...((vacation.responseBodyHtml || vacation.responseBodyPlainText) && {
message: vacation.responseBodyHtml ?? vacation.responseBodyPlainText ?? ''
})
}
})
}
} as GetGmailSettingsResponse;
} catch (error) {
throw this.handleError(error, 'Failed to get Gmail settings');
}
}
}