Metadata-Version: 2.4
Name: flowbox
Version: 0.2.0
Summary: A vendor-neutral email connector: read inboxes and send mail through the adapter you choose.
Author: Flowmatic, UniForceMusic
License-Expression: MIT
Project-URL: Homepage, https://github.com/Flowmatic-AI/flowmatic-toolkit
Project-URL: Repository, https://github.com/Flowmatic-AI/flowmatic-toolkit
Project-URL: Issues, https://github.com/Flowmatic-AI/flowmatic-toolkit/issues
Keywords: email,mail,inbox,outbox,smtp,imap,lettermint
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Email
Classifier: Typing :: Typed
Requires-Python: >=3.14
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: settings
Requires-Dist: pydantic-settings>=2.10; extra == "settings"
Provides-Extra: dev
Requires-Dist: pydantic-settings>=2.10; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Requires-Dist: pytest>=9.0; extra == "dev"
Requires-Dist: mypy>=2.0; extra == "dev"
Requires-Dist: ruff>=0.16; extra == "dev"
Dynamic: license-file

# flowbox

A vendor-neutral email connector: read inboxes and send mail through the adapter you choose. Standard library only.

[`../project`](../project) is a small flowlab app that shows how to wire it into an application: settings, one factory module, a queued send job, polling and a webhook route.

| Adapter             | Inbox | Webhooks | Outbox |
|---------------------|:-----:|:--------:|:------:|
| `IMAPAdapter`       |   ✓   |          |        |
| `SMTPAdapter`       |       |          |   ✓    |
| `LettermintAdapter` |   ✓   |    ✓     |   ✓    |
| `LogAdapter`        |       |          |   ✓    |

`LogAdapter` sends nothing; it logs each message on the `flowbox.log` logger, like Laravel's log mailer. Use it for local work, not production.

`FlowOutbox` takes an `OutboxAdapterABC`. `FlowInbox` takes an `InboxAdapterABC` (a mailbox you read from), a `WebhookInboxAdapterABC` (a provider that pushes mail to you), or an adapter that is both. Using an adapter for something it cannot do — `FlowInbox(SMTPAdapter(...))`, or `parse_webhook()` on an IMAP inbox — is a type error, so mypy or your editor flags it before it runs.

## Settings from the environment

`pip install flowbox[settings]` adds `FlowboxSettings`, a pydantic-settings class that picks the adapters from environment variables and builds the flows. Subclass it for your app's own settings:

```python
from pydantic_settings import SettingsConfigDict

from flowbox.settings import FlowboxSettings


class Settings(FlowboxSettings):
    model_config = SettingsConfigDict(env_file=".env")


settings = Settings()

with settings.outbox() as outbox:
    outbox.send_mail(message)

with settings.inbox() as inbox:
    messages = inbox.retrieve_mails("INBOX", since)
```

Each `outbox()` / `inbox()` call builds a new flow: open one per job, command or request, since IMAP and SMTP adapters hold a socket and are not thread-safe. Construction fails when a selected driver is missing its credentials, and the error names the variables to set, never their values.

| Variable | Default | Used for |
|---|---|---|
| `MAIL_OUTBOX` | `log` | `log`, `smtp` or `lettermint` |
| `MAIL_INBOX` | unset | `imap` or `lettermint`; unset when the app reads no mail |
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | unset | The sender of messages that set none |
| `MAIL_TIMEOUT` | `30` | Seconds before a provider call gives up |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` | | `MAIL_OUTBOX=smtp`; the port follows the encryption when unset |
| `SMTP_ENCRYPTION` | `starttls` | `ssl`, `starttls` or `none` |
| `IMAP_HOST`, `IMAP_PORT`, `IMAP_USERNAME`, `IMAP_PASSWORD` | | `MAIL_INBOX=imap` |
| `IMAP_ENCRYPTION` | `ssl` | `ssl`, `starttls` or `none` |
| `LETTERMINT_PROJECT_TOKEN`, `LETTERMINT_ROUTE` | | `MAIL_OUTBOX=lettermint` |
| `LETTERMINT_TEAM_TOKEN`, `LETTERMINT_PROJECT_ID` | | `MAIL_INBOX=lettermint` |
| `LETTERMINT_WEBHOOK_SECRET` | | `settings.lettermint_webhooks()` |

Webhooks stay per vendor: `settings.lettermint_webhooks()` returns a `FlowInbox[LettermintAdapter]` for that vendor's webhook route. Your own adapter goes in by overriding `outbox_adapter()` or `inbox_adapter()`:

```python
class Settings(FlowboxSettings):
    crm_api_key: str | None = None

    def outbox_adapter(self) -> OutboxAdapterABC:
        if self.crm_api_key:
            return CRMAdapter(self.crm_api_key)
        return super().outbox_adapter()
