#
tokens: 31880/50000 7/76 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
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(/&#39;/g, "'").replace(/&quot;/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 | 
```
Page 2/2FirstPrevNextLast