diff --git a/.gitignore b/.gitignore index f0956e5..558b4ef 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,10 @@ bun.lock npm-debug.log* yarn-debug.log* yarn-error.log* -logs/ \ No newline at end of file +logs/ +.idea/ + +# Local smoke-test copies (created from *.example.mjs counterparts). +# Keep personal verified-sender addresses out of the repo. +smoke-test.mjs +smoke-test-mutating.mjs \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..368470b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2026-04-29 + +This release expands the MCP tool surface from 4 tools to 24, organized into eight categories. It includes one breaking change for users on Node 16 or 18. + +### ⚠ Breaking changes + +- **Minimum Node.js version raised from 16 to 20.** The `@modelcontextprotocol/sdk` dependency requires Node ≥ 18; we set the floor at 20 to match a current LTS line. Users on Node 16 or 18 will see `EBADENGINE` warnings on install, or hard failures with `engine-strict=true`. The previous `>=16` declaration was already incompatible with the SDK at runtime — this release makes the floor honest. + +### Added + +- **20 new tools** across templates, messages, diagnostics, bounces, suppressions, webhooks, and server info. See the README "Tools" section for the complete reference. +- **`diagnoseDelivery`** — composite triage tool. Answers "did my email reach X, and if not, why?" by running message search, suppression check, and bounce history in parallel for a recipient, then synthesizing a plain-English recommendation. First tool in a new "Diagnostics" category. +- **`sendBatch`** and **`sendBatchWithTemplate`** — wraps Postmark's *synchronous* batch endpoints (`/email/batch`, `/email/batchWithTemplates`). Send up to 500 distinct messages or templated recipients in a single HTTP request, with immediate per-message success/failure reporting. Note: these are Postmark's batch endpoints; Postmark's separate *asynchronous* bulk email API at `/email/bulk` (submit-and-poll, no message count cap, 50 MB payload limit, subject to approval) is a parallel capability for large-volume jobs and is tracked as a v2.1 follow-up — not a replacement for batch. +- **Template CRUD + validation** — `getTemplate`, `createTemplate`, `editTemplate`, `deleteTemplate`, `validateTemplate`. End-to-end template authoring including layout binding (pass `layoutTemplate: ""` to bind, `null` to unbind). +- **Message search and details** — `searchOutboundMessages` (with `messageStream` filter), `getMessageDetails` (full event timeline). +- **Bounce tooling** — `searchBounces`, `getBounceDump`, `activateBounce`. +- **Suppression management** — `listSuppressions`, `createSuppressions`, `deleteSuppressions` (up to 50 addresses per call). +- **Webhook lifecycle** — `listWebhooks`, `createWebhook`, `deleteWebhook`. `createWebhook` requires at least one trigger enabled. +- **`getServerInfo`** — server name, color, tracking settings, configured webhook URLs. +- **`getDeliveryStats` `stat` parameter (optional).** With no argument, returns a friendly summary (preserves v1 behavior). With `stat: ""`, returns a polished per-stat breakdown. Supported values: `summary`, `overview`, `sent`, `bounces`, `spam`, `tracked`, `opens`, `openPlatforms`, `openClients`, `openReadTimes`, `clicks`, `clickBrowsers`, `clickPlatforms`, `clickLocation`. +- **Validation guards** on `editTemplate`, `createWebhook`, `createTemplate`, `validateTemplate`, and `sendEmailWithTemplate` — misuse fails fast with a clear message instead of hitting the API. +- **Smoke-test example harnesses** — `smoke-test.example.mjs` (read-only, 23 checks) and `smoke-test-mutating.example.mjs` (full lifecycles + real sends + cleanup, 14 checks). Copy to the non-example name (gitignored) and edit verified-sender addresses to use. The mutating harness includes a startup guard that refuses to run with placeholder values. + +### Changed + +- **`getDeliveryStats` default summary output reformatted.** Now includes bounce rate, spam rate, and tracked-of-sent percentage in addition to the v1 sent/open/click rates. All v1 fields are still present, just rendered with thousands separators and aligned columns. +- **All tools now use the official `postmark` SDK exclusively.** Previously `getDeliveryStats` bypassed the SDK with raw `fetch` against the REST API. The SDK handles auth, retries, and error mapping consistently. + +### Fixed + +- **`getServerInfo` displayed the wrong field for "First Open Only".** Was reading `EnableSmtpApiErrorHooks` (an unrelated boolean about SMTP API error hooks); now correctly reads `PostFirstOpenOnly`. +- **`engines.node` was lying.** Declared `>=16` but the MCP SDK requires `>=18`. The package would `npm install` on Node 16 and then fail at runtime. Floor is now `>=20` and accurate. + +### Removed + +- **`node-fetch` dependency.** No longer needed; the SDK handles all HTTP. + +## [1.0.0] - Initial release + +Initial public release of the official Postmark MCP server. + +### Added + +- Four MCP tools: `sendEmail`, `sendEmailWithTemplate`, `listTemplates`, `getDeliveryStats`. +- Stdio JSON-RPC transport for AI assistant consumption (Claude, Cursor, etc.). +- Configuration via `POSTMARK_SERVER_TOKEN`, `DEFAULT_SENDER_EMAIL`, `DEFAULT_MESSAGE_STREAM` environment variables. +- Automatic open and click tracking on every send. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ab60c16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Official Postmark MCP (Model Context Protocol) server that enables AI assistants (Claude, Cursor, etc.) to interact with the Postmark email API. Published as `@activecampaign/postmark-mcp` on NPM. + +## Commands + +- **Run server:** `npm start` (runs `node index.js`) +- **Debug with MCP Inspector:** `npm run inspector` (launches `@modelcontextprotocol/inspector`) +- **Smoke test (read-only):** `npm run smoke` — runs `smoke-test.mjs`, which is created from [smoke-test.example.mjs](smoke-test.example.mjs). Spawns the server over stdio and exercises every read-only tool against the live Postmark account configured in `.env`. Does not send mail or mutate state. +- **Smoke test (mutating):** `node smoke-test-mutating.mjs` — runs full create→edit→delete lifecycles for templates (including layout binding), webhooks, and suppressions, plus real email sends between two configured verified addresses. Cleans up after itself. Created from [smoke-test-mutating.example.mjs](smoke-test-mutating.example.mjs); edit `SENDER` and `RECIPIENT` before running. The script refuses to run with the placeholder values still in place. +- **`smoke-test.mjs` and `smoke-test-mutating.mjs` are gitignored.** Copy from the `*.example.mjs` counterparts; this keeps personal verified-sender addresses out of the repo. +- **No linter is configured.** + +## Architecture + +This is a single-file MCP server (`index.js`) using ES modules (`"type": "module"`). The structure within `index.js`: + +1. **Module-level formatting helpers** — `fmtInt`, `fmtPct`, `fmtPlatformUsage`, `fmtTopBreakdown`, `fmtDeliverySummary`, `fmtStatResponse`. Used by `getDeliveryStats` to render polished per-stat output. Keep these pure / dependency-free. +2. **`initializeServices()`** — Validates required env vars, creates a `postmark.ServerClient`, verifies connectivity via `getServer()`, and instantiates `McpServer` from `@modelcontextprotocol/sdk` +3. **`registerTools(server, postmarkClient)`** — Registers all 21 MCP tools with Zod schemas for input validation. Each tool wraps a Postmark SDK call and returns formatted text responses +4. **`main()`** — Orchestrates initialization, tool registration, and connects to `StdioServerTransport` + +Communication uses stdio transport (stdin/stdout), not HTTP. All `console.error` calls are diagnostic logging (stdout is reserved for MCP protocol). + +## MCP Tools Registered + +The server exposes 24 tools across these categories: +- **Email:** sendEmail, sendEmailWithTemplate, sendBatch, sendBatchWithTemplate +- **Templates:** listTemplates, getTemplate, createTemplate, editTemplate, deleteTemplate, validateTemplate +- **Messages:** searchOutboundMessages, getMessageDetails +- **Diagnostics:** diagnoseDelivery +- **Bounces:** searchBounces, getBounceDump, activateBounce +- **Suppressions:** listSuppressions, createSuppressions, deleteSuppressions +- **Stats & Server:** getDeliveryStats, getServerInfo +- **Webhooks:** listWebhooks, createWebhook, deleteWebhook + +`sendBatch` and `sendBatchWithTemplate` wrap Postmark's *synchronous* batch endpoints (`/email/batch` and `/email/batchWithTemplates` respectively), each accepting up to 500 messages. Both share a `formatBatchResults` helper defined inside `registerTools` that splits the SDK response by `ErrorCode` (0 = success, non-zero = failure) and renders a summary with capped success/failure lists. `sendBatchWithTemplate` accepts a top-level `from` / `tag` that's overridable per recipient — keep that override semantics if extending. Note: these are Postmark's *synchronous* batch endpoints — the API call returns per-message results in the response. Postmark's separate *asynchronous* bulk email API (`/email/bulk` — submit a job, get a request ID, poll `GET /email/bulk/{id}` for status; no message count cap, 50 MB payload limit; subject to approval) is a parallel capability not covered in v2.0. The two are parallel APIs in Postmark's docs, not one replacing the other; bulk is tracked as a v2.1 follow-up. If anyone asks "where's the bulk send tool," that's why — and adding it is more than a quick wrap because the SDK doesn't expose `/email/bulk` and the async pattern likely needs two tools (`submitBulk` + `getBulkStatus`). + +`diagnoseDelivery` is a **composite tool** — it does not mirror a single Postmark endpoint. Instead it runs `getOutboundMessages` (or `getOutboundMessageDetails`), `getSuppressions`, and `getBounces` in parallel for the given recipient, then synthesizes a plain-English recommendation. When adding new diagnostic tools, follow the same pattern: parallelize independent lookups via `Promise.all`, swallow individual failures with `.catch(() => fallback)` so one 404 doesn't sink the whole diagnosis, and end with a `Recommended action` block that interprets the data. + +`getDeliveryStats` is unified: with no arguments it returns a friendly headline summary; with `stat: ""` it returns a polished per-stat breakdown. Supported `stat` values: `summary`, `overview`, `sent`, `bounces`, `spam`, `tracked`, `opens`, `openPlatforms`, `openClients`, `openReadTimes`, `clicks`, `clickBrowsers`, `clickPlatforms`, `clickLocation`. Each value maps to a specific `postmarkClient.get*` method in the `fetchers` object inside the tool — when adding a new stat, add both the enum value and the fetcher entry. + +## Environment Variables + +Required (set in `.env` for local dev, or in MCP client config for production): +- `POSTMARK_SERVER_TOKEN` — Postmark server API token +- `DEFAULT_SENDER_EMAIL` — Fallback sender address +- `DEFAULT_MESSAGE_STREAM` — Message stream ID (typically `outbound`) + +## Key Patterns + +- All emails are auto-configured with `TrackOpens: true` and `TrackLinks: "HtmlAndText"` +- All tools use the `postmark` npm client (no raw `fetch` — the SDK handles auth, retries, and error mapping consistently) +- Tool handlers follow a consistent pattern: log start, call API, log result, return `{ content: [{ type: "text", text }] }` +- Postmark API field names are PascalCase (`From`, `To`, `Subject`, `EmailAddress`); query-string filters on list endpoints are typically lowercase (`fromdate`, `todate`, `messagestream`). The SDK accepts both since Postmark's API is case-insensitive on query params, but match the convention you see in nearby code +- Numeric IDs use `z.number().int()`; date strings use `.regex(/^\d{4}-\d{2}-\d{2}$/)` for YYYY-MM-DD validation +- Tools that mutate state include lightweight validation guards before hitting the API: + - `editTemplate` requires at least one updated field (otherwise the call would be a wasted no-op) + - `createWebhook` requires at least one trigger enabled (otherwise the webhook is dead-on-arrival) + - `createTemplate` requires at least one of `htmlBody` / `textBody` + - `validateTemplate` requires at least one of `subject` / `htmlBody` / `textBody` + - `sendEmailWithTemplate` requires exactly one of `templateId` / `templateAlias` + +## Gotchas + +- The Postmark suppressions list endpoint (`/message-streams/{stream}/suppressions/dump`) is **eventually consistent** — a suppression created milliseconds earlier may not appear in the next `listSuppressions` call. Don't write tests that assume strict read-your-writes for this endpoint. +- `getServer()` returns `PostFirstOpenOnly` for the "first open only" tracking setting. `EnableSmtpApiErrorHooks` is a different (unrelated) flag — don't conflate them in `getServerInfo`'s output. +- `getOutboundOverview` includes `Opens` / `UniqueOpens` despite the field naming — the response is broader than the endpoint name suggests, which is why `getDeliveryStats` can use it for both `summary` and `overview` modes. +- Several SDK methods don't exist with intuitive names. Use the correct ones: `getEmailOpenClientUsage` (not `getEmailClientUsage`), `getEmailOpenPlatformUsage` (not `getEmailPlatformUsage`), `getClickLocation` (not `getClickLocationUsage`). diff --git a/README.md b/README.md index f3ca2c1..07b1234 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,18 @@ Send emails 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 backed by your [Postmark account](https://account.postmarkapp.com/sign_up) +- 24 tools spanning email sending (single + batch), templates (CRUD + validation), message search, delivery diagnostics, bounces, suppressions, stats, server info, and webhooks - Simple configuration via environment variables - Comprehensive error handling and graceful shutdown - Secure logging practices (no sensitive data exposure) -- Automatic email tracking configuration +- Automatic open/click tracking on every send ## Useful Docs - [📒 API Documentation](https://postmarkapp.com/developer) - [🔎 API Explorer](https://postmarkapp.com/api-explorer) - [📖 Engineering Articles](https://postmarkapp.com/blog/topics/engineering) +- [📝 Changelog](CHANGELOG.md) — what's new in each release ## Feedback We'd love to hear from you! Please share your feedback and suggestions using our [feedback form](https://forms.gle/zVdZLAJPM81Vo2Wh8). @@ -24,7 +26,7 @@ Follow us on X - [@postmarkapp](https://x.com/postmarkapp) # Setup ## Requirements -- Node.js (v16 or higher recommended) +- Node.js v20 or higher - A [Postmark account](https://account.postmarkapp.com/sign_up) and server token ## Installation (Local Development) @@ -71,6 +73,25 @@ yarn start bun start ``` +**Smoke test (requires valid `.env`):** + +The repo ships two smoke-test example files. Copy each to its non-example name (which is gitignored) before running, so your local edits — including any verified-sender addresses — never end up committed. + +```sh +# Read-only suite (25 checks). Optionally edit RECIPIENT_WITH_HISTORY. +cp smoke-test.example.mjs smoke-test.mjs +npm run smoke + +# Mutating suite (full lifecycles + real email sends). +# REQUIRED: edit SENDER and RECIPIENT to two of your verified addresses. +cp smoke-test-mutating.example.mjs smoke-test-mutating.mjs +node smoke-test-mutating.mjs +``` + +The read-only suite spawns the server over stdio and exercises every read tool against your Postmark account, plus the validation paths for `editTemplate` and `createWebhook`. Does not send mail or mutate state. + +The mutating suite runs full create→edit→delete lifecycles for templates (including layout binding), webhooks, and suppressions, and sends real emails between the two addresses you configure. It cleans up after itself. The script refuses to run while the placeholder values are still in place. + ## Cursor Quick Install
@@ -102,24 +123,52 @@ After installing the MCP, update your configuration to set: ``` ## Tools -This section provides a complete reference for the Postmark MCP server tools including example prompts and payloads. +This section provides a complete reference for the Postmark MCP server tools including example prompts and payloads. The server registers **24 tools** organized into eight categories. ### Table of Contents -- [Email Management Tools](#email-management-tools) - - [sendEmail](#1-sendemail) - - [sendEmailWithTemplate](#2-sendemailwithtemplate) -- [Template Management Tools](#template-management-tools) - - [listTemplates](#3-listtemplates) -- [Statistics & Tracking Tools](#statistics--tracking-tools) - - [getDeliveryStats](#4-getdeliverystats) - -## Email Management Tools -### 1. sendEmail -Sends a single text email. +- [Email](#email) + - [sendEmail](#sendemail) + - [sendEmailWithTemplate](#sendemailwithtemplate) + - [sendBatch](#sendbatch) + - [sendBatchWithTemplate](#sendbatchwithtemplate) +- [Templates](#templates) + - [listTemplates](#listtemplates) + - [getTemplate](#gettemplate) + - [createTemplate](#createtemplate) + - [editTemplate](#edittemplate) + - [deleteTemplate](#deletetemplate) + - [validateTemplate](#validatetemplate) +- [Messages](#messages) + - [searchOutboundMessages](#searchoutboundmessages) + - [getMessageDetails](#getmessagedetails) +- [Diagnostics](#diagnostics) + - [diagnoseDelivery](#diagnosedelivery) +- [Bounces](#bounces) + - [searchBounces](#searchbounces) + - [getBounceDump](#getbouncedump) + - [activateBounce](#activatebounce) +- [Suppressions](#suppressions) + - [listSuppressions](#listsuppressions) + - [createSuppressions](#createsuppressions) + - [deleteSuppressions](#deletesuppressions) +- [Stats & Server](#stats--server) + - [getDeliveryStats](#getdeliverystats) + - [getServerInfo](#getserverinfo) +- [Webhooks](#webhooks) + - [listWebhooks](#listwebhooks) + - [createWebhook](#createwebhook) + - [deleteWebhook](#deletewebhook) + +--- + +## Email + +### sendEmail +Sends a single text (and optional HTML) email. **Example Prompt:** ``` -Send an email using Postmark to recipient@example.com with the subject "Meeting Reminder" and the message "Don't forget our team meeting tomorrow at 2 PM. Please bring your quarterly statistics report (and maybe some snacks)."" +Send an email using Postmark to recipient@example.com with the subject "Meeting Reminder" and the message "Don't forget our team meeting tomorrow at 2 PM." ``` **Expected Payload:** @@ -127,114 +176,429 @@ Send an email using Postmark to recipient@example.com with the subject "Meeting { "to": "recipient@example.com", "subject": "Meeting Reminder", - "textBody": "Don't forget our team meeting tomorrow at 2 PM. Please bring your quarterly statistics report (and maybe some snacks).", - "htmlBody": "HTML version of the email body", // Optional - "from": "sender@example.com", // Optional, uses DEFAULT_SENDER_EMAIL if not provided - "tag": "meetings" // Optional + "textBody": "Don't forget our team meeting tomorrow at 2 PM.", + "htmlBody": "

