Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ This section provides a complete reference for the Postmark MCP server tools inc

## Email

> **Confirmation required:** Before invoking any email send tool, the assistant
> should show the final recipients and subject or template and obtain explicit
> user confirmation. Corrections, retries, and resends require a new
> confirmation. Send tools are marked destructive and non-idempotent because an
> interrupted response does not prove that Postmark rejected the previous send.

### sendEmail
Sends a transactional email to one recipient or up to 50 recipients.

Expand Down Expand Up @@ -746,4 +752,4 @@ Because this MCP server can send email and register webhooks, it is a potential
*For more information about the Postmark API, visit [Postmark's Developer Documentation](https://postmarkapp.com/developer).*

## License
[MIT](LICENSE) © ActiveCampaign
[MIT](LICENSE) © ActiveCampaign
21 changes: 13 additions & 8 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ function isAllowedWebhookUrl(url, allowlist) {
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
const MUTATING = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
const EMAIL_SEND = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };

const EMAIL_CONFIRMATION_INSTRUCTION =
"Before every invocation, show the user the final recipients and subject or template, then obtain explicit confirmation. " +
"A correction, retry, or resend requires a new confirmation. Never retry automatically, because Postmark may have accepted the previous request even when its response was interrupted or ambiguous.";

/**
* Minimal hardened HTTP client for the Postmark REST API over native fetch.
Expand Down Expand Up @@ -440,7 +445,7 @@ function registerTools(server) {

server.tool(
"sendEmail",
"Send a single transactional email via Postmark. Accepts one recipient or an array of up to 50. The From address must be a verified sender signature. Open and link tracking are enabled automatically. Use sendBatch to send multiple distinct messages in one call.",
`${EMAIL_CONFIRMATION_INSTRUCTION} Send a single transactional email via Postmark. Accepts one recipient or an array of up to 50. The From address must be a verified sender signature. Open and link tracking are enabled automatically. Use sendBatch to send multiple distinct messages in one call.`,
{
to: z.union([
z.string().email(),
Expand All @@ -455,7 +460,7 @@ function registerTools(server) {
replyTo: z.string().email().optional().describe("Reply-To address (optional)"),
tag: z.string().optional().describe("Optional tag for categorization")
},
MUTATING,
EMAIL_SEND,
async ({ to, subject, textBody, htmlBody, from, cc, bcc, replyTo, tag }) => {
const emailData = {
From: from || defaultSender,
Expand Down Expand Up @@ -490,7 +495,7 @@ function registerTools(server) {

server.tool(
"sendEmailWithTemplate",
"Send a single email rendered from a saved Postmark template. Supply either templateId (numeric) or templateAlias (string) plus a templateModel object that provides the template variables. The From address must be a verified sender signature.",
`${EMAIL_CONFIRMATION_INSTRUCTION} Send a single email rendered from a saved Postmark template. Supply either templateId (numeric) or templateAlias (string) plus a templateModel object that provides the template variables. The From address must be a verified sender signature.`,
{
to: z.string().email().describe("Recipient email address"),
templateId: z.number().optional().describe("Template ID — provide either this or templateAlias, not both"),
Expand All @@ -502,7 +507,7 @@ function registerTools(server) {
replyTo: z.string().email().optional().describe("Reply-To address (optional)"),
tag: z.string().optional().describe("Optional tag for categorization")
},
MUTATING,
EMAIL_SEND,
async ({ to, templateId, templateAlias, templateModel, from, cc, bcc, replyTo, tag }) => {
if (!templateId && !templateAlias) {
throw new Error("Either templateId or templateAlias must be provided");
Expand Down Expand Up @@ -580,7 +585,7 @@ function registerTools(server) {

server.tool(
"sendBatch",
"Send up to 500 independent emails in a single synchronous Postmark API call (POST /email/batch). Each message has its own recipient, subject, and body. Returns per-message results — the overall HTTP call succeeds even when individual messages fail. Use sendEmail for a single message.",
`${EMAIL_CONFIRMATION_INSTRUCTION} Send up to 500 independent emails in a single synchronous Postmark API call (POST /email/batch). Each message has its own recipient, subject, and body. Returns per-message results — the overall HTTP call succeeds even when individual messages fail. Use sendEmail for a single message.`,
{
messages: z.array(z.object({
to: z.string().email().describe("Recipient email address"),
Expand All @@ -594,7 +599,7 @@ function registerTools(server) {
tag: z.string().optional().describe("Tag for categorization")
})).min(1).max(500).describe("Up to 500 messages to send in a single request")
},
MUTATING,
EMAIL_SEND,
async ({ messages }) => {
const payload = messages.map(m => {
const msg = {
Expand Down Expand Up @@ -625,7 +630,7 @@ function registerTools(server) {

server.tool(
"sendBatchWithTemplate",
"Send the same Postmark template to up to 500 recipients in a single call, with per-recipient template models (POST /email/batchWithTemplates). Supply either templateId or templateAlias. Returns per-message results. Use sendEmailWithTemplate for a single recipient.",
`${EMAIL_CONFIRMATION_INSTRUCTION} Send the same Postmark template to up to 500 recipients in a single call, with per-recipient template models (POST /email/batchWithTemplates). Supply either templateId or templateAlias. Returns per-message results. Use sendEmailWithTemplate for a single recipient.`,
{
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)"),
Expand All @@ -641,7 +646,7 @@ function registerTools(server) {
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")
},
MUTATING,
EMAIL_SEND,
async ({ templateId, templateAlias, from, tag, recipients }) => {
if (!templateId && !templateAlias) {
throw new Error("Either templateId or templateAlias must be provided");
Expand Down
29 changes: 26 additions & 3 deletions test-offline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
* 4. All registered tools carry annotation objects
* 5. Read-only tools carry readOnlyHint: true
* 6. Mutating tools carry readOnlyHint: false, destructiveHint: false
* 7. Destructive tools carry destructiveHint: true
* 7. Email send tools require confirmation and carry destructiveHint: true
* 8. Destructive tools carry destructiveHint: true
*
* Run: node --test test-offline.mjs
* or: npm run test:offline
Expand Down Expand Up @@ -232,10 +233,13 @@ const READ_ONLY_TOOLS = [
];

const MUTATING_TOOLS = [
'sendEmail', 'sendEmailWithTemplate', 'sendBatch', 'sendBatchWithTemplate',
'createTemplate', 'activateBounce', 'createSuppressions', 'createWebhook',
];

const EMAIL_SEND_TOOLS = [
'sendEmail', 'sendEmailWithTemplate', 'sendBatch', 'sendBatchWithTemplate',
];

const DESTRUCTIVE_TOOLS = [
'editTemplate', 'deleteTemplate', 'deleteSuppressions', 'deleteWebhook',
];
Expand Down Expand Up @@ -269,6 +273,20 @@ test('mutating tools have readOnlyHint: false and destructiveHint: false', async
}
});

test('email send tools require confirmation and are marked destructive', async () => {
const { tools } = await serverNoAllowlist.listTools();
const byName = Object.fromEntries(tools.map(t => [t.name, t]));
for (const name of EMAIL_SEND_TOOLS) {
const tool = byName[name];
assert.ok(tool, `tool "${name}" not found in listTools response`);
assert.equal(tool.annotations?.readOnlyHint, false, `${name}: readOnlyHint should be false`);
assert.equal(tool.annotations?.destructiveHint, true, `${name}: destructiveHint should be true`);
assert.equal(tool.annotations?.idempotentHint, false, `${name}: idempotentHint should be false`);
assert.match(tool.description, /obtain explicit confirmation/i, `${name}: description should require confirmation`);
assert.match(tool.description, /never retry automatically/i, `${name}: description should prohibit automatic retries`);
}
});

test('destructive tools have destructiveHint: true and readOnlyHint: false', async () => {
const { tools } = await serverNoAllowlist.listTools();
const byName = Object.fromEntries(tools.map(t => [t.name, t]));
Expand All @@ -282,7 +300,12 @@ test('destructive tools have destructiveHint: true and readOnlyHint: false', asy

test('every registered tool belongs to exactly one annotation category', async () => {
const { tools } = await serverNoAllowlist.listTools();
const categorised = new Set([...READ_ONLY_TOOLS, ...MUTATING_TOOLS, ...DESTRUCTIVE_TOOLS]);
const categorised = new Set([
...READ_ONLY_TOOLS,
...MUTATING_TOOLS,
...EMAIL_SEND_TOOLS,
...DESTRUCTIVE_TOOLS,
]);
const uncategorised = tools.filter(t => !categorised.has(t.name)).map(t => t.name);
assert.deepEqual(
uncategorised, [],
Expand Down