@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.
@@ -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
- const { data: existingContact } = await db()
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
- const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
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
- row.custom_properties = await normalizeMcpContactPropertyPatch({
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
- if (!needsConfirm)
1192
- row.opted_in_at = new Date().toISOString();
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 { data: contact, error: cErr } = await db()
1206
- .from('subscribers')
1207
- .upsert(row, { onConflict: 'project_id,email' })
1208
- .select(embedOk ? '*, company:companies(id, name, domain)' : '*')
1209
- .single();
1210
- if (cErr)
1211
- return { content: [{ type: 'text', text: `Error creating contact: ${cErr.message}` }] };
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
- logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
1227
- return { content: [{ type: 'text', text: JSON.stringify(contact, null, 2) }] };
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.82",
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",