Don't forget our team meeting tomorrow at 2 PM.

", + "from": "sender@example.com", + "tag": "meetings" } ``` -**Response Format:** +`htmlBody`, `from`, and `tag` are optional. If `from` is omitted, `DEFAULT_SENDER_EMAIL` is used. + +**Response:** ``` Email sent successfully! -MessageID: message-id-here +MessageID: 0a1b2c3d-... To: recipient@example.com Subject: Meeting Reminder ``` -### 2. sendEmailWithTemplate -Sends an email using a pre-defined template. +### sendEmailWithTemplate +Sends an email using a Postmark template. **Example Prompt:** ``` -Send an email with Postmark template alias "welcome" to customer@example.com with the following template variables: -{ - "name": "John Doe", - "product_name": "MyApp", - "login_url": "https://myapp.com/login" -} +Send the "welcome" template to customer@example.com with name "John Doe" and login_url "https://myapp.com/login". ``` **Expected Payload:** ```json { "to": "customer@example.com", - "templateId": 12345, // Either templateId or templateAlias must be provided, but not both - "templateAlias": "welcome", // Either templateId or templateAlias must be provided, but not both + "templateAlias": "welcome", "templateModel": { "name": "John Doe", - "product_name": "MyApp", "login_url": "https://myapp.com/login" }, - "from": "sender@example.com", // Optional, uses DEFAULT_SENDER_EMAIL if not provided - "tag": "onboarding" // Optional + "from": "sender@example.com", + "tag": "onboarding" } ``` -**Response Format:** +Provide **either** `templateId` (number) **or** `templateAlias` (string), not both. + +**Response:** ``` Template email sent successfully! -MessageID: message-id-here -To: recipient@example.com -Template: template-id-or-alias-here +MessageID: 0a1b2c3d-... +To: customer@example.com +Template: welcome ``` -## Template Management Tools -### 3. listTemplates +### sendBatch +Sends up to 500 emails in a single API call. Each message is fully independent (its own recipient, subject, body). This wraps Postmark's *synchronous* batch endpoint (`POST /email/batch`) — the call returns immediate per-message results — and synthesizes the success/failure summary. (Postmark also offers a separate *asynchronous* [bulk email API](https://postmarkapp.com/developer/api/bulk-email) at `/email/bulk` for large-volume jobs with submit-and-poll workflow, no message count cap, and a 50 MB payload limit. That's a parallel capability for different use cases — not currently wrapped by this MCP, tracked as a v2.1 follow-up.) -Lists all available templates. +**Expected Payload:** +```json +{ + "messages": [ + { + "to": "alice@example.com", + "subject": "Order #1234 confirmed", + "textBody": "Thanks Alice — your order is on its way.", + "tag": "order-confirmation" + }, + { + "to": "bob@example.com", + "subject": "Order #1235 confirmed", + "textBody": "Thanks Bob — your order is on its way.", + "tag": "order-confirmation" + } + ] +} +``` -**Example Prompt:** +Per-message fields: `to`, `subject`, `textBody` are required. `htmlBody`, `from`, `cc`, `bcc`, `replyTo`, and `tag` are optional. If `from` is omitted on a message, `DEFAULT_SENDER_EMAIL` is used. + +**Response:** ``` -Show me a list of all the email templates available in our Postmark account. +Sent 2/2 successfully + +Successes: + - alice@example.com — abc-123-def + - bob@example.com — abc-456-ghi ``` -**Response Format:** +When some messages fail at submission (e.g., suppressed recipients), failures are listed first with their `ErrorCode` and reason: ``` -📋 Found 2 templates: +Sent 8/10 successfully (2 failed) -• Basic - - ID: 12345678 - - Alias: basic - - Subject: none +Failures: + - blocked@example.com — 406: Address has been suppressed. + - bad@example.com — 300: Inactive recipient +... +``` + +### sendBatchWithTemplate +Sends up to 500 templated emails — same template, per-recipient template models. Ideal for "render this onboarding template for each new user" flows. -• Welcome - - ID: 02345679 +**Expected Payload:** +```json +{ + "templateAlias": "welcome", + "from": "hello@yourapp.com", + "tag": "onboarding", + "recipients": [ + { "to": "alice@example.com", "templateModel": { "name": "Alice", "plan": "Pro" } }, + { "to": "bob@example.com", "templateModel": { "name": "Bob", "plan": "Free" } } + ] +} +``` + +Provide **either** `templateId` (number) **or** `templateAlias` (string). Top-level `from` and `tag` apply to all recipients but can be overridden per-recipient. Each recipient also accepts optional `cc`, `bcc`, and `replyTo`. + +**Response:** same format as `sendBatch`. + +--- + +## Templates + +### listTemplates +Lists all templates on the server. + +**Response:** +``` +Found 2 templates: + +• **Welcome** + - ID: 12345678 - Alias: welcome - - Subject: none + - Subject: Welcome to {{product_name}} +``` + +### getTemplate +Retrieves a single template's full content (HTML body, text body, subject, type). + +**Payload:** `{ "templateIdOrAlias": "welcome" }` — accepts numeric ID or string alias. + +### createTemplate +Creates a new template. Requires `name`. At least one of `htmlBody` or `textBody` must be provided. + +`subject` is required for Standard templates and must be **omitted** for Layout templates — Postmark rejects the field on Layouts. + +`layoutTemplate` (Standard only) binds the new template to an existing Layout by alias. Without it, the new template renders unwrapped (no chrome from any layout). + +**Expected Payload:** +```json +{ + "name": "Order Confirmation", + "subject": "Your order #{{order_id}} is confirmed", + "htmlBody": "

Thanks {{name}}

", + "textBody": "Thanks {{name}}", + "alias": "order-confirmation", + "templateType": "Standard", + "layoutTemplate": "basic" +} +``` + +`templateType` may be `"Standard"` (default) or `"Layout"`. + +### editTemplate +Updates an existing template. Requires `templateIdOrAlias` plus **at least one** updated field (`name`, `subject`, `htmlBody`, `textBody`, `alias`, or `layoutTemplate`). + +Pass `"layoutTemplate": null` to unbind a template from its current Layout (the MCP translates this to the empty-string the Postmark API requires for clearing the association). + +### deleteTemplate +Deletes a template by ID or alias. + +**Payload:** `{ "templateIdOrAlias": "order-confirmation" }` + +### validateTemplate +Validates template content (Mustachio syntax, undefined variables) without saving. At least one of `subject`, `htmlBody`, or `textBody` is required. + +**Expected Payload:** +```json +{ + "subject": "Order #{{order_id}}", + "htmlBody": "

