@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.
Files changed (57) hide show
  1. package/README.md +35 -22
  2. package/build/anthropic-model.d.ts +30 -0
  3. package/build/anthropic-model.js +26 -1
  4. package/build/db.d.ts +17 -0
  5. package/build/db.js +15 -1
  6. package/build/index.js +6 -0
  7. package/build/lib/action-approvals.js +28 -0
  8. package/build/lib/brand-asset-persist.d.ts +1 -0
  9. package/build/lib/brand-asset-persist.js +20 -1
  10. package/build/lib/composer-blocks.d.ts +25 -25
  11. package/build/lib/composer-blocks.js +25 -5
  12. package/build/lib/connector-table-discovery.d.ts +12 -0
  13. package/build/lib/connector-table-discovery.js +117 -0
  14. package/build/lib/custom-properties-diff.d.ts +16 -0
  15. package/build/lib/custom-properties-diff.js +22 -0
  16. package/build/lib/schedule.d.ts +7 -2
  17. package/build/lib/schedule.js +8 -0
  18. package/build/lib/v1-client.d.ts +3 -0
  19. package/build/lib/v1-client.js +45 -0
  20. package/build/lib/voice.d.ts +7 -30
  21. package/build/lib/voice.js +6 -138
  22. package/build/prompts.d.ts +6 -0
  23. package/build/prompts.js +18 -0
  24. package/build/scoped-token.d.ts +1 -1
  25. package/build/server.d.ts +1 -0
  26. package/build/server.js +65 -6
  27. package/build/tools/analytics.js +97 -0
  28. package/build/tools/brand-kit.js +29 -30
  29. package/build/tools/campaigns.js +326 -52
  30. package/build/tools/cloud-import.d.ts +2 -0
  31. package/build/tools/cloud-import.js +76 -0
  32. package/build/tools/compound.d.ts +11 -0
  33. package/build/tools/compound.js +37 -7
  34. package/build/tools/connector.d.ts +2 -0
  35. package/build/tools/connector.js +280 -0
  36. package/build/tools/contacts.js +744 -16
  37. package/build/tools/content.js +228 -0
  38. package/build/tools/data.js +441 -0
  39. package/build/tools/delivery-ops.js +508 -1
  40. package/build/tools/forms.js +7 -6
  41. package/build/tools/gateway.d.ts +6 -0
  42. package/build/tools/gateway.js +92 -0
  43. package/build/tools/gdpr.js +185 -0
  44. package/build/tools/landing-pages.d.ts +2 -0
  45. package/build/tools/landing-pages.js +202 -0
  46. package/build/tools/lists.d.ts +2 -0
  47. package/build/tools/lists.js +428 -0
  48. package/build/tools/newsletters.js +1 -8
  49. package/build/tools/org.js +549 -33
  50. package/build/tools/segments.js +35 -184
  51. package/build/tools/signatures.d.ts +2 -0
  52. package/build/tools/signatures.js +168 -0
  53. package/build/tools/sites.js +3 -1
  54. package/build/tools/skills.d.ts +2 -0
  55. package/build/tools/skills.js +29 -0
  56. package/build/tools/templates.js +133 -7
  57. 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: `io.github.kmdesle/sendinel`
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 21 tool groups and about 155 tools. The full reference lives in [`docs/mcp-tools.md`](../../docs/mcp-tools.md).
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
- | `campaigns` | 26 | Full campaign lifecycle |
20
- | `contacts` | 22 | Subscriber CRUD, tags, suppression, merges, timelines |
21
- | `analytics` | 9 | Stats, engagement, deliverability, portfolio reporting |
22
- | `data` | 15 | Export, scoring, hygiene, migrations, import history |
23
- | `org` | 15 | Team members, API keys, platform connections, plan, onboarding |
24
- | `templates` | 7 | Template CRUD, AI brief generation, translation |
25
- | `sms` | 7 | SMS send, consent, inbound, logs |
26
- | `segments` | 6 | Audience builder and preview |
27
- | `content` | 5 | Content blocks, sources, send-time optimization |
28
- | `ab_testing` | 5 | A/B setup and significance checks |
29
- | `drafts` | 4 | Draft create and approval flow |
30
- | `delivery` | 9 | Domain and deliverability operations |
31
- | `warmup` | 4 | Warmup schedule controls |
32
- | `social` | 4 | Social campaigns and scheduling |
33
- | `forms` | 3 | Forms and stats |
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>;
@@ -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(['create_blog_post', 'list_blog_posts', 'list_blog_destinations']);
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`;
@@ -20,6 +20,7 @@ export type PersistBrandAssetInput = {
20
20
  collection?: string | null;
21
21
  tags?: string[];
22
22
  brandKitId?: string | null;
23
+ backstory?: string | null;
23
24
  };
24
25
  export type PersistBrandAssetResult = {
25
26
  assetId: string;
@@ -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: "array";
27
+ readonly type: 'array';
28
28
  readonly minItems: 2;
29
29
  readonly maxItems: 12;
30
30
  readonly items: {
31
- readonly type: "object";
31
+ readonly type: 'object';
32
32
  readonly additionalProperties: false;
33
33
  readonly properties: {
34
34
  readonly type: {
35
- readonly enum: readonly ["text", "heading", "button", "columns", "image", "social", "divider", "spacer", "unsubscribe"];
35
+ readonly enum: readonly ['text', 'heading', 'button', 'columns', 'image', 'social', 'divider', 'spacer', 'unsubscribe'];
36
36
  };
37
37
  readonly content: {
38
- readonly type: "string";
38
+ readonly type: 'string';
39
39
  };
40
40
  readonly align: {
41
- readonly enum: readonly ["left", "center", "right"];
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: "string";
47
+ readonly type: 'string';
48
48
  };
49
49
  readonly url: {
50
- readonly type: "string";
50
+ readonly type: 'string';
51
51
  };
52
52
  readonly bg_color: {
53
- readonly type: "string";
53
+ readonly type: 'string';
54
54
  };
55
55
  readonly text_color: {
56
- readonly type: "string";
56
+ readonly type: 'string';
57
57
  };
58
58
  readonly border_radius: {
59
- readonly type: "number";
59
+ readonly type: 'number';
60
60
  readonly minimum: 0;
61
61
  readonly maximum: 24;
62
62
  };
63
63
  readonly padding: {
64
- readonly type: "string";
64
+ readonly type: 'string';
65
65
  };
66
66
  readonly left: {
67
- readonly type: "string";
67
+ readonly type: 'string';
68
68
  };
69
69
  readonly right: {
70
- readonly type: "string";
70
+ readonly type: 'string';
71
71
  };
72
72
  readonly split: {
73
- readonly enum: readonly ["50/50", "60/40", "40/60"];
73
+ readonly enum: readonly ['50/50', '60/40', '40/60'];
74
74
  };
75
75
  readonly alt: {
76
- readonly type: "string";
76
+ readonly type: 'string';
77
77
  };
78
78
  readonly width: {
79
- readonly type: readonly ["number", "null"];
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 ["string", "null"];
84
+ readonly type: readonly ['string', 'null'];
85
85
  };
86
86
  readonly links: {
87
- readonly type: "array";
87
+ readonly type: 'array';
88
88
  readonly maxItems: 8;
89
89
  readonly items: {
90
- readonly type: "object";
90
+ readonly type: 'object';
91
91
  readonly additionalProperties: false;
92
92
  readonly properties: {
93
93
  readonly platform: {
94
- readonly type: "string";
94
+ readonly type: 'string';
95
95
  };
96
96
  readonly url: {
97
- readonly type: "string";
97
+ readonly type: 'string';
98
98
  };
99
99
  };
100
- readonly required: readonly ["platform", "url"];
100
+ readonly required: readonly ['platform', 'url'];
101
101
  };
102
102
  };
103
103
  readonly height: {
104
- readonly type: "number";
104
+ readonly type: 'number';
105
105
  readonly minimum: 4;
106
106
  readonly maximum: 96;
107
107
  };
108
108
  };
109
- readonly required: readonly ["type"];
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
- 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: cleanColor(block.bg_color, '#00BFFF'), text_color: cleanColor(block.text_color, '#ffffff'), border_radius: Math.max(0, Math.min(24, Number(block.border_radius ?? 6))), padding: cleanPadding(block.padding) }];
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
- 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:#111827;text-align:${cleanAlign(b.align)};">${escapeHtml(b.content || 'Your heading here')}</h${level}>`;
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 {};