Compare commits

..
Author SHA1 Message Date
dependabot[bot] 2a3ddfa33c build(deps): bump fast-uri from 3.1.0 to 3.1.2
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-05-09 00:08:52 +00:00
106 changed files with 1522 additions and 4532 deletions
+4 -13
View File
@@ -1,22 +1,13 @@
# Dependencies # Dependencies
node_modules node_modules
**/node_modules
.pnp .pnp
.pnp.js .pnp.js
# Build outputs # Build outputs
# Patterns without a leading `**/` only match the build-context root, NOT dist
# nested directories. In this monorepo the actual outputs live under .next
# apps/*/{dist,.next,.turbo} and packages/*/{dist,.turbo}; without `**/` .turbo
# any stale local builds (from running `yarn dev` or `yarn build` on the out
# host) get copied into the image and override the freshly-built outputs,
# producing silently broken builds (MODULE_NOT_FOUND for whatever the stale
# bundle still references).
**/dist
**/.next
**/.turbo
**/build
**/out
# Development # Development
.env .env
+14 -30
View File
@@ -52,13 +52,6 @@ SES_CONFIGURATION_SET=plunk-configuration-set
# When set, projects can choose to disable email tracking # When set, projects can choose to disable email tracking
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
# Custom MAIL FROM subdomain — used to construct `<subdomain>.<your-domain>`
# when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
# is already used for something else (e.g. an R2/CDN custom domain), since the
# MAIL FROM hostname needs MX + TXT records that can't coexist with a CNAME.
# Example: MAIL_FROM_SUBDOMAIN=emails → emails.<your-domain>
# MAIL_FROM_SUBDOMAIN=
# ======================================== # ========================================
# OPTIONAL: OAuth Login # OPTIONAL: OAuth Login
# ======================================== # ========================================
@@ -67,6 +60,15 @@ GITHUB_OAUTH_SECRET=
GOOGLE_OAUTH_CLIENT= GOOGLE_OAUTH_CLIENT=
GOOGLE_OAUTH_SECRET= GOOGLE_OAUTH_SECRET=
# ========================================
# OPTIONAL: Stripe Billing
# ========================================
STRIPE_SK=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ONBOARDING=
STRIPE_PRICE_EMAIL_USAGE=
STRIPE_METER_EVENT_NAME=emails
# ======================================== # ========================================
# OPTIONAL: File Storage (Minio) # OPTIONAL: File Storage (Minio)
# ======================================== # ========================================
@@ -152,29 +154,11 @@ SMTP_DOMAIN=smtp.example.com
# Default: false # Default: false
# DISABLE_SIGNUPS=false # DISABLE_SIGNUPS=false
# ======================================== # Controls whether email validation checks are performed on signup
# OPTIONAL: SES Sending Rate # When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
# ======================================== # When disabled (false), skips these validation checks and allows any email format
# Caps the email-worker's send rate (messages per second) for the SES sandbox or # Default: false
# manually-throttled accounts. When unset, the worker probes the AWS account at # VERIFY_EMAIL_ON_SIGNUP=false
# startup via ses:GetSendQuota; if that call is denied or transiently fails,
# the worker silently falls back to 14/sec which may exceed sandbox limits and
# trigger SES throttling errors. Set this explicitly to avoid the silent fallback.
# Default: unset (auto-detect, falls back to 14)
# EMAIL_RATE_LIMIT_PER_SECOND=1
# Number of emails the worker processes in parallel. When unset, concurrency is
# derived from the effective rate limit (~ rate * 0.5, min 5, capped by
# EMAIL_WORKER_MAX_CONCURRENCY) so a higher SES quota translates into higher
# throughput automatically. Pin this only when the Prisma pool or memory is the
# binding constraint.
# Default: unset (auto-derived)
# EMAIL_WORKER_CONCURRENCY=10
# Upper bound applied to the auto-derived concurrency. Raise this when your SES
# quota is high AND the Prisma connection pool has been sized for it.
# Default: 50
# EMAIL_WORKER_MAX_CONCURRENCY=50
# ======================================== # ========================================
# ADVANCED (rarely needed) # ADVANCED (rarely needed)
-12
View File
@@ -65,18 +65,6 @@ 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
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.11.0" ".": "0.10.0"
} }
-56
View File
@@ -1,61 +1,5 @@
# Changelog # Changelog
## [0.11.0](https://github.com/useplunk/plunk/compare/v0.10.0...v0.11.0) (2026-05-13)
### Features
* Ability to change subscription status in workflows ([fadc19d](https://github.com/useplunk/plunk/commit/fadc19d139084550eb442a8b8368edcd6075cad0))
* add 'notTriggeredWithin' operator to segment filters for enhanced event tracking ([0f00ca1](https://github.com/useplunk/plunk/commit/0f00ca1b8c9bd95f2158af845218ef432ab0d498))
* add external link to edit email templates in SendEmailStepDialog and WorkflowBuilder ([4ba43dd](https://github.com/useplunk/plunk/commit/4ba43dd3b64776413f813e091f527aa3e56bfc84))
* add Markdown cut link to footer and page for improved accessibility ([ccd516e](https://github.com/useplunk/plunk/commit/ccd516e4e882921fb1a8a3e87c47710e51e35311))
* add project switching functionality to command palette ([5724ab9](https://github.com/useplunk/plunk/commit/5724ab9536ea4e3bb4e916e963b1f39ba51bb7f3))
* add sanitize-html for improved email content sanitization ([735acff](https://github.com/useplunk/plunk/commit/735acff45423cb537f7aa636b9a9d31830775f93))
* add search functionality to campaigns list with debounce effect ([d114950](https://github.com/useplunk/plunk/commit/d11495061d907bcee699d56987784fa7ebe31bf6))
* add segment membership operators and enhance segment filter functionality ([a3cc622](https://github.com/useplunk/plunk/commit/a3cc62213f40f6a9341113b73b52852292fb9a10))
* add SwitchOffer component to promote switching from competitors for enhanced user engagement ([6195b39](https://github.com/useplunk/plunk/commit/6195b39f3dbdd9fe783b79f9ae7f7942509a49a0))
* add workflow duplication functionality with API endpoint and UI button ([c6340a1](https://github.com/useplunk/plunk/commit/c6340a1dc7385723d4ec30779340218935d814e6))
* enhance campaign scheduling and audience settings UI for better clarity and usability ([7658a59](https://github.com/useplunk/plunk/commit/7658a59b5df4e325ad9eca37356cad3cbb70e942))
* implement bulk contact action selector for improved flexibility in bulk operations ([de6335e](https://github.com/useplunk/plunk/commit/de6335e99999242f81e9eda9a20aeccab80a2de4))
* implement caching for recent activity count to optimize performance and reduce database load ([f22da4a](https://github.com/useplunk/plunk/commit/f22da4add1e455678f380aba8c2fd02012ef6457))
* implement early fraud warning handling in webhooks ([48425d1](https://github.com/useplunk/plunk/commit/48425d1df160a3fd5edb4cbdcc9e6725628db319))
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env ([463301b](https://github.com/useplunk/plunk/commit/463301b5db27b08b483db3c56a992ac62d6653b8))
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env var ([e0bf0f6](https://github.com/useplunk/plunk/commit/e0bf0f628af2a155ca3bbc38ef5c2f4ecc0455e6))
* refactor template editing layout for improved usability and clarity ([aaf5ac6](https://github.com/useplunk/plunk/commit/aaf5ac65307266a4d9deb02986b610757e929b49))
### Bug Fixes
* enhance campaign finalization process to handle pending emails and ensure accurate status updates ([2529c9b](https://github.com/useplunk/plunk/commit/2529c9b4282b003a94013259119e98654ae22b46))
* enhance email content parsing and logging for better debugging ([4587e9d](https://github.com/useplunk/plunk/commit/4587e9d670fa3a27fbc1ca7669c8fc75d804e85e))
* enhance project name validation to exclude invisible and decorative characters ([29883ff](https://github.com/useplunk/plunk/commit/29883ffc7104d4beb13965bfa65ea609367ede69))
* enhance Quick Start card layout for improved responsiveness and usability ([649bbf6](https://github.com/useplunk/plunk/commit/649bbf6d6b9db5139295a455c69425a5565d4323))
* handle undefined path in footer component for improved stability ([715961c](https://github.com/useplunk/plunk/commit/715961c007e450731434e5f343acff0debd6a851))
* implement mergeContactData method for efficient contact data updates ([a27d564](https://github.com/useplunk/plunk/commit/a27d564e1af6e5cf72c9ef405650f2475bfd0c86))
* improve iframe height adjustment logic in EmailEditor component ([9e4aa94](https://github.com/useplunk/plunk/commit/9e4aa9443b53ef38a3af1b269f4a949dd57e758f))
* pass DISABLE_SIGNUPS and EMAIL_RATE_LIMIT_PER_SECOND through compose; trim .env.self-host.example ([8a26d60](https://github.com/useplunk/plunk/commit/8a26d605b4aa5fdea0e02a17159c1cae478a7e47))
* pass missing env variables from .env.self-host.example to plunk service ([f178c59](https://github.com/useplunk/plunk/commit/f178c59b5bc461f9ba57dc6f0bac9617feaab4a3))
* replace font loading method with utility function for improved performance ([ba23a1c](https://github.com/useplunk/plunk/commit/ba23a1c6fa70ca6fa0343391fa8d642155ad45c2))
* update activity item colors and backgrounds for improved visual distinction ([e43f70d](https://github.com/useplunk/plunk/commit/e43f70d8a135b048d4e9ed013ffa25f1b2f89687))
* update middleware matcher to exclude webmanifest files ([cbb3bff](https://github.com/useplunk/plunk/commit/cbb3bffcf4f789e9c36912c9b0ce0ba9f6a1d4dd))
* update not found handling in GET route to return 404 response ([7615b62](https://github.com/useplunk/plunk/commit/7615b62945aa1939bf6a5b3638c232509090de60))
* update segment filter logic to retain value and unit, enhance activity name mapping ([d1f357c](https://github.com/useplunk/plunk/commit/d1f357c300c7798d5c8d521e1219e39e7da821dd))
* update segment filter logic to retain value and unit, enhance activity name mapping ([a126563](https://github.com/useplunk/plunk/commit/a1265635420e66b7cdef02a7136fdf9c3aec4418))
* update TemplateSearchPicker to maintain selected template name on change ([2ea1802](https://github.com/useplunk/plunk/commit/2ea1802290a4378d68e3878a44e502ea8541b301))
### Code Refactoring
* convert forwardRef components to function components for consistency ([361ec0b](https://github.com/useplunk/plunk/commit/361ec0b1eb17647696656c6a6d4cddd5b9348b45))
* implement step dialog components for workflow editing ([ed9027b](https://github.com/useplunk/plunk/commit/ed9027b4ef9c37e91c69ed5a90d5b74c08c7a054))
* remove unused .png files ([2d05c3f](https://github.com/useplunk/plunk/commit/2d05c3fbc1ded87880a307943bb93106b18cd406))
### Documentation
* expand documentation with new sections on importing contacts, unsubscribe pages, and API key management ([4ddafdc](https://github.com/useplunk/plunk/commit/4ddafdc0419389818de7e45eb4a0c82c2e9382eb))
* update README to clarify self-hosted alternative and add inbound emails feature ([80ef65e](https://github.com/useplunk/plunk/commit/80ef65e04b8c362f05ae5b7d5710b398a150cce5))
## [0.10.0](https://github.com/useplunk/plunk/compare/v0.9.0...v0.10.0) (2026-05-01) ## [0.10.0](https://github.com/useplunk/plunk/compare/v0.9.0...v0.10.0) (2026-05-01)
+1 -16
View File
@@ -161,7 +161,7 @@ Required for builds and deployment (see turbo.json and .env.example):
- `OPENROUTER_API_KEY` - API key for OpenRouter (enables phishing detection) - `OPENROUTER_API_KEY` - API key for OpenRouter (enables phishing detection)
- `OPENROUTER_MODEL` (default: anthropic/claude-3-haiku) - LLM model to use for content analysis - `OPENROUTER_MODEL` (default: anthropic/claude-3-haiku) - LLM model to use for content analysis
- `PHISHING_DETECTION_SAMPLE_RATE` (default: 0.1) - Percentage of emails to check (0.0-1.0, e.g., 0.1 = 10%) - `PHISHING_DETECTION_SAMPLE_RATE` (default: 0.1) - Percentage of emails to check (0.0-1.0, e.g., 0.1 = 10%)
- `PHISHING_CONFIDENCE_THRESHOLD` (default: 95) - Minimum confidence percentage (0-100) to auto-disable project for single detection - `PHISHING_CONFIDENCE_THRESHOLD` (default: 85) - Minimum confidence percentage (0-100) to auto-disable project for single detection
- `PHISHING_CUMULATIVE_THRESHOLD` (default: 3) - Number of phishing detections within time window to trigger auto-disable - `PHISHING_CUMULATIVE_THRESHOLD` (default: 3) - Number of phishing detections within time window to trigger auto-disable
- `PHISHING_CUMULATIVE_WINDOW_MS` (default: 3600000) - Time window in milliseconds for cumulative tracking (default 1 hour) - `PHISHING_CUMULATIVE_WINDOW_MS` (default: 3600000) - Time window in milliseconds for cumulative tracking (default 1 hour)
@@ -174,21 +174,6 @@ Required for builds and deployment (see turbo.json and .env.example):
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for - **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
client-side access client-side access
## Environment Variable Changes
When you add, rename, remove, or change the default/behaviour of any environment variable, you MUST update all THREE of the following in the same change:
1. `apps/api/.env.example` — local development defaults
2. `.env.self-host.example` — self-hosting / production template
3. `apps/wiki/content/docs/self-hosting/environment-variables.mdx` — user-facing reference
Rules:
- If the variable already exists in any file, **modify** its line/row/description — do not duplicate or leave a stale entry.
- Keep section/category names consistent across all three files (e.g. "AWS SES", "Phishing Detection").
- For dev-only or self-host-only variables, still mention them in the wiki and note the scope; only skip the example file where the variable is genuinely never applicable.
- When in doubt about whether a variable belongs in `apps/api/.env.example` (development), include it commented out with a short note.
## Plugins ## Plugins
There are two plugins installed for you to use. There are two plugins installed for you to use.
-58
View File
@@ -8,8 +8,6 @@
# ============================================================================== # ==============================================================================
NODE_ENV=development NODE_ENV=development
JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
# Port the API server listens on (default: 8080)
# PORT=8080
# ============================================================================== # ==============================================================================
# Application URLs # Application URLs
@@ -27,8 +25,6 @@ LANDING_URI=http://localhost:4000
# ============================================================================== # ==============================================================================
# API key for authenticating with the Plunk API (obtained from dashboard) # API key for authenticating with the Plunk API (obtained from dashboard)
PLUNK_API_KEY= PLUNK_API_KEY=
# From address used for platform notification emails (project disabled, billing limits, etc.)
# PLUNK_FROM_ADDRESS=
# ============================================================================== # ==============================================================================
# Database & Redis # Database & Redis
@@ -65,17 +61,6 @@ SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optiona
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts) # Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
# EMAIL_RATE_LIMIT_PER_SECOND=14 # EMAIL_RATE_LIMIT_PER_SECOND=14
# Email worker concurrency (number of emails processed in parallel)
# If not set, derived from the effective rate limit (~ rate * 0.5, min 5, capped
# by EMAIL_WORKER_MAX_CONCURRENCY). Set this to pin a fixed value when the
# Prisma connection pool or memory is the binding constraint.
# EMAIL_WORKER_CONCURRENCY=10
# Upper bound for auto-derived worker concurrency
# Raise this when your SES quota is high AND the Prisma pool has been sized for it.
# Default: 50
# EMAIL_WORKER_MAX_CONCURRENCY=50
# ============================================================================== # ==============================================================================
# OAuth (Optional - for social login) # OAuth (Optional - for social login)
# ============================================================================== # ==============================================================================
@@ -100,46 +85,3 @@ STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe mete
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually) # Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
# Default: true (automatic project disabling enabled) # Default: true (automatic project disabling enabled)
# AUTO_PROJECT_DISABLE=true # AUTO_PROJECT_DISABLE=true
# ==============================================================================
# Notifications (Optional - system notifications via ntfy)
# ==============================================================================
# ntfy topic URL for internal system notifications (e.g. project disabled, billing limits).
# When unset, ntfy notifications are disabled.
# Examples:
# - Public ntfy.sh: https://ntfy.sh/your-unique-topic-name
# - Self-hosted: https://your-ntfy-server.com/your-topic
# NTFY_URL=
# ==============================================================================
# Attachments (Optional)
# ==============================================================================
# Limits applied to attachments on transactional emails.
# AWS SES caps total message size at 40 MB; the defaults below leave headroom.
# MAX_ATTACHMENT_SIZE_MB=10
# MAX_ATTACHMENTS_COUNT=10
# ==============================================================================
# User Management (Optional)
# ==============================================================================
# When 'true', the signup endpoint rejects new user registrations.
# Default: false
# DISABLE_SIGNUPS=false
# When 'true', validates emails on signup (disposable domains, plus-addressing,
# domain existence, MX records).
# Default: false
# VERIFY_EMAIL_ON_SIGNUP=false
# ==============================================================================
# Phishing Detection (Optional - AI-powered phishing scan via OpenRouter)
# ==============================================================================
# When OPENROUTER_API_KEY is set, a random sample of outgoing emails is
# analyzed by an LLM. Projects can be auto-disabled if a single email exceeds
# PHISHING_CONFIDENCE_THRESHOLD, or if PHISHING_CUMULATIVE_THRESHOLD emails are
# flagged within PHISHING_CUMULATIVE_WINDOW_MS.
# OPENROUTER_API_KEY=
# OPENROUTER_MODEL=anthropic/claude-3-haiku
# PHISHING_DETECTION_SAMPLE_RATE=0.1
# PHISHING_CONFIDENCE_THRESHOLD=95
# PHISHING_CUMULATIVE_THRESHOLD=3
# PHISHING_CUMULATIVE_WINDOW_MS=3600000
-20
View File
@@ -45,12 +45,6 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID'); export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY'); export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
// Custom MAIL FROM subdomain used to construct `<subdomain>.<your-domain>`
// when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
// is already used for something else (e.g. a CDN), since the MAIL FROM hostname
// needs MX + TXT records that can't coexist with a CNAME.
export const MAIL_FROM_SUBDOMAIN = validateEnv('MAIL_FROM_SUBDOMAIN', '').trim() || 'plunk';
// Email Processing Rate Limit (optional override) // Email Processing Rate Limit (optional override)
// If not set, will automatically fetch from AWS SES account quota // If not set, will automatically fetch from AWS SES account quota
// Set this to override AWS quota (useful for setting lower limits or testing) // Set this to override AWS quota (useful for setting lower limits or testing)
@@ -58,20 +52,6 @@ export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECO
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND) ? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
: undefined; : undefined;
// Email Worker Concurrency (optional override)
// If not set, concurrency is derived from the effective rate limit so a higher
// SES quota actually translates into higher throughput. Set this to pin a fixed
// value (useful when Prisma pool size or memory is the binding constraint).
export const EMAIL_WORKER_CONCURRENCY = process.env.EMAIL_WORKER_CONCURRENCY
? Number(process.env.EMAIL_WORKER_CONCURRENCY)
: undefined;
// Upper bound for auto-derived concurrency. Raise this if you have a large SES
// quota AND have sized the Prisma connection pool accordingly.
export const EMAIL_WORKER_MAX_CONCURRENCY = process.env.EMAIL_WORKER_MAX_CONCURRENCY
? Number(process.env.EMAIL_WORKER_MAX_CONCURRENCY)
: 50;
// Storage // Storage
export const REDIS_URL = validateEnv('REDIS_URL'); export const REDIS_URL = validateEnv('REDIS_URL');
export const DATABASE_URL = validateEnv('DATABASE_URL'); export const DATABASE_URL = validateEnv('DATABASE_URL');
-2
View File
@@ -64,7 +64,6 @@ export class Campaigns {
private async list(req: Request, res: Response, _next: NextFunction) { private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; const auth = res.locals.auth;
const status = req.query.status as CampaignStatus | undefined; const status = req.query.status as CampaignStatus | undefined;
const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20; const pageSize = parseInt(req.query.pageSize as string) || 20;
@@ -75,7 +74,6 @@ export class Campaigns {
const result = await CampaignService.list(auth.projectId, { const result = await CampaignService.list(auth.projectId, {
status, status,
search,
page, page,
pageSize, pageSize,
}); });
-2
View File
@@ -8,7 +8,6 @@ import {
GITHUB_OAUTH_ENABLED, GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED,
LANDING_URI, LANDING_URI,
MAIL_FROM_SUBDOMAIN,
NODE_ENV, NODE_ENV,
S3_ENABLED, S3_ENABLED,
SMTP_DOMAIN, SMTP_DOMAIN,
@@ -63,7 +62,6 @@ export class Config {
}, },
aws: { aws: {
sesRegion: AWS_SES_REGION, sesRegion: AWS_SES_REGION,
mailFromSubdomain: MAIL_FROM_SUBDOMAIN,
}, },
}); });
} }
+73 -38
View File
@@ -1,8 +1,6 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import multer from 'multer'; import multer from 'multer';
import {ContactSchemas} from '@plunk/shared';
import type {BulkContactActionSelector} from '@plunk/types';
import signale from 'signale'; import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js'; import {ContactService} from '../services/ContactService.js';
@@ -426,7 +424,31 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) { public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
return queueBulkAction(req, res, 'subscribe'); const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
// Validate limit
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
return res.status(202).json({
message: 'Bulk subscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
});
}
} }
/** /**
@@ -437,7 +459,30 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) { public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
return queueBulkAction(req, res, 'unsubscribe'); const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
return res.status(202).json({
message: 'Bulk unsubscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
});
}
} }
/** /**
@@ -448,7 +493,30 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) { public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
return queueBulkAction(req, res, 'delete'); const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
return res.status(202).json({
message: 'Bulk delete queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
});
}
} }
/** /**
@@ -482,36 +550,3 @@ export class Contacts {
} }
} }
} }
async function queueBulkAction(
req: Request,
res: Response,
operation: 'subscribe' | 'unsubscribe' | 'delete',
) {
const auth = res.locals.auth;
const parsed = ContactSchemas.bulkAction.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: parsed.error.errors[0]?.message ?? 'Invalid bulk action payload',
});
}
const selector: BulkContactActionSelector =
parsed.data.mode === 'ids'
? {mode: 'ids', contactIds: parsed.data.contactIds}
: {mode: 'query', filter: parsed.data.filter, excludeIds: parsed.data.excludeIds};
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, selector, operation);
return res.status(202).json({
message: `Bulk ${operation} queued successfully`,
jobId: job.id,
});
} catch (error) {
signale.error(`[CONTACTS] Failed to queue bulk ${operation}:`, error);
return res.status(500).json({
error: error instanceof Error ? error.message : `Failed to queue bulk ${operation}`,
});
}
}
+23 -23
View File
@@ -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 {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {isAuthenticated, 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,12 +17,16 @@ export class Domains {
* Get all domains for a project * Get all domains for a project
*/ */
@Get('project/:projectId') @Get('project/:projectId')
@Middleware([requireAuth, requireEmailVerified]) @Middleware([isAuthenticated, 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);
const domains = await DomainService.getProjectDomains(auth.projectId!); // Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, projectId);
const domains = await DomainService.getProjectDomains(projectId);
return res.status(200).json(domains); return res.status(200).json(domains);
} }
@@ -31,18 +35,19 @@ export class Domains {
* Add a new domain to a project * Add a new domain to a project
*/ */
@Post('') @Post('')
@Middleware([requireAuth, requireEmailVerified]) @Middleware([isAuthenticated, 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 {domain} = DomainSchemas.create.parse(req.body); const {projectId, domain} = DomainSchemas.create.parse(req.body);
const projectId = auth.projectId!;
// Require admin role for JWT users (API keys bypass — project-scoped by design) if (!auth.userId) {
if (auth.type === 'jwt') { throw new NotFound('User authentication required');
await MembershipService.requireAdminAccess(auth.userId!, projectId);
} }
// Verify user has admin access to this project
await MembershipService.requireAdminAccess(auth.userId!, projectId);
// Block domain changes on disabled projects // Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(projectId); const isDisabled = await SecurityService.isProjectDisabled(projectId);
if (isDisabled) { if (isDisabled) {
@@ -63,12 +68,6 @@ 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({
@@ -100,7 +99,7 @@ export class Domains {
* Check verification status for a domain * Check verification status for a domain
*/ */
@Get(':id/verify') @Get(':id/verify')
@Middleware([requireAuth, requireEmailVerified]) @Middleware([isAuthenticated, 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;
@@ -108,10 +107,13 @@ export class Domains {
const domain = await DomainService.id(id); const domain = await DomainService.id(id);
if (!domain || domain.projectId !== auth.projectId) { if (!domain) {
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
@@ -125,7 +127,7 @@ export class Domains {
* Remove a domain from a project * Remove a domain from a project
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth, requireEmailVerified]) @Middleware([isAuthenticated, 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;
@@ -133,14 +135,12 @@ export class Domains {
const domain = await DomainService.id(id); const domain = await DomainService.id(id);
if (!domain || domain.projectId !== auth.projectId) { if (!domain) {
throw new NotFound('Domain not found'); throw new NotFound('Domain not found');
} }
// Require admin role for JWT users (API keys bypass — project-scoped by design) // Verify user has admin access to the project this domain belongs to
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);
-8
View File
@@ -230,14 +230,6 @@ export class Users {
client_reference_id: project.id, // Store project ID for webhook client_reference_id: project.id, // Store project ID for webhook
line_items: lineItems, line_items: lineItems,
...(checkoutCurrency && {currency: checkoutCurrency}), ...(checkoutCurrency && {currency: checkoutCurrency}),
custom_fields: [
{
key: 'promo_code',
label: {type: 'custom', custom: 'Promo code'},
type: 'text',
optional: true,
},
],
subscription_data: { subscription_data: {
billing_cycle_anchor: billingCycleAnchor, billing_cycle_anchor: billingCycleAnchor,
}, },
+2 -26
View File
@@ -530,24 +530,10 @@ export class Webhooks {
}, },
}); });
// Base onboarding credit: refund the 1-unit card-verification charge
let creditBalance = -100;
// Switching-offer promo: 2 extra units of credit if the customer typed SWITCH
// into the Promo code custom field on Stripe Checkout.
const promoField = session.custom_fields?.find((f) => f.key === 'promo_code');
const promoCode = promoField?.text?.value?.trim().toUpperCase();
if (promoCode === 'SWITCH') {
creditBalance -= 200;
signale.success(`[WEBHOOK] SWITCH promo applied for project ${projectId}`);
} else if (promoCode) {
signale.info(`[WEBHOOK] Unknown promo code "${promoCode}" entered for project ${projectId}`);
}
// Update Stripe customer name to match project name and add credit for onboarding fee // Update Stripe customer name to match project name and add credit for onboarding fee
await stripe.customers.update(customerId, { await stripe.customers.update(customerId, {
name: updatedProject.name, name: updatedProject.name,
balance: creditBalance, balance: -100,
}); });
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`); signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
@@ -573,16 +559,6 @@ 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;
@@ -616,7 +592,7 @@ export class Webhooks {
await prisma.project.update({ await prisma.project.update({
where: {id: project.id}, where: {id: project.id},
data: {disabled: true, disabledReason: 'PAYMENT_FAILED'}, data: {disabled: true},
}); });
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id); await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
-20
View File
@@ -149,26 +149,6 @@ export class Workflows {
return res.status(204).send(); return res.status(204).send();
} }
/**
* POST /workflows/:id/duplicate
* Duplicate a workflow (always disabled, no execution state)
*/
@Post(':id/duplicate')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const workflow = await WorkflowService.duplicate(auth.projectId!, workflowId);
return res.status(201).json(workflow);
}
/** /**
* POST /workflows/:id/steps * POST /workflows/:id/steps
* Add a step to a workflow * Add a step to a workflow
@@ -1,7 +1,6 @@
import {beforeEach, describe, expect, it} from 'vitest'; import {beforeEach, describe, expect, it} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
import {ContactService} from '../../services/ContactService.js'; import {ContactService} from '../../services/ContactService.js';
import {coerceCustomValue} from '../import-processor.js';
/** /**
* Tests for Contact Import Processor - Subscription Status Preservation * Tests for Contact Import Processor - Subscription Status Preservation
@@ -280,51 +279,3 @@ describe('Contact Import - Subscription Status Preservation', () => {
}); });
}); });
}); });
describe('coerceCustomValue', () => {
describe('boolean coercion', () => {
it.each(['true', 'TRUE', 'True', ' true ', 'yes', 'YES', 'Yes'])('coerces %j to true', value => {
expect(coerceCustomValue(value)).toBe(true);
});
it.each(['false', 'FALSE', 'False', ' false ', 'no', 'NO', 'No'])('coerces %j to false', value => {
expect(coerceCustomValue(value)).toBe(false);
});
});
describe('number coercion', () => {
it.each([
['42', 42],
['-7', -7],
['3.14', 3.14],
[' 42 ', 42],
['0.5', 0.5],
['-0.25', -0.25],
['0', 0],
['1', 1],
])('coerces %j to %j', (value, expected) => {
expect(coerceCustomValue(value)).toBe(expected);
});
it.each(['01234', '+42', '.5', '42.', '1e10', 'NaN', 'Infinity', '1.2.3', '4-2'])(
'leaves %j as a string (preserves IDs / rejects loose formats)',
value => {
expect(coerceCustomValue(value)).toBe(value);
},
);
it('"1.0" is a number (does not match the boolean truthy set)', () => {
expect(coerceCustomValue('1.0')).toBe(1);
});
});
describe('passthrough', () => {
it.each(['Alice', 'true!', 'yesno', 'maybe'])('leaves %j as a string', value => {
expect(coerceCustomValue(value)).toBe(value);
});
it('leaves empty string as empty string', () => {
expect(coerceCustomValue('')).toBe('');
});
});
});
+45 -118
View File
@@ -3,149 +3,73 @@
* Processes bulk subscribe, unsubscribe, and delete operations * Processes bulk subscribe, unsubscribe, and delete operations
*/ */
import {Prisma} from '@plunk/db'; import type {BulkContactActionJobData} from '@plunk/types';
import type {BulkContactActionJobData, BulkContactActionSelector} from '@plunk/types';
import {type Job, Worker} from 'bullmq'; import {type Job, Worker} from 'bullmq';
import signale from 'signale'; import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js'; import {ContactService} from '../services/ContactService.js';
import {bulkContactQueue} from '../services/QueueService.js'; import {bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; const BATCH_SIZE = 100; // Process contacts in batches of 100
interface BulkActionResult { interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete'; operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number; totalRequested: number;
/** Contacts whose state was actually changed by this run. */
successCount: number; successCount: number;
/** Subscribe/unsubscribe only: contacts already in the target state. */
unchangedCount: number;
/** Contacts that errored or weren't found (e.g. wrong project). */
failureCount: number; failureCount: number;
errors: {contactId: string; email: string; error: string}[]; errors: {contactId: string; email: string; error: string}[];
} }
function buildQueryWhere(projectId: string, selector: Extract<BulkContactActionSelector, {mode: 'query'}>): Prisma.ContactWhereInput {
const search = selector.filter?.search;
const excludeIds = selector.excludeIds ?? [];
return {
projectId,
...(search ? {email: {contains: search, mode: 'insensitive' as const}} : {}),
...(excludeIds.length > 0 ? {id: {notIn: excludeIds}} : {}),
};
}
async function applyBatch(
projectId: string,
operation: BulkActionResult['operation'],
ids: string[],
): Promise<{changed: number; unchanged: number}> {
switch (operation) {
case 'subscribe': {
const r = await ContactService.bulkSubscribe(projectId, ids);
return {changed: r.updated, unchanged: r.unchanged};
}
case 'unsubscribe': {
const r = await ContactService.bulkUnsubscribe(projectId, ids);
return {changed: r.updated, unchanged: r.unchanged};
}
case 'delete': {
const r = await ContactService.bulkDelete(projectId, ids);
return {changed: r.deleted, unchanged: 0};
}
}
}
export function createBulkContactWorker() { export function createBulkContactWorker() {
const worker = new Worker<BulkContactActionJobData>( const worker = new Worker<BulkContactActionJobData>(
bulkContactQueue.name, bulkContactQueue.name,
async (job: Job<BulkContactActionJobData>) => { async (job: Job<BulkContactActionJobData>) => {
const {projectId, operation, selector} = job.data; const {projectId, contactIds, operation} = job.data;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
);
const result: BulkActionResult = { const result: BulkActionResult = {
operation, operation,
totalRequested: 0, totalRequested: contactIds.length,
successCount: 0, successCount: 0,
unchangedCount: 0,
failureCount: 0, failureCount: 0,
errors: [], errors: [],
}; };
if (selector.mode === 'ids') { try {
const {contactIds} = selector; // Process contacts in batches
result.totalRequested = contactIds.length;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts (ids mode) in project ${projectId}`,
);
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) { for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
const batchIds = contactIds.slice(i, i + BATCH_SIZE); const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
try {
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
result.successCount += changed;
result.unchangedCount += unchanged;
const failed = batchIds.length - changed - unchanged;
if (failed > 0) result.failureCount += failed;
} catch (error) {
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
result.failureCount += batchIds.length;
result.errors.push({
contactId: 'batch',
email: '',
error: error instanceof Error ? error.message : 'Batch processing failed',
});
}
await job.updateProgress(Math.round(((i + batchIds.length) / contactIds.length) * 100));
}
} else {
const where = buildQueryWhere(projectId, selector);
const total = await prisma.contact.count({where});
result.totalRequested = total;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${total} contacts (query mode) in project ${projectId}`,
);
if (total === 0) {
await job.updateProgress(100);
return result;
}
// Cursor-based iteration over matching contacts. We re-evaluate the where clause
// each batch (with id < cursor) instead of Prisma's `cursor:` because for `delete`
// the rows we just processed disappear — a stable cursor would either skip survivors
// or revisit deletions. Sorting by id desc + `id < lastId` is idempotent under either.
let lastId: string | undefined;
let processedRows = 0;
// Cap the loop so a runaway query (e.g. growing table) can't spin forever.
const maxIterations = Math.ceil(total / BATCH_SIZE) + 50;
for (let iter = 0; iter < maxIterations; iter += 1) {
const batch = await prisma.contact.findMany({
where: {
...where,
...(lastId ? {id: {...(where.id as object | undefined), lt: lastId}} : {}),
},
select: {id: true},
orderBy: {id: 'desc'},
take: BATCH_SIZE,
});
if (batch.length === 0) break;
const batchIds = batch.map(c => c.id);
lastId = batchIds[batchIds.length - 1];
try { try {
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds); let batchResult: {updated?: number; deleted?: number};
result.successCount += changed;
result.unchangedCount += unchanged; switch (operation) {
const failed = batchIds.length - changed - unchanged; case 'subscribe':
if (failed > 0) result.failureCount += failed; batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'unsubscribe':
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'delete':
batchResult = await ContactService.bulkDelete(projectId, batchIds);
result.successCount += batchResult.deleted || 0;
break;
}
// If some contacts in batch weren't processed, track them as failures
const processedCount = batchResult.updated || batchResult.deleted || 0;
const failedCount = batchIds.length - processedCount;
if (failedCount > 0) {
result.failureCount += failedCount;
// Note: We don't have individual contact details for batch failures
}
} catch (error) { } catch (error) {
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error); signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
result.failureCount += batchIds.length; result.failureCount += batchIds.length;
result.errors.push({ result.errors.push({
contactId: 'batch', contactId: 'batch',
@@ -154,21 +78,24 @@ export function createBulkContactWorker() {
}); });
} }
processedRows += batchIds.length; // Update progress
await job.updateProgress(Math.min(100, Math.round((processedRows / total) * 100))); const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
await job.updateProgress(progress);
if (batch.length < BATCH_SIZE) break;
}
} }
signale.info( signale.info(
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`, `[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
); );
return result; return result;
} catch (error) {
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
throw error;
}
}, },
{ {
connection: bulkContactQueue.opts.connection, connection: bulkContactQueue.opts.connection,
concurrency: 3, concurrency: 3, // Process max 3 bulk operations concurrently
}, },
); );
+2 -29
View File
@@ -8,12 +8,7 @@ import type {SendEmailJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq'; import {type Job, Worker} from 'bullmq';
import signale from 'signale'; import signale from 'signale';
import { import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
DASHBOARD_URI,
EMAIL_RATE_LIMIT_PER_SECOND,
EMAIL_WORKER_CONCURRENCY,
EMAIL_WORKER_MAX_CONCURRENCY,
} from '../app/constants.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {CampaignService} from '../services/CampaignService.js'; import {CampaignService} from '../services/CampaignService.js';
import {EmailService} from '../services/EmailService.js'; import {EmailService} from '../services/EmailService.js';
@@ -52,31 +47,9 @@ async function getEmailRateLimit(): Promise<number> {
return DEFAULT_RATE_LIMIT; return DEFAULT_RATE_LIMIT;
} }
/**
* Derive worker concurrency from the rate limit so a higher SES quota actually
* translates into higher throughput. The mean job duration is ~0.5s (Prisma
* reads + HTML compile + SES call + writes), so `rate * 0.5` gives ~2× headroom
* over the per-second cap. Clamped to keep sandbox accounts useful and to
* protect the Prisma pool on very large quotas.
*/
function deriveWorkerConcurrency(rateLimit: number): number {
if (EMAIL_WORKER_CONCURRENCY !== undefined) {
return EMAIL_WORKER_CONCURRENCY;
}
const TARGET_JOB_SECONDS = 0.5;
const MIN_CONCURRENCY = 5;
const derived = Math.ceil(rateLimit * TARGET_JOB_SECONDS);
return Math.max(MIN_CONCURRENCY, Math.min(derived, EMAIL_WORKER_MAX_CONCURRENCY));
}
export async function createEmailWorker() { export async function createEmailWorker() {
// Fetch the rate limit (from env, AWS, or default) // Fetch the rate limit (from env, AWS, or default)
const rateLimit = await getEmailRateLimit(); const rateLimit = await getEmailRateLimit();
const concurrency = deriveWorkerConcurrency(rateLimit);
signale.info(
`[EMAIL-PROCESSOR] Worker concurrency: ${concurrency} (rate limit: ${rateLimit}/s)`,
);
const worker = new Worker<SendEmailJobData>( const worker = new Worker<SendEmailJobData>(
emailQueue.name, emailQueue.name,
async (job: Job<SendEmailJobData>) => { async (job: Job<SendEmailJobData>) => {
@@ -280,7 +253,7 @@ export async function createEmailWorker() {
}, },
{ {
connection: emailQueue.opts.connection, connection: emailQueue.opts.connection,
concurrency, concurrency: 10, // Process up to 10 emails concurrently
limiter: { limiter: {
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default) max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
duration: 1000, duration: 1000,
+1 -32
View File
@@ -123,11 +123,7 @@ export function createImportWorker() {
// Extract custom data (all fields except email and subscribed) // Extract custom data (all fields except email and subscribed)
const {email: _, subscribed: __, ...customData} = record; const {email: _, subscribed: __, ...customData} = record;
const customEntries = Object.entries(customData); const data = Object.keys(customData).length > 0 ? customData : undefined;
const data =
customEntries.length > 0
? Object.fromEntries(customEntries.map(([k, v]) => [k, coerceCustomValue(v)]))
: undefined;
// Check if contact exists before upserting // Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(projectId, email); const existingContact = await ContactService.findByEmail(projectId, email);
@@ -220,30 +216,3 @@ function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email); return emailRegex.test(email);
} }
// Values considered as boolean during import.
// Numbers (0, 1) are intentionally absent.
const BOOLEAN_TRUE = new Set(['true', 'yes']);
const BOOLEAN_FALSE = new Set(['false', 'no']);
// Strict integer-or-decimal number detection pattern.
// Valid: 0, 42, -42, 3.14
// Rejected: 007, +42, 1.2.3, 1e5
const NUMERIC_RE = /^-?(0|[1-9]\d*)(\.\d+)?$/;
/**
* Coerces a raw string into its most natural primitive type: `boolean`,
* `number`, or `string`. Values that match neither are
* returned unchanged.
*
* @param value The raw string to coerce.
* @returns The coerced value as `boolean`, `number`, or `string`.
*/
export function coerceCustomValue(value: string): string | boolean | number {
const trimmed = value.trim();
const lower = trimmed.toLowerCase();
if (BOOLEAN_TRUE.has(lower)) return true;
if (BOOLEAN_FALSE.has(lower)) return false;
if (NUMERIC_RE.test(trimmed)) return Number(trimmed);
return value;
}
@@ -3,20 +3,6 @@ import type {NextFunction, Request, Response} from 'express';
import {databaseRequestLogger} from '../requestLogger.js'; import {databaseRequestLogger} from '../requestLogger.js';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
async function waitForLog(prisma: ReturnType<typeof getPrismaClient>, id: string, timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const record = await prisma.apiRequest.findUnique({where: {id}});
if (record) return record;
await new Promise(resolve => setTimeout(resolve, 20));
}
return prisma.apiRequest.findUnique({where: {id}});
}
async function waitForNoLog(ms = 200) {
await new Promise(resolve => setTimeout(resolve, ms));
}
describe('Request Logger Middleware', () => { describe('Request Logger Middleware', () => {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
let req: Partial<Request>; let req: Partial<Request>;
@@ -91,7 +77,13 @@ describe('Request Logger Middleware', () => {
const responseBody = {success: true, data: {id: '123'}}; const responseBody = {success: true, data: {id: '123'}};
await res.json!(responseBody); await res.json!(responseBody);
const loggedRequest = await waitForLog(prisma, 'test-request-id-123'); // Wait for async logging to complete
await new Promise(resolve => setTimeout(resolve, 100));
// Verify database record was created
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest).toBeDefined(); expect(loggedRequest).toBeDefined();
expect(loggedRequest?.method).toBe('POST'); expect(loggedRequest?.method).toBe('POST');
@@ -117,7 +109,11 @@ describe('Request Logger Middleware', () => {
const responseBody = {success: true}; const responseBody = {success: true};
await res.json!(responseBody); await res.json!(responseBody);
const loggedRequest = await waitForLog(prisma, 'public-request-id'); await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'public-request-id'},
});
expect(loggedRequest).toBeDefined(); expect(loggedRequest).toBeDefined();
expect(loggedRequest?.projectId).toBeNull(); expect(loggedRequest?.projectId).toBeNull();
@@ -135,7 +131,11 @@ describe('Request Logger Middleware', () => {
await res.json!({success: true}); await res.json!({success: true});
const loggedRequest = await waitForLog(prisma, 'test-request-id-123'); await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
// Allow for timer imprecision (especially in CI environments) // Allow for timer imprecision (especially in CI environments)
expect(loggedRequest?.duration).toBeGreaterThanOrEqual(45); expect(loggedRequest?.duration).toBeGreaterThanOrEqual(45);
@@ -162,7 +162,11 @@ describe('Request Logger Middleware', () => {
await res.json!(errorResponse); await res.json!(errorResponse);
const loggedRequest = await waitForLog(prisma, 'test-request-id-123'); await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest).toBeDefined(); expect(loggedRequest).toBeDefined();
expect(loggedRequest?.statusCode).toBe(400); expect(loggedRequest?.statusCode).toBe(400);
@@ -185,7 +189,11 @@ describe('Request Logger Middleware', () => {
await res.json!(errorResponse); await res.json!(errorResponse);
const loggedRequest = await waitForLog(prisma, 'test-request-id-123'); await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest?.statusCode).toBe(500); expect(loggedRequest?.statusCode).toBe(500);
expect(loggedRequest?.errorCode).toBe('INTERNAL_SERVER_ERROR'); expect(loggedRequest?.errorCode).toBe('INTERNAL_SERVER_ERROR');
@@ -201,7 +209,11 @@ describe('Request Logger Middleware', () => {
error: {code: 'RESOURCE_NOT_FOUND', message: 'Template not found'}, error: {code: 'RESOURCE_NOT_FOUND', message: 'Template not found'},
}); });
const loggedRequest = await waitForLog(prisma, 'test-request-id-123'); await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest?.statusCode).toBe(404); expect(loggedRequest?.statusCode).toBe(404);
expect(loggedRequest?.errorCode).toBe('RESOURCE_NOT_FOUND'); expect(loggedRequest?.errorCode).toBe('RESOURCE_NOT_FOUND');
@@ -294,8 +306,11 @@ describe('Request Logger Middleware', () => {
databaseRequestLogger(req as Request, res as Response, next); databaseRequestLogger(req as Request, res as Response, next);
await res.json!({success: true}); await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await waitForLog(prisma, `log-${path.replace(/\//g, '-')}`); const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: `log-${path.replace(/\//g, '-')}`},
});
expect(loggedRequest).toBeDefined(); expect(loggedRequest).toBeDefined();
expect(loggedRequest?.path).toBe(path); expect(loggedRequest?.path).toBe(path);
@@ -337,18 +352,17 @@ describe('Request Logger Middleware', () => {
await res.json!({success: true}); await res.json!({success: true});
// Should create a record with generated UUID — poll for it await new Promise(resolve => setTimeout(resolve, 100));
const deadline = Date.now() + 2000;
let allRequests: Awaited<ReturnType<typeof prisma.apiRequest.findMany>> = []; // Should create a record with generated UUID
while (Date.now() < deadline) { const allRequests = await prisma.apiRequest.findMany({
allRequests = await prisma.apiRequest.findMany({ where: {
where: {path: '/v1/send', method: 'POST'}, path: '/v1/send',
method: 'POST',
},
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'},
take: 1, take: 1,
}); });
if (allRequests.length > 0) break;
await new Promise(resolve => setTimeout(resolve, 20));
}
expect(allRequests.length).toBeGreaterThan(0); expect(allRequests.length).toBeGreaterThan(0);
expect(allRequests[0].id).toBeDefined(); expect(allRequests[0].id).toBeDefined();
@@ -404,8 +418,11 @@ describe('Request Logger Middleware', () => {
databaseRequestLogger(reqWithSize as Request, resWithId as Response, next); databaseRequestLogger(reqWithSize as Request, resWithId as Response, next);
await resWithId.json!({success: true}); await resWithId.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await waitForLog(prisma, 'test-size-5000'); const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-size-5000'},
});
expect(loggedRequest?.requestSize).toBe(5000); expect(loggedRequest?.requestSize).toBe(5000);
}); });
@@ -428,8 +445,11 @@ describe('Request Logger Middleware', () => {
databaseRequestLogger(reqNoSize as Request, resWithId as Response, next); databaseRequestLogger(reqNoSize as Request, resWithId as Response, next);
await resWithId.json!({success: true}); await resWithId.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await waitForLog(prisma, 'test-no-size'); const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-no-size'},
});
expect(loggedRequest?.requestSize).toBeNull(); expect(loggedRequest?.requestSize).toBeNull();
}); });
@@ -454,8 +474,11 @@ describe('Request Logger Middleware', () => {
}; };
await resLarge.json!(largeResponse); await resLarge.json!(largeResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await waitForLog(prisma, 'test-large-response'); const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-large-response'},
});
const expectedSize = JSON.stringify(largeResponse).length; const expectedSize = JSON.stringify(largeResponse).length;
expect(loggedRequest?.responseSize).toBe(expectedSize); expect(loggedRequest?.responseSize).toBe(expectedSize);
+1 -32
View File
@@ -222,34 +222,11 @@ export class ActivityService {
} }
} }
/**
* Short Redis TTL for recent-count results. Short enough to feel live on the
* dashboard live-pulse, long enough to absorb the polling load when many
* tabs are open across the user base.
*/
private static readonly RECENT_COUNT_CACHE_TTL = 10; // seconds
/** /**
* Get recent activity count (for real-time updates) * Get recent activity count (for real-time updates)
* Returns count of activities in the last N minutes * Returns count of activities in the last N minutes
*
* Backed by a short Redis cache because the dashboard polls this endpoint
* every 30 seconds per open tab; without the cache, every poll would run
* three COUNT queries against the events, emails, and workflow_executions
* tables.
*/ */
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> { public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
const cacheKey = Keys.Activity.recentCount(projectId, minutes);
try {
const cached = await redis.get(cacheKey);
if (cached !== null) {
return parseInt(cached, 10);
}
} catch (error) {
signale.warn('[ACTIVITY] Failed to read recent-count cache:', error);
}
const since = new Date(Date.now() - minutes * 60 * 1000); const since = new Date(Date.now() - minutes * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {gte: since}; const dateFilter: Prisma.DateTimeFilter = {gte: since};
@@ -268,15 +245,7 @@ export class ActivityService {
}), }),
]); ]);
const total = eventCount + emailCount + workflowCount; return eventCount + emailCount + workflowCount;
try {
await redis.setex(cacheKey, this.RECENT_COUNT_CACHE_TTL, total.toString());
} catch (error) {
signale.warn('[ACTIVITY] Failed to cache recent-count:', error);
}
return total;
} }
/** /**
+1 -11
View File
@@ -186,26 +186,16 @@ export class CampaignService {
projectId: string, projectId: string,
options: { options: {
status?: CampaignStatus; status?: CampaignStatus;
search?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
} = {}, } = {},
): Promise<PaginatedResponse<Campaign>> { ): Promise<PaginatedResponse<Campaign>> {
const {status, search, page = 1, pageSize = 20} = options; const {status, page = 1, pageSize = 20} = options;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const where: Prisma.CampaignWhereInput = { const where: Prisma.CampaignWhereInput = {
projectId, projectId,
...(status ? {status} : {}), ...(status ? {status} : {}),
...(search
? {
OR: [
{name: {contains: search, mode: 'insensitive' as const}},
{subject: {contains: search, mode: 'insensitive' as const}},
{from: {contains: search, mode: 'insensitive' as const}},
],
}
: {}),
}; };
const [campaigns, total] = await Promise.all([ const [campaigns, total] = await Promise.all([
+112 -80
View File
@@ -135,47 +135,6 @@ export class ContactService {
* Update a contact * Update a contact
* Uses unique constraint violation to check for duplicates (more efficient) * Uses unique constraint violation to check for duplicates (more efficient)
*/ */
/**
* Merge an incoming partial data object into existing contact data.
* - `null` value on a key deletes that key
* - empty strings are ignored
* - reserved/system-generated keys are silently filtered
* - `{value, persistent: false}` entries are skipped (non-persistent)
*/
private static mergeContactData(
existing: Prisma.JsonValue | null,
incoming: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> =
existing && typeof existing === 'object' && !Array.isArray(existing) ? {...(existing as Record<string, unknown>)} : {};
const reservedFields = ['plunk_id', 'plunk_email', 'id', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
for (const [key, value] of Object.entries(incoming)) {
if (reservedFields.includes(key)) continue;
if (value === '') continue;
if (value === null) {
delete merged[key];
continue;
}
if (key === 'locale' && typeof value !== 'string') {
throw new HttpException(400, 'Locale must be a string');
}
if (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'persistent' in value &&
(value as {persistent: unknown}).persistent === false
) {
continue;
}
merged[key] = value;
}
return merged;
}
public static async update( public static async update(
projectId: string, projectId: string,
contactId: string, contactId: string,
@@ -190,14 +149,7 @@ export class ContactService {
updateData.email = data.email; updateData.email = data.email;
} }
if (data.data !== undefined) { if (data.data !== undefined) {
if (data.data === null) { updateData.data = data.data === null ? Prisma.JsonNull : data.data;
updateData.data = Prisma.JsonNull;
} else if (typeof data.data === 'object' && !Array.isArray(data.data)) {
const merged = ContactService.mergeContactData(existing.data, data.data as Record<string, unknown>);
updateData.data = Object.keys(merged).length > 0 ? toPrismaJson(merged) : Prisma.JsonNull;
} else {
throw new HttpException(400, 'data must be an object');
}
} }
if (data.subscribed !== undefined) { if (data.subscribed !== undefined) {
updateData.subscribed = data.subscribed; updateData.subscribed = data.subscribed;
@@ -273,7 +225,69 @@ export class ContactService {
}, },
}); });
const mergedData = ContactService.mergeContactData(existing?.data ?? null, data ?? {}); // Process data to merge with existing data
let mergedData: Record<string, unknown> = {};
if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) {
// Start with existing data
mergedData = {...existing.data};
}
// Merge new data (if provided)
if (data) {
for (const [key, value] of Object.entries(data)) {
// Skip reserved system-generated fields
// These fields are dynamically added during template rendering and cannot be overridden
const reservedFields = [
'plunk_id',
'plunk_email',
'id',
'email',
'unsubscribeUrl',
'subscribeUrl',
'manageUrl',
];
if (reservedFields.includes(key)) {
continue;
}
// Skip empty string values - they don't provide meaningful data
// and can cause issues with template rendering and data integrity
if (value === '') {
continue;
}
// Delete field if null is passed (allows removing fields from contact data)
if (value === null) {
delete mergedData[key];
continue;
}
// Validate locale field (special user-settable field)
// Only validate type - any locale string is accepted since we default to English if unsupported
if (key === 'locale') {
if (value !== undefined && typeof value !== 'string') {
throw new HttpException(400, 'Locale must be a string');
}
}
// Handle non-persistent data format: { value: "...", persistent: false }
if (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'persistent' in value &&
value.persistent === false
) {
// Non-persistent fields are not stored in contact data
// They would be used only for the current operation (like email template rendering)
continue;
}
// Store the value
mergedData[key] = value;
}
}
if (existing) { if (existing) {
// Track subscription status change // Track subscription status change
@@ -707,81 +721,99 @@ export class ContactService {
/** /**
* Bulk subscribe contacts * Bulk subscribe contacts
* Updates multiple contacts to subscribed=true in batches. * Updates multiple contacts to subscribed=true in batches
* `updated` = contacts flipped from unsubscribed to subscribed.
* `unchanged` = contacts that were already subscribed (no-op, not a failure).
*/ */
public static async bulkSubscribe( public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
projectId: string, // Verify all contacts belong to this project
contactIds: string[],
): Promise<{updated: number; unchanged: number}> {
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where: {id: {in: contactIds}, projectId}, where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true}, select: {id: true, subscribed: true},
}); });
if (contacts.length === 0) { const validIds = contacts.map(c => c.id);
return {updated: 0, unchanged: 0};
if (validIds.length === 0) {
return {updated: 0};
} }
// Only update contacts that are currently unsubscribed
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id); const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
const unchanged = contacts.length - unsubscribedIds.length;
if (unsubscribedIds.length === 0) { if (unsubscribedIds.length === 0) {
return {updated: 0, unchanged}; return {updated: 0};
} }
// Update in a single query for performance
const result = await prisma.contact.updateMany({ const result = await prisma.contact.updateMany({
where: {id: {in: unsubscribedIds}, projectId}, where: {
data: {subscribed: true}, id: {in: unsubscribedIds},
projectId,
},
data: {
subscribed: true,
},
}); });
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => { this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk subscribe events:', error); console.error('[ContactService] Failed to track bulk subscribe events:', error);
} }
}); });
return {updated: result.count, unchanged}; return {updated: result.count};
} }
/** /**
* Bulk unsubscribe contacts. * Bulk unsubscribe contacts
* `updated` = contacts flipped from subscribed to unsubscribed.
* `unchanged` = contacts that were already unsubscribed (no-op, not a failure).
*/ */
public static async bulkUnsubscribe( public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
projectId: string,
contactIds: string[],
): Promise<{updated: number; unchanged: number}> {
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where: {id: {in: contactIds}, projectId}, where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true}, select: {id: true, subscribed: true},
}); });
if (contacts.length === 0) { const validIds = contacts.map(c => c.id);
return {updated: 0, unchanged: 0};
if (validIds.length === 0) {
return {updated: 0};
} }
// Only update contacts that are currently subscribed
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id); const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
const unchanged = contacts.length - subscribedIds.length;
if (subscribedIds.length === 0) { if (subscribedIds.length === 0) {
return {updated: 0, unchanged}; return {updated: 0};
} }
const result = await prisma.contact.updateMany({ const result = await prisma.contact.updateMany({
where: {id: {in: subscribedIds}, projectId}, where: {
data: {subscribed: false}, id: {in: subscribedIds},
projectId,
},
data: {
subscribed: false,
},
}); });
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => { this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk unsubscribe events:', error); console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
} }
}); });
return {updated: result.count, unchanged}; return {updated: result.count};
} }
/** /**
+7 -18
View File
@@ -438,14 +438,15 @@ 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 existingDomain = await prisma.domain.findFirst({ const existingDomain = await prisma.domain.findFirst({
where: {domain}, where: {domain},
include: { include: {
project: { project: {
select: { include: {
id: true, members: {
name: true, where: {userId},
},
}, },
}, },
}, },
@@ -455,20 +456,8 @@ export class DomainService {
return {exists: false}; return {exists: false};
} }
let isMember = false; // Check if user is a member of the project that owns this domain
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,
+19 -23
View File
@@ -108,7 +108,7 @@ export class EmailService {
await BillingLimitService.incrementUsage(params.projectId, EmailSourceType.TRANSACTIONAL); await BillingLimitService.incrementUsage(params.projectId, EmailSourceType.TRANSACTIONAL);
// Queue email for sending // Queue email for sending
await this.queueEmail(email.id, EmailSourceType.TRANSACTIONAL); await this.queueEmail(email.id);
return email; return email;
} }
@@ -172,7 +172,7 @@ export class EmailService {
await BillingLimitService.incrementUsage(params.projectId, sourceType); await BillingLimitService.incrementUsage(params.projectId, sourceType);
// Queue email for sending // Queue email for sending
await this.queueEmail(email.id, sourceType); await this.queueEmail(email.id);
return email; return email;
} }
@@ -278,7 +278,7 @@ export class EmailService {
await BillingLimitService.incrementUsage(params.projectId, sourceType); await BillingLimitService.incrementUsage(params.projectId, sourceType);
// Queue email for sending // Queue email for sending
await this.queueEmail(email.id, sourceType); await this.queueEmail(email.id);
return email; return email;
} }
@@ -659,16 +659,12 @@ 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) {
@@ -683,21 +679,21 @@ export class EmailService {
} }
} }
// Element-attribute-scoped regex; the leading [\s"'] guard prevents `id=` inside const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
// href URLs (e.g. `?id=...`) from false-matching as an HTML id attribute. const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
const hasCustomAttributes = /<[a-z][^>]*?[\s"'](?:data-|aria-|role=|id=)/i.test(html); const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/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 hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags; return (
hasInlineStyles ||
hasCustomClasses ||
hasCustomAttributes ||
hasComplexTables ||
hasCustomElements ||
hasMediaQueries ||
hasStyleTags
);
} }
/** /**
@@ -1137,7 +1133,7 @@ export class EmailService {
* Queue an email for sending * Queue an email for sending
* Adds email to the BullMQ queue for processing by workers * Adds email to the BullMQ queue for processing by workers
*/ */
private static async queueEmail(emailId: string, sourceType: EmailSourceType, delay?: number): Promise<void> { private static async queueEmail(emailId: string, delay?: number): Promise<void> {
await QueueService.queueEmail(emailId, sourceType, delay); await QueueService.queueEmail(emailId, delay);
} }
} }
+7 -31
View File
@@ -1,11 +1,10 @@
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db'; import {CampaignStatus, EmailStatus} from '@plunk/db';
import {type Job, Queue} from 'bullmq'; import {type Job, Queue} from 'bullmq';
import type {RedisOptions} from 'ioredis'; import type {RedisOptions} from 'ioredis';
import signale from 'signale'; import signale from 'signale';
import type { import type {
ApiRequestCleanupJobData, ApiRequestCleanupJobData,
BulkContactActionJobData, BulkContactActionJobData,
BulkContactActionSelector,
CampaignBatchJobData, CampaignBatchJobData,
ContactImportJobData, ContactImportJobData,
DomainVerificationJobData, DomainVerificationJobData,
@@ -174,43 +173,20 @@ export const meterQueue = new Queue<MeterEventJobData>('meter', {
}, },
}); });
function emailPriorityFor(sourceType: EmailSourceType): number {
switch (sourceType) {
case EmailSourceType.TRANSACTIONAL:
return 1;
case EmailSourceType.WORKFLOW:
return 5;
case EmailSourceType.CAMPAIGN:
return 10;
default:
return 5;
}
}
/** /**
* Queue Service - Centralized queue management * Queue Service - Centralized queue management
*/ */
export class QueueService { export class QueueService {
/** /**
* Add email to queue for sending. * Add email to queue for sending
*
* Transactional emails jump the queue ahead of workflow and campaign sends
* via BullMQ's priority (lower number = higher precedence). This prevents
* latency-sensitive sends (login codes, password resets) from queuing behind
* large campaign bursts on the shared `email` queue.
*/ */
public static async queueEmail( public static async queueEmail(emailId: string, delay?: number): Promise<Job<SendEmailJobData>> {
emailId: string,
sourceType: EmailSourceType,
delay?: number,
): Promise<Job<SendEmailJobData>> {
return emailQueue.add( return emailQueue.add(
'send-email', 'send-email',
{emailId}, {emailId},
{ {
delay, delay, // Optional delay in milliseconds
jobId: `email-${emailId}`, jobId: `email-${emailId}`, // Prevent duplicate jobs
priority: emailPriorityFor(sourceType),
}, },
); );
} }
@@ -374,12 +350,12 @@ export class QueueService {
*/ */
public static async queueBulkContactAction( public static async queueBulkContactAction(
projectId: string, projectId: string,
selector: BulkContactActionSelector, contactIds: string[],
operation: 'subscribe' | 'unsubscribe' | 'delete', operation: 'subscribe' | 'unsubscribe' | 'delete',
): Promise<Job<BulkContactActionJobData>> { ): Promise<Job<BulkContactActionJobData>> {
return bulkContactQueue.add( return bulkContactQueue.add(
'bulk-contact-action', 'bulk-contact-action',
{projectId, operation, selector}, {projectId, contactIds, operation},
{ {
jobId: `bulk-${operation}-${projectId}-${Date.now()}`, jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
}, },
+2 -6
View File
@@ -6,7 +6,6 @@ import {
AWS_SES_REGION, AWS_SES_REGION,
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI, DASHBOARD_URI,
MAIL_FROM_SUBDOMAIN,
SES_CONFIGURATION_SET, SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING, SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED, TRACKING_TOGGLE_ENABLED,
@@ -251,13 +250,10 @@ export const verifyDomain = async (domain: string): Promise<string[]> => {
// Verify DKIM for the domain // Verify DKIM for the domain
const DKIM = await ses.verifyDomainDkim({Domain: domain}); const DKIM = await ses.verifyDomainDkim({Domain: domain});
// Set custom MAIL FROM domain. The subdomain defaults to `plunk` and can be // Set custom MAIL FROM domain (plunk.yourdomain.com)
// overridden via the MAIL_FROM_SUBDOMAIN env var — useful when `plunk.<domain>`
// is already in use for something else (e.g., a CNAME to a CDN), since the
// MAIL FROM subdomain needs MX + TXT records that conflict with a CNAME.
await ses.setIdentityMailFromDomain({ await ses.setIdentityMailFromDomain({
Identity: domain, Identity: domain,
MailFromDomain: `${MAIL_FROM_SUBDOMAIN}.${domain}`, MailFromDomain: `plunk.${domain}`,
}); });
return DKIM.DkimTokens ?? []; return DKIM.DkimTokens ?? [];
+70 -68
View File
@@ -50,13 +50,22 @@ 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 (new projects only) === // === Absolute count ceilings ===
// These trigger regardless of rate — catches new accounts blasting emails // These trigger regardless of rate — catches high-volume spammers who dilute their bounce rate
// before their bounce rate has caught up. Established projects rely on // 24-hour absolute ceilings
// rate-based checks only, since high absolute counts at high volume BOUNCE_24H_CEILING_WARNING: 50,
// (e.g. 100 bounces out of 10K) don't indicate abuse. BOUNCE_24H_CEILING_CRITICAL: 100,
// COMPLAINT_24H_CEILING_WARNING: 10,
// Legitimate senders ramp up gradually; spammers blast immediately. COMPLAINT_24H_CEILING_CRITICAL: 25,
// 7-day absolute ceilings
BOUNCE_7DAY_CEILING_WARNING: 200,
BOUNCE_7DAY_CEILING_CRITICAL: 500,
COMPLAINT_7DAY_CEILING_WARNING: 30,
COMPLAINT_7DAY_CEILING_CRITICAL: 75,
// === New project thresholds (projects < 30 days old) ===
// Legitimate senders ramp up gradually; spammers blast immediately
NEW_PROJECT_AGE_DAYS: 30, NEW_PROJECT_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,
@@ -507,55 +516,83 @@ export class SecurityService {
const violations: string[] = []; const violations: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
// === Absolute count ceiling checks (new projects only, rate-independent) === // Pick absolute count ceilings based on project age
// Catches new accounts blasting emails before their bounce rate catches up. const bounceCeilings = isNewProject
// Established projects skip these — high absolute counts at high volume ? {
// (e.g. 100 bounces out of 10K) don't indicate abuse; rate checks handle them. ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING,
if (isNewProject) { ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL,
}
: {
ceiling24hWarning: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_WARNING,
ceiling24hCritical: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_CRITICAL,
};
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 >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL) { if (twentyFourHour.bounces >= bounceCeilings.ceiling24hCritical) {
violations.push( violations.push(
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL})`, `24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling24hCritical})`,
); );
} else if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING) { } else if (twentyFourHour.bounces >= bounceCeilings.ceiling24hWarning) {
warnings.push( warnings.push(
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING})`, `24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling24hWarning})`,
); );
} }
// 7-day bounce ceilings // 7-day bounce ceilings
if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL) { if (sevenDay.bounces >= bounceCeilings.ceiling7dCritical) {
violations.push( violations.push(
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL})`, `7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling7dCritical})`,
); );
} else if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING) { } else if (sevenDay.bounces >= bounceCeilings.ceiling7dWarning) {
warnings.push( warnings.push(
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING})`, `7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling7dWarning})`,
); );
} }
// 24-hour complaint ceilings // 24-hour complaint ceilings
if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL) { if (twentyFourHour.complaints >= complaintCeilings.ceiling24hCritical) {
violations.push( violations.push(
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL})`, `24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling24hCritical})`,
); );
} else if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING) { } else if (twentyFourHour.complaints >= complaintCeilings.ceiling24hWarning) {
warnings.push( warnings.push(
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING})`, `24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling24hWarning})`,
); );
} }
// 7-day complaint ceilings // 7-day complaint ceilings
if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL) { if (sevenDay.complaints >= complaintCeilings.ceiling7dCritical) {
violations.push( violations.push(
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL})`, `7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling7dCritical})`,
); );
} else if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING) { } else if (sevenDay.complaints >= complaintCeilings.ceiling7dWarning) {
warnings.push( warnings.push(
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING})`, `7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling7dWarning})`,
); );
} }
}
// === 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
@@ -676,7 +713,7 @@ export class SecurityService {
// Disable the project // Disable the project
await prisma.project.update({ await prisma.project.update({
where: {id: projectId}, where: {id: projectId},
data: {disabled: true, disabledReason: 'EMAIL_REPUTATION'}, data: {disabled: true},
}); });
// Log critical security event // Log critical security event
@@ -765,36 +802,7 @@ export class SecurityService {
const uniqueUrls = [...new Set(urlMatches.map(u => u.replace(/[.,;)]+$/, '')))].slice(0, 20); const uniqueUrls = [...new Set(urlMatches.map(u => u.replace(/[.,;)]+$/, '')))].slice(0, 20);
// Extract sender domain for context // Extract sender domain for context
const senderDomain = (fromEmail.split('@')[1] ?? fromEmail).toLowerCase(); const senderDomain = fromEmail.includes('@') ? fromEmail.split('@')[1] : fromEmail;
// Check whether this domain is verified by the project. A verified
// domain means the sender proved DNS/DKIM control — strong evidence of
// legitimacy, especially for institutional TLDs like .gov, .edu, .mil.
const verifiedDomain = await prisma.domain.findFirst({
where: {projectId, domain: senderDomain, verified: true},
select: {domain: true},
});
const isDomainVerified = verifiedDomain !== null;
// Institutional TLDs that imply a vetted, real-world entity behind the
// domain (government, military, accredited education). When combined
// with DKIM verification these effectively cannot be phishing senders.
const institutionalTldPattern =
/\.(gov|mil|edu)(\.[a-z]{2,})?$|\.gc\.ca$|\.gouv\.fr$|\.gov\.uk$|\.ac\.[a-z]{2,}$/i;
const isInstitutionalDomain = institutionalTldPattern.test(senderDomain);
// Skip the LLM check entirely when the sender is a verified institutional
// domain (e.g. a .gov customer). These TLDs are gated by registries that
// verify the real-world entity, and DKIM verification proves the project
// controls the domain — together they make phishing effectively
// impossible from this sender. Avoids paying for an LLM call that
// sometimes false-positives on official government communications.
if (isDomainVerified && isInstitutionalDomain) {
signale.info(
`[PHISHING] Skipping check for project ${projectId} — verified institutional domain (${senderDomain})`,
);
return safeResponse;
}
// Call OpenRouter API // Call OpenRouter API
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
@@ -827,11 +835,7 @@ Criteria for phishing/dangerous content:
- Requests for sensitive personal information - Requests for sensitive personal information
IMPORTANT - Use sender and project context when evaluating: IMPORTANT - Use sender and project context when evaluating:
- The sender project name and domain are provided, along with whether the domain has been verified (DKIM/DNS) by this project. - The sender project name and domain are provided. Links to the sender's own domain(s) are expected and NOT suspicious.
- A VERIFIED sender domain means the sender proved ownership of the domain via DNS records. This is strong evidence of legitimacy.
- If the verified sender domain is an institutional domain (e.g. .gov, .gov.uk, .gouv.fr, .gc.ca, .mil, .edu, .ac.*), treat the email as legitimate institutional communication. Government, military, and accredited education domains cannot be obtained by phishers — do NOT flag these as impersonation of government/banks/etc. just because the content mentions official topics, taxes, benefits, court notices, etc.
- Impersonation rules only apply when the sender domain does NOT match the brand being referenced. A verified bank domain sending a banking email is not impersonating itself.
- Links to the sender's own domain(s) are expected and NOT suspicious.
- URLs that match or are clearly related to the project name or sender domain add credibility. - URLs that match or are clearly related to the project name or sender domain add credibility.
- Only flag a URL as suspicious if it is unrelated to or impersonates a different known brand. - Only flag a URL as suspicious if it is unrelated to or impersonates a different known brand.
- Lack of recognizable brand does NOT make an email phishing — many legitimate businesses are not famous. - Lack of recognizable brand does NOT make an email phishing — many legitimate businesses are not famous.
@@ -844,8 +848,6 @@ Set confidence to 100 only if you are absolutely certain it's phishing.`,
role: 'user', role: 'user',
content: `Sender project name: ${projectName} content: `Sender project name: ${projectName}
Sender domain: ${senderDomain} Sender domain: ${senderDomain}
Sender domain verified (DKIM/DNS confirmed by project): ${isDomainVerified ? 'yes' : 'no'}
Sender domain is an institutional TLD (gov/mil/edu/ac/etc.): ${isInstitutionalDomain ? 'yes' : 'no'}
${uniqueUrls.length > 0 ? `URLs found in email: ${uniqueUrls.join(', ')}` : ''} ${uniqueUrls.length > 0 ? `URLs found in email: ${uniqueUrls.join(', ')}` : ''}
Subject: ${subject} Subject: ${subject}
@@ -969,7 +971,7 @@ ${strippedBody.substring(0, 2000)}`,
// Disable the project // Disable the project
await prisma.project.update({ await prisma.project.update({
where: {id: projectId}, where: {id: projectId},
data: {disabled: true, disabledReason: 'PHISHING_DETECTED'}, data: {disabled: true},
}); });
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.`;
@@ -908,14 +908,7 @@ 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,
@@ -931,37 +924,9 @@ 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 || {};
// Render scope: SEND_EMAIL's scope (id, email, contact data, execution const payload = body || {
// 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,
@@ -979,11 +944,11 @@ export class WorkflowExecutionService {
}; };
// Make HTTP request // Make HTTP request
const response = await WorkflowExecutionService.safeFetch(renderedUrl, { const response = await WorkflowExecutionService.safeFetch(url, {
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...renderedHeaders, ...headers,
}, },
body: method !== 'GET' ? JSON.stringify(payload) : undefined, body: method !== 'GET' ? JSON.stringify(payload) : undefined,
}); });
@@ -997,7 +962,7 @@ export class WorkflowExecutionService {
} }
return { return {
url: renderedUrl, url,
method, method,
statusCode: response.status, statusCode: response.status,
success: response.ok, success: response.ok,
@@ -1005,28 +970,6 @@ export class WorkflowExecutionService {
}; };
} }
/**
* Helper: Recursively render template variables in any JSON-shaped value.
* Strings are rendered, arrays/objects are walked, and non-string scalars
* (numbers, booleans, null) are returned untouched.
*/
private static renderJsonTemplate(value: unknown, variables: Record<string, unknown>): unknown {
if (typeof value === 'string') {
return this.renderTemplate(value, variables);
}
if (Array.isArray(value)) {
return value.map(item => this.renderJsonTemplate(item, variables));
}
if (value !== null && typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
result[key] = this.renderJsonTemplate(child, variables);
}
return result;
}
return value;
}
/** /**
* UPDATE_CONTACT step - Update contact data * UPDATE_CONTACT step - Update contact data
*/ */
@@ -1036,7 +979,7 @@ export class WorkflowExecutionService {
_stepExecution: WorkflowStepExecution, _stepExecution: WorkflowStepExecution,
config: StepConfig, config: StepConfig,
): Promise<StepResult> { ): Promise<StepResult> {
const {updates, subscriptionAction} = WorkflowStepConfigSchemas.updateContact.parse(config); const {updates} = WorkflowStepConfigSchemas.updateContact.parse(config);
const contact = execution.contact; const contact = execution.contact;
const currentData = const currentData =
@@ -1044,43 +987,24 @@ export class WorkflowExecutionService {
? (contact.data as Record<string, unknown>) ? (contact.data as Record<string, unknown>)
: {}; : {};
const hasDataUpdates = updates && Object.keys(updates).length > 0; // Merge updates with current data
const newData = hasDataUpdates ? {...currentData, ...updates} : currentData; const newData = {
...currentData,
...updates,
};
const desiredSubscribed = // Update contact in database
subscriptionAction === 'subscribe' ? true : subscriptionAction === 'unsubscribe' ? false : undefined;
const subscriptionChanging = desiredSubscribed !== undefined && desiredSubscribed !== contact.subscribed;
const updateData: Prisma.ContactUpdateInput = {};
if (hasDataUpdates) {
updateData.data = toPrismaJson(newData);
}
if (subscriptionChanging) {
updateData.subscribed = desiredSubscribed;
}
if (Object.keys(updateData).length > 0) {
await prisma.contact.update({ await prisma.contact.update({
where: {id: contact.id}, where: {id: contact.id},
data: updateData, data: {
data: newData ? toPrismaJson(newData) : undefined,
},
}); });
}
if (subscriptionChanging) {
const {EventService} = await import('./EventService.js');
await EventService.trackEvent(
execution.workflow.projectId,
desiredSubscribed ? 'contact.subscribed' : 'contact.unsubscribed',
contact.id,
);
}
return { return {
updated: hasDataUpdates || subscriptionChanging, updated: true,
updates, updates,
newData, newData,
subscriptionAction,
subscribed: desiredSubscribed ?? contact.subscribed,
}; };
} }
-66
View File
@@ -310,72 +310,6 @@ export class WorkflowService {
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId); await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
} }
/**
* Duplicate a workflow including all steps and transitions.
* The duplicate always starts disabled to prevent accidental triggering.
* Runtime execution state is intentionally not copied.
*/
public static async duplicate(projectId: string, workflowId: string): Promise<Workflow> {
const source = await this.get(projectId, workflowId);
const transitions = await prisma.workflowTransition.findMany({
where: {fromStep: {workflowId}},
});
return prisma.$transaction(async tx => {
const newWorkflow = await tx.workflow.create({
data: {
projectId,
name: `${source.name} (Copy)`,
description: source.description,
triggerType: source.triggerType,
triggerConfig:
source.triggerConfig === null
? Prisma.JsonNull
: (source.triggerConfig as Prisma.InputJsonValue),
enabled: false,
allowReentry: source.allowReentry,
},
});
const stepIdMap = new Map<string, string>();
for (const step of source.steps) {
const created = await tx.workflowStep.create({
data: {
workflowId: newWorkflow.id,
type: step.type,
name: step.name,
position: step.position as Prisma.InputJsonValue,
config: step.config as Prisma.InputJsonValue,
templateId: step.templateId,
},
});
stepIdMap.set(step.id, created.id);
}
for (const transition of transitions) {
const fromStepId = stepIdMap.get(transition.fromStepId);
const toStepId = stepIdMap.get(transition.toStepId);
if (!fromStepId || !toStepId) continue;
await tx.workflowTransition.create({
data: {
fromStepId,
toStepId,
condition:
transition.condition === null
? Prisma.JsonNull
: (transition.condition as Prisma.InputJsonValue),
priority: transition.priority,
},
});
}
return newWorkflow;
});
}
/** /**
* Add a step to a workflow * Add a step to a workflow
*/ */
@@ -50,7 +50,11 @@ 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 data = Array.from({length: count}, (_, i) => ({ const emails = [];
for (let i = 0; i < count; i++) {
emails.push(
prisma.email.create({
data: {
projectId, projectId,
contactId, contactId,
subject: `Test ${i}`, subject: `Test ${i}`,
@@ -62,8 +66,11 @@ 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)', () => {
@@ -102,12 +109,14 @@ 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('Established projects skip absolute ceilings', () => { describe('Absolute count ceilings (established projects)', () => {
// Age the project past the new-project window // Age the project past the new-project window so standard ceilings apply
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({
@@ -116,27 +125,45 @@ describe('SecurityService', () => {
}); });
}); });
it('should NOT trigger on high absolute bounce count when rate is healthy', async () => { it('should trigger critical when 24-hour bounce count exceeds ceiling', async () => {
// 20,000 emails, 200 bounces = 1% rate (well below rate threshold) // 20,000 emails, 101 bounces = 0.5% rate (well below rate threshold)
// Established projects rely solely on rates — high absolute counts at // But 101 bounces > 100 (24h critical ceiling for established projects)
// high volume don't indicate abuse. await createEmails(20000, {bouncedCount: 101});
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', () => {
@@ -151,7 +178,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 NOT apply absolute ceilings for projects over 30 days old', async () => { it('should apply standard ceilings for projects over 30 days old', async () => {
// Age the project to 31 days // 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({
@@ -159,14 +186,15 @@ describe('SecurityService', () => {
data: {createdAt: oldDate}, data: {createdAt: oldDate},
}); });
// 10,000 emails, 26 bounces — would trip new-project ceiling, but // 10,000 emails, 26 bounces (above 25 new project ceiling, below 50 standard warning ceiling)
// 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);
expect(status.warnings.some(w => w.includes('bounce count'))).toBe(false); // 26 is below the 50-bounce 24h warning ceiling for established projects
expect(status.violations.some(v => v.includes('bounce count'))).toBe(false); expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(false);
// And below the 100-bounce 24h critical ceiling
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(false);
}); });
it('should catch new project blasting emails with delayed bounces', async () => { it('should catch new project blasting emails with delayed bounces', async () => {
@@ -184,8 +212,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 () => {
// New project, 20K emails with 30 bounces — exceeds new project 24h critical ceiling // Create enough bounces to trigger critical
await createEmails(20000, {bouncedCount: 30}); await createEmails(20000, {bouncedCount: 101});
await SecurityService.checkAndEnforceSecurityLimits(projectId); await SecurityService.checkAndEnforceSecurityLimits(projectId);
@@ -197,13 +225,13 @@ describe('SecurityService', () => {
}); });
it('should NOT disable project when only warnings exist', async () => { it('should NOT disable project when only warnings exist', async () => {
// Established project, 200 emails, 12 bounces = 6% (above 5% warning, below 10% critical) // 10,000 emails, 51 bounces (above warning but below critical for established project)
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); 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(200, {bouncedCount: 12}); await createEmails(10000, {bouncedCount: 51});
await SecurityService.checkAndEnforceSecurityLimits(projectId); await SecurityService.checkAndEnforceSecurityLimits(projectId);
@@ -1,229 +0,0 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
/**
* Tests for WEBHOOK step config templating.
*
* `executeWebhook` is a private static method but is invokable at runtime
* through a `as any` cast. We mock `safeFetch` (also private) via the
* same mechanism so we can capture the rendered request without making a
* real network call.
*/
describe('WorkflowExecutionService.executeWebhook templating', () => {
let projectId: string;
const prisma = getPrismaClient();
// Capture (url, options) passed to safeFetch
let safeFetchSpy: ReturnType<typeof vi.spyOn>;
let captured: {url: string; options: RequestInit} | null = null;
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
captured = null;
safeFetchSpy = vi
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.spyOn(WorkflowExecutionService as any, 'safeFetch')
.mockImplementation(async (...args: unknown[]) => {
const [url, options] = args as [string, RequestInit];
captured = {url, options};
return new Response('{"ok":true}', {
status: 200,
headers: {'Content-Type': 'application/json'},
});
});
});
afterEach(() => {
safeFetchSpy.mockRestore();
});
/**
* Helper: build a workflow with a single WEBHOOK step using the given
* config, plus a contact and a RUNNING execution. Returns the args
* shape `executeWebhook` expects.
*/
async function setup(
webhookConfig: Record<string, unknown>,
contactOverrides: {data?: Record<string, unknown>} = {},
executionContext: Record<string, unknown> = {},
) {
const contact = await factories.createContact({
projectId,
data: contactOverrides.data,
});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'test.event'},
});
const step = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.WEBHOOK,
name: 'Webhook',
position: {x: 0, y: 0},
config: webhookConfig,
},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
context: executionContext,
},
include: {contact: true, workflow: true},
});
const stepExecution = await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: step.id,
status: StepExecutionStatus.RUNNING,
startedAt: new Date(),
},
});
return {step, execution, stepExecution};
}
async function invokeWebhook(
step: unknown,
execution: unknown,
stepExecution: unknown,
config: unknown,
) {
// Call through `as any` because executeWebhook is private at the
// TypeScript level. JS has no actual access control.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (WorkflowExecutionService as any).executeWebhook(step, execution, stepExecution, config);
}
it('renders {{vars}} in the URL from contact.data', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/api/users/{{userId}}',
method: 'GET',
},
{data: {userId: 'abc-123'}},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
expect(captured!.url).toBe('https://example.com/api/users/abc-123');
});
it('renders {{vars}} in header values', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/hook',
method: 'POST',
headers: {
Authorization: 'Bearer {{apiToken}}',
'X-Static': 'literal',
},
},
{data: {apiToken: 'secret-token-xyz'}},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
const headers = captured!.options.headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer secret-token-xyz');
expect(headers['X-Static']).toBe('literal');
});
it('renders {{vars}} in nested object body leaves and JSON-encodes', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/hook',
method: 'POST',
body: {
user: {
email: '{{email}}',
name: '{{firstName}}',
},
ref: 'literal-ref',
tags: ['plan:{{plan}}', 'static'],
},
},
{data: {firstName: 'Ada', plan: 'gold'}},
{campaignId: 'camp-9'},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
const body = JSON.parse(captured!.options.body as string);
expect(body.user.email).toBe(execution.contact.email);
expect(body.user.name).toBe('Ada');
expect(body.ref).toBe('literal-ref');
expect(body.tags).toEqual(['plan:gold', 'static']);
});
it('leaves non-string body leaves untouched', async () => {
const {step, execution, stepExecution} = await setup({
url: 'https://example.com/hook',
method: 'POST',
body: {
score: 42,
active: true,
deleted: null,
meta: {
count: 7,
enabled: false,
},
tags: ['{{plan ?? free}}', 100, false],
},
});
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
const body = JSON.parse(captured!.options.body as string);
expect(body.score).toBe(42);
expect(body.active).toBe(true);
expect(body.deleted).toBe(null);
expect(body.meta).toEqual({count: 7, enabled: false});
// String leaf rendered (with default), non-string leaves preserved.
expect(body.tags).toEqual(['free', 100, false]);
});
it('renders {{event.*}} variables from the trigger payload', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/hooks/{{event.referrer}}',
method: 'POST',
headers: {
'X-Email-Id': '{{event.emailId}}',
},
body: {
referrer: '{{event.referrer}}',
subject: '{{event.subject}}',
},
},
{},
{referrer: 'newsletter-may', emailId: 'eml_abc123', subject: 'Welcome'},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
expect(captured!.url).toBe('https://example.com/hooks/newsletter-may');
const headers = captured!.options.headers as Record<string, string>;
expect(headers['X-Email-Id']).toBe('eml_abc123');
const body = JSON.parse(captured!.options.body as string);
expect(body.referrer).toBe('newsletter-may');
expect(body.subject).toBe('Welcome');
});
});
@@ -758,20 +758,8 @@ describe('WorkflowService', () => {
}); });
const contact = await factories.createContact({projectId}); const contact = await factories.createContact({projectId});
// Insert a RUNNING execution directly to avoid racing with the background // Start first execution (still running)
// step processor that startExecution kicks off (a trigger-only workflow can await WorkflowService.startExecution(projectId, workflow.id, contact.id);
// transition to COMPLETED before the second call observes it as RUNNING).
const triggerStep = await prisma.workflowStep.findFirst({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep?.id,
},
});
// Second execution should fail (first still running) // Second execution should fail (first still running)
await expect(WorkflowService.startExecution(projectId, workflow.id, contact.id)).rejects.toThrow( await expect(WorkflowService.startExecution(projectId, workflow.id, contact.id)).rejects.toThrow(
-3
View File
@@ -53,9 +53,6 @@ export const Keys = {
stats(projectId: string, startTime: number | string, endTime: number | string): string { stats(projectId: string, startTime: number | string, endTime: number | string): string {
return `activity:stats:${projectId}:${startTime}:${endTime}`; return `activity:stats:${projectId}:${startTime}:${endTime}`;
}, },
recentCount(projectId: string, minutes: number): string {
return `activity:recent-count:${projectId}:${minutes}`;
},
}, },
Analytics: { Analytics: {
timeseries(projectId: string, startDate: string, endDate: string): string { timeseries(projectId: string, startDate: string, endDate: string): string {
-78
View File
@@ -1,78 +0,0 @@
# Plunk
> Plunk is an open-source, all-in-one email platform for developers. It unifies marketing, transactional, and broadcast email behind a single API — built to handle millions of contacts with workflows, segments, templates, and deliverability tooling out of the box.
Plunk can be used as a hosted service at useplunk.com or self-hosted via Docker. Source code lives at https://github.com/useplunk/plunk.
Pages listed below with a `.md` suffix serve a Markdown version (also available by requesting the same URL with `Accept: text/markdown`). Other pages are HTML only.
For full product documentation, see the docs site: https://docs.useplunk.com/llms.txt
## Product
- [Plunk home](https://www.useplunk.com/index.md): Product overview, features, and positioning
- [Pricing](https://www.useplunk.com/pricing.md): Plans, included volume, and overage pricing
- [Made by humans](https://www.useplunk.com/made-by-humans): The team and story behind Plunk
## Features
- [Email editor](https://www.useplunk.com/features/email-editor.md): Drag-and-drop and code-based template editor
- [Workflows](https://www.useplunk.com/features/workflows.md): Event- and segment-triggered automation flows
- [Segments](https://www.useplunk.com/features/segments.md): Dynamic and static contact segments
- [SMTP](https://www.useplunk.com/features/smtp.md): Send through Plunk over standard SMTP
- [Inbound email](https://www.useplunk.com/features/inbound-email.md): Receive email at your verified domain and turn it into events
## Comparisons
- [Plunk vs Resend](https://www.useplunk.com/vs/resend)
- [Plunk vs SendGrid](https://www.useplunk.com/vs/sendgrid)
- [Plunk vs Mailchimp](https://www.useplunk.com/vs/mailchimp)
- [Plunk vs Mailgun](https://www.useplunk.com/vs/mailgun)
- [Plunk vs Postmark](https://www.useplunk.com/vs/postmark)
- [Plunk vs Customer.io](https://www.useplunk.com/vs/customerio)
- [Plunk vs Loops](https://www.useplunk.com/vs/loops)
- [Plunk vs Brevo](https://www.useplunk.com/vs/brevo)
- [Plunk vs ActiveCampaign](https://www.useplunk.com/vs/activecampaign)
- [Plunk vs Klaviyo](https://www.useplunk.com/vs/klaviyo)
- [Plunk vs ConvertKit](https://www.useplunk.com/vs/convertkit)
- [Plunk vs Bento](https://www.useplunk.com/vs/bento)
- [Plunk vs MailerLite](https://www.useplunk.com/vs/mailerlite)
- [Plunk vs Amazon SES](https://www.useplunk.com/vs/amazon-ses)
- [Plunk vs Mailjet](https://www.useplunk.com/vs/mailjet)
- [Plunk vs Buttondown](https://www.useplunk.com/vs/buttondown)
- [All comparisons](https://www.useplunk.com/vs)
## Guides
- [Email API guide](https://www.useplunk.com/guides/email-api-guide): Choosing and integrating with an email API
- [Email deliverability](https://www.useplunk.com/guides/email-deliverability): Reaching the inbox reliably
- [Email bounce rate](https://www.useplunk.com/guides/email-bounce-rate): What it is and how to reduce it
- [Email open rate](https://www.useplunk.com/guides/email-open-rate): Benchmarks and improvement tactics
- [Email click-through rate](https://www.useplunk.com/guides/email-click-through-rate): Measuring and improving CTR
- [Email sender reputation](https://www.useplunk.com/guides/email-sender-reputation): Protecting your sending reputation
- [Email marketing best practices](https://www.useplunk.com/guides/email-marketing-best-practices)
- [Transactional vs marketing email](https://www.useplunk.com/guides/transactional-vs-marketing-email)
- [What is SPF](https://www.useplunk.com/guides/what-is-spf)
- [What is DKIM](https://www.useplunk.com/guides/what-is-dkim)
- [What is DMARC](https://www.useplunk.com/guides/what-is-dmarc)
- [All guides](https://www.useplunk.com/guides)
## Tools
- [Verify email](https://www.useplunk.com/tools/verify-email): Check a single email address for validity
- [Spam checker](https://www.useplunk.com/tools/spam-checker): Score an email for spam triggers
- [Markdown to email](https://www.useplunk.com/tools/markdown-to-email): Convert Markdown into HTML email
- [SPF checker](https://www.useplunk.com/tools/spf-checker)
- [DKIM checker](https://www.useplunk.com/tools/dkim-checker)
- [DMARC checker](https://www.useplunk.com/tools/dmarc-checker)
- [MX checker](https://www.useplunk.com/tools/mx-checker)
- [Email headers analyzer](https://www.useplunk.com/tools/email-headers)
- [All tools](https://www.useplunk.com/tools)
## Optional
- [Documentation](https://docs.useplunk.com): Product, API, and self-hosting docs
- [Discord community](https://www.useplunk.com/discord)
- [Privacy policy](https://www.useplunk.com/privacy)
- [Terms of service](https://www.useplunk.com/terms)
- [Data processing agreement](https://www.useplunk.com/dpa)
+1 -13
View File
@@ -1,6 +1,5 @@
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router';
import {WIKI_URI} from '../../lib/constants'; import {WIKI_URI} from '../../lib/constants';
import logo from '../../../public/assets/logo.svg'; import logo from '../../../public/assets/logo.svg';
@@ -8,10 +7,6 @@ import logo from '../../../public/assets/logo.svg';
* *
*/ */
export default function Footer() { export default function Footer() {
const router = useRouter();
const path = (router.asPath || '/').split(/[?#]/)[0] ?? '/';
const trimmed = path === '/' ? '/' : path.replace(/\/$/, '');
const mdHref = `${trimmed}.md`;
return ( return (
<> <>
<footer className={'border-t border-neutral-200 bg-white'}> <footer className={'border-t border-neutral-200 bg-white'}>
@@ -268,15 +263,8 @@ export default function Footer() {
</div> </div>
</div> </div>
<div className="mt-16 flex flex-col gap-2 border-t border-neutral-200 pt-8 sm:flex-row sm:items-center sm:justify-between"> <div className="mt-16 border-t border-neutral-200 pt-8">
<p className="text-sm text-neutral-500">&copy; {new Date().getFullYear()} Plunk. All rights reserved.</p> <p className="text-sm text-neutral-500">&copy; {new Date().getFullYear()} Plunk. All rights reserved.</p>
<p style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] text-neutral-400">
Reading this with electronic eyes? Append{' '}
<a href={mdHref} className="text-neutral-500 underline decoration-dotted underline-offset-2 transition hover:text-neutral-900">
<code>.md</code>
</a>{' '}
to any URL for the Markdown cut.
</p>
</div> </div>
</div> </div>
</footer> </footer>
-114
View File
@@ -1,114 +0,0 @@
import {AnimatePresence, motion} from 'framer-motion';
import React, {useEffect, useState} from 'react';
import {ArrowRight, Gift, X} from 'lucide-react';
import {DASHBOARD_URI} from '../lib/constants';
interface SwitchOfferProps {
competitorName: string;
}
export function SwitchOffer({competitorName}: SwitchOfferProps) {
const storageKey = `switch-offer-dismissed:${competitorName}`;
const [visible, setVisible] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
if (window.localStorage.getItem(storageKey) === '1') return;
const t = window.setTimeout(() => setVisible(true), 800);
return () => window.clearTimeout(t);
}, [storageKey]);
const dismiss = () => {
setVisible(false);
if (typeof window !== 'undefined') {
window.localStorage.setItem(storageKey, '1');
}
};
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{opacity: 0, y: 24, scale: 0.96}}
animate={{opacity: 1, y: 0, scale: 1}}
exit={{opacity: 0, y: 16, scale: 0.97}}
transition={{duration: 0.45, ease: [0.22, 1, 0.36, 1]}}
className={
'pointer-events-auto fixed bottom-4 right-4 z-50 w-[calc(100vw-2rem)] max-w-sm sm:bottom-6 sm:right-6'
}
role="dialog"
aria-label={`Switch from ${competitorName} to Plunk offer`}
>
<div
className={
'relative overflow-hidden rounded-2xl border border-neutral-900 bg-white shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]'
}
>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss offer"
className={
'absolute right-2.5 top-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900'
}
>
<X className="h-3.5 w-3.5" />
</button>
<div className={'p-5 pr-10 sm:p-6 sm:pr-12'}>
<div
className={
'inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-50 px-2 py-1'
}
>
<Gift className="h-3 w-3 text-neutral-700" />
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[10px] uppercase tracking-[0.18em] text-neutral-600'}
>
Switching offer
</span>
</div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mt-3 text-lg font-bold leading-[1.15] tracking-[-0.02em] text-neutral-900'}
>
Switching from {competitorName}?
<br />
<span className={'text-neutral-500'}>Get 2,000 free emails.</span>
</h3>
<p className={'mt-3 text-xs leading-relaxed text-neutral-600'}>
Sign up and enter this code at checkout to redeem 2,000 email credits.
</p>
<div className={'mt-3 rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-3 py-2.5'}>
<div className={'mt-1 flex items-baseline justify-between gap-2'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-base font-bold tracking-[0.08em] text-neutral-900'}
>
SWITCH
</span>
</div>
</div>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-full bg-neutral-900 px-4 py-2.5 text-xs font-semibold text-white transition hover:bg-neutral-800'
}
>
Claim your credits
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</motion.a>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
-1
View File
@@ -4,4 +4,3 @@ export * from './ComparisonTable';
export * from './FAQSection'; export * from './FAQSection';
export * from './CodeBlock'; export * from './CodeBlock';
export * from './SectionHeader'; export * from './SectionHeader';
export * from './SwitchOffer';
@@ -1,14 +0,0 @@
export const MARKDOWN_SLUGS: ReadonlySet<string> = new Set([
'index',
'pricing',
'features/workflows',
'features/segments',
'features/inbound-email',
'features/email-editor',
'features/smtp',
]);
export function hasMarkdownVariant(pathname: string): boolean {
const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index';
return MARKDOWN_SLUGS.has(slug);
}
-2
View File
@@ -341,5 +341,3 @@ Plunk provides SMTP credentials so you can send emails from any application or f
[Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com) [Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com)
`, `,
}; };
export {MARKDOWN_SLUGS, hasMarkdownVariant} from './markdown-slugs';
+1 -6
View File
@@ -1,8 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { hasMarkdownVariant } from './content/markdown-slugs';
type Negotiated = 'markdown' | 'html' | 'none'; type Negotiated = 'markdown' | 'html' | 'none';
function parseAccept(accept: string): Array<{ type: string; q: number }> { function parseAccept(accept: string): Array<{ type: string; q: number }> {
@@ -28,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 = types.find(t => t.type === 'text/markdown')?.q ?? -1; const mdQ = getQ(types, 'text/markdown');
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';
@@ -61,9 +59,6 @@ export function middleware(request: NextRequest) {
const response = NextResponse.next(); const response = NextResponse.next();
response.headers.set('Vary', 'Accept'); response.headers.set('Vary', 'Accept');
if (hasMarkdownVariant(pathname)) {
response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`);
}
return response; return response;
} }
-7
View File
@@ -2,11 +2,9 @@ import '../styles/globals.css';
import React, {useEffect} from 'react'; import React, {useEffect} from 'react';
import Head from 'next/head'; import Head from 'next/head';
import {AppProps} from 'next/app'; import {AppProps} from 'next/app';
import {useRouter} from 'next/router';
import {toast, Toaster} from 'sonner'; import {toast, Toaster} from 'sonner';
import {SWRConfig} from 'swr'; import {SWRConfig} from 'swr';
import {network} from '../lib/network'; import {network} from '../lib/network';
import {hasMarkdownVariant} from '../content/markdown-slugs';
import {DefaultSeo} from 'next-seo'; import {DefaultSeo} from 'next-seo';
import Script from 'next/script'; import Script from 'next/script';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google'; import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
@@ -39,10 +37,6 @@ const mono = JetBrains_Mono({
* @param props.pageProps * @param props.pageProps
*/ */
function App({Component, pageProps}: AppProps) { function App({Component, pageProps}: AppProps) {
const router = useRouter();
const pathname = (router.asPath.split('?')[0] ?? '').split('#')[0] ?? '/';
const markdownHref = hasMarkdownVariant(pathname) ? `${pathname === '/' ? '/index' : pathname}.md` : null;
useEffect(() => { useEffect(() => {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const message = searchParams.get('message'); const message = searchParams.get('message');
@@ -57,7 +51,6 @@ function App({Component, pageProps}: AppProps) {
<Head> <Head>
<title>Plunk | The Open-Source Email Platform</title> <title>Plunk | The Open-Source Email Platform</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} /> <meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
{markdownHref && <link rel="alternate" type="text/markdown" href={markdownHref} />}
</Head> </Head>
<Toaster position={'top-right'} /> <Toaster position={'top-right'} />
-1
View File
@@ -725,7 +725,6 @@ 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'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -170,8 +170,6 @@ export default function ActiveCampaignComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" /> <FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
<SwitchOffer competitorName="ActiveCampaign" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -186,8 +186,6 @@ export default function AmazonSesComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-amazon-ses" /> <FAQSection faqs={faqs} schemaId="faq-schema-amazon-ses" />
<SwitchOffer competitorName="Amazon SES" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -169,8 +169,6 @@ export default function BentoComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-bento" /> <FAQSection faqs={faqs} schemaId="faq-schema-bento" />
<SwitchOffer competitorName="Bento" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -193,8 +193,6 @@ export default function BrevoComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" /> <FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
<SwitchOffer competitorName="Brevo" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -186,8 +186,6 @@ export default function ButtondownComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-buttondown" /> <FAQSection faqs={faqs} schemaId="faq-schema-buttondown" />
<SwitchOffer competitorName="Buttondown" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -193,8 +193,6 @@ export default function ConvertkitComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" /> <FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
<SwitchOffer competitorName="ConvertKit" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -189,8 +189,6 @@ export default function CustomerioComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" /> <FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
<SwitchOffer competitorName="Customer.io" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -170,8 +170,6 @@ export default function KlaviyoComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" /> <FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
<SwitchOffer competitorName="Klaviyo" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -193,8 +193,6 @@ export default function LoopsComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-loops" /> <FAQSection faqs={faqs} schemaId="faq-schema-loops" />
<SwitchOffer competitorName="Loops" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -195,8 +195,6 @@ export default function MailchimpComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" /> <FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
<SwitchOffer competitorName="Mailchimp" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -170,8 +170,6 @@ export default function MailerliteComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" /> <FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
<SwitchOffer competitorName="MailerLite" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -189,8 +189,6 @@ export default function MailgunComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" /> <FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
<SwitchOffer competitorName="Mailgun" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -186,8 +186,6 @@ export default function MailjetComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-mailjet" /> <FAQSection faqs={faqs} schemaId="faq-schema-mailjet" />
<SwitchOffer competitorName="Mailjet" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -190,8 +190,6 @@ export default function PostmarkComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" /> <FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
<SwitchOffer competitorName="Postmark" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -189,8 +189,6 @@ export default function ResendComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-resend" /> <FAQSection faqs={faqs} schemaId="faq-schema-resend" />
<SwitchOffer competitorName="Resend" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+1 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components'; import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion'; import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants'; import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react'; import React from 'react';
@@ -193,8 +193,6 @@ export default function SendGridComparison() {
{/* FAQ */} {/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" /> <FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
<SwitchOffer competitorName="SendGrid" />
{/* CTA */} {/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}> <section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}> <div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
+18 -18
View File
@@ -133,8 +133,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'event.triggered': case 'event.triggered':
return { return {
icon: Zap, icon: Zap,
color: 'text-amber-700', color: 'text-neutral-600',
bgColor: 'bg-amber-50', bgColor: 'bg-neutral-100',
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered', title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
description: undefined, description: undefined,
badge: { badge: {
@@ -150,8 +150,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.sent': case 'email.sent':
return { return {
icon: Send, icon: Send,
color: 'text-neutral-700', color: 'text-green-700',
bgColor: 'bg-neutral-100', bgColor: 'bg-green-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent', title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
description: metadata.campaignName description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}` ? `Campaign: ${String(metadata.campaignName)}`
@@ -169,8 +169,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.delivered': case 'email.delivered':
return { return {
icon: CheckCircle, icon: CheckCircle,
color: 'text-emerald-700', color: 'text-green-700',
bgColor: 'bg-emerald-50', bgColor: 'bg-green-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered', title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
description: metadata.campaignName description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}` ? `Campaign: ${String(metadata.campaignName)}`
@@ -199,8 +199,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.opened': case 'email.opened':
return { return {
icon: Eye, icon: Eye,
color: 'text-emerald-700', color: 'text-neutral-600',
bgColor: 'bg-emerald-50', bgColor: 'bg-neutral-100',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened', title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
description: description:
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1 typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
@@ -219,8 +219,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.clicked': case 'email.clicked':
return { return {
icon: MousePointerClick, icon: MousePointerClick,
color: 'text-sky-700', color: 'text-neutral-600',
bgColor: 'bg-sky-50', bgColor: 'bg-neutral-100',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked', title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
description: description:
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1 typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
@@ -269,8 +269,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'workflow.started': case 'workflow.started':
return { return {
icon: Workflow, icon: Workflow,
color: 'text-amber-700', color: 'text-neutral-600',
bgColor: 'bg-amber-50', bgColor: 'bg-neutral-100',
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started', title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
description: `Status: ${String(metadata.status || 'unknown')}`, description: `Status: ${String(metadata.status || 'unknown')}`,
badge: { badge: {
@@ -282,8 +282,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'workflow.completed': case 'workflow.completed':
return { return {
icon: CheckCheck, icon: CheckCheck,
color: 'text-amber-700', color: 'text-green-700',
bgColor: 'bg-amber-50', bgColor: 'bg-green-50',
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed', title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
description: metadata.exitReason description: metadata.exitReason
? `Exit: ${String(metadata.exitReason)}` ? `Exit: ${String(metadata.exitReason)}`
@@ -297,8 +297,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'campaign.scheduled': case 'campaign.scheduled':
return { return {
icon: Calendar, icon: Calendar,
color: 'text-sky-700', color: 'text-neutral-600',
bgColor: 'bg-sky-50', bgColor: 'bg-neutral-100',
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled', title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
description: metadata.subject description: metadata.subject
? `${String(metadata.subject)}${metadata.totalRecipients ? `${metadata.totalRecipients} recipients` : ''}` ? `${String(metadata.subject)}${metadata.totalRecipients ? `${metadata.totalRecipients} recipients` : ''}`
@@ -314,8 +314,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'workflow.email.scheduled': case 'workflow.email.scheduled':
return { return {
icon: Calendar, icon: Calendar,
color: 'text-amber-700', color: 'text-neutral-600',
bgColor: 'bg-amber-50', bgColor: 'bg-neutral-100',
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled', title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
description: metadata.workflowName description: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? `${String(metadata.subject)}` : ''}` ? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? `${String(metadata.subject)}` : ''}`
+8 -10
View File
@@ -335,8 +335,6 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
<div className="space-y-4"> <div className="space-y-4">
{domains.map(domain => { {domains.map(domain => {
const status = getDomainStatus(domain); const status = getDomainStatus(domain);
const mailFromSubdomain = config?.aws?.mailFromSubdomain ?? 'plunk';
const mailFromHost = `${mailFromSubdomain}.${domain.domain}`;
return ( return (
<div key={domain.id} className="border border-neutral-200 rounded-lg p-4"> <div key={domain.id} className="border border-neutral-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@@ -496,8 +494,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
</Badge> </Badge>
</div> </div>
<p className="text-xs text-neutral-600 mb-2"> <p className="text-xs text-neutral-600 mb-2">
Set up a custom MAIL FROM domain ({mailFromHost}) to improve deliverability and handle Set up a custom MAIL FROM domain (plunk.{domain.domain}) to improve deliverability and
bounces/complaints. handle bounces/complaints.
</p> </p>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -524,16 +522,16 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
<td className="py-3 px-3"> <td className="py-3 px-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1"> <code className="text-xs font-mono text-neutral-700 break-all flex-1">
{mailFromHost} plunk.{domain.domain}
</code> </code>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleCopyToken(mailFromHost, 3000)} onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
className="shrink-0 h-6 w-6 p-0 overflow-hidden" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
<AnimatedCopyIcon <AnimatedCopyIcon
isCopied={copiedToken === `${mailFromHost}-3000`} isCopied={copiedToken === `plunk.${domain.domain}-3000`}
/> />
</Button> </Button>
</div> </div>
@@ -573,16 +571,16 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
<td className="py-3 px-3"> <td className="py-3 px-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1"> <code className="text-xs font-mono text-neutral-700 break-all flex-1">
{mailFromHost} plunk.{domain.domain}
</code> </code>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleCopyToken(mailFromHost, 3001)} onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
className="shrink-0 h-6 w-6 p-0 overflow-hidden" className="shrink-0 h-6 w-6 p-0 overflow-hidden"
> >
<AnimatedCopyIcon <AnimatedCopyIcon
isCopied={copiedToken === `${mailFromHost}-3001`} isCopied={copiedToken === `plunk.${domain.domain}-3001`}
/> />
</Button> </Button>
</div> </div>
@@ -306,31 +306,21 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
iframeDoc.write(fullHtml); iframeDoc.write(fullHtml);
iframeDoc.close(); iframeDoc.close();
// Auto-adjust iframe height to content. Reset to a small value first so // Auto-adjust iframe height to content
// body content that uses % / vh heights doesn't lock the iframe to its
// previous size (which would otherwise cause the iframe to grow by the
// padding offset on every preview-device switch).
const adjustHeight = () => { const adjustHeight = () => {
if (!iframe.contentWindow) return; if (iframe.contentWindow) {
iframe.style.height = '0px'; const height = iframe.contentWindow.document.body.scrollHeight;
const doc = iframe.contentWindow.document;
const height = Math.max(
doc.body?.scrollHeight ?? 0,
doc.documentElement?.scrollHeight ?? 0,
);
iframe.style.height = `${Math.max(400, height + 40)}px`; iframe.style.height = `${Math.max(400, height + 40)}px`;
}
}; };
const timeouts = [ // Adjust height after content loads
window.setTimeout(adjustHeight, 100), if (iframe.contentWindow) {
window.setTimeout(adjustHeight, 300), iframe.contentWindow.addEventListener('load', adjustHeight);
]; // Also adjust immediately for already-loaded content
iframe.contentWindow?.addEventListener('load', adjustHeight); setTimeout(adjustHeight, 100);
setTimeout(adjustHeight, 300); // Fallback for slow-loading images
return () => { }
timeouts.forEach(window.clearTimeout);
iframe.contentWindow?.removeEventListener('load', adjustHeight);
};
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+10 -10
View File
@@ -33,7 +33,7 @@ function HelpResources() {
}; };
return ( return (
<div className="px-6 pb-6 pt-4 border-t border-neutral-200"> <div className="mt-4 pt-4 border-t border-neutral-200">
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p> <p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<Button asChild variant="outline" size="sm" className="flex-1"> <Button asChild variant="outline" size="sm" className="flex-1">
@@ -101,12 +101,12 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
if (isLoading || !setupState) { if (isLoading || !setupState) {
return ( return (
<Card className="flex flex-col h-full"> <Card>
<CardHeader> <CardHeader>
<CardTitle>Quick Start</CardTitle> <CardTitle>Quick Start</CardTitle>
<CardDescription>Get started with Plunk in minutes</CardDescription> <CardDescription>Get started with Plunk in minutes</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1 min-h-0 overflow-y-auto"> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{[1, 2, 3].map(i => ( {[1, 2, 3].map(i => (
<div <div
@@ -122,8 +122,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
</div> </div>
))} ))}
</div> </div>
</CardContent>
<HelpResources /> <HelpResources />
</CardContent>
</Card> </Card>
); );
} }
@@ -217,12 +217,12 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
// If core setup is complete and they're actively sending, show success message // If core setup is complete and they're actively sending, show success message
if (setupState.hasVerifiedDomain && hasContacts && hasSentCampaign && hasRecentCampaign) { if (setupState.hasVerifiedDomain && hasContacts && hasSentCampaign && hasRecentCampaign) {
return ( return (
<Card className="flex flex-col h-full"> <Card>
<CardHeader> <CardHeader>
<CardTitle>Quick Start</CardTitle> <CardTitle>Quick Start</CardTitle>
<CardDescription>Your project is fully set up</CardDescription> <CardDescription>Your project is fully set up</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1 min-h-0 overflow-y-auto"> <CardContent>
<div className="flex items-start gap-4 p-4 bg-green-50 rounded-lg border border-green-200"> <div className="flex items-start gap-4 p-4 bg-green-50 rounded-lg border border-green-200">
<div className="h-10 w-10 rounded-lg bg-green-100 border border-green-200 flex items-center justify-center flex-shrink-0"> <div className="h-10 w-10 rounded-lg bg-green-100 border border-green-200 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="h-5 w-5 text-green-700" /> <CheckCircle2 className="h-5 w-5 text-green-700" />
@@ -234,8 +234,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
</p> </p>
</div> </div>
</div> </div>
</CardContent>
<HelpResources /> <HelpResources />
</CardContent>
</Card> </Card>
); );
} }
@@ -244,14 +244,14 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
const visibleSteps = allSteps.slice(0, 3); const visibleSteps = allSteps.slice(0, 3);
return ( return (
<Card className="flex flex-col h-full"> <Card>
<CardHeader> <CardHeader>
<CardTitle>Quick Start</CardTitle> <CardTitle>Quick Start</CardTitle>
<CardDescription> <CardDescription>
{visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'} {visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1 min-h-0 overflow-y-auto"> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{visibleSteps.map(step => { {visibleSteps.map(step => {
const Icon = step.icon; const Icon = step.icon;
@@ -281,8 +281,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
); );
})} })}
</div> </div>
</CardContent>
<HelpResources /> <HelpResources />
</CardContent>
</Card> </Card>
); );
} }
+5 -15
View File
@@ -17,7 +17,6 @@ import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db'; import type {WorkflowStep} from '@plunk/db';
import { import {
Clock, Clock,
ExternalLink,
GitBranch, GitBranch,
Hourglass, Hourglass,
Lightbulb, Lightbulb,
@@ -244,7 +243,7 @@ function CustomNode({
bgColor?: string; bgColor?: string;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void; onDelete?: () => void;
template?: {id: string; name: string}; template?: {name: string};
config?: any; config?: any;
}; };
}) { }) {
@@ -342,19 +341,10 @@ function CustomNode({
{/* Details */} {/* Details */}
{data.template && ( {data.template && (
<div className="mt-3 pt-3 border-t border-neutral-100"> <div className="mt-3 pt-3 border-t border-neutral-100">
<a <div className="flex items-center gap-2 text-xs text-neutral-600">
href={`/templates/${data.template.id}`} <Mail className="h-3 w-3" />
target="_blank" <span className="truncate">{data.template.name}</span>
rel="noopener noreferrer" </div>
onClick={e => e.stopPropagation()}
onMouseDown={e => e.stopPropagation()}
className="nodrag flex items-center gap-2 text-xs text-neutral-600 hover:text-blue-600 hover:bg-blue-50 -mx-2 px-2 py-1 rounded transition-colors group/template"
title="Open template in a new tab"
>
<Mail className="h-3 w-3 shrink-0" />
<span className="truncate flex-1">{data.template.name}</span>
<ExternalLink className="h-3 w-3 shrink-0 opacity-0 group-hover/template:opacity-100 transition-opacity" />
</a>
</div> </div>
)} )}
{data.type === 'DELAY' && data.config?.amount && ( {data.type === 'DELAY' && data.config?.amount && (
@@ -1,5 +1,4 @@
import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue, Input} from '@plunk/ui'; import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue, Input} from '@plunk/ui';
import {ExternalLink} from 'lucide-react';
import {useState} from 'react'; import {useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
@@ -69,20 +68,7 @@ export function SendEmailStepDialog({step, workflowId, open, onOpenChange, onSuc
> >
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="flex items-center justify-between">
<Label htmlFor="editTemplate">Email Template</Label> <Label htmlFor="editTemplate">Email Template</Label>
{templateId && (
<a
href={`/templates/${templateId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-blue-600 transition-colors"
>
Edit template
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} /> <TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
</div> </div>
@@ -1,4 +1,3 @@
import {Label, RadioGroup, RadioGroupItem} from '@plunk/ui';
import {useState} from 'react'; import {useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
@@ -6,50 +5,31 @@ import {KeyValueEditor} from '../KeyValueEditor';
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared'; import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
type SubscriptionAction = 'none' | 'subscribe' | 'unsubscribe';
const SUBSCRIPTION_OPTIONS: Array<{value: SubscriptionAction; label: string; description: string}> = [
{value: 'none', label: 'Leave as is', description: "Don't change the contact's subscription state."},
{value: 'subscribe', label: 'Subscribe', description: 'Mark the contact as subscribed.'},
{value: 'unsubscribe', label: 'Unsubscribe', description: 'Mark the contact as unsubscribed.'},
];
export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) { export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
const config = getStepConfig(step); const config = getStepConfig(step);
const initialUpdates = const initialUpdates =
config.updates && typeof config.updates === 'object' config.updates && typeof config.updates === 'object'
? (config.updates as Record<string, string | number | boolean>) ? (config.updates as Record<string, string | number | boolean>)
: null; : null;
const initialSubscriptionAction: SubscriptionAction =
config.subscriptionAction === 'subscribe' || config.subscriptionAction === 'unsubscribe'
? config.subscriptionAction
: 'none';
const [name, setName] = useState(step.name); const [name, setName] = useState(step.name);
const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | null>( const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | null>(
initialUpdates, initialUpdates,
); );
const [subscriptionAction, setSubscriptionAction] = useState<SubscriptionAction>(initialSubscriptionAction);
const {update, isSubmitting} = useStepUpdate(workflowId, step.id); const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const hasUpdates = contactUpdateData && Object.keys(contactUpdateData).length > 0; if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) {
const hasSubscriptionAction = subscriptionAction !== 'none'; toast.error('At least one field to update is required');
if (!hasUpdates && !hasSubscriptionAction) {
toast.error('Add at least one field to update or choose a subscription action');
return; return;
} }
const ok = await update({ const ok = await update({
name, name,
config: { config: {updates: contactUpdateData},
updates: hasUpdates ? contactUpdateData : {},
subscriptionAction,
},
}); });
if (ok) { if (ok) {
@@ -68,32 +48,7 @@ export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, o
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
> >
<div className="space-y-2">
<Label>Subscription state</Label>
<RadioGroup
value={subscriptionAction}
onValueChange={value => setSubscriptionAction(value as SubscriptionAction)}
className="gap-2"
>
{SUBSCRIPTION_OPTIONS.map(option => (
<label
key={option.value}
htmlFor={`subscriptionAction-${option.value}`}
className="flex items-start gap-3 rounded-md border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50"
>
<RadioGroupItem id={`subscriptionAction-${option.value}`} value={option.value} className="mt-0.5" />
<div className="space-y-0.5">
<div className="text-sm font-medium text-neutral-900">{option.label}</div>
<div className="text-xs text-neutral-500">{option.description}</div>
</div>
</label>
))}
</RadioGroup>
</div>
<div className="space-y-2">
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} /> <KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
</div>
</StepDialogShell> </StepDialogShell>
); );
} }
@@ -1,126 +0,0 @@
import {describe, expect, it} from 'vitest';
import {detectCustomHtmlPatterns} from '../emailStyles';
describe('detectCustomHtmlPatterns', () => {
describe('empty / whitespace input', () => {
it('returns false for empty string', () => {
expect(detectCustomHtmlPatterns('')).toBe(false);
});
it('returns false for whitespace-only string', () => {
expect(detectCustomHtmlPatterns(' \n\t ')).toBe(false);
});
});
describe('content TipTap can round-trip (should NOT be flagged as custom)', () => {
it('returns false for a basic paragraph', () => {
expect(detectCustomHtmlPatterns('<p>Hello</p>')).toBe(false);
});
it('returns false for headings, lists, blockquote, bold, italic', () => {
expect(
detectCustomHtmlPatterns(
'<h1>Title</h1><p><strong>bold</strong> <em>italic</em></p><ul><li>one</li></ul><blockquote>quote</blockquote>',
),
).toBe(false);
});
it('returns false for <span> with inline color style (TipTap TextStyle output)', () => {
expect(detectCustomHtmlPatterns('<span style="color: rgb(220, 38, 38)">red</span>')).toBe(false);
});
it('returns false for <span> with background-color: initial (TipTap export artifact)', () => {
expect(detectCustomHtmlPatterns('<span style="background-color: initial">stuff</span>')).toBe(false);
});
it('returns false for <span> with background-color: transparent (TipTap export artifact)', () => {
expect(detectCustomHtmlPatterns('<span style="background-color: transparent">stuff</span>')).toBe(false);
});
it('returns false for <a> with inline style (TipTap Link output)', () => {
expect(detectCustomHtmlPatterns('<a href="https://example.com" style="color: red">link</a>')).toBe(false);
});
it('returns false for paragraph with inline text-align style', () => {
expect(detectCustomHtmlPatterns('<p style="text-align: center">centered</p>')).toBe(false);
});
it('returns false when an href URL contains "id=" or "contactId=" (must not match custom-attr regex)', () => {
expect(
detectCustomHtmlPatterns('<a href="https://example.com/u?contactId=abc&id=123">unsub</a>'),
).toBe(false);
});
it('returns false for allowed class prefixes', () => {
expect(detectCustomHtmlPatterns('<p class="prose">x</p>')).toBe(false);
expect(detectCustomHtmlPatterns('<span class="variable-mention">x</span>')).toBe(false);
expect(detectCustomHtmlPatterns('<img class="email-image" src="x" />')).toBe(false);
});
it('returns false for a TipTap-style colored span wrapped in a paragraph', () => {
expect(
detectCustomHtmlPatterns('<p>Hello <span style="color: rgb(220, 38, 38);">world</span>!</p>'),
).toBe(false);
});
});
describe('content TipTap can NOT round-trip (should be flagged as custom)', () => {
it('returns true for <div>', () => {
expect(detectCustomHtmlPatterns('<div>stuff</div>')).toBe(true);
});
it('returns true for <table> markup (no TipTap Table extension loaded)', () => {
expect(detectCustomHtmlPatterns('<table><tr><td>x</td></tr></table>')).toBe(true);
});
it('returns true for a single <table> tag', () => {
expect(detectCustomHtmlPatterns('<table>x</table>')).toBe(true);
});
it('returns true for <style> tag', () => {
expect(detectCustomHtmlPatterns('<style>p { color: red; }</style>')).toBe(true);
});
it('returns true for @media query inside a style block', () => {
expect(detectCustomHtmlPatterns('@media (max-width: 600px) { ... }')).toBe(true);
});
it('returns true for custom data-* attribute', () => {
expect(detectCustomHtmlPatterns('<p data-foo="bar">x</p>')).toBe(true);
});
it('returns true for aria-* attribute', () => {
expect(detectCustomHtmlPatterns('<p aria-label="x">y</p>')).toBe(true);
});
it('returns true for role= attribute', () => {
expect(detectCustomHtmlPatterns('<p role="presentation">x</p>')).toBe(true);
});
it('returns true for id= attribute on an element', () => {
expect(detectCustomHtmlPatterns('<p id="main">x</p>')).toBe(true);
});
it('returns true for a disallowed CSS class', () => {
expect(detectCustomHtmlPatterns('<p class="custom">x</p>')).toBe(true);
});
it('returns true for <section>, <article>, <header>, <footer>, <nav>, <aside>, <main>', () => {
expect(detectCustomHtmlPatterns('<section>x</section>')).toBe(true);
expect(detectCustomHtmlPatterns('<article>x</article>')).toBe(true);
expect(detectCustomHtmlPatterns('<header>x</header>')).toBe(true);
expect(detectCustomHtmlPatterns('<footer>x</footer>')).toBe(true);
expect(detectCustomHtmlPatterns('<nav>x</nav>')).toBe(true);
expect(detectCustomHtmlPatterns('<aside>x</aside>')).toBe(true);
expect(detectCustomHtmlPatterns('<main>x</main>')).toBe(true);
});
it('returns true for form/input/button/iframe/svg', () => {
expect(detectCustomHtmlPatterns('<form>x</form>')).toBe(true);
expect(detectCustomHtmlPatterns('<input type="text" />')).toBe(true);
expect(detectCustomHtmlPatterns('<button>x</button>')).toBe(true);
expect(detectCustomHtmlPatterns('<iframe src="x"></iframe>')).toBe(true);
expect(detectCustomHtmlPatterns('<svg><circle /></svg>')).toBe(true);
});
});
});
+14 -24
View File
@@ -1,15 +1,10 @@
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor // 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) {
@@ -33,26 +28,21 @@ export const detectCustomHtmlPatterns = (html: string): boolean => {
} }
} }
// Custom attributes that carry semantics TipTap doesn't preserve. We require an const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
// attribute-boundary (whitespace, `=`, or quote) before the prefix so that query const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
// strings like `?id=...` inside an `href="..."` value don't false-match. 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.
// - 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 hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags; return (
hasInlineStyles ||
hasCustomClasses ||
hasCustomAttributes ||
hasComplexTables ||
hasCustomElements ||
hasMediaQueries ||
hasStyleTags
);
}; };
export const wrapEmailWithStyles = (htmlBody: string): string => { export const wrapEmailWithStyles = (htmlBody: string): string => {
-1
View File
@@ -21,7 +21,6 @@ export interface ConfigResponse {
}; };
aws: { aws: {
sesRegion: string; sesRegion: string;
mailFromSubdomain: string;
}; };
} }
+225 -336
View File
@@ -42,7 +42,6 @@ import {
ArrowLeft, ArrowLeft,
Calendar, Calendar,
ChevronDown, ChevronDown,
Info,
Mail, Mail,
MousePointer, MousePointer,
Save, Save,
@@ -108,7 +107,6 @@ export default function CampaignDetailsPage() {
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({}); const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
const [scheduledDateTime, setScheduledDateTime] = useState(''); const [scheduledDateTime, setScheduledDateTime] = useState('');
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [testEmailAddress, setTestEmailAddress] = useState(''); const [testEmailAddress, setTestEmailAddress] = useState('');
type CampaignDialog = type CampaignDialog =
@@ -180,7 +178,6 @@ export default function CampaignDetailsPage() {
toast.success(`Campaign scheduled for ${localTimeString}`); toast.success(`Campaign scheduled for ${localTimeString}`);
setDialog({type: 'none'}); setDialog({type: 'none'});
setScheduledDateTime(''); setScheduledDateTime('');
setSelectedPreset(null);
void mutate(); void mutate();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign'); toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
@@ -439,162 +436,41 @@ export default function CampaignDetailsPage() {
</div> </div>
</div> </div>
{/* Audience surfaced first because Send lives in the header. {/* Campaign Settings - Horizontal Layout */}
Users need to see who/how many before pressing Send. */}
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<div>
<CardTitle>Audience</CardTitle>
<CardDescription>Who will receive this campaign when you send</CardDescription>
</div>
{draftRecipientCount > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 shrink-0">
<Users className="h-4 w-4 text-neutral-500" />
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
{draftRecipientCount.toLocaleString()} {draftRecipientCount === 1 ? 'recipient' : 'recipients'}
</span>
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="audienceType">
Audience Type <span className="text-red-500">*</span>
</Label>
<Select
value={editedCampaign.audienceType ?? c.audienceType}
onValueChange={(value: CampaignAudienceType) => {
setEditedCampaign({
...editedCampaign,
audienceType: value,
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
});
}}
>
<SelectTrigger id="audienceType">
<SelectValue placeholder="Select audience type" />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription
value={CampaignAudienceType.ALL}
title={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/>
<SelectItemWithDescription
value={CampaignAudienceType.SEGMENT}
title="Specific Segment"
description="Target a defined group of contacts"
/>
</SelectContent>
</Select>
</div>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
<div className="space-y-2">
<Label htmlFor="segment">
Select Segment <span className="text-red-500">*</span>
</Label>
<Select
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
onValueChange={(value: string) => {
setEditedCampaign({
...editedCampaign,
segmentId: value,
});
}}
disabled={!segments || segments.length === 0}
>
<SelectTrigger id="segment">
<SelectValue
placeholder={segments && segments.length > 0 ? 'Choose a segment' : 'No segments available'}
/>
</SelectTrigger>
<SelectContent>
{segments &&
segments.length > 0 &&
segments.map(segment => (
<SelectItemWithDescription
key={segment.id}
value={segment.id}
title={segment.name}
description={`${segment.memberCount.toLocaleString()} contacts`}
/>
))}
</SelectContent>
</Select>
{segments && segments.length === 0 && (
<p className="text-sm text-neutral-500">
No segments found.{' '}
<Link href="/segments/new" className="underline">
Create one first
</Link>
</p>
)}
</div>
)}
</div>
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
<p className="text-sm text-neutral-500">
Filtered audiences are configured with advanced filter conditions
</p>
)}
{draftRecipientCount > 0 && (
<p className="text-xs text-neutral-500">
Recalculated at send time. Final count may differ if contacts{' '}
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'are added or removed, or segment membership changes.'
: 'subscribe, unsubscribe, or segment membership changes.'
}
</p>
)}
</CardContent>
</Card>
{/* Row 1: Basic Info + Campaign Type */}
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{/* Basic Information */} {/* Campaign Settings */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Basic Information</CardTitle> <CardTitle>Campaign Settings</CardTitle>
<CardDescription>Name and describe your campaign</CardDescription> <CardDescription>Basic information about your campaign</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div>
<Label htmlFor="name"> <Label htmlFor="name">Campaign Name *</Label>
Campaign Name <span className="text-red-500">*</span>
</Label>
<Input <Input
id="name" id="name"
placeholder="e.g., Spring Sale Announcement" type="text"
value={editedCampaign.name || ''} value={editedCampaign.name || ''}
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})} onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
required required
placeholder="Spring Sale Campaign"
/> />
</div> </div>
<div className="space-y-2"> <div>
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Input <Input
id="description" id="description"
placeholder="Internal notes about this campaign" type="text"
value={editedCampaign.description || ''} value={editedCampaign.description || ''}
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})} onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
placeholder="Optional description for internal use"
/> />
</div> </div>
</CardContent>
</Card>
{/* Campaign Type */} <div>
<Card> <Label>Campaign Type</Label>
<CardHeader> <div className="flex flex-col gap-2 mt-2">
<CardTitle>Campaign Type</CardTitle>
<CardDescription>Choose how this campaign should be treated</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
{([ {([
{value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'}, {value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
{value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'}, {value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
@@ -637,17 +513,20 @@ export default function CampaignDetailsPage() {
</div> </div>
</div> </div>
)} )}
</CardContent>
</Card>
</div> </div>
{/* Email Settings */} <div>
<Card> <Label htmlFor="subject">Subject Line *</Label>
<CardHeader> <Input
<CardTitle>Email Settings</CardTitle> id="subject"
<CardDescription>Configure sender information and subject</CardDescription> type="text"
</CardHeader> value={editedCampaign.subject || ''}
<CardContent className="space-y-4"> onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})}
required
placeholder="Introducing our Spring Sale!"
/>
</div>
<EmailSettings <EmailSettings
from={editedCampaign.from || ''} from={editedCampaign.from || ''}
fromName={editedCampaign.fromName || ''} fromName={editedCampaign.fromName || ''}
@@ -656,28 +535,119 @@ export default function CampaignDetailsPage() {
onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})} onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})}
onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})} onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})}
fromNamePlaceholder={activeProject?.name || 'Your Company'} fromNamePlaceholder={activeProject?.name || 'Your Company'}
layout="vertical"
/> />
<div className="space-y-2">
<Label htmlFor="subject">
Email Subject <span className="text-red-500">*</span>
</Label>
<Input
id="subject"
placeholder="e.g., Introducing our Spring Sale!"
value={editedCampaign.subject || ''}
onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})}
required
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Email Content */} {/* Audience Settings */}
<Card>
<CardHeader>
<CardTitle>Audience</CardTitle>
<CardDescription>Who will receive this campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="audienceType">Audience Type *</Label>
<Select
value={editedCampaign.audienceType ?? c.audienceType}
onValueChange={(value: CampaignAudienceType) => {
setEditedCampaign({
...editedCampaign,
audienceType: value,
// Clear segmentId if changing away from SEGMENT
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
});
}}
>
<SelectTrigger id="audienceType">
<SelectValue placeholder="Select audience type" />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription
value={CampaignAudienceType.ALL}
title={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/>
<SelectItemWithDescription
value={CampaignAudienceType.SEGMENT}
title="Segment"
description="Target a defined group of contacts"
/>
</SelectContent>
</Select>
</div>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
<div>
<Label htmlFor="segment">Select Segment *</Label>
<Select
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
onValueChange={(value: string) => {
setEditedCampaign({
...editedCampaign,
segmentId: value,
});
}}
disabled={!segments || segments.length === 0}
>
<SelectTrigger id="segment">
<SelectValue
placeholder={segments && segments.length > 0 ? 'Choose a segment' : 'No segments available'}
/>
</SelectTrigger>
<SelectContent>
{segments &&
segments.length > 0 &&
segments.map(segment => (
<SelectItemWithDescription
key={segment.id}
value={segment.id}
title={segment.name}
description={`${segment.memberCount.toLocaleString()} contacts`}
/>
))}
</SelectContent>
</Select>
{segments && segments.length === 0 && (
<p className="text-xs text-neutral-500 mt-1">Create a segment first to use this option</p>
)}
</div>
)}
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
<p className="text-sm text-neutral-500">
Filtered audiences are configured with advanced filter conditions
</p>
)}
{/* Show recipient count */}
{draftRecipientCount > 0 && (
<div className="mt-4 p-3 bg-neutral-50 border border-neutral-200 rounded-lg space-y-1.5">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-900">
{draftRecipientCount.toLocaleString()} recipients
</span>
</div>
<p className="text-xs text-neutral-500 pl-6">
Recalculated at send time. Final count may differ if contacts{' '}
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'are added or removed, or segment membership changes.'
: 'subscribe, unsubscribe, or segment membership changes.'
}
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Email Editor - Full Width */}
<Card className="overflow-visible"> <Card className="overflow-visible">
<CardHeader> <CardHeader>
<CardTitle>Email Content</CardTitle> <CardTitle>Email Content</CardTitle>
<CardDescription>Design your email message</CardDescription> <CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<EmailEditor <EmailEditor
@@ -692,19 +662,20 @@ export default function CampaignDetailsPage() {
{/* Test Email Dialog */} {/* Test Email Dialog */}
<Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}> <Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Send a preview</DialogTitle> <DialogTitle>Send Test Email</DialogTitle>
<DialogDescription> <DialogDescription>
Get a copy of this campaign in your inbox before sending it for real. Send a test version of this campaign to a project member to verify how it looks. The test email will
be prefixed with [TEST] in the subject line.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2"> <div>
<Label htmlFor="testEmail">Send to</Label> <Label htmlFor="testEmail">Project Member</Label>
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}> <Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
<SelectTrigger id="testEmail"> <SelectTrigger id="testEmail" className="mt-2">
<SelectValue placeholder="Choose a teammate" /> <SelectValue placeholder="Select a project member..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{projectMembers?.data.map(member => ( {projectMembers?.data.map(member => (
@@ -714,29 +685,14 @@ export default function CampaignDetailsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> <p className="text-xs text-neutral-500 mt-2">
For security reasons, test emails can only be sent to project members.
{/* Preview of how the email will arrive */}
<div className="space-y-2">
<Label className="text-neutral-500">Will arrive as</Label>
<div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200 text-sm">
<div className="grid grid-cols-[64px_1fr] gap-3 px-3 py-2.5">
<span className="text-neutral-500">From</span>
<span className="text-neutral-900 truncate">{editedCampaign.from || c.from}</span>
</div>
<div className="grid grid-cols-[64px_1fr] gap-3 px-3 py-2.5">
<span className="text-neutral-500">Subject</span>
<span className="text-neutral-900 truncate">
<span className="font-medium">[TEST]</span> {editedCampaign.subject || c.subject}
</span>
</div>
</div>
</div>
<p className="text-xs text-neutral-500 leading-relaxed">
Variables like {'{{firstName}}'} aren{"'"}t replaced in previews. You{"'"}ll see them as written.
</p> </p>
<p className="text-xs text-neutral-500 mt-1">
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.
</p>
</div>
</div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -753,8 +709,7 @@ export default function CampaignDetailsPage() {
onClick={handleSendTestEmail} onClick={handleSendTestEmail}
disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress} disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress}
> >
<TestTube className="h-4 w-4" /> {dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send Test Email'}
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send preview'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -762,105 +717,92 @@ export default function CampaignDetailsPage() {
{/* Schedule Dialog */} {/* Schedule Dialog */}
<Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}> <Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Schedule for later</DialogTitle> <DialogTitle>Schedule Campaign</DialogTitle>
<DialogDescription> <DialogDescription>
Pick a time and Plunk will send it for you. Times shown in {getUserTimezone()}. Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()}
)
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-5 py-2"> {/* Quick Presets */}
{/* Quick presets */} <div>
<div className="space-y-2"> <Label>Quick Schedule</Label>
<Label>Quick options</Label> <div className="grid grid-cols-2 gap-2 mt-2">
<div className="grid grid-cols-2 gap-2"> <Button
{[
{key: 'in1h', label: 'In 1 hour', getValue: schedulePresets.inOneHour},
{key: 'in3h', label: 'In 3 hours', getValue: schedulePresets.inThreeHours},
{key: 'tom9', label: 'Tomorrow, 9 AM', getValue: schedulePresets.tomorrowAt9AM},
{key: 'tom2', label: 'Tomorrow, 2 PM', getValue: schedulePresets.tomorrowAt2PM},
{key: 'nextMon', label: 'Next Monday', getValue: schedulePresets.nextMonday},
{key: 'in1w', label: 'In 1 week', getValue: schedulePresets.inOneWeek},
].map(({key, label, getValue}) => {
const isActive = selectedPreset === key;
return (
<button
key={key}
type="button" type="button"
onClick={() => { variant="outline"
setScheduledDateTime(getValue()); size="sm"
setSelectedPreset(key); onClick={() => setScheduledDateTime(schedulePresets.inOneHour())}
}}
className={`min-h-[40px] px-3 py-2 rounded-lg border text-sm text-left transition-colors ${
isActive
? 'border-neutral-900 bg-neutral-50 text-neutral-900 font-medium'
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:text-neutral-900'
}`}
> >
{label} In 1 hour
</button> </Button>
); <Button
})} type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inThreeHours())}
>
In 3 hours
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt9AM())}
>
Tomorrow at 9 AM
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt2PM())}
>
Tomorrow at 2 PM
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.nextMonday())}
>
Next Monday
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inOneWeek())}
>
In 1 week
</Button>
</div> </div>
</div> </div>
{/* Custom Date/Time */} {/* Custom Date/Time */}
<div className="space-y-2"> <div>
<Label htmlFor="scheduledDateTime">Or pick an exact time</Label> <Label htmlFor="scheduledDateTime">Or choose a specific time</Label>
<Input <Input
id="scheduledDateTime" id="scheduledDateTime"
type="datetime-local" type="datetime-local"
value={scheduledDateTime} value={scheduledDateTime}
onChange={e => { onChange={e => setScheduledDateTime(e.target.value)}
setScheduledDateTime(e.target.value);
setSelectedPreset(null);
}}
min={new Date().toISOString().slice(0, 16)} min={new Date().toISOString().slice(0, 16)}
className="mt-2"
/> />
</div>
{/* Confirmation preview — date + audience together */}
{scheduledDateTime && ( {scheduledDateTime && (
<div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200"> <div className="mt-2 p-3 bg-neutral-50 border border-neutral-200 rounded-lg">
<div className="px-4 py-3"> <p className="text-xs font-medium text-neutral-500 mb-1">Scheduled for:</p>
<div className="flex items-center gap-2 text-neutral-500"> <p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(scheduledDateTime))}</p>
<Calendar className="h-3.5 w-3.5" /> <p className="text-xs text-neutral-500 mt-1">
<span className="text-xs font-medium uppercase tracking-wide">Sending on</span> UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
</div>
<p className="mt-1 text-base font-semibold text-neutral-900">
{formatFullDateTime(new Date(scheduledDateTime))}
</p>
</div>
{draftRecipientCount > 0 && (
<div className="px-4 py-3">
<div className="flex items-center gap-2 text-neutral-500">
<Users className="h-3.5 w-3.5" />
<span className="text-xs font-medium uppercase tracking-wide">To</span>
</div>
<p className="mt-1 text-sm text-neutral-900">
<span className="font-semibold tabular-nums">{draftRecipientCount.toLocaleString()}</span>
<span className="text-neutral-600">
{draftRecipientCount === 1 ? ' recipient in ' : ' recipients in '}
</span>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.ALL &&
((editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'all contacts'
: 'all subscribed contacts')}
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT &&
(segments?.find(s => s.id === (editedCampaign.segmentId ?? c.segmentId))?.name ?? 'the selected segment')}
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.FILTERED && 'filtered contacts'}
</p> </p>
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
<p className="text-xs text-neutral-500 leading-relaxed">
You can edit or cancel this campaign anytime before it sends.
</p>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -868,14 +810,12 @@ export default function CampaignDetailsPage() {
onClick={() => { onClick={() => {
setDialog({type: 'none'}); setDialog({type: 'none'});
setScheduledDateTime(''); setScheduledDateTime('');
setSelectedPreset(null);
}} }}
> >
Not yet Cancel
</Button> </Button>
<Button type="button" onClick={handleSchedule} disabled={!scheduledDateTime}> <Button type="button" onClick={handleSchedule}>
<Calendar className="h-4 w-4" /> Schedule Campaign
Schedule send
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -885,66 +825,15 @@ export default function CampaignDetailsPage() {
{/* Sticky Save Bar */} {/* Sticky Save Bar */}
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} /> <StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
<Dialog open={dialog.type === 'send'} onOpenChange={open => !open && setDialog({type: 'none'})}> <ConfirmDialog
<DialogContent className="sm:max-w-md"> open={dialog.type === 'send'}
<DialogHeader> onOpenChange={open => !open && setDialog({type: 'none'})}
<DialogTitle>Ready to send?</DialogTitle> onConfirm={handleSend}
<DialogDescription>Review the details below, then send when you{"'"}re ready.</DialogDescription> title="Send Campaign"
</DialogHeader> description="Are you sure you want to send this campaign now? This action cannot be undone."
confirmText="Send Now"
<div className="space-y-4 py-2"> variant="default"
{/* Hero: recipient count */} />
<div className="rounded-xl border border-neutral-200 bg-neutral-50 px-5 py-6 text-center">
<div className="flex items-center justify-center gap-2 text-neutral-500">
<Users className="h-4 w-4" />
<span className="text-xs font-medium uppercase tracking-wide">Recipients</span>
</div>
<div className="mt-1.5 text-4xl font-bold text-neutral-900 tabular-nums">
{draftRecipientCount.toLocaleString()}
</div>
<div className="mt-1 text-xs text-neutral-500">
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.ALL &&
((editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'All contacts'
: 'All subscribed contacts')}
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT &&
(segments?.find(s => s.id === (editedCampaign.segmentId ?? c.segmentId))?.name ?? 'Selected segment')}
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.FILTERED && 'Filtered contacts'}
</div>
</div>
{/* Compact summary */}
<div className="rounded-lg border border-neutral-200 divide-y divide-neutral-200 text-sm">
<div className="grid grid-cols-[80px_1fr] gap-3 px-3 py-2.5">
<span className="text-neutral-500">From</span>
<span className="text-neutral-900 truncate">{editedCampaign.from || c.from}</span>
</div>
<div className="grid grid-cols-[80px_1fr] gap-3 px-3 py-2.5">
<span className="text-neutral-500">Subject</span>
<span className="text-neutral-900 truncate">{editedCampaign.subject || c.subject}</span>
</div>
</div>
{/* Reassurance */}
<div className="flex items-start gap-2 rounded-lg bg-neutral-50 px-3 py-2.5">
<Info className="h-4 w-4 text-neutral-500 mt-0.5 shrink-0" />
<p className="text-xs text-neutral-600 leading-relaxed">
Sending takes a few minutes. You can cancel the campaign at any time while it{"'"}s still sending.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialog({type: 'none'})}>
Not yet
</Button>
<Button onClick={async () => { await handleSend(); setDialog({type: 'none'}); }}>
<Send className="h-4 w-4" />
Send to {draftRecipientCount.toLocaleString()} {draftRecipientCount === 1 ? 'contact' : 'contacts'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog <ConfirmDialog
open={dialog.type === 'delete'} open={dialog.type === 'delete'}
+35 -58
View File
@@ -8,7 +8,11 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
Input, Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Campaign, Template} from '@plunk/db'; import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db'; import {CampaignStatus} from '@plunk/db';
@@ -19,11 +23,11 @@ import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog'
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog'; import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils'; import {formatRelativeTime} from '../../lib/dateUtils';
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Search, Trash2, X} from 'lucide-react'; import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Trash2} from 'lucide-react';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useEffect, useState} from 'react'; import {useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSWR from 'swr'; import useSWR from 'swr';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -31,9 +35,7 @@ import dayjs from 'dayjs';
export default function CampaignsPage() { export default function CampaignsPage() {
const router = useRouter(); const router = useRouter();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState<string>('ALL');
const [searchInput, setSearchInput] = useState('');
const [statusFilter, setStatusFilter] = useState<'ALL' | 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'SENT' | 'CANCELLED'>('ALL');
const [showCancelDialog, setShowCancelDialog] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false);
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null); const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -42,18 +44,10 @@ export default function CampaignsPage() {
const [showCampaignDialog, setShowCampaignDialog] = useState(false); const [showCampaignDialog, setShowCampaignDialog] = useState(false);
const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>( const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>(
`/campaigns?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`, `/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
{revalidateOnFocus: false}, {revalidateOnFocus: false},
); );
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [searchInput]);
const getStatusBadge = (status: CampaignStatus) => { const getStatusBadge = (status: CampaignStatus) => {
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = { const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
DRAFT: {label: 'Draft', variant: 'neutral'}, DRAFT: {label: 'Draft', variant: 'neutral'},
@@ -251,45 +245,21 @@ export default function CampaignsPage() {
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Search & Filters */} {/* Filters */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3"> <div className="w-56">
<div className="relative flex-1"> <Select value={statusFilter} onValueChange={setStatusFilter}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" /> <SelectTrigger>
<Input <SelectValue placeholder="All Statuses" />
type="text" </SelectTrigger>
placeholder="Search campaigns..." <SelectContent>
value={searchInput} <SelectItem value="ALL">All Statuses</SelectItem>
onChange={e => setSearchInput(e.target.value)} <SelectItem value="DRAFT">Draft</SelectItem>
className="pl-10 pr-10 h-8 text-xs" <SelectItem value="SCHEDULED">Scheduled</SelectItem>
/> <SelectItem value="SENDING">Sending</SelectItem>
{searchInput && ( <SelectItem value="SENT">Sent</SelectItem>
<button <SelectItem value="CANCELLED">Cancelled</SelectItem>
type="button" </SelectContent>
aria-label="Clear search" </Select>
onClick={() => {
setSearchInput('');
setSearch('');
setPage(1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="flex gap-1.5 shrink-0 flex-wrap">
{(['ALL', 'DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED'] as const).map(status => (
<Button
key={status}
type="button"
onClick={() => { setStatusFilter(status); setPage(1); }}
variant={statusFilter === status ? 'default' : 'secondary'}
size="sm"
>
{status === 'ALL' ? 'All' : status.charAt(0) + status.slice(1).toLowerCase()}
</Button>
))}
</div>
</div> </div>
{/* Campaigns List */} {/* Campaigns List */}
@@ -305,15 +275,14 @@ export default function CampaignsPage() {
<CardContent> <CardContent>
<EmptyState <EmptyState
icon={Mail} icon={Mail}
title={search ? 'No campaigns match' : statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'} title={statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
description={ description={
search statusFilter !== 'ALL'
? 'Try a different search term.'
: statusFilter !== 'ALL'
? 'Adjust your filters or create a new campaign.' ? 'Adjust your filters or create a new campaign.'
: 'Send one-off emails to groups of contacts.' : 'Send one-off emails to groups of contacts.'
} }
action={ action={
statusFilter === 'ALL' ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button> <Button>
@@ -358,6 +327,14 @@ export default function CampaignsPage() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : (
<Button asChild>
<Link href="/campaigns/create">
<Plus className="h-4 w-4" />
Create Campaign
</Link>
</Button>
)
} }
/> />
</CardContent> </CardContent>
+138 -453
View File
@@ -2,6 +2,9 @@ import {
Button, Button,
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox, Checkbox,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
@@ -22,18 +25,14 @@ import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils'; import {formatRelativeTime} from '../../lib/dateUtils';
import { import {
AlertTriangle,
Check,
CheckCircle, CheckCircle,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Edit, Edit,
FileUp, FileUp,
Loader2,
Mail, Mail,
MailCheck, MailCheck,
MailX, MailX,
Minus,
Plus, Plus,
Search, Search,
Trash2, Trash2,
@@ -62,8 +61,6 @@ export default function ContactsPage() {
const [contactToDelete, setContactToDelete] = useState<string | null>(null); const [contactToDelete, setContactToDelete] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState<number>(0); const [totalCount, setTotalCount] = useState<number>(0);
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set()); const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set());
const [selectAllMatching, setSelectAllMatching] = useState(false);
const [excludedContacts, setExcludedContacts] = useState<Set<string>>(new Set());
const [showBulkActionsDialog, setShowBulkActionsDialog] = useState(false); const [showBulkActionsDialog, setShowBulkActionsDialog] = useState(false);
const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null); const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
const pageSize = 50; const pageSize = 50;
@@ -90,9 +87,6 @@ export default function ContactsPage() {
setCursorHistory([undefined]); setCursorHistory([undefined]);
setCurrentPage(0); setCurrentPage(0);
setContacts([]); setContacts([]);
setSelectedContacts(new Set());
setSelectAllMatching(false);
setExcludedContacts(new Set());
}, 350); }, 350);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchInput, search]); }, [searchInput, search]);
@@ -102,12 +96,9 @@ export default function ContactsPage() {
const newPage = currentPage + 1; const newPage = currentPage + 1;
setCursor(data.cursor); setCursor(data.cursor);
setCurrentPage(newPage); setCurrentPage(newPage);
// Preserve selection across pages only when "select all matching" is on; otherwise setSelectedContacts(new Set()); // Clear selection on page change
// clear, since per-page id sets stop being meaningful once you've left the page.
if (!selectAllMatching) {
setSelectedContacts(new Set());
}
// Store cursor in history if not already there
if (cursorHistory.length <= newPage) { if (cursorHistory.length <= newPage) {
setCursorHistory(prev => [...prev, data.cursor]); setCursorHistory(prev => [...prev, data.cursor]);
} }
@@ -120,38 +111,12 @@ export default function ContactsPage() {
const previousCursor = cursorHistory[newPage]; const previousCursor = cursorHistory[newPage];
setCursor(previousCursor); setCursor(previousCursor);
setCurrentPage(newPage); setCurrentPage(newPage);
if (!selectAllMatching) { setSelectedContacts(new Set()); // Clear selection on page change
setSelectedContacts(new Set());
}
} }
}; };
// True when the current page's checkbox should appear "all selected"
const allOnPageSelected = contacts.length > 0 && (
selectAllMatching
? contacts.every(c => !excludedContacts.has(c.id))
: selectedContacts.size === contacts.length && contacts.every(c => selectedContacts.has(c.id))
);
const handleSelectAll = () => { const handleSelectAll = () => {
if (selectAllMatching) { if (selectedContacts.size === contacts.length && contacts.length > 0) {
// Toggle: exclude or re-include all on this page
if (allOnPageSelected) {
setExcludedContacts(prev => {
const next = new Set(prev);
contacts.forEach(c => next.add(c.id));
return next;
});
} else {
setExcludedContacts(prev => {
const next = new Set(prev);
contacts.forEach(c => next.delete(c.id));
return next;
});
}
return;
}
if (allOnPageSelected) {
setSelectedContacts(new Set()); setSelectedContacts(new Set());
} else { } else {
setSelectedContacts(new Set(contacts.map(c => c.id))); setSelectedContacts(new Set(contacts.map(c => c.id)));
@@ -159,30 +124,15 @@ export default function ContactsPage() {
}; };
const handleSelectContact = (contactId: string) => { const handleSelectContact = (contactId: string) => {
if (selectAllMatching) { const newSelected = new Set(selectedContacts);
setExcludedContacts(prev => { if (newSelected.has(contactId)) {
const next = new Set(prev); newSelected.delete(contactId);
if (next.has(contactId)) next.delete(contactId); } else {
else next.add(contactId); newSelected.add(contactId);
return next;
});
return;
} }
setSelectedContacts(prev => { setSelectedContacts(newSelected);
const next = new Set(prev);
if (next.has(contactId)) next.delete(contactId);
else next.add(contactId);
return next;
});
}; };
const isContactSelected = (contactId: string) =>
selectAllMatching ? !excludedContacts.has(contactId) : selectedContacts.has(contactId);
const effectiveSelectionCount = selectAllMatching
? Math.max(0, totalCount - excludedContacts.size)
: selectedContacts.size;
const handleBulkAction = (operation: 'subscribe' | 'unsubscribe' | 'delete') => { const handleBulkAction = (operation: 'subscribe' | 'unsubscribe' | 'delete') => {
setBulkOperation(operation); setBulkOperation(operation);
setShowBulkActionsDialog(true); setShowBulkActionsDialog(true);
@@ -190,14 +140,6 @@ export default function ContactsPage() {
const clearSelection = () => { const clearSelection = () => {
setSelectedContacts(new Set()); setSelectedContacts(new Set());
setSelectAllMatching(false);
setExcludedContacts(new Set());
};
const handleSelectAllMatching = () => {
setSelectAllMatching(true);
setSelectedContacts(new Set());
setExcludedContacts(new Set());
}; };
const promptDelete = (contactId: string) => { const promptDelete = (contactId: string) => {
@@ -230,7 +172,8 @@ export default function ContactsPage() {
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Contacts</h1> <h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Contacts</h1>
<p className="text-neutral-500 mt-2 text-sm sm:text-base"> <p className="text-neutral-500 mt-2 text-sm sm:text-base">
Manage your email subscribers and their data. Manage your email subscribers and their data.{' '}
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -248,25 +191,15 @@ export default function ContactsPage() {
</div> </div>
</div> </div>
{/* Contacts Table */} {/* Search */}
<Card> <div className="relative">
{/* Contextual header strip: idle = search + count, selecting = bulk actions. <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
Single fixed-min-height row prevents layout shift as state toggles.
The select-all-matching link is folded inline into the toolbar. */}
<div
key={effectiveSelectionCount === 0 ? 'idle' : 'selecting'}
className="border-b border-neutral-200 px-6 min-h-[68px] flex items-center py-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-150"
>
{effectiveSelectionCount === 0 ? (
<div className="flex items-center gap-4 w-full">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400 pointer-events-none" />
<Input <Input
type="text" type="text"
placeholder="Search by email..." placeholder="Search by email..."
value={searchInput} value={searchInput}
onChange={e => setSearchInput(e.target.value)} onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-9 h-10" className="pl-10 pr-10"
/> />
{searchInput && ( {searchInput && (
<button <button
@@ -280,36 +213,23 @@ export default function ContactsPage() {
setCurrentPage(0); setCurrentPage(0);
setContacts([]); setContacts([]);
}} }}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-neutral-400 transition-colors hover:text-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400" className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
)} )}
</div> </div>
{totalCount > 0 && (
<span className="hidden sm:inline text-sm text-neutral-500 tabular-nums whitespace-nowrap"> {/* Bulk Actions Toolbar */}
{totalCount.toLocaleString()} {search ? 'matching' : 'total'} {selectedContacts.size > 0 && (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-neutral-900">
{selectedContacts.size} contact{selectedContacts.size !== 1 ? 's' : ''} selected
</span> </span>
)} <div className="flex gap-2">
</div>
) : (
<div className="flex items-center justify-between gap-3 w-full">
<div className="flex items-center gap-x-4 gap-y-2 min-w-0 flex-wrap">
<span className="text-sm font-medium text-neutral-900 tabular-nums whitespace-nowrap">
{effectiveSelectionCount.toLocaleString()} selected
</span>
{!selectAllMatching && allOnPageSelected && totalCount > contacts.length && (
<button
type="button"
onClick={handleSelectAllMatching}
className="text-sm font-medium text-neutral-600 underline-offset-4 transition-colors hover:text-neutral-900 hover:underline focus-visible:outline-none focus-visible:underline focus-visible:text-neutral-900 whitespace-nowrap rounded-sm tabular-nums"
>
Select all {totalCount.toLocaleString()}
{search ? ' matching' : ''}
</button>
)}
<div className="hidden sm:block h-5 w-px bg-neutral-200" aria-hidden="true" />
<div className="flex gap-1.5">
<Button variant="outline" size="sm" onClick={() => handleBulkAction('subscribe')}> <Button variant="outline" size="sm" onClick={() => handleBulkAction('subscribe')}>
<MailCheck className="h-4 w-4 mr-1.5" /> <MailCheck className="h-4 w-4 mr-1.5" />
Subscribe Subscribe
@@ -318,36 +238,35 @@ export default function ContactsPage() {
<MailX className="h-4 w-4 mr-1.5" /> <MailX className="h-4 w-4 mr-1.5" />
Unsubscribe Unsubscribe
</Button> </Button>
<Button <Button variant="outline" size="sm" onClick={() => handleBulkAction('delete')}>
variant="outline"
size="sm"
onClick={() => handleBulkAction('delete')}
className="text-neutral-700 transition-colors hover:bg-red-50 hover:text-red-700 hover:border-red-200"
>
<Trash2 className="h-4 w-4 mr-1.5" /> <Trash2 className="h-4 w-4 mr-1.5" />
Delete Delete
</Button> </Button>
</div> </div>
</div> </div>
<Button <Button variant="ghost" size="sm" onClick={clearSelection}>
variant="ghost" Clear Selection
size="sm"
onClick={clearSelection}
aria-label="Clear selection"
className="text-neutral-500 hover:text-neutral-900"
>
<X className="h-4 w-4" />
</Button> </Button>
</div> </div>
</CardContent>
</Card>
)} )}
</div>
<CardContent className="p-0"> {/* Contacts Table */}
<Card>
<CardHeader>
<CardTitle>All Contacts</CardTitle>
<CardDescription>
View and manage your contact list.
{totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading && contacts.length === 0 ? ( {isLoading && contacts.length === 0 ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-12">
<IconSpinner /> <IconSpinner />
</div> </div>
) : contacts.length === 0 ? ( ) : contacts.length === 0 ? (
<div className="px-6 py-12">
<EmptyState <EmptyState
icon={Mail} icon={Mail}
title={search ? 'No contacts match' : 'No contacts yet'} title={search ? 'No contacts match' : 'No contacts yet'}
@@ -361,7 +280,6 @@ export default function ContactsPage() {
) : undefined ) : undefined
} }
/> />
</div>
) : ( ) : (
<> <>
{/* Desktop Table View - Hidden on mobile */} {/* Desktop Table View - Hidden on mobile */}
@@ -371,7 +289,7 @@ export default function ContactsPage() {
<tr> <tr>
<th className="px-6 py-3 text-left w-12"> <th className="px-6 py-3 text-left w-12">
<Checkbox <Checkbox
checked={allOnPageSelected} checked={selectedContacts.size === contacts.length && contacts.length > 0}
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
/> />
</th> </th>
@@ -394,7 +312,7 @@ export default function ContactsPage() {
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors"> <tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<Checkbox <Checkbox
checked={isContactSelected(contact.id)} checked={selectedContacts.has(contact.id)}
onCheckedChange={() => handleSelectContact(contact.id)} onCheckedChange={() => handleSelectContact(contact.id)}
/> />
</td> </td>
@@ -405,12 +323,7 @@ export default function ContactsPage() {
) : ( ) : (
<MailX className="h-4 w-4 text-red-600" /> <MailX className="h-4 w-4 text-red-600" />
)} )}
<Link <span className="text-sm font-medium text-neutral-900">{contact.email}</span>
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">
@@ -447,7 +360,7 @@ export default function ContactsPage() {
</div> </div>
{/* Mobile Card View - Only visible on mobile */} {/* Mobile Card View - Only visible on mobile */}
<div className="md:hidden space-y-3 p-4"> <div className="md:hidden space-y-3">
{contacts.map(contact => ( {contacts.map(contact => (
<div <div
key={contact.id} key={contact.id}
@@ -460,12 +373,7 @@ 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" />
)} )}
<Link <span className="text-sm font-medium text-neutral-900 truncate">{contact.email}</span>
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 ${
@@ -497,7 +405,7 @@ export default function ContactsPage() {
{/* Pagination Controls */} {/* Pagination Controls */}
{(currentPage > 0 || data?.hasMore) && ( {(currentPage > 0 || data?.hasMore) && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 px-6 py-4 border-t border-neutral-200"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mt-6 pt-6 border-t border-neutral-200">
<div className="text-xs sm:text-sm text-neutral-600 text-center sm:text-left"> <div className="text-xs sm:text-sm text-neutral-600 text-center sm:text-left">
Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '} Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '}
<span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span> <span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span>
@@ -547,12 +455,7 @@ export default function ContactsPage() {
open={showBulkActionsDialog} open={showBulkActionsDialog}
onOpenChange={setShowBulkActionsDialog} onOpenChange={setShowBulkActionsDialog}
operation={bulkOperation} operation={bulkOperation}
selector={ contactIds={Array.from(selectedContacts)}
selectAllMatching
? {mode: 'query', filter: search ? {search} : {}, excludeIds: Array.from(excludedContacts)}
: {mode: 'ids', contactIds: Array.from(selectedContacts)}
}
targetCount={effectiveSelectionCount}
onSuccess={() => { onSuccess={() => {
mutate(); mutate();
clearSelection(); clearSelection();
@@ -982,32 +885,23 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
); );
} }
type BulkSelector =
| {mode: 'ids'; contactIds: string[]}
| {mode: 'query'; filter: {search?: string}; excludeIds: string[]};
interface BulkActionsDialogProps { interface BulkActionsDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
operation: 'subscribe' | 'unsubscribe' | 'delete' | null; operation: 'subscribe' | 'unsubscribe' | 'delete' | null;
selector: BulkSelector; contactIds: string[];
targetCount: number;
onSuccess: () => void; onSuccess: () => void;
} }
interface BulkActionResult { interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete'; operation: string;
totalRequested: number; totalRequested: number;
/** Contacts whose state was actually changed by this run. */
successCount: number; successCount: number;
/** Subscribe/unsubscribe only: contacts that were already in the target state. */
unchangedCount: number;
/** Contacts that errored or weren't found. */
failureCount: number; failureCount: number;
errors: Array<{contactId: string; email: string; error: string}>; errors: Array<{contactId: string; email: string; error: string}>;
} }
function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount, onSuccess}: BulkActionsDialogProps) { function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess}: BulkActionsDialogProps) {
const [, setJobId] = useState<string | null>(null); const [, setJobId] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@@ -1055,7 +949,8 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
} }
if (response.result) { if (response.result) {
toast.success(buildToastSummary(response.result)); const {successCount, failureCount} = response.result;
toast.success(`Completed: ${successCount} succeeded${failureCount > 0 ? `, ${failureCount} failed` : ''}`);
} }
onSuccess(); onSuccess();
@@ -1093,7 +988,7 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>( const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
'POST', 'POST',
endpoint, endpoint,
selector, {contactIds},
); );
setJobId(data.jobId); setJobId(data.jobId);
@@ -1124,97 +1019,103 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
onOpenChange(false); onOpenChange(false);
}; };
const copy = getOperationCopy(operation); const getOperationLabel = () => {
switch (operation) {
case 'subscribe':
return 'Subscribe';
case 'unsubscribe':
return 'Unsubscribe';
case 'delete':
return 'Delete';
default:
return 'Process';
}
};
const isQueueing = status === 'processing' && progress === 0; const getOperationColor = () => {
const dialogTitle = switch (operation) {
status === 'completed' case 'subscribe':
? copy.completedTitle return 'green';
: status === 'processing' case 'unsubscribe':
? copy.progressTitle return 'yellow';
: status === 'failed' case 'delete':
? copy.failedTitle return 'red';
: copy.title; default:
return 'blue';
const handleRetry = () => { }
setErrorMessage(null);
setStatus('idle');
void handleConfirm();
}; };
return ( return (
<> <>
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle className="transition-colors">{dialogTitle}</DialogTitle> <DialogTitle>{getOperationLabel()} Contacts</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{status === 'idle' && ( {status === 'idle' && (
<div className="space-y-3 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200"> <div className="space-y-1">
<p className="text-sm text-neutral-700 leading-relaxed"> <p className="text-sm text-neutral-700">
{copy.confirmVerb}{' '} {operation === 'delete' ? 'Permanently delete' : operation === 'subscribe' ? 'Subscribe' : 'Unsubscribe'}{' '}
<span className="font-medium text-neutral-900 tabular-nums"> <span className="font-medium text-neutral-900">{contactIds.length} contact{contactIds.length !== 1 ? 's' : ''}</span>?
{targetCount.toLocaleString()} contact{targetCount !== 1 ? 's' : ''}
</span>
?
{copy.skipNote && <span className="text-neutral-500"> {copy.skipNote}</span>}
</p> </p>
{operation === 'delete' && ( {operation === 'delete' && (
<div className="flex items-start gap-2.5 rounded-md border border-red-200 bg-red-50 px-3 py-2.5 text-xs text-red-700"> <p className="text-xs text-red-500">This action cannot be undone.</p>
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" strokeWidth={2.25} />
<p className="leading-relaxed">
<span className="font-medium">This action cannot be undone.</span> Contacts and their event history will be permanently removed.
</p>
</div>
)}
{selector.mode === 'query' && (
<p className="text-xs text-neutral-500 leading-relaxed">
Contacts are evaluated when the job runs any added in the meantime may also be included.
</p>
)} )}
</div> </div>
)} )}
{status === 'processing' && ( {status === 'processing' && (
<div className="space-y-3 py-1 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200"> <div className="space-y-2">
<div className="flex items-baseline justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-neutral-600"> <span className="text-neutral-600">Processing contacts...</span>
{isQueueing && <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />} <span className="text-neutral-900 font-medium">{progress}%</span>
<span>
{isQueueing
? 'Queued — starting up…'
: `${copy.processingLabel} ${targetCount.toLocaleString()} contact${targetCount !== 1 ? 's' : ''}`}
</span>
</span>
<span
className={`tabular-nums font-medium transition-opacity ${
isQueueing ? 'text-neutral-400' : 'text-neutral-900'
}`}
>
{progress}%
</span>
</div> </div>
<div className="relative w-full bg-neutral-100 rounded-full h-1.5 overflow-hidden"> <div className="w-full bg-neutral-200 rounded-full h-1.5">
{isQueueing ? (
<div className="absolute inset-y-0 left-0 w-1/3 rounded-full bg-neutral-300 motion-safe:animate-[indeterminate_1.4s_ease-in-out_infinite]" />
) : (
<div <div
className="bg-neutral-900 h-full rounded-full transition-[width] duration-500 ease-out" className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
style={{width: `${progress}%`}} style={{width: `${progress}%`}}
/> />
)}
</div> </div>
</div> </div>
)} )}
{status === 'completed' && result && <BulkResultSummary result={result} />} {status === 'completed' && result && (
<div className="space-y-3">
<div className="flex items-center gap-1.5 text-sm text-neutral-600">
<CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
<span>
<span className="font-medium text-neutral-900">{result.successCount}</span> succeeded
{result.failureCount > 0 && (
<>, <span className="text-red-600">{result.failureCount}</span> failed</>
)}
</span>
</div>
{result.errors && result.errors.length > 0 && (
<div className="max-h-40 overflow-y-auto border border-neutral-200 rounded-md">
<div className="text-xs text-neutral-600">
{result.errors.slice(0, 10).map((error, idx) => (
<div key={idx} className="px-3 py-2 border-b border-neutral-100 last:border-0 text-red-600">
{error.error}
</div>
))}
{result.errors.length > 10 && (
<div className="px-3 py-2 text-neutral-500">
+{result.errors.length - 10} more errors
</div>
)}
</div>
</div>
)}
</div>
)}
{status === 'failed' && ( {status === 'failed' && (
<div className="flex items-start gap-2.5 rounded-md border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200"> <div className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" strokeWidth={2.25} /> <XCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
<p className="leading-relaxed">{errorMessage || 'Something went wrong. Please try again.'}</p> <p className="text-red-600">{errorMessage || 'Please try again.'}</p>
</div> </div>
)} )}
</div> </div>
@@ -1231,25 +1132,16 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
disabled={isProcessing} disabled={isProcessing}
variant={operation === 'delete' ? 'destructive' : 'default'} variant={operation === 'delete' ? 'destructive' : 'default'}
> >
{isProcessing ? 'Starting' : copy.confirmButton} {isProcessing ? 'Starting...' : getOperationLabel()}
</Button> </Button>
</> </>
) : status === 'failed' ? ( ) : status === 'completed' ? (
<> <Button type="button" onClick={handleClose}>
<Button type="button" variant="outline" onClick={handleClose}>
Close Close
</Button> </Button>
<Button type="button" onClick={handleRetry} variant={operation === 'delete' ? 'destructive' : 'default'}>
Try again
</Button>
</>
) : ( ) : (
<Button <Button type="button" variant="outline" onClick={handleClose}>
type="button" Close
onClick={handleClose}
variant={status === 'completed' ? 'default' : 'outline'}
>
{status === 'completed' ? 'Done' : 'Hide'}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
@@ -1260,218 +1152,11 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
open={showCloseConfirmDialog} open={showCloseConfirmDialog}
onOpenChange={setShowCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}
onConfirm={confirmClose} onConfirm={confirmClose}
title="Hide this dialog?" title="Close Operation"
description="The job will keep running in the background. You won't see the result here, but the contacts will still be updated." description="Operation is still in progress. Are you sure you want to close?"
confirmText="Hide" confirmText="Close Anyway"
variant="default" variant="destructive"
/> />
</> </>
); );
} }
interface OperationCopy {
title: string;
progressTitle: string;
completedTitle: string;
failedTitle: string;
confirmVerb: string;
confirmButton: string;
processingLabel: string;
/** Past-tense verb used in result rows: "12 subscribed". */
changedVerb: string;
/** Result-state noun phrase: "contacts subscribed" — pluralisation handled separately. */
summaryNoun: string;
/** Past participle for "already X": "already subscribed". null = no skip case. */
alreadyState: string | null;
/** Note shown next to the confirm prompt for ops with skip semantics. */
skipNote: string | null;
}
function getOperationCopy(operation: 'subscribe' | 'unsubscribe' | 'delete' | null): OperationCopy {
switch (operation) {
case 'subscribe':
return {
title: 'Subscribe contacts',
progressTitle: 'Subscribing…',
completedTitle: 'Subscribed',
failedTitle: "Couldn't subscribe contacts",
confirmVerb: 'Subscribe',
confirmButton: 'Subscribe',
processingLabel: 'Subscribing',
changedVerb: 'subscribed',
summaryNoun: 'subscribed',
alreadyState: 'already subscribed',
skipNote: 'Already-subscribed contacts will be skipped.',
};
case 'unsubscribe':
return {
title: 'Unsubscribe contacts',
progressTitle: 'Unsubscribing…',
completedTitle: 'Unsubscribed',
failedTitle: "Couldn't unsubscribe contacts",
confirmVerb: 'Unsubscribe',
confirmButton: 'Unsubscribe',
processingLabel: 'Unsubscribing',
changedVerb: 'unsubscribed',
summaryNoun: 'unsubscribed',
alreadyState: 'already unsubscribed',
skipNote: 'Already-unsubscribed contacts will be skipped.',
};
case 'delete':
return {
title: 'Delete contacts',
progressTitle: 'Deleting…',
completedTitle: 'Deleted',
failedTitle: "Couldn't delete contacts",
confirmVerb: 'Permanently delete',
confirmButton: 'Delete',
processingLabel: 'Deleting',
changedVerb: 'deleted',
summaryNoun: 'removed',
alreadyState: null,
skipNote: null,
};
default:
return {
title: 'Process contacts',
progressTitle: 'Processing…',
completedTitle: 'Done',
failedTitle: 'Operation failed',
confirmVerb: 'Process',
confirmButton: 'Process',
processingLabel: 'Processing',
changedVerb: 'processed',
summaryNoun: 'processed',
alreadyState: null,
skipNote: null,
};
}
}
function buildToastSummary(result: BulkActionResult): string {
const copy = getOperationCopy(result.operation);
const parts: string[] = [];
if (result.successCount > 0) parts.push(`${result.successCount.toLocaleString()} ${copy.changedVerb}`);
if (result.unchangedCount > 0 && copy.alreadyState) {
parts.push(`${result.unchangedCount.toLocaleString()} ${copy.alreadyState}`);
}
if (result.failureCount > 0) parts.push(`${result.failureCount.toLocaleString()} failed`);
if (parts.length === 0) return 'No contacts to update';
return parts.join(' · ');
}
function BulkResultSummary({result}: {result: BulkActionResult}) {
const copy = getOperationCopy(result.operation);
const {successCount, unchangedCount, failureCount} = result;
const noChanges = successCount === 0 && failureCount === 0 && unchangedCount > 0;
const total = successCount + unchangedCount + failureCount;
// Build the row list. The "primary" row is the row that represents what the
// user actually got — usually the changed count, but when nothing changed we
// promote the "already in state" row so the summary still has a clear lead.
type Row = {
key: string;
label: string;
count: number;
primary?: boolean;
tone?: 'default' | 'danger';
};
const rows: Row[] = [];
if (noChanges && copy.alreadyState) {
rows.push({key: 'already', label: copy.alreadyState, count: unchangedCount, primary: true});
} else {
rows.push({key: 'changed', label: copy.completedTitle, count: successCount, primary: true});
if (unchangedCount > 0 && copy.alreadyState) {
rows.push({key: 'already', label: copy.alreadyState, count: unchangedCount});
}
}
if (failureCount > 0) {
rows.push({key: 'failed', label: 'Failed', count: failureCount, tone: 'danger'});
}
return (
<div className="space-y-3 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300">
<div className="rounded-lg border border-neutral-200 overflow-hidden divide-y divide-neutral-100">
{rows.map(row => {
const isPrimary = !!row.primary;
const isDanger = row.tone === 'danger';
return (
<div
key={row.key}
className={`flex items-center gap-3 px-4 ${isPrimary ? 'py-4' : 'py-2.5'}`}
>
{/* Status mark only on the primary row. Subsequent rows leave the
same column blank to keep the labels in a single visual track. */}
<div className="w-7 shrink-0 flex items-center">
{isPrimary && (
<div
className={`flex h-7 w-7 items-center justify-center rounded-full ${
noChanges ? 'bg-neutral-100 text-neutral-500' : 'bg-neutral-900 text-white'
}`}
>
{noChanges ? (
<Minus className="h-3.5 w-3.5" />
) : (
<Check className="h-3.5 w-3.5" strokeWidth={3} />
)}
</div>
)}
</div>
<div
className={`flex-1 first-letter:capitalize ${
isPrimary
? 'text-sm font-medium text-neutral-900'
: isDanger
? 'text-sm text-red-600'
: 'text-sm text-neutral-500'
}`}
>
{row.label}
</div>
<div
className={`tabular-nums tracking-tight ${
isPrimary
? 'text-2xl font-semibold text-neutral-900 leading-none'
: isDanger
? 'text-sm font-medium text-red-700'
: 'text-sm font-medium text-neutral-700'
}`}
>
{row.count.toLocaleString()}
</div>
</div>
);
})}
</div>
{total > 1 && rows.length > 1 && (
<div className="px-4 flex items-baseline justify-between text-xs text-neutral-500">
<span>Total processed</span>
<span className="tabular-nums font-medium text-neutral-700">{total.toLocaleString()}</span>
</div>
)}
{result.errors && result.errors.length > 0 && (
<details className="group">
<summary className="cursor-pointer text-xs text-neutral-500 hover:text-neutral-700 select-none px-4">
Show error details ({result.errors.length.toLocaleString()})
</summary>
<div className="mt-2 max-h-40 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100 text-xs">
{result.errors.slice(0, 10).map((error, idx) => (
<div key={idx} className="px-3 py-2 text-red-700">
{error.error}
</div>
))}
{result.errors.length > 10 && (
<div className="px-3 py-2 text-neutral-500">
+{(result.errors.length - 10).toLocaleString()} more
</div>
)}
</div>
</details>
)}
</div>
);
}
+25 -475
View File
@@ -10,30 +10,10 @@ import {
CardTitle, CardTitle,
Skeleton, Skeleton,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types'; import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
import {animate, AnimatePresence, motion, useMotionValue, useTransform} from 'framer-motion';
import {
AlertCircle,
ArrowDownRight,
ArrowUpRight,
Calendar,
Eye,
Inbox,
Mail,
Minus,
MousePointerClick,
Send,
ShieldCheck,
TrendingUp,
Users,
Workflow,
XCircle,
Zap,
} from 'lucide-react';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import Link from 'next/link'; import Link from 'next/link';
import {useEffect, useMemo, useState} from 'react'; import {useState} from 'react';
import useSWR from 'swr';
import {ApiKeyDisplay} from '../components/ApiKeyDisplay'; import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
import {DashboardLayout} from '../components/DashboardLayout'; import {DashboardLayout} from '../components/DashboardLayout';
import {QuickStart} from '../components/QuickStart'; import {QuickStart} from '../components/QuickStart';
@@ -48,195 +28,6 @@ import {useConfig} from '../lib/hooks/useConfig';
import {useUser} from '../lib/hooks/useUser'; import {useUser} from '../lib/hooks/useUser';
import {network} from '../lib/network'; import {network} from '../lib/network';
function getGreeting(): string {
const hour = new Date().getHours();
if (hour >= 23 || hour < 5) return 'Working late';
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
}
function relativeTime(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString(undefined, {month: 'short', day: 'numeric'});
}
type TrendDirection = 'up' | 'down' | 'flat' | 'new' | 'none';
interface TrendInfo {
direction: TrendDirection;
pct: number;
}
function computeTrend(current: number, previous: number): TrendInfo {
if (previous === 0 && current === 0) return {direction: 'none', pct: 0};
if (previous === 0 && current > 0) return {direction: 'new', pct: 0};
const pct = ((current - previous) / Math.abs(previous)) * 100;
if (Math.abs(pct) < 0.5) return {direction: 'flat', pct: 0};
return {direction: pct > 0 ? 'up' : 'down', pct: Math.abs(pct)};
}
function TrendChip({trend, label}: {trend: TrendInfo; label?: string}) {
if (trend.direction === 'none') {
return (
<p className="mt-1 text-xs text-neutral-400 tabular-nums">{label ?? 'No data yet'}</p>
);
}
const config = {
up: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'},
down: {Icon: ArrowDownRight, color: 'text-red-700', bg: 'bg-red-50'},
flat: {Icon: Minus, color: 'text-neutral-600', bg: 'bg-neutral-100'},
new: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'},
}[trend.direction];
const {Icon, color, bg} = config;
const text =
trend.direction === 'new'
? 'New'
: trend.direction === 'flat'
? 'No change'
: `${trend.pct.toFixed(trend.pct >= 100 ? 0 : 1)}%`;
return (
<div className="mt-2 flex items-center gap-2 text-xs">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium tabular-nums ${bg} ${color}`}>
<Icon className="h-3 w-3" strokeWidth={2.5} />
{text}
</span>
<span className="text-neutral-400">vs previous 30d</span>
</div>
);
}
function AnimatedNumber({value, format}: {value: number; format?: (n: number) => string}) {
const motionValue = useMotionValue(0);
const rounded = useTransform(motionValue, latest =>
format ? format(Math.round(latest)) : Math.round(latest).toLocaleString(),
);
useEffect(() => {
const controls = animate(motionValue, value, {
duration: 1.1,
ease: [0.22, 1, 0.36, 1],
});
return () => controls.stop();
}, [value, motionValue]);
return <motion.span>{rounded}</motion.span>;
}
interface ActivityVisual {
icon: React.ComponentType<{className?: string}>;
tone: 'neutral' | 'green' | 'blue' | 'amber' | 'red';
label: string;
}
function activityVisual(a: Activity): ActivityVisual {
switch (a.type) {
case 'email.sent':
return {icon: Send, tone: 'neutral', label: 'Sent'};
case 'email.delivered':
return {icon: Inbox, tone: 'green', label: 'Delivered'};
case 'email.opened':
return {icon: Eye, tone: 'green', label: 'Opened'};
case 'email.clicked':
return {icon: MousePointerClick, tone: 'blue', label: 'Clicked'};
case 'email.bounced':
return {icon: XCircle, tone: 'red', label: 'Bounced'};
case 'email.complaint':
return {icon: AlertCircle, tone: 'red', label: 'Complaint'};
case 'event.triggered':
return {icon: Zap, tone: 'amber', label: 'Event'};
case 'campaign.sent':
return {icon: Mail, tone: 'neutral', label: 'Campaign'};
case 'campaign.scheduled':
return {icon: Calendar, tone: 'blue', label: 'Scheduled'};
case 'workflow.started':
case 'workflow.completed':
case 'workflow.email.scheduled':
return {icon: Workflow, tone: 'amber', label: 'Workflow'};
default:
return {icon: Zap, tone: 'neutral', label: 'Event'};
}
}
const TONE_CLASSES: Record<ActivityVisual['tone'], {bg: string; fg: string}> = {
neutral: {bg: 'bg-neutral-100', fg: 'text-neutral-700'},
green: {bg: 'bg-emerald-50', fg: 'text-emerald-700'},
blue: {bg: 'bg-sky-50', fg: 'text-sky-700'},
amber: {bg: 'bg-amber-50', fg: 'text-amber-700'},
red: {bg: 'bg-red-50', fg: 'text-red-700'},
};
function activityTitle(a: Activity): string {
const m = a.metadata;
if (typeof m.subject === 'string' && m.subject) return m.subject;
if (typeof m.eventName === 'string' && m.eventName) return m.eventName;
if (typeof m.campaignName === 'string' && m.campaignName) return m.campaignName;
if (typeof m.workflowName === 'string' && m.workflowName) return m.workflowName;
return activityVisual(a).label;
}
function LivePulse({count}: {count: number}) {
const isLive = count > 0;
return (
<div className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700">
<span className="relative flex h-2 w-2">
{isLive && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
)}
<span
className={`relative inline-flex h-2 w-2 rounded-full ${isLive ? 'bg-emerald-500' : 'bg-neutral-300'}`}
/>
</span>
<span className="tabular-nums">
{isLive ? `${count.toLocaleString()} ${count === 1 ? 'event' : 'events'} in the last 5 min` : 'Quiet right now'}
</span>
</div>
);
}
function CompactActivityRow({activity}: {activity: Activity}) {
const visual = activityVisual(activity);
const Icon = visual.icon;
const tone = TONE_CLASSES[visual.tone];
const title = activityTitle(activity);
const subtitle = activity.contactEmail;
return (
<motion.div
layout
initial={{opacity: 0, y: -8}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: 8}}
transition={{duration: 0.35, ease: [0.22, 1, 0.36, 1]}}
className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-neutral-50"
>
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md ${tone.bg}`}>
<Icon className={`h-4 w-4 ${tone.fg}`} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<p className="truncate text-sm font-medium text-neutral-900">{title}</p>
<span className="flex-shrink-0 text-[11px] text-neutral-400">{visual.label}</span>
</div>
{subtitle && <p className="truncate text-xs text-neutral-500">{subtitle}</p>}
</div>
<span className="flex-shrink-0 tabular-nums text-xs text-neutral-400">
{relativeTime(new Date(activity.timestamp))}
</span>
</motion.div>
);
}
export default function Index() { export default function Index() {
const {activeProject} = useActiveProject(); const {activeProject} = useActiveProject();
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats(); const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
@@ -250,129 +41,29 @@ export default function Index() {
const [isResending, setIsResending] = useState(false); const [isResending, setIsResending] = useState(false);
const [resendMessage, setResendMessage] = useState<string>(''); const [resendMessage, setResendMessage] = useState<string>('');
// Previous-period stats (60d ago to 30d ago) for trend comparison.
// Round to UTC day boundary so the URL — and therefore the Redis cache key —
// is identical for every user on the same UTC day, letting the 5-minute
// server-side stats cache actually be shared across the user base.
const previousRangeUrl = useMemo(() => {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setUTCDate(today.getUTCDate() - 30);
const sixtyDaysAgo = new Date(today);
sixtyDaysAgo.setUTCDate(today.getUTCDate() - 60);
return `/activity/stats?startDate=${encodeURIComponent(sixtyDaysAgo.toISOString())}&endDate=${encodeURIComponent(thirtyDaysAgo.toISOString())}`;
}, []);
const {data: previousStats} = useSWR<ActivityStats>(previousRangeUrl, {
revalidateOnFocus: false,
dedupingInterval: 5 * 60 * 1000,
});
const emailsTrend = useMemo(
() => computeTrend(totalEmailsSent, previousStats?.totalEmailsSent ?? 0),
[totalEmailsSent, previousStats?.totalEmailsSent],
);
const openRateTrend = useMemo(
() => computeTrend(openRate, previousStats?.openRate ?? 0),
[openRate, previousStats?.openRate],
);
// Live pulse — refresh every 30s. This is the actual real-time signal, so it
// gets the tightest cadence. Server-side it is backed by a short Redis cache
// (see Activity controller) so the polling load stays bounded.
const {data: recentCount} = useSWR<{count: number; minutes: number}>('/activity/recent-count?minutes=5', {
refreshInterval: 30_000,
revalidateOnFocus: false,
dedupingInterval: 15_000,
});
// Live activity feed — last 10 events, refresh every 60s. Slower than the
// pulse because the heavier query doesn't need to be tracked second-by-second.
// Sized to roughly match the Quick Start card's height in the side-by-side layout.
const {data: recentActivity} = useSWR<CursorPaginatedResponse<Activity>>('/activity?limit=10', {
refreshInterval: 60_000,
revalidateOnFocus: false,
dedupingInterval: 30_000,
});
const greeting = useMemo(() => getGreeting(), []);
const subtitle = useMemo(() => {
if (isLoading) return 'Catching up on the last 30 days.';
if (totalEmailsSent === 0) {
if (totalContacts === 0) return `${activeProject?.name ?? 'Your project'} is fresh. Time to send the first email.`;
return `${totalContacts.toLocaleString()} ${totalContacts === 1 ? 'contact' : 'contacts'} ready. Time to send something.`;
}
const projectLabel = activeProject?.name ? `${activeProject.name} sent` : 'You sent';
const base = `${projectLabel} ${totalEmailsSent.toLocaleString()} ${totalEmailsSent === 1 ? 'email' : 'emails'} in the last 30 days.`;
if (openRate >= 40) return `${base} Open rate is well above average.`;
if (openRate >= 25) return `${base} Open rate is healthy.`;
return base;
}, [isLoading, totalEmailsSent, totalContacts, openRate, activeProject?.name]);
// Friendly console message for the developer audience. Once per session.
useEffect(() => {
if (typeof window === 'undefined') return;
const w = window as unknown as {__plunkHi?: boolean};
if (w.__plunkHi) return;
w.__plunkHi = true;
// eslint-disable-next-line no-console
console.log(
'%cPlunk%c Built for developers who care about email.\nFound a rough edge? [email protected]',
'font: 600 14px ui-sans-serif, system-ui; color: #0a0a0a; background: #f5f5f5; padding: 2px 8px; border-radius: 4px;',
'color: #525252; font: 12px ui-sans-serif, system-ui;',
);
}, []);
// totalCampaigns intentionally unused — replaced by Deliverability card below
void totalCampaigns;
const stats = [ const stats = [
{ {
name: 'Total Contacts', name: 'Total Contacts',
value: totalContacts, value: totalContacts.toLocaleString(),
icon: Users, icon: Users,
format: (n: number) => n.toLocaleString(),
}, },
{ {
name: 'Emails Sent', name: 'Emails Sent',
value: totalEmailsSent, value: totalEmailsSent.toLocaleString(),
icon: Mail, icon: Mail,
format: (n: number) => n.toLocaleString(), },
{
name: 'Campaigns',
value: totalCampaigns.toLocaleString(),
icon: Send,
}, },
{ {
name: 'Open Rate', name: 'Open Rate',
value: openRate, value: `${openRate.toFixed(1)}%`,
icon: TrendingUp, icon: TrendingUp,
format: (n: number) => `${n.toFixed(1)}%`,
}, },
]; ];
// Deliverability — prefer 7-day window, fall back to all-time when no 7-day sends
const sevenDay = securityMetrics?.status.sevenDay;
const allTime = securityMetrics?.status.allTime;
const delivWindow = sevenDay && sevenDay.total > 0 ? sevenDay : allTime;
const delivWindowLabel = sevenDay && sevenDay.total > 0 ? 'Last 7 days' : 'All time';
const deliveryRate =
delivWindow && delivWindow.total > 0 ? ((delivWindow.total - delivWindow.bounces) / delivWindow.total) * 100 : 0;
const bounceRate = delivWindow?.bounceRate ?? 0;
const complaintRate = delivWindow?.complaintRate ?? 0;
const hasDelivData = !!delivWindow && delivWindow.total > 0;
const bounceLevel = securityMetrics?.levels.bounce7Day ?? 'healthy';
const complaintLevel = securityMetrics?.levels.complaint7Day ?? 'healthy';
const worstLevel: 'healthy' | 'warning' | 'critical' =
bounceLevel === 'critical' || complaintLevel === 'critical'
? 'critical'
: bounceLevel === 'warning' || complaintLevel === 'warning'
? 'warning'
: 'healthy';
const healthLabel = !hasDelivData ? 'No data yet' : worstLevel === 'healthy' ? 'Healthy' : worstLevel === 'warning' ? 'Watch' : 'Critical';
const healthDot =
!hasDelivData ? 'bg-neutral-300' : worstLevel === 'healthy' ? 'bg-emerald-500' : worstLevel === 'warning' ? 'bg-amber-500' : 'bg-red-500';
const healthText =
!hasDelivData ? 'text-neutral-500' : worstLevel === 'healthy' ? 'text-emerald-700' : worstLevel === 'warning' ? 'text-amber-700' : 'text-red-700';
async function handleResendVerification() { async function handleResendVerification() {
setIsResending(true); setIsResending(true);
setResendMessage(''); setResendMessage('');
@@ -391,9 +82,6 @@ export default function Index() {
} }
} }
const recentItems = recentActivity?.data ?? [];
const liveCount = recentCount?.count ?? 0;
return ( return (
<> <>
<NextSeo title="Dashboard" /> <NextSeo title="Dashboard" />
@@ -474,185 +162,45 @@ export default function Index() {
)} )}
{/* Header */} {/* Header */}
<motion.div <div>
initial={{opacity: 0, y: 8}} <h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
>
<div className="min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 tracking-tight">{greeting}</h1>
<p className="mt-1.5 text-sm text-neutral-500">{subtitle}</p>
</div> </div>
<LivePulse count={liveCount} />
</motion.div>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat, index) => { {stats.map(stat => {
const Icon = stat.icon; const Icon = stat.icon;
const isEmails = stat.name === 'Emails Sent';
const isOpenRate = stat.name === 'Open Rate';
return ( return (
<motion.div <Card key={stat.name}>
key={stat.name}
initial={{opacity: 0, y: 12}}
animate={{opacity: 1, y: 0}}
transition={{
duration: 0.5,
delay: 0.05 + index * 0.06,
ease: [0.22, 1, 0.36, 1],
}}
whileHover={{y: -2}}
className="group"
>
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription> <CardDescription>{stat.name}</CardDescription>
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-neutral-50 border border-neutral-200/60 transition-colors duration-200 group-hover:bg-neutral-900 group-hover:border-neutral-900"> <Icon className="h-4 w-4 text-neutral-500" />
<Icon className="h-3.5 w-3.5 text-neutral-500 transition-colors duration-200 group-hover:text-white" />
</div>
</div> </div>
<CardTitle className="text-2xl tabular-nums"> <CardTitle className="text-2xl tabular-nums">
{isLoading ? ( {isLoading ? <Skeleton className="h-7 w-16" /> : stat.value}
<Skeleton className="h-7 w-16" />
) : (
<AnimatedNumber value={stat.value} format={stat.format} />
)}
</CardTitle> </CardTitle>
{isEmails && !isLoading && previousStats && <TrendChip trend={emailsTrend} />}
{isOpenRate && !isLoading && previousStats && <TrendChip trend={openRateTrend} />}
</CardHeader> </CardHeader>
</Card> </Card>
</motion.div>
); );
})} })}
{/* Deliverability health */}
<motion.div
initial={{opacity: 0, y: 12}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, delay: 0.05 + stats.length * 0.06, ease: [0.22, 1, 0.36, 1]}}
whileHover={{y: -2}}
className="group"
>
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>Deliverability</CardDescription>
<div className="inline-flex items-center gap-1.5 rounded-full bg-neutral-50 border border-neutral-200/60 px-2 py-0.5">
<span className="relative flex h-1.5 w-1.5">
{hasDelivData && worstLevel === 'healthy' && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
)}
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${healthDot}`} />
</span>
<span className={`text-[11px] font-medium ${healthText}`}>{healthLabel}</span>
</div>
</div>
<CardTitle className="text-2xl tabular-nums">
{!securityMetrics ? (
<Skeleton className="h-7 w-20" />
) : hasDelivData ? (
<AnimatedNumber value={deliveryRate} format={n => `${n.toFixed(1)}%`} />
) : (
<span className="text-neutral-400"></span>
)}
</CardTitle>
<p className="mt-1 text-xs text-neutral-500">
{hasDelivData ? 'delivered' : 'No emails sent yet'}
{hasDelivData && <span className="text-neutral-400"> · {delivWindowLabel}</span>}
</p>
</CardHeader>
{hasDelivData && (
<div className="px-6 pb-4 -mt-1">
<div className="flex items-center gap-4 text-[11px] text-neutral-500 tabular-nums">
<span className="inline-flex items-center gap-1.5">
<ShieldCheck className="h-3 w-3 text-neutral-400" />
Bounce <span className="font-medium text-neutral-700">{bounceRate.toFixed(2)}%</span>
</span>
<span className="inline-flex items-center gap-1.5">
<AlertCircle className="h-3 w-3 text-neutral-400" />
Complaint <span className="font-medium text-neutral-700">{complaintRate.toFixed(3)}%</span>
</span>
</div>
</div>
)}
</Card>
</motion.div>
</div> </div>
{/* Quick Start + Recent Activity 50/50 working area with a fixed {/* Quick Actions & API Keys */}
row height so the layout doesn't reflow as Quick Start steps are <div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
completed. Both cards scroll internally. */} {/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2 lg:h-[480px]'}`}>
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />} {!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
<div className={!bannerActive ? 'lg:relative' : ''}> {/* API Keys */}
<Card
className={`flex flex-col h-full ${
!bannerActive ? 'lg:absolute lg:inset-0' : ''
}`}
>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent activity</CardTitle>
<CardDescription>Live feed of whats happening across your project</CardDescription>
</div>
<Button asChild variant="ghost" size="sm">
<Link href="/activity">View all</Link>
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-y-auto">
{!recentActivity ? (
<div className="space-y-2">
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
<div key={i} className="flex items-center gap-3 px-2 py-2">
<Skeleton className="h-8 w-8 rounded-md" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-2/5" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-3 w-12" />
</div>
))}
</div>
) : recentItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 py-10 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-100">
<Inbox className="h-5 w-5 text-neutral-400" />
</div>
<p className="text-sm font-medium text-neutral-700">Nothing has happened yet</p>
<p className="text-xs text-neutral-500 max-w-xs">
Send your first email or trigger an event and youll see it land here in real time.
</p>
</div>
) : (
<div className="space-y-0.5">
<AnimatePresence initial={false}>
{recentItems.map(activity => (
<CompactActivityRow key={activity.id} activity={activity} />
))}
</AnimatePresence>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* API Keys — full-width slim band with the two keys side-by-side */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>API Keys</CardTitle> <CardTitle>API Keys</CardTitle>
<CardDescription>Use these keys to integrate with Plunk</CardDescription> <CardDescription>Use these keys to integrate with Plunk</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4">
{activeProject ? ( {activeProject ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6"> <>
<ApiKeyDisplay <ApiKeyDisplay
label="Public Key" label="Public Key"
value={activeProject.public} value={activeProject.public}
@@ -664,13 +212,15 @@ export default function Index() {
description="Keep this key secure and never expose it publicly" description="Keep this key secure and never expose it publicly"
isSecret isSecret
/> />
</div> </>
) : ( ) : (
<p className="text-sm text-neutral-500">No project selected</p> <p className="text-sm text-neutral-500">No project selected</p>
)} )}
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
</DashboardLayout> </DashboardLayout>
</> </>
); );
+47 -55
View File
@@ -121,35 +121,57 @@ export default function TemplateEditorPage() {
return ( return (
<DashboardLayout> <DashboardLayout>
<NextSeo title={template.name} /> <NextSeo title={template.name} />
<div className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}> <form onSubmit={handleSave} className={`max-w-5xl mx-auto space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
{/* Header */} {/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4"> <div className="flex items-center gap-3 sm:gap-4">
<Button asChild variant="ghost" size="sm"> <Button asChild variant="ghost" size="sm">
<Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link> <Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link>
</Button> </Button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Edit Template</h1> <h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Edit Template</h1>
<p className="text-neutral-500 mt-1 text-sm sm:text-base"> <p className="text-neutral-500 mt-1 text-sm sm:text-base">Make changes to your email template</p>
{isSubmitting </div>
? 'Saving...' </div>
: hasChanges <div className="flex flex-col sm:flex-row sm:items-center gap-3">
? <span className="text-amber-600">Unsaved changes</span> <div className="flex-1">
: 'All changes saved'} {!hasChanges && !isSubmitting && (
</p> <span className="text-xs sm:text-sm text-neutral-500">All changes saved</span>
)}
{hasChanges && !isSubmitting && (
<span className="text-xs sm:text-sm text-amber-600">Unsaved changes</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="flex-1 sm:flex-none"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Delete</span>
</Button>
<Button type="submit" disabled={!hasChanges || isSubmitting} className="flex-1 sm:flex-none">
<Save className="h-4 w-4" />
<span className="hidden sm:inline">{isSubmitting ? 'Saving...' : 'Save Changes'}</span>
<span className="sm:hidden">{isSubmitting ? 'Saving...' : 'Save'}</span>
</Button>
</div>
</div> </div>
</div> </div>
<form onSubmit={handleSave} className="space-y-6"> {/* Template Editor */}
{/* Row 1: Basic Info + Template Type */} <div className="space-y-6">
<div className="grid gap-6 md:grid-cols-2"> {/* Template Settings */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Basic Information</CardTitle> <CardTitle>Template Settings</CardTitle>
<CardDescription>Name and describe your template</CardDescription> <CardDescription>Configure the basic settings for your template</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div>
<Label htmlFor="name">Template Name <span className="text-red-500">*</span></Label> <Label htmlFor="name">Template Name *</Label>
<Input <Input
id="name" id="name"
type="text" type="text"
@@ -160,7 +182,7 @@ export default function TemplateEditorPage() {
/> />
</div> </div>
<div className="space-y-2"> <div>
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Input <Input
id="description" id="description"
@@ -170,18 +192,12 @@ export default function TemplateEditorPage() {
placeholder="Sent to new subscribers" placeholder="Sent to new subscribers"
/> />
</div> </div>
</CardContent>
</Card>
<Card> <div>
<CardHeader> <Label>Type *</Label>
<CardTitle>Template Type</CardTitle> <div className="flex flex-col gap-2 mt-2">
<CardDescription>Choose how this template should be treated</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
{([ {([
{value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'}, {value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'} ,
{value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'}, {value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'},
{value: 'HEADLESS', label: 'Headless', description: 'Subscribed contacts, no Plunk footer'}, {value: 'HEADLESS', label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
] as const).map(({value, label, description}) => ( ] as const).map(({value, label, description}) => (
@@ -201,7 +217,7 @@ export default function TemplateEditorPage() {
))} ))}
</div> </div>
{editedTemplate.type === 'HEADLESS' && !detectUnsubscribeSignal(editedTemplate.body ?? '') && ( {editedTemplate.type === 'HEADLESS' && !detectUnsubscribeSignal(editedTemplate.body ?? '') && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden"> <div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2"> <div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" /> <TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p> <p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
@@ -221,19 +237,10 @@ export default function TemplateEditorPage() {
</div> </div>
</div> </div>
)} )}
</CardContent>
</Card>
</div> </div>
{/* Email Settings */} <div>
<Card> <Label htmlFor="subject">Subject Line *</Label>
<CardHeader>
<CardTitle>Email Settings</CardTitle>
<CardDescription>Configure sender information and subject</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="subject">Subject Line <span className="text-red-500">*</span></Label>
<Input <Input
id="subject" id="subject"
type="text" type="text"
@@ -242,7 +249,7 @@ export default function TemplateEditorPage() {
required required
placeholder="Welcome to our platform!" placeholder="Welcome to our platform!"
/> />
<p className="text-xs text-neutral-500">Use {'{{variableName}}'} for dynamic content</p> <p className="text-xs text-neutral-500 mt-1">Use {'{{variableName}}'} for dynamic content</p>
</div> </div>
<EmailSettings <EmailSettings
@@ -253,6 +260,7 @@ export default function TemplateEditorPage() {
onFromNameChange={value => setEditedTemplate({...editedTemplate, fromName: value})} onFromNameChange={value => setEditedTemplate({...editedTemplate, fromName: value})}
onReplyToChange={value => setEditedTemplate({...editedTemplate, replyTo: value})} onReplyToChange={value => setEditedTemplate({...editedTemplate, replyTo: value})}
fromNamePlaceholder={activeProject?.name || 'Your Company'} fromNamePlaceholder={activeProject?.name || 'Your Company'}
layout="vertical"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -270,24 +278,8 @@ export default function TemplateEditorPage() {
/> />
</CardContent> </CardContent>
</Card> </Card>
{/* Actions */}
<div className="flex justify-between gap-3">
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="h-4 w-4" />
Delete Template
</Button>
<Button type="submit" disabled={!hasChanges || isSubmitting}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div> </div>
</form> </form>
</div>
{/* Sticky Save Bar */} {/* Sticky Save Bar */}
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} /> <StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
+2 -2
View File
@@ -90,7 +90,7 @@ export default function TemplatesPage() {
</div> </div>
{/* Search & Filters */} {/* Search & Filters */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1"> <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 h-8 text-xs" className="pl-10 pr-10"
/> />
{searchInput && ( {searchInput && (
<button <button
+3 -10
View File
@@ -206,19 +206,12 @@ export default function WorkflowEditorPage() {
} }
break; break;
case 'UPDATE_CONTACT': { case 'UPDATE_CONTACT':
const hasUpdates = if (!config.updates || (typeof config.updates === 'object' && Object.keys(config.updates).length === 0)) {
config.updates && typeof config.updates === 'object' && Object.keys(config.updates).length > 0; errors.push(`"${step.name}" step is missing contact updates`);
const hasSubscriptionAction =
typeof config.subscriptionAction === 'string' &&
config.subscriptionAction !== 'none' &&
config.subscriptionAction !== '';
if (!hasUpdates && !hasSubscriptionAction) {
errors.push(`"${step.name}" step is missing contact updates or a subscription action`);
} }
break; break;
} }
}
}); });
// Check for orphaned steps (steps with no incoming or outgoing transitions, except TRIGGER and EXIT) // Check for orphaned steps (steps with no incoming or outgoing transitions, except TRIGGER and EXIT)
+2 -20
View File
@@ -23,7 +23,7 @@ import {EmptyState} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils'; import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, Copy, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react'; import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import Link from 'next/link'; import Link from 'next/link';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
@@ -66,16 +66,6 @@ export default function WorkflowsPage() {
} }
}; };
const handleDuplicate = async (workflowId: string) => {
try {
await network.fetch('POST', `/workflows/${workflowId}/duplicate`);
toast.success('Workflow duplicated successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to duplicate workflow');
}
};
const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => { const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => {
try { try {
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${workflowId}`, { await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${workflowId}`, {
@@ -117,7 +107,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 h-8 text-xs" className="pl-10 pr-10"
/> />
{searchInput && ( {searchInput && (
<button <button
@@ -229,14 +219,6 @@ export default function WorkflowsPage() {
<Button asChild variant="ghost" size="sm" title="Edit workflow"> <Button asChild variant="ghost" size="sm" title="Edit workflow">
<Link href={`/workflows/${workflow.id}`} aria-label="Edit workflow"><Edit className="h-4 w-4" /></Link> <Link href={`/workflows/${workflow.id}`} aria-label="Edit workflow"><Edit className="h-4 w-4" /></Link>
</Button> </Button>
<Button
variant="ghost"
size="sm"
title="Duplicate workflow"
onClick={() => handleDuplicate(workflow.id)}
>
<Copy className="h-4 w-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
-9
View File
@@ -165,12 +165,3 @@
font-feature-settings: "rlig" 1, "calt" 1; font-feature-settings: "rlig" 1, "calt" 1;
} }
} }
@keyframes indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
-12
View File
@@ -26,13 +26,6 @@ export default async function Page(props: {params: Promise<{slug?: string[]}>})
markdownUrl={`/llms.mdx${page.url}`} markdownUrl={`/llms.mdx${page.url}`}
githubUrl={`https://github.com/useplunk/plunk/blob/next/apps/wiki/content/docs/${page.path}`} githubUrl={`https://github.com/useplunk/plunk/blob/next/apps/wiki/content/docs/${page.path}`}
/> />
<p className="ml-auto hidden text-[11px] text-fd-muted-foreground sm:block">
Reading this with electronic eyes? Add{' '}
<a href={`${page.url}.md`} className="underline decoration-dotted underline-offset-2 transition hover:text-fd-foreground">
<code>.md</code>
</a>{' '}
for the Markdown cut.
</p>
</div> </div>
<DocsBody> <DocsBody>
@@ -64,11 +57,6 @@ export async function generateMetadata(props: {params: Promise<{slug?: string[]}
return { return {
title: page.data.title, title: page.data.title,
description: page.data.description, description: page.data.description,
alternates: {
types: {
'text/markdown': `${page.url}.md`,
},
},
openGraph: { openGraph: {
images: [{url: ogUrl.toString(), width: 1200, height: 630}], images: [{url: ogUrl.toString(), width: 1200, height: 630}],
}, },
-13
View File
@@ -6,16 +6,3 @@
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%);
}
-16
View File
@@ -1,16 +0,0 @@
import {openapi} from '@/lib/openapi';
export const revalidate = false;
export async function GET() {
const schemas = await openapi.getSchemas();
const first = Object.values(schemas)[0];
if (!first) return new Response('Not found', {status: 404});
return new Response(JSON.stringify(first.bundled), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=300, s-maxage=3600',
},
});
}
@@ -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`, header values, and `body` support `{{variables}}`. | `url`, optional `method`, `headers`, `body` | | `WEBHOOK` | Calls an external HTTPS endpoint with contact + execution context as the JSON body. | `url`, optional `method`, `headers` |
| `UPDATE_CONTACT` | Patches contact data — useful for tagging contacts as they progress (`{ stage: "activated" }`). | `data` object | | `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` |
@@ -37,8 +37,6 @@ In this example, every imported contact ends up with `data.firstName`, `data.pla
- **Email column**: must be present and valid. Rows with missing or invalid emails are reported back as errors. - **Email column**: must be present and valid. Rows with missing or invalid emails are reported back as errors.
- **Reserved column names**: `id`, `subscribed`, `createdAt`, `updatedAt`, and the auto-generated URL variables (`unsubscribeUrl`, etc.) are silently filtered out. Don't include them as columns. - **Reserved column names**: `id`, `subscribed`, `createdAt`, `updatedAt`, and the auto-generated URL variables (`unsubscribeUrl`, etc.) are silently filtered out. Don't include them as columns.
- **Date columns**: use ISO 8601 (`2026-05-06T12:00:00Z`) so they're typed as dates and become usable with `within` / `olderThan` segment operators. - **Date columns**: use ISO 8601 (`2026-05-06T12:00:00Z`) so they're typed as dates and become usable with `within` / `olderThan` segment operators.
- **Boolean columns**: `true`, `false`, `yes`, `no` (case-insensitive) are stored as booleans and get the boolean toggle in segment filters.
- **Numeric columns**: plain integers and decimals (`42`, `3.14`) are stored as numbers and become usable with `gt` / `lt` segment operators. Leading-zero values (`01234`), `+`-prefixed numbers, and scientific notation stay strings so IDs, zip codes, and phone numbers aren't corrupted.
- **Existing contacts**: if a row's email matches an existing contact, the import **updates** the contact (merging the CSV's columns into `data`). It doesn't create a duplicate or overwrite the whole record. - **Existing contacts**: if a row's email matches an existing contact, the import **updates** the contact (merging the CSV's columns into `data`). It doesn't create a duplicate or overwrite the whole record.
## Importing your CSV ## Importing your CSV
@@ -84,53 +84,6 @@ After the trigger, add a **Webhook** step and configure it:
} }
``` ```
- **Body** (optional): Custom request body. When omitted, Plunk sends the [default payload](#webhook-payload) shown below. When provided, the value replaces the default payload entirely and is JSON-encoded before being sent.
</Step>
<Step>
### Use variables in the request (optional)
The `url`, header values, and `body` all support `{{variable}}` interpolation. The available scope is the same as `SEND_EMAIL` templates, plus a webhook-only `event` namespace exposing the trigger event payload:
| Variable | Value |
| --------------------------------------------------------- | --------------------------------------------------------------------------- |
| `{{id}}`, `{{email}}` | The contact's ID and email. |
| `{{<key>}}` (top-level) | Any key from the contact's `data` JSON (e.g. `{{firstName}}`, `{{plan}}`). |
| `{{data.<key>}}` | The same contact data, addressed via the `data` namespace. |
| `{{event.<key>}}` | Webhook-only. Fields from the trigger event payload (e.g. `{{event.subject}}`). |
| `{{<key>}}` (from execution context) | Keys passed in as `context` when starting a `MANUAL` execution. |
| `{{unsubscribeUrl}}`, `{{subscribeUrl}}`, `{{manageUrl}}` | Per-contact subscription management URLs. |
The HTTP `method` is **not** templated — it must be a literal verb (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`). The `url` must include a static scheme (`http://` or `https://`); placeholders are supported inside the URL but cannot replace the scheme.
Example — forward a contact event to your own API, parameterised by contact data:
**URL**
```text
https://api.example.com/users/{{id}}/events
```
**Headers**
```json
{
"Authorization": "Bearer your-secret-token"
}
```
**Body**
```json
{
"email": "{{email}}",
"plan": "{{plan}}",
"referrer": "{{event.referrer}}"
}
```
</Step> </Step>
<Step> <Step>
-2
View File
@@ -4,8 +4,6 @@
"---Docs---", "---Docs---",
"concepts", "concepts",
"guides", "guides",
"---Recipes---",
"recipes",
"---API Reference---", "---API Reference---",
"api-reference", "api-reference",
"---Self-Hosting---", "---Self-Hosting---",
@@ -1,102 +0,0 @@
---
title: Double opt-in
description: Require a confirmation click before a new signup starts receiving marketing email
icon: MailCheck
---
Double opt-in adds a confirmation step between "user signs up" and "user starts getting marketing email." It's the standard way to avoid mailing typoed addresses, role accounts, and anyone who didn't actually consent.
The trick is `{{subscribeUrl}}`: a per-contact link Plunk auto-injects into every send. Clicking it flips `subscribed` to `true` and fires a `contact.subscribed` event.
## Setup
import {Step, Steps} from 'fumadocs-ui/components/steps';
<Steps>
<Step>
### Create two templates
- A **Transactional** template for the confirmation email, containing `{{subscribeUrl}}`:
```html
<p>Hi {{firstName}}, please confirm your email to start receiving updates:</p>
<p><a href="{{subscribeUrl}}">Confirm my email</a></p>
```
- A **Marketing** template for the welcome email that goes out *after* they confirm.
<Callout title="The confirmation must be transactional" type="warn">
A marketing template targeted at an unsubscribed contact is [silently skipped](/concepts/contacts#emails-by-subscription-state). Use a transactional template for the confirmation specifically — it bypasses the subscription check.
</Callout>
</Step>
<Step>
### Trigger the signup from your backend
Two calls with your secret key (`sk_*`): create the contact unsubscribed, then track the event that fires the confirmation workflow.
```bash
curl https://next-api.useplunk.com/contacts \
-H "Authorization: Bearer sk_your_secret_key" \
-d '{ "email": "[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>
-19
View File
@@ -1,19 +0,0 @@
---
title: Recipes
description: End-to-end walkthroughs for common patterns built on Plunk events and workflows
icon: ChefHat
---
Recipes are concrete, step-by-step builds for patterns we see most often in Plunk projects. Each one assumes you already understand the underlying [concepts](/concepts/workflows) and walks you through the exact API calls, workflow steps, and template variables involved.
<Cards>
<Card title="Waitlist with confirmation email" href="/recipes/waitlist">
Capture signups with a single tracked event, then automatically email each person who joins.
</Card>
<Card title="Sync unsubscribes to your database" href="/recipes/sync-unsubscribes">
Keep your own user table in step with Plunk's subscription state using a webhook step.
</Card>
<Card title="Double opt-in" href="/recipes/double-opt-in">
Add a confirmation step before a contact starts receiving marketing email, using `{{subscribeUrl}}`.
</Card>
</Cards>
-3
View File
@@ -1,3 +0,0 @@
{
"pages": ["index", "waitlist", "sync-unsubscribes", "double-opt-in"]
}
@@ -1,87 +0,0 @@
---
title: Sync unsubscribes to your database
description: Mirror Plunk's subscription state into your own user table using a workflow + webhook
icon: RefreshCw
---
Every flip of a contact's `subscribed` state — manual edits, the hosted unsubscribe page, bounces, complaints — fires a `contact.unsubscribed` event. Wire a workflow with a `WEBHOOK` step to forward that to your backend.
## Setup
import {Step, Steps} from 'fumadocs-ui/components/steps';
<Steps>
<Step>
### Build the receiving endpoint
A public HTTPS endpoint that verifies a shared secret and updates the user row. Webhook requests time out after 10 seconds, so do the work async if it's slow.
```ts
app.post('/plunk/unsubscribes', async (req, res) => {
if (req.header('authorization') !== `Bearer ${process.env.PLUNK_WEBHOOK_SECRET}`) {
return res.status(401).end();
}
const { contact, event } = req.body;
await db.user.update({
where: { email: contact.email },
data: {
emailSubscribed: false,
emailUnsubscribedReason: event.reason ?? 'user_action',
},
});
res.status(204).end();
});
```
`event.reason` is `"bounce"` or `"complaint"` for automatic unsubscribes, and absent for manual / self-service ones.
</Step>
<Step>
### Create the workflow
**Workflows → New workflow**:
- **Trigger**: `EVENT` on `contact.unsubscribed`
- Add a `WEBHOOK` step:
- **URL**: `https://api.example.com/plunk/unsubscribes`
- **Headers**: `{ "Authorization": "Bearer your-shared-secret" }`
- Leave the body blank to get the [default payload](/guides/webhooks#webhook-payload).
Enable the workflow.
</Step>
</Steps>
## Mirroring resubscribes
Build a second workflow with the same shape, triggered by `contact.subscribed`. Keep it separate from the unsubscribe flow — two short workflows are easier to monitor than one branched one.
## The reverse direction
If your product is the source of truth (a user toggles their email preference in your settings UI), call `PATCH /contacts/:id` from your backend:
```bash
curl -X PATCH https://next-api.useplunk.com/contacts/cnt_abc \
-H "Authorization: Bearer sk_your_secret_key" \
-d '{"subscribed": false}'
```
That flip also fires `contact.unsubscribed`, meaning your own webhook will round-trip back into your handler. That's usually harmless because the update is idempotent — but be aware of it.
## What's next
<Cards>
<Card title="Webhooks" href="/guides/webhooks">
Webhook step reference, payload shape, and safety.
</Card>
<Card title="Unsubscribe pages" href="/guides/unsubscribe-pages">
The hosted pages and template URL variables.
</Card>
</Cards>
@@ -1,89 +0,0 @@
---
title: Waitlist with confirmation email
description: Track signups as a custom event, store everyone who joins as a contact, and automatically email them
icon: ListOrdered
---
A waitlist is the simplest possible Plunk workflow: one tracked event from your app, one workflow that listens for it, one email.
## Setup
import {Step, Steps} from 'fumadocs-ui/components/steps';
<Steps>
<Step>
### Create the confirmation template
In **Templates → New template**, create a **Marketing** template. Use `{{variable}}` placeholders for anything you want to personalise from contact data:
```text
Subject: You're on the list, {{firstName}}
Hi {{firstName}}, thanks for joining the {{product}} waitlist.
We'll let you know as soon as your spot opens up.
```
</Step>
<Step>
### Track the signup from your backend
Call `POST /v1/track` when a user submits the form. Use a secret key (`sk_*`) — never call this from the browser.
```bash
curl https://next-api.useplunk.com/v1/track \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"event": "waitlist.joined",
"email": "[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>
@@ -36,7 +36,6 @@ Set your subdomains here. The application automatically derives all internal and
| `AWS_SES_SECRET_ACCESS_KEY` | Yes | AWS secret access key for SES. | `wJalr...` | | `AWS_SES_SECRET_ACCESS_KEY` | Yes | AWS secret access key for SES. | `wJalr...` |
| `SES_CONFIGURATION_SET` | No | SES configuration set name used for open/click tracking. | `plunk-configuration-set` (default) | | `SES_CONFIGURATION_SET` | No | SES configuration set name used for open/click tracking. | `plunk-configuration-set` (default) |
| `SES_CONFIGURATION_SET_NO_TRACKING` | No | A second SES configuration set without tracking. When set, projects can toggle email tracking on/off. If omitted, the tracking toggle is hidden. | `plunk-no-tracking-configuration-set` (default) | | `SES_CONFIGURATION_SET_NO_TRACKING` | No | A second SES configuration set without tracking. When set, projects can toggle email tracking on/off. If omitted, the tracking toggle is hidden. | `plunk-no-tracking-configuration-set` (default) |
| `MAIL_FROM_SUBDOMAIN` | No | Subdomain prefix used when constructing the MAIL FROM hostname for a verified domain (e.g. with default `plunk` and domain `yourdomain.com`, the MAIL FROM is `plunk.yourdomain.com`). Override when the default subdomain is already in use (e.g. by an R2/CDN custom domain), since the MAIL FROM hostname needs MX + TXT records that can't coexist with a CNAME. | `plunk` |
## Storage (Minio) ## Storage (Minio)
@@ -130,16 +129,6 @@ Plunk bundles a self-hosted [ntfy](https://ntfy.sh) server for internal system n
| ----------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | ----------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `AUTO_PROJECT_DISABLE` | No | When `true`, projects are automatically suspended when bounce or complaint rate thresholds are exceeded. Set to `false` to manage project status manually. | `true` | | `AUTO_PROJECT_DISABLE` | No | When `true`, projects are automatically suspended when bounce or complaint rate thresholds are exceeded. Set to `false` to manage project status manually. | `true` |
| `EMAIL_RATE_LIMIT_PER_SECOND` | No | Override the email sending rate limit. If not set, Plunk automatically fetches the quota from your AWS SES account. | — | | `EMAIL_RATE_LIMIT_PER_SECOND` | No | Override the email sending rate limit. If not set, Plunk automatically fetches the quota from your AWS SES account. | — |
| `EMAIL_WORKER_CONCURRENCY` | No | Number of emails the worker processes in parallel. When unset, derived from the effective rate limit so a higher SES quota scales throughput automatically. | — |
| `EMAIL_WORKER_MAX_CONCURRENCY`| No | Upper bound applied to the auto-derived worker concurrency. Raise this only after sizing the Prisma connection pool accordingly. | `50` |
## Advanced
Variables for unusual deployments. The defaults work for the standard Docker Compose setup — only change these if you know you need to.
| Variable | Required | Description | Default |
| ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------ | ------- |
| `NGINX_PORT` | No | Host port the bundled Nginx reverse proxy binds to. Override when port 80/443 is already in use on the host (e.g. running behind another reverse proxy that forwards to a different port). | `80` |
## Phishing Detection ## Phishing Detection
+1 -9
View File
@@ -1,17 +1,9 @@
import {renderOpenAPIOperationFromMDX} from '@/lib/render-openapi-operation';
import {source} from '@/lib/source'; import {source} from '@/lib/source';
import type {InferPageType} from 'fumadocs-core/source'; import type {InferPageType} from 'fumadocs-core/source';
export async function getLLMText(page: InferPageType<typeof source>) { export async function getLLMText(page: InferPageType<typeof source>) {
const isOpenAPI = Boolean((page.data as {_openapi?: unknown})._openapi);
if (isOpenAPI) {
const raw = await page.data.getText('raw');
const rendered = await renderOpenAPIOperationFromMDX(raw, page.data.title, page.url);
if (rendered) return rendered;
}
const processed = await page.data.getText('processed'); const processed = await page.data.getText('processed');
return `# ${page.data.title} (${page.url}) return `# ${page.data.title} (${page.url})
${processed}`; ${processed}`;
-279
View File
@@ -1,279 +0,0 @@
import {openapi} from '@/lib/openapi';
interface SchemaLike {
type?: string | string[];
format?: string;
enum?: unknown[];
description?: string;
default?: unknown;
example?: unknown;
required?: string[];
properties?: Record<string, SchemaLike>;
items?: SchemaLike;
oneOf?: SchemaLike[];
anyOf?: SchemaLike[];
allOf?: SchemaLike[];
nullable?: boolean;
[key: string]: unknown;
}
interface Parameter {
name: string;
in: string;
required?: boolean;
description?: string;
schema?: SchemaLike;
}
interface Operation {
operationId?: string;
summary?: string;
description?: string;
deprecated?: boolean;
tags?: string[];
parameters?: Parameter[];
requestBody?: {
required?: boolean;
description?: string;
content?: Record<string, {schema?: SchemaLike}>;
};
responses?: Record<
string,
{
description?: string;
content?: Record<string, {schema?: SchemaLike}>;
}
>;
security?: Array<Record<string, string[]>>;
}
function typeLabel(schema?: SchemaLike): string {
if (!schema) return 'unknown';
if (schema.enum) return `enum (${schema.enum.map(v => JSON.stringify(v)).join(' | ')})`;
if (schema.oneOf) return schema.oneOf.map(typeLabel).join(' | ');
if (schema.anyOf) return schema.anyOf.map(typeLabel).join(' | ');
if (schema.allOf) return schema.allOf.map(typeLabel).join(' & ');
if (Array.isArray(schema.type)) return schema.type.join(' | ');
const base = schema.type ?? 'object';
if (base === 'array' && schema.items) return `array<${typeLabel(schema.items)}>`;
if (schema.format) return `${base} (${schema.format})`;
return base;
}
function renderSchemaTree(schema: SchemaLike | undefined, depth = 0, lines: string[] = []): string[] {
if (!schema) return lines;
const indent = ' '.repeat(depth);
if (schema.allOf) {
for (const sub of schema.allOf) renderSchemaTree(sub, depth, lines);
return lines;
}
if (schema.type === 'object' || schema.properties) {
const required = new Set(schema.required ?? []);
const props = schema.properties ?? {};
for (const [name, prop] of Object.entries(props)) {
const tag = required.has(name) ? ' (required)' : '';
const desc = prop.description ? `${prop.description.replace(/\n+/g, ' ')}` : '';
lines.push(`${indent}- \`${name}\`: ${typeLabel(prop)}${tag}${desc}`);
if (prop.type === 'object' || prop.properties) {
renderSchemaTree(prop, depth + 1, lines);
} else if (prop.type === 'array' && prop.items && (prop.items.type === 'object' || prop.items.properties)) {
lines.push(`${indent} items:`);
renderSchemaTree(prop.items, depth + 2, lines);
}
}
return lines;
}
if (schema.type === 'array' && schema.items) {
lines.push(`${indent}- items: ${typeLabel(schema.items)}`);
renderSchemaTree(schema.items, depth + 1, lines);
}
return lines;
}
function exampleFromSchema(schema?: SchemaLike): unknown {
if (!schema) return undefined;
if (schema.example !== undefined) return schema.example;
if (schema.default !== undefined) return schema.default;
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
if (schema.allOf) {
const merged: Record<string, unknown> = {};
for (const sub of schema.allOf) Object.assign(merged, exampleFromSchema(sub) as object ?? {});
return merged;
}
if (schema.oneOf?.[0]) return exampleFromSchema(schema.oneOf[0]);
if (schema.anyOf?.[0]) return exampleFromSchema(schema.anyOf[0]);
if (schema.properties) {
const out: Record<string, unknown> = {};
const required = new Set(schema.required ?? Object.keys(schema.properties));
for (const [name, prop] of Object.entries(schema.properties)) {
if (!required.has(name) && schema.required) continue;
out[name] = exampleFromSchema(prop);
}
return out;
}
if (schema.type === 'array') {
const item = exampleFromSchema(schema.items);
return item === undefined ? [] : [item];
}
switch (schema.type) {
case 'string':
if (schema.format === 'email') return '[email protected]';
if (schema.format === 'date-time') return new Date().toISOString();
if (schema.format === 'uri' || schema.format === 'url') return 'https://example.com';
return 'string';
case 'integer':
case 'number':
return 0;
case 'boolean':
return false;
default:
return null;
}
}
function findOperationByMethodPath(
doc: {paths?: Record<string, Record<string, Operation>>},
method: string,
path: string,
): Operation | undefined {
const pathItem = doc.paths?.[path];
return pathItem?.[method.toLowerCase()];
}
function extractOperationRef(mdxBody: string): {method: string; path: string} | undefined {
const block = mdxBody.match(/operations=\{(\[[\s\S]*?\])\}/);
if (!block) return undefined;
const pathMatch = block[1].match(/['"]?path['"]?\s*:\s*['"]([^'"]+)['"]/);
const methodMatch = block[1].match(/['"]?method['"]?\s*:\s*['"]([^'"]+)['"]/);
if (!pathMatch || !methodMatch) return undefined;
return {path: pathMatch[1], method: methodMatch[1]};
}
export async function renderOpenAPIOperationFromMDX(
mdxBody: string,
fallbackTitle: string,
pageUrl: string,
): Promise<string | undefined> {
const ref = extractOperationRef(mdxBody);
if (!ref) return undefined;
const schemas = await openapi.getSchemas();
const first = Object.values(schemas)[0];
if (!first) return undefined;
const doc = first.dereferenced as {
servers?: Array<{url: string}>;
paths?: Record<string, Record<string, Operation>>;
};
const op = findOperationByMethodPath(doc, ref.method, ref.path);
if (!op) return undefined;
const lines: string[] = [];
const method = ref.method.toUpperCase();
const baseUrl = doc.servers?.[0]?.url ?? '';
lines.push(`# ${op.summary ?? fallbackTitle} (${pageUrl})`);
lines.push('');
lines.push(`\`${method} ${ref.path}\``);
lines.push('');
if (baseUrl) {
lines.push(`Base URL: \`${baseUrl}\``);
lines.push('');
}
if (op.description) {
lines.push(op.description);
lines.push('');
}
if (op.deprecated) {
lines.push('> **Deprecated.** This endpoint should not be used in new integrations.');
lines.push('');
}
if (op.parameters && op.parameters.length > 0) {
const grouped: Record<string, Parameter[]> = {};
for (const p of op.parameters) (grouped[p.in] ??= []).push(p);
for (const [location, params] of Object.entries(grouped)) {
lines.push(`## ${location[0].toUpperCase()}${location.slice(1)} parameters`);
lines.push('');
for (const p of params) {
const req = p.required ? ' (required)' : '';
const desc = p.description ? `${p.description.replace(/\n+/g, ' ')}` : '';
lines.push(`- \`${p.name}\`: ${typeLabel(p.schema)}${req}${desc}`);
}
lines.push('');
}
}
const body = op.requestBody?.content?.['application/json']?.schema;
if (body) {
lines.push('## Request body');
lines.push('');
if (op.requestBody?.description) {
lines.push(op.requestBody.description);
lines.push('');
}
const tree = renderSchemaTree(body);
if (tree.length > 0) {
lines.push(...tree);
lines.push('');
}
const example = exampleFromSchema(body);
if (example !== undefined) {
lines.push('Example:');
lines.push('');
lines.push('```json');
lines.push(JSON.stringify(example, null, 2));
lines.push('```');
lines.push('');
}
}
if (op.responses) {
lines.push('## Responses');
lines.push('');
for (const [status, resp] of Object.entries(op.responses)) {
const desc = resp.description ? `${resp.description.replace(/\n+/g, ' ')}` : '';
lines.push(`### \`${status}\`${desc}`);
lines.push('');
const schema = resp.content?.['application/json']?.schema;
if (schema) {
const tree = renderSchemaTree(schema);
if (tree.length > 0) {
lines.push(...tree);
lines.push('');
}
const example = exampleFromSchema(schema);
if (example !== undefined) {
lines.push('```json');
lines.push(JSON.stringify(example, null, 2));
lines.push('```');
lines.push('');
}
}
}
}
if (baseUrl) {
lines.push('## Example request');
lines.push('');
lines.push('```bash');
const curlParts = [`curl -X ${method} '${baseUrl}${ref.path}'`, " -H 'Authorization: Bearer YOUR_API_KEY'"];
if (body) {
curlParts.push(" -H 'Content-Type: application/json'");
const example = exampleFromSchema(body);
curlParts.push(` -d '${JSON.stringify(example)}'`);
}
lines.push(curlParts.join(' \\\n'));
lines.push('```');
lines.push('');
}
return lines.join('\n');
}
+2 -3
View File
@@ -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 = types.find(t => t.type === 'text/markdown')?.q ?? -1; const mdQ = getQ(types, 'text/markdown');
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';
@@ -57,10 +57,9 @@ export function middleware(request: NextRequest) {
const response = NextResponse.next(); const response = NextResponse.next();
response.headers.set('Vary', 'Accept'); response.headers.set('Vary', 'Accept');
response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`);
return response; return response;
} }
export const config = { export const config = {
matcher: ['/((?!llms\\.mdx|api/search|openapi\\.json|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|json|xml|txt|webmanifest)).*)',], matcher: ['/((?!llms\\.mdx|api/search|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt|webmanifest)).*)',],
}; };
-83
View File
@@ -1,83 +0,0 @@
# Plunk Documentation
> Documentation for Plunk, an open-source email platform for developers. Covers core concepts, integration guides, the REST API, and self-hosting.
Every documentation page is available as Markdown by appending `.md` to the path (for example, `https://docs.useplunk.com/concepts/contacts.md`), or by requesting the page URL with `Accept: text/markdown`. API reference endpoints render the OpenAPI operation — including parameters, request/response schemas, and a sample curl — directly into the Markdown response.
The full OpenAPI specification is available at [https://docs.useplunk.com/openapi.json](https://docs.useplunk.com/openapi.json).
## Getting Started
- [Welcome to Plunk](https://docs.useplunk.com/index.md): What Plunk is and how to get started
## Concepts
- [Contacts](https://docs.useplunk.com/concepts/contacts.md): Manage and organize your contacts
- [Segments](https://docs.useplunk.com/concepts/segments.md): Group and target contacts with dynamic or static segments
- [Templates](https://docs.useplunk.com/concepts/templates.md): Reusable email templates for campaigns, workflows, and transactional emails
- [Campaigns](https://docs.useplunk.com/concepts/campaigns.md): One-off broadcast emails sent to a defined audience
- [Workflows](https://docs.useplunk.com/concepts/workflows.md): Automated, multi-step journeys triggered by events, segments, schedules, or manual entry
- [Transactional emails](https://docs.useplunk.com/concepts/transactional-emails.md): Send emails via API
- [Billing](https://docs.useplunk.com/concepts/billing.md): How Plunk's pricing, limits, and consumption work
## Guides
- [API keys](https://docs.useplunk.com/guides/api-keys.md): Plunk's two-key model and how to rotate or revoke keys safely
- [Verifying domains](https://docs.useplunk.com/guides/verifying-domains.md): Verify sending domains so emails reach the inbox
- [Tracking](https://docs.useplunk.com/guides/tracking.md): Track opens and clicks on emails sent through Plunk
- [Webhooks](https://docs.useplunk.com/guides/webhooks.md): Send real-time event data from Plunk to your own application
- [Receiving emails](https://docs.useplunk.com/guides/receiving-emails.md): Receive inbound email at your verified domain and turn it into events
- [Custom fields](https://docs.useplunk.com/guides/custom-fields.md): Store arbitrary data on contacts and use it for personalization and segmentation
- [Segment filter reference](https://docs.useplunk.com/guides/segment-filters.md): How to write filters for dynamic segments, campaign audiences, and workflow conditions
- [Importing contacts from CSV](https://docs.useplunk.com/guides/importing-contacts.md): Bulk-load contacts and their custom fields
- [Unsubscribe & preferences pages](https://docs.useplunk.com/guides/unsubscribe-pages.md): Hosted pages for unsubscribe, resubscribe, and preference management
- [Localization](https://docs.useplunk.com/guides/localization.md): Translate the unsubscribe footer and contact-facing pages
- [List hygiene](https://docs.useplunk.com/guides/list-hygiene.md): Maintain a healthy email list
## API Reference
- [API overview](https://docs.useplunk.com/api-reference/overview.md): Complete Plunk API documentation
- [Error codes](https://docs.useplunk.com/api-reference/errors.md): API error codes and troubleshooting
### Public API
- [Send transactional email](https://docs.useplunk.com/api-reference/public-api/sendEmail.md): POST — send a transactional email; auto-creates/updates contacts
- [Track event](https://docs.useplunk.com/api-reference/public-api/trackEvent.md): POST — track an event for a contact
- [Verify email address](https://docs.useplunk.com/api-reference/public-api/verifyEmail.md): POST — validate an address, check disposable/MX/typos
### Contacts
- [Create or update contact](https://docs.useplunk.com/api-reference/contacts/createContact.md): POST — upsert by email
- [Get contact](https://docs.useplunk.com/api-reference/contacts/getContact.md): GET — single contact by ID
- [List contacts](https://docs.useplunk.com/api-reference/contacts/listContacts.md): GET — paginated, cursor-based
- [Update contact](https://docs.useplunk.com/api-reference/contacts/updateContact.md): PATCH
- [Delete contact](https://docs.useplunk.com/api-reference/contacts/deleteContact.md): DELETE
### Campaigns
- [Create campaign](https://docs.useplunk.com/api-reference/campaigns/createCampaign.md): POST
- [List campaigns](https://docs.useplunk.com/api-reference/campaigns/listCampaigns.md): GET — paginated
- [Send or schedule campaign](https://docs.useplunk.com/api-reference/campaigns/sendCampaign.md): POST — send immediately or schedule
### Templates
- [Create template](https://docs.useplunk.com/api-reference/templates/createTemplate.md): POST
- [List templates](https://docs.useplunk.com/api-reference/templates/listTemplates.md): GET — paginated
### Segments
- [Create segment](https://docs.useplunk.com/api-reference/segments/createSegment.md): POST
- [List segments](https://docs.useplunk.com/api-reference/segments/listSegments.md): GET
## Self-Hosting
- [Self-hosting introduction](https://docs.useplunk.com/self-hosting/introduction.md): Deploy Plunk on your own infrastructure
- [Docker deployment](https://docs.useplunk.com/self-hosting/docker.md): Deploy with Docker Compose
- [Environment variables](https://docs.useplunk.com/self-hosting/environment-variables.md): Configuration reference
- [AWS SES setup](https://docs.useplunk.com/self-hosting/email-setup.md): Configure email delivery
## Optional
- [Marketing site](https://www.useplunk.com)
- [GitHub repository](https://github.com/useplunk/plunk)
- [Discord community](https://www.useplunk.com/discord)
+58 -29
View File
@@ -27,6 +27,39 @@ services:
# Infrastructure Services # Infrastructure Services
# ============================================ # ============================================
postgres:
image: postgres:16-alpine
container_name: plunk-postgres
restart: unless-stopped
environment:
POSTGRES_DB: plunk
POSTGRES_USER: plunk
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme123}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U plunk" ]
interval: 10s
timeout: 5s
retries: 5
networks:
- plunk
redis:
image: redis:7-alpine
container_name: plunk-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 5
networks:
- plunk
minio: minio:
image: minio/minio:latest image: minio/minio:latest
container_name: plunk-minio container_name: plunk-minio
@@ -37,10 +70,10 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-plunkminiopass} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-plunkminiopass}
volumes: volumes:
- minio_data:/data - minio_data:/data
# ports: ports:
# # Expose Minio API (for S3 operations) and Console (for web UI) # Expose Minio API (for S3 operations) and Console (for web UI)
# - "${MINIO_API_PORT:-9000}:9000" - "${MINIO_API_PORT:-9000}:9000"
# - "${MINIO_CONSOLE_PORT:-9001}:9001" - "${MINIO_CONSOLE_PORT:-9001}:9001"
healthcheck: healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
interval: 30s interval: 30s
@@ -55,13 +88,13 @@ services:
restart: unless-stopped restart: unless-stopped
command: serve command: serve
environment: environment:
- TZ=CST - TZ=UTC
volumes: volumes:
- ntfy_cache:/var/cache/ntfy - ntfy_cache:/var/cache/ntfy
- ntfy_etc:/etc/ntfy - ntfy_etc:/etc/ntfy
# ports: ports:
# # Expose ntfy web UI and API # Expose ntfy web UI and API
# - "${NTFY_PORT:-8080}:80" - "${NTFY_PORT:-8080}:80"
healthcheck: healthcheck:
test: [ "CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1" ] test: [ "CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1" ]
interval: 30s interval: 30s
@@ -85,11 +118,11 @@ services:
NODE_ENV: production NODE_ENV: production
# Database # Database
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: postgresql://plunk:${DB_PASSWORD:-changeme123}@postgres:5432/plunk
DIRECT_DATABASE_URL: ${DIRECT_DATABASE_URL} DIRECT_DATABASE_URL: postgresql://plunk:${DB_PASSWORD:-changeme123}@postgres:5432/plunk
# Redis # Redis
REDIS_URL: ${REDIS_URL} REDIS_URL: redis://redis:6379
# Security # Security
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
@@ -121,10 +154,6 @@ services:
SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET} SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET}
SES_CONFIGURATION_SET_NO_TRACKING: ${SES_CONFIGURATION_SET_NO_TRACKING:-} SES_CONFIGURATION_SET_NO_TRACKING: ${SES_CONFIGURATION_SET_NO_TRACKING:-}
# Custom MAIL FROM subdomain (defaults to 'plunk'; override when
# plunk.<your-domain> is already used for something else, e.g. a CDN)
MAIL_FROM_SUBDOMAIN: ${MAIL_FROM_SUBDOMAIN:-}
# Optional: OAuth # Optional: OAuth
GITHUB_OAUTH_CLIENT: ${GITHUB_OAUTH_CLIENT:-} GITHUB_OAUTH_CLIENT: ${GITHUB_OAUTH_CLIENT:-}
GITHUB_OAUTH_SECRET: ${GITHUB_OAUTH_SECRET:-} GITHUB_OAUTH_SECRET: ${GITHUB_OAUTH_SECRET:-}
@@ -161,14 +190,6 @@ services:
# Security # Security
AUTO_PROJECT_DISABLE: ${AUTO_PROJECT_DISABLE:-false} AUTO_PROJECT_DISABLE: ${AUTO_PROJECT_DISABLE:-false}
# Self-hosting user management (documented in .env.self-host.example
# and read by apps/api/src/app/constants.ts at import time)
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false}
# Explicit SES sending rate (avoid silent fallback to 14/sec when
# ses:GetSendQuota is denied or transiently fails at worker startup)
EMAIL_RATE_LIMIT_PER_SECOND: ${EMAIL_RATE_LIMIT_PER_SECOND:-}
volumes: volumes:
# Persistent storage for application data # Persistent storage for application data
- plunk_data:/app/data - plunk_data:/app/data
@@ -181,15 +202,19 @@ services:
ports: ports:
# SMTP ports (for email relay) # SMTP ports (for email relay)
# - "${PORT_SECURE:-465}:465" # SMTPS (implicit TLS) - "${PORT_SECURE:-465}:465" # SMTPS (implicit TLS)
# - "${PORT_SUBMISSION:-587}:587" # SMTP Submission (STARTTLS) - "${PORT_SUBMISSION:-587}:587" # SMTP Submission (STARTTLS)
# Optional: Expose individual service ports for debugging # Optional: Expose individual service ports for debugging
- "6000:8080" # API # - "8080:8080" # API
- "6001:3000" # Web # - "3000:3000" # Web
# - "6002:4000" # Landing # - "4000:4000" # Landing
# - "6003:1000" # Wiki # - "1000:1000" # Wiki
depends_on: depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio: minio:
condition: service_healthy condition: service_healthy
ntfy: ntfy:
@@ -207,6 +232,10 @@ services:
- plunk - plunk
volumes: volumes:
postgres_data:
driver: local
redis_data:
driver: local
minio_data: minio_data:
driver: local driver: local
plunk_data: plunk_data:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "plunk", "name": "plunk",
"version": "0.11.0", "version": "0.10.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "turbo build", "build": "turbo build",
@@ -1,5 +0,0 @@
-- CreateEnum
CREATE TYPE "ProjectDisabledReason" AS ENUM ('PAYMENT_FAILED', 'EMAIL_REPUTATION', 'PHISHING_DETECTED', 'MANUAL');
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "disabledReason" "ProjectDisabledReason";
-8
View File
@@ -44,7 +44,6 @@ model Project {
// Admin // Admin
disabled Boolean @default(false) disabled Boolean @default(false)
disabledReason ProjectDisabledReason?
// Billing // Billing
customer String? @unique customer String? @unique
@@ -633,13 +632,6 @@ 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
+4 -24
View File
@@ -96,21 +96,9 @@ export const ContactSchemas = {
subscribed: z.boolean().default(true), subscribed: z.boolean().default(true),
data: jsonSchema.optional(), data: jsonSchema.optional(),
}), }),
bulkAction: z.discriminatedUnion('mode', [ bulkAction: z.object({
z.object({
mode: z.literal('ids'),
contactIds: z.array(uuid).min(1).max(1000), contactIds: z.array(uuid).min(1).max(1000),
}), }),
z.object({
mode: z.literal('query'),
filter: z
.object({
search: z.string().max(255).optional(),
})
.default({}),
excludeIds: z.array(uuid).max(10000).optional(),
}),
]),
lookup: z.object({ lookup: z.object({
emails: z.array(z.string().email()).min(1).max(500), emails: z.array(z.string().email()).min(1).max(500),
}), }),
@@ -346,17 +334,9 @@ export const WorkflowStepConfigSchemas = {
headers: z.record(z.string()).optional(), headers: z.record(z.string()).optional(),
body: jsonSchema.optional(), body: jsonSchema.optional(),
}), }),
updateContact: z updateContact: z.object({
.object({ updates: z.record(z.any()),
updates: z.record(z.any()).optional(), }),
subscriptionAction: z.enum(['none', 'subscribe', 'unsubscribe']).optional(),
})
.refine(
value =>
(value.updates && Object.keys(value.updates).length > 0) ||
(value.subscriptionAction && value.subscriptionAction !== 'none'),
{message: 'Provide at least one field to update or a subscription action'},
),
}; };
export const DomainSchemas = { export const DomainSchemas = {

Some files were not shown because too many files have changed in this diff Show More