@sendinel/mcp-server 1.0.10 → 1.0.11
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 +35 -22
- package/build/anthropic-model.d.ts +30 -0
- package/build/anthropic-model.js +26 -1
- package/build/db.d.ts +17 -0
- package/build/db.js +15 -1
- package/build/index.js +6 -0
- package/build/lib/action-approvals.js +28 -0
- package/build/lib/brand-asset-persist.d.ts +1 -0
- package/build/lib/brand-asset-persist.js +20 -1
- package/build/lib/composer-blocks.d.ts +25 -25
- package/build/lib/composer-blocks.js +25 -5
- package/build/lib/connector-table-discovery.d.ts +12 -0
- package/build/lib/connector-table-discovery.js +117 -0
- package/build/lib/custom-properties-diff.d.ts +16 -0
- package/build/lib/custom-properties-diff.js +22 -0
- package/build/lib/schedule.d.ts +7 -2
- package/build/lib/schedule.js +8 -0
- package/build/lib/v1-client.d.ts +3 -0
- package/build/lib/v1-client.js +45 -0
- package/build/lib/voice.d.ts +7 -30
- package/build/lib/voice.js +6 -138
- package/build/prompts.d.ts +6 -0
- package/build/prompts.js +18 -0
- package/build/scoped-token.d.ts +1 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +65 -6
- package/build/tools/analytics.js +97 -0
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +326 -52
- package/build/tools/cloud-import.d.ts +2 -0
- package/build/tools/cloud-import.js +76 -0
- package/build/tools/compound.d.ts +11 -0
- package/build/tools/compound.js +37 -7
- package/build/tools/connector.d.ts +2 -0
- package/build/tools/connector.js +280 -0
- package/build/tools/contacts.js +744 -16
- package/build/tools/content.js +228 -0
- package/build/tools/data.js +441 -0
- package/build/tools/delivery-ops.js +508 -1
- package/build/tools/forms.js +7 -6
- package/build/tools/gateway.d.ts +6 -0
- package/build/tools/gateway.js +92 -0
- package/build/tools/gdpr.js +185 -0
- package/build/tools/landing-pages.d.ts +2 -0
- package/build/tools/landing-pages.js +202 -0
- package/build/tools/lists.d.ts +2 -0
- package/build/tools/lists.js +428 -0
- package/build/tools/newsletters.js +1 -8
- package/build/tools/org.js +549 -33
- package/build/tools/segments.js +35 -184
- package/build/tools/signatures.d.ts +2 -0
- package/build/tools/signatures.js +168 -0
- package/build/tools/sites.js +3 -1
- package/build/tools/skills.d.ts +2 -0
- package/build/tools/skills.js +29 -0
- package/build/tools/templates.js +133 -7
- package/package.json +13 -12
package/README.md
CHANGED
|
@@ -5,38 +5,51 @@ Sendinel's published MCP server package for local stdio clients such as Claude D
|
|
|
5
5
|
AI-controlled email operations control plane for campaigns, contacts, segments, templates, analytics, deliverability, and multi-provider sending across the Sendinel portfolio.
|
|
6
6
|
|
|
7
7
|
- Categories: `email`, `marketing`, `crm`, `automation`, `analytics`
|
|
8
|
-
- Official registry name: `
|
|
8
|
+
- Official registry name: `ai.sendinel/mcp-server`
|
|
9
9
|
- Hosted remote endpoint: `https://sendinel.ai/mcp`
|
|
10
10
|
- npm package: `@sendinel/mcp-server`
|
|
11
11
|
- Source: `https://github.com/kmdesle/sendinel-ai/tree/main/packages/mcp-server`
|
|
12
12
|
|
|
13
13
|
## Tool groups
|
|
14
14
|
|
|
15
|
-
Sendinel currently exposes
|
|
15
|
+
Sendinel currently exposes 34 tool groups and 302 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
16
16
|
|
|
17
17
|
| Group | Count | Purpose |
|
|
18
18
|
|-------|-------|---------|
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
19
|
+
| `ab-testing` | 4 | A/B test creation, listing, significance checks, winner promotion |
|
|
20
|
+
| `advisor` | 6 | Campaign advisor: recommendations, benchmarks, subject/content/send-time guidance |
|
|
21
|
+
| `ai-work` | 9 | AI work items, recommended segments, context events, agent proposals |
|
|
22
|
+
| `analytics` | 17 | Stats, portfolio analytics, domain health, engagement, deliverability, revenue analytics |
|
|
23
|
+
| `approvals` | 2 | Poll and list risk-tiered action approvals |
|
|
24
|
+
| `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
|
|
25
|
+
| `brand_kit` | 5 | Brand Core text import, media library upload/URL ingest, asset-context reads |
|
|
26
|
+
| `campaigns` | 30 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates |
|
|
27
|
+
| `cloud-import` | 6 | Cloud storage import |
|
|
28
|
+
| `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
|
|
29
|
+
| `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
|
|
30
|
+
| `contacts` | 37 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
|
|
31
|
+
| `content` | 14 | Content blocks, gated asset links, blog ingest and auto-publish, sources |
|
|
32
|
+
| `data` | 19 | Platform connectors, imports, API keys, migration jobs, notifications, tenant export |
|
|
33
|
+
| `data-proposals` | 3 | Data-derived campaign proposal generation, listing, approval |
|
|
34
|
+
| `delivery-ops` | 23 | Domains, DNSBL, deliverability diagnostics, queue, DLQ, exports, blackout dates |
|
|
35
|
+
| `drafts` | 6 | Draft create/approve/reject and draft-to-campaign promotion |
|
|
36
|
+
| `forms` | 3 | Form creation, listing, stats |
|
|
37
|
+
| `gdpr` | 4 | Contact data deletion and GDPR log |
|
|
38
|
+
| `landing-pages` | 5 | Landing page CRUD |
|
|
39
|
+
| `lists` | 7 | Static contact lists and membership management |
|
|
40
|
+
| `newsletters` | 12 | Newsletter publications, issues, subscribers, scheduling, sending, stats |
|
|
41
|
+
| `org` | 20 | Team members, plan usage/limits, audit log, model registry, white-label, provider switching |
|
|
42
|
+
| `segments` | 6 | Audience builder: create, edit, natural-language generation, preview |
|
|
43
|
+
| `short-links` | 3 | Short links, click stats, QR codes |
|
|
44
|
+
| `signatures` | 4 | Sending signature blocks |
|
|
45
|
+
| `sites` | 5 | Site, sender, and brand voice management |
|
|
46
|
+
| `skills` | 2 | Operator skills catalog |
|
|
47
|
+
| `sms` | 7 | SMS send, log, consent, inbound |
|
|
48
|
+
| `social-posts` | 6 | Social post creation, adaptation, scheduling state, archive/delete |
|
|
49
|
+
| `surveys` | 5 | Survey creation, listing, status, results |
|
|
50
|
+
| `templates` | 7 | Email template CRUD, version restore, migration translation |
|
|
51
|
+
| `warmup` | 4 | IP/domain warmup schedule control |
|
|
34
52
|
| `webhooks` | 4 | Webhook subscription management |
|
|
35
|
-
| `approvals` | 3 | Approval workflow and advisor hooks |
|
|
36
|
-
| `automations` | 4 | Automation triggers and previews |
|
|
37
|
-
| `sites` | 3 | Site and sender management |
|
|
38
|
-
| `gdpr` | 2 | Contact deletion and log |
|
|
39
|
-
| `history` | 3 | Chat thread history and search |
|
|
40
53
|
|
|
41
54
|
## Install / run
|
|
42
55
|
|
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
export { FALLBACK_MODEL_IDS, MODEL_REGISTRY, ROLE, getAnthropicFastModelId, getAnthropicFrontierModelId, getAnthropicModelId, getAnthropicReasoningModelId, getModelIdForRole, resolveModel, type ModelMetadata, type ModelRole, type ResolvedModel, } from '@desler/models';
|
|
2
|
+
import { type ModelRole, type ResolvedModel } from '@desler/models';
|
|
2
3
|
export declare const DEFAULT_ANTHROPIC_MODEL_ID: string;
|
|
3
4
|
export declare const DEFAULT_ANTHROPIC_FAST_MODEL_ID: string;
|
|
4
5
|
export declare const DEFAULT_ANTHROPIC_OPUS_MODEL_ID: string;
|
|
5
6
|
export { getAnthropicReasoningModelId as getAnthropicOpusModelId } from '@desler/models';
|
|
7
|
+
type ModelRegistryClient = {
|
|
8
|
+
from(table: 'model_registry'): {
|
|
9
|
+
select(columns: string): {
|
|
10
|
+
eq(column: string, value: string): {
|
|
11
|
+
eq(column: string, value: string): {
|
|
12
|
+
maybeSingle(): Promise<{
|
|
13
|
+
data: {
|
|
14
|
+
model_id?: string | null;
|
|
15
|
+
} | null;
|
|
16
|
+
error: unknown;
|
|
17
|
+
}>;
|
|
18
|
+
};
|
|
19
|
+
is(column: string, value: null): {
|
|
20
|
+
maybeSingle(): Promise<{
|
|
21
|
+
data: {
|
|
22
|
+
model_id?: string | null;
|
|
23
|
+
} | null;
|
|
24
|
+
error: unknown;
|
|
25
|
+
}>;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export declare function resolveModelWithRuntimeMirror(role: ModelRole, opts?: {
|
|
32
|
+
orgId?: string | null;
|
|
33
|
+
environment?: Record<string, string | undefined>;
|
|
34
|
+
client?: ModelRegistryClient;
|
|
35
|
+
}): Promise<ResolvedModel>;
|
package/build/anthropic-model.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
export { FALLBACK_MODEL_IDS, MODEL_REGISTRY, ROLE, getAnthropicFastModelId, getAnthropicFrontierModelId, getAnthropicModelId, getAnthropicReasoningModelId, getModelIdForRole, resolveModel, } from '@desler/models';
|
|
2
|
-
import { MODEL_REGISTRY } from '@desler/models';
|
|
2
|
+
import { MODEL_REGISTRY, resolveModel } from '@desler/models';
|
|
3
|
+
import { adminDb } from './db.js';
|
|
3
4
|
export const DEFAULT_ANTHROPIC_MODEL_ID = MODEL_REGISTRY.default.id;
|
|
4
5
|
export const DEFAULT_ANTHROPIC_FAST_MODEL_ID = MODEL_REGISTRY.fast.id;
|
|
5
6
|
export const DEFAULT_ANTHROPIC_OPUS_MODEL_ID = MODEL_REGISTRY.reasoning.id;
|
|
6
7
|
export { getAnthropicReasoningModelId as getAnthropicOpusModelId } from '@desler/models';
|
|
8
|
+
async function lookupRuntimeModel(role, orgId, client) {
|
|
9
|
+
const base = () => client.from('model_registry').select('model_id').eq('role', role);
|
|
10
|
+
if (orgId) {
|
|
11
|
+
const { data } = await base().eq('org_id', orgId).maybeSingle();
|
|
12
|
+
if (data?.model_id)
|
|
13
|
+
return data.model_id;
|
|
14
|
+
}
|
|
15
|
+
const { data } = await base().is('org_id', null).maybeSingle();
|
|
16
|
+
return data?.model_id ?? null;
|
|
17
|
+
}
|
|
18
|
+
export async function resolveModelWithRuntimeMirror(role, opts = {}) {
|
|
19
|
+
const resolved = resolveModel(role, opts.environment);
|
|
20
|
+
if (resolved.source === 'env')
|
|
21
|
+
return resolved;
|
|
22
|
+
try {
|
|
23
|
+
const modelId = await lookupRuntimeModel(role, opts.orgId, opts.client ?? adminDb());
|
|
24
|
+
if (modelId)
|
|
25
|
+
return { ...resolved, id: modelId, source: 'runtime_mirror' };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Runtime flips are best-effort; package/fallback resolution remains the safe baseline.
|
|
29
|
+
}
|
|
30
|
+
return resolved;
|
|
31
|
+
}
|
package/build/db.d.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import 'dotenv/config';
|
|
2
|
+
export type V1CallOptions = {
|
|
3
|
+
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
4
|
+
body?: unknown;
|
|
5
|
+
query?: Record<string, string>;
|
|
6
|
+
};
|
|
7
|
+
export type V1CallResult = {
|
|
8
|
+
ok: true;
|
|
9
|
+
data: unknown;
|
|
10
|
+
status: number;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
error: unknown;
|
|
14
|
+
status: number;
|
|
15
|
+
};
|
|
16
|
+
export type V1Caller = (path: string, opts?: V1CallOptions) => Promise<V1CallResult>;
|
|
2
17
|
/** Returns Sendinel's admin client. Always points to Sendinel's Supabase — never overridden. */
|
|
3
18
|
export declare function adminDb(): any;
|
|
4
19
|
/**
|
|
@@ -23,6 +38,7 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
|
|
|
23
38
|
actorType?: string;
|
|
24
39
|
actorId?: string | null;
|
|
25
40
|
workspaceId?: string | null;
|
|
41
|
+
v1Caller?: V1Caller;
|
|
26
42
|
}): Promise<T>;
|
|
27
43
|
export declare function runWithToolContext<T>(toolGroup: string, toolName: string, fn: () => Promise<T> | T): Promise<T>;
|
|
28
44
|
/**
|
|
@@ -37,6 +53,7 @@ export declare function requestContext(): {
|
|
|
37
53
|
actorType?: string;
|
|
38
54
|
actorId?: string | null;
|
|
39
55
|
workspaceId?: string | null;
|
|
56
|
+
v1Caller?: V1Caller;
|
|
40
57
|
} | undefined;
|
|
41
58
|
export declare function projectClientForWriteback(): any | null;
|
|
42
59
|
/**
|
package/build/db.js
CHANGED
|
@@ -69,6 +69,7 @@ export function requestContext() {
|
|
|
69
69
|
actorType: ctx.actorType,
|
|
70
70
|
actorId: ctx.actorId,
|
|
71
71
|
workspaceId: ctx.workspaceId,
|
|
72
|
+
v1Caller: ctx.v1Caller,
|
|
72
73
|
}
|
|
73
74
|
: undefined;
|
|
74
75
|
}
|
|
@@ -80,7 +81,20 @@ export function projectClientForWriteback() {
|
|
|
80
81
|
const ctx = _requestStore.getStore();
|
|
81
82
|
return ctx?.client ?? _processClient ?? null;
|
|
82
83
|
}
|
|
83
|
-
const BYOD_TOOL_NAMES = new Set([
|
|
84
|
+
const BYOD_TOOL_NAMES = new Set([
|
|
85
|
+
'create_blog_post',
|
|
86
|
+
'list_blog_posts',
|
|
87
|
+
'get_blog_post',
|
|
88
|
+
'generate_blog_post_image',
|
|
89
|
+
'recommend_blog_post_images',
|
|
90
|
+
'send_newsletter_issue',
|
|
91
|
+
'list_blog_destinations',
|
|
92
|
+
'get_brand_voice',
|
|
93
|
+
'update_brand_voice',
|
|
94
|
+
'update_site',
|
|
95
|
+
'list_connector_tables',
|
|
96
|
+
'get_dmarc_status',
|
|
97
|
+
]);
|
|
84
98
|
function shouldUseProjectClient(ctx) {
|
|
85
99
|
if (!ctx?.client)
|
|
86
100
|
return false;
|
package/build/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
8
8
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
9
9
|
import express from 'express';
|
|
10
10
|
import { registerAllTools } from './server.js';
|
|
11
|
+
import { registerAllPrompts } from './prompts.js';
|
|
11
12
|
import { runWithClient } from './db.js';
|
|
12
13
|
import { initProjectClient, resolveProjectClientStrict, TenantResolutionError } from './byod-client.js';
|
|
13
14
|
import { authenticateBearer, McpAuthenticationError, validateKey } from './auth.js';
|
|
@@ -36,6 +37,7 @@ async function runStdio() {
|
|
|
36
37
|
await initProjectClient(projectId);
|
|
37
38
|
}
|
|
38
39
|
registerAllTools(server, { toolGroups, scopes });
|
|
40
|
+
registerAllPrompts(server, { toolGroups, scopes });
|
|
39
41
|
const t = new StdioServerTransport();
|
|
40
42
|
await server.connect(t);
|
|
41
43
|
process.stderr.write('Sendinel MCP server running (stdio)\n');
|
|
@@ -97,6 +99,10 @@ async function runHttp() {
|
|
|
97
99
|
scopes: info.scopes,
|
|
98
100
|
toolGroups: info.toolGroups,
|
|
99
101
|
});
|
|
102
|
+
registerAllPrompts(server, {
|
|
103
|
+
scopes: info.scopes,
|
|
104
|
+
toolGroups: info.toolGroups,
|
|
105
|
+
});
|
|
100
106
|
const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
101
107
|
await server.connect(t);
|
|
102
108
|
await t.handleRequest(req, res, req.body);
|
|
@@ -10,13 +10,25 @@ import { fireWebhookEvent } from './webhook-events.js';
|
|
|
10
10
|
*/
|
|
11
11
|
const GATED_ACTIONS = new Set([
|
|
12
12
|
'launch_campaign', // sends real emails
|
|
13
|
+
'install_connector_schema', // installs schema into a customer-owned database
|
|
14
|
+
'install_connector_view', // reinstalls the customer-side snapshot view
|
|
15
|
+
'set_connector_mapping', // rewrites how customer data maps in
|
|
16
|
+
'resync_connector', // forces a full resync
|
|
17
|
+
'publish_dmarc_record', // writes a DNS record into a customer-owned Cloudflare zone
|
|
13
18
|
'broadcast_to_segment', // creates + activates a real broadcast
|
|
19
|
+
'rotate_custom_webhook_secret', // immediately invalidates an inbound signing secret
|
|
14
20
|
'create_campaign_with_content', // can activate + send in one call
|
|
15
21
|
'enroll_segment', // bulk enrollment
|
|
16
22
|
'import_subscribers', // bulk data ingestion
|
|
17
23
|
'subscribe_webhook', // outbound data channel
|
|
18
24
|
'send_transactional', // sends a real email
|
|
19
25
|
'update_campaign_status', // activation sends real emails (see special case below)
|
|
26
|
+
'add_list_members', // bulk contact-list membership write
|
|
27
|
+
'remove_list_members', // bulk contact-list membership write
|
|
28
|
+
'add_list_members_by_filter', // bulk membership write, unbounded match set
|
|
29
|
+
'remove_team_member', // revokes workspace access
|
|
30
|
+
'set_team_member_role', // changes workspace access privileges
|
|
31
|
+
'delete_provider_credential', // permanently removes or revokes stored provider access
|
|
20
32
|
]);
|
|
21
33
|
const PROTECTED_ACTIONS = new Set([
|
|
22
34
|
'delete_subscriber_data', // GDPR delete — irreversible
|
|
@@ -24,6 +36,7 @@ const PROTECTED_ACTIONS = new Set([
|
|
|
24
36
|
'delete_segment', // affects campaign targeting
|
|
25
37
|
'identify_inactive', // bulk suppression (when not dry_run)
|
|
26
38
|
'promote_ab_winner', // deletes losing variants
|
|
39
|
+
'action_ccpa_request', // CCPA delete completion is irreversible
|
|
27
40
|
]);
|
|
28
41
|
export function getApprovalTier(action, params) {
|
|
29
42
|
// CRITICAL: Protected actions ALWAYS require approval, even in autonomous mode.
|
|
@@ -32,6 +45,8 @@ export function getApprovalTier(action, params) {
|
|
|
32
45
|
// Exception: identify_inactive in dry_run mode is safe (read-only report)
|
|
33
46
|
if (action === 'identify_inactive' && params?.dry_run !== false)
|
|
34
47
|
return 'autonomous';
|
|
48
|
+
if (action === 'action_ccpa_request' && !(params?.status === 'completed' && params?.request_type === 'delete'))
|
|
49
|
+
return 'autonomous';
|
|
35
50
|
return 'protected';
|
|
36
51
|
}
|
|
37
52
|
// Autonomous mode: user explicitly opted out of approval gates for gated actions
|
|
@@ -112,7 +127,13 @@ export function currentChannelContext() {
|
|
|
112
127
|
export function buildApprovalSummary(action, params) {
|
|
113
128
|
const summaries = {
|
|
114
129
|
launch_campaign: (p) => `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
|
|
130
|
+
install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
|
|
131
|
+
install_connector_view: (p) => `Reinstall the customer-side contact view for project ${p.project_id ?? 'current'}`,
|
|
132
|
+
set_connector_mapping: () => `Rewrite the BYOD connector field mapping`,
|
|
133
|
+
resync_connector: () => `Force a full BYOD connector resync`,
|
|
134
|
+
publish_dmarc_record: (p) => `Publish DMARC record for ${p.domain} - writes DNS in a customer-owned Cloudflare zone`,
|
|
115
135
|
broadcast_to_segment: (p) => `Broadcast "${p.name || p.subject || 'untitled'}" to segment ${p.segment_id} — will send emails to real contacts`,
|
|
136
|
+
rotate_custom_webhook_secret: (p) => `Rotate custom webhook ${p.connection_id} signing secret — immediately invalidates the previous secret with no grace period`,
|
|
116
137
|
create_campaign_with_content: (p) => `Create + activate campaign "${p.name}" with ${p.steps?.length ?? 0} email(s) and enroll contacts`,
|
|
117
138
|
enroll_segment: (p) => `Enroll all contacts from site into campaign ${p.campaign_id}`,
|
|
118
139
|
import_subscribers: (p) => `Import subscribers (up to ${p.contacts?.length ?? 'unknown'} records)`,
|
|
@@ -123,6 +144,13 @@ export function buildApprovalSummary(action, params) {
|
|
|
123
144
|
delete_segment: (p) => `Delete segment ${p.segment_id} — campaigns using this segment lose their targeting`,
|
|
124
145
|
identify_inactive: (p) => `Suppress contacts inactive for ${p.days ?? 90} days — they will stop receiving emails`,
|
|
125
146
|
promote_ab_winner: (p) => `Promote A/B test winner "${p.winner}" — losing variants will be deleted`,
|
|
147
|
+
action_ccpa_request: (p) => `Complete CCPA delete request for ${p.email} — permanently and irreversibly deletes all subscriber data`,
|
|
148
|
+
add_list_members: (p) => `Add ${p.contact_ids?.length ?? 0} contact(s) to list ${p.list_id}`,
|
|
149
|
+
remove_list_members: (p) => `Remove ${p.contact_ids?.length ?? 0} contact(s) from list ${p.list_id}`,
|
|
150
|
+
add_list_members_by_filter: (p) => `Add contacts matching filter rules to list ${p.list_id}`,
|
|
151
|
+
remove_team_member: (p) => `Remove team member ${p.user_id} from the organization`,
|
|
152
|
+
set_team_member_role: (p) => `Change team member ${p.user_id}'s role to ${p.role}`,
|
|
153
|
+
delete_provider_credential: (p) => `Delete saved ${p.provider ?? 'provider'} credential ${p.credential_id} (${p.source})`,
|
|
126
154
|
};
|
|
127
155
|
const fn = summaries[action];
|
|
128
156
|
return fn ? fn(params) : `Execute ${action} with provided parameters`;
|
|
@@ -115,14 +115,32 @@ export async function persistBrandAsset(input) {
|
|
|
115
115
|
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
116
116
|
const storagePath = brandAssetStoragePath(input.projectId, sha256, extOf(input.filename));
|
|
117
117
|
const existing = await input.sb.schema('email').from('brand_media_assets')
|
|
118
|
-
.select('id').eq('project_id', input.projectId).eq('sha256', sha256).maybeSingle();
|
|
118
|
+
.select('id, kind').eq('project_id', input.projectId).eq('sha256', sha256).maybeSingle();
|
|
119
119
|
if (existing.data?.id) {
|
|
120
|
+
const dedupPatch = {};
|
|
121
|
+
if (input.kind === 'avatar' && existing.data.kind !== 'avatar') {
|
|
122
|
+
dedupPatch.kind = 'avatar';
|
|
123
|
+
dedupPatch.filename = input.filename.slice(0, 255);
|
|
124
|
+
}
|
|
125
|
+
if (Object.keys(dedupPatch).length > 0) {
|
|
126
|
+
await input.sb.schema('email').from('brand_media_assets')
|
|
127
|
+
.update(dedupPatch)
|
|
128
|
+
.eq('id', existing.data.id)
|
|
129
|
+
.eq('project_id', input.projectId);
|
|
130
|
+
}
|
|
120
131
|
if (input.brandKitId) {
|
|
121
132
|
await input.sb.schema('email').from('brand_media_assets')
|
|
122
133
|
.update({ brand_kit_id: input.brandKitId })
|
|
123
134
|
.eq('id', existing.data.id)
|
|
124
135
|
.is('brand_kit_id', null);
|
|
125
136
|
}
|
|
137
|
+
if (input.backstory !== undefined && input.backstory !== null) {
|
|
138
|
+
await input.sb.schema('email').from('brand_media_assets')
|
|
139
|
+
.update({ backstory: input.backstory })
|
|
140
|
+
.eq('id', existing.data.id)
|
|
141
|
+
.eq('project_id', input.projectId)
|
|
142
|
+
.or('backstory.is.null,backstory.eq.');
|
|
143
|
+
}
|
|
126
144
|
return { assetId: existing.data.id, skippedExisting: true, storagePath };
|
|
127
145
|
}
|
|
128
146
|
const upload = await input.sb.storage.from(BRAND_ASSETS_BUCKET).upload(storagePath, bytes, {
|
|
@@ -149,6 +167,7 @@ export async function persistBrandAsset(input) {
|
|
|
149
167
|
storage_backend: 'supabase',
|
|
150
168
|
collection: input.collection ?? null,
|
|
151
169
|
tags: input.tags ?? [],
|
|
170
|
+
backstory: input.backstory ?? null,
|
|
152
171
|
metadata: {},
|
|
153
172
|
}, { onConflict: 'project_id,sha256', ignoreDuplicates: true }).select('id');
|
|
154
173
|
if (insert.error)
|
|
@@ -24,92 +24,92 @@ export type ComposerBlock = {
|
|
|
24
24
|
height?: number;
|
|
25
25
|
};
|
|
26
26
|
export declare const BLOCK_GENERATION_SCHEMA: {
|
|
27
|
-
readonly type:
|
|
27
|
+
readonly type: 'array';
|
|
28
28
|
readonly minItems: 2;
|
|
29
29
|
readonly maxItems: 12;
|
|
30
30
|
readonly items: {
|
|
31
|
-
readonly type:
|
|
31
|
+
readonly type: 'object';
|
|
32
32
|
readonly additionalProperties: false;
|
|
33
33
|
readonly properties: {
|
|
34
34
|
readonly type: {
|
|
35
|
-
readonly enum: readonly [
|
|
35
|
+
readonly enum: readonly ['text', 'heading', 'button', 'columns', 'image', 'social', 'divider', 'spacer', 'unsubscribe'];
|
|
36
36
|
};
|
|
37
37
|
readonly content: {
|
|
38
|
-
readonly type:
|
|
38
|
+
readonly type: 'string';
|
|
39
39
|
};
|
|
40
40
|
readonly align: {
|
|
41
|
-
readonly enum: readonly [
|
|
41
|
+
readonly enum: readonly ['left', 'center', 'right'];
|
|
42
42
|
};
|
|
43
43
|
readonly level: {
|
|
44
44
|
readonly enum: readonly [1, 2, 3];
|
|
45
45
|
};
|
|
46
46
|
readonly label: {
|
|
47
|
-
readonly type:
|
|
47
|
+
readonly type: 'string';
|
|
48
48
|
};
|
|
49
49
|
readonly url: {
|
|
50
|
-
readonly type:
|
|
50
|
+
readonly type: 'string';
|
|
51
51
|
};
|
|
52
52
|
readonly bg_color: {
|
|
53
|
-
readonly type:
|
|
53
|
+
readonly type: 'string';
|
|
54
54
|
};
|
|
55
55
|
readonly text_color: {
|
|
56
|
-
readonly type:
|
|
56
|
+
readonly type: 'string';
|
|
57
57
|
};
|
|
58
58
|
readonly border_radius: {
|
|
59
|
-
readonly type:
|
|
59
|
+
readonly type: 'number';
|
|
60
60
|
readonly minimum: 0;
|
|
61
61
|
readonly maximum: 24;
|
|
62
62
|
};
|
|
63
63
|
readonly padding: {
|
|
64
|
-
readonly type:
|
|
64
|
+
readonly type: 'string';
|
|
65
65
|
};
|
|
66
66
|
readonly left: {
|
|
67
|
-
readonly type:
|
|
67
|
+
readonly type: 'string';
|
|
68
68
|
};
|
|
69
69
|
readonly right: {
|
|
70
|
-
readonly type:
|
|
70
|
+
readonly type: 'string';
|
|
71
71
|
};
|
|
72
72
|
readonly split: {
|
|
73
|
-
readonly enum: readonly [
|
|
73
|
+
readonly enum: readonly ['50/50', '60/40', '40/60'];
|
|
74
74
|
};
|
|
75
75
|
readonly alt: {
|
|
76
|
-
readonly type:
|
|
76
|
+
readonly type: 'string';
|
|
77
77
|
};
|
|
78
78
|
readonly width: {
|
|
79
|
-
readonly type: readonly [
|
|
79
|
+
readonly type: readonly ['number', 'null'];
|
|
80
80
|
readonly minimum: 1;
|
|
81
81
|
readonly maximum: 1200;
|
|
82
82
|
};
|
|
83
83
|
readonly brand_asset_id: {
|
|
84
|
-
readonly type: readonly [
|
|
84
|
+
readonly type: readonly ['string', 'null'];
|
|
85
85
|
};
|
|
86
86
|
readonly links: {
|
|
87
|
-
readonly type:
|
|
87
|
+
readonly type: 'array';
|
|
88
88
|
readonly maxItems: 8;
|
|
89
89
|
readonly items: {
|
|
90
|
-
readonly type:
|
|
90
|
+
readonly type: 'object';
|
|
91
91
|
readonly additionalProperties: false;
|
|
92
92
|
readonly properties: {
|
|
93
93
|
readonly platform: {
|
|
94
|
-
readonly type:
|
|
94
|
+
readonly type: 'string';
|
|
95
95
|
};
|
|
96
96
|
readonly url: {
|
|
97
|
-
readonly type:
|
|
97
|
+
readonly type: 'string';
|
|
98
98
|
};
|
|
99
99
|
};
|
|
100
|
-
readonly required: readonly [
|
|
100
|
+
readonly required: readonly ['platform', 'url'];
|
|
101
101
|
};
|
|
102
102
|
};
|
|
103
103
|
readonly height: {
|
|
104
|
-
readonly type:
|
|
104
|
+
readonly type: 'number';
|
|
105
105
|
readonly minimum: 4;
|
|
106
106
|
readonly maximum: 96;
|
|
107
107
|
};
|
|
108
108
|
};
|
|
109
|
-
readonly required: readonly [
|
|
109
|
+
readonly required: readonly ['type'];
|
|
110
110
|
};
|
|
111
111
|
};
|
|
112
|
-
export declare function normalizeComposerBlocks(raw: unknown): ComposerBlock[];
|
|
112
|
+
export declare function normalizeComposerBlocks(raw: unknown, defaultButtonColor?: string): ComposerBlock[];
|
|
113
113
|
export declare function blocksToHtml(blocks: ComposerBlock[], ctx?: {
|
|
114
114
|
preheader?: string | null;
|
|
115
115
|
}): string;
|
|
@@ -85,6 +85,19 @@ function cleanColor(value, fallback) {
|
|
|
85
85
|
const color = cleanText(value, 32);
|
|
86
86
|
return /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/.test(color) ? color : fallback;
|
|
87
87
|
}
|
|
88
|
+
// WCAG relative luminance threshold for readable text against fallback button
|
|
89
|
+
// backgrounds synthesized by Sendinel, not AI-specified button colors.
|
|
90
|
+
function fallbackTextColorForBg(hex) {
|
|
91
|
+
const expanded = hex.length === 4
|
|
92
|
+
? hex.replace(/^#?([0-9A-Fa-f])([0-9A-Fa-f])([0-9A-Fa-f])$/, '#$1$1$2$2$3$3')
|
|
93
|
+
: hex;
|
|
94
|
+
const m = /^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/.exec(expanded);
|
|
95
|
+
if (!m)
|
|
96
|
+
return '#ffffff';
|
|
97
|
+
const [r, g, b] = m.slice(1).map((h) => parseInt(h, 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
|
|
98
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
99
|
+
return luminance > 0.5 ? '#111827' : '#ffffff';
|
|
100
|
+
}
|
|
88
101
|
function cleanPadding(value) {
|
|
89
102
|
const padding = cleanText(value, 80);
|
|
90
103
|
return /^([0-9]{1,2}px)(\s+[0-9]{1,2}px){0,3}$/.test(padding) ? padding : '12px 28px';
|
|
@@ -115,9 +128,10 @@ function splitWidths(split) {
|
|
|
115
128
|
return [40, 60];
|
|
116
129
|
return [50, 50];
|
|
117
130
|
}
|
|
118
|
-
export function normalizeComposerBlocks(raw) {
|
|
131
|
+
export function normalizeComposerBlocks(raw, defaultButtonColor) {
|
|
119
132
|
if (!Array.isArray(raw))
|
|
120
133
|
return [];
|
|
134
|
+
const buttonFallback = cleanColor(defaultButtonColor, '#00BFFF');
|
|
121
135
|
return raw.slice(0, 12).flatMap((item, index) => {
|
|
122
136
|
if (!item || typeof item !== 'object' || !BLOCK_TYPES.has(String(item.type)))
|
|
123
137
|
return [];
|
|
@@ -127,9 +141,14 @@ export function normalizeComposerBlocks(raw) {
|
|
|
127
141
|
if (type === 'text')
|
|
128
142
|
return [{ ...base, content: cleanRichText(block.content), align: cleanAlign(block.align) }];
|
|
129
143
|
if (type === 'heading')
|
|
130
|
-
return [{ ...base, content: cleanText(block.content, 300), level: ([1, 2, 3].includes(Number(block.level)) ? Number(block.level) : 2), align: cleanAlign(block.align) }];
|
|
131
|
-
if (type === 'button')
|
|
132
|
-
|
|
144
|
+
return [{ ...base, content: cleanText(block.content, 300), level: ([1, 2, 3].includes(Number(block.level)) ? Number(block.level) : 2), align: cleanAlign(block.align), text_color: cleanColor(block.text_color, '') || undefined }];
|
|
145
|
+
if (type === 'button') {
|
|
146
|
+
const aiBgValid = /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/.test(cleanText(block.bg_color, 32));
|
|
147
|
+
const bg = aiBgValid ? cleanColor(block.bg_color, buttonFallback) : buttonFallback;
|
|
148
|
+
const aiTextValid = /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/.test(cleanText(block.text_color, 32));
|
|
149
|
+
const text = aiTextValid ? cleanColor(block.text_color, '#ffffff') : (aiBgValid ? '#ffffff' : fallbackTextColorForBg(bg));
|
|
150
|
+
return [{ ...base, label: cleanText(block.label, 120) || 'Learn more', url: cleanUrl(block.url) || '{{cta_url}}', align: cleanAlign(block.align) === 'left' ? 'center' : cleanAlign(block.align), bg_color: bg, text_color: text, border_radius: Math.max(0, Math.min(24, Number(block.border_radius ?? 6))), padding: cleanPadding(block.padding) }];
|
|
151
|
+
}
|
|
133
152
|
if (type === 'columns')
|
|
134
153
|
return [{ ...base, left: cleanRichText(block.left), right: cleanRichText(block.right), split: block.split === '60/40' || block.split === '40/60' ? block.split : '50/50' }];
|
|
135
154
|
if (type === 'image') {
|
|
@@ -159,7 +178,8 @@ export function blocksToHtml(blocks, ctx) {
|
|
|
159
178
|
return String(b.content ?? '').split(/\n\n+/).filter((p) => p.trim()).map((p) => p.trim() === '---' ? '<hr style="border:none;border-top:1px solid #374151;margin:24px 0;" />' : `<p style="margin-bottom:16px;line-height:1.6;text-align:${cleanAlign(b.align)};">${p.trim()}</p>`).join('\n');
|
|
160
179
|
if (b.type === 'heading') {
|
|
161
180
|
const level = [1, 2, 3].includes(Number(b.level)) ? Number(b.level) : 2;
|
|
162
|
-
|
|
181
|
+
const color = /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/.test(String(b.text_color ?? '')) ? b.text_color : '#111827';
|
|
182
|
+
return `<h${level} style="font-family:Arial,sans-serif;font-size:${HEADING_SIZES[level]}px;font-weight:700;line-height:1.2;margin:0 0 16px;color:${escapeAttr(String(color))};text-align:${cleanAlign(b.align)};">${escapeHtml(b.content || 'Your heading here')}</h${level}>`;
|
|
163
183
|
}
|
|
164
184
|
if (b.type === 'button')
|
|
165
185
|
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="${cleanAlign(b.align || 'center')}"><a href="${escapeAttr(b.url || '{{cta_url}}')}" target="_blank" style="display:inline-block;padding:${escapeAttr(b.padding || '12px 28px')};background:${escapeAttr(b.bg_color || '#00BFFF')};color:${escapeAttr(b.text_color || '#ffffff')};font-weight:600;text-decoration:none;border-radius:${Math.max(0, Math.min(24, Number(b.border_radius ?? 6)))}px;font-family:Arial,sans-serif;font-size:15px;line-height:1;">${escapeHtml(b.label || 'Get Started')}</a></td></tr></table>`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type ByodTableInfo = {
|
|
2
|
+
id: string;
|
|
3
|
+
schema: string;
|
|
4
|
+
name: string;
|
|
5
|
+
label: string;
|
|
6
|
+
columns: string[];
|
|
7
|
+
source: 'rest' | 'auth';
|
|
8
|
+
score: number;
|
|
9
|
+
};
|
|
10
|
+
/** Discover tables through the already-resolved per-project Supabase client. */
|
|
11
|
+
export declare function listConnectorTablesFromDb(client: any): Promise<ByodTableInfo[]>;
|
|
12
|
+
export {};
|