diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..5177b47 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,25 @@ +# AGENT.md + +## Build/Lint/Test Commands +- `npm run build` - Compile TypeScript to dist/ +- `npm run lint` - Run ESLint and TypeScript checks +- `npm test` - Run all Jest tests +- `npm run test:watch` - Run tests in watch mode +- `jest src/tools/sendEmail/__tests__/sendEmail.test.ts` - Run single test file +- `npm run dev` - Run MCP server with inspector for testing + +## Architecture +- MCP (Model Context Protocol) server integrating with Mailtrap email service +- Main entry point: `src/index.ts` registers tools and handles server lifecycle +- Tools located in `src/tools/` with pattern: each tool has subdirectory with index.ts, schema.ts, implementation.ts, and __tests__/ +- Client configuration: `src/client.ts` handles Mailtrap API initialization +- Configuration: `src/config/index.ts` for server constants + +## Code Style +- Uses Airbnb TypeScript ESLint config with Prettier +- TypeScript strict mode enabled, targeting ES2022 with CommonJS +- Zod schemas for all tool input validation +- Error handling: catch and re-throw with descriptive messages +- Import style: ES modules syntax, grouped with external first +- Naming: camelCase for functions/variables, PascalCase for types/interfaces +- No console.log allowed in production code (use proper error handling) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..750179a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Build and Development +- `npm run build` - Compile TypeScript to JavaScript in the `dist/` directory +- `npm run dev` - Run the MCP server with the MCP Inspector for testing +- `npm run prepublish` - Build the project and make the executable script executable + +### Code Quality +- `npm run lint` - Run both ESLint and TypeScript checks +- `npm run lint:eslint` - Run ESLint for code style checking +- `npm run lint:tsc` - Run TypeScript compiler for type checking + +### Testing +- `npm test` - Run all Jest tests +- `npm run test:watch` - Run tests in watch mode during development +- `npm run test:coverage` - Run tests with coverage reporting + +## Project Architecture + +This is an MCP (Model Context Protocol) server that integrates with Mailtrap's email service. The architecture follows a modular pattern: + +### Core Components +- **src/index.ts**: Main MCP server entry point that registers all tools and handles the server lifecycle +- **src/client.ts**: Mailtrap client configuration and initialization +- **src/config/index.ts**: Server configuration constants + +### Tool Architecture +All tools follow a consistent pattern in the `src/tools/` directory: +- Each tool has its own subdirectory (e.g., `sendEmail/`, `templates/`) +- Tools export both their implementation function and Zod schema for validation +- Template operations are grouped under `templates/` with individual files for each CRUD operation + +### Tool Structure Pattern +``` +src/tools/{toolName}/ +├── index.ts # Main tool export +├── schema.ts # Zod validation schema +├── {toolName}.ts # Tool implementation +└── __tests__/ # Jest test files +``` + +### Environment Variables Required +- `MAILTRAP_API_TOKEN`: Required API token from Mailtrap +- `DEFAULT_FROM_EMAIL`: Default sender email address +- `MAILTRAP_ACCOUNT_ID`: Optional account ID for multi-account setups + +### Testing Setup +- Uses Jest with TypeScript support via ts-jest +- Test files are located in `__tests__/` directories within each tool +- Environment variables are set up via `jest/setEnvVars.js` +- Coverage reports exclude test files and type definitions + +### Build Configuration +- TypeScript compilation targets ES2022 with CommonJS modules +- Separate build config (`tsconfig.build.json`) excludes test files from distribution +- Output goes to `dist/` directory with proper executable permissions + +### Available MCP Tools +1. **send-email**: Send transactional emails through Mailtrap +2. **create-template**: Create new email templates +3. **list-templates**: List all email templates +4. **update-template**: Update existing email templates +5. **delete-template**: Delete email templates + +Each tool uses Zod schemas for input validation and follows the MCP protocol for response formatting. \ No newline at end of file diff --git a/README.md b/README.md index de084ba..d696e8a 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,23 @@ # MCP Mailtrap Server -An MCP server that provides a tool for sending transactional emails via Mailtrap +An MCP server that provides tools for sending transactional emails and managing email templates via Mailtrap + +## Prerequisites + +Before using this MCP server, you need to: + +1. [Create a Mailtrap account](https://mailtrap.io/signup) +2. [Verify your domain](https://mailtrap.io/sending/domains) +3. Get your API token from [Mailtrap API settings](https://mailtrap.io/api-tokens) +4. Get your Account ID from [Mailtrap account management](https://mailtrap.io/account-management) ## Quick Install -[![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=mailtrap&config=eyJjb21tYW5kIjoibnB4IC15IG1jcC1tYWlsdHJhcCIsImVudiI6eyJNQUlMVFJBUF9BUElfVE9LRU4iOiJ5b3VyX21haWx0cmFwX2FwaV90b2tlbiIsIkRFRkFVTFRfRlJPTV9FTUFJTCI6InlvdXJfc2VuZGVyQGV4YW1wbGUuY29tIn19) +[![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=mailtrap&config=eyJjb21tYW5kIjoibnB4IC15IG1jcC1tYWlsdHJhcCIsImVudiI6eyJNQUlMVFJBUF9BUElfVE9LRU4iOiJ5b3VyX21haWx0cmFwX2FwaV90b2tlbiIsIkRFRkFVTFRfRlJPTV9FTUFJTCI6InlvdXJfc2VuZGVyQGV4YW1wbGUuY29tIiwiTUFJTFRSQVBfQUNDT1VOVF9JRCI6InlvdXJfYWNjb3VudF9pZCJ9fQ%3D%3D) -[![Install with Node in VS Code](https://img.shields.io/badge/VS_Code-Node-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=mailtrap-email&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mcp-mailtrap%22%5D%2C%22env%22%3A%7B%22MAILTRAP_API_TOKEN%22%3A%22%24%7Binput%3AmailtrapApiToken%7D%22%2C%22DEFAULT_FROM_EMAIL%22%3A%22%24%7Binput%3AsenderEmail%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22mailtrapApiToken%22%2C%22description%22%3A%22Mailtrap+API+Token%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22senderEmail%22%2C%22description%22%3A%22Sender+Email+Address%22%7D%5D) +[![Install with Node in VS Code](https://img.shields.io/badge/VS_Code-Node-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=mailtrap&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mcp-mailtrap%22%5D%2C%22env%22%3A%7B%22MAILTRAP_API_TOKEN%22%3A%22%24%7Binput%3AmailtrapApiToken%7D%22%2C%22DEFAULT_FROM_EMAIL%22%3A%22%24%7Binput%3AsenderEmail%7D%22%2C%22MAILTRAP_ACCOUNT_ID%22%3A%22%24%7Binput%3AmailtrapAccountId%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22mailtrapApiToken%22%2C%22description%22%3A%22Mailtrap+API+Token%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22senderEmail%22%2C%22description%22%3A%22Sender+Email+Address%22%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22mailtrapAccountId%22%2C%22description%22%3A%22Mailtrap+Account+ID%22%7D%5D) -[![Install with Node in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Node-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=mailtrap-email&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mcp-mailtrap%22%5D%2C%22env%22%3A%7B%22MAILTRAP_API_TOKEN%22%3A%22%24%7Binput%3AmailtrapApiToken%7D%22%2C%22DEFAULT_FROM_EMAIL%22%3A%22%24%7Binput%3AsenderEmail%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22mailtrapApiToken%22%2C%22description%22%3A%22Mailtrap+API+Token%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22senderEmail%22%2C%22description%22%3A%22Sender+Email+Address%22%7D%5D&quality=insiders) ## Setup @@ -29,7 +37,8 @@ Add the following configuration: "args": ["-y", "mcp-mailtrap"], "env": { "MAILTRAP_API_TOKEN": "your_mailtrap_api_token", - "DEFAULT_FROM_EMAIL": "your_sender@example.com" + "DEFAULT_FROM_EMAIL": "your_sender@example.com", + "MAILTRAP_ACCOUNT_ID": "your_account_id" } } } @@ -50,7 +59,8 @@ If you are using `asdf` for managing Node.js you must use absolute path to execu "ASDF_DATA_DIR": "/Users//.asdf", "ASDF_NODEJS_VERSION": "20.6.1", "MAILTRAP_API_TOKEN": "your_mailtrap_api_token", - "DEFAULT_FROM_EMAIL": "your_sender@example.com" + "DEFAULT_FROM_EMAIL": "your_sender@example.com", + "MAILTRAP_ACCOUNT_ID": "your_account_id" } } } @@ -89,7 +99,8 @@ Then, in the settings file, add the following configuration: "args": ["-y", "mcp-mailtrap"], "env": { "MAILTRAP_API_TOKEN": "your_mailtrap_api_token", - "DEFAULT_FROM_EMAIL": "your_sender@example.com" + "DEFAULT_FROM_EMAIL": "your_sender@example.com", + "MAILTRAP_ACCOUNT_ID": "your_account_id" } } } @@ -102,11 +113,20 @@ Then, in the settings file, add the following configuration: ## Usage -Once configured, you can ask agent to send emails, for example: +Once configured, you can ask agent to send emails and manage templates, for example: + +**Email Operations:** - "Send an email to john.doe@example.com with the subject 'Meeting Tomorrow' and a friendly reminder about our upcoming meeting." - "Email sarah@example.com about the project update, and CC the team at team@example.com" +**Template Operations:** + +- "List all email templates in my Mailtrap account" +- "Create a new email template called 'Welcome Email' with subject 'Welcome to our platform!'" +- "Update the template with ID 12345 to change the subject to 'Updated Welcome Message'" +- "Delete the template with ID 67890" + ## Available Tools ### send-email @@ -118,11 +138,55 @@ Sends a transactional email through Mailtrap. - `to` (required): Email address of the recipient - `subject` (required): Email subject line - `from` (optional): Email address of the sender, if not provided "DEFAULT_FROM_EMAIL" will be used -- `text` (optional): Email body text, require if "html" is empty +- `text` (optional): Email body text, required if "html" is empty - `html` (optional): HTML version of the email body, required if "text" is empty - `cc` (optional): Array of CC recipient email addresses - `bcc` (optional): Array of BCC recipient email addresses -- `category` (optional): Email category for tracking +- `category` (required): Email category for tracking and analytics + +### create-template + +Creates a new email template in your Mailtrap account. + +**Parameters:** + +- `name` (required): Name of the template +- `subject` (required): Email subject line +- `html` (or `text` is required): HTML content of the template +- `text` (or `html` is required): Plain text version of the template +- `category` (optional): Template category (defaults to "General") + +### list-templates + +Lists all email templates in your Mailtrap account. + +**Parameters:** + +- No parameters required + +### update-template + +Updates an existing email template. + +**Parameters:** + +- `template_id` (required): ID of the template to update +- `name` (optional): New name for the template +- `subject` (optional): New email subject line +- `html` (optional): New HTML content of the template +- `text` (optional): New plain text version of the template +- `category` (optional): New category for the template + +> [!NOTE] +> At least one updatable field (name, subject, html, text, or category) must be provided when calling update-template to perform an update. + +### delete-template + +Deletes an existing email template. + +**Parameters:** + +- `template_id` (required): ID of the template to delete ## Development @@ -154,7 +218,8 @@ Add the following configuration: "args": ["/path/to/mailtrap-mcp/dist/index.js"], "env": { "MAILTRAP_API_TOKEN": "your_mailtrap_api_token", - "DEFAULT_FROM_EMAIL": "your_sender@example.com" + "DEFAULT_FROM_EMAIL": "your_sender@example.com", + "MAILTRAP_ACCOUNT_ID": "your_account_id" } } } @@ -177,7 +242,8 @@ If you are using `asdf` for managing Node.js you should use absolute path to exe "ASDF_DATA_DIR": "/Users//.asdf", "ASDF_NODEJS_VERSION": "20.6.1", "MAILTRAP_API_TOKEN": "your_mailtrap_api_token", - "DEFAULT_FROM_EMAIL": "your_sender@example.com" + "DEFAULT_FROM_EMAIL": "your_sender@example.com", + "MAILTRAP_ACCOUNT_ID": "your_account_id" } } } @@ -198,7 +264,8 @@ If you are using `asdf` for managing Node.js you should use absolute path to exe "args": ["/path/to/mailtrap-mcp/dist/index.js"], "env": { "MAILTRAP_API_TOKEN": "your_mailtrap_api_token", - "DEFAULT_FROM_EMAIL": "your_sender@example.com" + "DEFAULT_FROM_EMAIL": "your_sender@example.com", + "MAILTRAP_ACCOUNT_ID": "your_account_id" } } } diff --git a/package-lock.json b/package-lock.json index 56486cb..375220d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", "dotenv": "^16.4.7", - "mailtrap": "^4.0.0", + "mailtrap": "^4.2.0", "zod": "^3.24.2" }, "bin": { @@ -6971,9 +6971,9 @@ } }, "node_modules/mailtrap": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mailtrap/-/mailtrap-4.0.0.tgz", - "integrity": "sha512-IXEPjI6VfzXvUsSGfAYHPcAaM2MBwKJnKj5m2YC5NJuu1Y8fGrOe1qv0pI15UdAU5jbQJrWzsHSE2ax0Sf8uAg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mailtrap/-/mailtrap-4.2.0.tgz", + "integrity": "sha512-kuVcI7h1oNyVpsoMPzaex7fNEFKL5X8fF4ncKocSBnE8IaFS24rUhFkVTo3RcjGL25NnKrhyNrWOVXyJ7G+d6A==", "license": "MIT", "dependencies": { "axios": ">=0.27" diff --git a/package.json b/package.json index bbd5600..742888b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-mailtrap", - "version": "0.0.2", + "version": "0.0.3", "description": "Official MCP Server for Mailtrap", "license": "MIT", "author": "Railsware Products Studio LLC", @@ -31,7 +31,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", "dotenv": "^16.4.7", - "mailtrap": "^4.0.0", + "mailtrap": "^4.2.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/src/client.ts b/src/client.ts index 8aeb23e..10153fa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -9,6 +9,10 @@ if (!MAILTRAP_API_TOKEN) { const client = new MailtrapClient({ token: MAILTRAP_API_TOKEN, + // conditionally set accountId if it's a valid number + ...(process.env.MAILTRAP_ACCOUNT_ID && !isNaN(Number(process.env.MAILTRAP_ACCOUNT_ID)) + ? { accountId: Number(process.env.MAILTRAP_ACCOUNT_ID) } + : {}), }); // eslint-disable-next-line import/prefer-default-export diff --git a/src/index.ts b/src/index.ts index 1e942a6..d3e4c34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,16 @@ import dotenv from "dotenv"; import CONFIG from "./config"; import { sendEmailSchema, sendEmail } from "./tools/sendEmail"; +import { + createTemplate, + createTemplateSchema, + deleteTemplate, + deleteTemplateSchema, + listTemplates, + listTemplatesSchema, + updateTemplate, + updateTemplateSchema, +} from "./tools/templates"; dotenv.config(); @@ -14,6 +24,9 @@ const server = new McpServer({ version: CONFIG.MCP_SERVER_VERSION, }); +/** + * Sending operations. + */ server.tool( "send-email", "Send transactional email using Mailtrap", @@ -21,6 +34,34 @@ server.tool( sendEmail ); +/** + * Templates operations. + */ +server.tool( + "create-template", + "Create a new email template", + createTemplateSchema, + createTemplate +); +server.tool( + "list-templates", + "List all email templates", + listTemplatesSchema, + listTemplates +); +server.tool( + "update-template", + "Update an existing email template", + updateTemplateSchema, + updateTemplate +); +server.tool( + "delete-template", + "Delete an existing email template", + deleteTemplateSchema, + deleteTemplate +); + async function main() { const transport = new StdioServerTransport(); diff --git a/src/tools/sendEmail/__tests__/sendEmail.test.ts b/src/tools/sendEmail/__tests__/sendEmail.test.ts index d025f73..0ed0934 100644 --- a/src/tools/sendEmail/__tests__/sendEmail.test.ts +++ b/src/tools/sendEmail/__tests__/sendEmail.test.ts @@ -12,6 +12,7 @@ describe("sendEmail", () => { to: "recipient@example.com", subject: "Test Subject", text: "Test email body", + category: "test-category", }; const mockResponse = { @@ -33,7 +34,7 @@ describe("sendEmail", () => { subject: mockEmailData.subject, text: mockEmailData.text, html: undefined, - category: undefined, + category: mockEmailData.category, }); expect(result).toEqual({ @@ -60,7 +61,7 @@ describe("sendEmail", () => { subject: mockEmailData.subject, text: mockEmailData.text, html: undefined, - category: undefined, + category: mockEmailData.category, }); expect(result).toEqual({ @@ -89,7 +90,7 @@ describe("sendEmail", () => { subject: mockEmailData.subject, text: mockEmailData.text, html: undefined, - category: undefined, + category: mockEmailData.category, cc: cc.map((email) => ({ email })), bcc: bcc.map((email) => ({ email })), }); @@ -118,7 +119,7 @@ describe("sendEmail", () => { subject: mockEmailData.subject, text: mockEmailData.text, html, - category: undefined, + category: mockEmailData.category, }); expect(result).toEqual({ @@ -163,6 +164,7 @@ describe("sendEmail", () => { const result = await sendEmail({ to: mockEmailData.to, subject: mockEmailData.subject, + category: mockEmailData.category, }); expect(client.send).not.toHaveBeenCalled(); diff --git a/src/tools/sendEmail/schema.ts b/src/tools/sendEmail/schema.ts index 8a0e531..6f564b7 100644 --- a/src/tools/sendEmail/schema.ts +++ b/src/tools/sendEmail/schema.ts @@ -17,10 +17,7 @@ const sendEmailSchema = { .array(z.string().email()) .optional() .describe("Optional BCC recipients"), - category: z - .string() - .optional() - .describe("Optional email category for tracking"), + category: z.string().describe("Email category for tracking"), text: z.string().optional().describe("Email body text"), html: z .string() diff --git a/src/tools/templates/__tests__/createTemplate.test.ts b/src/tools/templates/__tests__/createTemplate.test.ts new file mode 100644 index 0000000..83eebb2 --- /dev/null +++ b/src/tools/templates/__tests__/createTemplate.test.ts @@ -0,0 +1,173 @@ +import createTemplate from "../createTemplate"; +import { client } from "../../../client"; + +jest.mock("../../../client", () => ({ + client: { + templates: { + create: jest.fn(), + }, + }, +})); + +describe("createTemplate", () => { + const mockTemplateData = { + name: "Test Template", + subject: "Test Email Subject", + html: "

