(request: Request)
| 90 | |
| 91 | @app.post("/webhook") |
| 92 | async def webhook(request: Request) -> dict[str, str]: |
| 93 | body = await request.body() |
| 94 | # construct_event does parse + HMAC-verify in one call and raises |
| 95 | # E2AWebhookSignatureError on a bad signature or replay. Anyone can reach a |
| 96 | # public webhook URL; this verification is what proves the payload came from |
| 97 | # your e2a relay (the signature binds to the sender claim e2a verified via |
| 98 | # SPF/DKIM), so trust event.data only *after* it succeeds. |
| 99 | try: |
| 100 | event = construct_event(body, request.headers.get("X-E2A-Signature", ""), WEBHOOK_SECRET) |
| 101 | except E2AWebhookSignatureError: |
| 102 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="bad signature") |
| 103 | |
| 104 | # We only act on inbound mail; ignore other event types (sent/approved/…). |
| 105 | if event.type != "email.received": |
| 106 | return {"status": "ignored", "type": event.type} |
| 107 | |
| 108 | client: E2AClient = request.app.state.e2a |
| 109 | data = event.data # WebhookPayload: message_id, recipient, from, conversation_id, … |
| 110 | recipient = data["recipient"] |
| 111 | message_id = data["message_id"] |
| 112 | |
| 113 | # The inbound webhook payload is metadata; fetch the parsed message for the |
| 114 | # subject + body to feed the agent. |
| 115 | inbound = await client.messages.get(recipient, message_id) |
| 116 | |
| 117 | # First contact has no conversation_id — mint one so this thread has an |
| 118 | # ID we can echo back on every reply. The same id becomes the ADK |
| 119 | # session_id, keying multi-turn memory. |
| 120 | conversation_id = data.get("conversation_id") or f"conv_{uuid.uuid4().hex[:12]}" |
| 121 | |
| 122 | # user_id scopes a session to a particular human counterpart. Different |
| 123 | # senders get isolated session histories even on the same agent. |
| 124 | user_id = data["from"] |
| 125 | |
| 126 | sessions = request.app.state.sessions |
| 127 | session = await sessions.get_session( |
| 128 | app_name=APP_NAME, user_id=user_id, session_id=conversation_id |
| 129 | ) |
| 130 | if session is None: |
| 131 | session = await sessions.create_session( |
| 132 | app_name=APP_NAME, user_id=user_id, session_id=conversation_id |
| 133 | ) |
| 134 | |
| 135 | prompt = types.Content( |
| 136 | role="user", |
| 137 | parts=[types.Part(text=_format_email_for_agent(inbound))], |
| 138 | ) |
| 139 | |
| 140 | runner: Runner = request.app.state.runner |
| 141 | reply_text = "" |
| 142 | async for event in runner.run_async( |
| 143 | user_id=user_id, session_id=conversation_id, new_message=prompt |
| 144 | ): |
| 145 | if event.is_final_response() and event.content and event.content.parts: |
| 146 | reply_text = event.content.parts[0].text or "" |
| 147 | |
| 148 | if not reply_text: |
| 149 | # Agent produced no response (rare — usually means a tool call without |
nothing calls this directly
no test coverage detected