` fields you import are typed and used.
+- [Bulk operations](/api-reference/overview#contacts) — `bulk-subscribe`, `bulk-unsubscribe`, `bulk-delete` for mass actions on existing contacts (max 1,000 per call).
diff --git a/apps/wiki/content/docs/guides/list-hygiene.mdx b/apps/wiki/content/docs/guides/list-hygiene.mdx
index 9c85013..ff22375 100644
--- a/apps/wiki/content/docs/guides/list-hygiene.mdx
+++ b/apps/wiki/content/docs/guides/list-hygiene.mdx
@@ -8,24 +8,32 @@ Plunk automatically monitors the bounce and complaint rates of your emails to he
## Bounce Management
-A bounce occurs when an email cannot be delivered to the recipient's inbox. When an email bounces, Plunk will automatically unsubscribe the contact and also send an event on the contact for `email.bounced`.
+A bounce occurs when an email cannot be delivered to the recipient's inbox. Plunk records every bounce as an `email.bounce` event on the contact, and automatically unsubscribes the contact **only on permanent (hard) bounces**. Transient (soft) bounces don't change subscription state — they're typically retried by the upstream mail server.
### Types of Bounces
-| Type | Description |
-|------|-------------|
-| Hard Bounce | Permanent delivery failure (e.g., invalid email address) |
-| Soft Bounce | Temporary delivery failure (e.g., mailbox full) |
+
+| Type | Description | Auto-unsubscribes? |
+|------|-------------|---------------------|
+| **Permanent (hard)** | Permanent delivery failure — invalid address, blocked domain, recipient rejected. | Yes |
+| **Transient (soft)** | Temporary delivery failure — mailbox full, server unavailable, greylisted. | No |
+| **Undetermined** | The upstream provider couldn't classify the bounce. | No |
+
+Both bounce types fire an `email.bounce` event you can branch on inside workflows or webhooks (use the event payload's `bounceType` field to distinguish them).
### Preventing bounces
-- [Verify email addresses](/api-reference/public-api/verifyEmail)
-- Regularly clean your email list
-- Use double opt-in for subscriptions
+
+- [Verify email addresses](/api-reference/public-api/verifyEmail) at signup before adding them to your list.
+- Regularly clean your email list — segment unengaged contacts and re-confirm or remove them.
+- Build a confirmation flow with a workflow that sends a verification email and only marks the contact as confirmed when they click through.
## Complaint Management
-A complaint occurs when a recipient marks your email as spam. When a complaint is received, Plunk will automatically unsubscribe the contact and send an event on the contact for `email.complaint`.
+A complaint occurs when a recipient marks your email as spam in their inbox provider. When Plunk receives that complaint, the contact is **always automatically unsubscribed** and Plunk fires an `email.complaint` event on the contact.
+
+Complaints are taken more seriously than bounces by mail providers — even a small complaint rate can hurt your sender reputation and deliverability.
### Preventing complaints
-- Ensure your emails are relevant and valuable to your audience
-- Include a clear unsubscribe link in every email
-- Monitor your email frequency to avoid overwhelming your contacts
\ No newline at end of file
+- Ensure your emails are relevant and valuable to your audience.
+- Include a clear, working unsubscribe link in every marketing email — Plunk injects this automatically for `MARKETING` template/campaign types.
+- Monitor your email frequency to avoid overwhelming your contacts.
+- Make sure recipients clearly opted in. Avoid scraped, purchased, or stale lists.
\ No newline at end of file
diff --git a/apps/wiki/content/docs/guides/localization.mdx b/apps/wiki/content/docs/guides/localization.mdx
index 7e346a8..cf0dba3 100644
--- a/apps/wiki/content/docs/guides/localization.mdx
+++ b/apps/wiki/content/docs/guides/localization.mdx
@@ -1,15 +1,66 @@
---
title: Localization
-description: Support for multiple languages and regions
+description: Translate the unsubscribe footer and contact-facing pages into your contacts' languages
icon: Globe
---
-Localization in Plunk allows you to configure the language of the unsubscribe footer and contact-facing pages (subscribe, unsubscribe, manage) to better suit your audience.
+Plunk localizes the strings it controls — the auto-injected unsubscribe footer on marketing emails, the hosted unsubscribe / subscribe / preferences pages — into the language of your audience. Your own template content (subject lines, body copy) is **not** translated automatically; you control that yourself.
-## Configuring Localization
-You can set the default language for your project in the project settings.
+## Setting the project default
-## Overriding language per contact
-You can override the default language for individual contacts by setting the `locale` field in the contact data. This field should contain a valid [ISO 639](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language code (e.g., 'en' for English, 'fr' for French, 'es' for Spanish).
+Open **Settings → Project** and pick a default language. This is used for any contact who doesn't have a specific language set on their record.
-When sending emails, Plunk will use the contact's specified language if available; otherwise, it will fall back to the project's default language.
\ No newline at end of file
+## Overriding per contact
+
+Set a `locale` field on the contact's `data` to override the project default for that recipient:
+
+```bash
+curl -X POST {{API_URL}}/v1/track \
+ -H "Authorization: Bearer sk_..." \
+ -H "Content-Type: application/json" \
+ -d '{
+ "email": "ada@example.com",
+ "event": "signed_up",
+ "data": { "locale": "fr" }
+ }'
+```
+
+Use a [BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) / ISO 639 code that matches one of the supported languages below. You can also reference `{{locale}}` inside template variables to branch your own copy on the contact's language.
+
+If the contact's `locale` doesn't match a supported language, Plunk falls back to the project default. If neither is set, English is used.
+
+## Supported languages
+
+| Code | Language |
+| -------- | -------------------------------------- |
+| `en` | English |
+| `nl` | Dutch (Nederlands) |
+| `fr` | French (Français) |
+| `de` | German (Deutsch) |
+| `es` | Spanish (Español) |
+| `it` | Italian (Italiano) |
+| `pt` | Portuguese (Português) |
+| `pl` | Polish (Polski) |
+| `cs` | Czech (Čeština) |
+| `bg` | Bulgarian (Български) |
+| `hi` | Hindi (हिंदी) |
+| `zh-CN` | Chinese (Simplified, China) |
+| `zh-TW` | Chinese (Traditional, Taiwan) |
+| `zh-HK` | Chinese (Traditional, Hong Kong) |
+
+## What gets translated
+
+| Surface | Translated? |
+| ---------------------------------------- | ----------- |
+| Unsubscribe footer on marketing emails | Yes |
+| Hosted unsubscribe / subscribe / preferences pages | Yes |
+| **Your own template subject and body** | No — author per-language templates and branch on `{{locale}}` |
+
+## Translating your own templates
+
+Plunk doesn't translate the content of your templates. Two common patterns:
+
+- **One template per language**, picked in your code or workflow before sending. Name them with the locale suffix (`welcome-en`, `welcome-fr`).
+- **One template with branched copy**, using `{{locale}}` inside Handlebars conditionals to inline different language variants. Workable for short copy; gets unwieldy for long emails.
+
+For larger localization needs, the per-language template approach is easier to maintain.
diff --git a/apps/wiki/content/docs/guides/meta.json b/apps/wiki/content/docs/guides/meta.json
index 4a59a62..0a09b5d 100644
--- a/apps/wiki/content/docs/guides/meta.json
+++ b/apps/wiki/content/docs/guides/meta.json
@@ -1,3 +1,15 @@
{
- "pages": ["list-hygiene", "verifying-domains", "receiving-emails", "tracking", "api-keys", "localization", "webhooks"]
+ "pages": [
+ "list-hygiene",
+ "verifying-domains",
+ "receiving-emails",
+ "tracking",
+ "api-keys",
+ "localization",
+ "webhooks",
+ "importing-contacts",
+ "custom-fields",
+ "segment-filters",
+ "unsubscribe-pages"
+ ]
}
diff --git a/apps/wiki/content/docs/guides/receiving-emails.mdx b/apps/wiki/content/docs/guides/receiving-emails.mdx
index 6923fe2..6c07e7f 100644
--- a/apps/wiki/content/docs/guides/receiving-emails.mdx
+++ b/apps/wiki/content/docs/guides/receiving-emails.mdx
@@ -1,110 +1,145 @@
---
title: Receiving emails
-description: Receive incoming emails at your verified domain and trigger automated workflows
+description: Receive incoming email at your verified domain and turn it into events you can drive workflows from
icon: Inbox
---
-Plunk can receive emails sent to your verified domain and turn them into `email.received` events. This allows you to build automated workflows that respond to inbound emails, such as support ticket systems, auto-responders, or email-based integrations.
+Plunk can receive emails sent to any address at your verified domain, store them in your project, and emit an `email.received` event you can drive workflows from. This unlocks auto-replies, ticketing, conditional forwarding, and any other "do something when an email arrives" pattern.
-## How it works
+## What happens when an email arrives
-When someone sends an email to your verified domain (e.g., `support@yourdomain.com` or `hello@yourdomain.com`), Plunk will:
+When someone emails an address at your verified domain, Plunk:
-1. Receive the email through AWS SES
-2. Automatically create or update a contact for the sender
-3. Trigger an `email.received` event that can start workflows
-4. Make the email metadata available to your workflow steps
+1. Parses the message and stores it as an inbound `Email` record visible in your project's Activity feed.
+2. Creates the sender as a contact in your project (or updates them if they already exist), subscribed by default.
+3. Tracks an `email.received` event on the sender contact, which any workflow can listen to.
-The sender is automatically added to your contacts as a subscribed contact, allowing you to respond using Plunk's email sending capabilities.
+The HTML body is sanitized before being stored — scripts, iframes, event handlers, and `javascript:` URIs are stripped. Plain text is preserved as-is when no HTML part is available.
-## Setting up inbound email
+## Setup
-
+import {Step, Steps} from 'fumadocs-ui/components/steps';
-### Verify your domain
+
-Before you can receive emails, your domain must be verified in Plunk. Follow the [verifying domains](/guides/verifying-domains) guide to set up the required DKIM, SPF, and MX records for sending.
+
+
+### Verify your domain for sending
+
+Inbound is only enabled on domains that are fully verified for sending (DKIM + SPF + the bounce-feedback MX). Follow the [Verifying domains](/guides/verifying-domains) guide first.
+
+
+
+
### Add the inbound MX record
-In your project settings, navigate to the **Domains** tab and expand your verified domain. You'll see an optional "Inbound Email" section with an MX record configuration:
+Open your project's **Domains** tab, expand the verified domain, and look for the **Inbound Email** section. Plunk shows you the exact MX record value to add to your DNS — copy it from there and add it as an MX record on your domain.
-- **Type**: MX
-- **Name**: `yourdomain.com` (your root domain)
-- **Value**: `10 inbound-smtp.eu-north-1.amazonaws.com`
-
-Add this MX record to your domain's DNS settings. The priority value `10` ensures that inbound emails are routed to AWS SES for processing.
-
-
- If you're already using MX records for another email service (like Google Workspace or Microsoft 365), adding this MX
- record may conflict. You can only have one primary email receiver per domain. Consider using a subdomain (e.g.,
- `mail.yourdomain.com`) if you need to maintain both services.
+
+A domain can only have one primary inbound MX target. If you already use Google Workspace, Microsoft 365, or another provider for receiving email on the apex domain, your existing email will break if you switch the MX to point at Plunk. The usual fix is to receive Plunk inbound on a subdomain (e.g. `mail.yourdomain.com` or `support.yourdomain.com`) so you can keep your main mailbox on the existing provider. The subdomain still needs to be verified for sending in Plunk before its MX will be accepted.
+
+
+
+
### Wait for DNS propagation
-DNS changes can take anywhere from a few minutes to 48 hours to fully propagate. You can verify the MX record is set correctly using:
+DNS changes take anywhere from a few minutes to 48 hours to propagate. You can verify the MX record is live:
```bash
dig MX yourdomain.com
```
-You should see the AWS SES inbound endpoint in the response.
+You should see the value Plunk gave you in the response.
-
+
-## Creating workflows with email.received
+
-Once your MX record is configured, you can create workflows that respond to incoming emails.
+### Send a test email
-### Event trigger
+Send an email from any external account to any address at your domain (e.g. `anything@yourdomain.com`) and check that:
-Create a new workflow and use `email.received` as the trigger event. This workflow will run every time an email is received at your domain.
+- A new `Email` row appears in the project's Activity feed with type **Inbound**.
+- The sender shows up as a contact in the **Contacts** tab.
+- An `email.received` event is recorded on that contact.
-### Available event data
+
-The `email.received` event includes the following data that you can use in your workflow steps:
+
-| Field | Type | Description |
-| ---------------------- | -------- | ------------------------------------------------- |
-| `messageId` | string | Unique identifier for the received message |
-| `from` | string | Email address of the sender |
-| `fromHeader` | string | Full "From" header including display name |
-| `to` | string | Primary recipient email address |
-| `subject` | string | Email subject line |
-| `timestamp` | string | ISO 8601 timestamp when the email was received |
-| `recipients` | string[] | All recipient email addresses |
-| `hasContent` | boolean | Whether the email body was captured |
-| `body` | string | HTML body of the email (or plain text if no HTML) |
-| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
-| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
-| `spfVerdict` | string | SPF authentication result |
-| `dkimVerdict` | string | DKIM authentication result |
-| `dmarcVerdict` | string | DMARC authentication result |
-| `processingTimeMillis` | number | Time taken to process the email |
+## What gets stored
-You can access these fields in your workflow using variable syntax, for example: `{{event.subject}}`, `{{event.from}}`, or `{{event.body}}`.
+Every accepted inbound email shows up in your project's Activity feed alongside outbound emails — you can search, filter, and inspect them the same way.
-### Example: Auto-reply workflow
+Plunk stores: the parsed and sanitized HTML body (or plain text if no HTML is available), the headers surfaced in the event payload below, the authentication and spam verdicts, and the message ID.
-Here's a simple workflow that sends an automatic reply when an email is received at `support@yourdomain.com`:
+**Plunk does not store**: the original raw message, attachments, threading headers (`In-Reply-To`, `References`), or headers beyond what's exposed on the event. If you need any of those, forward the email to your own service via a `WEBHOOK` step in the workflow that fires.
-1. **Trigger**: `email.received`
-2. **Condition**: Check if `{{event.to}}` equals `support@yourdomain.com`
-3. **Send Email**:
+## The `email.received` event
+
+The event is emitted on the sender contact (auto-created if it doesn't exist yet) and carries the full parsed message in its data field:
+
+import {TypeTable} from 'fumadocs-ui/components/type-table';
+
+"`.' },
+ to: { type: 'string', description: 'Primary recipient at your verified domain.' },
+ recipients: { type: 'array of strings', description: 'Every recipient address (covers `To`, `Cc`, and BCC envelope recipients).' },
+ subject: { type: 'string', description: 'Subject line.' },
+ timestamp: { type: 'string', description: 'ISO 8601 receive timestamp.' },
+ hasContent: { type: 'boolean', description: '`true` when a body was successfully parsed.' },
+ body: { type: 'string', description: 'Sanitized HTML body, or plain text if no HTML part. See sanitization rules above.' },
+ spamVerdict: { type: 'string', description: 'Spam check result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
+ virusVerdict: { type: 'string', description: 'Virus scan result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
+ spfVerdict: { type: 'string', description: 'SPF authentication result.' },
+ dkimVerdict: { type: 'string', description: 'DKIM authentication result.' },
+ dmarcVerdict: { type: 'string', description: 'DMARC authentication result.' },
+ processingTimeMillis: { type: 'number', description: 'How long it took to process the email.' },
+ }}
+/>
+
+The event is **always** emitted — Plunk does not block emails based on the spam, virus, or authentication verdicts. Filter them yourself in the workflow that fires.
+
+In templates and workflow steps, access these fields via the event variable namespace, e.g. `{{event.subject}}`, `{{event.from}}`, or `{{event.body}}`.
+
+## Building workflows on inbound
+
+### Auto-reply
+
+A minimal auto-reply on `support@yourdomain.com`:
+
+1. **Trigger**: `email.received`.
+2. **Condition**: continue only if `event.to` equals `support@yourdomain.com` and `event.spamVerdict == "PASS"` and `event.virusVerdict == "PASS"`.
+3. **Send email**:
- **To**: `{{event.from}}`
- **Subject**: `Re: {{event.subject}}`
- - **Body**: `Thank you for your message. We received: "{{event.body}}". We'll get back to you soon!`
+ - **Body**: `Thanks for your message. We've received your email and will respond within one business day.`
-This workflow reads the incoming email body and includes it in the auto-reply response.
+Use a `TRANSACTIONAL` template for the auto-reply so it bypasses subscription checks (the sender is auto-subscribed but you don't want to fail to reply if they later unsubscribe).
-### Example: Forward to webhook
+### Routing by recipient
-For more advanced processing (like ticket creation or AI analysis), you can forward the email content to your own API:
+Route different addresses to different downstream actions in a single workflow:
-1. **Trigger**: `email.received`
+1. **Trigger**: `email.received`.
+2. **Condition**: branch on `event.to`:
+ - `support@…` → ticketing webhook → auto-reply.
+ - `sales@…` → CRM webhook → notify Slack.
+ - `billing@…` → billing system webhook.
+
+### Forward to your API
+
+For richer processing (NLP classification, ticket creation, attachments not captured by Plunk), forward the email to your own backend:
+
+1. **Trigger**: `email.received`.
2. **Webhook**:
- - **URL**: `https://api.example.com/support/tickets`
+ - **URL**: `https://api.example.com/inbound`
- **Method**: `POST`
- **Body**:
```json
@@ -112,59 +147,89 @@ For more advanced processing (like ticket creation or AI analysis), you can forw
"from": "{{event.from}}",
"subject": "{{event.subject}}",
"body": "{{event.body}}",
- "timestamp": "{{event.timestamp}}"
+ "messageId": "{{event.messageId}}",
+ "timestamp": "{{event.timestamp}}",
+ "verdicts": {
+ "spam": "{{event.spamVerdict}}",
+ "virus": "{{event.virusVerdict}}",
+ "spf": "{{event.spfVerdict}}",
+ "dkim": "{{event.dkimVerdict}}",
+ "dmarc": "{{event.dmarcVerdict}}"
+ }
}
```
-Your backend receives the full email content and can process it (create a ticket, run AI analysis, etc.).
+### Filter spam and virus before processing
+
+Always include a `CONDITION` step early in the workflow that drops anything where `spamVerdict` or `virusVerdict` is `FAIL`. Plunk does not pre-filter for you.
## Multi-project domains
-If you have verified the same domain in multiple projects, incoming emails will be processed for **all projects** that have the domain verified. Each project will:
+If the same domain is verified in multiple projects, every inbound email is delivered to **every project** that has it verified — each gets its own `Email` record, its own contact upsert, and its own `email.received` event. This is by design (it lets you split a single inbox across staging and production projects, or hand off the same inbound stream to multiple teams).
-- Create/update the sender as a contact in that project
-- Trigger the `email.received` event in that project
-- Run any workflows configured for that event
+If you don't want this, only verify the domain in one project at a time.
-This allows you to segment inbound email handling across different projects if needed.
+## Billing
-## Limitations
+Inbound emails count toward your project's email usage at **1 credit per received email**, just like outbound. The free tier and paid tiers consume the same pool.
-- **Catch-all addresses**: Plunk receives emails sent to any address at your verified domain (e.g., `anything@yourdomain.com`). You can use workflow conditions to route emails based on the `to` field.
-- **Attachments**: Email attachments are not currently captured or stored.
-- **Email size**: AWS SES has a maximum message size limit of 40 MB for inbound emails.
+You can set a per-project inbound cap under **Billing → Limits**. Once the cap is reached, **inbound emails are silently dropped for that project** until the cap resets — they aren't queued or replayed. Other projects sharing the same domain are unaffected by another project's cap.
## Security considerations
-Plunk captures several security verdicts for each incoming email:
+- **Treat the body as untrusted user input.** Plunk sanitizes HTML to prevent the obvious script-injection paths, but the message can still contain phishing links, social-engineering content, and unicode lookalikes. Don't render the body verbatim in any UI you control without re-escaping it for that context.
+- **Authentication verdicts are advisory.** Plunk records SPF / DKIM / DMARC results on the event but does not enforce them. Build your own policy: dropping unauthenticated mail at the workflow's first `CONDITION` step is a sensible default for sensitive inboxes (billing, account changes).
+- **Senders are auto-subscribed.** Inbound senders enter your audience as subscribed contacts. If you don't want that, add an `UPDATE_CONTACT` step at the end of the inbound workflow to set `subscribed: false`.
+- **Reply-loop risk.** If your auto-reply sends back to a domain you also receive on (or to a list address), you can create an infinite loop. Add a `CONDITION` that drops messages where `event.from` matches your own domain.
-- **SPF (Sender Policy Framework)**: Verifies the sender's mail server is authorized
-- **DKIM (DomainKeys Identified Mail)**: Validates the email hasn't been tampered with
-- **DMARC (Domain-based Message Authentication)**: Combines SPF and DKIM for additional validation
-- **Spam verdict**: AWS SES's spam detection result
-- **Virus verdict**: AWS SES's virus scanning result
+## Limitations
-You can use these verdicts in workflow conditions to automatically filter or quarantine suspicious emails before processing them.
+- **Catch-all only**: Plunk routes anything sent to any address at your domain to the same handler. You filter on `event.to` inside the workflow.
+- **No attachments**: attachments are dropped during parsing. Forward to your own service if you need them.
+- **No raw MIME**: the original message is not retained.
+- **No threading**: Plunk doesn't group inbound messages into threads or correlate them with outbound replies.
+- **40 MB size cap**: messages larger than 40 MB are rejected before processing.
## Troubleshooting
-### Emails not being received
+import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
-1. **Check DNS propagation**: Verify the MX record is correctly set using `dig MX yourdomain.com`
-2. **Verify domain**: Ensure your domain is fully verified in Plunk (all DKIM, SPF, and MX records for sending)
-3. **Check workflow**: Create a simple test workflow with just an `email.received` trigger and a webhook to verify events are being generated
-4. **Check sender**: Try sending from a different email provider as some may cache DNS records
+
-### Duplicate events
+
-If you have the same domain verified in multiple projects, you will receive duplicate `email.received` events (one per project). This is expected behavior. Use project-specific workflows to handle this.
+1. **DNS**: `dig MX yourdomain.com` — confirm the value Plunk gave you appears in the response.
+2. **Domain verification**: in the dashboard, confirm the domain is fully verified for sending. Inbound MX won't process anything on an unverified domain.
+3. **Workflow not firing**: build a test workflow with just an `email.received` trigger and a webhook to a service like webhook.site to see whether events are being emitted at all. If yes, the issue is in your workflow logic; if no, it's upstream.
+4. **Sender DNS cache**: some senders cache MX lookups for hours. Test from a different email provider.
-### Security verdicts failing
+
-If incoming emails consistently show failing security verdicts:
+
-- **SPF failures**: The sender's domain may not have SPF configured correctly
-- **DKIM failures**: The sender's domain may not have DKIM configured, or the email was forwarded/modified in transit
-- **DMARC failures**: The sender fails both SPF and DKIM checks
+- Check the workflow trigger event name is exactly `email.received`.
+- Check the workflow is **enabled** — workflows are created disabled.
+- Check the workflow's `CONDITION` steps aren't filtering everything out (verdict checks are a common culprit when the sender domain has loose authentication).
-These are issues with the sender's configuration, not your Plunk setup. You can choose to process these emails anyway or filter them using workflow conditions.
+
+
+
+
+- Check **Billing → Limits** for an inbound cap that's been reached.
+- Check the project's status in the dashboard — a disabled project doesn't process inbound mail.
+
+
+
+
+
+This points at the sender's configuration, not yours:
+
+- **SPF failures**: sender's domain has no SPF record or doesn't list their sending IP.
+- **DKIM failures**: sender's domain has no DKIM, or the message was modified in transit (some forwarding services break DKIM).
+- **DMARC failures**: sender fails both SPF and DKIM, or has strict alignment that forwarding broke.
+
+You can either drop these in your workflow or process them anyway — your call.
+
+
+
+
diff --git a/apps/wiki/content/docs/guides/segment-filters.mdx b/apps/wiki/content/docs/guides/segment-filters.mdx
new file mode 100644
index 0000000..6f0f02f
--- /dev/null
+++ b/apps/wiki/content/docs/guides/segment-filters.mdx
@@ -0,0 +1,216 @@
+---
+title: Segment filter reference
+description: How to write filters for dynamic segments, campaign audiences, and workflow conditions
+icon: ListFilter
+---
+
+The same filter format powers three things: **dynamic segments**, **campaign audiences** with `audienceType: "FILTERED"`, and **`CONDITION` steps inside workflows**. Learn it once, use it everywhere.
+
+For a conceptual introduction, see the [Segments concept page](/concepts/segments).
+
+## How a filter is structured
+
+A filter has two parts: a top-level connector (`AND` or `OR`) and a list of **groups**. Each group holds one or more conditions, which are combined with `AND` inside the group. The top-level connector then joins the groups together.
+
+That sounds complicated, so a quick example: "subscribed users **and** on the Pro plan **and** (opened **or** clicked an email recently)".
+
+```json
+{
+ "logic": "AND",
+ "groups": [
+ {
+ "filters": [
+ { "field": "subscribed", "operator": "equals", "value": true },
+ { "field": "data.plan", "operator": "equals", "value": "pro" }
+ ]
+ },
+ {
+ "filters": [],
+ "conditions": {
+ "logic": "OR",
+ "groups": [
+ { "filters": [{ "field": "email.opened", "operator": "triggeredWithin", "value": 14, "unit": "days" }] },
+ { "filters": [{ "field": "email.clicked", "operator": "triggeredWithin", "value": 14, "unit": "days" }] }
+ ]
+ }
+ }
+ ]
+}
+```
+
+Groups can nest by setting `conditions` on a group — that lets you build "this AND (that OR this)" expressions without flattening logic. In the dashboard, the segment builder UI handles this for you visually.
+
+## Fields you can filter on
+
+The `field` value is namespaced by prefix — the prefix tells Plunk what part of the contact you're querying.
+
+| Field pattern | Targets | Examples |
+| -------------------------- | ---------------------------------------------------------------- | --------------------------------- |
+| `email` | The contact's email address | `email` |
+| `subscribed` | The contact's subscription state | `subscribed` |
+| `createdAt`, `updatedAt` | Built-in contact timestamps | `createdAt` |
+| `data.` | Custom contact data — supports nested paths | `data.plan`, `data.profile.tier` |
+| `event.` | Custom events tracked via `/v1/track` | `event.signed_up` |
+| `email.` | Email engagement: `sent`, `delivered`, `opened`, `clicked`, `bounced`, `complained` | `email.opened` |
+| `segment.` | Membership of another segment | `segment.cuid_of_other_segment` |
+
+## Operators
+
+Operators are grouped by the kind of field they work on.
+
+### Text fields
+
+For `email` and any `data.` that holds a string.
+
+| Operator | What it does |
+| ------------- | --------------------------------------------------------- |
+| `equals` | Exact match. Case-insensitive on `email`, exact on `data.*`. |
+| `notEquals` | Negation of `equals`. |
+| `contains` | Case-insensitive substring match. |
+| `notContains` | Negation of `contains`. |
+
+### Yes/no fields
+
+For `subscribed` and any `data.` that holds `true` or `false`.
+
+| Operator | What it does |
+| ----------- | --------------------------- |
+| `equals` | Match true or false. |
+| `notEquals` | Negation. |
+
+### Dates and timestamps
+
+For `createdAt`, `updatedAt`, and any `data.` that holds an ISO 8601 date string.
+
+| Operator | What it does |
+| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
+| `equals` / `notEquals` | Match the timestamp. If the value is just a date (`YYYY-MM-DD`), the comparison covers the whole UTC day. |
+| `greaterThan` / `lessThan` | Strictly after / strictly before. |
+| `greaterThanOrEqual` / `lessThanOrEqual` | Inclusive variants. |
+| `within` | Within the last N units. Pair with `unit: "days" / "hours" / "minutes"`. |
+| `olderThan` | More than N units ago. Pair with `unit`. |
+
+For custom date fields (`data.`), `within` and `olderThan` only work when the value is stored as an ISO 8601 string (e.g. `"2026-05-06T12:00:00Z"`). Unix timestamps and other formats won't compare correctly.
+
+### Numbers
+
+For any `data.` that holds a number: `equals`, `notEquals`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`. No `unit` is needed.
+
+### Field existence
+
+For any `data.`, regardless of value type.
+
+| Operator | What it does |
+| ----------- | ----------------------------------------------------- |
+| `exists` | The key is present on the contact and is not null. |
+| `notExists` | The key is missing or explicitly null. |
+
+### Events and email activity
+
+These operators apply to both `event.` (custom events) and `email.` (email engagement).
+
+| Operator | What it does |
+| --------------------- | ------------------------------------------------------------------------------------------------------- |
+| `triggered` | The contact has had this event at least once, ever. |
+| `notTriggered` | The contact has never had this event. |
+| `triggeredWithin` | At least one occurrence in the last N units. Pair with `unit`. |
+| `triggeredOlderThan` | Has had this event, but not within the last N units. Pair with `unit`. |
+| `notTriggeredWithin` | Has not had this event in the last N units. Includes contacts who have never triggered it. Pair with `unit`. |
+
+### Segment membership
+
+For `segment.`, where `` is the ID of another segment in your project.
+
+| Operator | What it does |
+| --------------------- | --------------------------------------------------------- |
+| `memberOfSegment` | Contact is currently in the referenced segment. |
+| `notMemberOfSegment` | Contact is not in the referenced segment. |
+
+## Quick reference: what to pass with each operator
+
+- **Need a `value`**: `equals`, `notEquals`, `contains`, `notContains`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `within`, `olderThan`, `triggeredWithin`, `triggeredOlderThan`, `notTriggeredWithin`.
+- **Need a `unit`** (`"days"`, `"hours"`, or `"minutes"`): `within`, `olderThan`, `triggeredWithin`, `triggeredOlderThan`, `notTriggeredWithin`.
+- **Need nothing extra**: `exists`, `notExists`, `triggered`, `notTriggered`, `memberOfSegment`, `notMemberOfSegment`.
+
+## Examples
+
+### Engaged users on the Pro plan
+
+Subscribed Pro-plan users who opened or clicked any email in the last 14 days but haven't clicked anything in the last 30 days.
+
+```json
+{
+ "logic": "AND",
+ "groups": [
+ {
+ "filters": [
+ { "field": "subscribed", "operator": "equals", "value": true },
+ { "field": "data.plan", "operator": "equals", "value": "pro" }
+ ]
+ },
+ {
+ "filters": [],
+ "conditions": {
+ "logic": "OR",
+ "groups": [
+ { "filters": [{ "field": "email.opened", "operator": "triggeredWithin", "value": 14, "unit": "days" }] },
+ { "filters": [{ "field": "email.clicked", "operator": "triggeredWithin", "value": 14, "unit": "days" }] }
+ ]
+ }
+ },
+ {
+ "filters": [
+ { "field": "email.clicked", "operator": "notTriggeredWithin", "value": 30, "unit": "days" }
+ ]
+ }
+ ]
+}
+```
+
+### New trial users without a purchase
+
+Subscribed contacts created in the last 7 days who haven't yet triggered a `purchase` event.
+
+```json
+{
+ "logic": "AND",
+ "groups": [
+ {
+ "filters": [
+ { "field": "subscribed", "operator": "equals", "value": true },
+ { "field": "createdAt", "operator": "within", "value": 7, "unit": "days" },
+ { "field": "event.purchase", "operator": "notTriggered" }
+ ]
+ }
+ ]
+}
+```
+
+### Power users above a tier
+
+Members of the `power-users` segment whose lifetime value is at least 500.
+
+```json
+{
+ "logic": "AND",
+ "groups": [
+ {
+ "filters": [
+ { "field": "segment.", "operator": "memberOfSegment" },
+ { "field": "data.lifetimeValue", "operator": "greaterThanOrEqual", "value": 500 }
+ ]
+ }
+ ]
+}
+```
+
+## Related
+
+
+
+ Static vs dynamic segments, membership tracking, and entry / exit events.
+
+
+ How `data.*` fields are typed and used in filters.
+
+
diff --git a/apps/wiki/content/docs/guides/tracking.mdx b/apps/wiki/content/docs/guides/tracking.mdx
index fcea473..a54b0d5 100644
--- a/apps/wiki/content/docs/guides/tracking.mdx
+++ b/apps/wiki/content/docs/guides/tracking.mdx
@@ -1,32 +1,42 @@
---
title: Tracking
-description: Tracking for opens and clicks in your emails
+description: Track opens and clicks on the emails you send through Plunk
icon: HatGlasses
---
-Plunk provides built-in tracking for email opens and link clicks. This allows you to monitor the engagement of your emails and gain insights into how your contacts interact with your content.
+Plunk provides built-in tracking for email opens and link clicks so you can monitor engagement and drive workflows or segments off email activity.
## Open tracking
-When open tracking is enabled, Plunk includes a small, invisible tracking pixel in your emails. When a contact opens the email, the pixel is loaded, and Plunk records the open event.
+When open tracking is enabled, Plunk includes a small invisible tracking pixel at the bottom of your email. When the recipient's mail client loads the pixel, the open is recorded as an `email.open` event on the contact.
### Considerations
-- Open tracking relies on the loading of images in the email client. If a contact has images disabled, the open event may not be recorded.
-- Some email clients may pre-load images, which can result in false open events.
+
+- Open tracking depends on the mail client loading remote images. If a contact has images disabled, no open is recorded.
+- Some clients (and corporate mail security gateways) pre-fetch images, which can produce false positive opens.
+- Apple Mail Privacy Protection masks individual opens — opens from Apple Mail are recorded but reflect proxy fetches rather than human reads.
## Click tracking
-Click tracking is enabled by default for all emails sent through Plunk. When a contact clicks on a link in the email, Plunk records the click event and tracks which link was clicked.
+When click tracking is enabled, Plunk rewrites links in your email body to route through a tracking redirect. When a contact clicks a link, Plunk records the click and the original URL on an `email.click` event before forwarding the contact to the destination.
### Considerations
-- Click tracking works by rewriting the URLs in your email to point to Plunk's tracking servers
-- Some email clients or security software may block tracking links, which can result in missed click events
+
+- Some mail clients and security tools pre-scan links, which can produce false positive clicks.
+- The rewrite is transparent to recipients but means link URLs in the email source no longer match the original destination.
## Configuration
-There are three levels of configuration for tracking:
-| Level | Description |
-|-------|-------------|
-| Enabled | Tracking is enabled for all emails |
-| Disabled | Tracking is disabled for all emails |
-| Marketing only | Tracking is enabled only for marketing emails (templates and campaigns) |
\ No newline at end of file
+Tracking is configured per project under **Settings → Tracking**. Three modes are available:
+
+| Mode | Behaviour |
+| ---------------- | ------------------------------------------------------------------------------------------ |
+| **Enabled** | Tracking is applied to every email. |
+| **Disabled** | No tracking is applied to any email. |
+| **Marketing only** | Tracking is applied to marketing sends (campaigns and emails sent by workflow steps using marketing or headless templates). Transactional sends — anything via `/v1/send` using a transactional template, or campaigns of type `TRANSACTIONAL` — are sent without tracking. |
+
+Tracking is **all or nothing** within a single send — there's no separate toggle for opens vs clicks. Pick the mode that fits your privacy and analytics requirements.
+
+
+On self-hosted instances, the Marketing-only mode is only available when a no-tracking configuration set is configured. See [Self-hosting → Email setup](/self-hosting/email-setup) for details.
+
diff --git a/apps/wiki/content/docs/guides/unsubscribe-pages.mdx b/apps/wiki/content/docs/guides/unsubscribe-pages.mdx
new file mode 100644
index 0000000..2cf2f50
--- /dev/null
+++ b/apps/wiki/content/docs/guides/unsubscribe-pages.mdx
@@ -0,0 +1,70 @@
+---
+title: Unsubscribe & preferences pages
+description: Plunk's hosted pages for letting recipients unsubscribe, resubscribe, and manage their email preferences
+icon: UserX
+---
+
+Every email Plunk sends to a recipient who can unsubscribe (marketing or headless templates) carries a personalized link to a hosted page where they can manage their subscription. You don't need to build any of this — Plunk hosts the pages, handles the state changes, and tracks the events for you.
+
+## The three URL variables
+
+Three template variables are auto-injected on every send and resolve to per-recipient signed URLs:
+
+| Variable | Page it links to | What the recipient can do |
+| -------------------- | ----------------------------------------------------------------- | -------------------------------------------------------- |
+| `{{unsubscribeUrl}}` | One-click unsubscribe page | Confirm they want to stop receiving marketing emails |
+| `{{subscribeUrl}}` | Resubscribe page | Opt back in after previously unsubscribing |
+| `{{manageUrl}}` | Preferences page | View their current state and toggle subscription either way |
+
+Use them anywhere in a template's body or subject line. The placeholder is replaced with a unique URL when the email is rendered for that specific contact.
+
+## Default behaviour by template type
+
+Where Plunk drops these links depends on the template type:
+
+| Template type | Auto footer with `{{unsubscribeUrl}}`? | Notes |
+| ----------------- | -------------------------------------------- | ---------------------------------------------------------------------- |
+| **Marketing** | Yes — Plunk appends a localized footer | You don't need to do anything. The footer respects the recipient's `locale`. |
+| **Headless** | No | You must include `{{unsubscribeUrl}}` (or `{{manageUrl}}`) in your body or the recipient has no way to opt out. |
+| **Transactional** | No | Transactional emails skip subscription checks; an unsubscribe link is not added (and shouldn't be needed). |
+
+If you build your own footer in a marketing template, you can still rely on the auto-injected one or override it by including your own link with `{{unsubscribeUrl}}`.
+
+## Inside transactional sends
+
+`/v1/send` accepts the same template variables. Whether an unsubscribe footer is appended depends on the template you reference:
+
+- Sending with no template (just `subject` + `body`) → no auto footer.
+- Sending with a marketing template → footer auto-injected.
+- Sending with a headless or transactional template → no footer (you control the body).
+
+You can always reference `{{unsubscribeUrl}}` / `{{manageUrl}}` in your inline body if you want to render your own link.
+
+## What happens when the recipient acts
+
+When the recipient clicks one of the links and confirms:
+
+| Action | Effect |
+| ----------------------- | --------------------------------------------------------------------------------- |
+| Unsubscribe | Contact's `subscribed` flips to `false`. `contact.unsubscribed` event fires. |
+| Resubscribe | Contact's `subscribed` flips to `true`. `contact.subscribed` event fires. |
+| Update via preferences | Same as above, depending on which way they toggle. |
+
+You can drive workflows off `contact.unsubscribed` / `contact.subscribed` (e.g. send a "we're sorry to see you go" survey, or trigger a winback when they re-opt-in).
+
+## Localization
+
+The hosted pages and the auto-injected footer are localized into the contact's `locale` if set, or the project's default language otherwise. See [Localization](/guides/localization) for the full list of supported languages.
+
+## Branding
+
+The pages use your project's name and logo (configurable in **Settings → Project**). For more advanced customization, send marketing as **headless** templates and build the unsubscribe surface into your own product UI — link your in-product unsubscribe controls to the same `{{unsubscribeUrl}}` for one-click revocation.
+
+## API reference
+
+If you need to drive subscription changes programmatically rather than through the hosted pages — for example because you've built your own preferences UI — use the contacts API:
+
+- `PATCH /contacts/:id` with `{ "subscribed": false }` — unsubscribe a contact.
+- `PATCH /contacts/:id` with `{ "subscribed": true }` — resubscribe.
+
+Both flips automatically emit the corresponding `contact.subscribed` / `contact.unsubscribed` event, just like the hosted pages do.
diff --git a/apps/wiki/content/docs/guides/verifying-domains.mdx b/apps/wiki/content/docs/guides/verifying-domains.mdx
index 7e78d6e..9e3c0ff 100644
--- a/apps/wiki/content/docs/guides/verifying-domains.mdx
+++ b/apps/wiki/content/docs/guides/verifying-domains.mdx
@@ -40,6 +40,10 @@ You'll need to add **1 TXT record** that lists the authorized sending servers.
- Reduces the likelihood of your domain being used for spam
- Works together with DKIM for complete authentication
+
+A domain can only have **one** SPF TXT record. If you already use another email provider (Google Workspace, Microsoft 365, another sending platform), you must **merge** Plunk's SPF mechanism into your existing record — don't add a second SPF record. For example, if your existing record is `v=spf1 include:_spf.google.com ~all`, the merged version is `v=spf1 include:_spf.google.com include:<Plunk's include from the dashboard> ~all`. Two separate SPF records will cause both to fail.
+
+
### Bounce Handling (1 MX record)
This **MX record** allows Plunk to receive bounce notifications and spam complaints from email providers.
@@ -51,36 +55,62 @@ This **MX record** allows Plunk to receive bounce notifications and spam complai
- Prevents sending to invalid email addresses
- Required for deliverability monitoring
-
- The MX record above is for sending emails (handling bounces and complaints). If you want to **receive** emails at your
- domain and trigger workflows, see the [receiving emails](/guides/receiving-emails) guide for additional MX record
- setup.
+
+This MX record handles delivery feedback (bounces, complaints) for emails Plunk sends out — it's separate from the [inbound MX record](/guides/receiving-emails) used to **receive** emails sent to your domain. They serve different purposes; you can have either one or both.
+### DMARC (optional, recommended)
+
+**Domain-based Message Authentication, Reporting and Conformance (DMARC)** tells receiving mail servers what to do when an email fails SPF or DKIM checks, and where to send authentication reports.
+
+Plunk doesn't require DMARC, but most major mailbox providers (Gmail, Yahoo, Microsoft) now expect it for bulk senders. Add a TXT record at `_dmarc.yourdomain.com`:
+
+```
+v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com
+```
+
+Start with `p=none` to monitor without affecting delivery. Once you've reviewed reports for a couple of weeks and confirmed all your legitimate senders are authenticated, tighten to `p=quarantine` and eventually `p=reject`.
+
+### MAIL FROM domain (optional)
+
+You can configure a custom MAIL FROM domain (typically a subdomain like `mail.yourdomain.com`) so the bounce envelope address aligns with your sending domain. This improves DMARC alignment and is required by some inbox providers for full pass-through.
+
+If you set this up, you'll need an additional MX record and TXT record on the subdomain — Plunk's dashboard will show you the exact values to add when you enable it on a verified domain.
+
## Verification Status
-After adding all DNS records, Plunk will automatically check the verification status. Verification typically completes within a few minutes but can take up to 72 hours depending on DNS propagation.
+After adding all DNS records, Plunk automatically checks verification status in the background. Verification typically completes within a few minutes, but can take up to 72 hours depending on DNS propagation.
-You can check the status in the Domains section of your project settings. Each record type will show as verified once detected.
+You can check the status in the Domains section of your project settings. Each record type will show as verified once detected. If you've just added a record and don't want to wait, you can trigger a re-check from the dashboard.
## Troubleshooting
-### Records not verifying
+import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
+
+
+
+
If your DNS records aren't verifying after 24 hours:
-1. **Double-check the values**: Ensure you copied the exact values without extra spaces
-2. **Check DNS propagation**: Use tools like `dig` or online DNS checkers to verify the records are published
-3. **TTL settings**: Some DNS providers cache records. Try lowering the TTL (Time To Live) value
-4. **Contact your DNS provider**: Some providers have specific requirements or interfaces for adding these record types
+1. **Double-check the values**: ensure you copied the exact values without extra spaces.
+2. **Check DNS propagation**: use tools like `dig` or online DNS checkers to verify the records are published.
+3. **TTL settings**: some DNS providers cache records. Try lowering the TTL (Time To Live) value.
+4. **Contact your DNS provider**: some providers have specific requirements or interfaces for adding these record types.
-### Emails still going to spam
+
+
+
Even with a verified domain, emails may go to spam if:
-- Your content triggers spam filters (excessive links, suspicious keywords)
-- Your sender reputation is new or low
-- Recipients have marked your emails as spam in the past
-- You're sending to invalid or unengaged contacts
+- Your content triggers spam filters (excessive links, suspicious keywords).
+- Your sender reputation is new or low.
+- Recipients have marked your emails as spam in the past.
+- You're sending to invalid or unengaged contacts.
Follow email best practices and maintain good [list hygiene](/guides/list-hygiene) to improve deliverability.
+
+
+
+
diff --git a/apps/wiki/content/docs/guides/webhooks.mdx b/apps/wiki/content/docs/guides/webhooks.mdx
index 4612f31..8b17f36 100644
--- a/apps/wiki/content/docs/guides/webhooks.mdx
+++ b/apps/wiki/content/docs/guides/webhooks.mdx
@@ -5,6 +5,7 @@ icon: Webhook
---
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
+import {TypeTable} from 'fumadocs-ui/components/type-table';
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.
@@ -48,19 +49,27 @@ Plunk automatically tracks a set of internal events that you can use as workflow
| `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
-
+import {Step, Steps} from 'fumadocs-ui/components/steps';
+
+
+
+
### 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:
@@ -75,11 +84,17 @@ After the trigger, add a **Webhook** step and configure it:
}
```
+
+
+
+
### Enable the workflow
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
-
+
+
+
## Webhook payload
@@ -126,18 +141,20 @@ The `event` field contains the data associated with the event that triggered the
Most email events share a common set of base fields:
-| Field | Description |
-| ------------ | ------------------------------------------------------------------------------------------------------ |
-| `subject` | The email subject line |
-| `from` | The sender email address |
-| `fromName` | The sender display name |
-| `messageId` | The AWS SES message ID (for correlating with SES events) |
-| `emailId` | The Plunk email record ID (returned from `POST /v1/send`, for correlating webhooks with API responses) |
-| `templateId` | The template ID, if the email was sent using a template (otherwise `null`) |
-| `campaignId` | The campaign ID, if the email was part of a campaign (otherwise `null`) |
-| `sourceType` | How the email was triggered: `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, or `INBOUND` |
+
-In addition to these base fields, each event includes the following event-specific fields:
+In addition to these base fields, each event includes the following event-specific fields. The base fields above (`subject`, `from`, `fromName`, `messageId`, `emailId`, `templateId`, `campaignId`, `sourceType`) are present on every email event in addition to the event-specific fields shown below.
@@ -157,9 +174,11 @@ In addition to these base fields, each event includes the following event-specif
}
```
-| Field | Description |
-| -------- | ----------------------- |
-| `sentAt` | When the email was sent |
+
@@ -179,9 +198,11 @@ In addition to these base fields, each event includes the following event-specif
}
```
-| Field | Description |
-| ------------- | ---------------------------- |
-| `deliveredAt` | When the email was delivered |
+
@@ -203,11 +224,13 @@ In addition to these base fields, each event includes the following event-specif
}
```
-| Field | Description |
-| ------------- | ------------------------------------------------------------- |
-| `openedAt` | When the email was first opened |
-| `opens` | Total number of times this email has been opened |
-| `isFirstOpen` | `true` if this is the first time the contact opened the email |
+
@@ -230,12 +253,14 @@ In addition to these base fields, each event includes the following event-specif
}
```
-| Field | Description |
-| -------------- | ----------------------------------------------------------------- |
-| `link` | The URL that was clicked |
-| `clickedAt` | When the first click occurred |
-| `clicks` | Total number of times links in this email have been clicked |
-| `isFirstClick` | `true` if this is the first click from this contact on this email |
+
@@ -275,13 +300,15 @@ Transient (soft) bounce:
}
```
-| Field | Description |
-| ----------------- | ------------------------------------------------------------------------------------------------ |
-| `bounceType` | `Permanent` (hard bounce) or `Transient` (soft bounce, e.g. mailbox full or out-of-office) |
-| `bouncedAt` | When the bounce occurred (permanent bounces only) |
-| `transientBounce` | `true` for soft bounces — these do not count toward bounce rate and the contact stays subscribed |
+
-
+
Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription.
`Transient` bounces are tracked for visibility only.
@@ -304,9 +331,11 @@ Transient (soft) bounce:
}
```
-| Field | Description |
-| -------------- | ------------------------------------ |
-| `complainedAt` | When the spam complaint was received |
+
@@ -334,23 +363,25 @@ This event fires when an email is received at your verified domain. See [Receivi
}
```
-| Field | Description |
-| ---------------------- | --------------------------------------------------------------------- |
-| `messageId` | The AWS SES message ID |
-| `from` | The sender's email address |
-| `fromHeader` | The full `From` header, including display name if present |
-| `to` | The recipient address at your verified domain |
-| `subject` | The email subject line |
-| `timestamp` | When SES received the email |
-| `recipients` | All recipient addresses in the envelope |
-| `hasContent` | Whether the email body content is available |
-| `body` | HTML body of the email (or plain text if no HTML available) |
-| `spamVerdict` | SES spam check result: `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED` |
-| `virusVerdict` | SES virus check result |
-| `spfVerdict` | SPF authentication result |
-| `dkimVerdict` | DKIM authentication result |
-| `dmarcVerdict` | DMARC authentication result |
-| `processingTimeMillis` | Time SES took to process the inbound email |
+
@@ -368,9 +399,11 @@ The exception is when an unsubscription is triggered automatically by an email b
}
```
-| Field | Value |
-| -------- | --------------------------------------------------- |
-| `reason` | `"bounce"` or `"complaint"` (when system-triggered) |
+
#### Segment events
@@ -383,10 +416,12 @@ Both `segment..entry` and `segment..exit` include:
}
```
-| Field | Description |
-| ------------- | ------------------------------- |
-| `segmentId` | The ID of the segment |
-| `segmentName` | The display name of the segment |
+
#### Custom events
@@ -440,7 +475,7 @@ Response:
}
```
-This eliminates the need to match by contact email + timestamp or to listen for `email.sent` webhooks just to get the SES `messageId`.
+This eliminates the need to match by contact email + timestamp or to listen for `email.sent` webhooks just to get the provider `messageId`.
## Common use cases
@@ -462,6 +497,27 @@ Trigger a workflow on `contact.unsubscribed` to notify your application when a c
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.
+## Receiving webhooks safely
+
+Plunk's webhook step has a few characteristics worth knowing when you build the receiving endpoint:
+
+- **Method**: defaults to `POST` with `Content-Type: application/json`. You can override the method per step.
+- **Timeout**: each request times out after **10 seconds**. Long-running endpoints should accept the request, queue the work, and return `2xx` quickly.
+- **Redirects**: up to 5 redirects are followed. Each hop is re-validated against the SSRF rules below.
+- **Public URL required**: your webhook endpoint must be reachable on the public internet. Webhooks pointed at private or internal addresses (loopback, RFC 1918 ranges, etc.) won't be delivered.
+- **Schemes**: only `http://` and `https://` are accepted. Prefer HTTPS.
+- **No automatic retries**: a non-2xx response or timeout fails the workflow step. Build idempotency into your handler and use workflow logic (a `WAIT_FOR_EVENT` step, a fallback branch) if you need retry semantics.
+- **Verify authenticity with a shared secret**: configure a secret header on the webhook step and check it on your endpoint:
+
+ ```json
+ // Webhook step → Headers
+ {
+ "Authorization": "Bearer your-shared-secret"
+ }
+ ```
+
+ The secret travels with every request from that step. Rotate it like any other shared secret. Prefer this over IP allowlisting — egress IPs can change.
+
## Adding conditions and delays
Since webhooks are part of the workflow system, you can combine them with other step types for more advanced setups:
diff --git a/apps/wiki/content/docs/index.mdx b/apps/wiki/content/docs/index.mdx
index 192d04a..049b1a5 100644
--- a/apps/wiki/content/docs/index.mdx
+++ b/apps/wiki/content/docs/index.mdx
@@ -6,7 +6,7 @@ icon: House
## What is Plunk?
-Plunk is an open-source email platform built on top of AWS SES. It is designed for developers who need a powerful, scalable solution for transactional and marketing emails. Built with modern technologies and designed to handle millions of contacts, Plunk provides everything you need to manage your email communications.
+Plunk is an open-source email platform designed for developers who need a powerful, scalable solution for transactional and marketing emails. Built to handle millions of contacts, it provides everything you need to manage your email communications — sending, automation, segmentation, deliverability, and analytics.
## Key Features
diff --git a/apps/wiki/content/docs/self-hosting/docker.mdx b/apps/wiki/content/docs/self-hosting/docker.mdx
index 2462ff2..547c4f2 100644
--- a/apps/wiki/content/docs/self-hosting/docker.mdx
+++ b/apps/wiki/content/docs/self-hosting/docker.mdx
@@ -44,12 +44,27 @@ See [Environment Variables](/self-hosting/environment-variables) for all options
## Ports
-| Port | Service |
-|------|---------|
-| 80 | Nginx (HTTP) |
-| 465 | SMTP (implicit TLS) |
-| 587 | SMTP (STARTTLS) |
-| 9000 | Minio API |
+| Port | Service |
+| ----- | ---------------------------------------- |
+| 80 | Nginx (HTTP) — fronts API, dashboard, landing, wiki |
+| 465 | SMTP (implicit TLS) |
+| 587 | SMTP (STARTTLS) |
+| 8080 | API server (proxied through nginx; not usually exposed externally) |
+| 9000 | Minio API |
+| 9001 | Minio web console |
+
+When deploying behind a reverse proxy or load balancer, expose port 80 (and 465/587 if using SMTP). Postgres, Redis, and Minio are kept internal to the Docker network by default.
+
+## Health check
+
+The API exposes a health endpoint at `/health` for orchestrators (Docker Compose, Kubernetes, load balancers):
+
+```bash
+curl https://api.yourdomain.com/health
+# { "status": "ok", "time": ..., "uptime": ... }
+```
+
+Use this for liveness/readiness probes. It returns `200` when the API process is up and able to serve requests.
## Running Individual Services
diff --git a/apps/wiki/content/docs/self-hosting/email-setup.mdx b/apps/wiki/content/docs/self-hosting/email-setup.mdx
index 33066a7..0756b01 100644
--- a/apps/wiki/content/docs/self-hosting/email-setup.mdx
+++ b/apps/wiki/content/docs/self-hosting/email-setup.mdx
@@ -74,3 +74,49 @@ SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
Once you have configured AWS SES with the above settings, you can add and verify your domain directly through the Plunk dashboard. Plunk will handle the domain verification and DKIM setup with AWS SES automatically and show you the right records to add to your DNS.
+## 6. (Optional) Configure Inbound Email
+
+If you want to use Plunk's [Receiving emails](/guides/receiving-emails) feature, configure SES inbound separately. Plunk does not auto-provision receipt rules, so this is a manual one-time setup in your AWS account.
+
+Not all AWS regions support SES inbound — confirm `inbound-smtp..amazonaws.com` exists for your `AWS_SES_REGION` before continuing. If your region doesn't support inbound, you can keep outbound on your current region and run a separate identity in a region that does.
+
+### Create a receipt rule set
+
+1. SES Console → **Email receiving** → **Rule sets** → Create rule set (or use an existing one)
+2. Add a rule:
+ - **Recipient conditions**: `*@yourdomain.com` (or specific addresses you want to receive)
+ - **Actions**: Publish to Amazon SNS topic → select your `plunk-ses-events` topic (or create a separate topic that's also subscribed to `https://api.yourdomain.com/webhooks/sns`)
+3. Set the rule set as **active**
+
+### Inbound IAM permissions
+
+Plunk doesn't call SES inbound APIs at runtime — the IAM policy from step 1 covers everything Plunk needs. Configuring receipt rules in AWS is a manual one-time action you do as an AWS admin.
+
+### Add an MX record
+
+For each verified domain you want to receive on, add an MX record pointing at the SES inbound endpoint for your region:
+
+```
+Type: MX
+Name: yourdomain.com
+Value: 10 inbound-smtp..amazonaws.com
+```
+
+Plunk's dashboard will show the exact value to use for your configured region.
+
+## 7. (Optional) Move Out of the SES Sandbox
+
+By default, new AWS SES accounts are in **sandbox mode** with strict limits:
+
+- Send only to **verified** addresses
+- 200 messages per 24 hours
+- 1 message per second
+
+Sandbox is fine for testing but useless for production. Request production access in the SES console (**Account dashboard** → **Request production access**) — AWS typically approves within 24 hours after a short questionnaire about your sending practices.
+
+After approval, your sending quota will reflect a much higher daily and per-second limit specific to your account.
+
+## 8. (Optional) Configure a MAIL FROM Domain
+
+For better DMARC alignment, you can configure a custom MAIL FROM subdomain (e.g. `mail.yourdomain.com`). In the SES console under **Verified identities** → your domain → **MAIL FROM domain**, set a subdomain and add the additional MX and TXT records SES displays. Plunk's IAM policy already includes `ses:SetIdentityMailFromDomain` to support this.
+
diff --git a/apps/wiki/content/docs/self-hosting/environment-variables.mdx b/apps/wiki/content/docs/self-hosting/environment-variables.mdx
index 594e1a0..8afe7b0 100644
--- a/apps/wiki/content/docs/self-hosting/environment-variables.mdx
+++ b/apps/wiki/content/docs/self-hosting/environment-variables.mdx
@@ -7,11 +7,13 @@ description: Configuration reference
| Variable | Required | Description | Example |
| -------------- | -------- | ---------------------------------------------------------------------------- | ------------------------------------------------- |
-| `JWT_SECRET` | Yes | Secret key used to sign JWT tokens. Generate with `openssl rand -base64 32`. | `s3cr3t...` |
-| `DB_PASSWORD` | Yes | PostgreSQL database password. Used by the Docker Compose setup. | `changeme123` |
-| `DATABASE_URL` | Yes | Full PostgreSQL connection string. Auto-configured in Docker. | `postgresql://plunk:password@postgres:5432/plunk` |
-| `REDIS_URL` | Yes | Redis connection string. | `redis://redis:6379` |
-| `PORT` | No | Port the API server listens on. | `8080` (default) |
+| `JWT_SECRET` | Yes | Secret key used to sign JWT tokens. Generate with `openssl rand -base64 32`. | `s3cr3t...` |
+| `DB_PASSWORD` | Yes | PostgreSQL database password. Used by the Docker Compose setup. | `changeme123` |
+| `DATABASE_URL` | Yes | Full PostgreSQL connection string used by the application at runtime (typically through PgBouncer). Auto-configured in Docker. | `postgresql://plunk:password@postgres:5432/plunk` |
+| `DIRECT_DATABASE_URL` | Yes | Direct PostgreSQL connection string used by Prisma migrations. Must bypass any connection pooler. Auto-configured in Docker. | `postgresql://plunk:password@postgres:5432/plunk` |
+| `REDIS_URL` | Yes | Redis connection string. | `redis://redis:6379` |
+| `NODE_ENV` | No | Application environment. Set to `production` for production deployments. | `production` |
+| `PORT` | No | Port the API server listens on. | `8080` (default) |
## URLs & Domains
@@ -52,6 +54,15 @@ The bundled Docker setup includes Minio with defaults that work out of the box.
| `S3_PUBLIC_URL` | No | Publicly accessible base URL for stored files. | — |
| `S3_FORCE_PATH_STYLE` | No | Use path-style URLs instead of virtual-hosted. Required for Minio. | `true` |
+## Attachments
+
+Plunk supports attachments on transactional emails. AWS SES caps total message size at 40 MB; the defaults below leave headroom but can be tuned for your use case.
+
+| Variable | Required | Description | Default |
+| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------ | ------- |
+| `MAX_ATTACHMENT_SIZE_MB` | No | Maximum total attachment size in megabytes per email. Hard upper bound enforced by AWS SES is 40 MB. | `10` |
+| `MAX_ATTACHMENTS_COUNT` | No | Maximum number of attachments per email. | `10` |
+
## SMTP Server
The optional SMTP relay lets you send emails through Plunk via the SMTP protocol.
@@ -128,7 +139,7 @@ Plunk can use AI to detect and block phishing emails before they're sent. Requir
| `OPENROUTER_API_KEY` | No | OpenRouter API key. When set, enables AI-powered phishing detection. | — |
| `OPENROUTER_MODEL` | No | LLM model to use for content analysis. See [OpenRouter models](https://openrouter.ai/models). | `anthropic/claude-3-haiku` |
| `PHISHING_DETECTION_SAMPLE_RATE` | No | Percentage of emails to check (0.0-1.0). For example, `0.1` means 10% of emails are analyzed. | `0.1` (10%) |
-| `PHISHING_CONFIDENCE_THRESHOLD` | No | Minimum confidence percentage (0-100) required to auto-disable a project from a single detection. | `85` |
+| `PHISHING_CONFIDENCE_THRESHOLD` | No | Minimum confidence percentage (0-100) required to auto-disable a project from a single detection. | `95` |
| `PHISHING_CUMULATIVE_THRESHOLD` | No | Number of phishing detections within the time window required to auto-disable a project. | `3` |
| `PHISHING_CUMULATIVE_WINDOW_MS` | No | Time window in milliseconds for cumulative phishing tracking. | `3600000` (1 hour) |
diff --git a/apps/wiki/openapi.json b/apps/wiki/openapi.json
index 0d02380..27127bd 100644
--- a/apps/wiki/openapi.json
+++ b/apps/wiki/openapi.json
@@ -178,7 +178,7 @@
"post": {
"tags": ["Public API"],
"summary": "Send transactional email",
- "description": "Send a transactional email via the public API. Automatically creates/updates contacts.",
+ "description": "Send a transactional email via the public API. Automatically creates or updates the recipient contact.\n\n**Required content:** either a `template` ID, **or** both `subject` and `body`. Template fields can be overridden by explicit request fields.\n\n**Sender:** `from` is required unless using a template that already has a `from` configured. The sender's domain must be verified.\n\n**Multiple recipients:** when `to` is an array, each recipient is processed sequentially with its own contact upsert and rendered email — there is no batch-send semantics. Sending is always immediate; for scheduled sends, use a Campaign.\n\n**Attachments:** up to 10 attachments per email and 10 MB total by default. The total message size cannot exceed 40 MB.",
"operationId": "sendEmail",
"requestBody": {
"required": true,
@@ -186,7 +186,7 @@
"application/json": {
"schema": {
"type": "object",
- "required": ["to", "from"],
+ "required": ["to"],
"properties": {
"to": {
"oneOf": [
@@ -243,11 +243,14 @@
},
"subject": {
"type": "string",
- "description": "Email subject (required if no template)"
+ "minLength": 1,
+ "maxLength": 998,
+ "description": "Email subject. Required if no `template` is provided. Cannot contain newline characters."
},
"body": {
"type": "string",
- "description": "Email body HTML (required if no template)"
+ "minLength": 1,
+ "description": "Email body (HTML). Required if no `template` is provided."
},
"template": {
"type": "string",
@@ -281,33 +284,32 @@
},
"name": {
"type": "string",
- "description": "Sender display name (alternative to using from.name)"
+ "description": "**Deprecated.** Sender display name. Prefer `from: { name, email }`. Used only as a fallback when `from` is a string and no name is set there."
},
"subscribed": {
"type": "boolean",
- "description": "Whether recipient is subscribed to marketing emails. Defaults to false for transactional emails.",
- "default": false
+ "description": "Subscription state to apply to the recipient. For **new** contacts, defaults to `false` on `/v1/send`. For **existing** contacts, omitting this preserves their current state — pass `true` or `false` to explicitly change it. A change emits `contact.subscribed` or `contact.unsubscribed`."
},
"data": {
"type": "object",
"additionalProperties": true,
- "description": "Template variables"
+ "description": "Variables for template rendering and contact data updates. Each value can be:\n- A primitive (string, number, boolean) — saved on the contact and available as a template variable.\n- `null` — deletes the field from the contact.\n- An empty string — skipped (does not overwrite existing data).\n- An object `{ value, persistent: false }` — used for this send only, not stored on the contact (good for one-shot password reset codes, magic links).\n\nReserved keys (`id`, `plunk_id`, `plunk_email`, `email`, `unsubscribeUrl`, `subscribeUrl`, `manageUrl`) are silently filtered out."
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
},
- "description": "Custom email headers"
+ "description": "Custom email headers. Header names cannot contain `\\r\\n`. Header values are limited to 998 characters and cannot contain `\\r\\n` (header injection is rejected)."
},
"reply": {
"type": "string",
"format": "email",
- "description": "Reply-to address"
+ "description": "Reply-to address."
},
"attachments": {
"type": "array",
- "description": "Email attachments (max 10 attachments, 10MB total)",
+ "description": "Email attachments. Default cap: 10 attachments and 10 MB total. The full message size cannot exceed 40 MB.",
"maxItems": 10,
"items": {
"type": "object",
@@ -316,16 +318,26 @@
"filename": {
"type": "string",
"maxLength": 255,
- "description": "Attachment filename"
+ "description": "Attachment filename. Cannot contain newline or quote characters."
},
"content": {
"type": "string",
- "description": "Base64 encoded file content"
+ "description": "Base64-encoded file content."
},
"contentType": {
"type": "string",
"maxLength": 255,
- "description": "MIME type (e.g., application/pdf, image/png)"
+ "description": "MIME type (e.g., `application/pdf`, `image/png`)."
+ },
+ "contentId": {
+ "type": "string",
+ "description": "Content-ID for inline images. Required when `disposition` is `inline`. Reference the image in the email body via `
`."
+ },
+ "disposition": {
+ "type": "string",
+ "enum": ["attachment", "inline"],
+ "default": "attachment",
+ "description": "Use `inline` together with `contentId` to embed images in the body. Use `attachment` (the default) for downloadable files."
}
}
}
@@ -529,7 +541,7 @@
"post": {
"tags": ["Public API"],
"summary": "Track event",
- "description": "Track an event for a contact. Automatically creates/updates the contact.",
+ "description": "Track an event for a contact. Automatically creates or upserts the contact, then records the event. Tracked events can be used as workflow triggers, segment filters, and audience filters.\n\n**Reserved event names** (rejected with `VALIDATION_ERROR` and code `reserved_event`): anything matching `email.*`, `contact.subscribed`, `contact.unsubscribed`, `segment..entry`, `segment..exit`. These are emitted by Plunk itself.\n\n**No idempotency**: re-tracking the same event creates a new event record.",
"operationId": "trackEvent",
"requestBody": {
"required": true,
@@ -542,21 +554,20 @@
"email": {
"type": "string",
"format": "email",
- "description": "Contact email"
+ "description": "Contact email. The contact is auto-created if it doesn't exist."
},
"event": {
"type": "string",
- "description": "Event name"
+ "description": "Event name. Cannot match the reserved patterns above."
},
"subscribed": {
"type": "boolean",
- "description": "Whether contact is subscribed to marketing emails. Defaults to true for event tracking.",
- "default": true
+ "description": "Subscription state to apply to the contact. **New** contacts default to subscribed (`true`). **Existing** contacts keep their current state unless you pass an explicit value here. Pass `false` to track an event without resubscribing an unsubscribed contact."
},
"data": {
"type": "object",
"additionalProperties": true,
- "description": "Event data and contact metadata"
+ "description": "Contact data and one-off event variables. Persistent values (primitives, plain objects) are saved on the contact and become available as template variables. Pass `{ value, persistent: false }` for one-shot variables that should not be stored on the contact (e.g. order IDs, transaction details). `null` deletes a field. Empty strings are ignored. Reserved keys are filtered out — see the contacts concept page."
}
}
},
@@ -830,7 +841,7 @@
"get": {
"tags": ["Contacts"],
"summary": "List contacts",
- "description": "Get a paginated list of contacts with cursor-based pagination",
+ "description": "Get a paginated list of contacts with cursor-based pagination.",
"operationId": "listContacts",
"parameters": [
{
@@ -839,21 +850,13 @@
{
"$ref": "#/components/parameters/Cursor"
},
- {
- "name": "subscribed",
- "in": "query",
- "schema": {
- "type": "boolean"
- },
- "description": "Filter by subscription status"
- },
{
"name": "search",
"in": "query",
"schema": {
"type": "string"
},
- "description": "Search by email"
+ "description": "Case-insensitive substring match on email."
}
],
"responses": {
@@ -864,7 +867,7 @@
"schema": {
"type": "object",
"properties": {
- "contacts": {
+ "data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Contact"
@@ -873,14 +876,14 @@
"cursor": {
"type": "string",
"nullable": true,
- "description": "Cursor for next page"
+ "description": "Cursor for the next page. Pass this back as the `cursor` query parameter to fetch the next page."
},
"hasMore": {
"type": "boolean"
},
"total": {
"type": "integer",
- "description": "Total count (only included on first page)"
+ "description": "Total count. Only populated on the first page (when no `cursor` is supplied); subsequent pages return `0` to avoid the recount cost."
}
}
}
@@ -1027,7 +1030,7 @@
"patch": {
"tags": ["Contacts"],
"summary": "Update contact",
- "description": "Update an existing contact",
+ "description": "Update an existing contact's email, subscription state, or `data` fields.",
"operationId": "updateContact",
"parameters": [
{
@@ -1046,12 +1049,19 @@
"schema": {
"type": "object",
"properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "description": "Change the contact's email address. Returns 409 if another contact in the project already uses this email."
+ },
"subscribed": {
- "type": "boolean"
+ "type": "boolean",
+ "description": "Update subscription state. Flipping this fires `contact.subscribed` or `contact.unsubscribed`."
},
"data": {
"type": "object",
- "additionalProperties": true
+ "additionalProperties": true,
+ "description": "Patch contact data. `null` deletes a key, empty strings are ignored, primitives are stored. Reserved keys are silently filtered out."
}
}
}
@@ -1068,6 +1078,9 @@
}
}
}
+ },
+ "409": {
+ "description": "An existing contact already uses the email you're trying to set."
}
}
},