flowbox 0.1.0__py3-none-any.whl
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/__init__.py +14 -0
- flowbox/_exceptions.py +13 -0
- flowbox/_flowbox.py +157 -0
- flowbox/_message.py +60 -0
- flowbox/_mime.py +135 -0
- flowbox/adapters/__init__.py +23 -0
- flowbox/adapters/_abc.py +61 -0
- flowbox/adapters/_imap.py +245 -0
- flowbox/adapters/_lettermint.py +304 -0
- flowbox/adapters/_log.py +24 -0
- flowbox/adapters/_smtp.py +112 -0
- flowbox/py.typed +0 -0
- flowbox/settings/__init__.py +24 -0
- flowbox/settings/_settings.py +212 -0
- flowbox-0.1.0.dist-info/METADATA +169 -0
- flowbox-0.1.0.dist-info/RECORD +19 -0
- flowbox-0.1.0.dist-info/WHEEL +5 -0
- flowbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- flowbox-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import contextlib
|
|
5
|
+
import imaplib
|
|
6
|
+
import re
|
|
7
|
+
import ssl
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
from flowbox._exceptions import AdapterError
|
|
13
|
+
from flowbox._message import Message
|
|
14
|
+
from flowbox._mime import from_mime
|
|
15
|
+
from flowbox.adapters._abc import Encryption, InboxAdapterABC
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
_DEFAULT_PORTS: dict[Encryption, int] = {"ssl": 993, "starttls": 143, "none": 143}
|
|
20
|
+
|
|
21
|
+
# IMAP dates are always English, whatever the locale strftime/strptime would use.
|
|
22
|
+
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
23
|
+
|
|
24
|
+
# Messages per UID FETCH, which keeps the command line under server limits.
|
|
25
|
+
_FETCH_CHUNK = 100
|
|
26
|
+
|
|
27
|
+
_LIST_PATTERN = re.compile(rb'\((?P<flags>[^)]*)\) (?P<delimiter>"(?:[^"\\]|\\.)*"|NIL) (?P<name>.+)')
|
|
28
|
+
_UID_PATTERN = re.compile(rb"UID (\d+)")
|
|
29
|
+
_INTERNALDATE_PATTERN = re.compile(rb'INTERNALDATE "(\d{1,2})-(\w{3})-(\d{4}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})"')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def encode_mailbox_name(name: str) -> str:
|
|
33
|
+
"""IMAP's modified UTF-7 (RFC 3501, 5.1.3): what servers expect for any
|
|
34
|
+
mailbox name outside printable ASCII, e.g. ``"Entwürfe"``."""
|
|
35
|
+
encoded: list[str] = []
|
|
36
|
+
pending: list[str] = []
|
|
37
|
+
|
|
38
|
+
def _flush() -> None:
|
|
39
|
+
if pending:
|
|
40
|
+
raw = "".join(pending).encode("utf-16-be")
|
|
41
|
+
encoded.append("&" + base64.b64encode(raw).decode("ascii").rstrip("=").replace("/", ",") + "-")
|
|
42
|
+
pending.clear()
|
|
43
|
+
|
|
44
|
+
for char in name:
|
|
45
|
+
if 0x20 <= ord(char) <= 0x7E:
|
|
46
|
+
_flush()
|
|
47
|
+
encoded.append("&-" if char == "&" else char)
|
|
48
|
+
else:
|
|
49
|
+
pending.append(char)
|
|
50
|
+
_flush()
|
|
51
|
+
|
|
52
|
+
return "".join(encoded)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def decode_mailbox_name(name: str) -> str:
|
|
56
|
+
def _decode(match: re.Match[str]) -> str:
|
|
57
|
+
chunk = match.group(1)
|
|
58
|
+
if not chunk:
|
|
59
|
+
return "&"
|
|
60
|
+
chunk = chunk.replace(",", "/")
|
|
61
|
+
return base64.b64decode(chunk + "=" * (-len(chunk) % 4)).decode("utf-16-be")
|
|
62
|
+
|
|
63
|
+
return re.sub(r"&([A-Za-z0-9+,]*)-", _decode, name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _quote(value: str) -> str:
|
|
67
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _unquote(value: bytes) -> str:
|
|
71
|
+
text = value.decode("utf-8", errors="replace")
|
|
72
|
+
if len(text) >= 2 and text[0] == text[-1] == '"':
|
|
73
|
+
text = text[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
|
74
|
+
return text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _imap_date(value: datetime) -> str:
|
|
78
|
+
return f"{value.day:02d}-{_MONTHS[value.month - 1]}-{value.year}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_internaldate(meta: bytes) -> datetime | None:
|
|
82
|
+
match = _INTERNALDATE_PATTERN.search(meta)
|
|
83
|
+
if match is None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
day, month, year, hour, minute, second, sign, offset_hours, offset_minutes = match.groups()
|
|
87
|
+
offset = timedelta(hours=int(offset_hours), minutes=int(offset_minutes))
|
|
88
|
+
|
|
89
|
+
return datetime(
|
|
90
|
+
int(year),
|
|
91
|
+
_MONTHS.index(month.decode("ascii").title()) + 1,
|
|
92
|
+
int(day),
|
|
93
|
+
int(hour),
|
|
94
|
+
int(minute),
|
|
95
|
+
int(second),
|
|
96
|
+
tzinfo=timezone(-offset if sign == b"-" else offset),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class IMAPAdapter(InboxAdapterABC):
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
host: str,
|
|
104
|
+
username: str,
|
|
105
|
+
password: str,
|
|
106
|
+
port: int | None = None,
|
|
107
|
+
encryption: Encryption = "ssl",
|
|
108
|
+
timeout: float | None = 30.0,
|
|
109
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
super().__init__(driver_name="imap")
|
|
112
|
+
|
|
113
|
+
self._host = host
|
|
114
|
+
self._username = username
|
|
115
|
+
self._password = password
|
|
116
|
+
self._port = port or _DEFAULT_PORTS[encryption]
|
|
117
|
+
self._encryption = encryption
|
|
118
|
+
self._timeout = timeout
|
|
119
|
+
self._ssl_context = ssl_context
|
|
120
|
+
self._client: imaplib.IMAP4 | None = None
|
|
121
|
+
|
|
122
|
+
def _connect(self) -> imaplib.IMAP4:
|
|
123
|
+
context = self._ssl_context or ssl.create_default_context()
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
client: imaplib.IMAP4
|
|
127
|
+
if self._encryption == "ssl":
|
|
128
|
+
client = imaplib.IMAP4_SSL(self._host, self._port, ssl_context=context, timeout=self._timeout)
|
|
129
|
+
else:
|
|
130
|
+
client = imaplib.IMAP4(self._host, self._port, timeout=self._timeout)
|
|
131
|
+
if self._encryption == "starttls":
|
|
132
|
+
client.starttls(ssl_context=context)
|
|
133
|
+
|
|
134
|
+
client.login(self._username, self._password)
|
|
135
|
+
except (imaplib.IMAP4.error, OSError) as exc:
|
|
136
|
+
raise AdapterError(f"could not connect to IMAP server {self._host}:{self._port}: {exc}") from exc
|
|
137
|
+
|
|
138
|
+
return client
|
|
139
|
+
|
|
140
|
+
def _disconnect(self) -> None:
|
|
141
|
+
client, self._client = self._client, None
|
|
142
|
+
if client is None:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
with contextlib.suppress(Exception):
|
|
146
|
+
client.logout()
|
|
147
|
+
|
|
148
|
+
def is_connected(self) -> bool:
|
|
149
|
+
return self._client is not None
|
|
150
|
+
|
|
151
|
+
def reconnect(self) -> None:
|
|
152
|
+
self._disconnect()
|
|
153
|
+
self._client = self._connect()
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
self._disconnect()
|
|
157
|
+
|
|
158
|
+
def _call(self, operation: Callable[[imaplib.IMAP4], T]) -> T:
|
|
159
|
+
"""Runs ``operation`` on the open connection, opening one first if needed.
|
|
160
|
+
|
|
161
|
+
Servers drop idle connections, which only shows on the next command, so a
|
|
162
|
+
dropped connection is reopened and the operation retried once."""
|
|
163
|
+
if self._client is None:
|
|
164
|
+
self._client = self._connect()
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
return operation(self._client)
|
|
168
|
+
except (imaplib.IMAP4.abort, OSError):
|
|
169
|
+
self.reconnect()
|
|
170
|
+
assert self._client is not None
|
|
171
|
+
return operation(self._client)
|
|
172
|
+
except imaplib.IMAP4.error as exc:
|
|
173
|
+
raise AdapterError(f"IMAP command failed: {exc}") from exc
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _check(response: tuple[str, list[Any]], command: str) -> list[Any]:
|
|
177
|
+
status, data = response
|
|
178
|
+
if status != "OK":
|
|
179
|
+
raise AdapterError(f"IMAP {command} failed: {data!r}")
|
|
180
|
+
return data
|
|
181
|
+
|
|
182
|
+
def get_mailboxes(self) -> list[str]:
|
|
183
|
+
def _list(client: imaplib.IMAP4) -> list[str]:
|
|
184
|
+
mailboxes: list[str] = []
|
|
185
|
+
|
|
186
|
+
for line in self._check(client.list(), "LIST"):
|
|
187
|
+
if not isinstance(line, bytes):
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
match = _LIST_PATTERN.match(line)
|
|
191
|
+
if match is None or b"\\noselect" in match.group("flags").lower():
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
mailboxes.append(decode_mailbox_name(_unquote(match.group("name"))))
|
|
195
|
+
|
|
196
|
+
return mailboxes
|
|
197
|
+
|
|
198
|
+
return self._call(_list)
|
|
199
|
+
|
|
200
|
+
def retrieve_mails(self, mailbox: str, from_datetime: datetime) -> list[Message]:
|
|
201
|
+
since = from_datetime.astimezone(UTC)
|
|
202
|
+
|
|
203
|
+
def _retrieve(client: imaplib.IMAP4) -> list[Message]:
|
|
204
|
+
self._check(client.select(_quote(encode_mailbox_name(mailbox)), readonly=True), "SELECT")
|
|
205
|
+
|
|
206
|
+
# SEARCH SINCE only knows dates, in the server's own timezone; a day of
|
|
207
|
+
# slack covers that, and INTERNALDATE narrows it down exactly below.
|
|
208
|
+
data = self._check(client.uid("SEARCH", "SINCE", _imap_date(since - timedelta(days=1))), "SEARCH")
|
|
209
|
+
uids = data[0].split() if data and data[0] else []
|
|
210
|
+
|
|
211
|
+
messages: list[Message] = []
|
|
212
|
+
for start in range(0, len(uids), _FETCH_CHUNK):
|
|
213
|
+
chunk = b",".join(uids[start : start + _FETCH_CHUNK]).decode("ascii")
|
|
214
|
+
response = client.uid("FETCH", chunk, "(UID INTERNALDATE BODY.PEEK[])")
|
|
215
|
+
messages.extend(self._parse_fetch(self._check(response, "FETCH")))
|
|
216
|
+
|
|
217
|
+
messages = [message for message in messages if message.received_at is None or message.received_at >= since]
|
|
218
|
+
messages.sort(key=lambda message: int(message.id or 0))
|
|
219
|
+
return messages
|
|
220
|
+
|
|
221
|
+
return self._call(_retrieve)
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _parse_fetch(data: list[Any]) -> list[Message]:
|
|
225
|
+
"""A FETCH response is a ``(meta, literal)`` tuple per message, followed by
|
|
226
|
+
a closing ``b")"`` -- into which some servers put the UID or INTERNALDATE
|
|
227
|
+
after the body, so that trailing chunk counts as meta as well."""
|
|
228
|
+
records: list[tuple[bytes, bytes]] = []
|
|
229
|
+
|
|
230
|
+
for item in data:
|
|
231
|
+
if isinstance(item, tuple):
|
|
232
|
+
records.append((item[0], item[1]))
|
|
233
|
+
elif isinstance(item, bytes) and records:
|
|
234
|
+
meta, raw = records[-1]
|
|
235
|
+
records[-1] = (meta + item, raw)
|
|
236
|
+
|
|
237
|
+
messages: list[Message] = []
|
|
238
|
+
for meta, raw in records:
|
|
239
|
+
uid = _UID_PATTERN.search(meta)
|
|
240
|
+
message = from_mime(raw)
|
|
241
|
+
message.id = uid.group(1).decode("ascii") if uid else None
|
|
242
|
+
message.received_at = _parse_internaldate(meta) or message.received_at
|
|
243
|
+
messages.append(message)
|
|
244
|
+
|
|
245
|
+
return messages
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from flowbox._exceptions import AdapterError, SignatureError
|
|
16
|
+
from flowbox._message import Address, Attachment, Message
|
|
17
|
+
from flowbox._mime import from_mime
|
|
18
|
+
from flowbox.adapters._abc import InboxAdapterABC, OutboxAdapterABC, WebhookInboxAdapterABC, require_sender
|
|
19
|
+
|
|
20
|
+
_BASE_URL = "https://api.lettermint.co/v1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _datetime(value: str) -> datetime:
|
|
24
|
+
parsed = datetime.fromisoformat(value)
|
|
25
|
+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _address(value: dict[str, Any]) -> Address:
|
|
29
|
+
return Address(email=value["email"], name=value.get("name") or None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LettermintAdapter(InboxAdapterABC, WebhookInboxAdapterABC, OutboxAdapterABC):
|
|
33
|
+
"""Each capability needs its own credential, and only that one:
|
|
34
|
+
|
|
35
|
+
- sending: a project token (``lm_...``) for the Sending API
|
|
36
|
+
- retrieve_mails(): a team token (``lm_team_...``) for the Team API
|
|
37
|
+
- parse_webhook(): the inbound route webhook's signing secret (``whsec_...``)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
project_token: str | None = None,
|
|
43
|
+
team_token: str | None = None,
|
|
44
|
+
webhook_secret: str | None = None,
|
|
45
|
+
project_id: str | None = None,
|
|
46
|
+
route: str | None = None,
|
|
47
|
+
base_url: str = _BASE_URL,
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
webhook_tolerance: int = 300,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(driver_name="lettermint")
|
|
52
|
+
|
|
53
|
+
self._project_token = project_token
|
|
54
|
+
self._team_token = team_token
|
|
55
|
+
self._webhook_secret = webhook_secret
|
|
56
|
+
#: How far, in seconds, a webhook's signed timestamp may be off: replay protection.
|
|
57
|
+
self._webhook_tolerance = webhook_tolerance
|
|
58
|
+
#: Limits get_mailboxes() to one project's routes; all projects otherwise.
|
|
59
|
+
self._project_id = project_id
|
|
60
|
+
#: The route outgoing mail is sent through; the project's default otherwise.
|
|
61
|
+
self._route = route
|
|
62
|
+
self._base_url = base_url.rstrip("/")
|
|
63
|
+
self._timeout = timeout
|
|
64
|
+
self._route_ids: dict[str, list[str]] | None = None
|
|
65
|
+
|
|
66
|
+
# -- outbox -----------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def send_mail(self, message: Message) -> str:
|
|
69
|
+
if self._project_token is None:
|
|
70
|
+
raise AdapterError("sending through Lettermint needs a project token")
|
|
71
|
+
|
|
72
|
+
response = self._json("POST", "/send", {"x-lettermint-token": self._project_token}, self._payload(message))
|
|
73
|
+
return str(response["message_id"])
|
|
74
|
+
|
|
75
|
+
def _payload(self, message: Message) -> dict[str, Any]:
|
|
76
|
+
payload: dict[str, Any] = {
|
|
77
|
+
"from": str(require_sender(message)),
|
|
78
|
+
"to": [str(address) for address in message.to],
|
|
79
|
+
"subject": message.subject,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for key, addresses in (("cc", message.cc), ("bcc", message.bcc), ("reply_to", message.reply_to)):
|
|
83
|
+
if addresses:
|
|
84
|
+
payload[key] = [str(address) for address in addresses]
|
|
85
|
+
|
|
86
|
+
if message.text is not None:
|
|
87
|
+
payload["text"] = message.text
|
|
88
|
+
if message.html is not None:
|
|
89
|
+
payload["html"] = message.html
|
|
90
|
+
if message.headers:
|
|
91
|
+
payload["headers"] = dict(message.headers)
|
|
92
|
+
if self._route is not None:
|
|
93
|
+
payload["route"] = self._route
|
|
94
|
+
|
|
95
|
+
if message.attachments:
|
|
96
|
+
payload["attachments"] = [
|
|
97
|
+
{
|
|
98
|
+
"filename": attachment.filename,
|
|
99
|
+
"content": base64.b64encode(attachment.content).decode("ascii"),
|
|
100
|
+
"content_type": attachment.content_type,
|
|
101
|
+
**({"content_id": attachment.content_id} if attachment.content_id else {}),
|
|
102
|
+
}
|
|
103
|
+
for attachment in message.attachments
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
return payload
|
|
107
|
+
|
|
108
|
+
# -- inbox ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def get_mailboxes(self) -> list[str]:
|
|
111
|
+
"""The slugs of the team's inbound routes."""
|
|
112
|
+
route_ids: dict[str, list[str]] = {}
|
|
113
|
+
|
|
114
|
+
project_ids = [self._project_id] if self._project_id else [p["id"] for p in self._paginate("/projects", {})]
|
|
115
|
+
for project_id in project_ids:
|
|
116
|
+
for route in self._paginate(f"/projects/{project_id}/routes", {"filter[route_type]": "inbound"}):
|
|
117
|
+
route_ids.setdefault(route["slug"], []).append(route["id"])
|
|
118
|
+
|
|
119
|
+
self._route_ids = route_ids
|
|
120
|
+
return list(route_ids)
|
|
121
|
+
|
|
122
|
+
def retrieve_mails(self, mailbox: str, from_datetime: datetime) -> list[Message]:
|
|
123
|
+
"""``mailbox`` is a route slug from get_mailboxes(), or a route id."""
|
|
124
|
+
since = from_datetime.astimezone(UTC)
|
|
125
|
+
params = {
|
|
126
|
+
"filter[type]": "inbound",
|
|
127
|
+
"filter[route_id]": self._resolve_route_id(mailbox),
|
|
128
|
+
# The filter only takes a date, and "after" may exclude the day
|
|
129
|
+
# itself; a day of slack covers both, created_at narrows it down.
|
|
130
|
+
"filter[from_date]": (since - timedelta(days=1)).date().isoformat(),
|
|
131
|
+
"sort": "created_at",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
messages: list[Message] = []
|
|
135
|
+
for item in self._paginate("/messages", params):
|
|
136
|
+
received_at = _datetime(item["created_at"])
|
|
137
|
+
if received_at < since:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
message = from_mime(self._request("GET", f"/messages/{item['id']}/source", self._team_headers()))
|
|
141
|
+
message.id = item["id"]
|
|
142
|
+
message.received_at = received_at
|
|
143
|
+
messages.append(message)
|
|
144
|
+
|
|
145
|
+
return messages
|
|
146
|
+
|
|
147
|
+
# -- webhooks ---------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def parse_webhook(self, body: bytes, headers: Mapping[str, str]) -> Message:
|
|
150
|
+
"""Translates a ``message.inbound`` webhook from an inbound route.
|
|
151
|
+
|
|
152
|
+
Lettermint retries a delivery it got no 2xx for, with the same payload,
|
|
153
|
+
so the same message can arrive twice; its id stays the same."""
|
|
154
|
+
self._verify_signature(body, headers)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
event = json.loads(body)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
raise AdapterError("Lettermint webhook body is not valid JSON") from exc
|
|
160
|
+
|
|
161
|
+
if not isinstance(event, dict) or event.get("event") != "message.inbound":
|
|
162
|
+
kind = event.get("event") if isinstance(event, dict) else None
|
|
163
|
+
raise AdapterError(f"expected a message.inbound webhook, got {kind!r}")
|
|
164
|
+
|
|
165
|
+
return self._inbound_message(event["data"])
|
|
166
|
+
|
|
167
|
+
def _verify_signature(self, body: bytes, headers: Mapping[str, str]) -> None:
|
|
168
|
+
"""``X-Lettermint-Signature: t=<unix time>,v1=<hex>``, where v1 is the
|
|
169
|
+
HMAC-SHA256 of ``"<t>.<body>"`` keyed with the whole ``whsec_`` secret."""
|
|
170
|
+
if self._webhook_secret is None:
|
|
171
|
+
raise AdapterError("verifying Lettermint webhooks needs the webhook signing secret")
|
|
172
|
+
|
|
173
|
+
signature = next((value for name, value in headers.items() if name.lower() == "x-lettermint-signature"), None)
|
|
174
|
+
if signature is None:
|
|
175
|
+
raise SignatureError("the request has no X-Lettermint-Signature header")
|
|
176
|
+
|
|
177
|
+
parts = dict(part.split("=", 1) for part in signature.split(",") if "=" in part)
|
|
178
|
+
timestamp, received = parts.get("t", ""), parts.get("v1", "")
|
|
179
|
+
if not timestamp.isdigit() or not received:
|
|
180
|
+
raise SignatureError("the X-Lettermint-Signature header is malformed")
|
|
181
|
+
|
|
182
|
+
if abs(time.time() - int(timestamp)) > self._webhook_tolerance:
|
|
183
|
+
raise SignatureError("the webhook's signed timestamp is too old or too far ahead")
|
|
184
|
+
|
|
185
|
+
signed = timestamp.encode("ascii") + b"." + body
|
|
186
|
+
expected = hmac.new(self._webhook_secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
|
187
|
+
if not hmac.compare_digest(expected, received):
|
|
188
|
+
raise SignatureError("the webhook signature does not match")
|
|
189
|
+
|
|
190
|
+
def _inbound_message(self, data: dict[str, Any]) -> Message:
|
|
191
|
+
body = data.get("body") or {}
|
|
192
|
+
reply_to = data.get("reply_to")
|
|
193
|
+
|
|
194
|
+
return Message(
|
|
195
|
+
subject=data.get("subject") or "",
|
|
196
|
+
sender=_address(data["from"]) if data.get("from") else None,
|
|
197
|
+
to=[_address(value) for value in data.get("to") or []],
|
|
198
|
+
cc=[_address(value) for value in data.get("cc") or []],
|
|
199
|
+
reply_to=[Address.parse(reply_to)] if isinstance(reply_to, str) and reply_to else [],
|
|
200
|
+
text=body.get("text"),
|
|
201
|
+
html=body.get("html"),
|
|
202
|
+
attachments=[self._inbound_attachment(value) for value in data.get("attachments") or []],
|
|
203
|
+
headers={header["name"]: header["value"] for header in data.get("headers") or []},
|
|
204
|
+
id=data.get("message_id"),
|
|
205
|
+
received_at=_datetime(data["date"]) if data.get("date") else None,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _inbound_attachment(self, value: dict[str, Any]) -> Attachment:
|
|
209
|
+
"""Small files come inline as base64; a route set to deliver attachments
|
|
210
|
+
by URL sends a signed download link instead, valid for a limited time."""
|
|
211
|
+
if value.get("content") is not None:
|
|
212
|
+
content = base64.b64decode(value["content"])
|
|
213
|
+
elif value.get("url"):
|
|
214
|
+
content = self._download(value["url"])
|
|
215
|
+
else:
|
|
216
|
+
content = b""
|
|
217
|
+
|
|
218
|
+
return Attachment(
|
|
219
|
+
filename=value.get("filename") or "attachment",
|
|
220
|
+
content=content,
|
|
221
|
+
content_type=value.get("content_type") or "",
|
|
222
|
+
content_id=value.get("content_id"),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _download(self, url: str) -> bytes:
|
|
226
|
+
request = urllib.request.Request(url, headers={"User-Agent": "flowbox"})
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
230
|
+
content: bytes = response.read()
|
|
231
|
+
return content
|
|
232
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
233
|
+
raise AdapterError(f"could not download Lettermint attachment: {exc}") from exc
|
|
234
|
+
|
|
235
|
+
def _resolve_route_id(self, mailbox: str) -> str:
|
|
236
|
+
if self._route_ids is None or mailbox not in self._route_ids:
|
|
237
|
+
self.get_mailboxes()
|
|
238
|
+
assert self._route_ids is not None
|
|
239
|
+
|
|
240
|
+
route_ids = self._route_ids.get(mailbox)
|
|
241
|
+
if route_ids is None:
|
|
242
|
+
return mailbox
|
|
243
|
+
if len(route_ids) > 1:
|
|
244
|
+
raise AdapterError(f"route slug {mailbox!r} exists in several projects; pass its route id instead")
|
|
245
|
+
return route_ids[0]
|
|
246
|
+
|
|
247
|
+
def _team_headers(self) -> dict[str, str]:
|
|
248
|
+
if self._team_token is None:
|
|
249
|
+
raise AdapterError("reading Lettermint inbound mail needs a team token")
|
|
250
|
+
return {"Authorization": f"Bearer {self._team_token}"}
|
|
251
|
+
|
|
252
|
+
def _paginate(self, path: str, params: dict[str, str]) -> list[dict[str, Any]]:
|
|
253
|
+
items: list[dict[str, Any]] = []
|
|
254
|
+
cursor: str | None = None
|
|
255
|
+
|
|
256
|
+
while True:
|
|
257
|
+
query = {**params, "page[cursor]": cursor} if cursor else params
|
|
258
|
+
page = self._json("GET", f"{path}?{urllib.parse.urlencode(query)}", self._team_headers())
|
|
259
|
+
items.extend(page.get("data") or [])
|
|
260
|
+
|
|
261
|
+
cursor = page.get("next_cursor")
|
|
262
|
+
if not cursor:
|
|
263
|
+
return items
|
|
264
|
+
|
|
265
|
+
# -- http -------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def _json(
|
|
268
|
+
self, method: str, path: str, headers: dict[str, str], payload: dict[str, Any] | None = None
|
|
269
|
+
) -> dict[str, Any]:
|
|
270
|
+
body = self._request(method, path, {**headers, "Accept": "application/json"}, payload)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
decoded = json.loads(body)
|
|
274
|
+
except ValueError as exc:
|
|
275
|
+
raise AdapterError(f"Lettermint returned invalid JSON for {method} {path}") from exc
|
|
276
|
+
|
|
277
|
+
if not isinstance(decoded, dict):
|
|
278
|
+
raise AdapterError(f"Lettermint returned unexpected JSON for {method} {path}")
|
|
279
|
+
return decoded
|
|
280
|
+
|
|
281
|
+
def _request(
|
|
282
|
+
self, method: str, path: str, headers: dict[str, str], payload: dict[str, Any] | None = None
|
|
283
|
+
) -> bytes:
|
|
284
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
285
|
+
request = urllib.request.Request(
|
|
286
|
+
self._base_url + path,
|
|
287
|
+
data=data,
|
|
288
|
+
method=method,
|
|
289
|
+
headers={
|
|
290
|
+
**headers,
|
|
291
|
+
"User-Agent": "flowbox",
|
|
292
|
+
**({"Content-Type": "application/json"} if data is not None else {}),
|
|
293
|
+
},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
298
|
+
body: bytes = response.read()
|
|
299
|
+
return body
|
|
300
|
+
except urllib.error.HTTPError as exc:
|
|
301
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
302
|
+
raise AdapterError(f"Lettermint {method} {path} failed with HTTP {exc.code}: {detail}") from exc
|
|
303
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
304
|
+
raise AdapterError(f"could not reach Lettermint: {exc}") from exc
|
flowbox/adapters/_log.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from flowbox._message import Message
|
|
7
|
+
from flowbox.adapters._abc import OutboxAdapterABC
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("flowbox.log")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LogAdapter(OutboxAdapterABC):
|
|
13
|
+
"""Sends nothing: logs each message at INFO on the ``flowbox.log`` logger,
|
|
14
|
+
like Laravel's log mailer. For local development and demos; the log holds
|
|
15
|
+
the recipients and subject, so keep it out of production."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__(driver_name="log")
|
|
19
|
+
|
|
20
|
+
def send_mail(self, message: Message) -> str:
|
|
21
|
+
message_id = f"log-{uuid.uuid4()}"
|
|
22
|
+
recipients = ", ".join(address.email for address in (*message.to, *message.cc, *message.bcc))
|
|
23
|
+
logger.info("Mail %s %r to %s (not sent)", message_id, message.subject, recipients)
|
|
24
|
+
return message_id
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import smtplib
|
|
5
|
+
import ssl
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
from email.utils import make_msgid
|
|
8
|
+
|
|
9
|
+
from flowbox._exceptions import AdapterError
|
|
10
|
+
from flowbox._message import Message
|
|
11
|
+
from flowbox._mime import to_mime
|
|
12
|
+
from flowbox.adapters._abc import Encryption, OutboxAdapterABC, require_sender
|
|
13
|
+
|
|
14
|
+
_DEFAULT_PORTS: dict[Encryption, int] = {"ssl": 465, "starttls": 587, "none": 25}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SMTPAdapter(OutboxAdapterABC):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
host: str,
|
|
21
|
+
username: str | None = None,
|
|
22
|
+
password: str | None = None,
|
|
23
|
+
port: int | None = None,
|
|
24
|
+
encryption: Encryption = "starttls",
|
|
25
|
+
timeout: float = 30.0,
|
|
26
|
+
local_hostname: str | None = None,
|
|
27
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(driver_name="smtp")
|
|
30
|
+
|
|
31
|
+
self._host = host
|
|
32
|
+
self._username = username
|
|
33
|
+
self._password = password
|
|
34
|
+
self._port = port or _DEFAULT_PORTS[encryption]
|
|
35
|
+
self._encryption = encryption
|
|
36
|
+
self._timeout = timeout
|
|
37
|
+
self._local_hostname = local_hostname
|
|
38
|
+
self._ssl_context = ssl_context
|
|
39
|
+
self._client: smtplib.SMTP | None = None
|
|
40
|
+
|
|
41
|
+
def _connect(self) -> smtplib.SMTP:
|
|
42
|
+
context = self._ssl_context or ssl.create_default_context()
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
client: smtplib.SMTP
|
|
46
|
+
if self._encryption == "ssl":
|
|
47
|
+
client = smtplib.SMTP_SSL(
|
|
48
|
+
self._host, self._port, self._local_hostname, timeout=self._timeout, context=context
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
client = smtplib.SMTP(self._host, self._port, self._local_hostname, timeout=self._timeout)
|
|
52
|
+
if self._encryption == "starttls":
|
|
53
|
+
client.starttls(context=context)
|
|
54
|
+
|
|
55
|
+
if self._username is not None:
|
|
56
|
+
client.login(self._username, self._password or "")
|
|
57
|
+
except (smtplib.SMTPException, OSError) as exc:
|
|
58
|
+
raise AdapterError(f"could not connect to SMTP server {self._host}:{self._port}: {exc}") from exc
|
|
59
|
+
|
|
60
|
+
return client
|
|
61
|
+
|
|
62
|
+
def _disconnect(self) -> None:
|
|
63
|
+
client, self._client = self._client, None
|
|
64
|
+
if client is None:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
client.quit()
|
|
69
|
+
except (smtplib.SMTPException, OSError):
|
|
70
|
+
with contextlib.suppress(Exception):
|
|
71
|
+
client.close()
|
|
72
|
+
|
|
73
|
+
def is_connected(self) -> bool:
|
|
74
|
+
return self._client is not None
|
|
75
|
+
|
|
76
|
+
def reconnect(self) -> None:
|
|
77
|
+
self._disconnect()
|
|
78
|
+
self._client = self._connect()
|
|
79
|
+
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
self._disconnect()
|
|
82
|
+
|
|
83
|
+
def send_mail(self, message: Message) -> str:
|
|
84
|
+
"""Returns the Message-ID header, which is the only id SMTP has.
|
|
85
|
+
|
|
86
|
+
Recipients the server refuses while accepting others are not an error:
|
|
87
|
+
the message is already on its way to the rest, and raising would invite a
|
|
88
|
+
retry that sends it to them twice."""
|
|
89
|
+
sender = require_sender(message)
|
|
90
|
+
message_id = message.headers.get("Message-ID") or make_msgid(domain=sender.email.rpartition("@")[2] or None)
|
|
91
|
+
mime = to_mime(message, sender, message_id)
|
|
92
|
+
recipients = [address.email for address in (*message.to, *message.cc, *message.bcc)]
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
self._send(mime, sender.email, recipients)
|
|
96
|
+
except smtplib.SMTPServerDisconnected:
|
|
97
|
+
# Servers close idle connections; that only shows on the next command.
|
|
98
|
+
self.reconnect()
|
|
99
|
+
self._send(mime, sender.email, recipients)
|
|
100
|
+
|
|
101
|
+
return message_id
|
|
102
|
+
|
|
103
|
+
def _send(self, mime: EmailMessage, sender: str, recipients: list[str]) -> None:
|
|
104
|
+
if self._client is None:
|
|
105
|
+
self._client = self._connect()
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
self._client.send_message(mime, from_addr=sender, to_addrs=recipients)
|
|
109
|
+
except smtplib.SMTPServerDisconnected:
|
|
110
|
+
raise
|
|
111
|
+
except (smtplib.SMTPException, OSError) as exc:
|
|
112
|
+
raise AdapterError(f"SMTP send failed: {exc}") from exc
|
flowbox/py.typed
ADDED
|
File without changes
|