Metadata-Version: 2.4
Name: zeno-channel-email
Version: 1.0.2
Summary: Email channel for Zeno (Resend inbound + outbound).
Project-URL: Homepage, https://github.com/nkootstra/zeno
Project-URL: Repository, https://github.com/nkootstra/zeno
Project-URL: Issues, https://github.com/nkootstra/zeno/issues
Project-URL: Changelog, https://github.com/nkootstra/zeno/blob/main/CHANGELOG.md
Author: Niels Kootstra
License-Expression: MIT
License-File: LICENSE
Keywords: agent,ai,channel,email,resend,webhook,zeno
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: httpx<1,>=0.27
Requires-Dist: starlette<2,>=1.0
Requires-Dist: uvicorn<1,>=0.44
Requires-Dist: zeno-core
Description-Content-Type: text/markdown

# zeno-channel-email

Email channel for [Zeno](https://github.com/nkootstra/zeno) backed by [Resend](https://resend.com).
Hosts a Starlette + uvicorn server for Resend's inbound webhook and posts
outbound replies (with correct RFC 5322 threading headers) to Resend's
REST API.

Verifies inbound webhooks with Svix-style HMAC-SHA256 signatures and
enforces a sender allowlist. Replies to an inbound email land in the
same thread in Gmail, Outlook, and Apple Mail because the channel
owns the `Message-ID` / `In-Reply-To` / `References` header math.

## Install

```bash
uv add 'zeno-framework[email]'
# or, without the AI package:
uv add zeno-channel-email
```

## Minimal usage

```python
from zeno.channels.email import EmailChannel

channel = EmailChannel(
    api_key="re_...",                                    # Resend API key
    signing_secret="whsec_...",                          # Resend webhook secret
    from_address="zeno@mail.example.com",
    allowed_senders=("you@example.com",),
    host="127.0.0.1",
    port=8080,
    webhook_path="/webhook",
)
# Then pass it to ZenoApp(channels=[channel]) exactly like any Channel.
```

The signing secret rotation flow uses Svix's multi-secret window:
add the new secret in the Resend dashboard first, restart the process
with the new env var, then remove the old secret. No downtime.

See [`apps/zeno-example-chat`](https://github.com/nkootstra/zeno/blob/main/apps/zeno-example-chat/README.md)
for a runnable reference wiring that turns the email channel on only
when the relevant env vars are set.

## Behavior

- **Signature verification**: Resend signs inbound webhooks with
  Svix. Bad or missing signature → `401`. Every other drop (stale
  timestamp, unknown event type, unallowlisted sender, malformed
  JSON, `fetch_received` failure, queue full) returns `200` so
  Resend doesn't retry and so the 401 audit signal stays meaningful.
- **Two-phase inbound**: Resend's `email.received` webhook carries
  metadata only. The channel GETs `/emails/received/{email_id}` for
  the body and headers before enqueuing an `IncomingMessage`.
- **Inbound → `IncomingMessage`**: `user_id` = normalized lower-case
  `From:` address (parsed with `email.utils.parseaddr` to defeat
  display-name injection), `text` = preferred `text/plain` body
  with HTML fallback, `thread_key` = same as `user_id` so replies
  route back. RFC-5322 headers are cached per-thread in-process so
  outbound replies can build correct `In-Reply-To` / `References`.
  Attachments on inbound are ignored in 0.7.0.
- **Outbound**: text + optional attachments via `POST /emails`.
  `multipart/alternative` is synthesized from the agent's plaintext
  reply (no user-authored HTML templates). Message-ID is generated
  per reply. References chain is capped at 50 entries to defend
  against crafted-header DoS.
- **Capabilities**: `supports_threading=True`, `supports_images=False`
  (attachments flow outbound only in 0.7.0).

## Security notes

- `signing_secret` and `api_key` are marked `repr=False` on the
  channel dataclass so they don't leak in traceback / debug output.
- The 300 s Svix timestamp window is mandatory (replay defense).
- `allowed_senders` is normalized to lower-case at construction;
  inbound `From:` is also normalized so the allowlist check is
  case-insensitive.
- Resend's `POST /emails` caps a single email at 40 MB
  (attachments counted after base64 encoding).

## Testing

```bash
uv run pytest packages/zeno-channel-email
```

Tests use `httpx.MockTransport` for Resend's REST surface and the
Starlette `TestClient` for the router. No Resend account required
in CI.

## See also

- [`zeno-channel-cli`](../zeno-channel-cli/README.md), [`zeno-channel-sendblue`](../zeno-channel-sendblue/README.md) — other first-party channels.
- [`docs/adapters.md`](../../docs/adapters.md) — writing your own `Channel`.

Part of the [Zeno framework](https://github.com/nkootstra/zeno).
