flowbox 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowbox-0.1.0/LICENSE +21 -0
- flowbox-0.1.0/PKG-INFO +169 -0
- flowbox-0.1.0/README.md +139 -0
- flowbox-0.1.0/pyproject.toml +66 -0
- flowbox-0.1.0/setup.cfg +4 -0
- flowbox-0.1.0/src/flowbox/__init__.py +14 -0
- flowbox-0.1.0/src/flowbox/_exceptions.py +13 -0
- flowbox-0.1.0/src/flowbox/_flowbox.py +157 -0
- flowbox-0.1.0/src/flowbox/_message.py +60 -0
- flowbox-0.1.0/src/flowbox/_mime.py +135 -0
- flowbox-0.1.0/src/flowbox/adapters/__init__.py +23 -0
- flowbox-0.1.0/src/flowbox/adapters/_abc.py +61 -0
- flowbox-0.1.0/src/flowbox/adapters/_imap.py +245 -0
- flowbox-0.1.0/src/flowbox/adapters/_lettermint.py +304 -0
- flowbox-0.1.0/src/flowbox/adapters/_log.py +24 -0
- flowbox-0.1.0/src/flowbox/adapters/_smtp.py +112 -0
- flowbox-0.1.0/src/flowbox/py.typed +0 -0
- flowbox-0.1.0/src/flowbox/settings/__init__.py +24 -0
- flowbox-0.1.0/src/flowbox/settings/_settings.py +212 -0
- flowbox-0.1.0/src/flowbox.egg-info/PKG-INFO +169 -0
- flowbox-0.1.0/src/flowbox.egg-info/SOURCES.txt +30 -0
- flowbox-0.1.0/src/flowbox.egg-info/dependency_links.txt +1 -0
- flowbox-0.1.0/src/flowbox.egg-info/requires.txt +11 -0
- flowbox-0.1.0/src/flowbox.egg-info/top_level.txt +1 -0
- flowbox-0.1.0/tests/test_boundary.py +56 -0
- flowbox-0.1.0/tests/test_flowbox.py +49 -0
- flowbox-0.1.0/tests/test_imap.py +101 -0
- flowbox-0.1.0/tests/test_lettermint.py +225 -0
- flowbox-0.1.0/tests/test_mime.py +76 -0
- flowbox-0.1.0/tests/test_settings.py +144 -0
- flowbox-0.1.0/tests/test_smtp.py +79 -0
- flowbox-0.1.0/tests/test_typing.py +33 -0
flowbox-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Flowmatic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
flowbox-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowbox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A vendor-neutral email connector: read inboxes and send mail through the adapter you choose.
|
|
5
|
+
Author: Flowmatic, UniForceMusic
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Flowmatic-AI/flowmatic-toolkit
|
|
8
|
+
Project-URL: Repository, https://github.com/Flowmatic-AI/flowmatic-toolkit
|
|
9
|
+
Project-URL: Issues, https://github.com/Flowmatic-AI/flowmatic-toolkit/issues
|
|
10
|
+
Keywords: email,mail,inbox,outbox,smtp,imap,lettermint
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Communications :: Email
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: settings
|
|
21
|
+
Requires-Dist: pydantic-settings>=2.10; extra == "settings"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pydantic-settings>=2.10; extra == "dev"
|
|
24
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
25
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy>=2.0; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# flowbox
|
|
32
|
+
|
|
33
|
+
A vendor-neutral email connector: read inboxes and send mail through the adapter you choose. Standard library only.
|
|
34
|
+
|
|
35
|
+
[`../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.
|
|
36
|
+
|
|
37
|
+
| Adapter | Inbox | Webhooks | Outbox |
|
|
38
|
+
|---------------------|:-----:|:--------:|:------:|
|
|
39
|
+
| `IMAPAdapter` | ✓ | | |
|
|
40
|
+
| `SMTPAdapter` | | | ✓ |
|
|
41
|
+
| `LettermintAdapter` | ✓ | ✓ | ✓ |
|
|
42
|
+
| `LogAdapter` | | | ✓ |
|
|
43
|
+
|
|
44
|
+
`LogAdapter` sends nothing; it logs each message on the `flowbox.log` logger, like Laravel's log mailer. Use it for local work, not production.
|
|
45
|
+
|
|
46
|
+
`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.
|
|
47
|
+
|
|
48
|
+
## Settings from the environment
|
|
49
|
+
|
|
50
|
+
`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:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from pydantic_settings import SettingsConfigDict
|
|
54
|
+
|
|
55
|
+
from flowbox.settings import FlowboxSettings
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Settings(FlowboxSettings):
|
|
59
|
+
model_config = SettingsConfigDict(env_file=".env")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
settings = Settings()
|
|
63
|
+
|
|
64
|
+
with settings.outbox() as outbox:
|
|
65
|
+
outbox.send_mail(message)
|
|
66
|
+
|
|
67
|
+
with settings.inbox() as inbox:
|
|
68
|
+
messages = inbox.retrieve_mails("INBOX", since)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
| Variable | Default | Used for |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `MAIL_OUTBOX` | `log` | `log`, `smtp` or `lettermint` |
|
|
76
|
+
| `MAIL_INBOX` | unset | `imap` or `lettermint`; unset when the app reads no mail |
|
|
77
|
+
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | unset | The sender of messages that set none |
|
|
78
|
+
| `MAIL_TIMEOUT` | `30` | Seconds before a provider call gives up |
|
|
79
|
+
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` | | `MAIL_OUTBOX=smtp`; the port follows the encryption when unset |
|
|
80
|
+
| `SMTP_ENCRYPTION` | `starttls` | `ssl`, `starttls` or `none` |
|
|
81
|
+
| `IMAP_HOST`, `IMAP_PORT`, `IMAP_USERNAME`, `IMAP_PASSWORD` | | `MAIL_INBOX=imap` |
|
|
82
|
+
| `IMAP_ENCRYPTION` | `ssl` | `ssl`, `starttls` or `none` |
|
|
83
|
+
| `LETTERMINT_PROJECT_TOKEN`, `LETTERMINT_ROUTE` | | `MAIL_OUTBOX=lettermint` |
|
|
84
|
+
| `LETTERMINT_TEAM_TOKEN`, `LETTERMINT_PROJECT_ID` | | `MAIL_INBOX=lettermint` |
|
|
85
|
+
| `LETTERMINT_WEBHOOK_SECRET` | | `settings.lettermint_webhooks()` |
|
|
86
|
+
|
|
87
|
+
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()`:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
class Settings(FlowboxSettings):
|
|
91
|
+
crm_api_key: str | None = None
|
|
92
|
+
|
|
93
|
+
def outbox_adapter(self) -> OutboxAdapterABC:
|
|
94
|
+
if self.crm_api_key:
|
|
95
|
+
return CRMAdapter(self.crm_api_key)
|
|
96
|
+
return super().outbox_adapter()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`flowbox.settings` is the only module that reads the environment, and the core never imports it.
|
|
100
|
+
|
|
101
|
+
## Sending
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from flowbox import Address, Attachment, FlowOutbox, Message
|
|
105
|
+
|
|
106
|
+
with FlowOutbox.connect_smtp("smtp.example.com", "user", "secret", sender=Address("noreply@acme.com", "Acme")) as outbox:
|
|
107
|
+
message_id = outbox.send_mail(
|
|
108
|
+
Message(
|
|
109
|
+
subject="Your invoice",
|
|
110
|
+
to=[Address("customer@example.com")],
|
|
111
|
+
text="Your invoice is attached.",
|
|
112
|
+
attachments=[Attachment.from_path("invoice.pdf")],
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Reading
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from datetime import datetime, timedelta
|
|
121
|
+
|
|
122
|
+
from flowbox import FlowInbox
|
|
123
|
+
|
|
124
|
+
with FlowInbox.connect_imap("imap.example.com", "user", "secret") as inbox:
|
|
125
|
+
print(inbox.get_mailboxes())
|
|
126
|
+
|
|
127
|
+
for message in inbox.retrieve_mails("INBOX", datetime.now() - timedelta(days=1)):
|
|
128
|
+
print(message.received_at, message.sender, message.subject)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`retrieve_mails()` returns everything received at or after `from_datetime`, oldest first, and never marks mail as read.
|
|
132
|
+
|
|
133
|
+
## Webhooks
|
|
134
|
+
|
|
135
|
+
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:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
139
|
+
|
|
140
|
+
from flowbox import FlowInbox, SignatureError
|
|
141
|
+
|
|
142
|
+
app = FastAPI()
|
|
143
|
+
inbox = FlowInbox.connect_lettermint(webhook_secret="whsec_...")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.post("/webhooks/inbound")
|
|
147
|
+
async def inbound(request: Request) -> None:
|
|
148
|
+
try:
|
|
149
|
+
message = inbox.parse_webhook(await request.body(), request.headers)
|
|
150
|
+
except SignatureError:
|
|
151
|
+
raise HTTPException(status_code=401)
|
|
152
|
+
...
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
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.
|
|
156
|
+
|
|
157
|
+
## Lettermint
|
|
158
|
+
|
|
159
|
+
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_...`).
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from flowbox import Address, FlowInbox, FlowOutbox
|
|
163
|
+
from flowbox.adapters import LettermintAdapter
|
|
164
|
+
|
|
165
|
+
lettermint = LettermintAdapter(project_token="lm_...", team_token="lm_team_...", webhook_secret="whsec_...")
|
|
166
|
+
|
|
167
|
+
inbox = FlowInbox(lettermint)
|
|
168
|
+
outbox = FlowOutbox(lettermint, sender=Address("noreply@acme.com"))
|
|
169
|
+
```
|
flowbox-0.1.0/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# flowbox
|
|
2
|
+
|
|
3
|
+
A vendor-neutral email connector: read inboxes and send mail through the adapter you choose. Standard library only.
|
|
4
|
+
|
|
5
|
+
[`../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.
|
|
6
|
+
|
|
7
|
+
| Adapter | Inbox | Webhooks | Outbox |
|
|
8
|
+
|---------------------|:-----:|:--------:|:------:|
|
|
9
|
+
| `IMAPAdapter` | ✓ | | |
|
|
10
|
+
| `SMTPAdapter` | | | ✓ |
|
|
11
|
+
| `LettermintAdapter` | ✓ | ✓ | ✓ |
|
|
12
|
+
| `LogAdapter` | | | ✓ |
|
|
13
|
+
|
|
14
|
+
`LogAdapter` sends nothing; it logs each message on the `flowbox.log` logger, like Laravel's log mailer. Use it for local work, not production.
|
|
15
|
+
|
|
16
|
+
`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.
|
|
17
|
+
|
|
18
|
+
## Settings from the environment
|
|
19
|
+
|
|
20
|
+
`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:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pydantic_settings import SettingsConfigDict
|
|
24
|
+
|
|
25
|
+
from flowbox.settings import FlowboxSettings
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Settings(FlowboxSettings):
|
|
29
|
+
model_config = SettingsConfigDict(env_file=".env")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
settings = Settings()
|
|
33
|
+
|
|
34
|
+
with settings.outbox() as outbox:
|
|
35
|
+
outbox.send_mail(message)
|
|
36
|
+
|
|
37
|
+
with settings.inbox() as inbox:
|
|
38
|
+
messages = inbox.retrieve_mails("INBOX", since)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
| Variable | Default | Used for |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `MAIL_OUTBOX` | `log` | `log`, `smtp` or `lettermint` |
|
|
46
|
+
| `MAIL_INBOX` | unset | `imap` or `lettermint`; unset when the app reads no mail |
|
|
47
|
+
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | unset | The sender of messages that set none |
|
|
48
|
+
| `MAIL_TIMEOUT` | `30` | Seconds before a provider call gives up |
|
|
49
|
+
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` | | `MAIL_OUTBOX=smtp`; the port follows the encryption when unset |
|
|
50
|
+
| `SMTP_ENCRYPTION` | `starttls` | `ssl`, `starttls` or `none` |
|
|
51
|
+
| `IMAP_HOST`, `IMAP_PORT`, `IMAP_USERNAME`, `IMAP_PASSWORD` | | `MAIL_INBOX=imap` |
|
|
52
|
+
| `IMAP_ENCRYPTION` | `ssl` | `ssl`, `starttls` or `none` |
|
|
53
|
+
| `LETTERMINT_PROJECT_TOKEN`, `LETTERMINT_ROUTE` | | `MAIL_OUTBOX=lettermint` |
|
|
54
|
+
| `LETTERMINT_TEAM_TOKEN`, `LETTERMINT_PROJECT_ID` | | `MAIL_INBOX=lettermint` |
|
|
55
|
+
| `LETTERMINT_WEBHOOK_SECRET` | | `settings.lettermint_webhooks()` |
|
|
56
|
+
|
|
57
|
+
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()`:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
class Settings(FlowboxSettings):
|
|
61
|
+
crm_api_key: str | None = None
|
|
62
|
+
|
|
63
|
+
def outbox_adapter(self) -> OutboxAdapterABC:
|
|
64
|
+
if self.crm_api_key:
|
|
65
|
+
return CRMAdapter(self.crm_api_key)
|
|
66
|
+
return super().outbox_adapter()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`flowbox.settings` is the only module that reads the environment, and the core never imports it.
|
|
70
|
+
|
|
71
|
+
## Sending
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from flowbox import Address, Attachment, FlowOutbox, Message
|
|
75
|
+
|
|
76
|
+
with FlowOutbox.connect_smtp("smtp.example.com", "user", "secret", sender=Address("noreply@acme.com", "Acme")) as outbox:
|
|
77
|
+
message_id = outbox.send_mail(
|
|
78
|
+
Message(
|
|
79
|
+
subject="Your invoice",
|
|
80
|
+
to=[Address("customer@example.com")],
|
|
81
|
+
text="Your invoice is attached.",
|
|
82
|
+
attachments=[Attachment.from_path("invoice.pdf")],
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Reading
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from datetime import datetime, timedelta
|
|
91
|
+
|
|
92
|
+
from flowbox import FlowInbox
|
|
93
|
+
|
|
94
|
+
with FlowInbox.connect_imap("imap.example.com", "user", "secret") as inbox:
|
|
95
|
+
print(inbox.get_mailboxes())
|
|
96
|
+
|
|
97
|
+
for message in inbox.retrieve_mails("INBOX", datetime.now() - timedelta(days=1)):
|
|
98
|
+
print(message.received_at, message.sender, message.subject)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`retrieve_mails()` returns everything received at or after `from_datetime`, oldest first, and never marks mail as read.
|
|
102
|
+
|
|
103
|
+
## Webhooks
|
|
104
|
+
|
|
105
|
+
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:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
109
|
+
|
|
110
|
+
from flowbox import FlowInbox, SignatureError
|
|
111
|
+
|
|
112
|
+
app = FastAPI()
|
|
113
|
+
inbox = FlowInbox.connect_lettermint(webhook_secret="whsec_...")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.post("/webhooks/inbound")
|
|
117
|
+
async def inbound(request: Request) -> None:
|
|
118
|
+
try:
|
|
119
|
+
message = inbox.parse_webhook(await request.body(), request.headers)
|
|
120
|
+
except SignatureError:
|
|
121
|
+
raise HTTPException(status_code=401)
|
|
122
|
+
...
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
## Lettermint
|
|
128
|
+
|
|
129
|
+
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_...`).
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from flowbox import Address, FlowInbox, FlowOutbox
|
|
133
|
+
from flowbox.adapters import LettermintAdapter
|
|
134
|
+
|
|
135
|
+
lettermint = LettermintAdapter(project_token="lm_...", team_token="lm_team_...", webhook_secret="whsec_...")
|
|
136
|
+
|
|
137
|
+
inbox = FlowInbox(lettermint)
|
|
138
|
+
outbox = FlowOutbox(lettermint, sender=Address("noreply@acme.com"))
|
|
139
|
+
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flowbox"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A vendor-neutral email connector: read inboxes and send mail through the adapter you choose."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["email", "mail", "inbox", "outbox", "smtp", "imap", "lettermint"]
|
|
14
|
+
|
|
15
|
+
authors = [
|
|
16
|
+
{name = "Flowmatic"},
|
|
17
|
+
{name = "UniForceMusic"},
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Topic :: Communications :: Email",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Standard library only, Lettermint included: its API is plain JSON over HTTPS.
|
|
30
|
+
# flowbox.settings, which reads the environment, is the one extra.
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
settings = ["pydantic-settings>=2.10"]
|
|
35
|
+
dev = [
|
|
36
|
+
"pydantic-settings>=2.10",
|
|
37
|
+
"build>=1.2",
|
|
38
|
+
"twine>=5.0",
|
|
39
|
+
"pytest>=9.0",
|
|
40
|
+
"mypy>=2.0",
|
|
41
|
+
"ruff>=0.16",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/Flowmatic-AI/flowmatic-toolkit"
|
|
46
|
+
Repository = "https://github.com/Flowmatic-AI/flowmatic-toolkit"
|
|
47
|
+
Issues = "https://github.com/Flowmatic-AI/flowmatic-toolkit/issues"
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["src"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.package-data]
|
|
53
|
+
flowbox = ["py.typed"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
python_files = ["test_*.py"]
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
strict = true
|
|
61
|
+
python_version = "3.14"
|
|
62
|
+
ignore_missing_imports = true
|
|
63
|
+
|
|
64
|
+
[tool.ruff]
|
|
65
|
+
target-version = "py314"
|
|
66
|
+
line-length = 120
|
flowbox-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from flowbox._exceptions import AdapterError, MessageError, SignatureError
|
|
2
|
+
from flowbox._flowbox import FlowInbox, FlowOutbox
|
|
3
|
+
from flowbox._message import Address, Attachment, Message
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AdapterError",
|
|
7
|
+
"Address",
|
|
8
|
+
"Attachment",
|
|
9
|
+
"FlowInbox",
|
|
10
|
+
"FlowOutbox",
|
|
11
|
+
"Message",
|
|
12
|
+
"MessageError",
|
|
13
|
+
"SignatureError",
|
|
14
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import TYPE_CHECKING, Self
|
|
8
|
+
|
|
9
|
+
from flowbox._exceptions import MessageError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import ssl
|
|
13
|
+
|
|
14
|
+
from flowbox._message import Address, Message
|
|
15
|
+
from flowbox.adapters import (
|
|
16
|
+
Encryption,
|
|
17
|
+
IMAPAdapter,
|
|
18
|
+
InboxAdapterABC,
|
|
19
|
+
LettermintAdapter,
|
|
20
|
+
OutboxAdapterABC,
|
|
21
|
+
WebhookInboxAdapterABC,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FlowInbox[AdapterT: InboxAdapterABC | WebhookInboxAdapterABC]:
|
|
26
|
+
"""Reads mail through a mailbox adapter, a webhook adapter, or one that is both.
|
|
27
|
+
|
|
28
|
+
Each method is annotated with the adapter kind it needs, so calling one the
|
|
29
|
+
adapter cannot serve -- parse_webhook() on an IMAP inbox, say -- is flagged
|
|
30
|
+
by the type checker rather than failing at runtime."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, adapter: AdapterT) -> None:
|
|
33
|
+
self._adapter = adapter
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def connect_imap(
|
|
37
|
+
host: str,
|
|
38
|
+
username: str,
|
|
39
|
+
password: str,
|
|
40
|
+
port: int | None = None,
|
|
41
|
+
encryption: Encryption = "ssl",
|
|
42
|
+
timeout: float | None = 30.0,
|
|
43
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
44
|
+
) -> FlowInbox[IMAPAdapter]:
|
|
45
|
+
from flowbox.adapters import IMAPAdapter
|
|
46
|
+
|
|
47
|
+
return FlowInbox(IMAPAdapter(host, username, password, port, encryption, timeout, ssl_context))
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def connect_lettermint(
|
|
51
|
+
team_token: str | None = None,
|
|
52
|
+
webhook_secret: str | None = None,
|
|
53
|
+
project_id: str | None = None,
|
|
54
|
+
timeout: float = 30.0,
|
|
55
|
+
) -> FlowInbox[LettermintAdapter]:
|
|
56
|
+
"""``team_token`` is for retrieve_mails(), ``webhook_secret`` (``whsec_...``)
|
|
57
|
+
for parse_webhook(); pass whichever this inbox uses."""
|
|
58
|
+
from flowbox.adapters import LettermintAdapter
|
|
59
|
+
|
|
60
|
+
adapter = LettermintAdapter(
|
|
61
|
+
team_token=team_token, webhook_secret=webhook_secret, project_id=project_id, timeout=timeout
|
|
62
|
+
)
|
|
63
|
+
return FlowInbox(adapter)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def adapter(self) -> AdapterT:
|
|
67
|
+
return self._adapter
|
|
68
|
+
|
|
69
|
+
def get_mailboxes(self: FlowInbox[InboxAdapterABC]) -> list[str]:
|
|
70
|
+
return self._adapter.get_mailboxes()
|
|
71
|
+
|
|
72
|
+
def retrieve_mails(self: FlowInbox[InboxAdapterABC], mailbox: str, from_datetime: datetime) -> list[Message]:
|
|
73
|
+
return self._adapter.retrieve_mails(mailbox, from_datetime)
|
|
74
|
+
|
|
75
|
+
def parse_webhook(self: FlowInbox[WebhookInboxAdapterABC], body: bytes, headers: Mapping[str, str]) -> Message:
|
|
76
|
+
"""Pass the raw request body, before any JSON or form parsing."""
|
|
77
|
+
return self._adapter.parse_webhook(body, headers)
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
self._adapter.close()
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> Self:
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(
|
|
86
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: TracebackType | None
|
|
87
|
+
) -> None:
|
|
88
|
+
self.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FlowOutbox:
|
|
92
|
+
def __init__(self, adapter: OutboxAdapterABC, sender: Address | None = None) -> None:
|
|
93
|
+
self._adapter = adapter
|
|
94
|
+
#: Used for every message that does not set its own sender.
|
|
95
|
+
self._sender = sender
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def connect_smtp(
|
|
99
|
+
cls,
|
|
100
|
+
host: str,
|
|
101
|
+
username: str | None = None,
|
|
102
|
+
password: str | None = None,
|
|
103
|
+
port: int | None = None,
|
|
104
|
+
encryption: Encryption = "starttls",
|
|
105
|
+
timeout: float = 30.0,
|
|
106
|
+
sender: Address | None = None,
|
|
107
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
108
|
+
) -> Self:
|
|
109
|
+
from flowbox.adapters import SMTPAdapter
|
|
110
|
+
|
|
111
|
+
adapter = SMTPAdapter(host, username, password, port, encryption, timeout, ssl_context=ssl_context)
|
|
112
|
+
return cls(adapter, sender)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def connect_lettermint(
|
|
116
|
+
cls,
|
|
117
|
+
project_token: str,
|
|
118
|
+
route: str | None = None,
|
|
119
|
+
sender: Address | None = None,
|
|
120
|
+
timeout: float = 30.0,
|
|
121
|
+
) -> Self:
|
|
122
|
+
from flowbox.adapters import LettermintAdapter
|
|
123
|
+
|
|
124
|
+
return cls(LettermintAdapter(project_token=project_token, route=route, timeout=timeout), sender)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def adapter(self) -> OutboxAdapterABC:
|
|
128
|
+
return self._adapter
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def sender(self) -> Address | None:
|
|
132
|
+
return self._sender
|
|
133
|
+
|
|
134
|
+
def send_mail(self, message: Message) -> str:
|
|
135
|
+
"""Send the message and return the provider's id for it."""
|
|
136
|
+
if message.sender is None:
|
|
137
|
+
if self._sender is None:
|
|
138
|
+
raise MessageError("the message has no sender and the outbox has no default sender")
|
|
139
|
+
message = replace(message, sender=self._sender)
|
|
140
|
+
|
|
141
|
+
if not message.to:
|
|
142
|
+
raise MessageError("the message has no recipients in 'to'")
|
|
143
|
+
if message.text is None and message.html is None:
|
|
144
|
+
raise MessageError("the message has neither a text nor an html body")
|
|
145
|
+
|
|
146
|
+
return self._adapter.send_mail(message)
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
self._adapter.close()
|
|
150
|
+
|
|
151
|
+
def __enter__(self) -> Self:
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def __exit__(
|
|
155
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: TracebackType | None
|
|
156
|
+
) -> None:
|
|
157
|
+
self.close()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from email.utils import formataddr, parseaddr
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True, frozen=True)
|
|
11
|
+
class Address:
|
|
12
|
+
email: str
|
|
13
|
+
name: str | None = None
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def parse(cls, value: str) -> Address:
|
|
17
|
+
"""``"Jane Doe <jane@example.com>"`` or a bare ``"jane@example.com"``."""
|
|
18
|
+
name, email = parseaddr(value)
|
|
19
|
+
return cls(email=email, name=name or None)
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
return formataddr((self.name, self.email)) if self.name else self.email
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class Attachment:
|
|
27
|
+
filename: str
|
|
28
|
+
content: bytes
|
|
29
|
+
#: Guessed from the filename when left empty.
|
|
30
|
+
content_type: str = ""
|
|
31
|
+
#: Makes the attachment inline: the HTML body references it as ``cid:<content_id>``.
|
|
32
|
+
content_id: str | None = None
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if not self.content_type:
|
|
36
|
+
self.content_type = mimetypes.guess_type(self.filename)[0] or "application/octet-stream"
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_path(cls, path: str | Path, content_id: str | None = None) -> Attachment:
|
|
40
|
+
path = Path(path)
|
|
41
|
+
return cls(filename=path.name, content=path.read_bytes(), content_id=content_id)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class Message:
|
|
46
|
+
subject: str
|
|
47
|
+
to: list[Address] = field(default_factory=list)
|
|
48
|
+
#: Falls back to the outbox's default sender when sending.
|
|
49
|
+
sender: Address | None = None
|
|
50
|
+
cc: list[Address] = field(default_factory=list)
|
|
51
|
+
bcc: list[Address] = field(default_factory=list)
|
|
52
|
+
reply_to: list[Address] = field(default_factory=list)
|
|
53
|
+
text: str | None = None
|
|
54
|
+
html: str | None = None
|
|
55
|
+
attachments: list[Attachment] = field(default_factory=list)
|
|
56
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
57
|
+
#: Set by the adapter on retrieved mail: the provider's id for the message.
|
|
58
|
+
id: str | None = None
|
|
59
|
+
#: Set by the adapter on retrieved mail: when the provider received it.
|
|
60
|
+
received_at: datetime | None = None
|