Bird

Process mailbox events and attachments

Start work when a message reaches your mailbox. Verify the delivery, store its identity and let a separate worker retrieve the current thread. Download an attachment only when the task requires its contents.

Choose the event and processing identity

Subscribe to email_mailbox.message_received through webhook setup. The event supplies data.mailbox_id, data.thread_id and data.message_id. Its extracted_text can be absent or truncated; read the message when your task needs retained text or the original body.
Inbox mail can also produce email.received. That event's data.inbound_message_id identifies the same received message. This example subscribes to the mailbox event and deduplicates by received-message ID. Repeated deliveries with different delivery IDs still create one queue entry for that message.

Store the verified event

Use the directory from the Python support inbox, with mailbox_app.py and its initialized state file. Install the web framework and set the mailbox ID returned by init:
Code example
pip install "fastapi[standard]"
export BIRD_MAILBOX_ID="mbx_..."
export BIRD_WEBHOOK_SECRET="whsec_..."
Save this as mailbox_receiver.py. The signing secret comes from the registered webhook endpoint. The receiver keeps identifiers in SQLite and acknowledges a message after its transaction commits.
Code example
import os
import sqlite3
from contextlib import asynccontextmanager

from bird import Bird, WebhookVerificationError
from fastapi import FastAPI, HTTPException, Request, Response

DB = os.environ.get("MAILBOX_EVENTS_DB", "mailbox-events.sqlite3")
MAILBOX_ID = os.environ["BIRD_MAILBOX_ID"]
os.umask(0o077)


@asynccontextmanager
async def lifespan(app):
    with Bird(webhook_secret=os.environ["BIRD_WEBHOOK_SECRET"]) as bird:
        app.state.bird = bird
        yield


app = FastAPI(lifespan=lifespan)


def database():
    db = sqlite3.connect(DB, timeout=5)
    db.execute("""CREATE TABLE IF NOT EXISTS incoming (
        message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL,
        event_id TEXT NOT NULL, occurred_at TEXT NOT NULL,
        state TEXT NOT NULL DEFAULT 'pending')""")
    db.commit()
    return db


@app.post("/webhooks/mailboxes")
async def receive(request: Request):
    raw = bytearray()
    async for chunk in request.stream():
        raw.extend(chunk)
        if len(raw) > 2 * 1024 * 1024:
            raise HTTPException(413, "This receiver accepts bodies up to 2 MiB.")
    try:
        event = request.app.state.bird.webhooks.unwrap(bytes(raw), dict(request.headers)).root
    except WebhookVerificationError:
        raise HTTPException(400, "Invalid webhook signature or payload.")
    if event.type != "email_mailbox.message_received":
        return Response(status_code=204)
    data = event.data
    if data.mailbox_id != MAILBOX_ID:
        return Response(status_code=204)
    db = database()
    try:
        with db:
            db.execute("BEGIN IMMEDIATE")
            existing = db.execute("SELECT thread_id FROM incoming WHERE message_id=?", (data.message_id,)).fetchone()
            if existing and existing[0] != data.thread_id:
                raise HTTPException(409, "Message and thread identity disagree.")
            if not existing:
                if db.execute("SELECT count(*) FROM incoming").fetchone()[0] >= 1000:
                    raise HTTPException(503, "Demo queue is full; retain deduplication records while recovering.")
                db.execute("INSERT INTO incoming(message_id, thread_id, event_id, occurred_at) VALUES (?, ?, ?, ?)",
                           (data.message_id, data.thread_id, request.headers["webhook-id"], event.timestamp))
    finally:
        db.close()
    return Response(status_code=204)
Run fastapi dev mailbox_receiver.py --host 127.0.0.1. For a local test, use a reachable HTTPS tunnel you control and register its /webhooks/mailboxes URL. Bird cannot deliver to localhost directly.
The 2 MiB body cap and 1,000-row queue are limits of this teaching application. A full queue returns 503; it does not acknowledge and discard the event. Retain deduplication records for the replay horizon you support. For production, use shared durable storage and an explicit retention policy.

