goatnotify-core 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.
- goatnotify/core/__init__.py +86 -0
- goatnotify/core/_json.py +1 -0
- goatnotify/core/channel.py +13 -0
- goatnotify/core/channels/__init__.py +0 -0
- goatnotify/core/channels/database.py +55 -0
- goatnotify/core/channels/email.py +23 -0
- goatnotify/core/channels/webhook.py +104 -0
- goatnotify/core/errors.py +47 -0
- goatnotify/core/events.py +51 -0
- goatnotify/core/http.py +102 -0
- goatnotify/core/mail/__init__.py +0 -0
- goatnotify/core/mail/mailable.py +36 -0
- goatnotify/core/mail/mailer.py +60 -0
- goatnotify/core/mail/message.py +142 -0
- goatnotify/core/mail/mime.py +61 -0
- goatnotify/core/mail/smtp.py +90 -0
- goatnotify/core/mail/transport.py +7 -0
- goatnotify/core/notification.py +16 -0
- goatnotify/core/notifier.py +187 -0
- goatnotify/core/py.typed +0 -0
- goatnotify/core/queue.py +97 -0
- goatnotify/core/results.py +43 -0
- goatnotify/core/retry.py +50 -0
- goatnotify/core/templates.py +6 -0
- goatnotify/core/testing.py +54 -0
- goatnotify_core-0.1.0.dist-info/METADATA +31 -0
- goatnotify_core-0.1.0.dist-info/RECORD +29 -0
- goatnotify_core-0.1.0.dist-info/WHEEL +4 -0
- goatnotify_core-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from goatnotify.core import events
|
|
2
|
+
from goatnotify.core._json import JsonValue
|
|
3
|
+
from goatnotify.core.channel import Channel
|
|
4
|
+
from goatnotify.core.channels.database import (
|
|
5
|
+
DatabaseChannel,
|
|
6
|
+
DatabaseMessage,
|
|
7
|
+
NotificationStore,
|
|
8
|
+
StoredNotification,
|
|
9
|
+
)
|
|
10
|
+
from goatnotify.core.channels.email import EmailChannel
|
|
11
|
+
from goatnotify.core.channels.webhook import WebhookChannel, WebhookMessage
|
|
12
|
+
from goatnotify.core.errors import (
|
|
13
|
+
ConfigurationError,
|
|
14
|
+
DeliveryError,
|
|
15
|
+
DeliveryFailed,
|
|
16
|
+
InvalidMessageError,
|
|
17
|
+
NotificationError,
|
|
18
|
+
PermanentError,
|
|
19
|
+
RetryableError,
|
|
20
|
+
SkipDelivery,
|
|
21
|
+
)
|
|
22
|
+
from goatnotify.core.events import Event, Hook
|
|
23
|
+
from goatnotify.core.http import HttpRequest, HttpResponse, HttpTransport, UrllibTransport
|
|
24
|
+
from goatnotify.core.mail.mailable import Mailable
|
|
25
|
+
from goatnotify.core.mail.mailer import Mailer
|
|
26
|
+
from goatnotify.core.mail.message import Address, Attachment, EmailMessage
|
|
27
|
+
from goatnotify.core.mail.mime import to_mime
|
|
28
|
+
from goatnotify.core.mail.smtp import SmtpTransport
|
|
29
|
+
from goatnotify.core.mail.transport import EmailTransport
|
|
30
|
+
from goatnotify.core.notification import Message, Notifiable, Notification
|
|
31
|
+
from goatnotify.core.notifier import Notifier
|
|
32
|
+
from goatnotify.core.queue import AsyncioQueue, DrainableQueue, NotificationJob, Queue
|
|
33
|
+
from goatnotify.core.results import DeliveryResult, DeliveryStatus, NotificationResult
|
|
34
|
+
from goatnotify.core.retry import NO_RETRY, Backoff, ExponentialBackoff, RetryPolicy
|
|
35
|
+
from goatnotify.core.templates import TemplateRenderer
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"NO_RETRY",
|
|
39
|
+
"Address",
|
|
40
|
+
"AsyncioQueue",
|
|
41
|
+
"Attachment",
|
|
42
|
+
"Backoff",
|
|
43
|
+
"Channel",
|
|
44
|
+
"ConfigurationError",
|
|
45
|
+
"DatabaseChannel",
|
|
46
|
+
"DatabaseMessage",
|
|
47
|
+
"DeliveryError",
|
|
48
|
+
"DeliveryFailed",
|
|
49
|
+
"DeliveryResult",
|
|
50
|
+
"DeliveryStatus",
|
|
51
|
+
"DrainableQueue",
|
|
52
|
+
"EmailChannel",
|
|
53
|
+
"EmailMessage",
|
|
54
|
+
"EmailTransport",
|
|
55
|
+
"Event",
|
|
56
|
+
"ExponentialBackoff",
|
|
57
|
+
"Hook",
|
|
58
|
+
"HttpRequest",
|
|
59
|
+
"HttpResponse",
|
|
60
|
+
"HttpTransport",
|
|
61
|
+
"InvalidMessageError",
|
|
62
|
+
"JsonValue",
|
|
63
|
+
"Mailable",
|
|
64
|
+
"Mailer",
|
|
65
|
+
"Message",
|
|
66
|
+
"Notifiable",
|
|
67
|
+
"Notification",
|
|
68
|
+
"NotificationError",
|
|
69
|
+
"NotificationJob",
|
|
70
|
+
"NotificationResult",
|
|
71
|
+
"NotificationStore",
|
|
72
|
+
"Notifier",
|
|
73
|
+
"PermanentError",
|
|
74
|
+
"Queue",
|
|
75
|
+
"RetryPolicy",
|
|
76
|
+
"RetryableError",
|
|
77
|
+
"SkipDelivery",
|
|
78
|
+
"SmtpTransport",
|
|
79
|
+
"StoredNotification",
|
|
80
|
+
"TemplateRenderer",
|
|
81
|
+
"UrllibTransport",
|
|
82
|
+
"WebhookChannel",
|
|
83
|
+
"WebhookMessage",
|
|
84
|
+
"events",
|
|
85
|
+
"to_mime",
|
|
86
|
+
]
|
goatnotify/core/_json.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from goatnotify.core.notification import Message, Notifiable
|
|
4
|
+
from goatnotify.core.retry import RetryPolicy
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Channel[M: Message](ABC):
|
|
8
|
+
name: str
|
|
9
|
+
message_type: type[M]
|
|
10
|
+
retry: RetryPolicy | None = None
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def send(self, notifiable: Notifiable, message: M) -> str | None: ...
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from collections.abc import Callable, Mapping
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
|
+
|
|
7
|
+
from goatnotify.core._json import JsonValue
|
|
8
|
+
from goatnotify.core.channel import Channel
|
|
9
|
+
from goatnotify.core.notification import Message, Notifiable
|
|
10
|
+
from goatnotify.core.retry import RetryPolicy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class DatabaseMessage(Message):
|
|
15
|
+
kind: str
|
|
16
|
+
data: Mapping[str, JsonValue]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class StoredNotification:
|
|
21
|
+
id: UUID
|
|
22
|
+
kind: str
|
|
23
|
+
data: Mapping[str, JsonValue]
|
|
24
|
+
created_at: datetime
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NotificationStore(Protocol):
|
|
28
|
+
async def store(self, notifiable: Notifiable, record: StoredNotification) -> None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def utc_now() -> datetime:
|
|
32
|
+
return datetime.now(UTC)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DatabaseChannel(Channel[DatabaseMessage]):
|
|
36
|
+
name = "database"
|
|
37
|
+
message_type = DatabaseMessage
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
store: NotificationStore,
|
|
42
|
+
*,
|
|
43
|
+
retry: RetryPolicy | None = None,
|
|
44
|
+
clock: Callable[[], datetime] = utc_now,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._store = store
|
|
47
|
+
self._clock = clock
|
|
48
|
+
self.retry = retry
|
|
49
|
+
|
|
50
|
+
async def send(self, notifiable: Notifiable, message: DatabaseMessage) -> str | None:
|
|
51
|
+
record = StoredNotification(
|
|
52
|
+
id=uuid4(), kind=message.kind, data=message.data, created_at=self._clock()
|
|
53
|
+
)
|
|
54
|
+
await self._store.store(notifiable, record)
|
|
55
|
+
return str(record.id)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from goatnotify.core.channel import Channel
|
|
2
|
+
from goatnotify.core.errors import SkipDelivery
|
|
3
|
+
from goatnotify.core.mail.mailable import Mailable
|
|
4
|
+
from goatnotify.core.mail.mailer import Mailer
|
|
5
|
+
from goatnotify.core.notification import Notifiable
|
|
6
|
+
from goatnotify.core.retry import RetryPolicy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EmailChannel(Channel[Mailable]):
|
|
10
|
+
name = "email"
|
|
11
|
+
message_type = Mailable
|
|
12
|
+
|
|
13
|
+
def __init__(self, mailer: Mailer, *, retry: RetryPolicy | None = None) -> None:
|
|
14
|
+
self._mailer = mailer
|
|
15
|
+
self.retry = retry
|
|
16
|
+
|
|
17
|
+
async def send(self, notifiable: Notifiable, message: Mailable) -> str | None:
|
|
18
|
+
if message.to:
|
|
19
|
+
return await self._mailer.send(message)
|
|
20
|
+
route = notifiable.route_notification(self.name)
|
|
21
|
+
if route is None:
|
|
22
|
+
raise SkipDelivery(f"{type(notifiable).__qualname__} has no email route")
|
|
23
|
+
return await self._mailer.send(message, to=[route])
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Final, Literal
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from goatnotify.core._json import JsonValue
|
|
11
|
+
from goatnotify.core.channel import Channel
|
|
12
|
+
from goatnotify.core.errors import DeliveryError, PermanentError, RetryableError, SkipDelivery
|
|
13
|
+
from goatnotify.core.http import (
|
|
14
|
+
HttpRequest,
|
|
15
|
+
HttpResponse,
|
|
16
|
+
HttpTransport,
|
|
17
|
+
UrllibTransport,
|
|
18
|
+
require_http_url,
|
|
19
|
+
)
|
|
20
|
+
from goatnotify.core.notification import Message, Notifiable
|
|
21
|
+
from goatnotify.core.retry import RetryPolicy
|
|
22
|
+
|
|
23
|
+
SIGNATURE_HEADER: Final = "X-GoatNotify-Signature"
|
|
24
|
+
TIMESTAMP_HEADER: Final = "X-GoatNotify-Timestamp"
|
|
25
|
+
MAX_RETRY_AFTER: Final = 300.0
|
|
26
|
+
RETRYABLE_STATUSES: Final = frozenset({408, 429})
|
|
27
|
+
ERROR_BODY_LIMIT: Final = 200
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class WebhookMessage(Message):
|
|
32
|
+
payload: JsonValue
|
|
33
|
+
url: str | None = None
|
|
34
|
+
headers: Mapping[str, str] = field(default_factory=dict[str, str])
|
|
35
|
+
method: Literal["POST", "PUT"] = "POST"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WebhookChannel(Channel[WebhookMessage]):
|
|
39
|
+
name = "webhook"
|
|
40
|
+
message_type = WebhookMessage
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
transport: HttpTransport | None = None,
|
|
45
|
+
*,
|
|
46
|
+
secret: str | None = None,
|
|
47
|
+
timeout: float = 10.0,
|
|
48
|
+
retry: RetryPolicy | None = None,
|
|
49
|
+
clock: Callable[[], float] = time.time,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._transport = transport or UrllibTransport()
|
|
52
|
+
self._secret = secret
|
|
53
|
+
self._timeout = timeout
|
|
54
|
+
self._clock = clock
|
|
55
|
+
self.retry = retry
|
|
56
|
+
|
|
57
|
+
async def send(self, notifiable: Notifiable, message: WebhookMessage) -> str | None:
|
|
58
|
+
url = message.url or notifiable.route_notification(self.name)
|
|
59
|
+
if url is None:
|
|
60
|
+
raise SkipDelivery(f"{type(notifiable).__qualname__} has no webhook route")
|
|
61
|
+
require_http_url(url)
|
|
62
|
+
body = json.dumps(message.payload, separators=(",", ":"), ensure_ascii=False).encode()
|
|
63
|
+
headers = {"Content-Type": "application/json", **message.headers, **self._sign(body)}
|
|
64
|
+
host = urlsplit(url).netloc
|
|
65
|
+
try:
|
|
66
|
+
response = await self._transport.send(
|
|
67
|
+
HttpRequest(message.method, url, headers, body, self._timeout)
|
|
68
|
+
)
|
|
69
|
+
except DeliveryError:
|
|
70
|
+
raise
|
|
71
|
+
# "from None": transport errors often quote the full URL, which may embed a token.
|
|
72
|
+
except OSError as error:
|
|
73
|
+
detail = f"Webhook request to {host} failed: {type(error).__name__}"
|
|
74
|
+
raise RetryableError(detail) from None
|
|
75
|
+
except Exception as error:
|
|
76
|
+
detail = f"Webhook request to {host} failed: {type(error).__name__}"
|
|
77
|
+
raise PermanentError(detail) from None
|
|
78
|
+
_raise_for_status(response, host)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def _sign(self, body: bytes) -> dict[str, str]:
|
|
82
|
+
if self._secret is None:
|
|
83
|
+
return {}
|
|
84
|
+
timestamp = str(int(self._clock()))
|
|
85
|
+
digest = hmac.new(
|
|
86
|
+
self._secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
|
|
87
|
+
).hexdigest()
|
|
88
|
+
return {TIMESTAMP_HEADER: timestamp, SIGNATURE_HEADER: f"sha256={digest}"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _raise_for_status(response: HttpResponse, host: str) -> None:
|
|
92
|
+
if 200 <= response.status < 300:
|
|
93
|
+
return
|
|
94
|
+
detail = f"Webhook {host} answered {response.status}: {response.body[:ERROR_BODY_LIMIT]!r}"
|
|
95
|
+
if response.status in RETRYABLE_STATUSES or response.status >= 500:
|
|
96
|
+
raise RetryableError(detail, retry_after=_retry_after(response))
|
|
97
|
+
raise PermanentError(detail)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _retry_after(response: HttpResponse) -> float | None:
|
|
101
|
+
value = (response.header("Retry-After") or "").strip()
|
|
102
|
+
if not (value.isascii() and value.isdecimal()):
|
|
103
|
+
return None
|
|
104
|
+
return min(float(value), MAX_RETRY_AFTER)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Self
|
|
2
|
+
|
|
3
|
+
if TYPE_CHECKING:
|
|
4
|
+
from goatnotify.core.results import NotificationResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NotificationError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigurationError(NotificationError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeliveryError(NotificationError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RetryableError(DeliveryError):
|
|
20
|
+
def __init__(self, message: str, *, retry_after: float | None = None) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.retry_after = retry_after
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PermanentError(DeliveryError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InvalidMessageError(PermanentError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SkipDelivery(DeliveryError): # noqa: N818 (a control-flow signal, not an error)
|
|
34
|
+
def __init__(self, reason: str) -> None:
|
|
35
|
+
super().__init__(reason)
|
|
36
|
+
self.reason = reason
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeliveryFailed(ExceptionGroup[Exception]):
|
|
40
|
+
result: NotificationResult
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_result(cls, result: NotificationResult) -> Self:
|
|
44
|
+
errors = [delivery.error for delivery in result.deliveries if delivery.error is not None]
|
|
45
|
+
group = cls(f"{len(errors)} of {len(result.deliveries)} deliveries failed", errors)
|
|
46
|
+
group.result = result
|
|
47
|
+
return group
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, ClassVar, Literal
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from goatnotify.core.errors import RetryableError
|
|
7
|
+
from goatnotify.core.notification import Message, Notifiable, Notification
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
11
|
+
class _DeliveryEvent:
|
|
12
|
+
notification_id: UUID
|
|
13
|
+
notification: Notification[Any]
|
|
14
|
+
notifiable: Notifiable
|
|
15
|
+
channel: str
|
|
16
|
+
message: Message
|
|
17
|
+
attempt: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
21
|
+
class Sending(_DeliveryEvent):
|
|
22
|
+
name: ClassVar[Literal["notification.sending"]] = "notification.sending"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
26
|
+
class Retrying(_DeliveryEvent):
|
|
27
|
+
name: ClassVar[Literal["notification.retrying"]] = "notification.retrying"
|
|
28
|
+
delay: float
|
|
29
|
+
error: RetryableError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
33
|
+
class Sent(_DeliveryEvent):
|
|
34
|
+
name: ClassVar[Literal["notification.sent"]] = "notification.sent"
|
|
35
|
+
provider_id: str | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
39
|
+
class Failed(_DeliveryEvent):
|
|
40
|
+
name: ClassVar[Literal["notification.failed"]] = "notification.failed"
|
|
41
|
+
error: Exception
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
45
|
+
class Skipped(_DeliveryEvent):
|
|
46
|
+
name: ClassVar[Literal["notification.skipped"]] = "notification.skipped"
|
|
47
|
+
reason: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
type Event = Sending | Retrying | Sent | Failed | Skipped
|
|
51
|
+
type Hook = Callable[[Event], Awaitable[None] | None]
|
goatnotify/core/http.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import urllib.error
|
|
3
|
+
import urllib.request
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from email.message import Message
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
from typing import IO, Protocol, override
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from goatnotify.core.errors import InvalidMessageError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _distribution_version() -> str:
|
|
15
|
+
try:
|
|
16
|
+
return version("goatnotify-core")
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
return "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
USER_AGENT = f"goatnotify/{_distribution_version()}"
|
|
22
|
+
ALLOWED_SCHEMES = frozenset({"http", "https"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def require_http_url(url: str) -> None:
|
|
26
|
+
parts = urlsplit(url)
|
|
27
|
+
if parts.scheme not in ALLOWED_SCHEMES or not parts.netloc:
|
|
28
|
+
raise InvalidMessageError("Only absolute http and https URLs are allowed")
|
|
29
|
+
if not url.isascii() or not url.isprintable() or " " in url:
|
|
30
|
+
raise InvalidMessageError("URLs must be percent-encoded ASCII without spaces")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class HttpRequest:
|
|
35
|
+
method: str
|
|
36
|
+
url: str
|
|
37
|
+
headers: Mapping[str, str]
|
|
38
|
+
body: bytes
|
|
39
|
+
timeout: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class HttpResponse:
|
|
44
|
+
status: int
|
|
45
|
+
headers: Mapping[str, str]
|
|
46
|
+
body: bytes
|
|
47
|
+
|
|
48
|
+
def header(self, name: str) -> str | None:
|
|
49
|
+
wanted = name.lower()
|
|
50
|
+
return next((v for k, v in self.headers.items() if k.lower() == wanted), None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HttpTransport(Protocol):
|
|
54
|
+
async def send(self, request: HttpRequest) -> HttpResponse: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _with_user_agent(headers: Mapping[str, str]) -> dict[str, str]:
|
|
58
|
+
# urllib's default "Python-urllib" agent is rejected by some bot-protected endpoints.
|
|
59
|
+
if any(name.lower() == "user-agent" for name in headers):
|
|
60
|
+
return dict(headers)
|
|
61
|
+
return {**headers, "User-Agent": USER_AGENT}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
65
|
+
# urllib would replay a POST as a bodiless GET and forward signature headers to any host.
|
|
66
|
+
@override
|
|
67
|
+
def redirect_request(
|
|
68
|
+
self,
|
|
69
|
+
req: urllib.request.Request,
|
|
70
|
+
fp: IO[bytes],
|
|
71
|
+
code: int,
|
|
72
|
+
msg: str,
|
|
73
|
+
headers: Message,
|
|
74
|
+
newurl: str,
|
|
75
|
+
) -> None:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UrllibTransport:
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self._opener = urllib.request.build_opener(_NoRedirect)
|
|
82
|
+
|
|
83
|
+
async def send(self, request: HttpRequest) -> HttpResponse:
|
|
84
|
+
require_http_url(request.url)
|
|
85
|
+
return await asyncio.to_thread(self._send, request)
|
|
86
|
+
|
|
87
|
+
def _send(self, request: HttpRequest) -> HttpResponse:
|
|
88
|
+
prepared = urllib.request.Request( # noqa: S310 (scheme checked by require_http_url)
|
|
89
|
+
request.url,
|
|
90
|
+
data=request.body,
|
|
91
|
+
headers=_with_user_agent(request.headers),
|
|
92
|
+
method=request.method,
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
# Safe: send() has already restricted the URL to http and https.
|
|
96
|
+
with self._opener.open(prepared, timeout=request.timeout) as response: # nosemgrep
|
|
97
|
+
return HttpResponse(
|
|
98
|
+
response.status, dict(response.headers.items()), response.read()
|
|
99
|
+
)
|
|
100
|
+
except urllib.error.HTTPError as error:
|
|
101
|
+
with error:
|
|
102
|
+
return HttpResponse(error.code, dict(error.headers.items()), error.read())
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from types import MappingProxyType
|
|
3
|
+
|
|
4
|
+
from goatnotify.core.mail.message import AddressLike, Attachment
|
|
5
|
+
from goatnotify.core.notification import Message
|
|
6
|
+
|
|
7
|
+
_EMPTY: MappingProxyType[str, str] = MappingProxyType({})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Mailable(Message):
|
|
11
|
+
subject: str = ""
|
|
12
|
+
sender: AddressLike | None = None
|
|
13
|
+
to: Sequence[AddressLike] = ()
|
|
14
|
+
cc: Sequence[AddressLike] = ()
|
|
15
|
+
bcc: Sequence[AddressLike] = ()
|
|
16
|
+
reply_to: Sequence[AddressLike] = ()
|
|
17
|
+
headers: Mapping[str, str] = _EMPTY
|
|
18
|
+
metadata: Mapping[str, str] = _EMPTY
|
|
19
|
+
|
|
20
|
+
def attachments(self) -> Sequence[Attachment]:
|
|
21
|
+
return ()
|
|
22
|
+
|
|
23
|
+
def html(self) -> str | None:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
def text(self) -> str | None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
def template(self) -> str | None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
def text_template(self) -> str | None:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
def context(self) -> Mapping[str, object]:
|
|
36
|
+
return _EMPTY
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
2
|
+
|
|
3
|
+
from goatnotify.core.errors import ConfigurationError, InvalidMessageError
|
|
4
|
+
from goatnotify.core.mail.mailable import Mailable
|
|
5
|
+
from goatnotify.core.mail.message import Address, AddressLike, EmailMessage
|
|
6
|
+
from goatnotify.core.mail.transport import EmailTransport
|
|
7
|
+
from goatnotify.core.templates import TemplateRenderer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Mailer:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
transport: EmailTransport,
|
|
14
|
+
*,
|
|
15
|
+
sender: AddressLike,
|
|
16
|
+
renderer: TemplateRenderer | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
self._transport = transport
|
|
19
|
+
self._sender = Address.parse(sender)
|
|
20
|
+
self._renderer = renderer
|
|
21
|
+
|
|
22
|
+
async def render(self, mailable: Mailable, *, to: Sequence[AddressLike] = ()) -> EmailMessage:
|
|
23
|
+
if not mailable.subject:
|
|
24
|
+
raise InvalidMessageError(f"{type(mailable).__qualname__} has no subject")
|
|
25
|
+
context = mailable.context()
|
|
26
|
+
html = mailable.html()
|
|
27
|
+
if html is None:
|
|
28
|
+
html = await self._render(mailable.template(), context)
|
|
29
|
+
text = mailable.text()
|
|
30
|
+
if text is None:
|
|
31
|
+
text = await self._render(mailable.text_template(), context)
|
|
32
|
+
return EmailMessage(
|
|
33
|
+
subject=mailable.subject,
|
|
34
|
+
sender=self._sender if mailable.sender is None else Address.parse(mailable.sender),
|
|
35
|
+
to=_addresses(to or mailable.to),
|
|
36
|
+
cc=_addresses(mailable.cc),
|
|
37
|
+
bcc=_addresses(mailable.bcc),
|
|
38
|
+
reply_to=_addresses(mailable.reply_to),
|
|
39
|
+
html=html,
|
|
40
|
+
text=text,
|
|
41
|
+
attachments=tuple(mailable.attachments()),
|
|
42
|
+
headers=dict(mailable.headers),
|
|
43
|
+
metadata=dict(mailable.metadata),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def send(self, mailable: Mailable, *, to: Sequence[AddressLike] = ()) -> str | None:
|
|
47
|
+
return await self._transport.send(await self.render(mailable, to=to))
|
|
48
|
+
|
|
49
|
+
async def _render(self, template: str | None, context: Mapping[str, object]) -> str | None:
|
|
50
|
+
if template is None:
|
|
51
|
+
return None
|
|
52
|
+
if self._renderer is None:
|
|
53
|
+
raise ConfigurationError(
|
|
54
|
+
f"Template {template!r} needs a TemplateRenderer: pass renderer= to Mailer"
|
|
55
|
+
)
|
|
56
|
+
return await self._renderer.render(template, context)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _addresses(values: Iterable[AddressLike]) -> tuple[Address, ...]:
|
|
60
|
+
return tuple(Address.parse(value) for value in values)
|