Merge branch 'next' of https://github.com/5h0ov/plunk into multipart/related-support-embed-image

This commit is contained in:
Shuvadipta Das
2026-02-16 18:27:07 +05:30
13 changed files with 462 additions and 125 deletions
+1 -24
View File
@@ -29,10 +29,6 @@ async function processProjectSegments(projectId: string, projectName?: string):
const trackedSegments = segments.filter(s => s.trackMembership); const trackedSegments = segments.filter(s => s.trackMembership);
const nonTrackedSegments = segments.filter(s => !s.trackMembership); const nonTrackedSegments = segments.filter(s => !s.trackMembership);
signale.info(
`[SEGMENT-COUNT-WORKER] Project ${logPrefix}: ${trackedSegments.length} tracked, ${nonTrackedSegments.length} non-tracked segments`,
);
// Process tracked segments with full membership computation (creates events) // Process tracked segments with full membership computation (creates events)
let updatedSegmentCount = 0; let updatedSegmentCount = 0;
let totalAdded = 0; let totalAdded = 0;
@@ -41,13 +37,7 @@ async function processProjectSegments(projectId: string, projectName?: string):
if (trackedSegments.length > 0) { if (trackedSegments.length > 0) {
for (const segment of trackedSegments) { for (const segment of trackedSegments) {
try { try {
signale.info(
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${segment.id})`,
);
const result = await SegmentService.computeMembership(projectId, segment.id); const result = await SegmentService.computeMembership(projectId, segment.id);
signale.success(
`[SEGMENT-COUNT-WORKER] Segment "${segment.name}": +${result.added} entries, -${result.removed} exits, ${result.total} total members`,
);
// Track segments with actual changes for bundled notification // Track segments with actual changes for bundled notification
if (result.added > 0 || result.removed > 0) { if (result.added > 0 || result.removed > 0) {
@@ -77,7 +67,6 @@ async function processProjectSegments(projectId: string, projectName?: string):
if (nonTrackedSegments.length > 0) { if (nonTrackedSegments.length > 0) {
try { try {
await SegmentService.refreshAllMemberCounts(projectId); await SegmentService.refreshAllMemberCounts(projectId);
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
} catch (error) { } catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error); signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
} }
@@ -90,14 +79,10 @@ async function processProjectSegments(projectId: string, projectName?: string):
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> { async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
const {projectId} = job.data; const {projectId} = job.data;
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
try { try {
if (projectId) { if (projectId) {
// Process specific project // Process specific project
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
await processProjectSegments(projectId); await processProjectSegments(projectId);
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
} else { } else {
// Process all active projects // Process all active projects
const projects = await prisma.project.findMany({ const projects = await prisma.project.findMany({
@@ -105,7 +90,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
select: {id: true, name: true}, select: {id: true, name: true},
}); });
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`); signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
// Process projects in batches to avoid overwhelming the database // Process projects in batches to avoid overwhelming the database
const PROJECT_BATCH_SIZE = 10; const PROJECT_BATCH_SIZE = 10;
@@ -115,9 +100,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
await Promise.all( await Promise.all(
batch.map(async project => { batch.map(async project => {
try { try {
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
await processProjectSegments(project.id, project.name); await processProjectSegments(project.id, project.name);
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
} catch (error) { } catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error); signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
// Don't throw - continue with other projects // Don't throw - continue with other projects
@@ -130,8 +113,6 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
} }
} }
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
} }
} catch (error) { } catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error); signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
@@ -158,10 +139,6 @@ export function createSegmentCountWorker(): Worker {
}, },
); );
worker.on('completed', job => {
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
});
worker.on('failed', (job, error) => { worker.on('failed', (job, error) => {
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error); signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
}); });
+51 -18
View File
@@ -29,6 +29,13 @@ const SECURITY_THRESHOLDS = {
COMPLAINT_7DAY_CRITICAL: 0.15, COMPLAINT_7DAY_CRITICAL: 0.15,
COMPLAINT_ALLTIME_WARNING: 0.03, COMPLAINT_ALLTIME_WARNING: 0.03,
COMPLAINT_ALLTIME_CRITICAL: 0.12, COMPLAINT_ALLTIME_CRITICAL: 0.12,
// Minimum absolute counts (prevents small sample size false positives)
// Both percentage AND absolute count must be exceeded to trigger
MIN_BOUNCES_FOR_CRITICAL: 10,
MIN_BOUNCES_FOR_WARNING: 5,
MIN_COMPLAINTS_FOR_CRITICAL: 5,
MIN_COMPLAINTS_FOR_WARNING: 3,
} as const; } as const;
interface RateData { interface RateData {
@@ -280,51 +287,77 @@ export class SecurityService {
// Check 7-day bounce rate (only if 7-day volume is sufficient) // Check 7-day bounce rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) { if (hasMinimumVolume7Day) {
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) { // Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
) {
violations.push( violations.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`, `7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
); );
} else if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING) { } else if (
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING &&
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
) {
warnings.push( warnings.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`, `7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
); );
} }
} }
// Check 7-day complaint rate (only if 7-day volume is sufficient) // Check 7-day complaint rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) { if (hasMinimumVolume7Day) {
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) { // Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
) {
violations.push( violations.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`, `7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
); );
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) { } else if (
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING &&
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
) {
warnings.push( warnings.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`, `7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
); );
} }
} }
// Check all-time rates (only if all-time volume is sufficient) // Check all-time rates (only if all-time volume is sufficient)
if (hasMinimumVolumeAllTime) { if (hasMinimumVolumeAllTime) {
// Check all-time bounce rate // Check all-time bounce rate - requires BOTH rate AND absolute count
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) { if (
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
) {
violations.push( violations.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%)`, `All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
); );
} else if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING) { } else if (
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING &&
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
) {
warnings.push( warnings.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%)`, `All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
); );
} }
// Check all-time complaint rate // Check all-time complaint rate - requires BOTH rate AND absolute count
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) { if (
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
) {
violations.push( violations.push(
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%)`, `All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
); );
} else if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING) { } else if (
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
) {
warnings.push( warnings.push(
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%)`, `All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
); );
} }
} }
+34 -5
View File
@@ -34,7 +34,8 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
setIsLoadingMore(true); setIsLoadingMore(true);
} else { } else {
setIsLoading(true); setIsLoading(true);
setActivities([]); // Don't clear activities immediately - we'll do a smart merge
// This preserves React component instances and their local state
setNextCursor(undefined); setNextCursor(undefined);
setHasMore(true); setHasMore(true);
} }
@@ -58,11 +59,25 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
const result = await network.fetch<CursorPaginatedResponse<Activity>>('GET', `/activity?${params.toString()}`); const result = await network.fetch<CursorPaginatedResponse<Activity>>('GET', `/activity?${params.toString()}`);
if (cursor) { if (cursor) {
// Append to existing activities // Append to existing activities (pagination)
setActivities(prev => [...prev, ...result.data]); setActivities(prev => [...prev, ...result.data]);
} else { } else {
// Replace activities // Smart merge: preserve existing activity objects by ID to maintain component state
setActivities(result.data); setActivities(prev => {
// If no previous activities, just use new data
if (prev.length === 0) {
return result.data;
}
// Create a map of existing activities by ID for fast lookup
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
// For each new activity, reuse existing object if ID matches (preserves React component instances)
// Otherwise use new object. This maintains correct ordering while preserving component state.
return result.data.map(newActivity =>
existingMap.get(newActivity.id) ?? newActivity
);
});
} }
setNextCursor(result.cursor); setNextCursor(result.cursor);
@@ -95,7 +110,21 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`); const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`);
setUpcomingActivities(result.activities); // Smart merge: preserve existing activity objects by ID to maintain component state
setUpcomingActivities(prev => {
// If no previous activities, just use new data
if (prev.length === 0) {
return result.activities;
}
// Create a map of existing activities by ID for fast lookup
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
// For each new activity, reuse existing object if ID matches
return result.activities.map(newActivity =>
existingMap.get(newActivity.id) ?? newActivity
);
});
} catch (err) { } catch (err) {
console.error('Error fetching upcoming activities:', err); console.error('Error fetching upcoming activities:', err);
// Don't set error state for upcoming - just fail silently // Don't set error state for upcoming - just fail silently
+14 -11
View File
@@ -5,6 +5,7 @@ import React, {useEffect} from 'react';
import {Toaster} from 'sonner'; import {Toaster} from 'sonner';
import {SWRConfig} from 'swr'; import {SWRConfig} from 'swr';
import {DefaultSeo} from 'next-seo'; import {DefaultSeo} from 'next-seo';
import {NuqsAdapter} from 'nuqs/adapters/next/pages';
import {Loader} from '@plunk/ui'; import {Loader} from '@plunk/ui';
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider'; import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
import {useProjects} from '../lib/hooks/useProject'; import {useProjects} from '../lib/hooks/useProject';
@@ -103,17 +104,19 @@ function ProjectGuard({children}: {children: React.ReactNode}) {
*/ */
export default function WithProviders(props: AppProps) { export default function WithProviders(props: AppProps) {
return ( return (
<SWRConfig <NuqsAdapter>
value={{ <SWRConfig
fetcher: (url: string) => network.fetch('GET', url), value={{
shouldRetryOnError: false, fetcher: (url: string) => network.fetch('GET', url),
}} shouldRetryOnError: false,
> }}
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" /> >
<ActiveProjectProvider> <DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
<Root {...props} /> <ActiveProjectProvider>
</ActiveProjectProvider> <Root {...props} />
</SWRConfig> </ActiveProjectProvider>
</SWRConfig>
</NuqsAdapter>
); );
} }
+3 -3
View File
@@ -14,7 +14,7 @@ import {DashboardLayout} from '../../components/DashboardLayout';
import {ActivityFeed} from '../../components/ActivityFeed'; import {ActivityFeed} from '../../components/ActivityFeed';
import {Eye, MousePointerClick, Send, Zap} from 'lucide-react'; import {Eye, MousePointerClick, Send, Zap} from 'lucide-react';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import {useState} from 'react'; import {useQueryState, parseAsString} from 'nuqs';
import useSWR from 'swr'; import useSWR from 'swr';
interface ActivityStats { interface ActivityStats {
@@ -28,8 +28,8 @@ interface ActivityStats {
} }
export default function ActivityPage() { export default function ActivityPage() {
const [typeFilter, setTypeFilter] = useState<string>('ALL'); const [typeFilter, setTypeFilter] = useQueryState('type', parseAsString.withDefault('ALL'));
const [dateRange, setDateRange] = useState<string>('30'); const [dateRange, setDateRange] = useQueryState('days', parseAsString.withDefault('30'));
// Fetch activity stats // Fetch activity stats
const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, { const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, {
+29 -1
View File
@@ -43,4 +43,32 @@ Certain keys are reserved by the system and automatically set by Plunk:
### Special keys ### Special keys
| Key | Description | | Key | Description |
|-----|-------------| |-----|-------------|
| locale | The contact's preferred locale in ISO 639 (e.g. 'en', 'fr', 'es'). Specifying the locale field on a contact will override the project-wide locale for contact-facing pages and email footers | | locale | The contact's preferred locale in ISO 639 (e.g. 'en', 'fr', 'es'). Specifying the locale field on a contact will override the project-wide locale for contact-facing pages and email footers |
## Subscription State
Every contact has a `subscribed` field that determines which types of emails they will receive. A newly created contact is subscribed by default.
### How contacts become unsubscribed
A contact can become unsubscribed in several ways:
- **Manually** through the dashboard or via the API
- **Self-service** by clicking the unsubscribe link in an email
- **Automatically** when an email to the contact bounces or results in a complaint
### Emails by subscription state
The subscription state controls whether a contact receives marketing emails. Transactional emails are always delivered regardless of subscription state.
| Email type | Subscribed | Unsubscribed |
|---|---|---|
| **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered |
| **Campaigns** | Delivered | Not delivered |
| **Automations** (transactional template) | Delivered | Delivered |
| **Automations** (marketing template) | Delivered | Not delivered |
<Callout
title="Transactional emails and marketing templates"
variant="warn">
Even when using the transactional API endpoint (`/v1/send`), you cannot send a marketing template to an unsubscribed contact. Use a transactional template instead if the email must reach unsubscribed contacts.
</Callout>
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization"] "pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization", "webhooks"]
} }
+153
View File
@@ -0,0 +1,153 @@
---
title: Webhooks
description: Send real-time event data from Plunk to your own application using webhooks
icon: Webhook
---
Plunk can send real-time HTTP requests to your application when specific events occur, such as email bounces, spam complaints, or custom events. This is done by creating a [workflow](/concepts/workflows) that uses the **Webhook** step to forward event data to your own endpoint.
## How it works
Webhooks in Plunk are powered by the workflow system. The basic flow is:
1. An event occurs in Plunk (e.g. an email bounces, a contact subscribes, or a custom event is tracked)
2. A workflow is triggered by that event
3. The workflow executes a **Webhook** step, sending an HTTP request to your URL with relevant data
This means you can receive notifications for any event Plunk tracks, including both system events and your own custom events.
## Internal events
Plunk automatically tracks a set of internal events that you can use as workflow triggers. These events cannot be manually tracked via the API — they are generated by the system.
### Email events
| Event | Description |
|-------|-------------|
| `email.sent` | An email was successfully sent |
| `email.delivery` | An email was delivered to the recipient |
| `email.open` | A contact opened an email for the first time |
| `email.click` | A contact clicked a link in an email for the first time |
| `email.bounce` | An email bounced (hard or soft bounce) |
| `email.complaint` | A contact marked an email as spam |
### Contact events
| Event | Description |
|-------|-------------|
| `contact.subscribed` | A contact's subscription status changed to subscribed |
| `contact.unsubscribed` | A contact's subscription status changed to unsubscribed |
### Segment events
| Event | Description |
|-------|-------------|
| `segment.<name>.entry` | A contact entered a segment |
| `segment.<name>.exit` | A contact exited a segment |
<Callout
title="Segment event names"
variant="idea">
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce the events `segment.vip-users.entry` and `segment.vip-users.exit`.
</Callout>
## Setting up a webhook
<div className='fd-steps [&_h3]:fd-step'>
### Create the workflow
Navigate to the **Workflows** section in the dashboard and create a new workflow. Choose the event you want to listen for as the trigger. For example, to receive notifications when an email bounces, use `email.bounce` as the trigger event.
### Add a Webhook step
After the trigger, add a **Webhook** step and configure it:
- **URL**: The endpoint on your server that will receive the webhook (e.g. `https://api.example.com/webhooks/plunk`)
- **Method**: The HTTP method to use. Defaults to `POST`, which is recommended for most use cases.
- **Headers** (optional): Custom headers to include in the request, provided as JSON. This is useful for authentication.
```json
{
"Authorization": "Bearer your-secret-token"
}
```
### Enable the workflow
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
</div>
## Webhook payload
When using the default payload (no custom body configured), Plunk sends a JSON request with the following structure:
```json
{
"contact": {
"email": "[email protected]",
"subscribed": true,
"data": {
"name": "John",
"plan": "pro"
}
},
"workflow": {
"id": "wf_abc123",
"name": "Bounce Notifications"
},
"execution": {
"id": "exec_xyz789",
"startedAt": "2025-01-15T10:30:00.000Z"
},
"event": {
"subject": "Welcome to Plunk",
"from": "[email protected]",
"bounceType": "Permanent"
}
}
```
The `event` field contains the data associated with the event that triggered the workflow. The exact contents depend on the event type.
### Event data by type
The `event` field varies depending on which event triggered the workflow:
| Event | Fields in `event` |
|-------|-------------------|
| `email.sent` | `subject`, `from`, `messageId`, `templateId`, `campaignId`, `sourceType` |
| `email.open` | `subject`, `from`, `openedAt`, `isFirstOpen` |
| `email.click` | `subject`, `from`, `clickedAt`, `clicks`, `isFirstClick` |
| `email.bounce` | `subject`, `from`, `bounceType`, `bouncedAt` |
| `email.complaint` | `subject`, `from`, `complainedAt` |
| Custom events | Whatever data you passed when tracking the event |
## Common use cases
### Bounce and complaint monitoring
Create a workflow triggered by `email.bounce` or `email.complaint` to forward these events to your application. This allows you to keep your own database in sync with Plunk's contact statuses.
You can use additional workflow steps before the webhook to add logic:
- **Condition**: Only send the webhook for hard bounces by checking the `bounceType` field
- **Delay**: Add a short delay to batch-process related events
- **Update Contact**: Mark the contact with metadata before sending the webhook
### Syncing unsubscribes
Trigger a workflow on `contact.unsubscribed` to notify your application when a contact opts out. This is useful for keeping subscription status synchronized across multiple systems.
### Custom event forwarding
If you track custom events in Plunk (e.g. `user.signup`, `order.completed`), you can forward those same events to other services via webhooks. This turns Plunk into an event router — track once, distribute to multiple endpoints.
## Adding conditions and delays
Since webhooks are part of the workflow system, you can combine them with other step types for more advanced setups:
- Use a **Condition** step to only fire the webhook when certain criteria are met (e.g. only notify for contacts on a specific plan)
- Use a **Wait for Event** step to wait for a follow-up event before sending the webhook (e.g. wait to see if a bounced contact re-subscribes)
- Use a **Delay** step to add a time buffer before the webhook fires
+5
View File
@@ -7,6 +7,9 @@ import deTranslations from './locales/de.json' with {type: 'json'};
import hiTranslations from './locales/hi.json' with {type: 'json'}; import hiTranslations from './locales/hi.json' with {type: 'json'};
import ptTranslations from './locales/pt.json' with {type: 'json'}; import ptTranslations from './locales/pt.json' with {type: 'json'};
import bgTranslations from './locales/bg.json' with {type: 'json'}; import bgTranslations from './locales/bg.json' with {type: 'json'};
import csTranslations from './locales/cs.json' with {type: 'json'};
import plTranslations from './locales/pl.json' with {type: 'json'};
export { export {
SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES,
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
@@ -38,6 +41,8 @@ const translationsMap: Record<string, Translations> = {
hi: hiTranslations, hi: hiTranslations,
pt: ptTranslations, pt: ptTranslations,
bg: bgTranslations, bg: bgTranslations,
cs: csTranslations,
pl: plTranslations,
}; };
// In-memory cache for loaded translations // In-memory cache for loaded translations
+2
View File
@@ -13,6 +13,8 @@ export const SUPPORTED_LANGUAGES: Language[] = [
{code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪'}, {code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪'},
{code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷'}, {code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷'},
{code: 'bg', name: 'Bulgarian', nativeName: 'Български', flag: '🇧🇬'}, {code: 'bg', name: 'Bulgarian', nativeName: 'Български', flag: '🇧🇬'},
{code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿'},
{code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱'},
]; ];
export const DEFAULT_LANGUAGE = 'en'; export const DEFAULT_LANGUAGE = 'en';
+45
View File
@@ -0,0 +1,45 @@
{
"pages": {
"unsubscribe": {
"title": "Odhlášení z odběru",
"description": "Mrzí nás, že odcházíte. Opravdu chcete odhlásit {email} z odběru e-mailů?",
"button": "Odhlásit se",
"buttonLoading": "Odhlašování...",
"managePreferences": "Spravovat předvolby",
"successTitle": "Odběr byl zrušen",
"successDescription": "E-mail {email} byl odhlášen z odběru. Už vám nebudeme zasílat žádné e-maily.",
"changedMind": "Změnili jste názor?",
"subscribeAgain": "Přihlásit se znovu"
},
"subscribe": {
"title": "Přihlášení k odběru",
"description": "Chcete přihlásit {email} k odběru e-mailů?",
"button": "Přihlásit se k odběru",
"buttonLoading": "Přihlašování...",
"successTitle": "Odběr byl aktivován!",
"successDescription": "E-mail {email} byl přihlášen k odběru e-mailů."
},
"manage": {
"title": "Správa předvoleb",
"description": "Správa e-mailových předvoleb pro {email}",
"subscriptionLabel": "Odběr e-mailů",
"subscribedStatus": "Aktuálně jste přihlášeni k odběru e-mailů",
"unsubscribedStatus": "Aktuálně jste odhlášeni z odběru e-mailů",
"subscribedSuccess": "Odběr byl úspěšně aktivován!",
"unsubscribedSuccess": "Odběr byl úspěšně zrušen!",
"unsubscribeCompletely": "Úplně se odhlásit z odběru",
"subscribeToEmails": "Přihlásit se k odběru e-mailů",
"disclaimer": "Na této stránce můžete spravovat své e-mailové předvolby. Stav odběru se aktualizuje v reálném čase."
},
"common": {
"loading": "Načítání...",
"error": "Chyba"
}
},
"email": {
"footer": {
"unsubscribeText": "Tento e-mail jste obdrželi, protože jste souhlasili se zasíláním e-mailů od {projectName}. Pokud si již nepřejete dostávat tyto e-maily, můžete",
"updatePreferences": "změnit své předvolby"
}
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"pages": {
"unsubscribe": {
"title": "Wypisz się",
"description": "Przykro nam, że odchodzisz. Czy na pewno chcesz wypisać {email} z otrzymywania wiadomości?",
"button": "Wypisz się",
"buttonLoading": "Wypisywanie...",
"managePreferences": "Zarządzaj preferencjami zamiast tego",
"successTitle": "Wypisano",
"successDescription": "{email} został wypisany. Nie będziesz już otrzymywać od nas wiadomości.",
"changedMind": "Zmieniłeś(-aś) zdanie?",
"subscribeAgain": "Zapisz się ponownie"
},
"subscribe": {
"title": "Zapisz się na aktualizacje",
"description": "Czy chcesz zapisać {email} do otrzymywania wiadomości?",
"button": "Zapisz się",
"buttonLoading": "Zapisywanie...",
"successTitle": "Zapisano!",
"successDescription": "{email} jest teraz zapisany do otrzymywania od nas wiadomości."
},
"manage": {
"title": "Zarządzaj preferencjami",
"description": "Zarządzaj preferencjami e-mail dla adresu {email}",
"subscriptionLabel": "Subskrypcja e-mail",
"subscribedStatus": "Obecnie jesteś zapisany do otrzymywania wiadomości",
"unsubscribedStatus": "Obecnie jesteś wypisany z otrzymywania wiadomości",
"subscribedSuccess": "Pomyślnie zapisano!",
"unsubscribedSuccess": "Pomyślnie wypisano!",
"unsubscribeCompletely": "Wypisz się całkowicie",
"subscribeToEmails": "Zapisz się na e-maile",
"disclaimer": "Ta strona pozwala zarządzać preferencjami e-mail. Status subskrypcji jest aktualizowany w czasie rzeczywistym."
},
"common": {
"loading": "Ładowanie...",
"error": "Błąd"
}
},
"email": {
"footer": {
"unsubscribeText": "Otrzymujesz tę wiadomość, ponieważ wyraziłeś(-aś) zgodę na otrzymywanie wiadomości od {projectName}. Jeśli nie chcesz już otrzymywać takich wiadomości, prosimy",
"updatePreferences": "zaktualizuj swoje preferencje"
}
}
}
+79 -62
View File
@@ -2821,10 +2821,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/env@npm:16.1.5": "@next/env@npm:16.1.6":
version: 16.1.5 version: 16.1.6
resolution: "@next/env@npm:16.1.5" resolution: "@next/env@npm:16.1.6"
checksum: 10c0/9d6442bee75386593d5da6e952146cf3c4338202e68a0ba9464d70c24ab7abb0c7e4ecd0ab661ca211e544fe7d6bd075270379bee3e97c5da3b6d082f95a04cd checksum: 10c0/ed7023edb94b9b2e5da3f9c99d08b614da9757c1edd0ecec792fce4d336b4f0c64db1a84955e07cfbd848b9e61c4118fff28f4098cd7b0a7f97814a90565ebe6
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2851,9 +2851,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-darwin-arm64@npm:16.1.5": "@next/swc-darwin-arm64@npm:16.1.6":
version: 16.1.5 version: 16.1.6
resolution: "@next/swc-darwin-arm64@npm:16.1.5" resolution: "@next/swc-darwin-arm64@npm:16.1.6"
conditions: os=darwin & cpu=arm64 conditions: os=darwin & cpu=arm64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2865,9 +2865,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-darwin-x64@npm:16.1.5": "@next/swc-darwin-x64@npm:16.1.6":
version: 16.1.5 version: 16.1.6
resolution: "@next/swc-darwin-x64@npm:16.1.5" resolution: "@next/swc-darwin-x64@npm:16.1.6"
conditions: os=darwin & cpu=x64 conditions: os=darwin & cpu=x64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2879,9 +2879,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-linux-arm64-gnu@npm:16.1.5": "@next/swc-linux-arm64-gnu@npm:16.1.6":
version: 16.1.5 version: 16.1.6
resolution: "@next/swc-linux-arm64-gnu@npm:16.1.5" resolution: "@next/swc-linux-arm64-gnu@npm:16.1.6"
conditions: os=linux & cpu=arm64 & libc=glibc conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2893,9 +2893,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-linux-arm64-musl@npm:16.1.5": "@next/swc-linux-arm64-musl@npm:16.1.6":
version: 16.1.5 version: 16.1.6
resolution: "@next/swc-linux-arm64-musl@npm:16.1.5" resolution: "@next/swc-linux-arm64-musl@npm:16.1.6"
conditions: os=linux & cpu=arm64 & libc=musl conditions: os=linux & cpu=arm64 & libc=musl
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2907,9 +2907,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-linux-x64-gnu@npm:16.1.5": "@next/swc-linux-x64-gnu@npm:16.1.6":
version: 16.1.5 version: 16.1.6
resolution: "@next/swc-linux-x64-gnu@npm:16.1.5" resolution: "@next/swc-linux-x64-gnu@npm:16.1.6"
conditions: os=linux & cpu=x64 & libc=glibc conditions: os=linux & cpu=x64 & libc=glibc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2921,9 +2921,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-linux-x64-musl@npm:16.1.5": "@next/swc-linux-x64-musl@npm:16.1.6":
version: 16.1.5 version: 16.1.6
resolution: "@next/swc-linux-x64-musl@npm:16.1.5" resolution: "@next/swc-linux-x64-musl@npm:16.1.6"
conditions: os=linux & cpu=x64 & libc=musl conditions: os=linux & cpu=x64 & libc=musl
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2935,9 +2935,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-win32-arm64-msvc@npm:16.1.5": "@next/swc-win32-arm64-msvc@npm:16.1.6":
version: 16.1.5 version: 16.1.6
resolution: "@next/swc-win32-arm64-msvc@npm:16.1.5" resolution: "@next/swc-win32-arm64-msvc@npm:16.1.6"
conditions: os=win32 & cpu=arm64 conditions: os=win32 & cpu=arm64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2949,9 +2949,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-win32-x64-msvc@npm:16.1.5": "@next/swc-win32-x64-msvc@npm:16.1.6":
version: 16.1.5 version: 16.1.6
resolution: "@next/swc-win32-x64-msvc@npm:16.1.5" resolution: "@next/swc-win32-x64-msvc@npm:16.1.6"
conditions: os=win32 & cpu=x64 conditions: os=win32 & cpu=x64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -8317,7 +8317,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"browserslist@npm:^4.24.0, browserslist@npm:^4.24.4, browserslist@npm:^4.26.3": "browserslist@npm:^4.24.0, browserslist@npm:^4.24.4, browserslist@npm:^4.28.1":
version: 4.28.1 version: 4.28.1
resolution: "browserslist@npm:4.28.1" resolution: "browserslist@npm:4.28.1"
dependencies: dependencies:
@@ -9820,7 +9820,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"enhanced-resolve@npm:^5.17.3, enhanced-resolve@npm:^5.18.3": "enhanced-resolve@npm:^5.18.3":
version: 5.18.4 version: 5.18.4
resolution: "enhanced-resolve@npm:5.18.4" resolution: "enhanced-resolve@npm:5.18.4"
dependencies: dependencies:
@@ -9830,6 +9830,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"enhanced-resolve@npm:^5.19.0":
version: 5.19.0
resolution: "enhanced-resolve@npm:5.19.0"
dependencies:
graceful-fs: "npm:^4.2.4"
tapable: "npm:^2.3.0"
checksum: 10c0/966b1dffb82d5f6a4d6a86e904e812104a999066aa29f9223040aaa751e7c453b462a3f5ef91f8bd4408131ff6f7f90651dd1c804bdcb7944e2099a9c2e45ee2
languageName: node
linkType: hard
"entities@npm:^2.0.0": "entities@npm:^2.0.0":
version: 2.2.0 version: 2.2.0
resolution: "entities@npm:2.2.0" resolution: "entities@npm:2.2.0"
@@ -9988,13 +9998,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"es-module-lexer@npm:^1.2.1, es-module-lexer@npm:^1.7.0": "es-module-lexer@npm:^1.7.0":
version: 1.7.0 version: 1.7.0
resolution: "es-module-lexer@npm:1.7.0" resolution: "es-module-lexer@npm:1.7.0"
checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b
languageName: node languageName: node
linkType: hard linkType: hard
"es-module-lexer@npm:^2.0.0":
version: 2.0.0
resolution: "es-module-lexer@npm:2.0.0"
checksum: 10c0/ae78dbbd43035a4b972c46cfb6877e374ea290adfc62bc2f5a083fea242c0b2baaab25c5886af86be55f092f4a326741cb94334cd3c478c383fdc8a9ec5ff817
languageName: node
linkType: hard
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1 version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1" resolution: "es-object-atoms@npm:1.1.1"
@@ -13405,8 +13422,8 @@ __metadata:
linkType: hard linkType: hard
"markdown-it@npm:^14.0.0": "markdown-it@npm:^14.0.0":
version: 14.1.0 version: 14.1.1
resolution: "markdown-it@npm:14.1.0" resolution: "markdown-it@npm:14.1.1"
dependencies: dependencies:
argparse: "npm:^2.0.1" argparse: "npm:^2.0.1"
entities: "npm:^4.4.0" entities: "npm:^4.4.0"
@@ -13416,7 +13433,7 @@ __metadata:
uc.micro: "npm:^2.1.0" uc.micro: "npm:^2.1.0"
bin: bin:
markdown-it: bin/markdown-it.mjs markdown-it: bin/markdown-it.mjs
checksum: 10c0/9a6bb444181d2db7016a4173ae56a95a62c84d4cbfb6916a399b11d3e6581bf1cc2e4e1d07a2f022ae72c25f56db90fbe1e529fca16fbf9541659dc53480d4b4 checksum: 10c0/c67f2a4c8069a307c78d8c15104bbcb15a2c6b17f4c904364ca218ec2eccf76a397eba1ea05f5ac5de72c4b67fcf115d422d22df0bfb86a09b663f55b9478d4f
languageName: node languageName: node
linkType: hard linkType: hard
@@ -14651,18 +14668,18 @@ __metadata:
linkType: hard linkType: hard
"next@npm:^16.1.5": "next@npm:^16.1.5":
version: 16.1.5 version: 16.1.6
resolution: "next@npm:16.1.5" resolution: "next@npm:16.1.6"
dependencies: dependencies:
"@next/env": "npm:16.1.5" "@next/env": "npm:16.1.6"
"@next/swc-darwin-arm64": "npm:16.1.5" "@next/swc-darwin-arm64": "npm:16.1.6"
"@next/swc-darwin-x64": "npm:16.1.5" "@next/swc-darwin-x64": "npm:16.1.6"
"@next/swc-linux-arm64-gnu": "npm:16.1.5" "@next/swc-linux-arm64-gnu": "npm:16.1.6"
"@next/swc-linux-arm64-musl": "npm:16.1.5" "@next/swc-linux-arm64-musl": "npm:16.1.6"
"@next/swc-linux-x64-gnu": "npm:16.1.5" "@next/swc-linux-x64-gnu": "npm:16.1.6"
"@next/swc-linux-x64-musl": "npm:16.1.5" "@next/swc-linux-x64-musl": "npm:16.1.6"
"@next/swc-win32-arm64-msvc": "npm:16.1.5" "@next/swc-win32-arm64-msvc": "npm:16.1.6"
"@next/swc-win32-x64-msvc": "npm:16.1.5" "@next/swc-win32-x64-msvc": "npm:16.1.6"
"@swc/helpers": "npm:0.5.15" "@swc/helpers": "npm:0.5.15"
baseline-browser-mapping: "npm:^2.8.3" baseline-browser-mapping: "npm:^2.8.3"
caniuse-lite: "npm:^1.0.30001579" caniuse-lite: "npm:^1.0.30001579"
@@ -14706,7 +14723,7 @@ __metadata:
optional: true optional: true
bin: bin:
next: dist/bin/next next: dist/bin/next
checksum: 10c0/ff9f7dd0cae79f7ba2a64ac0d29ca77c94921dfc37b36899f4f997b948242f72585f4bb75829aaf8704d46c6f7d1a9796b8ff30688a3467c04c738b3a4247a9f checksum: 10c0/543766bf879bb5a5d454dc18cb302953270a92efba1d01dd028ea83c64b69573ce7d6e6c3759ecbaabec0a84131b0237263c24d1ccd7c8a97205e776dcd34e0b
languageName: node languageName: node
linkType: hard linkType: hard
@@ -15898,11 +15915,11 @@ __metadata:
linkType: hard linkType: hard
"qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:~6.14.0": "qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:~6.14.0":
version: 6.14.1 version: 6.14.2
resolution: "qs@npm:6.14.1" resolution: "qs@npm:6.14.2"
dependencies: dependencies:
side-channel: "npm:^1.1.0" side-channel: "npm:^1.1.0"
checksum: 10c0/0e3b22dc451f48ce5940cbbc7c7d9068d895074f8c969c0801ac15c1313d1859c4d738e46dc4da2f498f41a9ffd8c201bd9fb12df67799b827db94cc373d2613 checksum: 10c0/646110124476fc9acf3c80994c8c3a0600cbad06a4ede1c9e93341006e8426d64e85e048baf8f0c4995f0f1bf0f37d1f3acc5ec1455850b81978792969a60ef6
languageName: node languageName: node
linkType: hard linkType: hard
@@ -17999,7 +18016,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"terser-webpack-plugin@npm:^5.3.11": "terser-webpack-plugin@npm:^5.3.16":
version: 5.3.16 version: 5.3.16
resolution: "terser-webpack-plugin@npm:5.3.16" resolution: "terser-webpack-plugin@npm:5.3.16"
dependencies: dependencies:
@@ -19017,13 +19034,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"watchpack@npm:^2.4.4": "watchpack@npm:^2.5.1":
version: 2.4.4 version: 2.5.1
resolution: "watchpack@npm:2.4.4" resolution: "watchpack@npm:2.5.1"
dependencies: dependencies:
glob-to-regexp: "npm:^0.4.1" glob-to-regexp: "npm:^0.4.1"
graceful-fs: "npm:^4.1.2" graceful-fs: "npm:^4.1.2"
checksum: 10c0/6c0901f75ce245d33991225af915eea1c5ae4ba087f3aee2b70dd377d4cacb34bef02a48daf109da9d59b2d31ec6463d924a0d72f8618ae1643dd07b95de5275 checksum: 10c0/dffbb483d1f61be90dc570630a1eb308581e2227d507d783b1d94a57ac7b705ecd9a1a4b73d73c15eab596d39874e5276a3d9cb88bbb698bafc3f8d08c34cf17
languageName: node languageName: node
linkType: hard linkType: hard
@@ -19118,8 +19135,8 @@ __metadata:
linkType: hard linkType: hard
"webpack@npm:^5": "webpack@npm:^5":
version: 5.103.0 version: 5.105.0
resolution: "webpack@npm:5.103.0" resolution: "webpack@npm:5.105.0"
dependencies: dependencies:
"@types/eslint-scope": "npm:^3.7.7" "@types/eslint-scope": "npm:^3.7.7"
"@types/estree": "npm:^1.0.8" "@types/estree": "npm:^1.0.8"
@@ -19129,10 +19146,10 @@ __metadata:
"@webassemblyjs/wasm-parser": "npm:^1.14.1" "@webassemblyjs/wasm-parser": "npm:^1.14.1"
acorn: "npm:^8.15.0" acorn: "npm:^8.15.0"
acorn-import-phases: "npm:^1.0.3" acorn-import-phases: "npm:^1.0.3"
browserslist: "npm:^4.26.3" browserslist: "npm:^4.28.1"
chrome-trace-event: "npm:^1.0.2" chrome-trace-event: "npm:^1.0.2"
enhanced-resolve: "npm:^5.17.3" enhanced-resolve: "npm:^5.19.0"
es-module-lexer: "npm:^1.2.1" es-module-lexer: "npm:^2.0.0"
eslint-scope: "npm:5.1.1" eslint-scope: "npm:5.1.1"
events: "npm:^3.2.0" events: "npm:^3.2.0"
glob-to-regexp: "npm:^0.4.1" glob-to-regexp: "npm:^0.4.1"
@@ -19143,15 +19160,15 @@ __metadata:
neo-async: "npm:^2.6.2" neo-async: "npm:^2.6.2"
schema-utils: "npm:^4.3.3" schema-utils: "npm:^4.3.3"
tapable: "npm:^2.3.0" tapable: "npm:^2.3.0"
terser-webpack-plugin: "npm:^5.3.11" terser-webpack-plugin: "npm:^5.3.16"
watchpack: "npm:^2.4.4" watchpack: "npm:^2.5.1"
webpack-sources: "npm:^3.3.3" webpack-sources: "npm:^3.3.3"
peerDependenciesMeta: peerDependenciesMeta:
webpack-cli: webpack-cli:
optional: true optional: true
bin: bin:
webpack: bin/webpack.js webpack: bin/webpack.js
checksum: 10c0/d0cf86f8cac249874d6f36292e25011413ebb5bae82c48fa78a165a217e63db00b1a1f563f5195070eb17a055c6da4b6ab89fbdd37f781abdda862aa8c0bd623 checksum: 10c0/4aea6b976485b5364e122f301c08f48efa84ddb2c0cb5d09f27445d1f2da0b9875cd889e41b58cac3ff05618a9c965be716df52586d151b5f52a7bbed7662174
languageName: node languageName: node
linkType: hard linkType: hard