mcp-memory-libsql
by spences10
Verified
import { gmail_v1 } from 'googleapis';
import {
Label,
CreateLabelParams,
UpdateLabelParams,
DeleteLabelParams,
GetLabelsParams,
GetLabelsResponse,
ModifyMessageLabelsParams,
GmailError,
CreateLabelFilterParams,
GetLabelFiltersParams,
GetLabelFiltersResponse,
UpdateLabelFilterParams,
DeleteLabelFilterParams,
LabelFilterCriteria,
LabelFilterActions,
LabelFilter
} from '../types.js';
import {
isValidGmailLabelColor,
getNearestGmailLabelColor,
LABEL_ERROR_MESSAGES
} from '../constants.js';
export type LabelAction = 'create' | 'read' | 'update' | 'delete';
export type LabelAssignmentAction = 'add' | 'remove';
export type LabelFilterAction = 'create' | 'read' | 'update' | 'delete';
export interface ManageLabelParams {
action: LabelAction;
email: string;
labelId?: string;
data?: {
name?: string;
messageListVisibility?: 'show' | 'hide';
labelListVisibility?: 'labelShow' | 'labelHide' | 'labelShowIfUnread';
color?: {
textColor: string;
backgroundColor: string;
};
};
}
export interface ManageLabelAssignmentParams {
action: LabelAssignmentAction;
email: string;
messageId: string;
labelIds: string[];
}
export interface ManageLabelFilterParams {
action: LabelFilterAction;
email: string;
filterId?: string;
labelId?: string;
data?: {
criteria: LabelFilterCriteria;
actions: LabelFilterActions;
};
}
export class LabelService {
private client: gmail_v1.Gmail | null = null;
updateClient(client: gmail_v1.Gmail) {
this.client = client;
}
private ensureClient() {
if (!this.client) {
throw new GmailError(
'Gmail client not initialized',
'CLIENT_ERROR',
'Please ensure the service is initialized with a valid client'
);
}
}
async manageLabel(params: ManageLabelParams): Promise<Label | GetLabelsResponse | void> {
this.ensureClient();
switch (params.action) {
case 'create':
if (!params.data?.name) {
throw new GmailError(
'Label name is required for creation',
'VALIDATION_ERROR',
'Please provide a name for the label'
);
}
return this.createLabel({
email: params.email,
name: params.data.name,
messageListVisibility: params.data.messageListVisibility,
labelListVisibility: params.data.labelListVisibility,
color: params.data.color
});
case 'read':
if (params.labelId) {
// Get specific label
const response = await this.client?.users.labels.get({
userId: params.email,
id: params.labelId
});
if (!response?.data) {
throw new GmailError(
'Label not found',
'NOT_FOUND_ERROR',
`Label ${params.labelId} does not exist`
);
}
return this.mapGmailLabel(response.data);
} else {
// Get all labels
return this.getLabels({ email: params.email });
}
case 'update':
if (!params.labelId) {
throw new GmailError(
'Label ID is required for update',
'VALIDATION_ERROR',
'Please provide a label ID'
);
}
return this.updateLabel({
email: params.email,
labelId: params.labelId,
...params.data
});
case 'delete':
if (!params.labelId) {
throw new GmailError(
'Label ID is required for deletion',
'VALIDATION_ERROR',
'Please provide a label ID'
);
}
return this.deleteLabel({
email: params.email,
labelId: params.labelId
});
default:
throw new GmailError(
'Invalid label action',
'VALIDATION_ERROR',
`Action ${params.action} is not supported`
);
}
}
async manageLabelAssignment(params: ManageLabelAssignmentParams): Promise<void> {
this.ensureClient();
const modifyParams: ModifyMessageLabelsParams = {
email: params.email,
messageId: params.messageId,
addLabelIds: params.action === 'add' ? params.labelIds : [],
removeLabelIds: params.action === 'remove' ? params.labelIds : []
};
return this.modifyMessageLabels(modifyParams);
}
/**
* Validate filter criteria to ensure all required fields are present and properly formatted
*/
private validateFilterCriteria(criteria: LabelFilterCriteria): void {
if (!criteria) {
throw new GmailError(
'Filter criteria is required',
'VALIDATION_ERROR',
'Please provide filter criteria'
);
}
// At least one filtering condition must be specified
const hasCondition = (criteria.from && criteria.from.length > 0) ||
(criteria.to && criteria.to.length > 0) ||
criteria.subject ||
(criteria.hasWords && criteria.hasWords.length > 0) ||
(criteria.doesNotHaveWords && criteria.doesNotHaveWords.length > 0) ||
criteria.hasAttachment ||
criteria.size;
if (!hasCondition) {
throw new GmailError(
'Invalid filter criteria',
'VALIDATION_ERROR',
'At least one filtering condition must be specified (from, to, subject, hasWords, doesNotHaveWords, hasAttachment, or size)'
);
}
// Validate email arrays
if (criteria.from?.length) {
criteria.from.forEach(email => {
if (!email.includes('@')) {
throw new GmailError(
'Invalid email address in from criteria',
'VALIDATION_ERROR',
`Invalid email address: ${email}`
);
}
});
}
if (criteria.to?.length) {
criteria.to.forEach(email => {
if (!email.includes('@')) {
throw new GmailError(
'Invalid email address in to criteria',
'VALIDATION_ERROR',
`Invalid email address: ${email}`
);
}
});
}
// Validate size criteria if present
if (criteria.size) {
if (typeof criteria.size.size !== 'number' || criteria.size.size <= 0) {
throw new GmailError(
'Invalid size criteria',
'VALIDATION_ERROR',
'Size must be a positive number'
);
}
if (!['larger', 'smaller'].includes(criteria.size.operator)) {
throw new GmailError(
'Invalid size operator',
'VALIDATION_ERROR',
'Size operator must be either "larger" or "smaller"'
);
}
}
}
/**
* Build Gmail API query string from filter criteria
*/
private buildFilterQuery(criteria: LabelFilterCriteria): string {
const conditions: string[] = [];
if (criteria.from?.length) {
conditions.push(`{${criteria.from.map(email => `from:${email}`).join(' OR ')}}`);
}
if (criteria.to?.length) {
conditions.push(`{${criteria.to.map(email => `to:${email}`).join(' OR ')}}`);
}
if (criteria.subject) {
conditions.push(`subject:"${criteria.subject}"`);
}
if (criteria.hasWords?.length) {
conditions.push(`{${criteria.hasWords.join(' OR ')}}`);
}
if (criteria.doesNotHaveWords?.length) {
conditions.push(`-{${criteria.doesNotHaveWords.join(' OR ')}}`);
}
if (criteria.hasAttachment) {
conditions.push('has:attachment');
}
if (criteria.size) {
conditions.push(`size${criteria.size.operator === 'larger' ? '>' : '<'}${criteria.size.size}`);
}
return conditions.join(' ');
}
async manageLabelFilter(params: ManageLabelFilterParams): Promise<LabelFilter | GetLabelFiltersResponse | void> {
this.ensureClient();
switch (params.action) {
case 'create':
if (!params.labelId) {
throw new GmailError(
'Label ID is required',
'VALIDATION_ERROR',
'Please provide a valid label ID'
);
}
if (!params.data?.criteria || !params.data?.actions) {
throw new GmailError(
'Filter configuration is required',
'VALIDATION_ERROR',
'Please provide both criteria and actions for the filter'
);
}
// Validate filter criteria
this.validateFilterCriteria(params.data.criteria);
return this.createLabelFilter({
email: params.email,
labelId: params.labelId,
criteria: params.data.criteria,
actions: params.data.actions
});
case 'read':
return this.getLabelFilters({
email: params.email,
labelId: params.labelId
});
case 'update':
if (!params.filterId || !params.labelId || !params.data?.criteria || !params.data?.actions) {
throw new GmailError(
'Missing required filter update data',
'VALIDATION_ERROR',
'Please provide filterId, labelId, criteria, and actions'
);
}
return this.updateLabelFilter({
email: params.email,
filterId: params.filterId,
labelId: params.labelId,
criteria: params.data.criteria,
actions: params.data.actions
});
case 'delete':
if (!params.filterId) {
throw new GmailError(
'Filter ID is required for deletion',
'VALIDATION_ERROR',
'Please provide a filter ID'
);
}
return this.deleteLabelFilter({
email: params.email,
filterId: params.filterId
});
default:
throw new GmailError(
'Invalid filter action',
'VALIDATION_ERROR',
`Action ${params.action} is not supported`
);
}
}
// Helper methods that implement the actual operations
private async createLabel(params: CreateLabelParams): Promise<Label> {
try {
if (params.color) {
const { textColor, backgroundColor } = params.color;
if (!isValidGmailLabelColor(textColor, backgroundColor)) {
const suggestedColor = getNearestGmailLabelColor(backgroundColor);
throw new GmailError(
LABEL_ERROR_MESSAGES.INVALID_COLOR,
'COLOR_ERROR',
LABEL_ERROR_MESSAGES.COLOR_SUGGESTION(backgroundColor, suggestedColor)
);
}
}
if (!this.client) {
throw new GmailError(
'Gmail client not initialized',
'CLIENT_ERROR',
'Please ensure the service is initialized with a valid client'
);
}
const response = await this.client.users.labels.create({
userId: params.email,
requestBody: {
name: params.name,
messageListVisibility: params.messageListVisibility || 'show',
labelListVisibility: params.labelListVisibility || 'labelShow',
color: params.color && {
textColor: params.color.textColor,
backgroundColor: params.color.backgroundColor
}
}
});
if (!response?.data) {
throw new GmailError(
'No response data from create label request',
'CREATE_ERROR',
'Server returned empty response'
);
}
return this.mapGmailLabel(response.data);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === '401') {
throw new GmailError(
'Authentication failed',
'AUTH_ERROR',
'Please re-authenticate your account'
);
}
if (error instanceof Error && error.message.includes('Invalid grant')) {
throw new GmailError(
'Authentication token expired',
'TOKEN_ERROR',
'Please re-authenticate your account'
);
}
throw new GmailError(
'Failed to create label',
'CREATE_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
private async getLabels(params: GetLabelsParams): Promise<GetLabelsResponse> {
try {
const response = await this.client?.users.labels.list({
userId: params.email
});
if (!response?.data.labels) {
return { labels: [] };
}
return {
labels: response.data.labels.map(this.mapGmailLabel)
};
} catch (error: unknown) {
throw new GmailError(
'Failed to fetch labels',
'FETCH_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
private async updateLabel(params: UpdateLabelParams): Promise<Label> {
try {
if (params.color) {
const { textColor, backgroundColor } = params.color;
if (!isValidGmailLabelColor(textColor, backgroundColor)) {
const suggestedColor = getNearestGmailLabelColor(backgroundColor);
throw new GmailError(
LABEL_ERROR_MESSAGES.INVALID_COLOR,
'COLOR_ERROR',
LABEL_ERROR_MESSAGES.COLOR_SUGGESTION(backgroundColor, suggestedColor)
);
}
}
if (!this.client) {
throw new GmailError(
'Gmail client not initialized',
'CLIENT_ERROR',
'Please ensure the service is initialized with a valid client'
);
}
const response = await this.client.users.labels.patch({
userId: params.email,
id: params.labelId,
requestBody: {
name: params.name,
messageListVisibility: params.messageListVisibility,
labelListVisibility: params.labelListVisibility,
color: params.color && {
textColor: params.color.textColor,
backgroundColor: params.color.backgroundColor
}
}
});
if (!response?.data) {
throw new GmailError(
'No response data from update label request',
'UPDATE_ERROR',
'Server returned empty response'
);
}
return this.mapGmailLabel(response.data);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === '401') {
throw new GmailError(
'Authentication failed',
'AUTH_ERROR',
'Please re-authenticate your account'
);
}
if (error instanceof Error && error.message.includes('Invalid grant')) {
throw new GmailError(
'Authentication token expired',
'TOKEN_ERROR',
'Please re-authenticate your account'
);
}
throw new GmailError(
'Failed to update label',
'UPDATE_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
private async deleteLabel(params: DeleteLabelParams): Promise<void> {
try {
await this.client?.users.labels.delete({
userId: params.email,
id: params.labelId
});
} catch (error: unknown) {
throw new GmailError(
'Failed to delete label',
'DELETE_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
private async modifyMessageLabels(params: ModifyMessageLabelsParams): Promise<void> {
try {
await this.client?.users.messages.modify({
userId: params.email,
id: params.messageId,
requestBody: {
addLabelIds: params.addLabelIds,
removeLabelIds: params.removeLabelIds
}
});
} catch (error: unknown) {
throw new GmailError(
'Failed to modify message labels',
'MODIFY_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
private async createLabelFilter(params: CreateLabelFilterParams): Promise<LabelFilter> {
try {
// Build filter criteria for Gmail API
const filterCriteria: gmail_v1.Schema$FilterCriteria = {
from: params.criteria.from?.join(' OR ') || undefined,
to: params.criteria.to?.join(' OR ') || undefined,
subject: params.criteria.subject || undefined,
query: this.buildFilterQuery(params.criteria),
hasAttachment: params.criteria.hasAttachment || undefined,
excludeChats: true,
size: params.criteria.size ? Number(params.criteria.size.size) : undefined,
sizeComparison: params.criteria.size?.operator || undefined
};
// Build filter action with initialized arrays
const addLabelIds = [params.labelId];
const removeLabelIds: string[] = [];
// Add system label modifications based on actions
if (params.actions.markImportant) {
addLabelIds.push('IMPORTANT');
}
if (params.actions.markRead) {
removeLabelIds.push('UNREAD');
}
if (params.actions.archive) {
removeLabelIds.push('INBOX');
}
const filterAction: gmail_v1.Schema$FilterAction = {
addLabelIds,
removeLabelIds,
forward: undefined
};
// Create the filter
const response = await this.client?.users.settings.filters.create({
userId: params.email,
requestBody: {
criteria: filterCriteria,
action: filterAction
}
});
if (!response?.data) {
throw new GmailError(
'Failed to create filter',
'CREATE_ERROR',
'No response data received from Gmail API'
);
}
// Return the created filter in our standard format
return {
id: response.data.id || '',
labelId: params.labelId,
criteria: params.criteria,
actions: params.actions
};
} catch (error: unknown) {
throw new GmailError(
'Failed to create label filter',
'CREATE_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
private async getLabelFilters(params: GetLabelFiltersParams): Promise<GetLabelFiltersResponse> {
try {
const response = await this.client?.users.settings.filters.list({
userId: params.email
});
if (!response?.data.filter) {
return { filters: [] };
}
// Map Gmail API filters to our format
const filters = response.data.filter
.filter(filter => {
if (!filter.action?.addLabelIds?.length) return false;
// If labelId is provided, only return filters for that label
if (params.labelId) {
return filter.action.addLabelIds.includes(params.labelId);
}
return true;
})
.map(filter => ({
id: filter.id || '',
labelId: filter.action?.addLabelIds?.[0] || '',
criteria: {
from: filter.criteria?.from ? filter.criteria.from.split(' OR ') : undefined,
to: filter.criteria?.to ? filter.criteria.to.split(' OR ') : undefined,
subject: filter.criteria?.subject || undefined,
hasAttachment: filter.criteria?.hasAttachment || undefined,
hasWords: filter.criteria?.query ? [filter.criteria.query] : undefined,
doesNotHaveWords: filter.criteria?.negatedQuery ? [filter.criteria.negatedQuery] : undefined,
size: filter.criteria?.size && filter.criteria?.sizeComparison ? {
operator: filter.criteria.sizeComparison as 'larger' | 'smaller',
size: Number(filter.criteria.size)
} : undefined
},
actions: {
addLabel: true,
markImportant: filter.action?.addLabelIds?.includes('IMPORTANT') || false,
markRead: filter.action?.removeLabelIds?.includes('UNREAD') || false,
archive: filter.action?.removeLabelIds?.includes('INBOX') || false
}
}));
return { filters };
} catch (error: unknown) {
throw new GmailError(
'Failed to get label filters',
'FETCH_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
private async updateLabelFilter(params: UpdateLabelFilterParams): Promise<LabelFilter> {
try {
// Gmail API doesn't support direct filter updates, so we need to delete and recreate
await this.deleteLabelFilter({
email: params.email,
filterId: params.filterId
});
// Convert our criteria format to Gmail API format
const criteria: gmail_v1.Schema$FilterCriteria = {
from: params.criteria.from?.join(' OR ') || null,
to: params.criteria.to?.join(' OR ') || null,
subject: params.criteria.subject || null,
query: params.criteria.hasWords?.join(' OR ') || null,
negatedQuery: params.criteria.doesNotHaveWords?.join(' OR ') || null,
hasAttachment: params.criteria.hasAttachment || null,
size: params.criteria.size?.size || null,
sizeComparison: params.criteria.size?.operator || null
};
// Initialize arrays for label IDs
const addLabelIds: string[] = [params.labelId];
const removeLabelIds: string[] = [];
// Add additional label IDs based on actions
if (params.actions.markImportant) {
addLabelIds.push('IMPORTANT');
}
if (params.actions.markRead) {
removeLabelIds.push('UNREAD');
}
if (params.actions.archive) {
removeLabelIds.push('INBOX');
}
// Create the filter action
const action: gmail_v1.Schema$FilterAction = {
addLabelIds,
removeLabelIds,
forward: null
};
const response = await this.client?.users.settings.filters.create({
userId: params.email,
requestBody: {
criteria,
action
}
});
if (!response?.data) {
throw new GmailError(
'No response data from update filter request',
'UPDATE_ERROR',
'Server returned empty response'
);
}
// Map response to our LabelFilter format
return {
id: response.data.id || '',
labelId: params.labelId,
criteria: params.criteria,
actions: params.actions
};
} catch (error: unknown) {
throw new GmailError(
'Failed to update label filter',
'UPDATE_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
private async deleteLabelFilter(params: DeleteLabelFilterParams): Promise<void> {
try {
await this.client?.users.settings.filters.delete({
userId: params.email,
id: params.filterId
});
} catch (error: unknown) {
throw new GmailError(
'Failed to delete label filter',
'DELETE_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
private mapGmailLabel(label: gmail_v1.Schema$Label): Label {
const mappedLabel: Label = {
id: label.id || '',
name: label.name || '',
type: (label.type || 'user') as 'system' | 'user',
messageListVisibility: (label.messageListVisibility || 'show') as 'hide' | 'show',
labelListVisibility: (label.labelListVisibility || 'labelShow') as 'labelHide' | 'labelShow' | 'labelShowIfUnread'
};
if (label.color?.textColor && label.color?.backgroundColor) {
mappedLabel.color = {
textColor: label.color.textColor,
backgroundColor: label.color.backgroundColor
};
}
return mappedLabel;
}
}