This is page 2 of 2. Use http://codebase.md/ejb503/systemprompt-mcp-gmail?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .babelrc
├── .env.example
├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── CHANGELOG.md
├── config
│ ├── __llm__
│ │ └── README.md
│ └── server-config.ts
├── eslint.config.js
├── jest.config.mjs
├── jest.setup.ts
├── LICENSE.md
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── __mocks__
│ │ ├── @modelcontextprotocol
│ │ │ └── sdk.ts
│ │ ├── node_process.ts
│ │ ├── server.ts
│ │ └── systemprompt-service.ts
│ ├── __tests__
│ │ ├── index.test.ts
│ │ ├── mock-objects.ts
│ │ ├── server.test.ts
│ │ ├── test-utils.test.ts
│ │ └── test-utils.ts
│ ├── config
│ │ ├── __llm__
│ │ │ └── README.md
│ │ ├── __tests__
│ │ │ └── server-config.test.ts
│ │ └── server-config.ts
│ ├── constants
│ │ ├── instructions.ts
│ │ ├── message-handler.ts
│ │ ├── sampling-prompts.ts
│ │ └── tools.ts
│ ├── handlers
│ │ ├── __llm__
│ │ │ └── README.md
│ │ ├── __tests__
│ │ │ ├── callbacks.test.ts
│ │ │ ├── notifications.test.ts
│ │ │ ├── prompt-handlers.test.ts
│ │ │ ├── resource-handlers.test.ts
│ │ │ ├── sampling.test.ts
│ │ │ └── tool-handlers.test.ts
│ │ ├── callbacks.ts
│ │ ├── notifications.ts
│ │ ├── prompt-handlers.ts
│ │ ├── resource-handlers.ts
│ │ ├── sampling.ts
│ │ └── tool-handlers.ts
│ ├── index.ts
│ ├── schemas
│ │ └── generated
│ │ ├── index.ts
│ │ ├── SystempromptAgentRequestSchema.ts
│ │ ├── SystempromptBlockRequestSchema.ts
│ │ └── SystempromptPromptRequestSchema.ts
│ ├── server.ts
│ ├── services
│ │ ├── __llm__
│ │ │ └── README.md
│ │ ├── __tests__
│ │ │ ├── gmail-service.test.ts
│ │ │ ├── google-auth-service.test.ts
│ │ │ ├── google-base-service.test.ts
│ │ │ └── systemprompt-service.test.ts
│ │ ├── gmail-service.ts
│ │ ├── google-auth-service.ts
│ │ ├── google-base-service.ts
│ │ └── systemprompt-service.ts
│ ├── types
│ │ ├── __llm__
│ │ │ └── README.md
│ │ ├── gmail-types.ts
│ │ ├── index.ts
│ │ ├── sampling-schemas.ts
│ │ ├── sampling.ts
│ │ ├── systemprompt.ts
│ │ ├── tool-args.ts
│ │ └── tool-schemas.ts
│ └── utils
│ ├── __tests__
│ │ ├── mcp-mappers.test.ts
│ │ ├── message-handlers.test.ts
│ │ ├── tool-validation.test.ts
│ │ └── validation.test.ts
│ ├── mcp-mappers.ts
│ ├── message-handlers.ts
│ ├── tool-validation.ts
│ └── validation.ts
├── tsconfig.json
└── tsconfig.test.json
```
# Files
--------------------------------------------------------------------------------
/src/constants/tools.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Tool } from "@modelcontextprotocol/sdk/types.js";
2 |
3 | export const TOOL_ERROR_MESSAGES = {
4 | UNKNOWN_TOOL: "Unknown tool:",
5 | TOOL_CALL_FAILED: "Tool call failed:",
6 | } as const;
7 |
8 | export const TOOL_RESPONSE_MESSAGES = {
9 | ASYNC_PROCESSING: "Request is being processed asynchronously",
10 | } as const;
11 |
12 | export const TOOLS: Tool[] = [
13 | {
14 | name: "gmail_list_emails",
15 | description: "Lists recent Gmail messages from the user's inbox with optional filtering.",
16 | inputSchema: {
17 | type: "object",
18 | properties: {
19 | maxResults: {
20 | type: "number",
21 | description:
22 | "Maximum number of emails to return. Default 5. Never more than 10, for token limits.",
23 | },
24 | after: {
25 | type: "string",
26 | description:
27 | "Return emails after this date. Format: YYYY/MM/DD or RFC3339 timestamp (e.g. 2024-03-20T10:00:00Z)",
28 | },
29 | before: {
30 | type: "string",
31 | description:
32 | "Return emails before this date. Format: YYYY/MM/DD or RFC3339 timestamp (e.g. 2024-03-20T10:00:00Z)",
33 | },
34 | sender: {
35 | type: "string",
36 | description: "Filter emails by sender email address. Can be a partial match.",
37 | },
38 | to: {
39 | type: "string",
40 | description: "Filter emails by recipient email address. Can be a partial match.",
41 | },
42 | subject: {
43 | type: "string",
44 | description: "Filter emails by subject line. Can be a partial match.",
45 | },
46 | hasAttachment: {
47 | type: "boolean",
48 | description:
49 | "If true, only return emails with attachments. If false, only return emails without attachments.",
50 | },
51 | label: {
52 | type: "string",
53 | description:
54 | "Filter emails by Gmail label name (e.g. 'INBOX', 'SENT', 'IMPORTANT', or custom labels).",
55 | },
56 | },
57 | },
58 | },
59 | {
60 | name: "gmail_get_email",
61 | description: "Retrieves the full content of a specific Gmail message by its ID.",
62 | inputSchema: {
63 | type: "object",
64 | properties: {
65 | messageId: {
66 | type: "string",
67 | description:
68 | "The unique ID of the Gmail message to retrieve. This can be obtained from list_emails or search_messages results.",
69 | },
70 | },
71 | required: ["messageId"],
72 | },
73 | },
74 | {
75 | name: "gmail_search_emails",
76 | description: "Searches Gmail messages using Gmail's search syntax.",
77 | inputSchema: {
78 | type: "object",
79 | properties: {
80 | query: {
81 | type: "string",
82 | description:
83 | "Gmail search query using Gmail's search operators (e.g. 'from:[email protected] has:attachment')",
84 | },
85 | maxResults: {
86 | type: "number",
87 | description:
88 | "Maximum number of search results to return. Defaults to 10 if not specified.",
89 | },
90 | after: {
91 | type: "string",
92 | description:
93 | "Return emails after this date. Format: YYYY/MM/DD or RFC3339 timestamp (e.g. 2024-03-20T10:00:00Z)",
94 | },
95 | before: {
96 | type: "string",
97 | description:
98 | "Return emails before this date. Format: YYYY/MM/DD or RFC3339 timestamp (e.g. 2024-03-20T10:00:00Z)",
99 | },
100 | },
101 | required: ["query"],
102 | },
103 | },
104 | {
105 | name: "gmail_send_email_ai",
106 | description:
107 | "Uses AI to generate and send an email or reply based on user instructions. User must specify AI or manual. This is AI.",
108 | inputSchema: {
109 | type: "object",
110 | properties: {
111 | to: {
112 | type: "string",
113 | description:
114 | "Recipient email address(es). Multiple addresses can be comma-separated. Must be a valid email address",
115 | },
116 | userInstructions: {
117 | type: "string",
118 | description:
119 | "Detailed user instructions for an AI system to generate a HTML email. Should be a description of the email contents used to guide AI to generate the content.",
120 | },
121 | replyTo: {
122 | type: "string",
123 | description:
124 | "Optional message ID to reply to. If provided, this will be treated as a reply to that email",
125 | },
126 | },
127 | required: ["to", "userInstructions"],
128 | },
129 | },
130 | {
131 | name: "gmail_send_email_manual",
132 | description:
133 | "Sends an email or reply with the provided content directly. User must specify AI or manual. This is manual.",
134 | inputSchema: {
135 | type: "object",
136 | properties: {
137 | to: {
138 | type: "string",
139 | description: "Recipient email address(es). Multiple addresses can be comma-separated.",
140 | },
141 | subject: {
142 | type: "string",
143 | description: "Email subject line. Not required if this is a reply (replyTo is provided)",
144 | },
145 | body: {
146 | type: "string",
147 | description: "Email body content",
148 | },
149 | cc: {
150 | type: "string",
151 | description: "CC recipient email address(es)",
152 | },
153 | bcc: {
154 | type: "string",
155 | description: "BCC recipient email address(es)",
156 | },
157 | isHtml: {
158 | type: "boolean",
159 | description: "Whether the body content is HTML",
160 | },
161 | replyTo: {
162 | type: "string",
163 | description:
164 | "Optional message ID to reply to. If provided, this will be treated as a reply to that email",
165 | },
166 | },
167 | required: ["to", "body"],
168 | },
169 | },
170 | {
171 | name: "gmail_trash_message",
172 | description: "Moves a Gmail message to the trash by its ID.",
173 | inputSchema: {
174 | type: "object",
175 | properties: {
176 | messageId: {
177 | type: "string",
178 | description:
179 | "The unique ID of the Gmail message to move to trash. This can be obtained from list_emails or search_messages results.",
180 | },
181 | },
182 | required: ["messageId"],
183 | },
184 | },
185 | {
186 | name: "gmail_get_draft",
187 | description: "Retrieves the full content of a specific Gmail draft by its ID.",
188 | inputSchema: {
189 | type: "object",
190 | properties: {
191 | draftId: {
192 | type: "string",
193 | description:
194 | "The unique ID of the Gmail draft to retrieve. This can be obtained from list_drafts results.",
195 | },
196 | },
197 | required: ["draftId"],
198 | },
199 | },
200 | {
201 | name: "gmail_create_draft_ai",
202 | description:
203 | "Uses AI to generate and send an email or reply based on user instructions. User must specify AI or manual. This is AI.",
204 | inputSchema: {
205 | type: "object",
206 | properties: {
207 | to: {
208 | type: "string",
209 | description:
210 | "Recipient email address(es). Multiple addresses can be comma-separated. Must be a valid email address",
211 | },
212 | userInstructions: {
213 | type: "string",
214 | description:
215 | "Detailed user instructions for an AI system to generate a HTML email. Should be a description of the email contents used to guide AI to generate the content.",
216 | },
217 | replyTo: {
218 | type: "string",
219 | description:
220 | "Optional message ID to reply to. If provided, this will be treated as a reply to that email",
221 | },
222 | },
223 | required: ["to", "userInstructions"],
224 | },
225 | },
226 | {
227 | name: "gmail_edit_draft_ai",
228 | description:
229 | "Uses AI to generate and send an email or reply based on user instructions. User must specify AI or manual. This is AI.",
230 | inputSchema: {
231 | type: "object",
232 | properties: {
233 | draftId: {
234 | type: "string",
235 | description: "The ID of the draft email to edit",
236 | },
237 | userInstructions: {
238 | type: "string",
239 | description:
240 | "Detailed user instructions for an AI system to generate a HTML email. Should be a description of the email contents used to guide AI to generate the content.",
241 | },
242 | },
243 | required: ["draftId", "userInstructions"],
244 | },
245 | },
246 |
247 | {
248 | name: "gmail_list_drafts",
249 | description: "Lists all draft emails in the user's account.",
250 | inputSchema: {
251 | type: "object",
252 | properties: {
253 | maxResults: {
254 | type: "number",
255 | description: "Maximum number of draft emails to return. Defaults to 10 if not specified.",
256 | },
257 | },
258 | },
259 | },
260 | {
261 | name: "gmail_delete_draft",
262 | description: "Deletes a draft email by its ID.",
263 | inputSchema: {
264 | type: "object",
265 | properties: {
266 | draftId: {
267 | type: "string",
268 | description:
269 | "The unique ID of the draft email to delete. This can be obtained from list_drafts results.",
270 | },
271 | },
272 | required: ["draftId"],
273 | },
274 | },
275 | ];
276 |
```
--------------------------------------------------------------------------------
/src/__tests__/mock-objects.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
2 | import type { JSONSchema7TypeName } from "json-schema";
3 | import type { SystempromptPromptResponse } from "../types/index.js";
4 |
5 | // Basic mock with simple string input
6 | export const mockSystemPromptResult: SystempromptPromptResponse = {
7 | id: "123",
8 | instruction: {
9 | static: "You are a helpful assistant that helps users write documentation.",
10 | dynamic: "",
11 | state: "",
12 | },
13 | input: {
14 | name: "message",
15 | description: "The user's documentation request",
16 | type: ["message"],
17 | schema: {
18 | type: "object" as JSONSchema7TypeName,
19 | properties: {
20 | message: {
21 | type: "string" as JSONSchema7TypeName,
22 | description: "The user's documentation request",
23 | },
24 | },
25 | required: ["message"],
26 | },
27 | },
28 | output: {
29 | name: "response",
30 | description: "The assistant's response",
31 | type: ["message"],
32 | schema: {
33 | type: "object" as JSONSchema7TypeName,
34 | properties: {
35 | response: {
36 | type: "string" as JSONSchema7TypeName,
37 | description: "The assistant's response",
38 | },
39 | },
40 | required: ["response"],
41 | },
42 | },
43 | metadata: {
44 | title: "Documentation Helper",
45 | description: "An assistant that helps users write better documentation",
46 | created: new Date().toISOString(),
47 | updated: new Date().toISOString(),
48 | version: 1,
49 | status: "published",
50 | author: "test-user",
51 | log_message: "Initial creation",
52 | tag: ["documentation", "helper"],
53 | },
54 | _link: "https://systemprompt.io/prompts/123",
55 | };
56 |
57 | // Mock with array input
58 | export const mockArrayPromptResult: SystempromptPromptResponse = {
59 | id: "124",
60 | instruction: {
61 | dynamic: "",
62 | state: "",
63 | static:
64 | "You are a helpful assistant that helps users manage their todo lists.",
65 | },
66 | input: {
67 | name: "todos",
68 | description: "The user's todo list items",
69 | type: ["structured_data"],
70 | schema: {
71 | type: "object" as JSONSchema7TypeName,
72 | properties: {
73 | items: {
74 | type: "array" as JSONSchema7TypeName,
75 | description: "List of todo items",
76 | items: {
77 | type: "string" as JSONSchema7TypeName,
78 | description: "A todo item",
79 | },
80 | minItems: 1,
81 | },
82 | priority: {
83 | type: "string" as JSONSchema7TypeName,
84 | enum: ["high", "medium", "low"],
85 | description: "Priority level for the items",
86 | },
87 | },
88 | required: ["items"],
89 | },
90 | },
91 | output: {
92 | name: "organized_todos",
93 | description: "The organized todo list",
94 | type: ["structured_data"],
95 | schema: {
96 | type: "object" as JSONSchema7TypeName,
97 | properties: {
98 | organized_items: {
99 | type: "array" as JSONSchema7TypeName,
100 | items: {
101 | type: "string" as JSONSchema7TypeName,
102 | },
103 | },
104 | },
105 | required: ["organized_items"],
106 | },
107 | },
108 | metadata: {
109 | title: "Todo List Organizer",
110 | description: "An assistant that helps users organize their todo lists",
111 | created: new Date().toISOString(),
112 | updated: new Date().toISOString(),
113 | version: 1,
114 | status: "published",
115 | author: "test-user",
116 | log_message: "Initial creation",
117 | tag: ["todo", "organizer"],
118 | },
119 | _link: "https://systemprompt.io/prompts/124",
120 | };
121 |
122 | // Mock with nested object input
123 | export const mockNestedPromptResult: SystempromptPromptResponse = {
124 | id: "125",
125 | instruction: {
126 | dynamic: "",
127 | state: "",
128 | static:
129 | "You are a helpful assistant that helps users manage their contacts.",
130 | },
131 | input: {
132 | name: "contact",
133 | description: "The contact information",
134 | type: ["structured_data"],
135 | schema: {
136 | type: "object" as JSONSchema7TypeName,
137 | properties: {
138 | person: {
139 | type: "object" as JSONSchema7TypeName,
140 | description: "Person's information",
141 | properties: {
142 | name: {
143 | type: "object" as JSONSchema7TypeName,
144 | properties: {
145 | first: {
146 | type: "string" as JSONSchema7TypeName,
147 | description: "First name",
148 | },
149 | last: {
150 | type: "string" as JSONSchema7TypeName,
151 | description: "Last name",
152 | },
153 | },
154 | required: ["first", "last"],
155 | },
156 | contact: {
157 | type: "object" as JSONSchema7TypeName,
158 | properties: {
159 | email: {
160 | type: "string" as JSONSchema7TypeName,
161 | description: "Email address",
162 | format: "email",
163 | },
164 | phone: {
165 | type: "string" as JSONSchema7TypeName,
166 | description: "Phone number",
167 | pattern: "^\\+?[1-9]\\d{1,14}$",
168 | },
169 | },
170 | required: ["email"],
171 | },
172 | },
173 | required: ["name"],
174 | },
175 | tags: {
176 | type: "array" as JSONSchema7TypeName,
177 | description: "Contact tags",
178 | items: {
179 | type: "string" as JSONSchema7TypeName,
180 | },
181 | },
182 | },
183 | required: ["person"],
184 | },
185 | },
186 | output: {
187 | name: "formatted_contact",
188 | description: "The formatted contact information",
189 | type: ["structured_data"],
190 | schema: {
191 | type: "object" as JSONSchema7TypeName,
192 | properties: {
193 | formatted: {
194 | type: "string" as JSONSchema7TypeName,
195 | },
196 | },
197 | required: ["formatted"],
198 | },
199 | },
200 | metadata: {
201 | title: "Contact Manager",
202 | description: "An assistant that helps users manage their contacts",
203 | created: new Date().toISOString(),
204 | updated: new Date().toISOString(),
205 | version: 1,
206 | status: "published",
207 | author: "test-user",
208 | log_message: "Initial creation",
209 | tag: ["contact", "manager"],
210 | },
211 | _link: "https://systemprompt.io/prompts/125",
212 | };
213 |
214 | // Test mocks for edge cases
215 | export const mockEmptyPropsPrompt = {
216 | ...mockSystemPromptResult,
217 | input: {
218 | ...mockSystemPromptResult.input,
219 | schema: {
220 | type: "object" as JSONSchema7TypeName,
221 | properties: {},
222 | },
223 | },
224 | };
225 |
226 | export const mockInvalidPropsPrompt = {
227 | ...mockSystemPromptResult,
228 | input: {
229 | ...mockSystemPromptResult.input,
230 | schema: {
231 | type: "object" as JSONSchema7TypeName,
232 | properties: {
233 | test1: {
234 | type: "string" as JSONSchema7TypeName,
235 | },
236 | },
237 | },
238 | },
239 | };
240 |
241 | export const mockWithoutDescPrompt = {
242 | ...mockSystemPromptResult,
243 | input: {
244 | ...mockSystemPromptResult.input,
245 | schema: {
246 | type: "object" as JSONSchema7TypeName,
247 | properties: {
248 | test: {
249 | type: "string" as JSONSchema7TypeName,
250 | },
251 | },
252 | required: ["test"],
253 | },
254 | },
255 | };
256 |
257 | export const mockWithoutRequiredPrompt = {
258 | ...mockSystemPromptResult,
259 | input: {
260 | ...mockSystemPromptResult.input,
261 | schema: {
262 | type: "object" as JSONSchema7TypeName,
263 | properties: {
264 | test: {
265 | type: "string" as JSONSchema7TypeName,
266 | description: "test field",
267 | },
268 | },
269 | },
270 | },
271 | };
272 |
273 | export const mockFalsyDescPrompt = {
274 | ...mockSystemPromptResult,
275 | input: {
276 | ...mockSystemPromptResult.input,
277 | schema: {
278 | type: "object" as JSONSchema7TypeName,
279 | properties: {
280 | test1: {
281 | type: "string" as JSONSchema7TypeName,
282 | description: "",
283 | },
284 | test2: {
285 | type: "string" as JSONSchema7TypeName,
286 | description: "",
287 | },
288 | test3: {
289 | type: "string" as JSONSchema7TypeName,
290 | description: "",
291 | },
292 | },
293 | required: ["test1", "test2", "test3"],
294 | },
295 | },
296 | };
297 |
298 | // Expected MCP format for basic mock
299 | export const mockMCPPrompt: Prompt = {
300 | name: "Documentation Helper",
301 | description: "An assistant that helps users write better documentation",
302 | messages: [
303 | {
304 | role: "assistant",
305 | content: {
306 | type: "text",
307 | text: "You are a helpful assistant that helps users write documentation.",
308 | },
309 | },
310 | ],
311 | arguments: [
312 | {
313 | name: "message",
314 | description: "The user's documentation request",
315 | required: true,
316 | },
317 | ],
318 | };
319 |
320 | // Expected MCP format for array mock
321 | export const mockArrayMCPPrompt: Prompt = {
322 | name: "Todo List Organizer",
323 | description: "An assistant that helps users organize their todo lists",
324 | messages: [
325 | {
326 | role: "assistant",
327 | content: {
328 | type: "text",
329 | text: "You are a helpful assistant that helps users manage their todo lists.",
330 | },
331 | },
332 | ],
333 | arguments: [
334 | {
335 | name: "items",
336 | description: "List of todo items",
337 | required: true,
338 | },
339 | {
340 | name: "priority",
341 | description: "Priority level for the items",
342 | required: false,
343 | },
344 | ],
345 | };
346 |
347 | // Expected MCP format for nested mock
348 | export const mockNestedMCPPrompt: Prompt = {
349 | name: "Contact Manager",
350 | description: "An assistant that helps users manage their contacts",
351 | messages: [
352 | {
353 | role: "assistant",
354 | content: {
355 | type: "text",
356 | text: "You are a helpful assistant that helps users manage their contacts.",
357 | },
358 | },
359 | ],
360 | arguments: [
361 | {
362 | name: "person",
363 | description: "Person's information",
364 | required: true,
365 | },
366 | {
367 | name: "tags",
368 | description: "Contact tags",
369 | required: false,
370 | },
371 | ],
372 | };
373 |
```
--------------------------------------------------------------------------------
/src/handlers/tool-handlers.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GmailService } from "../services/gmail-service.js";
2 | import {
3 | CallToolRequest,
4 | CallToolResult,
5 | ListToolsRequest,
6 | ListToolsResult,
7 | } from "@modelcontextprotocol/sdk/types.js";
8 | import { TOOLS } from "../constants/tools.js";
9 | import {
10 | ListEmailsArgs,
11 | GetEmailArgs,
12 | GetDraftArgs,
13 | SearchEmailsArgs,
14 | SendEmailAIArgs,
15 | SendEmailManualArgs,
16 | CreateDraftAIArgs,
17 | EditDraftAIArgs,
18 | ListDraftsArgs,
19 | DeleteDraftArgs,
20 | TrashMessageArgs,
21 | } from "../types/tool-schemas.js";
22 | import { TOOL_ERROR_MESSAGES } from "../constants/tools.js";
23 | import { sendSamplingRequest } from "./sampling.js";
24 | import { handleGetPrompt } from "./prompt-handlers.js";
25 | import { injectVariables } from "../utils/message-handlers.js";
26 | import { gmail_v1 } from "googleapis";
27 |
28 | const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
29 |
30 | function validateEmail(email: string): boolean {
31 | return EMAIL_REGEX.test(email.trim());
32 | }
33 |
34 | function validateEmailList(emails: string | string[] | undefined): void {
35 | if (!emails) return;
36 |
37 | const emailList = Array.isArray(emails) ? emails : emails.split(",").map((e) => e.trim());
38 |
39 | for (const email of emailList) {
40 | if (!validateEmail(email)) {
41 | throw new Error(`Invalid email address: ${email}`);
42 | }
43 | }
44 | }
45 |
46 | export async function handleListTools(request: ListToolsRequest): Promise<ListToolsResult> {
47 | return { tools: TOOLS };
48 | }
49 |
50 | export async function handleToolCall(request: CallToolRequest): Promise<CallToolResult> {
51 | try {
52 | switch (request.params.name) {
53 | case "gmail_list_emails": {
54 | const gmail = new GmailService();
55 | const args = request.params.arguments as unknown as ListEmailsArgs;
56 | const messages = await gmail.listMessages(args.maxResults ?? 5);
57 |
58 | // Format messages into a more concise structure
59 | const formattedMessages = messages.map((msg: gmail_v1.Schema$Message) => ({
60 | id: msg.id,
61 | threadId: msg.threadId,
62 | snippet: msg.snippet,
63 | // Extract key headers
64 | from: msg.payload?.headers?.find((h) => h?.name?.toLowerCase() === "from")?.value,
65 | to: msg.payload?.headers?.find((h) => h?.name?.toLowerCase() === "to")?.value,
66 | subject: msg.payload?.headers?.find((h) => h?.name?.toLowerCase() === "subject")?.value,
67 | date: msg.payload?.headers?.find((h) => h?.name?.toLowerCase() === "date")?.value,
68 | }));
69 |
70 | return {
71 | content: [
72 | {
73 | type: "text",
74 | text: JSON.stringify(
75 | {
76 | count: formattedMessages.length,
77 | messages: formattedMessages,
78 | },
79 | null,
80 | 2,
81 | ),
82 | },
83 | ],
84 | };
85 | }
86 |
87 | case "gmail_get_email": {
88 | const gmail = new GmailService();
89 | const args = request.params.arguments as unknown as GetEmailArgs;
90 | const message = await gmail.getMessage(args.messageId);
91 | return {
92 | content: [
93 | {
94 | type: "text",
95 | text: JSON.stringify(message, null, 2),
96 | },
97 | ],
98 | };
99 | }
100 |
101 | case "gmail_get_draft": {
102 | const gmail = new GmailService();
103 | const args = request.params.arguments as unknown as GetDraftArgs;
104 | const draft = await gmail.getDraft(args.draftId);
105 | return {
106 | content: [
107 | {
108 | type: "text",
109 | text: JSON.stringify(draft, null, 2),
110 | },
111 | ],
112 | };
113 | }
114 |
115 | case "gmail_search_emails": {
116 | const gmail = new GmailService();
117 | const args = request.params.arguments as unknown as SearchEmailsArgs;
118 | const messages = await gmail.searchMessages(args.query, args.maxResults);
119 | return {
120 | content: [
121 | {
122 | type: "text",
123 | text: JSON.stringify(messages, null, 2),
124 | },
125 | ],
126 | };
127 | }
128 |
129 | case "gmail_send_email_ai": {
130 | const args = request.params.arguments as unknown as SendEmailAIArgs;
131 | const { userInstructions, to, replyTo } = args;
132 | if (!userInstructions) {
133 | throw new Error(
134 | "Tool call failed: Missing required parameters - userInstructions is required",
135 | );
136 | }
137 |
138 | if (!to) {
139 | throw new Error("Tool call failed: Missing required parameters - to is required");
140 | }
141 |
142 | validateEmailList(to);
143 |
144 | let threadContent: string | undefined;
145 | if (replyTo) {
146 | const gmail = new GmailService();
147 | const message = await gmail.getMessage(replyTo);
148 | threadContent = JSON.stringify(message);
149 | }
150 |
151 | const prompt = await handleGetPrompt({
152 | method: "prompts/get",
153 | params: {
154 | name: replyTo ? "gmail_reply_email" : "gmail_send_email",
155 | arguments: {
156 | userInstructions,
157 | to,
158 | ...(replyTo && threadContent
159 | ? {
160 | messageId: replyTo,
161 | threadContent,
162 | }
163 | : {}),
164 | },
165 | },
166 | });
167 |
168 | if (!prompt._meta?.responseSchema) {
169 | throw new Error("Invalid prompt configuration: missing response schema");
170 | }
171 |
172 | await sendSamplingRequest({
173 | method: "sampling/createMessage",
174 | params: {
175 | messages: prompt.messages.map((msg) =>
176 | injectVariables(msg, {
177 | userInstructions,
178 | to,
179 | ...(replyTo && threadContent
180 | ? {
181 | messageId: replyTo,
182 | threadContent,
183 | }
184 | : {}),
185 | }),
186 | ) as Array<{
187 | role: "user" | "assistant";
188 | content: { type: "text"; text: string };
189 | }>,
190 | maxTokens: 100000,
191 | temperature: 0.7,
192 | _meta: {
193 | callback: replyTo ? "reply_email" : "send_email",
194 | responseSchema: prompt._meta.responseSchema,
195 | },
196 | arguments: { userInstructions, to, ...(replyTo ? { messageId: replyTo } : {}) },
197 | },
198 | });
199 | return {
200 | content: [
201 | {
202 | type: "text",
203 | text: `Your ${replyTo ? "reply" : "email"} request has been received and is being processed, we will notify you when it is complete.`,
204 | },
205 | ],
206 | };
207 | }
208 |
209 | case "gmail_send_email_manual": {
210 | const gmail = new GmailService();
211 | const args = request.params.arguments as unknown as SendEmailManualArgs;
212 | const { to, subject, body, cc, bcc, isHtml, replyTo } = args;
213 |
214 | validateEmailList(to);
215 | if (cc) validateEmailList(cc);
216 | if (bcc) validateEmailList(bcc);
217 |
218 | if (replyTo) {
219 | await gmail.replyEmail(replyTo, body, isHtml);
220 | } else {
221 | if (!subject) {
222 | throw new Error(
223 | "Tool call failed: Missing required parameters - subject is required for new emails",
224 | );
225 | }
226 | await gmail.sendEmail({
227 | to,
228 | subject,
229 | body,
230 | cc,
231 | bcc,
232 | isHtml,
233 | });
234 | }
235 |
236 | return {
237 | content: [
238 | {
239 | type: "text",
240 | text: JSON.stringify({
241 | status: `${replyTo ? "Reply" : "Email"} sent successfully`,
242 | to,
243 | }),
244 | },
245 | ],
246 | };
247 | }
248 |
249 | case "gmail_create_draft_ai": {
250 | const args = request.params.arguments as unknown as CreateDraftAIArgs;
251 | const { userInstructions, to, replyTo } = args;
252 | if (!userInstructions) {
253 | throw new Error(
254 | "Tool call failed: Missing required parameters - userInstructions is required",
255 | );
256 | }
257 |
258 | if (!to) {
259 | throw new Error("Tool call failed: Missing required parameters - to is required");
260 | }
261 |
262 | validateEmailList(to);
263 |
264 | if (replyTo) {
265 | const gmail = new GmailService();
266 | await gmail.getMessage(replyTo);
267 | }
268 |
269 | const prompt = await handleGetPrompt({
270 | method: "prompts/get",
271 | params: {
272 | name: replyTo ? "gmail_reply_draft" : "gmail_create_draft",
273 | arguments: { userInstructions, to, ...(replyTo ? { messageId: replyTo } : {}) },
274 | },
275 | });
276 |
277 | if (!prompt._meta?.responseSchema) {
278 | throw new Error("Invalid prompt configuration: missing response schema");
279 | }
280 |
281 | await sendSamplingRequest({
282 | method: "sampling/createMessage",
283 | params: {
284 | messages: prompt.messages.map((msg) =>
285 | injectVariables(msg, {
286 | userInstructions,
287 | to,
288 | ...(replyTo ? { messageId: replyTo } : {}),
289 | }),
290 | ) as Array<{
291 | role: "user" | "assistant";
292 | content: { type: "text"; text: string };
293 | }>,
294 | maxTokens: 100000,
295 | temperature: 0.7,
296 | _meta: {
297 | callback: replyTo ? "reply_draft" : "create_draft",
298 | responseSchema: prompt._meta.responseSchema,
299 | },
300 | arguments: { userInstructions, to, ...(replyTo ? { messageId: replyTo } : {}) },
301 | },
302 | });
303 | return {
304 | content: [
305 | {
306 | type: "text",
307 | text: `Your draft ${replyTo ? "reply" : "email"} request has been received and is being processed, we will notify you when it is complete.`,
308 | },
309 | ],
310 | };
311 | }
312 |
313 | case "gmail_edit_draft_ai": {
314 | const args = request.params.arguments as unknown as EditDraftAIArgs;
315 | const { draftId, userInstructions } = args;
316 | if (!userInstructions) {
317 | throw new Error(
318 | "Tool call failed: Missing required parameters - userInstructions is required",
319 | );
320 | }
321 |
322 | if (!draftId) {
323 | throw new Error("Tool call failed: Missing required parameters - draftId is required");
324 | }
325 |
326 | const gmail = new GmailService();
327 | const draft = await gmail.getDraft(draftId);
328 |
329 | const prompt = await handleGetPrompt({
330 | method: "prompts/get",
331 | params: {
332 | name: "gmail_edit_draft",
333 | arguments: { userInstructions, draftId, draft: JSON.stringify(draft) },
334 | },
335 | });
336 |
337 | if (!prompt._meta?.responseSchema) {
338 | throw new Error("Invalid prompt configuration: missing response schema");
339 | }
340 |
341 | await sendSamplingRequest({
342 | method: "sampling/createMessage",
343 | params: {
344 | messages: prompt.messages.map((msg) =>
345 | injectVariables(msg, {
346 | userInstructions,
347 | draftId,
348 | draft: JSON.stringify(draft),
349 | }),
350 | ) as Array<{
351 | role: "user" | "assistant";
352 | content: { type: "text"; text: string };
353 | }>,
354 | maxTokens: 100000,
355 | temperature: 0.7,
356 | _meta: {
357 | callback: "edit_draft",
358 | responseSchema: prompt._meta.responseSchema,
359 | },
360 | arguments: { userInstructions, draftId, draft: JSON.stringify(draft) },
361 | },
362 | });
363 | return {
364 | content: [
365 | {
366 | type: "text",
367 | text: `Your draft edit request has been received and is being processed, we will notify you when it is complete.`,
368 | },
369 | ],
370 | };
371 | }
372 |
373 | case "gmail_list_drafts": {
374 | const gmail = new GmailService();
375 | const args = request.params.arguments as unknown as ListDraftsArgs;
376 | const { maxResults } = args;
377 | const drafts = await gmail.listDrafts(maxResults);
378 | return {
379 | content: [
380 | {
381 | type: "text",
382 | text: JSON.stringify(drafts, null, 2),
383 | },
384 | ],
385 | };
386 | }
387 |
388 | case "gmail_delete_draft": {
389 | const gmail = new GmailService();
390 | const args = request.params.arguments as unknown as DeleteDraftArgs;
391 | const { draftId } = args;
392 | await gmail.deleteDraft(draftId);
393 | return {
394 | content: [
395 | {
396 | type: "text",
397 | text: JSON.stringify({
398 | status: "Draft deleted successfully",
399 | }),
400 | },
401 | ],
402 | };
403 | }
404 |
405 | case "gmail_delete_email": {
406 | const gmail = new GmailService();
407 | const args = request.params.arguments as unknown as TrashMessageArgs;
408 | const { messageId } = args;
409 | await gmail.trashMessage(messageId);
410 | return {
411 | content: [
412 | {
413 | type: "text",
414 | text: JSON.stringify({
415 | status: "Message moved to trash successfully",
416 | }),
417 | },
418 | ],
419 | };
420 | }
421 |
422 | default:
423 | throw new Error(`${TOOL_ERROR_MESSAGES.UNKNOWN_TOOL} ${request.params.name}`);
424 | }
425 | } catch (error) {
426 | console.error(`${TOOL_ERROR_MESSAGES.TOOL_CALL_FAILED} ${error}`);
427 | throw error;
428 | }
429 | }
430 |
```
--------------------------------------------------------------------------------
/src/services/__tests__/systemprompt-service.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | jest,
3 | describe,
4 | it,
5 | expect,
6 | beforeEach,
7 | afterEach,
8 | } from "@jest/globals";
9 | import type { SpyInstance } from "jest-mock";
10 | import { SystemPromptService } from "../systemprompt-service";
11 | import type {
12 | SystempromptPromptResponse,
13 | SystempromptBlockResponse,
14 | SystempromptAgentResponse,
15 | SystempromptUserStatusResponse,
16 | SystempromptPromptRequest,
17 | SystempromptBlockRequest,
18 | SystempromptAgentRequest,
19 | Metadata,
20 | } from "../../types/index.js";
21 |
22 | describe("SystemPromptService", () => {
23 | const mockApiKey = "test-api-key";
24 | const mockBaseUrl = "http://test-api.com";
25 | let fetchSpy: SpyInstance<typeof fetch>;
26 |
27 | beforeEach(() => {
28 | // Reset the singleton instance
29 | SystemPromptService.cleanup();
30 | // Reset fetch mock
31 | fetchSpy = jest
32 | .spyOn(global, "fetch")
33 | .mockImplementation(
34 | async (input: string | URL | Request, init?: RequestInit) => {
35 | const url =
36 | input instanceof URL ? input.toString() : input.toString();
37 |
38 | // Handle error cases
39 | if (url.includes("invalid-api-key")) {
40 | return new Response(
41 | JSON.stringify({ message: "Invalid API key" }),
42 | {
43 | status: 403,
44 | headers: { "Content-Type": "application/json" },
45 | }
46 | );
47 | }
48 |
49 | if (url.includes("not-found")) {
50 | return new Response(
51 | JSON.stringify({
52 | message: "Resource not found - it may have been deleted",
53 | }),
54 | { status: 404, headers: { "Content-Type": "application/json" } }
55 | );
56 | }
57 |
58 | if (url.includes("conflict")) {
59 | return new Response(
60 | JSON.stringify({
61 | message: "Resource conflict - it may have been edited",
62 | }),
63 | { status: 409, headers: { "Content-Type": "application/json" } }
64 | );
65 | }
66 |
67 | if (url.includes("bad-request")) {
68 | return new Response(JSON.stringify({ message: "Invalid data" }), {
69 | status: 400,
70 | headers: { "Content-Type": "application/json" },
71 | });
72 | }
73 |
74 | if (url.includes("invalid-json")) {
75 | return new Response("invalid json", {
76 | status: 200,
77 | headers: { "Content-Type": "application/json" },
78 | });
79 | }
80 |
81 | // Handle successful cases
82 | if (init?.method === "DELETE") {
83 | return new Response(null, { status: 204 });
84 | }
85 |
86 | return new Response(JSON.stringify({ data: "test" }), {
87 | status: 200,
88 | statusText: "OK",
89 | headers: new Headers({
90 | "Content-Type": "application/json",
91 | }),
92 | });
93 | }
94 | );
95 | });
96 |
97 | afterEach(() => {
98 | fetchSpy.mockRestore();
99 | });
100 |
101 | describe("initialization", () => {
102 | it("should initialize with API key", () => {
103 | SystemPromptService.initialize(mockApiKey);
104 | const instance = SystemPromptService.getInstance();
105 | expect(instance).toBeDefined();
106 | });
107 |
108 | it("should initialize with custom base URL", () => {
109 | SystemPromptService.initialize(mockApiKey, mockBaseUrl);
110 | const instance = SystemPromptService.getInstance();
111 | expect(instance).toBeDefined();
112 | });
113 |
114 | it("should throw error if initialized without API key", () => {
115 | expect(() => SystemPromptService.initialize("")).toThrow(
116 | "API key is required"
117 | );
118 | });
119 |
120 | it("should throw error if getInstance called before initialization", () => {
121 | expect(() => SystemPromptService.getInstance()).toThrow(
122 | "SystemPromptService must be initialized with an API key first"
123 | );
124 | });
125 | });
126 |
127 | describe("API requests", () => {
128 | let service: SystemPromptService;
129 |
130 | beforeEach(() => {
131 | SystemPromptService.initialize(mockApiKey, mockBaseUrl);
132 | service = SystemPromptService.getInstance();
133 | });
134 |
135 | it("should handle successful GET request", async () => {
136 | const mockResponse = { data: "test" };
137 | const result = await service.getAllPrompts();
138 | expect(result).toEqual(mockResponse);
139 | expect(fetchSpy).toHaveBeenCalledWith(
140 | `${mockBaseUrl}/prompt`,
141 | expect.objectContaining({
142 | method: "GET",
143 | headers: {
144 | "Content-Type": "application/json",
145 | "api-key": mockApiKey,
146 | },
147 | })
148 | );
149 | });
150 |
151 | it("should handle successful POST request", async () => {
152 | const data = {
153 | metadata: {
154 | title: "Test",
155 | description: "Test description",
156 | version: 1,
157 | status: "active",
158 | author: "test",
159 | log_message: "test",
160 | },
161 | instruction: {
162 | static: "Test instruction",
163 | },
164 | input: {
165 | type: ["text"],
166 | },
167 | output: {
168 | type: ["text"],
169 | },
170 | };
171 | const mockResponse = { data: "test" };
172 | const result = await service.createPrompt(data);
173 | expect(result).toEqual(mockResponse);
174 | expect(fetchSpy).toHaveBeenCalledWith(
175 | `${mockBaseUrl}/prompt`,
176 | expect.objectContaining({
177 | method: "POST",
178 | headers: {
179 | "Content-Type": "application/json",
180 | "api-key": mockApiKey,
181 | },
182 | body: JSON.stringify(data),
183 | })
184 | );
185 | });
186 |
187 | it("should handle 204 response", async () => {
188 | await service.deletePrompt("test-id");
189 | expect(fetchSpy).toHaveBeenCalledWith(
190 | `${mockBaseUrl}/prompt/test-id`,
191 | expect.objectContaining({
192 | method: "DELETE",
193 | headers: {
194 | "Content-Type": "application/json",
195 | "api-key": mockApiKey,
196 | },
197 | })
198 | );
199 | });
200 |
201 | it("should handle invalid API key error", async () => {
202 | fetchSpy.mockImplementationOnce(() =>
203 | Promise.resolve(
204 | new Response(JSON.stringify({ message: "Invalid API key" }), {
205 | status: 403,
206 | headers: { "Content-Type": "application/json" },
207 | })
208 | )
209 | );
210 | await expect(service.getAllPrompts()).rejects.toThrow("Invalid API key");
211 | });
212 |
213 | it("should handle not found error", async () => {
214 | await expect(service.getBlock("not-found")).rejects.toThrow(
215 | "Resource not found - it may have been deleted"
216 | );
217 | });
218 |
219 | it("should handle conflict error", async () => {
220 | await expect(service.editPrompt("conflict", {})).rejects.toThrow(
221 | "Resource conflict - it may have been edited"
222 | );
223 | });
224 |
225 | it("should handle bad request error", async () => {
226 | fetchSpy.mockImplementationOnce(() =>
227 | Promise.resolve(
228 | new Response(JSON.stringify({ message: "Invalid data" }), {
229 | status: 400,
230 | headers: { "Content-Type": "application/json" },
231 | })
232 | )
233 | );
234 | const invalidData: SystempromptPromptRequest = {
235 | metadata: {
236 | title: "Test",
237 | description: "Test description",
238 | version: 1,
239 | status: "active",
240 | author: "test",
241 | log_message: "test",
242 | tag: ["test"],
243 | },
244 | instruction: { static: "Test instruction" },
245 | };
246 | await expect(service.createPrompt(invalidData)).rejects.toThrow(
247 | "Invalid data"
248 | );
249 | });
250 |
251 | it("should handle network error", async () => {
252 | fetchSpy.mockImplementationOnce(() =>
253 | Promise.reject(new Error("Failed to fetch"))
254 | );
255 | await expect(service.getAllPrompts()).rejects.toThrow(
256 | "API request failed"
257 | );
258 | });
259 |
260 | it("should handle JSON parse error", async () => {
261 | fetchSpy.mockImplementationOnce(() =>
262 | Promise.resolve(
263 | new Response("invalid json", {
264 | status: 200,
265 | headers: { "Content-Type": "application/json" },
266 | })
267 | )
268 | );
269 | await expect(service.getAllPrompts()).rejects.toThrow(
270 | "Failed to parse API response"
271 | );
272 | });
273 | });
274 |
275 | describe("API endpoints", () => {
276 | let service: SystemPromptService;
277 |
278 | beforeEach(() => {
279 | SystemPromptService.initialize(mockApiKey, mockBaseUrl);
280 | service = SystemPromptService.getInstance();
281 | fetchSpy.mockResolvedValue(
282 | new Response(JSON.stringify({}), {
283 | status: 200,
284 | headers: { "Content-Type": "application/json" },
285 | })
286 | );
287 | });
288 |
289 | it("should call getAllPrompts endpoint", async () => {
290 | await service.getAllPrompts();
291 | expect(fetchSpy).toHaveBeenCalledWith(
292 | expect.stringContaining("/prompt"),
293 | expect.objectContaining({
294 | method: "GET",
295 | headers: {
296 | "Content-Type": "application/json",
297 | "api-key": "test-api-key",
298 | },
299 | })
300 | );
301 | });
302 |
303 | it("should call createPrompt endpoint", async () => {
304 | const data = {
305 | metadata: {
306 | title: "Test",
307 | description: "Test description",
308 | version: 1,
309 | status: "active",
310 | author: "test",
311 | log_message: "test",
312 | },
313 | instruction: {
314 | static: "Test instruction",
315 | },
316 | input: {
317 | type: ["text"],
318 | },
319 | output: {
320 | type: ["text"],
321 | },
322 | };
323 | await service.createPrompt(data);
324 | expect(fetchSpy).toHaveBeenCalledWith(
325 | expect.stringContaining("/prompt"),
326 | expect.objectContaining({
327 | method: "POST",
328 | headers: {
329 | "Content-Type": "application/json",
330 | "api-key": "test-api-key",
331 | },
332 | body: JSON.stringify(data),
333 | })
334 | );
335 | });
336 |
337 | it("should call editPrompt endpoint", async () => {
338 | const data = {
339 | metadata: {
340 | title: "Test",
341 | description: "Test description",
342 | version: 1,
343 | status: "active",
344 | author: "test",
345 | log_message: "test",
346 | },
347 | };
348 | await service.editPrompt("test-id", data);
349 | expect(fetchSpy).toHaveBeenCalledWith(
350 | expect.stringContaining("/prompt/test-id"),
351 | expect.objectContaining({
352 | method: "PUT",
353 | headers: {
354 | "Content-Type": "application/json",
355 | "api-key": "test-api-key",
356 | },
357 | body: JSON.stringify(data),
358 | })
359 | );
360 | });
361 |
362 | it("should call deletePrompt endpoint", async () => {
363 | await service.deletePrompt("test-id");
364 | expect(fetchSpy).toHaveBeenCalledWith(
365 | expect.stringContaining("/prompt/test-id"),
366 | expect.objectContaining({
367 | method: "DELETE",
368 | headers: {
369 | "Content-Type": "application/json",
370 | "api-key": "test-api-key",
371 | },
372 | })
373 | );
374 | });
375 |
376 | it("should call createBlock endpoint", async () => {
377 | const data = {
378 | content: "test",
379 | prefix: "test",
380 | metadata: {
381 | title: "Test",
382 | description: "Test description",
383 | },
384 | };
385 | await service.createBlock(data);
386 | expect(fetchSpy).toHaveBeenCalledWith(
387 | expect.stringContaining("/block"),
388 | expect.objectContaining({
389 | method: "POST",
390 | headers: {
391 | "Content-Type": "application/json",
392 | "api-key": "test-api-key",
393 | },
394 | body: JSON.stringify(data),
395 | })
396 | );
397 | });
398 |
399 | it("should call editBlock endpoint", async () => {
400 | const data = {
401 | content: "test",
402 | metadata: {
403 | title: "Test",
404 | },
405 | };
406 | await service.editBlock("test-id", data);
407 | expect(fetchSpy).toHaveBeenCalledWith(
408 | expect.stringContaining("/block/test-id"),
409 | expect.objectContaining({
410 | method: "PUT",
411 | headers: {
412 | "Content-Type": "application/json",
413 | "api-key": "test-api-key",
414 | },
415 | body: JSON.stringify(data),
416 | })
417 | );
418 | });
419 |
420 | it("should call listBlocks endpoint", async () => {
421 | await service.listBlocks();
422 | expect(fetchSpy).toHaveBeenCalledWith(
423 | expect.stringContaining("/block"),
424 | expect.objectContaining({
425 | method: "GET",
426 | headers: {
427 | "Content-Type": "application/json",
428 | "api-key": "test-api-key",
429 | },
430 | })
431 | );
432 | });
433 |
434 | it("should call getBlock endpoint", async () => {
435 | await service.getBlock("test-id");
436 | expect(fetchSpy).toHaveBeenCalledWith(
437 | expect.stringContaining("/block/test-id"),
438 | expect.objectContaining({
439 | method: "GET",
440 | headers: {
441 | "Content-Type": "application/json",
442 | "api-key": "test-api-key",
443 | },
444 | })
445 | );
446 | });
447 |
448 | it("should call listAgents endpoint", async () => {
449 | await service.listAgents();
450 | expect(fetchSpy).toHaveBeenCalledWith(
451 | expect.stringContaining("/agent"),
452 | expect.objectContaining({
453 | method: "GET",
454 | headers: {
455 | "Content-Type": "application/json",
456 | "api-key": "test-api-key",
457 | },
458 | })
459 | );
460 | });
461 |
462 | it("should call createAgent endpoint", async () => {
463 | const data = {
464 | content: "test",
465 | metadata: {
466 | title: "Test",
467 | description: "Test description",
468 | },
469 | };
470 | await service.createAgent(data);
471 | expect(fetchSpy).toHaveBeenCalledWith(
472 | expect.stringContaining("/agent"),
473 | expect.objectContaining({
474 | method: "POST",
475 | headers: {
476 | "Content-Type": "application/json",
477 | "api-key": "test-api-key",
478 | },
479 | body: JSON.stringify(data),
480 | })
481 | );
482 | });
483 |
484 | it("should call editAgent endpoint", async () => {
485 | const data = {
486 | content: "test",
487 | metadata: {
488 | title: "Test",
489 | },
490 | };
491 | await service.editAgent("test-id", data);
492 | expect(fetchSpy).toHaveBeenCalledWith(
493 | expect.stringContaining("/agent/test-id"),
494 | expect.objectContaining({
495 | method: "PUT",
496 | headers: {
497 | "Content-Type": "application/json",
498 | "api-key": "test-api-key",
499 | },
500 | body: JSON.stringify(data),
501 | })
502 | );
503 | });
504 |
505 | it("should call deleteBlock endpoint", async () => {
506 | await service.deleteBlock("test-id");
507 | expect(fetchSpy).toHaveBeenCalledWith(
508 | expect.stringContaining("/block/test-id"),
509 | expect.objectContaining({
510 | method: "DELETE",
511 | headers: {
512 | "Content-Type": "application/json",
513 | "api-key": "test-api-key",
514 | },
515 | })
516 | );
517 | });
518 |
519 | it("should call fetchUserStatus endpoint", async () => {
520 | await service.fetchUserStatus();
521 | expect(fetchSpy).toHaveBeenCalledWith(
522 | expect.stringContaining("/user/mcp"),
523 | expect.objectContaining({
524 | method: "GET",
525 | headers: {
526 | "Content-Type": "application/json",
527 | "api-key": "test-api-key",
528 | },
529 | })
530 | );
531 | });
532 | });
533 | });
534 |
```
--------------------------------------------------------------------------------
/src/utils/__tests__/mcp-mappers.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | mapPromptToGetPromptResult,
3 | mapPromptsToListPromptsResult,
4 | mapBlockToReadResourceResult,
5 | mapBlocksToListResourcesResult,
6 | } from "../mcp-mappers.js";
7 | import {
8 | mockSystemPromptResult,
9 | mockArrayPromptResult,
10 | mockNestedPromptResult,
11 | } from "../../__tests__/mock-objects.js";
12 | import type {
13 | SystempromptPromptResponse,
14 | SystempromptBlockResponse,
15 | } from "../../types/systemprompt.js";
16 | import type { GetPromptResult } from "@modelcontextprotocol/sdk/types.js";
17 |
18 | describe("MCP Mappers", () => {
19 | describe("mapPromptToGetPromptResult", () => {
20 | it("should map a prompt with all fields", () => {
21 | const prompt: SystempromptPromptResponse = {
22 | id: "test-prompt",
23 | metadata: {
24 | title: "Test Prompt",
25 | description: "Test Description",
26 | created: "2024-01-01",
27 | updated: "2024-01-01",
28 | version: 1,
29 | status: "active",
30 | author: "test",
31 | log_message: "Initial version",
32 | tag: ["test"],
33 | },
34 | instruction: {
35 | static: "Test instruction",
36 | dynamic: "",
37 | state: "",
38 | },
39 | input: {
40 | name: "test-input",
41 | description: "Test input",
42 | type: ["object"],
43 | schema: {
44 | type: "object",
45 | properties: {
46 | testArg: {
47 | type: "string",
48 | description: "Test argument",
49 | },
50 | requiredArg: {
51 | type: "string",
52 | description: "Required argument",
53 | },
54 | },
55 | required: ["requiredArg"],
56 | },
57 | },
58 | output: {
59 | name: "test-output",
60 | description: "Test output",
61 | type: ["object"],
62 | schema: {
63 | type: "object",
64 | properties: {},
65 | },
66 | },
67 | _link: "test-link",
68 | };
69 |
70 | const result = mapPromptToGetPromptResult(prompt);
71 |
72 | expect(result).toEqual({
73 | name: "Test Prompt",
74 | description: "Test Description",
75 | messages: [
76 | {
77 | role: "assistant",
78 | content: {
79 | type: "text",
80 | text: "Test instruction",
81 | },
82 | },
83 | ],
84 | arguments: [
85 | {
86 | name: "testArg",
87 | description: "Test argument",
88 | required: false,
89 | },
90 | {
91 | name: "requiredArg",
92 | description: "Required argument",
93 | required: true,
94 | },
95 | ],
96 | tools: [],
97 | _meta: { prompt },
98 | });
99 | });
100 |
101 | it("should handle missing optional fields", () => {
102 | const prompt: SystempromptPromptResponse = {
103 | id: "test-prompt-2",
104 | metadata: {
105 | title: "Test Prompt",
106 | description: "Test Description",
107 | created: "2024-01-01",
108 | updated: "2024-01-01",
109 | version: 1,
110 | status: "active",
111 | author: "test",
112 | log_message: "Initial version",
113 | tag: ["test"],
114 | },
115 | instruction: {
116 | static: "Test instruction",
117 | dynamic: "",
118 | state: "",
119 | },
120 | input: {
121 | name: "test-input",
122 | description: "Test input",
123 | type: ["object"],
124 | schema: {
125 | type: "object",
126 | properties: {},
127 | },
128 | },
129 | output: {
130 | name: "test-output",
131 | description: "Test output",
132 | type: ["object"],
133 | schema: {
134 | type: "object",
135 | properties: {},
136 | },
137 | },
138 | _link: "test-link",
139 | };
140 |
141 | const result = mapPromptToGetPromptResult(prompt);
142 |
143 | expect(result).toEqual({
144 | name: "Test Prompt",
145 | description: "Test Description",
146 | messages: [
147 | {
148 | role: "assistant",
149 | content: {
150 | type: "text",
151 | text: "Test instruction",
152 | },
153 | },
154 | ],
155 | arguments: [],
156 | tools: [],
157 | _meta: { prompt },
158 | });
159 | });
160 |
161 | it("should handle invalid argument schemas", () => {
162 | const prompt: SystempromptPromptResponse = {
163 | id: "test-prompt-3",
164 | metadata: {
165 | title: "Test Prompt",
166 | description: "Test Description",
167 | created: "2024-01-01",
168 | updated: "2024-01-01",
169 | version: 1,
170 | status: "active",
171 | author: "test",
172 | log_message: "Initial version",
173 | tag: ["test"],
174 | },
175 | instruction: {
176 | static: "Test instruction",
177 | dynamic: "",
178 | state: "",
179 | },
180 | input: {
181 | name: "test-input",
182 | description: "Test input",
183 | type: ["object"],
184 | schema: {
185 | type: "object",
186 | properties: {
187 | boolProp: { type: "boolean" },
188 | nullProp: { type: "null" },
189 | stringProp: { type: "string" },
190 | validProp: {
191 | type: "string",
192 | description: "Valid property",
193 | },
194 | },
195 | required: ["validProp"],
196 | },
197 | },
198 | output: {
199 | name: "test-output",
200 | description: "Test output",
201 | type: ["object"],
202 | schema: {
203 | type: "object",
204 | properties: {},
205 | },
206 | },
207 | _link: "test-link",
208 | };
209 |
210 | const result = mapPromptToGetPromptResult(prompt);
211 |
212 | expect(result.arguments).toEqual([
213 | {
214 | name: "boolProp",
215 | description: "",
216 | required: false,
217 | },
218 | {
219 | name: "nullProp",
220 | description: "",
221 | required: false,
222 | },
223 | {
224 | name: "stringProp",
225 | description: "",
226 | required: false,
227 | },
228 | {
229 | name: "validProp",
230 | description: "Valid property",
231 | required: true,
232 | },
233 | ]);
234 | });
235 |
236 | it("should correctly map a single prompt to GetPromptResult format", () => {
237 | const result = mapPromptToGetPromptResult(mockSystemPromptResult);
238 |
239 | expect(result.name).toBe(mockSystemPromptResult.metadata.title);
240 | expect(result.description).toBe(
241 | mockSystemPromptResult.metadata.description
242 | );
243 | expect(result.messages).toEqual([
244 | {
245 | role: "assistant",
246 | content: {
247 | type: "text",
248 | text: mockSystemPromptResult.instruction.static,
249 | },
250 | },
251 | ]);
252 | expect(result.tools).toEqual([]);
253 | expect(result._meta).toEqual({ prompt: mockSystemPromptResult });
254 | });
255 |
256 | it("should handle prompts with array inputs", () => {
257 | const result = mapPromptToGetPromptResult(mockArrayPromptResult);
258 |
259 | expect(result.name).toBe(mockArrayPromptResult.metadata.title);
260 | expect(result.description).toBe(
261 | mockArrayPromptResult.metadata.description
262 | );
263 | expect(result.messages).toEqual([
264 | {
265 | role: "assistant",
266 | content: {
267 | type: "text",
268 | text: mockArrayPromptResult.instruction.static,
269 | },
270 | },
271 | ]);
272 | expect(result.tools).toEqual([]);
273 | expect(result._meta).toEqual({ prompt: mockArrayPromptResult });
274 | });
275 |
276 | it("should handle prompts with nested object inputs", () => {
277 | const result = mapPromptToGetPromptResult(mockNestedPromptResult);
278 |
279 | expect(result.name).toBe(mockNestedPromptResult.metadata.title);
280 | expect(result.description).toBe(
281 | mockNestedPromptResult.metadata.description
282 | );
283 | expect(result.messages).toEqual([
284 | {
285 | role: "assistant",
286 | content: {
287 | type: "text",
288 | text: mockNestedPromptResult.instruction.static,
289 | },
290 | },
291 | ]);
292 | expect(result.tools).toEqual([]);
293 | expect(result._meta).toEqual({ prompt: mockNestedPromptResult });
294 | });
295 | });
296 |
297 | describe("mapPromptsToListPromptsResult", () => {
298 | it("should map an array of prompts", () => {
299 | const prompts: SystempromptPromptResponse[] = [
300 | {
301 | id: "prompt-1",
302 | metadata: {
303 | title: "Prompt 1",
304 | description: "Description 1",
305 | created: "2024-01-01",
306 | updated: "2024-01-01",
307 | version: 1,
308 | status: "active",
309 | author: "test",
310 | log_message: "Initial version",
311 | tag: ["test"],
312 | },
313 | instruction: {
314 | static: "Instruction 1",
315 | dynamic: "",
316 | state: "",
317 | },
318 | input: {
319 | name: "input-1",
320 | description: "Input 1",
321 | type: ["object"],
322 | schema: {
323 | type: "object",
324 | properties: {},
325 | },
326 | },
327 | output: {
328 | name: "output-1",
329 | description: "Output 1",
330 | type: ["object"],
331 | schema: {
332 | type: "object",
333 | properties: {},
334 | },
335 | },
336 | _link: "link-1",
337 | },
338 | {
339 | id: "prompt-2",
340 | metadata: {
341 | title: "Prompt 2",
342 | description: "Description 2",
343 | created: "2024-01-01",
344 | updated: "2024-01-01",
345 | version: 1,
346 | status: "active",
347 | author: "test",
348 | log_message: "Initial version",
349 | tag: ["test"],
350 | },
351 | instruction: {
352 | static: "Instruction 2",
353 | dynamic: "",
354 | state: "",
355 | },
356 | input: {
357 | name: "input-2",
358 | description: "Input 2",
359 | type: ["object"],
360 | schema: {
361 | type: "object",
362 | properties: {},
363 | },
364 | },
365 | output: {
366 | name: "output-2",
367 | description: "Output 2",
368 | type: ["object"],
369 | schema: {
370 | type: "object",
371 | properties: {},
372 | },
373 | },
374 | _link: "link-2",
375 | },
376 | ];
377 |
378 | const result = mapPromptsToListPromptsResult(prompts);
379 |
380 | expect(result).toEqual({
381 | _meta: { prompts },
382 | prompts: [
383 | {
384 | name: "Prompt 1",
385 | description: "Description 1",
386 | arguments: [],
387 | },
388 | {
389 | name: "Prompt 2",
390 | description: "Description 2",
391 | arguments: [],
392 | },
393 | ],
394 | });
395 | });
396 |
397 | it("should handle empty prompt array", () => {
398 | const result = mapPromptsToListPromptsResult([]);
399 |
400 | expect(result.prompts).toHaveLength(0);
401 | expect(result._meta).toEqual({ prompts: [] });
402 | });
403 | });
404 |
405 | describe("mapBlockToReadResourceResult", () => {
406 | const mockBlock: SystempromptBlockResponse = {
407 | id: "block-123",
408 | content: "Test block content",
409 | prefix: "{{message}}",
410 | metadata: {
411 | title: "Test Block",
412 | description: "Test block description",
413 | created: new Date().toISOString(),
414 | updated: new Date().toISOString(),
415 | version: 1,
416 | status: "published",
417 | author: "test-user",
418 | log_message: "Initial creation",
419 | tag: ["test"],
420 | },
421 | };
422 |
423 | it("should map a block to read resource result", () => {
424 | const block: SystempromptBlockResponse = {
425 | id: "test-block",
426 | prefix: "test-prefix",
427 | metadata: {
428 | title: "Test Block",
429 | description: "Test Description",
430 | created: "2024-01-01",
431 | updated: "2024-01-01",
432 | version: 1,
433 | status: "active",
434 | author: "test",
435 | log_message: "Initial version",
436 | tag: ["test"],
437 | },
438 | content: "Test content",
439 | _link: "test-link",
440 | };
441 |
442 | const result = mapBlockToReadResourceResult(block);
443 |
444 | expect(result).toEqual({
445 | contents: [
446 | {
447 | uri: "resource:///block/test-block",
448 | mimeType: "text/plain",
449 | text: "Test content",
450 | },
451 | ],
452 | _meta: {},
453 | });
454 | });
455 |
456 | it("should correctly map a single block to ReadResourceResult format", () => {
457 | const result = mapBlockToReadResourceResult(mockBlock);
458 |
459 | expect(result.contents).toHaveLength(1);
460 | expect(result.contents[0]).toEqual({
461 | uri: `resource:///block/${mockBlock.id}`,
462 | mimeType: "text/plain",
463 | text: mockBlock.content,
464 | });
465 | expect(result._meta).toEqual({});
466 | });
467 | });
468 |
469 | describe("mapBlocksToListResourcesResult", () => {
470 | const mockBlocks: SystempromptBlockResponse[] = [
471 | {
472 | id: "block-123",
473 | content: "Test block content 1",
474 | prefix: "{{message}}",
475 | metadata: {
476 | title: "Test Block 1",
477 | description: "Test block description 1",
478 | created: new Date().toISOString(),
479 | updated: new Date().toISOString(),
480 | version: 1,
481 | status: "published",
482 | author: "test-user",
483 | log_message: "Initial creation",
484 | tag: ["test"],
485 | },
486 | },
487 | {
488 | id: "block-456",
489 | content: "Test block content 2",
490 | prefix: "{{message}}",
491 | metadata: {
492 | title: "Test Block 2",
493 | description: null,
494 | created: new Date().toISOString(),
495 | updated: new Date().toISOString(),
496 | version: 1,
497 | status: "published",
498 | author: "test-user",
499 | log_message: "Initial creation",
500 | tag: ["test"],
501 | },
502 | },
503 | ];
504 |
505 | it("should map blocks to list resources result", () => {
506 | const blocks: SystempromptBlockResponse[] = [
507 | {
508 | id: "block-1",
509 | prefix: "prefix-1",
510 | metadata: {
511 | title: "Block 1",
512 | description: "Description 1",
513 | created: "2024-01-01",
514 | updated: "2024-01-01",
515 | version: 1,
516 | status: "active",
517 | author: "test",
518 | log_message: "Initial version",
519 | tag: ["test"],
520 | },
521 | content: "Content 1",
522 | _link: "link-1",
523 | },
524 | {
525 | id: "block-2",
526 | prefix: "prefix-2",
527 | metadata: {
528 | title: "Block 2",
529 | description: "Description 2",
530 | created: "2024-01-01",
531 | updated: "2024-01-01",
532 | version: 1,
533 | status: "active",
534 | author: "test",
535 | log_message: "Initial version",
536 | tag: ["test"],
537 | },
538 | content: "Content 2",
539 | _link: "link-2",
540 | },
541 | ];
542 |
543 | const result = mapBlocksToListResourcesResult(blocks);
544 |
545 | expect(result).toEqual({
546 | _meta: {},
547 | resources: [
548 | {
549 | uri: "resource:///block/block-1",
550 | name: "Block 1",
551 | description: "Description 1",
552 | mimeType: "text/plain",
553 | },
554 | {
555 | uri: "resource:///block/block-2",
556 | name: "Block 2",
557 | description: "Description 2",
558 | mimeType: "text/plain",
559 | },
560 | ],
561 | });
562 | });
563 |
564 | it("should correctly map an array of blocks to ListResourcesResult format", () => {
565 | const result = mapBlocksToListResourcesResult(mockBlocks);
566 |
567 | expect(result.resources).toHaveLength(2);
568 | expect(result.resources[0]).toEqual({
569 | uri: `resource:///block/${mockBlocks[0].id}`,
570 | name: mockBlocks[0].metadata.title,
571 | description: mockBlocks[0].metadata.description,
572 | mimeType: "text/plain",
573 | });
574 | expect(result.resources[1]).toEqual({
575 | uri: `resource:///block/${mockBlocks[1].id}`,
576 | name: mockBlocks[1].metadata.title,
577 | description: undefined,
578 | mimeType: "text/plain",
579 | });
580 | expect(result._meta).toEqual({});
581 | });
582 |
583 | it("should handle empty block array", () => {
584 | const result = mapBlocksToListResourcesResult([]);
585 |
586 | expect(result.resources).toHaveLength(0);
587 | expect(result._meta).toEqual({});
588 | });
589 | });
590 | });
591 |
```
--------------------------------------------------------------------------------
/src/services/gmail-service.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { google } from "googleapis";
2 | import { GoogleBaseService } from "./google-base-service.js";
3 | import { gmail_v1 } from "googleapis/build/src/apis/gmail/v1.js";
4 | import { EmailMetadata, SendEmailOptions, DraftEmailOptions } from "../types/gmail-types.js";
5 |
6 | const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
7 |
8 | function validateEmail(email: string): boolean {
9 | return EMAIL_REGEX.test(email.trim());
10 | }
11 |
12 | function validateEmailList(emails: string | string[] | undefined): void {
13 | if (!emails) return;
14 |
15 | const emailList = Array.isArray(emails) ? emails : emails.split(",").map((e) => e.trim());
16 |
17 | for (const email of emailList) {
18 | if (!validateEmail(email)) {
19 | throw new Error(`Invalid email address: ${email}`);
20 | }
21 | }
22 | }
23 |
24 | export class GmailService extends GoogleBaseService {
25 | private gmail!: gmail_v1.Gmail;
26 | private labelCache: Map<string, gmail_v1.Schema$Label> = new Map();
27 | private gmailInitPromise: Promise<void>;
28 |
29 | constructor() {
30 | super();
31 | this.gmailInitPromise = this.initializeGmailClient();
32 | }
33 |
34 | private async initializeGmailClient(): Promise<void> {
35 | await this.waitForInit();
36 | this.gmail = google.gmail({ version: "v1", auth: this.auth.getAuth() });
37 | }
38 |
39 | // Helper method to ensure initialization is complete
40 | private async ensureInitialized(): Promise<void> {
41 | await this.gmailInitPromise;
42 | }
43 |
44 | private async loadLabels(): Promise<void> {
45 | await this.ensureInitialized();
46 | if (this.labelCache.size === 0) {
47 | const labels = await this.getLabels();
48 | labels.forEach((label) => {
49 | this.labelCache.set(label.id!, label);
50 | });
51 | }
52 | }
53 |
54 | private parseEmailAddress(address: string): { name?: string; email: string } {
55 | const match = address.match(/(?:"?([^"]*)"?\s)?(?:<)?(.+@[^>]+)(?:>)?/);
56 | if (match) {
57 | return {
58 | name: match[1]?.trim(),
59 | email: match[2].trim(),
60 | };
61 | }
62 | return { email: address.trim() };
63 | }
64 |
65 | private async getMessageMetadata(messageId: string): Promise<gmail_v1.Schema$Message> {
66 | await this.ensureInitialized();
67 | try {
68 | const response = await this.gmail.users.messages.get({
69 | userId: "me",
70 | id: messageId,
71 | format: "metadata",
72 | metadataHeaders: [
73 | "From",
74 | "To",
75 | "Cc",
76 | "Bcc",
77 | "Subject",
78 | "Date",
79 | "Reply-To",
80 | "Message-ID",
81 | "References",
82 | "Content-Type",
83 | ],
84 | });
85 | return response.data;
86 | } catch (error) {
87 | console.error(`Failed to get message metadata for ${messageId}:`, error);
88 | throw error;
89 | }
90 | }
91 |
92 | private async extractEmailMetadata(message: gmail_v1.Schema$Message): Promise<EmailMetadata> {
93 | await this.loadLabels();
94 | const headers = message.payload?.headers || [];
95 | const fromHeader = headers.find((h) => h.name === "From")?.value || "";
96 | const toHeader = headers.find((h) => h.name === "To")?.value || "";
97 | const dateStr = headers.find((h) => h.name === "Date")?.value;
98 |
99 | const labels = (message.labelIds || [])
100 | .map((id) => {
101 | const label = this.labelCache.get(id);
102 | return label ? { id, name: label.name || id } : null;
103 | })
104 | .filter((label): label is { id: string; name: string } => label !== null);
105 |
106 | return {
107 | id: message.id!,
108 | threadId: message.threadId!,
109 | snippet: message.snippet?.replace(/'/g, "'").replace(/"/g, '"') || "",
110 | from: this.parseEmailAddress(fromHeader),
111 | to: toHeader.split(",").map((addr) => this.parseEmailAddress(addr.trim())),
112 | subject: headers.find((h) => h.name === "Subject")?.value || "(no subject)",
113 | date: dateStr ? new Date(dateStr) : new Date(),
114 | labels,
115 | hasAttachments: Boolean(
116 | message.payload?.parts?.some((part) => part.filename && part.filename.length > 0),
117 | ),
118 | isUnread: message.labelIds?.includes("UNREAD") || false,
119 | isImportant: message.labelIds?.includes("IMPORTANT") || false,
120 | };
121 | }
122 |
123 | async listMessages(maxResults: number = 100): Promise<EmailMetadata[]> {
124 | await this.ensureInitialized();
125 | try {
126 | const response = await this.gmail.users.messages.list({
127 | userId: "me",
128 | maxResults,
129 | });
130 |
131 | const messages = response.data.messages || [];
132 | const messageDetails = await Promise.all(
133 | messages.map((msg) => this.getMessageMetadata(msg.id!)),
134 | );
135 |
136 | return await Promise.all(messageDetails.map((msg) => this.extractEmailMetadata(msg)));
137 | } catch (error) {
138 | console.error("Failed to list Gmail messages:", error);
139 | throw error;
140 | }
141 | }
142 |
143 | async getMessage(messageId: string): Promise<EmailMetadata & { body: string }> {
144 | await this.ensureInitialized();
145 | try {
146 | const response = await this.gmail.users.messages.get({
147 | userId: "me",
148 | id: messageId,
149 | format: "full",
150 | });
151 |
152 | const metadata = await this.extractEmailMetadata(response.data);
153 | let body = "";
154 |
155 | // Extract message body
156 | const message = response.data;
157 | if (message.payload) {
158 | if (message.payload.body?.data) {
159 | body = Buffer.from(message.payload.body.data, "base64").toString("utf8");
160 | } else if (message.payload.parts) {
161 | const textPart = message.payload.parts.find(
162 | (part) => part.mimeType === "text/plain" || part.mimeType === "text/html",
163 | );
164 | if (textPart?.body?.data) {
165 | body = Buffer.from(textPart.body.data, "base64").toString("utf8");
166 | }
167 | }
168 | }
169 |
170 | return {
171 | ...metadata,
172 | body,
173 | };
174 | } catch (error) {
175 | console.error("Failed to get Gmail message:", error);
176 | throw error;
177 | }
178 | }
179 |
180 | async searchMessages(query: string, maxResults: number = 10): Promise<EmailMetadata[]> {
181 | await this.ensureInitialized();
182 | try {
183 | const response = await this.gmail.users.messages.list({
184 | userId: "me",
185 | q: query,
186 | maxResults,
187 | });
188 |
189 | const messages = response.data.messages || [];
190 | const messageDetails = await Promise.all(
191 | messages.map((msg) => this.getMessageMetadata(msg.id!)),
192 | );
193 |
194 | return await Promise.all(messageDetails.map((msg) => this.extractEmailMetadata(msg)));
195 | } catch (error) {
196 | console.error("Failed to search Gmail messages:", error);
197 | throw error;
198 | }
199 | }
200 |
201 | async getLabels(): Promise<gmail_v1.Schema$Label[]> {
202 | await this.ensureInitialized();
203 | try {
204 | const response = await this.gmail.users.labels.list({
205 | userId: "me",
206 | });
207 |
208 | return response.data.labels || [];
209 | } catch (error) {
210 | console.error("Failed to get Gmail labels:", error);
211 | throw error;
212 | }
213 | }
214 |
215 | private createEmailRaw(options: SendEmailOptions): string {
216 | const boundary = "boundary" + Date.now().toString();
217 | const toList = Array.isArray(options.to) ? options.to : [options.to];
218 | const ccList = options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : [];
219 | const bccList = options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : [];
220 |
221 | let email = [
222 | `Content-Type: multipart/mixed; boundary="${boundary}"`,
223 | "MIME-Version: 1.0",
224 | `To: ${toList.join(", ")}`,
225 | `Subject: ${options.subject}`,
226 | ];
227 |
228 | if (ccList.length > 0) email.push(`Cc: ${ccList.join(", ")}`);
229 | if (bccList.length > 0) email.push(`Bcc: ${bccList.join(", ")}`);
230 | if (options.replyTo) email.push(`Reply-To: ${options.replyTo}`);
231 |
232 | email.push("", `--${boundary}`);
233 |
234 | // Add the email body
235 | email.push(
236 | `Content-Type: ${options.isHtml ? "text/html" : "text/plain"}; charset="UTF-8"`,
237 | "MIME-Version: 1.0",
238 | "Content-Transfer-Encoding: 7bit",
239 | "",
240 | options.body,
241 | "",
242 | );
243 |
244 | // Add attachments if any
245 | if (options.attachments?.length) {
246 | for (const attachment of options.attachments) {
247 | const content = Buffer.isBuffer(attachment.content)
248 | ? attachment.content.toString("base64")
249 | : Buffer.from(attachment.content).toString("base64");
250 |
251 | email.push(
252 | `--${boundary}`,
253 | "Content-Type: " + (attachment.contentType || "application/octet-stream"),
254 | "MIME-Version: 1.0",
255 | "Content-Transfer-Encoding: base64",
256 | `Content-Disposition: attachment; filename="${attachment.filename}"`,
257 | "",
258 | content.replace(/(.{76})/g, "$1\n"),
259 | "",
260 | );
261 | }
262 | }
263 |
264 | email.push(`--${boundary}--`);
265 |
266 | return Buffer.from(email.join("\r\n")).toString("base64url");
267 | }
268 |
269 | async sendEmail(options: SendEmailOptions): Promise<string> {
270 | await this.ensureInitialized();
271 | try {
272 | // Validate email addresses
273 | validateEmailList(options.to);
274 | validateEmailList(options.cc);
275 | validateEmailList(options.bcc);
276 |
277 | const raw = this.createEmailRaw(options);
278 | const response = await this.gmail.users.messages.send({
279 | userId: "me",
280 | requestBody: { raw },
281 | });
282 |
283 | return response.data.id!;
284 | } catch (error: any) {
285 | console.error("Failed to send email:", error);
286 | throw error;
287 | }
288 | }
289 |
290 | async createDraft(options: DraftEmailOptions): Promise<string> {
291 | await this.ensureInitialized();
292 | try {
293 | // Validate email addresses
294 | validateEmailList(options.to);
295 | validateEmailList(options.cc);
296 | validateEmailList(options.bcc);
297 |
298 | const raw = this.createEmailRaw(options);
299 | const response = await this.gmail.users.drafts.create({
300 | userId: "me",
301 | requestBody: {
302 | message: { raw },
303 | },
304 | });
305 |
306 | return response.data.id!;
307 | } catch (error: any) {
308 | console.error("Failed to create draft:", error);
309 | throw error;
310 | }
311 | }
312 |
313 | async updateDraft(options: DraftEmailOptions): Promise<string> {
314 | if (!options.id) {
315 | throw new Error("Draft ID is required for updating");
316 | }
317 |
318 | await this.ensureInitialized();
319 | try {
320 | // Validate email addresses
321 | validateEmailList(options.to);
322 | validateEmailList(options.cc);
323 | validateEmailList(options.bcc);
324 |
325 | const raw = this.createEmailRaw(options);
326 | const response = await this.gmail.users.drafts.update({
327 | userId: "me",
328 | id: options.id,
329 | requestBody: {
330 | message: { raw },
331 | },
332 | });
333 |
334 | return response.data.id!;
335 | } catch (error: any) {
336 | console.error("Failed to update draft:", error);
337 | throw error;
338 | }
339 | }
340 |
341 | async listDrafts(maxResults: number = 10): Promise<EmailMetadata[]> {
342 | await this.ensureInitialized();
343 | try {
344 | const response = await this.gmail.users.drafts.list({
345 | userId: "me",
346 | maxResults,
347 | });
348 |
349 | const drafts = response.data.drafts || [];
350 | const messageDetails = await Promise.all(
351 | drafts.map((draft) => this.getMessageMetadata(draft.message!.id!)),
352 | );
353 |
354 | return await Promise.all(messageDetails.map((msg) => this.extractEmailMetadata(msg)));
355 | } catch (error) {
356 | console.error("Failed to list drafts:", error);
357 | throw error;
358 | }
359 | }
360 |
361 | async deleteDraft(draftId: string): Promise<void> {
362 | await this.ensureInitialized();
363 | try {
364 | await this.gmail.users.drafts.delete({
365 | userId: "me",
366 | id: draftId,
367 | });
368 | } catch (error) {
369 | console.error("Failed to delete draft:", error);
370 | throw error;
371 | }
372 | }
373 |
374 | async getDraft(draftId: string): Promise<EmailMetadata & { body: string }> {
375 | await this.ensureInitialized();
376 | try {
377 | const response = await this.gmail.users.drafts.get({
378 | userId: "me",
379 | id: draftId,
380 | format: "full",
381 | });
382 |
383 | if (!response.data.message) {
384 | throw new Error("Draft message not found");
385 | }
386 |
387 | const metadata = await this.extractEmailMetadata(response.data.message);
388 | let body = "";
389 |
390 | // Extract message body
391 | const message = response.data.message;
392 | if (message.payload) {
393 | if (message.payload.body?.data) {
394 | body = Buffer.from(message.payload.body.data, "base64").toString("utf8");
395 | } else if (message.payload.parts) {
396 | const textPart = message.payload.parts.find(
397 | (part) => part.mimeType === "text/plain" || part.mimeType === "text/html",
398 | );
399 | if (textPart?.body?.data) {
400 | body = Buffer.from(textPart.body.data, "base64").toString("utf8");
401 | }
402 | }
403 | }
404 |
405 | return {
406 | ...metadata,
407 | body,
408 | };
409 | } catch (error) {
410 | console.error("Failed to get draft:", error);
411 | throw error;
412 | }
413 | }
414 |
415 | async modifyMessage(
416 | messageId: string,
417 | options: {
418 | addLabelIds?: string[];
419 | removeLabelIds?: string[];
420 | },
421 | ): Promise<EmailMetadata> {
422 | await this.ensureInitialized();
423 | try {
424 | const response = await this.gmail.users.messages.modify({
425 | userId: "me",
426 | id: messageId,
427 | requestBody: options,
428 | });
429 |
430 | return this.extractEmailMetadata(response.data);
431 | } catch (error) {
432 | console.error("Failed to modify message:", error);
433 | throw error;
434 | }
435 | }
436 |
437 | async trashMessage(messageId: string): Promise<void> {
438 | await this.ensureInitialized();
439 | try {
440 | await this.gmail.users.messages.trash({
441 | userId: "me",
442 | id: messageId,
443 | });
444 | } catch (error) {
445 | console.error("Failed to trash message:", error);
446 | throw error;
447 | }
448 | }
449 |
450 | async untrashMessage(messageId: string): Promise<void> {
451 | await this.ensureInitialized();
452 | try {
453 | await this.gmail.users.messages.untrash({
454 | userId: "me",
455 | id: messageId,
456 | });
457 | } catch (error) {
458 | console.error("Failed to untrash message:", error);
459 | throw error;
460 | }
461 | }
462 |
463 | async deleteMessage(messageId: string): Promise<void> {
464 | await this.ensureInitialized();
465 | try {
466 | await this.gmail.users.messages.delete({
467 | userId: "me",
468 | id: messageId,
469 | });
470 | } catch (error) {
471 | console.error("Failed to delete message:", error);
472 | throw error;
473 | }
474 | }
475 |
476 | async createLabel(
477 | name: string,
478 | options: {
479 | textColor?: string;
480 | backgroundColor?: string;
481 | messageListVisibility?: "hide" | "show";
482 | labelListVisibility?: "labelHide" | "labelShow" | "labelShowIfUnread";
483 | } = {},
484 | ): Promise<gmail_v1.Schema$Label> {
485 | await this.ensureInitialized();
486 | try {
487 | const response = await this.gmail.users.labels.create({
488 | userId: "me",
489 | requestBody: {
490 | name,
491 | messageListVisibility: options.messageListVisibility,
492 | labelListVisibility: options.labelListVisibility,
493 | color:
494 | options.textColor || options.backgroundColor
495 | ? {
496 | textColor: options.textColor,
497 | backgroundColor: options.backgroundColor,
498 | }
499 | : undefined,
500 | },
501 | });
502 |
503 | // Update label cache
504 | this.labelCache.set(response.data.id!, response.data);
505 | return response.data;
506 | } catch (error) {
507 | console.error("Failed to create label:", error);
508 | throw error;
509 | }
510 | }
511 |
512 | async deleteLabel(labelId: string): Promise<void> {
513 | await this.ensureInitialized();
514 | try {
515 | await this.gmail.users.labels.delete({
516 | userId: "me",
517 | id: labelId,
518 | });
519 | // Remove from cache
520 | this.labelCache.delete(labelId);
521 | } catch (error) {
522 | console.error("Failed to delete label:", error);
523 | throw error;
524 | }
525 | }
526 |
527 | async replyEmail(messageId: string, body: string, isHtml: boolean = false): Promise<string> {
528 | await this.ensureInitialized();
529 | try {
530 | // Get the original message to extract threading information
531 | const originalMessage: gmail_v1.Schema$Message = (
532 | await this.gmail.users.messages.get({
533 | userId: "me",
534 | id: messageId,
535 | format: "metadata",
536 | metadataHeaders: ["Subject", "Message-ID", "References", "From", "To"],
537 | })
538 | ).data;
539 |
540 | const headers = originalMessage.payload?.headers || [];
541 | const subjectHeader = headers.find(
542 | (h: gmail_v1.Schema$MessagePartHeader) => h.name === "Subject",
543 | );
544 | const messageIdHeader = headers.find(
545 | (h: gmail_v1.Schema$MessagePartHeader) => h.name === "Message-ID",
546 | );
547 | const referencesHeader = headers.find(
548 | (h: gmail_v1.Schema$MessagePartHeader) => h.name === "References",
549 | );
550 | const fromHeader = headers.find((h: gmail_v1.Schema$MessagePartHeader) => h.name === "From");
551 | const toHeader = headers.find((h: gmail_v1.Schema$MessagePartHeader) => h.name === "To");
552 |
553 | const subject = subjectHeader?.value || "";
554 | const originalMessageId = messageIdHeader?.value || "";
555 | const references = referencesHeader?.value || "";
556 | const from = fromHeader?.value || "";
557 | const to = toHeader?.value || "";
558 |
559 | // Build References header for proper threading
560 | const newReferences = references ? `${references} ${originalMessageId}` : originalMessageId;
561 |
562 | // Create email with proper threading headers
563 | const email = [
564 | `Content-Type: ${isHtml ? "text/html" : "text/plain"}; charset="UTF-8"`,
565 | "MIME-Version: 1.0",
566 | `Subject: ${subject.startsWith("Re:") ? subject : `Re: ${subject}`}`,
567 | `To: ${from}`,
568 | `References: ${newReferences}`,
569 | `In-Reply-To: ${originalMessageId}`,
570 | "",
571 | body,
572 | ].join("\r\n");
573 |
574 | const raw = Buffer.from(email).toString("base64url");
575 |
576 | const response = await this.gmail.users.messages.send({
577 | userId: "me",
578 | requestBody: {
579 | raw,
580 | threadId: originalMessage.threadId,
581 | },
582 | });
583 |
584 | return response.data.id!;
585 | } catch (error) {
586 | console.error("Failed to reply to email:", error);
587 | throw error;
588 | }
589 | }
590 | }
591 |
```
--------------------------------------------------------------------------------
/src/services/__tests__/gmail-service.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { jest } from "@jest/globals";
2 | import { google } from "googleapis";
3 | import { GmailService } from "../gmail-service";
4 | import { GoogleAuthService } from "../google-auth-service";
5 | import { gmail_v1 } from "googleapis";
6 |
7 | // Create a test class that exposes waitForInit
8 | class TestGmailService extends GmailService {
9 | public async testInit(): Promise<void> {
10 | await this.waitForInit();
11 | }
12 | }
13 |
14 | jest.mock("googleapis");
15 | jest.mock("../google-auth-service");
16 |
17 | describe("GmailService", () => {
18 | let service: TestGmailService;
19 | let mockGmailAPI: any;
20 | let mockAuth: jest.Mocked<GoogleAuthService>;
21 | let mockMessage: any;
22 | let mockMessageMetadata: any;
23 |
24 | beforeEach(async () => {
25 | jest.clearAllMocks();
26 |
27 | mockMessage = {
28 | id: "1",
29 | threadId: "thread1",
30 | snippet: "Test email",
31 | from: {
32 | name: "Test Sender",
33 | email: "[email protected]",
34 | },
35 | to: [
36 | {
37 | name: "Test Recipient",
38 | email: "[email protected]",
39 | },
40 | ],
41 | subject: "Test Subject",
42 | date: new Date("2025-01-14T11:47:39.417Z"),
43 | isUnread: false,
44 | isImportant: false,
45 | hasAttachments: false,
46 | labels: [
47 | {
48 | id: "INBOX",
49 | name: "INBOX",
50 | },
51 | ],
52 | };
53 |
54 | mockMessageMetadata = { ...mockMessage }; // Create metadata version without body
55 | mockMessage.body = "Test body"; // Add body only to full message version
56 |
57 | // Mock Gmail API methods
58 | mockGmailAPI = {
59 | users: {
60 | messages: {
61 | list: jest.fn(),
62 | get: jest.fn(),
63 | modify: jest.fn(),
64 | trash: jest.fn(),
65 | untrash: jest.fn(),
66 | delete: jest.fn(),
67 | send: jest.fn(),
68 | },
69 | labels: {
70 | list: jest.fn(),
71 | create: jest.fn(),
72 | delete: jest.fn(),
73 | },
74 | drafts: {
75 | create: jest.fn(),
76 | update: jest.fn(),
77 | list: jest.fn(),
78 | delete: jest.fn(),
79 | },
80 | },
81 | };
82 |
83 | // Set up default mock responses
84 | mockGmailAPI.users.messages.send.mockResolvedValue({
85 | data: { id: "msg1" },
86 | });
87 |
88 | mockGmailAPI.users.messages.list.mockResolvedValue({
89 | data: {
90 | messages: [{ id: "1" }],
91 | },
92 | });
93 |
94 | mockGmailAPI.users.messages.get.mockImplementation(
95 | (params: { format?: string }) => {
96 | if (params.format === "metadata") {
97 | return Promise.resolve({
98 | data: {
99 | id: "1",
100 | threadId: "thread1",
101 | labelIds: ["INBOX"],
102 | snippet: "Test email",
103 | payload: {
104 | headers: [
105 | { name: "Subject", value: "Test Subject" },
106 | { name: "From", value: "Test Sender <[email protected]>" },
107 | {
108 | name: "To",
109 | value: "Test Recipient <[email protected]>",
110 | },
111 | { name: "Date", value: "2025-01-14T11:47:39.417Z" },
112 | ],
113 | },
114 | },
115 | });
116 | } else {
117 | return Promise.resolve({
118 | data: {
119 | id: "1",
120 | threadId: "thread1",
121 | labelIds: ["INBOX"],
122 | snippet: "Test email",
123 | payload: {
124 | headers: [
125 | { name: "Subject", value: "Test Subject" },
126 | { name: "From", value: "Test Sender <[email protected]>" },
127 | {
128 | name: "To",
129 | value: "Test Recipient <[email protected]>",
130 | },
131 | { name: "Date", value: "2025-01-14T11:47:39.417Z" },
132 | ],
133 | parts: [
134 | {
135 | mimeType: "text/plain",
136 | body: { data: Buffer.from("Test body").toString("base64") },
137 | },
138 | ],
139 | },
140 | },
141 | });
142 | }
143 | }
144 | );
145 |
146 | mockGmailAPI.users.messages.modify.mockResolvedValue({
147 | data: {
148 | id: "1",
149 | threadId: "thread1",
150 | labelIds: ["Label_1"],
151 | snippet: "Test email",
152 | payload: {
153 | headers: [
154 | { name: "Subject", value: "Test Subject" },
155 | { name: "From", value: "Test Sender <[email protected]>" },
156 | { name: "To", value: "Test Recipient <[email protected]>" },
157 | { name: "Date", value: "2025-01-14T11:47:39.417Z" },
158 | ],
159 | },
160 | },
161 | });
162 |
163 | mockGmailAPI.users.drafts.create.mockResolvedValue({
164 | data: { id: "draft1" },
165 | });
166 |
167 | mockGmailAPI.users.drafts.update.mockResolvedValue({
168 | data: { id: "draft1" },
169 | });
170 |
171 | mockGmailAPI.users.drafts.list.mockResolvedValue({
172 | data: {
173 | drafts: [
174 | {
175 | id: "draft1",
176 | message: {
177 | id: "1",
178 | threadId: "thread1",
179 | labelIds: ["DRAFT"],
180 | snippet: "Test email",
181 | payload: {
182 | headers: [
183 | { name: "Subject", value: "Test Subject" },
184 | { name: "From", value: "Test Sender <[email protected]>" },
185 | {
186 | name: "To",
187 | value: "Test Recipient <[email protected]>",
188 | },
189 | { name: "Date", value: "2025-01-14T11:47:39.417Z" },
190 | ],
191 | },
192 | },
193 | },
194 | ],
195 | },
196 | });
197 |
198 | mockGmailAPI.users.labels.list.mockResolvedValue({
199 | data: {
200 | labels: [{ id: "INBOX", name: "INBOX" }],
201 | },
202 | });
203 |
204 | mockGmailAPI.users.labels.create.mockResolvedValue({
205 | data: { id: "new-label", name: "New Label" },
206 | });
207 |
208 | (google.gmail as jest.Mock).mockReturnValue(mockGmailAPI);
209 |
210 | // Mock auth service
211 | mockAuth = {
212 | initialize: jest.fn().mockImplementation(() => Promise.resolve()),
213 | authenticate: jest.fn().mockImplementation(() => Promise.resolve()),
214 | getAuth: jest.fn(),
215 | saveToken: jest.fn().mockImplementation(() => Promise.resolve()),
216 | oAuth2Client: undefined,
217 | authUrl: "",
218 | } as unknown as jest.Mocked<GoogleAuthService>;
219 |
220 | (GoogleAuthService.getInstance as jest.Mock).mockReturnValue(mockAuth);
221 |
222 | // Create service instance and wait for initialization
223 | service = new TestGmailService();
224 | await service.testInit();
225 | });
226 |
227 | describe("Email Validation", () => {
228 | it("should validate correct email addresses", async () => {
229 | await expect(
230 | service.sendEmail({
231 | to: "[email protected]",
232 | subject: "Test",
233 | body: "Test",
234 | })
235 | ).resolves.toBeDefined();
236 | });
237 |
238 | it("should reject invalid email addresses", async () => {
239 | await expect(
240 | service.sendEmail({
241 | to: "invalid-email",
242 | subject: "Test",
243 | body: "Test",
244 | })
245 | ).rejects.toThrow("Invalid email address");
246 | });
247 |
248 | it("should validate multiple email addresses", async () => {
249 | await expect(
250 | service.sendEmail({
251 | to: ["[email protected]", "[email protected]"],
252 | subject: "Test",
253 | body: "Test",
254 | })
255 | ).resolves.toBeDefined();
256 | });
257 |
258 | it("should handle malformed email addresses", async () => {
259 | const malformedEmail = "[email protected]"; // Without angle brackets
260 | mockGmailAPI.users.messages.send.mockResolvedValueOnce({
261 | data: { id: "123" },
262 | });
263 | await expect(
264 | service.sendEmail({
265 | to: malformedEmail,
266 | subject: "Test",
267 | body: "Test body",
268 | })
269 | ).resolves.toBeDefined();
270 | expect(mockGmailAPI.users.messages.send).toHaveBeenCalled();
271 | });
272 |
273 | it("should handle email addresses with display names", async () => {
274 | const emailWithName = "[email protected]"; // Without display name
275 | mockGmailAPI.users.messages.send.mockResolvedValueOnce({
276 | data: { id: "123" },
277 | });
278 | await expect(
279 | service.sendEmail({
280 | to: emailWithName,
281 | subject: "Test",
282 | body: "Test body",
283 | })
284 | ).resolves.toBeDefined();
285 | expect(mockGmailAPI.users.messages.send).toHaveBeenCalled();
286 | });
287 | });
288 |
289 | describe("Message Operations", () => {
290 | it("should list messages", async () => {
291 | const result = await service.listMessages();
292 | expect(result).toEqual([mockMessageMetadata]);
293 | });
294 |
295 | it("should handle empty message list", async () => {
296 | mockGmailAPI.users.messages.list.mockResolvedValueOnce({
297 | data: { messages: [] },
298 | });
299 | const result = await service.listMessages();
300 | expect(result).toEqual([]);
301 | });
302 |
303 | it("should get message with simple body", async () => {
304 | mockGmailAPI.users.messages.get.mockResolvedValueOnce({
305 | data: {
306 | id: "1",
307 | threadId: "thread1",
308 | labelIds: ["INBOX"],
309 | snippet: "Test email",
310 | payload: {
311 | headers: [
312 | { name: "Subject", value: "Test Subject" },
313 | { name: "From", value: "Test Sender <[email protected]>" },
314 | { name: "To", value: "Test Recipient <[email protected]>" },
315 | { name: "Date", value: "2025-01-14T11:47:39.417Z" },
316 | ],
317 | body: {
318 | data: Buffer.from("Test body").toString("base64"),
319 | },
320 | },
321 | },
322 | });
323 |
324 | const result = await service.getMessage("1");
325 | expect(result).toEqual(mockMessage);
326 | });
327 |
328 | it("should get message with multipart body", async () => {
329 | const result = await service.getMessage("1");
330 | expect(result).toEqual(mockMessage);
331 | });
332 |
333 | it("should handle message without body", async () => {
334 | mockGmailAPI.users.messages.get.mockResolvedValueOnce({
335 | data: {
336 | id: "1",
337 | threadId: "thread1",
338 | labelIds: ["INBOX"],
339 | snippet: "Test email",
340 | payload: {
341 | headers: [
342 | { name: "Subject", value: "Test Subject" },
343 | { name: "From", value: "Test Sender <[email protected]>" },
344 | { name: "To", value: "Test Recipient <[email protected]>" },
345 | { name: "Date", value: "2025-01-14T11:47:39.417Z" },
346 | ],
347 | },
348 | },
349 | });
350 |
351 | const result = await service.getMessage("1");
352 | expect(result.body).toBe("");
353 | });
354 |
355 | it("should search messages", async () => {
356 | const result = await service.searchMessages("test query");
357 | expect(result).toEqual([mockMessageMetadata]);
358 | });
359 |
360 | it("should modify message labels", async () => {
361 | await service.modifyMessage("1", {
362 | addLabelIds: ["Label_1"],
363 | removeLabelIds: ["Label_2"],
364 | });
365 | expect(mockGmailAPI.users.messages.modify).toHaveBeenCalledWith({
366 | userId: "me",
367 | id: "1",
368 | requestBody: {
369 | addLabelIds: ["Label_1"],
370 | removeLabelIds: ["Label_2"],
371 | },
372 | });
373 | });
374 |
375 | it("should trash message", async () => {
376 | await service.trashMessage("1");
377 | expect(mockGmailAPI.users.messages.trash).toHaveBeenCalledWith({
378 | userId: "me",
379 | id: "1",
380 | });
381 | });
382 |
383 | it("should untrash message", async () => {
384 | await service.untrashMessage("1");
385 | expect(mockGmailAPI.users.messages.untrash).toHaveBeenCalledWith({
386 | userId: "me",
387 | id: "1",
388 | });
389 | });
390 |
391 | it("should delete message", async () => {
392 | await service.deleteMessage("1");
393 | expect(mockGmailAPI.users.messages.delete).toHaveBeenCalledWith({
394 | userId: "me",
395 | id: "1",
396 | });
397 | });
398 |
399 | it("should handle errors in message metadata retrieval", async () => {
400 | mockGmailAPI.users.messages.get.mockRejectedValueOnce(
401 | new Error("Metadata Error")
402 | );
403 | await expect(service.getMessage("1")).rejects.toThrow("Metadata Error");
404 | });
405 |
406 | it("should create email with CC and BCC", async () => {
407 | await service.sendEmail({
408 | to: "[email protected]",
409 | cc: ["[email protected]", "[email protected]"],
410 | bcc: "[email protected]",
411 | subject: "Test",
412 | body: "Test body",
413 | });
414 | expect(mockGmailAPI.users.messages.send).toHaveBeenCalled();
415 | });
416 |
417 | it("should create email with attachments", async () => {
418 | await service.sendEmail({
419 | to: "[email protected]",
420 | subject: "Test with attachment",
421 | body: "Test body",
422 | attachments: [
423 | {
424 | filename: "test.txt",
425 | content: "Test content",
426 | contentType: "text/plain",
427 | },
428 | ],
429 | });
430 | expect(mockGmailAPI.users.messages.send).toHaveBeenCalled();
431 | });
432 |
433 | it("should create HTML email with reply-to", async () => {
434 | await service.sendEmail({
435 | to: "[email protected]",
436 | subject: "Test HTML",
437 | body: "<p>Test body</p>",
438 | isHtml: true,
439 | replyTo: "[email protected]",
440 | });
441 | expect(mockGmailAPI.users.messages.send).toHaveBeenCalled();
442 | });
443 | });
444 |
445 | describe("Draft Operations", () => {
446 | it("should create draft", async () => {
447 | const result = await service.createDraft({
448 | to: "[email protected]",
449 | subject: "Test Draft",
450 | body: "Draft body",
451 | });
452 | expect(result).toBe("draft1");
453 | });
454 |
455 | it("should update draft", async () => {
456 | const result = await service.updateDraft({
457 | id: "draft1",
458 | to: "[email protected]",
459 | subject: "Updated Draft",
460 | body: "Updated body",
461 | });
462 | expect(result).toBe("draft1");
463 | });
464 |
465 | it("should list drafts", async () => {
466 | const result = await service.listDrafts();
467 | expect(result).toEqual([mockMessageMetadata]);
468 | });
469 |
470 | it("should delete draft", async () => {
471 | await service.deleteDraft("draft1");
472 | expect(mockGmailAPI.users.drafts.delete).toHaveBeenCalledWith({
473 | userId: "me",
474 | id: "draft1",
475 | });
476 | });
477 |
478 | it("should handle errors in draft creation", async () => {
479 | mockGmailAPI.users.drafts.create.mockRejectedValueOnce(
480 | new Error("Draft Error")
481 | );
482 | await expect(
483 | service.createDraft({
484 | to: "[email protected]",
485 | subject: "Test",
486 | body: "Test",
487 | })
488 | ).rejects.toThrow("Draft Error");
489 | });
490 |
491 | it("should handle errors in draft update", async () => {
492 | mockGmailAPI.users.drafts.update.mockRejectedValueOnce(
493 | new Error("Update Error")
494 | );
495 | await expect(
496 | service.updateDraft({
497 | id: "draft1",
498 | to: "[email protected]",
499 | subject: "Test",
500 | body: "Test",
501 | })
502 | ).rejects.toThrow("Update Error");
503 | });
504 |
505 | it("should handle errors in draft deletion", async () => {
506 | mockGmailAPI.users.drafts.delete.mockRejectedValueOnce(
507 | new Error("Delete Error")
508 | );
509 | await expect(service.deleteDraft("draft1")).rejects.toThrow(
510 | "Delete Error"
511 | );
512 | });
513 |
514 | it("should handle empty draft list", async () => {
515 | mockGmailAPI.users.drafts.list.mockResolvedValueOnce({
516 | data: {}, // No drafts property
517 | });
518 | const result = await service.listDrafts();
519 | expect(result).toEqual([]);
520 | });
521 |
522 | it("should handle draft creation with minimal options", async () => {
523 | await service.createDraft({
524 | to: "[email protected]",
525 | subject: "Test",
526 | body: "Test",
527 | });
528 | expect(mockGmailAPI.users.drafts.create).toHaveBeenCalled();
529 | });
530 |
531 | it("should handle draft metadata", async () => {
532 | const draftId = "draft123";
533 | const emailOptions = {
534 | to: "[email protected]",
535 | subject: "Test Draft",
536 | body: "Test body",
537 | };
538 | mockGmailAPI.users.drafts.create.mockResolvedValueOnce({
539 | data: {
540 | id: draftId,
541 | message: {
542 | id: "msg123",
543 | threadId: "thread123",
544 | },
545 | },
546 | });
547 | await service.createDraft(emailOptions);
548 | expect(mockGmailAPI.users.drafts.create).toHaveBeenCalled();
549 | });
550 | });
551 |
552 | describe("Label Operations", () => {
553 | it("should list labels", async () => {
554 | const result = await service.getLabels();
555 | expect(result).toEqual([{ id: "INBOX", name: "INBOX" }]);
556 | });
557 |
558 | it("should create label", async () => {
559 | const result = await service.createLabel("New Label", {
560 | textColor: "#000000",
561 | backgroundColor: "#ffffff",
562 | messageListVisibility: "show",
563 | labelListVisibility: "labelShow",
564 | });
565 | expect(result).toEqual({ id: "new-label", name: "New Label" });
566 | });
567 |
568 | it("should delete label", async () => {
569 | await service.deleteLabel("label1");
570 | expect(mockGmailAPI.users.labels.delete).toHaveBeenCalledWith({
571 | userId: "me",
572 | id: "label1",
573 | });
574 | });
575 |
576 | it("should handle errors in label creation", async () => {
577 | mockGmailAPI.users.labels.create.mockRejectedValueOnce(
578 | new Error("Label Error")
579 | );
580 | await expect(service.createLabel("Test Label")).rejects.toThrow(
581 | "Label Error"
582 | );
583 | });
584 |
585 | it("should handle errors in label deletion", async () => {
586 | mockGmailAPI.users.labels.delete.mockRejectedValueOnce(
587 | new Error("Delete Error")
588 | );
589 | await expect(service.deleteLabel("label1")).rejects.toThrow(
590 | "Delete Error"
591 | );
592 | });
593 |
594 | it("should handle empty label list", async () => {
595 | mockGmailAPI.users.labels.list.mockResolvedValueOnce({
596 | data: {}, // No labels property
597 | });
598 | const result = await service.getLabels();
599 | expect(result).toEqual([]);
600 | });
601 |
602 | it("should handle label creation with all options", async () => {
603 | await service.createLabel("Test Label", {
604 | textColor: "#000000",
605 | backgroundColor: "#ffffff",
606 | messageListVisibility: "show",
607 | labelListVisibility: "labelShow",
608 | });
609 | expect(mockGmailAPI.users.labels.create).toHaveBeenCalledWith({
610 | userId: "me",
611 | requestBody: {
612 | name: "Test Label",
613 | color: {
614 | textColor: "#000000",
615 | backgroundColor: "#ffffff",
616 | },
617 | messageListVisibility: "show",
618 | labelListVisibility: "labelShow",
619 | },
620 | });
621 | });
622 |
623 | it("should handle label creation with minimal options", async () => {
624 | await service.createLabel("Test Label");
625 | expect(mockGmailAPI.users.labels.create).toHaveBeenCalledWith({
626 | userId: "me",
627 | requestBody: {
628 | name: "Test Label",
629 | },
630 | });
631 | });
632 | });
633 |
634 | describe("Error Handling", () => {
635 | it("should handle API errors in listMessages", async () => {
636 | mockGmailAPI.users.messages.list.mockRejectedValue(
637 | new Error("API Error")
638 | );
639 | await expect(service.listMessages()).rejects.toThrow("API Error");
640 | });
641 |
642 | it("should handle API errors in getMessage", async () => {
643 | mockGmailAPI.users.messages.get.mockRejectedValue(new Error("API Error"));
644 | await expect(service.getMessage("1")).rejects.toThrow("API Error");
645 | });
646 |
647 | it("should handle API errors in searchMessages", async () => {
648 | mockGmailAPI.users.messages.list.mockRejectedValue(
649 | new Error("API Error")
650 | );
651 | await expect(service.searchMessages("query")).rejects.toThrow(
652 | "API Error"
653 | );
654 | });
655 | });
656 |
657 | describe("Message Modification", () => {
658 | it("should handle errors in message modification", async () => {
659 | mockGmailAPI.users.messages.modify.mockRejectedValueOnce(
660 | new Error("Modify Error")
661 | );
662 | await expect(
663 | service.modifyMessage("1", { addLabelIds: ["Label_1"] })
664 | ).rejects.toThrow("Modify Error");
665 | });
666 |
667 | it("should handle errors in message trash operation", async () => {
668 | mockGmailAPI.users.messages.trash.mockRejectedValueOnce(
669 | new Error("Trash Error")
670 | );
671 | await expect(service.trashMessage("1")).rejects.toThrow("Trash Error");
672 | });
673 |
674 | it("should handle errors in message untrash operation", async () => {
675 | mockGmailAPI.users.messages.untrash.mockRejectedValueOnce(
676 | new Error("Untrash Error")
677 | );
678 | await expect(service.untrashMessage("1")).rejects.toThrow(
679 | "Untrash Error"
680 | );
681 | });
682 |
683 | it("should handle errors in message delete operation", async () => {
684 | mockGmailAPI.users.messages.delete.mockRejectedValueOnce(
685 | new Error("Delete Error")
686 | );
687 | await expect(service.deleteMessage("1")).rejects.toThrow("Delete Error");
688 | });
689 | });
690 |
691 | describe("Email Parsing", () => {
692 | it("should handle errors in email parsing", async () => {
693 | const messageId = "123";
694 | mockGmailAPI.users.messages.get.mockRejectedValueOnce(
695 | new Error("Parse Error")
696 | );
697 | await expect(service.getMessage(messageId)).rejects.toThrow(
698 | "Parse Error"
699 | );
700 | });
701 |
702 | it("should handle malformed email parsing", async () => {
703 | const messageId = "123";
704 | mockGmailAPI.users.messages.get.mockResolvedValue({
705 | data: {
706 | id: messageId,
707 | threadId: undefined,
708 | labelIds: [],
709 | snippet: "",
710 | payload: {
711 | headers: [],
712 | parts: [],
713 | },
714 | },
715 | });
716 | const result = await service.getMessage(messageId);
717 | expect(result).toEqual({
718 | id: messageId,
719 | threadId: undefined,
720 | subject: "(no subject)",
721 | from: { email: "" },
722 | to: [{ email: "" }],
723 | date: expect.any(Date),
724 | body: "",
725 | snippet: "",
726 | labels: [],
727 | isUnread: false,
728 | isImportant: false,
729 | hasAttachments: false,
730 | });
731 | });
732 | });
733 |
734 | describe("Label Handling", () => {
735 | it("should handle errors in label handling", async () => {
736 | const labelName = "Test Label";
737 | mockGmailAPI.users.labels.create.mockRejectedValueOnce(
738 | new Error("Label Error")
739 | );
740 | await expect(service.createLabel(labelName)).rejects.toThrow(
741 | "Label Error"
742 | );
743 | });
744 |
745 | it("should handle label visibility options", async () => {
746 | const labelName = "Test Label";
747 | mockGmailAPI.users.labels.create.mockResolvedValueOnce({
748 | data: {
749 | id: "Label_123",
750 | name: labelName,
751 | labelListVisibility: "labelShow",
752 | messageListVisibility: "show",
753 | },
754 | });
755 | await service.createLabel(labelName, {
756 | labelListVisibility: "labelShow",
757 | messageListVisibility: "show",
758 | });
759 | expect(mockGmailAPI.users.labels.create).toHaveBeenCalledWith({
760 | userId: "me",
761 | requestBody: {
762 | name: labelName,
763 | labelListVisibility: "labelShow",
764 | messageListVisibility: "show",
765 | },
766 | });
767 | });
768 | });
769 |
770 | describe("Draft Operations", () => {
771 | it("should handle errors in draft operations with metadata", async () => {
772 | const draftId = "draft123";
773 | const emailOptions = {
774 | to: "[email protected]",
775 | subject: "Test Draft",
776 | body: "Test body",
777 | };
778 | mockGmailAPI.users.drafts.create.mockRejectedValueOnce(
779 | new Error("Draft Error")
780 | );
781 | await expect(service.createDraft(emailOptions)).rejects.toThrow(
782 | "Draft Error"
783 | );
784 | });
785 |
786 | it("should handle draft metadata", async () => {
787 | const draftId = "draft123";
788 | const emailOptions = {
789 | to: "[email protected]",
790 | subject: "Test Draft",
791 | body: "Test body",
792 | };
793 | mockGmailAPI.users.drafts.create.mockResolvedValueOnce({
794 | data: {
795 | id: draftId,
796 | message: {
797 | id: "msg123",
798 | threadId: "thread123",
799 | },
800 | },
801 | });
802 | await service.createDraft(emailOptions);
803 | expect(mockGmailAPI.users.drafts.create).toHaveBeenCalled();
804 | });
805 | });
806 | });
807 |
```