lettr-mcp 1.4.0 → 1.6.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 +18 -4
- package/dist/index.js +2 -1
- package/dist/lettr.js +38 -4
- package/dist/package.json +2 -2
- package/dist/tools/audience-contacts.js +116 -16
- package/dist/tools/audience-memberships.js +54 -0
- package/dist/tools/emails.js +30 -4
- package/dist/tools/folders.js +70 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/templates.js +51 -7
- package/package.json +2 -2
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.
|
|
@@ -106,14 +106,26 @@ Environment variables:
|
|
|
106
106
|
|
|
107
107
|
| Tool | Description |
|
|
108
108
|
|------|-------------|
|
|
109
|
-
| `list-templates` | List email templates
|
|
109
|
+
| `list-templates` | List email templates, filterable by purpose and folder |
|
|
110
110
|
| `get-template` | Get full template details including HTML content |
|
|
111
|
-
| `create-template` | Create a new template with HTML or visual editor JSON |
|
|
111
|
+
| `create-template` | Create a new template with HTML or visual editor JSON, transactional or campaign |
|
|
112
112
|
| `update-template` | Update template name and/or content (creates new version) |
|
|
113
113
|
| `delete-template` | Permanently delete a template and all versions |
|
|
114
114
|
| `get-merge-tags` | Discover merge tag variables a template expects |
|
|
115
115
|
| `get-template-html` | Retrieve a template's rendered HTML, subject, and merge tags by project ID and slug |
|
|
116
116
|
|
|
117
|
+
**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.
|
|
118
|
+
|
|
119
|
+
**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.
|
|
120
|
+
|
|
121
|
+
### Folders
|
|
122
|
+
|
|
123
|
+
| Tool | Description |
|
|
124
|
+
|------|-------------|
|
|
125
|
+
| `list-folders` | List the folders templates are filed into, with purpose and template count |
|
|
126
|
+
|
|
127
|
+
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.
|
|
128
|
+
|
|
117
129
|
### Domains
|
|
118
130
|
|
|
119
131
|
| Tool | Description |
|
|
@@ -153,7 +165,7 @@ Environment variables:
|
|
|
153
165
|
| `list-audience-contacts` | List contacts with search, status, list, and segment filters |
|
|
154
166
|
| `get-audience-contact` | Get a contact with its properties, lists, and topics |
|
|
155
167
|
| `create-audience-contact` | Create a contact, optionally with double opt-in |
|
|
156
|
-
| `bulk-create-audience-contacts` | Create many contacts from a list of emails |
|
|
168
|
+
| `bulk-create-audience-contacts` | Create many contacts, either from a flat list of emails or one row per contact |
|
|
157
169
|
| `update-audience-contact` | Update a contact's email, status, or properties |
|
|
158
170
|
| `delete-audience-contact` | Delete a contact |
|
|
159
171
|
| `attach-contact-to-list` | Add a contact to a list |
|
|
@@ -162,6 +174,8 @@ Environment variables:
|
|
|
162
174
|
| `unsubscribe-contact-from-topic` | Unsubscribe a contact from a topic |
|
|
163
175
|
| `bulk-attach-contacts-to-lists` | Attach many contacts to many lists at once |
|
|
164
176
|
| `bulk-detach-contacts-from-lists` | Detach many contacts from many lists at once |
|
|
177
|
+
| `bulk-subscribe-contacts-to-topics` | Subscribe many contacts to many topics at once |
|
|
178
|
+
| `bulk-unsubscribe-contacts-from-topics` | Unsubscribe many contacts from many topics at once |
|
|
165
179
|
| `list-audience-topics` | List subscription topics with pagination |
|
|
166
180
|
| `create-audience-topic` | Create a subscription topic |
|
|
167
181
|
| `get-audience-topic` | Get a single topic |
|
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.
|
|
4
|
-
"description": "MCP server for the Lettr email API
|
|
3
|
+
"version": "1.6.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",
|
|
@@ -3,6 +3,13 @@ import { z } from 'zod';
|
|
|
3
3
|
// On update, a null value removes the property from the contact.
|
|
4
4
|
const createPropertyValue = z.string().max(1000);
|
|
5
5
|
const updatePropertyValue = z.string().max(1000).nullable();
|
|
6
|
+
const topicSubscription = z.object({
|
|
7
|
+
id: z.string().nonempty().describe('The topic ID'),
|
|
8
|
+
subscription: z
|
|
9
|
+
.enum(['opt_in', 'opt_out'])
|
|
10
|
+
.optional()
|
|
11
|
+
.describe('Defaults to opt_in. Use opt_out to keep the contact off a topic that would otherwise auto-subscribe them.'),
|
|
12
|
+
});
|
|
6
13
|
function formatContact(c) {
|
|
7
14
|
const props = Object.entries(c.properties ?? {});
|
|
8
15
|
const lines = [
|
|
@@ -111,7 +118,8 @@ export function addAudienceContactTools(server, lettr) {
|
|
|
111
118
|
description: `Create a single audience contact.
|
|
112
119
|
|
|
113
120
|
- \`properties\` keys must match properties already defined for the team (use list-audience-properties).
|
|
114
|
-
- When \`double_opt_in\` is provided, the contact is created in \`unverified\` status and receives a confirmation email; all four of its fields (from, subject, template_slug, redirect_url) are required
|
|
121
|
+
- When \`double_opt_in\` is provided, the contact is created in \`unverified\` status and receives a confirmation email; all four of its fields (from, subject, template_slug, redirect_url) are required.
|
|
122
|
+
- If the email already exists for the team this fails with HTTP 409 (\`resource_already_exists\`). That is a client-correctable condition, not an outage — do NOT retry it. Update the existing contact with update-audience-contact, or use bulk-create-audience-contacts with \`update_existing\` set.`,
|
|
115
123
|
inputSchema: {
|
|
116
124
|
email: z.email().max(255).describe('Contact email address'),
|
|
117
125
|
list_id: z
|
|
@@ -167,38 +175,130 @@ export function addAudienceContactTools(server, lettr) {
|
|
|
167
175
|
});
|
|
168
176
|
server.registerTool('bulk-create-audience-contacts', {
|
|
169
177
|
title: 'Bulk Create Audience Contacts',
|
|
170
|
-
description:
|
|
178
|
+
description: `Create many contacts in one request (max 1000).
|
|
179
|
+
|
|
180
|
+
Provide exactly one of:
|
|
181
|
+
- \`emails\` — a flat list of addresses, when every contact gets the same treatment.
|
|
182
|
+
- \`contacts\` — one row per contact, when they differ. Each row takes its own \`properties\`, \`list_ids\` and \`topics\`, applied on top of the batch-wide \`list_ids\`, \`topics\` and \`properties\`.
|
|
183
|
+
|
|
184
|
+
A row-level topic \`opt_out\` beats a batch-level \`opt_in\`. That is how you keep specific people off a topic that auto-subscribes new contacts, without a second cleanup call.
|
|
185
|
+
|
|
186
|
+
\`update_existing\` (default false) controls only whether properties are merged into contacts that already exist — submitted keys overwrite, absent keys are preserved. Existing contacts are attached to the requested lists and topics either way.
|
|
187
|
+
|
|
188
|
+
IMPORTANT — this call can partially succeed. Rows that fail validation are skipped and the rest of the batch still commits, so a successful response does NOT mean every row landed. Always read the reported error count back to the user rather than claiming the whole batch was imported.
|
|
189
|
+
|
|
190
|
+
With \`contacts\`, pass rows through as the user gave them — a malformed address is reported back as a skipped row, so do not drop or "fix" entries yourself first. With \`emails\`, a single invalid address rejects the whole request, so check them before sending.`,
|
|
171
191
|
inputSchema: {
|
|
172
192
|
emails: z
|
|
173
193
|
.array(z.email().max(255))
|
|
174
194
|
.min(1)
|
|
175
195
|
.max(1000)
|
|
176
|
-
.
|
|
196
|
+
.optional()
|
|
197
|
+
.describe('Flat list of email addresses (max 1000). Mutually exclusive with `contacts`.'),
|
|
198
|
+
contacts: z
|
|
199
|
+
.array(z.object({
|
|
200
|
+
// Deliberately not z.email(): the API skips a malformed row and
|
|
201
|
+
// commits the rest, reporting it as `invalid_email` in `errors`.
|
|
202
|
+
// Validating the address here would reject the whole batch
|
|
203
|
+
// instead, so one bad row in a pasted list would import nothing.
|
|
204
|
+
// The flat `emails` field above stays strict because there the
|
|
205
|
+
// API does reject the whole request (422).
|
|
206
|
+
email: z
|
|
207
|
+
.string()
|
|
208
|
+
.nonempty()
|
|
209
|
+
.max(255)
|
|
210
|
+
.describe('Contact email address'),
|
|
211
|
+
properties: z
|
|
212
|
+
.record(z.string(), createPropertyValue)
|
|
213
|
+
.optional()
|
|
214
|
+
.describe('Property values for this contact only, each a string (max 1000 chars). Each key must match a property defined for the team.'),
|
|
215
|
+
list_ids: z
|
|
216
|
+
.array(z.string().nonempty())
|
|
217
|
+
.max(50)
|
|
218
|
+
.optional()
|
|
219
|
+
.describe('Lists for this contact only (max 50), on top of the batch-wide list_ids'),
|
|
220
|
+
topics: z
|
|
221
|
+
.array(topicSubscription)
|
|
222
|
+
.max(50)
|
|
223
|
+
.optional()
|
|
224
|
+
.describe('Topic subscriptions for this contact only (max 50)'),
|
|
225
|
+
}))
|
|
226
|
+
.min(1)
|
|
227
|
+
.max(1000)
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('One row per contact (max 1000), when contacts differ from each other. Mutually exclusive with `emails`.'),
|
|
177
230
|
list_id: z
|
|
178
231
|
.string()
|
|
179
232
|
.optional()
|
|
180
|
-
.describe('
|
|
233
|
+
.describe('Single list ID applied to the whole batch. Kept for convenience; `list_ids` is the general form.'),
|
|
234
|
+
list_ids: z
|
|
235
|
+
.array(z.string().nonempty())
|
|
236
|
+
.max(50)
|
|
237
|
+
.optional()
|
|
238
|
+
.describe('List IDs applied to every contact in the batch (max 50)'),
|
|
239
|
+
topics: z
|
|
240
|
+
.array(topicSubscription)
|
|
241
|
+
.max(50)
|
|
242
|
+
.optional()
|
|
243
|
+
.describe('Topic subscriptions applied to every contact in the batch (max 50)'),
|
|
181
244
|
properties: z
|
|
182
245
|
.record(z.string(), createPropertyValue)
|
|
183
246
|
.optional()
|
|
184
|
-
.describe('Custom property values applied to every contact
|
|
247
|
+
.describe('Custom property values applied to every contact in this batch, each as a string (max 1000 chars). Each key must match a property defined for the team.'),
|
|
248
|
+
update_existing: z
|
|
249
|
+
.boolean()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe('Whether to merge the submitted properties into contacts that already exist (default false)'),
|
|
185
252
|
},
|
|
186
|
-
}, async ({ emails, list_id, properties }) => {
|
|
187
|
-
|
|
253
|
+
}, async ({ emails, contacts, list_id, list_ids, topics, properties, update_existing, }) => {
|
|
254
|
+
if (!emails && !contacts) {
|
|
255
|
+
return {
|
|
256
|
+
isError: true,
|
|
257
|
+
content: [
|
|
258
|
+
{
|
|
259
|
+
type: 'text',
|
|
260
|
+
text: 'Provide either `emails` (a flat list of addresses) or `contacts` (one row per contact).',
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const body = {};
|
|
266
|
+
if (emails)
|
|
267
|
+
body.emails = emails;
|
|
268
|
+
if (contacts)
|
|
269
|
+
body.contacts = contacts;
|
|
188
270
|
if (list_id)
|
|
189
271
|
body.list_id = list_id;
|
|
272
|
+
if (list_ids?.length)
|
|
273
|
+
body.list_ids = list_ids;
|
|
274
|
+
if (topics?.length)
|
|
275
|
+
body.topics = topics;
|
|
190
276
|
if (properties)
|
|
191
277
|
body.properties = properties;
|
|
278
|
+
if (update_existing !== undefined)
|
|
279
|
+
body.update_existing = update_existing;
|
|
192
280
|
const response = await lettr.post('/audience/contacts/bulk', body);
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
281
|
+
const data = response.data;
|
|
282
|
+
const errors = data.errors ?? [];
|
|
283
|
+
const errorCount = data.error_count ?? errors.length;
|
|
284
|
+
const lines = [
|
|
285
|
+
`Created ${data.created} contact(s); ${data.already_existed} already existed; ${data.updated ?? 0} updated.`,
|
|
286
|
+
// These two counters answer different questions ("was it already
|
|
287
|
+
// there?" vs "did we change it?") and overlap, so spelling that out
|
|
288
|
+
// stops the model reporting a total that does not add up.
|
|
289
|
+
'Note: `already existed` and `updated` overlap — a contact that existed and got a list or topic attached is counted in both, so they do not sum to the number of rows submitted.',
|
|
290
|
+
];
|
|
291
|
+
if (errorCount > 0) {
|
|
292
|
+
lines.push(`${errorCount} row(s) were SKIPPED and not imported. The rest of the batch did commit:`);
|
|
293
|
+
for (const e of errors) {
|
|
294
|
+
lines.push(` - row ${e.index} (${e.email ?? 'no email'}): [${e.error_code}] ${e.error}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const refs = data.contacts ?? [];
|
|
298
|
+
if (refs.length > 0) {
|
|
299
|
+
lines.push(`Contacts (${refs.length}):`, ...refs.map((c) => ` - ${c.email} (id: ${c.id}, ${c.created ? 'created' : 'existing'})`));
|
|
300
|
+
}
|
|
301
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
202
302
|
});
|
|
203
303
|
server.registerTool('update-audience-contact', {
|
|
204
304
|
title: 'Update Audience Contact',
|
|
@@ -126,4 +126,58 @@ export function addAudienceMembershipTools(server, lettr) {
|
|
|
126
126
|
],
|
|
127
127
|
};
|
|
128
128
|
});
|
|
129
|
+
server.registerTool('bulk-subscribe-contacts-to-topics', {
|
|
130
|
+
title: 'Bulk Subscribe Contacts to Topics',
|
|
131
|
+
description: 'Subscribe multiple contacts to multiple topics at once. Every contact is subscribed to every topic (a cartesian product of contact_ids × topic_ids). All IDs must belong to your team.',
|
|
132
|
+
inputSchema: {
|
|
133
|
+
contact_ids: z
|
|
134
|
+
.array(z.string().nonempty())
|
|
135
|
+
.min(1)
|
|
136
|
+
.max(1000)
|
|
137
|
+
.describe('Contact IDs to subscribe (max 1000)'),
|
|
138
|
+
topic_ids: z
|
|
139
|
+
.array(z.string().nonempty())
|
|
140
|
+
.min(1)
|
|
141
|
+
.max(50)
|
|
142
|
+
.describe('Topic IDs to subscribe the contacts to (max 50)'),
|
|
143
|
+
},
|
|
144
|
+
}, async ({ contact_ids, topic_ids }) => {
|
|
145
|
+
const response = await lettr.post('/audience/contacts/topics/bulk', { contact_ids, topic_ids });
|
|
146
|
+
const { subscribed, already_subscribed, total_pairs } = response.data;
|
|
147
|
+
return {
|
|
148
|
+
content: [
|
|
149
|
+
{
|
|
150
|
+
type: 'text',
|
|
151
|
+
text: `Subscribed ${subscribed} of ${total_pairs} contact-topic pair(s); ${already_subscribed} were already subscribed.`,
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
server.registerTool('bulk-unsubscribe-contacts-from-topics', {
|
|
157
|
+
title: 'Bulk Unsubscribe Contacts from Topics',
|
|
158
|
+
description: 'Unsubscribe multiple contacts from multiple topics at once (a cartesian product of contact_ids × topic_ids). Before using this tool, you MUST double-check with the user, as it drops many subscriptions at once.',
|
|
159
|
+
inputSchema: {
|
|
160
|
+
contact_ids: z
|
|
161
|
+
.array(z.string().nonempty())
|
|
162
|
+
.min(1)
|
|
163
|
+
.max(1000)
|
|
164
|
+
.describe('Contact IDs to unsubscribe (max 1000)'),
|
|
165
|
+
topic_ids: z
|
|
166
|
+
.array(z.string().nonempty())
|
|
167
|
+
.min(1)
|
|
168
|
+
.max(50)
|
|
169
|
+
.describe('Topic IDs to unsubscribe the contacts from (max 50)'),
|
|
170
|
+
},
|
|
171
|
+
}, async ({ contact_ids, topic_ids }) => {
|
|
172
|
+
const response = await lettr.delete('/audience/contacts/topics/bulk', { contact_ids, topic_ids });
|
|
173
|
+
const { unsubscribed, total_pairs } = response.data;
|
|
174
|
+
return {
|
|
175
|
+
content: [
|
|
176
|
+
{
|
|
177
|
+
type: 'text',
|
|
178
|
+
text: `Unsubscribed ${unsubscribed} of ${total_pairs} contact-topic pair(s).`,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
});
|
|
129
183
|
}
|
package/dist/tools/emails.js
CHANGED
|
@@ -253,16 +253,42 @@ export function addEmailTools(server, lettr, defaults) {
|
|
|
253
253
|
- User says "email this to X", "notify them", "send a message to..."
|
|
254
254
|
- Sending with a template: use template_slug and substitution_data (subject is optional in this case)
|
|
255
255
|
|
|
256
|
-
**Key trigger phrases:** "Send an email", "Email this to", "Notify", "Send a message", "Reply to them"
|
|
257
|
-
|
|
256
|
+
**Key trigger phrases:** "Send an email", "Email this to", "Notify", "Send a message", "Reply to them"
|
|
257
|
+
|
|
258
|
+
**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.`,
|
|
259
|
+
inputSchema: {
|
|
260
|
+
...sendEmailShape(senderEmailAddress, replierEmailAddress),
|
|
261
|
+
idempotency_key: z
|
|
262
|
+
.string()
|
|
263
|
+
.min(1)
|
|
264
|
+
.max(255)
|
|
265
|
+
.optional()
|
|
266
|
+
.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.'),
|
|
267
|
+
},
|
|
258
268
|
}, async (input) => {
|
|
259
269
|
const body = await buildSendEmailBody(input, defaults);
|
|
260
|
-
|
|
270
|
+
// Keyless sends keep the plain path: with no key there is nothing to
|
|
271
|
+
// replay, so there is no header worth reading back.
|
|
272
|
+
if (!input.idempotency_key) {
|
|
273
|
+
const response = await lettr.post('/emails', body);
|
|
274
|
+
return {
|
|
275
|
+
content: [
|
|
276
|
+
{
|
|
277
|
+
type: 'text',
|
|
278
|
+
text: `Email sent successfully! Request ID: ${response.data.request_id}, Accepted: ${response.data.accepted}, Rejected: ${response.data.rejected}`,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const { data: response, replayed } = await lettr.postIdempotent('/emails', body, input.idempotency_key);
|
|
284
|
+
const outcome = replayed
|
|
285
|
+
? 'Replayed an earlier send with this idempotency key — no second email went out.'
|
|
286
|
+
: 'Email sent successfully!';
|
|
261
287
|
return {
|
|
262
288
|
content: [
|
|
263
289
|
{
|
|
264
290
|
type: 'text',
|
|
265
|
-
text:
|
|
291
|
+
text: `${outcome} Request ID: ${response.data.request_id}, Accepted: ${response.data.accepted}, Rejected: ${response.data.rejected}`,
|
|
266
292
|
},
|
|
267
293
|
],
|
|
268
294
|
};
|
|
@@ -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
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -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';
|
package/dist/tools/templates.js
CHANGED
|
@@ -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) =>
|
|
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:
|
|
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.
|
|
4
|
-
"description": "MCP server for the Lettr email API
|
|
3
|
+
"version": "1.6.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",
|