django-nativemojo 0.1.15__py3-none-any.whl → 0.1.17__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.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
- django_nativemojo-0.1.17.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/commands/serializer_admin.py +121 -1
- 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 +279 -0
- mojo/apps/account/models/group.py +294 -8
- mojo/apps/account/models/member.py +14 -1
- 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 +190 -17
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +8 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +95 -5
- 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 +6 -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/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/backends/s3.py +209 -0
- mojo/apps/fileman/models/file.py +45 -9
- mojo/apps/fileman/models/manager.py +269 -3
- 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 +1 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/incident.py +2 -0
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -3
- mojo/apps/incident/rest/__init__.py +1 -0
- 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/models/log.py +3 -0
- 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 +17 -0
- mojo/decorators/http.py +40 -1
- mojo/helpers/aws/__init__.py +11 -7
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -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 +8 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +5 -0
- mojo/models/rest.py +271 -57
- mojo/models/secrets.py +86 -0
- mojo/serializers/__init__.py +16 -10
- 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/{manager.py → core/manager.py} +53 -4
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +14 -0
- testit/runner.py +23 -6
- django_nativemojo-0.1.15.dist-info/RECORD +0 -234
- 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/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 -44
- mojo/apps/tasks/manager.py +0 -644
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -76
- mojo/apps/tasks/runner.py +0 -439
- mojo/apps/tasks/task.py +0 -99
- mojo/apps/tasks/tq_handlers.py +0 -132
- mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/serializers/advanced/README.md +0 -363
- mojo/serializers/advanced/__init__.py +0 -247
- mojo/serializers/advanced/formats/__init__.py +0 -28
- mojo/serializers/advanced/formats/excel.py +0 -516
- mojo/serializers/advanced/formats/json.py +0 -239
- mojo/serializers/advanced/formats/response.py +0 -485
- mojo/serializers/advanced/serializer.py +0 -568
- mojo/serializers/optimized.py +0 -618
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
- /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
- /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
- /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -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
|
+
]
|