Thanks {{name}}

", + "textBody": "Thanks {{name}}", + "testRenderModel": { "order_id": 42, "name": "John" }, + "templateType": "Standard" +} +``` + +--- + +## Messages + +### searchOutboundMessages +Searches the outbound message history. + +**Expected Payload (all filters optional):** +```json +{ + "recipient": "user@example.com", + "fromEmail": "sender@example.com", + "tag": "marketing", + "subject": "Welcome", + "status": "sent", + "messageStream": "outbound", + "fromDate": "2025-05-01", + "toDate": "2025-05-15", + "count": 50, + "offset": 0 +} ``` -## Statistics & Tracking Tools -### 4. getDeliveryStats +`status` is one of `queued`, `sent`, `processed`. `count` is 1–500 (default 50). + +### getMessageDetails +Retrieves full details and event timeline for a single outbound message. + +**Payload:** `{ "messageId": "0a1b2c3d-..." }` + +--- + +## Diagnostics + +### diagnoseDelivery +Composite triage tool. Answers "did my email reach X, and if not, why?" by running message search, suppression check, and bounce history lookups in parallel against a recipient address, then synthesizing a plain-English recommendation. -Retrieves email delivery statistics. +This is a **diagnostic** tool: it composes multiple Postmark API calls into a single coherent answer rather than mirroring a single endpoint. **Example Prompt:** ``` -Show me our Postmark email delivery statistics from 2025-05-01 to 2025-05-15 for the "marketing" tag. +Did my email to recipient@example.com get delivered? If not, what should I do? ``` **Expected Payload:** ```json { - "tag": "marketing", // Optional - "fromDate": "2025-05-01", // Optional, YYYY-MM-DD format - "toDate": "2025-05-15" // Optional, YYYY-MM-DD format + "recipient": "recipient@example.com", + "messageId": "0a1b2c3d-...", + "fromDate": "2026-04-21", + "toDate": "2026-04-28", + "messageStream": "outbound" } ``` -**Response Format:** +All fields except `recipient` are optional. If `messageId` is omitted, the most recent message to the recipient is used. The default search window is the last 7 days. + +**Sample response:** ``` -Email Statistics Summary +Delivery Diagnosis: recipient@example.com +──────────────────────────────────────────────── + +Suppression: not suppressed on stream "outbound" + +Most recent message: + MessageID: fadeae4e-fb04-4102-9303-9876078c7b81 + Subject: Welcome to MyApp + Sent: 2026-04-27T18:42:19.0000000-04:00 + Status: Sent + Events: Delivered, Opened×2, Clicked + +Bounce history: none + +Recommended action: + Email was delivered. If recipient says they didn't see it, check their + spam folder or ask them to whitelist the sender domain. +``` + +When the recipient is suppressed, the recommendation differs based on reason: `SpamComplaint` is permanent, `HardBounce` may be reactivatable, `ManualSuppression` can be deleted via `deleteSuppressions`. + +--- + +## Bounces + +### searchBounces +Searches the bounce log with optional filters by type, recipient, tag, message ID, message stream, date range, and active/inactive status. -Sent: 100 emails -Open Rate: 45.5% (45/99 tracked emails) -Click Rate: 15.2% (15/99 tracked links) +**Expected Payload (all optional):** +```json +{ + "type": "HardBounce", + "inactive": true, + "emailFilter": "@example.com", + "tag": "marketing", + "messageID": "0a1b2c3d-...", + "messageStream": "outbound", + "fromDate": "2025-05-01", + "toDate": "2025-05-15", + "count": 50, + "offset": 0 +} +``` + +Supported `type` values (matches Postmark's `BounceType` enum — 22 values): `AddressChange`, `AutoResponder`, `BadEmailAddress`, `Blocked`, `ChallengeVerification`, `DMARCPolicy`, `DnsError`, `HardBounce`, `InboundError`, `ManuallyDeactivated`, `OpenRelayTest`, `SMTPApiError`, `SoftBounce`, `SpamComplaint`, `SpamNotification`, `Subscribe`, `TemplateRenderingFailed`, `Transient`, `Unconfirmed`, `Unknown`, `Unsubscribe`, `VirusNotification`. + +### getBounceDump +Returns the raw SMTP dump for a bounce. Bounce dumps are retained for 30 days. + +**Payload:** `{ "bounceId": 123456 }` + +### activateBounce +Reactivates a deactivated email address (only bounces where `CanActivate: true`). + +**Payload:** `{ "bounceId": 123456 }` + +--- + +## Suppressions + +### listSuppressions +Lists suppressions for a message stream. + +**Expected Payload (all optional):** +```json +{ + "messageStream": "outbound", + "suppressionReason": "HardBounce", + "origin": "Recipient", + "emailAddress": "user@example.com", + "fromDate": "2025-05-01", + "toDate": "2025-05-15" +} +``` -Period: 2025-05-01 to 2025-05-15 +`suppressionReason` ∈ `HardBounce`, `SpamComplaint`, `ManualSuppression`. `origin` ∈ `Recipient`, `Customer`, `Admin`. If `messageStream` is omitted, `DEFAULT_MESSAGE_STREAM` is used. + +### createSuppressions +Suppresses up to 50 email addresses on a message stream. + +**Payload:** `{ "emailAddresses": ["a@example.com", "b@example.com"], "messageStream": "outbound" }` + +### deleteSuppressions +Removes up to 50 addresses from the suppression list. Note: `SpamComplaint` suppressions cannot be deleted. + +**Payload:** `{ "emailAddresses": ["a@example.com"], "messageStream": "outbound" }` + +--- + +## Stats & Server + +### getDeliveryStats +Unified stats tool. Default behavior returns a friendly headline summary; pass an optional `stat` for a focused breakdown. + +**Expected Payload (all optional):** +```json +{ + "stat": "summary", + "tag": "marketing", + "fromDate": "2025-05-01", + "toDate": "2025-05-15", + "messageStream": "outbound" +} +``` + +Supported `stat` values: + +| `stat` | What it returns | +|---|---| +| `summary` *(default)* | Headline open / click / bounce / spam rates | +| `overview` | All overview counts (sent, tracked, opens, clicks, bounces, …) | +| `sent` | Sent count | +| `bounces` | Bounce breakdown by type | +| `spam` | Spam complaint count | +| `tracked` | Tracked email count | +| `opens` | Total + unique opens | +| `openPlatforms` | Open platform breakdown (Desktop / Mobile / WebMail / Unknown) | +| `openClients` | Top 10 email clients (Apple Mail, Gmail, …) | +| `openReadTimes` | Read-time histogram | +| `clicks` | Total + unique link clicks | +| `clickBrowsers` | Top 10 browsers used to click | +| `clickPlatforms` | Click platform breakdown (Desktop / Mobile / WebMail / Unknown) | +| `clickLocation` | HTML vs. plain-text click location | + +**Default summary response:** +``` +Email Delivery Summary + +Sent: 74 +Tracked: 33 (44.6% of sent) +Open rate: 93.9% (31/33 unique opens) +Click rate: 4.8% (10/207 unique links clicked) +Bounced: 1 (1.4%) +Spam: 0 (0.0%) + +Period: 2025-05-01 → 2025-05-15 Tag: marketing ``` +**Sample `stat: "openPlatforms"` response:** +``` +Open Platform Usage + + Desktop 20 (64.5%) + Mobile 0 (0.0%) + WebMail 11 (35.5%) + Unknown 0 (0.0%) +``` + +### getServerInfo +Returns the Postmark server's name, color, tracking settings, and webhook URLs. + +**Payload:** `{}` + +--- + +## Webhooks + +### listWebhooks +Lists configured webhooks. Optional `messageStream` filter. + +### createWebhook +Creates a webhook subscription. Requires a `url` and **at least one** trigger. + +**Expected Payload:** +```json +{ + "url": "https://example.com/postmark-hook", + "messageStream": "outbound", + "openEnabled": true, + "clickEnabled": true, + "deliveryEnabled": false, + "bounceEnabled": true, + "spamComplaintEnabled": true, + "subscriptionChangeEnabled": false +} +``` + +### deleteWebhook +Deletes a webhook by ID. + +**Payload:** `{ "webhookId": 1234567 }` + ## Implementation Details ### Automatic Configuration All emails are automatically configured with: diff --git a/index.js b/index.js index c810157..d7a25cc 100755 --- a/index.js +++ b/index.js @@ -7,7 +7,6 @@ */ import 'dotenv/config'; -import fetch from 'node-fetch'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; @@ -40,7 +39,7 @@ async function initializeServices() { console.error('Message stream: ', defaultMessageStream); const client = new postmark.ServerClient(serverToken); - + // Verify Postmark client by making a test API call await client.getServer(); @@ -71,7 +70,13 @@ 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 (24): sendEmail, sendEmailWithTemplate, sendBatch, sendBatchWithTemplate, ' + + 'listTemplates, getTemplate, createTemplate, editTemplate, deleteTemplate, validateTemplate, ' + + 'searchOutboundMessages, getMessageDetails, diagnoseDelivery, ' + + 'searchBounces, getBounceDump, activateBounce, ' + + 'listSuppressions, createSuppressions, deleteSuppressions, ' + + 'getDeliveryStats, getServerInfo, ' + + 'listWebhooks, createWebhook, deleteWebhook'); process.on('SIGTERM', () => handleShutdown(server)); process.on('SIGINT', () => handleShutdown(server)); @@ -106,9 +111,169 @@ process.on('unhandledRejection', (reason) => { process.exit(1); }); -// Move tool registration to a separate function for better organization +// ───── Formatting helpers ───── + +const fmtInt = n => (n ?? 0).toLocaleString('en-US'); + +const fmtPct = (numerator, denominator) => { + if (!denominator) return '0.0%'; + return `${((numerator / denominator) * 100).toFixed(1)}%`; +}; + +// Render { Desktop, Mobile, WebMail, Unknown } breakdown with percentages +const fmtPlatformUsage = (data) => { + const buckets = ['Desktop', 'Mobile', 'WebMail', 'Unknown']; + const total = buckets.reduce((sum, k) => sum + (data[k] || 0), 0); + if (total === 0) return ' (no data in range)'; + return buckets + .map(k => ` ${k.padEnd(8)} ${fmtInt(data[k] || 0).padStart(8)} (${fmtPct(data[k] || 0, total)})`) + .join('\n'); +}; + +// Render top-N { name: count } breakdown sorted descending. Skips `Days`. +const fmtTopBreakdown = (data, limit = 10) => { + const entries = Object.entries(data) + .filter(([k, v]) => k !== 'Days' && typeof v === 'number') + .sort((a, b) => b[1] - a[1]); + if (entries.length === 0) return ' (no data in range)'; + const total = entries.reduce((sum, [, v]) => sum + v, 0); + const shown = entries.slice(0, limit); + const lines = shown.map(([k, v]) => + ` ${k.padEnd(20)} ${fmtInt(v).padStart(8)} (${fmtPct(v, total)})` + ); + if (entries.length > limit) { + lines.push(` ${`… and ${entries.length - limit} more`}`); + } + return lines.join('\n'); +}; + +// Render delivery summary (default behavior of getDeliveryStats — v1 compatible) +const fmtDeliverySummary = (d, { fromDate, toDate, tag, messageStream } = {}) => { + const sent = d.Sent || 0; + const tracked = d.Tracked || 0; + const bounced = d.Bounced || 0; + const spam = d.SpamComplaints || 0; + const uniqueOpens = d.UniqueOpens || 0; + const totalLinks = d.TotalTrackedLinksSent || 0; + const uniqueClicks = d.UniqueLinksClicked || 0; + + const lines = [ + 'Email Delivery Summary', + '', + `Sent: ${fmtInt(sent)}`, + `Tracked: ${fmtInt(tracked)} (${fmtPct(tracked, sent)} of sent)`, + `Open rate: ${fmtPct(uniqueOpens, tracked)} (${fmtInt(uniqueOpens)}/${fmtInt(tracked)} unique opens)`, + `Click rate: ${fmtPct(uniqueClicks, totalLinks)} (${fmtInt(uniqueClicks)}/${fmtInt(totalLinks)} unique links clicked)`, + `Bounced: ${fmtInt(bounced)} (${fmtPct(bounced, sent)})`, + `Spam: ${fmtInt(spam)} (${fmtPct(spam, sent)})`, + ]; + + const filters = []; + if (fromDate || toDate) filters.push(`Period: ${fromDate || 'start'} → ${toDate || 'now'}`); + if (tag) filters.push(`Tag: ${tag}`); + if (messageStream) filters.push(`Stream: ${messageStream}`); + if (filters.length) { + lines.push(''); + lines.push(...filters); + } + + return lines.join('\n'); +}; + +// Format any of the per-stat responses returned by getDeliveryStats +const fmtStatResponse = (stat, d) => { + switch (stat) { + case 'overview': + return [ + 'Outbound Overview', + '', + `Sent: ${fmtInt(d.Sent)}`, + `Bounced: ${fmtInt(d.Bounced)} (${(d.BounceRate ?? 0).toFixed(2)}%)`, + `SMTP API errors: ${fmtInt(d.SMTPApiErrors)}`, + `Spam complaints: ${fmtInt(d.SpamComplaints)} (${(d.SpamComplaintsRate ?? 0).toFixed(2)}%)`, + `Tracked: ${fmtInt(d.Tracked)}`, + `Opens (total): ${fmtInt(d.Opens)}`, + `Opens (unique): ${fmtInt(d.UniqueOpens)}`, + `Tracked links sent: ${fmtInt(d.TotalTrackedLinksSent)}`, + `Total clicks: ${fmtInt(d.TotalClicks)}`, + `Unique link clicks: ${fmtInt(d.UniqueLinksClicked)}`, + `With open tracking: ${fmtInt(d.WithOpenTracking)}`, + `With link tracking: ${fmtInt(d.WithLinkTracking)}`, + ].join('\n'); + + case 'sent': + return `Sent\n\n Total: ${fmtInt(d.Sent)}`; + + case 'bounces': { + const typeEntries = Object.entries(d) + .filter(([k, v]) => k !== 'Days' && k !== 'Total' && typeof v === 'number' && v > 0) + .sort((a, b) => b[1] - a[1]); + const total = typeEntries.reduce((sum, [, v]) => sum + v, 0); + if (total === 0) return 'Bounces\n\n (no bounces in range)'; + const types = typeEntries + .map(([k, v]) => ` ${k.padEnd(24)} ${fmtInt(v).padStart(8)} (${fmtPct(v, total)})`) + .join('\n'); + return `Bounces\n\n Total: ${fmtInt(total)}\n\n${types}`; + } + + case 'spam': + return `Spam Complaints\n\n Total: ${fmtInt(d.SpamComplaint)}`; + + case 'tracked': + return `Tracked Emails\n\n Total: ${fmtInt(d.Tracked)}`; + + case 'opens': + return [ + 'Email Opens', + '', + ` Total opens: ${fmtInt(d.Opens)}`, + ` Unique opens: ${fmtInt(d.Unique)}`, + ].join('\n'); + + case 'openPlatforms': + return `Open Platform Usage\n\n${fmtPlatformUsage(d)}`; + + case 'openClients': + return `Email Client Usage (top 10)\n\n${fmtTopBreakdown(d)}`; + + case 'openReadTimes': + return `Open Read Times\n\n${fmtTopBreakdown(d)}`; + + case 'clicks': + return [ + 'Link Clicks', + '', + ` Total clicks: ${fmtInt(d.Clicks)}`, + ` Unique clicks: ${fmtInt(d.Unique)}`, + ].join('\n'); + + case 'clickBrowsers': + return `Click Browser Usage (top 10)\n\n${fmtTopBreakdown(d)}`; + + case 'clickPlatforms': + return `Click Platform Usage\n\n${fmtPlatformUsage(d)}`; + + case 'clickLocation': { + const html = d.HTML || 0; + const text = d.Text || 0; + const total = html + text; + return [ + 'Click Location', + '', + ` HTML: ${fmtInt(html).padStart(8)} (${fmtPct(html, total)})`, + ` Text: ${fmtInt(text).padStart(8)} (${fmtPct(text, total)})`, + ].join('\n'); + } + + default: + return JSON.stringify(d, null, 2); + } +}; + +// Tool registration function registerTools(server, postmarkClient) { - // Define and register the sendEmail tool + // ─────────────── Email ─────────────── + server.tool( "sendEmail", { @@ -146,7 +311,6 @@ function registerTools(server, postmarkClient) { } ); - // Define and register the sendEmailWithTemplate tool server.tool( "sendEmailWithTemplate", { @@ -182,17 +346,145 @@ function registerTools(server, postmarkClient) { console.error('Sending template email..', { to, templateId: templateId || templateAlias }); const result = await postmarkClient.sendEmailWithTemplate(emailData); console.error('Template email sent successfully: ', result.MessageID); - + return { content: [{ - type: "text", + type: "text", text: `Template email sent successfully!\nMessageID: ${result.MessageID}\nTo: ${to}\nTemplate: ${templateId || templateAlias}` }] }; } ); - // Define and register the listTemplates tool + // ─────────────── Batch send ─────────────── + + // Format batch send results into successes / failures summary. + // Postmark returns one MessageSendingResponse per input message; ErrorCode + // 0 indicates success. Failed sends still come back with `To` and a + // `Message` describing why. + const formatBatchResults = (results) => { + const successes = results.filter(r => r.ErrorCode === 0); + const failures = results.filter(r => r.ErrorCode !== 0); + const lines = [`Sent ${successes.length}/${results.length} successfully` + + (failures.length ? ` (${failures.length} failed)` : '')]; + + if (failures.length) { + lines.push('', 'Failures:'); + failures.slice(0, 20).forEach(f => { + lines.push(` - ${f.To || '(unknown)'} — ${f.ErrorCode}: ${f.Message}`); + }); + if (failures.length > 20) lines.push(` - ... and ${failures.length - 20} more`); + } + + if (successes.length) { + lines.push('', `Successes${successes.length > 10 ? ' (first 10 shown)' : ''}:`); + successes.slice(0, 10).forEach(s => { + lines.push(` - ${s.To} — ${s.MessageID}`); + }); + if (successes.length > 10) lines.push(` - ... and ${successes.length - 10} more successful sends`); + } + + return lines.join('\n'); + }; + + server.tool( + "sendBatch", + { + messages: z.array(z.object({ + to: z.string().email().describe("Recipient email address"), + subject: z.string().describe("Email subject"), + textBody: z.string().describe("Plain text body"), + htmlBody: z.string().optional().describe("HTML body"), + from: z.string().email().optional().describe("Sender (defaults to DEFAULT_SENDER_EMAIL)"), + cc: z.string().optional().describe("CC recipient(s), comma-separated"), + bcc: z.string().optional().describe("BCC recipient(s), comma-separated"), + replyTo: z.string().email().optional().describe("Reply-To address"), + tag: z.string().optional().describe("Tag for categorization") + })).min(1).max(500).describe("Up to 500 messages to send in a single request") + }, + async ({ messages }) => { + const payload = messages.map(m => { + const msg = { + From: m.from || defaultSender, + To: m.to, + Subject: m.subject, + TextBody: m.textBody, + MessageStream: defaultMessageStream, + TrackOpens: true, + TrackLinks: "HtmlAndText" + }; + if (m.htmlBody) msg.HtmlBody = m.htmlBody; + if (m.cc) msg.Cc = m.cc; + if (m.bcc) msg.Bcc = m.bcc; + if (m.replyTo) msg.ReplyTo = m.replyTo; + if (m.tag) msg.Tag = m.tag; + return msg; + }); + + console.error('Sending batch..', { count: payload.length }); + const results = await postmarkClient.sendEmailBatch(payload); + const failures = results.filter(r => r.ErrorCode !== 0).length; + console.error(`Batch sent: ${results.length - failures}/${results.length} succeeded`); + + return { content: [{ type: "text", text: formatBatchResults(results) }] }; + } + ); + + server.tool( + "sendBatchWithTemplate", + { + templateId: z.number().int().optional().describe("Template ID (use either this or templateAlias)"), + templateAlias: z.string().optional().describe("Template alias (use either this or templateId)"), + from: z.string().email().optional().describe("Default sender for all messages (defaults to DEFAULT_SENDER_EMAIL)"), + tag: z.string().optional().describe("Default tag applied to all messages (overridable per-recipient)"), + recipients: z.array(z.object({ + to: z.string().email().describe("Recipient email address"), + templateModel: z.object({}).passthrough().describe("Per-recipient template variables"), + from: z.string().email().optional().describe("Override sender for this recipient"), + cc: z.string().optional().describe("CC recipient(s), comma-separated"), + bcc: z.string().optional().describe("BCC recipient(s), comma-separated"), + replyTo: z.string().email().optional().describe("Reply-To address"), + tag: z.string().optional().describe("Override tag for this recipient") + })).min(1).max(500).describe("Up to 500 recipients, each with their own template model") + }, + async ({ templateId, templateAlias, from, tag, recipients }) => { + if (!templateId && !templateAlias) { + throw new Error("Either templateId or templateAlias must be provided"); + } + if (templateId && templateAlias) { + throw new Error("Provide only one of templateId or templateAlias, not both"); + } + + const payload = recipients.map(r => { + const msg = { + From: r.from || from || defaultSender, + To: r.to, + TemplateModel: r.templateModel, + MessageStream: defaultMessageStream, + TrackOpens: true, + TrackLinks: "HtmlAndText" + }; + if (templateId) msg.TemplateId = templateId; + else msg.TemplateAlias = templateAlias; + if (r.cc) msg.Cc = r.cc; + if (r.bcc) msg.Bcc = r.bcc; + if (r.replyTo) msg.ReplyTo = r.replyTo; + const effectiveTag = r.tag ?? tag; + if (effectiveTag) msg.Tag = effectiveTag; + return msg; + }); + + console.error('Sending template batch..', { count: payload.length, template: templateId || templateAlias }); + const results = await postmarkClient.sendEmailBatchWithTemplates(payload); + const failures = results.filter(r => r.ErrorCode !== 0).length; + console.error(`Template batch sent: ${results.length - failures}/${results.length} succeeded`); + + return { content: [{ type: "text", text: formatBatchResults(results) }] }; + } + ); + + // ─────────────── Templates ─────────────── + server.tool( "listTemplates", {}, @@ -201,9 +493,17 @@ function registerTools(server, postmarkClient) { const result = await postmarkClient.getTemplates(); console.error(`Found ${result.Templates.length} templates`); - const templateList = result.Templates.map(t => - `• **${t.Name}**\n - ID: ${t.TemplateId}\n - Alias: ${t.Alias || 'none'}\n - Subject: ${t.Subject || 'none'}` - ).join('\n\n'); + const templateList = result.Templates.map(t => { + const lines = [ + `• **${t.Name}**`, + ` - ID: ${t.TemplateId}`, + ` - Alias: ${t.Alias || 'none'}`, + ` - Subject: ${t.Subject || 'none'}`, + ]; + if (t.TemplateType) lines.push(` - Type: ${t.TemplateType}`); + if (t.LayoutTemplate) lines.push(` - Layout: ${t.LayoutTemplate}`); + return lines.join('\n'); + }).join('\n\n'); return { content: [{ @@ -214,55 +514,810 @@ function registerTools(server, postmarkClient) { } ); - // Define and register the getDeliveryStats tool server.tool( - "getDeliveryStats", + "getTemplate", + { + templateIdOrAlias: z.union([z.number(), z.string()]).describe("Template ID (number) or alias (string)") + }, + async ({ templateIdOrAlias }) => { + console.error('Fetching template..', { templateIdOrAlias }); + const result = await postmarkClient.getTemplate(templateIdOrAlias); + console.error('Template retrieved'); + + return { + content: [{ + type: "text", + text: `Template: ${result.Name}\n\n` + + `ID: ${result.TemplateId}\n` + + `Alias: ${result.Alias || 'none'}\n` + + `Subject: ${result.Subject}\n` + + `Type: ${result.TemplateType}\n` + + `Layout: ${result.LayoutTemplate || 'none'}\n` + + `Active: ${result.Active}\n` + + `Associated Server: ${result.AssociatedServerId}\n\n` + + `--- HTML Body ---\n${result.HtmlBody || '(empty)'}\n\n` + + `--- Text Body ---\n${result.TextBody || '(empty)'}` + }] + }; + } + ); + + server.tool( + "createTemplate", { - 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 (optional)"), - toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("End date in YYYY-MM-DD format (optional)") + name: z.string().describe("Template name"), + subject: z.string().optional().describe("Template subject line. Required for Standard templates; must be omitted for Layout templates (Postmark rejects Subject on Layouts)."), + htmlBody: z.string().optional().describe("HTML content of the template"), + textBody: z.string().optional().describe("Plain text content of the template"), + alias: z.string().optional().describe("A unique alias for the template (letters, numbers, dots, hyphens, underscores)"), + templateType: z.enum(["Standard", "Layout"]).optional().describe("Template type (default: Standard)"), + layoutTemplate: z.string().optional().describe("Alias of an existing Layout template to wrap this template's content. Only valid when templateType is 'Standard' (the default).") }, - 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)}`); + async ({ name, subject, htmlBody, textBody, alias, templateType, layoutTemplate }) => { + if (!htmlBody && !textBody) { + throw new Error("At least one of htmlBody or textBody must be provided"); + } + const isLayout = templateType === "Layout"; + if (!isLayout && !subject) { + throw new Error("Subject is required for Standard templates"); + } + if (isLayout && subject) { + throw new Error("Subject must not be provided for Layout templates — Postmark rejects this field on Layouts"); + } + if (layoutTemplate && isLayout) { + throw new Error("layoutTemplate cannot be set on a Layout template — only Standard templates wrap a layout"); + } - const url = `https://api.postmarkapp.com/stats/outbound${query.length ? '?' + query.join('&') : ''}`; + const options = { Name: name }; + if (subject) options.Subject = subject; + if (htmlBody) options.HtmlBody = htmlBody; + if (textBody) options.TextBody = textBody; + if (alias) options.Alias = alias; + if (templateType) options.TemplateType = templateType; + if (layoutTemplate) options.LayoutTemplate = layoutTemplate; - console.error('Fetching delivery stats..'); + console.error('Creating template..', { name }); + const result = await postmarkClient.createTemplate(options); + console.error('Template created: ', result.TemplateId); - const response = await fetch(url, { - headers: { - "Accept": "application/json", - "X-Postmark-Server-Token": serverToken + return { + content: [{ + type: "text", + text: `Template created successfully!\n\n` + + `ID: ${result.TemplateId}\n` + + `Name: ${result.Name}\n` + + `Alias: ${result.Alias || 'none'}\n` + + `Layout: ${result.LayoutTemplate || 'none'}\n` + + `Active: ${result.Active}` + }] + }; + } + ); + + server.tool( + "editTemplate", + { + templateIdOrAlias: z.union([z.number(), z.string()]).describe("Template ID (number) or alias (string)"), + name: z.string().optional().describe("Updated template name"), + subject: z.string().optional().describe("Updated subject line"), + htmlBody: z.string().optional().describe("Updated HTML content"), + textBody: z.string().optional().describe("Updated plain text content"), + alias: z.string().optional().describe("Updated alias"), + layoutTemplate: z.string().nullable().optional().describe("Alias of a Layout template to bind this Standard template to. Pass null to unbind (remove the layout association).") + }, + async ({ templateIdOrAlias, name, subject, htmlBody, textBody, alias, layoutTemplate }) => { + const options = {}; + if (name) options.Name = name; + if (subject) options.Subject = subject; + if (htmlBody) options.HtmlBody = htmlBody; + if (textBody) options.TextBody = textBody; + if (alias) options.Alias = alias; + // Postmark's edit endpoint treats JSON null as "no change". Sending an + // empty string is the documented way to unbind a layout association. + if (layoutTemplate !== undefined) { + options.LayoutTemplate = layoutTemplate === null ? "" : layoutTemplate; + } + + if (Object.keys(options).length === 0) { + throw new Error("Provide at least one field to update (name, subject, htmlBody, textBody, alias, or layoutTemplate)"); + } + + console.error('Editing template..', { templateIdOrAlias }); + const result = await postmarkClient.editTemplate(templateIdOrAlias, options); + console.error('Template updated: ', result.TemplateId); + + return { + content: [{ + type: "text", + text: `Template updated successfully!\n\n` + + `ID: ${result.TemplateId}\n` + + `Name: ${result.Name}\n` + + `Alias: ${result.Alias || 'none'}\n` + + `Layout: ${result.LayoutTemplate || 'none'}\n` + + `Active: ${result.Active}` + }] + }; + } + ); + + server.tool( + "deleteTemplate", + { + templateIdOrAlias: z.union([z.number(), z.string()]).describe("Template ID (number) or alias (string) to delete") + }, + async ({ templateIdOrAlias }) => { + console.error('Deleting template..', { templateIdOrAlias }); + await postmarkClient.deleteTemplate(templateIdOrAlias); + console.error('Template deleted'); + + return { + content: [{ + type: "text", + text: `Template "${templateIdOrAlias}" deleted successfully.` + }] + }; + } + ); + + server.tool( + "validateTemplate", + { + subject: z.string().optional().describe("Template subject to validate"), + htmlBody: z.string().optional().describe("HTML body to validate"), + textBody: z.string().optional().describe("Text body to validate"), + testRenderModel: z.object({}).passthrough().optional().describe("Test data model to render the template with"), + templateType: z.enum(["Standard", "Layout"]).optional().describe("Template type (default: Standard)"), + layoutTemplate: z.string().optional().describe("Layout template alias to validate against") + }, + async ({ subject, htmlBody, textBody, testRenderModel, templateType, layoutTemplate }) => { + if (!subject && !htmlBody && !textBody) { + throw new Error("At least one of subject, htmlBody, or textBody must be provided"); + } + + const options = {}; + if (subject) options.Subject = subject; + if (htmlBody) options.HtmlBody = htmlBody; + if (textBody) options.TextBody = textBody; + if (testRenderModel) options.TestRenderModel = testRenderModel; + if (templateType) options.TemplateType = templateType; + if (layoutTemplate) options.LayoutTemplate = layoutTemplate; + + console.error('Validating template..'); + const result = await postmarkClient.validateTemplate(options); + console.error('Template validation complete'); + + const sections = []; + + if (result.Subject) { + sections.push(`Subject: ${result.Subject.ContentIsValid ? 'Valid' : 'INVALID'}` + + (result.Subject.ValidationErrors?.length ? `\n Errors: ${result.Subject.ValidationErrors.map(e => e.Message).join(', ')}` : '') + + (result.Subject.RenderedContent ? `\n Rendered: ${result.Subject.RenderedContent}` : '')); + } + if (result.HtmlBody) { + sections.push(`HTML Body: ${result.HtmlBody.ContentIsValid ? 'Valid' : 'INVALID'}` + + (result.HtmlBody.ValidationErrors?.length ? `\n Errors: ${result.HtmlBody.ValidationErrors.map(e => e.Message).join(', ')}` : '')); + } + if (result.TextBody) { + sections.push(`Text Body: ${result.TextBody.ContentIsValid ? 'Valid' : 'INVALID'}` + + (result.TextBody.ValidationErrors?.length ? `\n Errors: ${result.TextBody.ValidationErrors.map(e => e.Message).join(', ')}` : '')); + } + + const allValid = result.AllContentIsValid; + + return { + content: [{ + type: "text", + text: `Template Validation: ${allValid ? 'ALL VALID' : 'HAS ERRORS'}\n\n${sections.join('\n\n')}` + }] + }; + } + ); + + // ─────────────── Messages ─────────────── + + server.tool( + "searchOutboundMessages", + { + recipient: z.string().optional().describe("Filter by recipient email address"), + fromEmail: z.string().optional().describe("Filter by sender email address"), + tag: z.string().optional().describe("Filter by tag"), + subject: z.string().optional().describe("Filter by subject line"), + status: z.enum(["queued", "sent", "processed"]).optional().describe("Filter by message status"), + messageStream: z.string().optional().describe("Filter by message stream ID (e.g. 'outbound')"), + 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"), + count: z.number().int().min(1).max(500).optional().describe("Number of results to return (default 50, max 500)"), + offset: z.number().int().min(0).optional().describe("Pagination offset (default 0)") + }, + async ({ recipient, fromEmail, tag, subject, status, messageStream, fromDate, toDate, count, offset }) => { + const filter = {}; + if (recipient) filter.recipient = recipient; + if (fromEmail) filter.fromemail = fromEmail; + if (tag) filter.tag = tag; + if (subject) filter.subject = subject; + if (status) filter.status = status; + if (messageStream) filter.messagestream = messageStream; + if (fromDate) filter.fromdate = fromDate; + if (toDate) filter.todate = toDate; + filter.count = count || 50; + filter.offset = offset || 0; + + console.error('Searching outbound messages..', filter); + const result = await postmarkClient.getOutboundMessages(filter); + console.error(`Found ${result.TotalCount} messages`); + + if (result.Messages.length === 0) { + return { content: [{ type: "text", text: "No messages found matching your criteria." }] }; + } + + const messageList = result.Messages.map(m => + `• **${m.Subject}**\n - MessageID: ${m.MessageID}\n - To: ${m.Recipients.join(', ')}\n - From: ${m.From}\n - Status: ${m.Status}\n - Date: ${m.ReceivedAt}\n - Tag: ${m.Tag || 'none'}` + ).join('\n\n'); + + return { + content: [{ + type: "text", + text: `Found ${result.TotalCount} messages (showing ${result.Messages.length}):\n\n${messageList}` + }] + }; + } + ); + + server.tool( + "getMessageDetails", + { + messageId: z.string().describe("The MessageID of the email to retrieve details for") + }, + async ({ messageId }) => { + console.error('Fetching message details..', { messageId }); + const result = await postmarkClient.getOutboundMessageDetails(messageId); + console.error('Message details retrieved'); + + const events = (result.MessageEvents || []).map(e => + ` - ${e.Type} at ${e.ReceivedAt}${e.Details?.Summary ? ` (${e.Details.Summary})` : ''}` + ).join('\n'); + + return { + content: [{ + type: "text", + text: `Message Details\n\n` + + `MessageID: ${result.MessageID}\n` + + `Subject: ${result.Subject}\n` + + `From: ${result.From}\n` + + `To: ${(result.Recipients || []).join(', ')}\n` + + `Status: ${result.Status}\n` + + `Date: ${result.ReceivedAt}\n` + + `Tag: ${result.Tag || 'none'}\n` + + `${events ? `\nEvents:\n${events}` : '\nNo events recorded.'}` + }] + }; + } + ); + + // ─────────────── Diagnostics ─────────────── + + // Composite triage tool: answers "did my email reach X, and if not, why?" + // by running searches/lookups in parallel and synthesizing a recommendation. + server.tool( + "diagnoseDelivery", + { + recipient: z.string().email().describe("The recipient address to investigate"), + messageId: z.string().optional().describe("Optional specific MessageID to investigate. If omitted, the most recent message to recipient is used"), + fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Search window start, YYYY-MM-DD (default: 7 days ago)"), + toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Search window end, YYYY-MM-DD (default: today)"), + messageStream: z.string().optional().describe("Message stream for suppression check (default: DEFAULT_MESSAGE_STREAM)") + }, + async ({ recipient, messageId, fromDate, toDate, messageStream }) => { + const stream = messageStream || defaultMessageStream; + const today = new Date().toISOString().slice(0, 10); + const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); + const windowFrom = fromDate || weekAgo; + const windowTo = toDate || today; + + console.error('Diagnosing delivery..', { recipient, messageId, stream }); + + // Independent lookups in parallel — each tolerant of failure so a + // single 404 (e.g., bad messageId) doesn't sink the whole diagnosis. + const [messages, suppressions, bounces] = await Promise.all([ + messageId + ? postmarkClient.getOutboundMessageDetails(messageId) + .then(r => [r]).catch(() => []) + : postmarkClient.getOutboundMessages({ + recipient, + fromdate: windowFrom, + todate: windowTo, + count: 5, + }).then(r => r.Messages || []).catch(() => []), + postmarkClient.getSuppressions(stream, { EmailAddress: recipient }) + .then(r => r.Suppressions || []).catch(() => []), + postmarkClient.getBounces({ emailFilter: recipient, count: 10 }) + .then(r => r.Bounces || []).catch(() => []), + ]); + + // Promote the most recent search hit to full details so we have events + let latest = null; + if (messages.length > 0) { + latest = messages[0].MessageEvents + ? messages[0] + : await postmarkClient.getOutboundMessageDetails(messages[0].MessageID).catch(() => messages[0]); + } + + const lines = [`Delivery Diagnosis: ${recipient}`, '─'.repeat(48), '']; + + // 1. Suppression status (most decisive single signal) + if (suppressions.length > 0) { + const s = suppressions[0]; + lines.push(`Suppression: SUPPRESSED on stream "${stream}"`); + lines.push(` Reason: ${s.SuppressionReason}`); + lines.push(` Origin: ${s.Origin}`); + lines.push(` Since: ${s.CreatedAt}`); + } else { + lines.push(`Suppression: not suppressed on stream "${stream}"`); + } + lines.push(''); + + // 2. Most recent message + its events + if (latest) { + const events = (latest.MessageEvents || []).map(e => e.Type); + const counts = events.reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {}); + lines.push('Most recent message:'); + lines.push(` MessageID: ${latest.MessageID}`); + lines.push(` Subject: ${latest.Subject || '(none)'}`); + lines.push(` Sent: ${latest.ReceivedAt}`); + lines.push(` Status: ${latest.Status}`); + if (events.length) { + const summary = Object.entries(counts) + .map(([t, n]) => n > 1 ? `${t}×${n}` : t).join(', '); + lines.push(` Events: ${summary}`); } - }); + } else { + lines.push(`No messages found for ${recipient} between ${windowFrom} and ${windowTo}.`); + } + lines.push(''); + + // 3. Bounce history + if (bounces.length > 0) { + lines.push(`Bounce history (${bounces.length} recent):`); + bounces.slice(0, 3).forEach(b => { + const reactivatable = b.CanActivate ? ' [can reactivate]' : ''; + lines.push(` - ${b.BouncedAt} ${b.Type}: ${b.Description}${reactivatable}`); + }); + if (bounces.length > 3) lines.push(` - ... and ${bounces.length - 3} more`); + } else { + lines.push('Bounce history: none'); + } + lines.push(''); + + // 4. Synthesized recommendation + lines.push('Recommended action:'); + if (suppressions.length > 0) { + const s = suppressions[0]; + if (s.SuppressionReason === 'SpamComplaint') { + lines.push(' Recipient marked previous mail as spam — suppression is permanent and'); + lines.push(' cannot be lifted via API. Do not retry.'); + } else if (s.SuppressionReason === 'HardBounce') { + const reactivatable = bounces.find(b => b.CanActivate); + if (reactivatable) { + lines.push(` Run activateBounce with bounceId ${reactivatable.ID}, then resend.`); + } else { + lines.push(' Address hard-bounced. Verify the address is valid before retrying;'); + lines.push(' if confirmed valid, run deleteSuppressions then resend.'); + } + } else { + lines.push(' Run deleteSuppressions with this address to lift the suppression, then resend.'); + } + } else if (latest && (latest.MessageEvents || []).some(e => e.Type === 'Delivered')) { + lines.push(' Email was delivered. If recipient says they didn\'t see it, check their'); + lines.push(' spam folder or ask them to whitelist the sender domain.'); + } else if (latest?.Status === 'Queued') { + lines.push(' Most recent message is still queued. Re-run in a few minutes.'); + } else if (!latest) { + lines.push(` No recent send to this address. Use sendEmail to send, or expand the date range.`); + } else { + lines.push(` Message status is "${latest.Status}". Review events above to identify the failure.`); + } + + return { content: [{ type: "text", text: lines.join('\n') }] }; + } + ); + + // ─────────────── Bounces ─────────────── + + server.tool( + "searchBounces", + { + type: z.enum([ + "AddressChange", "AutoResponder", "BadEmailAddress", "Blocked", + "ChallengeVerification", "DMARCPolicy", "DnsError", "HardBounce", + "InboundError", "ManuallyDeactivated", "OpenRelayTest", "SMTPApiError", + "SoftBounce", "SpamComplaint", "SpamNotification", "Subscribe", + "TemplateRenderingFailed", "Transient", "Unconfirmed", "Unknown", + "Unsubscribe", "VirusNotification" + ]).optional().describe("Filter by bounce type (matches Postmark's BounceType enum — 22 values)"), + inactive: z.boolean().optional().describe("Filter by deactivated status"), + emailFilter: z.string().optional().describe("Filter by full or partial email address"), + tag: z.string().optional().describe("Filter by tag"), + messageID: z.string().optional().describe("Filter by original message ID"), + messageStream: z.string().optional().describe("Filter by message stream ID (e.g. 'outbound')"), + 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"), + count: z.number().int().min(1).max(500).optional().describe("Number of results (default 50, max 500)"), + offset: z.number().int().min(0).optional().describe("Pagination offset (default 0)") + }, + async ({ type, inactive, emailFilter, tag, messageID, messageStream, fromDate, toDate, count, offset }) => { + const filter = {}; + if (type) filter.type = type; + if (inactive !== undefined) filter.inactive = inactive; + if (emailFilter) filter.emailFilter = emailFilter; + if (tag) filter.tag = tag; + if (messageID) filter.messageID = messageID; + if (messageStream) filter.messagestream = messageStream; + if (fromDate) filter.fromdate = fromDate; + if (toDate) filter.todate = toDate; + filter.count = count || 50; + filter.offset = offset || 0; + + console.error('Searching bounces..', filter); + const result = await postmarkClient.getBounces(filter); + console.error(`Found ${result.TotalCount} bounces`); + + if (result.Bounces.length === 0) { + return { content: [{ type: "text", text: "No bounces found matching your criteria." }] }; + } + + const bounceList = result.Bounces.map(b => + `• **${b.Email}**\n - BounceID: ${b.ID}\n - Type: ${b.Type} (${b.TypeCode})\n - Description: ${b.Description}\n - Date: ${b.BouncedAt}\n - Inactive: ${b.Inactive}\n - Can Activate: ${b.CanActivate}\n - Subject: ${b.Subject || 'N/A'}\n - Tag: ${b.Tag || 'none'}` + ).join('\n\n'); + + return { + content: [{ + type: "text", + text: `Found ${result.TotalCount} bounces (showing ${result.Bounces.length}):\n\n${bounceList}` + }] + }; + } + ); + + server.tool( + "getBounceDump", + { + bounceId: z.number().int().describe("The ID of the bounce to retrieve the SMTP dump for") + }, + async ({ bounceId }) => { + console.error('Fetching bounce dump..', { bounceId }); + const result = await postmarkClient.getBounceDump(bounceId); + console.error('Bounce dump retrieved'); + + return { + content: [{ + type: "text", + text: result.Body + ? `SMTP Bounce Dump (Bounce ID: ${bounceId}):\n\n${result.Body}` + : `No SMTP dump available for bounce ${bounceId}. Dumps are retained for 30 days.` + }] + }; + } + ); + + server.tool( + "activateBounce", + { + bounceId: z.number().int().describe("The ID of the bounce to reactivate") + }, + async ({ bounceId }) => { + console.error('Activating bounce..', { bounceId }); + const result = await postmarkClient.activateBounce(bounceId); + console.error('Bounce activated'); + + return { + content: [{ + type: "text", + text: `Bounce reactivated successfully!\n\n` + + `Bounce ID: ${result.Bounce.ID}\n` + + `Email: ${result.Bounce.Email}\n` + + `Message: ${result.Message}` + }] + }; + } + ); + + // ─────────────── Suppressions ─────────────── + + server.tool( + "listSuppressions", + { + messageStream: z.string().optional().describe("Message stream ID (default: DEFAULT_MESSAGE_STREAM)"), + suppressionReason: z.enum(["HardBounce", "SpamComplaint", "ManualSuppression"]).optional().describe("Filter by suppression reason"), + origin: z.enum(["Recipient", "Customer", "Admin"]).optional().describe("Filter by suppression origin"), + emailAddress: z.string().optional().describe("Filter by full or partial email address"), + 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 ({ messageStream, suppressionReason, origin, emailAddress, fromDate, toDate }) => { + const stream = messageStream || defaultMessageStream; + const filter = {}; + if (suppressionReason) filter.SuppressionReason = suppressionReason; + if (origin) filter.Origin = origin; + if (emailAddress) filter.EmailAddress = emailAddress; + if (fromDate) filter.fromdate = fromDate; + if (toDate) filter.todate = toDate; + + console.error('Fetching suppressions..', { stream, filter }); + const result = await postmarkClient.getSuppressions(stream, filter); + console.error(`Found ${result.Suppressions.length} suppressions`); + + if (result.Suppressions.length === 0) { + return { content: [{ type: "text", text: `No suppressions found on stream "${stream}" matching your criteria.` }] }; + } + + const list = result.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 ${result.Suppressions.length} suppressions (stream: ${stream}):\n\n${list}` + }] + }; + } + ); + + server.tool( + "createSuppressions", + { + emailAddresses: z.array(z.string().email()).min(1).max(50).describe("Email addresses to suppress (max 50)"), + messageStream: z.string().optional().describe("Message stream ID (default: DEFAULT_MESSAGE_STREAM)") + }, + async ({ emailAddresses, messageStream }) => { + const stream = messageStream || defaultMessageStream; + const options = { + Suppressions: emailAddresses.map(email => ({ EmailAddress: email })) + }; + + console.error('Creating suppressions..', { count: emailAddresses.length, stream }); + const result = await postmarkClient.createSuppressions(stream, options); + console.error('Suppressions created'); - if (!response.ok) { - throw new Error(`API request failed: ${response.status} ${response.statusText}`); + const list = result.Suppressions.map(s => + `• ${s.EmailAddress}: ${s.Status}${s.Message ? ` — ${s.Message}` : ''}` + ).join('\n'); + + return { + content: [{ + type: "text", + text: `Suppression results (stream: ${stream}):\n\n${list}` + }] + }; + } + ); + + server.tool( + "deleteSuppressions", + { + emailAddresses: z.array(z.string().email()).min(1).max(50).describe("Email addresses to unsuppress (max 50). Note: SpamComplaint suppressions cannot be deleted."), + messageStream: z.string().optional().describe("Message stream ID (default: DEFAULT_MESSAGE_STREAM)") + }, + async ({ emailAddresses, messageStream }) => { + const stream = messageStream || defaultMessageStream; + const options = { + Suppressions: emailAddresses.map(email => ({ EmailAddress: email })) + }; + + console.error('Deleting suppressions..', { count: emailAddresses.length, stream }); + const result = await postmarkClient.deleteSuppressions(stream, options); + console.error('Suppressions deleted'); + + const list = result.Suppressions.map(s => + `• ${s.EmailAddress}: ${s.Status}${s.Message ? ` — ${s.Message}` : ''}` + ).join('\n'); + + return { + content: [{ + type: "text", + text: `Suppression deletion results (stream: ${stream}):\n\n${list}` + }] + }; + } + ); + + // ─────────────── Stats & Server ─────────────── + + server.tool( + "getDeliveryStats", + { + stat: z.enum([ + "summary", "overview", "sent", "bounces", "spam", "tracked", + "opens", "openPlatforms", "openClients", "openReadTimes", + "clicks", "clickBrowsers", "clickPlatforms", "clickLocation" + ]).optional().describe("Which stat to retrieve. Default 'summary' returns headline open/click/bounce rates."), + tag: z.string().optional().describe("Filter by tag"), + 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 ID") + }, + async ({ stat, tag, fromDate, toDate, messageStream }) => { + const filter = {}; + if (tag) filter.tag = tag; + if (fromDate) filter.fromdate = fromDate; + if (toDate) filter.todate = toDate; + if (messageStream) filter.messagestream = messageStream; + + const requested = stat || "summary"; + console.error('Fetching stats..', { stat: requested, filter }); + + const fetchers = { + summary: () => postmarkClient.getOutboundOverview(filter), + overview: () => postmarkClient.getOutboundOverview(filter), + sent: () => postmarkClient.getSentCounts(filter), + bounces: () => postmarkClient.getBounceCounts(filter), + spam: () => postmarkClient.getSpamComplaintsCounts(filter), + tracked: () => postmarkClient.getTrackedEmailCounts(filter), + opens: () => postmarkClient.getEmailOpenCounts(filter), + openPlatforms: () => postmarkClient.getEmailOpenPlatformUsage(filter), + openClients: () => postmarkClient.getEmailOpenClientUsage(filter), + openReadTimes: () => postmarkClient.getEmailOpenReadTimes(filter), + clicks: () => postmarkClient.getClickCounts(filter), + clickBrowsers: () => postmarkClient.getClickBrowserUsage(filter), + clickPlatforms: () => postmarkClient.getClickPlatformUsage(filter), + clickLocation: () => postmarkClient.getClickLocation(filter), + }; + + const data = await fetchers[requested](); + console.error('Stats retrieved'); + + const text = requested === "summary" + ? fmtDeliverySummary(data, { fromDate, toDate, tag, messageStream }) + : fmtStatResponse(requested, data); + + return { content: [{ type: "text", text }] }; + } + ); + + server.tool( + "getServerInfo", + {}, + async () => { + console.error('Fetching server info..'); + const result = await postmarkClient.getServer(); + console.error('Server info retrieved'); + + return { + content: [{ + type: "text", + text: `Server: ${result.Name}\n\n` + + `ID: ${result.ID}\n` + + `Color: ${result.Color}\n` + + `SMTP Activated: ${result.SmtpApiActivated}\n` + + `Inbound Address: ${result.InboundAddress || 'none'}\n` + + `Inbound Domain: ${result.InboundDomain || 'none'}\n\n` + + `Tracking:\n` + + ` Open Tracking: ${result.TrackOpens}\n` + + ` Link Tracking: ${result.TrackLinks}\n` + + ` First Open Only: ${result.PostFirstOpenOnly}\n\n` + + `Webhooks:\n` + + ` Bounce: ${result.BounceHookUrl || 'none'}\n` + + ` Open: ${result.OpenHookUrl || 'none'}\n` + + ` Delivery: ${result.DeliveryHookUrl || 'none'}\n` + + ` Click: ${result.ClickHookUrl || 'none'}\n` + + ` Inbound: ${result.InboundHookUrl || 'none'}` + }] + }; + } + ); + + // ─────────────── Webhooks ─────────────── + + server.tool( + "listWebhooks", + { + messageStream: z.string().optional().describe("Filter by message stream ID (e.g. 'outbound')") + }, + async ({ messageStream }) => { + const filter = {}; + if (messageStream) filter.MessageStream = messageStream; + + console.error('Fetching webhooks..', filter); + const result = await postmarkClient.getWebhooks(filter); + console.error(`Found ${result.Webhooks.length} webhooks`); + + if (result.Webhooks.length === 0) { + return { content: [{ type: "text", text: "No webhooks configured." }] }; + } + + const list = result.Webhooks.map(w => { + const triggers = []; + if (w.Triggers?.Open?.Enabled) triggers.push('Open'); + if (w.Triggers?.Click?.Enabled) triggers.push('Click'); + if (w.Triggers?.Delivery?.Enabled) triggers.push('Delivery'); + if (w.Triggers?.Bounce?.Enabled) triggers.push('Bounce'); + if (w.Triggers?.SpamComplaint?.Enabled) triggers.push('SpamComplaint'); + if (w.Triggers?.SubscriptionChange?.Enabled) triggers.push('SubscriptionChange'); + + return `• **${w.Url}**\n - ID: ${w.ID}\n - Stream: ${w.MessageStream || 'all'}\n - Triggers: ${triggers.join(', ') || 'none'}`; + }).join('\n\n'); + + return { + content: [{ + type: "text", + text: `Found ${result.Webhooks.length} webhooks:\n\n${list}` + }] + }; + } + ); + + server.tool( + "createWebhook", + { + url: z.string().url().describe("The webhook URL to receive POST requests"), + messageStream: z.string().optional().describe("Message stream ID (e.g. 'outbound')"), + openEnabled: z.boolean().optional().describe("Trigger on email opens"), + clickEnabled: z.boolean().optional().describe("Trigger on link clicks"), + deliveryEnabled: z.boolean().optional().describe("Trigger on email delivery"), + bounceEnabled: z.boolean().optional().describe("Trigger on bounces"), + spamComplaintEnabled: z.boolean().optional().describe("Trigger on spam complaints"), + subscriptionChangeEnabled: z.boolean().optional().describe("Trigger on subscription changes") + }, + async ({ url, messageStream, openEnabled, clickEnabled, deliveryEnabled, bounceEnabled, spamComplaintEnabled, subscriptionChangeEnabled }) => { + const anyTrigger = openEnabled || clickEnabled || deliveryEnabled || + bounceEnabled || spamComplaintEnabled || subscriptionChangeEnabled; + if (!anyTrigger) { + throw new Error("At least one trigger must be enabled (openEnabled, clickEnabled, deliveryEnabled, bounceEnabled, spamComplaintEnabled, or subscriptionChangeEnabled)"); } - const data = await response.json(); - console.error('Stats retrieved successfully'); + const options = { + Url: url, + Triggers: { + Open: { Enabled: openEnabled || false }, + Click: { Enabled: clickEnabled || false }, + Delivery: { Enabled: deliveryEnabled || false }, + Bounce: { Enabled: bounceEnabled || false }, + SpamComplaint: { Enabled: spamComplaintEnabled || false }, + SubscriptionChange: { Enabled: subscriptionChangeEnabled || false } + } + }; + + if (messageStream) options.MessageStream = messageStream; + + console.error('Creating webhook..', { url }); + const result = await postmarkClient.createWebhook(options); + console.error('Webhook created: ', result.ID); + + const triggers = []; + if (result.Triggers?.Open?.Enabled) triggers.push('Open'); + if (result.Triggers?.Click?.Enabled) triggers.push('Click'); + if (result.Triggers?.Delivery?.Enabled) triggers.push('Delivery'); + if (result.Triggers?.Bounce?.Enabled) triggers.push('Bounce'); + if (result.Triggers?.SpamComplaint?.Enabled) triggers.push('SpamComplaint'); + if (result.Triggers?.SubscriptionChange?.Enabled) triggers.push('SubscriptionChange'); + + return { + content: [{ + type: "text", + text: `Webhook created successfully!\n\n` + + `ID: ${result.ID}\n` + + `URL: ${result.Url}\n` + + `Stream: ${result.MessageStream || 'all'}\n` + + `Triggers: ${triggers.join(', ') || 'none'}` + }] + }; + } + ); - const sent = data.Sent || 0; - const tracked = data.Tracked || 0; - const uniqueOpens = data.UniqueOpens || 0; - const totalTrackedLinks = data.TotalTrackedLinksSent || 0; - const uniqueLinksClicked = data.UniqueLinksClicked || 0; - const openRate = tracked > 0 ? ((uniqueOpens / tracked) * 100).toFixed(1) : '0.0'; - const clickRate = totalTrackedLinks > 0 ? ((uniqueLinksClicked / totalTrackedLinks) * 100).toFixed(1) : '0.0'; + server.tool( + "deleteWebhook", + { + webhookId: z.number().int().describe("The ID of the webhook to delete") + }, + async ({ webhookId }) => { + console.error('Deleting webhook..', { webhookId }); + await postmarkClient.deleteWebhook(webhookId); + console.error('Webhook deleted'); return { content: [{ type: "text", - text: `Email Statistics Summary\n\n` + - `Sent: ${sent} emails\n` + - `Open Rate: ${openRate}% (${uniqueOpens}/${tracked} tracked emails)\n` + - `Click Rate: ${clickRate}% (${uniqueLinksClicked}/${totalTrackedLinks} tracked links)\n\n` + - `${fromDate || toDate ? `Period: ${fromDate || 'start'} to ${toDate || 'now'}\n` : ''}` + - `${tag ? `Tag: ${tag}\n` : ''}` + text: `Webhook ${webhookId} deleted successfully.` }] }; } @@ -272,4 +1327,4 @@ function registerTools(server, postmarkClient) { main().catch((error) => { console.error('[ERROR] Failed to start server: ', error.message); process.exit(1); -}); \ No newline at end of file +}); diff --git a/package.json b/package.json index 377eaf6..8bd109c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@activecampaign/postmark-mcp", - "version": "1.0.0", + "version": "2.0.0", "description": "Official Postmark MCP server for sending emails via Claude and AI assistants", "keywords": ["postmark", "activecampaign", "email", "mcp", "model-context-protocol", "claude", "cursor", "ai"], "author": "Jabal Torres", @@ -16,21 +16,24 @@ "main": "index.js", "type": "module", "engines": { - "node": ">=16.0.0" + "node": ">=20.0.0" }, "files": [ "index.js", "README.md", - "LICENSE" + "LICENSE", + "CHANGELOG.md", + "smoke-test.example.mjs", + "smoke-test-mutating.example.mjs" ], "scripts": { "start": "node index.js", - "inspector": "npx @modelcontextprotocol/inspector index.js" + "inspector": "npx @modelcontextprotocol/inspector index.js", + "smoke": "node smoke-test.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "dotenv": "^16.4.5", - "node-fetch": "^3.3.2", "postmark": "^4.0.5", "zod": "^3.23.8" }, diff --git a/smoke-test-mutating.example.mjs b/smoke-test-mutating.example.mjs new file mode 100644 index 0000000..b98a787 --- /dev/null +++ b/smoke-test-mutating.example.mjs @@ -0,0 +1,277 @@ +// Mutating smoke test for the Postmark MCP server. +// +// SETUP: +// 1. Copy this file to `smoke-test-mutating.mjs`: +// cp smoke-test-mutating.example.mjs smoke-test-mutating.mjs +// 2. Edit SENDER and RECIPIENT below to two of YOUR verified Postmark +// addresses. Both must be sender signatures on the same Postmark account. +// 3. Ensure your `.env` has POSTMARK_SERVER_TOKEN, DEFAULT_SENDER_EMAIL, +// and DEFAULT_MESSAGE_STREAM set. +// 4. Run: node smoke-test-mutating.mjs +// +// `smoke-test-mutating.mjs` is gitignored so your local copy stays out +// of the repo. +// +// This script runs full create→edit→delete lifecycles for templates, +// layouts, webhooks, and suppressions, plus real email sends from SENDER +// to RECIPIENT (single, templated, batch of 3, template-batch of 2 — total +// 7 emails). It cleans up after itself; check your inbox to confirm sends. + +import "dotenv/config"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +// ─── Configuration ───────────────────────────────────────────────────── +// REPLACE these placeholders with your verified Postmark sender signatures. +// SENDER is typically your DEFAULT_SENDER_EMAIL; RECIPIENT can be any +// verified address on the same account that you have access to read. +const SENDER = "you@example.com"; // your DEFAULT_SENDER_EMAIL +const RECIPIENT = "another-you@example.com"; // any verified address you control +// ────────────────────────────────────────────────────────────────────── + +// Guard against running with placeholder values — refuses to send mail +// from clearly-illustrative addresses. +const PLACEHOLDERS = ["you@example.com", "another-you@example.com"]; +if (PLACEHOLDERS.includes(SENDER) || PLACEHOLDERS.includes(RECIPIENT)) { + console.error("Error: SENDER and RECIPIENT are still set to placeholder values."); + console.error("Edit the constants near the top of this file with your verified"); + console.error("Postmark sender signatures before running."); + process.exit(1); +} + +const ts = Date.now(); +const TEMPLATE_ALIAS = `mcp-smoke-${ts}`; +const LAYOUT_ALIAS = `mcp-smoke-layout-${ts}`; +const WEBHOOK_URL = `https://example.com/mcp-smoke-${ts}`; +const SUPPRESS_EMAIL = `mcp-smoke-${ts}@example.com`; + +const transport = new StdioClientTransport({ command: "node", args: ["index.js"] }); +const client = new Client({ name: "smoke-mutating", version: "0.0.0" }, { capabilities: {} }); +await client.connect(transport); + +const results = []; +const log = (name, ok, detail = "") => { + results.push({ name, ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail.split("\n")[0].slice(0, 140) : ""}`); +}; + +async function call(name, args = {}) { + try { + const r = await client.callTool({ name, arguments: args }); + if (r.isError) { + return { ok: false, text: r.content?.[0]?.text || "(no message)" }; + } + return { ok: true, text: r.content?.[0]?.text || "" }; + } catch (err) { + return { ok: false, text: err.message }; + } +} + +let createdTemplateId = null; +let createdLayoutId = null; +let createdWebhookId = null; +let suppressionCreated = false; + +try { + // ─────────── Layout template (used for layout-binding lifecycle below) ─────────── + let r = await call("createTemplate", { + name: `MCP Smoke Layout ${ts}`, + htmlBody: "
{{{ @content }}}
smoke layout
", + textBody: "{{{ @content }}}\n\n— smoke layout", + alias: LAYOUT_ALIAS, + templateType: "Layout", + }); + log("createTemplate (Layout)", r.ok, r.text); + if (r.ok) { + const m = r.text.match(/ID:\s*(\d+)/); + if (m) createdLayoutId = parseInt(m[1], 10); + } + + // ─────────── Standard template lifecycle ─────────── + r = await call("createTemplate", { + name: `MCP Smoke ${ts}`, + subject: "Hello {{name}}", + htmlBody: "

