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 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
+ ]
flowbox/_exceptions.py ADDED
@@ -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
flowbox/_flowbox.py ADDED
@@ -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()
flowbox/_message.py ADDED
@@ -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
flowbox/_mime.py ADDED
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from email import message_from_bytes, policy
5
+ from email.headerregistry import AddressHeader
6
+ from email.message import EmailMessage
7
+ from email.utils import formatdate
8
+
9
+ from flowbox._message import Address, Attachment, Message
10
+
11
+
12
+ def to_mime(message: Message, sender: Address, message_id: str) -> EmailMessage:
13
+ """Bcc is deliberately left out of the headers: SMTP hands it to the server
14
+ as envelope recipients only, so the other recipients never see it."""
15
+ mime = EmailMessage()
16
+ mime["Subject"] = message.subject
17
+ mime["From"] = str(sender)
18
+ mime["Date"] = formatdate(localtime=True)
19
+ mime["Message-ID"] = message_id
20
+
21
+ for header, addresses in (("To", message.to), ("Cc", message.cc), ("Reply-To", message.reply_to)):
22
+ if addresses:
23
+ mime[header] = ", ".join(str(address) for address in addresses)
24
+
25
+ for name, value in message.headers.items():
26
+ if name.lower() == "message-id":
27
+ continue
28
+ mime[name] = value
29
+
30
+ if message.text is not None:
31
+ mime.set_content(message.text)
32
+ if message.html is not None:
33
+ mime.add_alternative(message.html, subtype="html")
34
+ else:
35
+ mime.set_content(message.html or "", subtype="html")
36
+
37
+ inline = [attachment for attachment in message.attachments if attachment.content_id is not None]
38
+ regular = [attachment for attachment in message.attachments if attachment.content_id is None]
39
+
40
+ html_part = mime.get_body(("html",)) if message.html is not None else None
41
+
42
+ for attachment in inline:
43
+ maintype, subtype = _split_content_type(attachment.content_type)
44
+ # Without an html body there is nothing to relate to, so it becomes a plain attachment.
45
+ target = html_part if isinstance(html_part, EmailMessage) else mime
46
+ add = target.add_related if target is html_part else target.add_attachment
47
+ add(
48
+ attachment.content,
49
+ maintype,
50
+ subtype,
51
+ cid=f"<{attachment.content_id}>",
52
+ filename=attachment.filename,
53
+ disposition="inline",
54
+ )
55
+
56
+ for attachment in regular:
57
+ maintype, subtype = _split_content_type(attachment.content_type)
58
+ mime.add_attachment(attachment.content, maintype, subtype, filename=attachment.filename)
59
+
60
+ return mime
61
+
62
+
63
+ def from_mime(raw: bytes) -> Message:
64
+ mime = message_from_bytes(raw, policy=policy.default)
65
+ assert isinstance(mime, EmailMessage)
66
+
67
+ senders = _addresses(mime, "From")
68
+ text_part = mime.get_body(("plain",))
69
+ html_part = mime.get_body(("html",))
70
+
71
+ return Message(
72
+ subject=str(mime.get("Subject", "")),
73
+ sender=senders[0] if senders else None,
74
+ to=_addresses(mime, "To"),
75
+ cc=_addresses(mime, "Cc"),
76
+ reply_to=_addresses(mime, "Reply-To"),
77
+ text=text_part.get_content() if isinstance(text_part, EmailMessage) else None,
78
+ html=html_part.get_content() if isinstance(html_part, EmailMessage) else None,
79
+ attachments=[_attachment(part) for part in _attachment_parts(mime, (text_part, html_part))],
80
+ headers={name: str(value) for name, value in mime.items()},
81
+ received_at=_date(mime),
82
+ )
83
+
84
+
85
+ def _addresses(mime: EmailMessage, header: str) -> list[Address]:
86
+ value = mime.get(header)
87
+ if not isinstance(value, AddressHeader):
88
+ return []
89
+ return [Address(email=address.addr_spec, name=address.display_name or None) for address in value.addresses]
90
+
91
+
92
+ def _attachment_parts(part: EmailMessage, body_parts: tuple[object, ...]) -> list[EmailMessage]:
93
+ """Every leaf part except the chosen text and html bodies.
94
+
95
+ ``iter_attachments()`` is not enough: it treats the inline images of a
96
+ ``multipart/related`` body as part of that body. An attached email stays one
97
+ attachment rather than being opened up into its own parts."""
98
+ if any(part is body for body in body_parts):
99
+ return []
100
+ if part.get_content_type() == "message/rfc822" or not part.is_multipart():
101
+ return [part]
102
+
103
+ parts: list[EmailMessage] = []
104
+ for child in part.iter_parts():
105
+ if isinstance(child, EmailMessage):
106
+ parts.extend(_attachment_parts(child, body_parts))
107
+ return parts
108
+
109
+
110
+ def _attachment(part: EmailMessage) -> Attachment:
111
+ content_id = part.get("Content-ID")
112
+ if part.get_content_type() == "message/rfc822":
113
+ inner = part.get_payload(0)
114
+ payload: object = inner.as_bytes() if isinstance(inner, EmailMessage) else b""
115
+ else:
116
+ payload = part.get_payload(decode=True)
117
+
118
+ return Attachment(
119
+ filename=part.get_filename() or ("message.eml" if part.get_content_type() == "message/rfc822" else "attachment"),
120
+ content=payload if isinstance(payload, bytes) else b"",
121
+ content_type=part.get_content_type(),
122
+ content_id=str(content_id).strip().strip("<>") if content_id else None,
123
+ )
124
+
125
+
126
+ def _date(mime: EmailMessage) -> datetime | None:
127
+ """A malformed Date header parses to a header without a datetime rather than
128
+ raising, so a missing value is the only failure to handle."""
129
+ value = mime.get("Date")
130
+ return getattr(value, "datetime", None)
131
+
132
+
133
+ def _split_content_type(content_type: str) -> tuple[str, str]:
134
+ maintype, _, subtype = content_type.partition("/")
135
+ return (maintype, subtype) if subtype else ("application", "octet-stream")
@@ -0,0 +1,23 @@
1
+ from flowbox.adapters._abc import (
2
+ AdapterABC,
3
+ Encryption,
4
+ InboxAdapterABC,
5
+ OutboxAdapterABC,
6
+ WebhookInboxAdapterABC,
7
+ )
8
+ from flowbox.adapters._imap import IMAPAdapter
9
+ from flowbox.adapters._lettermint import LettermintAdapter
10
+ from flowbox.adapters._log import LogAdapter
11
+ from flowbox.adapters._smtp import SMTPAdapter
12
+
13
+ __all__ = [
14
+ "AdapterABC",
15
+ "Encryption",
16
+ "IMAPAdapter",
17
+ "InboxAdapterABC",
18
+ "LettermintAdapter",
19
+ "LogAdapter",
20
+ "OutboxAdapterABC",
21
+ "SMTPAdapter",
22
+ "WebhookInboxAdapterABC",
23
+ ]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Mapping
5
+ from datetime import datetime
6
+ from typing import Literal
7
+
8
+ from flowbox._exceptions import MessageError
9
+ from flowbox._message import Address, Message
10
+
11
+ #: How IMAP and SMTP connections are secured: TLS from the first byte, an upgrade
12
+ #: of a plain connection, or (local test servers only) not at all.
13
+ Encryption = Literal["ssl", "starttls", "none"]
14
+
15
+
16
+ class AdapterABC(ABC):
17
+ def __init__(self, driver_name: str) -> None:
18
+ self._driver_name = driver_name
19
+
20
+ @property
21
+ def driver_name(self) -> str:
22
+ return self._driver_name
23
+
24
+ def close(self) -> None:
25
+ """Drop any open connection. Stateless adapters have nothing to close."""
26
+
27
+
28
+ class InboxAdapterABC(AdapterABC):
29
+ @abstractmethod
30
+ def get_mailboxes(self) -> list[str]: ...
31
+
32
+ @abstractmethod
33
+ def retrieve_mails(self, mailbox: str, from_datetime: datetime) -> list[Message]:
34
+ """Every message received in ``mailbox`` at or after ``from_datetime``,
35
+ oldest first. A naive ``from_datetime`` is taken as local time."""
36
+
37
+
38
+ class WebhookInboxAdapterABC(AdapterABC):
39
+ """For providers that push received mail to an HTTP endpoint instead of (or
40
+ besides) keeping it in a mailbox. Each provider signs and shapes its
41
+ webhooks differently; the adapter verifies and translates them."""
42
+
43
+ @abstractmethod
44
+ def parse_webhook(self, body: bytes, headers: Mapping[str, str]) -> Message:
45
+ """Verify the request and translate it into a message.
46
+
47
+ ``body`` must be the raw request body, before any JSON or form parsing:
48
+ signatures are computed over the exact bytes. Raises SignatureError when
49
+ the request is not from the provider."""
50
+
51
+
52
+ class OutboxAdapterABC(AdapterABC):
53
+ @abstractmethod
54
+ def send_mail(self, message: Message) -> str:
55
+ """Send the message and return the provider's id for it."""
56
+
57
+
58
+ def require_sender(message: Message) -> Address:
59
+ if message.sender is None:
60
+ raise MessageError("the message has no sender")
61
+ return message.sender