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.
@@ -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
@@ -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