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
|
- name: Install dependencies
|
||||||
run: yarn install --frozen-lockfile
|
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
|
- name: Setup environment variables
|
||||||
run: |
|
run: |
|
||||||
cat > .env << EOF
|
cat > .env << EOF
|
||||||
|
|||||||
@@ -39,10 +39,9 @@
|
|||||||
"mailparser": "^3.9.8",
|
"mailparser": "^3.9.8",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"sanitize-html": "^2.17.3",
|
"sanitize-html": "^2.17.4",
|
||||||
"signale": "^1.4.0",
|
"signale": "^1.4.0",
|
||||||
"stripe": "^20.0.0",
|
"stripe": "^20.0.0"
|
||||||
"tldts": "^7.0.30"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -374,14 +374,13 @@ describe('Domain Verification and Ownership Tests', () => {
|
|||||||
// EDGE CASES
|
// EDGE CASES
|
||||||
// ========================================
|
// ========================================
|
||||||
describe('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();
|
const {project} = await factories.createUserWithProject();
|
||||||
|
|
||||||
// DNS is case-insensitive — domains must be stored canonically so a tenant
|
// Domains are typically case-insensitive in DNS, but stored as-is in DB
|
||||||
// can't claim "Example.com" while another project owns "example.com".
|
|
||||||
const domain1 = await DomainService.addDomain(project.id, '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 () => {
|
it('should handle subdomain vs root domain', async () => {
|
||||||
|
|||||||
@@ -321,12 +321,9 @@ export class Auth {
|
|||||||
data: {password: hashedPassword},
|
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.passwordResetToken(token));
|
||||||
await redis.del(Keys.User.id(userId));
|
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'}});
|
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 {redis} from '../database/redis.js';
|
||||||
import {NotAllowed, NotFound} from '../exceptions/index.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 {DomainService} from '../services/DomainService.js';
|
||||||
import {Keys} from '../services/keys.js';
|
import {Keys} from '../services/keys.js';
|
||||||
import {MembershipService} from '../services/MembershipService.js';
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
@@ -17,16 +17,12 @@ export class Domains {
|
|||||||
* Get all domains for a project
|
* Get all domains for a project
|
||||||
*/
|
*/
|
||||||
@Get('project/:projectId')
|
@Get('project/:projectId')
|
||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@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 auth = res.locals.auth;
|
||||||
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
|
||||||
|
|
||||||
// Verify user has access to this project
|
const domains = await DomainService.getProjectDomains(auth.projectId!);
|
||||||
await MembershipService.requireAccess(auth.userId!, projectId);
|
|
||||||
|
|
||||||
const domains = await DomainService.getProjectDomains(projectId);
|
|
||||||
|
|
||||||
return res.status(200).json(domains);
|
return res.status(200).json(domains);
|
||||||
}
|
}
|
||||||
@@ -35,18 +31,17 @@ export class Domains {
|
|||||||
* Add a new domain to a project
|
* Add a new domain to a project
|
||||||
*/
|
*/
|
||||||
@Post('')
|
@Post('')
|
||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
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) {
|
// Require admin role for JWT users (API keys bypass — project-scoped by design)
|
||||||
throw new NotFound('User authentication required');
|
if (auth.type === 'jwt') {
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user has admin access to this project
|
|
||||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
// Block domain changes on disabled projects
|
// Block domain changes on disabled projects
|
||||||
const isDisabled = await SecurityService.isProjectDisabled(projectId);
|
const isDisabled = await SecurityService.isProjectDisabled(projectId);
|
||||||
@@ -68,6 +63,12 @@ export class Domains {
|
|||||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
||||||
|
|
||||||
if (ownershipCheck.exists) {
|
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 domain exists and user is a member of that project, allow it
|
||||||
if (ownershipCheck.isMember) {
|
if (ownershipCheck.isMember) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -99,7 +100,7 @@ export class Domains {
|
|||||||
* Check verification status for a domain
|
* Check verification status for a domain
|
||||||
*/
|
*/
|
||||||
@Get(':id/verify')
|
@Get(':id/verify')
|
||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
@@ -107,13 +108,10 @@ export class Domains {
|
|||||||
|
|
||||||
const domain = await DomainService.id(id);
|
const domain = await DomainService.id(id);
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain || domain.projectId !== auth.projectId) {
|
||||||
throw new NotFound('Domain not found');
|
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);
|
const verificationStatus = await DomainService.checkVerification(id);
|
||||||
|
|
||||||
// Invalidate cache if status changed
|
// Invalidate cache if status changed
|
||||||
@@ -127,7 +125,7 @@ export class Domains {
|
|||||||
* Remove a domain from a project
|
* Remove a domain from a project
|
||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
@@ -135,12 +133,14 @@ export class Domains {
|
|||||||
|
|
||||||
const domain = await DomainService.id(id);
|
const domain = await DomainService.id(id);
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain || domain.projectId !== auth.projectId) {
|
||||||
throw new NotFound('Domain not found');
|
throw new NotFound('Domain not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has admin access to the project this domain belongs to
|
// Require admin role for JWT users (API keys bypass — project-scoped by design)
|
||||||
|
if (auth.type === 'jwt') {
|
||||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
// Block domain changes on disabled projects
|
// Block domain changes on disabled projects
|
||||||
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
|
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 {BillingLimitService} from '../services/BillingLimitService.js';
|
||||||
import {MembershipService} from '../services/MembershipService.js';
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {ProjectService} from '../services/ProjectService.js';
|
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {UserService} from '../services/UserService.js';
|
import {UserService} from '../services/UserService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
@@ -118,8 +117,6 @@ export class Users {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ProjectService.invalidate(id, [{public: project.public, secret: project.secret}]);
|
|
||||||
|
|
||||||
return res.status(200).json(project);
|
return res.status(200).json(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +130,6 @@ export class Users {
|
|||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
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
|
// Generate new unique API keys
|
||||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||||
const secretKey = `sk_${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
|
// Send notification about API key regeneration
|
||||||
await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!);
|
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 {MembershipService} from '../services/MembershipService.js';
|
||||||
import {MeterService} from '../services/MeterService.js';
|
import {MeterService} from '../services/MeterService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {ProjectService} from '../services/ProjectService.js';
|
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.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
|
// Base onboarding credit: refund the 1-unit card-verification charge
|
||||||
let creditBalance = -100;
|
let creditBalance = -100;
|
||||||
|
|
||||||
@@ -578,6 +573,16 @@ export class Webhooks {
|
|||||||
|
|
||||||
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
|
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
|
// Send notification about invoice payment
|
||||||
await NtfyService.notifyInvoicePaid(project.name, project.id);
|
await NtfyService.notifyInvoicePaid(project.name, project.id);
|
||||||
break;
|
break;
|
||||||
@@ -611,11 +616,9 @@ export class Webhooks {
|
|||||||
|
|
||||||
await prisma.project.update({
|
await prisma.project.update({
|
||||||
where: {id: project.id},
|
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);
|
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
|
||||||
|
|
||||||
// Send email notification to project members
|
// 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})`);
|
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
|
||||||
|
|
||||||
// Send notification about subscription cancellation
|
// Send notification about subscription cancellation
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import {getDomain as getRegistrableDomain} from 'tldts';
|
|
||||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
@@ -17,14 +16,6 @@ import {
|
|||||||
} from './SESService.js';
|
} from './SESService.js';
|
||||||
|
|
||||||
export class DomainService {
|
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
|
* Get a domain by ID
|
||||||
*/
|
*/
|
||||||
@@ -50,16 +41,14 @@ export class DomainService {
|
|||||||
* Add a new domain to a project and start verification
|
* Add a new domain to a project and start verification
|
||||||
*/
|
*/
|
||||||
public static async addDomain(projectId: string, domain: string) {
|
public static async addDomain(projectId: string, domain: string) {
|
||||||
const canonical = this.canonicalize(domain);
|
|
||||||
|
|
||||||
// Start verification process with AWS SES
|
// Start verification process with AWS SES
|
||||||
const dkimTokens = await verifyDomain(canonical);
|
const dkimTokens = await verifyDomain(domain);
|
||||||
|
|
||||||
// Create domain record
|
// Create domain record
|
||||||
const newDomain = await prisma.domain.create({
|
const newDomain = await prisma.domain.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
domain: canonical,
|
domain,
|
||||||
verified: false,
|
verified: false,
|
||||||
dkimTokens,
|
dkimTokens,
|
||||||
},
|
},
|
||||||
@@ -71,7 +60,7 @@ export class DomainService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send notification about domain added
|
// Send notification about domain added
|
||||||
await NtfyService.notifyDomainAdded(canonical, newDomain.project.name, projectId);
|
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId);
|
||||||
|
|
||||||
return newDomain;
|
return newDomain;
|
||||||
}
|
}
|
||||||
@@ -364,7 +353,7 @@ export class DomainService {
|
|||||||
throw new HttpException(400, 'Invalid email format');
|
throw new HttpException(400, 'Invalid email format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const domainName = this.canonicalize(emailParts[1] ?? '');
|
const domainName = emailParts[1];
|
||||||
|
|
||||||
// Find domain in database
|
// Find domain in database
|
||||||
const domain = await prisma.domain.findFirst({
|
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.
|
* Extract the registrable root domain (last two labels) from a domain name.
|
||||||
* e.g. "mail.example.com" → "example.com", "mail.example.co.uk" → "example.co.uk"
|
* e.g. "mail.example.com" → "example.com", "example.com" → "example.com"
|
||||||
*/
|
*/
|
||||||
private static rootDomain(domain: string): string {
|
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(
|
public static async checkSubdomainOfDisabledRoot(
|
||||||
domain: string,
|
domain: string,
|
||||||
): Promise<{blocked: boolean; projectName?: string; projectId?: string}> {
|
): Promise<{blocked: boolean; projectName?: string; projectId?: string}> {
|
||||||
const canonical = this.canonicalize(domain);
|
const root = this.rootDomain(domain);
|
||||||
const root = this.rootDomain(canonical);
|
|
||||||
|
|
||||||
// Only relevant when the submitted domain is actually a subdomain
|
// Only relevant when the submitted domain is actually a subdomain
|
||||||
if (root === canonical) {
|
if (root === domain) {
|
||||||
return {blocked: false};
|
return {blocked: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,16 +438,14 @@ export class DomainService {
|
|||||||
* @param userId User ID to check membership
|
* @param userId User ID to check membership
|
||||||
* @returns Object with exists flag and membership info
|
* @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({
|
const existingDomain = await prisma.domain.findFirst({
|
||||||
where: {domain: canonical},
|
where: {domain},
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
include: {
|
select: {
|
||||||
members: {
|
id: true,
|
||||||
where: {userId},
|
name: true,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -468,8 +455,20 @@ export class DomainService {
|
|||||||
return {exists: false};
|
return {exists: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a member of the project that owns this domain
|
let isMember = false;
|
||||||
const isMember = existingDomain.project.members.length > 0;
|
|
||||||
|
if (userId) {
|
||||||
|
const membership = await prisma.membership.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_projectId: {
|
||||||
|
userId,
|
||||||
|
projectId: existingDomain.project.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
isMember = membership !== null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exists: true,
|
exists: true,
|
||||||
|
|||||||
@@ -659,12 +659,16 @@ export class EmailService {
|
|||||||
/**
|
/**
|
||||||
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
* 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.
|
* 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 {
|
private static detectCustomHtmlPatterns(html: string): boolean {
|
||||||
if (!html || html.trim() === '') return false;
|
if (!html || html.trim() === '') return false;
|
||||||
|
|
||||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
|
||||||
|
|
||||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||||
let hasCustomClasses = false;
|
let hasCustomClasses = false;
|
||||||
for (const match of classMatches) {
|
for (const match of classMatches) {
|
||||||
@@ -679,21 +683,21 @@ export class EmailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
// Element-attribute-scoped regex; the leading [\s"'] guard prevents `id=` inside
|
||||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
// href URLs (e.g. `?id=...`) from false-matching as an HTML id attribute.
|
||||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
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 hasMediaQueries = /@media/i.test(html);
|
||||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||||
|
|
||||||
return (
|
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
|
||||||
hasInlineStyles ||
|
|
||||||
hasCustomClasses ||
|
|
||||||
hasCustomAttributes ||
|
|
||||||
hasComplexTables ||
|
|
||||||
hasCustomElements ||
|
|
||||||
hasMediaQueries ||
|
|
||||||
hasStyleTags
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import signale from 'signale';
|
|
||||||
|
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
import {redis, wrapRedis} from '../database/redis.js';
|
import {wrapRedis} from '../database/redis.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
|
|
||||||
export class ProjectService {
|
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 {Keys} from './keys.js';
|
||||||
import {MembershipService} from './MembershipService.js';
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {ProjectService} from './ProjectService.js';
|
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
import {
|
import {
|
||||||
AUTO_PROJECT_DISABLE,
|
AUTO_PROJECT_DISABLE,
|
||||||
@@ -51,22 +50,13 @@ const SECURITY_THRESHOLDS = {
|
|||||||
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||||
MIN_COMPLAINTS_FOR_WARNING: 3,
|
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||||
|
|
||||||
// === Absolute count ceilings ===
|
// === Absolute count ceilings (new projects only) ===
|
||||||
// These trigger regardless of rate — catches high-volume spammers who dilute their bounce rate
|
// These trigger regardless of rate — catches new accounts blasting emails
|
||||||
// 24-hour absolute ceilings
|
// before their bounce rate has caught up. Established projects rely on
|
||||||
BOUNCE_24H_CEILING_WARNING: 50,
|
// rate-based checks only, since high absolute counts at high volume
|
||||||
BOUNCE_24H_CEILING_CRITICAL: 100,
|
// (e.g. 100 bounces out of 10K) don't indicate abuse.
|
||||||
COMPLAINT_24H_CEILING_WARNING: 10,
|
//
|
||||||
COMPLAINT_24H_CEILING_CRITICAL: 25,
|
// Legitimate senders ramp up gradually; spammers blast immediately.
|
||||||
|
|
||||||
// 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_AGE_DAYS: 30,
|
||||||
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
|
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
|
||||||
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
|
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
|
||||||
@@ -517,83 +507,55 @@ export class SecurityService {
|
|||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
// Pick absolute count ceilings based on project age
|
// === Absolute count ceiling checks (new projects only, rate-independent) ===
|
||||||
const bounceCeilings = isNewProject
|
// Catches new accounts blasting emails before their bounce rate catches up.
|
||||||
? {
|
// Established projects skip these — high absolute counts at high volume
|
||||||
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING,
|
// (e.g. 100 bounces out of 10K) don't indicate abuse; rate checks handle them.
|
||||||
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL,
|
if (isNewProject) {
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
const projectLabel = isNewProject ? ' (new project)' : '';
|
|
||||||
|
|
||||||
// === Absolute count ceiling checks (rate-independent) ===
|
|
||||||
// These catch high-volume spammers who dilute their bounce rate by blasting emails
|
|
||||||
|
|
||||||
// 24-hour bounce ceilings
|
// 24-hour bounce ceilings
|
||||||
if (twentyFourHour.bounces >= bounceCeilings.ceiling24hCritical) {
|
if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling24hCritical})`,
|
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL})`,
|
||||||
);
|
);
|
||||||
} else if (twentyFourHour.bounces >= bounceCeilings.ceiling24hWarning) {
|
} else if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling24hWarning})`,
|
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7-day bounce ceilings
|
// 7-day bounce ceilings
|
||||||
if (sevenDay.bounces >= bounceCeilings.ceiling7dCritical) {
|
if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling7dCritical})`,
|
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL})`,
|
||||||
);
|
);
|
||||||
} else if (sevenDay.bounces >= bounceCeilings.ceiling7dWarning) {
|
} else if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling7dWarning})`,
|
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 24-hour complaint ceilings
|
// 24-hour complaint ceilings
|
||||||
if (twentyFourHour.complaints >= complaintCeilings.ceiling24hCritical) {
|
if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling24hCritical})`,
|
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL})`,
|
||||||
);
|
);
|
||||||
} else if (twentyFourHour.complaints >= complaintCeilings.ceiling24hWarning) {
|
} else if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling24hWarning})`,
|
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7-day complaint ceilings
|
// 7-day complaint ceilings
|
||||||
if (sevenDay.complaints >= complaintCeilings.ceiling7dCritical) {
|
if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling7dCritical})`,
|
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL})`,
|
||||||
);
|
);
|
||||||
} else if (sevenDay.complaints >= complaintCeilings.ceiling7dWarning) {
|
} else if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling7dWarning})`,
|
`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) ===
|
// === Rate-based checks (existing logic) ===
|
||||||
// Only enforce if minimum emails threshold is met
|
// Only enforce if minimum emails threshold is met
|
||||||
@@ -712,14 +674,11 @@ export class SecurityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Disable the project
|
// Disable the project
|
||||||
const disabled = await prisma.project.update({
|
await prisma.project.update({
|
||||||
where: {id: projectId},
|
where: {id: projectId},
|
||||||
data: {disabled: true},
|
data: {disabled: true, disabledReason: 'EMAIL_REPUTATION'},
|
||||||
select: {public: true, secret: true},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await ProjectService.invalidate(projectId, [{public: disabled.public, secret: disabled.secret}]);
|
|
||||||
|
|
||||||
// Log critical security event
|
// Log critical security event
|
||||||
signale.error(
|
signale.error(
|
||||||
`[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`,
|
`[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`,
|
||||||
@@ -973,14 +932,11 @@ ${strippedBody.substring(0, 2000)}`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Disable the project
|
// Disable the project
|
||||||
const disabled = await prisma.project.update({
|
await prisma.project.update({
|
||||||
where: {id: projectId},
|
where: {id: projectId},
|
||||||
data: {disabled: true},
|
data: {disabled: true, disabledReason: 'PHISHING_DETECTED'},
|
||||||
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.`;
|
const violation = `A policy violation was detected. Please contact support for more details.`;
|
||||||
|
|
||||||
// Log critical security event
|
// 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(
|
private static async executeWebhook(
|
||||||
_step: WorkflowStep,
|
_step: WorkflowStep,
|
||||||
@@ -924,9 +931,37 @@ export class WorkflowExecutionService {
|
|||||||
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)
|
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)
|
||||||
? (contact.data as Record<string, unknown>)
|
? (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 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: {
|
contact: {
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
subscribed: contact.subscribed,
|
subscribed: contact.subscribed,
|
||||||
@@ -944,19 +979,16 @@ export class WorkflowExecutionService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Make HTTP request
|
// Make HTTP request
|
||||||
const response = await WorkflowExecutionService.safeFetch(url, {
|
const response = await WorkflowExecutionService.safeFetch(renderedUrl, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...headers,
|
...renderedHeaders,
|
||||||
},
|
},
|
||||||
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
|
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {body: responseData, truncated} = await WorkflowExecutionService.readBodyCapped(
|
const responseData = await response.text();
|
||||||
response,
|
|
||||||
WorkflowExecutionService.WEBHOOK_RESPONSE_MAX_BYTES,
|
|
||||||
);
|
|
||||||
let parsedResponse;
|
let parsedResponse;
|
||||||
try {
|
try {
|
||||||
parsedResponse = JSON.parse(responseData);
|
parsedResponse = JSON.parse(responseData);
|
||||||
@@ -965,68 +997,34 @@ export class WorkflowExecutionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url,
|
url: renderedUrl,
|
||||||
method,
|
method,
|
||||||
statusCode: response.status,
|
statusCode: response.status,
|
||||||
success: response.ok,
|
success: response.ok,
|
||||||
response: parsedResponse,
|
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.
|
* Helper: Recursively render template variables in any JSON-shaped value.
|
||||||
* Aborts further reading once the cap is reached so a malicious server
|
* Strings are rendered, arrays/objects are walked, and non-string scalars
|
||||||
* cannot exhaust worker memory.
|
* (numbers, booleans, null) are returned untouched.
|
||||||
*/
|
*/
|
||||||
private static async readBodyCapped(
|
private static renderJsonTemplate(value: unknown, variables: Record<string, unknown>): unknown {
|
||||||
response: Response,
|
if (typeof value === 'string') {
|
||||||
maxBytes: number,
|
return this.renderTemplate(value, variables);
|
||||||
): Promise<{body: string; truncated: boolean}> {
|
|
||||||
if (!response.body) {
|
|
||||||
return {body: '', truncated: false};
|
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
const reader = response.body.getReader();
|
return value.map(item => this.renderJsonTemplate(item, variables));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
chunks.push(value);
|
const result: Record<string, unknown> = {};
|
||||||
received += value.byteLength;
|
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
result[key] = this.renderJsonTemplate(child, variables);
|
||||||
}
|
}
|
||||||
} finally {
|
return result;
|
||||||
try {
|
|
||||||
await reader.cancel();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
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,11 +50,7 @@ describe('SecurityService', () => {
|
|||||||
const complainedCount = opts?.complainedCount ?? 0;
|
const complainedCount = opts?.complainedCount ?? 0;
|
||||||
const createdAt = opts?.createdAt ?? new Date();
|
const createdAt = opts?.createdAt ?? new Date();
|
||||||
|
|
||||||
const emails = [];
|
const data = Array.from({length: count}, (_, i) => ({
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
emails.push(
|
|
||||||
prisma.email.create({
|
|
||||||
data: {
|
|
||||||
projectId,
|
projectId,
|
||||||
contactId,
|
contactId,
|
||||||
subject: `Test ${i}`,
|
subject: `Test ${i}`,
|
||||||
@@ -66,11 +62,8 @@ describe('SecurityService', () => {
|
|||||||
createdAt,
|
createdAt,
|
||||||
bouncedAt: i < bouncedCount ? createdAt : null,
|
bouncedAt: i < bouncedCount ? createdAt : null,
|
||||||
complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null,
|
complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null,
|
||||||
},
|
}));
|
||||||
}),
|
await prisma.email.createMany({data});
|
||||||
);
|
|
||||||
}
|
|
||||||
await Promise.all(emails);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Rate-based checks (existing behavior)', () => {
|
describe('Rate-based checks (existing behavior)', () => {
|
||||||
@@ -109,14 +102,12 @@ describe('SecurityService', () => {
|
|||||||
await createEmails(50, {bouncedCount: 10});
|
await createEmails(50, {bouncedCount: 10});
|
||||||
|
|
||||||
const status = await SecurityService.getSecurityStatus(projectId);
|
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);
|
expect(status.violations).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Absolute count ceilings (established projects)', () => {
|
describe('Established projects skip absolute ceilings', () => {
|
||||||
// Age the project past the new-project window so standard ceilings apply
|
// Age the project past the new-project window
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||||
await prisma.project.update({
|
await prisma.project.update({
|
||||||
@@ -125,45 +116,27 @@ describe('SecurityService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger critical when 24-hour bounce count exceeds ceiling', async () => {
|
it('should NOT trigger on high absolute bounce count when rate is healthy', async () => {
|
||||||
// 20,000 emails, 101 bounces = 0.5% rate (well below rate threshold)
|
// 20,000 emails, 200 bounces = 1% rate (well below rate threshold)
|
||||||
// But 101 bounces > 100 (24h critical ceiling for established projects)
|
// Established projects rely solely on rates — high absolute counts at
|
||||||
await createEmails(20000, {bouncedCount: 101});
|
// high volume don't indicate abuse.
|
||||||
|
await createEmails(20000, {bouncedCount: 200});
|
||||||
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);
|
const status = await SecurityService.getSecurityStatus(projectId);
|
||||||
expect(status.isHealthy).toBe(true);
|
expect(status.isHealthy).toBe(true);
|
||||||
|
expect(status.shouldDisable).toBe(false);
|
||||||
expect(status.violations).toHaveLength(0);
|
expect(status.violations).toHaveLength(0);
|
||||||
expect(status.warnings).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', () => {
|
describe('New project stricter thresholds', () => {
|
||||||
@@ -178,7 +151,7 @@ describe('SecurityService', () => {
|
|||||||
expect(status.violations.some(v => v.includes('new project'))).toBe(true);
|
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
|
// Age the project to 31 days
|
||||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||||
await prisma.project.update({
|
await prisma.project.update({
|
||||||
@@ -186,15 +159,14 @@ describe('SecurityService', () => {
|
|||||||
data: {createdAt: oldDate},
|
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});
|
await createEmails(10000, {bouncedCount: 26});
|
||||||
|
|
||||||
const status = await SecurityService.getSecurityStatus(projectId);
|
const status = await SecurityService.getSecurityStatus(projectId);
|
||||||
expect(status.isNewProject).toBe(false);
|
expect(status.isNewProject).toBe(false);
|
||||||
// 26 is below the 50-bounce 24h warning ceiling for established projects
|
expect(status.warnings.some(w => w.includes('bounce count'))).toBe(false);
|
||||||
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(false);
|
expect(status.violations.some(v => v.includes('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 () => {
|
it('should catch new project blasting emails with delayed bounces', async () => {
|
||||||
@@ -212,8 +184,8 @@ describe('SecurityService', () => {
|
|||||||
|
|
||||||
describe('checkAndEnforceSecurityLimits', () => {
|
describe('checkAndEnforceSecurityLimits', () => {
|
||||||
it('should disable project when critical thresholds are exceeded', async () => {
|
it('should disable project when critical thresholds are exceeded', async () => {
|
||||||
// Create enough bounces to trigger critical
|
// New project, 20K emails with 30 bounces — exceeds new project 24h critical ceiling
|
||||||
await createEmails(20000, {bouncedCount: 101});
|
await createEmails(20000, {bouncedCount: 30});
|
||||||
|
|
||||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
||||||
|
|
||||||
@@ -225,13 +197,13 @@ describe('SecurityService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT disable project when only warnings exist', async () => {
|
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);
|
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||||
await prisma.project.update({
|
await prisma.project.update({
|
||||||
where: {id: projectId},
|
where: {id: projectId},
|
||||||
data: {createdAt: oldDate},
|
data: {createdAt: oldDate},
|
||||||
});
|
});
|
||||||
await createEmails(10000, {bouncedCount: 51});
|
await createEmails(200, {bouncedCount: 12});
|
||||||
|
|
||||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
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}`;
|
return `account:id:${id}`;
|
||||||
},
|
},
|
||||||
email(email: string): string {
|
email(email: string): string {
|
||||||
return `account:${email.trim().toLowerCase()}`;
|
return `account:${email}`;
|
||||||
},
|
},
|
||||||
emailVerificationToken(token: string): string {
|
emailVerificationToken(token: string): string {
|
||||||
return `auth:email_verification:${token}`;
|
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 {
|
function negotiate(accept: string): Negotiated {
|
||||||
if (!accept) return 'html';
|
if (!accept) return 'html';
|
||||||
const types = parseAccept(accept);
|
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');
|
const htmlQ = getQ(types, 'text/html');
|
||||||
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
||||||
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
|
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
|
||||||
|
|||||||
@@ -725,6 +725,7 @@ export default function Index() {
|
|||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.8, delay: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.8, delay: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-xl'}
|
className={'mx-auto max-w-xl'}
|
||||||
|
data-nosnippet
|
||||||
>
|
>
|
||||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||||
<div className={'flex items-center gap-5 p-6'}>
|
<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
|
// 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.
|
// 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 => {
|
export const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||||
if (!html || html.trim() === '') return false;
|
if (!html || html.trim() === '') return false;
|
||||||
|
|
||||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
|
||||||
|
|
||||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||||
let hasCustomClasses = false;
|
let hasCustomClasses = false;
|
||||||
for (const match of classMatches) {
|
for (const match of classMatches) {
|
||||||
@@ -28,21 +33,26 @@ export const detectCustomHtmlPatterns = (html: string): boolean => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
// Custom attributes that carry semantics TipTap doesn't preserve. We require an
|
||||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
// attribute-boundary (whitespace, `=`, or quote) before the prefix so that query
|
||||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
// 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 hasMediaQueries = /@media/i.test(html);
|
||||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||||
|
|
||||||
return (
|
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
|
||||||
hasInlineStyles ||
|
|
||||||
hasCustomClasses ||
|
|
||||||
hasCustomAttributes ||
|
|
||||||
hasComplexTables ||
|
|
||||||
hasCustomElements ||
|
|
||||||
hasMediaQueries ||
|
|
||||||
hasStyleTags
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const wrapEmailWithStyles = (htmlBody: string): string => {
|
export const wrapEmailWithStyles = (htmlBody: string): string => {
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export default function CampaignsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search & Filters */}
|
{/* 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">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||||
<Input
|
<Input
|
||||||
@@ -260,7 +260,7 @@ export default function CampaignsPage() {
|
|||||||
placeholder="Search campaigns..."
|
placeholder="Search campaigns..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={e => setSearchInput(e.target.value)}
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
className="pl-10 pr-10"
|
className="pl-10 pr-10 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
{searchInput && (
|
{searchInput && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -405,7 +405,12 @@ export default function ContactsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<MailX className="h-4 w-4 text-red-600" />
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<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" />
|
<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>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Search & Filters */}
|
{/* 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">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||||
<Input
|
<Input
|
||||||
@@ -98,7 +98,7 @@ export default function TemplatesPage() {
|
|||||||
placeholder="Search templates..."
|
placeholder="Search templates..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={e => setSearchInput(e.target.value)}
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
className="pl-10 pr-10"
|
className="pl-10 pr-10 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
{searchInput && (
|
{searchInput && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function WorkflowsPage() {
|
|||||||
placeholder="Search workflows..."
|
placeholder="Search workflows..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={e => setSearchInput(e.target.value)}
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
className="pl-10 pr-10"
|
className="pl-10 pr-10 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
{searchInput && (
|
{searchInput && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -6,3 +6,16 @@
|
|||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
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`) |
|
| `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) |
|
| `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) |
|
| `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 |
|
| `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` |
|
| `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>
|
||||||
|
|
||||||
<Step>
|
<Step>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"---Docs---",
|
"---Docs---",
|
||||||
"concepts",
|
"concepts",
|
||||||
"guides",
|
"guides",
|
||||||
|
"---Recipes---",
|
||||||
|
"recipes",
|
||||||
"---API Reference---",
|
"---API Reference---",
|
||||||
"api-reference",
|
"api-reference",
|
||||||
"---Self-Hosting---",
|
"---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": "[email protected]", "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": "[email protected]", "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": "[email protected]",
|
||||||
|
"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 {
|
function negotiate(accept: string): Negotiated {
|
||||||
if (!accept) return 'html';
|
if (!accept) return 'html';
|
||||||
const types = parseAccept(accept);
|
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');
|
const htmlQ = getQ(types, 'text/html');
|
||||||
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
||||||
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
|
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";
|
||||||
@@ -44,6 +44,7 @@ model Project {
|
|||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
disabled Boolean @default(false)
|
disabled Boolean @default(false)
|
||||||
|
disabledReason ProjectDisabledReason?
|
||||||
|
|
||||||
// Billing
|
// Billing
|
||||||
customer String? @unique
|
customer String? @unique
|
||||||
@@ -632,6 +633,13 @@ model Event {
|
|||||||
// ENUMS
|
// 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 {
|
enum AuthMethod {
|
||||||
PASSWORD
|
PASSWORD
|
||||||
GOOGLE_OAUTH
|
GOOGLE_OAUTH
|
||||||
|
|||||||
+86
-78
@@ -1,6 +1,50 @@
|
|||||||
import {PrismaClient} from '@plunk/db';
|
import {PrismaClient} from '@plunk/db';
|
||||||
import {execSync} from 'child_process';
|
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
|
* Test database helper
|
||||||
* Manages test database isolation and cleanup
|
* Manages test database isolation and cleanup
|
||||||
@@ -9,44 +53,56 @@ class TestDatabase {
|
|||||||
private prisma: PrismaClient | null = null;
|
private prisma: PrismaClient | null = null;
|
||||||
|
|
||||||
async initialize() {
|
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;
|
const databaseUrl = process.env.TEST_DATABASE_URL || process.env.DATABASE_URL;
|
||||||
|
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
throw new Error('DATABASE_URL or TEST_DATABASE_URL must be set for testing');
|
throw new Error('DATABASE_URL or TEST_DATABASE_URL must be set for testing');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Prisma client with connection pool limits
|
const url = new URL(databaseUrl);
|
||||||
this.prisma = new PrismaClient({
|
const workerDbName = url.pathname.replace(/^\//, '');
|
||||||
datasources: {
|
if (!workerDbName) {
|
||||||
db: {
|
throw new Error('DATABASE_URL must include a database name');
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect to database
|
// Bump the pool above Prisma's default (~5 on CI). Test Postgres has
|
||||||
await this.prisma.$connect();
|
// 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 {
|
try {
|
||||||
execSync('yarn workspace @plunk/db migrate:dev', {
|
execSync('yarn workspace @plunk/db migrate:prod', {
|
||||||
env: {
|
env: {
|
||||||
...process.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) {
|
} 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
|
* Wipe all per-test data with a single TRUNCATE ... CASCADE statement.
|
||||||
* Deletes all records in reverse order of dependencies
|
* Roughly an order of magnitude faster than 14 sequential deleteMany calls
|
||||||
* Uses batched deletes to prevent memory issues with large datasets
|
* — TRUNCATE skips the row scan and only touches table headers.
|
||||||
* Retries on deadlock to handle race conditions with background event tracking
|
|
||||||
*/
|
*/
|
||||||
async cleanup() {
|
async cleanup() {
|
||||||
if (!this.prisma) return;
|
if (!this.prisma) return;
|
||||||
|
|
||||||
|
const tables = TRUNCATE_TABLES.map(t => `"${t}"`).join(', ');
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
// Use a transaction to ensure all deletes happen atomically
|
await this.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} RESTART IDENTITY CASCADE`);
|
||||||
// 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;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error as 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');
|
const isDeadlock = error instanceof Error && error.message?.includes('deadlock detected');
|
||||||
|
|
||||||
if (isDeadlock && attempt < maxRetries) {
|
if (isDeadlock && attempt < maxRetries) {
|
||||||
// Wait before retrying (exponential backoff)
|
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 50));
|
||||||
const delay = Math.pow(2, attempt) * 50; // 100ms, 200ms, 400ms
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not a deadlock or out of retries
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, all retries failed
|
|
||||||
console.error(`Error cleaning up database after ${maxRetries} attempts:`, lastError);
|
console.error(`Error cleaning up database after ${maxRetries} attempts:`, lastError);
|
||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ export class TestFactories {
|
|||||||
async createUser(options: UserFactoryOptions = {}) {
|
async createUser(options: UserFactoryOptions = {}) {
|
||||||
const email = options.email || `user-${uniqueId()}@test.com`;
|
const email = options.email || `user-${uniqueId()}@test.com`;
|
||||||
const password = options.password || 'password123';
|
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({
|
return this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
+34
-21
@@ -1,39 +1,52 @@
|
|||||||
import { beforeAll, afterAll, afterEach, vi } from 'vitest';
|
// IMPORTANT: this file runs before each test file's imports execute.
|
||||||
import { testDatabase } from './helpers/database';
|
// 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 dotenv from 'dotenv';
|
||||||
import path from 'path';
|
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')});
|
||||||
|
|
||||||
// Global test setup
|
// 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';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Initialize test database
|
|
||||||
await testDatabase.initialize();
|
await testDatabase.initialize();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
// Clear all mocks first
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Restore real timers
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
||||||
// Clean up database after each test
|
|
||||||
// This must be last to ensure proper cleanup order
|
|
||||||
await testDatabase.cleanup();
|
await testDatabase.cleanup();
|
||||||
|
|
||||||
// Force garbage collection hint (if available in test environment)
|
|
||||||
if (global.gc) {
|
|
||||||
global.gc();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Disconnect from database
|
|
||||||
await testDatabase.disconnect();
|
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,
|
testTimeout: 30000,
|
||||||
hookTimeout: 30000,
|
hookTimeout: 30000,
|
||||||
// Memory optimization: Run tests in sequence to prevent memory issues
|
// Each fork is a worker with an isolated Postgres database and Redis db-number
|
||||||
// This is critical for tests that create large datasets
|
// (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',
|
pool: 'forks',
|
||||||
poolOptions: {
|
poolOptions: {
|
||||||
forks: {
|
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
|
maxConcurrency: 5,
|
||||||
fileParallelism: false,
|
|
||||||
// Limit concurrent test files to reduce memory pressure
|
|
||||||
maxConcurrency: 3,
|
|
||||||
// Only include our test files, not dependency tests
|
// Only include our test files, not dependency tests
|
||||||
include: [
|
include: [
|
||||||
'apps/**/__tests__/**/*.{test,spec}.{ts,tsx}',
|
'apps/**/__tests__/**/*.{test,spec}.{ts,tsx}',
|
||||||
|
|||||||
@@ -8101,10 +8101,9 @@ __metadata:
|
|||||||
mailparser: "npm:^3.9.8"
|
mailparser: "npm:^3.9.8"
|
||||||
morgan: "npm:^1.10.0"
|
morgan: "npm:^1.10.0"
|
||||||
multer: "npm:^2.1.1"
|
multer: "npm:^2.1.1"
|
||||||
sanitize-html: "npm:^2.17.3"
|
sanitize-html: "npm:^2.17.4"
|
||||||
signale: "npm:^1.4.0"
|
signale: "npm:^1.4.0"
|
||||||
stripe: "npm:^20.0.0"
|
stripe: "npm:^20.0.0"
|
||||||
tldts: "npm:^7.0.30"
|
|
||||||
tsx: "npm:^4.20.6"
|
tsx: "npm:^4.20.6"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
@@ -9523,6 +9522,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"debounce-fn@npm:^6.0.0":
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
resolution: "debounce-fn@npm:6.0.0"
|
resolution: "debounce-fn@npm:6.0.0"
|
||||||
@@ -13292,6 +13298,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"leac@npm:^0.6.0":
|
||||||
version: 0.6.0
|
version: 0.6.0
|
||||||
resolution: "leac@npm:0.6.0"
|
resolution: "leac@npm:0.6.0"
|
||||||
@@ -17216,17 +17231,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"sanitize-html@npm:^2.17.3":
|
"sanitize-html@npm:^2.17.4":
|
||||||
version: 2.17.3
|
version: 2.17.4
|
||||||
resolution: "sanitize-html@npm:2.17.3"
|
resolution: "sanitize-html@npm:2.17.4"
|
||||||
dependencies:
|
dependencies:
|
||||||
deepmerge: "npm:^4.2.2"
|
deepmerge: "npm:^4.2.2"
|
||||||
escape-string-regexp: "npm:^4.0.0"
|
escape-string-regexp: "npm:^4.0.0"
|
||||||
htmlparser2: "npm:^10.1.0"
|
htmlparser2: "npm:^10.1.0"
|
||||||
is-plain-object: "npm:^5.0.0"
|
is-plain-object: "npm:^5.0.0"
|
||||||
|
launder: "npm:^1.7.1"
|
||||||
parse-srcset: "npm:^1.0.2"
|
parse-srcset: "npm:^1.0.2"
|
||||||
postcss: "npm:^8.3.11"
|
postcss: "npm:^8.3.11"
|
||||||
checksum: 10c0/8afa59bed125b38bf4b437f9b5a3289a4307f42d720e45105de5a0b3d665be70e27d1722d223121993be2e54a2b99304cd9c54317fb2d251fd7f4abf06b68d27
|
checksum: 10c0/5c352376a44bf8a70644f6d4421684000a982f6bda59beac051693d8fc08acbe48dc6358f5c8eb8ae4a815746260167926747a858e6a6e2daf01ccfb775100dd
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -18564,24 +18580,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"to-regex-range@npm:^5.0.1":
|
||||||
version: 5.0.1
|
version: 5.0.1
|
||||||
resolution: "to-regex-range@npm:5.0.1"
|
resolution: "to-regex-range@npm:5.0.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user