← Back to docs

Inbound Email Processing

Receive, route, and process incoming emails with EuroMail

Overview

EuroMail can receive emails on your behalf, parse them, and deliver the contents to your application via webhooks or the REST API. The inbound pipeline handles MIME parsing, attachment extraction, route resolution, and webhook delivery automatically.

How It Works

When someone sends an email to an address on your verified domain, it flows through these stages:

  1. SMTP reception -- EuroMail's inbound SMTP server accepts the message (port 25), validates the sender IP rate limit, and captures the raw message.
  2. Queue -- The raw message is published to a Redis stream (email:inbound) for reliable, ordered processing.
  3. Worker processing -- A worker consumer picks up the message, decodes the MIME content, extracts headers, body parts, and attachments, then resolves the recipient against your configured routes.
  4. Storage -- The parsed email is stored in the database with all extracted metadata (subject, from, to, cc, text body, HTML body, headers, attachments).
  5. Webhook delivery -- If the matched route has a webhook URL, an email.inbound event is fired to your endpoint with the full email contents.

Setup

1. Add MX Records

Point your domain's MX records to EuroMail so incoming mail is directed to our servers:

TypeHostValuePriority
MXyourdomain.commail1.euromail.dev10
MXyourdomain.commail2.euromail.dev20

The accepted MX targets are mail1.euromail.dev and mail2.euromail.dev (publish both for redundancy; mail1 alone also verifies, as does the legacy inbound.euromail.dev). Verification fails for any other target.

Note that this is a different record from the return-path MX in domain verification. The two coexist because they live on different hosts:

  • Inbound MX goes on the domain you receive mail at (yourdomain.com above) and points to mail1.euromail.dev
  • Return-path MX goes on your sending subdomain (em.yourdomain.com) and points to bounce.euromail.dev

Don't point the sending subdomain's MX at mail1.euromail.dev; that breaks return-path verification without making inbound work.

2. Verify and Enable Inbound on Your Domain

Run a verification check from the domain page in the dashboard (or POST /v1/domains/{domain_id}/verify). The response includes an mx check that confirms your MX records point to an accepted EuroMail target; the failure detail names the target it found instead. Then enable inbound email processing in the domain's settings to mark the domain as eligible to receive mail.

3. Create Inbound Routes

Routes determine which recipient addresses your account accepts and where the emails are delivered. Mail sent to an address no route accepts is refused during delivery, so the sender gets a bounce rather than silence. See When mail is refused below.

Match Types

Match TypePatternMatchesExample
exactsupport@Only [email protected]Customer support inbox
prefixnoreplyAny address starting with noreply[email protected]
catch_all*@All addresses on the domainAccept everything

Routes are evaluated by priority (highest first), then by specificity: exact matches take precedence over prefix matches, which take precedence over catch-all.

Where Matched Mail Goes

A route with a webhook URL delivers each matched message to that URL, signed and retried like any other webhook, and its attempts appear in the delivery history. A route without one stores the message for you to fetch over the API.

Account-level webhooks subscribed to email.inbound also receive matched messages. If an account webhook already points at the same URL as the route, the message is delivered once, not twice.

Create a Route via API

curl -X POST https://api.euromail.dev/v1/inbound-routes \
  -H "X-EuroMail-Api-Key: em_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "domain_id": "your-domain-uuid",
    "pattern": "support@",
    "match_type": "exact",
    "priority": 10,
    "webhook_url": "https://yourapp.com/webhooks/inbound"
  }'

Create a Route via Dashboard

Navigate to Inbound > Routes in the dashboard. Click New Route, select the domain, choose the match type, enter the pattern, and optionally set a webhook URL.

When Mail Is Refused

If nothing has a route for the address, the receiving server is told so while the message is still being handed over:

550 5.1.1 No such recipient here

The sender gets a bounce naming the address, which is what a wrong address should produce. The same answer is given when the domain has inbound switched on but its MX records are not verified yet, because mail to it could not be delivered anyway.

Every refusal is listed on the Inbound page under Refused deliveries: the address, why it was refused, who sent it, how many times, and when. Each row offers the fix, so a missing route is one click from being created with the address already filled in. We keep the envelope and the reason only. The message itself was never accepted, so there is no body or subject to store.

One case is deliberately different. If we cannot reach our own database to answer the question, the sending server is told:

451 4.3.0 Recipient verification unavailable, try again later

That is a temporary answer, and well-behaved senders retry within minutes. A permanent refusal there would tell a real sender that a real address does not exist, over a fault of ours.

Why nothing arrived

Work down this list; the Inbound page answers the first three.

  1. Does the domain say "receiving"? The Receiving setup panel counts domains with inbound switched on and MX verified. A domain with inbound on and MX unverified refuses everything.
  2. Is there a route? Without one, every message is refused. The panel says so when the count is zero.
  3. Was it refused? Check Refused deliveries for the address you sent to. The reason distinguishes a missing route from unfinished DNS.
  4. Did your endpoint get it? The Routes page shows each route's last delivery: the status code we got back, or the error your endpoint returned. A route with no URL stores the message and delivers nothing, which is a valid setup and says so.

A route takes up to a minute to start accepting mail after you create it.

Webhook Payload

When an inbound email matches a route with a webhook URL configured, EuroMail fires an email.inbound event. The payload includes the full parsed email:

