@sendinel/mcp-server 1.0.139 → 1.0.140
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/lib/db-errors.d.ts +12 -0
- package/build/lib/db-errors.js +20 -0
- package/build/tools/campaigns.js +50 -33
- package/build/tools/contacts.js +303 -292
- package/build/tools/segments.js +57 -46
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Caller-safe database errors. Never serialize database messages, details or hints. */
|
|
2
|
+
export declare function mapDbError(error: unknown): {
|
|
3
|
+
error: string;
|
|
4
|
+
code: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function dbErrorResult(error: unknown): {
|
|
7
|
+
isError: boolean;
|
|
8
|
+
content: {
|
|
9
|
+
type: 'text';
|
|
10
|
+
text: string;
|
|
11
|
+
}[];
|
|
12
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Caller-safe database errors. Never serialize database messages, details or hints. */
|
|
2
|
+
export function mapDbError(error) {
|
|
3
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
|
4
|
+
if (code === '23505')
|
|
5
|
+
return { error: 'A record with these values already exists.', code: 'conflict' };
|
|
6
|
+
if (code === '23503' || code === 'PGRST116') {
|
|
7
|
+
return { error: 'The requested or referenced record was not found.', code: 'not_found' };
|
|
8
|
+
}
|
|
9
|
+
if (code.startsWith('23') || code.startsWith('22')) {
|
|
10
|
+
return { error: 'The supplied values are not valid. Check the input and try again.', code: 'validation_failed' };
|
|
11
|
+
}
|
|
12
|
+
return { error: 'The operation could not be completed. Please try again later.', code: 'internal_error' };
|
|
13
|
+
}
|
|
14
|
+
export function dbErrorResult(error) {
|
|
15
|
+
console.warn('[mcp] Database operation failed:', error);
|
|
16
|
+
return {
|
|
17
|
+
isError: true,
|
|
18
|
+
content: [{ type: 'text', text: JSON.stringify(mapDbError(error)) }],
|
|
19
|
+
};
|
|
20
|
+
}
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dbErrorResult } from '../lib/db-errors.js';
|
|
1
2
|
import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
|
|
2
3
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
3
4
|
import { z } from 'zod';
|
|
@@ -882,40 +883,56 @@ export function registerCampaignTools(server) {
|
|
|
882
883
|
send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
|
|
883
884
|
},
|
|
884
885
|
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property }) => {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
886
|
+
try {
|
|
887
|
+
const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
|
|
888
|
+
if (!properties.ok)
|
|
889
|
+
return j({ error: properties.error, code: "INVALID_INPUT" });
|
|
890
|
+
const { data: campaign, error: campaignError } = await db()
|
|
891
|
+
.schema('email')
|
|
892
|
+
.from('campaigns')
|
|
893
|
+
.select('id')
|
|
894
|
+
.eq('id', campaign_id)
|
|
895
|
+
.eq('project_id', getProjectId())
|
|
896
|
+
.maybeSingle();
|
|
897
|
+
if (campaignError)
|
|
898
|
+
return dbErrorResult(campaignError);
|
|
899
|
+
if (!campaign)
|
|
900
|
+
return { ...j({ error: 'Campaign not found.', code: 'not_found' }), isError: true };
|
|
901
|
+
const renderVars = {};
|
|
902
|
+
if (survey_id) {
|
|
903
|
+
const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
|
|
904
|
+
if (!surveyLink)
|
|
905
|
+
return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
|
|
906
|
+
renderVars.survey_link = surveyLink;
|
|
907
|
+
}
|
|
908
|
+
const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
|
|
909
|
+
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
910
|
+
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
911
|
+
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
|
|
912
|
+
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
913
|
+
const { data, error } = await db()
|
|
914
|
+
.from('campaign_steps')
|
|
915
|
+
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
|
|
916
|
+
.select()
|
|
917
|
+
.single();
|
|
918
|
+
if (error)
|
|
919
|
+
return dbErrorResult(error);
|
|
920
|
+
await recordBrandAssetUsage({
|
|
921
|
+
client: db(),
|
|
922
|
+
projectId: getProjectId(),
|
|
923
|
+
source: 'campaign_step',
|
|
924
|
+
resourceId: data.id,
|
|
925
|
+
html: renderedHtml,
|
|
926
|
+
assetIds: brand_asset_ids,
|
|
927
|
+
}).catch((err) => {
|
|
928
|
+
console.warn('Failed to record brand asset usage for MCP campaign step:', err);
|
|
929
|
+
});
|
|
930
|
+
await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
|
|
931
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
return dbErrorResult(error);
|
|
894
935
|
}
|
|
895
|
-
const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
|
|
896
|
-
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
897
|
-
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
898
|
-
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
|
|
899
|
-
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
900
|
-
const { data, error } = await db()
|
|
901
|
-
.from('campaign_steps')
|
|
902
|
-
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
|
|
903
|
-
.select()
|
|
904
|
-
.single();
|
|
905
|
-
if (error)
|
|
906
|
-
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
907
|
-
await recordBrandAssetUsage({
|
|
908
|
-
client: db(),
|
|
909
|
-
projectId: getProjectId(),
|
|
910
|
-
source: 'campaign_step',
|
|
911
|
-
resourceId: data.id,
|
|
912
|
-
html: renderedHtml,
|
|
913
|
-
assetIds: brand_asset_ids,
|
|
914
|
-
}).catch((err) => {
|
|
915
|
-
console.warn('Failed to record brand asset usage for MCP campaign step:', err);
|
|
916
|
-
});
|
|
917
|
-
await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
|
|
918
|
-
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
919
936
|
});
|
|
920
937
|
server.registerTool('update_campaign_status', {
|
|
921
938
|
title: 'Update Campaign Status',
|
package/build/tools/contacts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dbErrorResult } from '../lib/db-errors.js';
|
|
1
2
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
|
|
3
4
|
import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
|
|
@@ -1253,133 +1254,138 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1253
1254
|
utm_term: z.string().optional(),
|
|
1254
1255
|
},
|
|
1255
1256
|
}, 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 }) => {
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
// Validate project ownership before the cap, and reuse the double opt-in lookup.
|
|
1266
|
-
const { data: siteInfo, error: siteError } = await db().from('sites')
|
|
1267
|
-
.select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
|
|
1268
|
-
if (siteError)
|
|
1269
|
-
throw new Error(`Site lookup failed: ${siteError.message}`);
|
|
1270
|
-
if (!siteInfo)
|
|
1271
|
-
return contactInputError('not_found', 'Site not found for this project.');
|
|
1272
|
-
const needsConfirm = siteInfo.double_opt_in === true;
|
|
1273
|
-
const limit = await checkPlanLimit('contacts');
|
|
1274
|
-
if (!limit.allowed) {
|
|
1275
|
-
return contactInputError('plan_limit', contactLimitMessage(limit));
|
|
1276
|
-
}
|
|
1277
|
-
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1278
|
-
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
1279
|
-
let { data: existingContact, error: lookupError } = await db()
|
|
1280
|
-
.from('subscribers')
|
|
1281
|
-
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1282
|
-
.eq('project_id', projectId)
|
|
1283
|
-
.eq('email', email)
|
|
1284
|
-
.maybeSingle();
|
|
1285
|
-
if (lookupError)
|
|
1286
|
-
return txt(`Error looking up contact: ${lookupError.message}`);
|
|
1287
|
-
let customProperties;
|
|
1288
|
-
if (company_name || company_domain) {
|
|
1289
|
-
const orgId = await getProjectOrgId(projectId);
|
|
1290
|
-
customProperties = await normalizeMcpContactPropertyPatch({
|
|
1291
|
-
...(company_name ? { company_name } : {}),
|
|
1292
|
-
...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
|
|
1293
|
-
}, { orgId, projectId });
|
|
1294
|
-
}
|
|
1295
|
-
const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
|
|
1296
|
-
let contact;
|
|
1297
|
-
if (!existingContact) {
|
|
1298
|
-
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1299
|
-
if (customProperties)
|
|
1300
|
-
row.custom_properties = customProperties;
|
|
1301
|
-
if (!needsConfirm)
|
|
1302
|
-
row.opted_in_at = new Date().toISOString();
|
|
1303
|
-
if (utm_source)
|
|
1304
|
-
row.utm_source = utm_source;
|
|
1305
|
-
if (utm_medium)
|
|
1306
|
-
row.utm_medium = utm_medium;
|
|
1307
|
-
if (utm_campaign)
|
|
1308
|
-
row.utm_campaign = utm_campaign;
|
|
1309
|
-
if (utm_content)
|
|
1310
|
-
row.utm_content = utm_content;
|
|
1311
|
-
if (utm_term)
|
|
1312
|
-
row.utm_term = utm_term;
|
|
1313
|
-
const { data, error } = await db().from('subscribers')
|
|
1314
|
-
.upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
|
|
1315
|
-
.select(selection).maybeSingle();
|
|
1316
|
-
if (error)
|
|
1317
|
-
return txt(`Error creating contact: ${error.message}`);
|
|
1318
|
-
contact = data;
|
|
1319
|
-
if (!contact) {
|
|
1320
|
-
// A concurrent add won the unique key; its provenance belongs to that insert.
|
|
1321
|
-
const result = await db().from('subscribers')
|
|
1322
|
-
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1323
|
-
.eq('project_id', projectId).eq('email', email).single();
|
|
1324
|
-
if (result.error)
|
|
1325
|
-
return txt(`Error looking up contact: ${result.error.message}`);
|
|
1326
|
-
existingContact = result.data;
|
|
1257
|
+
try {
|
|
1258
|
+
email = email.trim().toLowerCase();
|
|
1259
|
+
const projectId = getProjectId();
|
|
1260
|
+
// Block distribution list / system addresses
|
|
1261
|
+
const DISTRO_LOCAL_PARTS = new Set(['all', 'everyone', 'distro', 'broadcast', 'announce', 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces', 'noreply', 'no-reply', 'nobody', 'null', 'void']);
|
|
1262
|
+
const DISTRO_PATTERNS = [/^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/, /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/, /[-+.]broadcast$/, /^broadcast[-+.]/];
|
|
1263
|
+
const local = email.trim().toLowerCase().split('@')[0] ?? '';
|
|
1264
|
+
if (DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local))) {
|
|
1265
|
+
return contactInputError('validation_failed', `Blocked: "${email}" looks like a group or distribution list address. Sendinel sends to individual people only.`);
|
|
1327
1266
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1267
|
+
// Validate project ownership before the cap, and reuse the double opt-in lookup.
|
|
1268
|
+
const { data: siteInfo, error: siteError } = await db().from('sites')
|
|
1269
|
+
.select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
|
|
1270
|
+
if (siteError)
|
|
1271
|
+
return dbErrorResult(siteError);
|
|
1272
|
+
if (!siteInfo)
|
|
1273
|
+
return contactInputError('not_found', 'Site not found for this project.');
|
|
1274
|
+
const needsConfirm = siteInfo.double_opt_in === true;
|
|
1275
|
+
const limit = await checkPlanLimit('contacts');
|
|
1276
|
+
if (!limit.allowed) {
|
|
1277
|
+
return contactInputError('plan_limit', contactLimitMessage(limit));
|
|
1336
1278
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
.
|
|
1341
|
-
|
|
1342
|
-
|
|
1279
|
+
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1280
|
+
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
1281
|
+
let { data: existingContact, error: lookupError } = await db()
|
|
1282
|
+
.from('subscribers')
|
|
1283
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1284
|
+
.eq('project_id', projectId)
|
|
1285
|
+
.eq('email', email)
|
|
1286
|
+
.maybeSingle();
|
|
1287
|
+
if (lookupError)
|
|
1288
|
+
return dbErrorResult(lookupError);
|
|
1289
|
+
let customProperties;
|
|
1290
|
+
if (company_name || company_domain) {
|
|
1291
|
+
const orgId = await getProjectOrgId(projectId);
|
|
1292
|
+
customProperties = await normalizeMcpContactPropertyPatch({
|
|
1293
|
+
...(company_name ? { company_name } : {}),
|
|
1294
|
+
...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
|
|
1295
|
+
}, { orgId, projectId });
|
|
1343
1296
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1297
|
+
const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
|
|
1298
|
+
let contact;
|
|
1299
|
+
if (!existingContact) {
|
|
1300
|
+
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1301
|
+
if (customProperties)
|
|
1302
|
+
row.custom_properties = customProperties;
|
|
1303
|
+
if (!needsConfirm)
|
|
1304
|
+
row.opted_in_at = new Date().toISOString();
|
|
1305
|
+
if (utm_source)
|
|
1306
|
+
row.utm_source = utm_source;
|
|
1307
|
+
if (utm_medium)
|
|
1308
|
+
row.utm_medium = utm_medium;
|
|
1309
|
+
if (utm_campaign)
|
|
1310
|
+
row.utm_campaign = utm_campaign;
|
|
1311
|
+
if (utm_content)
|
|
1312
|
+
row.utm_content = utm_content;
|
|
1313
|
+
if (utm_term)
|
|
1314
|
+
row.utm_term = utm_term;
|
|
1315
|
+
const { data, error } = await db().from('subscribers')
|
|
1316
|
+
.upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
|
|
1317
|
+
.select(selection).maybeSingle();
|
|
1354
1318
|
if (error)
|
|
1355
|
-
return
|
|
1319
|
+
return dbErrorResult(error);
|
|
1320
|
+
contact = data;
|
|
1321
|
+
if (!contact) {
|
|
1322
|
+
// A concurrent add won the unique key; its provenance belongs to that insert.
|
|
1323
|
+
const result = await db().from('subscribers')
|
|
1324
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1325
|
+
.eq('project_id', projectId).eq('email', email).single();
|
|
1326
|
+
if (result.error)
|
|
1327
|
+
return dbErrorResult(result.error);
|
|
1328
|
+
existingContact = result.data;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const existing = !!existingContact;
|
|
1332
|
+
if (existingContact) {
|
|
1333
|
+
const empty = (value) => value == null || value === '';
|
|
1334
|
+
const row = {};
|
|
1335
|
+
for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
|
|
1336
|
+
if (empty(existingContact[key]) && !empty(value))
|
|
1337
|
+
row[key] = value;
|
|
1338
|
+
}
|
|
1339
|
+
if (!needsConfirm && existingContact.opted_in_at == null)
|
|
1340
|
+
row.opted_in_at = new Date().toISOString();
|
|
1341
|
+
const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
|
|
1342
|
+
.filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
|
|
1343
|
+
if (Object.keys(missingProperties).length) {
|
|
1344
|
+
row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
|
|
1345
|
+
}
|
|
1346
|
+
if (Object.keys(row).length) {
|
|
1347
|
+
let update = db().from('subscribers').update(row)
|
|
1348
|
+
.eq('id', existingContact.id).eq('project_id', projectId);
|
|
1349
|
+
// The stored values must still match: a concurrent edit always wins over a re-add.
|
|
1350
|
+
for (const key of Object.keys(row)) {
|
|
1351
|
+
const previous = existingContact[key];
|
|
1352
|
+
update = previous == null ? update.is(key, null)
|
|
1353
|
+
: update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
|
|
1354
|
+
}
|
|
1355
|
+
const { error } = await update;
|
|
1356
|
+
if (error)
|
|
1357
|
+
return dbErrorResult(error);
|
|
1358
|
+
}
|
|
1359
|
+
const result = await db().from('subscribers').select(selection)
|
|
1360
|
+
.eq('id', existingContact.id).eq('project_id', projectId).single();
|
|
1361
|
+
if (result.error)
|
|
1362
|
+
return dbErrorResult(result.error);
|
|
1363
|
+
contact = result.data;
|
|
1364
|
+
}
|
|
1365
|
+
const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
|
|
1366
|
+
const { error: subErr } = await db()
|
|
1367
|
+
.from('site_subscriptions')
|
|
1368
|
+
.upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
|
|
1369
|
+
if (subErr)
|
|
1370
|
+
return dbErrorResult(subErr);
|
|
1371
|
+
if (existing)
|
|
1372
|
+
logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
|
|
1373
|
+
if (needsConfirm) {
|
|
1374
|
+
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1375
|
+
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1376
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1377
|
+
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
|
|
1378
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
|
|
1356
1379
|
}
|
|
1357
|
-
const result = await db().from('subscribers').select(selection)
|
|
1358
|
-
.eq('id', existingContact.id).eq('project_id', projectId).single();
|
|
1359
|
-
if (result.error)
|
|
1360
|
-
return txt(`Error reading contact: ${result.error.message}`);
|
|
1361
|
-
contact = result.data;
|
|
1362
|
-
}
|
|
1363
|
-
const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
|
|
1364
|
-
const { error: subErr } = await db()
|
|
1365
|
-
.from('site_subscriptions')
|
|
1366
|
-
.upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
|
|
1367
|
-
if (subErr)
|
|
1368
|
-
return { content: [{ type: 'text', text: `Contact created but subscription failed: ${subErr.message}` }] };
|
|
1369
|
-
if (existing)
|
|
1370
|
-
logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
|
|
1371
|
-
if (needsConfirm) {
|
|
1372
|
-
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1373
|
-
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1374
1380
|
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1375
|
-
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: '
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1381
|
+
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
1382
|
+
if (!existing)
|
|
1383
|
+
logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
|
|
1384
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
|
|
1385
|
+
}
|
|
1386
|
+
catch (error) {
|
|
1387
|
+
return dbErrorResult(error);
|
|
1388
|
+
}
|
|
1383
1389
|
});
|
|
1384
1390
|
server.registerTool('import_subscribers', {
|
|
1385
1391
|
title: 'Import Subscribers',
|
|
@@ -1719,194 +1725,199 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1719
1725
|
timezone: z.string().nullable().optional().describe('IANA time zone identifier, e.g. America/Los_Angeles'),
|
|
1720
1726
|
},
|
|
1721
1727
|
}, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
definitions = await registeredCustomFields(projectId);
|
|
1729
|
-
}
|
|
1730
|
-
catch {
|
|
1731
|
-
return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1732
|
-
}
|
|
1733
|
-
fields = Object.fromEntries(definitions.map(field => [field.key, field]));
|
|
1734
|
-
const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
|
|
1735
|
-
const invalidShape = Object.entries(values).find(([key, value]) => {
|
|
1736
|
-
if (!Object.hasOwn(fields, key))
|
|
1737
|
-
return false;
|
|
1738
|
-
const type = fields[key].type;
|
|
1739
|
-
if (type === 'multi_select')
|
|
1740
|
-
return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
|
|
1741
|
-
return typeof value === 'object' && type !== 'contact_reference';
|
|
1742
|
-
});
|
|
1743
|
-
const { anomalies } = normalizeContactProperties(values, { fields });
|
|
1744
|
-
if (invalidShape || anomalies.length) {
|
|
1745
|
-
const key = invalidShape?.[0] ?? anomalies[0].key;
|
|
1746
|
-
return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
|
|
1747
|
-
}
|
|
1748
|
-
propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
|
|
1749
|
-
}
|
|
1750
|
-
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1751
|
-
const updates = {};
|
|
1752
|
-
let customPropertyChanges = [];
|
|
1753
|
-
if (email !== undefined)
|
|
1754
|
-
updates.email = email;
|
|
1755
|
-
if (first_name !== undefined)
|
|
1756
|
-
updates.first_name = first_name;
|
|
1757
|
-
if (last_name !== undefined)
|
|
1758
|
-
updates.last_name = last_name;
|
|
1759
|
-
if (company_id !== undefined) {
|
|
1760
|
-
updates.company_id = company_id;
|
|
1761
|
-
}
|
|
1762
|
-
else if (company_name || company_domain) {
|
|
1763
|
-
updates.company_id = await resolveCompanyId(projectId, {
|
|
1764
|
-
company_name: company_name ?? undefined,
|
|
1765
|
-
company_domain: company_domain ?? undefined,
|
|
1766
|
-
});
|
|
1767
|
-
}
|
|
1768
|
-
if (tags !== undefined)
|
|
1769
|
-
updates.tags = tags;
|
|
1770
|
-
if (custom_properties !== undefined) {
|
|
1771
|
-
const { data: existing, error: existingError } = await db()
|
|
1772
|
-
.from('subscribers')
|
|
1773
|
-
.select('custom_properties')
|
|
1774
|
-
.eq('id', contact_id)
|
|
1775
|
-
.eq('project_id', projectId)
|
|
1776
|
-
.maybeSingle();
|
|
1777
|
-
if (existingError)
|
|
1778
|
-
return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1779
|
-
if (!existing)
|
|
1780
|
-
return contactInputError('not_found', 'Contact not found');
|
|
1781
|
-
const orgId = await getProjectOrgId(projectId);
|
|
1782
|
-
const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
|
|
1783
|
-
orgId,
|
|
1784
|
-
projectId,
|
|
1785
|
-
subscriberId: contact_id,
|
|
1786
|
-
preserveNullDeletions: true,
|
|
1787
|
-
fields,
|
|
1788
|
-
});
|
|
1789
|
-
customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
|
|
1790
|
-
updates.custom_properties = {
|
|
1791
|
-
...(existing?.custom_properties ?? {}),
|
|
1792
|
-
...normalizedPatch,
|
|
1793
|
-
...(company_name ? { company_name } : {}),
|
|
1794
|
-
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1795
|
-
};
|
|
1796
|
-
for (const [key, value] of Object.entries(normalizedPatch)) {
|
|
1797
|
-
if (value === null)
|
|
1798
|
-
delete updates.custom_properties[key];
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
else if (company_name || company_domain) {
|
|
1802
|
-
const { data: existing } = await db()
|
|
1803
|
-
.from('subscribers')
|
|
1804
|
-
.select('custom_properties')
|
|
1805
|
-
.eq('id', contact_id)
|
|
1806
|
-
.eq('project_id', projectId)
|
|
1807
|
-
.maybeSingle();
|
|
1808
|
-
const orgId = await getProjectOrgId(projectId);
|
|
1809
|
-
const companyPatch = await normalizeMcpContactPropertyPatch({
|
|
1810
|
-
...(company_name ? { company_name } : {}),
|
|
1811
|
-
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1812
|
-
}, { orgId, projectId, subscriberId: contact_id });
|
|
1813
|
-
updates.custom_properties = {
|
|
1814
|
-
...(existing?.custom_properties ?? {}),
|
|
1815
|
-
...companyPatch,
|
|
1816
|
-
};
|
|
1817
|
-
}
|
|
1818
|
-
if (phone_e164 !== undefined) {
|
|
1819
|
-
if (phone_e164 === null) {
|
|
1820
|
-
updates.phone_e164 = null;
|
|
1821
|
-
}
|
|
1822
|
-
else {
|
|
1823
|
-
const normalized = normalizePhoneE164(phone_e164);
|
|
1824
|
-
if (!normalized)
|
|
1825
|
-
return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
|
|
1826
|
-
updates.phone_e164 = normalized;
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
if (timezone !== undefined) {
|
|
1830
|
-
if (timezone === null) {
|
|
1831
|
-
updates.timezone = null;
|
|
1832
|
-
}
|
|
1833
|
-
else {
|
|
1834
|
-
// Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
|
|
1835
|
-
// ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
|
|
1836
|
-
if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
|
|
1837
|
-
return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
|
|
1838
|
-
}
|
|
1728
|
+
try {
|
|
1729
|
+
const projectId = getProjectId();
|
|
1730
|
+
let propertyWarnings = [];
|
|
1731
|
+
let fields;
|
|
1732
|
+
if (custom_properties !== undefined) {
|
|
1733
|
+
let definitions;
|
|
1839
1734
|
try {
|
|
1840
|
-
|
|
1735
|
+
definitions = await registeredCustomFields(projectId);
|
|
1841
1736
|
}
|
|
1842
1737
|
catch {
|
|
1843
|
-
return
|
|
1738
|
+
return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1844
1739
|
}
|
|
1845
|
-
|
|
1740
|
+
fields = Object.fromEntries(definitions.map(field => [field.key, field]));
|
|
1741
|
+
const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
|
|
1742
|
+
const invalidShape = Object.entries(values).find(([key, value]) => {
|
|
1743
|
+
if (!Object.hasOwn(fields, key))
|
|
1744
|
+
return false;
|
|
1745
|
+
const type = fields[key].type;
|
|
1746
|
+
if (type === 'multi_select')
|
|
1747
|
+
return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
|
|
1748
|
+
return typeof value === 'object' && type !== 'contact_reference';
|
|
1749
|
+
});
|
|
1750
|
+
const { anomalies } = normalizeContactProperties(values, { fields });
|
|
1751
|
+
if (invalidShape || anomalies.length) {
|
|
1752
|
+
const key = invalidShape?.[0] ?? anomalies[0].key;
|
|
1753
|
+
return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
|
|
1754
|
+
}
|
|
1755
|
+
propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
|
|
1846
1756
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1757
|
+
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1758
|
+
const updates = {};
|
|
1759
|
+
let customPropertyChanges = [];
|
|
1760
|
+
if (email !== undefined)
|
|
1761
|
+
updates.email = email;
|
|
1762
|
+
if (first_name !== undefined)
|
|
1763
|
+
updates.first_name = first_name;
|
|
1764
|
+
if (last_name !== undefined)
|
|
1765
|
+
updates.last_name = last_name;
|
|
1766
|
+
if (company_id !== undefined) {
|
|
1767
|
+
updates.company_id = company_id;
|
|
1768
|
+
}
|
|
1769
|
+
else if (company_name || company_domain) {
|
|
1770
|
+
updates.company_id = await resolveCompanyId(projectId, {
|
|
1771
|
+
company_name: company_name ?? undefined,
|
|
1772
|
+
company_domain: company_domain ?? undefined,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
if (tags !== undefined)
|
|
1776
|
+
updates.tags = tags;
|
|
1777
|
+
if (custom_properties !== undefined) {
|
|
1778
|
+
const { data: existing, error: existingError } = await db()
|
|
1855
1779
|
.from('subscribers')
|
|
1856
|
-
.select('
|
|
1780
|
+
.select('custom_properties')
|
|
1857
1781
|
.eq('id', contact_id)
|
|
1858
1782
|
.eq('project_id', projectId)
|
|
1859
1783
|
.maybeSingle();
|
|
1860
|
-
if (
|
|
1861
|
-
|
|
1784
|
+
if (existingError)
|
|
1785
|
+
return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1786
|
+
if (!existing)
|
|
1787
|
+
return contactInputError('not_found', 'Contact not found');
|
|
1788
|
+
const orgId = await getProjectOrgId(projectId);
|
|
1789
|
+
const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
|
|
1790
|
+
orgId,
|
|
1791
|
+
projectId,
|
|
1792
|
+
subscriberId: contact_id,
|
|
1793
|
+
preserveNullDeletions: true,
|
|
1794
|
+
fields,
|
|
1795
|
+
});
|
|
1796
|
+
customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
|
|
1797
|
+
updates.custom_properties = {
|
|
1798
|
+
...(existing?.custom_properties ?? {}),
|
|
1799
|
+
...normalizedPatch,
|
|
1800
|
+
...(company_name ? { company_name } : {}),
|
|
1801
|
+
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1802
|
+
};
|
|
1803
|
+
for (const [key, value] of Object.entries(normalizedPatch)) {
|
|
1804
|
+
if (value === null)
|
|
1805
|
+
delete updates.custom_properties[key];
|
|
1862
1806
|
}
|
|
1863
1807
|
}
|
|
1864
|
-
|
|
1865
|
-
|
|
1808
|
+
else if (company_name || company_domain) {
|
|
1809
|
+
const { data: existing } = await db()
|
|
1810
|
+
.from('subscribers')
|
|
1811
|
+
.select('custom_properties')
|
|
1812
|
+
.eq('id', contact_id)
|
|
1813
|
+
.eq('project_id', projectId)
|
|
1814
|
+
.maybeSingle();
|
|
1815
|
+
const orgId = await getProjectOrgId(projectId);
|
|
1816
|
+
const companyPatch = await normalizeMcpContactPropertyPatch({
|
|
1817
|
+
...(company_name ? { company_name } : {}),
|
|
1818
|
+
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1819
|
+
}, { orgId, projectId, subscriberId: contact_id });
|
|
1820
|
+
updates.custom_properties = {
|
|
1821
|
+
...(existing?.custom_properties ?? {}),
|
|
1822
|
+
...companyPatch,
|
|
1823
|
+
};
|
|
1866
1824
|
}
|
|
1867
|
-
if (
|
|
1868
|
-
|
|
1825
|
+
if (phone_e164 !== undefined) {
|
|
1826
|
+
if (phone_e164 === null) {
|
|
1827
|
+
updates.phone_e164 = null;
|
|
1828
|
+
}
|
|
1829
|
+
else {
|
|
1830
|
+
const normalized = normalizePhoneE164(phone_e164);
|
|
1831
|
+
if (!normalized)
|
|
1832
|
+
return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
|
|
1833
|
+
updates.phone_e164 = normalized;
|
|
1834
|
+
}
|
|
1869
1835
|
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1836
|
+
if (timezone !== undefined) {
|
|
1837
|
+
if (timezone === null) {
|
|
1838
|
+
updates.timezone = null;
|
|
1839
|
+
}
|
|
1840
|
+
else {
|
|
1841
|
+
// Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
|
|
1842
|
+
// ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
|
|
1843
|
+
if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
|
|
1844
|
+
return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
|
|
1845
|
+
}
|
|
1846
|
+
try {
|
|
1847
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
|
1848
|
+
}
|
|
1849
|
+
catch {
|
|
1850
|
+
return contactInputError('validation_failed', 'Invalid timezone: must be a valid IANA time zone identifier.');
|
|
1851
|
+
}
|
|
1852
|
+
updates.timezone = timezone;
|
|
1877
1853
|
}
|
|
1878
1854
|
}
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1855
|
+
if (!Object.keys(updates).length)
|
|
1856
|
+
return contactInputError('validation_failed', 'No fields to update.');
|
|
1857
|
+
const tagResult = {};
|
|
1858
|
+
if (tags !== undefined) {
|
|
1859
|
+
let previousTags;
|
|
1860
|
+
try {
|
|
1861
|
+
const { data: existing, error: readError } = await db()
|
|
1862
|
+
.from('subscribers')
|
|
1863
|
+
.select('tags')
|
|
1864
|
+
.eq('id', contact_id)
|
|
1865
|
+
.eq('project_id', projectId)
|
|
1866
|
+
.maybeSingle();
|
|
1867
|
+
if (!readError && Array.isArray(existing?.tags) && existing.tags.every((tag) => typeof tag === 'string')) {
|
|
1868
|
+
previousTags = existing.tags;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
catch {
|
|
1872
|
+
// The replacement can still succeed, but the previous set is unknown.
|
|
1873
|
+
}
|
|
1874
|
+
if (previousTags === undefined) {
|
|
1875
|
+
tagResult.warnings = ['Tags were REPLACED, but previous tags could not be read; removed and added tags are unknown. Use set_subscriber_tags to add tags without replacing the whole set.'];
|
|
1876
|
+
}
|
|
1877
|
+
else {
|
|
1878
|
+
const previousSet = new Set(previousTags);
|
|
1879
|
+
const nextSet = new Set(tags);
|
|
1880
|
+
tagResult.tags_removed = [...previousSet].filter((tag) => !nextSet.has(tag));
|
|
1881
|
+
tagResult.tags_added = [...nextSet].filter((tag) => !previousSet.has(tag));
|
|
1882
|
+
if (tagResult.tags_removed.length > 0) {
|
|
1883
|
+
tagResult.warnings = ['Tags were REPLACED. Use set_subscriber_tags to add tags without replacing the whole set.'];
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
updates.updated_at = new Date().toISOString();
|
|
1888
|
+
const { data, error } = await db()
|
|
1889
|
+
.from('subscribers')
|
|
1890
|
+
.update(updates)
|
|
1891
|
+
.eq('id', contact_id)
|
|
1892
|
+
.eq('project_id', projectId)
|
|
1893
|
+
.select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
|
|
1894
|
+
.single();
|
|
1895
|
+
if (error?.code === '23505' && updates.phone_e164 !== undefined) {
|
|
1896
|
+
return contactInputError('conflict', 'Another contact in this project already has this phone number.');
|
|
1897
|
+
}
|
|
1898
|
+
if (error)
|
|
1899
|
+
return dbErrorResult(error);
|
|
1900
|
+
if (customPropertyChanges.length > 0) {
|
|
1901
|
+
logAudit({
|
|
1902
|
+
action: 'contact.custom_properties_changed',
|
|
1903
|
+
resourceType: 'contact',
|
|
1904
|
+
resourceId: contact_id,
|
|
1905
|
+
details: { changes: customPropertyChanges },
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
await writeBackMcpContact(projectId, data, {
|
|
1909
|
+
...(updates.email !== undefined ? { email: updates.email } : {}),
|
|
1910
|
+
...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
|
|
1911
|
+
...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
|
|
1912
|
+
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
1913
|
+
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
1899
1914
|
});
|
|
1915
|
+
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
|
|
1916
|
+
return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
|
|
1917
|
+
}
|
|
1918
|
+
catch (error) {
|
|
1919
|
+
return dbErrorResult(error);
|
|
1900
1920
|
}
|
|
1901
|
-
await writeBackMcpContact(projectId, data, {
|
|
1902
|
-
...(updates.email !== undefined ? { email: updates.email } : {}),
|
|
1903
|
-
...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
|
|
1904
|
-
...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
|
|
1905
|
-
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
1906
|
-
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
1907
|
-
});
|
|
1908
|
-
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
|
|
1909
|
-
return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
|
|
1910
1921
|
});
|
|
1911
1922
|
server.registerTool('get_subscriber', {
|
|
1912
1923
|
title: 'Get Subscriber',
|
package/build/tools/segments.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dbErrorResult } from '../lib/db-errors.js';
|
|
1
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
@@ -80,31 +81,36 @@ export function registerSegmentTools(server) {
|
|
|
80
81
|
rules: z.record(z.string(), z.unknown()).describe('Segment rule tree: {op:"AND"|"OR", conditions:[{field, operator, value}]} or legacy flat rules. Supported fields include lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.<key>, email_sends (eq only, value: {min_count: positive integer, within_days: positive number}; at most one per segment). tag may be a plain string (one tag) or {values: string[], mode: "any"|"all"} to match several tags at once.'),
|
|
81
82
|
},
|
|
82
83
|
}, async ({ name, description, rules }) => {
|
|
83
|
-
let storedRules;
|
|
84
84
|
try {
|
|
85
|
-
storedRules
|
|
85
|
+
let storedRules;
|
|
86
|
+
try {
|
|
87
|
+
storedRules = prepareSegmentRulesForStorage(rules);
|
|
88
|
+
}
|
|
89
|
+
catch (rulesError) {
|
|
90
|
+
const invalid = invalidRules(rulesError);
|
|
91
|
+
if (invalid)
|
|
92
|
+
return invalid;
|
|
93
|
+
throw rulesError;
|
|
94
|
+
}
|
|
95
|
+
const { data, error } = await db()
|
|
96
|
+
.from('segments')
|
|
97
|
+
.insert({ project_id: getProjectId(), name, description, rules: storedRules })
|
|
98
|
+
.select('id, name, description, rules, created_at')
|
|
99
|
+
.single();
|
|
100
|
+
if (error)
|
|
101
|
+
return dbErrorResult(error);
|
|
102
|
+
// Count against the stored (normalized) tree, not the raw input: a tree skips
|
|
103
|
+
// applySegmentRules' SQL push-down and filters in JS, which is what list_segments
|
|
104
|
+
// does for this same segment a moment later. Pushing the flat rules down instead
|
|
105
|
+
// makes the two disagree wherever SQL and JS compare differently -- notably
|
|
106
|
+
// custom_property against a non-string JSONB value (boolean/number), where
|
|
107
|
+
// `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
|
|
108
|
+
const ids = await getSegmentContactIds(data.rules);
|
|
109
|
+
return j({ ...data, estimated_contacts: ids.length });
|
|
86
110
|
}
|
|
87
|
-
catch (
|
|
88
|
-
|
|
89
|
-
if (invalid)
|
|
90
|
-
return invalid;
|
|
91
|
-
throw rulesError;
|
|
111
|
+
catch (error) {
|
|
112
|
+
return dbErrorResult(error);
|
|
92
113
|
}
|
|
93
|
-
const { data, error } = await db()
|
|
94
|
-
.from('segments')
|
|
95
|
-
.insert({ project_id: getProjectId(), name, description, rules: storedRules })
|
|
96
|
-
.select('id, name, description, rules, created_at')
|
|
97
|
-
.single();
|
|
98
|
-
if (error)
|
|
99
|
-
return txt(`Error: ${error.message}`);
|
|
100
|
-
// Count against the stored (normalized) tree, not the raw input: a tree skips
|
|
101
|
-
// applySegmentRules' SQL push-down and filters in JS, which is what list_segments
|
|
102
|
-
// does for this same segment a moment later. Pushing the flat rules down instead
|
|
103
|
-
// makes the two disagree wherever SQL and JS compare differently -- notably
|
|
104
|
-
// custom_property against a non-string JSONB value (boolean/number), where
|
|
105
|
-
// `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
|
|
106
|
-
const ids = await getSegmentContactIds(data.rules);
|
|
107
|
-
return j({ ...data, estimated_contacts: ids.length });
|
|
108
114
|
});
|
|
109
115
|
server.registerTool('list_segments', {
|
|
110
116
|
title: 'List Segments',
|
|
@@ -157,32 +163,37 @@ export function registerSegmentTools(server) {
|
|
|
157
163
|
rules: z.record(z.string(), z.unknown()).optional(),
|
|
158
164
|
},
|
|
159
165
|
}, async ({ segment_id, name, description, rules }) => {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
166
|
+
try {
|
|
167
|
+
const updates = { updated_at: new Date().toISOString() };
|
|
168
|
+
if (name)
|
|
169
|
+
updates.name = name;
|
|
170
|
+
if (description !== undefined)
|
|
171
|
+
updates.description = description;
|
|
172
|
+
if (rules) {
|
|
173
|
+
try {
|
|
174
|
+
updates.rules = prepareSegmentRulesForStorage(rules);
|
|
175
|
+
}
|
|
176
|
+
catch (rulesError) {
|
|
177
|
+
const invalid = invalidRules(rulesError);
|
|
178
|
+
if (invalid)
|
|
179
|
+
return invalid;
|
|
180
|
+
throw rulesError;
|
|
181
|
+
}
|
|
174
182
|
}
|
|
183
|
+
const { data, error } = await db()
|
|
184
|
+
.from('segments')
|
|
185
|
+
.update(updates)
|
|
186
|
+
.eq('id', segment_id)
|
|
187
|
+
.eq('project_id', getProjectId())
|
|
188
|
+
.select('id, name, description, rules, updated_at')
|
|
189
|
+
.single();
|
|
190
|
+
if (error)
|
|
191
|
+
return dbErrorResult(error);
|
|
192
|
+
return j(data);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
return dbErrorResult(error);
|
|
175
196
|
}
|
|
176
|
-
const { data, error } = await db()
|
|
177
|
-
.from('segments')
|
|
178
|
-
.update(updates)
|
|
179
|
-
.eq('id', segment_id)
|
|
180
|
-
.eq('project_id', getProjectId())
|
|
181
|
-
.select('id, name, description, rules, updated_at')
|
|
182
|
-
.single();
|
|
183
|
-
if (error)
|
|
184
|
-
return txt(`Error: ${error.message}`);
|
|
185
|
-
return j(data);
|
|
186
197
|
});
|
|
187
198
|
server.registerTool('delete_segment', {
|
|
188
199
|
title: 'Delete Segment',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.140",
|
|
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",
|