Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83507af6ad | ||
|
|
32dd7bba46 | ||
|
|
71e2277643 | ||
|
|
079e1879b3 | ||
|
|
4de40f40fa | ||
|
|
94ceadbbe4 | ||
|
|
9797aed47f | ||
|
|
8b3657d056 | ||
|
|
01ec34a8cb | ||
|
|
ba3813e242 | ||
|
|
283f40239d | ||
|
|
53b631e6c6 | ||
|
|
6759bffd2a | ||
|
|
d2496bc51d | ||
|
|
bfecf04fa3 | ||
|
|
6d98d51222 | ||
|
|
c484da88ab | ||
|
|
6aee5db588 | ||
|
|
3f30a48c40 |
@@ -65,6 +65,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Tune Postgres for ephemeral CI workload
|
||||
env:
|
||||
PGPASSWORD: postgres
|
||||
run: |
|
||||
# synchronous_commit=off is the biggest single I/O win and is safe to lose
|
||||
# data on crash for a throwaway CI database.
|
||||
# synchronous_commit is dynamic — applies on reload. max_connections would
|
||||
# require a restart, so we leave it at the default of 100 and cap workers
|
||||
# at 4 × connection_limit=20 = 80 to stay under that budget.
|
||||
psql -h localhost -U postgres -d plunk_test -c "ALTER SYSTEM SET synchronous_commit = 'off';"
|
||||
psql -h localhost -U postgres -d plunk_test -c "SELECT pg_reload_conf();"
|
||||
|
||||
- name: Setup environment variables
|
||||
run: |
|
||||
cat > .env << EOF
|
||||
|
||||
@@ -39,10 +39,9 @@
|
||||
"mailparser": "^3.9.8",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"sanitize-html": "^2.17.4",
|
||||
"signale": "^1.4.0",
|
||||
"stripe": "^20.0.0",
|
||||
"tldts": "^7.0.30"
|
||||
"stripe": "^20.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
|
||||
@@ -374,14 +374,13 @@ describe('Domain Verification and Ownership Tests', () => {
|
||||
// EDGE CASES
|
||||
// ========================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should canonicalize mixed-case domain names to lowercase', async () => {
|
||||
it('should handle case-sensitive domain names', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
// DNS is case-insensitive — domains must be stored canonically so a tenant
|
||||
// can't claim "Example.com" while another project owns "example.com".
|
||||
// Domains are typically case-insensitive in DNS, but stored as-is in DB
|
||||
const domain1 = await DomainService.addDomain(project.id, 'Example.com');
|
||||
|
||||
expect(domain1.domain).toBe('example.com');
|
||||
expect(domain1.domain).toBe('Example.com');
|
||||
});
|
||||
|
||||
it('should handle subdomain vs root domain', async () => {
|
||||
|
||||
@@ -321,12 +321,9 @@ export class Auth {
|
||||
data: {password: hashedPassword},
|
||||
});
|
||||
|
||||
// Delete token and invalidate cache (id + email projections both cache the password hash)
|
||||
// Delete token and invalidate cache
|
||||
await redis.del(Keys.User.passwordResetToken(token));
|
||||
await redis.del(Keys.User.id(userId));
|
||||
if (user.email) {
|
||||
await redis.del(Keys.User.email(user.email));
|
||||
}
|
||||
|
||||
return res.json({success: true, data: {message: 'Password reset successfully'}});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {redis} from '../database/redis.js';
|
||||
import {NotAllowed, NotFound} from '../exceptions/index.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
@@ -17,16 +17,12 @@ export class Domains {
|
||||
* Get all domains for a project
|
||||
*/
|
||||
@Get('project/:projectId')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
|
||||
public async getProjectDomains(_req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, projectId);
|
||||
|
||||
const domains = await DomainService.getProjectDomains(projectId);
|
||||
const domains = await DomainService.getProjectDomains(auth.projectId!);
|
||||
|
||||
return res.status(200).json(domains);
|
||||
}
|
||||
@@ -35,19 +31,18 @@ export class Domains {
|
||||
* Add a new domain to a project
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {projectId, domain} = DomainSchemas.create.parse(req.body);
|
||||
const {domain} = DomainSchemas.create.parse(req.body);
|
||||
const projectId = auth.projectId!;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotFound('User authentication required');
|
||||
// Require admin role for JWT users (API keys bypass — project-scoped by design)
|
||||
if (auth.type === 'jwt') {
|
||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||
}
|
||||
|
||||
// Verify user has admin access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||
|
||||
// Block domain changes on disabled projects
|
||||
const isDisabled = await SecurityService.isProjectDisabled(projectId);
|
||||
if (isDisabled) {
|
||||
@@ -68,6 +63,12 @@ export class Domains {
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
||||
|
||||
if (ownershipCheck.exists) {
|
||||
if (ownershipCheck.projectId === projectId) {
|
||||
return res.status(400).json({
|
||||
error: 'This domain is already linked to this project.',
|
||||
});
|
||||
}
|
||||
|
||||
// If domain exists and user is a member of that project, allow it
|
||||
if (ownershipCheck.isMember) {
|
||||
return res.status(400).json({
|
||||
@@ -99,7 +100,7 @@ export class Domains {
|
||||
* Check verification status for a domain
|
||||
*/
|
||||
@Get(':id/verify')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
@@ -107,13 +108,10 @@ export class Domains {
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
|
||||
if (!domain) {
|
||||
if (!domain || domain.projectId !== auth.projectId) {
|
||||
throw new NotFound('Domain not found');
|
||||
}
|
||||
|
||||
// Verify user has access to the project this domain belongs to
|
||||
await MembershipService.requireAccess(auth.userId!, domain.projectId);
|
||||
|
||||
const verificationStatus = await DomainService.checkVerification(id);
|
||||
|
||||
// Invalidate cache if status changed
|
||||
@@ -127,7 +125,7 @@ export class Domains {
|
||||
* Remove a domain from a project
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
@@ -135,12 +133,14 @@ export class Domains {
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
|
||||
if (!domain) {
|
||||
if (!domain || domain.projectId !== auth.projectId) {
|
||||
throw new NotFound('Domain not found');
|
||||
}
|
||||
|
||||
// Verify user has admin access to the project this domain belongs to
|
||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||
// Require admin role for JWT users (API keys bypass — project-scoped by design)
|
||||
if (auth.type === 'jwt') {
|
||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||
}
|
||||
|
||||
// Block domain changes on disabled projects
|
||||
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {ProjectService} from '../services/ProjectService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -118,8 +117,6 @@ export class Users {
|
||||
data,
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(id, [{public: project.public, secret: project.secret}]);
|
||||
|
||||
return res.status(200).json(project);
|
||||
}
|
||||
|
||||
@@ -133,12 +130,6 @@ export class Users {
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Capture the existing keys so we can drop them from cache after rotation
|
||||
const previousProject = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {public: true, secret: true},
|
||||
});
|
||||
|
||||
// Generate new unique API keys
|
||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
|
||||
@@ -163,13 +154,6 @@ export class Users {
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cached lookups for both old and new keys so revoked keys
|
||||
// stop authorizing requests immediately instead of after cache TTL.
|
||||
await ProjectService.invalidate(id, [
|
||||
{public: previousProject?.public, secret: previousProject?.secret},
|
||||
{public: project.public, secret: project.secret},
|
||||
]);
|
||||
|
||||
// Send notification about API key regeneration
|
||||
await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {EventService} from '../services/EventService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {ProjectService} from '../services/ProjectService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -531,10 +530,6 @@ export class Webhooks {
|
||||
},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(projectId, [
|
||||
{public: updatedProject.public, secret: updatedProject.secret},
|
||||
]);
|
||||
|
||||
// Base onboarding credit: refund the 1-unit card-verification charge
|
||||
let creditBalance = -100;
|
||||
|
||||
@@ -578,6 +573,16 @@ export class Webhooks {
|
||||
|
||||
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
|
||||
|
||||
// Re-enable the project only if it was previously disabled for a failed payment.
|
||||
// Projects disabled for other reasons (reputation, phishing, manual) must stay disabled.
|
||||
if (project.disabled && project.disabledReason === 'PAYMENT_FAILED') {
|
||||
await prisma.project.update({
|
||||
where: {id: project.id},
|
||||
data: {disabled: false, disabledReason: null},
|
||||
});
|
||||
signale.success(`[WEBHOOK] Project ${project.name} (${project.id}) re-enabled after payment`);
|
||||
}
|
||||
|
||||
// Send notification about invoice payment
|
||||
await NtfyService.notifyInvoicePaid(project.name, project.id);
|
||||
break;
|
||||
@@ -611,11 +616,9 @@ export class Webhooks {
|
||||
|
||||
await prisma.project.update({
|
||||
where: {id: project.id},
|
||||
data: {disabled: true},
|
||||
data: {disabled: true, disabledReason: 'PAYMENT_FAILED'},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(project.id, [{public: project.public, secret: project.secret}]);
|
||||
|
||||
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
|
||||
|
||||
// Send email notification to project members
|
||||
@@ -661,8 +664,6 @@ export class Webhooks {
|
||||
},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(project.id, [{public: project.public, secret: project.secret}]);
|
||||
|
||||
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
|
||||
|
||||
// Send notification about subscription cancellation
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
import {getDomain as getRegistrableDomain} from 'tldts';
|
||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -17,14 +16,6 @@ import {
|
||||
} from './SESService.js';
|
||||
|
||||
export class DomainService {
|
||||
/**
|
||||
* Canonicalize a domain name for storage and comparison.
|
||||
* DNS is case-insensitive and a trailing dot represents the same name.
|
||||
*/
|
||||
public static canonicalize(domain: string): string {
|
||||
return domain.trim().toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a domain by ID
|
||||
*/
|
||||
@@ -50,16 +41,14 @@ export class DomainService {
|
||||
* Add a new domain to a project and start verification
|
||||
*/
|
||||
public static async addDomain(projectId: string, domain: string) {
|
||||
const canonical = this.canonicalize(domain);
|
||||
|
||||
// Start verification process with AWS SES
|
||||
const dkimTokens = await verifyDomain(canonical);
|
||||
const dkimTokens = await verifyDomain(domain);
|
||||
|
||||
// Create domain record
|
||||
const newDomain = await prisma.domain.create({
|
||||
data: {
|
||||
projectId,
|
||||
domain: canonical,
|
||||
domain,
|
||||
verified: false,
|
||||
dkimTokens,
|
||||
},
|
||||
@@ -71,7 +60,7 @@ export class DomainService {
|
||||
});
|
||||
|
||||
// Send notification about domain added
|
||||
await NtfyService.notifyDomainAdded(canonical, newDomain.project.name, projectId);
|
||||
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId);
|
||||
|
||||
return newDomain;
|
||||
}
|
||||
@@ -364,7 +353,7 @@ export class DomainService {
|
||||
throw new HttpException(400, 'Invalid email format');
|
||||
}
|
||||
|
||||
const domainName = this.canonicalize(emailParts[1] ?? '');
|
||||
const domainName = emailParts[1];
|
||||
|
||||
// Find domain in database
|
||||
const domain = await prisma.domain.findFirst({
|
||||
@@ -400,11 +389,12 @@ export class DomainService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the registrable root domain from a domain name using the Public Suffix List.
|
||||
* e.g. "mail.example.com" → "example.com", "mail.example.co.uk" → "example.co.uk"
|
||||
* Extract the registrable root domain (last two labels) from a domain name.
|
||||
* e.g. "mail.example.com" → "example.com", "example.com" → "example.com"
|
||||
*/
|
||||
private static rootDomain(domain: string): string {
|
||||
return getRegistrableDomain(domain) ?? domain;
|
||||
const parts = domain.split('.');
|
||||
return parts.length > 2 ? parts.slice(-2).join('.') : domain;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,11 +404,10 @@ export class DomainService {
|
||||
public static async checkSubdomainOfDisabledRoot(
|
||||
domain: string,
|
||||
): Promise<{blocked: boolean; projectName?: string; projectId?: string}> {
|
||||
const canonical = this.canonicalize(domain);
|
||||
const root = this.rootDomain(canonical);
|
||||
const root = this.rootDomain(domain);
|
||||
|
||||
// Only relevant when the submitted domain is actually a subdomain
|
||||
if (root === canonical) {
|
||||
if (root === domain) {
|
||||
return {blocked: false};
|
||||
}
|
||||
|
||||
@@ -449,16 +438,14 @@ export class DomainService {
|
||||
* @param userId User ID to check membership
|
||||
* @returns Object with exists flag and membership info
|
||||
*/
|
||||
public static async checkDomainOwnership(domain: string, userId: string) {
|
||||
const canonical = this.canonicalize(domain);
|
||||
public static async checkDomainOwnership(domain: string, userId?: string) {
|
||||
const existingDomain = await prisma.domain.findFirst({
|
||||
where: {domain: canonical},
|
||||
where: {domain},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
members: {
|
||||
where: {userId},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -468,8 +455,20 @@ export class DomainService {
|
||||
return {exists: false};
|
||||
}
|
||||
|
||||
// Check if user is a member of the project that owns this domain
|
||||
const isMember = existingDomain.project.members.length > 0;
|
||||
let isMember = false;
|
||||
|
||||
if (userId) {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: existingDomain.project.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
isMember = membership !== null;
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
|
||||
@@ -659,12 +659,16 @@ export class EmailService {
|
||||
/**
|
||||
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
|
||||
*
|
||||
* The TipTap editor loads StarterKit + TextAlign + Color + TextStyle + Link +
|
||||
* ResizableImage + VariableMention. TextStyle/Color/Link round-trip <span style="..."> and
|
||||
* <a style="..."> markup. This detection therefore PERMITS span + inline styles and only
|
||||
* REJECTS markup TipTap cannot represent (tables, divs, forms, embeds, custom attrs,
|
||||
* <style> blocks, etc).
|
||||
*/
|
||||
private static detectCustomHtmlPatterns(html: string): boolean {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
@@ -679,21 +683,21 @@ export class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
// Element-attribute-scoped regex; the leading [\s"'] guard prevents `id=` inside
|
||||
// href URLs (e.g. `?id=...`) from false-matching as an HTML id attribute.
|
||||
const hasCustomAttributes = /<[a-z][^>]*?[\s"'](?:data-|aria-|role=|id=)/i.test(html);
|
||||
|
||||
// Elements TipTap cannot round-trip with the currently-loaded extension set.
|
||||
// <span> is intentionally excluded -- TipTap's TextStyle extension handles it.
|
||||
const hasCustomElements =
|
||||
/<(?:div|section|article|header|footer|nav|aside|main|table|tr|td|th|tbody|thead|tfoot|colgroup|col|form|input|button|select|textarea|iframe|video|audio|svg|object|embed|details|summary|dialog)\b/i.test(
|
||||
html,
|
||||
);
|
||||
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import signale from 'signale';
|
||||
|
||||
import {Keys} from './keys.js';
|
||||
import {redis, wrapRedis} from '../database/redis.js';
|
||||
import {wrapRedis} from '../database/redis.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
|
||||
export class ProjectService {
|
||||
@@ -30,28 +28,4 @@ export class ProjectService {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached project lookups (id + secret/public keys).
|
||||
* Must be called whenever a project's API keys, `disabled` flag, or other
|
||||
* auth-affecting fields change, otherwise stale records can keep
|
||||
* revoked keys or just-disabled projects authorized until cache TTL.
|
||||
*
|
||||
* Accepts the previous key values too, so rotated keys are also dropped.
|
||||
*/
|
||||
public static async invalidate(
|
||||
projectId: string,
|
||||
keys?: {secret?: string | null; public?: string | null}[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cacheKeys = new Set<string>([Keys.Project.id(projectId)]);
|
||||
for (const k of keys ?? []) {
|
||||
if (k.secret) cacheKeys.add(Keys.Project.secret(k.secret));
|
||||
if (k.public) cacheKeys.add(Keys.Project.public(k.public));
|
||||
}
|
||||
await Promise.all([...cacheKeys].map(key => redis.del(key)));
|
||||
} catch (error) {
|
||||
signale.warn(`[PROJECT] Failed to invalidate cache for ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {ProjectService} from './ProjectService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {
|
||||
AUTO_PROJECT_DISABLE,
|
||||
@@ -51,22 +50,13 @@ const SECURITY_THRESHOLDS = {
|
||||
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||
|
||||
// === Absolute count ceilings ===
|
||||
// These trigger regardless of rate — catches high-volume spammers who dilute their bounce rate
|
||||
// 24-hour absolute ceilings
|
||||
BOUNCE_24H_CEILING_WARNING: 50,
|
||||
BOUNCE_24H_CEILING_CRITICAL: 100,
|
||||
COMPLAINT_24H_CEILING_WARNING: 10,
|
||||
COMPLAINT_24H_CEILING_CRITICAL: 25,
|
||||
|
||||
// 7-day absolute ceilings
|
||||
BOUNCE_7DAY_CEILING_WARNING: 200,
|
||||
BOUNCE_7DAY_CEILING_CRITICAL: 500,
|
||||
COMPLAINT_7DAY_CEILING_WARNING: 30,
|
||||
COMPLAINT_7DAY_CEILING_CRITICAL: 75,
|
||||
|
||||
// === New project thresholds (projects < 30 days old) ===
|
||||
// Legitimate senders ramp up gradually; spammers blast immediately
|
||||
// === Absolute count ceilings (new projects only) ===
|
||||
// These trigger regardless of rate — catches new accounts blasting emails
|
||||
// before their bounce rate has caught up. Established projects rely on
|
||||
// rate-based checks only, since high absolute counts at high volume
|
||||
// (e.g. 100 bounces out of 10K) don't indicate abuse.
|
||||
//
|
||||
// Legitimate senders ramp up gradually; spammers blast immediately.
|
||||
NEW_PROJECT_AGE_DAYS: 30,
|
||||
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
|
||||
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
|
||||
@@ -517,82 +507,54 @@ export class SecurityService {
|
||||
const violations: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Pick absolute count ceilings based on project age
|
||||
const bounceCeilings = isNewProject
|
||||
? {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL,
|
||||
}
|
||||
: {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_CRITICAL,
|
||||
};
|
||||
// === Absolute count ceiling checks (new projects only, rate-independent) ===
|
||||
// Catches new accounts blasting emails before their bounce rate catches up.
|
||||
// Established projects skip these — high absolute counts at high volume
|
||||
// (e.g. 100 bounces out of 10K) don't indicate abuse; rate checks handle them.
|
||||
if (isNewProject) {
|
||||
// 24-hour bounce ceilings
|
||||
if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL) {
|
||||
violations.push(
|
||||
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL})`,
|
||||
);
|
||||
} else if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING) {
|
||||
warnings.push(
|
||||
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING})`,
|
||||
);
|
||||
}
|
||||
|
||||
const complaintCeilings = isNewProject
|
||||
? {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL,
|
||||
}
|
||||
: {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_CRITICAL,
|
||||
};
|
||||
// 7-day bounce ceilings
|
||||
if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL) {
|
||||
violations.push(
|
||||
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL})`,
|
||||
);
|
||||
} else if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING) {
|
||||
warnings.push(
|
||||
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING})`,
|
||||
);
|
||||
}
|
||||
|
||||
const projectLabel = isNewProject ? ' (new project)' : '';
|
||||
// 24-hour complaint ceilings
|
||||
if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL) {
|
||||
violations.push(
|
||||
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL})`,
|
||||
);
|
||||
} else if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING) {
|
||||
warnings.push(
|
||||
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING})`,
|
||||
);
|
||||
}
|
||||
|
||||
// === Absolute count ceiling checks (rate-independent) ===
|
||||
// These catch high-volume spammers who dilute their bounce rate by blasting emails
|
||||
|
||||
// 24-hour bounce ceilings
|
||||
if (twentyFourHour.bounces >= bounceCeilings.ceiling24hCritical) {
|
||||
violations.push(
|
||||
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling24hCritical})`,
|
||||
);
|
||||
} else if (twentyFourHour.bounces >= bounceCeilings.ceiling24hWarning) {
|
||||
warnings.push(
|
||||
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling24hWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7-day bounce ceilings
|
||||
if (sevenDay.bounces >= bounceCeilings.ceiling7dCritical) {
|
||||
violations.push(
|
||||
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling7dCritical})`,
|
||||
);
|
||||
} else if (sevenDay.bounces >= bounceCeilings.ceiling7dWarning) {
|
||||
warnings.push(
|
||||
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling7dWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 24-hour complaint ceilings
|
||||
if (twentyFourHour.complaints >= complaintCeilings.ceiling24hCritical) {
|
||||
violations.push(
|
||||
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling24hCritical})`,
|
||||
);
|
||||
} else if (twentyFourHour.complaints >= complaintCeilings.ceiling24hWarning) {
|
||||
warnings.push(
|
||||
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling24hWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7-day complaint ceilings
|
||||
if (sevenDay.complaints >= complaintCeilings.ceiling7dCritical) {
|
||||
violations.push(
|
||||
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling7dCritical})`,
|
||||
);
|
||||
} else if (sevenDay.complaints >= complaintCeilings.ceiling7dWarning) {
|
||||
warnings.push(
|
||||
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling7dWarning})`,
|
||||
);
|
||||
// 7-day complaint ceilings
|
||||
if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL) {
|
||||
violations.push(
|
||||
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL})`,
|
||||
);
|
||||
} else if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING) {
|
||||
warnings.push(
|
||||
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Rate-based checks (existing logic) ===
|
||||
@@ -712,14 +674,11 @@ export class SecurityService {
|
||||
}
|
||||
|
||||
// Disable the project
|
||||
const disabled = await prisma.project.update({
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {disabled: true},
|
||||
select: {public: true, secret: true},
|
||||
data: {disabled: true, disabledReason: 'EMAIL_REPUTATION'},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(projectId, [{public: disabled.public, secret: disabled.secret}]);
|
||||
|
||||
// Log critical security event
|
||||
signale.error(
|
||||
`[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`,
|
||||
@@ -973,14 +932,11 @@ ${strippedBody.substring(0, 2000)}`,
|
||||
}
|
||||
|
||||
// Disable the project
|
||||
const disabled = await prisma.project.update({
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {disabled: true},
|
||||
select: {public: true, secret: true},
|
||||
data: {disabled: true, disabledReason: 'PHISHING_DETECTED'},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(projectId, [{public: disabled.public, secret: disabled.secret}]);
|
||||
|
||||
const violation = `A policy violation was detected. Please contact support for more details.`;
|
||||
|
||||
// Log critical security event
|
||||
|
||||
@@ -908,7 +908,14 @@ export class WorkflowExecutionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* WEBHOOK step - Call an external webhook
|
||||
* WEBHOOK step - Call an external webhook.
|
||||
*
|
||||
* Renders `{{vars}}` in `url`, header values, and `body`. The variable
|
||||
* scope is a superset of the SEND_EMAIL scope: id, email, contact data,
|
||||
* execution context, and subscribe/unsubscribe/manage URLs — plus a
|
||||
* webhook-only `event` namespace exposing the trigger event payload.
|
||||
* `method` is intentionally NOT rendered — it must remain a literal
|
||||
* HTTP verb.
|
||||
*/
|
||||
private static async executeWebhook(
|
||||
_step: WorkflowStep,
|
||||
@@ -924,9 +931,37 @@ export class WorkflowExecutionService {
|
||||
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)
|
||||
? (contact.data as Record<string, unknown>)
|
||||
: {};
|
||||
const executionContext =
|
||||
execution.context && typeof execution.context === 'object' && !Array.isArray(execution.context)
|
||||
? (execution.context as Record<string, unknown>)
|
||||
: {};
|
||||
const context = execution.context || {};
|
||||
|
||||
const payload = body || {
|
||||
// Render scope: SEND_EMAIL's scope (id, email, contact data, execution
|
||||
// context, subscribe/unsubscribe/manage URLs) plus a webhook-only
|
||||
// `event` namespace carrying the trigger event payload. `method` is
|
||||
// intentionally NOT rendered — it must remain a literal HTTP verb.
|
||||
const variables = {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
...contactData,
|
||||
...executionContext,
|
||||
data: contactData,
|
||||
event: context,
|
||||
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
||||
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
|
||||
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
|
||||
};
|
||||
|
||||
const renderedUrl = this.renderTemplate(url, variables);
|
||||
const renderedHeaders = headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, this.renderTemplate(value, variables)]),
|
||||
)
|
||||
: undefined;
|
||||
const renderedBody = body ? this.renderJsonTemplate(body, variables) : undefined;
|
||||
|
||||
const payload = renderedBody || {
|
||||
contact: {
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
@@ -944,19 +979,16 @@ export class WorkflowExecutionService {
|
||||
};
|
||||
|
||||
// Make HTTP request
|
||||
const response = await WorkflowExecutionService.safeFetch(url, {
|
||||
const response = await WorkflowExecutionService.safeFetch(renderedUrl, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
...renderedHeaders,
|
||||
},
|
||||
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
|
||||
const {body: responseData, truncated} = await WorkflowExecutionService.readBodyCapped(
|
||||
response,
|
||||
WorkflowExecutionService.WEBHOOK_RESPONSE_MAX_BYTES,
|
||||
);
|
||||
const responseData = await response.text();
|
||||
let parsedResponse;
|
||||
try {
|
||||
parsedResponse = JSON.parse(responseData);
|
||||
@@ -965,68 +997,34 @@ export class WorkflowExecutionService {
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
url: renderedUrl,
|
||||
method,
|
||||
statusCode: response.status,
|
||||
success: response.ok,
|
||||
response: parsedResponse,
|
||||
...(truncated ? {truncated: true} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly WEBHOOK_RESPONSE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Read a fetch Response body up to a maximum number of bytes.
|
||||
* Aborts further reading once the cap is reached so a malicious server
|
||||
* cannot exhaust worker memory.
|
||||
* Helper: Recursively render template variables in any JSON-shaped value.
|
||||
* Strings are rendered, arrays/objects are walked, and non-string scalars
|
||||
* (numbers, booleans, null) are returned untouched.
|
||||
*/
|
||||
private static async readBodyCapped(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<{body: string; truncated: boolean}> {
|
||||
if (!response.body) {
|
||||
return {body: '', truncated: false};
|
||||
private static renderJsonTemplate(value: unknown, variables: Record<string, unknown>): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return this.renderTemplate(value, variables);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
let truncated = false;
|
||||
|
||||
try {
|
||||
while (received < maxBytes) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
const remaining = maxBytes - received;
|
||||
if (value.byteLength > remaining) {
|
||||
chunks.push(value.subarray(0, remaining));
|
||||
received += remaining;
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
chunks.push(value);
|
||||
received += value.byteLength;
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => this.renderJsonTemplate(item, variables));
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
result[key] = this.renderJsonTemplate(child, variables);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const merged = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return {body: new TextDecoder().decode(merged), truncated};
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,27 +50,20 @@ describe('SecurityService', () => {
|
||||
const complainedCount = opts?.complainedCount ?? 0;
|
||||
const createdAt = opts?.createdAt ?? new Date();
|
||||
|
||||
const emails = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
emails.push(
|
||||
prisma.email.create({
|
||||
data: {
|
||||
projectId,
|
||||
contactId,
|
||||
subject: `Test ${i}`,
|
||||
body: '<p>test</p>',
|
||||
from: 'test@example.com',
|
||||
status: EmailStatus.SENT,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
sentAt: createdAt,
|
||||
createdAt,
|
||||
bouncedAt: i < bouncedCount ? createdAt : null,
|
||||
complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(emails);
|
||||
const data = Array.from({length: count}, (_, i) => ({
|
||||
projectId,
|
||||
contactId,
|
||||
subject: `Test ${i}`,
|
||||
body: '<p>test</p>',
|
||||
from: 'test@example.com',
|
||||
status: EmailStatus.SENT,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
sentAt: createdAt,
|
||||
createdAt,
|
||||
bouncedAt: i < bouncedCount ? createdAt : null,
|
||||
complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null,
|
||||
}));
|
||||
await prisma.email.createMany({data});
|
||||
}
|
||||
|
||||
describe('Rate-based checks (existing behavior)', () => {
|
||||
@@ -109,14 +102,12 @@ describe('SecurityService', () => {
|
||||
await createEmails(50, {bouncedCount: 10});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
// Rate-based check doesn't trigger, but absolute count ceiling might
|
||||
// With 10 bounces in 24h, this is below the 50-bounce ceiling for established projects
|
||||
expect(status.violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Absolute count ceilings (established projects)', () => {
|
||||
// Age the project past the new-project window so standard ceilings apply
|
||||
describe('Established projects skip absolute ceilings', () => {
|
||||
// Age the project past the new-project window
|
||||
beforeEach(async () => {
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
@@ -125,45 +116,27 @@ describe('SecurityService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should trigger critical when 24-hour bounce count exceeds ceiling', async () => {
|
||||
// 20,000 emails, 101 bounces = 0.5% rate (well below rate threshold)
|
||||
// But 101 bounces > 100 (24h critical ceiling for established projects)
|
||||
await createEmails(20000, {bouncedCount: 101});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.shouldDisable).toBe(true);
|
||||
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger warning when 24-hour bounce count exceeds warning ceiling', async () => {
|
||||
// 10,000 emails, 51 bounces = 0.51% (below rate threshold)
|
||||
// But 51 > 50 (24h warning ceiling), below 100 critical
|
||||
await createEmails(10000, {bouncedCount: 51});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isHealthy).toBe(true); // warnings don't make it unhealthy
|
||||
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger critical when 24-hour complaint count exceeds ceiling', async () => {
|
||||
// 20,000 emails, 26 complaints = 0.13% (below complaint rate critical of 0.15%)
|
||||
// But 26 > 25 (24h complaint critical ceiling)
|
||||
await createEmails(20000, {complainedCount: 26});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.shouldDisable).toBe(true);
|
||||
expect(status.violations.some(v => v.includes('24-hour complaint count'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT trigger ceiling when bounce count is below ceiling', async () => {
|
||||
// 20,000 emails, 40 bounces = below 50 warning ceiling for established projects
|
||||
await createEmails(20000, {bouncedCount: 40});
|
||||
it('should NOT trigger on high absolute bounce count when rate is healthy', async () => {
|
||||
// 20,000 emails, 200 bounces = 1% rate (well below rate threshold)
|
||||
// Established projects rely solely on rates — high absolute counts at
|
||||
// high volume don't indicate abuse.
|
||||
await createEmails(20000, {bouncedCount: 200});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isHealthy).toBe(true);
|
||||
expect(status.shouldDisable).toBe(false);
|
||||
expect(status.violations).toHaveLength(0);
|
||||
expect(status.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should NOT trigger on high absolute complaint count when rate is healthy', async () => {
|
||||
// 100,000 emails, 30 complaints = 0.03% (at warning floor, below critical 0.15%)
|
||||
// Old absolute ceiling (25 complaints in 7d critical) would have tripped.
|
||||
await createEmails(100000, {complainedCount: 30});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.shouldDisable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('New project stricter thresholds', () => {
|
||||
@@ -178,7 +151,7 @@ describe('SecurityService', () => {
|
||||
expect(status.violations.some(v => v.includes('new project'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply standard ceilings for projects over 30 days old', async () => {
|
||||
it('should NOT apply absolute ceilings for projects over 30 days old', async () => {
|
||||
// Age the project to 31 days
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
@@ -186,15 +159,14 @@ describe('SecurityService', () => {
|
||||
data: {createdAt: oldDate},
|
||||
});
|
||||
|
||||
// 10,000 emails, 26 bounces (above 25 new project ceiling, below 50 standard warning ceiling)
|
||||
// 10,000 emails, 26 bounces — would trip new-project ceiling, but
|
||||
// established projects skip ceilings entirely (rate is 0.26%, healthy).
|
||||
await createEmails(10000, {bouncedCount: 26});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isNewProject).toBe(false);
|
||||
// 26 is below the 50-bounce 24h warning ceiling for established projects
|
||||
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(false);
|
||||
// And below the 100-bounce 24h critical ceiling
|
||||
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(false);
|
||||
expect(status.warnings.some(w => w.includes('bounce count'))).toBe(false);
|
||||
expect(status.violations.some(v => v.includes('bounce count'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should catch new project blasting emails with delayed bounces', async () => {
|
||||
@@ -212,8 +184,8 @@ describe('SecurityService', () => {
|
||||
|
||||
describe('checkAndEnforceSecurityLimits', () => {
|
||||
it('should disable project when critical thresholds are exceeded', async () => {
|
||||
// Create enough bounces to trigger critical
|
||||
await createEmails(20000, {bouncedCount: 101});
|
||||
// New project, 20K emails with 30 bounces — exceeds new project 24h critical ceiling
|
||||
await createEmails(20000, {bouncedCount: 30});
|
||||
|
||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
||||
|
||||
@@ -225,13 +197,13 @@ describe('SecurityService', () => {
|
||||
});
|
||||
|
||||
it('should NOT disable project when only warnings exist', async () => {
|
||||
// 10,000 emails, 51 bounces (above warning but below critical for established project)
|
||||
// Established project, 200 emails, 12 bounces = 6% (above 5% warning, below 10% critical)
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {createdAt: oldDate},
|
||||
});
|
||||
await createEmails(10000, {bouncedCount: 51});
|
||||
await createEmails(200, {bouncedCount: 12});
|
||||
|
||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
||||
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
/**
|
||||
* Tests for WEBHOOK step config templating.
|
||||
*
|
||||
* `executeWebhook` is a private static method but is invokable at runtime
|
||||
* through a `as any` cast. We mock `safeFetch` (also private) via the
|
||||
* same mechanism so we can capture the rendered request without making a
|
||||
* real network call.
|
||||
*/
|
||||
describe('WorkflowExecutionService.executeWebhook templating', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Capture (url, options) passed to safeFetch
|
||||
let safeFetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
let captured: {url: string; options: RequestInit} | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
|
||||
captured = null;
|
||||
safeFetchSpy = vi
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.spyOn(WorkflowExecutionService as any, 'safeFetch')
|
||||
.mockImplementation(async (...args: unknown[]) => {
|
||||
const [url, options] = args as [string, RequestInit];
|
||||
captured = {url, options};
|
||||
return new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
safeFetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: build a workflow with a single WEBHOOK step using the given
|
||||
* config, plus a contact and a RUNNING execution. Returns the args
|
||||
* shape `executeWebhook` expects.
|
||||
*/
|
||||
async function setup(
|
||||
webhookConfig: Record<string, unknown>,
|
||||
contactOverrides: {data?: Record<string, unknown>} = {},
|
||||
executionContext: Record<string, unknown> = {},
|
||||
) {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: contactOverrides.data,
|
||||
});
|
||||
|
||||
const workflow = await factories.createWorkflow({
|
||||
projectId,
|
||||
enabled: true,
|
||||
triggerType: WorkflowTriggerType.EVENT,
|
||||
triggerConfig: {eventName: 'test.event'},
|
||||
});
|
||||
|
||||
const step = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.WEBHOOK,
|
||||
name: 'Webhook',
|
||||
position: {x: 0, y: 0},
|
||||
config: webhookConfig,
|
||||
},
|
||||
});
|
||||
|
||||
const execution = await prisma.workflowExecution.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
context: executionContext,
|
||||
},
|
||||
include: {contact: true, workflow: true},
|
||||
});
|
||||
|
||||
const stepExecution = await prisma.workflowStepExecution.create({
|
||||
data: {
|
||||
executionId: execution.id,
|
||||
stepId: step.id,
|
||||
status: StepExecutionStatus.RUNNING,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {step, execution, stepExecution};
|
||||
}
|
||||
|
||||
async function invokeWebhook(
|
||||
step: unknown,
|
||||
execution: unknown,
|
||||
stepExecution: unknown,
|
||||
config: unknown,
|
||||
) {
|
||||
// Call through `as any` because executeWebhook is private at the
|
||||
// TypeScript level. JS has no actual access control.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (WorkflowExecutionService as any).executeWebhook(step, execution, stepExecution, config);
|
||||
}
|
||||
|
||||
it('renders {{vars}} in the URL from contact.data', async () => {
|
||||
const {step, execution, stepExecution} = await setup(
|
||||
{
|
||||
url: 'https://example.com/api/users/{{userId}}',
|
||||
method: 'GET',
|
||||
},
|
||||
{data: {userId: 'abc-123'}},
|
||||
);
|
||||
|
||||
await invokeWebhook(step, execution, stepExecution, step.config);
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.url).toBe('https://example.com/api/users/abc-123');
|
||||
});
|
||||
|
||||
it('renders {{vars}} in header values', async () => {
|
||||
const {step, execution, stepExecution} = await setup(
|
||||
{
|
||||
url: 'https://example.com/hook',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer {{apiToken}}',
|
||||
'X-Static': 'literal',
|
||||
},
|
||||
},
|
||||
{data: {apiToken: 'secret-token-xyz'}},
|
||||
);
|
||||
|
||||
await invokeWebhook(step, execution, stepExecution, step.config);
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
const headers = captured!.options.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer secret-token-xyz');
|
||||
expect(headers['X-Static']).toBe('literal');
|
||||
});
|
||||
|
||||
it('renders {{vars}} in nested object body leaves and JSON-encodes', async () => {
|
||||
const {step, execution, stepExecution} = await setup(
|
||||
{
|
||||
url: 'https://example.com/hook',
|
||||
method: 'POST',
|
||||
body: {
|
||||
user: {
|
||||
email: '{{email}}',
|
||||
name: '{{firstName}}',
|
||||
},
|
||||
ref: 'literal-ref',
|
||||
tags: ['plan:{{plan}}', 'static'],
|
||||
},
|
||||
},
|
||||
{data: {firstName: 'Ada', plan: 'gold'}},
|
||||
{campaignId: 'camp-9'},
|
||||
);
|
||||
|
||||
await invokeWebhook(step, execution, stepExecution, step.config);
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
const body = JSON.parse(captured!.options.body as string);
|
||||
expect(body.user.email).toBe(execution.contact.email);
|
||||
expect(body.user.name).toBe('Ada');
|
||||
expect(body.ref).toBe('literal-ref');
|
||||
expect(body.tags).toEqual(['plan:gold', 'static']);
|
||||
});
|
||||
|
||||
it('leaves non-string body leaves untouched', async () => {
|
||||
const {step, execution, stepExecution} = await setup({
|
||||
url: 'https://example.com/hook',
|
||||
method: 'POST',
|
||||
body: {
|
||||
score: 42,
|
||||
active: true,
|
||||
deleted: null,
|
||||
meta: {
|
||||
count: 7,
|
||||
enabled: false,
|
||||
},
|
||||
tags: ['{{plan ?? free}}', 100, false],
|
||||
},
|
||||
});
|
||||
|
||||
await invokeWebhook(step, execution, stepExecution, step.config);
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
const body = JSON.parse(captured!.options.body as string);
|
||||
expect(body.score).toBe(42);
|
||||
expect(body.active).toBe(true);
|
||||
expect(body.deleted).toBe(null);
|
||||
expect(body.meta).toEqual({count: 7, enabled: false});
|
||||
// String leaf rendered (with default), non-string leaves preserved.
|
||||
expect(body.tags).toEqual(['free', 100, false]);
|
||||
});
|
||||
|
||||
it('renders {{event.*}} variables from the trigger payload', async () => {
|
||||
const {step, execution, stepExecution} = await setup(
|
||||
{
|
||||
url: 'https://example.com/hooks/{{event.referrer}}',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Email-Id': '{{event.emailId}}',
|
||||
},
|
||||
body: {
|
||||
referrer: '{{event.referrer}}',
|
||||
subject: '{{event.subject}}',
|
||||
},
|
||||
},
|
||||
{},
|
||||
{referrer: 'newsletter-may', emailId: 'eml_abc123', subject: 'Welcome'},
|
||||
);
|
||||
|
||||
await invokeWebhook(step, execution, stepExecution, step.config);
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.url).toBe('https://example.com/hooks/newsletter-may');
|
||||
const headers = captured!.options.headers as Record<string, string>;
|
||||
expect(headers['X-Email-Id']).toBe('eml_abc123');
|
||||
const body = JSON.parse(captured!.options.body as string);
|
||||
expect(body.referrer).toBe('newsletter-may');
|
||||
expect(body.subject).toBe('Welcome');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ export const Keys = {
|
||||
return `account:id:${id}`;
|
||||
},
|
||||
email(email: string): string {
|
||||
return `account:${email.trim().toLowerCase()}`;
|
||||
return `account:${email}`;
|
||||
},
|
||||
emailVerificationToken(token: string): string {
|
||||
return `auth:email_verification:${token}`;
|
||||
|
||||
@@ -28,7 +28,7 @@ function getQ(types: Array<{ type: string; q: number }>, target: string): number
|
||||
function negotiate(accept: string): Negotiated {
|
||||
if (!accept) return 'html';
|
||||
const types = parseAccept(accept);
|
||||
const mdQ = getQ(types, 'text/markdown');
|
||||
const mdQ = types.find(t => t.type === 'text/markdown')?.q ?? -1;
|
||||
const htmlQ = getQ(types, 'text/html');
|
||||
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
||||
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
|
||||
|
||||
@@ -725,6 +725,7 @@ export default function Index() {
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.8, delay: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-xl'}
|
||||
data-nosnippet
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'flex items-center gap-5 p-6'}>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {detectCustomHtmlPatterns} from '../emailStyles';
|
||||
|
||||
describe('detectCustomHtmlPatterns', () => {
|
||||
describe('empty / whitespace input', () => {
|
||||
it('returns false for empty string', () => {
|
||||
expect(detectCustomHtmlPatterns('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for whitespace-only string', () => {
|
||||
expect(detectCustomHtmlPatterns(' \n\t ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content TipTap can round-trip (should NOT be flagged as custom)', () => {
|
||||
it('returns false for a basic paragraph', () => {
|
||||
expect(detectCustomHtmlPatterns('<p>Hello</p>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for headings, lists, blockquote, bold, italic', () => {
|
||||
expect(
|
||||
detectCustomHtmlPatterns(
|
||||
'<h1>Title</h1><p><strong>bold</strong> <em>italic</em></p><ul><li>one</li></ul><blockquote>quote</blockquote>',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for <span> with inline color style (TipTap TextStyle output)', () => {
|
||||
expect(detectCustomHtmlPatterns('<span style="color: rgb(220, 38, 38)">red</span>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for <span> with background-color: initial (TipTap export artifact)', () => {
|
||||
expect(detectCustomHtmlPatterns('<span style="background-color: initial">stuff</span>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for <span> with background-color: transparent (TipTap export artifact)', () => {
|
||||
expect(detectCustomHtmlPatterns('<span style="background-color: transparent">stuff</span>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for <a> with inline style (TipTap Link output)', () => {
|
||||
expect(detectCustomHtmlPatterns('<a href="https://example.com" style="color: red">link</a>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for paragraph with inline text-align style', () => {
|
||||
expect(detectCustomHtmlPatterns('<p style="text-align: center">centered</p>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when an href URL contains "id=" or "contactId=" (must not match custom-attr regex)', () => {
|
||||
expect(
|
||||
detectCustomHtmlPatterns('<a href="https://example.com/u?contactId=abc&id=123">unsub</a>'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for allowed class prefixes', () => {
|
||||
expect(detectCustomHtmlPatterns('<p class="prose">x</p>')).toBe(false);
|
||||
expect(detectCustomHtmlPatterns('<span class="variable-mention">x</span>')).toBe(false);
|
||||
expect(detectCustomHtmlPatterns('<img class="email-image" src="x" />')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a TipTap-style colored span wrapped in a paragraph', () => {
|
||||
expect(
|
||||
detectCustomHtmlPatterns('<p>Hello <span style="color: rgb(220, 38, 38);">world</span>!</p>'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content TipTap can NOT round-trip (should be flagged as custom)', () => {
|
||||
it('returns true for <div>', () => {
|
||||
expect(detectCustomHtmlPatterns('<div>stuff</div>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for <table> markup (no TipTap Table extension loaded)', () => {
|
||||
expect(detectCustomHtmlPatterns('<table><tr><td>x</td></tr></table>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a single <table> tag', () => {
|
||||
expect(detectCustomHtmlPatterns('<table>x</table>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for <style> tag', () => {
|
||||
expect(detectCustomHtmlPatterns('<style>p { color: red; }</style>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for @media query inside a style block', () => {
|
||||
expect(detectCustomHtmlPatterns('@media (max-width: 600px) { ... }')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for custom data-* attribute', () => {
|
||||
expect(detectCustomHtmlPatterns('<p data-foo="bar">x</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for aria-* attribute', () => {
|
||||
expect(detectCustomHtmlPatterns('<p aria-label="x">y</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for role= attribute', () => {
|
||||
expect(detectCustomHtmlPatterns('<p role="presentation">x</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for id= attribute on an element', () => {
|
||||
expect(detectCustomHtmlPatterns('<p id="main">x</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a disallowed CSS class', () => {
|
||||
expect(detectCustomHtmlPatterns('<p class="custom">x</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for <section>, <article>, <header>, <footer>, <nav>, <aside>, <main>', () => {
|
||||
expect(detectCustomHtmlPatterns('<section>x</section>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<article>x</article>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<header>x</header>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<footer>x</footer>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<nav>x</nav>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<aside>x</aside>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<main>x</main>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for form/input/button/iframe/svg', () => {
|
||||
expect(detectCustomHtmlPatterns('<form>x</form>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<input type="text" />')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<button>x</button>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<iframe src="x"></iframe>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<svg><circle /></svg>')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
|
||||
//
|
||||
// The TipTap editor in EmailEditor.tsx loads: StarterKit (paragraphs, headings, lists,
|
||||
// blockquote, code, hr, bold, italic, strike, etc.), TextAlign, Color, TextStyle, Link,
|
||||
// ResizableImage, and VariableMention. Of these, TextStyle + Color + Link natively
|
||||
// round-trip <span style="color: ..."> / <a style="color: ..."> markup that TipTap itself
|
||||
// generates when you change text color or style a link. We must therefore PERMIT what
|
||||
// TipTap can represent and REJECT only what it can't.
|
||||
export const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
@@ -28,21 +33,26 @@ export const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
// Custom attributes that carry semantics TipTap doesn't preserve. We require an
|
||||
// attribute-boundary (whitespace, `=`, or quote) before the prefix so that query
|
||||
// strings like `?id=...` inside an `href="..."` value don't false-match.
|
||||
const hasCustomAttributes = /<[a-z][^>]*?[\s"'](?:data-|aria-|role=|id=)/i.test(html);
|
||||
|
||||
// Elements TipTap cannot round-trip with the currently-loaded extension set.
|
||||
// - No Table/TableRow/TableCell extensions are loaded -> all table markup is custom.
|
||||
// - No Div/Section/etc. block-layout extensions -> reject layout containers.
|
||||
// - Form/embed/media/interactive elements have no TipTap representation here.
|
||||
// <span> is intentionally NOT in this list: TipTap's TextStyle extension emits and
|
||||
// accepts <span style="..."> for things like text color.
|
||||
const hasCustomElements =
|
||||
/<(?:div|section|article|header|footer|nav|aside|main|table|tr|td|th|tbody|thead|tfoot|colgroup|col|form|input|button|select|textarea|iframe|video|audio|svg|object|embed|details|summary|dialog)\b/i.test(
|
||||
html,
|
||||
);
|
||||
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
|
||||
};
|
||||
|
||||
export const wrapEmailWithStyles = (htmlBody: string): string => {
|
||||
|
||||
@@ -252,7 +252,7 @@ export default function CampaignsPage() {
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
@@ -260,7 +260,7 @@ export default function CampaignsPage() {
|
||||
placeholder="Search campaigns..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
className="pl-10 pr-10 h-8 text-xs"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
|
||||
@@ -405,7 +405,12 @@ export default function ContactsPage() {
|
||||
) : (
|
||||
<MailX className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-neutral-900">{contact.email}</span>
|
||||
<Link
|
||||
href={`/contacts/${contact.id}`}
|
||||
className="text-sm font-medium text-neutral-900 hover:text-neutral-700 focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
{contact.email}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -455,7 +460,12 @@ export default function ContactsPage() {
|
||||
) : (
|
||||
<MailX className="h-4 w-4 text-red-600 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-neutral-900 truncate">{contact.email}</span>
|
||||
<Link
|
||||
href={`/contacts/${contact.id}`}
|
||||
className="text-sm font-medium text-neutral-900 truncate hover:text-neutral-700 focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
{contact.email}
|
||||
</Link>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function TemplatesPage() {
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
@@ -98,7 +98,7 @@ export default function TemplatesPage() {
|
||||
placeholder="Search templates..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
className="pl-10 pr-10 h-8 text-xs"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function WorkflowsPage() {
|
||||
placeholder="Search workflows..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
className="pl-10 pr-10 h-8 text-xs"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
|
||||
@@ -6,3 +6,16 @@
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/*
|
||||
* Two-tone palette: white content, gray chrome.
|
||||
* `--color-fd-background` paints the page (content area + nav).
|
||||
* `--color-fd-card` paints the sidebar (via `bg-fd-card` on `#nd-sidebar`)
|
||||
* and the `<Cards>` component — both read well as soft gray against white.
|
||||
*/
|
||||
:root {
|
||||
--color-fd-background: hsl(0, 0%, 100%);
|
||||
--color-fd-card: hsl(0, 0%, 96.5%);
|
||||
--color-fd-secondary: hsl(0, 0%, 95%);
|
||||
--color-fd-border: hsla(0, 0%, 80%, 60%);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ A workflow always begins with a single auto-created `TRIGGER` step. You build th
|
||||
| `DELAY` | Pauses the execution for a fixed duration before continuing. | `amount`, `unit` (`minutes` / `hours` / `days`) |
|
||||
| `WAIT_FOR_EVENT` | Pauses until a specified event is tracked on the contact, with a timeout fallback. | `eventName`, `timeout` (seconds) |
|
||||
| `CONDITION` | Branches the execution based on contact data or event data. Each `CONDITION` step has two outgoing transitions tagged `yes` / `no`. | A filter expression (same shape as segment filters) |
|
||||
| `WEBHOOK` | Calls an external HTTPS endpoint with contact + execution context as the JSON body. | `url`, optional `method`, `headers` |
|
||||
| `WEBHOOK` | Calls an external HTTPS endpoint with contact + execution context as the JSON body. `url`, header values, and `body` support `{{variables}}`. | `url`, optional `method`, `headers`, `body` |
|
||||
| `UPDATE_CONTACT` | Patches contact data — useful for tagging contacts as they progress (`{ stage: "activated" }`). | `data` object |
|
||||
| `EXIT` | Terminates the execution. Optionally records an `exitReason` for analytics. | optional `reason` |
|
||||
|
||||
|
||||
@@ -84,6 +84,53 @@ After the trigger, add a **Webhook** step and configure it:
|
||||
}
|
||||
```
|
||||
|
||||
- **Body** (optional): Custom request body. When omitted, Plunk sends the [default payload](#webhook-payload) shown below. When provided, the value replaces the default payload entirely and is JSON-encoded before being sent.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Use variables in the request (optional)
|
||||
|
||||
The `url`, header values, and `body` all support `{{variable}}` interpolation. The available scope is the same as `SEND_EMAIL` templates, plus a webhook-only `event` namespace exposing the trigger event payload:
|
||||
|
||||
| Variable | Value |
|
||||
| --------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `{{id}}`, `{{email}}` | The contact's ID and email. |
|
||||
| `{{<key>}}` (top-level) | Any key from the contact's `data` JSON (e.g. `{{firstName}}`, `{{plan}}`). |
|
||||
| `{{data.<key>}}` | The same contact data, addressed via the `data` namespace. |
|
||||
| `{{event.<key>}}` | Webhook-only. Fields from the trigger event payload (e.g. `{{event.subject}}`). |
|
||||
| `{{<key>}}` (from execution context) | Keys passed in as `context` when starting a `MANUAL` execution. |
|
||||
| `{{unsubscribeUrl}}`, `{{subscribeUrl}}`, `{{manageUrl}}` | Per-contact subscription management URLs. |
|
||||
|
||||
The HTTP `method` is **not** templated — it must be a literal verb (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`). The `url` must include a static scheme (`http://` or `https://`); placeholders are supported inside the URL but cannot replace the scheme.
|
||||
|
||||
Example — forward a contact event to your own API, parameterised by contact data:
|
||||
|
||||
**URL**
|
||||
|
||||
```text
|
||||
https://api.example.com/users/{{id}}/events
|
||||
```
|
||||
|
||||
**Headers**
|
||||
|
||||
```json
|
||||
{
|
||||
"Authorization": "Bearer your-secret-token"
|
||||
}
|
||||
```
|
||||
|
||||
**Body**
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "{{email}}",
|
||||
"plan": "{{plan}}",
|
||||
"referrer": "{{event.referrer}}"
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"---Docs---",
|
||||
"concepts",
|
||||
"guides",
|
||||
"---Recipes---",
|
||||
"recipes",
|
||||
"---API Reference---",
|
||||
"api-reference",
|
||||
"---Self-Hosting---",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Double opt-in
|
||||
description: Require a confirmation click before a new signup starts receiving marketing email
|
||||
icon: MailCheck
|
||||
---
|
||||
|
||||
Double opt-in adds a confirmation step between "user signs up" and "user starts getting marketing email." It's the standard way to avoid mailing typoed addresses, role accounts, and anyone who didn't actually consent.
|
||||
|
||||
The trick is `{{subscribeUrl}}`: a per-contact link Plunk auto-injects into every send. Clicking it flips `subscribed` to `true` and fires a `contact.subscribed` event.
|
||||
|
||||
## Setup
|
||||
|
||||
import {Step, Steps} from 'fumadocs-ui/components/steps';
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create two templates
|
||||
|
||||
- A **Transactional** template for the confirmation email, containing `{{subscribeUrl}}`:
|
||||
|
||||
```html
|
||||
<p>Hi {{firstName}}, please confirm your email to start receiving updates:</p>
|
||||
<p><a href="{{subscribeUrl}}">Confirm my email</a></p>
|
||||
```
|
||||
|
||||
- A **Marketing** template for the welcome email that goes out *after* they confirm.
|
||||
|
||||
<Callout title="The confirmation must be transactional" type="warn">
|
||||
A marketing template targeted at an unsubscribed contact is [silently skipped](/concepts/contacts#emails-by-subscription-state). Use a transactional template for the confirmation specifically — it bypasses the subscription check.
|
||||
</Callout>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Trigger the signup from your backend
|
||||
|
||||
Two calls with your secret key (`sk_*`): create the contact unsubscribed, then track the event that fires the confirmation workflow.
|
||||
|
||||
```bash
|
||||
curl https://next-api.useplunk.com/contacts \
|
||||
-H "Authorization: Bearer sk_your_secret_key" \
|
||||
-d '{ "email": "ada@example.com", "subscribed": false, "data": { "firstName": "Ada" } }'
|
||||
|
||||
curl https://next-api.useplunk.com/v1/track \
|
||||
-H "Authorization: Bearer sk_your_secret_key" \
|
||||
-d '{ "event": "signup.pending", "email": "ada@example.com", "subscribed": false }'
|
||||
```
|
||||
|
||||
Both calls pass `subscribed: false`. If you skip the first call and rely on `/v1/track` alone, tracking on an unknown email creates the contact — but defaults it to subscribed, which defeats the point.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Workflow A: send the confirmation
|
||||
|
||||
**Workflows → New workflow**:
|
||||
|
||||
- **Trigger**: `EVENT` on `signup.pending`
|
||||
- `SEND_EMAIL` step → transactional confirmation template
|
||||
|
||||
Enable it.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Workflow B: welcome them after confirmation
|
||||
|
||||
**Workflows → New workflow**:
|
||||
|
||||
- **Trigger**: `EVENT` on `contact.subscribed`
|
||||
- `SEND_EMAIL` step → marketing welcome template
|
||||
|
||||
Enable it. `contact.subscribed` fires whenever a contact opts in — including via `{{subscribeUrl}}`, the preferences page, or the API — so this workflow handles both first-time confirmations and resubscribes.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Reminder if they don't confirm
|
||||
|
||||
Extend Workflow A with a `WAIT_FOR_EVENT` step after the send:
|
||||
|
||||
- **Event**: `contact.subscribed`
|
||||
- **Timeout**: `86400` (24 hours)
|
||||
|
||||
On timeout, send a single reminder (also transactional). Keep the number of reminders small — repeated confirmation prompts look like spam to mailbox providers as much as to recipients.
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="Unsubscribe & preferences pages" href="/guides/unsubscribe-pages">
|
||||
Detail on `{{subscribeUrl}}` and the hosted pages.
|
||||
</Card>
|
||||
<Card title="Templates" href="/concepts/templates">
|
||||
The difference between Marketing, Transactional, and Headless templates.
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Recipes
|
||||
description: End-to-end walkthroughs for common patterns built on Plunk events and workflows
|
||||
icon: ChefHat
|
||||
---
|
||||
|
||||
Recipes are concrete, step-by-step builds for patterns we see most often in Plunk projects. Each one assumes you already understand the underlying [concepts](/concepts/workflows) and walks you through the exact API calls, workflow steps, and template variables involved.
|
||||
|
||||
<Cards>
|
||||
<Card title="Waitlist with confirmation email" href="/recipes/waitlist">
|
||||
Capture signups with a single tracked event, then automatically email each person who joins.
|
||||
</Card>
|
||||
<Card title="Sync unsubscribes to your database" href="/recipes/sync-unsubscribes">
|
||||
Keep your own user table in step with Plunk's subscription state using a webhook step.
|
||||
</Card>
|
||||
<Card title="Double opt-in" href="/recipes/double-opt-in">
|
||||
Add a confirmation step before a contact starts receiving marketing email, using `{{subscribeUrl}}`.
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pages": ["index", "waitlist", "sync-unsubscribes", "double-opt-in"]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Sync unsubscribes to your database
|
||||
description: Mirror Plunk's subscription state into your own user table using a workflow + webhook
|
||||
icon: RefreshCw
|
||||
---
|
||||
|
||||
Every flip of a contact's `subscribed` state — manual edits, the hosted unsubscribe page, bounces, complaints — fires a `contact.unsubscribed` event. Wire a workflow with a `WEBHOOK` step to forward that to your backend.
|
||||
|
||||
## Setup
|
||||
|
||||
import {Step, Steps} from 'fumadocs-ui/components/steps';
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### Build the receiving endpoint
|
||||
|
||||
A public HTTPS endpoint that verifies a shared secret and updates the user row. Webhook requests time out after 10 seconds, so do the work async if it's slow.
|
||||
|
||||
```ts
|
||||
app.post('/plunk/unsubscribes', async (req, res) => {
|
||||
if (req.header('authorization') !== `Bearer ${process.env.PLUNK_WEBHOOK_SECRET}`) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
|
||||
const { contact, event } = req.body;
|
||||
await db.user.update({
|
||||
where: { email: contact.email },
|
||||
data: {
|
||||
emailSubscribed: false,
|
||||
emailUnsubscribedReason: event.reason ?? 'user_action',
|
||||
},
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
```
|
||||
|
||||
`event.reason` is `"bounce"` or `"complaint"` for automatic unsubscribes, and absent for manual / self-service ones.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the workflow
|
||||
|
||||
**Workflows → New workflow**:
|
||||
|
||||
- **Trigger**: `EVENT` on `contact.unsubscribed`
|
||||
- Add a `WEBHOOK` step:
|
||||
- **URL**: `https://api.example.com/plunk/unsubscribes`
|
||||
- **Headers**: `{ "Authorization": "Bearer your-shared-secret" }`
|
||||
- Leave the body blank to get the [default payload](/guides/webhooks#webhook-payload).
|
||||
|
||||
Enable the workflow.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Mirroring resubscribes
|
||||
|
||||
Build a second workflow with the same shape, triggered by `contact.subscribed`. Keep it separate from the unsubscribe flow — two short workflows are easier to monitor than one branched one.
|
||||
|
||||
## The reverse direction
|
||||
|
||||
If your product is the source of truth (a user toggles their email preference in your settings UI), call `PATCH /contacts/:id` from your backend:
|
||||
|
||||
```bash
|
||||
curl -X PATCH https://next-api.useplunk.com/contacts/cnt_abc \
|
||||
-H "Authorization: Bearer sk_your_secret_key" \
|
||||
-d '{"subscribed": false}'
|
||||
```
|
||||
|
||||
That flip also fires `contact.unsubscribed`, meaning your own webhook will round-trip back into your handler. That's usually harmless because the update is idempotent — but be aware of it.
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="Webhooks" href="/guides/webhooks">
|
||||
Webhook step reference, payload shape, and safety.
|
||||
</Card>
|
||||
<Card title="Unsubscribe pages" href="/guides/unsubscribe-pages">
|
||||
The hosted pages and template URL variables.
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: Waitlist with confirmation email
|
||||
description: Track signups as a custom event, store everyone who joins as a contact, and automatically email them
|
||||
icon: ListOrdered
|
||||
---
|
||||
|
||||
A waitlist is the simplest possible Plunk workflow: one tracked event from your app, one workflow that listens for it, one email.
|
||||
|
||||
## Setup
|
||||
|
||||
import {Step, Steps} from 'fumadocs-ui/components/steps';
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the confirmation template
|
||||
|
||||
In **Templates → New template**, create a **Marketing** template. Use `{{variable}}` placeholders for anything you want to personalise from contact data:
|
||||
|
||||
```text
|
||||
Subject: You're on the list, {{firstName}}
|
||||
|
||||
Hi {{firstName}}, thanks for joining the {{product}} waitlist.
|
||||
We'll let you know as soon as your spot opens up.
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Track the signup from your backend
|
||||
|
||||
Call `POST /v1/track` when a user submits the form. Use a secret key (`sk_*`) — never call this from the browser.
|
||||
|
||||
```bash
|
||||
curl https://next-api.useplunk.com/v1/track \
|
||||
-H "Authorization: Bearer sk_your_secret_key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event": "waitlist.joined",
|
||||
"email": "ada@example.com",
|
||||
"data": { "firstName": "Ada", "product": "Beta" }
|
||||
}'
|
||||
```
|
||||
|
||||
This call upserts the contact (subscribed by default) and records `waitlist.joined` on them. Anything you put in `data` lands on the contact and is available as `{{firstName}}`, `{{product}}`, etc. in the template.
|
||||
|
||||
<Callout title="Pick a stable event name" type="info">
|
||||
A workflow's trigger event **cannot be changed after the first execution**. Namespace it (`waitlist.joined`) rather than something generic you might want to reuse.
|
||||
</Callout>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the workflow
|
||||
|
||||
**Workflows → New workflow**:
|
||||
|
||||
- **Trigger**: `EVENT` on `waitlist.joined`
|
||||
- Add a `SEND_EMAIL` step pointing at the template from step 1
|
||||
|
||||
Enable the workflow. Workflows are created disabled — until the toggle is on, nothing fires.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Tagging signups for later
|
||||
|
||||
If you want to segment on waitlist signups later, add an `UPDATE_CONTACT` step before the email:
|
||||
|
||||
```json
|
||||
{ "stage": "waitlist", "waitlistSource": "{{event.referrer}}" }
|
||||
```
|
||||
|
||||
You can then build a [segment](/concepts/segments) of contacts where `stage == "waitlist"` to target with follow-up campaigns. This is cleaner than filtering on "ever fired `waitlist.joined`."
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="Workflows" href="/concepts/workflows">
|
||||
Step types and trigger semantics.
|
||||
</Card>
|
||||
<Card title="Track event API" href="/api-reference/public-api/trackEvent">
|
||||
Full reference for `POST /v1/track`.
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -26,7 +26,7 @@ function getQ(types: Array<{ type: string; q: number }>, target: string): number
|
||||
function negotiate(accept: string): Negotiated {
|
||||
if (!accept) return 'html';
|
||||
const types = parseAccept(accept);
|
||||
const mdQ = getQ(types, 'text/markdown');
|
||||
const mdQ = types.find(t => t.type === 'text/markdown')?.q ?? -1;
|
||||
const htmlQ = getQ(types, 'text/html');
|
||||
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
||||
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectDisabledReason" AS ENUM ('PAYMENT_FAILED', 'EMAIL_REPUTATION', 'PHISHING_DETECTED', 'MANUAL');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "projects" ADD COLUMN "disabledReason" "ProjectDisabledReason";
|
||||
@@ -43,7 +43,8 @@ model Project {
|
||||
secret String @unique
|
||||
|
||||
// Admin
|
||||
disabled Boolean @default(false)
|
||||
disabled Boolean @default(false)
|
||||
disabledReason ProjectDisabledReason?
|
||||
|
||||
// Billing
|
||||
customer String? @unique
|
||||
@@ -632,6 +633,13 @@ model Event {
|
||||
// ENUMS
|
||||
// ============================================
|
||||
|
||||
enum ProjectDisabledReason {
|
||||
PAYMENT_FAILED // Subscription renewal payment failed
|
||||
EMAIL_REPUTATION // Bounce or complaint rate thresholds exceeded
|
||||
PHISHING_DETECTED // Phishing content detected by LLM scan
|
||||
MANUAL // Disabled by support/admin (e.g. directly in DB)
|
||||
}
|
||||
|
||||
enum AuthMethod {
|
||||
PASSWORD
|
||||
GOOGLE_OAUTH
|
||||
|
||||
+86
-78
@@ -1,6 +1,50 @@
|
||||
import {PrismaClient} from '@plunk/db';
|
||||
import {execSync} from 'child_process';
|
||||
|
||||
// Snake-cased table names from prisma schema (see @@map directives).
|
||||
// Order doesn't matter — TRUNCATE with CASCADE handles FK dependencies in one statement.
|
||||
const TRUNCATE_TABLES = [
|
||||
'events',
|
||||
'workflow_step_executions',
|
||||
'emails',
|
||||
'workflow_executions',
|
||||
'workflow_transitions',
|
||||
'workflow_steps',
|
||||
'workflows',
|
||||
'campaigns',
|
||||
'templates',
|
||||
'segment_memberships',
|
||||
'segments',
|
||||
'contacts',
|
||||
'domains',
|
||||
'memberships',
|
||||
'projects',
|
||||
'users',
|
||||
];
|
||||
|
||||
/**
|
||||
* Connects to the admin `postgres` database to ensure the worker's test DB exists.
|
||||
* Postgres has no `CREATE DATABASE IF NOT EXISTS`, so we check pg_database first.
|
||||
*/
|
||||
async function ensureDatabaseExists(databaseUrl: string, workerDbName: string) {
|
||||
const adminUrl = new URL(databaseUrl);
|
||||
adminUrl.pathname = '/postgres';
|
||||
adminUrl.searchParams.delete('connection_limit');
|
||||
adminUrl.searchParams.delete('pool_timeout');
|
||||
|
||||
const admin = new PrismaClient({datasources: {db: {url: adminUrl.toString()}}});
|
||||
try {
|
||||
const rows = await admin.$queryRawUnsafe<{exists: boolean}[]>(
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = '${workerDbName}') AS exists`,
|
||||
);
|
||||
if (!rows[0]?.exists) {
|
||||
await admin.$executeRawUnsafe(`CREATE DATABASE "${workerDbName}"`);
|
||||
}
|
||||
} finally {
|
||||
await admin.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test database helper
|
||||
* Manages test database isolation and cleanup
|
||||
@@ -9,44 +53,56 @@ class TestDatabase {
|
||||
private prisma: PrismaClient | null = null;
|
||||
|
||||
async initialize() {
|
||||
// Use test database URL if provided, otherwise use main database
|
||||
// setup.ts has already rewritten DATABASE_URL to include the per-worker DB name
|
||||
// (e.g. plunk_test_w1, plunk_test_w2). We create that DB if missing, migrate it,
|
||||
// then open the long-lived client we use for tests.
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL || process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL or TEST_DATABASE_URL must be set for testing');
|
||||
}
|
||||
|
||||
// Create Prisma client with connection pool limits
|
||||
this.prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: databaseUrl,
|
||||
},
|
||||
},
|
||||
// Limit connection pool to prevent memory issues in tests
|
||||
// @ts-ignore - These options exist but may not be in types
|
||||
__internal: {
|
||||
engine: {
|
||||
connection_limit: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
const url = new URL(databaseUrl);
|
||||
const workerDbName = url.pathname.replace(/^\//, '');
|
||||
if (!workerDbName) {
|
||||
throw new Error('DATABASE_URL must include a database name');
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
await this.prisma.$connect();
|
||||
// Bump the pool above Prisma's default (~5 on CI). Test Postgres has
|
||||
// max_connections=100; with N workers we want N*20 ≤ 100 — fine up to 4 workers.
|
||||
if (!url.searchParams.has('connection_limit')) {
|
||||
url.searchParams.set('connection_limit', '20');
|
||||
}
|
||||
if (!url.searchParams.has('pool_timeout')) {
|
||||
url.searchParams.set('pool_timeout', '20');
|
||||
}
|
||||
|
||||
// Run migrations (only once per test suite)
|
||||
await ensureDatabaseExists(databaseUrl, workerDbName);
|
||||
|
||||
// Run pending migrations against this worker's DB. `migrate deploy` is a no-op
|
||||
// when up-to-date and avoids the drift prompts that `migrate dev` does.
|
||||
try {
|
||||
execSync('yarn workspace @plunk/db migrate:dev', {
|
||||
execSync('yarn workspace @plunk/db migrate:prod', {
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
DATABASE_URL: url.toString(),
|
||||
DIRECT_DATABASE_URL: process.env.DIRECT_DATABASE_URL || url.toString(),
|
||||
},
|
||||
stdio: 'ignore',
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Migration warning (may already be up to date):', error);
|
||||
const err = error as {stdout?: string; stderr?: string; message?: string};
|
||||
console.error('Migration failed for', workerDbName);
|
||||
if (err.stdout) console.error('stdout:', err.stdout);
|
||||
if (err.stderr) console.error('stderr:', err.stderr);
|
||||
if (!err.stdout && !err.stderr) console.error(err.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.prisma = new PrismaClient({
|
||||
datasources: {db: {url: url.toString()}},
|
||||
});
|
||||
await this.prisma.$connect();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,80 +116,32 @@ class TestDatabase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up database after each test
|
||||
* Deletes all records in reverse order of dependencies
|
||||
* Uses batched deletes to prevent memory issues with large datasets
|
||||
* Retries on deadlock to handle race conditions with background event tracking
|
||||
* Wipe all per-test data with a single TRUNCATE ... CASCADE statement.
|
||||
* Roughly an order of magnitude faster than 14 sequential deleteMany calls
|
||||
* — TRUNCATE skips the row scan and only touches table headers.
|
||||
*/
|
||||
async cleanup() {
|
||||
if (!this.prisma) return;
|
||||
|
||||
const tables = TRUNCATE_TABLES.map(t => `"${t}"`).join(', ');
|
||||
const maxRetries = 3;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Use a transaction to ensure all deletes happen atomically
|
||||
// This prevents foreign key constraint violations and race conditions
|
||||
await this.prisma.$transaction([
|
||||
// Level 1: Delete deepest dependencies first
|
||||
this.prisma.event.deleteMany(),
|
||||
this.prisma.workflowStepExecution.deleteMany(),
|
||||
|
||||
// Level 2: Delete entities that depend on Level 1
|
||||
this.prisma.email.deleteMany(),
|
||||
this.prisma.workflowExecution.deleteMany(),
|
||||
|
||||
// Level 3: Delete workflow structure
|
||||
this.prisma.workflowTransition.deleteMany(),
|
||||
this.prisma.workflowStep.deleteMany(),
|
||||
this.prisma.workflow.deleteMany(),
|
||||
|
||||
// Level 4: Delete campaigns and templates
|
||||
this.prisma.campaign.deleteMany(),
|
||||
this.prisma.template.deleteMany(),
|
||||
|
||||
// Level 5: Delete segment relationships
|
||||
this.prisma.segmentMembership.deleteMany(),
|
||||
this.prisma.segment.deleteMany(),
|
||||
|
||||
// Level 6: Delete contacts
|
||||
this.prisma.contact.deleteMany(),
|
||||
|
||||
// Level 7: Delete domains
|
||||
this.prisma.domain.deleteMany(),
|
||||
|
||||
// Level 8: Delete memberships (has FK to both user and project)
|
||||
this.prisma.membership.deleteMany(),
|
||||
|
||||
// Level 9: Delete projects
|
||||
this.prisma.project.deleteMany(),
|
||||
|
||||
// Level 10: Delete users last
|
||||
this.prisma.user.deleteMany(),
|
||||
]);
|
||||
|
||||
// Success - exit retry loop
|
||||
await this.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} RESTART IDENTITY CASCADE`);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
// Check if this is a deadlock error (PostgreSQL error code 40P01)
|
||||
const isDeadlock = error instanceof Error && error.message?.includes('deadlock detected');
|
||||
|
||||
if (isDeadlock && attempt < maxRetries) {
|
||||
// Wait before retrying (exponential backoff)
|
||||
const delay = Math.pow(2, attempt) * 50; // 100ms, 200ms, 400ms
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 50));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a deadlock or out of retries
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
console.error(`Error cleaning up database after ${maxRetries} attempts:`, lastError);
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,9 @@ export class TestFactories {
|
||||
async createUser(options: UserFactoryOptions = {}) {
|
||||
const email = options.email || `user-${uniqueId()}@test.com`;
|
||||
const password = options.password || 'password123';
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
// Cost factor 4 is the bcrypt minimum — ~100x faster than the production cost of 10.
|
||||
// Test users don't need real-world hash strength.
|
||||
const hashedPassword = await bcrypt.hash(password, 4);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
|
||||
+35
-22
@@ -1,39 +1,52 @@
|
||||
import { beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import { testDatabase } from './helpers/database';
|
||||
// IMPORTANT: this file runs before each test file's imports execute.
|
||||
// We rewrite DATABASE_URL and REDIS_URL here so per-worker isolation is
|
||||
// applied before any service module constructs a Prisma/Redis client.
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import {afterAll, afterEach, beforeAll, vi} from 'vitest';
|
||||
|
||||
// Load environment variables from root .env file
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
dotenv.config({path: path.resolve(__dirname, '../.env')});
|
||||
|
||||
// Vitest assigns each worker a 1-based pool id; defaults to "1" for single-worker runs.
|
||||
const workerId = process.env.VITEST_POOL_ID || '1';
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
const baseDb = url.pathname.replace(/^\//, '') || 'plunk_test';
|
||||
url.pathname = `/${baseDb}_w${workerId}`;
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
// Mirror onto DIRECT_DATABASE_URL so prisma migrate uses the same worker DB.
|
||||
if (process.env.DIRECT_DATABASE_URL) {
|
||||
const direct = new URL(process.env.DIRECT_DATABASE_URL);
|
||||
direct.pathname = `/${baseDb}_w${workerId}`;
|
||||
process.env.DIRECT_DATABASE_URL = direct.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.REDIS_URL) {
|
||||
const url = new URL(process.env.REDIS_URL);
|
||||
url.pathname = `/${(parseInt(workerId, 10) - 1) % 16}`;
|
||||
process.env.REDIS_URL = url.toString();
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-key-for-testing';
|
||||
|
||||
// Static import is safe: database.ts only reads env in initialize(), which runs
|
||||
// in beforeAll — well after the env mutations above.
|
||||
import {testDatabase} from './helpers/database';
|
||||
|
||||
// Global test setup
|
||||
beforeAll(async () => {
|
||||
// Initialize test database
|
||||
await testDatabase.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clear all mocks first
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Restore real timers
|
||||
vi.useRealTimers();
|
||||
|
||||
// Clean up database after each test
|
||||
// This must be last to ensure proper cleanup order
|
||||
await testDatabase.cleanup();
|
||||
|
||||
// Force garbage collection hint (if available in test environment)
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Disconnect from database
|
||||
await testDatabase.disconnect();
|
||||
});
|
||||
|
||||
// Set test environment variables
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-key-for-testing';
|
||||
|
||||
+8
-7
@@ -22,18 +22,19 @@ export default defineConfig({
|
||||
},
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 30000,
|
||||
// Memory optimization: Run tests in sequence to prevent memory issues
|
||||
// This is critical for tests that create large datasets
|
||||
// Each fork is a worker with an isolated Postgres database and Redis db-number
|
||||
// (see test/setup.ts). That isolation is what lets us run files in parallel
|
||||
// without the cross-test interference we used to hit with a shared DB.
|
||||
pool: 'forks',
|
||||
poolOptions: {
|
||||
forks: {
|
||||
singleFork: true, // Run all tests in a single fork to limit memory
|
||||
// Cap at 4 to stay within Postgres' default max_connections=100
|
||||
// when each worker uses connection_limit=20.
|
||||
maxForks: 4,
|
||||
minForks: 1,
|
||||
},
|
||||
},
|
||||
// Run tests sequentially to avoid database cleanup conflicts
|
||||
fileParallelism: false,
|
||||
// Limit concurrent test files to reduce memory pressure
|
||||
maxConcurrency: 3,
|
||||
maxConcurrency: 5,
|
||||
// Only include our test files, not dependency tests
|
||||
include: [
|
||||
'apps/**/__tests__/**/*.{test,spec}.{ts,tsx}',
|
||||
|
||||
@@ -8101,10 +8101,9 @@ __metadata:
|
||||
mailparser: "npm:^3.9.8"
|
||||
morgan: "npm:^1.10.0"
|
||||
multer: "npm:^2.1.1"
|
||||
sanitize-html: "npm:^2.17.3"
|
||||
sanitize-html: "npm:^2.17.4"
|
||||
signale: "npm:^1.4.0"
|
||||
stripe: "npm:^20.0.0"
|
||||
tldts: "npm:^7.0.30"
|
||||
tsx: "npm:^4.20.6"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@@ -9523,6 +9522,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dayjs@npm:^1.11.7":
|
||||
version: 1.11.20
|
||||
resolution: "dayjs@npm:1.11.20"
|
||||
checksum: 10c0/8af525e2aa100c8db9923d706c42b2b2d30579faf89456619413a5c10916efc92c2b166e193c27c02eb3174b30aa440ee1e7b72b0a2876b3da651d204db848a0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debounce-fn@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "debounce-fn@npm:6.0.0"
|
||||
@@ -13292,6 +13298,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"launder@npm:^1.7.1":
|
||||
version: 1.7.1
|
||||
resolution: "launder@npm:1.7.1"
|
||||
dependencies:
|
||||
dayjs: "npm:^1.11.7"
|
||||
checksum: 10c0/c4884c08cc5a1a19cbec840aac7fa97db4928c25fc99ea2981a0482df3ebdbf1cf6605226a3c968e3281025126ff10055686e81f428ecc0e8f8666ca05bae8cc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"leac@npm:^0.6.0":
|
||||
version: 0.6.0
|
||||
resolution: "leac@npm:0.6.0"
|
||||
@@ -17216,17 +17231,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sanitize-html@npm:^2.17.3":
|
||||
version: 2.17.3
|
||||
resolution: "sanitize-html@npm:2.17.3"
|
||||
"sanitize-html@npm:^2.17.4":
|
||||
version: 2.17.4
|
||||
resolution: "sanitize-html@npm:2.17.4"
|
||||
dependencies:
|
||||
deepmerge: "npm:^4.2.2"
|
||||
escape-string-regexp: "npm:^4.0.0"
|
||||
htmlparser2: "npm:^10.1.0"
|
||||
is-plain-object: "npm:^5.0.0"
|
||||
launder: "npm:^1.7.1"
|
||||
parse-srcset: "npm:^1.0.2"
|
||||
postcss: "npm:^8.3.11"
|
||||
checksum: 10c0/8afa59bed125b38bf4b437f9b5a3289a4307f42d720e45105de5a0b3d665be70e27d1722d223121993be2e54a2b99304cd9c54317fb2d251fd7f4abf06b68d27
|
||||
checksum: 10c0/5c352376a44bf8a70644f6d4421684000a982f6bda59beac051693d8fc08acbe48dc6358f5c8eb8ae4a815746260167926747a858e6a6e2daf01ccfb775100dd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18564,24 +18580,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tldts-core@npm:^7.0.30":
|
||||
version: 7.0.30
|
||||
resolution: "tldts-core@npm:7.0.30"
|
||||
checksum: 10c0/e3cd730e96b0e9c0332fcaab44d0257b668f9089644508e4f6f870d37bbf5c218243b7e83aa39690c87b386d1b0ad577772a5994969c4c81cc25a476f783ccd7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tldts@npm:^7.0.30":
|
||||
version: 7.0.30
|
||||
resolution: "tldts@npm:7.0.30"
|
||||
dependencies:
|
||||
tldts-core: "npm:^7.0.30"
|
||||
bin:
|
||||
tldts: bin/cli.js
|
||||
checksum: 10c0/c36f7b480f09128303158e4738a82426c33e8da9f77d4fb57a2d5ef5896c803d7a3c1d53ade965712f9cb4946935139b6d192a18698665556ca504493c7c265e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"to-regex-range@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "to-regex-range@npm:5.0.1"
|
||||
|
||||
Reference in New Issue
Block a user