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 | ||
|
|
64ba19e589 | ||
|
|
3a42012ac7 | ||
|
|
08e5c0d930 | ||
|
|
18788ac1ca | ||
|
|
c40394ffd6 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.6.0"
|
".": "0.7.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# 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)
|
## [0.6.0](https://github.com/useplunk/plunk/compare/v0.5.0...v0.6.0) (2026-02-19)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailchecker": "^6.0.19",
|
"mailchecker": "^6.0.19",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.1.1",
|
||||||
"signale": "^1.4.0",
|
"signale": "^1.4.0",
|
||||||
"stripe": "^20.0.0"
|
"stripe": "^20.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,12 +72,13 @@ export class Actions {
|
|||||||
|
|
||||||
// Create or update contact with persistent data only
|
// Create or update contact with persistent data only
|
||||||
// ContactService.upsert will filter out non-persistent fields
|
// 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(
|
const contact = await ContactService.upsert(
|
||||||
auth.projectId,
|
auth.projectId,
|
||||||
email,
|
email,
|
||||||
data as Record<string, unknown> | undefined,
|
data as Record<string, unknown> | undefined,
|
||||||
subscribed ?? true,
|
subscribed,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track the event with ALL data (persistent + non-persistent)
|
// Track the event with ALL data (persistent + non-persistent)
|
||||||
@@ -268,7 +269,9 @@ export class Actions {
|
|||||||
|
|
||||||
// Create or update contact with metadata
|
// Create or update contact with metadata
|
||||||
// Transactional emails should not subscribe contacts by default
|
// 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
|
// Get merged data including non-persistent fields for template rendering
|
||||||
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
||||||
|
|||||||
@@ -72,20 +72,23 @@ export class Segments {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const {name, description, condition, trackMembership} = req.body;
|
const {name, description, type, condition, trackMembership} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({error: 'Name is required'});
|
return res.status(400).json({error: 'Name is required'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!condition || typeof condition !== 'object') {
|
const segmentType = type ?? 'DYNAMIC';
|
||||||
return res.status(400).json({error: 'Condition is required and must be an object'});
|
|
||||||
|
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!, {
|
const segment = await SegmentService.create(auth.projectId!, {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
condition,
|
type: segmentType,
|
||||||
|
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||||
trackMembership,
|
trackMembership,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,6 +145,56 @@ export class Segments {
|
|||||||
return res.status(204).send();
|
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
|
* POST /segments/:id/compute
|
||||||
* Recompute segment membership for all contacts
|
* Recompute segment membership for all contacts
|
||||||
|
|||||||
@@ -34,11 +34,35 @@ export class Webhooks {
|
|||||||
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
||||||
if (req.body.Type === 'SubscriptionConfirmation') {
|
if (req.body.Type === 'SubscriptionConfirmation') {
|
||||||
signale.info('SNS Subscription Confirmation received');
|
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
|
// Automatically confirm the subscription
|
||||||
try {
|
try {
|
||||||
const confirmResponse = await fetch(req.body.SubscribeURL);
|
const confirmResponse = await fetch(subscribeURL);
|
||||||
if (confirmResponse.ok) {
|
if (confirmResponse.ok) {
|
||||||
signale.success('SNS subscription confirmed successfully');
|
signale.success('SNS subscription confirmed successfully');
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
@@ -50,7 +74,6 @@ export class Webhooks {
|
|||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to confirm subscription',
|
message: 'Failed to confirm subscription',
|
||||||
subscribeURL: req.body.SubscribeURL,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (confirmError) {
|
} catch (confirmError) {
|
||||||
@@ -58,7 +81,6 @@ export class Webhooks {
|
|||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Error confirming subscription',
|
message: 'Error confirming subscription',
|
||||||
subscribeURL: req.body.SubscribeURL,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -760,6 +760,18 @@ export class CampaignService {
|
|||||||
throw new HttpException(404, 'Segment not found');
|
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 condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {type Contact, Prisma} from '@plunk/db';
|
import {type Contact, Prisma} from '@plunk/db';
|
||||||
import {isValidLanguageCode} from '@plunk/shared';
|
|
||||||
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
||||||
import {toPrismaJson} from '@plunk/types';
|
import {toPrismaJson} from '@plunk/types';
|
||||||
|
|
||||||
@@ -199,6 +198,7 @@ export class ContactService {
|
|||||||
email: string,
|
email: string,
|
||||||
data?: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
subscribed?: boolean,
|
subscribed?: boolean,
|
||||||
|
defaultSubscribed: boolean = true,
|
||||||
): Promise<Contact> {
|
): Promise<Contact> {
|
||||||
// Find existing contact
|
// Find existing contact
|
||||||
const existing = await prisma.contact.findFirst({
|
const existing = await prisma.contact.findFirst({
|
||||||
@@ -235,12 +235,9 @@ export class ContactService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate locale field (special user-settable field)
|
// 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 (key === 'locale') {
|
||||||
if (typeof value === 'string') {
|
if (value !== null && value !== undefined && 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) {
|
|
||||||
throw new HttpException(400, 'Locale must be a string');
|
throw new HttpException(400, 'Locale must be a string');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,7 +289,7 @@ export class ContactService {
|
|||||||
projectId,
|
projectId,
|
||||||
email,
|
email,
|
||||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
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 {Keys} from './keys.js';
|
||||||
import {MembershipService} from './MembershipService.js';
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {deleteIdentity, getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
import {
|
||||||
|
deleteIdentity,
|
||||||
|
disableFeedbackForwarding,
|
||||||
|
getDomainVerificationAttributes,
|
||||||
|
verifyDomain,
|
||||||
|
} from './SESService.js';
|
||||||
|
|
||||||
export class DomainService {
|
export class DomainService {
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +77,43 @@ export class DomainService {
|
|||||||
|
|
||||||
const attributes = await getDomainVerificationAttributes(domain.domain);
|
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
|
// Update domain if verification status changed
|
||||||
if (attributes.status === 'Success' && !domain.verified) {
|
if (attributes.status === 'Success' && !domain.verified) {
|
||||||
const updatedDomain = await prisma.domain.update({
|
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
|
// Send notification about domain verified
|
||||||
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface Attachment {
|
|||||||
filename: string;
|
filename: string;
|
||||||
content: string; // Base64 encoded
|
content: string; // Base64 encoded
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
contentId?: string;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SendEmailParams {
|
interface SendEmailParams {
|
||||||
@@ -381,7 +383,13 @@ export class EmailService {
|
|||||||
// Parse attachments from JSON
|
// Parse attachments from JSON
|
||||||
const attachments =
|
const attachments =
|
||||||
email.attachments && Array.isArray(email.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;
|
: undefined;
|
||||||
|
|
||||||
// Determine tracking based on project settings and email type
|
// Determine tracking based on project settings and email type
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ interface SendRawEmailParams {
|
|||||||
filename: string;
|
filename: string;
|
||||||
content: string; // Base64 encoded
|
content: string; // Base64 encoded
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
contentId?: string;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
tracking?: boolean;
|
tracking?: boolean;
|
||||||
@@ -101,8 +103,13 @@ export async function sendRawEmail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique boundaries for multipart messages
|
// Generate unique boundaries for multipart messages
|
||||||
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
|
const altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
|
||||||
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null;
|
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
|
// Format To header with names if provided
|
||||||
const toHeader = to
|
const toHeader = to
|
||||||
@@ -118,17 +125,21 @@ export async function sendRawEmail({
|
|||||||
// Extract just email addresses for Destinations (SES requirement)
|
// Extract just email addresses for Destinations (SES requirement)
|
||||||
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
|
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
|
// Build raw MIME message
|
||||||
const rawMessage = `From: ${from.name} <${from.email}>
|
let rawMessage = `From: ${from.name} <${from.email}>
|
||||||
To: ${toHeader}
|
To: ${toHeader}
|
||||||
Reply-To: ${reply || from.email}
|
Reply-To: ${reply || from.email}
|
||||||
Subject: ${content.subject}
|
Subject: ${content.subject}
|
||||||
MIME-Version: 1.0
|
MIME-Version: 1.0
|
||||||
${
|
Content-Type: ${rootContentType}
|
||||||
mixedBoundary
|
|
||||||
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
|
|
||||||
: `Content-Type: multipart/alternative; boundary="${boundary}"`
|
|
||||||
}
|
|
||||||
${
|
${
|
||||||
headers
|
headers
|
||||||
? Object.entries(headers)
|
? Object.entries(headers)
|
||||||
@@ -138,29 +149,61 @@ ${
|
|||||||
}
|
}
|
||||||
${unsubscribeHeader}
|
${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-Type: text/html; charset=utf-8
|
||||||
Content-Transfer-Encoding: 7bit
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
${breakLongLines(content.html, 500)}
|
${breakLongLines(content.html, 500)}
|
||||||
--${boundary}--
|
--${altBoundary}--
|
||||||
${
|
`;
|
||||||
attachments && attachments.length > 0
|
|
||||||
? '\n' +
|
// Add inline attachments to the related container
|
||||||
attachments
|
if (relatedBoundary) {
|
||||||
.map(
|
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
|
||||||
attachment => `--${mixedBoundary}
|
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-Type: ${attachment.contentType}
|
||||||
Content-Transfer-Encoding: base64
|
Content-Transfer-Encoding: base64
|
||||||
Content-Disposition: attachment; filename="${attachment.filename}"
|
Content-Disposition: attachment; filename="${attachment.filename}"
|
||||||
|
|
||||||
${breakLongLines(attachment.content, 76, true)}`,
|
${breakLongLines(attachment.content, 76, true)}`;
|
||||||
)
|
}
|
||||||
.join('\n')
|
rawMessage += `\n--${mixedBoundary}--`;
|
||||||
: ''
|
}
|
||||||
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
|
|
||||||
|
|
||||||
// Determine which configuration set to use
|
// Determine which configuration set to use
|
||||||
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
// 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 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 {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
@@ -66,11 +66,33 @@ export class SegmentService {
|
|||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
): Promise<PaginatedResponse<Contact>> {
|
): Promise<PaginatedResponse<Contact>> {
|
||||||
const segment = await this.get(projectId, segmentId);
|
const segment = await this.get(projectId, segmentId);
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
|
||||||
|
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
|
||||||
const skip = (page - 1) * pageSize;
|
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([
|
const [contacts, total] = await Promise.all([
|
||||||
prisma.contact.findMany({
|
prisma.contact.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -98,23 +120,35 @@ export class SegmentService {
|
|||||||
data: {
|
data: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
condition: FilterCondition;
|
type?: SegmentType;
|
||||||
|
condition?: FilterCondition;
|
||||||
trackMembership?: boolean;
|
trackMembership?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<Segment> {
|
): 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
|
// Validate condition
|
||||||
this.validateCondition(data.condition);
|
this.validateCondition(data.condition);
|
||||||
|
|
||||||
// Compute initial member count
|
// Compute initial member count
|
||||||
const where = this.buildWhereClause(projectId, data.condition);
|
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({
|
const segment = await prisma.segment.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
condition: toPrismaJson(data.condition),
|
type: segmentType,
|
||||||
|
condition: conditionJson,
|
||||||
trackMembership: data.trackMembership ?? false,
|
trackMembership: data.trackMembership ?? false,
|
||||||
memberCount,
|
memberCount,
|
||||||
},
|
},
|
||||||
@@ -145,12 +179,7 @@ export class SegmentService {
|
|||||||
},
|
},
|
||||||
): Promise<Segment> {
|
): Promise<Segment> {
|
||||||
// First verify segment exists and belongs to project
|
// First verify segment exists and belongs to project
|
||||||
await this.get(projectId, segmentId);
|
const existing = await this.get(projectId, segmentId);
|
||||||
|
|
||||||
// Validate condition if provided
|
|
||||||
if (data.condition) {
|
|
||||||
this.validateCondition(data.condition);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Prisma.SegmentUpdateInput = {};
|
const updateData: Prisma.SegmentUpdateInput = {};
|
||||||
|
|
||||||
@@ -160,7 +189,9 @@ export class SegmentService {
|
|||||||
if (data.description !== undefined) {
|
if (data.description !== undefined) {
|
||||||
updateData.description = data.description;
|
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);
|
updateData.condition = toPrismaJson(data.condition);
|
||||||
|
|
||||||
// Recompute member count when condition changes
|
// Recompute member count when condition changes
|
||||||
@@ -229,10 +260,16 @@ export class SegmentService {
|
|||||||
*/
|
*/
|
||||||
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
||||||
const segment = await this.get(projectId, segmentId);
|
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 condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
memberCount = await prisma.contact.count({where});
|
||||||
const memberCount = await prisma.contact.count({where});
|
}
|
||||||
|
|
||||||
await prisma.segment.update({
|
await prisma.segment.update({
|
||||||
where: {id: segmentId},
|
where: {id: segmentId},
|
||||||
@@ -249,7 +286,7 @@ export class SegmentService {
|
|||||||
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
||||||
const segments = await prisma.segment.findMany({
|
const segments = await prisma.segment.findMany({
|
||||||
where: {projectId},
|
where: {projectId},
|
||||||
select: {id: true, condition: true},
|
select: {id: true, type: true, condition: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process in batches to avoid overwhelming the database
|
// Process in batches to avoid overwhelming the database
|
||||||
@@ -260,9 +297,17 @@ export class SegmentService {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async segment => {
|
batch.map(async segment => {
|
||||||
try {
|
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 condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
const memberCount = await prisma.contact.count({where});
|
memberCount = await prisma.contact.count({where});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.segment.update({
|
await prisma.segment.update({
|
||||||
where: {id: segment.id},
|
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
|
* Compute or recompute segment membership for all contacts
|
||||||
* Now uses cursor-based pagination for memory efficiency with large contact lists
|
* 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');
|
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 condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const where = this.buildWhereClause(projectId, 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
|
* Extract base domain from URL for cookie sharing across subdomains
|
||||||
* e.g., "http://api.example.com" -> ".example.com"
|
* e.g., "http://api.example.com" -> ".example.com"
|
||||||
* e.g., "http://api.localhost" -> ".localhost"
|
* e.g., "http://api.localhost" -> ".localhost"
|
||||||
|
* e.g., "http://app.plunk.local" -> ".plunk.local"
|
||||||
*/
|
*/
|
||||||
function getCookieDomain(): string | undefined {
|
function getCookieDomain(): string | undefined {
|
||||||
if (NODE_ENV === 'development') {
|
if (NODE_ENV === 'development') {
|
||||||
@@ -28,10 +29,14 @@ function getCookieDomain(): string | undefined {
|
|||||||
// Extract base domain (last two parts for most domains, or .localhost)
|
// Extract base domain (last two parts for most domains, or .localhost)
|
||||||
const parts = hostname.split('.');
|
const parts = hostname.split('.');
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
// For *.localhost or *.local, use the full hostname with leading dot
|
// For *.localhost, use .localhost (reserved TLD)
|
||||||
if (hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
|
if (hostname.endsWith('.localhost')) {
|
||||||
return '.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)
|
// For other domains, use the last two parts (e.g., .example.com)
|
||||||
return `.${parts.slice(-2).join('.')}`;
|
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 {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import {ActionSchemas} from '@plunk/shared';
|
import {ActionSchemas} from '@plunk/shared';
|
||||||
import {EmailService} from '../EmailService';
|
import {EmailService} from '../EmailService';
|
||||||
import {sendRawEmail} from '../SESService';
|
import {sendRawEmail} from '../SESService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
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', () => ({
|
vi.mock('../SESService', () => ({
|
||||||
sendRawEmail: vi.fn(),
|
sendRawEmail: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -801,5 +822,204 @@ describe('EmailService', () => {
|
|||||||
expect(result.success).toBe(true);
|
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}--`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plunk/db": "*",
|
"@plunk/db": "*",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"mailparser": "^3.7.1",
|
"mailparser": "^3.9.3",
|
||||||
"signale": "^1.4.0",
|
"signale": "^1.4.0",
|
||||||
"smtp-server": "^3.13.4"
|
"smtp-server": "^3.13.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -74,6 +74,11 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
const mobileProjectMenuRef = useRef<HTMLDivElement>(null);
|
const mobileProjectMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const mobileUserMenuRef = 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
|
// Handle click outside for project menu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
@@ -179,8 +184,8 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
|
|
||||||
{/* Project Dropdown */}
|
{/* Project Dropdown */}
|
||||||
{showProjectMenu && (
|
{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">
|
<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">
|
||||||
{availableProjects.map(project => (
|
{sortedProjects.map(project => (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
@@ -189,14 +194,14 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
setActiveProject(project);
|
setActiveProject(project);
|
||||||
setShowProjectMenu(false);
|
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()}
|
{project.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
||||||
{activeProject?.id === project.id && (
|
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -204,7 +209,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
<Link
|
<Link
|
||||||
href="/projects/create"
|
href="/projects/create"
|
||||||
onClick={() => setShowProjectMenu(false)}
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
<span>Create project</span>
|
<span>Create project</span>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {Contact, Segment} from '@plunk/db';
|
|||||||
import type {PaginatedResponse} from '@plunk/types';
|
import type {PaginatedResponse} from '@plunk/types';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
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 Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
@@ -22,8 +22,12 @@ import useSWR from 'swr';
|
|||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
import {SegmentSchemas} from '@plunk/shared';
|
import {SegmentSchemas} from '@plunk/shared';
|
||||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||||
|
import {ContactPicker} from '../../components/ContactPicker';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||||
|
type SegmentWithType = Segment & {type: SegmentType};
|
||||||
|
|
||||||
// Count total filters in a condition (recursive)
|
// Count total filters in a condition (recursive)
|
||||||
function countFilters(condition: FilterCondition): number {
|
function countFilters(condition: FilterCondition): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -40,11 +44,13 @@ export default function SegmentDetailPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {id} = router.query;
|
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 [contactsPage, setContactsPage] = useState(1);
|
||||||
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedResponse<Contact>>(
|
const {
|
||||||
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
|
data: contactsData,
|
||||||
);
|
isLoading: isLoadingContacts,
|
||||||
|
mutate: mutateContacts,
|
||||||
|
} = useSWR<PaginatedResponse<Contact>>(id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null);
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
@@ -57,6 +63,11 @@ export default function SegmentDetailPage() {
|
|||||||
const [isComputing, setIsComputing] = useState(false);
|
const [isComputing, setIsComputing] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = 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(() => {
|
useEffect(() => {
|
||||||
if (segment) {
|
if (segment) {
|
||||||
setName(segment.name);
|
setName(segment.name);
|
||||||
@@ -79,7 +90,7 @@ export default function SegmentDetailPage() {
|
|||||||
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
||||||
name,
|
name,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
condition,
|
...(segment?.type !== 'STATIC' && {condition}),
|
||||||
trackMembership,
|
trackMembership,
|
||||||
});
|
});
|
||||||
toast.success('Segment updated successfully');
|
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 () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await network.fetch('DELETE', `/segments/${id}`);
|
await network.fetch('DELETE', `/segments/${id}`);
|
||||||
@@ -166,6 +218,8 @@ export default function SegmentDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isStatic = segment.type === 'STATIC';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -178,8 +232,17 @@ export default function SegmentDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
||||||
@@ -244,12 +307,14 @@ export default function SegmentDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Filter Builder */}
|
{/* Filter Builder (DYNAMIC only) */}
|
||||||
|
{!isStatic && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
@@ -260,15 +325,44 @@ export default function SegmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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 */}
|
{/* Contacts */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Matching Contacts</CardTitle>
|
<CardTitle>{isStatic ? 'Members' : 'Matching Contacts'}</CardTitle>
|
||||||
<CardDescription>Contacts that match this segment's filters</CardDescription>
|
<CardDescription>
|
||||||
|
{isStatic ? 'Contacts in this static segment' : "Contacts that match this segment's filters"}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{trackMembership && (
|
{!isStatic && trackMembership && (
|
||||||
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
||||||
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
||||||
{isComputing ? 'Computing...' : 'Recompute'}
|
{isComputing ? 'Computing...' : 'Recompute'}
|
||||||
@@ -284,7 +378,9 @@ export default function SegmentDetailPage() {
|
|||||||
) : contactsData?.data.length === 0 ? (
|
) : contactsData?.data.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -299,11 +395,24 @@ export default function SegmentDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">{contact.email}</span>
|
<span className="text-sm font-medium">{contact.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Link href={`/contacts/${contact.id}`}>
|
<Link href={`/contacts/${contact.id}`}>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -355,6 +464,8 @@ export default function SegmentDetailPage() {
|
|||||||
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
|
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isStatic && (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter className="h-4 w-4 text-neutral-500" />
|
<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}
|
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,18 @@ export default function SegmentsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-lg">{segment.name}</CardTitle>
|
<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 && (
|
{segment.description && (
|
||||||
<CardDescription className="mt-1">{segment.description}</CardDescription>
|
<CardDescription className="mt-1">{segment.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
@@ -170,7 +181,9 @@ export default function SegmentsPage() {
|
|||||||
<span className="text-sm text-neutral-600">Filters</span>
|
<span className="text-sm text-neutral-600">Filters</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
{countFiltersInCondition(segment.condition)}
|
{(segment as unknown as {type: string}).type === 'STATIC'
|
||||||
|
? '—'
|
||||||
|
: countFiltersInCondition(segment.condition)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input
|
|||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||||
|
import {ContactPicker} from '../../components/ContactPicker';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {ArrowLeft, Save} from 'lucide-react';
|
import {ArrowLeft, Save} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -12,10 +13,13 @@ import type {FilterCondition} from '@plunk/types';
|
|||||||
import type {Segment} from '@plunk/db';
|
import type {Segment} from '@plunk/db';
|
||||||
import {SegmentSchemas} from '@plunk/shared';
|
import {SegmentSchemas} from '@plunk/shared';
|
||||||
|
|
||||||
|
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||||
|
|
||||||
export default function NewSegmentPage() {
|
export default function NewSegmentPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [segmentType, setSegmentType] = useState<SegmentType>('DYNAMIC');
|
||||||
const [trackMembership, setTrackMembership] = useState(false);
|
const [trackMembership, setTrackMembership] = useState(false);
|
||||||
const [condition, setCondition] = useState<FilterCondition>({
|
const [condition, setCondition] = useState<FilterCondition>({
|
||||||
logic: 'AND',
|
logic: 'AND',
|
||||||
@@ -25,6 +29,7 @@ export default function NewSegmentPage() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
const [selectedContacts, setSelectedContacts] = useState<string[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -32,13 +37,33 @@ export default function NewSegmentPage() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
const segment = await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||||
name,
|
name,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
condition,
|
type: segmentType,
|
||||||
|
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||||
trackMembership,
|
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');
|
toast.success('Segment created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
void router.push('/segments');
|
void router.push('/segments');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
||||||
@@ -61,10 +86,40 @@ export default function NewSegmentPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
|
<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>
|
||||||
</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">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -118,12 +173,30 @@ export default function NewSegmentPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Filter Builder */}
|
{/* Filter Builder or Contact Picker */}
|
||||||
|
{segmentType === 'DYNAMIC' ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<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
|
### Segments
|
||||||
|
|
||||||
**GET /segments** — List all 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
|
**GET /segments/:id** — Get segment details
|
||||||
**PATCH /segments/:id** — Update segment
|
**PATCH /segments/:id** — Update segment
|
||||||
**DELETE /segments/:id** — Delete segment
|
**DELETE /segments/:id** — Delete segment
|
||||||
**GET /segments/:id/contacts** — List segment members
|
**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
|
### Workflows
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,56 @@
|
|||||||
---
|
---
|
||||||
title: Segments
|
title: Segments
|
||||||
description: Group and target your contacts with dynamic segments
|
description: Group and target your contacts with dynamic or static segments
|
||||||
icon: Layers
|
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
|
## Dynamic 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.
|
|
||||||
|
|
||||||
### Track Membership Changes
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
## Using segments
|
||||||
Segment can be used in various parts of Plunk, including:
|
|
||||||
|
Segments can be used in:
|
||||||
- Targeting contacts in [email campaigns](/concepts/campaigns)
|
- Targeting contacts in [email campaigns](/concepts/campaigns)
|
||||||
- Triggering workflows in [marketing automation](/concepts/workflows)
|
- 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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "plunk",
|
"name": "plunk",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"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;
|
||||||
@@ -199,8 +199,11 @@ model Segment {
|
|||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
|
|
||||||
// Filter condition (evaluated dynamically)
|
// Segment type: DYNAMIC (filter-based) or STATIC (manually managed)
|
||||||
condition Json
|
type SegmentType @default(DYNAMIC)
|
||||||
|
|
||||||
|
// Filter condition (evaluated dynamically, null for STATIC segments)
|
||||||
|
condition Json?
|
||||||
// Nested filter structure with AND/OR logic:
|
// Nested filter structure with AND/OR logic:
|
||||||
// {
|
// {
|
||||||
// logic: "OR",
|
// logic: "OR",
|
||||||
@@ -747,6 +750,11 @@ enum StepExecutionStatus {
|
|||||||
FAILED // Failed with error
|
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 {
|
enum EmailSourceType {
|
||||||
TRANSACTIONAL // Sent via API call
|
TRANSACTIONAL // Sent via API call
|
||||||
CAMPAIGN // Sent as part of broadcast
|
CAMPAIGN // Sent as part of broadcast
|
||||||
|
|||||||
@@ -132,15 +132,20 @@ export const SegmentSchemas = {
|
|||||||
create: z.object({
|
create: z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
condition: filterConditionSchema,
|
type: z.enum(['DYNAMIC', 'STATIC']).default('DYNAMIC'),
|
||||||
|
condition: filterConditionSchema.optional(),
|
||||||
trackMembership: z.boolean().default(false),
|
trackMembership: z.boolean().default(false),
|
||||||
}),
|
}),
|
||||||
update: z.object({
|
update: z.object({
|
||||||
name: z.string().min(1).max(100).optional(),
|
name: z.string().min(1).max(100).optional(),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
|
type: z.enum(['DYNAMIC', 'STATIC']).optional(),
|
||||||
condition: filterConditionSchema.optional(),
|
condition: filterConditionSchema.optional(),
|
||||||
trackMembership: z.boolean().optional(),
|
trackMembership: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
|
members: z.object({
|
||||||
|
emails: z.array(z.string().email()).min(1).max(500),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateSchemas = {
|
export const TemplateSchemas = {
|
||||||
@@ -402,6 +407,17 @@ export const ActionSchemas = {
|
|||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
content: z.string().min(1), // Base64 encoded file content
|
content: z.string().min(1), // Base64 encoded file content
|
||||||
contentType: z.string().min(1).max(255),
|
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
|
.max(10) // Maximum 10 attachments per email
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* Segment and filter types
|
* Segment and filter types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||||
|
|
||||||
// Segment filter types
|
// Segment filter types
|
||||||
export type SegmentFilterOperator =
|
export type SegmentFilterOperator =
|
||||||
// Standard operators (for contact fields)
|
// Standard operators (for contact fields)
|
||||||
@@ -45,13 +47,15 @@ export interface FilterCondition {
|
|||||||
export interface CreateSegmentData {
|
export interface CreateSegmentData {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
condition: FilterCondition;
|
type?: SegmentType;
|
||||||
|
condition?: FilterCondition;
|
||||||
trackMembership?: boolean;
|
trackMembership?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSegmentData {
|
export interface UpdateSegmentData {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
type?: SegmentType;
|
||||||
condition?: FilterCondition;
|
condition?: FilterCondition;
|
||||||
trackMembership?: boolean;
|
trackMembership?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4741,156 +4741,177 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi@npm:4.53.3":
|
"@rollup/rollup-android-arm-eabi@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-android-arm-eabi@npm:4.53.3"
|
resolution: "@rollup/rollup-android-arm-eabi@npm:4.59.0"
|
||||||
conditions: os=android & cpu=arm
|
conditions: os=android & cpu=arm
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64@npm:4.53.3":
|
"@rollup/rollup-android-arm64@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-android-arm64@npm:4.53.3"
|
resolution: "@rollup/rollup-android-arm64@npm:4.59.0"
|
||||||
conditions: os=android & cpu=arm64
|
conditions: os=android & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64@npm:4.53.3":
|
"@rollup/rollup-darwin-arm64@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-darwin-arm64@npm:4.53.3"
|
resolution: "@rollup/rollup-darwin-arm64@npm:4.59.0"
|
||||||
conditions: os=darwin & cpu=arm64
|
conditions: os=darwin & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-darwin-x64@npm:4.53.3":
|
"@rollup/rollup-darwin-x64@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-darwin-x64@npm:4.53.3"
|
resolution: "@rollup/rollup-darwin-x64@npm:4.59.0"
|
||||||
conditions: os=darwin & cpu=x64
|
conditions: os=darwin & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-arm64@npm:4.53.3":
|
"@rollup/rollup-freebsd-arm64@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-freebsd-arm64@npm:4.53.3"
|
resolution: "@rollup/rollup-freebsd-arm64@npm:4.59.0"
|
||||||
conditions: os=freebsd & cpu=arm64
|
conditions: os=freebsd & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-x64@npm:4.53.3":
|
"@rollup/rollup-freebsd-x64@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-freebsd-x64@npm:4.53.3"
|
resolution: "@rollup/rollup-freebsd-x64@npm:4.59.0"
|
||||||
conditions: os=freebsd & cpu=x64
|
conditions: os=freebsd & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf@npm:4.53.3":
|
"@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.53.3"
|
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0"
|
||||||
conditions: os=linux & cpu=arm & libc=glibc
|
conditions: os=linux & cpu=arm & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf@npm:4.53.3":
|
"@rollup/rollup-linux-arm-musleabihf@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.53.3"
|
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.59.0"
|
||||||
conditions: os=linux & cpu=arm & libc=musl
|
conditions: os=linux & cpu=arm & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-gnu@npm:4.53.3":
|
"@rollup/rollup-linux-arm64-gnu@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.53.3"
|
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.59.0"
|
||||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-musl@npm:4.53.3":
|
"@rollup/rollup-linux-arm64-musl@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.53.3"
|
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.59.0"
|
||||||
conditions: os=linux & cpu=arm64 & libc=musl
|
conditions: os=linux & cpu=arm64 & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-gnu@npm:4.53.3":
|
"@rollup/rollup-linux-loong64-gnu@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.53.3"
|
resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.59.0"
|
||||||
conditions: os=linux & cpu=loong64 & libc=glibc
|
conditions: os=linux & cpu=loong64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-gnu@npm:4.53.3":
|
"@rollup/rollup-linux-loong64-musl@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.53.3"
|
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
|
conditions: os=linux & cpu=ppc64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu@npm:4.53.3":
|
"@rollup/rollup-linux-ppc64-musl@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.53.3"
|
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
|
conditions: os=linux & cpu=riscv64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-musl@npm:4.53.3":
|
"@rollup/rollup-linux-riscv64-musl@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.53.3"
|
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.59.0"
|
||||||
conditions: os=linux & cpu=riscv64 & libc=musl
|
conditions: os=linux & cpu=riscv64 & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-s390x-gnu@npm:4.53.3":
|
"@rollup/rollup-linux-s390x-gnu@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.53.3"
|
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.59.0"
|
||||||
conditions: os=linux & cpu=s390x & libc=glibc
|
conditions: os=linux & cpu=s390x & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu@npm:4.53.3":
|
"@rollup/rollup-linux-x64-gnu@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.53.3"
|
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.59.0"
|
||||||
conditions: os=linux & cpu=x64 & libc=glibc
|
conditions: os=linux & cpu=x64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl@npm:4.53.3":
|
"@rollup/rollup-linux-x64-musl@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-linux-x64-musl@npm:4.53.3"
|
resolution: "@rollup/rollup-linux-x64-musl@npm:4.59.0"
|
||||||
conditions: os=linux & cpu=x64 & libc=musl
|
conditions: os=linux & cpu=x64 & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-openharmony-arm64@npm:4.53.3":
|
"@rollup/rollup-openbsd-x64@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-openharmony-arm64@npm:4.53.3"
|
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
|
conditions: os=openharmony & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-win32-arm64-msvc@npm:4.53.3":
|
"@rollup/rollup-win32-arm64-msvc@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.53.3"
|
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.59.0"
|
||||||
conditions: os=win32 & cpu=arm64
|
conditions: os=win32 & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-win32-ia32-msvc@npm:4.53.3":
|
"@rollup/rollup-win32-ia32-msvc@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.53.3"
|
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.59.0"
|
||||||
conditions: os=win32 & cpu=ia32
|
conditions: os=win32 & cpu=ia32
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-gnu@npm:4.53.3":
|
"@rollup/rollup-win32-x64-gnu@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-win32-x64-gnu@npm:4.53.3"
|
resolution: "@rollup/rollup-win32-x64-gnu@npm:4.59.0"
|
||||||
conditions: os=win32 & cpu=x64
|
conditions: os=win32 & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc@npm:4.53.3":
|
"@rollup/rollup-win32-x64-msvc@npm:4.59.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.53.3"
|
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.59.0"
|
||||||
conditions: os=win32 & cpu=x64
|
conditions: os=win32 & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -7746,14 +7767,14 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"ajv@npm:^6.12.4":
|
"ajv@npm:^6.12.4":
|
||||||
version: 6.12.6
|
version: 6.14.0
|
||||||
resolution: "ajv@npm:6.12.6"
|
resolution: "ajv@npm:6.14.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: "npm:^3.1.1"
|
fast-deep-equal: "npm:^3.1.1"
|
||||||
fast-json-stable-stringify: "npm:^2.0.0"
|
fast-json-stable-stringify: "npm:^2.0.0"
|
||||||
json-schema-traverse: "npm:^0.4.1"
|
json-schema-traverse: "npm:^0.4.1"
|
||||||
uri-js: "npm:^4.2.2"
|
uri-js: "npm:^4.2.2"
|
||||||
checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71
|
checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -7869,7 +7890,7 @@ __metadata:
|
|||||||
jsonwebtoken: "npm:^9.0.2"
|
jsonwebtoken: "npm:^9.0.2"
|
||||||
mailchecker: "npm:^6.0.19"
|
mailchecker: "npm:^6.0.19"
|
||||||
morgan: "npm:^1.10.0"
|
morgan: "npm:^1.10.0"
|
||||||
multer: "npm:^2.0.2"
|
multer: "npm:^2.1.1"
|
||||||
signale: "npm:^1.4.0"
|
signale: "npm:^1.4.0"
|
||||||
stripe: "npm:^20.0.0"
|
stripe: "npm:^20.0.0"
|
||||||
tsx: "npm:^4.20.6"
|
tsx: "npm:^4.20.6"
|
||||||
@@ -12037,12 +12058,12 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"iconv-lite@npm:0.7.0":
|
"iconv-lite@npm:0.7.2":
|
||||||
version: 0.7.0
|
version: 0.7.2
|
||||||
resolution: "iconv-lite@npm:0.7.0"
|
resolution: "iconv-lite@npm:0.7.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: "npm:>= 2.1.2 < 3.0.0"
|
safer-buffer: "npm:>= 2.1.2 < 3.0.0"
|
||||||
checksum: 10c0/2382400469071c55b6746c531eed5fa4d033e5db6690b7331fb2a5f59a30d7a9782932e92253db26df33c1cf46fa200a3fbe524a2a7c62037c762283f188ec2f
|
checksum: 10c0/3c228920f3bd307f56bf8363706a776f4a060eb042f131cd23855ceca962951b264d0997ab38a1ad340e1c5df8499ed26e1f4f0db6b2a2ad9befaff22f14b722
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -13361,21 +13382,21 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"mailparser@npm:^3.7.1":
|
"mailparser@npm:^3.9.3":
|
||||||
version: 3.9.1
|
version: 3.9.3
|
||||||
resolution: "mailparser@npm:3.9.1"
|
resolution: "mailparser@npm:3.9.3"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@zone-eu/mailsplit": "npm:5.4.8"
|
"@zone-eu/mailsplit": "npm:5.4.8"
|
||||||
encoding-japanese: "npm:2.2.0"
|
encoding-japanese: "npm:2.2.0"
|
||||||
he: "npm:1.2.0"
|
he: "npm:1.2.0"
|
||||||
html-to-text: "npm:9.0.5"
|
html-to-text: "npm:9.0.5"
|
||||||
iconv-lite: "npm:0.7.0"
|
iconv-lite: "npm:0.7.2"
|
||||||
libmime: "npm:5.3.7"
|
libmime: "npm:5.3.7"
|
||||||
linkify-it: "npm:5.0.0"
|
linkify-it: "npm:5.0.0"
|
||||||
nodemailer: "npm:7.0.11"
|
nodemailer: "npm:7.0.13"
|
||||||
punycode.js: "npm:2.3.1"
|
punycode.js: "npm:2.3.1"
|
||||||
tlds: "npm:1.261.0"
|
tlds: "npm:1.261.0"
|
||||||
checksum: 10c0/3542fd211b7a2b3266c5e5469aa4281a280c500ae84eadcd2af7e69ec92faa4fccc883f19de614566c6eb647504412cb0bf37844bcb6d3eecf9f0277154f6f90
|
checksum: 10c0/da62c7cd977867da8be0dd1e6cf3b137821258e0d1e0976a00d3ec17bbd8a92bbefbccc7b0ec1dd33bc5ccd28dc5d51bbae723fac8ca05be843e88b7d579c751
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -14376,17 +14397,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"module-punycode@npm:[email protected], punycode@npm:^2.1.0":
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
resolution: "punycode@npm:2.3.1"
|
resolution: "punycode@npm:2.3.1"
|
||||||
@@ -14487,18 +14497,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"multer@npm:^2.0.2":
|
"multer@npm:^2.1.1":
|
||||||
version: 2.0.2
|
version: 2.1.1
|
||||||
resolution: "multer@npm:2.0.2"
|
resolution: "multer@npm:2.1.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
append-field: "npm:^1.0.0"
|
append-field: "npm:^1.0.0"
|
||||||
busboy: "npm:^1.6.0"
|
busboy: "npm:^1.6.0"
|
||||||
concat-stream: "npm:^2.0.0"
|
concat-stream: "npm:^2.0.0"
|
||||||
mkdirp: "npm:^0.5.6"
|
|
||||||
object-assign: "npm:^4.1.1"
|
|
||||||
type-is: "npm:^1.6.18"
|
type-is: "npm:^1.6.18"
|
||||||
xtend: "npm:^4.0.2"
|
checksum: 10c0/2ec4e02833b20f403cfb879d4b64d2a9070d902b9deae7aef18a6faadb707d7665385456cf540aa8a6dadfe3d4c5fc8e0e7b0675b94e1077048b1125426deee6
|
||||||
checksum: 10c0/d3b99dd0512169bbabf15440e1bbb3ecdc000b761e5a3e4aaca40b5e5e213c6cdcc9b7dffebaa601b7691a84f6876aa87e0173ffcc47139253793cf5657819eb
|
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -14818,6 +14825,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"nopt@npm:^9.0.0":
|
||||||
version: 9.0.0
|
version: 9.0.0
|
||||||
resolution: "nopt@npm:9.0.0"
|
resolution: "nopt@npm:9.0.0"
|
||||||
@@ -16626,31 +16640,34 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"rollup@npm:^4.43.0":
|
"rollup@npm:^4.43.0":
|
||||||
version: 4.53.3
|
version: 4.59.0
|
||||||
resolution: "rollup@npm:4.53.3"
|
resolution: "rollup@npm:4.59.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@rollup/rollup-android-arm-eabi": "npm:4.53.3"
|
"@rollup/rollup-android-arm-eabi": "npm:4.59.0"
|
||||||
"@rollup/rollup-android-arm64": "npm:4.53.3"
|
"@rollup/rollup-android-arm64": "npm:4.59.0"
|
||||||
"@rollup/rollup-darwin-arm64": "npm:4.53.3"
|
"@rollup/rollup-darwin-arm64": "npm:4.59.0"
|
||||||
"@rollup/rollup-darwin-x64": "npm:4.53.3"
|
"@rollup/rollup-darwin-x64": "npm:4.59.0"
|
||||||
"@rollup/rollup-freebsd-arm64": "npm:4.53.3"
|
"@rollup/rollup-freebsd-arm64": "npm:4.59.0"
|
||||||
"@rollup/rollup-freebsd-x64": "npm:4.53.3"
|
"@rollup/rollup-freebsd-x64": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.53.3"
|
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "npm:4.53.3"
|
"@rollup/rollup-linux-arm-musleabihf": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-arm64-gnu": "npm:4.53.3"
|
"@rollup/rollup-linux-arm64-gnu": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-arm64-musl": "npm:4.53.3"
|
"@rollup/rollup-linux-arm64-musl": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-loong64-gnu": "npm:4.53.3"
|
"@rollup/rollup-linux-loong64-gnu": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "npm:4.53.3"
|
"@rollup/rollup-linux-loong64-musl": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "npm:4.53.3"
|
"@rollup/rollup-linux-ppc64-gnu": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-riscv64-musl": "npm:4.53.3"
|
"@rollup/rollup-linux-ppc64-musl": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-s390x-gnu": "npm:4.53.3"
|
"@rollup/rollup-linux-riscv64-gnu": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-x64-gnu": "npm:4.53.3"
|
"@rollup/rollup-linux-riscv64-musl": "npm:4.59.0"
|
||||||
"@rollup/rollup-linux-x64-musl": "npm:4.53.3"
|
"@rollup/rollup-linux-s390x-gnu": "npm:4.59.0"
|
||||||
"@rollup/rollup-openharmony-arm64": "npm:4.53.3"
|
"@rollup/rollup-linux-x64-gnu": "npm:4.59.0"
|
||||||
"@rollup/rollup-win32-arm64-msvc": "npm:4.53.3"
|
"@rollup/rollup-linux-x64-musl": "npm:4.59.0"
|
||||||
"@rollup/rollup-win32-ia32-msvc": "npm:4.53.3"
|
"@rollup/rollup-openbsd-x64": "npm:4.59.0"
|
||||||
"@rollup/rollup-win32-x64-gnu": "npm:4.53.3"
|
"@rollup/rollup-openharmony-arm64": "npm:4.59.0"
|
||||||
"@rollup/rollup-win32-x64-msvc": "npm:4.53.3"
|
"@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"
|
"@types/estree": "npm:1.0.8"
|
||||||
fsevents: "npm:~2.3.2"
|
fsevents: "npm:~2.3.2"
|
||||||
dependenciesMeta:
|
dependenciesMeta:
|
||||||
@@ -16676,8 +16693,12 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
"@rollup/rollup-linux-loong64-gnu":
|
"@rollup/rollup-linux-loong64-gnu":
|
||||||
optional: true
|
optional: true
|
||||||
|
"@rollup/rollup-linux-loong64-musl":
|
||||||
|
optional: true
|
||||||
"@rollup/rollup-linux-ppc64-gnu":
|
"@rollup/rollup-linux-ppc64-gnu":
|
||||||
optional: true
|
optional: true
|
||||||
|
"@rollup/rollup-linux-ppc64-musl":
|
||||||
|
optional: true
|
||||||
"@rollup/rollup-linux-riscv64-gnu":
|
"@rollup/rollup-linux-riscv64-gnu":
|
||||||
optional: true
|
optional: true
|
||||||
"@rollup/rollup-linux-riscv64-musl":
|
"@rollup/rollup-linux-riscv64-musl":
|
||||||
@@ -16688,6 +16709,8 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
"@rollup/rollup-linux-x64-musl":
|
"@rollup/rollup-linux-x64-musl":
|
||||||
optional: true
|
optional: true
|
||||||
|
"@rollup/rollup-openbsd-x64":
|
||||||
|
optional: true
|
||||||
"@rollup/rollup-openharmony-arm64":
|
"@rollup/rollup-openharmony-arm64":
|
||||||
optional: true
|
optional: true
|
||||||
"@rollup/rollup-win32-arm64-msvc":
|
"@rollup/rollup-win32-arm64-msvc":
|
||||||
@@ -16702,7 +16725,7 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
rollup: dist/bin/rollup
|
rollup: dist/bin/rollup
|
||||||
checksum: 10c0/a21305aac72013083bd0dec92162b0f7f24cacf57c876ca601ec76e892895952c9ea592c1c07f23b8c125f7979c2b17f7fb565e386d03ee4c1f0952ac4ab0d75
|
checksum: 10c0/f38742da34cfee5e899302615fa157aa77cb6a2a1495e5e3ce4cc9c540d3262e235bbe60caa31562bbfe492b01fdb3e7a8c43c39d842d3293bcf843123b766fc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -17341,7 +17364,7 @@ __metadata:
|
|||||||
"@types/signale": "npm:^1.4.7"
|
"@types/signale": "npm:^1.4.7"
|
||||||
"@types/smtp-server": "npm:^3.5.10"
|
"@types/smtp-server": "npm:^3.5.10"
|
||||||
dotenv: "npm:^17.2.3"
|
dotenv: "npm:^17.2.3"
|
||||||
mailparser: "npm:^3.7.1"
|
mailparser: "npm:^3.9.3"
|
||||||
signale: "npm:^1.4.0"
|
signale: "npm:^1.4.0"
|
||||||
smtp-server: "npm:^3.13.4"
|
smtp-server: "npm:^3.13.4"
|
||||||
tsx: "npm:^4.20.6"
|
tsx: "npm:^4.20.6"
|
||||||
@@ -18004,15 +18027,15 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"tar@npm:^7.5.2":
|
"tar@npm:^7.5.2":
|
||||||
version: 7.5.7
|
version: 7.5.10
|
||||||
resolution: "tar@npm:7.5.7"
|
resolution: "tar@npm:7.5.10"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@isaacs/fs-minipass": "npm:^4.0.0"
|
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||||
chownr: "npm:^3.0.0"
|
chownr: "npm:^3.0.0"
|
||||||
minipass: "npm:^7.1.2"
|
minipass: "npm:^7.1.2"
|
||||||
minizlib: "npm:^3.1.0"
|
minizlib: "npm:^3.1.0"
|
||||||
yallist: "npm:^5.0.0"
|
yallist: "npm:^5.0.0"
|
||||||
checksum: 10c0/51f261afc437e1112c3e7919478d6176ea83f7f7727864d8c2cce10f0b03a631d1911644a567348c3063c45abdae39718ba97abb073d22aa3538b9a53ae1e31c
|
checksum: 10c0/ed905e4b33886377df6e9206e5d1bd34458c21666e27943f946799416f86348c938590d573d6a69312cb29c583b122647a64ec92782f2b7e24e68d985dd72531
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -19385,13 +19408,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"y18n@npm:^5.0.5":
|
||||||
version: 5.0.8
|
version: 5.0.8
|
||||||
resolution: "y18n@npm:5.0.8"
|
resolution: "y18n@npm:5.0.8"
|
||||||
|
|||||||
Reference in New Issue
Block a user