@sendinel/mcp-server 1.0.4 → 1.0.6
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/audit.d.ts +1 -0
- package/build/audit.js +9 -3
- package/build/auth.d.ts +10 -0
- package/build/auth.js +82 -9
- package/build/db.d.ts +12 -0
- package/build/db.js +37 -2
- package/build/index.js +38 -12
- package/build/lib/action-approvals.d.ts +28 -1
- package/build/lib/action-approvals.js +89 -2
- package/build/lib/brand-asset-persist.d.ts +35 -0
- package/build/lib/brand-asset-persist.js +161 -0
- package/build/lib/brand-asset-usage.d.ts +13 -0
- package/build/lib/brand-asset-usage.js +46 -0
- package/build/lib/email-provider.d.ts +19 -0
- package/build/lib/email-provider.js +281 -0
- package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
- package/build/lib/ssrf-pinned-fetch.js +101 -0
- package/build/lib/survey-link-resolver.d.ts +6 -0
- package/build/lib/survey-link-resolver.js +23 -0
- package/build/lib/svg-sanitize.d.ts +2 -0
- package/build/lib/svg-sanitize.js +110 -0
- package/build/lib/webhook-events.d.ts +1 -1
- package/build/scoped-token.d.ts +11 -0
- package/build/scoped-token.js +59 -0
- package/build/server.js +30 -9
- package/build/tools/ab-testing.js +28 -0
- package/build/tools/advisor.js +52 -2
- package/build/tools/ai-work.d.ts +2 -0
- package/build/tools/ai-work.js +460 -0
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +203 -0
- package/build/tools/approvals.js +15 -1
- package/build/tools/automations.js +288 -65
- package/build/tools/brand-kit.d.ts +2 -0
- package/build/tools/brand-kit.js +277 -0
- package/build/tools/campaigns.js +900 -69
- package/build/tools/compound.js +28 -0
- package/build/tools/contacts.js +338 -17
- package/build/tools/content.js +266 -1
- package/build/tools/data-proposals.js +21 -0
- package/build/tools/data.js +140 -8
- package/build/tools/delivery-ops.js +84 -0
- package/build/tools/drafts.js +64 -1
- package/build/tools/forms.js +42 -9
- package/build/tools/gdpr.js +22 -3
- package/build/tools/newsletters.d.ts +2 -0
- package/build/tools/newsletters.js +583 -0
- package/build/tools/org.js +297 -3
- package/build/tools/segments.js +45 -3
- package/build/tools/short-links.d.ts +2 -0
- package/build/tools/short-links.js +157 -0
- package/build/tools/sites.js +35 -0
- package/build/tools/sms.js +49 -0
- package/build/tools/social-posts.js +81 -5
- package/build/tools/surveys.d.ts +2 -0
- package/build/tools/surveys.js +222 -0
- package/build/tools/templates.js +78 -2
- package/build/tools/warmup.js +28 -0
- package/build/tools/webhooks.js +29 -0
- package/package.json +9 -3
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Self-contained port of packages/dashboard/lib/brand-kit/{asset-persist,asset-mutations,asset-persister}.ts
|
|
2
|
+
// — see ssrf-pinned-fetch.ts for why this is a copy, not a cross-package import.
|
|
3
|
+
// R2/video storage is deliberately NOT ported (out of scope) — video uploads via
|
|
4
|
+
// this path are rejected with a clear error rather than silently mishandled.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { fileTypeFromBuffer } from 'file-type';
|
|
7
|
+
import { sanitizeSvgBytes } from './svg-sanitize.js';
|
|
8
|
+
export const BRAND_ASSETS_BUCKET = 'brand-assets';
|
|
9
|
+
export const MAX_ASSET_BYTES = 100 * 1024 * 1024;
|
|
10
|
+
export const BRAND_ASSET_KINDS = ['logo', 'image', 'document', 'font', 'avatar', 'video'];
|
|
11
|
+
const MIME_BY_EXT = {
|
|
12
|
+
'.png': 'image/png',
|
|
13
|
+
'.jpg': 'image/jpeg',
|
|
14
|
+
'.jpeg': 'image/jpeg',
|
|
15
|
+
'.gif': 'image/gif',
|
|
16
|
+
'.webp': 'image/webp',
|
|
17
|
+
'.svg': 'image/svg+xml',
|
|
18
|
+
'.pdf': 'application/pdf',
|
|
19
|
+
'.md': 'text/markdown',
|
|
20
|
+
'.txt': 'text/plain',
|
|
21
|
+
'.csv': 'text/csv',
|
|
22
|
+
'.json': 'application/json',
|
|
23
|
+
'.html': 'text/html',
|
|
24
|
+
'.woff': 'font/woff',
|
|
25
|
+
'.woff2': 'font/woff2',
|
|
26
|
+
'.ttf': 'font/ttf',
|
|
27
|
+
'.otf': 'font/otf',
|
|
28
|
+
'.mp4': 'video/mp4',
|
|
29
|
+
'.webm': 'video/webm',
|
|
30
|
+
'.mov': 'video/quicktime',
|
|
31
|
+
};
|
|
32
|
+
const MAGIC_MIME_ALIASES = {
|
|
33
|
+
'application/zip': ['application/x-zip-compressed'],
|
|
34
|
+
'application/x-zip-compressed': ['application/zip'],
|
|
35
|
+
'text/csv': ['application/vnd.ms-excel', 'text/plain'],
|
|
36
|
+
'application/vnd.ms-excel': ['text/csv', 'text/plain'],
|
|
37
|
+
};
|
|
38
|
+
export function extOf(filename) {
|
|
39
|
+
const slash = Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\'));
|
|
40
|
+
const dot = filename.lastIndexOf('.');
|
|
41
|
+
return dot > slash ? filename.slice(dot).toLowerCase() : '';
|
|
42
|
+
}
|
|
43
|
+
export function resolveMime(filename, declared) {
|
|
44
|
+
const d = (declared ?? '').split(';')[0].trim().toLowerCase();
|
|
45
|
+
if (d)
|
|
46
|
+
return d;
|
|
47
|
+
return MIME_BY_EXT[extOf(filename)] ?? 'application/octet-stream';
|
|
48
|
+
}
|
|
49
|
+
export function isValidKind(value) {
|
|
50
|
+
return typeof value === 'string' && BRAND_ASSET_KINDS.includes(value);
|
|
51
|
+
}
|
|
52
|
+
export function inferKind(mime) {
|
|
53
|
+
if (mime.startsWith('video/'))
|
|
54
|
+
return 'video';
|
|
55
|
+
if (mime.startsWith('font/'))
|
|
56
|
+
return 'font';
|
|
57
|
+
if (mime.startsWith('image/'))
|
|
58
|
+
return 'image';
|
|
59
|
+
return 'document';
|
|
60
|
+
}
|
|
61
|
+
export function refineKindFromName(filename, mime) {
|
|
62
|
+
if (inferKind(mime) !== 'image')
|
|
63
|
+
return inferKind(mime);
|
|
64
|
+
if (/logo|wordmark|brandmark/i.test(filename))
|
|
65
|
+
return 'logo';
|
|
66
|
+
if (/avatar|headshot|profile/i.test(filename))
|
|
67
|
+
return 'avatar';
|
|
68
|
+
return 'image';
|
|
69
|
+
}
|
|
70
|
+
export function resolveKind(inputKind, filename, mimeType) {
|
|
71
|
+
if (isValidKind(inputKind))
|
|
72
|
+
return inputKind;
|
|
73
|
+
return refineKindFromName(filename, mimeType);
|
|
74
|
+
}
|
|
75
|
+
async function validateMagicBytes(buffer, declaredMime) {
|
|
76
|
+
const normalized = declaredMime.split(';')[0].trim().toLowerCase();
|
|
77
|
+
const detected = await fileTypeFromBuffer(buffer);
|
|
78
|
+
if (!detected) {
|
|
79
|
+
const prefix = buffer.subarray(0, 512).toString('utf8').trimStart().toLowerCase();
|
|
80
|
+
if (!normalized.startsWith('text/') &&
|
|
81
|
+
normalized !== 'application/json' &&
|
|
82
|
+
(prefix.startsWith('<!doctype html') || prefix.startsWith('<html') || prefix.startsWith('<script'))) {
|
|
83
|
+
return { ok: false, reason: `Declared MIME ${normalized} appears to contain HTML` };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, reason: null };
|
|
86
|
+
}
|
|
87
|
+
const compatible = detected.mime === normalized || (MAGIC_MIME_ALIASES[normalized] ?? []).includes(detected.mime);
|
|
88
|
+
if (compatible)
|
|
89
|
+
return { ok: true, reason: null };
|
|
90
|
+
return { ok: false, reason: `Declared MIME ${normalized} does not match detected MIME ${detected.mime}` };
|
|
91
|
+
}
|
|
92
|
+
function brandAssetStoragePath(projectId, sha256, extension) {
|
|
93
|
+
return `${projectId}/${sha256}${extension}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Uploads bytes to Sendinel's own Supabase Storage + inserts/dedupes the row
|
|
97
|
+
* in email.brand_media_assets, using the MCP server's own adminDb() client —
|
|
98
|
+
* no round-trip through the dashboard's v1 API, no separate SENDINEL_API_KEY.
|
|
99
|
+
* Video (R2-backed in the dashboard) is not supported via this path.
|
|
100
|
+
*/
|
|
101
|
+
export async function persistBrandAsset(input) {
|
|
102
|
+
if (input.kind === 'video') {
|
|
103
|
+
throw new Error('Video uploads are not supported via MCP yet — use the Sendinel dashboard for video assets.');
|
|
104
|
+
}
|
|
105
|
+
if (input.bytes.byteLength > MAX_ASSET_BYTES) {
|
|
106
|
+
throw new Error(`File exceeds the ${MAX_ASSET_BYTES / 1048576} MB limit`);
|
|
107
|
+
}
|
|
108
|
+
const mimeType = resolveMime(input.filename, input.mimeType);
|
|
109
|
+
const magic = await validateMagicBytes(input.bytes, mimeType);
|
|
110
|
+
if (!magic.ok)
|
|
111
|
+
throw new Error(magic.reason ?? 'Asset content does not match its declared file type');
|
|
112
|
+
let bytes = input.bytes;
|
|
113
|
+
if (mimeType === 'image/svg+xml')
|
|
114
|
+
bytes = sanitizeSvgBytes(bytes);
|
|
115
|
+
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
116
|
+
const storagePath = brandAssetStoragePath(input.projectId, sha256, extOf(input.filename));
|
|
117
|
+
const existing = await input.sb.schema('email').from('brand_media_assets')
|
|
118
|
+
.select('id').eq('project_id', input.projectId).eq('sha256', sha256).maybeSingle();
|
|
119
|
+
if (existing.data?.id) {
|
|
120
|
+
if (input.brandKitId) {
|
|
121
|
+
await input.sb.schema('email').from('brand_media_assets')
|
|
122
|
+
.update({ brand_kit_id: input.brandKitId })
|
|
123
|
+
.eq('id', existing.data.id)
|
|
124
|
+
.is('brand_kit_id', null);
|
|
125
|
+
}
|
|
126
|
+
return { assetId: existing.data.id, skippedExisting: true, storagePath };
|
|
127
|
+
}
|
|
128
|
+
const upload = await input.sb.storage.from(BRAND_ASSETS_BUCKET).upload(storagePath, bytes, {
|
|
129
|
+
contentType: mimeType,
|
|
130
|
+
upsert: true,
|
|
131
|
+
});
|
|
132
|
+
if (upload.error)
|
|
133
|
+
throw new Error(upload.error.message);
|
|
134
|
+
const insert = await input.sb.schema('email').from('brand_media_assets').upsert({
|
|
135
|
+
org_id: input.orgId,
|
|
136
|
+
project_id: input.projectId,
|
|
137
|
+
import_job_id: null,
|
|
138
|
+
brand_kit_id: input.brandKitId ?? null,
|
|
139
|
+
folder_id: null,
|
|
140
|
+
review_status: 'confirmed',
|
|
141
|
+
kind: input.kind,
|
|
142
|
+
filename: input.filename.slice(0, 255),
|
|
143
|
+
original_path: null,
|
|
144
|
+
storage_path: storagePath,
|
|
145
|
+
mime_type: mimeType,
|
|
146
|
+
size_bytes: bytes.byteLength,
|
|
147
|
+
sha256,
|
|
148
|
+
source: input.source,
|
|
149
|
+
storage_backend: 'supabase',
|
|
150
|
+
collection: input.collection ?? null,
|
|
151
|
+
tags: input.tags ?? [],
|
|
152
|
+
metadata: {},
|
|
153
|
+
}, { onConflict: 'project_id,sha256', ignoreDuplicates: true }).select('id');
|
|
154
|
+
if (insert.error)
|
|
155
|
+
throw new Error(insert.error.message);
|
|
156
|
+
const assetId = insert.data?.[0]?.id || (await input.sb.schema('email').from('brand_media_assets')
|
|
157
|
+
.select('id').eq('project_id', input.projectId).eq('sha256', sha256).maybeSingle()).data?.id;
|
|
158
|
+
if (!assetId)
|
|
159
|
+
throw new Error('Asset was stored but its row could not be resolved');
|
|
160
|
+
return { assetId: assetId, skippedExisting: !insert.data?.length, storagePath };
|
|
161
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function normalizeBrandAssetIds(value: unknown): string[];
|
|
2
|
+
export declare function extractBrandAssetIdsFromHtml(html: unknown): string[];
|
|
3
|
+
export declare function recordBrandAssetUsage(input: {
|
|
4
|
+
client: any;
|
|
5
|
+
projectId: string;
|
|
6
|
+
source: string;
|
|
7
|
+
resourceId: string;
|
|
8
|
+
html?: unknown;
|
|
9
|
+
assetIds?: unknown;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
tracked: number;
|
|
12
|
+
assetIds: string[];
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
2
|
+
const ASSET_ATTR_RE = /\b(?:data-brand-asset-id|data-sendinel-asset-id|data-asset-id)=["']([0-9a-f-]{36})["']/gi;
|
|
3
|
+
function normalizeUuid(value) {
|
|
4
|
+
if (typeof value !== 'string')
|
|
5
|
+
return null;
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
return UUID_RE.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
8
|
+
}
|
|
9
|
+
export function normalizeBrandAssetIds(value) {
|
|
10
|
+
const raw = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
|
|
11
|
+
const ids = new Set();
|
|
12
|
+
for (const item of raw) {
|
|
13
|
+
const id = normalizeUuid(item);
|
|
14
|
+
if (id)
|
|
15
|
+
ids.add(id);
|
|
16
|
+
}
|
|
17
|
+
return Array.from(ids);
|
|
18
|
+
}
|
|
19
|
+
export function extractBrandAssetIdsFromHtml(html) {
|
|
20
|
+
const source = typeof html === 'string' ? html : '';
|
|
21
|
+
const ids = new Set();
|
|
22
|
+
let match;
|
|
23
|
+
while ((match = ASSET_ATTR_RE.exec(source)) !== null) {
|
|
24
|
+
const id = normalizeUuid(match[1]);
|
|
25
|
+
if (id)
|
|
26
|
+
ids.add(id);
|
|
27
|
+
}
|
|
28
|
+
return Array.from(ids);
|
|
29
|
+
}
|
|
30
|
+
export async function recordBrandAssetUsage(input) {
|
|
31
|
+
const assetIds = Array.from(new Set([
|
|
32
|
+
...normalizeBrandAssetIds(input.assetIds),
|
|
33
|
+
...extractBrandAssetIdsFromHtml(input.html),
|
|
34
|
+
]));
|
|
35
|
+
if (assetIds.length === 0)
|
|
36
|
+
return { tracked: 0, assetIds: [] };
|
|
37
|
+
const { data, error } = await input.client.rpc('record_brand_asset_references', {
|
|
38
|
+
p_project_id: input.projectId,
|
|
39
|
+
p_asset_ids: assetIds,
|
|
40
|
+
p_source: input.source,
|
|
41
|
+
p_resource_id: input.resourceId,
|
|
42
|
+
});
|
|
43
|
+
if (error)
|
|
44
|
+
throw new Error(`Failed to record brand asset usage: ${error.message}`);
|
|
45
|
+
return { tracked: Number(data ?? 0), assetIds };
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const EMAIL_PROVIDER_NAMES: readonly ["resend", "sendgrid", "ses", "postmark", "mailgun", "elastic-email"];
|
|
2
|
+
export type EmailProviderName = (typeof EMAIL_PROVIDER_NAMES)[number];
|
|
3
|
+
type SendArgs = {
|
|
4
|
+
from: string;
|
|
5
|
+
to: string;
|
|
6
|
+
subject: string;
|
|
7
|
+
html?: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
replyTo?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
type SendResult = {
|
|
13
|
+
provider: EmailProviderName;
|
|
14
|
+
messageId: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function sendEmailForSite(projectId: string, siteId: string, args: SendArgs): Promise<SendResult>;
|
|
17
|
+
export declare function dkimSelectorForProvider(provider: string | null | undefined): string | null;
|
|
18
|
+
export declare function dkimRemediationText(provider: string | null | undefined): string;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Resend } from "resend";
|
|
2
|
+
import { db } from "../db.js";
|
|
3
|
+
import { decrypt } from "../crypto.js";
|
|
4
|
+
export const EMAIL_PROVIDER_NAMES = [
|
|
5
|
+
"resend",
|
|
6
|
+
"sendgrid",
|
|
7
|
+
"ses",
|
|
8
|
+
"postmark",
|
|
9
|
+
"mailgun",
|
|
10
|
+
"elastic-email",
|
|
11
|
+
];
|
|
12
|
+
function normalizeProviderName(value) {
|
|
13
|
+
if (!value || value === "resend")
|
|
14
|
+
return "resend";
|
|
15
|
+
if (value === "elastic_email")
|
|
16
|
+
return "elastic-email";
|
|
17
|
+
if (EMAIL_PROVIDER_NAMES.includes(value))
|
|
18
|
+
return value;
|
|
19
|
+
throw new Error(`Unsupported email provider: ${value}`);
|
|
20
|
+
}
|
|
21
|
+
function parseFromAddress(from) {
|
|
22
|
+
const match = from.match(/^(.*?)\s*<([^>]+)>$/);
|
|
23
|
+
if (!match)
|
|
24
|
+
return { email: from.trim() };
|
|
25
|
+
return {
|
|
26
|
+
name: match[1].trim().replace(/^"|"$/g, ""),
|
|
27
|
+
email: match[2].trim(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function fromEmailDomain(from) {
|
|
31
|
+
const { email } = parseFromAddress(from);
|
|
32
|
+
const domain = email.split("@")[1]?.replace(/[>"]/g, "").trim();
|
|
33
|
+
if (!domain)
|
|
34
|
+
throw new Error("Mailgun requires a from email with a domain");
|
|
35
|
+
return domain;
|
|
36
|
+
}
|
|
37
|
+
async function readError(res) {
|
|
38
|
+
const body = await res.json().catch(() => null);
|
|
39
|
+
return body?.Error ?? body?.Message ?? body?.error ?? body?.message ?? `Provider error: ${res.status}`;
|
|
40
|
+
}
|
|
41
|
+
async function resolveElasticTenantKey(projectId) {
|
|
42
|
+
const { data } = await db()
|
|
43
|
+
.from("channel_provisions")
|
|
44
|
+
.select("encrypted_provider_auth_token")
|
|
45
|
+
.eq("project_id", projectId)
|
|
46
|
+
.eq("channel", "email")
|
|
47
|
+
.eq("provider", "elastic_email")
|
|
48
|
+
.neq("status", "deprovisioned")
|
|
49
|
+
.order("created_at", { ascending: false })
|
|
50
|
+
.limit(1)
|
|
51
|
+
.maybeSingle();
|
|
52
|
+
const provision = data;
|
|
53
|
+
if (!provision?.encrypted_provider_auth_token)
|
|
54
|
+
return null;
|
|
55
|
+
return decrypt(provision.encrypted_provider_auth_token);
|
|
56
|
+
}
|
|
57
|
+
function providerEnvKey(provider) {
|
|
58
|
+
switch (provider) {
|
|
59
|
+
case "resend":
|
|
60
|
+
return process.env.RESEND_API_KEY ?? null;
|
|
61
|
+
case "sendgrid":
|
|
62
|
+
return process.env.SENDGRID_API_KEY ?? null;
|
|
63
|
+
case "postmark":
|
|
64
|
+
return process.env.POSTMARK_API_KEY ?? null;
|
|
65
|
+
case "mailgun":
|
|
66
|
+
return process.env.MAILGUN_API_KEY ?? null;
|
|
67
|
+
case "elastic-email":
|
|
68
|
+
return process.env.ELASTIC_EMAIL_API_KEY ?? null;
|
|
69
|
+
case "ses":
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function resolveProviderConfig(projectId, siteId) {
|
|
74
|
+
const { data: projectData } = await db()
|
|
75
|
+
.from("projects")
|
|
76
|
+
.select("plan, provider_api_key_enc, managed_provider")
|
|
77
|
+
.eq("id", projectId)
|
|
78
|
+
.maybeSingle();
|
|
79
|
+
const project = projectData;
|
|
80
|
+
const { data: siteData } = await db()
|
|
81
|
+
.from("sites")
|
|
82
|
+
.select("provider, provider_api_key_enc, is_shared_domain")
|
|
83
|
+
.eq("id", siteId)
|
|
84
|
+
.eq("project_id", projectId)
|
|
85
|
+
.maybeSingle();
|
|
86
|
+
const site = siteData;
|
|
87
|
+
if (!site)
|
|
88
|
+
throw new Error("Site not found");
|
|
89
|
+
const provider = normalizeProviderName(site.provider);
|
|
90
|
+
const siteKey = site.provider_api_key_enc ? decrypt(site.provider_api_key_enc) : null;
|
|
91
|
+
const projectKey = project?.provider_api_key_enc ? decrypt(project.provider_api_key_enc) : null;
|
|
92
|
+
if (site.is_shared_domain) {
|
|
93
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
94
|
+
if (!apiKey)
|
|
95
|
+
throw new Error("RESEND_API_KEY not configured");
|
|
96
|
+
return { name: "resend", apiKey };
|
|
97
|
+
}
|
|
98
|
+
if (project?.plan === "managed" && (!project.managed_provider || project.managed_provider === "sendinel_resend")) {
|
|
99
|
+
const apiKey = process.env.RESEND_MANAGED_API_KEY;
|
|
100
|
+
if (!apiKey)
|
|
101
|
+
throw new Error("RESEND_MANAGED_API_KEY not configured");
|
|
102
|
+
return { name: "resend", apiKey };
|
|
103
|
+
}
|
|
104
|
+
const directKey = siteKey ?? projectKey;
|
|
105
|
+
if (directKey)
|
|
106
|
+
return { name: provider, apiKey: directKey };
|
|
107
|
+
if (provider === "elastic-email") {
|
|
108
|
+
const tenantKey = await resolveElasticTenantKey(projectId);
|
|
109
|
+
if (tenantKey)
|
|
110
|
+
return { name: provider, apiKey: tenantKey };
|
|
111
|
+
}
|
|
112
|
+
const envKey = providerEnvKey(provider);
|
|
113
|
+
if (envKey)
|
|
114
|
+
return { name: provider, apiKey: envKey };
|
|
115
|
+
throw new Error(`No API key configured for ${provider}`);
|
|
116
|
+
}
|
|
117
|
+
async function sendViaResend(apiKey, args) {
|
|
118
|
+
const resend = new Resend(apiKey);
|
|
119
|
+
const payload = {
|
|
120
|
+
from: args.from,
|
|
121
|
+
to: args.to,
|
|
122
|
+
subject: args.subject,
|
|
123
|
+
};
|
|
124
|
+
if (args.html)
|
|
125
|
+
payload.html = args.html;
|
|
126
|
+
if (args.text)
|
|
127
|
+
payload.text = args.text;
|
|
128
|
+
if (args.replyTo)
|
|
129
|
+
payload.reply_to = args.replyTo;
|
|
130
|
+
if (args.headers)
|
|
131
|
+
payload.headers = args.headers;
|
|
132
|
+
const { data, error } = await resend.emails.send(payload);
|
|
133
|
+
if (error || !data?.id)
|
|
134
|
+
throw new Error(error?.message || "Resend send failed");
|
|
135
|
+
return data.id;
|
|
136
|
+
}
|
|
137
|
+
async function sendViaElasticEmail(apiKey, args) {
|
|
138
|
+
const from = parseFromAddress(args.from);
|
|
139
|
+
const res = await fetch("https://api.elasticemail.com/v4/emails/transactional", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
"Content-Type": "application/json",
|
|
143
|
+
"X-ElasticEmail-ApiKey": apiKey,
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
Recipients: { To: [args.to] },
|
|
147
|
+
Content: {
|
|
148
|
+
From: from.email,
|
|
149
|
+
FromName: from.name,
|
|
150
|
+
Subject: args.subject,
|
|
151
|
+
Body: [
|
|
152
|
+
...(args.html ? [{ ContentType: "HTML", Content: args.html }] : []),
|
|
153
|
+
...(args.text ? [{ ContentType: "PlainText", Content: args.text }] : []),
|
|
154
|
+
],
|
|
155
|
+
ReplyTo: args.replyTo,
|
|
156
|
+
Headers: args.headers,
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok)
|
|
161
|
+
throw new Error(await readError(res));
|
|
162
|
+
const json = await res.json().catch(() => ({}));
|
|
163
|
+
return json.MessageID ?? json.messageid ?? json.TransactionID ?? `ee_${Date.now()}`;
|
|
164
|
+
}
|
|
165
|
+
async function sendViaSendGrid(apiKey, args) {
|
|
166
|
+
const from = parseFromAddress(args.from);
|
|
167
|
+
const res = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: {
|
|
170
|
+
Authorization: `Bearer ${apiKey}`,
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
personalizations: [{ to: [{ email: args.to }] }],
|
|
175
|
+
from: { email: from.email, name: from.name },
|
|
176
|
+
subject: args.subject,
|
|
177
|
+
content: [
|
|
178
|
+
...(args.text ? [{ type: "text/plain", value: args.text }] : []),
|
|
179
|
+
...(args.html ? [{ type: "text/html", value: args.html }] : []),
|
|
180
|
+
],
|
|
181
|
+
...(args.replyTo ? { reply_to: { email: args.replyTo } } : {}),
|
|
182
|
+
...(args.headers ? { headers: args.headers } : {}),
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok)
|
|
186
|
+
throw new Error(await readError(res));
|
|
187
|
+
return res.headers.get("x-message-id") || `sg_${Date.now()}`;
|
|
188
|
+
}
|
|
189
|
+
async function sendViaPostmark(apiKey, args) {
|
|
190
|
+
const res = await fetch("https://api.postmarkapp.com/email", {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: {
|
|
193
|
+
Accept: "application/json",
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
"X-Postmark-Server-Token": apiKey,
|
|
196
|
+
},
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
From: args.from,
|
|
199
|
+
To: args.to,
|
|
200
|
+
Subject: args.subject,
|
|
201
|
+
HtmlBody: args.html,
|
|
202
|
+
TextBody: args.text,
|
|
203
|
+
...(args.replyTo ? { ReplyTo: args.replyTo } : {}),
|
|
204
|
+
...(args.headers ? { Headers: Object.entries(args.headers).map(([Name, Value]) => ({ Name, Value })) } : {}),
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok)
|
|
208
|
+
throw new Error(await readError(res));
|
|
209
|
+
const data = await res.json().catch(() => ({}));
|
|
210
|
+
return data.MessageID || `pm_${Date.now()}`;
|
|
211
|
+
}
|
|
212
|
+
async function sendViaMailgun(apiKey, args) {
|
|
213
|
+
const form = new FormData();
|
|
214
|
+
form.set("from", args.from);
|
|
215
|
+
form.set("to", args.to);
|
|
216
|
+
form.set("subject", args.subject);
|
|
217
|
+
if (args.html)
|
|
218
|
+
form.set("html", args.html);
|
|
219
|
+
if (args.text)
|
|
220
|
+
form.set("text", args.text);
|
|
221
|
+
if (args.replyTo)
|
|
222
|
+
form.set("h:Reply-To", args.replyTo);
|
|
223
|
+
for (const [key, value] of Object.entries(args.headers ?? {})) {
|
|
224
|
+
form.set(`h:${key}`, value);
|
|
225
|
+
}
|
|
226
|
+
const res = await fetch(`https://api.mailgun.net/v3/${fromEmailDomain(args.from)}/messages`, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: {
|
|
229
|
+
Authorization: `Basic ${Buffer.from(`api:${apiKey}`).toString("base64")}`,
|
|
230
|
+
},
|
|
231
|
+
body: form,
|
|
232
|
+
});
|
|
233
|
+
if (!res.ok)
|
|
234
|
+
throw new Error(await readError(res));
|
|
235
|
+
const data = await res.json().catch(() => ({}));
|
|
236
|
+
const id = typeof data.id === "string" ? data.id.replace(/^<|>$/g, "") : "";
|
|
237
|
+
return id || `mg_${Date.now()}`;
|
|
238
|
+
}
|
|
239
|
+
export async function sendEmailForSite(projectId, siteId, args) {
|
|
240
|
+
const config = await resolveProviderConfig(projectId, siteId);
|
|
241
|
+
switch (config.name) {
|
|
242
|
+
case "resend":
|
|
243
|
+
return { provider: config.name, messageId: await sendViaResend(config.apiKey, args) };
|
|
244
|
+
case "elastic-email":
|
|
245
|
+
return { provider: config.name, messageId: await sendViaElasticEmail(config.apiKey, args) };
|
|
246
|
+
case "sendgrid":
|
|
247
|
+
return { provider: config.name, messageId: await sendViaSendGrid(config.apiKey, args) };
|
|
248
|
+
case "postmark":
|
|
249
|
+
return { provider: config.name, messageId: await sendViaPostmark(config.apiKey, args) };
|
|
250
|
+
case "mailgun":
|
|
251
|
+
return { provider: config.name, messageId: await sendViaMailgun(config.apiKey, args) };
|
|
252
|
+
case "ses":
|
|
253
|
+
throw new Error("SES sending is not yet supported from the MCP server");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export function dkimSelectorForProvider(provider) {
|
|
257
|
+
try {
|
|
258
|
+
switch (normalizeProviderName(provider)) {
|
|
259
|
+
case "resend":
|
|
260
|
+
return "resend._domainkey";
|
|
261
|
+
case "elastic-email":
|
|
262
|
+
return "api._domainkey";
|
|
263
|
+
case "mailgun":
|
|
264
|
+
return "k1._domainkey";
|
|
265
|
+
case "sendgrid":
|
|
266
|
+
return "s1._domainkey / s2._domainkey";
|
|
267
|
+
case "postmark":
|
|
268
|
+
case "ses":
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export function dkimRemediationText(provider) {
|
|
277
|
+
const selector = dkimSelectorForProvider(provider);
|
|
278
|
+
if (selector)
|
|
279
|
+
return `Configure DKIM record for your sending provider (${selector})`;
|
|
280
|
+
return "Configure DKIM record for your sending provider";
|
|
281
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Agent } from 'undici';
|
|
2
|
+
export type ResolvedPublicAddress = {
|
|
3
|
+
address: string;
|
|
4
|
+
family: 4 | 6;
|
|
5
|
+
};
|
|
6
|
+
export declare function createPinnedIpDispatcher(hostname: string, resolved: ResolvedPublicAddress): Agent;
|
|
7
|
+
export declare function fetchPinnedToResolvedIp(url: URL, resolved: ResolvedPublicAddress, init?: RequestInit): Promise<Response>;
|
|
8
|
+
export declare function validatePublicHttpsUrl(rawUrl: string): Promise<{
|
|
9
|
+
url: URL;
|
|
10
|
+
resolvedAddress: ResolvedPublicAddress;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function downloadPublicAsset(rawUrl: string, maxBytes?: number): Promise<{
|
|
13
|
+
url: URL;
|
|
14
|
+
bytes: Buffer;
|
|
15
|
+
mimeType: string | null;
|
|
16
|
+
}>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Self-contained port of packages/dashboard/lib/ssrf-pinned-fetch.ts +
|
|
2
|
+
// the private-IP/URL validation from app/api/v1/brand-kit/ingest-assets/route.ts.
|
|
3
|
+
// mcp-server has no dependency on the dashboard package (separate deployable
|
|
4
|
+
// app, separate build root), so this is a deliberate, faithful copy rather
|
|
5
|
+
// than a cross-package import — keep both in sync if the SSRF guard changes.
|
|
6
|
+
import dns from 'node:dns/promises';
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
import { Agent } from 'undici';
|
|
9
|
+
export function createPinnedIpDispatcher(hostname, resolved) {
|
|
10
|
+
const expectedHost = hostname.toLowerCase();
|
|
11
|
+
return new Agent({
|
|
12
|
+
connect: {
|
|
13
|
+
lookup(requestedHost, _options, callback) {
|
|
14
|
+
if (String(requestedHost).toLowerCase() !== expectedHost) {
|
|
15
|
+
callback(new Error('Pinned fetch attempted to resolve an unexpected host'), []);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
callback(null, resolved.address, resolved.family);
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
connections: 1,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function fetchPinnedToResolvedIp(url, resolved, init = {}) {
|
|
25
|
+
const dispatcher = createPinnedIpDispatcher(url.hostname, resolved);
|
|
26
|
+
return fetch(url, { ...init, dispatcher });
|
|
27
|
+
}
|
|
28
|
+
function isPrivateIp(address) {
|
|
29
|
+
if (net.isIPv4(address)) {
|
|
30
|
+
const parts = address.split('.').map((part) => Number(part));
|
|
31
|
+
const [a, b] = parts;
|
|
32
|
+
return a === 0 || a === 10 || a === 127 || a >= 224 ||
|
|
33
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
34
|
+
(a === 169 && b === 254) ||
|
|
35
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
36
|
+
(a === 192 && (b === 0 || b === 168)) ||
|
|
37
|
+
(a === 198 && (b === 18 || b === 19));
|
|
38
|
+
}
|
|
39
|
+
if (net.isIPv6(address)) {
|
|
40
|
+
const value = address.toLowerCase();
|
|
41
|
+
return value === '::1' || value === '::' || value.startsWith('fc') || value.startsWith('fd') ||
|
|
42
|
+
value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb') ||
|
|
43
|
+
value.startsWith('::ffff:127.') || value.startsWith('::ffff:10.') || value.startsWith('::ffff:192.168.');
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
export async function validatePublicHttpsUrl(rawUrl) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = new URL(rawUrl);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error('URL is invalid');
|
|
54
|
+
}
|
|
55
|
+
if (parsed.protocol !== 'https:')
|
|
56
|
+
throw new Error('Only https URLs are supported');
|
|
57
|
+
if (parsed.username || parsed.password)
|
|
58
|
+
throw new Error('Credentialed URLs are not supported');
|
|
59
|
+
const addresses = await dns.lookup(parsed.hostname, { all: true, verbatim: false });
|
|
60
|
+
if (!addresses.length || addresses.some((entry) => isPrivateIp(entry.address))) {
|
|
61
|
+
throw new Error('URL must resolve to a public address');
|
|
62
|
+
}
|
|
63
|
+
return { url: parsed, resolvedAddress: addresses[0] };
|
|
64
|
+
}
|
|
65
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
66
|
+
const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
67
|
+
export async function downloadPublicAsset(rawUrl, maxBytes = MAX_DOWNLOAD_BYTES) {
|
|
68
|
+
const { url, resolvedAddress } = await validatePublicHttpsUrl(rawUrl);
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetchPinnedToResolvedIp(url, resolvedAddress, { redirect: 'manual', signal: controller.signal });
|
|
73
|
+
if (response.status >= 300 && response.status < 400)
|
|
74
|
+
throw new Error('Redirects are not supported');
|
|
75
|
+
if (!response.ok)
|
|
76
|
+
throw new Error(`Fetch failed with HTTP ${response.status}`);
|
|
77
|
+
const length = Number(response.headers.get('content-length') || '0');
|
|
78
|
+
if (length > maxBytes)
|
|
79
|
+
throw new Error(`File exceeds the ${maxBytes / 1048576} MB limit`);
|
|
80
|
+
const reader = response.body?.getReader();
|
|
81
|
+
if (!reader)
|
|
82
|
+
throw new Error('Response body is empty');
|
|
83
|
+
const chunks = [];
|
|
84
|
+
let total = 0;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const { done, value } = await reader.read();
|
|
87
|
+
if (done)
|
|
88
|
+
break;
|
|
89
|
+
if (!value)
|
|
90
|
+
continue;
|
|
91
|
+
total += value.byteLength;
|
|
92
|
+
if (total > maxBytes)
|
|
93
|
+
throw new Error(`File exceeds the ${maxBytes / 1048576} MB limit`);
|
|
94
|
+
chunks.push(value);
|
|
95
|
+
}
|
|
96
|
+
return { url, bytes: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))), mimeType: response.headers.get('content-type') };
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timeout);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
type ProjectDbClient = {
|
|
2
|
+
from: (table: string) => any;
|
|
3
|
+
};
|
|
4
|
+
export declare function renderTemplateString(input: string, vars?: Record<string, string>): string;
|
|
5
|
+
export declare function resolveSurveyShortLink(sb: ProjectDbClient, projectId: string, surveyId: string): Promise<string | null>;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function renderTemplateString(input, vars = {}) {
|
|
2
|
+
return String(input ?? '').replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
|
|
3
|
+
}
|
|
4
|
+
function appBaseUrl() {
|
|
5
|
+
return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
6
|
+
}
|
|
7
|
+
export async function resolveSurveyShortLink(sb, projectId, surveyId) {
|
|
8
|
+
const { data: survey } = await sb
|
|
9
|
+
.from('surveys')
|
|
10
|
+
.select('id, short_link_id')
|
|
11
|
+
.eq('id', surveyId)
|
|
12
|
+
.eq('project_id', projectId)
|
|
13
|
+
.maybeSingle();
|
|
14
|
+
if (!survey?.short_link_id)
|
|
15
|
+
return null;
|
|
16
|
+
const { data: link } = await sb
|
|
17
|
+
.from('short_links')
|
|
18
|
+
.select('id, slug, project_id')
|
|
19
|
+
.eq('id', survey.short_link_id)
|
|
20
|
+
.eq('project_id', projectId)
|
|
21
|
+
.maybeSingle();
|
|
22
|
+
return link?.slug ? `${appBaseUrl()}/s/${link.slug}` : null;
|
|
23
|
+
}
|