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.