lettr-mcp 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ The official [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) se
15
15
  ## Features
16
16
 
17
17
  - **Send Emails** — Send transactional emails with HTML, plain text, CC/BCC, attachments, tracking options, metadata, and tags. Supports [template-based sending](https://docs.lettr.com/learn/templates/introduction) with merge tag substitution, scheduled delivery, and inspecting sent messages and events.
18
- - **Templates** — List, create, get, update, and delete email templates. Retrieve rendered HTML and [merge tags](https://docs.lettr.com/learn/templates/template-language) to discover which variables a template expects before sending.
18
+ - **Templates** — List, create, get, update, and delete email templates, transactional or campaign. Retrieve rendered HTML and [merge tags](https://docs.lettr.com/learn/templates/template-language) to discover which variables a template expects before sending.
19
19
  - **Domains** — List, create, get, delete, and [verify sending domains](https://docs.lettr.com/learn/domains/sending-domains). View DNS records required for SPF, DKIM, and DMARC authentication.
20
20
  - **Webhooks** — List, create, get, update, and delete [webhook configurations](https://docs.lettr.com/learn/webhooks/introduction) for real-time email event notifications.
21
21
  - **Projects** — List the projects available to your team so you can target template and email tools at a specific project.
@@ -98,22 +98,35 @@ Environment variables:
98
98
  | `list-emails` | List recently sent emails (cursor-paginated, with recipient and date filters) |
99
99
  | `list-email-events` | List email events (delivery, bounce, click, open, …) with filters by type, recipient, transmission, and date range |
100
100
  | `get-email-detail` | Retrieve the full delivery timeline for a single transmission by request ID |
101
- | `schedule-email` | Schedule a transactional email for future delivery (5+ minutes ahead, within 3 days) |
102
- | `get-scheduled-email` | Get the state and events of a scheduled transmission |
103
- | `cancel-scheduled-email` | Cancel a scheduled transmission before it is sent |
101
+ | `schedule-email` | Schedule a transactional email for future delivery (5+ minutes ahead, within 30 days) |
102
+ | `list-scheduled-emails` | List emails waiting to be sent, with a state filter |
103
+ | `get-scheduled-email` | Get the state and events of a scheduled email |
104
+ | `cancel-scheduled-email` | Cancel a scheduled email before it is sent |
104
105
 
105
106
  ### Templates
106
107
 
107
108
  | Tool | Description |
108
109
  |------|-------------|
109
- | `list-templates` | List email templates with pagination |
110
+ | `list-templates` | List email templates, filterable by purpose and folder |
110
111
  | `get-template` | Get full template details including HTML content |
111
- | `create-template` | Create a new template with HTML or visual editor JSON |
112
+ | `create-template` | Create a new template with HTML or visual editor JSON, transactional or campaign |
112
113
  | `update-template` | Update template name and/or content (creates new version) |
113
114
  | `delete-template` | Permanently delete a template and all versions |
114
115
  | `get-merge-tags` | Discover merge tag variables a template expects |
115
116
  | `get-template-html` | Retrieve a template's rendered HTML, subject, and merge tags by project ID and slug |
116
117
 
118
+ **Template purpose.** A template is either `transactional` (the default — receipts, password resets, alerts) or `campaign` (marketing sent to an audience list). A campaign can only send a template whose purpose is `campaign`, and the purpose cannot be changed after creation, so a newsletter created with the default has to be rebuilt. Pass `purpose` to `create-template` whenever the message is going to an audience rather than to one person.
119
+
120
+ **Preparation status.** Imported templates render asynchronously, so a template can exist before it is sendable. `list-templates` and `get-template` report `pending`, `ready` or `failed`. After an *update* the previous render keeps serving until the new one settles, so a pending template still sends — just not yet the new content.
121
+
122
+ ### Folders
123
+
124
+ | Tool | Description |
125
+ |------|-------------|
126
+ | `list-folders` | List the folders templates are filed into, with purpose and template count |
127
+
128
+ Folders are the only way to discover a `folder_id`. Without this tool the choice is to omit `folder_id` and accept whichever folder the API picks, or to guess an integer read out of an app URL. A folder's purpose is independent of its templates' — filing a template in a campaign folder does not make it a campaign template.
129
+
117
130
  ### Domains
118
131
 
119
132
  | Tool | Description |
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
4
  import minimist from 'minimist';
5
5
  import { LettrClient } from './lettr.js';
6
6
  import packageJson from './package.json' with { type: 'json' };
7
- import { addAudienceContactTools, addAudienceListTools, addAudienceMembershipTools, addAudiencePropertyTools, addAudienceSegmentTools, addAudienceTopicTools, addCampaignTools, addDomainTools, addEmailTools, addProjectTools, addSystemTools, addTemplateTools, addWebhookTools, } from './tools/index.js';
7
+ import { addAudienceContactTools, addAudienceListTools, addAudienceMembershipTools, addAudiencePropertyTools, addAudienceSegmentTools, addAudienceTopicTools, addCampaignTools, addDomainTools, addEmailTools, addFolderTools, addProjectTools, addSystemTools, addTemplateTools, addWebhookTools, } from './tools/index.js';
8
8
  const argv = minimist(process.argv.slice(2));
9
9
  const apiKey = argv.key || process.env.LETTR_API_KEY;
10
10
  const senderEmailAddress = argv.sender || process.env.SENDER_EMAIL_ADDRESS;
@@ -22,6 +22,7 @@ const server = new McpServer({
22
22
  });
23
23
  addEmailTools(server, lettr, { senderEmailAddress, replierEmailAddress });
24
24
  addTemplateTools(server, lettr);
25
+ addFolderTools(server, lettr);
25
26
  addDomainTools(server, lettr);
26
27
  addWebhookTools(server, lettr);
27
28
  addProjectTools(server, lettr);
package/dist/lettr.js CHANGED
@@ -6,7 +6,11 @@ export class LettrClient {
6
6
  this.apiKey = apiKey;
7
7
  this.version = version;
8
8
  }
9
- async request(method, path, body, query) {
9
+ async request(method, path, body, query, extraHeaders) {
10
+ const { data } = await this.requestWithHeaders(method, path, body, query, extraHeaders);
11
+ return data;
12
+ }
13
+ async requestWithHeaders(method, path, body, query, extraHeaders) {
10
14
  const url = new URL(`${BASE_URL}${path}`);
11
15
  if (query) {
12
16
  for (const [key, value] of Object.entries(query)) {
@@ -19,6 +23,7 @@ export class LettrClient {
19
23
  Authorization: `Bearer ${this.apiKey}`,
20
24
  Accept: 'application/json',
21
25
  'User-Agent': `lettr-mcp/${this.version}`,
26
+ ...extraHeaders,
22
27
  };
23
28
  const options = { method, headers };
24
29
  if (body &&
@@ -39,15 +44,44 @@ export class LettrClient {
39
44
  .map(([field, msgs]) => ` ${field}: ${msgs.join(', ')}`)
40
45
  .join('\n')}`
41
46
  : '';
47
+ // The two idempotency 409s look identical but call for opposite
48
+ // reactions, so say which one happened rather than leaving the agent to
49
+ // guess from the message.
50
+ if (response.status === 409) {
51
+ const retryAfter = response.headers.get('Retry-After');
52
+ if (err.error_code === 'idempotency_in_progress') {
53
+ throw new Error(`Lettr API error (409): an earlier send with this idempotency key is still in flight. Retry with the SAME key${retryAfter ? ` after ${retryAfter}s` : ''}.`);
54
+ }
55
+ if (err.error_code === 'idempotency_key_conflict') {
56
+ throw new Error('Lettr API error (409): this idempotency key was already used with a different payload. Do NOT retry — it will fail identically forever. Use a new key, or resend the original payload.');
57
+ }
58
+ }
42
59
  throw new Error(`Lettr API error (${response.status}): ${err.message ?? response.statusText}${detail}`);
43
60
  }
44
- return json;
61
+ return { data: json, headers: response.headers };
45
62
  }
46
63
  async get(path, query) {
47
64
  return this.request('GET', path, undefined, query);
48
65
  }
49
- async post(path, body, query) {
50
- return this.request('POST', path, body, query);
66
+ async post(path, body, query, headers) {
67
+ return this.request('POST', path, body, query, headers);
68
+ }
69
+ /**
70
+ * POST an idempotent request.
71
+ *
72
+ * Reusing the key returns the original result instead of repeating the
73
+ * action; `replayed` says whether that is what happened.
74
+ */
75
+ async postIdempotent(path, body, idempotencyKey) {
76
+ const { data, headers } = await this.requestWithHeaders('POST', path, body, undefined, { 'Idempotency-Key': idempotencyKey });
77
+ return {
78
+ data,
79
+ // Case-insensitive to match every Lettr SDK. The spec pins the value to
80
+ // the literal 'true', but a strict compare here would silently report
81
+ // "not replayed" if that ever varied - and a missed replay reads as a
82
+ // second email having gone out.
83
+ replayed: headers.get('Idempotency-Replayed')?.toLowerCase() === 'true',
84
+ };
51
85
  }
52
86
  async put(path, body, query) {
53
87
  return this.request('PUT', path, body, query);
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lettr-mcp",
3
- "version": "1.5.0",
4
- "description": "MCP server for the Lettr email API — send transactional emails, manage templates, domains, and webhooks from any AI assistant",
3
+ "version": "1.7.0",
4
+ "description": "MCP server for the Lettr email API \u2014 send transactional emails, manage templates, domains, and webhooks from any AI assistant",
5
5
  "keywords": [
6
6
  "lettr",
7
7
  "email",
@@ -19,6 +19,24 @@ const EMAIL_EVENT_TYPES = [
19
19
  'list_unsubscribe',
20
20
  'link_unsubscribe',
21
21
  ];
22
+ function formatScheduledEmail(d) {
23
+ return [
24
+ `Scheduled email: ${d.request_id}`,
25
+ `State: ${d.state}`,
26
+ d.scheduled_at ? `Scheduled for: ${d.scheduled_at}` : null,
27
+ `From: ${d.from_name ? `${d.from_name} <${d.from}>` : d.from}`,
28
+ d.subject ? `Subject: ${d.subject}` : null,
29
+ `Recipients (${d.num_recipients}): ${d.recipients.join(', ')}`,
30
+ d.tag ? `Tag: ${d.tag}` : null,
31
+ // Only meaningful once sent — this is the id webhook events carry.
32
+ d.transmission_id
33
+ ? `Transmission: ${d.transmission_id}`
34
+ : 'Transmission: not sent yet',
35
+ d.failure_reason ? `Failure reason: ${d.failure_reason}` : null,
36
+ ]
37
+ .filter((x) => x !== null)
38
+ .join('\n');
39
+ }
22
40
  const sendEmailShape = (senderEmailAddress, replierEmailAddress) => ({
23
41
  to: z
24
42
  .array(z.email().max(255))
@@ -253,16 +271,42 @@ export function addEmailTools(server, lettr, defaults) {
253
271
  - User says "email this to X", "notify them", "send a message to..."
254
272
  - Sending with a template: use template_slug and substitution_data (subject is optional in this case)
255
273
 
256
- **Key trigger phrases:** "Send an email", "Email this to", "Notify", "Send a message", "Reply to them"`,
257
- inputSchema: sendEmailShape(senderEmailAddress, replierEmailAddress),
274
+ **Key trigger phrases:** "Send an email", "Email this to", "Notify", "Send a message", "Reply to them"
275
+
276
+ **Retrying:** pass idempotency_key and reuse the same value on a retry. Without it, a retry after a timeout sends the email twice — the first attempt may well have succeeded and only the response was lost.`,
277
+ inputSchema: {
278
+ ...sendEmailShape(senderEmailAddress, replierEmailAddress),
279
+ idempotency_key: z
280
+ .string()
281
+ .min(1)
282
+ .max(255)
283
+ .optional()
284
+ .describe('Opaque key making this send safe to retry. Reuse the SAME value when retrying and the API returns the original result instead of delivering a second email. Derive it from what the send is about (e.g. "order-12345-receipt"), not from a timestamp or random value — a fresh key on a retry defeats the point. Keys are kept 24 hours and are scoped per team and API key.'),
285
+ },
258
286
  }, async (input) => {
259
287
  const body = await buildSendEmailBody(input, defaults);
260
- const response = await lettr.post('/emails', body);
288
+ // Keyless sends keep the plain path: with no key there is nothing to
289
+ // replay, so there is no header worth reading back.
290
+ if (!input.idempotency_key) {
291
+ const response = await lettr.post('/emails', body);
292
+ return {
293
+ content: [
294
+ {
295
+ type: 'text',
296
+ text: `Email sent successfully! Request ID: ${response.data.request_id}, Accepted: ${response.data.accepted}, Rejected: ${response.data.rejected}`,
297
+ },
298
+ ],
299
+ };
300
+ }
301
+ const { data: response, replayed } = await lettr.postIdempotent('/emails', body, input.idempotency_key);
302
+ const outcome = replayed
303
+ ? 'Replayed an earlier send with this idempotency key — no second email went out.'
304
+ : 'Email sent successfully!';
261
305
  return {
262
306
  content: [
263
307
  {
264
308
  type: 'text',
265
- text: `Email sent successfully! Request ID: ${response.data.request_id}, Accepted: ${response.data.accepted}, Rejected: ${response.data.rejected}`,
309
+ text: `${outcome} Request ID: ${response.data.request_id}, Accepted: ${response.data.accepted}, Rejected: ${response.data.rejected}`,
266
310
  },
267
311
  ],
268
312
  };
@@ -403,14 +447,14 @@ export function addEmailTools(server, lettr, defaults) {
403
447
  });
404
448
  server.registerTool('schedule-email', {
405
449
  title: 'Schedule Email',
406
- description: `Schedule an email for future delivery. Accepts the same fields as send-email plus a required scheduled_at (ISO 8601, UTC) that is at least 5 minutes in the future and at most 3 days out.
450
+ description: `Schedule an email for future delivery. Accepts the same fields as send-email plus a required scheduled_at (ISO 8601, UTC) that is at least 5 minutes in the future and at most 30 days out.
407
451
 
408
- **Returns:** Same response as send-email (request_id + accepted/rejected counts).`,
452
+ **Returns:** The scheduled email, including the \`sch_\` request ID that get-scheduled-email and cancel-scheduled-email take.`,
409
453
  inputSchema: {
410
454
  ...sendEmailShape(senderEmailAddress, replierEmailAddress),
411
455
  scheduled_at: z
412
456
  .string()
413
- .describe('ISO 8601 UTC datetime (e.g. 2024-01-16T10:00:00Z). Must be 5+ minutes in the future and within 3 days.'),
457
+ .describe('ISO 8601 UTC datetime (e.g. 2024-01-16T10:00:00Z). Must be 5+ minutes in the future and within 30 days.'),
414
458
  },
415
459
  }, async (input) => {
416
460
  const { scheduled_at, ...sendInput } = input;
@@ -418,64 +462,80 @@ export function addEmailTools(server, lettr, defaults) {
418
462
  body.scheduled_at = scheduled_at;
419
463
  const response = await lettr.post('/emails/scheduled', body);
420
464
  return {
421
- content: [
422
- {
423
- type: 'text',
424
- text: `Email scheduled for ${scheduled_at}. Request ID: ${response.data.request_id}, Accepted: ${response.data.accepted}, Rejected: ${response.data.rejected}`,
425
- },
426
- ],
465
+ content: [{ type: 'text', text: formatScheduledEmail(response.data) }],
427
466
  };
428
467
  });
429
468
  server.registerTool('get-scheduled-email', {
430
469
  title: 'Get Scheduled Email',
431
- description: 'Retrieve details of a scheduled (but not yet sent) email, including its state, scheduled_at timestamp, recipients and any events collected so far.',
470
+ description: 'Retrieve details of a scheduled email, including its state, scheduled_at timestamp, recipients and any events collected so far. Works for every state, not only pending ones — a cancelled or sent email is still readable.',
432
471
  inputSchema: {
433
- transmission_id: z
472
+ request_id: z
434
473
  .string()
435
474
  .nonempty()
436
- .describe('Transmission ID returned by schedule-email'),
475
+ .describe('The `sch_` request ID returned by schedule-email'),
437
476
  },
438
- }, async ({ transmission_id }) => {
439
- const response = await lettr.get(`/emails/scheduled/${encodeURIComponent(transmission_id)}`);
477
+ }, async ({ request_id }) => {
478
+ const response = await lettr.get(`/emails/scheduled/${encodeURIComponent(request_id)}`);
440
479
  const d = response.data;
441
480
  const eventLines = d.events.length === 0
442
481
  ? '(no events yet)'
443
482
  : d.events.map(formatEvent).join('\n');
444
483
  return {
445
484
  content: [
446
- {
447
- type: 'text',
448
- text: [
449
- `Transmission: ${d.transmission_id}`,
450
- `State: ${d.state}`,
451
- d.scheduled_at ? `Scheduled for: ${d.scheduled_at}` : null,
452
- `From: ${d.from_name ? `${d.from_name} <${d.from}>` : d.from}`,
453
- `Subject: ${d.subject}`,
454
- `Recipients (${d.num_recipients}): ${d.recipients.join(', ')}`,
455
- ]
456
- .filter((x) => x !== null)
457
- .join('\n'),
458
- },
485
+ { type: 'text', text: formatScheduledEmail(d) },
459
486
  { type: 'text', text: `Events:\n${eventLines}` },
460
487
  ],
461
488
  };
462
489
  });
463
490
  server.registerTool('cancel-scheduled-email', {
464
491
  title: 'Cancel Scheduled Email',
465
- description: 'Cancel a scheduled email before it is sent. Before using this tool, you MUST confirm with the user that they really want to cancel this transmission — this action cannot be undone.',
492
+ description: 'Cancel a scheduled email before it is sent. Before using this tool, you MUST confirm with the user that they really want to cancel this email — this action cannot be undone. Only an email still in the `scheduled` state can be cancelled.',
466
493
  inputSchema: {
467
- transmission_id: z
494
+ request_id: z
468
495
  .string()
469
496
  .nonempty()
470
- .describe('Transmission ID to cancel'),
497
+ .describe('The `sch_` request ID to cancel'),
471
498
  },
472
- }, async ({ transmission_id }) => {
473
- await lettr.delete(`/emails/scheduled/${encodeURIComponent(transmission_id)}`);
499
+ }, async ({ request_id }) => {
500
+ // Cancelling answers with the cancelled email, so report its real state
501
+ // rather than asserting success.
502
+ const response = await lettr.delete(`/emails/scheduled/${encodeURIComponent(request_id)}`);
503
+ return {
504
+ content: [{ type: 'text', text: formatScheduledEmail(response.data) }],
505
+ };
506
+ });
507
+ server.registerTool('list-scheduled-emails', {
508
+ title: 'List Scheduled Emails',
509
+ description: 'List emails waiting to be sent, soonest delivery time first. Use this to find the `sch_` request ID of an email the user describes but cannot name, before getting or cancelling it.',
510
+ inputSchema: {
511
+ status: z
512
+ .enum(['scheduled', 'sending', 'sent', 'cancelled', 'failed'])
513
+ .optional()
514
+ .describe('Only return emails in this state. Omit to see every state.'),
515
+ per_page: z
516
+ .number()
517
+ .int()
518
+ .min(1)
519
+ .max(100)
520
+ .optional()
521
+ .describe('Results per page (1-100, default 25)'),
522
+ page: z.number().int().min(1).optional().describe('Page number'),
523
+ },
524
+ }, async (input) => {
525
+ const response = await lettr.get('/emails/scheduled', input);
526
+ const { scheduled_emails, pagination } = response.data;
527
+ if (scheduled_emails.length === 0) {
528
+ return {
529
+ content: [{ type: 'text', text: 'No scheduled emails found.' }],
530
+ };
531
+ }
474
532
  return {
475
533
  content: [
476
534
  {
477
535
  type: 'text',
478
- text: `Scheduled transmission "${transmission_id}" cancelled.`,
536
+ text: `Scheduled emails (total ${pagination.total}, page ${pagination.current_page} of ${pagination.last_page}, page size ${pagination.per_page}):\n\n${scheduled_emails
537
+ .map(formatScheduledEmail)
538
+ .join('\n\n')}`,
479
539
  },
480
540
  ],
481
541
  };
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+ export function addFolderTools(server, lettr) {
3
+ server.registerTool('list-folders', {
4
+ title: 'List Folders',
5
+ description: `**Purpose:** List the folders templates are filed into, with each folder's purpose and template count.
6
+
7
+ **Returns:** Folder id, name, project, purpose (transactional or campaign) and how many templates are inside.
8
+
9
+ **When to use:**
10
+ - Before creating a template, to pick a folder id — nothing else in the API returns one, so without this you either omit folder_id and accept whichever folder the API picks, or guess an integer
11
+ - To find the campaign folder when the user asks for a marketing template
12
+ - To answer "where are my templates organised", "what folders do I have"
13
+
14
+ **Note:** A folder's purpose and a template's purpose are separate. Filing a template in a campaign folder does not make the template a campaign template — set purpose on the template itself.`,
15
+ inputSchema: {
16
+ project_id: z
17
+ .number()
18
+ .int()
19
+ .min(1)
20
+ .optional()
21
+ .describe("Project ID to list folders from. If not provided, uses the team's default project."),
22
+ purpose: z
23
+ .enum(['transactional', 'campaign'])
24
+ .optional()
25
+ .describe('Only return folders of this purpose. Omit to return both.'),
26
+ per_page: z
27
+ .number()
28
+ .int()
29
+ .min(1)
30
+ .max(100)
31
+ .optional()
32
+ .describe('Results per page (1-100, default 25)'),
33
+ page: z
34
+ .number()
35
+ .int()
36
+ .min(1)
37
+ .optional()
38
+ .describe('Page number (default 1)'),
39
+ },
40
+ }, async ({ project_id, purpose, per_page, page }) => {
41
+ const query = {};
42
+ if (project_id)
43
+ query.project_id = project_id;
44
+ if (purpose)
45
+ query.purpose = purpose;
46
+ if (per_page)
47
+ query.per_page = per_page;
48
+ if (page)
49
+ query.page = page;
50
+ const response = await lettr.get('/folders', query);
51
+ const folders = response.data.folders;
52
+ const pagination = response.data.pagination;
53
+ if (folders.length === 0) {
54
+ return {
55
+ content: [{ type: 'text', text: 'No folders found.' }],
56
+ };
57
+ }
58
+ const folderList = folders
59
+ .map((f) => `- ${f.name} (id: ${f.id}) | Purpose: ${f.purpose} | Templates: ${f.templates_count} | Project: ${f.project_id}`)
60
+ .join('\n');
61
+ return {
62
+ content: [
63
+ {
64
+ type: 'text',
65
+ text: `Found ${pagination.total} folder(s) (page ${pagination.current_page}/${pagination.last_page}):\n\n${folderList}`,
66
+ },
67
+ ],
68
+ };
69
+ });
70
+ }
@@ -7,6 +7,7 @@ export * from './audience-topics.js';
7
7
  export * from './campaigns.js';
8
8
  export * from './domains.js';
9
9
  export * from './emails.js';
10
+ export * from './folders.js';
10
11
  export * from './projects.js';
11
12
  export * from './system.js';
12
13
  export * from './templates.js';
@@ -4,11 +4,13 @@ export function addTemplateTools(server, lettr) {
4
4
  title: 'List Templates',
5
5
  description: `**Purpose:** List email templates with pagination. Returns template names, slugs, and project info.
6
6
 
7
- **Returns:** Paginated list of templates with id, name, slug, project_id, folder_id, timestamps.
7
+ **Returns:** Paginated list of templates with id, name, slug, project_id, folder_id, purpose, preparation status and timestamps.
8
8
 
9
9
  **When to use:**
10
10
  - User asks "show my templates", "what templates do I have?"
11
11
  - Before sending a template-based email, to find the template slug
12
+ - Filter by purpose=campaign to find templates a campaign can actually use
13
+ - Filter by folder_id to reconcile a bulk import in one call instead of one get-template per template
12
14
  - Use get-template for full details of a specific template`,
13
15
  inputSchema: {
14
16
  project_id: z
@@ -24,6 +26,16 @@ export function addTemplateTools(server, lettr) {
24
26
  .max(100)
25
27
  .optional()
26
28
  .describe('Number of results per page (1-100). Default: 25'),
29
+ purpose: z
30
+ .enum(['transactional', 'campaign'])
31
+ .optional()
32
+ .describe('Only return templates of this purpose. Use campaign to find templates that a campaign can send.'),
33
+ folder_id: z
34
+ .number()
35
+ .int()
36
+ .min(1)
37
+ .optional()
38
+ .describe('Only return templates in this folder. A folder outside the resolved project is an error, not an empty list, so a wrong id cannot be misread as "nothing there yet". Use list-folders to find one.'),
27
39
  page: z
28
40
  .number()
29
41
  .int()
@@ -31,10 +43,14 @@ export function addTemplateTools(server, lettr) {
31
43
  .optional()
32
44
  .describe('Page number. Default: 1'),
33
45
  },
34
- }, async ({ project_id, per_page, page }) => {
46
+ }, async ({ project_id, purpose, folder_id, per_page, page }) => {
35
47
  const query = {};
36
48
  if (project_id)
37
49
  query.project_id = project_id;
50
+ if (purpose)
51
+ query.purpose = purpose;
52
+ if (folder_id)
53
+ query.folder_id = folder_id;
38
54
  if (per_page)
39
55
  query.per_page = per_page;
40
56
  if (page)
@@ -48,7 +64,15 @@ export function addTemplateTools(server, lettr) {
48
64
  };
49
65
  }
50
66
  const templateList = templates
51
- .map((t) => `- ${t.name} (slug: ${t.slug}) | Project: ${t.project_id} | Updated: ${t.updated_at}`)
67
+ .map((t) => {
68
+ const purpose = t.purpose ? ` | ${t.purpose}` : '';
69
+ // Only worth surfacing when it is not ready - a settled template is
70
+ // the uninteresting case and would just add noise to every row.
71
+ const preparing = t.preparation_status && t.preparation_status !== 'ready'
72
+ ? ` | preparation: ${t.preparation_status}`
73
+ : '';
74
+ return `- ${t.name} (slug: ${t.slug}) | Project: ${t.project_id}${purpose}${preparing} | Updated: ${t.updated_at}`;
75
+ })
52
76
  .join('\n');
53
77
  return {
54
78
  content: [
@@ -83,6 +107,18 @@ export function addTemplateTools(server, lettr) {
83
107
  details += `- Slug: ${t.slug}\n`;
84
108
  details += `- Project ID: ${t.project_id}\n`;
85
109
  details += `- Folder ID: ${t.folder_id}\n`;
110
+ details += `- Purpose: ${t.purpose ?? 'transactional'}\n`;
111
+ if (t.preparation_status) {
112
+ details += `- Preparation: ${t.preparation_status}\n`;
113
+ if (t.preparation_status === 'pending') {
114
+ details +=
115
+ ' (still rendering — an update keeps serving the previous HTML until this settles)\n';
116
+ }
117
+ if (t.preparation_status === 'failed') {
118
+ details +=
119
+ ' (rendering failed — this template will not send the content you imported)\n';
120
+ }
121
+ }
86
122
  details += `- Active Version: ${t.active_version ?? 'none'}\n`;
87
123
  details += `- Total Versions: ${t.versions_count}\n`;
88
124
  details += `- Created: ${t.created_at}\n`;
@@ -96,7 +132,9 @@ export function addTemplateTools(server, lettr) {
96
132
  });
97
133
  server.registerTool('create-template', {
98
134
  title: 'Create Template',
99
- description: 'Create a new email template with HTML or Topol editor JSON content. Provide either html or json — they are mutually exclusive. Merge tags are automatically extracted from the content.',
135
+ description: `Create a new email template with HTML or Topol editor JSON content. Provide either html or json — they are mutually exclusive. Merge tags are automatically extracted from the content.
136
+
137
+ **Set purpose deliberately.** It defaults to transactional, and it cannot be changed after creation. A campaign can only send a template whose purpose is campaign, so a newsletter or promotion created with the default has to be recreated from scratch. If the user is writing anything that goes to an audience list, pass purpose: "campaign".`,
100
138
  inputSchema: {
101
139
  name: z
102
140
  .string()
@@ -122,9 +160,13 @@ export function addTemplateTools(server, lettr) {
122
160
  .int()
123
161
  .min(1)
124
162
  .optional()
125
- .describe('Folder ID within the project. If not provided, uses the first folder.'),
163
+ .describe('Folder ID within the project. If not provided, uses the first folder. Use list-folders to find one.'),
164
+ purpose: z
165
+ .enum(['transactional', 'campaign'])
166
+ .optional()
167
+ .describe("What the template is for. **transactional** (default) is triggered by one user's action — a receipt, password reset, alert. **campaign** is marketing sent to an audience list, and is the ONLY kind a campaign can send. This cannot be changed later: a newsletter created as transactional must be recreated."),
126
168
  },
127
- }, async ({ name, html, json, project_id, folder_id }) => {
169
+ }, async ({ name, html, json, project_id, folder_id, purpose }) => {
128
170
  if (html && json) {
129
171
  throw new Error('html and json are mutually exclusive — provide only one.');
130
172
  }
@@ -137,6 +179,8 @@ export function addTemplateTools(server, lettr) {
137
179
  body.project_id = project_id;
138
180
  if (folder_id)
139
181
  body.folder_id = folder_id;
182
+ if (purpose)
183
+ body.purpose = purpose;
140
184
  const response = await lettr.post('/templates', body);
141
185
  const t = response.data;
142
186
  const mergeTags = t.merge_tags.length > 0
@@ -146,7 +190,7 @@ export function addTemplateTools(server, lettr) {
146
190
  content: [
147
191
  {
148
192
  type: 'text',
149
- text: `Template created successfully!\nName: ${t.name}\nSlug: ${t.slug}\nVersion: ${t.active_version}${mergeTags}`,
193
+ text: `Template created successfully!\nName: ${t.name}\nSlug: ${t.slug}\nPurpose: ${t.purpose ?? 'transactional'}\nVersion: ${t.active_version}${mergeTags}`,
150
194
  },
151
195
  ],
152
196
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lettr-mcp",
3
- "version": "1.5.0",
4
- "description": "MCP server for the Lettr email API — send transactional emails, manage templates, domains, and webhooks from any AI assistant",
3
+ "version": "1.7.0",
4
+ "description": "MCP server for the Lettr email API \u2014 send transactional emails, manage templates, domains, and webhooks from any AI assistant",
5
5
  "keywords": [
6
6
  "lettr",
7
7
  "email",