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.
Files changed (32) hide show
  1. flowbox-0.1.0/LICENSE +21 -0
  2. flowbox-0.1.0/PKG-INFO +169 -0
  3. flowbox-0.1.0/README.md +139 -0
  4. flowbox-0.1.0/pyproject.toml +66 -0
  5. flowbox-0.1.0/setup.cfg +4 -0
  6. flowbox-0.1.0/src/flowbox/__init__.py +14 -0
  7. flowbox-0.1.0/src/flowbox/_exceptions.py +13 -0
  8. flowbox-0.1.0/src/flowbox/_flowbox.py +157 -0
  9. flowbox-0.1.0/src/flowbox/_message.py +60 -0
  10. flowbox-0.1.0/src/flowbox/_mime.py +135 -0
  11. flowbox-0.1.0/src/flowbox/adapters/__init__.py +23 -0
  12. flowbox-0.1.0/src/flowbox/adapters/_abc.py +61 -0
  13. flowbox-0.1.0/src/flowbox/adapters/_imap.py +245 -0
  14. flowbox-0.1.0/src/flowbox/adapters/_lettermint.py +304 -0
  15. flowbox-0.1.0/src/flowbox/adapters/_log.py +24 -0
  16. flowbox-0.1.0/src/flowbox/adapters/_smtp.py +112 -0
  17. flowbox-0.1.0/src/flowbox/py.typed +0 -0
  18. flowbox-0.1.0/src/flowbox/settings/__init__.py +24 -0
  19. flowbox-0.1.0/src/flowbox/settings/_settings.py +212 -0
  20. flowbox-0.1.0/src/flowbox.egg-info/PKG-INFO +169 -0
  21. flowbox-0.1.0/src/flowbox.egg-info/SOURCES.txt +30 -0
  22. flowbox-0.1.0/src/flowbox.egg-info/dependency_links.txt +1 -0
  23. flowbox-0.1.0/src/flowbox.egg-info/requires.txt +11 -0
  24. flowbox-0.1.0/src/flowbox.egg-info/top_level.txt +1 -0
  25. flowbox-0.1.0/tests/test_boundary.py +56 -0
  26. flowbox-0.1.0/tests/test_flowbox.py +49 -0
  27. flowbox-0.1.0/tests/test_imap.py +101 -0
  28. flowbox-0.1.0/tests/test_lettermint.py +225 -0
  29. flowbox-0.1.0/tests/test_mime.py +76 -0
  30. flowbox-0.1.0/tests/test_settings.py +144 -0
  31. flowbox-0.1.0/tests/test_smtp.py +79 -0
  32. 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
+ ```
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,13 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class AdapterError(Exception):
5
+ pass
6
+
7
+
8
+ class SignatureError(AdapterError):
9
+ pass
10
+
11
+
12
+ class MessageError(Exception):
13
+ pass
@@ -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