# Receiving emails (/guides/receiving-emails)

Any address at a verified domain can become an inbox: Bitelio accepts the incoming mail, stores it in your project, and fires an `email.received` event that workflows can react to. That's the foundation for auto-replies, ticketing, conditional forwarding — any "when an email arrives, do X" automation.

## What happens when an email arrives

For each message sent to an address at your verified domain, Bitelio:

1. Parses it and saves it as an inbound `Email` record, visible in the project's Activity feed.
2. Upserts the sender as a contact — created (subscribed by default) if new, updated otherwise.
3. Records an `email.received` event on that contact, available as a trigger for any workflow.

Before storage, the HTML body goes through sanitization: scripts, iframes, event handlers, and `javascript:` URIs are all removed. If the message has no HTML part, the plain text is kept unchanged.

## Setup

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

<Steps>
  <Step>
    ### Verify your domain for sending

    A domain only accepts inbound mail once it's fully verified for sending (DKIM + SPF + the bounce-feedback MX). Work through the [Verifying domains](/guides/verifying-domains) guide before continuing.
  </Step>

  <Step>
    ### Add the inbound MX record

    In your project's **Domains** tab, expand the verified domain and find the **Inbound Email** section. It displays the exact MX record value for your DNS — copy it and create the 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 Bitelio. The usual fix is to receive Bitelio 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 Bitelio before its MX will be accepted.
    </Callout>
  </Step>

  <Step>
    ### Wait for DNS propagation

    Propagation can take anywhere from minutes to 48 hours. To confirm the MX record has gone live:

    ```bash
    dig MX yourdomain.com
    ```

    The value from the dashboard should appear in the output.
  </Step>

  <Step>
    ### Send a test email

    From any external account, email an arbitrary address at your domain (e.g. `anything@yourdomain.com`) and verify that:

    * The project's Activity feed shows a new `Email` row with type **Inbound**.
    * The sender appears in the **Contacts** tab.
    * That contact has an `email.received` event recorded.
  </Step>
</Steps>

## What gets stored

Accepted inbound mail lands in the project's Activity feed right next to your outbound emails, with the same search, filter, and inspection tools.

What Bitelio keeps: the parsed, sanitized HTML body (falling back to plain text when there's no HTML), the headers listed in the event payload below, the authentication and spam verdicts, and the message ID.

What it doesn't keep: the raw original message, attachments, threading headers (`In-Reply-To`, `References`), or any headers beyond those on the event. When you need those, have the triggered workflow forward the email to your own service through a `WEBHOOK` step.

## The `email.received` event

The event lands on the sender's contact record (created on the fly if needed), with the fully 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.' },
}}
/>

Emission is **unconditional** — spam, virus, and authentication verdicts never cause Bitelio to withhold the event. Any filtering is yours to do inside the workflow it triggers.

Within templates and workflow steps, these fields live under the event variable namespace: `{{event.subject}}`, `{{event.from}}`, `{{event.body}}`, and so on.

## Building workflows on inbound

### Auto-reply

The simplest possible auto-reply for `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.`

Give the auto-reply a `TRANSACTIONAL` template so subscription checks don't apply — senders are auto-subscribed, but if one later unsubscribes, your reply should still go out.

### Routing by recipient

One workflow can fan different addresses out to different actions:

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

When processing needs more than Bitelio offers (NLP classification, ticket creation, attachments Bitelio doesn't capture), hand the email off 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

Make a habit of placing a `CONDITION` step near the top of every inbound workflow that discards messages whose `spamVerdict` or `virusVerdict` is `FAIL` — Bitelio does no pre-filtering on your behalf.

## Multi-project domains

When several projects have verified the same domain, each incoming email fans out to **all of them** — every project gets its own `Email` record, contact upsert, and `email.received` event. That's intentional: it lets staging and production share one inbox, or several teams consume the same inbound stream.

To avoid the fan-out, keep the domain verified in a single project at a time.

## Billing

Each received email consumes **1 credit** from your project's email usage, exactly like an outbound send. Free and paid tiers draw from the same pool.

Under **Billing → Limits** you can put a per-project cap on inbound. When the cap is hit, **that project's inbound emails are silently dropped** until the cap resets — nothing is queued or replayed later. A cap on one project has no effect on other projects verified on the same domain.

## Security considerations

* **Treat the body as untrusted user input.** Sanitization closes the obvious script-injection paths, but phishing links, social-engineering copy, and unicode lookalikes all survive it. Never render the body verbatim in your own UI without escaping it appropriately for that context.
* **Authentication verdicts are advisory.** SPF / DKIM / DMARC results are recorded on the event, not enforced. The policy is yours to define — for sensitive inboxes (billing, account changes), rejecting unauthenticated mail in the workflow's first `CONDITION` step is a reasonable baseline.
* **Senders are auto-subscribed.** Every inbound sender joins your audience as a subscribed contact. To opt them out, finish the inbound workflow with an `UPDATE_CONTACT` step setting `subscribed: false`.
* **Reply-loop risk.** An auto-reply aimed at a domain you also receive on (or at a list address) can loop forever. Guard against it with a `CONDITION` that discards messages whose `event.from` is on your own domain.

## Limitations

* **Catch-all only**: every address at the domain funnels into the same handler; distinguishing recipients happens inside the workflow via `event.to`.
* **No attachments**: parsing discards them. Forward to your own service if you need them.
* **No raw MIME**: the original message isn't kept.
* **No threading**: inbound messages aren't grouped into threads or matched to outbound replies.
* **40 MB size cap**: anything above 40 MB is rejected before processing begins.

## Troubleshooting

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

<Accordions type="single">
  <Accordion title="Emails are not being received">
    1. **DNS**: run `dig MX yourdomain.com` and check the dashboard's value shows up in the answer.
    2. **Domain verification**: make sure the domain is fully verified for sending — an unverified domain's inbound MX processes nothing.
    3. **Workflow not firing**: create a bare test workflow (just an `email.received` trigger plus a webhook to something like webhook.site) to establish whether events are emitted at all. If they are, your workflow logic is the problem; if not, the issue is further upstream.
    4. **Sender DNS cache**: MX lookups can be cached for hours on the sending side — try again from a different email provider.
  </Accordion>

  <Accordion title="Email arrives in Activity but no workflow runs">
    * Confirm the trigger event name is exactly `email.received`.
    * Confirm the workflow is **enabled** — new workflows start out disabled.
    * Confirm the `CONDITION` steps aren't rejecting everything (verdict checks often do this when the sender's domain has weak authentication).
  </Accordion>

  <Accordion title="Inbound email is dropped">
    * Look in **Billing → Limits** for an inbound cap that's been exhausted.
    * Confirm the project itself is active in the dashboard — disabled projects ignore inbound mail.
  </Accordion>

  <Accordion title="Verdicts are consistently FAIL">
    The cause is almost always on the sender's side, not yours:

    * **SPF failures**: the sender's domain lacks an SPF record, or the record omits their sending IP.
    * **DKIM failures**: the sender's domain has no DKIM, or the message was altered en route (forwarding services frequently break DKIM).
    * **DMARC failures**: both SPF and DKIM fail, or forwarding broke a strict alignment policy.

    Whether to drop or process these messages anyway is a judgment call for your workflow.
  </Accordion>
</Accordions>