```

`flowbox.settings` is the only module that reads the environment, and the core never imports it.

## Sending

```python
from flowbox import Address, Attachment, FlowOutbox, Message

with FlowOutbox.connect_smtp("smtp.example.com", "user", "secret", sender=Address("noreply@acme.com", "Acme")) as outbox:
    message_id = outbox.send_mail(
        Message(
            subject="Your invoice",
            to=[Address("customer@example.com")],
            text="Your invoice is attached.",
            attachments=[Attachment.from_path("invoice.pdf")],
        )
    )
```

### Sending options

A few `Message` fields are for providers that support them; an adapter whose provider has no equivalent ignores them.

| Field | Lettermint | SMTP |
|---|---|---|
| `idempotency_key` | `Idempotency-Key`: the same key is sent once, so retrying after an uncertain failure cannot send twice | ignored |
| `tag`, `metadata` | stored with the message, returned in delivery webhooks | ignored |
| `track_opens`, `track_clicks` | per-message tracking; `None` keeps the project default | ignored |
| `require_tls` | `tls: enforced` | ignored; the connection's own `encryption` applies |

### Errors

Every provider failure is an `AdapterError`. `status_code` is the provider's HTTP status when it answered with one, and `transient` says whether trying again later may succeed (timeouts, dropped connections, rate limits, server errors, SMTP 4xx replies). A transient failure while sending can mean the message went out before the connection broke: retry with the same `idempotency_key`.

```python
try:
    outbox.send_mail(message)
except AdapterError as exc:
    if not exc.transient:
        raise
    ...  # retry later, same message, same idempotency_key
```

## Reading

```python
from datetime import datetime, timedelta

from flowbox import FlowInbox

with FlowInbox.connect_imap("imap.example.com", "user", "secret") as inbox:
    print(inbox.get_mailboxes())

    for message in inbox.retrieve_mails("INBOX", datetime.now() - timedelta(days=1)):
        print(message.received_at, message.sender, message.subject)
```

`retrieve_mails()` returns everything received at or after `from_datetime`, oldest first, and never marks mail as read.

## Webhooks

Providers that push received mail to you sign and shape their webhooks differently; the adapter verifies the signature and translates the payload. Hand it the raw request body, before any JSON or form parsing, and the headers:

```python
from fastapi import FastAPI, HTTPException, Request

from flowbox import FlowInbox, SignatureError

app = FastAPI()
inbox = FlowInbox.connect_lettermint(webhook_secret="whsec_...")


@app.post("/webhooks/inbound")
async def inbound(request: Request) -> None:
    try:
        message = inbox.parse_webhook(await request.body(), request.headers)
    except SignatureError:
        raise HTTPException(status_code=401)
    ...
```

Responding is up to your app. Providers retry deliveries that get no 2xx, so the same message can arrive twice; `message.id` stays the same.

A correctly signed webhook for another event (a delivery report sent to the same URL) raises `UnsupportedWebhookError`, a subclass of `AdapterError` with the event name in `event`; answer it with a 2xx so the provider stops retrying. Fields the provider sends that `Message` has no place for — Lettermint's `route`, envelope `recipient`, `is_spam`, `authentication_results` and the event `id` — are in `message.provider_data`, as sent, without the body and attachment contents.

A Lettermint route can deliver large attachments as signed download links instead of inline. `parse_webhook()` downloads them by default; `FlowInbox.connect_lettermint(webhook_secret=..., download_attachments=False)` leaves their `content` empty and their `url` in `provider_data`, so a queued job can fetch them after the webhook has been answered.

## Lettermint

One adapter covers everything; each part needs only its own credential. Sending uses a project token (`lm_...`), `retrieve_mails()` a team token (`lm_team_...`) with the team's inbound routes as the mailboxes, and `parse_webhook()` the inbound route webhook's signing secret (`whsec_...`).

```python
from flowbox import Address, FlowInbox, FlowOutbox
from flowbox.adapters import LettermintAdapter

lettermint = LettermintAdapter(project_token="lm_...", team_token="lm_team_...", webhook_secret="whsec_...")

inbox = FlowInbox(lettermint)
outbox = FlowOutbox(lettermint, sender=Address("noreply@acme.com"))
```