Process pending work

Save this as mailbox_worker.py, next to the Python support-inbox application:
Code example
import json
from pathlib import Path
from mailbox_app import run
from mailbox_receiver import database


def drain():
    db = database()
    try:
        pending = db.execute("SELECT message_id, thread_id FROM incoming WHERE state='pending' LIMIT 20").fetchall()
        for message_id, thread_id in pending:
            try:
                run("sync", thread_id)
                local = json.loads(Path("mailbox-state.json").read_text())
                job = local["jobs"].get(message_id)
                outcome = "done" if job and job["state"] == "accepted" else "needs_review"
                with db:
                    db.execute("UPDATE incoming SET state=? WHERE message_id=? AND state='pending'", (outcome, message_id))
            except Exception as error:
                print(json.dumps({"message": message_id, "result": "pending", "error": str(error)}))
    finally:
        db.close()


if __name__ == "__main__":
    drain()
Send a controlled message to the mailbox, then run python mailbox_worker.py. The worker reads pending identifiers and runs the same thread-aware application. Inspect the queue:
Code example
python -c 'import sqlite3; db=sqlite3.connect("mailbox-events.sqlite3"); print(db.execute("SELECT message_id, thread_id, state FROM incoming").fetchall()); db.close()'
done means the example recorded an accepted reply for that source message. It does not mean the customer received it or the support request was resolved. pending retains a failed attempt for investigation or another worker run. needs_review covers an uncertain reply or an event for an earlier message that the latest-message workflow did not acknowledge individually.
For uncertain replies, use the support-inbox example's resolve command after locating the matching sent message. Then requeue that specific event in your application to have the worker confirm the recorded result. Do not automatically clear needs_review records or delete the mailbox state file.
A process exit after the reply is recorded but before the queue row is updated leaves the row pending. On the next run, the application recognizes its saved reply. The local file lock prevents concurrent commands; the SQLite transaction separately owns durable event receipt.
Test a signed delivery twice, an altered body, a message for another mailbox and a failure opening the database. A signature failure should return 400; a storage failure must not return success. The minimal connectivity stub from the endpoint test action is not a complete mailbox event; use an actual controlled incoming message or a complete signed fixture.

Inspect and retrieve an attachment

Read attachment identifiers, names, declared content types and sizes with list thread-message attachments. The response contains metadata, not a pre-signed download URL. A filename can be null and should not become a local filesystem path.
Download one attachment from the authenticated bytes endpoint. Set these identifiers from a message in your own mailbox and use the region matching your key:
Code example
export BIRD_API_BASE="https://us1.platform.bird.com"
export THREAD_ID="thr_..."
export RECEIVED_MESSAGE_ID="rem_..."
export ATTACHMENT_ID="rea_..."
curl --fail --show-error --silent \
  "$BIRD_API_BASE/v1/email/threads/$THREAD_ID/messages/$RECEIVED_MESSAGE_ID/attachments/$ATTACHMENT_ID" \
  -H "Authorization: Bearer $BIRD_API_KEY" \
  --max-time 30 \
  --max-filesize 1048576 \
  --output attachment.bin
This command sets a 1 MiB application download budget for a small test attachment and writes to a name you chose. If it fails, treat any partial file as incomplete. Before parsing or forwarding content, enforce your file-size and type policy, scan it as required by your application, and avoid executing or automatically rendering it. A MIME label is metadata, not evidence that the file is safe.
The attachment reference defines the bytes response and 410 Gone behavior after retention. Original bodies and received MIME have their own 30-day window. Keep the exact retention distinctions in the worker's recovery plan.

Recover after an event gap

A webhook is a trigger. Reconcile inbox threads and their received messages after a paused receiver or exhausted delivery retries. Replay redelivers failed deliveries; it does not manufacture events that were never sent. The support-inbox application's sync command lists the mailbox and helps inspect current work within retention.
Rule-blocked mail can be stored without an inbox event; a drop policy discards incoming mail. Inspect receive policies before assuming that missing events mean missing mail.