diff --git a/README.md b/README.md index f3ca2c1..847efb0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ # Official Postmark MCP Server   [![NPM Version](https://img.shields.io/npm/v/@activecampaign/postmark-mcp.svg)](https://www.npmjs.com/package/@activecampaign/postmark-mcp)  ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fsdk) -Send emails with Postmark using Claude and other MCP-compatible AI assistants. +Send emails, manage bounces, handle suppressions, and track delivery stats with Postmark using Claude and other MCP-compatible AI assistants. ## Features -- Exposes a Model Context Protocol (MCP) server for sending emails via your [Postmark account](https://account.postmarkapp.com/sign_up) +- Exposes a Model Context Protocol (MCP) server for interacting with [Postmark](https://account.postmarkapp.com/sign_up) +- **Email sending** — Send emails directly or via templates +- **Bounce management** — List, inspect, and reactivate bounced addresses +- **Suppression management** — View, create, and delete suppressions +- **Delivery statistics** — Track bounce rates, spam complaints, and overall delivery stats - Simple configuration via environment variables - Comprehensive error handling and graceful shutdown - Secure logging practices (no sensitive data exposure) - Automatic email tracking configuration +- Ready for [MCP Registry](https://registry.modelcontextprotocol.io) publishing ## Useful Docs - [📒 API Documentation](https://postmarkapp.com/developer) @@ -112,6 +117,16 @@ This section provides a complete reference for the Postmark MCP server tools inc - [listTemplates](#3-listtemplates) - [Statistics & Tracking Tools](#statistics--tracking-tools) - [getDeliveryStats](#4-getdeliverystats) + - [getBounceStats](#8-getbouncestats) + - [getSpamStats](#9-getspamstats) +- [Bounce Management Tools](#bounce-management-tools) + - [getBounces](#5-getbounces) + - [getBounce](#6-getbounce) + - [activateBounce](#7-activatebounce) +- [Suppression Management Tools](#suppression-management-tools) + - [getSuppressions](#10-getsuppressions) + - [createSuppressions](#11-createsuppressions) + - [deleteSuppressions](#12-deletesuppressions) ## Email Management Tools ### 1. sendEmail @@ -235,6 +250,247 @@ Period: 2025-05-01 to 2025-05-15 Tag: marketing ``` +## Bounce Management Tools +### 5. getBounces +Search and list bounced emails with optional filters. + +**Example Prompt:** +``` +Show me all hard bounces from the last week. +``` + +**Expected Payload:** +```json +{ + "count": 50, // Optional, 1-500, default 25 + "offset": 0, // Optional + "type": "HardBounce", // Optional: HardBounce, SoftBounce, SpamNotification, Transient + "inactive": true, // Optional + "emailFilter": "example.com", // Optional, partial match + "tag": "marketing", // Optional + "fromDate": "2025-05-01", // Optional, YYYY-MM-DD + "toDate": "2025-05-15", // Optional, YYYY-MM-DD + "messageStream": "outbound" // Optional +} +``` + +**Response Format:** +``` +Found 3 bounces (showing 3): + +• recipient@example.com + - Type: HardBounce + - Description: The server was unable to deliver your message + - Date: 2025-05-10T14:29:09Z + - Inactive: true + - ID: 1234567890 + - MessageStream: outbound +``` + +### 6. getBounce +Get detailed information about a specific bounce. + +**Example Prompt:** +``` +Show me the details of bounce ID 1234567890. +``` + +**Expected Payload:** +```json +{ + "bounceId": 1234567890 +} +``` + +**Response Format:** +``` +Bounce Details + +Email: recipient@example.com +Type: HardBounce +Description: The server was unable to deliver your message +Details: no such user +Date: 2025-05-10T14:29:09Z +Inactive: true +Can Activate: true +ID: 1234567890 +MessageStream: outbound +Subject: Order Confirmation +ServerID: 12345678 +``` + +### 7. activateBounce +Reactivate a bounced email address so emails can be sent to it again. + +**Example Prompt:** +``` +Reactivate bounce ID 1234567890 so we can send emails to that address again. +``` + +**Expected Payload:** +```json +{ + "bounceId": 1234567890 +} +``` + +**Response Format:** +``` +Bounce activated successfully! + +Message: OK +Email: recipient@example.com +ID: 1234567890 +Inactive: false +``` + +### 8. getBounceStats +Get bounce statistics broken down by day. + +**Example Prompt:** +``` +Show me bounce statistics for the last month. +``` + +**Expected Payload:** +```json +{ + "tag": "marketing", // Optional + "fromDate": "2025-05-01", // Optional, YYYY-MM-DD + "toDate": "2025-05-31" // Optional, YYYY-MM-DD +} +``` + +**Response Format:** +``` +Bounce Statistics + +Period: 2025-05-01 to 2025-05-31 + +Daily breakdown: + 2025-05-01: Hard=0, Soft=1, Transient=0, SMTPApiError=0 + 2025-05-02: Hard=2, Soft=0, Transient=1, SMTPApiError=0 +``` + +### 9. getSpamStats +Get spam complaint statistics broken down by day. + +**Example Prompt:** +``` +Show me spam complaint stats for this month. +``` + +**Expected Payload:** +```json +{ + "tag": "marketing", // Optional + "fromDate": "2025-05-01", // Optional, YYYY-MM-DD + "toDate": "2025-05-31" // Optional, YYYY-MM-DD +} +``` + +**Response Format:** +``` +Spam Complaint Statistics + +Period: 2025-05-01 to 2025-05-31 + +Daily breakdown: + 2025-05-01: SpamComplaint=0 + 2025-05-02: SpamComplaint=1 +``` + +## Suppression Management Tools +### 10. getSuppressions +List suppressed email addresses in a message stream. + +**Example Prompt:** +``` +Show me all suppressed email addresses on the outbound stream. +``` + +**Expected Payload:** +```json +{ + "messageStream": "outbound", // Optional, default: outbound + "suppressionReason": "HardBounce", // Optional: ManualSuppression, HardBounce, SpamComplaint + "origin": "Recipient", // Optional: Recipient, Customer, Admin + "emailAddress": "example.com" // Optional, filter by address +} +``` + +**Response Format:** +``` +Found 2 suppressions in stream "outbound": + +• blocked@example.com + - Reason: HardBounce + - Origin: Recipient + - Created: 2025-05-10T14:29:09Z + +• spam@example.com + - Reason: SpamComplaint + - Origin: Recipient + - Created: 2025-05-08T10:15:00Z +``` + +### 11. createSuppressions +Suppress one or more email addresses to prevent sending to them. + +**Example Prompt:** +``` +Suppress the email addresses spam@example.com and invalid@example.com on the outbound stream. +``` + +**Expected Payload:** +```json +{ + "messageStream": "outbound", // Optional, default: outbound + "emailAddresses": ["spam@example.com", "invalid@example.com"] +} +``` + +**Response Format:** +``` +Suppression results: + +• spam@example.com: Suppressed +• invalid@example.com: Suppressed +``` + +### 12. deleteSuppressions +Remove suppressions to allow sending to those addresses again. + +**Example Prompt:** +``` +Unsuppress customer@example.com so we can send them emails again. +``` + +**Expected Payload:** +```json +{ + "messageStream": "outbound", // Optional, default: outbound + "emailAddresses": ["customer@example.com"] +} +``` + +**Response Format:** +``` +Unsuppression results: + +• customer@example.com: Deleted +``` + +## MCP Registry + +This server is ready for publishing to the [MCP Registry](https://registry.modelcontextprotocol.io). A `server.json` file is included with the required metadata. To publish: + +1. Install the `mcp-publisher` CLI tool +2. Authenticate: `mcp-publisher login github` +3. Publish: `mcp-publisher publish` + +For full instructions, see the [MCP Registry Quickstart](https://modelcontextprotocol.io/docs/registry/publishing/quickstart). + ## Implementation Details ### Automatic Configuration All emails are automatically configured with: diff --git a/index.js b/index.js index c810157..b475fc2 100755 --- a/index.js +++ b/index.js @@ -71,7 +71,7 @@ async function main() { await server.connect(transport); console.error('Postmark MCP server is running and ready!'); - console.error(`Available tools: sendEmail, sendEmailWithTemplate, listTemplates, getDeliveryStats`); + console.error(`Available tools: sendEmail, sendEmailWithTemplate, listTemplates, getDeliveryStats, getBounces, getBounce, activateBounce, getSuppressions, createSuppressions, deleteSuppressions, getBounceStats, getSpamStats`); process.on('SIGTERM', () => handleShutdown(server)); process.on('SIGINT', () => handleShutdown(server)); @@ -267,6 +267,380 @@ function registerTools(server, postmarkClient) { }; } ); + + // Define and register the getBounces tool + server.tool( + "getBounces", + { + count: z.number().min(1).max(500).optional().describe("Number of bounces to return (1-500, default 25)"), + offset: z.number().optional().describe("Number of bounces to skip (default 0)"), + type: z.string().optional().describe("Filter by bounce type (e.g. HardBounce, SoftBounce, SpamNotification, Transient)"), + inactive: z.boolean().optional().describe("Filter by inactive/active status"), + emailFilter: z.string().optional().describe("Filter by email address (partial match)"), + tag: z.string().optional().describe("Filter by tag"), + messageID: z.string().optional().describe("Filter by message ID"), + fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Start date in YYYY-MM-DD format"), + toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("End date in YYYY-MM-DD format"), + messageStream: z.string().optional().describe("Filter by message stream (default: all streams)") + }, + async ({ count, offset, type, inactive, emailFilter, tag, messageID, fromDate, toDate, messageStream }) => { + const query = []; + if (count) query.push(`count=${count}`); + else query.push('count=25'); + if (offset) query.push(`offset=${offset}`); + else query.push('offset=0'); + if (type) query.push(`type=${encodeURIComponent(type)}`); + if (inactive !== undefined) query.push(`inactive=${inactive}`); + if (emailFilter) query.push(`emailFilter=${encodeURIComponent(emailFilter)}`); + if (tag) query.push(`tag=${encodeURIComponent(tag)}`); + if (messageID) query.push(`messageID=${encodeURIComponent(messageID)}`); + if (fromDate) query.push(`fromdate=${encodeURIComponent(fromDate)}`); + if (toDate) query.push(`todate=${encodeURIComponent(toDate)}`); + if (messageStream) query.push(`messagestream=${encodeURIComponent(messageStream)}`); + + const url = `https://api.postmarkapp.com/bounces?${query.join('&')}`; + console.error('Fetching bounces..'); + + const response = await fetch(url, { + headers: { + "Accept": "application/json", + "X-Postmark-Server-Token": serverToken + } + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.error(`Found ${data.TotalCount} bounces`); + + const bounceList = data.Bounces.map(b => + `• **${b.Email}**\n - Type: ${b.Type}\n - Description: ${b.Description || 'none'}\n - Date: ${b.BouncedAt}\n - Inactive: ${b.Inactive}\n - ID: ${b.ID}\n - MessageStream: ${b.MessageStream || 'default'}` + ).join('\n\n'); + + return { + content: [{ + type: "text", + text: `Found ${data.TotalCount} bounces (showing ${data.Bounces.length}):\n\n${bounceList}` + }] + }; + } + ); + + // Define and register the getBounce tool + server.tool( + "getBounce", + { + bounceId: z.number().describe("The bounce ID to retrieve") + }, + async ({ bounceId }) => { + console.error('Fetching bounce details..', { bounceId }); + + const response = await fetch(`https://api.postmarkapp.com/bounces/${bounceId}`, { + headers: { + "Accept": "application/json", + "X-Postmark-Server-Token": serverToken + } + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const b = await response.json(); + console.error('Bounce details retrieved'); + + return { + content: [{ + type: "text", + text: `Bounce Details\n\n` + + `Email: ${b.Email}\n` + + `Type: ${b.Type}\n` + + `Description: ${b.Description || 'none'}\n` + + `Details: ${b.Details || 'none'}\n` + + `Date: ${b.BouncedAt}\n` + + `Inactive: ${b.Inactive}\n` + + `Can Activate: ${b.CanActivate}\n` + + `ID: ${b.ID}\n` + + `MessageStream: ${b.MessageStream || 'default'}\n` + + `Subject: ${b.Subject || 'none'}\n` + + `ServerID: ${b.ServerID}` + }] + }; + } + ); + + // Define and register the activateBounce tool (reactivate a blocked recipient) + server.tool( + "activateBounce", + { + bounceId: z.number().describe("The bounce ID to activate/unblock") + }, + async ({ bounceId }) => { + console.error('Activating bounce..', { bounceId }); + + const response = await fetch(`https://api.postmarkapp.com/bounces/${bounceId}/activate`, { + method: 'PUT', + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Postmark-Server-Token": serverToken + } + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.error('Bounce activated successfully'); + + return { + content: [{ + type: "text", + text: `Bounce activated successfully!\n\n` + + `Message: ${data.Message}\n` + + `Email: ${data.Bounce.Email}\n` + + `ID: ${data.Bounce.ID}\n` + + `Inactive: ${data.Bounce.Inactive}` + }] + }; + } + ); + + // Define and register the getSuppressions tool + server.tool( + "getSuppressions", + { + messageStream: z.string().optional().describe("Message stream ID (default: outbound)"), + suppressionReason: z.string().optional().describe("Filter by reason: ManualSuppression, HardBounce, SpamComplaint"), + origin: z.string().optional().describe("Filter by origin: Recipient, Customer, Admin"), + emailAddress: z.string().optional().describe("Filter by email address") + }, + async ({ messageStream, suppressionReason, origin, emailAddress }) => { + const stream = messageStream || 'outbound'; + const query = []; + if (suppressionReason) query.push(`SuppressionReason=${encodeURIComponent(suppressionReason)}`); + if (origin) query.push(`Origin=${encodeURIComponent(origin)}`); + if (emailAddress) query.push(`EmailAddress=${encodeURIComponent(emailAddress)}`); + + const url = `https://api.postmarkapp.com/message-streams/${stream}/suppressions/dump${query.length ? '?' + query.join('&') : ''}`; + console.error('Fetching suppressions..', { stream }); + + const response = await fetch(url, { + headers: { + "Accept": "application/json", + "X-Postmark-Server-Token": serverToken + } + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const suppressions = data.Suppressions || []; + console.error(`Found ${suppressions.length} suppressions`); + + const list = suppressions.map(s => + `• **${s.EmailAddress}**\n - Reason: ${s.SuppressionReason}\n - Origin: ${s.Origin}\n - Created: ${s.CreatedAt}` + ).join('\n\n'); + + return { + content: [{ + type: "text", + text: `Found ${suppressions.length} suppressions in stream "${stream}":\n\n${list || 'No suppressions found.'}` + }] + }; + } + ); + + // Define and register the createSuppressions tool + server.tool( + "createSuppressions", + { + messageStream: z.string().optional().describe("Message stream ID (default: outbound)"), + emailAddresses: z.array(z.string().email()).describe("List of email addresses to suppress") + }, + async ({ messageStream, emailAddresses }) => { + const stream = messageStream || 'outbound'; + const body = { + Suppressions: emailAddresses.map(e => ({ EmailAddress: e })) + }; + + console.error('Creating suppressions..', { stream, count: emailAddresses.length }); + + const response = await fetch(`https://api.postmarkapp.com/message-streams/${stream}/suppressions`, { + method: 'POST', + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Postmark-Server-Token": serverToken + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const results = data.Suppressions || []; + console.error('Suppressions created'); + + const list = results.map(s => + `• ${s.EmailAddress}: ${s.Status}${s.Message ? ' - ' + s.Message : ''}` + ).join('\n'); + + return { + content: [{ + type: "text", + text: `Suppression results:\n\n${list}` + }] + }; + } + ); + + // Define and register the deleteSuppressions tool (unblock suppressed addresses) + server.tool( + "deleteSuppressions", + { + messageStream: z.string().optional().describe("Message stream ID (default: outbound)"), + emailAddresses: z.array(z.string().email()).describe("List of email addresses to unsuppress/unblock") + }, + async ({ messageStream, emailAddresses }) => { + const stream = messageStream || 'outbound'; + const body = { + Suppressions: emailAddresses.map(e => ({ EmailAddress: e })) + }; + + console.error('Deleting suppressions..', { stream, count: emailAddresses.length }); + + const response = await fetch(`https://api.postmarkapp.com/message-streams/${stream}/suppressions/delete`, { + method: 'POST', + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Postmark-Server-Token": serverToken + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const results = data.Suppressions || []; + console.error('Suppressions deleted'); + + const list = results.map(s => + `• ${s.EmailAddress}: ${s.Status}${s.Message ? ' - ' + s.Message : ''}` + ).join('\n'); + + return { + content: [{ + type: "text", + text: `Unsuppression results:\n\n${list}` + }] + }; + } + ); + + // Define and register the getBounceStats tool + server.tool( + "getBounceStats", + { + tag: z.string().optional().describe("Filter by tag (optional)"), + fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Start date in YYYY-MM-DD format"), + toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("End date in YYYY-MM-DD format") + }, + async ({ tag, fromDate, toDate }) => { + const query = []; + if (fromDate) query.push(`fromdate=${encodeURIComponent(fromDate)}`); + if (toDate) query.push(`todate=${encodeURIComponent(toDate)}`); + if (tag) query.push(`tag=${encodeURIComponent(tag)}`); + + const url = `https://api.postmarkapp.com/stats/outbound/bounces${query.length ? '?' + query.join('&') : ''}`; + console.error('Fetching bounce stats..'); + + const response = await fetch(url, { + headers: { + "Accept": "application/json", + "X-Postmark-Server-Token": serverToken + } + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.error('Bounce stats retrieved'); + + const days = (data.Days || []).map(d => + ` ${d.Date}: Hard=${d.HardBounce || 0}, Soft=${d.SoftBounce || 0}, Transient=${d.Transient || 0}, SMTPApiError=${d.SMTPApiError || 0}` + ).join('\n'); + + return { + content: [{ + type: "text", + text: `Bounce Statistics\n\n` + + `${fromDate || toDate ? `Period: ${fromDate || 'start'} to ${toDate || 'now'}\n` : ''}` + + `${tag ? `Tag: ${tag}\n` : ''}\n` + + `Daily breakdown:\n${days || 'No data available.'}` + }] + }; + } + ); + + // Define and register the getSpamStats tool + server.tool( + "getSpamStats", + { + tag: z.string().optional().describe("Filter by tag (optional)"), + fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Start date in YYYY-MM-DD format"), + toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("End date in YYYY-MM-DD format") + }, + async ({ tag, fromDate, toDate }) => { + const query = []; + if (fromDate) query.push(`fromdate=${encodeURIComponent(fromDate)}`); + if (toDate) query.push(`todate=${encodeURIComponent(toDate)}`); + if (tag) query.push(`tag=${encodeURIComponent(tag)}`); + + const url = `https://api.postmarkapp.com/stats/outbound/spam${query.length ? '?' + query.join('&') : ''}`; + console.error('Fetching spam complaint stats..'); + + const response = await fetch(url, { + headers: { + "Accept": "application/json", + "X-Postmark-Server-Token": serverToken + } + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.error('Spam stats retrieved'); + + const days = (data.Days || []).map(d => + ` ${d.Date}: SpamComplaint=${d.SpamComplaint || 0}` + ).join('\n'); + + return { + content: [{ + type: "text", + text: `Spam Complaint Statistics\n\n` + + `${fromDate || toDate ? `Period: ${fromDate || 'start'} to ${toDate || 'now'}\n` : ''}` + + `${tag ? `Tag: ${tag}\n` : ''}\n` + + `Daily breakdown:\n${days || 'No data available.'}` + }] + }; + } + ); } main().catch((error) => { diff --git a/package.json b/package.json index 377eaf6..b8b21a7 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "@activecampaign/postmark-mcp", - "version": "1.0.0", + "version": "1.1.0", + "mcpName": "io.github.activecampaign/postmark", "description": "Official Postmark MCP server for sending emails via Claude and AI assistants", - "keywords": ["postmark", "activecampaign", "email", "mcp", "model-context-protocol", "claude", "cursor", "ai"], + "keywords": ["postmark", "activecampaign", "email", "mcp", "model-context-protocol", "claude", "cursor", "ai", "bounces", "suppressions", "stats"], "author": "Jabal Torres", "license": "MIT", "repository": { @@ -20,6 +21,7 @@ }, "files": [ "index.js", + "server.json", "README.md", "LICENSE" ], diff --git a/server.json b/server.json new file mode 100644 index 0000000..1b06232 --- /dev/null +++ b/server.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.activecampaign/postmark", + "description": "Official Postmark MCP server for sending emails, managing bounces, suppressions, and tracking delivery stats via Claude and AI assistants.", + "repository": { + "url": "https://github.com/ActiveCampaign/postmark-mcp", + "source": "github" + }, + "version": "1.1.0", + "packages": [ + { + "registryType": "npm", + "identifier": "@activecampaign/postmark-mcp", + "version": "1.1.0", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "POSTMARK_SERVER_TOKEN", + "description": "Your Postmark server API token", + "isRequired": true, + "format": "string", + "isSecret": true + }, + { + "name": "DEFAULT_SENDER_EMAIL", + "description": "Default sender email address", + "isRequired": true, + "format": "string", + "isSecret": false + }, + { + "name": "DEFAULT_MESSAGE_STREAM", + "description": "Postmark message stream (e.g., 'outbound')", + "isRequired": true, + "format": "string", + "isSecret": false + } + ] + } + ] +}