// Google Gmail API Service
import { google } from 'googleapis';
import type { OAuth2Client } from 'google-auth-library';
import type { gmail_v1 } from 'googleapis';
// ─────────────────────────────────────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────────────────────────────────────
export interface Attachment {
filename: string;
mimeType: string;
content?: string; // base64 encoded
driveFileId?: string; // alternative: attach from Drive
}
export interface EmailAddress {
email: string;
name?: string;
}
export interface MessageHeaders {
from?: string;
to?: string;
cc?: string;
bcc?: string;
subject?: string;
date?: string;
messageId?: string;
}
export interface MessageSummary {
id: string;
threadId: string;
snippet: string;
labelIds: string[];
headers: MessageHeaders;
}
export interface MessageFull extends MessageSummary {
body: {
text?: string;
html?: string;
};
attachments: {
id: string;
filename: string;
mimeType: string;
size: number;
}[];
}
export interface ThreadSummary {
id: string;
snippet: string;
historyId: string;
messagesCount: number;
}
export interface DraftSummary {
id: string;
messageId: string;
snippet: string;
headers: MessageHeaders;
}
export interface Label {
id: string;
name: string;
type: 'system' | 'user';
messageListVisibility?: string;
labelListVisibility?: string;
messagesTotal?: number;
messagesUnread?: number;
color?: {
textColor: string;
backgroundColor: string;
};
}
export interface VacationSettings {
enableAutoReply: boolean;
responseSubject?: string;
responseBodyPlainText?: string;
responseBodyHtml?: string;
restrictToContacts?: boolean;
restrictToDomain?: boolean;
startTime?: string;
endTime?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// SERVICE
// ─────────────────────────────────────────────────────────────────────────────
export class GmailService {
private gmail: gmail_v1.Gmail;
private userId = 'me';
constructor(authClient: OAuth2Client) {
this.gmail = google.gmail({ version: 'v1', auth: authClient });
}
// ─────────────────────────────────────────────────────────────────────────────
// MESSAGES
// ─────────────────────────────────────────────────────────────────────────────
/**
* List messages with optional search query
*/
async listMessages(options: {
q?: string;
maxResults?: number;
labelIds?: string[];
pageToken?: string;
includeSpamTrash?: boolean;
} = {}): Promise<{ messages: MessageSummary[]; nextPageToken?: string }> {
const response = await this.gmail.users.messages.list({
userId: this.userId,
q: options.q,
maxResults: options.maxResults || 20,
labelIds: options.labelIds,
pageToken: options.pageToken,
includeSpamTrash: options.includeSpamTrash || false,
});
const messages: MessageSummary[] = [];
if (response.data.messages) {
// Fetch headers for each message (batch would be more efficient but keeping it simple)
for (const msg of response.data.messages) {
const detail = await this.gmail.users.messages.get({
userId: this.userId,
id: msg.id!,
format: 'metadata',
metadataHeaders: ['From', 'To', 'Subject', 'Date'],
});
messages.push(this.parseMessageSummary(detail.data));
}
}
return {
messages,
nextPageToken: response.data.nextPageToken || undefined,
};
}
/**
* Get a single message with full content
*/
async getMessage(
messageId: string,
format: 'full' | 'metadata' | 'minimal' = 'full'
): Promise<MessageFull> {
const response = await this.gmail.users.messages.get({
userId: this.userId,
id: messageId,
format,
});
return this.parseMessageFull(response.data);
}
/**
* Send a new email
*/
async sendMessage(options: {
to: string | string[];
cc?: string | string[];
bcc?: string | string[];
subject: string;
body: string;
isHtml?: boolean;
threadId?: string;
attachments?: Attachment[];
}): Promise<{ id: string; threadId: string; labelIds: string[] }> {
const raw = await this.buildMimeMessage(options);
const response = await this.gmail.users.messages.send({
userId: this.userId,
requestBody: {
raw,
threadId: options.threadId,
},
});
return {
id: response.data.id!,
threadId: response.data.threadId!,
labelIds: response.data.labelIds || [],
};
}
/**
* Reply to an existing message
*/
async replyToMessage(options: {
messageId: string;
body: string;
isHtml?: boolean;
replyAll?: boolean;
attachments?: Attachment[];
}): Promise<{ id: string; threadId: string }> {
// Get original message to extract reply headers
const original = await this.getMessage(options.messageId, 'metadata');
const to = options.replyAll
? [original.headers.from, original.headers.to].filter(Boolean).join(', ')
: original.headers.from;
const subject = original.headers.subject?.startsWith('Re:')
? original.headers.subject
: `Re: ${original.headers.subject || ''}`;
const raw = await this.buildMimeMessage({
to: to || '',
subject,
body: options.body,
isHtml: options.isHtml,
attachments: options.attachments,
inReplyTo: original.headers.messageId,
references: original.headers.messageId,
});
const response = await this.gmail.users.messages.send({
userId: this.userId,
requestBody: {
raw,
threadId: original.threadId,
},
});
return {
id: response.data.id!,
threadId: response.data.threadId!,
};
}
/**
* Forward a message
*/
async forwardMessage(options: {
messageId: string;
to: string | string[];
additionalBody?: string;
}): Promise<{ id: string; threadId: string }> {
const original = await this.getMessage(options.messageId, 'full');
const forwardBody = options.additionalBody
? `${options.additionalBody}\n\n---------- Forwarded message ----------\n`
: '---------- Forwarded message ----------\n';
const body = forwardBody +
`From: ${original.headers.from}\n` +
`Date: ${original.headers.date}\n` +
`Subject: ${original.headers.subject}\n` +
`To: ${original.headers.to}\n\n` +
(original.body.text || original.body.html || '');
const subject = original.headers.subject?.startsWith('Fwd:')
? original.headers.subject
: `Fwd: ${original.headers.subject || ''}`;
return this.sendMessage({
to: options.to,
subject,
body,
isHtml: false,
});
}
/**
* Move message to trash
*/
async trashMessage(messageId: string): Promise<{ id: string }> {
const response = await this.gmail.users.messages.trash({
userId: this.userId,
id: messageId,
});
return { id: response.data.id! };
}
/**
* Permanently delete a message (cannot be undone)
*/
async deleteMessage(messageId: string): Promise<void> {
await this.gmail.users.messages.delete({
userId: this.userId,
id: messageId,
});
}
/**
* Modify labels on a message
*/
async modifyMessageLabels(
messageId: string,
addLabelIds?: string[],
removeLabelIds?: string[]
): Promise<{ id: string; labelIds: string[] }> {
const response = await this.gmail.users.messages.modify({
userId: this.userId,
id: messageId,
requestBody: {
addLabelIds: addLabelIds || [],
removeLabelIds: removeLabelIds || [],
},
});
return {
id: response.data.id!,
labelIds: response.data.labelIds || [],
};
}
// ─────────────────────────────────────────────────────────────────────────────
// THREADS
// ─────────────────────────────────────────────────────────────────────────────
/**
* List threads (conversations)
*/
async listThreads(options: {
q?: string;
maxResults?: number;
labelIds?: string[];
pageToken?: string;
includeSpamTrash?: boolean;
} = {}): Promise<{ threads: ThreadSummary[]; nextPageToken?: string }> {
const response = await this.gmail.users.threads.list({
userId: this.userId,
q: options.q,
maxResults: options.maxResults || 20,
labelIds: options.labelIds,
pageToken: options.pageToken,
includeSpamTrash: options.includeSpamTrash || false,
});
const threads: ThreadSummary[] = (response.data.threads || []).map(t => ({
id: t.id!,
snippet: t.snippet || '',
historyId: t.historyId || '',
messagesCount: 0, // Will be populated on getThread
}));
return {
threads,
nextPageToken: response.data.nextPageToken || undefined,
};
}
/**
* Get a full thread with all messages
*/
async getThread(
threadId: string,
format: 'full' | 'metadata' | 'minimal' = 'full'
): Promise<{ id: string; messages: MessageFull[]; historyId: string }> {
const response = await this.gmail.users.threads.get({
userId: this.userId,
id: threadId,
format,
});
const messages = (response.data.messages || []).map(m => this.parseMessageFull(m));
return {
id: response.data.id!,
messages,
historyId: response.data.historyId || '',
};
}
/**
* Move thread to trash
*/
async trashThread(threadId: string): Promise<{ id: string }> {
const response = await this.gmail.users.threads.trash({
userId: this.userId,
id: threadId,
});
return { id: response.data.id! };
}
/**
* Modify labels on all messages in a thread
*/
async modifyThreadLabels(
threadId: string,
addLabelIds?: string[],
removeLabelIds?: string[]
): Promise<{ id: string }> {
const response = await this.gmail.users.threads.modify({
userId: this.userId,
id: threadId,
requestBody: {
addLabelIds: addLabelIds || [],
removeLabelIds: removeLabelIds || [],
},
});
return { id: response.data.id! };
}
// ─────────────────────────────────────────────────────────────────────────────
// DRAFTS
// ─────────────────────────────────────────────────────────────────────────────
/**
* List drafts
*/
async listDrafts(options: {
maxResults?: number;
pageToken?: string;
} = {}): Promise<{ drafts: DraftSummary[]; nextPageToken?: string }> {
const response = await this.gmail.users.drafts.list({
userId: this.userId,
maxResults: options.maxResults || 20,
pageToken: options.pageToken,
});
const drafts: DraftSummary[] = [];
if (response.data.drafts) {
for (const draft of response.data.drafts) {
const detail = await this.gmail.users.drafts.get({
userId: this.userId,
id: draft.id!,
format: 'metadata',
});
const message = detail.data.message;
drafts.push({
id: draft.id!,
messageId: message?.id || '',
snippet: message?.snippet || '',
headers: this.parseHeaders(message?.payload?.headers || []),
});
}
}
return {
drafts,
nextPageToken: response.data.nextPageToken || undefined,
};
}
/**
* Create a new draft
*/
async createDraft(options: {
to?: string | string[];
cc?: string | string[];
bcc?: string | string[];
subject?: string;
body?: string;
isHtml?: boolean;
attachments?: Attachment[];
}): Promise<{ id: string; messageId: string }> {
const raw = await this.buildMimeMessage({
to: options.to || '',
subject: options.subject || '',
body: options.body || '',
cc: options.cc,
bcc: options.bcc,
isHtml: options.isHtml,
attachments: options.attachments,
});
const response = await this.gmail.users.drafts.create({
userId: this.userId,
requestBody: {
message: { raw },
},
});
return {
id: response.data.id!,
messageId: response.data.message?.id || '',
};
}
/**
* Update an existing draft
*/
async updateDraft(
draftId: string,
options: {
to?: string | string[];
cc?: string | string[];
bcc?: string | string[];
subject?: string;
body?: string;
isHtml?: boolean;
attachments?: Attachment[];
}
): Promise<{ id: string; messageId: string }> {
const raw = await this.buildMimeMessage({
to: options.to || '',
subject: options.subject || '',
body: options.body || '',
cc: options.cc,
bcc: options.bcc,
isHtml: options.isHtml,
attachments: options.attachments,
});
const response = await this.gmail.users.drafts.update({
userId: this.userId,
id: draftId,
requestBody: {
message: { raw },
},
});
return {
id: response.data.id!,
messageId: response.data.message?.id || '',
};
}
/**
* Send a draft
*/
async sendDraft(draftId: string): Promise<{ id: string; threadId: string }> {
const response = await this.gmail.users.drafts.send({
userId: this.userId,
requestBody: { id: draftId },
});
return {
id: response.data.id!,
threadId: response.data.threadId!,
};
}
/**
* Delete a draft
*/
async deleteDraft(draftId: string): Promise<void> {
await this.gmail.users.drafts.delete({
userId: this.userId,
id: draftId,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// LABELS
// ─────────────────────────────────────────────────────────────────────────────
/**
* List all labels
*/
async listLabels(): Promise<Label[]> {
const response = await this.gmail.users.labels.list({
userId: this.userId,
});
return (response.data.labels || []).map(l => ({
id: l.id!,
name: l.name!,
type: l.type as 'system' | 'user',
messageListVisibility: l.messageListVisibility || undefined,
labelListVisibility: l.labelListVisibility || undefined,
messagesTotal: l.messagesTotal || 0,
messagesUnread: l.messagesUnread || 0,
color: l.color ? {
textColor: l.color.textColor || '',
backgroundColor: l.color.backgroundColor || '',
} : undefined,
}));
}
/**
* Create a new label
*/
async createLabel(options: {
name: string;
labelListVisibility?: 'labelShow' | 'labelShowIfUnread' | 'labelHide';
messageListVisibility?: 'show' | 'hide';
backgroundColor?: string;
textColor?: string;
}): Promise<Label> {
const response = await this.gmail.users.labels.create({
userId: this.userId,
requestBody: {
name: options.name,
labelListVisibility: options.labelListVisibility,
messageListVisibility: options.messageListVisibility,
color: options.backgroundColor || options.textColor ? {
backgroundColor: options.backgroundColor,
textColor: options.textColor,
} : undefined,
},
});
const l = response.data;
return {
id: l.id!,
name: l.name!,
type: l.type as 'system' | 'user',
messageListVisibility: l.messageListVisibility || undefined,
labelListVisibility: l.labelListVisibility || undefined,
color: l.color ? {
textColor: l.color.textColor || '',
backgroundColor: l.color.backgroundColor || '',
} : undefined,
};
}
/**
* Update a label
*/
async updateLabel(
labelId: string,
options: {
name?: string;
labelListVisibility?: 'labelShow' | 'labelShowIfUnread' | 'labelHide';
messageListVisibility?: 'show' | 'hide';
backgroundColor?: string;
textColor?: string;
}
): Promise<Label> {
const response = await this.gmail.users.labels.patch({
userId: this.userId,
id: labelId,
requestBody: {
name: options.name,
labelListVisibility: options.labelListVisibility,
messageListVisibility: options.messageListVisibility,
color: options.backgroundColor || options.textColor ? {
backgroundColor: options.backgroundColor,
textColor: options.textColor,
} : undefined,
},
});
const l = response.data;
return {
id: l.id!,
name: l.name!,
type: l.type as 'system' | 'user',
messageListVisibility: l.messageListVisibility || undefined,
labelListVisibility: l.labelListVisibility || undefined,
color: l.color ? {
textColor: l.color.textColor || '',
backgroundColor: l.color.backgroundColor || '',
} : undefined,
};
}
/**
* Delete a label
*/
async deleteLabel(labelId: string): Promise<void> {
await this.gmail.users.labels.delete({
userId: this.userId,
id: labelId,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// ATTACHMENTS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get an attachment from a message
*/
async getAttachment(
messageId: string,
attachmentId: string
): Promise<{ data: string; size: number }> {
const response = await this.gmail.users.messages.attachments.get({
userId: this.userId,
messageId,
id: attachmentId,
});
return {
data: response.data.data || '',
size: response.data.size || 0,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// SETTINGS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get vacation (auto-reply) settings
*/
async getVacationSettings(): Promise<VacationSettings> {
const response = await this.gmail.users.settings.getVacation({
userId: this.userId,
});
const v = response.data;
return {
enableAutoReply: v.enableAutoReply || false,
responseSubject: v.responseSubject || undefined,
responseBodyPlainText: v.responseBodyPlainText || undefined,
responseBodyHtml: v.responseBodyHtml || undefined,
restrictToContacts: v.restrictToContacts || false,
restrictToDomain: v.restrictToDomain || false,
startTime: v.startTime ? String(v.startTime) : undefined,
endTime: v.endTime ? String(v.endTime) : undefined,
};
}
/**
* Update vacation (auto-reply) settings
*/
async setVacationSettings(settings: {
enableAutoReply: boolean;
responseSubject?: string;
responseBodyPlainText?: string;
responseBodyHtml?: string;
restrictToContacts?: boolean;
restrictToDomain?: boolean;
startTime?: number;
endTime?: number;
}): Promise<VacationSettings> {
const response = await this.gmail.users.settings.updateVacation({
userId: this.userId,
requestBody: {
enableAutoReply: settings.enableAutoReply,
responseSubject: settings.responseSubject,
responseBodyPlainText: settings.responseBodyPlainText,
responseBodyHtml: settings.responseBodyHtml,
restrictToContacts: settings.restrictToContacts,
restrictToDomain: settings.restrictToDomain,
startTime: settings.startTime ? String(settings.startTime) : undefined,
endTime: settings.endTime ? String(settings.endTime) : undefined,
},
});
const v = response.data;
return {
enableAutoReply: v.enableAutoReply || false,
responseSubject: v.responseSubject || undefined,
responseBodyPlainText: v.responseBodyPlainText || undefined,
responseBodyHtml: v.responseBodyHtml || undefined,
restrictToContacts: v.restrictToContacts || false,
restrictToDomain: v.restrictToDomain || false,
startTime: v.startTime ? String(v.startTime) : undefined,
endTime: v.endTime ? String(v.endTime) : undefined,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// PROFILE
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get user profile info
*/
async getProfile(): Promise<{
emailAddress: string;
messagesTotal: number;
threadsTotal: number;
historyId: string;
}> {
const response = await this.gmail.users.getProfile({
userId: this.userId,
});
return {
emailAddress: response.data.emailAddress || '',
messagesTotal: response.data.messagesTotal || 0,
threadsTotal: response.data.threadsTotal || 0,
historyId: response.data.historyId || '',
};
}
// ─────────────────────────────────────────────────────────────────────────────
// PRIVATE HELPERS
// ─────────────────────────────────────────────────────────────────────────────
private parseHeaders(headers: gmail_v1.Schema$MessagePartHeader[]): MessageHeaders {
const result: MessageHeaders = {};
for (const header of headers) {
const name = header.name?.toLowerCase();
const value = header.value || '';
switch (name) {
case 'from': result.from = value; break;
case 'to': result.to = value; break;
case 'cc': result.cc = value; break;
case 'bcc': result.bcc = value; break;
case 'subject': result.subject = value; break;
case 'date': result.date = value; break;
case 'message-id': result.messageId = value; break;
}
}
return result;
}
private parseMessageSummary(message: gmail_v1.Schema$Message): MessageSummary {
return {
id: message.id!,
threadId: message.threadId!,
snippet: message.snippet || '',
labelIds: message.labelIds || [],
headers: this.parseHeaders(message.payload?.headers || []),
};
}
private parseMessageFull(message: gmail_v1.Schema$Message): MessageFull {
const summary = this.parseMessageSummary(message);
// Extract body
const body: { text?: string; html?: string } = {};
const attachments: MessageFull['attachments'] = [];
const extractParts = (parts: gmail_v1.Schema$MessagePart[] | undefined) => {
if (!parts) return;
for (const part of parts) {
const mimeType = part.mimeType || '';
if (mimeType === 'text/plain' && part.body?.data) {
body.text = this.decodeBase64Url(part.body.data);
} else if (mimeType === 'text/html' && part.body?.data) {
body.html = this.decodeBase64Url(part.body.data);
} else if (part.filename && part.body?.attachmentId) {
attachments.push({
id: part.body.attachmentId,
filename: part.filename,
mimeType: mimeType,
size: part.body.size || 0,
});
}
// Recurse into nested parts
if (part.parts) {
extractParts(part.parts);
}
}
};
// Handle single-part messages
if (message.payload?.body?.data) {
const mimeType = message.payload.mimeType || '';
if (mimeType === 'text/plain' || mimeType.includes('text')) {
body.text = this.decodeBase64Url(message.payload.body.data);
} else if (mimeType === 'text/html') {
body.html = this.decodeBase64Url(message.payload.body.data);
}
}
// Handle multi-part messages
extractParts(message.payload?.parts);
return {
...summary,
body,
attachments,
};
}
private decodeBase64Url(data: string): string {
// Convert base64url to base64
const base64 = data.replace(/-/g, '+').replace(/_/g, '/');
return Buffer.from(base64, 'base64').toString('utf-8');
}
private encodeBase64Url(data: string): string {
return Buffer.from(data, 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
private async buildMimeMessage(options: {
to: string | string[];
cc?: string | string[];
bcc?: string | string[];
subject: string;
body: string;
isHtml?: boolean;
attachments?: Attachment[];
inReplyTo?: string;
references?: string;
}): Promise<string> {
const toAddresses = Array.isArray(options.to) ? options.to.join(', ') : options.to;
const ccAddresses = options.cc
? (Array.isArray(options.cc) ? options.cc.join(', ') : options.cc)
: '';
const bccAddresses = options.bcc
? (Array.isArray(options.bcc) ? options.bcc.join(', ') : options.bcc)
: '';
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(2)}`;
const hasAttachments = options.attachments && options.attachments.length > 0;
let message = '';
// Headers
message += `To: ${toAddresses}\r\n`;
if (ccAddresses) message += `Cc: ${ccAddresses}\r\n`;
if (bccAddresses) message += `Bcc: ${bccAddresses}\r\n`;
message += `Subject: ${options.subject}\r\n`;
message += `MIME-Version: 1.0\r\n`;
if (options.inReplyTo) {
message += `In-Reply-To: ${options.inReplyTo}\r\n`;
}
if (options.references) {
message += `References: ${options.references}\r\n`;
}
if (hasAttachments) {
message += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
message += `\r\n`;
// Body part
message += `--${boundary}\r\n`;
message += `Content-Type: ${options.isHtml ? 'text/html' : 'text/plain'}; charset="UTF-8"\r\n`;
message += `Content-Transfer-Encoding: base64\r\n`;
message += `\r\n`;
message += `${Buffer.from(options.body).toString('base64')}\r\n`;
// Attachment parts
for (const attachment of options.attachments!) {
message += `--${boundary}\r\n`;
message += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r\n`;
message += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
message += `Content-Transfer-Encoding: base64\r\n`;
message += `\r\n`;
if (attachment.content) {
message += `${attachment.content}\r\n`;
}
// Note: driveFileId attachments would need Drive API integration
}
message += `--${boundary}--\r\n`;
} else {
message += `Content-Type: ${options.isHtml ? 'text/html' : 'text/plain'}; charset="UTF-8"\r\n`;
message += `\r\n`;
message += options.body;
}
return this.encodeBase64Url(message);
}
}