Test Template

This is a test template.

", + text: "Test Template\n\nThis is a test template.", + category: "Test", + }; + + const mockResponse = { + id: 12345, + uuid: "abc-def-ghi", + name: mockTemplateData.name, + subject: mockTemplateData.subject, + category: mockTemplateData.category, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (client.templates.create as jest.Mock).mockResolvedValue(mockResponse); + }); + + it("should create template successfully with all required fields", async () => { + const result = await createTemplate(mockTemplateData); + + expect(client.templates.create).toHaveBeenCalledWith({ + name: mockTemplateData.name, + subject: mockTemplateData.subject, + category: mockTemplateData.category, + body_html: mockTemplateData.html, + body_text: mockTemplateData.text, + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockTemplateData.name}" created successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should create template successfully with default category", async () => { + const templateDataWithoutCategory = { + name: mockTemplateData.name, + subject: mockTemplateData.subject, + html: mockTemplateData.html, + text: mockTemplateData.text, + }; + + const result = await createTemplate(templateDataWithoutCategory); + + expect(client.templates.create).toHaveBeenCalledWith({ + name: mockTemplateData.name, + subject: mockTemplateData.subject, + category: "General", + body_html: mockTemplateData.html, + body_text: mockTemplateData.text, + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockTemplateData.name}" created successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should create template successfully without text content", async () => { + const templateDataWithoutText = { + name: mockTemplateData.name, + subject: mockTemplateData.subject, + html: mockTemplateData.html, + category: mockTemplateData.category, + }; + + const result = await createTemplate(templateDataWithoutText); + + expect(client.templates.create).toHaveBeenCalledWith({ + name: mockTemplateData.name, + subject: mockTemplateData.subject, + category: mockTemplateData.category, + body_html: mockTemplateData.html, + body_text: undefined, + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockTemplateData.name}" created successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should create template successfully with custom category", async () => { + const customCategory = "Marketing"; + const templateDataWithCustomCategory = { + ...mockTemplateData, + category: customCategory, + }; + + const result = await createTemplate(templateDataWithCustomCategory); + + expect(client.templates.create).toHaveBeenCalledWith({ + name: mockTemplateData.name, + subject: mockTemplateData.subject, + category: customCategory, + body_html: mockTemplateData.html, + body_text: mockTemplateData.text, + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockTemplateData.name}" created successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + describe("error handling", () => { + it("should handle client.templates.create failure", async () => { + const mockError = new Error("Failed to create template"); + (client.templates.create as jest.Mock).mockRejectedValue(mockError); + + const result = await createTemplate(mockTemplateData); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to create template: Failed to create template", + }, + ], + isError: true, + }); + }); + + it("should handle non-Error exceptions", async () => { + const mockError = "String error"; + (client.templates.create as jest.Mock).mockRejectedValue(mockError); + + const result = await createTemplate(mockTemplateData); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to create template: String error", + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/tools/templates/__tests__/deleteTemplate.test.ts b/src/tools/templates/__tests__/deleteTemplate.test.ts new file mode 100644 index 0000000..02a36df --- /dev/null +++ b/src/tools/templates/__tests__/deleteTemplate.test.ts @@ -0,0 +1,103 @@ +import deleteTemplate from "../deleteTemplate"; +import { client } from "../../../client"; + +jest.mock("../../../client", () => ({ + client: { + templates: { + delete: jest.fn(), + }, + }, +})); + +describe("deleteTemplate", () => { + const mockTemplateId = 12345; + + beforeEach(() => { + jest.clearAllMocks(); + (client.templates.delete as jest.Mock).mockResolvedValue(undefined); + }); + + it("should delete template successfully", async () => { + const result = await deleteTemplate({ template_id: mockTemplateId }); + + expect(client.templates.delete).toHaveBeenCalledWith(mockTemplateId); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template with ID ${mockTemplateId} deleted successfully!`, + }, + ], + }); + }); + + it("should delete template with different ID", async () => { + const differentTemplateId = 67890; + const result = await deleteTemplate({ template_id: differentTemplateId }); + + expect(client.templates.delete).toHaveBeenCalledWith(differentTemplateId); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template with ID ${differentTemplateId} deleted successfully!`, + }, + ], + }); + }); + + describe("error handling", () => { + it("should handle client.templates.delete failure", async () => { + const mockError = new Error("Failed to delete template"); + (client.templates.delete as jest.Mock).mockRejectedValue(mockError); + + const result = await deleteTemplate({ template_id: mockTemplateId }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to delete template: Failed to delete template", + }, + ], + isError: true, + }); + }); + + it("should handle non-Error exceptions", async () => { + const mockError = "String error"; + (client.templates.delete as jest.Mock).mockRejectedValue(mockError); + + const result = await deleteTemplate({ template_id: mockTemplateId }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to delete template: String error", + }, + ], + isError: true, + }); + }); + + it("should handle template not found error", async () => { + const mockError = new Error("Template not found"); + (client.templates.delete as jest.Mock).mockRejectedValue(mockError); + + const result = await deleteTemplate({ template_id: mockTemplateId }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to delete template: Template not found", + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/tools/templates/__tests__/listTemplates.test.ts b/src/tools/templates/__tests__/listTemplates.test.ts new file mode 100644 index 0000000..842a234 --- /dev/null +++ b/src/tools/templates/__tests__/listTemplates.test.ts @@ -0,0 +1,208 @@ +import listTemplates from "../listTemplates"; +import { client } from "../../../client"; + +jest.mock("../../../client", () => ({ + client: { + templates: { + getList: jest.fn(), + }, + }, +})); + +describe("listTemplates", () => { + const mockTemplates = [ + { + id: 12345, + uuid: "abc-def-ghi", + name: "Welcome Email", + subject: "Welcome to our platform!", + category: "Onboarding", + created_at: "2024-01-15T10:30:00Z", + }, + { + id: 12346, + uuid: "def-ghi-jkl", + name: "Password Reset", + subject: "Reset your password", + category: "Security", + created_at: "2024-01-20T14:45:00Z", + }, + { + id: 12347, + uuid: "ghi-jkl-mno", + name: "Newsletter Template", + subject: "This week's updates", + category: "Marketing", + created_at: "2024-01-25T09:15:00Z", + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should list templates successfully when templates exist", async () => { + (client.templates.getList as jest.Mock).mockResolvedValue(mockTemplates); + + const result = await listTemplates(); + + expect(client.templates.getList).toHaveBeenCalledWith(); + + const expectedText = `Found 3 template(s): + +• Welcome Email (ID: 12345, UUID: abc-def-ghi) + Subject: Welcome to our platform! + Category: Onboarding + Created: 2024-01-15T10:30:00Z + +• Password Reset (ID: 12346, UUID: def-ghi-jkl) + Subject: Reset your password + Category: Security + Created: 2024-01-20T14:45:00Z + +• Newsletter Template (ID: 12347, UUID: ghi-jkl-mno) + Subject: This week's updates + Category: Marketing + Created: 2024-01-25T09:15:00Z +`; + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expectedText, + }, + ], + }); + }); + + it("should handle empty templates list", async () => { + (client.templates.getList as jest.Mock).mockResolvedValue([]); + + const result = await listTemplates(); + + expect(client.templates.getList).toHaveBeenCalledWith(); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "No templates found in your Mailtrap account.", + }, + ], + }); + }); + + it("should handle null templates response", async () => { + (client.templates.getList as jest.Mock).mockResolvedValue(null); + + const result = await listTemplates(); + + expect(client.templates.getList).toHaveBeenCalledWith(); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "No templates found in your Mailtrap account.", + }, + ], + }); + }); + + it("should handle undefined templates response", async () => { + (client.templates.getList as jest.Mock).mockResolvedValue(undefined); + + const result = await listTemplates(); + + expect(client.templates.getList).toHaveBeenCalledWith(); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "No templates found in your Mailtrap account.", + }, + ], + }); + }); + + it("should handle single template", async () => { + const singleTemplate = [mockTemplates[0]]; + (client.templates.getList as jest.Mock).mockResolvedValue(singleTemplate); + + const result = await listTemplates(); + + expect(client.templates.getList).toHaveBeenCalledWith(); + + const expectedText = `Found 1 template(s): + +• Welcome Email (ID: 12345, UUID: abc-def-ghi) + Subject: Welcome to our platform! + Category: Onboarding + Created: 2024-01-15T10:30:00Z +`; + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expectedText, + }, + ], + }); + }); + + describe("error handling", () => { + it("should handle client.templates.getList failure", async () => { + const mockError = new Error("Failed to fetch templates"); + (client.templates.getList as jest.Mock).mockRejectedValue(mockError); + + const result = await listTemplates(); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to list templates: Failed to fetch templates", + }, + ], + isError: true, + }); + }); + + it("should handle non-Error exceptions", async () => { + const mockError = "String error"; + (client.templates.getList as jest.Mock).mockRejectedValue(mockError); + + const result = await listTemplates(); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to list templates: String error", + }, + ], + isError: true, + }); + }); + + it("should handle network error", async () => { + const mockError = new Error("Network error"); + (client.templates.getList as jest.Mock).mockRejectedValue(mockError); + + const result = await listTemplates(); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to list templates: Network error", + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/tools/templates/__tests__/updateTemplate.test.ts b/src/tools/templates/__tests__/updateTemplate.test.ts new file mode 100644 index 0000000..0f36006 --- /dev/null +++ b/src/tools/templates/__tests__/updateTemplate.test.ts @@ -0,0 +1,305 @@ +import updateTemplate from "../updateTemplate"; +import { client } from "../../../client"; + +jest.mock("../../../client", () => ({ + client: { + templates: { + update: jest.fn(), + }, + }, +})); + +describe("updateTemplate", () => { + const mockTemplateId = 12345; + const mockUpdateData = { + template_id: mockTemplateId, + name: "Updated Template Name", + subject: "Updated Email Subject", + html: "

Updated Template

This is an updated template.

", + text: "Updated Template\n\nThis is an updated template.", + category: "Updated Category", + }; + + const mockResponse = { + id: mockTemplateId, + uuid: "abc-def-ghi", + name: mockUpdateData.name, + subject: mockUpdateData.subject, + category: mockUpdateData.category, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (client.templates.update as jest.Mock).mockResolvedValue(mockResponse); + }); + + it("should update template successfully with all fields", async () => { + const result = await updateTemplate(mockUpdateData); + + expect(client.templates.update).toHaveBeenCalledWith(mockTemplateId, { + name: mockUpdateData.name, + subject: mockUpdateData.subject, + body_html: mockUpdateData.html, + body_text: mockUpdateData.text, + category: mockUpdateData.category, + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockUpdateData.name}" updated successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should update template successfully with only name", async () => { + const updateDataWithOnlyName = { + template_id: mockTemplateId, + name: "New Template Name", + }; + + const result = await updateTemplate(updateDataWithOnlyName); + + expect(client.templates.update).toHaveBeenCalledWith(mockTemplateId, { + name: "New Template Name", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockResponse.name}" updated successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should update template successfully with only subject", async () => { + const updateDataWithOnlySubject = { + template_id: mockTemplateId, + subject: "New Email Subject", + }; + + const result = await updateTemplate(updateDataWithOnlySubject); + + expect(client.templates.update).toHaveBeenCalledWith(mockTemplateId, { + subject: "New Email Subject", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockResponse.name}" updated successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should update template successfully with only html", async () => { + const updateDataWithOnlyHtml = { + template_id: mockTemplateId, + html: "

New HTML Content

", + }; + + const result = await updateTemplate(updateDataWithOnlyHtml); + + expect(client.templates.update).toHaveBeenCalledWith(mockTemplateId, { + body_html: "

New HTML Content

", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockResponse.name}" updated successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should update template successfully with only text", async () => { + const updateDataWithOnlyText = { + template_id: mockTemplateId, + text: "New text content", + }; + + const result = await updateTemplate(updateDataWithOnlyText); + + expect(client.templates.update).toHaveBeenCalledWith(mockTemplateId, { + body_text: "New text content", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockResponse.name}" updated successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should update template successfully with only category", async () => { + const updateDataWithOnlyCategory = { + template_id: mockTemplateId, + category: "New Category", + }; + + const result = await updateTemplate(updateDataWithOnlyCategory); + + expect(client.templates.update).toHaveBeenCalledWith(mockTemplateId, { + category: "New Category", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockResponse.name}" updated successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should update template successfully with multiple fields", async () => { + const updateDataWithMultipleFields = { + template_id: mockTemplateId, + name: "Updated Name", + subject: "Updated Subject", + category: "Updated Category", + }; + + const result = await updateTemplate(updateDataWithMultipleFields); + + expect(client.templates.update).toHaveBeenCalledWith(mockTemplateId, { + name: "Updated Name", + subject: "Updated Subject", + category: "Updated Category", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockResponse.name}" updated successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should update template with different template ID", async () => { + const differentTemplateId = 67890; + const updateDataWithDifferentId = { + template_id: differentTemplateId, + name: "Different Template", + }; + + const result = await updateTemplate(updateDataWithDifferentId); + + expect(client.templates.update).toHaveBeenCalledWith(differentTemplateId, { + name: "Different Template", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: `Template "${mockResponse.name}" updated successfully!\nTemplate ID: ${mockResponse.id}\nTemplate UUID: ${mockResponse.uuid}`, + }, + ], + }); + }); + + it("should reject update with no fields provided", async () => { + const updateDataWithNoFields = { + template_id: mockTemplateId, + }; + + const result = await updateTemplate(updateDataWithNoFields); + + expect(client.templates.update).not.toHaveBeenCalled(); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Error: At least one update field (name, subject, html, text, or category) must be provided", + }, + ], + isError: true, + }); + }); + + describe("error handling", () => { + it("should handle client.templates.update failure", async () => { + const mockError = new Error("Failed to update template"); + (client.templates.update as jest.Mock).mockRejectedValue(mockError); + + const result = await updateTemplate(mockUpdateData); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to update template: Failed to update template", + }, + ], + isError: true, + }); + }); + + it("should handle non-Error exceptions", async () => { + const mockError = "String error"; + (client.templates.update as jest.Mock).mockRejectedValue(mockError); + + const result = await updateTemplate(mockUpdateData); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to update template: String error", + }, + ], + isError: true, + }); + }); + + it("should handle template not found error", async () => { + const mockError = new Error("Template not found"); + (client.templates.update as jest.Mock).mockRejectedValue(mockError); + + const result = await updateTemplate(mockUpdateData); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to update template: Template not found", + }, + ], + isError: true, + }); + }); + + it("should handle validation error", async () => { + const mockError = new Error("Validation failed"); + (client.templates.update as jest.Mock).mockRejectedValue(mockError); + + const result = await updateTemplate(mockUpdateData); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: "Failed to update template: Validation failed", + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/tools/templates/createTemplate.ts b/src/tools/templates/createTemplate.ts new file mode 100644 index 0000000..c0e5bd0 --- /dev/null +++ b/src/tools/templates/createTemplate.ts @@ -0,0 +1,65 @@ +import { CreateTemplateRequest } from "../../types/mailtrap"; +import { client } from "../../client"; + +async function createTemplate({ + name, + subject, + html, + text, + category, +}: CreateTemplateRequest): Promise<{ content: any[]; isError?: boolean }> { + try { + // Validate that at least one of html or text is provided + if (!html && !text) { + return { + content: [ + { + type: "text", + text: "Failed to create template: At least one of 'html' or 'text' content must be provided.", + }, + ], + isError: true, + }; + } + + const createParams: any = { + name, + subject, + category: category || "General", + }; + + if (html) { + createParams.body_html = html; + } + if (text) { + createParams.body_text = text; + } + + const template = await client.templates.create(createParams); + + return { + content: [ + { + type: "text", + text: `Template "${name}" created successfully!\nTemplate ID: ${template.id}\nTemplate UUID: ${template.uuid}`, + }, + ], + }; + } catch (error) { + console.error("Error creating template:", error); + + const errorMessage = error instanceof Error ? error.message : String(error); + + return { + content: [ + { + type: "text", + text: `Failed to create template: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default createTemplate; diff --git a/src/tools/templates/deleteTemplate.ts b/src/tools/templates/deleteTemplate.ts new file mode 100644 index 0000000..194dde5 --- /dev/null +++ b/src/tools/templates/deleteTemplate.ts @@ -0,0 +1,35 @@ +import { DeleteTemplateRequest } from "../../types/mailtrap"; +import { client } from "../../client"; + +async function deleteTemplate({ + template_id, +}: DeleteTemplateRequest): Promise<{ content: any[]; isError?: boolean }> { + try { + await client.templates.delete(template_id); + + return { + content: [ + { + type: "text", + text: `Template with ID ${template_id} deleted successfully!`, + }, + ], + }; + } catch (error) { + console.error("Error deleting template:", error); + + const errorMessage = error instanceof Error ? error.message : String(error); + + return { + content: [ + { + type: "text", + text: `Failed to delete template: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default deleteTemplate; diff --git a/src/tools/templates/index.ts b/src/tools/templates/index.ts new file mode 100644 index 0000000..deaa677 --- /dev/null +++ b/src/tools/templates/index.ts @@ -0,0 +1,19 @@ +import createTemplateSchema from "./schemas/createTemplate"; +import createTemplate from "./createTemplate"; +import listTemplatesSchema from "./schemas/listTemplates"; +import listTemplates from "./listTemplates"; +import updateTemplateSchema from "./schemas/updateTemplate"; +import updateTemplate from "./updateTemplate"; +import deleteTemplateSchema from "./schemas/deleteTemplate"; +import deleteTemplate from "./deleteTemplate"; + +export { + createTemplateSchema, + createTemplate, + listTemplatesSchema, + listTemplates, + updateTemplateSchema, + updateTemplate, + deleteTemplateSchema, + deleteTemplate, +}; diff --git a/src/tools/templates/listTemplates.ts b/src/tools/templates/listTemplates.ts new file mode 100644 index 0000000..aeee5af --- /dev/null +++ b/src/tools/templates/listTemplates.ts @@ -0,0 +1,50 @@ +import { client } from "../../client"; + +async function listTemplates(): Promise<{ content: any[]; isError?: boolean }> { + try { + const templates = await client.templates.getList(); + + if (!templates || templates.length === 0) { + return { + content: [ + { + type: "text", + text: "No templates found in your Mailtrap account.", + }, + ], + }; + } + + const templateList = templates + .map( + (template) => + `• ${template.name} (ID: ${template.id}, UUID: ${template.uuid})\n Subject: ${template.subject}\n Category: ${template.category}\n Created: ${template.created_at}\n` + ) + .join("\n"); + + return { + content: [ + { + type: "text", + text: `Found ${templates.length} template(s):\n\n${templateList}`, + }, + ], + }; + } catch (error) { + console.error("Error listing templates:", error); + + const errorMessage = error instanceof Error ? error.message : String(error); + + return { + content: [ + { + type: "text", + text: `Failed to list templates: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default listTemplates; diff --git a/src/tools/templates/schemas/createTemplate.ts b/src/tools/templates/schemas/createTemplate.ts new file mode 100644 index 0000000..990f238 --- /dev/null +++ b/src/tools/templates/schemas/createTemplate.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +const createTemplateSchema = { + name: z.string().describe("Name of the template"), + subject: z.string().describe("Email subject line"), + html: z + .string() + .optional() + .describe("HTML content of the template (optional)"), + text: z + .string() + .optional() + .describe("Plain text version of the template (optional)"), + category: z + .string() + .optional() + .describe("Template category (optional, defaults to 'General')"), +}; + +export default createTemplateSchema; diff --git a/src/tools/templates/schemas/deleteTemplate.ts b/src/tools/templates/schemas/deleteTemplate.ts new file mode 100644 index 0000000..89e02f1 --- /dev/null +++ b/src/tools/templates/schemas/deleteTemplate.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +const deleteTemplateSchema = { + template_id: z.number().describe("ID of the template to delete"), +}; + +export default deleteTemplateSchema; diff --git a/src/tools/templates/schemas/listTemplates.ts b/src/tools/templates/schemas/listTemplates.ts new file mode 100644 index 0000000..87cfc06 --- /dev/null +++ b/src/tools/templates/schemas/listTemplates.ts @@ -0,0 +1,5 @@ +const listTemplatesSchema = { + // No parameters needed for listing templates +}; + +export default listTemplatesSchema; diff --git a/src/tools/templates/schemas/updateTemplate.ts b/src/tools/templates/schemas/updateTemplate.ts new file mode 100644 index 0000000..278cdcf --- /dev/null +++ b/src/tools/templates/schemas/updateTemplate.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +const updateTemplateSchema = { + template_id: z.number().describe("ID of the template to update"), + name: z.string().optional().describe("New name for the template"), + subject: z.string().optional().describe("New email subject line"), + html: z.string().optional().describe("New HTML content of the template"), + text: z + .string() + .optional() + .describe("New plain text version of the template"), + category: z.string().optional().describe("New category for the template"), +}; + +export default updateTemplateSchema; diff --git a/src/tools/templates/updateTemplate.ts b/src/tools/templates/updateTemplate.ts new file mode 100644 index 0000000..540171c --- /dev/null +++ b/src/tools/templates/updateTemplate.ts @@ -0,0 +1,79 @@ +import { UpdateTemplateRequest } from "../../types/mailtrap"; +import { client } from "../../client"; + +async function updateTemplate({ + template_id, + name, + subject, + html, + text, + category, +}: UpdateTemplateRequest): Promise<{ content: any[]; isError?: boolean }> { + try { + // Validate that at least one update field is provided + if ( + name === undefined && + subject === undefined && + html === undefined && + text === undefined && + category === undefined + ) { + return { + content: [ + { + type: "text", + text: "Error: At least one update field (name, subject, html, text, or category) must be provided", + }, + ], + isError: true, + }; + } + + // Validate that if both html and text are being updated, at least one has content + if (html !== undefined && text !== undefined && !html && !text) { + return { + content: [ + { + type: "text", + text: "Error: If updating both html and text, at least one must have content", + }, + ], + isError: true, + }; + } + + const updateData: any = {}; + if (name !== undefined) updateData.name = name; + if (subject !== undefined) updateData.subject = subject; + if (html !== undefined) updateData.body_html = html; + if (text !== undefined) updateData.body_text = text; + if (category !== undefined) updateData.category = category; + + const template = await client.templates.update(template_id, updateData); + + return { + content: [ + { + type: "text", + text: `Template "${template.name}" updated successfully!\nTemplate ID: ${template.id}\nTemplate UUID: ${template.uuid}`, + }, + ], + }; + } catch (error) { + console.error("Error updating template:", error); + + const errorMessage = error instanceof Error ? error.message : String(error); + + return { + content: [ + { + type: "text", + text: `Failed to update template: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default updateTemplate; diff --git a/src/types/mailtrap.ts b/src/types/mailtrap.ts index 8627c6c..9367a8a 100644 --- a/src/types/mailtrap.ts +++ b/src/types/mailtrap.ts @@ -6,5 +6,26 @@ export interface SendMailToolRequest { html?: string; cc?: string[]; bcc?: string[]; + category: string; +} + +export interface CreateTemplateRequest { + name: string; + subject: string; + html?: string; + text?: string; category?: string; } + +export interface UpdateTemplateRequest { + template_id: number; + name?: string; + subject?: string; + html?: string; + text?: string; + category?: string; +} + +export interface DeleteTemplateRequest { + template_id: number; +}