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.
Files changed (276) hide show
  1. django_nativemojo-0.1.16.dist-info/METADATA +138 -0
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/__init__.py +5 -0
  5. mojo/apps/account/management/commands/__init__.py +6 -0
  6. mojo/apps/account/management/commands/serializer_admin.py +651 -0
  7. mojo/apps/account/migrations/0004_user_avatar.py +20 -0
  8. mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
  9. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  10. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  11. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  12. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  13. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  14. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  15. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  16. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  17. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  18. mojo/apps/account/models/__init__.py +2 -0
  19. mojo/apps/account/models/device.py +281 -0
  20. mojo/apps/account/models/group.py +319 -15
  21. mojo/apps/account/models/member.py +29 -5
  22. mojo/apps/account/models/push/__init__.py +4 -0
  23. mojo/apps/account/models/push/config.py +112 -0
  24. mojo/apps/account/models/push/delivery.py +93 -0
  25. mojo/apps/account/models/push/device.py +66 -0
  26. mojo/apps/account/models/push/template.py +99 -0
  27. mojo/apps/account/models/user.py +369 -19
  28. mojo/apps/account/rest/__init__.py +2 -0
  29. mojo/apps/account/rest/device.py +39 -0
  30. mojo/apps/account/rest/group.py +9 -0
  31. mojo/apps/account/rest/push.py +187 -0
  32. mojo/apps/account/rest/user.py +100 -6
  33. mojo/apps/account/services/__init__.py +1 -0
  34. mojo/apps/account/services/push.py +363 -0
  35. mojo/apps/aws/migrations/0001_initial.py +206 -0
  36. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  37. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  38. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  39. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  40. mojo/apps/aws/models/__init__.py +19 -0
  41. mojo/apps/aws/models/email_attachment.py +99 -0
  42. mojo/apps/aws/models/email_domain.py +218 -0
  43. mojo/apps/aws/models/email_template.py +132 -0
  44. mojo/apps/aws/models/incoming_email.py +197 -0
  45. mojo/apps/aws/models/mailbox.py +288 -0
  46. mojo/apps/aws/models/sent_message.py +175 -0
  47. mojo/apps/aws/rest/__init__.py +7 -0
  48. mojo/apps/aws/rest/email.py +33 -0
  49. mojo/apps/aws/rest/email_ops.py +183 -0
  50. mojo/apps/aws/rest/messages.py +32 -0
  51. mojo/apps/aws/rest/s3.py +64 -0
  52. mojo/apps/aws/rest/send.py +101 -0
  53. mojo/apps/aws/rest/sns.py +403 -0
  54. mojo/apps/aws/rest/templates.py +19 -0
  55. mojo/apps/aws/services/__init__.py +32 -0
  56. mojo/apps/aws/services/email.py +390 -0
  57. mojo/apps/aws/services/email_ops.py +548 -0
  58. mojo/apps/docit/__init__.py +6 -0
  59. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  60. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  61. mojo/apps/docit/migrations/0001_initial.py +113 -0
  62. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  63. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  64. mojo/apps/docit/models/__init__.py +17 -0
  65. mojo/apps/docit/models/asset.py +231 -0
  66. mojo/apps/docit/models/book.py +227 -0
  67. mojo/apps/docit/models/page.py +319 -0
  68. mojo/apps/docit/models/page_revision.py +203 -0
  69. mojo/apps/docit/rest/__init__.py +10 -0
  70. mojo/apps/docit/rest/asset.py +17 -0
  71. mojo/apps/docit/rest/book.py +22 -0
  72. mojo/apps/docit/rest/page.py +22 -0
  73. mojo/apps/docit/rest/page_revision.py +17 -0
  74. mojo/apps/docit/services/__init__.py +11 -0
  75. mojo/apps/docit/services/docit.py +315 -0
  76. mojo/apps/docit/services/markdown.py +44 -0
  77. mojo/apps/fileman/README.md +8 -8
  78. mojo/apps/fileman/backends/base.py +76 -70
  79. mojo/apps/fileman/backends/filesystem.py +86 -86
  80. mojo/apps/fileman/backends/s3.py +409 -108
  81. mojo/apps/fileman/migrations/0001_initial.py +106 -0
  82. mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
  83. mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
  84. mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
  85. mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
  86. mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
  87. mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
  88. mojo/apps/fileman/migrations/0008_file_category.py +18 -0
  89. mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
  90. mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
  91. mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
  92. mojo/apps/fileman/models/__init__.py +1 -5
  93. mojo/apps/fileman/models/file.py +240 -58
  94. mojo/apps/fileman/models/manager.py +427 -31
  95. mojo/apps/fileman/models/rendition.py +118 -0
  96. mojo/apps/fileman/renderer/__init__.py +111 -0
  97. mojo/apps/fileman/renderer/audio.py +403 -0
  98. mojo/apps/fileman/renderer/base.py +205 -0
  99. mojo/apps/fileman/renderer/document.py +404 -0
  100. mojo/apps/fileman/renderer/image.py +222 -0
  101. mojo/apps/fileman/renderer/utils.py +297 -0
  102. mojo/apps/fileman/renderer/video.py +304 -0
  103. mojo/apps/fileman/rest/__init__.py +1 -18
  104. mojo/apps/fileman/rest/upload.py +22 -32
  105. mojo/apps/fileman/signals.py +58 -0
  106. mojo/apps/fileman/tasks.py +254 -0
  107. mojo/apps/fileman/utils/__init__.py +40 -16
  108. mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
  109. mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
  110. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  111. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  112. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  113. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  114. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  115. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  116. mojo/apps/incident/models/__init__.py +2 -0
  117. mojo/apps/incident/models/event.py +35 -0
  118. mojo/apps/incident/models/history.py +36 -0
  119. mojo/apps/incident/models/incident.py +3 -1
  120. mojo/apps/incident/models/ticket.py +62 -0
  121. mojo/apps/incident/reporter.py +21 -1
  122. mojo/apps/incident/rest/__init__.py +1 -0
  123. mojo/apps/incident/rest/event.py +7 -1
  124. mojo/apps/incident/rest/ticket.py +43 -0
  125. mojo/apps/jobs/__init__.py +489 -0
  126. mojo/apps/jobs/adapters.py +24 -0
  127. mojo/apps/jobs/cli.py +616 -0
  128. mojo/apps/jobs/daemon.py +370 -0
  129. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  130. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  131. mojo/apps/jobs/handlers/__init__.py +5 -0
  132. mojo/apps/jobs/handlers/webhook.py +317 -0
  133. mojo/apps/jobs/job_engine.py +734 -0
  134. mojo/apps/jobs/keys.py +203 -0
  135. mojo/apps/jobs/local_queue.py +363 -0
  136. mojo/apps/jobs/management/__init__.py +3 -0
  137. mojo/apps/jobs/management/commands/__init__.py +3 -0
  138. mojo/apps/jobs/manager.py +1327 -0
  139. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  140. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  141. mojo/apps/jobs/models/__init__.py +6 -0
  142. mojo/apps/jobs/models/job.py +441 -0
  143. mojo/apps/jobs/rest/__init__.py +2 -0
  144. mojo/apps/jobs/rest/control.py +466 -0
  145. mojo/apps/jobs/rest/jobs.py +421 -0
  146. mojo/apps/jobs/scheduler.py +571 -0
  147. mojo/apps/jobs/services/__init__.py +6 -0
  148. mojo/apps/jobs/services/job_actions.py +465 -0
  149. mojo/apps/jobs/settings.py +209 -0
  150. mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
  151. mojo/apps/logit/models/log.py +7 -1
  152. mojo/apps/metrics/__init__.py +8 -1
  153. mojo/apps/metrics/redis_metrics.py +198 -0
  154. mojo/apps/metrics/rest/__init__.py +3 -0
  155. mojo/apps/metrics/rest/categories.py +266 -0
  156. mojo/apps/metrics/rest/helpers.py +48 -0
  157. mojo/apps/metrics/rest/permissions.py +99 -0
  158. mojo/apps/metrics/rest/values.py +277 -0
  159. mojo/apps/metrics/utils.py +19 -2
  160. mojo/decorators/auth.py +6 -1
  161. mojo/decorators/http.py +47 -3
  162. mojo/helpers/aws/__init__.py +45 -0
  163. mojo/helpers/aws/ec2.py +804 -0
  164. mojo/helpers/aws/iam.py +748 -0
  165. mojo/helpers/aws/inbound_email.py +309 -0
  166. mojo/helpers/aws/kms.py +413 -0
  167. mojo/helpers/aws/s3.py +451 -11
  168. mojo/helpers/aws/ses.py +483 -0
  169. mojo/helpers/aws/ses_domain.py +959 -0
  170. mojo/helpers/aws/sns.py +461 -0
  171. mojo/helpers/crypto/__init__.py +1 -1
  172. mojo/helpers/crypto/utils.py +15 -0
  173. mojo/helpers/dates.py +18 -0
  174. mojo/helpers/location/__init__.py +2 -0
  175. mojo/helpers/location/countries.py +262 -0
  176. mojo/helpers/location/geolocation.py +196 -0
  177. mojo/helpers/logit.py +37 -0
  178. mojo/helpers/redis/__init__.py +2 -0
  179. mojo/helpers/redis/adapter.py +606 -0
  180. mojo/helpers/redis/client.py +48 -0
  181. mojo/helpers/redis/pool.py +225 -0
  182. mojo/helpers/request.py +8 -0
  183. mojo/helpers/response.py +14 -2
  184. mojo/helpers/settings/__init__.py +2 -0
  185. mojo/helpers/{settings.py → settings/helper.py} +1 -37
  186. mojo/helpers/settings/parser.py +132 -0
  187. mojo/middleware/auth.py +1 -1
  188. mojo/middleware/cors.py +40 -0
  189. mojo/middleware/logging.py +131 -12
  190. mojo/middleware/mojo.py +10 -0
  191. mojo/models/rest.py +494 -65
  192. mojo/models/secrets.py +98 -3
  193. mojo/serializers/__init__.py +106 -0
  194. mojo/serializers/core/__init__.py +90 -0
  195. mojo/serializers/core/cache/__init__.py +121 -0
  196. mojo/serializers/core/cache/backends.py +518 -0
  197. mojo/serializers/core/cache/base.py +102 -0
  198. mojo/serializers/core/cache/disabled.py +181 -0
  199. mojo/serializers/core/cache/memory.py +287 -0
  200. mojo/serializers/core/cache/redis.py +533 -0
  201. mojo/serializers/core/cache/utils.py +454 -0
  202. mojo/serializers/core/manager.py +550 -0
  203. mojo/serializers/core/serializer.py +475 -0
  204. mojo/serializers/examples/settings.py +322 -0
  205. mojo/serializers/formats/csv.py +393 -0
  206. mojo/serializers/formats/localizers.py +509 -0
  207. mojo/serializers/{models.py → simple.py} +38 -15
  208. mojo/serializers/suggested_improvements.md +388 -0
  209. testit/client.py +1 -1
  210. testit/helpers.py +35 -4
  211. testit/runner.py +23 -6
  212. django_nativemojo-0.1.10.dist-info/METADATA +0 -96
  213. django_nativemojo-0.1.10.dist-info/RECORD +0 -194
  214. mojo/apps/metrics/rest/db.py +0 -0
  215. mojo/apps/notify/README.md +0 -91
  216. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  217. mojo/apps/notify/admin.py +0 -52
  218. mojo/apps/notify/handlers/example_handlers.py +0 -516
  219. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  220. mojo/apps/notify/handlers/ses/bounce.py +0 -0
  221. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  222. mojo/apps/notify/handlers/ses/message.py +0 -86
  223. mojo/apps/notify/management/commands/__init__.py +0 -1
  224. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  225. mojo/apps/notify/mod +0 -0
  226. mojo/apps/notify/models/__init__.py +0 -12
  227. mojo/apps/notify/models/account.py +0 -128
  228. mojo/apps/notify/models/attachment.py +0 -24
  229. mojo/apps/notify/models/bounce.py +0 -68
  230. mojo/apps/notify/models/complaint.py +0 -40
  231. mojo/apps/notify/models/inbox.py +0 -113
  232. mojo/apps/notify/models/inbox_message.py +0 -173
  233. mojo/apps/notify/models/outbox.py +0 -129
  234. mojo/apps/notify/models/outbox_message.py +0 -288
  235. mojo/apps/notify/models/template.py +0 -30
  236. mojo/apps/notify/providers/aws.py +0 -73
  237. mojo/apps/notify/rest/ses.py +0 -0
  238. mojo/apps/notify/utils/__init__.py +0 -2
  239. mojo/apps/notify/utils/notifications.py +0 -404
  240. mojo/apps/notify/utils/parsing.py +0 -202
  241. mojo/apps/notify/utils/render.py +0 -144
  242. mojo/apps/tasks/README.md +0 -118
  243. mojo/apps/tasks/__init__.py +0 -11
  244. mojo/apps/tasks/manager.py +0 -489
  245. mojo/apps/tasks/rest/__init__.py +0 -2
  246. mojo/apps/tasks/rest/hooks.py +0 -0
  247. mojo/apps/tasks/rest/tasks.py +0 -62
  248. mojo/apps/tasks/runner.py +0 -174
  249. mojo/apps/tasks/tq_handlers.py +0 -14
  250. mojo/helpers/aws/setup_email.py +0 -0
  251. mojo/helpers/redis.py +0 -10
  252. mojo/models/meta.py +0 -262
  253. mojo/ws4redis/README.md +0 -174
  254. mojo/ws4redis/__init__.py +0 -2
  255. mojo/ws4redis/client.py +0 -283
  256. mojo/ws4redis/connection.py +0 -327
  257. mojo/ws4redis/exceptions.py +0 -32
  258. mojo/ws4redis/redis.py +0 -183
  259. mojo/ws4redis/servers/base.py +0 -86
  260. mojo/ws4redis/servers/django.py +0 -171
  261. mojo/ws4redis/servers/uwsgi.py +0 -63
  262. mojo/ws4redis/settings.py +0 -45
  263. mojo/ws4redis/utf8validator.py +0 -128
  264. mojo/ws4redis/websocket.py +0 -403
  265. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  266. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  267. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  268. /mojo/apps/{notify → aws}/__init__.py +0 -0
  269. /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
  270. /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
  271. /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
  272. /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
  273. /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
  274. /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
  275. /mojo/{serializers → rest}/openapi.py +0 -0
  276. /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)
@@ -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
+ ]