diff --git a/apps/api/src/jobs/segment-count-processor.ts b/apps/api/src/jobs/segment-count-processor.ts index 17963dd..4225042 100644 --- a/apps/api/src/jobs/segment-count-processor.ts +++ b/apps/api/src/jobs/segment-count-processor.ts @@ -29,10 +29,6 @@ async function processProjectSegments(projectId: string, projectName?: string): const trackedSegments = 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) let updatedSegmentCount = 0; let totalAdded = 0; @@ -41,13 +37,7 @@ async function processProjectSegments(projectId: string, projectName?: string): if (trackedSegments.length > 0) { for (const segment of trackedSegments) { try { - signale.info( - `[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${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 if (result.added > 0 || result.removed > 0) { @@ -77,7 +67,6 @@ async function processProjectSegments(projectId: string, projectName?: string): if (nonTrackedSegments.length > 0) { try { await SegmentService.refreshAllMemberCounts(projectId); - signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`); } catch (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): Promise { const {projectId} = job.data; - signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`); - try { if (projectId) { // Process specific project - signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`); await processProjectSegments(projectId); - signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`); } else { // Process all active projects const projects = await prisma.project.findMany({ @@ -105,7 +90,7 @@ async function processSegmentCountUpdate(job: Job): Promise 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 const PROJECT_BATCH_SIZE = 10; @@ -115,9 +100,7 @@ async function processSegmentCountUpdate(job: Job): Promise await Promise.all( batch.map(async project => { try { - signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`); await processProjectSegments(project.id, project.name); - signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`); } catch (error) { signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error); // Don't throw - continue with other projects @@ -130,8 +113,6 @@ async function processSegmentCountUpdate(job: Job): Promise await new Promise(resolve => setTimeout(resolve, 2000)); } } - - signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`); } } catch (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) => { signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error); }); diff --git a/apps/api/src/services/SecurityService.ts b/apps/api/src/services/SecurityService.ts index 3ef7e24..13379a0 100644 --- a/apps/api/src/services/SecurityService.ts +++ b/apps/api/src/services/SecurityService.ts @@ -29,6 +29,13 @@ const SECURITY_THRESHOLDS = { COMPLAINT_7DAY_CRITICAL: 0.15, COMPLAINT_ALLTIME_WARNING: 0.03, 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; interface RateData { @@ -280,51 +287,77 @@ export class SecurityService { // Check 7-day bounce rate (only if 7-day volume is sufficient) 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( - `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( - `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) 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( - `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( - `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) if (hasMinimumVolumeAllTime) { - // Check all-time bounce rate - if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) { + // Check all-time bounce rate - requires BOTH rate AND absolute count + if ( + allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL && + allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL + ) { 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( - `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 - if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) { + // Check all-time complaint rate - requires BOTH rate AND absolute count + if ( + allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL && + allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL + ) { 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( - `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)`, ); } } diff --git a/apps/web/src/components/ActivityFeed.tsx b/apps/web/src/components/ActivityFeed.tsx index 6752f5e..26d2100 100644 --- a/apps/web/src/components/ActivityFeed.tsx +++ b/apps/web/src/components/ActivityFeed.tsx @@ -34,7 +34,8 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi setIsLoadingMore(true); } else { 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); setHasMore(true); } @@ -58,11 +59,25 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi const result = await network.fetch>('GET', `/activity?${params.toString()}`); if (cursor) { - // Append to existing activities + // Append to existing activities (pagination) setActivities(prev => [...prev, ...result.data]); } else { - // Replace activities - setActivities(result.data); + // Smart merge: preserve existing activity objects by ID to maintain component state + 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); @@ -95,7 +110,21 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi 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) { console.error('Error fetching upcoming activities:', err); // Don't set error state for upcoming - just fail silently diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 2aebacb..3f01845 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -5,6 +5,7 @@ import React, {useEffect} from 'react'; import {Toaster} from 'sonner'; import {SWRConfig} from 'swr'; import {DefaultSeo} from 'next-seo'; +import {NuqsAdapter} from 'nuqs/adapters/next/pages'; import {Loader} from '@plunk/ui'; import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider'; import {useProjects} from '../lib/hooks/useProject'; @@ -103,17 +104,19 @@ function ProjectGuard({children}: {children: React.ReactNode}) { */ export default function WithProviders(props: AppProps) { return ( - network.fetch('GET', url), - shouldRetryOnError: false, - }} - > - - - - - + + network.fetch('GET', url), + shouldRetryOnError: false, + }} + > + + + + + + ); } diff --git a/apps/web/src/pages/activity/index.tsx b/apps/web/src/pages/activity/index.tsx index ebb9b0b..b07af1e 100644 --- a/apps/web/src/pages/activity/index.tsx +++ b/apps/web/src/pages/activity/index.tsx @@ -14,7 +14,7 @@ import {DashboardLayout} from '../../components/DashboardLayout'; import {ActivityFeed} from '../../components/ActivityFeed'; import {Eye, MousePointerClick, Send, Zap} from 'lucide-react'; import {NextSeo} from 'next-seo'; -import {useState} from 'react'; +import {useQueryState, parseAsString} from 'nuqs'; import useSWR from 'swr'; interface ActivityStats { @@ -28,8 +28,8 @@ interface ActivityStats { } export default function ActivityPage() { - const [typeFilter, setTypeFilter] = useState('ALL'); - const [dateRange, setDateRange] = useState('30'); + const [typeFilter, setTypeFilter] = useQueryState('type', parseAsString.withDefault('ALL')); + const [dateRange, setDateRange] = useQueryState('days', parseAsString.withDefault('30')); // Fetch activity stats const {data: stats} = useSWR(`/activity/stats`, { diff --git a/apps/wiki/content/docs/concepts/contacts.mdx b/apps/wiki/content/docs/concepts/contacts.mdx index 318d857..cbfade7 100644 --- a/apps/wiki/content/docs/concepts/contacts.mdx +++ b/apps/wiki/content/docs/concepts/contacts.mdx @@ -43,4 +43,32 @@ Certain keys are reserved by the system and automatically set by Plunk: ### Special keys | 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 | \ No newline at end of file +| 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 | + + +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. + \ No newline at end of file diff --git a/apps/wiki/content/docs/guides/meta.json b/apps/wiki/content/docs/guides/meta.json index ae716c1..a2290cf 100644 --- a/apps/wiki/content/docs/guides/meta.json +++ b/apps/wiki/content/docs/guides/meta.json @@ -1,3 +1,3 @@ { - "pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization"] + "pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization", "webhooks"] } diff --git a/apps/wiki/content/docs/guides/webhooks.mdx b/apps/wiki/content/docs/guides/webhooks.mdx new file mode 100644 index 0000000..ef71c2a --- /dev/null +++ b/apps/wiki/content/docs/guides/webhooks.mdx @@ -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..entry` | A contact entered a segment | +| `segment..exit` | A contact exited a segment | + + +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`. + + +## Setting up a webhook + +
+ +### 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. + +
+ +## Webhook payload + +When using the default payload (no custom body configured), Plunk sends a JSON request with the following structure: + +```json +{ + "contact": { + "email": "user@example.com", + "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": "hello@example.com", + "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 \ No newline at end of file diff --git a/packages/shared/src/i18n/index.ts b/packages/shared/src/i18n/index.ts index a8646ff..5170c93 100644 --- a/packages/shared/src/i18n/index.ts +++ b/packages/shared/src/i18n/index.ts @@ -7,6 +7,9 @@ import deTranslations from './locales/de.json' with {type: 'json'}; import hiTranslations from './locales/hi.json' with {type: 'json'}; import ptTranslations from './locales/pt.json' with {type: 'json'}; import bgTranslations from './locales/bg.json' with {type: 'json'}; +import csTranslations from './locales/cs.json' with {type: 'json'}; +import plTranslations from './locales/pl.json' with {type: 'json'}; + export { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE, @@ -38,6 +41,8 @@ const translationsMap: Record = { hi: hiTranslations, pt: ptTranslations, bg: bgTranslations, + cs: csTranslations, + pl: plTranslations, }; // In-memory cache for loaded translations diff --git a/packages/shared/src/i18n/languages.ts b/packages/shared/src/i18n/languages.ts index e081a13..3fa4a13 100644 --- a/packages/shared/src/i18n/languages.ts +++ b/packages/shared/src/i18n/languages.ts @@ -13,6 +13,8 @@ export const SUPPORTED_LANGUAGES: Language[] = [ {code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪'}, {code: 'pt', name: 'Portuguese', nativeName: 'Português', 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'; diff --git a/packages/shared/src/i18n/locales/cs.json b/packages/shared/src/i18n/locales/cs.json new file mode 100644 index 0000000..ea1ea9d --- /dev/null +++ b/packages/shared/src/i18n/locales/cs.json @@ -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" + } + } +} diff --git a/packages/shared/src/i18n/locales/pl.json b/packages/shared/src/i18n/locales/pl.json new file mode 100644 index 0000000..4e2202d --- /dev/null +++ b/packages/shared/src/i18n/locales/pl.json @@ -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" + } + } +} diff --git a/yarn.lock b/yarn.lock index e7c0037..a5d4268 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2821,10 +2821,10 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:16.1.5": - version: 16.1.5 - resolution: "@next/env@npm:16.1.5" - checksum: 10c0/9d6442bee75386593d5da6e952146cf3c4338202e68a0ba9464d70c24ab7abb0c7e4ecd0ab661ca211e544fe7d6bd075270379bee3e97c5da3b6d082f95a04cd +"@next/env@npm:16.1.6": + version: 16.1.6 + resolution: "@next/env@npm:16.1.6" + checksum: 10c0/ed7023edb94b9b2e5da3f9c99d08b614da9757c1edd0ecec792fce4d336b4f0c64db1a84955e07cfbd848b9e61c4118fff28f4098cd7b0a7f97814a90565ebe6 languageName: node linkType: hard @@ -2851,9 +2851,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:16.1.5": - version: 16.1.5 - resolution: "@next/swc-darwin-arm64@npm:16.1.5" +"@next/swc-darwin-arm64@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-darwin-arm64@npm:16.1.6" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -2865,9 +2865,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-x64@npm:16.1.5": - version: 16.1.5 - resolution: "@next/swc-darwin-x64@npm:16.1.5" +"@next/swc-darwin-x64@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-darwin-x64@npm:16.1.6" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -2879,9 +2879,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:16.1.5": - version: 16.1.5 - resolution: "@next/swc-linux-arm64-gnu@npm:16.1.5" +"@next/swc-linux-arm64-gnu@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-arm64-gnu@npm:16.1.6" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -2893,9 +2893,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:16.1.5": - version: 16.1.5 - resolution: "@next/swc-linux-arm64-musl@npm:16.1.5" +"@next/swc-linux-arm64-musl@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-arm64-musl@npm:16.1.6" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -2907,9 +2907,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:16.1.5": - version: 16.1.5 - resolution: "@next/swc-linux-x64-gnu@npm:16.1.5" +"@next/swc-linux-x64-gnu@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-x64-gnu@npm:16.1.6" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -2921,9 +2921,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:16.1.5": - version: 16.1.5 - resolution: "@next/swc-linux-x64-musl@npm:16.1.5" +"@next/swc-linux-x64-musl@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-x64-musl@npm:16.1.6" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -2935,9 +2935,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:16.1.5": - version: 16.1.5 - resolution: "@next/swc-win32-arm64-msvc@npm:16.1.5" +"@next/swc-win32-arm64-msvc@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-win32-arm64-msvc@npm:16.1.6" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -2949,9 +2949,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:16.1.5": - version: 16.1.5 - resolution: "@next/swc-win32-x64-msvc@npm:16.1.5" +"@next/swc-win32-x64-msvc@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-win32-x64-msvc@npm:16.1.6" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -8317,7 +8317,7 @@ __metadata: languageName: node 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 resolution: "browserslist@npm:4.28.1" dependencies: @@ -9820,7 +9820,7 @@ __metadata: languageName: node linkType: hard -"enhanced-resolve@npm:^5.17.3, enhanced-resolve@npm:^5.18.3": +"enhanced-resolve@npm:^5.18.3": version: 5.18.4 resolution: "enhanced-resolve@npm:5.18.4" dependencies: @@ -9830,6 +9830,16 @@ __metadata: languageName: node 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": version: 2.2.0 resolution: "entities@npm:2.2.0" @@ -9988,13 +9998,20 @@ __metadata: languageName: node 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 resolution: "es-module-lexer@npm:1.7.0" checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b languageName: node 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": version: 1.1.1 resolution: "es-object-atoms@npm:1.1.1" @@ -13405,8 +13422,8 @@ __metadata: linkType: hard "markdown-it@npm:^14.0.0": - version: 14.1.0 - resolution: "markdown-it@npm:14.1.0" + version: 14.1.1 + resolution: "markdown-it@npm:14.1.1" dependencies: argparse: "npm:^2.0.1" entities: "npm:^4.4.0" @@ -13416,7 +13433,7 @@ __metadata: uc.micro: "npm:^2.1.0" bin: markdown-it: bin/markdown-it.mjs - checksum: 10c0/9a6bb444181d2db7016a4173ae56a95a62c84d4cbfb6916a399b11d3e6581bf1cc2e4e1d07a2f022ae72c25f56db90fbe1e529fca16fbf9541659dc53480d4b4 + checksum: 10c0/c67f2a4c8069a307c78d8c15104bbcb15a2c6b17f4c904364ca218ec2eccf76a397eba1ea05f5ac5de72c4b67fcf115d422d22df0bfb86a09b663f55b9478d4f languageName: node linkType: hard @@ -14651,18 +14668,18 @@ __metadata: linkType: hard "next@npm:^16.1.5": - version: 16.1.5 - resolution: "next@npm:16.1.5" + version: 16.1.6 + resolution: "next@npm:16.1.6" dependencies: - "@next/env": "npm:16.1.5" - "@next/swc-darwin-arm64": "npm:16.1.5" - "@next/swc-darwin-x64": "npm:16.1.5" - "@next/swc-linux-arm64-gnu": "npm:16.1.5" - "@next/swc-linux-arm64-musl": "npm:16.1.5" - "@next/swc-linux-x64-gnu": "npm:16.1.5" - "@next/swc-linux-x64-musl": "npm:16.1.5" - "@next/swc-win32-arm64-msvc": "npm:16.1.5" - "@next/swc-win32-x64-msvc": "npm:16.1.5" + "@next/env": "npm:16.1.6" + "@next/swc-darwin-arm64": "npm:16.1.6" + "@next/swc-darwin-x64": "npm:16.1.6" + "@next/swc-linux-arm64-gnu": "npm:16.1.6" + "@next/swc-linux-arm64-musl": "npm:16.1.6" + "@next/swc-linux-x64-gnu": "npm:16.1.6" + "@next/swc-linux-x64-musl": "npm:16.1.6" + "@next/swc-win32-arm64-msvc": "npm:16.1.6" + "@next/swc-win32-x64-msvc": "npm:16.1.6" "@swc/helpers": "npm:0.5.15" baseline-browser-mapping: "npm:^2.8.3" caniuse-lite: "npm:^1.0.30001579" @@ -14706,7 +14723,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10c0/ff9f7dd0cae79f7ba2a64ac0d29ca77c94921dfc37b36899f4f997b948242f72585f4bb75829aaf8704d46c6f7d1a9796b8ff30688a3467c04c738b3a4247a9f + checksum: 10c0/543766bf879bb5a5d454dc18cb302953270a92efba1d01dd028ea83c64b69573ce7d6e6c3759ecbaabec0a84131b0237263c24d1ccd7c8a97205e776dcd34e0b languageName: node linkType: hard @@ -15898,11 +15915,11 @@ __metadata: linkType: hard "qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:~6.14.0": - version: 6.14.1 - resolution: "qs@npm:6.14.1" + version: 6.14.2 + resolution: "qs@npm:6.14.2" dependencies: side-channel: "npm:^1.1.0" - checksum: 10c0/0e3b22dc451f48ce5940cbbc7c7d9068d895074f8c969c0801ac15c1313d1859c4d738e46dc4da2f498f41a9ffd8c201bd9fb12df67799b827db94cc373d2613 + checksum: 10c0/646110124476fc9acf3c80994c8c3a0600cbad06a4ede1c9e93341006e8426d64e85e048baf8f0c4995f0f1bf0f37d1f3acc5ec1455850b81978792969a60ef6 languageName: node linkType: hard @@ -17999,7 +18016,7 @@ __metadata: languageName: node linkType: hard -"terser-webpack-plugin@npm:^5.3.11": +"terser-webpack-plugin@npm:^5.3.16": version: 5.3.16 resolution: "terser-webpack-plugin@npm:5.3.16" dependencies: @@ -19017,13 +19034,13 @@ __metadata: languageName: node linkType: hard -"watchpack@npm:^2.4.4": - version: 2.4.4 - resolution: "watchpack@npm:2.4.4" +"watchpack@npm:^2.5.1": + version: 2.5.1 + resolution: "watchpack@npm:2.5.1" dependencies: glob-to-regexp: "npm:^0.4.1" graceful-fs: "npm:^4.1.2" - checksum: 10c0/6c0901f75ce245d33991225af915eea1c5ae4ba087f3aee2b70dd377d4cacb34bef02a48daf109da9d59b2d31ec6463d924a0d72f8618ae1643dd07b95de5275 + checksum: 10c0/dffbb483d1f61be90dc570630a1eb308581e2227d507d783b1d94a57ac7b705ecd9a1a4b73d73c15eab596d39874e5276a3d9cb88bbb698bafc3f8d08c34cf17 languageName: node linkType: hard @@ -19118,8 +19135,8 @@ __metadata: linkType: hard "webpack@npm:^5": - version: 5.103.0 - resolution: "webpack@npm:5.103.0" + version: 5.105.0 + resolution: "webpack@npm:5.105.0" dependencies: "@types/eslint-scope": "npm:^3.7.7" "@types/estree": "npm:^1.0.8" @@ -19129,10 +19146,10 @@ __metadata: "@webassemblyjs/wasm-parser": "npm:^1.14.1" acorn: "npm:^8.15.0" acorn-import-phases: "npm:^1.0.3" - browserslist: "npm:^4.26.3" + browserslist: "npm:^4.28.1" chrome-trace-event: "npm:^1.0.2" - enhanced-resolve: "npm:^5.17.3" - es-module-lexer: "npm:^1.2.1" + enhanced-resolve: "npm:^5.19.0" + es-module-lexer: "npm:^2.0.0" eslint-scope: "npm:5.1.1" events: "npm:^3.2.0" glob-to-regexp: "npm:^0.4.1" @@ -19143,15 +19160,15 @@ __metadata: neo-async: "npm:^2.6.2" schema-utils: "npm:^4.3.3" tapable: "npm:^2.3.0" - terser-webpack-plugin: "npm:^5.3.11" - watchpack: "npm:^2.4.4" + terser-webpack-plugin: "npm:^5.3.16" + watchpack: "npm:^2.5.1" webpack-sources: "npm:^3.3.3" peerDependenciesMeta: webpack-cli: optional: true bin: webpack: bin/webpack.js - checksum: 10c0/d0cf86f8cac249874d6f36292e25011413ebb5bae82c48fa78a165a217e63db00b1a1f563f5195070eb17a055c6da4b6ab89fbdd37f781abdda862aa8c0bd623 + checksum: 10c0/4aea6b976485b5364e122f301c08f48efa84ddb2c0cb5d09f27445d1f2da0b9875cd889e41b58cac3ff05618a9c965be716df52586d151b5f52a7bbed7662174 languageName: node linkType: hard