Compare commits

...
31 Commits
Author SHA1 Message Date
Dries Augustyns d75aed81e1 Merge pull request #295 from useplunk/release-please--branches--next--components--plunk 2026-03-05 11:58:38 +01:00
github-actions[bot] 0cbaa8fc66 chore(next): release 0.7.0 2026-03-05 10:27:05 +00:00
Dries Augustyns 0686b3a3f2 Merge pull request #307 from useplunk/dependabot/npm_and_yarn/tar-7.5.10
chore(deps): bump tar from 7.5.9 to 7.5.10
2026-03-05 11:26:52 +01:00
Dries Augustyns 9b15b93b96 Merge pull request #281 from 5h0ov/multipart/related-support-embed-image
feat(api): support inline images in emails using Content-ID
2026-03-05 11:26:41 +01:00
dependabot[bot] 8f2510f5ec chore(deps): bump tar from 7.5.9 to 7.5.10
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.9 to 7.5.10.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.9...v7.5.10)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-05 10:03:03 +00:00
Dries Augustyns e196d798c5 Merge pull request #293 from useplunk/dependabot/npm_and_yarn/tar-7.5.9 2026-03-05 10:18:30 +01:00
Dries Augustyns 777a12ec89 Merge pull request #296 from useplunk/dependabot/npm_and_yarn/ajv-6.14.0 2026-03-05 10:18:21 +01:00
Dries Augustyns 6c6af775bc Merge pull request #301 from useplunk/dependabot/npm_and_yarn/rollup-4.59.0 2026-03-05 10:18:12 +01:00
Dries Augustyns 4f8c9f029d Merge pull request #305 from useplunk/dependabot/npm_and_yarn/mailparser-3.9.3 2026-03-05 10:18:03 +01:00
Dries Augustyns 8f72994b46 Merge pull request #306 from useplunk/dependabot/npm_and_yarn/multer-2.1.1 2026-03-05 10:17:43 +01:00
dependabot[bot] d162f255c2 chore(deps): bump multer from 2.0.2 to 2.1.1
Bumps [multer](https://github.com/expressjs/multer) from 2.0.2 to 2.1.1.
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.0.2...v2.1.1)

---
updated-dependencies:
- dependency-name: multer
  dependency-version: 2.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-05 07:56:38 +00:00
dependabot[bot] c8e252fefd chore(deps): bump mailparser from 3.9.1 to 3.9.3
Bumps [mailparser](https://github.com/nodemailer/mailparser) from 3.9.1 to 3.9.3.
- [Release notes](https://github.com/nodemailer/mailparser/releases)
- [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/mailparser/compare/v3.9.1...v3.9.3)

---
updated-dependencies:
- dependency-name: mailparser
  dependency-version: 3.9.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-04 22:23:43 +00:00
Dries Augustyns b8f1ad9ab5 fix: Verify SNS URL before sending fetch request 2026-03-03 08:02:36 +01:00
dependabot[bot] 708ab506f1 chore(deps): bump rollup from 4.53.3 to 4.59.0
Bumps [rollup](https://github.com/rollup/rollup) from 4.53.3 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.53.3...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-28 21:10:59 +00:00
Dries Augustyns ec1f4c9374 fix: Support any locale on creation 2026-02-26 20:57:14 +01:00
Dries Augustyns 5528288c7b Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-02-23 21:45:25 +01:00
Dries Augustyns 7bd098bdd0 fix: Add support for STATIC segment type in CampaignService 2026-02-23 21:45:17 +01:00
Dries Augustyns cbea263a91 Merge pull request #298 from josephsellers/fix/cookie-domain-local-tld 2026-02-23 14:17:36 +01:00
Dries Augustyns e8a247fe12 docs: Static segments 2026-02-23 14:03:05 +01:00
Dries Augustyns 4b51e386e3 feat: Static segments 2026-02-23 13:57:32 +01:00
josephsellers 59aa7845ba fix: correct cookie domain for .local TLD hostnames
getCookieDomain() returns '.localhost' for hostnames ending in '.local'
(e.g., app.plunk.local), causing the browser to reject the cookie
because the domain doesn't match the request origin.

Split the .localhost and .local cases so that .local hostnames return
the actual base domain (e.g., .plunk.local).
2026-02-22 16:01:25 +00:00
dependabot[bot] 5600c49bb6 chore(deps): bump ajv from 6.12.6 to 6.14.0
Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.6 to 6.14.0.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 6.14.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-22 13:46:46 +00:00
Dries Augustyns 21af8fe05e fix: Correctly set domain status on manual verify 2026-02-21 08:41:24 +01:00
Dries Augustyns 2c4d95e604 fix: Do not unsubscribe existing contacts 2026-02-20 16:58:17 +01:00
Dries Augustyns 64bd094b47 feat: Sort projects alphabetically in the dashboard and fix layout 2026-02-20 16:01:57 +01:00
dependabot[bot] 53b33bbd36 chore(deps): bump tar from 7.5.7 to 7.5.9
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.9.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.9)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-19 19:00:34 +00:00
Shuvadipta Das 64ba19e589 fix(test): consolidate SES MIME boundary tests into EmailService.test.ts 2026-02-16 18:55:03 +05:30
Shuvadipta Das 3a42012ac7 fix: removed "any" data type from SESService.test.ts 2026-02-16 18:47:42 +05:30
Shuvadipta Das 08e5c0d930 fix: contentId header injection fixed and separte unit test SESService added to verify MIME boundaries 2026-02-16 18:27:32 +05:30
Shuvadipta Das 18788ac1ca Merge branch 'next' of https://github.com/5h0ov/plunk into multipart/related-support-embed-image 2026-02-16 18:27:07 +05:30
Shuvadipta Das c40394ffd6 feat(api): support inline images in emails using Content-ID 2026-01-31 10:23:42 +05:30
28 changed files with 1317 additions and 286 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.6.0"
".": "0.7.0"
}
+24
View File
@@ -1,5 +1,29 @@
# 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)
+1 -1
View File
@@ -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"
},
+6 -3
View File
@@ -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);
+57 -4
View File
@@ -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
+26 -4
View File
@@ -34,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({
@@ -50,7 +74,6 @@ export class Webhooks {
return res.status(200).json({
success: false,
message: 'Failed to confirm subscription',
subscribeURL: req.body.SubscribeURL,
});
}
} catch (confirmError) {
@@ -58,7 +81,6 @@ export class Webhooks {
return res.status(200).json({
success: false,
message: 'Error confirming subscription',
subscribeURL: req.body.SubscribeURL,
});
}
}
+12
View File
@@ -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);
+4 -7
View File
@@ -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,
},
});
}
+51 -1
View File
@@ -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);
+9 -1
View File
@@ -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
+66 -23
View File
@@ -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
+168 -18
View File
@@ -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);
+7 -2
View File
@@ -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('.')}`;
}
@@ -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}--`);
});
});
+1 -1
View File
@@ -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"
},
+152
View File
@@ -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>
);
}
+12 -7
View File
@@ -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>
+125 -12
View File
@@ -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&apos;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>
+14 -1
View File
@@ -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>
+77 -4
View File
@@ -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">
@@ -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
+44 -8
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plunk",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"scripts": {
"build": "turbo build",
@@ -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;
+10 -2
View File
@@ -199,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",
@@ -747,6 +750,11 @@ 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
+17 -1
View File
@@ -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 = {
@@ -402,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
+5 -1
View File
@@ -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;
}
+150 -134
View File
@@ -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"