Code Snippet Server
by ngeojiajun
Verified
- src
- services
import { google } from "googleapis";
import { GoogleBaseService } from "./google-base-service.js";
import { gmail_v1 } from "googleapis/build/src/apis/gmail/v1.js";
export interface EmailMetadata {
id: string;
threadId: string;
snippet: string;
from: {
name?: string;
email: string;
};
to: {
name?: string;
email: string;
}[];
subject: string;
date: Date;
labels: {
id: string;
name: string;
}[];
hasAttachments: boolean;
isUnread: boolean;
isImportant: boolean;
}
export interface SendEmailOptions {
to: string | string[];
subject: string;
body: string;
cc?: string | string[];
bcc?: string | string[];
replyTo?: string;
attachments?: Array<{
filename: string;
content: Buffer | string;
contentType?: string;
}>;
isHtml?: boolean;
}
export interface DraftEmailOptions extends SendEmailOptions {
id?: string; // For updating existing drafts
}
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function validateEmail(email: string): boolean {
return EMAIL_REGEX.test(email.trim());
}
function validateEmailList(emails: string | string[] | undefined): void {
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 {
private gmail: gmail_v1.Gmail;
private labelCache: Map<string, gmail_v1.Schema$Label> = new Map();
constructor() {
super();
this.gmail = google.gmail({ version: "v1", auth: this.auth.getAuth() });
}
private async loadLabels(): Promise<void> {
if (this.labelCache.size === 0) {
const labels = await this.getLabels();
labels.forEach((label) => {
this.labelCache.set(label.id!, label);
});
}
}
private parseEmailAddress(address: string): { name?: string; email: string } {
const match = address.match(/(?:"?([^"]*)"?\s)?(?:<)?(.+@[^>]+)(?:>)?/);
if (match) {
return {
name: match[1]?.trim(),
email: match[2].trim(),
};
}
return { email: address.trim() };
}
private async getMessageMetadata(
messageId: string
): Promise<gmail_v1.Schema$Message> {
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;
}
}
private async extractEmailMetadata(
message: gmail_v1.Schema$Message
): Promise<EmailMetadata> {
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 is { id: string; name: string } => 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: number = 10): Promise<EmailMetadata[]> {
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: string
): Promise<EmailMetadata & { body: string }> {
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: string,
maxResults: number = 10
): Promise<EmailMetadata[]> {
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(): Promise<gmail_v1.Schema$Label[]> {
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;
}
}
private createEmailRaw(options: SendEmailOptions): string {
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: SendEmailOptions): Promise<string> {
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: any) {
console.error("Failed to send email:", error);
throw error;
}
}
async createDraft(options: DraftEmailOptions): Promise<string> {
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: any) {
console.error("Failed to create draft:", error);
throw error;
}
}
async updateDraft(options: DraftEmailOptions): Promise<string> {
if (!options.id) {
throw new Error("Draft ID is required for updating");
}
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: any) {
console.error("Failed to update draft:", error);
throw error;
}
}
async listDrafts(maxResults: number = 10): Promise<EmailMetadata[]> {
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: string): Promise<void> {
try {
await this.gmail.users.drafts.delete({
userId: "me",
id: draftId,
});
} catch (error) {
console.error("Failed to delete draft:", error);
throw error;
}
}
async modifyMessage(
messageId: string,
options: {
addLabelIds?: string[];
removeLabelIds?: string[];
}
): Promise<EmailMetadata> {
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: string): Promise<void> {
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: string): Promise<void> {
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: string): Promise<void> {
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: string,
options: {
textColor?: string;
backgroundColor?: string;
messageListVisibility?: "hide" | "show";
labelListVisibility?: "labelHide" | "labelShow" | "labelShowIfUnread";
} = {}
): Promise<gmail_v1.Schema$Label> {
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: string): Promise<void> {
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;
}
}
}