CarlyEmail docs

Receiving

What happens between the sender pressing send and your API call.

Mail addressed to one of your inboxes is accepted, written to storage, parsed, threaded and stored — usually within a couple of seconds. Then it is on GET /v0/inboxes/{id}/messages.

Every message carries its verdicts

The receiving path checks SPF, DKIM and DMARC before we ever see the message, and those results are recorded. A message that fails DMARC — or fails both SPF and DKIM — is labelled unauthenticated.

It is kept, not dropped. You can list it explicitly and inspect it. It is simply not returned by default, because handing an agent spoofed mail as though it were genuine is the whole attack this is defending against.

Spam and virus verdicts apply the spam label the same way.

Nothing is acknowledged before it is stored

If storing a message fails — a transient outage anywhere in the path — the delivery is not acknowledged, and it is retried. Anything that exhausts its retries goes to a dead-letter queue and raises an alarm rather than disappearing.

Permanent failures are acknowledged instead: mail for an address that does not exist can never succeed, and retrying it only burns the delivery budget.

Reacting to arrival

Polling works. Webhooks are better — you get message.received pushed the moment it lands.

The receiver

Writing the handler by hand is six decisions, and the same six every time. The Python SDK ships them:

pip install carlyemail

Python

from carlyemail.inbound import create_email_router
from fastapi import FastAPI

app = FastAPI()


async def on_email(email):
    print(email.from_address, email.subject, email.text)


app.include_router(create_email_router(on_email, path="/hooks/carlyemail"))

The handler runs only for mail worth answering. Everything else has already been decided:

The signature is checked over the raw bytes Re-serializing JSON can reorder keys, and the signature covers the bytes
A delivery that does not verify gets 401 Including a signature header that is not valid base64 — the case that otherwise returns 500 to anyone who posts junk at a public URL
Only message.received is admitted Spam, blocked and unauthenticated mail arrive as separate event types, so a prefix match on message.received lets all three through
Mail from the inbox itself is dropped An alias or a list can loop mail round, and an agent answering itself does not stop
The sender is checked against allow_from Parsed whole, so emma@example.com.attacker.net is not a match
A repeated event_id is ignored Delivery is at least once
The request is answered before your handler runs A model turn can outlast the delivery timeout, and a timed-out delivery is retried into a second reply

Options

secret Signing secret. Defaults to CARLYEMAIL_WEBHOOK_SECRET
inbox The address being answered, so its own sends are dropped. Defaults to CARLYEMAIL_INBOX
allow_from emma@example.com or @example.com. Defaults to ALLOWED_SENDERS; empty means anyone
event_types Which events reach the handler. Defaults to ("message.received",)
background True acks with 202 then runs. False runs first and lets an exception become a 5xx so CarlyEmail retries
tolerance_seconds How old a signature may be. Defaults to 300
remember How many event_ids to hold. Defaults to 2048

The replay guard is in memory and bounded, so it resets when the process does. That covers the redeliveries that cluster after a failure. If acting twice on one email is genuinely costly, record event_id wherever you already keep state.

Without FastAPI

decide() is the same logic with no framework near it — raw bytes and headers in, a decision out. Anything that can supply those can use it.

Python

from carlyemail.inbound import InboundReceiver

receiver = InboundReceiver(allow_from=["emma@example.com"])


def handle(body: bytes, headers: dict) -> int:
    decision = receiver.decide(body, headers)
    if decision.email is not None:
        answer(decision.email)
    return decision.status

decision.status is the response to send: 401 if it was never CarlyEmail, 204 if there is nothing to do, 202 if the agent should run.

Reading the message

email.text is the new writing with the quoted chain stripped, so a long thread is not resent on every turn. email.thread_id is the conversation key to store memory under, and email.message_id is what to reply to — composing a new message starts a second thread the sender sees separately.

Deliveries above 1 MB arrive with the body dropped. When email.text is empty, fetch the message by id.