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.
- stapel_notifications-0.3.1/LICENSE +21 -0
- stapel_notifications-0.3.1/PKG-INFO +56 -0
- stapel_notifications-0.3.1/README.md +34 -0
- stapel_notifications-0.3.1/__init__.py +52 -0
- stapel_notifications-0.3.1/actions.py +70 -0
- stapel_notifications-0.3.1/admin.py +46 -0
- stapel_notifications-0.3.1/apps.py +17 -0
- stapel_notifications-0.3.1/channels/__init__.py +0 -0
- stapel_notifications-0.3.1/channels/email.py +181 -0
- stapel_notifications-0.3.1/channels/push.py +157 -0
- stapel_notifications-0.3.1/channels/sms.py +125 -0
- stapel_notifications-0.3.1/conf.py +77 -0
- stapel_notifications-0.3.1/conftest.py +118 -0
- stapel_notifications-0.3.1/dto.py +47 -0
- stapel_notifications-0.3.1/errors.py +12 -0
- stapel_notifications-0.3.1/gdpr.py +69 -0
- stapel_notifications-0.3.1/management/__init__.py +0 -0
- stapel_notifications-0.3.1/management/commands/__init__.py +0 -0
- stapel_notifications-0.3.1/management/commands/check_notifications.py +158 -0
- stapel_notifications-0.3.1/management/commands/consume_contacts.py +50 -0
- stapel_notifications-0.3.1/management/commands/consume_notifications.py +50 -0
- stapel_notifications-0.3.1/management/commands/consume_profiles.py +61 -0
- stapel_notifications-0.3.1/management/commands/eject_notification_templates.py +109 -0
- stapel_notifications-0.3.1/management/commands/sync_translations.py +61 -0
- stapel_notifications-0.3.1/migrations/0001_initial.py +94 -0
- stapel_notifications-0.3.1/migrations/0002_add_auto_detected_language.py +23 -0
- stapel_notifications-0.3.1/migrations/0003_add_sms_preferences.py +31 -0
- stapel_notifications-0.3.1/migrations/0004_usercontact_is_active.py +24 -0
- stapel_notifications-0.3.1/migrations/__init__.py +0 -0
- stapel_notifications-0.3.1/models.py +137 -0
- stapel_notifications-0.3.1/py.typed +0 -0
- stapel_notifications-0.3.1/pyproject.toml +47 -0
- stapel_notifications-0.3.1/routing.py +121 -0
- stapel_notifications-0.3.1/schemas/consumes/translations.changed.json +12 -0
- stapel_notifications-0.3.1/schemas/consumes/user.deleted.json +13 -0
- stapel_notifications-0.3.1/schemas/consumes/user.deletion_initiated.json +13 -0
- stapel_notifications-0.3.1/serializers.py +18 -0
- stapel_notifications-0.3.1/services.py +358 -0
- stapel_notifications-0.3.1/setup.cfg +4 -0
- stapel_notifications-0.3.1/stapel_notifications.egg-info/PKG-INFO +56 -0
- stapel_notifications-0.3.1/stapel_notifications.egg-info/SOURCES.txt +150 -0
- stapel_notifications-0.3.1/stapel_notifications.egg-info/dependency_links.txt +1 -0
- stapel_notifications-0.3.1/stapel_notifications.egg-info/requires.txt +10 -0
- stapel_notifications-0.3.1/stapel_notifications.egg-info/top_level.txt +1 -0
- stapel_notifications-0.3.1/static/notifications/logo.png +0 -0
- stapel_notifications-0.3.1/templates/notifications/email/_base.html +87 -0
- stapel_notifications-0.3.1/templates/notifications/email/_footer_unsubscribe.html +17 -0
- stapel_notifications-0.3.1/templates/notifications/email/_raw_content.html +15 -0
- stapel_notifications-0.3.1/templates/notifications/email/all_sessions_revoked.html +15 -0
- stapel_notifications-0.3.1/templates/notifications/email/auth_change.html +15 -0
- stapel_notifications-0.3.1/templates/notifications/email/gdpr_export_ready.html +30 -0
- stapel_notifications-0.3.1/templates/notifications/email/gdpr_inactivity_closed.html +15 -0
- stapel_notifications-0.3.1/templates/notifications/email/gdpr_inactivity_warning.html +15 -0
- stapel_notifications-0.3.1/templates/notifications/email/listing_blocked.html +32 -0
- stapel_notifications-0.3.1/templates/notifications/email/listing_expiring.html +27 -0
- stapel_notifications-0.3.1/templates/notifications/email/magic_link_login.html +30 -0
- stapel_notifications-0.3.1/templates/notifications/email/new_device_login.html +30 -0
- stapel_notifications-0.3.1/templates/notifications/email/new_message.html +28 -0
- stapel_notifications-0.3.1/templates/notifications/email/otp_code.html +36 -0
- stapel_notifications-0.3.1/templates/notifications/email/report_reviewed.html +27 -0
- stapel_notifications-0.3.1/templates/notifications/email/suspicious_login.html +40 -0
- stapel_notifications-0.3.1/templates/notifications/email/workspace_invitation.html +28 -0
- stapel_notifications-0.3.1/tests/__init__.py +0 -0
- stapel_notifications-0.3.1/tests/test_api_endpoints.py +146 -0
- stapel_notifications-0.3.1/tests/test_branding_and_content.py +219 -0
- stapel_notifications-0.3.1/tests/test_channels.py +330 -0
- stapel_notifications-0.3.1/tests/test_consumers.py +282 -0
- stapel_notifications-0.3.1/tests/test_extensibility.py +94 -0
- stapel_notifications-0.3.1/tests/test_features.py +217 -0
- stapel_notifications-0.3.1/tests/test_gdpr_and_misc.py +119 -0
- stapel_notifications-0.3.1/tests/test_i18n_loop.py +108 -0
- stapel_notifications-0.3.1/tests/test_library_commands.py +149 -0
- stapel_notifications-0.3.1/tests/test_models.py +13 -0
- stapel_notifications-0.3.1/tests/test_public_api.py +63 -0
- stapel_notifications-0.3.1/tests/test_serializer_seams.py +52 -0
- stapel_notifications-0.3.1/tests/test_services_pipeline.py +390 -0
- stapel_notifications-0.3.1/tests/test_translations_sync.py +135 -0
- stapel_notifications-0.3.1/translation_keys.py +125 -0
- stapel_notifications-0.3.1/translations.py +64 -0
- stapel_notifications-0.3.1/urls.py +16 -0
- 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
|
+
[](https://github.com/usestapel/stapel-notifications/actions/workflows/ci.yml)
|
|
26
|
+
[](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
|
+
[](https://github.com/usestapel/stapel-notifications/actions/workflows/ci.yml)
|
|
4
|
+
[](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)
|