Compare commits

..
40 changed files with 516 additions and 1184 deletions
-12
View File
@@ -65,18 +65,6 @@ 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
+3 -2
View File
@@ -39,9 +39,10 @@
"mailparser": "^3.9.8",
"morgan": "^1.10.0",
"multer": "^2.1.1",
"sanitize-html": "^2.17.4",
"sanitize-html": "^2.17.3",
"signale": "^1.4.0",
"stripe": "^20.0.0"
"stripe": "^20.0.0",
"tldts": "^7.0.30"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
@@ -374,13 +374,14 @@ describe('Domain Verification and Ownership Tests', () => {
// EDGE CASES
// ========================================
describe('Edge Cases', () => {
it('should handle case-sensitive domain names', async () => {
it('should canonicalize mixed-case domain names to lowercase', async () => {
const {project} = await factories.createUserWithProject();
// Domains are typically case-insensitive in DNS, but stored as-is in DB
// DNS is case-insensitive — domains must be stored canonically so a tenant
// can't claim "Example.com" while another project owns "example.com".
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 () => {
+4 -1
View File
@@ -321,9 +321,12 @@ export class Auth {
data: {password: hashedPassword},
});
// Delete token and invalidate cache
// Delete token and invalidate cache (id + email projections both cache the password hash)
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'}});
}
+24 -24
View File
@@ -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 {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {MembershipService} from '../services/MembershipService.js';
@@ -17,12 +17,16 @@ export class Domains {
* Get all domains for a project
*/
@Get('project/:projectId')
@Middleware([requireAuth, requireEmailVerified])
@Middleware([isAuthenticated, 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);
const domains = await DomainService.getProjectDomains(auth.projectId!);
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, projectId);
const domains = await DomainService.getProjectDomains(projectId);
return res.status(200).json(domains);
}
@@ -31,18 +35,19 @@ export class Domains {
* Add a new domain to a project
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {domain} = DomainSchemas.create.parse(req.body);
const projectId = auth.projectId!;
const {projectId, domain} = DomainSchemas.create.parse(req.body);
// Require admin role for JWT users (API keys bypass — project-scoped by design)
if (auth.type === 'jwt') {
await MembershipService.requireAdminAccess(auth.userId!, projectId);
if (!auth.userId) {
throw new NotFound('User authentication required');
}
// 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) {
@@ -63,12 +68,6 @@ 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({
@@ -100,7 +99,7 @@ export class Domains {
* Check verification status for a domain
*/
@Get(':id/verify')
@Middleware([requireAuth, requireEmailVerified])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
@@ -108,10 +107,13 @@ export class Domains {
const domain = await DomainService.id(id);
if (!domain || domain.projectId !== auth.projectId) {
if (!domain) {
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
@@ -125,7 +127,7 @@ export class Domains {
* Remove a domain from a project
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
@@ -133,14 +135,12 @@ export class Domains {
const domain = await DomainService.id(id);
if (!domain || domain.projectId !== auth.projectId) {
if (!domain) {
throw new NotFound('Domain not found');
}
// Require admin role for JWT users (API keys bypass — project-scoped by design)
if (auth.type === 'jwt') {
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
}
// Verify user has admin access to the project this domain belongs to
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
// Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
+16
View File
@@ -12,6 +12,7 @@ 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';
@@ -117,6 +118,8 @@ export class Users {
data,
});
await ProjectService.invalidate(id, [{public: project.public, secret: project.secret}]);
return res.status(200).json(project);
}
@@ -130,6 +133,12 @@ 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')}`;
@@ -154,6 +163,13 @@ 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!);
+10 -11
View File
@@ -19,6 +19,7 @@ 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';
@@ -530,6 +531,10 @@ 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;
@@ -573,16 +578,6 @@ 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;
@@ -616,9 +611,11 @@ export class Webhooks {
await prisma.project.update({
where: {id: project.id},
data: {disabled: true, disabledReason: 'PAYMENT_FAILED'},
data: {disabled: true},
});
await ProjectService.invalidate(project.id, [{public: project.public, secret: project.secret}]);
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
// Send email notification to project members
@@ -664,6 +661,8 @@ 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
+30 -29
View File
@@ -1,5 +1,6 @@
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';
@@ -16,6 +17,14 @@ 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
*/
@@ -41,14 +50,16 @@ 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(domain);
const dkimTokens = await verifyDomain(canonical);
// Create domain record
const newDomain = await prisma.domain.create({
data: {
projectId,
domain,
domain: canonical,
verified: false,
dkimTokens,
},
@@ -60,7 +71,7 @@ export class DomainService {
});
// Send notification about domain added
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId);
await NtfyService.notifyDomainAdded(canonical, newDomain.project.name, projectId);
return newDomain;
}
@@ -353,7 +364,7 @@ export class DomainService {
throw new HttpException(400, 'Invalid email format');
}
const domainName = emailParts[1];
const domainName = this.canonicalize(emailParts[1] ?? '');
// Find domain in database
const domain = await prisma.domain.findFirst({
@@ -389,12 +400,11 @@ export class DomainService {
}
/**
* Extract the registrable root domain (last two labels) from a domain name.
* e.g. "mail.example.com" → "example.com", "example.com" → "example.com"
* 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"
*/
private static rootDomain(domain: string): string {
const parts = domain.split('.');
return parts.length > 2 ? parts.slice(-2).join('.') : domain;
return getRegistrableDomain(domain) ?? domain;
}
/**
@@ -404,10 +414,11 @@ export class DomainService {
public static async checkSubdomainOfDisabledRoot(
domain: string,
): Promise<{blocked: boolean; projectName?: string; projectId?: string}> {
const root = this.rootDomain(domain);
const canonical = this.canonicalize(domain);
const root = this.rootDomain(canonical);
// Only relevant when the submitted domain is actually a subdomain
if (root === domain) {
if (root === canonical) {
return {blocked: false};
}
@@ -438,14 +449,16 @@ 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) {
public static async checkDomainOwnership(domain: string, userId: string) {
const canonical = this.canonicalize(domain);
const existingDomain = await prisma.domain.findFirst({
where: {domain},
where: {domain: canonical},
include: {
project: {
select: {
id: true,
name: true,
include: {
members: {
where: {userId},
},
},
},
},
@@ -455,20 +468,8 @@ export class DomainService {
return {exists: false};
}
let isMember = false;
if (userId) {
const membership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: existingDomain.project.id,
},
},
});
isMember = membership !== null;
}
// Check if user is a member of the project that owns this domain
const isMember = existingDomain.project.members.length > 0;
return {
exists: true,
+14 -18
View File
@@ -659,16 +659,12 @@ 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) {
@@ -683,21 +679,21 @@ export class EmailService {
}
}
// 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 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);
const hasMediaQueries = /@media/i.test(html);
const hasStyleTags = /<style[^>]*>/i.test(html);
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
return (
hasInlineStyles ||
hasCustomClasses ||
hasCustomAttributes ||
hasComplexTables ||
hasCustomElements ||
hasMediaQueries ||
hasStyleTags
);
}
/**
+27 -1
View File
@@ -1,5 +1,7 @@
import signale from 'signale';
import {Keys} from './keys.js';
import {wrapRedis} from '../database/redis.js';
import {redis, wrapRedis} from '../database/redis.js';
import {prisma} from '../database/prisma.js';
export class ProjectService {
@@ -28,4 +30,28 @@ 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);
}
}
}
+100 -56
View File
@@ -9,6 +9,7 @@ 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,
@@ -50,13 +51,22 @@ const SECURITY_THRESHOLDS = {
MIN_COMPLAINTS_FOR_CRITICAL: 5,
MIN_COMPLAINTS_FOR_WARNING: 3,
// === 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.
// === 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
NEW_PROJECT_AGE_DAYS: 30,
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
@@ -507,54 +517,82 @@ export class SecurityService {
const violations: string[] = [];
const warnings: string[] = [];
// === 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})`,
);
}
// 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,
};
// 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 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,
};
// 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})`,
);
}
const projectLabel = isNewProject ? ' (new project)' : '';
// 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})`,
);
}
// === 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})`,
);
}
// === Rate-based checks (existing logic) ===
@@ -674,11 +712,14 @@ export class SecurityService {
}
// Disable the project
await prisma.project.update({
const disabled = await prisma.project.update({
where: {id: projectId},
data: {disabled: true, disabledReason: 'EMAIL_REPUTATION'},
data: {disabled: true},
select: {public: true, secret: true},
});
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:`,
@@ -932,11 +973,14 @@ ${strippedBody.substring(0, 2000)}`,
}
// Disable the project
await prisma.project.update({
const disabled = await prisma.project.update({
where: {id: projectId},
data: {disabled: true, disabledReason: 'PHISHING_DETECTED'},
data: {disabled: true},
select: {public: true, secret: true},
});
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,14 +908,7 @@ export class WorkflowExecutionService {
}
/**
* 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.
* WEBHOOK step - Call an external webhook
*/
private static async executeWebhook(
_step: WorkflowStep,
@@ -931,37 +924,9 @@ 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 || {};
// 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 || {
const payload = body || {
contact: {
email: contact.email,
subscribed: contact.subscribed,
@@ -979,16 +944,19 @@ export class WorkflowExecutionService {
};
// Make HTTP request
const response = await WorkflowExecutionService.safeFetch(renderedUrl, {
const response = await WorkflowExecutionService.safeFetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...renderedHeaders,
...headers,
},
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
});
const responseData = await response.text();
const {body: responseData, truncated} = await WorkflowExecutionService.readBodyCapped(
response,
WorkflowExecutionService.WEBHOOK_RESPONSE_MAX_BYTES,
);
let parsedResponse;
try {
parsedResponse = JSON.parse(responseData);
@@ -997,34 +965,68 @@ export class WorkflowExecutionService {
}
return {
url: renderedUrl,
url,
method,
statusCode: response.status,
success: response.ok,
response: parsedResponse,
...(truncated ? {truncated: true} : {}),
};
}
private static readonly WEBHOOK_RESPONSE_MAX_BYTES = 64 * 1024;
/**
* 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.
* 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.
*/
private static renderJsonTemplate(value: unknown, variables: Record<string, unknown>): unknown {
if (typeof value === 'string') {
return this.renderTemplate(value, variables);
private static async readBodyCapped(
response: Response,
maxBytes: number,
): Promise<{body: string; truncated: boolean}> {
if (!response.body) {
return {body: '', truncated: false};
}
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);
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
}
return result;
}
return value;
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};
}
/**
@@ -50,20 +50,27 @@ describe('SecurityService', () => {
const complainedCount = opts?.complainedCount ?? 0;
const createdAt = opts?.createdAt ?? new Date();
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});
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);
}
describe('Rate-based checks (existing behavior)', () => {
@@ -102,12 +109,14 @@ 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('Established projects skip absolute ceilings', () => {
// Age the project past the new-project window
describe('Absolute count ceilings (established projects)', () => {
// Age the project past the new-project window so standard ceilings apply
beforeEach(async () => {
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
@@ -116,27 +125,45 @@ describe('SecurityService', () => {
});
});
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});
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});
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', () => {
@@ -151,7 +178,7 @@ describe('SecurityService', () => {
expect(status.violations.some(v => v.includes('new project'))).toBe(true);
});
it('should NOT apply absolute ceilings for projects over 30 days old', async () => {
it('should apply standard 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({
@@ -159,14 +186,15 @@ describe('SecurityService', () => {
data: {createdAt: oldDate},
});
// 10,000 emails, 26 bounces — would trip new-project ceiling, but
// established projects skip ceilings entirely (rate is 0.26%, healthy).
// 10,000 emails, 26 bounces (above 25 new project ceiling, below 50 standard warning ceiling)
await createEmails(10000, {bouncedCount: 26});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isNewProject).toBe(false);
expect(status.warnings.some(w => w.includes('bounce count'))).toBe(false);
expect(status.violations.some(v => v.includes('bounce count'))).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);
});
it('should catch new project blasting emails with delayed bounces', async () => {
@@ -184,8 +212,8 @@ describe('SecurityService', () => {
describe('checkAndEnforceSecurityLimits', () => {
it('should disable project when critical thresholds are exceeded', async () => {
// New project, 20K emails with 30 bounces — exceeds new project 24h critical ceiling
await createEmails(20000, {bouncedCount: 30});
// Create enough bounces to trigger critical
await createEmails(20000, {bouncedCount: 101});
await SecurityService.checkAndEnforceSecurityLimits(projectId);
@@ -197,13 +225,13 @@ describe('SecurityService', () => {
});
it('should NOT disable project when only warnings exist', async () => {
// Established project, 200 emails, 12 bounces = 6% (above 5% warning, below 10% critical)
// 10,000 emails, 51 bounces (above warning but below critical for established project)
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
where: {id: projectId},
data: {createdAt: oldDate},
});
await createEmails(200, {bouncedCount: 12});
await createEmails(10000, {bouncedCount: 51});
await SecurityService.checkAndEnforceSecurityLimits(projectId);
@@ -1,229 +0,0 @@
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');
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ export const Keys = {
return `account:id:${id}`;
},
email(email: string): string {
return `account:${email}`;
return `account:${email.trim().toLowerCase()}`;
},
emailVerificationToken(token: string): string {
return `auth:email_verification:${token}`;
+1 -1
View File
@@ -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 = types.find(t => t.type === 'text/markdown')?.q ?? -1;
const mdQ = getQ(types, 'text/markdown');
const htmlQ = getQ(types, 'text/html');
if (mdQ <= 0 && htmlQ <= 0) return 'none';
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
-1
View File
@@ -725,7 +725,6 @@ 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'}>
@@ -1,126 +0,0 @@
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);
});
});
});
+14 -24
View File
@@ -1,15 +1,10 @@
// 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) {
@@ -33,26 +28,21 @@ export const detectCustomHtmlPatterns = (html: string): boolean => {
}
}
// 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 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);
const hasMediaQueries = /@media/i.test(html);
const hasStyleTags = /<style[^>]*>/i.test(html);
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
return (
hasInlineStyles ||
hasCustomClasses ||
hasCustomAttributes ||
hasComplexTables ||
hasCustomElements ||
hasMediaQueries ||
hasStyleTags
);
};
export const wrapEmailWithStyles = (htmlBody: string): string => {
+2 -2
View File
@@ -252,7 +252,7 @@ export default function CampaignsPage() {
</div>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex flex-col sm:flex-row 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 h-8 text-xs"
className="pl-10 pr-10"
/>
{searchInput && (
<button
+2 -12
View File
@@ -405,12 +405,7 @@ export default function ContactsPage() {
) : (
<MailX className="h-4 w-4 text-red-600" />
)}
<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>
<span className="text-sm font-medium text-neutral-900">{contact.email}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
@@ -460,12 +455,7 @@ export default function ContactsPage() {
) : (
<MailX className="h-4 w-4 text-red-600 flex-shrink-0" />
)}
<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>
<span className="text-sm font-medium text-neutral-900 truncate">{contact.email}</span>
</div>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${
+2 -2
View File
@@ -90,7 +90,7 @@ export default function TemplatesPage() {
</div>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex flex-col sm:flex-row 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 h-8 text-xs"
className="pl-10 pr-10"
/>
{searchInput && (
<button
+1 -1
View File
@@ -117,7 +117,7 @@ export default function WorkflowsPage() {
placeholder="Search workflows..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10 h-8 text-xs"
className="pl-10 pr-10"
/>
{searchInput && (
<button
-13
View File
@@ -6,16 +6,3 @@
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`, header values, and `body` support `{{variables}}`. | `url`, optional `method`, `headers`, `body` |
| `WEBHOOK` | Calls an external HTTPS endpoint with contact + execution context as the JSON body. | `url`, optional `method`, `headers` |
| `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,53 +84,6 @@ 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>
-2
View File
@@ -4,8 +4,6 @@
"---Docs---",
"concepts",
"guides",
"---Recipes---",
"recipes",
"---API Reference---",
"api-reference",
"---Self-Hosting---",
@@ -1,102 +0,0 @@
---
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>
-19
View File
@@ -1,19 +0,0 @@
---
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>
-3
View File
@@ -1,3 +0,0 @@
{
"pages": ["index", "waitlist", "sync-unsubscribes", "double-opt-in"]
}
@@ -1,87 +0,0 @@
---
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>
@@ -1,89 +0,0 @@
---
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>
+1 -1
View File
@@ -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 = types.find(t => t.type === 'text/markdown')?.q ?? -1;
const mdQ = getQ(types, 'text/markdown');
const htmlQ = getQ(types, 'text/html');
if (mdQ <= 0 && htmlQ <= 0) return 'none';
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
@@ -1,5 +0,0 @@
-- CreateEnum
CREATE TYPE "ProjectDisabledReason" AS ENUM ('PAYMENT_FAILED', 'EMAIL_REPUTATION', 'PHISHING_DETECTED', 'MANUAL');
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "disabledReason" "ProjectDisabledReason";
+1 -9
View File
@@ -43,8 +43,7 @@ model Project {
secret String @unique
// Admin
disabled Boolean @default(false)
disabledReason ProjectDisabledReason?
disabled Boolean @default(false)
// Billing
customer String? @unique
@@ -633,13 +632,6 @@ 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
+78 -86
View File
@@ -1,50 +1,6 @@
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
@@ -53,56 +9,44 @@ class TestDatabase {
private prisma: PrismaClient | null = null;
async initialize() {
// 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.
// Use test database URL if provided, otherwise use main database
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');
}
const url = new URL(databaseUrl);
const workerDbName = url.pathname.replace(/^\//, '');
if (!workerDbName) {
throw new Error('DATABASE_URL must include a database name');
}
// 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,
},
},
});
// 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');
}
// Connect to database
await this.prisma.$connect();
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.
// Run migrations (only once per test suite)
try {
execSync('yarn workspace @plunk/db migrate:prod', {
execSync('yarn workspace @plunk/db migrate:dev', {
env: {
...process.env,
DATABASE_URL: url.toString(),
DIRECT_DATABASE_URL: process.env.DIRECT_DATABASE_URL || url.toString(),
DATABASE_URL: databaseUrl,
},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
stdio: 'ignore',
});
} catch (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;
console.warn('Migration warning (may already be up to date):', error);
}
this.prisma = new PrismaClient({
datasources: {db: {url: url.toString()}},
});
await this.prisma.$connect();
}
/**
@@ -116,32 +60,80 @@ class TestDatabase {
}
/**
* 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.
* 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
*/
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 {
await this.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} RESTART IDENTITY CASCADE`);
// 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
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) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 50));
// Wait before retrying (exponential backoff)
const delay = Math.pow(2, attempt) * 50; // 100ms, 200ms, 400ms
await new Promise(resolve => setTimeout(resolve, delay));
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;
}
+1 -3
View File
@@ -108,9 +108,7 @@ export class TestFactories {
async createUser(options: UserFactoryOptions = {}) {
const email = options.email || `user-${uniqueId()}@test.com`;
const password = options.password || 'password123';
// 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);
const hashedPassword = await bcrypt.hash(password, 10);
return this.prisma.user.create({
data: {
+22 -35
View File
@@ -1,52 +1,39 @@
// 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 { beforeAll, afterAll, afterEach, vi } from 'vitest';
import { testDatabase } from './helpers/database';
import dotenv from 'dotenv';
import path from 'path';
import {afterAll, afterEach, beforeAll, vi} from 'vitest';
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';
// Load environment variables from root .env file
dotenv.config({ path: path.resolve(__dirname, '../.env') });
// 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';
+7 -8
View File
@@ -22,19 +22,18 @@ export default defineConfig({
},
testTimeout: 30000,
hookTimeout: 30000,
// 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.
// Memory optimization: Run tests in sequence to prevent memory issues
// This is critical for tests that create large datasets
pool: 'forks',
poolOptions: {
forks: {
// Cap at 4 to stay within Postgres' default max_connections=100
// when each worker uses connection_limit=20.
maxForks: 4,
minForks: 1,
singleFork: true, // Run all tests in a single fork to limit memory
},
},
maxConcurrency: 5,
// Run tests sequentially to avoid database cleanup conflicts
fileParallelism: false,
// Limit concurrent test files to reduce memory pressure
maxConcurrency: 3,
// Only include our test files, not dependency tests
include: [
'apps/**/__tests__/**/*.{test,spec}.{ts,tsx}',
+24 -22
View File
@@ -8101,9 +8101,10 @@ __metadata:
mailparser: "npm:^3.9.8"
morgan: "npm:^1.10.0"
multer: "npm:^2.1.1"
sanitize-html: "npm:^2.17.4"
sanitize-html: "npm:^2.17.3"
signale: "npm:^1.4.0"
stripe: "npm:^20.0.0"
tldts: "npm:^7.0.30"
tsx: "npm:^4.20.6"
languageName: unknown
linkType: soft
@@ -9522,13 +9523,6 @@ __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"
@@ -13298,15 +13292,6 @@ __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"
@@ -17231,18 +17216,17 @@ __metadata:
languageName: node
linkType: hard
"sanitize-html@npm:^2.17.4":
version: 2.17.4
resolution: "sanitize-html@npm:2.17.4"
"sanitize-html@npm:^2.17.3":
version: 2.17.3
resolution: "sanitize-html@npm:2.17.3"
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/5c352376a44bf8a70644f6d4421684000a982f6bda59beac051693d8fc08acbe48dc6358f5c8eb8ae4a815746260167926747a858e6a6e2daf01ccfb775100dd
checksum: 10c0/8afa59bed125b38bf4b437f9b5a3289a4307f42d720e45105de5a0b3d665be70e27d1722d223121993be2e54a2b99304cd9c54317fb2d251fd7f4abf06b68d27
languageName: node
linkType: hard
@@ -18580,6 +18564,24 @@ __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"