django-nativemojo 0.1.10__py3-none-any.whl → 0.1.16__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.
- django_nativemojo-0.1.16.dist-info/METADATA +138 -0
- django_nativemojo-0.1.16.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/__init__.py +5 -0
- mojo/apps/account/management/commands/__init__.py +6 -0
- mojo/apps/account/management/commands/serializer_admin.py +651 -0
- mojo/apps/account/migrations/0004_user_avatar.py +20 -0
- mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
- mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
- mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
- mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
- mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
- mojo/apps/account/migrations/0010_group_avatar.py +20 -0
- mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
- mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
- mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
- mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
- mojo/apps/account/models/__init__.py +2 -0
- mojo/apps/account/models/device.py +281 -0
- mojo/apps/account/models/group.py +319 -15
- mojo/apps/account/models/member.py +29 -5
- mojo/apps/account/models/push/__init__.py +4 -0
- mojo/apps/account/models/push/config.py +112 -0
- mojo/apps/account/models/push/delivery.py +93 -0
- mojo/apps/account/models/push/device.py +66 -0
- mojo/apps/account/models/push/template.py +99 -0
- mojo/apps/account/models/user.py +369 -19
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +9 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +100 -6
- mojo/apps/account/services/__init__.py +1 -0
- mojo/apps/account/services/push.py +363 -0
- mojo/apps/aws/migrations/0001_initial.py +206 -0
- mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
- mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
- mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
- mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
- mojo/apps/aws/models/__init__.py +19 -0
- mojo/apps/aws/models/email_attachment.py +99 -0
- mojo/apps/aws/models/email_domain.py +218 -0
- mojo/apps/aws/models/email_template.py +132 -0
- mojo/apps/aws/models/incoming_email.py +197 -0
- mojo/apps/aws/models/mailbox.py +288 -0
- mojo/apps/aws/models/sent_message.py +175 -0
- mojo/apps/aws/rest/__init__.py +7 -0
- mojo/apps/aws/rest/email.py +33 -0
- mojo/apps/aws/rest/email_ops.py +183 -0
- mojo/apps/aws/rest/messages.py +32 -0
- mojo/apps/aws/rest/s3.py +64 -0
- mojo/apps/aws/rest/send.py +101 -0
- mojo/apps/aws/rest/sns.py +403 -0
- mojo/apps/aws/rest/templates.py +19 -0
- mojo/apps/aws/services/__init__.py +32 -0
- mojo/apps/aws/services/email.py +390 -0
- mojo/apps/aws/services/email_ops.py +548 -0
- mojo/apps/docit/__init__.py +6 -0
- mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
- mojo/apps/docit/markdown_plugins/toc.py +12 -0
- mojo/apps/docit/migrations/0001_initial.py +113 -0
- mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
- mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
- mojo/apps/docit/models/__init__.py +17 -0
- mojo/apps/docit/models/asset.py +231 -0
- mojo/apps/docit/models/book.py +227 -0
- mojo/apps/docit/models/page.py +319 -0
- mojo/apps/docit/models/page_revision.py +203 -0
- mojo/apps/docit/rest/__init__.py +10 -0
- mojo/apps/docit/rest/asset.py +17 -0
- mojo/apps/docit/rest/book.py +22 -0
- mojo/apps/docit/rest/page.py +22 -0
- mojo/apps/docit/rest/page_revision.py +17 -0
- mojo/apps/docit/services/__init__.py +11 -0
- mojo/apps/docit/services/docit.py +315 -0
- mojo/apps/docit/services/markdown.py +44 -0
- mojo/apps/fileman/README.md +8 -8
- mojo/apps/fileman/backends/base.py +76 -70
- mojo/apps/fileman/backends/filesystem.py +86 -86
- mojo/apps/fileman/backends/s3.py +409 -108
- mojo/apps/fileman/migrations/0001_initial.py +106 -0
- mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
- mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
- mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
- mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
- mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
- mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
- mojo/apps/fileman/migrations/0008_file_category.py +18 -0
- mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
- mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
- mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
- mojo/apps/fileman/models/__init__.py +1 -5
- mojo/apps/fileman/models/file.py +240 -58
- mojo/apps/fileman/models/manager.py +427 -31
- mojo/apps/fileman/models/rendition.py +118 -0
- mojo/apps/fileman/renderer/__init__.py +111 -0
- mojo/apps/fileman/renderer/audio.py +403 -0
- mojo/apps/fileman/renderer/base.py +205 -0
- mojo/apps/fileman/renderer/document.py +404 -0
- mojo/apps/fileman/renderer/image.py +222 -0
- mojo/apps/fileman/renderer/utils.py +297 -0
- mojo/apps/fileman/renderer/video.py +304 -0
- mojo/apps/fileman/rest/__init__.py +1 -18
- mojo/apps/fileman/rest/upload.py +22 -32
- mojo/apps/fileman/signals.py +58 -0
- mojo/apps/fileman/tasks.py +254 -0
- mojo/apps/fileman/utils/__init__.py +40 -16
- mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
- mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
- mojo/apps/incident/migrations/0007_event_uid.py +18 -0
- mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
- mojo/apps/incident/migrations/0009_incident_status.py +18 -0
- mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
- mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
- mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
- mojo/apps/incident/models/__init__.py +2 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/history.py +36 -0
- mojo/apps/incident/models/incident.py +3 -1
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -1
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/event.py +7 -1
- mojo/apps/incident/rest/ticket.py +43 -0
- mojo/apps/jobs/__init__.py +489 -0
- mojo/apps/jobs/adapters.py +24 -0
- mojo/apps/jobs/cli.py +616 -0
- mojo/apps/jobs/daemon.py +370 -0
- mojo/apps/jobs/examples/sample_jobs.py +376 -0
- mojo/apps/jobs/examples/webhook_examples.py +203 -0
- mojo/apps/jobs/handlers/__init__.py +5 -0
- mojo/apps/jobs/handlers/webhook.py +317 -0
- mojo/apps/jobs/job_engine.py +734 -0
- mojo/apps/jobs/keys.py +203 -0
- mojo/apps/jobs/local_queue.py +363 -0
- mojo/apps/jobs/management/__init__.py +3 -0
- mojo/apps/jobs/management/commands/__init__.py +3 -0
- mojo/apps/jobs/manager.py +1327 -0
- mojo/apps/jobs/migrations/0001_initial.py +97 -0
- mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
- mojo/apps/jobs/models/__init__.py +6 -0
- mojo/apps/jobs/models/job.py +441 -0
- mojo/apps/jobs/rest/__init__.py +2 -0
- mojo/apps/jobs/rest/control.py +466 -0
- mojo/apps/jobs/rest/jobs.py +421 -0
- mojo/apps/jobs/scheduler.py +571 -0
- mojo/apps/jobs/services/__init__.py +6 -0
- mojo/apps/jobs/services/job_actions.py +465 -0
- mojo/apps/jobs/settings.py +209 -0
- mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
- mojo/apps/logit/models/log.py +7 -1
- mojo/apps/metrics/__init__.py +8 -1
- mojo/apps/metrics/redis_metrics.py +198 -0
- mojo/apps/metrics/rest/__init__.py +3 -0
- mojo/apps/metrics/rest/categories.py +266 -0
- mojo/apps/metrics/rest/helpers.py +48 -0
- mojo/apps/metrics/rest/permissions.py +99 -0
- mojo/apps/metrics/rest/values.py +277 -0
- mojo/apps/metrics/utils.py +19 -2
- mojo/decorators/auth.py +6 -1
- mojo/decorators/http.py +47 -3
- mojo/helpers/aws/__init__.py +45 -0
- mojo/helpers/aws/ec2.py +804 -0
- mojo/helpers/aws/iam.py +748 -0
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/s3.py +451 -11
- mojo/helpers/aws/ses.py +483 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/aws/sns.py +461 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/dates.py +18 -0
- mojo/helpers/location/__init__.py +2 -0
- mojo/helpers/location/countries.py +262 -0
- mojo/helpers/location/geolocation.py +196 -0
- mojo/helpers/logit.py +37 -0
- mojo/helpers/redis/__init__.py +2 -0
- mojo/helpers/redis/adapter.py +606 -0
- mojo/helpers/redis/client.py +48 -0
- mojo/helpers/redis/pool.py +225 -0
- mojo/helpers/request.py +8 -0
- mojo/helpers/response.py +14 -2
- mojo/helpers/settings/__init__.py +2 -0
- mojo/helpers/{settings.py → settings/helper.py} +1 -37
- mojo/helpers/settings/parser.py +132 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +10 -0
- mojo/models/rest.py +494 -65
- mojo/models/secrets.py +98 -3
- mojo/serializers/__init__.py +106 -0
- mojo/serializers/core/__init__.py +90 -0
- mojo/serializers/core/cache/__init__.py +121 -0
- mojo/serializers/core/cache/backends.py +518 -0
- mojo/serializers/core/cache/base.py +102 -0
- mojo/serializers/core/cache/disabled.py +181 -0
- mojo/serializers/core/cache/memory.py +287 -0
- mojo/serializers/core/cache/redis.py +533 -0
- mojo/serializers/core/cache/utils.py +454 -0
- mojo/serializers/core/manager.py +550 -0
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/examples/settings.py +322 -0
- mojo/serializers/formats/csv.py +393 -0
- mojo/serializers/formats/localizers.py +509 -0
- mojo/serializers/{models.py → simple.py} +38 -15
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +35 -4
- testit/runner.py +23 -6
- django_nativemojo-0.1.10.dist-info/METADATA +0 -96
- django_nativemojo-0.1.10.dist-info/RECORD +0 -194
- mojo/apps/metrics/rest/db.py +0 -0
- mojo/apps/notify/README.md +0 -91
- mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
- mojo/apps/notify/admin.py +0 -52
- mojo/apps/notify/handlers/example_handlers.py +0 -516
- mojo/apps/notify/handlers/ses/__init__.py +0 -25
- mojo/apps/notify/handlers/ses/bounce.py +0 -0
- mojo/apps/notify/handlers/ses/complaint.py +0 -25
- mojo/apps/notify/handlers/ses/message.py +0 -86
- mojo/apps/notify/management/commands/__init__.py +0 -1
- mojo/apps/notify/management/commands/process_notifications.py +0 -370
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +0 -12
- mojo/apps/notify/models/account.py +0 -128
- mojo/apps/notify/models/attachment.py +0 -24
- mojo/apps/notify/models/bounce.py +0 -68
- mojo/apps/notify/models/complaint.py +0 -40
- mojo/apps/notify/models/inbox.py +0 -113
- mojo/apps/notify/models/inbox_message.py +0 -173
- mojo/apps/notify/models/outbox.py +0 -129
- mojo/apps/notify/models/outbox_message.py +0 -288
- mojo/apps/notify/models/template.py +0 -30
- mojo/apps/notify/providers/aws.py +0 -73
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +0 -2
- mojo/apps/notify/utils/notifications.py +0 -404
- mojo/apps/notify/utils/parsing.py +0 -202
- mojo/apps/notify/utils/render.py +0 -144
- mojo/apps/tasks/README.md +0 -118
- mojo/apps/tasks/__init__.py +0 -11
- mojo/apps/tasks/manager.py +0 -489
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -62
- mojo/apps/tasks/runner.py +0 -174
- mojo/apps/tasks/tq_handlers.py +0 -14
- mojo/helpers/aws/setup_email.py +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/ws4redis/README.md +0 -174
- mojo/ws4redis/__init__.py +0 -2
- mojo/ws4redis/client.py +0 -283
- mojo/ws4redis/connection.py +0 -327
- mojo/ws4redis/exceptions.py +0 -32
- mojo/ws4redis/redis.py +0 -183
- mojo/ws4redis/servers/base.py +0 -86
- mojo/ws4redis/servers/django.py +0 -171
- mojo/ws4redis/servers/uwsgi.py +0 -63
- mojo/ws4redis/settings.py +0 -45
- mojo/ws4redis/utf8validator.py +0 -128
- mojo/ws4redis/websocket.py +0 -403
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
- /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
- /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/{apps/fileman/rest/__init__ → serializers/formats/__init__.py} +0 -0
@@ -0,0 +1,32 @@
|
|
1
|
+
from mojo import decorators as md
|
2
|
+
from mojo.apps.aws.models import IncomingEmail, SentMessage
|
3
|
+
|
4
|
+
"""
|
5
|
+
AWS Email Messages REST Handlers
|
6
|
+
|
7
|
+
Endpoints:
|
8
|
+
- Incoming emails (list/detail via model.on_rest_request):
|
9
|
+
- GET/POST/PUT/DELETE /email/incoming
|
10
|
+
- GET/POST/PUT/DELETE /email/incoming/<int:pk>
|
11
|
+
|
12
|
+
- Sent messages (list/detail via model.on_rest_request):
|
13
|
+
- GET/POST/PUT/DELETE /email/sent
|
14
|
+
- GET/POST/PUT/DELETE /email/sent/<int:pk>
|
15
|
+
|
16
|
+
All endpoints require the "manage_aws" permission and delegate to the models' on_rest_request
|
17
|
+
for CRUD operations, leveraging RestMeta permissions and graphs.
|
18
|
+
"""
|
19
|
+
|
20
|
+
|
21
|
+
@md.URL('email/incoming')
|
22
|
+
@md.URL('email/incoming/<int:pk>')
|
23
|
+
@md.requires_perms("manage_aws")
|
24
|
+
def on_incoming_email(request, pk=None):
|
25
|
+
return IncomingEmail.on_rest_request(request, pk)
|
26
|
+
|
27
|
+
|
28
|
+
@md.URL('email/sent')
|
29
|
+
@md.URL('email/sent/<int:pk>')
|
30
|
+
@md.requires_perms("manage_aws")
|
31
|
+
def on_sent_message(request, pk=None):
|
32
|
+
return SentMessage.on_rest_request(request, pk)
|
mojo/apps/aws/rest/s3.py
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
from mojo import decorators as md
|
2
|
+
from mojo import JsonResponse
|
3
|
+
from mojo.helpers.aws import s3
|
4
|
+
|
5
|
+
|
6
|
+
@md.URL('s3/bucket')
|
7
|
+
@md.URL('s3/bucket/<str:bucket_name>')
|
8
|
+
@md.requires_perms("manage_aws")
|
9
|
+
def on_s3_bucket(request, bucket_name=None):
|
10
|
+
bucket_name = request.DATA.get('bucket_name', bucket_name)
|
11
|
+
if request.method == "GET":
|
12
|
+
if bucket_name is None:
|
13
|
+
# List all buckets
|
14
|
+
buckets = s3.S3.list_all_buckets()
|
15
|
+
return JsonResponse({
|
16
|
+
"size": len(buckets),
|
17
|
+
"count": len(buckets),
|
18
|
+
"data": buckets,
|
19
|
+
"status": True
|
20
|
+
})
|
21
|
+
else:
|
22
|
+
# Get specific bucket info
|
23
|
+
bucket = s3.S3Bucket(bucket_name)
|
24
|
+
if bucket._check_exists():
|
25
|
+
return JsonResponse({"data": {"name": bucket_name, "exists": True}, "status": True})
|
26
|
+
else:
|
27
|
+
return JsonResponse({"error": "Bucket not found", "code": 404}, status=404)
|
28
|
+
|
29
|
+
elif request.method == "POST":
|
30
|
+
if bucket_name is None:
|
31
|
+
return JsonResponse({"error": "Bucket name required"}, status=400)
|
32
|
+
|
33
|
+
# Create or update bucket
|
34
|
+
bucket = s3.S3Bucket(bucket_name)
|
35
|
+
try:
|
36
|
+
if not bucket._check_exists():
|
37
|
+
bucket.create()
|
38
|
+
bucket.enable_cors()
|
39
|
+
return JsonResponse({"message": f"Bucket {bucket_name} created successfully"})
|
40
|
+
else:
|
41
|
+
return JsonResponse({"message": f"Bucket {bucket_name} already exists"})
|
42
|
+
except Exception as e:
|
43
|
+
return JsonResponse({"error": str(e)}, status=500)
|
44
|
+
|
45
|
+
# elif request.method == "DELETE":
|
46
|
+
# if bucket_name is None:
|
47
|
+
# return JsonResponse({"error": "Bucket name required"}, status=400)
|
48
|
+
|
49
|
+
# # Check for confirmation
|
50
|
+
# if request.DATA.get("confirm_delete") != "yes delete bucket":
|
51
|
+
# return JsonResponse({"error": "Confirmation required: confirm_delete = 'yes delete bucket'"}, status=400)
|
52
|
+
|
53
|
+
# # Delete bucket
|
54
|
+
# bucket = s3.S3Bucket(bucket_name)
|
55
|
+
# try:
|
56
|
+
# if bucket._check_exists():
|
57
|
+
# bucket.delete()
|
58
|
+
# return JsonResponse({"message": f"Bucket {bucket_name} deleted successfully"})
|
59
|
+
# else:
|
60
|
+
# return JsonResponse({"error": "Bucket not found"}, status=404)
|
61
|
+
# except Exception as e:
|
62
|
+
# return JsonResponse({"error": str(e)}, status=500)
|
63
|
+
|
64
|
+
return JsonResponse({"message": "Invalid request method"}, status=405)
|
@@ -0,0 +1,101 @@
|
|
1
|
+
from mojo.apps.aws.services import send_template_email
|
2
|
+
from typing import Any, Dict, List, Optional
|
3
|
+
|
4
|
+
from mojo import decorators as md
|
5
|
+
from mojo import JsonResponse
|
6
|
+
from mojo.apps.aws.models import Mailbox, SentMessage, EmailDomain, EmailTemplate
|
7
|
+
from mojo.helpers.aws.ses import EmailSender
|
8
|
+
from mojo.helpers.settings import settings
|
9
|
+
from mojo.helpers import logit
|
10
|
+
|
11
|
+
logger = logit.get_logger("email", "email.log")
|
12
|
+
|
13
|
+
|
14
|
+
def _as_list(value: Any) -> List[str]:
|
15
|
+
if value is None:
|
16
|
+
return []
|
17
|
+
if isinstance(value, (list, tuple)):
|
18
|
+
return [str(v).strip() for v in value if str(v).strip()]
|
19
|
+
return [str(value).strip()] if str(value).strip() else []
|
20
|
+
|
21
|
+
|
22
|
+
@md.URL("email/send")
|
23
|
+
@md.requires_perms("manage_aws")
|
24
|
+
def on_send_email(request):
|
25
|
+
"""
|
26
|
+
Send an email through AWS SES using a Mailbox resolved by from_email.
|
27
|
+
|
28
|
+
Request (POST JSON):
|
29
|
+
{
|
30
|
+
"from_email": "support@example.com", // required, resolves Mailbox
|
31
|
+
"to": ["user@example.org"], // required (list or string)
|
32
|
+
"cc": [], // optional
|
33
|
+
"bcc": [], // optional
|
34
|
+
"subject": "Hello", // required if not using template_name
|
35
|
+
"body_text": "Text body", // optional
|
36
|
+
"body_html": "<p>HTML body</p>", // optional
|
37
|
+
"reply_to": ["replies@example.com"], // optional
|
38
|
+
"template_name": "db-template-optional", // optional, uses DB EmailTemplate if provided
|
39
|
+
"ses_template_name": "ses-template-optional", // optional, uses AWS SES managed template
|
40
|
+
"template_context": { ... }, // optional, for DB/SES template context
|
41
|
+
"aws_access_key": "...", // optional, defaults to settings
|
42
|
+
"aws_secret_key": "...", // optional, defaults to settings
|
43
|
+
"allow_unverified": false // optional, allow send even if domain.status != 'verified'
|
44
|
+
}
|
45
|
+
|
46
|
+
Behavior:
|
47
|
+
- Resolves the Mailbox by from_email (case-insensitive).
|
48
|
+
- Ensures mailbox.allow_outbound is True.
|
49
|
+
- Uses mailbox.domain.region (or settings.AWS_REGION) to send via SES.
|
50
|
+
- If template_name is provided and matches a DB EmailTemplate, renders and uses EmailSender.send_email with the rendered subject/body.
|
51
|
+
If ses_template_name is provided, uses EmailSender.send_template_email (AWS SES managed template).
|
52
|
+
Otherwise uses EmailSender.send_email with subject/body_text/body_html.
|
53
|
+
- Creates a SentMessage row and updates with SES MessageId and status.
|
54
|
+
"""
|
55
|
+
if request.method != "POST":
|
56
|
+
return JsonResponse({"error": "Method not allowed"}, status=405)
|
57
|
+
|
58
|
+
data: Dict[str, Any] = getattr(request, "DATA", {}) or {}
|
59
|
+
|
60
|
+
from_email = (data.get("from_email") or "").strip()
|
61
|
+
if not from_email:
|
62
|
+
return JsonResponse({"error": "from_email is required"}, status=400)
|
63
|
+
|
64
|
+
# Resolve Mailbox by email (case-insensitive)
|
65
|
+
mailbox = Mailbox.objects.select_related("domain").filter(email__iexact=from_email).first()
|
66
|
+
if not mailbox:
|
67
|
+
return JsonResponse({"error": f"Mailbox not found for from_email={from_email}", "code": 404}, status=404)
|
68
|
+
|
69
|
+
if not mailbox.allow_outbound:
|
70
|
+
return JsonResponse({"error": "Outbound sending is disabled for this mailbox", "code": 403}, status=403)
|
71
|
+
|
72
|
+
|
73
|
+
to = _as_list(data.get("to"))
|
74
|
+
cc = _as_list(data.get("cc"))
|
75
|
+
bcc = _as_list(data.get("bcc"))
|
76
|
+
reply_to = _as_list(data.get("reply_to")) or [from_email]
|
77
|
+
|
78
|
+
if not to:
|
79
|
+
return JsonResponse({"error": "At least one recipient in 'to' is required"}, status=400)
|
80
|
+
|
81
|
+
subject = (data.get("subject") or "").strip()
|
82
|
+
body_text = data.get("body_text")
|
83
|
+
body_html = data.get("body_html")
|
84
|
+
template_name = (data.get("template_name") or "").strip() or None
|
85
|
+
template_context = data.get("template_context") or {}
|
86
|
+
|
87
|
+
if template_name:
|
88
|
+
res = mailbox.send_template_email(
|
89
|
+
to, template_name, template_context,
|
90
|
+
cc, bcc, reply_to)
|
91
|
+
else:
|
92
|
+
res = mailbox.send_email(
|
93
|
+
to=to,
|
94
|
+
subject=subject,
|
95
|
+
body_text=body_text,
|
96
|
+
body_html=body_html,
|
97
|
+
cc=cc,
|
98
|
+
bcc=bcc,
|
99
|
+
reply_to=reply_to
|
100
|
+
)
|
101
|
+
return res.on_rest_get(request)
|
@@ -0,0 +1,403 @@
|
|
1
|
+
from typing import Any, Dict, Optional
|
2
|
+
import json
|
3
|
+
import time
|
4
|
+
import requests
|
5
|
+
|
6
|
+
from mojo import decorators as md
|
7
|
+
from mojo import JsonResponse
|
8
|
+
from mojo.helpers import logit
|
9
|
+
from mojo.helpers.settings import settings
|
10
|
+
from mojo.helpers.aws.inbound_email import process_inbound_email_from_s3
|
11
|
+
|
12
|
+
# from mojo.apps.aws.models import SentMessage # Uncomment when implementing status updates
|
13
|
+
# from mojo.apps.aws.models import IncomingEmail # Uncomment when implementing inbound storage
|
14
|
+
|
15
|
+
logger = logit.get_logger("email", "email.log")
|
16
|
+
|
17
|
+
|
18
|
+
# Simple in-memory cache for SNS signing certificates
|
19
|
+
# Key: SigningCertURL, Value: (fetched_at_epoch_seconds, pem_bytes)
|
20
|
+
_SNS_CERT_CACHE: Dict[str, tuple[float, bytes]] = {}
|
21
|
+
_SNS_CERT_TTL_SECONDS = settings.get('SNS_CERT_TTL_SECONDS', 3600) # default 1 hour
|
22
|
+
|
23
|
+
|
24
|
+
def _json_loads_safe(data: str) -> Optional[Dict[str, Any]]:
|
25
|
+
try:
|
26
|
+
return json.loads(data)
|
27
|
+
except Exception:
|
28
|
+
return None
|
29
|
+
|
30
|
+
|
31
|
+
def _validate_sns_signature(sns: Dict[str, Any]) -> bool:
|
32
|
+
"""
|
33
|
+
Validate Amazon SNS signature for SubscriptionConfirmation and Notification messages.
|
34
|
+
|
35
|
+
Behavior:
|
36
|
+
- When settings.DEBUG is True and settings.get('SNS_VALIDATION_BYPASS_DEBUG', False) is True,
|
37
|
+
this returns True to simplify local development.
|
38
|
+
- Otherwise performs full validation and uses an in-memory certificate cache to
|
39
|
+
reduce network calls to the SigningCertURL.
|
40
|
+
"""
|
41
|
+
try:
|
42
|
+
import base64
|
43
|
+
from urllib.parse import urlparse
|
44
|
+
from cryptography import x509
|
45
|
+
from cryptography.hazmat.primitives import hashes
|
46
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
47
|
+
from cryptography.hazmat.backends import default_backend # noqa: F401
|
48
|
+
except Exception as e:
|
49
|
+
logger.error(f"SNS signature validation unavailable (missing dependencies): {e}")
|
50
|
+
return False
|
51
|
+
|
52
|
+
# DEBUG bypass (opt-in)
|
53
|
+
if getattr(settings, "DEBUG", False) and bool(getattr(settings, "SNS_VALIDATION_BYPASS_DEBUG", False)):
|
54
|
+
logger.info("SNS signature validation bypassed (DEBUG mode with SNS_VALIDATION_BYPASS_DEBUG=True)")
|
55
|
+
return True
|
56
|
+
|
57
|
+
signing_cert_url = sns.get("SigningCertURL")
|
58
|
+
signature_b64 = sns.get("Signature")
|
59
|
+
msg_type = sns.get("Type")
|
60
|
+
|
61
|
+
if not signing_cert_url or not signature_b64 or not msg_type:
|
62
|
+
return False
|
63
|
+
|
64
|
+
# Validate SigningCertURL
|
65
|
+
parsed = urlparse(signing_cert_url)
|
66
|
+
if parsed.scheme.lower() != "https":
|
67
|
+
logger.warning("SNS SigningCertURL is not HTTPS")
|
68
|
+
return False
|
69
|
+
hostname = (parsed.hostname or "").lower()
|
70
|
+
# Allow sns.amazonaws.com and sns.<region>.amazonaws.com
|
71
|
+
if not (hostname == "sns.amazonaws.com" or (hostname.endswith(".amazonaws.com") and hostname.startswith("sns."))):
|
72
|
+
logger.warning(f"SNS SigningCertURL host not allowed: {hostname}")
|
73
|
+
return False
|
74
|
+
|
75
|
+
# Build canonical string per AWS docs
|
76
|
+
def build_canonical_notification(m: Dict[str, Any]) -> bytes:
|
77
|
+
# Order: Message, MessageId, Subject (if present), Timestamp, TopicArn, Type
|
78
|
+
lines = []
|
79
|
+
def add(k):
|
80
|
+
v = m.get(k)
|
81
|
+
if v is not None:
|
82
|
+
lines.append(f"{k}\n{v}\n")
|
83
|
+
add("Message")
|
84
|
+
add("MessageId")
|
85
|
+
if m.get("Subject") is not None:
|
86
|
+
add("Subject")
|
87
|
+
add("Timestamp")
|
88
|
+
add("TopicArn")
|
89
|
+
add("Type")
|
90
|
+
return "".join(lines).encode("utf-8")
|
91
|
+
|
92
|
+
def build_canonical_subscription(m: Dict[str, Any]) -> bytes:
|
93
|
+
# Order: Message, MessageId, SubscribeURL, Timestamp, Token, TopicArn, Type
|
94
|
+
lines = []
|
95
|
+
def add(k):
|
96
|
+
v = m.get(k)
|
97
|
+
if v is not None:
|
98
|
+
lines.append(f"{k}\n{v}\n")
|
99
|
+
add("Message")
|
100
|
+
add("MessageId")
|
101
|
+
add("SubscribeURL")
|
102
|
+
add("Timestamp")
|
103
|
+
add("Token")
|
104
|
+
add("TopicArn")
|
105
|
+
add("Type")
|
106
|
+
return "".join(lines).encode("utf-8")
|
107
|
+
|
108
|
+
if msg_type in ("Notification",):
|
109
|
+
canonical = build_canonical_notification(sns)
|
110
|
+
elif msg_type in ("SubscriptionConfirmation", "UnsubscribeConfirmation"):
|
111
|
+
canonical = build_canonical_subscription(sns)
|
112
|
+
else:
|
113
|
+
# Unknown type; do not accept
|
114
|
+
return False
|
115
|
+
|
116
|
+
# Fetch certificate with caching
|
117
|
+
pem_bytes: Optional[bytes] = None
|
118
|
+
cache_entry = _SNS_CERT_CACHE.get(signing_cert_url)
|
119
|
+
now = time.time()
|
120
|
+
if cache_entry:
|
121
|
+
fetched_at, cached_pem = cache_entry
|
122
|
+
if now - fetched_at < _SNS_CERT_TTL_SECONDS:
|
123
|
+
pem_bytes = cached_pem
|
124
|
+
else:
|
125
|
+
# expired, drop from cache
|
126
|
+
_SNS_CERT_CACHE.pop(signing_cert_url, None)
|
127
|
+
if pem_bytes is None:
|
128
|
+
try:
|
129
|
+
resp = requests.get(signing_cert_url, timeout=10)
|
130
|
+
resp.raise_for_status()
|
131
|
+
pem_bytes = resp.content
|
132
|
+
_SNS_CERT_CACHE[signing_cert_url] = (now, pem_bytes)
|
133
|
+
except Exception as e:
|
134
|
+
logger.error(f"Failed to fetch SNS SigningCert: {e}")
|
135
|
+
return False
|
136
|
+
|
137
|
+
# Parse certificate and verify signature
|
138
|
+
try:
|
139
|
+
cert = x509.load_pem_x509_certificate(pem_bytes)
|
140
|
+
pubkey = cert.public_key()
|
141
|
+
except Exception as e:
|
142
|
+
logger.error(f"Failed to load SNS SigningCert: {e}")
|
143
|
+
return False
|
144
|
+
|
145
|
+
# Verify signature (try SHA1 then SHA256 for compatibility)
|
146
|
+
try:
|
147
|
+
signature = base64.b64decode(signature_b64)
|
148
|
+
except Exception as e:
|
149
|
+
logger.error(f"Invalid SNS signature (base64 decode): {e}")
|
150
|
+
return False
|
151
|
+
|
152
|
+
for hash_algo in (hashes.SHA1(), hashes.SHA256()):
|
153
|
+
try:
|
154
|
+
pubkey.verify(
|
155
|
+
signature,
|
156
|
+
canonical,
|
157
|
+
padding.PKCS1v15(),
|
158
|
+
hash_algo
|
159
|
+
)
|
160
|
+
return True
|
161
|
+
except Exception:
|
162
|
+
continue
|
163
|
+
|
164
|
+
logger.error("SNS signature verification failed")
|
165
|
+
return False
|
166
|
+
|
167
|
+
|
168
|
+
def _handle_subscription_confirmation(sns: Dict[str, Any]) -> Dict[str, Any]:
|
169
|
+
subscribe_url = sns.get("SubscribeURL")
|
170
|
+
topic_arn = sns.get("TopicArn")
|
171
|
+
if subscribe_url:
|
172
|
+
try:
|
173
|
+
resp = requests.get(subscribe_url, timeout=10)
|
174
|
+
logger.info(f"SNS subscription confirmed for topic {topic_arn}: {resp.status_code}")
|
175
|
+
return {"confirmed": True, "status_code": resp.status_code}
|
176
|
+
except Exception as e:
|
177
|
+
logger.error(f"Failed to confirm SNS subscription for topic {topic_arn}: {e}")
|
178
|
+
return {"confirmed": False, "error": str(e)}
|
179
|
+
logger.warning("SubscriptionConfirmation missing SubscribeURL")
|
180
|
+
return {"confirmed": False, "error": "missing_subscribe_url"}
|
181
|
+
|
182
|
+
|
183
|
+
def _parse_sns_request(request) -> Optional[Dict[str, Any]]:
|
184
|
+
# SNS sends JSON in the raw body (content-type text/plain or json), not x-www-form-urlencoded
|
185
|
+
try:
|
186
|
+
body = request.body.decode("utf-8") if hasattr(request, "body") else (request.DATA or "")
|
187
|
+
except Exception:
|
188
|
+
body = request.DATA or ""
|
189
|
+
if isinstance(body, dict):
|
190
|
+
# Some frameworks may parse JSON automatically
|
191
|
+
return body
|
192
|
+
return _json_loads_safe(body)
|
193
|
+
|
194
|
+
|
195
|
+
def _handle_inbound_notification(message: Dict[str, Any]) -> None:
|
196
|
+
"""
|
197
|
+
Handle SES inbound event delivered via SNS:
|
198
|
+
- Determine S3 bucket/key from receipt.action and mail.messageId/prefix
|
199
|
+
- Parse/store the message and attachments
|
200
|
+
- Associate to Mailbox (if matched) and enqueue async handler
|
201
|
+
"""
|
202
|
+
mail = (message.get("mail") or {})
|
203
|
+
receipt = (message.get("receipt") or {})
|
204
|
+
msg_id = mail.get("messageId")
|
205
|
+
recipients = receipt.get("recipients") or mail.get("destination") or []
|
206
|
+
|
207
|
+
action = (receipt.get("action") or {})
|
208
|
+
bucket = action.get("bucketName") or action.get("bucket")
|
209
|
+
key = action.get("objectKey")
|
210
|
+
prefix = action.get("objectKeyPrefix") or ""
|
211
|
+
|
212
|
+
# Derive key if not present
|
213
|
+
if not key and msg_id:
|
214
|
+
key = f"{prefix}{msg_id}"
|
215
|
+
|
216
|
+
if not bucket or not key:
|
217
|
+
logger.error(f"Inbound SNS missing bucket/key; msg_id={msg_id} bucket={bucket} key={key} prefix={prefix}")
|
218
|
+
return
|
219
|
+
|
220
|
+
try:
|
221
|
+
process_inbound_email_from_s3(bucket, key, recipients_hint=recipients)
|
222
|
+
logger.info(f"Inbound email processed: s3://{bucket}/{key}")
|
223
|
+
except Exception as e:
|
224
|
+
# Try fallback with '.eml' suffix if initial guess fails
|
225
|
+
if msg_id and prefix and not key.endswith(".eml"):
|
226
|
+
fallback_key = f"{prefix}{msg_id}.eml"
|
227
|
+
try:
|
228
|
+
process_inbound_email_from_s3(bucket, fallback_key, recipients_hint=recipients)
|
229
|
+
logger.info(f"Inbound email processed with fallback key: s3://{bucket}/{fallback_key}")
|
230
|
+
return
|
231
|
+
except Exception as e2:
|
232
|
+
logger.error(f"Fallback inbound processing failed for s3://{bucket}/{fallback_key}: {e2}")
|
233
|
+
logger.error(f"Inbound processing failed for s3://{bucket}/{key}: {e}")
|
234
|
+
|
235
|
+
|
236
|
+
def _handle_bounce_notification(message: Dict[str, Any]) -> None:
|
237
|
+
"""
|
238
|
+
Handle SES bounce notification delivered via SNS.
|
239
|
+
Updates SentMessage status to 'bounced' with details.
|
240
|
+
"""
|
241
|
+
from mojo.apps.aws.models import SentMessage # local import to avoid circulars
|
242
|
+
mid = message.get("mail", {}).get("messageId")
|
243
|
+
details = message.get("bounce") or {}
|
244
|
+
logger.info(f"Received bounce for SES MessageId: {mid}")
|
245
|
+
if not mid:
|
246
|
+
return
|
247
|
+
sent = SentMessage.objects.filter(ses_message_id=mid).first()
|
248
|
+
if not sent:
|
249
|
+
logger.warning(f"No SentMessage found for bounce MessageId={mid}")
|
250
|
+
return
|
251
|
+
sent.status = SentMessage.STATUS_BOUNCED
|
252
|
+
try:
|
253
|
+
sent.status_reason = json.dumps(details)
|
254
|
+
except Exception:
|
255
|
+
sent.status_reason = str(details)
|
256
|
+
sent.save(update_fields=["status", "status_reason", "modified"])
|
257
|
+
|
258
|
+
|
259
|
+
def _handle_complaint_notification(message: Dict[str, Any]) -> None:
|
260
|
+
"""
|
261
|
+
Handle SES complaint notification delivered via SNS.
|
262
|
+
Updates SentMessage status to 'complained' with details.
|
263
|
+
"""
|
264
|
+
from mojo.apps.aws.models import SentMessage
|
265
|
+
mid = message.get("mail", {}).get("messageId")
|
266
|
+
details = message.get("complaint") or {}
|
267
|
+
logger.info(f"Received complaint for SES MessageId: {mid}")
|
268
|
+
if not mid:
|
269
|
+
return
|
270
|
+
sent = SentMessage.objects.filter(ses_message_id=mid).first()
|
271
|
+
if not sent:
|
272
|
+
logger.warning(f"No SentMessage found for complaint MessageId={mid}")
|
273
|
+
return
|
274
|
+
sent.status = SentMessage.STATUS_COMPLAINED
|
275
|
+
try:
|
276
|
+
sent.status_reason = json.dumps(details)
|
277
|
+
except Exception:
|
278
|
+
sent.status_reason = str(details)
|
279
|
+
sent.save(update_fields=["status", "status_reason", "modified"])
|
280
|
+
|
281
|
+
|
282
|
+
def _handle_delivery_notification(message: Dict[str, Any]) -> None:
|
283
|
+
"""
|
284
|
+
Handle SES delivery notification delivered via SNS.
|
285
|
+
Updates SentMessage status to 'delivered' with details.
|
286
|
+
"""
|
287
|
+
from mojo.apps.aws.models import SentMessage
|
288
|
+
mid = message.get("mail", {}).get("messageId")
|
289
|
+
details = message.get("delivery") or {}
|
290
|
+
logger.info(f"Received delivery for SES MessageId: {mid}")
|
291
|
+
if not mid:
|
292
|
+
return
|
293
|
+
sent = SentMessage.objects.filter(ses_message_id=mid).first()
|
294
|
+
if not sent:
|
295
|
+
logger.warning(f"No SentMessage found for delivery MessageId={mid}")
|
296
|
+
return
|
297
|
+
sent.status = SentMessage.STATUS_DELIVERED
|
298
|
+
try:
|
299
|
+
sent.status_reason = json.dumps(details)
|
300
|
+
except Exception:
|
301
|
+
sent.status_reason = str(details)
|
302
|
+
sent.save(update_fields=["status", "status_reason", "modified"])
|
303
|
+
|
304
|
+
|
305
|
+
def _handle_sns(kind: str, request):
|
306
|
+
"""
|
307
|
+
Common SNS webhook handler:
|
308
|
+
- Validates SNS signature (TODO)
|
309
|
+
- Handles SubscriptionConfirmation
|
310
|
+
- Handles Notification (parses Message and dispatches by notificationType)
|
311
|
+
"""
|
312
|
+
if request.method != "POST":
|
313
|
+
return JsonResponse({"error": "Method not allowed"}, status=405)
|
314
|
+
|
315
|
+
sns = _parse_sns_request(request)
|
316
|
+
if not sns:
|
317
|
+
return JsonResponse({"error": "Invalid SNS payload"}, status=400)
|
318
|
+
|
319
|
+
# Optional: compare with HTTP header x-amz-sns-message-type for consistency
|
320
|
+
msg_type = sns.get("Type")
|
321
|
+
topic_arn = sns.get("TopicArn")
|
322
|
+
logger.info(f"SNS webhook ({kind}) Type={msg_type} TopicArn={topic_arn}")
|
323
|
+
|
324
|
+
# Validate SNS signature and allowed topic
|
325
|
+
if not _validate_sns_signature(sns):
|
326
|
+
return JsonResponse({"error": "Invalid SNS signature"}, status=403)
|
327
|
+
# Ensure TopicArn matches a configured/known ARN
|
328
|
+
def _is_allowed_topic(topic: Optional[str]) -> bool:
|
329
|
+
if not topic:
|
330
|
+
return False
|
331
|
+
try:
|
332
|
+
from django.db.models import Q
|
333
|
+
from mojo.apps.aws.models import EmailDomain
|
334
|
+
return EmailDomain.objects.filter(
|
335
|
+
Q(sns_topic_bounce_arn=topic) |
|
336
|
+
Q(sns_topic_complaint_arn=topic) |
|
337
|
+
Q(sns_topic_delivery_arn=topic) |
|
338
|
+
Q(sns_topic_inbound_arn=topic)
|
339
|
+
).exists()
|
340
|
+
except Exception as e:
|
341
|
+
logger.error(f"TopicArn allow-check failed: {e}")
|
342
|
+
return False
|
343
|
+
if not _is_allowed_topic(topic_arn):
|
344
|
+
return JsonResponse({"error": "Disallowed TopicArn"}, status=403)
|
345
|
+
|
346
|
+
if msg_type == "SubscriptionConfirmation":
|
347
|
+
res = _handle_subscription_confirmation(sns)
|
348
|
+
return JsonResponse({"status": True, "data": res})
|
349
|
+
|
350
|
+
if msg_type == "Notification":
|
351
|
+
# SNS Message may be a JSON string
|
352
|
+
message_raw = sns.get("Message", "")
|
353
|
+
message = _json_loads_safe(message_raw) or {"raw": message_raw}
|
354
|
+
notification_type = (message.get("notificationType") or kind).lower()
|
355
|
+
|
356
|
+
if kind == "inbound" or notification_type in ("received", "inbound"):
|
357
|
+
_handle_inbound_notification(message)
|
358
|
+
elif kind == "bounce" or notification_type == "bounce":
|
359
|
+
_handle_bounce_notification(message)
|
360
|
+
elif kind == "complaint" or notification_type == "complaint":
|
361
|
+
_handle_complaint_notification(message)
|
362
|
+
elif kind == "delivery" or notification_type == "delivery":
|
363
|
+
_handle_delivery_notification(message)
|
364
|
+
else:
|
365
|
+
logger.info(f"SNS webhook ({kind}) received unknown notificationType: {notification_type}")
|
366
|
+
|
367
|
+
return JsonResponse({"status": True})
|
368
|
+
|
369
|
+
# Unhandled types (UnsubscribeConfirmation, etc.) can be handled here if needed
|
370
|
+
logger.info(f"SNS webhook ({kind}) received unhandled Type: {msg_type}")
|
371
|
+
return JsonResponse({"status": True, "info": f"Unhandled Type: {msg_type}"})
|
372
|
+
|
373
|
+
|
374
|
+
@md.URL("email/sns/inbound")
|
375
|
+
def on_sns_inbound(request):
|
376
|
+
"""
|
377
|
+
Public webhook endpoint for SES inbound (S3 + SNS).
|
378
|
+
"""
|
379
|
+
return _handle_sns("inbound", request)
|
380
|
+
|
381
|
+
|
382
|
+
@md.URL("email/sns/bounce")
|
383
|
+
def on_sns_bounce(request):
|
384
|
+
"""
|
385
|
+
Public webhook endpoint for SES bounce notifications.
|
386
|
+
"""
|
387
|
+
return _handle_sns("bounce", request)
|
388
|
+
|
389
|
+
|
390
|
+
@md.URL("email/sns/complaint")
|
391
|
+
def on_sns_complaint(request):
|
392
|
+
"""
|
393
|
+
Public webhook endpoint for SES complaint notifications.
|
394
|
+
"""
|
395
|
+
return _handle_sns("complaint", request)
|
396
|
+
|
397
|
+
|
398
|
+
@md.URL("email/sns/delivery")
|
399
|
+
def on_sns_delivery(request):
|
400
|
+
"""
|
401
|
+
Public webhook endpoint for SES delivery notifications.
|
402
|
+
"""
|
403
|
+
return _handle_sns("delivery", request)
|
@@ -0,0 +1,19 @@
|
|
1
|
+
from mojo import decorators as md
|
2
|
+
from mojo.apps.aws.models import EmailTemplate
|
3
|
+
|
4
|
+
"""
|
5
|
+
EmailTemplate REST Handlers
|
6
|
+
|
7
|
+
CRUD endpoints:
|
8
|
+
- GET/POST/PUT/DELETE /email/template
|
9
|
+
- GET/POST/PUT/DELETE /email/template/<int:pk>
|
10
|
+
|
11
|
+
These delegate to the model's on_rest_request, leveraging RestMeta for permissions and graphs.
|
12
|
+
"""
|
13
|
+
|
14
|
+
|
15
|
+
@md.URL('email/template')
|
16
|
+
@md.URL('email/template/<int:pk>')
|
17
|
+
@md.requires_perms("manage_aws")
|
18
|
+
def on_email_template(request, pk=None):
|
19
|
+
return EmailTemplate.on_rest_request(request, pk)
|
@@ -0,0 +1,32 @@
|
|
1
|
+
"""
|
2
|
+
AWS services package
|
3
|
+
|
4
|
+
Convenience re-exports for AWS services so callers can do:
|
5
|
+
from mojo.apps.aws.services import send_email, send_template_email
|
6
|
+
from mojo.apps.aws.services import onboard_email_domain, audit_email_domain
|
7
|
+
"""
|
8
|
+
|
9
|
+
from .email import send_email, send_template_email, send_with_template
|
10
|
+
from .email_ops import (
|
11
|
+
onboard_email_domain,
|
12
|
+
audit_email_domain,
|
13
|
+
reconcile_email_domain,
|
14
|
+
generate_audit_recommendations,
|
15
|
+
EmailDomainNotFound,
|
16
|
+
InvalidConfiguration,
|
17
|
+
)
|
18
|
+
|
19
|
+
__all__ = [
|
20
|
+
# Email sending
|
21
|
+
"send_email",
|
22
|
+
"send_template_email",
|
23
|
+
"send_with_template",
|
24
|
+
# Domain management
|
25
|
+
"onboard_email_domain",
|
26
|
+
"audit_email_domain",
|
27
|
+
"reconcile_email_domain",
|
28
|
+
"generate_audit_recommendations",
|
29
|
+
# Exceptions
|
30
|
+
"EmailDomainNotFound",
|
31
|
+
"InvalidConfiguration",
|
32
|
+
]
|