flowbox
A vendor-neutral email connector: read inboxes and send mail through the adapter you choose. Standard library only.
../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:
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():
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
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.
try:
outbox.send_mail(message)
except AdapterError as exc:
if not exc.transient:
raise
... # retry later, same message, same idempotency_key
Reading
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:
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_...).
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"))
Metadata
Release files for flowbox 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| flowbox-0.2.0.tar.gz | 33.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| flowbox-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 60.3 kB
Release files / flowbox-0.2.0.tar.gz
| Download URL | flowbox-0.2.0.tar.gz |
|---|---|
| Size | 33.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1dcaf56c45cc861ba44926dc6502f1dff013fcdce19892c192ae0960fbb96154
|
|
BLAKE2b-256 checksum How to use checksums |
2b16a72a13dc47f570a82c6be28bf47b39fcaed97a4124dba3b86dabfe6d0380
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.3
|
Release files / flowbox-0.2.0-py3-none-any.whl
| Download URL | flowbox-0.2.0-py3-none-any.whl |
|---|---|
| Size | 26.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8fd6ef168959090490f276decdfc6382d0d3fa171f43a6d134b01c1b476f94d4
|
|
BLAKE2b-256 checksum How to use checksums |
4816a137297cb4ab3e92051505a7e6328beaee51c663e3ecb77e5b1e7df0a0e0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.3
|