stapel-notifications 0.3.1__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 (81) hide show
  1. stapel_notifications-0.3.1/LICENSE +21 -0
  2. stapel_notifications-0.3.1/PKG-INFO +56 -0
  3. stapel_notifications-0.3.1/README.md +34 -0
  4. stapel_notifications-0.3.1/__init__.py +52 -0
  5. stapel_notifications-0.3.1/actions.py +70 -0
  6. stapel_notifications-0.3.1/admin.py +46 -0
  7. stapel_notifications-0.3.1/apps.py +17 -0
  8. stapel_notifications-0.3.1/channels/__init__.py +0 -0
  9. stapel_notifications-0.3.1/channels/email.py +181 -0
  10. stapel_notifications-0.3.1/channels/push.py +157 -0
  11. stapel_notifications-0.3.1/channels/sms.py +125 -0
  12. stapel_notifications-0.3.1/conf.py +77 -0
  13. stapel_notifications-0.3.1/conftest.py +118 -0
  14. stapel_notifications-0.3.1/dto.py +47 -0
  15. stapel_notifications-0.3.1/errors.py +12 -0
  16. stapel_notifications-0.3.1/gdpr.py +69 -0
  17. stapel_notifications-0.3.1/management/__init__.py +0 -0
  18. stapel_notifications-0.3.1/management/commands/__init__.py +0 -0
  19. stapel_notifications-0.3.1/management/commands/check_notifications.py +158 -0
  20. stapel_notifications-0.3.1/management/commands/consume_contacts.py +50 -0
  21. stapel_notifications-0.3.1/management/commands/consume_notifications.py +50 -0
  22. stapel_notifications-0.3.1/management/commands/consume_profiles.py +61 -0
  23. stapel_notifications-0.3.1/management/commands/eject_notification_templates.py +109 -0
  24. stapel_notifications-0.3.1/management/commands/sync_translations.py +61 -0
  25. stapel_notifications-0.3.1/migrations/0001_initial.py +94 -0
  26. stapel_notifications-0.3.1/migrations/0002_add_auto_detected_language.py +23 -0
  27. stapel_notifications-0.3.1/migrations/0003_add_sms_preferences.py +31 -0
  28. stapel_notifications-0.3.1/migrations/0004_usercontact_is_active.py +24 -0
  29. stapel_notifications-0.3.1/migrations/__init__.py +0 -0
  30. stapel_notifications-0.3.1/models.py +137 -0
  31. stapel_notifications-0.3.1/py.typed +0 -0
  32. stapel_notifications-0.3.1/pyproject.toml +47 -0
  33. stapel_notifications-0.3.1/routing.py +121 -0
  34. stapel_notifications-0.3.1/schemas/consumes/translations.changed.json +12 -0
  35. stapel_notifications-0.3.1/schemas/consumes/user.deleted.json +13 -0
  36. stapel_notifications-0.3.1/schemas/consumes/user.deletion_initiated.json +13 -0
  37. stapel_notifications-0.3.1/serializers.py +18 -0
  38. stapel_notifications-0.3.1/services.py +358 -0
  39. stapel_notifications-0.3.1/setup.cfg +4 -0
  40. stapel_notifications-0.3.1/stapel_notifications.egg-info/PKG-INFO +56 -0
  41. stapel_notifications-0.3.1/stapel_notifications.egg-info/SOURCES.txt +150 -0
  42. stapel_notifications-0.3.1/stapel_notifications.egg-info/dependency_links.txt +1 -0
  43. stapel_notifications-0.3.1/stapel_notifications.egg-info/requires.txt +10 -0
  44. stapel_notifications-0.3.1/stapel_notifications.egg-info/top_level.txt +1 -0
  45. stapel_notifications-0.3.1/static/notifications/logo.png +0 -0
  46. stapel_notifications-0.3.1/templates/notifications/email/_base.html +87 -0
  47. stapel_notifications-0.3.1/templates/notifications/email/_footer_unsubscribe.html +17 -0
  48. stapel_notifications-0.3.1/templates/notifications/email/_raw_content.html +15 -0
  49. stapel_notifications-0.3.1/templates/notifications/email/all_sessions_revoked.html +15 -0
  50. stapel_notifications-0.3.1/templates/notifications/email/auth_change.html +15 -0
  51. stapel_notifications-0.3.1/templates/notifications/email/gdpr_export_ready.html +30 -0
  52. stapel_notifications-0.3.1/templates/notifications/email/gdpr_inactivity_closed.html +15 -0
  53. stapel_notifications-0.3.1/templates/notifications/email/gdpr_inactivity_warning.html +15 -0
  54. stapel_notifications-0.3.1/templates/notifications/email/listing_blocked.html +32 -0
  55. stapel_notifications-0.3.1/templates/notifications/email/listing_expiring.html +27 -0
  56. stapel_notifications-0.3.1/templates/notifications/email/magic_link_login.html +30 -0
  57. stapel_notifications-0.3.1/templates/notifications/email/new_device_login.html +30 -0
  58. stapel_notifications-0.3.1/templates/notifications/email/new_message.html +28 -0
  59. stapel_notifications-0.3.1/templates/notifications/email/otp_code.html +36 -0
  60. stapel_notifications-0.3.1/templates/notifications/email/report_reviewed.html +27 -0
  61. stapel_notifications-0.3.1/templates/notifications/email/suspicious_login.html +40 -0
  62. stapel_notifications-0.3.1/templates/notifications/email/workspace_invitation.html +28 -0
  63. stapel_notifications-0.3.1/tests/__init__.py +0 -0
  64. stapel_notifications-0.3.1/tests/test_api_endpoints.py +146 -0
  65. stapel_notifications-0.3.1/tests/test_branding_and_content.py +219 -0
  66. stapel_notifications-0.3.1/tests/test_channels.py +330 -0
  67. stapel_notifications-0.3.1/tests/test_consumers.py +282 -0
  68. stapel_notifications-0.3.1/tests/test_extensibility.py +94 -0
  69. stapel_notifications-0.3.1/tests/test_features.py +217 -0
  70. stapel_notifications-0.3.1/tests/test_gdpr_and_misc.py +119 -0
  71. stapel_notifications-0.3.1/tests/test_i18n_loop.py +108 -0
  72. stapel_notifications-0.3.1/tests/test_library_commands.py +149 -0
  73. stapel_notifications-0.3.1/tests/test_models.py +13 -0
  74. stapel_notifications-0.3.1/tests/test_public_api.py +63 -0
  75. stapel_notifications-0.3.1/tests/test_serializer_seams.py +52 -0
  76. stapel_notifications-0.3.1/tests/test_services_pipeline.py +390 -0
  77. stapel_notifications-0.3.1/tests/test_translations_sync.py +135 -0
  78. stapel_notifications-0.3.1/translation_keys.py +125 -0
  79. stapel_notifications-0.3.1/translations.py +64 -0
  80. stapel_notifications-0.3.1/urls.py +16 -0
  81. stapel_notifications-0.3.1/views.py +200 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 usestapel
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,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-notifications
3
+ Version: 0.3.1
4
+ Summary: Push and in-app notifications Django app for the Stapel framework
5
+ License: MIT
6
+ Keywords: django,stapel,notifications,push,firebase
7
+ Classifier: Framework :: Django
8
+ Classifier: Framework :: Django :: 5.2
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: stapel-core<0.4,>=0.3.0
15
+ Provides-Extra: firebase
16
+ Requires-Dist: firebase-admin>=6.4; extra == "firebase"
17
+ Provides-Extra: kafka
18
+ Requires-Dist: confluent-kafka>=2.3; extra == "kafka"
19
+ Provides-Extra: all
20
+ Requires-Dist: stapel-notifications[firebase,kafka]; extra == "all"
21
+ Dynamic: license-file
22
+
23
+ # stapel-notifications
24
+
25
+ [![CI](https://github.com/usestapel/stapel-notifications/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-notifications/actions/workflows/ci.yml)
26
+ [![codecov](https://codecov.io/gh/usestapel/stapel-notifications/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-notifications)
27
+
28
+ > Notifications — push (Firebase), email, SMS channels with delivery logging
29
+
30
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install stapel-notifications
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ # settings.py
42
+ INSTALLED_APPS = [
43
+ ...
44
+ 'stapel_notifications',
45
+ ]
46
+ ```
47
+
48
+ ## Bus events
49
+
50
+ ### Consumes
51
+ | `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
52
+ | `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
53
+
54
+ ## License
55
+
56
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,34 @@
1
+ # stapel-notifications
2
+
3
+ [![CI](https://github.com/usestapel/stapel-notifications/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-notifications/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/usestapel/stapel-notifications/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-notifications)
5
+
6
+ > Notifications — push (Firebase), email, SMS channels with delivery logging
7
+
8
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install stapel-notifications
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```python
19
+ # settings.py
20
+ INSTALLED_APPS = [
21
+ ...
22
+ 'stapel_notifications',
23
+ ]
24
+ ```
25
+
26
+ ## Bus events
27
+
28
+ ### Consumes
29
+ | `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
30
+ | `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
31
+
32
+ ## License
33
+
34
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,52 @@
1
+ """stapel-notifications — multi-channel notifications (email / SMS / push).
2
+
3
+ Public API (lazily resolved, PEP 562 — importing this package pulls in
4
+ no Django code until an attribute is actually accessed):
5
+
6
+ notifications_settings — the ``STAPEL_NOTIFICATIONS`` settings namespace
7
+ request_notification — publish a notification request to the bus
8
+ process_notification — resolve language/contacts/templates and dispatch
9
+ get_channels — channels configured for a notification type
10
+ get_group — preference group of a notification type
11
+ get_email_template — effective email template for a notification type
12
+ registered_types — all known notification types (built-in + host)
13
+ """
14
+
15
+ __all__ = [
16
+ "get_channels",
17
+ "get_email_template",
18
+ "get_group",
19
+ "notifications_settings",
20
+ "process_notification",
21
+ "registered_types",
22
+ "request_notification",
23
+ ]
24
+
25
+ # name -> (module, attribute); relative modules resolve inside this package
26
+ _EXPORTS = {
27
+ "notifications_settings": (".conf", "notifications_settings"),
28
+ "request_notification": ("stapel_core.notifications", "request_notification"),
29
+ "process_notification": (".services", "process_notification"),
30
+ "get_channels": (".routing", "get_channels"),
31
+ "get_group": (".routing", "get_group"),
32
+ "get_email_template": (".routing", "get_email_template"),
33
+ "registered_types": (".routing", "registered_types"),
34
+ }
35
+
36
+
37
+ def __getattr__(name):
38
+ try:
39
+ module_path, attr = _EXPORTS[name]
40
+ except KeyError:
41
+ raise AttributeError(
42
+ f"module {__name__!r} has no attribute {name!r}"
43
+ ) from None
44
+ from importlib import import_module
45
+
46
+ value = getattr(import_module(module_path, __name__), attr)
47
+ globals()[name] = value # cache: subsequent lookups skip __getattr__
48
+ return value
49
+
50
+
51
+ def __dir__():
52
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,70 @@
1
+ """Action subscriptions of the notifications module.
2
+
3
+ Handlers must be idempotent: delivery is at-least-once (outbox retries,
4
+ broker redelivery).
5
+ """
6
+ import logging
7
+
8
+ from stapel_core.comm import on_action
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @on_action("user.deleted")
14
+ def handle_user_deleted(event):
15
+ """Erase this module's PII when an account deletion is executed."""
16
+ from .gdpr import NotificationsGDPRProvider
17
+
18
+ user_id = event.payload.get("user_id")
19
+ if not user_id:
20
+ logger.error("user.deleted event without user_id: %s", event.event_id)
21
+ return
22
+ NotificationsGDPRProvider().delete(user_id)
23
+ logger.info("notifications data erased for deleted user %s", user_id)
24
+
25
+
26
+ @on_action("user.deletion_initiated")
27
+ def handle_user_deletion_initiated(event):
28
+ """Account-closure grace period started: stop notifying the user.
29
+
30
+ Soft and reversible — the contact and the push tokens are only
31
+ deactivated, not erased (full erasure stays on ``user.deleted``).
32
+ Reactivation happens through the normal sync paths (contact-changed
33
+ events, device re-registration); there is currently no dedicated
34
+ "closure cancelled" event to subscribe to (see CHANGELOG).
35
+ """
36
+ from .models import DevicePushToken, UserContact
37
+
38
+ user_id = event.payload.get("user_id")
39
+ if not user_id:
40
+ logger.error(
41
+ "user.deletion_initiated event without user_id: %s", event.event_id
42
+ )
43
+ return
44
+ contacts = UserContact.objects.filter(user_id=user_id).update(is_active=False)
45
+ tokens = DevicePushToken.objects.filter(user_id=user_id).update(is_active=False)
46
+ logger.info(
47
+ "deactivated %d contact(s) and %d push token(s) for user %s "
48
+ "(deletion grace period)", contacts, tokens, user_id,
49
+ )
50
+
51
+
52
+ @on_action("translations.changed")
53
+ def handle_translations_changed(event):
54
+ """Refresh cached ``notification.*`` translations on invalidation.
55
+
56
+ The event is a thin invalidation ({language, keys_changed}); the values
57
+ are pulled through the ``translate.resolve`` comm Function. Errors
58
+ propagate so at-least-once delivery retries the sync.
59
+ """
60
+ from .translations import resolve_and_cache
61
+
62
+ language = event.payload.get("language")
63
+ keys_changed = event.payload.get("keys_changed") or []
64
+ keys = [
65
+ k for k in keys_changed
66
+ if isinstance(k, str) and k.startswith("notification.")
67
+ ]
68
+ if not language or not keys:
69
+ return
70
+ resolve_and_cache(keys, language)
@@ -0,0 +1,46 @@
1
+ from django.contrib import admin
2
+ from .models import (
3
+ UserNotificationSettings,
4
+ UserContact,
5
+ TranslationCache,
6
+ NotificationLog,
7
+ DevicePushToken,
8
+ )
9
+
10
+
11
+ @admin.register(UserNotificationSettings)
12
+ class UserNotificationSettingsAdmin(admin.ModelAdmin):
13
+ list_display = ['user_id', 'language', 'email_messages', 'email_system', 'push_messages', 'push_system', 'updated_at']
14
+ search_fields = ['user_id']
15
+ readonly_fields = ['updated_at']
16
+
17
+
18
+ @admin.register(UserContact)
19
+ class UserContactAdmin(admin.ModelAdmin):
20
+ list_display = ['user_id', 'email', 'phone', 'updated_at']
21
+ search_fields = ['user_id', 'email', 'phone']
22
+ readonly_fields = ['updated_at']
23
+
24
+
25
+ @admin.register(TranslationCache)
26
+ class TranslationCacheAdmin(admin.ModelAdmin):
27
+ list_display = ['key', 'updated_at']
28
+ search_fields = ['key']
29
+ readonly_fields = ['updated_at']
30
+
31
+
32
+ @admin.register(NotificationLog)
33
+ class NotificationLogAdmin(admin.ModelAdmin):
34
+ list_display = ['id', 'notification_type', 'channel', 'status', 'recipient', 'language', 'created_at']
35
+ list_filter = ['status', 'channel', 'notification_type']
36
+ search_fields = ['user_id', 'recipient', 'notification_type']
37
+ readonly_fields = ['id', 'created_at']
38
+ ordering = ['-created_at']
39
+
40
+
41
+ @admin.register(DevicePushToken)
42
+ class DevicePushTokenAdmin(admin.ModelAdmin):
43
+ list_display = ['user_id', 'platform', 'is_active', 'created_at', 'updated_at']
44
+ list_filter = ['platform', 'is_active']
45
+ search_fields = ['user_id', 'token']
46
+ readonly_fields = ['created_at', 'updated_at']
@@ -0,0 +1,17 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class NotificationsConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "stapel_notifications"
7
+ label = 'notifications'
8
+ verbose_name = "Stapel Notifications"
9
+
10
+ def ready(self):
11
+ from stapel_core.gdpr import gdpr_registry
12
+ from .gdpr import NotificationsGDPRProvider
13
+ gdpr_registry.register(NotificationsGDPRProvider())
14
+
15
+ # Action subscriptions (in-process in a monolith, bus consumer in
16
+ # microservices — same code, transport chosen by STAPEL_COMM).
17
+ from . import actions # noqa: F401
File without changes
@@ -0,0 +1,181 @@
1
+ """
2
+ Email channel facade.
3
+
4
+ Dispatches to the provider configured via EMAIL_PROVIDER setting:
5
+ resend — Resend API (https://resend.com)
6
+ smtp — Standard SMTP via Django email backend
7
+ mailgun — Mailgun API (https://mailgun.com)
8
+ mock — Log only, no real sending (default)
9
+
10
+ Unknown values fall back to mock with a warning.
11
+ """
12
+
13
+ import base64
14
+ import logging
15
+ import os
16
+
17
+ from django.conf import settings
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _logo_data: bytes | None = None
22
+ LOGO_PATH = os.path.join(
23
+ os.path.dirname(os.path.dirname(__file__)),
24
+ 'static', 'notifications', 'logo.png',
25
+ )
26
+
27
+
28
+ def _get_logo_data() -> bytes | None:
29
+ global _logo_data
30
+ if _logo_data is None:
31
+ try:
32
+ with open(LOGO_PATH, 'rb') as f:
33
+ _logo_data = f.read()
34
+ except FileNotFoundError:
35
+ logger.warning("Logo file not found: %s", LOGO_PATH)
36
+ return _logo_data
37
+
38
+
39
+ def _inline_logo_data() -> bytes | None:
40
+ """Logo bytes for the inline cid:logo attachment, or None when
41
+ STAPEL_NOTIFICATIONS['LOGO_URL'] is set (templates then reference the
42
+ URL directly and no attachment is needed)."""
43
+ from stapel_notifications.conf import notifications_settings
44
+
45
+ if notifications_settings.LOGO_URL:
46
+ return None
47
+ return _get_logo_data()
48
+
49
+
50
+ # ──────────────────────────────────────────────────────────────────
51
+ # Provider classes
52
+ # ──────────────────────────────────────────────────────────────────
53
+
54
+ class _MockEmailProvider:
55
+ def send(self, recipient: str, subject: str, html_body: str, headers: dict | None) -> None:
56
+ logger.info("[mock email] to=%s subject=%r", _mask(recipient), subject)
57
+
58
+
59
+ class _ResendEmailProvider:
60
+ def send(self, recipient: str, subject: str, html_body: str, headers: dict | None) -> None:
61
+ import requests as _http
62
+
63
+ from stapel_notifications.conf import notifications_settings
64
+
65
+ api_key = notifications_settings.RESEND_API_KEY
66
+ if not api_key:
67
+ raise RuntimeError("EMAIL_PROVIDER=resend requires RESEND_API_KEY")
68
+
69
+ payload: dict = {
70
+ "from": settings.DEFAULT_FROM_EMAIL,
71
+ "to": [recipient],
72
+ "subject": subject,
73
+ "html": html_body,
74
+ }
75
+ logo = _inline_logo_data()
76
+ if logo:
77
+ payload["attachments"] = [{
78
+ "filename": "logo.png",
79
+ "content": base64.b64encode(logo).decode(),
80
+ "content_type": "image/png",
81
+ "content_id": "logo",
82
+ }]
83
+ if headers:
84
+ payload["headers"] = headers
85
+
86
+ resp = _http.post(
87
+ "https://api.resend.com/emails",
88
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
89
+ json=payload,
90
+ timeout=15,
91
+ )
92
+ if not resp.ok:
93
+ raise RuntimeError(f"Resend API error {resp.status_code}: {resp.text}")
94
+ logger.info("Email sent to %s via Resend (id=%s)", _mask(recipient), resp.json().get("id"))
95
+
96
+
97
+ class _SMTPEmailProvider:
98
+ def send(self, recipient: str, subject: str, html_body: str, headers: dict | None) -> None:
99
+ from email.mime.image import MIMEImage
100
+ from django.core.mail import EmailMessage
101
+
102
+ msg = EmailMessage(
103
+ subject=subject,
104
+ body=html_body,
105
+ from_email=settings.DEFAULT_FROM_EMAIL,
106
+ to=[recipient],
107
+ headers=headers or {},
108
+ )
109
+ msg.content_subtype = 'html'
110
+ logo = _inline_logo_data()
111
+ if logo:
112
+ logo_mime = MIMEImage(logo, _subtype='png')
113
+ logo_mime.add_header('Content-ID', '<logo>')
114
+ logo_mime.add_header('Content-Disposition', 'inline', filename='logo.png')
115
+ msg.attach(logo_mime)
116
+ msg.send(fail_silently=False)
117
+ logger.info("Email sent to %s via SMTP", _mask(recipient))
118
+
119
+
120
+ class _MailgunEmailProvider:
121
+ def send(self, recipient: str, subject: str, html_body: str, headers: dict | None) -> None:
122
+ import requests as _http
123
+
124
+ from stapel_notifications.conf import notifications_settings
125
+
126
+ api_key = notifications_settings.MAILGUN_API_KEY
127
+ domain = notifications_settings.MAILGUN_DOMAIN
128
+ if not api_key or not domain:
129
+ raise RuntimeError("EMAIL_PROVIDER=mailgun requires MAILGUN_API_KEY and MAILGUN_DOMAIN")
130
+
131
+ resp = _http.post(
132
+ f"https://api.mailgun.net/v3/{domain}/messages",
133
+ auth=("api", api_key),
134
+ data={
135
+ "from": settings.DEFAULT_FROM_EMAIL,
136
+ "to": recipient,
137
+ "subject": subject,
138
+ "html": html_body,
139
+ },
140
+ timeout=15,
141
+ )
142
+ resp.raise_for_status()
143
+ logger.info("Email sent to %s via Mailgun", _mask(recipient))
144
+
145
+
146
+ # ──────────────────────────────────────────────────────────────────
147
+ # Registry + facade
148
+ # ──────────────────────────────────────────────────────────────────
149
+
150
+ _PROVIDERS: dict[str, type] = {
151
+ 'resend': _ResendEmailProvider,
152
+ 'smtp': _SMTPEmailProvider,
153
+ 'mailgun': _MailgunEmailProvider,
154
+ 'mock': _MockEmailProvider,
155
+ }
156
+
157
+
158
+ def _get_provider():
159
+ from stapel_notifications.channels.sms import _resolve_provider
160
+ from stapel_notifications.conf import notifications_settings
161
+
162
+ return _resolve_provider(
163
+ notifications_settings.EMAIL_PROVIDER, _PROVIDERS, _MockEmailProvider, "email"
164
+ )
165
+
166
+
167
+ def send_email(
168
+ recipient: str,
169
+ subject: str,
170
+ html_body: str,
171
+ headers: dict | None = None,
172
+ ) -> None:
173
+ """Send an HTML email via the configured provider."""
174
+ _get_provider().send(recipient, subject, html_body, headers)
175
+
176
+
177
+ def _mask(email: str) -> str:
178
+ if '@' not in email:
179
+ return '***'
180
+ local, domain = email.split('@', 1)
181
+ return f"{local[0]}***@{domain}" if local else f"***@{domain}"
@@ -0,0 +1,157 @@
1
+ """
2
+ Push notification channel.
3
+
4
+ Dispatches to the provider configured via the ``PUSH_PROVIDER`` key of the
5
+ ``STAPEL_NOTIFICATIONS`` namespace (or the flat ``PUSH_PROVIDER`` setting /
6
+ env var):
7
+
8
+ fcm — Firebase Cloud Messaging (default)
9
+ mock — Log only, no real sending
10
+
11
+ Besides the short names, any dotted path to a provider class with a
12
+ ``send(user_id, title, body, data) -> int`` method is accepted — the same
13
+ fork-free escape hatch as the email/SMS channels and captcha backends.
14
+ """
15
+
16
+ import logging
17
+ import threading
18
+
19
+
20
+ from stapel_notifications.models import DevicePushToken
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _firebase_lock = threading.Lock()
25
+ _app_initialized = False
26
+
27
+
28
+ def _ensure_firebase():
29
+ """Initialize Firebase app once (thread-safe)."""
30
+ global _app_initialized
31
+ if _app_initialized:
32
+ return True
33
+
34
+ with _firebase_lock:
35
+ # Double-check after acquiring lock
36
+ if _app_initialized:
37
+ return True
38
+ try:
39
+ import firebase_admin
40
+ from firebase_admin import credentials
41
+
42
+ from stapel_notifications.conf import notifications_settings
43
+
44
+ cred_path = notifications_settings.GOOGLE_APPLICATION_CREDENTIALS
45
+ if not cred_path:
46
+ logger.warning("GOOGLE_APPLICATION_CREDENTIALS not set, push disabled")
47
+ return False
48
+
49
+ if not firebase_admin._apps:
50
+ cred = credentials.Certificate(cred_path)
51
+ firebase_admin.initialize_app(cred)
52
+
53
+ _app_initialized = True
54
+ return True
55
+ except Exception as e:
56
+ logger.error("Firebase initialization failed: %s", e)
57
+ return False
58
+
59
+
60
+ def _active_tokens(user_id: str):
61
+ return DevicePushToken.objects.filter(
62
+ user_id=user_id,
63
+ is_active=True,
64
+ ).values_list('token', 'platform')
65
+
66
+
67
+ # ──────────────────────────────────────────────────────────────────
68
+ # Provider classes
69
+ # ──────────────────────────────────────────────────────────────────
70
+
71
+ class _MockPushProvider:
72
+ def send(self, user_id: str, title: str, body: str, data: dict | None) -> int:
73
+ count = len(_active_tokens(user_id))
74
+ logger.info(
75
+ "[mock push] user=%s title=%r active_tokens=%d", user_id, title, count
76
+ )
77
+ return count
78
+
79
+
80
+ class _FCMPushProvider:
81
+ def send(self, user_id: str, title: str, body: str, data: dict | None) -> int:
82
+ if not _ensure_firebase():
83
+ raise RuntimeError("Firebase not configured")
84
+
85
+ from firebase_admin import messaging
86
+
87
+ tokens = _active_tokens(user_id)
88
+
89
+ if not tokens:
90
+ logger.info("No active push tokens for user %s", user_id)
91
+ return 0
92
+
93
+ sent_count = 0
94
+ for token, platform in tokens:
95
+ try:
96
+ # Build platform-specific message
97
+ notification = messaging.Notification(title=title, body=body)
98
+ message = messaging.Message(
99
+ notification=notification,
100
+ token=token,
101
+ data={k: str(v) for k, v in (data or {}).items()},
102
+ )
103
+
104
+ # iOS-specific: set badge and sound
105
+ if platform == 'ios':
106
+ message.apns = messaging.APNSConfig(
107
+ payload=messaging.APNSPayload(
108
+ aps=messaging.Aps(sound='default'),
109
+ ),
110
+ )
111
+
112
+ messaging.send(message)
113
+ sent_count += 1
114
+
115
+ except messaging.UnregisteredError:
116
+ logger.info("Deactivating unregistered token for user %s", user_id)
117
+ DevicePushToken.objects.filter(token=token).update(is_active=False)
118
+
119
+ except Exception as e:
120
+ logger.error("Push failed for user %s token %s...: %s", user_id, token[:20], e)
121
+
122
+ return sent_count
123
+
124
+
125
+ # ──────────────────────────────────────────────────────────────────
126
+ # Registry + facade
127
+ # ──────────────────────────────────────────────────────────────────
128
+
129
+ _PROVIDERS: dict[str, type] = {
130
+ 'fcm': _FCMPushProvider,
131
+ 'mock': _MockPushProvider,
132
+ }
133
+
134
+
135
+ def _get_provider():
136
+ from stapel_notifications.channels.sms import _resolve_provider
137
+ from stapel_notifications.conf import notifications_settings
138
+
139
+ return _resolve_provider(
140
+ notifications_settings.PUSH_PROVIDER, _PROVIDERS, _MockPushProvider, "push"
141
+ )
142
+
143
+
144
+ def send_push(user_id: str, title: str, body: str, data: dict | None = None) -> int:
145
+ """
146
+ Send push notification to all active devices for a user.
147
+
148
+ Args:
149
+ user_id: Target user UUID
150
+ title: Notification title
151
+ body: Notification body
152
+ data: Optional data payload (deep links, etc.)
153
+
154
+ Returns:
155
+ Number of successfully sent messages.
156
+ """
157
+ return _get_provider().send(user_id, title, body, data)