@sendinel/mcp-server 1.0.108 → 1.0.109

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.
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod';
2
+ import { validateSiteUnsubscribeSettings } from '@sendinel/domain';
3
+ import { logAudit } from '../audit.js';
2
4
  import { db } from '../db.js';
3
5
  import { getProjectId } from '../project.js';
4
6
  import { checkPlanLimit } from '../plan-limits.js';
@@ -112,14 +114,14 @@ export function registerSiteTools(server) {
112
114
  idempotentHint: true,
113
115
  openWorldHint: false,
114
116
  },
115
- description: 'Read the brand voice configuration for a sender/site.',
117
+ description: 'Read the brand voice configuration and current site-wide footer, unsubscribe link settings, unsubscribe text, and physical mailing address for a sender/site.',
116
118
  inputSchema: {
117
119
  site_id: z.string().uuid(),
118
120
  },
119
121
  }, async ({ site_id }) => {
120
122
  const { data, error } = await db()
121
123
  .from('sites')
122
- .select('id, name, brand_voice')
124
+ .select('id, name, brand_voice, physical_address, unsub_link_enabled, unsub_one_click_enabled, unsub_custom_url, unsub_footer_template, unsub_custom_message, unsub_brand_logo_url, unsub_brand_color')
123
125
  .eq('project_id', getProjectId())
124
126
  .eq('id', site_id)
125
127
  .maybeSingle();
@@ -184,15 +186,23 @@ export function registerSiteTools(server) {
184
186
  idempotentHint: true,
185
187
  openWorldHint: false,
186
188
  },
187
- description: 'Update a site name, from address, or other settings',
189
+ description: 'Update site-wide email footer, unsubscribe text, unsubscribe link, one-click unsubscribe, unsubscribe page branding, sender name/from address, active status, or physical mailing address. unsub_footer_template must be non-empty and include <unsubscribe>link text</unsubscribe>. Omitted settings and physical_address stay unchanged. Returns stored settings and previous_unsub_footer_template when the footer changes, so callers can undo.',
188
190
  inputSchema: {
189
191
  site_id: z.string().uuid(),
190
192
  name: z.string().optional(),
191
193
  from_email: z.string().email().optional(),
192
194
  from_name: z.string().optional(),
193
195
  active: z.boolean().optional(),
196
+ physical_address: z.string().trim().min(1).optional().describe('Physical mailing address; unchanged unless explicitly supplied.'),
197
+ unsub_link_enabled: z.boolean().optional().describe('Enable the unsubscribe link in the email footer.'),
198
+ unsub_one_click_enabled: z.boolean().optional().describe('Enable one-click unsubscribe headers.'),
199
+ unsub_custom_url: z.string().nullable().optional().describe('HTTP(S) unsubscribe URL; null or blank clears it.'),
200
+ unsub_footer_template: z.string().nullable().optional().describe('Non-empty site-wide footer including <unsubscribe>link text</unsubscribe>.'),
201
+ unsub_custom_message: z.string().nullable().optional().describe('Unsubscribe page message, up to 280 characters; null or blank clears it.'),
202
+ unsub_brand_logo_url: z.string().nullable().optional().describe('HTTP(S) unsubscribe page logo URL; null or blank clears it.'),
203
+ unsub_brand_color: z.string().nullable().optional().describe('Six-digit hex unsubscribe page color; null or blank clears it.'),
194
204
  },
195
- }, async ({ site_id, name, from_email, from_name, active }) => {
205
+ }, async ({ site_id, name, from_email, from_name, active, physical_address, ...unsubscribeInput }) => {
196
206
  const updates = {};
197
207
  if (name)
198
208
  updates.name = name;
@@ -202,18 +212,54 @@ export function registerSiteTools(server) {
202
212
  updates.from_name = from_name;
203
213
  if (active !== undefined)
204
214
  updates.active = active;
215
+ if (physical_address !== undefined)
216
+ updates.physical_address = physical_address.trim().slice(0, 500);
217
+ const unsubscribe = validateSiteUnsubscribeSettings(unsubscribeInput);
218
+ if (!unsubscribe.ok)
219
+ return txt(`Error: ${unsubscribe.error}`);
220
+ Object.assign(updates, unsubscribe.value);
205
221
  if (!Object.keys(updates).length)
206
222
  return txt('No fields to update.');
223
+ // Read only when a footer is supplied, and guard the write so the undo value is exact.
224
+ const sb = db();
225
+ const projectId = getProjectId();
226
+ let previousTemplate;
227
+ if (updates.unsub_footer_template !== undefined) {
228
+ const { data: current, error } = await sb
229
+ .from('sites')
230
+ .select('unsub_footer_template')
231
+ .eq('id', site_id)
232
+ .eq('project_id', projectId)
233
+ .maybeSingle();
234
+ if (error)
235
+ return txt(`Error: ${error.message}`);
236
+ if (!current)
237
+ return j({ error: 'Site not found', code: 'NOT_FOUND' });
238
+ previousTemplate = current.unsub_footer_template;
239
+ }
207
240
  updates.updated_at = new Date().toISOString();
208
- const { data, error } = await db()
241
+ let query = sb
209
242
  .from('sites')
210
243
  .update(updates)
211
244
  .eq('id', site_id)
212
- .eq('project_id', getProjectId())
213
- .select()
214
- .single();
245
+ .eq('project_id', projectId);
246
+ if (previousTemplate === null)
247
+ query = query.is('unsub_footer_template', null);
248
+ else if (previousTemplate !== undefined)
249
+ query = query.eq('unsub_footer_template', previousTemplate);
250
+ const { data, error } = await query.select().maybeSingle();
215
251
  if (error)
216
252
  return txt(`Error: ${error.message}`);
217
- return j(data);
253
+ if (!data)
254
+ return j({ error: 'Site not found or footer changed during update. Read the site and retry.', code: 'SITE_CHANGED_OR_NOT_FOUND' });
255
+ await logAudit({
256
+ action: 'site.updated', resourceType: 'site', resourceId: site_id,
257
+ details: { fields: Object.keys(updates).filter((field) => field !== 'updated_at') },
258
+ });
259
+ return j({
260
+ ...data,
261
+ ...(previousTemplate !== undefined && previousTemplate !== data.unsub_footer_template
262
+ ? { previous_unsub_footer_template: previousTemplate } : {}),
263
+ });
218
264
  });
219
265
  }
@@ -12,6 +12,7 @@ export const STOPWORDS = new Set([
12
12
  * Only tools that exist in the registry belong here.
13
13
  */
14
14
  export const TOOL_ALIASES = {
15
+ update_site: ['footer', 'email footer', 'unsubscribe text', 'unsubscribe link', 'site footer', 'unsubscribe settings'],
15
16
  set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'trial ending'],
16
17
  send_broadcast: [
17
18
  'send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.108",
3
+ "version": "1.0.109",
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",