lettr-mcp 1.4.0 → 1.5.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 +3 -1
- package/dist/package.json +1 -1
- package/dist/tools/audience-contacts.js +116 -16
- package/dist/tools/audience-memberships.js +54 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -153,7 +153,7 @@ Environment variables:
|
|
|
153
153
|
| `list-audience-contacts` | List contacts with search, status, list, and segment filters |
|
|
154
154
|
| `get-audience-contact` | Get a contact with its properties, lists, and topics |
|
|
155
155
|
| `create-audience-contact` | Create a contact, optionally with double opt-in |
|
|
156
|
-
| `bulk-create-audience-contacts` | Create many contacts from a list of emails |
|
|
156
|
+
| `bulk-create-audience-contacts` | Create many contacts, either from a flat list of emails or one row per contact |
|
|
157
157
|
| `update-audience-contact` | Update a contact's email, status, or properties |
|
|
158
158
|
| `delete-audience-contact` | Delete a contact |
|
|
159
159
|
| `attach-contact-to-list` | Add a contact to a list |
|
|
@@ -162,6 +162,8 @@ Environment variables:
|
|
|
162
162
|
| `unsubscribe-contact-from-topic` | Unsubscribe a contact from a topic |
|
|
163
163
|
| `bulk-attach-contacts-to-lists` | Attach many contacts to many lists at once |
|
|
164
164
|
| `bulk-detach-contacts-from-lists` | Detach many contacts from many lists at once |
|
|
165
|
+
| `bulk-subscribe-contacts-to-topics` | Subscribe many contacts to many topics at once |
|
|
166
|
+
| `bulk-unsubscribe-contacts-from-topics` | Unsubscribe many contacts from many topics at once |
|
|
165
167
|
| `list-audience-topics` | List subscription topics with pagination |
|
|
166
168
|
| `create-audience-topic` | Create a subscription topic |
|
|
167
169
|
| `get-audience-topic` | Get a single topic |
|
package/dist/package.json
CHANGED
|
@@ -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/package.json
CHANGED