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
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
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
|
+
]
|
flowbox/adapters/_abc.py
ADDED
|
@@ -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
|