Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d75aed81e1 | ||
|
|
0cbaa8fc66 | ||
|
|
0686b3a3f2 | ||
|
|
9b15b93b96 | ||
|
|
8f2510f5ec | ||
|
|
e196d798c5 | ||
|
|
777a12ec89 | ||
|
|
6c6af775bc | ||
|
|
4f8c9f029d | ||
|
|
8f72994b46 | ||
|
|
d162f255c2 | ||
|
|
c8e252fefd | ||
|
|
b8f1ad9ab5 | ||
|
|
708ab506f1 | ||
|
|
ec1f4c9374 | ||
|
|
5528288c7b | ||
|
|
7bd098bdd0 | ||
|
|
cbea263a91 | ||
|
|
e8a247fe12 | ||
|
|
4b51e386e3 | ||
|
|
59aa7845ba | ||
|
|
5600c49bb6 | ||
|
|
21af8fe05e | ||
|
|
2c4d95e604 | ||
|
|
64bd094b47 | ||
|
|
53b33bbd36 | ||
|
|
5fa9b9dda5 | ||
|
|
4bc5424815 | ||
|
|
573e2e8449 | ||
|
|
00ae942c3c | ||
|
|
9c69c7a36e | ||
|
|
9568b7d345 | ||
|
|
6bba9b6199 | ||
|
|
2f55d32485 | ||
|
|
eec37ecb31 | ||
|
|
f947ee6f22 | ||
|
|
3330d83d08 | ||
|
|
de7ed84fcf | ||
|
|
e639c72e8b | ||
|
|
43ea660d52 | ||
|
|
1f85d448e2 | ||
|
|
e7f168c373 | ||
|
|
b42698616a | ||
|
|
64ba19e589 | ||
|
|
3a42012ac7 | ||
|
|
08e5c0d930 | ||
|
|
18788ac1ca | ||
|
|
c40394ffd6 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.5.0"
|
||||
".": "0.7.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## [0.7.0](https://github.com/useplunk/plunk/compare/v0.6.0...v0.7.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** support inline images in emails using Content-ID ([9b15b93](https://github.com/useplunk/plunk/commit/9b15b93b96344f811d869d103b3b6d344b531811))
|
||||
* Sort projects alphabetically in the dashboard and fix layout ([64bd094](https://github.com/useplunk/plunk/commit/64bd094b47abbc4feaeb93d97915df57763b3907))
|
||||
* Static segments ([4b51e38](https://github.com/useplunk/plunk/commit/4b51e386e39ae38d6ea52eb87858de36fa45ab46))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add support for STATIC segment type in CampaignService ([7bd098b](https://github.com/useplunk/plunk/commit/7bd098bdd01a8af0dd41ec515880289b1795e5af))
|
||||
* correct cookie domain for .local TLD hostnames ([59aa784](https://github.com/useplunk/plunk/commit/59aa7845bad69a1768bb88f83cfcea607c447538))
|
||||
* Correctly set domain status on manual verify ([21af8fe](https://github.com/useplunk/plunk/commit/21af8fe05e0450e8d826a3a01224511a337ca072))
|
||||
* Do not unsubscribe existing contacts ([2c4d95e](https://github.com/useplunk/plunk/commit/2c4d95e604cbed9189f7ee07c24b1f236fce7990))
|
||||
* Support any locale on creation ([ec1f4c9](https://github.com/useplunk/plunk/commit/ec1f4c9374e06e3defed3c728be603c10e4e7baa))
|
||||
* Verify SNS URL before sending fetch request ([b8f1ad9](https://github.com/useplunk/plunk/commit/b8f1ad9ab53c78f8ef063fdc125f397c8bfc7652))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Static segments ([e8a247f](https://github.com/useplunk/plunk/commit/e8a247fe12b79ae8a25a74485179e93081fe2002))
|
||||
|
||||
## [0.6.0](https://github.com/useplunk/plunk/compare/v0.5.0...v0.6.0) (2026-02-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to change workflow trigger ([eec37ec](https://github.com/useplunk/plunk/commit/eec37ecb31c63888879a1933dba4fd0c6243018b))
|
||||
* Add billing for inbound ([3330d83](https://github.com/useplunk/plunk/commit/3330d83d08904bc547740bfc9fdcbc5ff214d977))
|
||||
* Add billing for inbound ([de7ed84](https://github.com/useplunk/plunk/commit/de7ed84fcfddde16a9297c947048e9876712f6fa))
|
||||
* Add dedicated received type ([f947ee6](https://github.com/useplunk/plunk/commit/f947ee6f22371055801fa9cfc62e15df7851986c))
|
||||
* **i18n:** Add Spanish language ([9c69c7a](https://github.com/useplunk/plunk/commit/9c69c7a36edd86fdc02b8d0d3e3f8d64ed8ecfa6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Enhance email bounce notification with latest bounce details ([e639c72](https://github.com/useplunk/plunk/commit/e639c72e8b940ba1da472d355f930359da7c868b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add receiving emails functionality and update DNS records documentation ([b426986](https://github.com/useplunk/plunk/commit/b42698616a3f7bb079a092375ac886f2a9d7405d))
|
||||
|
||||
## [0.5.0](https://github.com/useplunk/plunk/compare/v0.4.0...v0.5.0) (2026-02-17)
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailchecker": "^6.0.19",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"signale": "^1.4.0",
|
||||
"stripe": "^20.0.0"
|
||||
},
|
||||
|
||||
@@ -72,12 +72,13 @@ export class Actions {
|
||||
|
||||
// Create or update contact with persistent data only
|
||||
// ContactService.upsert will filter out non-persistent fields
|
||||
// Event tracking should subscribe contacts by default
|
||||
// Event tracking should subscribe new contacts by default (subscribed=true in ContactService)
|
||||
// but preserve existing subscription state for existing contacts
|
||||
const contact = await ContactService.upsert(
|
||||
auth.projectId,
|
||||
email,
|
||||
data as Record<string, unknown> | undefined,
|
||||
subscribed ?? true,
|
||||
subscribed,
|
||||
);
|
||||
|
||||
// Track the event with ALL data (persistent + non-persistent)
|
||||
@@ -268,7 +269,9 @@ export class Actions {
|
||||
|
||||
// Create or update contact with metadata
|
||||
// Transactional emails should not subscribe contacts by default
|
||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed ?? false);
|
||||
// New contacts default to unsubscribed unless explicitly opted in
|
||||
// Existing contacts preserve their subscription state unless explicitly changed
|
||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed, false);
|
||||
|
||||
// Get merged data including non-persistent fields for template rendering
|
||||
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
||||
|
||||
@@ -315,6 +315,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const jobId = req.params.jobId;
|
||||
|
||||
if (!jobId) {
|
||||
@@ -322,7 +323,7 @@ export class Contacts {
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await QueueService.getImportJobStatus(jobId);
|
||||
const status = await QueueService.getImportJobStatus(jobId, auth.projectId!);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({error: 'Import job not found'});
|
||||
@@ -502,6 +503,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const jobId = req.params.jobId;
|
||||
|
||||
if (!jobId) {
|
||||
@@ -509,7 +511,7 @@ export class Contacts {
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await QueueService.getBulkActionJobStatus(jobId);
|
||||
const status = await QueueService.getBulkActionJobStatus(jobId, auth.projectId!);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({error: 'Bulk action job not found'});
|
||||
|
||||
@@ -72,20 +72,23 @@ export class Segments {
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, condition, trackMembership} = req.body;
|
||||
const {name, description, type, condition, trackMembership} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({error: 'Name is required'});
|
||||
}
|
||||
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return res.status(400).json({error: 'Condition is required and must be an object'});
|
||||
const segmentType = type ?? 'DYNAMIC';
|
||||
|
||||
if (segmentType === 'DYNAMIC' && (!condition || typeof condition !== 'object')) {
|
||||
return res.status(400).json({error: 'Condition is required and must be an object for DYNAMIC segments'});
|
||||
}
|
||||
|
||||
const segment = await SegmentService.create(auth.projectId!, {
|
||||
name,
|
||||
description,
|
||||
condition,
|
||||
type: segmentType,
|
||||
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||
trackMembership,
|
||||
});
|
||||
|
||||
@@ -142,6 +145,56 @@ export class Segments {
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments/:id/members
|
||||
* Add contacts to a static segment by email
|
||||
*/
|
||||
@Post(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {emails} = req.body;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /segments/:id/members
|
||||
* Remove contacts from a static segment by email
|
||||
*/
|
||||
@Delete(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async removeMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {emails} = req.body;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.removeContacts(auth.projectId!, segmentId, emails);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments/:id/compute
|
||||
* Recompute segment membership for all contacts
|
||||
|
||||
@@ -330,6 +330,7 @@ export class Users {
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -349,6 +350,7 @@ export class Users {
|
||||
billingLimitWorkflows: data.workflows,
|
||||
billingLimitCampaigns: data.campaigns,
|
||||
billingLimitTransactional: data.transactional,
|
||||
billingLimitInbound: data.inbound,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -360,6 +362,7 @@ export class Users {
|
||||
workflows: project.billingLimitWorkflows,
|
||||
campaigns: project.billingLimitCampaigns,
|
||||
transactional: project.billingLimitTransactional,
|
||||
inbound: project.billingLimitInbound,
|
||||
},
|
||||
data,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Controller, Post} from '@overnightjs/core';
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import {EmailStatus} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
import type Stripe from 'stripe';
|
||||
@@ -8,8 +8,10 @@ import type Stripe from 'stripe';
|
||||
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -32,11 +34,35 @@ export class Webhooks {
|
||||
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
||||
if (req.body.Type === 'SubscriptionConfirmation') {
|
||||
signale.info('SNS Subscription Confirmation received');
|
||||
signale.info('Subscribe URL:', req.body.SubscribeURL);
|
||||
|
||||
// Validate SubscribeURL to prevent SSRF: must be HTTPS and from an official AWS SNS host.
|
||||
// Legitimate URLs look like:
|
||||
// https://sns.<region>.amazonaws.com/?Action=ConfirmSubscription&...
|
||||
const subscribeURL: unknown = req.body.SubscribeURL;
|
||||
if (typeof subscribeURL !== 'string') {
|
||||
signale.warn('SNS SubscriptionConfirmation missing SubscribeURL');
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
let parsedURL: URL;
|
||||
try {
|
||||
parsedURL = new URL(subscribeURL);
|
||||
} catch {
|
||||
signale.warn('SNS SubscriptionConfirmation has unparseable SubscribeURL');
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
// Only allow HTTPS requests to official AWS SNS endpoints.
|
||||
// The hostname must be exactly sns.<region>.amazonaws.com.
|
||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.com$/;
|
||||
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
||||
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
// Automatically confirm the subscription
|
||||
try {
|
||||
const confirmResponse = await fetch(req.body.SubscribeURL);
|
||||
const confirmResponse = await fetch(subscribeURL);
|
||||
if (confirmResponse.ok) {
|
||||
signale.success('SNS subscription confirmed successfully');
|
||||
return res.status(200).json({
|
||||
@@ -48,7 +74,6 @@ export class Webhooks {
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Failed to confirm subscription',
|
||||
subscribeURL: req.body.SubscribeURL,
|
||||
});
|
||||
}
|
||||
} catch (confirmError) {
|
||||
@@ -56,7 +81,6 @@ export class Webhooks {
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Error confirming subscription',
|
||||
subscribeURL: req.body.SubscribeURL,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -122,6 +146,16 @@ export class Webhooks {
|
||||
for (const domainRecord of domainRecords) {
|
||||
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
|
||||
|
||||
// Check billing limits before processing inbound email
|
||||
const limitCheck = await BillingLimitService.checkLimit(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||
|
||||
if (!limitCheck.allowed) {
|
||||
signale.warn(
|
||||
`[WEBHOOK] Inbound email blocked for project ${domainRecord.project.name}: ${limitCheck.message}`,
|
||||
);
|
||||
continue; // Skip this project but continue processing for other projects
|
||||
}
|
||||
|
||||
// Find or create a contact for the sender in this project
|
||||
let contact;
|
||||
if (senderEmail) {
|
||||
@@ -133,6 +167,32 @@ export class Webhooks {
|
||||
);
|
||||
}
|
||||
|
||||
// Create an Email record for tracking (no actual email content since it's inbound)
|
||||
const inboundEmail = await prisma.email.create({
|
||||
data: {
|
||||
projectId: domainRecord.projectId,
|
||||
contactId: contact!.id,
|
||||
subject: body.mail?.commonHeaders?.subject || '(No subject)',
|
||||
body: '', // Inbound emails don't have body content in our system
|
||||
from: recipientEmail, // The recipient address that received the email
|
||||
sourceType: EmailSourceType.INBOUND,
|
||||
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
|
||||
deliveredAt: new Date(body.mail?.timestamp || new Date()),
|
||||
},
|
||||
});
|
||||
|
||||
// Increment usage counter in cache
|
||||
await BillingLimitService.incrementUsage(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||
|
||||
// Record Stripe metering if project has customer
|
||||
if (domainRecord.project.customer) {
|
||||
await MeterService.recordEmailSent(
|
||||
domainRecord.project.customer,
|
||||
1, // Inbound emails count as 1 credit
|
||||
`email_${inboundEmail.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare event data with all inbound email details
|
||||
const eventData = {
|
||||
messageId: body.mail?.messageId,
|
||||
@@ -158,7 +218,7 @@ export class Webhooks {
|
||||
domainRecord.projectId,
|
||||
'email.received',
|
||||
contact?.id,
|
||||
undefined, // No emailId for inbound emails (they're not sent by us)
|
||||
inboundEmail.id, // Link the event to the inbound email record
|
||||
eventData,
|
||||
);
|
||||
|
||||
|
||||
@@ -376,6 +376,9 @@ export class ActivityService {
|
||||
if (!types || types.includes(ActivityType.EMAIL_DELIVERED)) {
|
||||
orConditions.push({deliveredAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_RECEIVED)) {
|
||||
orConditions.push({deliveredAt: {not: null, ...dateFilter}, sourceType: 'INBOUND'});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_OPENED)) {
|
||||
orConditions.push({openedAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
@@ -485,6 +488,22 @@ export class ActivityService {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
email.deliveredAt &&
|
||||
email.sourceType === 'INBOUND' &&
|
||||
(!types || types.includes(ActivityType.EMAIL_RECEIVED)) &&
|
||||
isInDateRange(email.deliveredAt)
|
||||
) {
|
||||
activities.push({
|
||||
id: `${email.id}_received`,
|
||||
type: ActivityType.EMAIL_RECEIVED,
|
||||
timestamp: email.deliveredAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: baseMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED)) && isInDateRange(email.openedAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_opened`,
|
||||
|
||||
@@ -157,6 +157,7 @@ export class BillingLimitService {
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -183,6 +184,9 @@ export class BillingLimitService {
|
||||
case EmailSourceType.TRANSACTIONAL:
|
||||
limit = project.billingLimitTransactional;
|
||||
break;
|
||||
case EmailSourceType.INBOUND:
|
||||
limit = project.billingLimitInbound;
|
||||
break;
|
||||
default:
|
||||
limit = null;
|
||||
}
|
||||
@@ -191,7 +195,8 @@ export class BillingLimitService {
|
||||
const hasCustomLimits =
|
||||
project.billingLimitWorkflows !== null ||
|
||||
project.billingLimitCampaigns !== null ||
|
||||
project.billingLimitTransactional !== null;
|
||||
project.billingLimitTransactional !== null ||
|
||||
project.billingLimitInbound !== null;
|
||||
|
||||
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
||||
// Only enforce free tier limits if billing is enabled
|
||||
@@ -368,6 +373,7 @@ export class BillingLimitService {
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -390,10 +396,11 @@ export class BillingLimitService {
|
||||
}
|
||||
|
||||
// Get usage for all categories in parallel
|
||||
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
|
||||
const [workflowUsage, campaignUsage, transactionalUsage, inboundUsage] = await Promise.all([
|
||||
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
||||
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
||||
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
||||
this.getUsage(projectId, EmailSourceType.INBOUND),
|
||||
]);
|
||||
|
||||
// Helper to calculate category usage
|
||||
@@ -412,18 +419,19 @@ export class BillingLimitService {
|
||||
const hasCustomLimits =
|
||||
project.billingLimitWorkflows !== null ||
|
||||
project.billingLimitCampaigns !== null ||
|
||||
project.billingLimitTransactional !== null;
|
||||
project.billingLimitTransactional !== null ||
|
||||
project.billingLimitInbound !== null;
|
||||
|
||||
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
||||
// Only show free tier limits if billing is enabled
|
||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage + inboundUsage;
|
||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||
const percentage = (totalUsage / limit) * 100;
|
||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||
const isBlocked = totalUsage >= limit;
|
||||
|
||||
// For free tier, show the same limit and total usage for all three categories
|
||||
// For free tier, show the same limit and total usage for all four categories
|
||||
// This makes it clear in the UI that it's a shared limit
|
||||
const sharedUsageInfo: CategoryUsage = {
|
||||
limit,
|
||||
@@ -437,6 +445,7 @@ export class BillingLimitService {
|
||||
workflows: sharedUsageInfo,
|
||||
campaigns: sharedUsageInfo,
|
||||
transactional: sharedUsageInfo,
|
||||
inbound: sharedUsageInfo,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
@@ -446,6 +455,7 @@ export class BillingLimitService {
|
||||
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
||||
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
||||
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
||||
inbound: calculateCategoryUsage(inboundUsage, project.billingLimitInbound),
|
||||
currency,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -470,6 +480,7 @@ export class BillingLimitService {
|
||||
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
||||
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
||||
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
||||
this.getCacheKey(projectId, EmailSourceType.INBOUND),
|
||||
];
|
||||
|
||||
await Promise.all(keys.map(key => redis.del(key)));
|
||||
@@ -493,11 +504,13 @@ export class BillingLimitService {
|
||||
workflows: number | null;
|
||||
campaigns: number | null;
|
||||
transactional: number | null;
|
||||
inbound: number | null;
|
||||
},
|
||||
newLimits: {
|
||||
workflows: number | null;
|
||||
campaigns: number | null;
|
||||
transactional: number | null;
|
||||
inbound: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -531,6 +544,14 @@ export class BillingLimitService {
|
||||
);
|
||||
}
|
||||
|
||||
// Check inbound limit
|
||||
if (oldLimits.inbound !== newLimits.inbound) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
||||
signale.debug(
|
||||
|
||||
@@ -760,6 +760,18 @@ export class CampaignService {
|
||||
throw new HttpException(404, 'Segment not found');
|
||||
}
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
return {
|
||||
...baseWhere,
|
||||
segmentMemberships: {
|
||||
some: {
|
||||
segmentId,
|
||||
exitedAt: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {type Contact, Prisma} from '@plunk/db';
|
||||
import {isValidLanguageCode} from '@plunk/shared';
|
||||
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
|
||||
@@ -199,6 +198,7 @@ export class ContactService {
|
||||
email: string,
|
||||
data?: Record<string, unknown>,
|
||||
subscribed?: boolean,
|
||||
defaultSubscribed: boolean = true,
|
||||
): Promise<Contact> {
|
||||
// Find existing contact
|
||||
const existing = await prisma.contact.findFirst({
|
||||
@@ -235,12 +235,9 @@ export class ContactService {
|
||||
}
|
||||
|
||||
// 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 (typeof value === 'string') {
|
||||
if (!isValidLanguageCode(value)) {
|
||||
throw new HttpException(400, `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`);
|
||||
}
|
||||
} else if (value !== null && value !== undefined) {
|
||||
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
}
|
||||
@@ -292,7 +289,7 @@ export class ContactService {
|
||||
projectId,
|
||||
email,
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
subscribed: subscribed ?? true,
|
||||
subscribed: subscribed ?? defaultSubscribed,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@ import {HttpException} from '../exceptions/index.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {deleteIdentity, getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
||||
import {
|
||||
deleteIdentity,
|
||||
disableFeedbackForwarding,
|
||||
getDomainVerificationAttributes,
|
||||
verifyDomain,
|
||||
} from './SESService.js';
|
||||
|
||||
export class DomainService {
|
||||
/**
|
||||
@@ -72,6 +77,43 @@ export class DomainService {
|
||||
|
||||
const attributes = await getDomainVerificationAttributes(domain.domain);
|
||||
|
||||
// If domain failed verification, retry
|
||||
if (attributes.status === 'Failed') {
|
||||
signale.warn(`[DOMAIN-SERVICE] Restarting verification for ${domain.domain}`);
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 5;
|
||||
let success = false;
|
||||
let delay = 5000;
|
||||
|
||||
while (attempt < maxAttempts && !success) {
|
||||
try {
|
||||
await verifyDomain(domain.domain);
|
||||
success = true;
|
||||
signale.success(`[DOMAIN-SERVICE] Restarted verification for ${domain.domain}`);
|
||||
} catch (e: unknown) {
|
||||
const error = e as {Code?: string; name?: string; message?: string};
|
||||
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
||||
signale.warn(
|
||||
`[DOMAIN-SERVICE] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||
);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
attempt++;
|
||||
} else {
|
||||
signale.error(`[DOMAIN-SERVICE] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
signale.error(
|
||||
`[DOMAIN-SERVICE] Failed to verify ${domain.domain} after ${maxAttempts} attempts due to throttling`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain if verification status changed
|
||||
if (attributes.status === 'Success' && !domain.verified) {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
@@ -84,6 +126,14 @@ export class DomainService {
|
||||
},
|
||||
});
|
||||
|
||||
// Disable feedback forwarding for verified domain
|
||||
try {
|
||||
await disableFeedbackForwarding(domain.domain);
|
||||
signale.info(`[DOMAIN-SERVICE] Disabled feedback forwarding for ${domain.domain}`);
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-SERVICE] Error disabling feedback forwarding for ${domain.domain}:`, error);
|
||||
}
|
||||
|
||||
// Send notification about domain verified
|
||||
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ interface Attachment {
|
||||
filename: string;
|
||||
content: string; // Base64 encoded
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}
|
||||
|
||||
interface SendEmailParams {
|
||||
@@ -381,7 +383,13 @@ export class EmailService {
|
||||
// Parse attachments from JSON
|
||||
const attachments =
|
||||
email.attachments && Array.isArray(email.attachments)
|
||||
? (email.attachments as Array<{filename: string; content: string; contentType: string}>)
|
||||
? (email.attachments as Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}>)
|
||||
: undefined;
|
||||
|
||||
// Determine tracking based on project settings and email type
|
||||
@@ -585,10 +593,11 @@ export class EmailService {
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [total, sent, delivered, opened, clicked, bounced, failed] = await Promise.all([
|
||||
const [total, sent, delivered, received, opened, clicked, bounced, failed] = await Promise.all([
|
||||
prisma.email.count({where}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.RECEIVED}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
|
||||
@@ -599,6 +608,7 @@ export class EmailService {
|
||||
total,
|
||||
sent,
|
||||
delivered,
|
||||
received,
|
||||
opened,
|
||||
clicked,
|
||||
bounced,
|
||||
|
||||
@@ -229,7 +229,7 @@ export class NtfyService {
|
||||
|
||||
/**
|
||||
* Notify about email bounce - LOW priority (high volume)
|
||||
* Rate-limited to only send notification every 20 bounces with per-project breakdown
|
||||
* Rate-limited to only send notification every 20 bounces with latest 20 bounce details
|
||||
*/
|
||||
public static async notifyEmailBounce(
|
||||
projectName: string,
|
||||
@@ -240,63 +240,51 @@ export class NtfyService {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
// Use Redis counters to track bounce count globally and per-project
|
||||
// Use Redis counters to track bounce count globally
|
||||
const globalCountKey = `ntfy:bounce:count`;
|
||||
const projectCountKey = `ntfy:bounce:count:${projectId}`;
|
||||
const projectListKey = `ntfy:bounce:projects`;
|
||||
const bounceListKey = `ntfy:bounce:latest`;
|
||||
|
||||
// Increment global counter
|
||||
const globalCount = await redis.incr(globalCountKey);
|
||||
|
||||
// Increment project-specific counter
|
||||
await redis.incr(projectCountKey);
|
||||
|
||||
// Add project to the set of projects with bounces (for tracking)
|
||||
await redis.sadd(projectListKey, projectId);
|
||||
// Store this bounce event in a list (keep latest 20)
|
||||
const bounceEvent = JSON.stringify({
|
||||
projectName,
|
||||
projectId,
|
||||
recipientEmail,
|
||||
bounceType: bounceType || 'Unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await redis.lpush(bounceListKey, bounceEvent);
|
||||
await redis.ltrim(bounceListKey, 0, 19); // Keep only latest 20
|
||||
|
||||
// Set expiry on first increment (24 hour rolling window)
|
||||
if (globalCount === 1) {
|
||||
await redis.expire(globalCountKey, 86400);
|
||||
await redis.expire(projectListKey, 86400);
|
||||
await redis.expire(bounceListKey, 86400);
|
||||
}
|
||||
// Always refresh project counter expiry to match global window
|
||||
await redis.expire(projectCountKey, 86400);
|
||||
|
||||
// Only send notification every 20 bounces
|
||||
if (globalCount % 20 === 0) {
|
||||
// Get all projects with bounces
|
||||
const projectIds = await redis.smembers(projectListKey);
|
||||
// Get the latest 20 bounces
|
||||
const latestBounces = await redis.lrange(bounceListKey, 0, 19);
|
||||
|
||||
// Get bounce counts for each project
|
||||
const projectCounts: Array<{projectId: string; count: number; name: string}> = [];
|
||||
for (const pid of projectIds) {
|
||||
const count = await redis.get(`ntfy:bounce:count:${pid}`);
|
||||
if (count) {
|
||||
// Fetch project name from database
|
||||
const {prisma} = await import('../database/prisma.js');
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: pid},
|
||||
select: {name: true},
|
||||
});
|
||||
|
||||
projectCounts.push({
|
||||
projectId: pid,
|
||||
count: parseInt(count, 10),
|
||||
name: project?.name || 'Unknown',
|
||||
});
|
||||
}
|
||||
// Parse and format the bounce events
|
||||
const bounceDetails = latestBounces
|
||||
.map(bounce => {
|
||||
try {
|
||||
const parsed = JSON.parse(bounce);
|
||||
return ` • ${parsed.recipientEmail} (${parsed.bounceType}) - ${parsed.projectName}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
// Sort by count descending
|
||||
projectCounts.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Build breakdown message
|
||||
const breakdown = projectCounts.map(p => ` • ${p.name} (${p.projectId}): ${p.count}`).join('\n');
|
||||
|
||||
const bounceInfo = bounceType ? ` (${bounceType})` : '';
|
||||
await this.send({
|
||||
title: 'Email Bounces',
|
||||
message: `20 email bounces detected (total: ${globalCount})\n\nBreakdown by project:\n${breakdown}\n\nLatest: ${recipientEmail}${bounceInfo} in "${projectName}"`,
|
||||
message: `20 email bounces detected (total: ${globalCount})\n\nLatest 20 bounces:\n${bounceDetails}`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.WARNING],
|
||||
});
|
||||
|
||||
@@ -282,14 +282,22 @@ export class QueueService {
|
||||
|
||||
/**
|
||||
* Get import job status and progress
|
||||
* @param jobId - The job ID
|
||||
* @param projectId - The project ID to verify authorization
|
||||
* @returns Job status or null if not found or unauthorized
|
||||
*/
|
||||
public static async getImportJobStatus(jobId: string) {
|
||||
public static async getImportJobStatus(jobId: string, projectId: string) {
|
||||
const job = await importQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Security: Verify that the job belongs to the requesting project
|
||||
if (job.data.projectId !== projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const progress = job.progress;
|
||||
const returnValue = job.returnvalue;
|
||||
@@ -324,14 +332,22 @@ export class QueueService {
|
||||
|
||||
/**
|
||||
* Get bulk action job status and progress
|
||||
* @param jobId - The job ID
|
||||
* @param projectId - The project ID to verify authorization
|
||||
* @returns Job status or null if not found or unauthorized
|
||||
*/
|
||||
public static async getBulkActionJobStatus(jobId: string) {
|
||||
public static async getBulkActionJobStatus(jobId: string, projectId: string) {
|
||||
const job = await bulkContactQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Security: Verify that the job belongs to the requesting project
|
||||
if (job.data.projectId !== projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const progress = job.progress;
|
||||
const returnValue = job.returnvalue;
|
||||
|
||||
@@ -40,6 +40,8 @@ interface SendRawEmailParams {
|
||||
filename: string;
|
||||
content: string; // Base64 encoded
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}[]
|
||||
| null;
|
||||
tracking?: boolean;
|
||||
@@ -101,8 +103,13 @@ export async function sendRawEmail({
|
||||
}
|
||||
|
||||
// Generate unique boundaries for multipart messages
|
||||
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
|
||||
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null;
|
||||
const altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
|
||||
const mixedBoundary = attachments?.some(a => (a.disposition ?? 'attachment') === 'attachment')
|
||||
? `----=_MixedPart_${Math.random().toString(36).substring(2)}`
|
||||
: null;
|
||||
const relatedBoundary = attachments?.some(a => a.disposition === 'inline')
|
||||
? `----=_RelatedPart_${Math.random().toString(36).substring(2)}`
|
||||
: null;
|
||||
|
||||
// Format To header with names if provided
|
||||
const toHeader = to
|
||||
@@ -118,17 +125,21 @@ export async function sendRawEmail({
|
||||
// Extract just email addresses for Destinations (SES requirement)
|
||||
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
|
||||
|
||||
// Determine root content type
|
||||
let rootContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||
if (mixedBoundary) {
|
||||
rootContentType = `multipart/mixed; boundary="${mixedBoundary}"`;
|
||||
} else if (relatedBoundary) {
|
||||
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
|
||||
}
|
||||
|
||||
// Build raw MIME message
|
||||
const rawMessage = `From: ${from.name} <${from.email}>
|
||||
let rawMessage = `From: ${from.name} <${from.email}>
|
||||
To: ${toHeader}
|
||||
Reply-To: ${reply || from.email}
|
||||
Subject: ${content.subject}
|
||||
MIME-Version: 1.0
|
||||
${
|
||||
mixedBoundary
|
||||
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
|
||||
: `Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||
}
|
||||
Content-Type: ${rootContentType}
|
||||
${
|
||||
headers
|
||||
? Object.entries(headers)
|
||||
@@ -138,29 +149,61 @@ ${
|
||||
}
|
||||
${unsubscribeHeader}
|
||||
|
||||
${mixedBoundary ? `--${mixedBoundary}\n` : ''}${
|
||||
mixedBoundary ? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n` : ''
|
||||
}--${boundary}
|
||||
`;
|
||||
|
||||
// building the body
|
||||
if (mixedBoundary) {
|
||||
rawMessage += `--${mixedBoundary}\n`;
|
||||
if (relatedBoundary) {
|
||||
rawMessage += `Content-Type: multipart/related; boundary="${relatedBoundary}"\n\n`;
|
||||
rawMessage += `--${relatedBoundary}\n`;
|
||||
}
|
||||
} else if (relatedBoundary) {
|
||||
rawMessage += `--${relatedBoundary}\n`;
|
||||
}
|
||||
|
||||
// If we are nested, we need to specify that this next part is the alternative container
|
||||
if (mixedBoundary || relatedBoundary) {
|
||||
rawMessage += `Content-Type: multipart/alternative; boundary="${altBoundary}"\n\n`;
|
||||
}
|
||||
|
||||
// The alternative part content (always contains HTML)
|
||||
rawMessage += `--${altBoundary}
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
${breakLongLines(content.html, 500)}
|
||||
--${boundary}--
|
||||
${
|
||||
attachments && attachments.length > 0
|
||||
? '\n' +
|
||||
attachments
|
||||
.map(
|
||||
attachment => `--${mixedBoundary}
|
||||
--${altBoundary}--
|
||||
`;
|
||||
|
||||
// Add inline attachments to the related container
|
||||
if (relatedBoundary) {
|
||||
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
|
||||
for (const attachment of inlineAttachments) {
|
||||
rawMessage += `\n--${relatedBoundary}
|
||||
Content-Type: ${attachment.contentType}
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <${attachment.contentId || attachment.filename}>
|
||||
Content-Disposition: inline; filename="${attachment.filename}"
|
||||
|
||||
${breakLongLines(attachment.content, 76, true)}`;
|
||||
}
|
||||
rawMessage += `\n--${relatedBoundary}--`;
|
||||
}
|
||||
|
||||
// Add regular attachments to the mixed container
|
||||
if (mixedBoundary) {
|
||||
const regularAttachments = attachments?.filter(a => (a.disposition ?? 'attachment') === 'attachment') ?? [];
|
||||
for (const attachment of regularAttachments) {
|
||||
rawMessage += `\n--${mixedBoundary}
|
||||
Content-Type: ${attachment.contentType}
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="${attachment.filename}"
|
||||
|
||||
${breakLongLines(attachment.content, 76, true)}`,
|
||||
)
|
||||
.join('\n')
|
||||
: ''
|
||||
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
|
||||
${breakLongLines(attachment.content, 76, true)}`;
|
||||
}
|
||||
rawMessage += `\n--${mixedBoundary}--`;
|
||||
}
|
||||
|
||||
// Determine which configuration set to use
|
||||
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter} from '@plunk/types';
|
||||
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter, SegmentType} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
@@ -66,11 +66,33 @@ export class SegmentService {
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedResponse<Contact>> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
// For static segments, query via SegmentMembership records
|
||||
const [memberships, total] = await Promise.all([
|
||||
prisma.segmentMembership.findMany({
|
||||
where: {segmentId, exitedAt: null},
|
||||
include: {contact: true},
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: {enteredAt: 'desc'},
|
||||
}),
|
||||
prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: memberships.map(m => m.contact),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
const [contacts, total] = await Promise.all([
|
||||
prisma.contact.findMany({
|
||||
where,
|
||||
@@ -98,23 +120,35 @@ export class SegmentService {
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
condition: FilterCondition;
|
||||
type?: SegmentType;
|
||||
condition?: FilterCondition;
|
||||
trackMembership?: boolean;
|
||||
},
|
||||
): Promise<Segment> {
|
||||
const segmentType = data.type ?? 'DYNAMIC';
|
||||
let memberCount = 0;
|
||||
let conditionJson: Prisma.InputJsonValue | typeof Prisma.JsonNull = Prisma.JsonNull;
|
||||
|
||||
if (segmentType === 'DYNAMIC') {
|
||||
if (!data.condition) {
|
||||
throw new HttpException(400, 'Condition is required for DYNAMIC segments');
|
||||
}
|
||||
// Validate condition
|
||||
this.validateCondition(data.condition);
|
||||
|
||||
// Compute initial member count
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
memberCount = await prisma.contact.count({where});
|
||||
conditionJson = toPrismaJson(data.condition);
|
||||
}
|
||||
|
||||
const segment = await prisma.segment.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
condition: toPrismaJson(data.condition),
|
||||
type: segmentType,
|
||||
condition: conditionJson,
|
||||
trackMembership: data.trackMembership ?? false,
|
||||
memberCount,
|
||||
},
|
||||
@@ -145,12 +179,7 @@ export class SegmentService {
|
||||
},
|
||||
): Promise<Segment> {
|
||||
// First verify segment exists and belongs to project
|
||||
await this.get(projectId, segmentId);
|
||||
|
||||
// Validate condition if provided
|
||||
if (data.condition) {
|
||||
this.validateCondition(data.condition);
|
||||
}
|
||||
const existing = await this.get(projectId, segmentId);
|
||||
|
||||
const updateData: Prisma.SegmentUpdateInput = {};
|
||||
|
||||
@@ -160,7 +189,9 @@ export class SegmentService {
|
||||
if (data.description !== undefined) {
|
||||
updateData.description = data.description;
|
||||
}
|
||||
if (data.condition !== undefined) {
|
||||
if (data.condition !== undefined && existing.type !== 'STATIC') {
|
||||
// Validate condition if provided (only for DYNAMIC segments)
|
||||
this.validateCondition(data.condition);
|
||||
updateData.condition = toPrismaJson(data.condition);
|
||||
|
||||
// Recompute member count when condition changes
|
||||
@@ -229,10 +260,16 @@ export class SegmentService {
|
||||
*/
|
||||
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
|
||||
let memberCount: number;
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
await prisma.segment.update({
|
||||
where: {id: segmentId},
|
||||
@@ -249,7 +286,7 @@ export class SegmentService {
|
||||
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, condition: true},
|
||||
select: {id: true, type: true, condition: true},
|
||||
});
|
||||
|
||||
// Process in batches to avoid overwhelming the database
|
||||
@@ -260,9 +297,17 @@ export class SegmentService {
|
||||
await Promise.all(
|
||||
batch.map(async segment => {
|
||||
try {
|
||||
let memberCount: number;
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
memberCount = await prisma.segmentMembership.count({
|
||||
where: {segmentId: segment.id, exitedAt: null},
|
||||
});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
await prisma.segment.update({
|
||||
where: {id: segment.id},
|
||||
@@ -276,6 +321,104 @@ export class SegmentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add contacts to a static segment by email
|
||||
*/
|
||||
public static async addContacts(
|
||||
projectId: string,
|
||||
segmentId: string,
|
||||
emails: string[],
|
||||
): Promise<{added: number; notFound: string[]}> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
|
||||
if (segment.type !== 'STATIC') {
|
||||
throw new HttpException(400, 'Can only add contacts to STATIC segments');
|
||||
}
|
||||
|
||||
// Look up contacts by email (case-insensitive)
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
email: {in: emails, mode: 'insensitive'},
|
||||
},
|
||||
select: {id: true, email: true},
|
||||
});
|
||||
|
||||
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
||||
|
||||
if (contacts.length > 0) {
|
||||
// Check for existing memberships (to reactivate vs create new)
|
||||
const existingMemberships = await prisma.segmentMembership.findMany({
|
||||
where: {segmentId, contactId: {in: contacts.map(c => c.id)}},
|
||||
select: {contactId: true},
|
||||
});
|
||||
const existingIds = new Set(existingMemberships.map(m => m.contactId));
|
||||
|
||||
const newContactIds = contacts.filter(c => !existingIds.has(c.id)).map(c => c.id);
|
||||
const reEntryIds = contacts.filter(c => existingIds.has(c.id)).map(c => c.id);
|
||||
|
||||
if (newContactIds.length > 0) {
|
||||
await prisma.segmentMembership.createMany({
|
||||
data: newContactIds.map(contactId => ({segmentId, contactId, enteredAt: new Date()})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (reEntryIds.length > 0) {
|
||||
await prisma.segmentMembership.updateMany({
|
||||
where: {segmentId, contactId: {in: reEntryIds}},
|
||||
data: {exitedAt: null, enteredAt: new Date()},
|
||||
});
|
||||
}
|
||||
|
||||
// Update member count
|
||||
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||
}
|
||||
|
||||
return {added: contacts.length, notFound};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove contacts from a static segment by email
|
||||
*/
|
||||
public static async removeContacts(
|
||||
projectId: string,
|
||||
segmentId: string,
|
||||
emails: string[],
|
||||
): Promise<{removed: number}> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
|
||||
if (segment.type !== 'STATIC') {
|
||||
throw new HttpException(400, 'Can only remove contacts from STATIC segments');
|
||||
}
|
||||
|
||||
// Look up contacts by email
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
email: {in: emails, mode: 'insensitive'},
|
||||
},
|
||||
select: {id: true},
|
||||
});
|
||||
|
||||
if (contacts.length > 0) {
|
||||
const contactIds = contacts.map(c => c.id);
|
||||
|
||||
await prisma.segmentMembership.updateMany({
|
||||
where: {segmentId, contactId: {in: contactIds}, exitedAt: null},
|
||||
data: {exitedAt: new Date()},
|
||||
});
|
||||
|
||||
// Update member count
|
||||
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||
}
|
||||
|
||||
return {removed: contacts.length};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute or recompute segment membership for all contacts
|
||||
* Now uses cursor-based pagination for memory efficiency with large contact lists
|
||||
@@ -290,6 +433,13 @@ export class SegmentService {
|
||||
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
||||
}
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
// For static segments, just update the count from memberships — no contact scanning
|
||||
const total = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount: total}});
|
||||
return {added: 0, removed: 0, total};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {Keys} from './keys.js';
|
||||
* Extract base domain from URL for cookie sharing across subdomains
|
||||
* e.g., "http://api.example.com" -> ".example.com"
|
||||
* e.g., "http://api.localhost" -> ".localhost"
|
||||
* e.g., "http://app.plunk.local" -> ".plunk.local"
|
||||
*/
|
||||
function getCookieDomain(): string | undefined {
|
||||
if (NODE_ENV === 'development') {
|
||||
@@ -28,10 +29,14 @@ function getCookieDomain(): string | undefined {
|
||||
// Extract base domain (last two parts for most domains, or .localhost)
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// For *.localhost or *.local, use the full hostname with leading dot
|
||||
if (hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
|
||||
// For *.localhost, use .localhost (reserved TLD)
|
||||
if (hostname.endsWith('.localhost')) {
|
||||
return '.localhost';
|
||||
}
|
||||
// For *.local (mDNS TLD), use the actual base domain
|
||||
if (hostname.endsWith('.local')) {
|
||||
return `.${parts.slice(-2).join('.')}`;
|
||||
}
|
||||
// For other domains, use the last two parts (e.g., .example.com)
|
||||
return `.${parts.slice(-2).join('.')}`;
|
||||
}
|
||||
|
||||
@@ -194,6 +194,8 @@ export class WorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
// Use transaction to update workflow and TRIGGER step atomically
|
||||
const updated = await prisma.$transaction(async tx => {
|
||||
const updateData: Prisma.WorkflowUpdateInput = {};
|
||||
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
@@ -205,7 +207,7 @@ export class WorkflowService {
|
||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
||||
|
||||
const updated = await prisma.workflow.update({
|
||||
const updatedWorkflow = await tx.workflow.update({
|
||||
where: {id: workflowId},
|
||||
data: updateData,
|
||||
include: {
|
||||
@@ -215,11 +217,46 @@ export class WorkflowService {
|
||||
},
|
||||
});
|
||||
|
||||
// If triggerConfig changed and it's an EVENT trigger, update the TRIGGER step
|
||||
if (data.triggerConfig !== undefined && updatedWorkflow.triggerType === 'EVENT') {
|
||||
const newTriggerConfig = data.triggerConfig as {eventName?: string} | null;
|
||||
const eventName = newTriggerConfig?.eventName;
|
||||
|
||||
if (eventName) {
|
||||
// Find the TRIGGER step
|
||||
const triggerStep = await tx.workflowStep.findFirst({
|
||||
where: {
|
||||
workflowId: workflowId,
|
||||
type: 'TRIGGER',
|
||||
},
|
||||
});
|
||||
|
||||
if (triggerStep) {
|
||||
// Update TRIGGER step config and name to match
|
||||
await tx.workflowStep.update({
|
||||
where: {id: triggerStep.id},
|
||||
data: {
|
||||
name: `Trigger: ${eventName}`,
|
||||
config: {eventName},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedWorkflow;
|
||||
});
|
||||
|
||||
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
||||
if (data.enabled !== undefined || updated.enabled) {
|
||||
await EventService.invalidateWorkflowCache(projectId);
|
||||
}
|
||||
|
||||
// Also invalidate cache if triggerConfig changed on an enabled workflow
|
||||
if (data.triggerConfig !== undefined && updated.enabled) {
|
||||
await EventService.invalidateWorkflowCache(projectId);
|
||||
}
|
||||
|
||||
// Send notification if enabled status changed
|
||||
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
||||
if (data.enabled) {
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import {ActionSchemas} from '@plunk/shared';
|
||||
import {EmailService} from '../EmailService';
|
||||
import {sendRawEmail} from '../SESService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock SES service
|
||||
// Mock AWS SDK globally (used by real SESService calls in MIME tests)
|
||||
vi.mock('@aws-sdk/client-ses', () => {
|
||||
const SESMock = vi.fn();
|
||||
SESMock.prototype.sendRawEmail = vi.fn().mockResolvedValue({MessageId: 'test-message-id'});
|
||||
return {SES: SESMock};
|
||||
});
|
||||
|
||||
// Mock constants to provide AWS credentials for SESService, preserving other exports
|
||||
vi.mock('../../app/constants.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../app/constants.js')>();
|
||||
return {
|
||||
...actual,
|
||||
AWS_SES_ACCESS_KEY_ID: 'test-key-id',
|
||||
AWS_SES_REGION: 'us-east-1',
|
||||
AWS_SES_SECRET_ACCESS_KEY: 'test-secret',
|
||||
SES_CONFIGURATION_SET: 'test-config-set',
|
||||
SES_CONFIGURATION_SET_NO_TRACKING: 'test-no-tracking-set',
|
||||
TRACKING_TOGGLE_ENABLED: true,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock SES service (default behavior for most tests)
|
||||
vi.mock('../SESService', () => ({
|
||||
sendRawEmail: vi.fn(),
|
||||
}));
|
||||
@@ -801,5 +822,204 @@ describe('EmailService', () => {
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept inline attachment with contentId', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
from: '[email protected]',
|
||||
subject: 'Inline Image',
|
||||
body: '<img src="cid:logo" />',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'logo.png',
|
||||
content: Buffer.from('image').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
contentId: 'logo',
|
||||
disposition: 'inline',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const attachment = result.data.attachments![0];
|
||||
expect(attachment.contentId).toBe('logo');
|
||||
expect(attachment.disposition).toBe('inline');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject contentId exceeding 255 chars', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'image.png',
|
||||
content: Buffer.from('content').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
contentId: 'a'.repeat(256),
|
||||
disposition: 'inline',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject invalid disposition', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'image.png',
|
||||
content: Buffer.from('content').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
disposition: 'invalid-disposition',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// SES MIME BOUNDARY STRUCTURE
|
||||
// ========================================
|
||||
// These tests verify the raw MIME assembly logic inside sendRawEmail.
|
||||
// They need the REAL sendRawEmail (not the mock above), so we mock
|
||||
// at the AWS SDK level instead.
|
||||
|
||||
describe('SES MIME Boundary Structure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should correctly structure MIME boundaries for mixed content (attachments)', async () => {
|
||||
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||
|
||||
const params = {
|
||||
from: {name: 'Sender', email: '[email protected]'},
|
||||
to: ['[email protected]'],
|
||||
content: {subject: 'Test Subject', html: '<p>Hello world</p>'},
|
||||
attachments: [
|
||||
{
|
||||
filename: 'test.txt',
|
||||
content: 'SGVsbG8=',
|
||||
contentType: 'text/plain',
|
||||
disposition: 'attachment' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await realSendRawEmail(params);
|
||||
|
||||
expect(ses.sendRawEmail).toHaveBeenCalled();
|
||||
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||
|
||||
// Verify boundary hierarchy: Mixed -> Alternative
|
||||
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
|
||||
expect(rawMessage).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
|
||||
|
||||
const mixedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
|
||||
const mixedBoundary = mixedBoundaryMatch ? mixedBoundaryMatch[1] : '';
|
||||
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/alternative`);
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}--`);
|
||||
});
|
||||
|
||||
it('should correctly structure MIME boundaries for related content (inline images)', async () => {
|
||||
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||
|
||||
const params = {
|
||||
from: {name: 'Sender', email: '[email protected]'},
|
||||
to: ['[email protected]'],
|
||||
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
|
||||
attachments: [
|
||||
{
|
||||
filename: 'image.png',
|
||||
content:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
contentType: 'image/png',
|
||||
contentId: 'image1',
|
||||
disposition: 'inline' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await realSendRawEmail(params);
|
||||
|
||||
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||
|
||||
// Verify boundary hierarchy: Related -> Alternative
|
||||
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/related; boundary="([^"]+)"/s);
|
||||
|
||||
const relatedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
|
||||
const relatedBoundary = relatedBoundaryMatch ? relatedBoundaryMatch[1] : '';
|
||||
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
|
||||
expect(rawMessage).toContain(`Content-Disposition: inline; filename="image.png"`);
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}--`);
|
||||
});
|
||||
|
||||
it('should correctly nest mixed > related > alternative boundaries', async () => {
|
||||
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||
|
||||
const params = {
|
||||
from: {name: 'Sender', email: '[email protected]'},
|
||||
to: ['[email protected]'],
|
||||
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
|
||||
attachments: [
|
||||
{
|
||||
filename: 'test.txt',
|
||||
content: 'SGVsbG8=',
|
||||
contentType: 'text/plain',
|
||||
disposition: 'attachment' as const,
|
||||
},
|
||||
{
|
||||
filename: 'image.png',
|
||||
content:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
contentType: 'image/png',
|
||||
contentId: 'image1',
|
||||
disposition: 'inline' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await realSendRawEmail(params);
|
||||
|
||||
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||
|
||||
// Root should be mixed
|
||||
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
|
||||
|
||||
const mixedMatch = rawMessage.match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/);
|
||||
const mixedBoundary = mixedMatch ? mixedMatch[1] : 'NOT_FOUND_MIXED';
|
||||
|
||||
// Within mixed, we should find related
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/related`);
|
||||
|
||||
const relatedMatch = rawMessage.match(/Content-Type: multipart\/related; boundary="([^"]+)"/);
|
||||
const relatedBoundary = relatedMatch ? relatedMatch[1] : 'NOT_FOUND_RELATED';
|
||||
|
||||
// Within related, we should find alternative
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
|
||||
|
||||
const altMatch = rawMessage.match(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
|
||||
const altBoundary = altMatch ? altMatch[1] : 'NOT_FOUND_ALT';
|
||||
|
||||
// Verify all closing boundaries exist
|
||||
expect(rawMessage).toContain(`--${altBoundary}--`);
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}--`);
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}--`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,11 @@ export default function Footer() {
|
||||
Terms
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/dpa'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
DPA
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
import {NextSeo} from 'next-seo';
|
||||
import React from 'react';
|
||||
import {Navbar} from '../components';
|
||||
|
||||
export default function DataProcessingAgreement() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk Data Processing Agreement (DPA) | GDPR Compliance"
|
||||
description="Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights."
|
||||
openGraph={{
|
||||
title: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||
description:
|
||||
'Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights.',
|
||||
}}
|
||||
additionalMetaTags={[
|
||||
{
|
||||
property: 'title',
|
||||
content: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="px-8 pb-32 sm:px-12 md:px-16 lg:px-20 xl:px-24 2xl:px-56">
|
||||
<Navbar />
|
||||
|
||||
<div className="mx-auto max-w-4xl py-16">
|
||||
{/* Hero */}
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-neutral-900">Data Processing Agreement</h1>
|
||||
<p className="mt-2 text-sm text-neutral-600">Last Updated: February 18, 2026</p>
|
||||
<p className="mt-4 text-base leading-relaxed text-neutral-700">
|
||||
This Data Processing Agreement ("DPA") forms part of the Terms of Service between you ("Customer", "Data
|
||||
Controller") and Plunk ("Processor", "we", "us") and governs the processing of Personal Data in
|
||||
accordance with GDPR requirements.
|
||||
</p>
|
||||
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="text-sm leading-relaxed text-blue-900">
|
||||
<strong className="font-semibold">GDPR Requirement:</strong> This DPA is required under Article 28 of
|
||||
the GDPR. By using Plunk's hosted service, you accept and agree to the terms of this DPA.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table of Contents */}
|
||||
<nav className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-neutral-900">Contents</h2>
|
||||
<ol className="space-y-2 text-sm text-neutral-700">
|
||||
<li>
|
||||
<a href="#definitions" className="text-blue-600 hover:underline">
|
||||
1. Definitions
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#scope" className="text-blue-600 hover:underline">
|
||||
2. Scope & Applicability
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#processing-details" className="text-blue-600 hover:underline">
|
||||
3. Processing Details
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#obligations" className="text-blue-600 hover:underline">
|
||||
4. Data Processor Obligations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#security" className="text-blue-600 hover:underline">
|
||||
5. Security Measures
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#sub-processors" className="text-blue-600 hover:underline">
|
||||
6. Sub-Processors
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#data-subject-rights" className="text-blue-600 hover:underline">
|
||||
7. Data Subject Rights
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#data-breach" className="text-blue-600 hover:underline">
|
||||
8. Data Breach Notification
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#international-transfers" className="text-blue-600 hover:underline">
|
||||
9. International Data Transfers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#audits" className="text-blue-600 hover:underline">
|
||||
10. Audits & Compliance
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#deletion" className="text-blue-600 hover:underline">
|
||||
11. Data Deletion & Return
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#liability" className="text-blue-600 hover:underline">
|
||||
12. Liability & Indemnification
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#term" className="text-blue-600 hover:underline">
|
||||
13. Term & Termination
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Content Sections */}
|
||||
<div className="space-y-12">
|
||||
{/* Section 1 */}
|
||||
<section id="definitions" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">1. Definitions</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Terms used in this DPA have the meanings set forth in the GDPR. Specifically:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Personal Data"</strong> means any information
|
||||
relating to an identified or identifiable natural person processed via Plunk
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Data Controller"</strong> means the Customer who
|
||||
determines the purposes and means of processing Personal Data
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Data Processor"</strong> means Plunk, which
|
||||
processes Personal Data on behalf of the Data Controller
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Sub-processor"</strong> means any third party
|
||||
engaged by Plunk to process Personal Data
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Data Subject"</strong> means the individuals
|
||||
whose Personal Data is processed (your contacts/subscribers)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"GDPR"</strong> means Regulation (EU) 2016/679
|
||||
(General Data Protection Regulation)
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 2 */}
|
||||
<section id="scope" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">2. Scope & Applicability</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Hosted Service Only</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
This DPA applies ONLY to customers using Plunk's hosted service (useplunk.com). Self-hosted
|
||||
deployments are NOT covered by this DPA - you are solely responsible for GDPR compliance when
|
||||
self-hosting.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Agreement Hierarchy</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
This DPA supplements the Plunk Terms of Service and Privacy Policy. In case of conflict regarding data
|
||||
processing:
|
||||
</p>
|
||||
<ol className="mb-4 ml-6 list-decimal space-y-2 text-neutral-700">
|
||||
<li>This DPA takes precedence</li>
|
||||
<li>Then the Terms of Service</li>
|
||||
<li>Then the Privacy Policy</li>
|
||||
</ol>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Acceptance</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
By using Plunk's hosted service, you acknowledge that you have read, understood, and agree to be bound
|
||||
by this DPA. This constitutes a legally binding agreement between Customer and Plunk.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 3 */}
|
||||
<section id="processing-details" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">3. Processing Details</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
As required by GDPR Article 28(3), the following details describe the processing activities:
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Subject Matter</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Processing of Personal Data necessary to provide email automation, transactional email, marketing
|
||||
campaigns, and workflow automation services.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Duration</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Processing occurs for the duration of your Plunk subscription/account, plus 30 days for API logs
|
||||
(which are automatically deleted), and until you request account deletion.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Nature & Purpose</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Storing and managing contact databases</li>
|
||||
<li>Sending transactional, marketing, and workflow-triggered emails</li>
|
||||
<li>Tracking email delivery, opens, clicks, bounces, and complaints (per your settings)</li>
|
||||
<li>Managing email templates and automation workflows</li>
|
||||
<li>Processing email events and webhooks</li>
|
||||
<li>Providing analytics and reporting</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Type of Personal Data</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Email addresses (required)</li>
|
||||
<li>Names and custom contact fields (optional, Customer-defined)</li>
|
||||
<li>Email content (subject lines, message bodies, attachments)</li>
|
||||
<li>Subscription status and preferences</li>
|
||||
<li>Email activity data (opens, clicks, bounces, complaints)</li>
|
||||
<li>Timestamps and metadata</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Categories of Data Subjects</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Customer's email subscribers and contacts</li>
|
||||
<li>Recipients of transactional emails</li>
|
||||
<li>Marketing campaign recipients</li>
|
||||
<li>Workflow automation recipients</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 4 */}
|
||||
<section id="obligations" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">4. Data Processor Obligations</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk commits to the following obligations as Data Processor:
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Processing Instructions</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
Process Personal Data only on documented instructions from Customer (via API, dashboard, etc.)
|
||||
</li>
|
||||
<li>Not process Personal Data for any other purpose without Customer's prior written consent</li>
|
||||
<li>Immediately inform Customer if instructions violate GDPR or other EU data protection laws</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Confidentiality</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Ensure persons authorized to process Personal Data are bound by confidentiality obligations</li>
|
||||
<li>Maintain confidentiality of all Personal Data processed via Plunk</li>
|
||||
<li>
|
||||
Not disclose Personal Data to third parties except as required by law or with Customer consent
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Cooperation</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Assist Customer in responding to Data Subject rights requests (see Section 7)</li>
|
||||
<li>
|
||||
Assist Customer in ensuring compliance with GDPR security, breach notification, and impact
|
||||
assessment obligations
|
||||
</li>
|
||||
<li>Provide information necessary to demonstrate compliance with Article 28</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 5 */}
|
||||
<section id="security" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">5. Security Measures</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
As required by GDPR Article 32, Plunk implements appropriate technical and organizational measures to
|
||||
ensure a level of security appropriate to the risk:
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Technical Safeguards</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Industry-standard password hashing (never stored in plaintext)</li>
|
||||
<li>HTTPS-only API access (TLS 1.2+)</li>
|
||||
<li>Database connections encrypted via TLS/SSL</li>
|
||||
<li>HttpOnly, secure cookies with appropriate SameSite settings</li>
|
||||
<li>Authentication tokens with limited expiration periods</li>
|
||||
<li>Rate limiting to prevent brute force attacks</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Organizational Safeguards</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Access controls limiting personnel access to Personal Data</li>
|
||||
<li>Automated bounce and complaint rate monitoring</li>
|
||||
<li>Regular security updates and patching</li>
|
||||
<li>Data segregation by project (multi-tenancy with isolation)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data Residency</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Primary data storage: EU/EEA (Hetzner infrastructure)</li>
|
||||
<li>Database and file storage remain within EU/EEA</li>
|
||||
<li>Email delivery may transit non-EU regions (see Section 9)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 6 */}
|
||||
<section id="sub-processors" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">6. Sub-Processors</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Customer authorizes Plunk to engage the following sub-processors to process Personal Data:
|
||||
</p>
|
||||
|
||||
<div className="mb-6 overflow-x-auto">
|
||||
<table className="min-w-full border border-neutral-300">
|
||||
<thead className="bg-neutral-50">
|
||||
<tr>
|
||||
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||
Sub-Processor
|
||||
</th>
|
||||
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||
Service
|
||||
</th>
|
||||
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||
Location
|
||||
</th>
|
||||
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||
Purpose
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
<tr>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Amazon Web Services (AWS SES)
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Email Delivery</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Global (data in transit only)
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Sending emails to recipients
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Stripe, Inc.</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Payment Processing
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
USA (PCI-DSS compliant)
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Billing for paid accounts
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Hetzner Online GmbH
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Infrastructure Hosting
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
EU/EEA (Germany)
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Database and application hosting
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Sub-Processor Obligations</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>All sub-processors are bound by data protection obligations equivalent to this DPA</li>
|
||||
<li>Plunk remains fully liable to Customer for sub-processor performance</li>
|
||||
<li>Sub-processors have executed Data Processing Agreements with Plunk</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Changes to Sub-Processors</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Plunk will provide 30 days advance notice of new or replacement sub-processors via email</li>
|
||||
<li>Notice will be sent to the email address associated with your account</li>
|
||||
<li>
|
||||
If you object on reasonable grounds related to data protection, you may terminate your account
|
||||
within 30 days
|
||||
</li>
|
||||
<li>Updated sub-processor list will be maintained on this page (check "Last Updated" date)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 7 */}
|
||||
<section id="data-subject-rights" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">7. Data Subject Rights</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk will assist Customer in fulfilling Data Subject rights requests under GDPR Articles 15-22:
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Self-Service via API</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Customer can fulfill most Data Subject requests independently via API:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Access (Art. 15):</strong> Retrieve contact data
|
||||
via GET /contacts/:id
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Rectification (Art. 16):</strong> Update contact
|
||||
data via PATCH /contacts/:id
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Erasure (Art. 17):</strong> Delete contact via
|
||||
DELETE /contacts/:id
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Restriction (Art. 18):</strong> Update
|
||||
subscription status to "unsubscribed"
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Portability (Art. 20):</strong> Use API to access
|
||||
data in JSON format
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Assistance from Plunk</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
If Customer cannot fulfill a request via API, contact [email protected]:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Provide Data Subject's email address and nature of request</li>
|
||||
<li>Plunk will respond within 10 business days with requested information or assistance</li>
|
||||
<li>
|
||||
Customer remains responsible for verifying Data Subject identity before disclosing information
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Email Activity Data</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Email opens, clicks, bounces, and complaints are linked to contact records</li>
|
||||
<li>Deleting a contact will cascade delete associated email activity</li>
|
||||
<li>API logs (non-Personal Data) are automatically deleted after 30 days</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 8 */}
|
||||
<section id="data-breach" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">8. Data Breach Notification</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Notification Obligation</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
In the event of a Personal Data breach affecting Customer data, Plunk will:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
Notify Customer without undue delay and within 72 hours of becoming aware of the breach (per GDPR
|
||||
Art. 33)
|
||||
</li>
|
||||
<li>Send notification to the primary email address associated with Customer's account</li>
|
||||
<li>Provide information to enable Customer to meet any GDPR breach reporting obligations</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Breach Information</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Notifications will include (to the extent known):
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Nature of the breach (what happened)</li>
|
||||
<li>Categories and approximate number of Data Subjects affected</li>
|
||||
<li>Categories and approximate number of Personal Data records affected</li>
|
||||
<li>Likely consequences of the breach</li>
|
||||
<li>Measures taken or proposed to address the breach and mitigate harm</li>
|
||||
<li>Contact point for more information ([email protected])</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibility</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Customer is responsible for notifying their supervisory authority if required by GDPR Art. 33</li>
|
||||
<li>Customer is responsible for notifying affected Data Subjects if required by GDPR Art. 34</li>
|
||||
<li>Plunk's breach notification to Customer does NOT constitute legal or compliance advice</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 9 */}
|
||||
<section id="international-transfers" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">9. International Data Transfers</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Primary Storage (EU/EEA)</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>All contact data, templates, workflows, and account data stored in EU/EEA (Hetzner, Germany)</li>
|
||||
<li>No routine transfers of stored Personal Data outside EU/EEA</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data in Transit (Email Delivery)</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
When emails are sent to recipients, Personal Data may transit through non-EU regions:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>AWS SES processes emails globally for delivery purposes</li>
|
||||
<li>Data is in transit only (not stored long-term outside EU/EEA)</li>
|
||||
<li>Protected by AWS Data Processing Agreement and Standard Contractual Clauses (SCCs)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Payment Data (Stripe)</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Payment information (credit cards) processed by Stripe (USA-based)</li>
|
||||
<li>Stripe is PCI-DSS Level 1 certified</li>
|
||||
<li>Protected by Stripe's Data Processing Agreement and Standard Contractual Clauses</li>
|
||||
<li>Plunk does NOT store credit card numbers (tokenized by Stripe)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Standard Contractual Clauses</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
All sub-processors that process Personal Data outside EU/EEA have executed Standard Contractual
|
||||
Clauses (SCCs) approved by the European Commission, providing appropriate safeguards for international
|
||||
transfers per GDPR Article 46.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 10 */}
|
||||
<section id="audits" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">10. Audits & Compliance</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Information Provision</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk will make available to Customer information necessary to demonstrate compliance with GDPR
|
||||
Article 28 obligations, including:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>This DPA (publicly available)</li>
|
||||
<li>Privacy Policy describing processing activities</li>
|
||||
<li>Sub-processor list (Section 6 above)</li>
|
||||
<li>Security measures documentation (available upon reasonable request)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Audit Rights</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Customer may audit Plunk's compliance with this DPA, subject to the following:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Audits limited to once per year unless required by supervisory authority</li>
|
||||
<li>Request must be submitted in writing to legal@useplunk.com with 30 days notice</li>
|
||||
<li>Audits must be conducted by independent third-party auditors bound by confidentiality</li>
|
||||
<li>Audits conducted during business hours with minimal disruption to operations</li>
|
||||
<li>Customer bears all costs of audit</li>
|
||||
<li>Audit scope limited to GDPR compliance (not general security assessments)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Alternative to Audits</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
In lieu of conducting a full audit, Customer may request and review sub-processor certifications and
|
||||
compliance documentation (SOC 2, ISO 27001, etc.) where available.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 11 */}
|
||||
<section id="deletion" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">11. Data Deletion & Return</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Account Deletion</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
When Customer deletes their Plunk account (via dashboard or by request):
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>All Personal Data is immediately deleted from production systems</li>
|
||||
<li>No grace period or recovery window (deletion is permanent)</li>
|
||||
<li>
|
||||
Customer should access or backup data via API before deletion (Plunk does NOT provide data export)
|
||||
</li>
|
||||
<li>Deletion includes: contacts, emails, templates, workflows, campaigns, and email activity</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Backup Retention</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Deleted data may remain in encrypted backups for up to 30 days for disaster recovery purposes</li>
|
||||
<li>Backup data is not accessible or restorable after account deletion</li>
|
||||
<li>Backups are automatically overwritten after retention period</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Legal Retention</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk may retain certain data if required by law (e.g., accounting records, fraud prevention):
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Billing records: Retained per tax/accounting laws (typically 7 years)</li>
|
||||
<li>Fraud/abuse records: Retained for security purposes (email addresses of suspended accounts)</li>
|
||||
<li>Legal requests: Data subject to legal holds retained as required by law</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">No Data Return</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk does NOT provide data export or return upon termination. Customer must retrieve data via API
|
||||
before deleting account. Once deleted, data cannot be recovered.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 12 */}
|
||||
<section id="liability" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">12. Liability & Indemnification</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibilities</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">Customer (Data Controller) is responsible for:</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
Ensuring lawful basis for processing under GDPR (consent, contract, legitimate interest, etc.)
|
||||
</li>
|
||||
<li>Obtaining necessary consents from Data Subjects before adding them to Plunk</li>
|
||||
<li>Providing privacy notices to Data Subjects per GDPR Article 13/14</li>
|
||||
<li>Complying with anti-spam laws (CAN-SPAM, CASL, GDPR, etc.)</li>
|
||||
<li>Verifying Data Subject identity before fulfilling rights requests</li>
|
||||
<li>Notifying supervisory authorities and Data Subjects of breaches where required</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Plunk's Liability</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Plunk is liable to Customer for compliance with this DPA and GDPR Article 28 obligations</li>
|
||||
<li>Plunk is liable for sub-processor acts/omissions to the same extent as its own acts</li>
|
||||
<li>
|
||||
Liability limitations in Terms of Service apply, except where prohibited by GDPR (particularly Art.
|
||||
82)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Indemnification</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
Customer indemnifies Plunk for claims arising from Customer's violation of GDPR or data protection
|
||||
laws
|
||||
</li>
|
||||
<li>
|
||||
Plunk indemnifies Customer for claims arising solely from Plunk's breach of this DPA or GDPR Article
|
||||
28
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">GDPR Fines</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Under GDPR Article 82(3), liability for damages is allocated between Controller and Processor based on
|
||||
fault. Each party is liable only for the damage caused by its own GDPR violation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 13 */}
|
||||
<section id="term" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">13. Term & Termination</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Effective Date</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
This DPA is effective as of the date you first use Plunk's hosted service and remains in effect for
|
||||
the duration of the Terms of Service.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Termination</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">This DPA terminates automatically when:</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Customer deletes their Plunk account</li>
|
||||
<li>Terms of Service are terminated</li>
|
||||
<li>All Personal Data has been deleted per Section 11</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Survival</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Obligations regarding confidentiality, data deletion, and liability survive termination to the extent
|
||||
necessary to fulfill their purpose.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">DPA Updates</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Plunk may update this DPA to reflect changes in law, sub-processors, or processing activities</li>
|
||||
<li>Material changes will be notified via email 30 days in advance</li>
|
||||
<li>Continued use of Plunk after changes constitutes acceptance</li>
|
||||
<li>Check "Last Updated" date at top of page for version tracking</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold text-neutral-900">Questions or Requests?</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
For questions about this DPA, data processing activities, or to exercise audit rights:
|
||||
</p>
|
||||
<p className="mb-2 leading-relaxed text-neutral-700">
|
||||
<strong className="font-semibold text-neutral-900">Email:</strong> legal@useplunk.com
|
||||
</p>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
<strong className="font-semibold text-neutral-900">Response Time:</strong> Within 10 business days
|
||||
</p>
|
||||
<p className="text-sm text-neutral-600">
|
||||
For Data Subject rights requests, Customers should use the API (see Section 7) or contact
|
||||
legal@useplunk.com with Data Subject's email address and nature of request.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1041
-8658
File diff suppressed because it is too large
Load Diff
+655
-4064
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@
|
||||
"dependencies": {
|
||||
"@plunk/db": "*",
|
||||
"dotenv": "^17.2.3",
|
||||
"mailparser": "^3.7.1",
|
||||
"mailparser": "^3.9.3",
|
||||
"signale": "^1.4.0",
|
||||
"smtp-server": "^3.13.4"
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Inbox,
|
||||
MousePointerClick,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
@@ -99,6 +100,7 @@ function isEmailActivity(type: string): boolean {
|
||||
return [
|
||||
'email.sent',
|
||||
'email.delivered',
|
||||
'email.received',
|
||||
'email.opened',
|
||||
'email.clicked',
|
||||
'email.bounced',
|
||||
@@ -181,6 +183,19 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
},
|
||||
};
|
||||
|
||||
case 'email.received':
|
||||
return {
|
||||
icon: Inbox,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email received',
|
||||
description: typeof metadata.from === 'string' ? `From: ${metadata.from}` : 'Inbound email',
|
||||
badge: {
|
||||
label: 'Received',
|
||||
variant: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
case 'email.opened':
|
||||
return {
|
||||
icon: Eye,
|
||||
|
||||
@@ -78,6 +78,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
workflows: null,
|
||||
campaigns: null,
|
||||
transactional: null,
|
||||
inbound: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,6 +89,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
workflows: limitsData.workflows.limit,
|
||||
campaigns: limitsData.campaigns.limit,
|
||||
transactional: limitsData.transactional.limit,
|
||||
inbound: limitsData.inbound.limit,
|
||||
});
|
||||
}
|
||||
}, [limitsData, form]);
|
||||
@@ -121,6 +123,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
workflows: limitsData.workflows.limit,
|
||||
campaigns: limitsData.campaigns.limit,
|
||||
transactional: limitsData.transactional.limit,
|
||||
inbound: limitsData.inbound.limit,
|
||||
});
|
||||
}
|
||||
setIsEditing(false);
|
||||
@@ -217,6 +220,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
usage={limitsData.transactional}
|
||||
currency={limitsData.currency}
|
||||
/>
|
||||
<UsageDisplay category="Inbound" usage={limitsData.inbound} currency={limitsData.currency} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -322,6 +326,36 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="inbound"
|
||||
render={({field}) => {
|
||||
const estimatedCost =
|
||||
limitsData?.currency && field.value
|
||||
? formatEmailCost(Number(field.value), limitsData.currency)
|
||||
: null;
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Inbound Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum inbound emails per month. Leave empty for unlimited.
|
||||
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {Contact} from '@plunk/db';
|
||||
import type {CursorPaginatedResponse} from '@plunk/types';
|
||||
import {Input, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
|
||||
import {Check, ChevronsUpDown, MailCheck, MailX, Search, X} from 'lucide-react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface ContactPickerProps {
|
||||
/** Currently selected emails */
|
||||
selected: string[];
|
||||
/** Called when selection changes */
|
||||
onChange: (emails: string[]) => void;
|
||||
/** Emails already in the segment (shown as disabled) */
|
||||
existing?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable multi-select contact picker backed by the /contacts API.
|
||||
* Only fetches when the user types (safe for large contact lists).
|
||||
*/
|
||||
export function ContactPicker({
|
||||
selected,
|
||||
onChange,
|
||||
existing = [],
|
||||
placeholder = 'Search contacts...',
|
||||
}: ContactPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
// Only fetch when there's a search term — avoids loading all contacts on open
|
||||
const {data, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
|
||||
open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const contacts = data?.data ?? [];
|
||||
|
||||
const toggle = (email: string) => {
|
||||
if (selected.includes(email)) {
|
||||
onChange(selected.filter(e => e !== email));
|
||||
} else {
|
||||
onChange([...selected, email]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 hover:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-0 transition-colors"
|
||||
>
|
||||
<span>{placeholder}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 opacity-40 shrink-0" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{width: 'var(--radix-popover-trigger-width)'}}
|
||||
align="start"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b border-neutral-200 px-3 py-2">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-neutral-400" />
|
||||
<Input
|
||||
placeholder="Type an email to search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[240px] overflow-y-auto p-1">
|
||||
{debouncedSearch.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
|
||||
) : isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Searching...</p>
|
||||
) : contacts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">No contacts found</p>
|
||||
) : (
|
||||
contacts.map(contact => {
|
||||
const isSelected = selected.includes(contact.email);
|
||||
const isExisting = existing.includes(contact.email);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={contact.id}
|
||||
type="button"
|
||||
disabled={isExisting}
|
||||
onClick={() => toggle(contact.email)}
|
||||
className="w-full flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed text-left transition-colors"
|
||||
>
|
||||
{contact.subscribed ? (
|
||||
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<MailX className="h-4 w-4 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
|
||||
{isExisting && (
|
||||
<span className="text-xs text-neutral-400 shrink-0">already member</span>
|
||||
)}
|
||||
{isSelected && !isExisting && (
|
||||
<Check className="h-4 w-4 text-neutral-900 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Selected chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selected.map(email => (
|
||||
<span
|
||||
key={email}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-100 border border-neutral-200 pl-3 pr-1.5 py-1 text-sm text-neutral-800"
|
||||
>
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter(e => e !== email))}
|
||||
className="rounded-full p-0.5 hover:bg-neutral-300 transition-colors"
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -74,6 +74,11 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
const mobileProjectMenuRef = useRef<HTMLDivElement>(null);
|
||||
const mobileUserMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sort projects alphabetically by name
|
||||
const sortedProjects = useMemo(() => {
|
||||
return [...availableProjects].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [availableProjects]);
|
||||
|
||||
// Handle click outside for project menu
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -179,8 +184,8 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
|
||||
{/* Project Dropdown */}
|
||||
{showProjectMenu && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||
{availableProjects.map(project => (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1 max-h-[400px] overflow-y-auto min-w-full w-max">
|
||||
{sortedProjects.map(project => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={e => {
|
||||
@@ -189,14 +194,14 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
setActiveProject(project);
|
||||
setShowProjectMenu(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{project.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
||||
{activeProject?.id === project.id && (
|
||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
@@ -204,7 +209,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
<Link
|
||||
href="/projects/create"
|
||||
onClick={() => setShowProjectMenu(false)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Create project</span>
|
||||
|
||||
@@ -226,6 +226,22 @@ function CustomNode({
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
{/* Action buttons - shown on hover */}
|
||||
{showActions && data.type === 'TRIGGER' && (
|
||||
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
||||
<Button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
data.onEdit?.();
|
||||
}}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7 shadow-md"
|
||||
title="Edit trigger settings"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showActions && data.type !== 'TRIGGER' && (
|
||||
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
||||
<Button
|
||||
@@ -322,6 +338,14 @@ function CustomNode({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'TRIGGER' && data.config?.eventName && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<Lightbulb className="h-3 w-3" />
|
||||
<span className="truncate">{data.config.eventName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'WEBHOOK' && data.config?.url && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
@@ -375,11 +399,23 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
||||
|
||||
// Define handlers before they are used in useMemo
|
||||
const handleEditStep = useCallback((stepId: string) => {
|
||||
// This will be handled by the parent component
|
||||
const handleEditStep = useCallback(
|
||||
(stepId: string) => {
|
||||
// Check if this is a TRIGGER step
|
||||
const step = steps.find(s => s.id === stepId);
|
||||
|
||||
if (step?.type === 'TRIGGER') {
|
||||
// For TRIGGER steps, open workflow settings instead
|
||||
const event = new CustomEvent('workflow-open-settings');
|
||||
window.dispatchEvent(event);
|
||||
} else {
|
||||
// For other steps, open step editor
|
||||
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
|
||||
window.dispatchEvent(event);
|
||||
}, []);
|
||||
}
|
||||
},
|
||||
[steps],
|
||||
);
|
||||
|
||||
const handleDeleteStepClick = useCallback((stepId: string) => {
|
||||
setStepToDelete(stepId);
|
||||
|
||||
@@ -119,10 +119,12 @@ export default function ActivityPage() {
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All Activity Types</SelectItem>
|
||||
<SelectItem value="event.triggered">Events</SelectItem>
|
||||
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced,email.complaint">
|
||||
<SelectItem value="email.sent,email.delivered,email.received,email.opened,email.clicked,email.bounced,email.complaint">
|
||||
Emails
|
||||
</SelectItem>
|
||||
<SelectItem value="email.sent">Emails Sent</SelectItem>
|
||||
<SelectItem value="email.delivered">Emails Delivered</SelectItem>
|
||||
<SelectItem value="email.received">Emails Received</SelectItem>
|
||||
<SelectItem value="email.opened">Emails Opened</SelectItem>
|
||||
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
|
||||
<SelectItem value="email.bounced">Emails Bounced</SelectItem>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {Contact, Segment} from '@plunk/db';
|
||||
import type {PaginatedResponse} from '@plunk/types';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
||||
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, UserMinus, Users} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
@@ -22,8 +22,12 @@ import useSWR from 'swr';
|
||||
import type {FilterCondition} from '@plunk/types';
|
||||
import {SegmentSchemas} from '@plunk/shared';
|
||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||
import {ContactPicker} from '../../components/ContactPicker';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||
type SegmentWithType = Segment & {type: SegmentType};
|
||||
|
||||
// Count total filters in a condition (recursive)
|
||||
function countFilters(condition: FilterCondition): number {
|
||||
let count = 0;
|
||||
@@ -40,11 +44,13 @@ export default function SegmentDetailPage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
|
||||
const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null);
|
||||
const {data: segment, mutate, isLoading} = useSWR<SegmentWithType>(id ? `/segments/${id}` : null);
|
||||
const [contactsPage, setContactsPage] = useState(1);
|
||||
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedResponse<Contact>>(
|
||||
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
|
||||
);
|
||||
const {
|
||||
data: contactsData,
|
||||
isLoading: isLoadingContacts,
|
||||
mutate: mutateContacts,
|
||||
} = useSWR<PaginatedResponse<Contact>>(id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -57,6 +63,11 @@ export default function SegmentDetailPage() {
|
||||
const [isComputing, setIsComputing] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Static segment member management
|
||||
const [pickedEmails, setPickedEmails] = useState<string[]>([]);
|
||||
const [isAddingMembers, setIsAddingMembers] = useState(false);
|
||||
const [removingEmail, setRemovingEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (segment) {
|
||||
setName(segment.name);
|
||||
@@ -79,7 +90,7 @@ export default function SegmentDetailPage() {
|
||||
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
condition,
|
||||
...(segment?.type !== 'STATIC' && {condition}),
|
||||
trackMembership,
|
||||
});
|
||||
toast.success('Segment updated successfully');
|
||||
@@ -112,6 +123,47 @@ export default function SegmentDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMembers = async () => {
|
||||
if (pickedEmails.length === 0) {
|
||||
toast.error('Select at least one contact');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingMembers(true);
|
||||
try {
|
||||
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||
'POST',
|
||||
`/segments/${id}/members`,
|
||||
{emails: pickedEmails},
|
||||
);
|
||||
|
||||
toast.success(`Added ${result.added} contact${result.added !== 1 ? 's' : ''} to segment`);
|
||||
setPickedEmails([]);
|
||||
void mutate();
|
||||
void mutateContacts();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to add contacts');
|
||||
} finally {
|
||||
setIsAddingMembers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (email: string) => {
|
||||
setRemovingEmail(email);
|
||||
try {
|
||||
await network.fetch<{removed: number}, typeof SegmentSchemas.members>('DELETE', `/segments/${id}/members`, {
|
||||
emails: [email],
|
||||
});
|
||||
toast.success(`Removed ${email} from segment`);
|
||||
void mutate();
|
||||
void mutateContacts();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to remove contact');
|
||||
} finally {
|
||||
setRemovingEmail(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await network.fetch('DELETE', `/segments/${id}`);
|
||||
@@ -166,6 +218,8 @@ export default function SegmentDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const isStatic = segment.type === 'STATIC';
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
@@ -178,8 +232,17 @@ export default function SegmentDetailPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
|
||||
<p className="text-neutral-500 mt-1">{segment.description}</p>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
isStatic ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isStatic ? 'Static' : 'Dynamic'}
|
||||
</span>
|
||||
</div>
|
||||
{segment.description && <p className="text-neutral-500 mt-1">{segment.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
||||
@@ -244,12 +307,14 @@ export default function SegmentDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filter Builder */}
|
||||
{/* Filter Builder (DYNAMIC only) */}
|
||||
{!isStatic && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end">
|
||||
@@ -260,15 +325,44 @@ export default function SegmentDetailPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Static member management */}
|
||||
{isStatic && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Members</CardTitle>
|
||||
<CardDescription>Search and select contacts to add to this segment</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ContactPicker
|
||||
selected={pickedEmails}
|
||||
onChange={setPickedEmails}
|
||||
existing={contactsData?.data.map(c => c.email) ?? []}
|
||||
placeholder="Search contacts to add..."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddMembers}
|
||||
disabled={isAddingMembers || pickedEmails.length === 0}
|
||||
>
|
||||
{isAddingMembers
|
||||
? 'Adding...'
|
||||
: `Add ${pickedEmails.length > 0 ? pickedEmails.length : ''} Contact${pickedEmails.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Contacts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Matching Contacts</CardTitle>
|
||||
<CardDescription>Contacts that match this segment's filters</CardDescription>
|
||||
<CardTitle>{isStatic ? 'Members' : 'Matching Contacts'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isStatic ? 'Contacts in this static segment' : "Contacts that match this segment's filters"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{trackMembership && (
|
||||
{!isStatic && trackMembership && (
|
||||
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
||||
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
||||
{isComputing ? 'Computing...' : 'Recompute'}
|
||||
@@ -284,7 +378,9 @@ export default function SegmentDetailPage() {
|
||||
) : contactsData?.data.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||
<p className="text-neutral-500">No contacts match this segment</p>
|
||||
<p className="text-neutral-500">
|
||||
{isStatic ? 'No members in this segment yet' : 'No contacts match this segment'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -299,11 +395,24 @@ export default function SegmentDetailPage() {
|
||||
)}
|
||||
<span className="text-sm font-medium">{contact.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/contacts/${contact.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
{isStatic && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveMember(contact.email)}
|
||||
disabled={removingEmail === contact.email}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -355,6 +464,8 @@ export default function SegmentDetailPage() {
|
||||
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
|
||||
</div>
|
||||
|
||||
{!isStatic && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-neutral-500" />
|
||||
@@ -373,6 +484,8 @@ export default function SegmentDetailPage() {
|
||||
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -146,7 +146,18 @@ export default function SegmentsPage() {
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-lg">{segment.name}</CardTitle>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
(segment as unknown as {type: string}).type === 'STATIC'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{(segment as unknown as {type: string}).type === 'STATIC' ? 'Static' : 'Dynamic'}
|
||||
</span>
|
||||
</div>
|
||||
{segment.description && (
|
||||
<CardDescription className="mt-1">{segment.description}</CardDescription>
|
||||
)}
|
||||
@@ -170,7 +181,9 @@ export default function SegmentsPage() {
|
||||
<span className="text-sm text-neutral-600">Filters</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{countFiltersInCondition(segment.condition)}
|
||||
{(segment as unknown as {type: string}).type === 'STATIC'
|
||||
? '—'
|
||||
: countFiltersInCondition(segment.condition)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||
import {ContactPicker} from '../../components/ContactPicker';
|
||||
import {network} from '../../lib/network';
|
||||
import {ArrowLeft, Save} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
@@ -12,10 +13,13 @@ import type {FilterCondition} from '@plunk/types';
|
||||
import type {Segment} from '@plunk/db';
|
||||
import {SegmentSchemas} from '@plunk/shared';
|
||||
|
||||
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||
|
||||
export default function NewSegmentPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [segmentType, setSegmentType] = useState<SegmentType>('DYNAMIC');
|
||||
const [trackMembership, setTrackMembership] = useState(false);
|
||||
const [condition, setCondition] = useState<FilterCondition>({
|
||||
logic: 'AND',
|
||||
@@ -25,6 +29,7 @@ export default function NewSegmentPage() {
|
||||
},
|
||||
],
|
||||
});
|
||||
const [selectedContacts, setSelectedContacts] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -32,13 +37,33 @@ export default function NewSegmentPage() {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||
const segment = await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||
name,
|
||||
description: description || undefined,
|
||||
condition,
|
||||
type: segmentType,
|
||||
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||
trackMembership,
|
||||
});
|
||||
|
||||
// For static segments with pre-selected contacts, add them now
|
||||
if (segmentType === 'STATIC' && selectedContacts.length > 0) {
|
||||
try {
|
||||
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||
'POST',
|
||||
`/segments/${segment.id}/members`,
|
||||
{emails: selectedContacts},
|
||||
);
|
||||
toast.success(
|
||||
`Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''}`,
|
||||
);
|
||||
} catch {
|
||||
// Segment was created; just warn about members
|
||||
toast.warning('Segment created, but some contacts could not be added');
|
||||
}
|
||||
} else {
|
||||
toast.success('Segment created successfully');
|
||||
}
|
||||
|
||||
void router.push('/segments');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
||||
@@ -61,10 +86,40 @@ export default function NewSegmentPage() {
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
|
||||
<p className="text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
|
||||
<p className="text-neutral-500 mt-1">
|
||||
{segmentType === 'DYNAMIC'
|
||||
? 'Build complex audience filters with AND/OR logic'
|
||||
: 'Manually curate a list of contacts'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type Toggle */}
|
||||
<div className="flex gap-2 p-1 bg-neutral-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSegmentType('DYNAMIC')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
segmentType === 'DYNAMIC'
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-600 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
Dynamic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSegmentType('STATIC')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
segmentType === 'STATIC'
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-600 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
Static
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
@@ -118,12 +173,30 @@ export default function NewSegmentPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filter Builder */}
|
||||
{/* Filter Builder or Contact Picker */}
|
||||
{segmentType === 'DYNAMIC' ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Initial Members</CardTitle>
|
||||
<CardDescription>
|
||||
Optionally add contacts now — you can always add or remove members later
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ContactPicker
|
||||
selected={selectedContacts}
|
||||
onChange={setSelectedContacts}
|
||||
placeholder="Search and select contacts..."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
@@ -318,7 +318,12 @@ export default function WorkflowEditorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSettings = async (data: {name: string; description?: string}) => {
|
||||
const handleUpdateSettings = async (data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
allowReentry?: boolean;
|
||||
triggerConfig?: {eventName: string};
|
||||
}) => {
|
||||
try {
|
||||
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${id}`, data);
|
||||
toast.success('Workflow updated successfully');
|
||||
@@ -352,9 +357,15 @@ export default function WorkflowEditorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSettingsEvent = () => {
|
||||
setShowSettingsDialog(true);
|
||||
};
|
||||
|
||||
window.addEventListener('workflow-edit-step', handleEditStepEvent);
|
||||
window.addEventListener('workflow-open-settings', handleOpenSettingsEvent);
|
||||
return () => {
|
||||
window.removeEventListener('workflow-edit-step', handleEditStepEvent);
|
||||
window.removeEventListener('workflow-open-settings', handleOpenSettingsEvent);
|
||||
};
|
||||
}, [workflow]);
|
||||
|
||||
@@ -741,21 +752,49 @@ interface SettingsDialogProps {
|
||||
workflow: Workflow;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (data: {name: string; description?: string; allowReentry?: boolean}) => Promise<void>;
|
||||
onSave: (data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
allowReentry?: boolean;
|
||||
triggerConfig?: {eventName: string};
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogProps) {
|
||||
const triggerConfig = workflow.triggerConfig as {eventName?: string} | null;
|
||||
const [name, setName] = useState(workflow.name);
|
||||
const [description, setDescription] = useState(workflow.description ?? '');
|
||||
const [allowReentry, setAllowReentry] = useState(workflow.allowReentry ?? false);
|
||||
const [eventName, setEventName] = useState(triggerConfig?.eventName ?? '');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Sync state when workflow changes or dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(workflow.name);
|
||||
setDescription(workflow.description ?? '');
|
||||
setAllowReentry(workflow.allowReentry ?? false);
|
||||
const config = workflow.triggerConfig as {eventName?: string} | null;
|
||||
setEventName(config?.eventName ?? '');
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
// Fetch available event names
|
||||
const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSave({name, description: description || undefined, allowReentry});
|
||||
await onSave({
|
||||
name,
|
||||
description: description || undefined,
|
||||
allowReentry,
|
||||
triggerConfig: eventName.trim() ? {eventName: eventName.trim()} : undefined,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -784,6 +823,36 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="eventName">Trigger Event *</Label>
|
||||
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
|
||||
<Select value={eventName} onValueChange={setEventName} required>
|
||||
<SelectTrigger id="eventName">
|
||||
<SelectValue placeholder="Select an event" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventNamesData.eventNames.map(name => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id="eventName"
|
||||
type="text"
|
||||
value={eventName}
|
||||
onChange={e => setEventName(e.target.value)}
|
||||
placeholder="e.g., contact.created, email.opened"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
The event that triggers this workflow to start for a contact
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||
<Switch id="allowReentry" checked={allowReentry} onCheckedChange={setAllowReentry} />
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -244,11 +244,13 @@ For a complete list of error codes and troubleshooting guidance, see the [Error
|
||||
### Segments
|
||||
|
||||
**GET /segments** — List all segments
|
||||
**POST /segments** — Create new segment
|
||||
**POST /segments** — Create new segment (Dynamic or Static)
|
||||
**GET /segments/:id** — Get segment details
|
||||
**PATCH /segments/:id** — Update segment
|
||||
**DELETE /segments/:id** — Delete segment
|
||||
**GET /segments/:id/contacts** — List segment members
|
||||
**POST /segments/:id/members** — Add contacts to a static segment (by email)
|
||||
**DELETE /segments/:id/members** — Remove contacts from a static segment (by email)
|
||||
|
||||
### Workflows
|
||||
|
||||
|
||||
@@ -1,20 +1,56 @@
|
||||
---
|
||||
title: Segments
|
||||
description: Group and target your contacts with dynamic segments
|
||||
description: Group and target your contacts with dynamic or static segments
|
||||
icon: Layers
|
||||
---
|
||||
|
||||
Segments in Plunk allow you to create dynamic groups of contacts based on [contact data](/concepts/contacts#contact-data) and events.
|
||||
Segments let you create named groups of contacts that can be targeted in campaigns and used as triggers in workflows. There are two types: **Dynamic** and **Static**.
|
||||
|
||||
## Creating segments
|
||||
Segments can be created through the Plunk dashboard. When creating a segment, you can define multiple conditions that contacts must meet to be included in the segment. Plunk will automatically update the segment membership as contact data and events change.
|
||||
## Dynamic segments
|
||||
|
||||
### Track Membership Changes
|
||||
Plunk will automatically add and remove contacts from segments as their data and events change. When toggling on `Track membership changes`, Plunk will send an event to your webhook each time a contact is added or removed from the segment.
|
||||
Dynamic segments evaluate a set of filter conditions against your contacts in real time. Membership is kept up to date automatically as contact data and events change — no manual work required.
|
||||
|
||||
These events will have the following name `segment.trial-users.entry` or `segment.trial-users.exit`, where `trial-users` is the segment's name.
|
||||
You can filter on:
|
||||
- Contact fields (`email`, `subscribed`, custom data fields like `data.plan`)
|
||||
- Contact dates (`createdAt`, `updatedAt`)
|
||||
- Custom events (`event.signed_up`, `event.purchased`, …)
|
||||
- Email activity (`email.opened`, `email.clicked`, `email.bounced`, …)
|
||||
|
||||
Conditions can be combined with `AND`/`OR` logic and nested into groups for complex rules.
|
||||
|
||||
## Static segments
|
||||
|
||||
Static segments are manually curated lists. Membership does not change automatically — you decide exactly who is in the segment. This is useful for things like beta testers, event attendees, or any group imported from an external source.
|
||||
|
||||
## Creating a segment
|
||||
|
||||
Go to **Segments** in the dashboard and click **Create Segment**. Use the toggle at the top to choose **Dynamic** or **Static**.
|
||||
|
||||
**For dynamic segments**, use the filter builder to define your conditions. Plunk will show you a live count of matching contacts.
|
||||
|
||||
**For static segments**, you can optionally add initial members right away using the contact search. Start typing an email address and select contacts from the list — selected contacts appear as chips you can remove before saving.
|
||||
|
||||
## Managing static segment members
|
||||
|
||||
Open a static segment and use the **Add Members** search to find and select contacts. The search looks up contacts already in your project, so you can't accidentally add someone who doesn't exist. Contacts already in the segment are greyed out.
|
||||
|
||||
To remove a member, click the remove button on their row in the members list.
|
||||
|
||||
## Track membership changes
|
||||
|
||||
Both segment types support **Track membership changes**. When enabled, Plunk fires a webhook event each time a contact enters or leaves the segment:
|
||||
|
||||
- `segment.trial-users.entry` — contact joined the segment
|
||||
- `segment.trial-users.exit` — contact left the segment
|
||||
|
||||
Where `trial-users` is derived from the segment name. See [Webhooks](/guides/webhooks) for the full event payload.
|
||||
|
||||
## Using segments
|
||||
Segment can be used in various parts of Plunk, including:
|
||||
|
||||
Segments can be used in:
|
||||
- Targeting contacts in [email campaigns](/concepts/campaigns)
|
||||
- Triggering workflows in [marketing automation](/concepts/workflows)
|
||||
|
||||
## Managing members via API
|
||||
|
||||
If you need to manage static segment membership programmatically, use the `POST /segments/:id/members` and `DELETE /segments/:id/members` endpoints. See the [API reference](/api-reference/overview#segments) for details.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization", "webhooks"]
|
||||
"pages": ["list-hygiene", "verifying-domains", "receiving-emails", "tracking", "api-keys", "localization", "webhooks"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: Receiving emails
|
||||
description: Receive incoming emails at your verified domain and trigger automated workflows
|
||||
icon: Inbox
|
||||
---
|
||||
|
||||
Plunk can receive emails sent to your verified domain and turn them into `email.received` events. This allows you to build automated workflows that respond to inbound emails, such as support ticket systems, auto-responders, or email-based integrations.
|
||||
|
||||
## How it works
|
||||
|
||||
When someone sends an email to your verified domain (e.g., `[email protected]` or `[email protected]`), Plunk will:
|
||||
|
||||
1. Receive the email through AWS SES
|
||||
2. Automatically create or update a contact for the sender
|
||||
3. Trigger an `email.received` event that can start workflows
|
||||
4. Make the email metadata available to your workflow steps
|
||||
|
||||
The sender is automatically added to your contacts as a subscribed contact, allowing you to respond using Plunk's email sending capabilities.
|
||||
|
||||
## Setting up inbound email
|
||||
|
||||
<div className='fd-steps [&_h3]:fd-step'>
|
||||
|
||||
### Verify your domain
|
||||
|
||||
Before you can receive emails, your domain must be verified in Plunk. Follow the [verifying domains](/guides/verifying-domains) guide to set up the required DKIM, SPF, and MX records for sending.
|
||||
|
||||
### Add the inbound MX record
|
||||
|
||||
In your project settings, navigate to the **Domains** tab and expand your verified domain. You'll see an optional "Inbound Email" section with an MX record configuration:
|
||||
|
||||
- **Type**: MX
|
||||
- **Name**: `yourdomain.com` (your root domain)
|
||||
- **Value**: `10 inbound-smtp.eu-north-1.amazonaws.com`
|
||||
|
||||
Add this MX record to your domain's DNS settings. The priority value `10` ensures that inbound emails are routed to AWS SES for processing.
|
||||
|
||||
<Callout title="Multiple MX records" variant="warning">
|
||||
If you're already using MX records for another email service (like Google Workspace or Microsoft 365), adding this MX
|
||||
record may conflict. You can only have one primary email receiver per domain. Consider using a subdomain (e.g.,
|
||||
`mail.yourdomain.com`) if you need to maintain both services.
|
||||
</Callout>
|
||||
|
||||
### Wait for DNS propagation
|
||||
|
||||
DNS changes can take anywhere from a few minutes to 48 hours to fully propagate. You can verify the MX record is set correctly using:
|
||||
|
||||
```bash
|
||||
dig MX yourdomain.com
|
||||
```
|
||||
|
||||
You should see the AWS SES inbound endpoint in the response.
|
||||
|
||||
</div>
|
||||
|
||||
## Creating workflows with email.received
|
||||
|
||||
Once your MX record is configured, you can create workflows that respond to incoming emails.
|
||||
|
||||
### Event trigger
|
||||
|
||||
Create a new workflow and use `email.received` as the trigger event. This workflow will run every time an email is received at your domain.
|
||||
|
||||
### Available event data
|
||||
|
||||
The `email.received` event includes the following data that you can use in your workflow steps:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------------- | -------- | ---------------------------------------------- |
|
||||
| `messageId` | string | Unique identifier for the received message |
|
||||
| `from` | string | Email address of the sender |
|
||||
| `fromHeader` | string | Full "From" header including display name |
|
||||
| `to` | string | Primary recipient email address |
|
||||
| `subject` | string | Email subject line |
|
||||
| `timestamp` | string | ISO 8601 timestamp when the email was received |
|
||||
| `recipients` | string[] | All recipient email addresses |
|
||||
| `hasContent` | boolean | Whether the email body was captured |
|
||||
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
|
||||
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
|
||||
| `spfVerdict` | string | SPF authentication result |
|
||||
| `dkimVerdict` | string | DKIM authentication result |
|
||||
| `dmarcVerdict` | string | DMARC authentication result |
|
||||
| `processingTimeMillis` | number | Time taken to process the email |
|
||||
|
||||
You can access these fields in your workflow using variable syntax, for example: `{{event.subject}}` or `{{event.from}}`.
|
||||
|
||||
## Multi-project domains
|
||||
|
||||
If you have verified the same domain in multiple projects, incoming emails will be processed for **all projects** that have the domain verified. Each project will:
|
||||
|
||||
- Create/update the sender as a contact in that project
|
||||
- Trigger the `email.received` event in that project
|
||||
- Run any workflows configured for that event
|
||||
|
||||
This allows you to segment inbound email handling across different projects if needed.
|
||||
|
||||
## Limitations
|
||||
|
||||
<Callout title="Email body content" variant="warning">
|
||||
Currently, the email body content is not stored or made available in the event data. Only metadata (sender, subject,
|
||||
recipients, timestamps, and security verdicts) is captured. The `hasContent` field indicates whether body content was
|
||||
present, but the content itself is not accessible in workflows.
|
||||
</Callout>
|
||||
|
||||
- **Catch-all addresses**: Plunk receives emails sent to any address at your verified domain (e.g., `[email protected]`). You can use workflow conditions to route emails based on the `to` field.
|
||||
- **Attachments**: Email attachments are not currently captured or stored.
|
||||
- **Email size**: AWS SES has a maximum message size limit of 40 MB for inbound emails.
|
||||
|
||||
## Security considerations
|
||||
|
||||
Plunk captures several security verdicts for each incoming email:
|
||||
|
||||
- **SPF (Sender Policy Framework)**: Verifies the sender's mail server is authorized
|
||||
- **DKIM (DomainKeys Identified Mail)**: Validates the email hasn't been tampered with
|
||||
- **DMARC (Domain-based Message Authentication)**: Combines SPF and DKIM for additional validation
|
||||
- **Spam verdict**: AWS SES's spam detection result
|
||||
- **Virus verdict**: AWS SES's virus scanning result
|
||||
|
||||
You can use these verdicts in workflow conditions to automatically filter or quarantine suspicious emails before processing them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Emails not being received
|
||||
|
||||
1. **Check DNS propagation**: Verify the MX record is correctly set using `dig MX yourdomain.com`
|
||||
2. **Verify domain**: Ensure your domain is fully verified in Plunk (all DKIM, SPF, and MX records for sending)
|
||||
3. **Check workflow**: Create a simple test workflow with just an `email.received` trigger and a webhook to verify events are being generated
|
||||
4. **Check sender**: Try sending from a different email provider as some may cache DNS records
|
||||
|
||||
### Duplicate events
|
||||
|
||||
If you have the same domain verified in multiple projects, you will receive duplicate `email.received` events (one per project). This is expected behavior. Use project-specific workflows to handle this.
|
||||
|
||||
### Security verdicts failing
|
||||
|
||||
If incoming emails consistently show failing security verdicts:
|
||||
|
||||
- **SPF failures**: The sender's domain may not have SPF configured correctly
|
||||
- **DKIM failures**: The sender's domain may not have DKIM configured, or the email was forwarded/modified in transit
|
||||
- **DMARC failures**: The sender fails both SPF and DKIM checks
|
||||
|
||||
These are issues with the sender's configuration, not your Plunk setup. You can choose to process these emails anyway or filter them using workflow conditions.
|
||||
@@ -9,9 +9,78 @@ Verifying your domain is a requirement to send emails through Plunk. Domain veri
|
||||
## Verifying a domain
|
||||
|
||||
You can verify a domain by adding it in the domain tab of the project settings. Once added, Plunk will provide you with the necessary DNS records to add to your domain's DNS settings.
|
||||
|
||||
Once you have added the DNS records, it may take some time for the changes to propagate. You can check the verification status in the domain tab of the project settings.
|
||||
|
||||
### DNS Records
|
||||
- 3 CNAME records for DKIM (DomainKeys Identified Mail) to authenticate your emails.
|
||||
- 1 TXT record for SPF (Sender Policy Framework) to specify which mail servers are authorized
|
||||
- 1 MX record to handle bounces and feedback loops
|
||||
## DNS Records
|
||||
|
||||
Domain verification requires adding several DNS records to your domain. Each record serves a specific purpose in email authentication and delivery.
|
||||
|
||||
### DKIM Records (3 CNAME records)
|
||||
|
||||
**DomainKeys Identified Mail (DKIM)** adds a digital signature to your emails, proving they haven't been tampered with during transit.
|
||||
|
||||
You'll need to add **3 CNAME records** provided by Plunk. These records contain cryptographic keys that email receivers use to verify your emails are authentic.
|
||||
|
||||
**Why it matters:**
|
||||
|
||||
- Prevents email spoofing and tampering
|
||||
- Improves deliverability and inbox placement
|
||||
- Required by most email providers (Gmail, Outlook, etc.)
|
||||
|
||||
### SPF Record (1 TXT record)
|
||||
|
||||
**Sender Policy Framework (SPF)** specifies which mail servers are authorized to send emails on behalf of your domain.
|
||||
|
||||
You'll need to add **1 TXT record** that lists the authorized sending servers.
|
||||
|
||||
**Why it matters:**
|
||||
|
||||
- Prevents unauthorized servers from sending emails using your domain
|
||||
- Reduces the likelihood of your domain being used for spam
|
||||
- Works together with DKIM for complete authentication
|
||||
|
||||
### Bounce Handling (1 MX record)
|
||||
|
||||
This **MX record** allows Plunk to receive bounce notifications and spam complaints from email providers.
|
||||
|
||||
**Why it matters:**
|
||||
|
||||
- Automatically tracks which contacts have bounced or complained
|
||||
- Helps maintain your sender reputation
|
||||
- Prevents sending to invalid email addresses
|
||||
- Required for deliverability monitoring
|
||||
|
||||
<Callout title="Receiving inbound emails" variant="idea">
|
||||
The MX record above is for sending emails (handling bounces and complaints). If you want to **receive** emails at your
|
||||
domain and trigger workflows, see the [receiving emails](/guides/receiving-emails) guide for additional MX record
|
||||
setup.
|
||||
</Callout>
|
||||
|
||||
## Verification Status
|
||||
|
||||
After adding all DNS records, Plunk will automatically check the verification status. Verification typically completes within a few minutes but can take up to 72 hours depending on DNS propagation.
|
||||
|
||||
You can check the status in the Domains section of your project settings. Each record type will show as verified once detected.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Records not verifying
|
||||
|
||||
If your DNS records aren't verifying after 24 hours:
|
||||
|
||||
1. **Double-check the values**: Ensure you copied the exact values without extra spaces
|
||||
2. **Check DNS propagation**: Use tools like `dig` or online DNS checkers to verify the records are published
|
||||
3. **TTL settings**: Some DNS providers cache records. Try lowering the TTL (Time To Live) value
|
||||
4. **Contact your DNS provider**: Some providers have specific requirements or interfaces for adding these record types
|
||||
|
||||
### Emails still going to spam
|
||||
|
||||
Even with a verified domain, emails may go to spam if:
|
||||
|
||||
- Your content triggers spam filters (excessive links, suspicious keywords)
|
||||
- Your sender reputation is new or low
|
||||
- Recipients have marked your emails as spam in the past
|
||||
- You're sending to invalid or unengaged contacts
|
||||
|
||||
Follow email best practices and maintain good [list hygiene](/guides/list-hygiene) to improve deliverability.
|
||||
|
||||
@@ -23,32 +23,32 @@ Plunk automatically tracks a set of internal events that you can use as workflow
|
||||
### Email events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `email.sent` | An email was successfully sent |
|
||||
| `email.delivery` | An email was delivered to the recipient |
|
||||
| `email.open` | A contact opened an email for the first time |
|
||||
| `email.click` | A contact clicked a link in an email for the first time |
|
||||
| `email.bounce` | An email bounced (hard or soft bounce) |
|
||||
| `email.complaint` | A contact marked an email as spam |
|
||||
| `email.received` | An email was received at your verified domain (requires [inbound email setup](/guides/receiving-emails)) |
|
||||
|
||||
### Contact events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| ---------------------- | ------------------------------------------------------- |
|
||||
| `contact.subscribed` | A contact's subscription status changed to subscribed |
|
||||
| `contact.unsubscribed` | A contact's subscription status changed to unsubscribed |
|
||||
|
||||
### Segment events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| ---------------------- | --------------------------- |
|
||||
| `segment.<name>.entry` | A contact entered a segment |
|
||||
| `segment.<name>.exit` | A contact exited a segment |
|
||||
|
||||
<Callout
|
||||
title="Segment event names"
|
||||
variant="idea">
|
||||
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce the events `segment.vip-users.entry` and `segment.vip-users.exit`.
|
||||
<Callout title="Segment event names" variant="idea">
|
||||
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce
|
||||
the events `segment.vip-users.entry` and `segment.vip-users.exit`.
|
||||
</Callout>
|
||||
|
||||
## Setting up a webhook
|
||||
@@ -116,7 +116,7 @@ The `event` field contains the data associated with the event that triggered the
|
||||
The `event` field varies depending on which event triggered the workflow:
|
||||
|
||||
| Event | Fields in `event` |
|
||||
|-------|-------------------|
|
||||
| ----------------- | ------------------------------------------------------------------------ |
|
||||
| `email.sent` | `subject`, `from`, `messageId`, `templateId`, `campaignId`, `sourceType` |
|
||||
| `email.open` | `subject`, `from`, `openedAt`, `isFirstOpen` |
|
||||
| `email.click` | `subject`, `from`, `clickedAt`, `clicks`, `isFirstClick` |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plunk",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "EmailSourceType" ADD VALUE 'INBOUND';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "projects" ADD COLUMN "billingLimitInbound" INTEGER;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "EmailStatus" ADD VALUE 'RECEIVED';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SegmentType" AS ENUM ('DYNAMIC', 'STATIC');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "segments" ADD COLUMN "type" "SegmentType" NOT NULL DEFAULT 'DYNAMIC',
|
||||
ALTER COLUMN "condition" DROP NOT NULL;
|
||||
@@ -53,6 +53,7 @@ model Project {
|
||||
billingLimitWorkflows Int? // Max workflow emails per month
|
||||
billingLimitCampaigns Int? // Max campaign emails per month
|
||||
billingLimitTransactional Int? // Max transactional emails per month
|
||||
billingLimitInbound Int? // Max inbound emails per month
|
||||
|
||||
// Email Tracking
|
||||
tracking TrackingMode @default(ENABLED) // Open and click tracking mode
|
||||
@@ -198,8 +199,11 @@ model Segment {
|
||||
name String
|
||||
description String?
|
||||
|
||||
// Filter condition (evaluated dynamically)
|
||||
condition Json
|
||||
// Segment type: DYNAMIC (filter-based) or STATIC (manually managed)
|
||||
type SegmentType @default(DYNAMIC)
|
||||
|
||||
// Filter condition (evaluated dynamically, null for STATIC segments)
|
||||
condition Json?
|
||||
// Nested filter structure with AND/OR logic:
|
||||
// {
|
||||
// logic: "OR",
|
||||
@@ -746,10 +750,16 @@ enum StepExecutionStatus {
|
||||
FAILED // Failed with error
|
||||
}
|
||||
|
||||
enum SegmentType {
|
||||
DYNAMIC // Filter-based segment evaluated against contacts at query time
|
||||
STATIC // Manually curated list of contacts managed via memberships
|
||||
}
|
||||
|
||||
enum EmailSourceType {
|
||||
TRANSACTIONAL // Sent via API call
|
||||
CAMPAIGN // Sent as part of broadcast
|
||||
WORKFLOW // Sent via workflow automation
|
||||
INBOUND // Received via SES inbound
|
||||
}
|
||||
|
||||
enum EmailStatus {
|
||||
@@ -757,6 +767,7 @@ enum EmailStatus {
|
||||
SENDING // Currently being sent
|
||||
SENT // Successfully sent to provider
|
||||
DELIVERED // Confirmed delivered
|
||||
RECEIVED // Received by platform (inbound)
|
||||
OPENED // Recipient opened email
|
||||
CLICKED // Recipient clicked link
|
||||
BOUNCED // Bounced (hard or soft)
|
||||
|
||||
@@ -9,6 +9,7 @@ import ptTranslations from './locales/pt.json' with {type: 'json'};
|
||||
import bgTranslations from './locales/bg.json' with {type: 'json'};
|
||||
import csTranslations from './locales/cs.json' with {type: 'json'};
|
||||
import plTranslations from './locales/pl.json' with {type: 'json'};
|
||||
import esTranslations from './locales/es.json' with {type: 'json'};
|
||||
|
||||
export {
|
||||
SUPPORTED_LANGUAGES,
|
||||
@@ -43,6 +44,7 @@ const translationsMap: Record<string, Translations> = {
|
||||
bg: bgTranslations,
|
||||
cs: csTranslations,
|
||||
pl: plTranslations,
|
||||
es: esTranslations,
|
||||
};
|
||||
|
||||
// In-memory cache for loaded translations
|
||||
|
||||
@@ -15,6 +15,7 @@ export const SUPPORTED_LANGUAGES: Language[] = [
|
||||
{code: 'bg', name: 'Bulgarian', nativeName: 'Български', flag: '🇧🇬'},
|
||||
{code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿'},
|
||||
{code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱'},
|
||||
{code: 'es', name: 'Spanish (Spain)', nativeName: 'Español (España)', flag: '🇪🇸'},
|
||||
];
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "Cancelar suscripción",
|
||||
"description": "Lamentamos que te vayas. ¿Estás seguro de que quieres cancelar la suscripción de {email} para dejar de recibir correos electrónicos?",
|
||||
"button": "Cancelar suscripción",
|
||||
"buttonLoading": "Cancelando suscripción...",
|
||||
"managePreferences": "Gestionar preferencias en su lugar",
|
||||
"successTitle": "Suscripción cancelada",
|
||||
"successDescription": "Se ha cancelado la suscripción de {email}. No recibirás más correos electrónicos de nuestra parte.",
|
||||
"changedMind": "¿Has cambiado de opinión?",
|
||||
"subscribeAgain": "Suscribirse de nuevo"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "Suscribirse a las novedades",
|
||||
"description": "¿Te gustaría suscribir {email} para recibir correos electrónicos?",
|
||||
"button": "Suscribirse",
|
||||
"buttonLoading": "Suscribiendo...",
|
||||
"successTitle": "¡Suscripción completada!",
|
||||
"successDescription": "{email} está ahora suscrito para recibir correos electrónicos de nuestra parte."
|
||||
},
|
||||
"manage": {
|
||||
"title": "Gestionar preferencias",
|
||||
"description": "Gestiona las preferencias de correo electrónico de {email}",
|
||||
"subscriptionLabel": "Suscripción de correo electrónico",
|
||||
"subscribedStatus": "Actualmente estás suscrito para recibir correos electrónicos",
|
||||
"unsubscribedStatus": "Actualmente tienes cancelada la suscripción a los correos electrónicos",
|
||||
"subscribedSuccess": "¡Suscripción realizada con éxito!",
|
||||
"unsubscribedSuccess": "¡Suscripción cancelada con éxito!",
|
||||
"unsubscribeCompletely": "Cancelar suscripción completamente",
|
||||
"subscribeToEmails": "Suscribirse a los correos electrónicos",
|
||||
"disclaimer": "Esta página te permite gestionar tus preferencias de correo electrónico. El estado de tu suscripción se actualiza en tiempo real."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Cargando...",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "Has recibido este correo electrónico porque aceptaste recibir correos electrónicos de {projectName}. Si ya no deseas recibir correos electrónicos como este, por favor",
|
||||
"updatePreferences": "actualiza tus preferencias"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,15 +132,20 @@ export const SegmentSchemas = {
|
||||
create: z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
condition: filterConditionSchema,
|
||||
type: z.enum(['DYNAMIC', 'STATIC']).default('DYNAMIC'),
|
||||
condition: filterConditionSchema.optional(),
|
||||
trackMembership: z.boolean().default(false),
|
||||
}),
|
||||
update: z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
type: z.enum(['DYNAMIC', 'STATIC']).optional(),
|
||||
condition: filterConditionSchema.optional(),
|
||||
trackMembership: z.boolean().optional(),
|
||||
}),
|
||||
members: z.object({
|
||||
emails: z.array(z.string().email()).min(1).max(500),
|
||||
}),
|
||||
};
|
||||
|
||||
export const TemplateSchemas = {
|
||||
@@ -180,6 +185,7 @@ export const WorkflowSchemas = {
|
||||
triggerType: z.nativeEnum(WorkflowTriggerType).optional(),
|
||||
triggerConfig: jsonSchema.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowReentry: z.boolean().optional(),
|
||||
}),
|
||||
addStep: z.object({
|
||||
type: z.nativeEnum(WorkflowStepType),
|
||||
@@ -401,6 +407,17 @@ export const ActionSchemas = {
|
||||
filename: z.string().min(1).max(255),
|
||||
content: z.string().min(1), // Base64 encoded file content
|
||||
contentType: z.string().min(1).max(255),
|
||||
contentId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[^<>\r\n]+$/, 'Content ID cannot contain <, >, \\r, or \\n')
|
||||
.optional(),
|
||||
disposition: z.enum(['attachment', 'inline']).default('attachment'),
|
||||
})
|
||||
.refine(data => data.disposition !== 'inline' || !!data.contentId, {
|
||||
message: 'Content ID is required when disposition is inline',
|
||||
path: ['contentId'],
|
||||
}),
|
||||
)
|
||||
.max(10) // Maximum 10 attachments per email
|
||||
@@ -439,6 +456,7 @@ export const BillingLimitSchemas = {
|
||||
workflows: z.coerce.number().int().positive().nullable(),
|
||||
campaigns: z.coerce.number().int().positive().nullable(),
|
||||
transactional: z.coerce.number().int().positive().nullable(),
|
||||
inbound: z.coerce.number().int().positive().nullable(),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum ActivityType {
|
||||
EVENT_TRIGGERED = 'event.triggered',
|
||||
EMAIL_SENT = 'email.sent',
|
||||
EMAIL_DELIVERED = 'email.delivered',
|
||||
EMAIL_RECEIVED = 'email.received',
|
||||
EMAIL_OPENED = 'email.opened',
|
||||
EMAIL_CLICKED = 'email.clicked',
|
||||
EMAIL_BOUNCED = 'email.bounced',
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface BillingLimitsResponse {
|
||||
workflows: CategoryUsage;
|
||||
campaigns: CategoryUsage;
|
||||
transactional: CategoryUsage;
|
||||
inbound: CategoryUsage;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Segment and filter types
|
||||
*/
|
||||
|
||||
export type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||
|
||||
// Segment filter types
|
||||
export type SegmentFilterOperator =
|
||||
// Standard operators (for contact fields)
|
||||
@@ -45,13 +47,15 @@ export interface FilterCondition {
|
||||
export interface CreateSegmentData {
|
||||
name: string;
|
||||
description?: string;
|
||||
condition: FilterCondition;
|
||||
type?: SegmentType;
|
||||
condition?: FilterCondition;
|
||||
trackMembership?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateSegmentData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
type?: SegmentType;
|
||||
condition?: FilterCondition;
|
||||
trackMembership?: boolean;
|
||||
}
|
||||
|
||||
@@ -4741,156 +4741,177 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-android-arm-eabi@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-android-arm-eabi@npm:4.53.3"
|
||||
"@rollup/rollup-android-arm-eabi@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-android-arm-eabi@npm:4.59.0"
|
||||
conditions: os=android & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-android-arm64@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-android-arm64@npm:4.53.3"
|
||||
"@rollup/rollup-android-arm64@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-android-arm64@npm:4.59.0"
|
||||
conditions: os=android & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-darwin-arm64@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-darwin-arm64@npm:4.53.3"
|
||||
"@rollup/rollup-darwin-arm64@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-darwin-arm64@npm:4.59.0"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-darwin-x64@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-darwin-x64@npm:4.53.3"
|
||||
"@rollup/rollup-darwin-x64@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-darwin-x64@npm:4.59.0"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-freebsd-arm64@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-freebsd-arm64@npm:4.53.3"
|
||||
"@rollup/rollup-freebsd-arm64@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-freebsd-arm64@npm:4.59.0"
|
||||
conditions: os=freebsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-freebsd-x64@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-freebsd-x64@npm:4.53.3"
|
||||
"@rollup/rollup-freebsd-x64@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-freebsd-x64@npm:4.59.0"
|
||||
conditions: os=freebsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.53.3"
|
||||
"@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0"
|
||||
conditions: os=linux & cpu=arm & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.53.3"
|
||||
"@rollup/rollup-linux-arm-musleabihf@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.59.0"
|
||||
conditions: os=linux & cpu=arm & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.53.3"
|
||||
"@rollup/rollup-linux-arm64-gnu@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.59.0"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.53.3"
|
||||
"@rollup/rollup-linux-arm64-musl@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.59.0"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.53.3"
|
||||
"@rollup/rollup-linux-loong64-gnu@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.59.0"
|
||||
conditions: os=linux & cpu=loong64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.53.3"
|
||||
"@rollup/rollup-linux-loong64-musl@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-loong64-musl@npm:4.59.0"
|
||||
conditions: os=linux & cpu=loong64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.59.0"
|
||||
conditions: os=linux & cpu=ppc64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.53.3"
|
||||
"@rollup/rollup-linux-ppc64-musl@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.59.0"
|
||||
conditions: os=linux & cpu=ppc64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.59.0"
|
||||
conditions: os=linux & cpu=riscv64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.53.3"
|
||||
"@rollup/rollup-linux-riscv64-musl@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.59.0"
|
||||
conditions: os=linux & cpu=riscv64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.53.3"
|
||||
"@rollup/rollup-linux-s390x-gnu@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.59.0"
|
||||
conditions: os=linux & cpu=s390x & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.53.3"
|
||||
"@rollup/rollup-linux-x64-gnu@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.59.0"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-linux-x64-musl@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-linux-x64-musl@npm:4.53.3"
|
||||
"@rollup/rollup-linux-x64-musl@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-linux-x64-musl@npm:4.59.0"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-openharmony-arm64@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-openharmony-arm64@npm:4.53.3"
|
||||
"@rollup/rollup-openbsd-x64@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-openbsd-x64@npm:4.59.0"
|
||||
conditions: os=openbsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-openharmony-arm64@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-openharmony-arm64@npm:4.59.0"
|
||||
conditions: os=openharmony & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.53.3"
|
||||
"@rollup/rollup-win32-arm64-msvc@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.59.0"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.53.3"
|
||||
"@rollup/rollup-win32-ia32-msvc@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.59.0"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-win32-x64-gnu@npm:4.53.3"
|
||||
"@rollup/rollup-win32-x64-gnu@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-win32-x64-gnu@npm:4.59.0"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc@npm:4.53.3":
|
||||
version: 4.53.3
|
||||
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.53.3"
|
||||
"@rollup/rollup-win32-x64-msvc@npm:4.59.0":
|
||||
version: 4.59.0
|
||||
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.59.0"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -7746,14 +7767,14 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"ajv@npm:^6.12.4":
|
||||
version: 6.12.6
|
||||
resolution: "ajv@npm:6.12.6"
|
||||
version: 6.14.0
|
||||
resolution: "ajv@npm:6.14.0"
|
||||
dependencies:
|
||||
fast-deep-equal: "npm:^3.1.1"
|
||||
fast-json-stable-stringify: "npm:^2.0.0"
|
||||
json-schema-traverse: "npm:^0.4.1"
|
||||
uri-js: "npm:^4.2.2"
|
||||
checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71
|
||||
checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7869,7 +7890,7 @@ __metadata:
|
||||
jsonwebtoken: "npm:^9.0.2"
|
||||
mailchecker: "npm:^6.0.19"
|
||||
morgan: "npm:^1.10.0"
|
||||
multer: "npm:^2.0.2"
|
||||
multer: "npm:^2.1.1"
|
||||
signale: "npm:^1.4.0"
|
||||
stripe: "npm:^20.0.0"
|
||||
tsx: "npm:^4.20.6"
|
||||
@@ -12037,12 +12058,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"iconv-lite@npm:0.7.0":
|
||||
version: 0.7.0
|
||||
resolution: "iconv-lite@npm:0.7.0"
|
||||
"iconv-lite@npm:0.7.2":
|
||||
version: 0.7.2
|
||||
resolution: "iconv-lite@npm:0.7.2"
|
||||
dependencies:
|
||||
safer-buffer: "npm:>= 2.1.2 < 3.0.0"
|
||||
checksum: 10c0/2382400469071c55b6746c531eed5fa4d033e5db6690b7331fb2a5f59a30d7a9782932e92253db26df33c1cf46fa200a3fbe524a2a7c62037c762283f188ec2f
|
||||
checksum: 10c0/3c228920f3bd307f56bf8363706a776f4a060eb042f131cd23855ceca962951b264d0997ab38a1ad340e1c5df8499ed26e1f4f0db6b2a2ad9befaff22f14b722
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13361,21 +13382,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mailparser@npm:^3.7.1":
|
||||
version: 3.9.1
|
||||
resolution: "mailparser@npm:3.9.1"
|
||||
"mailparser@npm:^3.9.3":
|
||||
version: 3.9.3
|
||||
resolution: "mailparser@npm:3.9.3"
|
||||
dependencies:
|
||||
"@zone-eu/mailsplit": "npm:5.4.8"
|
||||
encoding-japanese: "npm:2.2.0"
|
||||
he: "npm:1.2.0"
|
||||
html-to-text: "npm:9.0.5"
|
||||
iconv-lite: "npm:0.7.0"
|
||||
iconv-lite: "npm:0.7.2"
|
||||
libmime: "npm:5.3.7"
|
||||
linkify-it: "npm:5.0.0"
|
||||
nodemailer: "npm:7.0.11"
|
||||
nodemailer: "npm:7.0.13"
|
||||
punycode.js: "npm:2.3.1"
|
||||
tlds: "npm:1.261.0"
|
||||
checksum: 10c0/3542fd211b7a2b3266c5e5469aa4281a280c500ae84eadcd2af7e69ec92faa4fccc883f19de614566c6eb647504412cb0bf37844bcb6d3eecf9f0277154f6f90
|
||||
checksum: 10c0/da62c7cd977867da8be0dd1e6cf3b137821258e0d1e0976a00d3ec17bbd8a92bbefbccc7b0ec1dd33bc5ccd28dc5d51bbae723fac8ca05be843e88b7d579c751
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14376,17 +14397,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mkdirp@npm:^0.5.6":
|
||||
version: 0.5.6
|
||||
resolution: "mkdirp@npm:0.5.6"
|
||||
dependencies:
|
||||
minimist: "npm:^1.2.6"
|
||||
bin:
|
||||
mkdirp: bin/cmd.js
|
||||
checksum: 10c0/e2e2be789218807b58abced04e7b49851d9e46e88a2f9539242cc8a92c9b5c3a0b9bab360bd3014e02a140fc4fbc58e31176c408b493f8a2a6f4986bd7527b01
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"module-punycode@npm:[email protected], punycode@npm:^2.1.0":
|
||||
version: 2.3.1
|
||||
resolution: "punycode@npm:2.3.1"
|
||||
@@ -14487,18 +14497,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"multer@npm:^2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "multer@npm:2.0.2"
|
||||
"multer@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "multer@npm:2.1.1"
|
||||
dependencies:
|
||||
append-field: "npm:^1.0.0"
|
||||
busboy: "npm:^1.6.0"
|
||||
concat-stream: "npm:^2.0.0"
|
||||
mkdirp: "npm:^0.5.6"
|
||||
object-assign: "npm:^4.1.1"
|
||||
type-is: "npm:^1.6.18"
|
||||
xtend: "npm:^4.0.2"
|
||||
checksum: 10c0/d3b99dd0512169bbabf15440e1bbb3ecdc000b761e5a3e4aaca40b5e5e213c6cdcc9b7dffebaa601b7691a84f6876aa87e0173ffcc47139253793cf5657819eb
|
||||
checksum: 10c0/2ec4e02833b20f403cfb879d4b64d2a9070d902b9deae7aef18a6faadb707d7665385456cf540aa8a6dadfe3d4c5fc8e0e7b0675b94e1077048b1125426deee6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14818,6 +14825,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nodemailer@npm:7.0.13":
|
||||
version: 7.0.13
|
||||
resolution: "nodemailer@npm:7.0.13"
|
||||
checksum: 10c0/b26aa5b9fa4a033bbc1e1c16ef75ee2a9c8641fd290c00a8361d6a251b3c1b8bad545a23efa627f59cb266340a448891ea8aa49d8a9307c767b8505219d95079
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nopt@npm:^9.0.0":
|
||||
version: 9.0.0
|
||||
resolution: "nopt@npm:9.0.0"
|
||||
@@ -16626,31 +16640,34 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"rollup@npm:^4.43.0":
|
||||
version: 4.53.3
|
||||
resolution: "rollup@npm:4.53.3"
|
||||
version: 4.59.0
|
||||
resolution: "rollup@npm:4.59.0"
|
||||
dependencies:
|
||||
"@rollup/rollup-android-arm-eabi": "npm:4.53.3"
|
||||
"@rollup/rollup-android-arm64": "npm:4.53.3"
|
||||
"@rollup/rollup-darwin-arm64": "npm:4.53.3"
|
||||
"@rollup/rollup-darwin-x64": "npm:4.53.3"
|
||||
"@rollup/rollup-freebsd-arm64": "npm:4.53.3"
|
||||
"@rollup/rollup-freebsd-x64": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-arm-musleabihf": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-arm64-gnu": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-arm64-musl": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-loong64-gnu": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-ppc64-gnu": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-riscv64-gnu": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-riscv64-musl": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-s390x-gnu": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-x64-gnu": "npm:4.53.3"
|
||||
"@rollup/rollup-linux-x64-musl": "npm:4.53.3"
|
||||
"@rollup/rollup-openharmony-arm64": "npm:4.53.3"
|
||||
"@rollup/rollup-win32-arm64-msvc": "npm:4.53.3"
|
||||
"@rollup/rollup-win32-ia32-msvc": "npm:4.53.3"
|
||||
"@rollup/rollup-win32-x64-gnu": "npm:4.53.3"
|
||||
"@rollup/rollup-win32-x64-msvc": "npm:4.53.3"
|
||||
"@rollup/rollup-android-arm-eabi": "npm:4.59.0"
|
||||
"@rollup/rollup-android-arm64": "npm:4.59.0"
|
||||
"@rollup/rollup-darwin-arm64": "npm:4.59.0"
|
||||
"@rollup/rollup-darwin-x64": "npm:4.59.0"
|
||||
"@rollup/rollup-freebsd-arm64": "npm:4.59.0"
|
||||
"@rollup/rollup-freebsd-x64": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-arm-musleabihf": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-arm64-gnu": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-arm64-musl": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-loong64-gnu": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-loong64-musl": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-ppc64-gnu": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-ppc64-musl": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-riscv64-gnu": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-riscv64-musl": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-s390x-gnu": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-x64-gnu": "npm:4.59.0"
|
||||
"@rollup/rollup-linux-x64-musl": "npm:4.59.0"
|
||||
"@rollup/rollup-openbsd-x64": "npm:4.59.0"
|
||||
"@rollup/rollup-openharmony-arm64": "npm:4.59.0"
|
||||
"@rollup/rollup-win32-arm64-msvc": "npm:4.59.0"
|
||||
"@rollup/rollup-win32-ia32-msvc": "npm:4.59.0"
|
||||
"@rollup/rollup-win32-x64-gnu": "npm:4.59.0"
|
||||
"@rollup/rollup-win32-x64-msvc": "npm:4.59.0"
|
||||
"@types/estree": "npm:1.0.8"
|
||||
fsevents: "npm:~2.3.2"
|
||||
dependenciesMeta:
|
||||
@@ -16676,8 +16693,12 @@ __metadata:
|
||||
optional: true
|
||||
"@rollup/rollup-linux-loong64-gnu":
|
||||
optional: true
|
||||
"@rollup/rollup-linux-loong64-musl":
|
||||
optional: true
|
||||
"@rollup/rollup-linux-ppc64-gnu":
|
||||
optional: true
|
||||
"@rollup/rollup-linux-ppc64-musl":
|
||||
optional: true
|
||||
"@rollup/rollup-linux-riscv64-gnu":
|
||||
optional: true
|
||||
"@rollup/rollup-linux-riscv64-musl":
|
||||
@@ -16688,6 +16709,8 @@ __metadata:
|
||||
optional: true
|
||||
"@rollup/rollup-linux-x64-musl":
|
||||
optional: true
|
||||
"@rollup/rollup-openbsd-x64":
|
||||
optional: true
|
||||
"@rollup/rollup-openharmony-arm64":
|
||||
optional: true
|
||||
"@rollup/rollup-win32-arm64-msvc":
|
||||
@@ -16702,7 +16725,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
rollup: dist/bin/rollup
|
||||
checksum: 10c0/a21305aac72013083bd0dec92162b0f7f24cacf57c876ca601ec76e892895952c9ea592c1c07f23b8c125f7979c2b17f7fb565e386d03ee4c1f0952ac4ab0d75
|
||||
checksum: 10c0/f38742da34cfee5e899302615fa157aa77cb6a2a1495e5e3ce4cc9c540d3262e235bbe60caa31562bbfe492b01fdb3e7a8c43c39d842d3293bcf843123b766fc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -17341,7 +17364,7 @@ __metadata:
|
||||
"@types/signale": "npm:^1.4.7"
|
||||
"@types/smtp-server": "npm:^3.5.10"
|
||||
dotenv: "npm:^17.2.3"
|
||||
mailparser: "npm:^3.7.1"
|
||||
mailparser: "npm:^3.9.3"
|
||||
signale: "npm:^1.4.0"
|
||||
smtp-server: "npm:^3.13.4"
|
||||
tsx: "npm:^4.20.6"
|
||||
@@ -18004,15 +18027,15 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"tar@npm:^7.5.2":
|
||||
version: 7.5.7
|
||||
resolution: "tar@npm:7.5.7"
|
||||
version: 7.5.10
|
||||
resolution: "tar@npm:7.5.10"
|
||||
dependencies:
|
||||
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||
chownr: "npm:^3.0.0"
|
||||
minipass: "npm:^7.1.2"
|
||||
minizlib: "npm:^3.1.0"
|
||||
yallist: "npm:^5.0.0"
|
||||
checksum: 10c0/51f261afc437e1112c3e7919478d6176ea83f7f7727864d8c2cce10f0b03a631d1911644a567348c3063c45abdae39718ba97abb073d22aa3538b9a53ae1e31c
|
||||
checksum: 10c0/ed905e4b33886377df6e9206e5d1bd34458c21666e27943f946799416f86348c938590d573d6a69312cb29c583b122647a64ec92782f2b7e24e68d985dd72531
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -19385,13 +19408,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xtend@npm:^4.0.2":
|
||||
version: 4.0.2
|
||||
resolution: "xtend@npm:4.0.2"
|
||||
checksum: 10c0/366ae4783eec6100f8a02dff02ac907bf29f9a00b82ac0264b4d8b832ead18306797e283cf19de776538babfdcb2101375ec5646b59f08c52128ac4ab812ed0e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"y18n@npm:^5.0.5":
|
||||
version: 5.0.8
|
||||
resolution: "y18n@npm:5.0.8"
|
||||
|
||||
Reference in New Issue
Block a user