goatnotify-core 0.1.0__tar.gz

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.
Files changed (30) hide show
  1. goatnotify_core-0.1.0/LICENSE +21 -0
  2. goatnotify_core-0.1.0/PKG-INFO +31 -0
  3. goatnotify_core-0.1.0/README.md +9 -0
  4. goatnotify_core-0.1.0/pyproject.toml +42 -0
  5. goatnotify_core-0.1.0/pyproject.toml.orig +32 -0
  6. goatnotify_core-0.1.0/src/goatnotify/core/__init__.py +86 -0
  7. goatnotify_core-0.1.0/src/goatnotify/core/_json.py +1 -0
  8. goatnotify_core-0.1.0/src/goatnotify/core/channel.py +13 -0
  9. goatnotify_core-0.1.0/src/goatnotify/core/channels/__init__.py +0 -0
  10. goatnotify_core-0.1.0/src/goatnotify/core/channels/database.py +55 -0
  11. goatnotify_core-0.1.0/src/goatnotify/core/channels/email.py +23 -0
  12. goatnotify_core-0.1.0/src/goatnotify/core/channels/webhook.py +104 -0
  13. goatnotify_core-0.1.0/src/goatnotify/core/errors.py +47 -0
  14. goatnotify_core-0.1.0/src/goatnotify/core/events.py +51 -0
  15. goatnotify_core-0.1.0/src/goatnotify/core/http.py +102 -0
  16. goatnotify_core-0.1.0/src/goatnotify/core/mail/__init__.py +0 -0
  17. goatnotify_core-0.1.0/src/goatnotify/core/mail/mailable.py +36 -0
  18. goatnotify_core-0.1.0/src/goatnotify/core/mail/mailer.py +60 -0
  19. goatnotify_core-0.1.0/src/goatnotify/core/mail/message.py +142 -0
  20. goatnotify_core-0.1.0/src/goatnotify/core/mail/mime.py +61 -0
  21. goatnotify_core-0.1.0/src/goatnotify/core/mail/smtp.py +90 -0
  22. goatnotify_core-0.1.0/src/goatnotify/core/mail/transport.py +7 -0
  23. goatnotify_core-0.1.0/src/goatnotify/core/notification.py +16 -0
  24. goatnotify_core-0.1.0/src/goatnotify/core/notifier.py +187 -0
  25. goatnotify_core-0.1.0/src/goatnotify/core/py.typed +0 -0
  26. goatnotify_core-0.1.0/src/goatnotify/core/queue.py +97 -0
  27. goatnotify_core-0.1.0/src/goatnotify/core/results.py +43 -0
  28. goatnotify_core-0.1.0/src/goatnotify/core/retry.py +50 -0
  29. goatnotify_core-0.1.0/src/goatnotify/core/templates.py +6 -0
  30. goatnotify_core-0.1.0/src/goatnotify/core/testing.py +54 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Louis Midson LAJEANTY <midsonlajeanty@proton.me>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: goatnotify-core
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic core of GoatNotify: notifications, channels, transports and retries.
5
+ Keywords: notifications,email,smtp,webhooks,asyncio,typed
6
+ Author: Louis Midson LAJEANTY
7
+ Author-email: Louis Midson LAJEANTY <midsonlajeanty@proton.me>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Communications :: Email
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.14
18
+ Project-URL: Homepage, https://github.com/goatlabshq/goatnotify
19
+ Project-URL: Documentation, https://goatlabshq.github.io/goatnotify/
20
+ Project-URL: Issues, https://github.com/goatlabshq/goatnotify/issues
21
+ Description-Content-Type: text/markdown
22
+
23
+ # goatnotify-core
24
+
25
+ Framework-agnostic core of GoatNotify: notifications, channels, transports and retries.
26
+
27
+ ```bash
28
+ pip install goatnotify-core
29
+ ```
30
+
31
+ Documentation: <https://goatlabshq.github.io/goatnotify/>
@@ -0,0 +1,9 @@
1
+ # goatnotify-core
2
+
3
+ Framework-agnostic core of GoatNotify: notifications, channels, transports and retries.
4
+
5
+ ```bash
6
+ pip install goatnotify-core
7
+ ```
8
+
9
+ Documentation: <https://goatlabshq.github.io/goatnotify/>
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "goatnotify-core"
3
+ version = "0.1.0"
4
+ description = "Framework-agnostic core of GoatNotify: notifications, channels, transports and retries."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.14"
9
+ keywords = [
10
+ "notifications",
11
+ "email",
12
+ "smtp",
13
+ "webhooks",
14
+ "asyncio",
15
+ "typed",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Framework :: AsyncIO",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Communications :: Email",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = []
27
+
28
+ [[project.authors]]
29
+ name = "Louis Midson LAJEANTY"
30
+ email = "midsonlajeanty@proton.me"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/goatlabshq/goatnotify"
34
+ Documentation = "https://goatlabshq.github.io/goatnotify/"
35
+ Issues = "https://github.com/goatlabshq/goatnotify/issues"
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.12,<0.14"]
39
+ build-backend = "uv_build"
40
+
41
+ [tool.uv.build-backend]
42
+ module-name = "goatnotify.core"
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "goatnotify-core"
3
+ version = "0.1.0" # x-release-please-version
4
+ description = "Framework-agnostic core of GoatNotify: notifications, channels, transports and retries."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Louis Midson LAJEANTY", email = "midsonlajeanty@proton.me" }]
9
+ requires-python = ">=3.14"
10
+ keywords = ["notifications", "email", "smtp", "webhooks", "asyncio", "typed"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Framework :: AsyncIO",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Topic :: Communications :: Email",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/goatlabshq/goatnotify"
24
+ Documentation = "https://goatlabshq.github.io/goatnotify/"
25
+ Issues = "https://github.com/goatlabshq/goatnotify/issues"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.12,<0.14"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ module-name = "goatnotify.core"
@@ -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
+ ]
@@ -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: ...
@@ -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]
@@ -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())
@@ -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