Hi {{name}}

Sent at " + ts + "

", + textBody: "Hi {{name}} — sent at " + ts, + alias: TEMPLATE_ALIAS, + layoutTemplate: LAYOUT_ALIAS, + }); + log("createTemplate (with layoutTemplate binding)", + r.ok && r.text.includes(`Layout: ${LAYOUT_ALIAS}`), + r.text); + if (r.ok) { + const m = r.text.match(/ID:\s*(\d+)/); + if (m) createdTemplateId = parseInt(m[1], 10); + } + + r = await call("getTemplate", { templateIdOrAlias: TEMPLATE_ALIAS }); + log("getTemplate (by alias)", r.ok && r.text.includes(`MCP Smoke ${ts}`), r.text); + log("getTemplate (surfaces Layout binding)", + r.ok && r.text.includes(`Layout: ${LAYOUT_ALIAS}`), + r.text); + + r = await call("validateTemplate", { + subject: "Hello {{name}}", + htmlBody: "

Hi {{name}}

", + textBody: "Hi {{name}}", + testRenderModel: { name: "Test User" }, + }); + log("validateTemplate (valid)", r.ok && /ALL VALID/.test(r.text), r.text); + + r = await call("validateTemplate", { + subject: "Hello {{#unclosed}}", + }); + log("validateTemplate (invalid surfaced)", r.ok && /INVALID|HAS ERRORS/i.test(r.text), r.text); + + r = await call("editTemplate", { + templateIdOrAlias: TEMPLATE_ALIAS, + subject: "Updated subject {{name}}", + }); + log("editTemplate", r.ok, r.text); + + // ─────────── Layout binding round-trip ─────────── + r = await call("editTemplate", { + templateIdOrAlias: TEMPLATE_ALIAS, + layoutTemplate: null, + }); + log("editTemplate (unbind layout via null)", + r.ok && r.text.includes(`Layout: none`), + r.text); + + r = await call("getTemplate", { templateIdOrAlias: TEMPLATE_ALIAS }); + log("getTemplate (confirms layout unbound)", + r.ok && r.text.includes(`Layout: none`), + r.text); + + r = await call("editTemplate", { + templateIdOrAlias: TEMPLATE_ALIAS, + layoutTemplate: LAYOUT_ALIAS, + }); + log("editTemplate (rebind layout)", + r.ok && r.text.includes(`Layout: ${LAYOUT_ALIAS}`), + r.text); + + r = await call("listTemplates", {}); + log("listTemplates (rows include Layout binding)", + r.ok && r.text.includes(`Layout: ${LAYOUT_ALIAS}`), + r.text); + + // ─────────── Email sends ─────────── + r = await call("sendEmail", { + to: RECIPIENT, + from: SENDER, + subject: `MCP smoke test ${ts}`, + textBody: "Plain text body from the MCP smoke test.", + htmlBody: "

HTML body from the MCP smoke test.

", + tag: "mcp-smoke-test", + }); + log("sendEmail (real send)", r.ok, r.text); + + r = await call("sendEmailWithTemplate", { + to: RECIPIENT, + from: SENDER, + templateAlias: TEMPLATE_ALIAS, + templateModel: { name: "Test User" }, + tag: "mcp-smoke-test", + }); + log("sendEmailWithTemplate (real send)", r.ok, r.text); + + // ─────────── Batch sends ─────────── + r = await call("sendBatch", { + messages: [ + { to: RECIPIENT, from: SENDER, subject: `MCP batch #1 ${ts}`, textBody: "Batch test 1", tag: "mcp-smoke-test" }, + { to: RECIPIENT, from: SENDER, subject: `MCP batch #2 ${ts}`, textBody: "Batch test 2", tag: "mcp-smoke-test" }, + { to: RECIPIENT, from: SENDER, subject: `MCP batch #3 ${ts}`, textBody: "Batch test 3", tag: "mcp-smoke-test" }, + ], + }); + log("sendBatch (3 messages, formatter renders)", + r.ok && /Sent \d+\/3/.test(r.text), + r.text); + + r = await call("sendBatchWithTemplate", { + templateAlias: TEMPLATE_ALIAS, + from: SENDER, + tag: "mcp-smoke-test", + recipients: [ + { to: RECIPIENT, templateModel: { name: "Test User (batch 1)" } }, + { to: RECIPIENT, templateModel: { name: "Test User (batch 2)" } }, + ], + }); + log("sendBatchWithTemplate (real sends)", + r.ok && /Sent 2\/2/.test(r.text), + r.text); + + // ─────────── Webhook lifecycle ─────────── + r = await call("createWebhook", { + url: WEBHOOK_URL, + messageStream: process.env.DEFAULT_MESSAGE_STREAM, + bounceEnabled: true, + spamComplaintEnabled: true, + }); + log("createWebhook", r.ok, r.text); + if (r.ok) { + const m = r.text.match(/ID:\s*(\d+)/); + if (m) createdWebhookId = parseInt(m[1], 10); + } + + r = await call("listWebhooks", { messageStream: process.env.DEFAULT_MESSAGE_STREAM }); + log("listWebhooks (sees new hook)", + r.ok && r.text.includes(WEBHOOK_URL), + r.text); + + // ─────────── Suppression lifecycle ─────────── + r = await call("createSuppressions", { + emailAddresses: [SUPPRESS_EMAIL], + messageStream: process.env.DEFAULT_MESSAGE_STREAM, + }); + log("createSuppressions", r.ok && /Suppressed|Pending/i.test(r.text), r.text); + if (r.ok && /Suppressed|Pending/i.test(r.text)) suppressionCreated = true; + + // Note: the Postmark suppressions dump endpoint is eventually consistent, + // so we only assert the call succeeds — not that the just-created entry + // appears in the snapshot. + r = await call("listSuppressions", { + messageStream: process.env.DEFAULT_MESSAGE_STREAM, + emailAddress: SUPPRESS_EMAIL, + }); + log("listSuppressions (call succeeds)", r.ok, r.text); + +} finally { + // ─────────── Cleanup ─────────── + console.log("\n──── cleanup ────"); + + if (createdTemplateId !== null || true) { + const r = await call("deleteTemplate", { templateIdOrAlias: TEMPLATE_ALIAS }); + log("deleteTemplate", r.ok, r.text); + } + + if (createdLayoutId !== null) { + const r = await call("deleteTemplate", { templateIdOrAlias: LAYOUT_ALIAS }); + log("deleteTemplate (Layout)", r.ok, r.text); + } else { + log("deleteTemplate (Layout — skipped, never created)", true, ""); + } + + if (createdWebhookId !== null) { + const r = await call("deleteWebhook", { webhookId: createdWebhookId }); + log("deleteWebhook", r.ok, r.text); + } else { + log("deleteWebhook (skipped — no webhook created)", true, ""); + } + + if (suppressionCreated) { + const r = await call("deleteSuppressions", { + emailAddresses: [SUPPRESS_EMAIL], + messageStream: process.env.DEFAULT_MESSAGE_STREAM, + }); + log("deleteSuppressions", r.ok, r.text); + } else { + log("deleteSuppressions (skipped — no suppression created)", true, ""); + } + + await client.close(); + + const failed = results.filter(r => !r.ok).length; + console.log(`\n${results.length - failed}/${results.length} passed`); + process.exit(failed === 0 ? 0 : 1); +} diff --git a/smoke-test.example.mjs b/smoke-test.example.mjs new file mode 100644 index 0000000..2a62f52 --- /dev/null +++ b/smoke-test.example.mjs @@ -0,0 +1,107 @@ +// Smoke test for the Postmark MCP server (read-only). +// +// SETUP: +// 1. Copy this file to `smoke-test.mjs`: cp smoke-test.example.mjs smoke-test.mjs +// 2. Edit RECIPIENT_WITH_HISTORY below if you'd like to exercise the +// diagnoseDelivery happy path against an address you've actually sent to. +// Otherwise the placeholder is fine — the empty-history path is still tested. +// 3. Ensure your `.env` has POSTMARK_SERVER_TOKEN, DEFAULT_SENDER_EMAIL, +// and DEFAULT_MESSAGE_STREAM set. +// 4. Run: npm run smoke +// +// `smoke-test.mjs` is gitignored so your local copy stays out of the repo. +// +// This script spawns ./index.js over stdio and exercises read-only tools. +// Skips: sendEmail, sendEmailWithTemplate, sendBatch, sendBatchWithTemplate, +// createTemplate, editTemplate, deleteTemplate, createWebhook, +// deleteWebhook, activateBounce, createSuppressions, deleteSuppressions +// — these mutate state. See smoke-test-mutating.example.mjs for those. + +import "dotenv/config"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +// ─── Configuration ───────────────────────────────────────────────────── +// Optional: an address you've recently sent to, so diagnoseDelivery has +// real history to report. Leave the placeholder if you don't have one — +// the test will still pass, just exercising the "no recent sends" path. +const RECIPIENT_WITH_HISTORY = "recipient@example.com"; +// ────────────────────────────────────────────────────────────────────── + +const transport = new StdioClientTransport({ + command: "node", + args: ["index.js"], +}); + +const client = new Client({ name: "smoke-test", version: "0.0.0" }, { capabilities: {} }); +await client.connect(transport); + +const results = []; +const record = (name, ok, detail) => { + results.push({ name, ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); +}; + +// 1. tools/list +const toolList = await client.listTools(); +const toolNames = toolList.tools.map(t => t.name).sort(); +const expected = 24; +record(`tools/list (${expected} expected)`, toolNames.length === expected, `${toolNames.length} tools: ${toolNames.join(", ")}`); + +async function call(name, args = {}) { + try { + const r = await client.callTool({ name, arguments: args }); + if (r.isError) { + const text = r.content?.[0]?.text || "(no message)"; + return { ok: false, detail: `tool returned error: ${text.slice(0, 200)}` }; + } + const text = r.content?.[0]?.text || ""; + return { ok: true, detail: text.split("\n")[0].slice(0, 120) }; + } catch (err) { + return { ok: false, detail: err.message }; + } +} + +for (const [label, name, args] of [ + ["getServerInfo", "getServerInfo", {}], + ["listTemplates", "listTemplates", {}], + ["getDeliveryStats (default summary)", "getDeliveryStats", {}], + ["getDeliveryStats stat=overview", "getDeliveryStats", { stat: "overview" }], + ["getDeliveryStats stat=sent", "getDeliveryStats", { stat: "sent" }], + ["getDeliveryStats stat=bounces", "getDeliveryStats", { stat: "bounces" }], + ["getDeliveryStats stat=spam", "getDeliveryStats", { stat: "spam" }], + ["getDeliveryStats stat=tracked", "getDeliveryStats", { stat: "tracked" }], + ["getDeliveryStats stat=opens", "getDeliveryStats", { stat: "opens" }], + ["getDeliveryStats stat=openClients", "getDeliveryStats", { stat: "openClients" }], + ["getDeliveryStats stat=openPlatforms", "getDeliveryStats", { stat: "openPlatforms" }], + ["getDeliveryStats stat=openReadTimes", "getDeliveryStats", { stat: "openReadTimes" }], + ["getDeliveryStats stat=clicks", "getDeliveryStats", { stat: "clicks" }], + ["getDeliveryStats stat=clickBrowsers", "getDeliveryStats", { stat: "clickBrowsers" }], + ["getDeliveryStats stat=clickPlatforms", "getDeliveryStats", { stat: "clickPlatforms" }], + ["getDeliveryStats stat=clickLocation", "getDeliveryStats", { stat: "clickLocation" }], + ["searchOutboundMessages (count=1, messageStream filter)", "searchOutboundMessages", { count: 1, messageStream: process.env.DEFAULT_MESSAGE_STREAM }], + ["diagnoseDelivery (configured recipient)", "diagnoseDelivery", { recipient: RECIPIENT_WITH_HISTORY }], + ["diagnoseDelivery (recipient with no recent sends)", "diagnoseDelivery", { recipient: "no-such-address@example.com" }], + ["searchBounces (count=1)", "searchBounces", { count: 1 }], + ["listSuppressions", "listSuppressions", {}], + ["listWebhooks", "listWebhooks", {}], +]) { + const r = await call(name, args); + record(label, r.ok, r.detail); +} + +const noopEdit = await call("editTemplate", { templateIdOrAlias: "nonexistent" }); +record("editTemplate refuses no-op (validation)", + !noopEdit.ok && /at least one field/i.test(noopEdit.detail), + noopEdit.detail); + +const noTrigWebhook = await call("createWebhook", { url: "https://example.com/hook" }); +record("createWebhook refuses zero triggers (validation)", + !noTrigWebhook.ok && /at least one trigger/i.test(noTrigWebhook.detail), + noTrigWebhook.detail); + +await client.close(); + +const failed = results.filter(r => !r.ok).length; +console.log(`\n${results.length - failed}/${results.length} passed`); +process.exit(failed === 0 ? 0 : 1);