{
  "event": "email.inbound",
  "inbound_email_id": "550e8400-e29b-41d4-a716-446655440000",
  "account_id": "123e4567-e89b-12d3-a456-426614174000",
  "domain": "yourdomain.com",
  "from": "[email protected]",
  "to": "[email protected]",
  "subject": "Question about my order",
  "text_body": "Hi, I have a question about order #12345...",
  "html_body": "<html><body><p>Hi, I have a question...</p></body></html>",
  "attachments": [
    {
      "filename": "receipt.pdf",
      "content_type": "application/pdf",
      "size": 45231
    }
  ],
  "message_id": "<[email protected]>",
  "authentication": { "spf": "pass" },
  "source_ip": "203.0.113.42",
  "timestamp": "2026-03-09T14:32:01Z"
}
FieldDescription
fromSMTP envelope sender (MAIL FROM)
toThe matched recipient address
subjectParsed Subject header (may be null)
text_bodyPlain text body part (may be null)
html_bodyHTML body part (may be null)
attachmentsArray of attachment metadata (filename, content type, size in bytes), or null if none
message_idThe Message-ID header value (may be null)
authentication.spfSender SPF verdict: pass, fail, softfail, neutral, none, temperror, or permerror (null if the check couldn't run). Use it to decide how much to trust the sender. A fail means the sending server is not authorized for the envelope domain and the sender is very likely forged.
source_ipIP address of the sending server

Note: euromail also uses this verdict itself. An agent mailbox with an auto-responder will not reply to a message whose SPF failed or softfailed, to avoid sending backscatter to a likely-forged sender.

The webhook is signed with HMAC-SHA256 using your webhook signing secret, delivered in the X-Euromail-Signature header as t=<unix_timestamp>,v1=<hex_signature>. See the webhooks guide for the exact verification algorithm, code in five languages, and retry behavior.

API Reference

All inbound API endpoints require authentication via the X-EuroMail-Api-Key header.

Inbound Emails

List Inbound Emails

GET /v1/inbound?page=1&per_page=25

Returns a paginated list of received emails for your account.

Response:

{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "mail_from": "[email protected]",
      "rcpt_to": ["[email protected]"],
      "subject": "Question about my order",
      "from_header": "John Doe <[email protected]>",
      "to_header": "[email protected]",
      "source_ip": "203.0.113.42",
      "size_bytes": 12480,
      "status": "delivered",
      "created_at": "2026-03-09T14:32:01Z"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 25,
    "total": 42,
    "total_pages": 2
  }
}

Get Inbound Email

GET /v1/inbound/{id}

Returns the full inbound email including text body, HTML body, raw headers, and attachment metadata.

Delete Inbound Email

DELETE /v1/inbound/{id}

Permanently deletes an inbound email. Returns 204 No Content on success.

Inbound Routes

Create Route

POST /v1/inbound-routes

Request body:

{
  "domain_id": "uuid",
  "pattern": "support@",
  "match_type": "exact",
  "priority": 10,
  "webhook_url": "https://yourapp.com/webhooks/inbound"
}
FieldRequiredDescription
domain_idYesUUID of a verified domain you own
patternYesAddress pattern to match (see match types above)
match_typeYesOne of exact, prefix, or catch_all
priorityNoHigher priority routes are evaluated first (default: 0)
webhook_urlNoHTTPS URL to receive email.inbound webhook events

Pattern rules:

  • exact patterns must end with @ (e.g. support@)
  • prefix patterns must not contain @ (e.g. noreply)
  • catch_all pattern must be *@

List Routes

GET /v1/inbound-routes?page=1&per_page=25

Get Route

GET /v1/inbound-routes/{id}

Update Route

PUT /v1/inbound-routes/{id}

Request body:

{
  "pattern": "support@",
  "match_type": "exact",
  "priority": 10,
  "webhook_url": "https://yourapp.com/webhooks/inbound",
  "is_active": true
}

Delete Route

DELETE /v1/inbound-routes/{id}

Returns 204 No Content on success.

Processing a Webhook in Your Application

from flask import Flask, request, jsonify
import hmac
import hashlib
import time

app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_signing_secret"


def verify_signature(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        return False
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(), f"{timestamp}.{payload.decode()}".encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.route("/webhooks/inbound", methods=["POST"])
def handle_inbound():
    signature = request.headers.get("X-Euromail-Signature", "")
    if not verify_signature(request.data, signature, WEBHOOK_SECRET):
        return "Invalid signature", 401

    data = request.json
    print(f"From: {data['from']}")
    print(f"Subject: {data['subject']}")
    print(f"Body: {data['text_body']}")

    # Process attachments
    for att in data.get("attachments") or []:
        print(f"Attachment: {att['filename']} ({att['content_type']}, {att['size']} bytes)")

    return jsonify({"status": "ok"}), 200
const express = require("express");
const crypto = require("crypto");

const app = express();
const WEBHOOK_SECRET = "your_webhook_signing_secret";

function verifySignature(payload, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=").map((s) => s.trim()))
  );
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${payload}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhooks/inbound", express.raw({ type: "*/*" }), (req, res) => {
  const signature = req.headers["x-euromail-signature"] || "";
  if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  const data = JSON.parse(req.body);
  console.log(`From: ${data.from}`);
  console.log(`Subject: ${data.subject}`);

  res.json({ status: "ok" });
});

app.listen(3000);

Limits

LimitValue
Maximum message size25 MB
Rate limit per source IP50 connections per minute
Attachment metadataFilename, content type, and size are stored per attachment

Dashboard

The dashboard provides a complete interface for managing inbound email:

  • Inbound list -- Search, filter by status and date range, view sender, subject, and recipients at a glance.
  • Email detail -- View parsed headers, toggle between text and HTML body rendering, see attachment metadata, and delete individual emails.
  • Route management -- Create, edit, activate/deactivate, and delete inbound routes from the Routes tab.

Send your first email in about 90 seconds

The free tier includes 3,000 emails a month. All data stays in Finland | GDPR compliance without the paperwork.

Create free account See live delivery data