# Receiving emails (/guides/receiving-emails)

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.

## What happens when an email arrives

When someone emails an address at your verified domain, Plunk:

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 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.

## Setup

import {Step, Steps} from 'fumadocs-ui/components/steps';

<Steps>
  <Step>
    ### 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.
  </Step>

  <Step>
    ### Add the inbound MX record

    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.

    <Callout title="Conflict with existing email" type="warn">
      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.
    </Callout>
  </Step>

  <Step>
    ### Wait for DNS propagation

    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 value Plunk gave you in the response.
  </Step>

  <Step>
    ### Send a test email

    Send an email from any external account to any address at your domain (e.g. `anything@yourdomain.com`) and check that:

    * 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.
  </Step>
</Steps>

## What gets stored

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.

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.

**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.

## 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';

<TypeTable
  type={{
  messageId: { type: 'string', description: 'Unique message identifier for the received email.' },
  from: { type: 'string', description: 'Sender email address (envelope / `From` header).' },
  fromHeader: { type: 'string', description: 'Full "From" header including display name, e.g. `"Ada <ada@example.com>"`.' },
  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**: `Thanks for your message. We've received your email and will respond within one business day.`

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).

### Routing by recipient

Route different addresses to different downstream actions in a single workflow:

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/inbound`
   * **Method**: `POST`
   * **Body**:
     ```json
     {
       "from": "{{event.from}}",
       "subject": "{{event.subject}}",
       "body": "{{event.body}}",
       "messageId": "{{event.messageId}}",
       "timestamp": "{{event.timestamp}}",
       "verdicts": {
         "spam": "{{event.spamVerdict}}",
         "virus": "{{event.virusVerdict}}",
         "spf": "{{event.spfVerdict}}",
         "dkim": "{{event.dkimVerdict}}",
         "dmarc": "{{event.dmarcVerdict}}"
       }
     }
     ```

### 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 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).

If you don't want this, only verify the domain in one project at a time.

## Billing

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.

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

* **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.

## Limitations

* **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

import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';

<Accordions type="single">
  <Accordion title="Emails are not being received">
    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.
  </Accordion>

  <Accordion title="Email arrives in Activity but no workflow runs">
    * 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).
  </Accordion>

  <Accordion title="Inbound email is dropped">
    * 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.
  </Accordion>

  <Accordion title="Verdicts are consistently FAIL">
    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.
  </Accordion>
</Accordions>
