@sendinel/mcp-server 1.0.135 → 1.0.136
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/plan-limits.d.ts +3 -0
- package/build/plan-limits.js +8 -0
- package/build/tools/contacts.js +18 -12
- package/package.json +1 -1
package/build/plan-limits.d.ts
CHANGED
|
@@ -3,6 +3,9 @@ interface LimitCheck {
|
|
|
3
3
|
current: number;
|
|
4
4
|
limit: number;
|
|
5
5
|
resource: string;
|
|
6
|
+
upgrade_url?: string | null;
|
|
6
7
|
}
|
|
8
|
+
/** Shared cap copy for single adds and bulk imports, without assuming a plan tier. */
|
|
9
|
+
export declare function contactLimitMessage(limit: LimitCheck, newCount?: number): string;
|
|
7
10
|
export declare function checkPlanLimit(resource: 'contacts' | 'monthly_sends' | 'active_campaigns' | 'sites' | 'ai_drafts'): Promise<LimitCheck>;
|
|
8
11
|
export {};
|
package/build/plan-limits.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { adminDb } from './db.js';
|
|
2
2
|
import { getProjectId } from './project.js';
|
|
3
|
+
/** Shared cap copy for single adds and bulk imports, without assuming a plan tier. */
|
|
4
|
+
export function contactLimitMessage(limit, newCount) {
|
|
5
|
+
const usage = `(${limit.current}/${limit.limit})`;
|
|
6
|
+
const message = newCount === undefined
|
|
7
|
+
? `Contact limit reached ${usage}.`
|
|
8
|
+
: `Import would add ${newCount} new contacts but only ${Math.max(0, limit.limit - limit.current)} slots remaining ${usage}.`;
|
|
9
|
+
return `${message} Raise your contact limit${limit.upgrade_url ? `: ${limit.upgrade_url}` : '.'}`;
|
|
10
|
+
}
|
|
3
11
|
export async function checkPlanLimit(resource) {
|
|
4
12
|
const { data, error } = await adminDb()
|
|
5
13
|
.rpc('check_plan_limit', { p_project_id: getProjectId(), p_resource: resource });
|
package/build/tools/contacts.js
CHANGED
|
@@ -6,7 +6,7 @@ import { z } from 'zod';
|
|
|
6
6
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
7
7
|
import { db, projectClientForWriteback } from '../db.js';
|
|
8
8
|
import { getProjectId } from '../project.js';
|
|
9
|
-
import { checkPlanLimit } from '../plan-limits.js';
|
|
9
|
+
import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
|
|
10
10
|
import { logConsent, logAudit } from '../audit.js';
|
|
11
11
|
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
12
12
|
import { decrypt } from '../crypto.js';
|
|
@@ -18,6 +18,7 @@ import { AI_MAX_TOKENS, outputConfig, anthropicCheckedText } from '@sendinel/dom
|
|
|
18
18
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
19
19
|
const err = (text) => ({ ...txt(text), isError: true });
|
|
20
20
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
21
|
+
const contactInputError = (code, message) => err(JSON.stringify({ code, error: message }));
|
|
21
22
|
const CUSTOM_FIELD_TYPES = [
|
|
22
23
|
'email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date',
|
|
23
24
|
'multi_select', 'contact_reference',
|
|
@@ -1220,22 +1221,27 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1220
1221
|
}, 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 }) => {
|
|
1221
1222
|
email = email.trim().toLowerCase();
|
|
1222
1223
|
const projectId = getProjectId();
|
|
1223
|
-
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1224
|
-
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
1225
1224
|
// Block distribution list / system addresses
|
|
1226
1225
|
const DISTRO_LOCAL_PARTS = new Set(['all', 'everyone', 'distro', 'broadcast', 'announce', 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces', 'noreply', 'no-reply', 'nobody', 'null', 'void']);
|
|
1227
1226
|
const DISTRO_PATTERNS = [/^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/, /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/, /[-+.]broadcast$/, /^broadcast[-+.]/];
|
|
1228
1227
|
const local = email.trim().toLowerCase().split('@')[0] ?? '';
|
|
1229
1228
|
if (DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local))) {
|
|
1230
|
-
return
|
|
1231
|
-
}
|
|
1229
|
+
return contactInputError('validation_failed', `Blocked: "${email}" looks like a group or distribution list address. Sendinel sends to individual people only.`);
|
|
1230
|
+
}
|
|
1231
|
+
// Validate project ownership before the cap, and reuse the double opt-in lookup.
|
|
1232
|
+
const { data: siteInfo, error: siteError } = await db().from('sites')
|
|
1233
|
+
.select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
|
|
1234
|
+
if (siteError)
|
|
1235
|
+
throw new Error(`Site lookup failed: ${siteError.message}`);
|
|
1236
|
+
if (!siteInfo)
|
|
1237
|
+
return contactInputError('not_found', 'Site not found for this project.');
|
|
1238
|
+
const needsConfirm = siteInfo.double_opt_in === true;
|
|
1232
1239
|
const limit = await checkPlanLimit('contacts');
|
|
1233
1240
|
if (!limit.allowed) {
|
|
1234
|
-
return
|
|
1241
|
+
return contactInputError('plan_limit', contactLimitMessage(limit));
|
|
1235
1242
|
}
|
|
1236
|
-
|
|
1237
|
-
const
|
|
1238
|
-
const needsConfirm = siteInfo?.double_opt_in === true;
|
|
1243
|
+
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1244
|
+
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
1239
1245
|
let { data: existingContact, error: lookupError } = await db()
|
|
1240
1246
|
.from('subscribers')
|
|
1241
1247
|
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
@@ -1395,7 +1401,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1395
1401
|
const msg = skippedDistro.length
|
|
1396
1402
|
? `No valid contacts to import. ${skippedDistro.length} address(es) blocked as distribution lists: ${skippedDistro.slice(0, 5).join(', ')}`
|
|
1397
1403
|
: 'No valid contacts to import.';
|
|
1398
|
-
return
|
|
1404
|
+
return contactInputError('validation_failed', msg);
|
|
1399
1405
|
}
|
|
1400
1406
|
// Check which already exist
|
|
1401
1407
|
const { data: existing } = await db()
|
|
@@ -1407,9 +1413,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1407
1413
|
const newCount = valid.filter(c => !existingEmails.has(c.email)).length;
|
|
1408
1414
|
if (newCount > 0) {
|
|
1409
1415
|
const limit = await checkPlanLimit('contacts');
|
|
1410
|
-
const remaining = limit.limit - limit.current;
|
|
1416
|
+
const remaining = Math.max(0, limit.limit - limit.current);
|
|
1411
1417
|
if (newCount > remaining) {
|
|
1412
|
-
return
|
|
1418
|
+
return contactInputError('plan_limit', contactLimitMessage(limit, newCount));
|
|
1413
1419
|
}
|
|
1414
1420
|
}
|
|
1415
1421
|
const rows = valid.map(c => ({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.136",
|
|
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",
|