@sendinel/mcp-server 1.0.82 → 1.0.83
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/build/tools/contacts.js +71 -17
- package/package.json +1 -1
package/build/tools/contacts.js
CHANGED
|
@@ -1140,7 +1140,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1140
1140
|
idempotentHint: false,
|
|
1141
1141
|
openWorldHint: false,
|
|
1142
1142
|
},
|
|
1143
|
-
description: 'Add a contact and subscribe them to a site',
|
|
1143
|
+
description: 'Add a contact and subscribe them to a site. Existing contacts keep their source and stored fields; the response includes existing: true.',
|
|
1144
1144
|
inputSchema: {
|
|
1145
1145
|
email: z.string().email(),
|
|
1146
1146
|
site_id: z.string().uuid(),
|
|
@@ -1157,6 +1157,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1157
1157
|
utm_term: z.string().optional(),
|
|
1158
1158
|
},
|
|
1159
1159
|
}, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
|
|
1160
|
+
email = email.trim().toLowerCase();
|
|
1160
1161
|
const projectId = getProjectId();
|
|
1161
1162
|
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1162
1163
|
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
@@ -1174,23 +1175,30 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1174
1175
|
// Check if site requires double opt-in
|
|
1175
1176
|
const { data: siteInfo } = await db().from('sites').select('double_opt_in').eq('id', site_id).maybeSingle();
|
|
1176
1177
|
const needsConfirm = siteInfo?.double_opt_in === true;
|
|
1177
|
-
|
|
1178
|
+
let { data: existingContact, error: lookupError } = await db()
|
|
1178
1179
|
.from('subscribers')
|
|
1179
|
-
.select('id')
|
|
1180
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1180
1181
|
.eq('project_id', projectId)
|
|
1181
1182
|
.eq('email', email)
|
|
1182
1183
|
.maybeSingle();
|
|
1183
|
-
|
|
1184
|
+
if (lookupError)
|
|
1185
|
+
return txt(`Error looking up contact: ${lookupError.message}`);
|
|
1186
|
+
let customProperties;
|
|
1184
1187
|
if (company_name || company_domain) {
|
|
1185
1188
|
const orgId = await getProjectOrgId(projectId);
|
|
1186
|
-
|
|
1189
|
+
customProperties = await normalizeMcpContactPropertyPatch({
|
|
1187
1190
|
...(company_name ? { company_name } : {}),
|
|
1188
1191
|
...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
|
|
1189
1192
|
}, { orgId, projectId });
|
|
1190
1193
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1194
|
+
const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
|
|
1195
|
+
let contact;
|
|
1193
1196
|
if (!existingContact) {
|
|
1197
|
+
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1198
|
+
if (customProperties)
|
|
1199
|
+
row.custom_properties = customProperties;
|
|
1200
|
+
if (!needsConfirm)
|
|
1201
|
+
row.opted_in_at = new Date().toISOString();
|
|
1194
1202
|
if (utm_source)
|
|
1195
1203
|
row.utm_source = utm_source;
|
|
1196
1204
|
if (utm_medium)
|
|
@@ -1201,30 +1209,76 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1201
1209
|
row.utm_content = utm_content;
|
|
1202
1210
|
if (utm_term)
|
|
1203
1211
|
row.utm_term = utm_term;
|
|
1212
|
+
const { data, error } = await db().from('subscribers')
|
|
1213
|
+
.upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
|
|
1214
|
+
.select(selection).maybeSingle();
|
|
1215
|
+
if (error)
|
|
1216
|
+
return txt(`Error creating contact: ${error.message}`);
|
|
1217
|
+
contact = data;
|
|
1218
|
+
if (!contact) {
|
|
1219
|
+
// A concurrent add won the unique key; its provenance belongs to that insert.
|
|
1220
|
+
const result = await db().from('subscribers')
|
|
1221
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1222
|
+
.eq('project_id', projectId).eq('email', email).single();
|
|
1223
|
+
if (result.error)
|
|
1224
|
+
return txt(`Error looking up contact: ${result.error.message}`);
|
|
1225
|
+
existingContact = result.data;
|
|
1226
|
+
}
|
|
1204
1227
|
}
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
.
|
|
1210
|
-
|
|
1211
|
-
|
|
1228
|
+
const existing = !!existingContact;
|
|
1229
|
+
if (existingContact) {
|
|
1230
|
+
const empty = (value) => value == null || value === '';
|
|
1231
|
+
const row = {};
|
|
1232
|
+
for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
|
|
1233
|
+
if (empty(existingContact[key]) && !empty(value))
|
|
1234
|
+
row[key] = value;
|
|
1235
|
+
}
|
|
1236
|
+
if (!needsConfirm && existingContact.opted_in_at == null)
|
|
1237
|
+
row.opted_in_at = new Date().toISOString();
|
|
1238
|
+
const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
|
|
1239
|
+
.filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
|
|
1240
|
+
if (Object.keys(missingProperties).length) {
|
|
1241
|
+
row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
|
|
1242
|
+
}
|
|
1243
|
+
if (Object.keys(row).length) {
|
|
1244
|
+
let update = db().from('subscribers').update(row)
|
|
1245
|
+
.eq('id', existingContact.id).eq('project_id', projectId);
|
|
1246
|
+
// The stored values must still match: a concurrent edit always wins over a re-add.
|
|
1247
|
+
for (const key of Object.keys(row)) {
|
|
1248
|
+
const previous = existingContact[key];
|
|
1249
|
+
update = previous == null ? update.is(key, null)
|
|
1250
|
+
: update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
|
|
1251
|
+
}
|
|
1252
|
+
const { error } = await update;
|
|
1253
|
+
if (error)
|
|
1254
|
+
return txt(`Error updating contact: ${error.message}`);
|
|
1255
|
+
}
|
|
1256
|
+
const result = await db().from('subscribers').select(selection)
|
|
1257
|
+
.eq('id', existingContact.id).eq('project_id', projectId).single();
|
|
1258
|
+
if (result.error)
|
|
1259
|
+
return txt(`Error reading contact: ${result.error.message}`);
|
|
1260
|
+
contact = result.data;
|
|
1261
|
+
}
|
|
1262
|
+
const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
|
|
1212
1263
|
const { error: subErr } = await db()
|
|
1213
1264
|
.from('site_subscriptions')
|
|
1214
1265
|
.upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
|
|
1215
1266
|
if (subErr)
|
|
1216
1267
|
return { content: [{ type: 'text', text: `Contact created but subscription failed: ${subErr.message}` }] };
|
|
1268
|
+
if (existing)
|
|
1269
|
+
logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
|
|
1217
1270
|
if (needsConfirm) {
|
|
1218
1271
|
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1219
1272
|
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1220
1273
|
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1221
1274
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
|
|
1222
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.' }, null, 2) }] };
|
|
1275
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
|
|
1223
1276
|
}
|
|
1224
1277
|
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1225
1278
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
1226
|
-
|
|
1227
|
-
|
|
1279
|
+
if (!existing)
|
|
1280
|
+
logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
|
|
1281
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
|
|
1228
1282
|
});
|
|
1229
1283
|
server.registerTool('import_subscribers', {
|
|
1230
1284
|
title: 'Import Subscribers',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.83",
|
|
4
4
|
"description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
|
|
5
5
|
"mcpName": "ai.sendinel/mcp-server",
|
|
6
6
|
"type": "module",
|