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,288 @@
1
+ from django.db import models
2
+ from django.core.exceptions import ValidationError
3
+ from mojo.models import MojoModel
4
+ from typing import Optional, Union, Sequence, Dict, Any
5
+
6
+
7
+ class Mailbox(models.Model, MojoModel):
8
+ """
9
+ Mailbox
10
+
11
+ Minimal model representing a single email address (mailbox) within a verified EmailDomain.
12
+ Sending and receiving policies are configured per mailbox. When inbound messages arrive
13
+ (domain-level catch-all), they are routed to the matching mailbox by recipient address and
14
+ optionally dispatched to an async handler.
15
+
16
+ Notes:
17
+ - `email` is the full email address (e.g., support@example.com) and is unique.
18
+ - `domain` references the owning EmailDomain (e.g., example.com).
19
+ - `allow_inbound` and `allow_outbound` control behavior for this mailbox.
20
+ - `async_handler` is a dotted path "package.module:function" used by the Tasks system.
21
+ - `metadata` allows flexible extension without schema churn.
22
+ """
23
+
24
+ created = models.DateTimeField(auto_now_add=True, editable=False)
25
+ modified = models.DateTimeField(auto_now=True, db_index=True)
26
+
27
+ domain = models.ForeignKey(
28
+ "EmailDomain",
29
+ related_name="mailboxes",
30
+ on_delete=models.CASCADE,
31
+ help_text="Owning email domain (SES identity)"
32
+ )
33
+
34
+ email = models.EmailField(
35
+ unique=True,
36
+ db_index=True,
37
+ help_text="Full email address for this mailbox (e.g., support@example.com)"
38
+ )
39
+
40
+ allow_inbound = models.BooleanField(
41
+ default=True,
42
+ help_text="If true, inbound messages addressed to this mailbox will be processed"
43
+ )
44
+ allow_outbound = models.BooleanField(
45
+ default=True,
46
+ help_text="If true, outbound messages can be sent from this mailbox"
47
+ )
48
+
49
+ async_handler = models.CharField(
50
+ max_length=255,
51
+ null=True,
52
+ blank=True,
53
+ help_text="Dotted path to async handler: 'package.module:function'"
54
+ )
55
+
56
+ metadata = models.JSONField(default=dict, blank=True)
57
+
58
+ is_system_default = models.BooleanField(
59
+ default=False,
60
+ db_index=True,
61
+ help_text="System-wide default mailbox (only one allowed)"
62
+ )
63
+
64
+ is_domain_default = models.BooleanField(
65
+ default=False,
66
+ db_index=True,
67
+ help_text="Default mailbox for this domain (one per domain)"
68
+ )
69
+
70
+ class Meta:
71
+ db_table = "aws_mailbox"
72
+ indexes = [
73
+ models.Index(fields=["modified"]),
74
+ models.Index(fields=["email"]),
75
+ models.Index(fields=["is_system_default"]),
76
+ models.Index(fields=["is_domain_default", "domain"]),
77
+ ]
78
+ ordering = ["email"]
79
+
80
+ class RestMeta:
81
+ VIEW_PERMS = ["manage_aws"]
82
+ SAVE_PERMS = ["manage_aws"]
83
+ DELETE_PERMS = ["manage_aws"]
84
+ SEARCH_FIELDS = ["email"]
85
+ GRAPHS = {
86
+ "basic": {
87
+ "fields": [
88
+ "id",
89
+ "email",
90
+ "domain",
91
+ "allow_inbound",
92
+ "allow_outbound",
93
+ "is_system_default",
94
+ "is_domain_default",
95
+ ]
96
+ },
97
+ "default": {
98
+ "fields": [
99
+ "id",
100
+ "email",
101
+ "domain",
102
+ "allow_inbound",
103
+ "allow_outbound",
104
+ "async_handler",
105
+ "metadata",
106
+ "is_system_default",
107
+ "is_domain_default",
108
+ "created",
109
+ "modified",
110
+ ],
111
+ "graphs": {
112
+ "domain": "basic"
113
+ }
114
+ },
115
+ }
116
+
117
+ def __str__(self) -> str:
118
+ return self.email
119
+
120
+ def clean(self):
121
+ """
122
+ Ensure the mailbox email belongs to the associated domain (simple sanity check).
123
+ """
124
+ super().clean()
125
+ if self.domain and self.email:
126
+ domain_name = f"@{self.domain.name.lower()}"
127
+ if not self.email.lower().endswith(domain_name):
128
+ raise ValidationError(
129
+ {"email": f"Email must belong to domain '{self.domain.name}'"}
130
+ )
131
+
132
+ def on_rest_saved(self, changed_fields, created):
133
+ """Handle default field uniqueness after REST save"""
134
+
135
+ # Clear other system defaults if this was just set as system default
136
+ if 'is_system_default' in changed_fields and self.is_system_default:
137
+ Mailbox.objects.exclude(pk=self.pk).update(is_system_default=False)
138
+
139
+ # Clear other domain defaults if this was just set as domain default
140
+ if 'is_domain_default' in changed_fields and self.is_domain_default:
141
+ Mailbox.objects.filter(domain=self.domain).exclude(pk=self.pk).update(is_domain_default=False)
142
+
143
+ super().on_rest_saved(changed_fields, created)
144
+
145
+ @classmethod
146
+ def get_system_default(cls) -> Optional['Mailbox']:
147
+ """Get the system-wide default mailbox"""
148
+ return cls.objects.filter(is_system_default=True).first()
149
+
150
+ @classmethod
151
+ def get_domain_default(cls, domain: Union[str, 'EmailDomain']) -> Optional['Mailbox']:
152
+ """Get the default mailbox for a specific domain
153
+
154
+ Args:
155
+ domain: Either a domain name string or EmailDomain instance
156
+ """
157
+ if isinstance(domain, str):
158
+ return cls.objects.filter(domain__name__iexact=domain, is_domain_default=True).first()
159
+ else:
160
+ return cls.objects.filter(domain=domain, is_domain_default=True).first()
161
+
162
+ @classmethod
163
+ def get_default(cls, domain: Optional[Union[str, 'EmailDomain']] = None, prefer_domain: bool = True) -> Optional['Mailbox']:
164
+ """Smart default: try domain default first (if domain provided), then fall back to system default
165
+
166
+ Args:
167
+ domain: Optional domain to look for domain-specific default
168
+ prefer_domain: If True (default), prefer domain default over system default
169
+ """
170
+ if domain and prefer_domain:
171
+ domain_default = cls.get_domain_default(domain)
172
+ if domain_default:
173
+ return domain_default
174
+
175
+ return cls.get_system_default()
176
+
177
+ def send_email(
178
+ self,
179
+ to: Union[str, Sequence[str]],
180
+ subject: Optional[str] = None,
181
+ body_text: Optional[str] = None,
182
+ body_html: Optional[str] = None,
183
+ cc: Optional[Union[str, Sequence[str]]] = None,
184
+ bcc: Optional[Union[str, Sequence[str]]] = None,
185
+ reply_to: Optional[Union[str, Sequence[str]]] = None,
186
+ **kwargs
187
+ ) -> 'SentMessage':
188
+ """Send plain email from this mailbox
189
+
190
+ Args:
191
+ to: One or more recipient addresses
192
+ subject: Email subject
193
+ body_text: Optional plain text body
194
+ body_html: Optional HTML body
195
+ cc, bcc, reply_to: Optional addressing
196
+ **kwargs: Additional arguments passed to email service (allow_unverified, aws_access_key, etc.)
197
+
198
+ Returns:
199
+ SentMessage instance
200
+
201
+ Raises:
202
+ OutboundNotAllowed: If this mailbox has allow_outbound=False
203
+ """
204
+ from mojo.apps.aws.services import email as email_service
205
+
206
+ if not self.allow_outbound:
207
+ raise email_service.OutboundNotAllowed(f"Outbound sending is disabled for mailbox {self.email}")
208
+
209
+ aws_access_key = self.domain.aws_key
210
+ aws_secret_key = self.domain.aws_secret
211
+ aws_region = self.domain.aws_region
212
+
213
+ return email_service.send_email(
214
+ from_email=self.email,
215
+ to=to,
216
+ subject=subject,
217
+ body_text=body_text,
218
+ body_html=body_html,
219
+ cc=cc,
220
+ bcc=bcc,
221
+ reply_to=reply_to,
222
+ aws_access_key=aws_access_key,
223
+ aws_secret_key=aws_secret_key,
224
+ region=aws_region,
225
+ **kwargs
226
+ )
227
+
228
+ def send_template_email(
229
+ self,
230
+ to: Union[str, Sequence[str]],
231
+ template_name: str,
232
+ context: Optional[Dict[str, Any]] = None,
233
+ cc: Optional[Union[str, Sequence[str]]] = None,
234
+ bcc: Optional[Union[str, Sequence[str]]] = None,
235
+ reply_to: Optional[Union[str, Sequence[str]]] = None,
236
+ **kwargs
237
+ ) -> 'SentMessage':
238
+ """Send email using DB EmailTemplate
239
+
240
+ Args:
241
+ to: One or more recipient addresses
242
+ template_name: Name of the EmailTemplate in database
243
+ context: Template context variables
244
+ cc, bcc, reply_to: Optional addressing
245
+ **kwargs: Additional arguments passed to email service (allow_unverified, aws_access_key, etc.)
246
+
247
+ Returns:
248
+ SentMessage instance
249
+
250
+ Raises:
251
+ OutboundNotAllowed: If this mailbox has allow_outbound=False
252
+ ValueError: If template not found
253
+
254
+ Note:
255
+ Automatically checks for domain-specific template overrides.
256
+ If "{domain.name}.{template_name}" exists, it will be used instead of the base template.
257
+ """
258
+ from mojo.apps.aws.services import email as email_service
259
+ from mojo.apps.aws.models import EmailTemplate
260
+
261
+ if not self.allow_outbound:
262
+ raise email_service.OutboundNotAllowed(f"Outbound sending is disabled for mailbox {self.email}")
263
+
264
+ # Check for domain-specific template override
265
+ final_template_name = template_name
266
+ if self.domain and self.domain.name:
267
+ domain_template_name = f"{self.domain.name}.{template_name}"
268
+ # Check if domain-specific template exists
269
+ if EmailTemplate.objects.filter(name=domain_template_name).exists():
270
+ final_template_name = domain_template_name
271
+
272
+ aws_access_key = self.domain.aws_access_key
273
+ aws_secret_key = self.domain.aws_secret_key
274
+ aws_region = self.domain.aws_region
275
+
276
+ return email_service.send_with_template(
277
+ from_email=self.email,
278
+ to=to,
279
+ template_name=final_template_name,
280
+ context=context,
281
+ cc=cc,
282
+ bcc=bcc,
283
+ reply_to=reply_to,
284
+ aws_access_key=aws_access_key,
285
+ aws_secret_key=aws_secret_key,
286
+ region=aws_region,
287
+ **kwargs
288
+ )
@@ -0,0 +1,175 @@
1
+ from django.db import models
2
+ from mojo.models import MojoModel
3
+
4
+
5
+ class SentMessage(models.Model, MojoModel):
6
+ """
7
+ SentMessage
8
+
9
+ Represents an outbound email sent via AWS SES using a specific Mailbox (email address).
10
+ Tracks SES MessageId and delivery lifecycle (delivery, bounce, complaint) updated via SNS webhooks.
11
+
12
+ Notes:
13
+ - `mailbox` identifies the sending address; sending is only allowed when mailbox.allow_outbound is True.
14
+ - `ses_message_id` is populated after a successful SES send API call.
15
+ - `to_addresses`, `cc_addresses`, `bcc_addresses` are stored as JSON arrays.
16
+ - `template_name` and `template_context` support simple templated sending (EmailTemplate model can be added later).
17
+ - `status` reflects the current delivery state; `status_reason` stores detailed info (bounce/complaint payloads, errors).
18
+ """
19
+
20
+ STATUS_QUEUED = "queued"
21
+ STATUS_SENDING = "sending"
22
+ STATUS_DELIVERED = "delivered"
23
+ STATUS_BOUNCED = "bounced"
24
+ STATUS_COMPLAINED = "complained"
25
+ STATUS_FAILED = "failed"
26
+ STATUS_UNKNOWN = "unknown"
27
+
28
+ STATUS_CHOICES = [
29
+ (STATUS_QUEUED, "Queued"),
30
+ (STATUS_SENDING, "Sending"),
31
+ (STATUS_DELIVERED, "Delivered"),
32
+ (STATUS_BOUNCED, "Bounced"),
33
+ (STATUS_COMPLAINED, "Complained"),
34
+ (STATUS_FAILED, "Failed"),
35
+ (STATUS_UNKNOWN, "Unknown"),
36
+ ]
37
+
38
+ created = models.DateTimeField(auto_now_add=True, editable=False)
39
+ modified = models.DateTimeField(auto_now=True, db_index=True)
40
+
41
+ mailbox = models.ForeignKey(
42
+ "aws.Mailbox",
43
+ related_name="sent_messages",
44
+ on_delete=models.CASCADE,
45
+ help_text="Mailbox used as the sender (envelope MAIL FROM = mailbox.email)"
46
+ )
47
+
48
+ ses_message_id = models.CharField(
49
+ max_length=255,
50
+ null=True,
51
+ blank=True,
52
+ db_index=True,
53
+ help_text="AWS SES MessageId returned after a successful send"
54
+ )
55
+
56
+ # Recipients
57
+ to_addresses = models.JSONField(
58
+ default=list,
59
+ blank=True,
60
+ help_text="List of recipient addresses (To)"
61
+ )
62
+ cc_addresses = models.JSONField(
63
+ default=list,
64
+ blank=True,
65
+ help_text="List of recipient addresses (Cc)"
66
+ )
67
+ bcc_addresses = models.JSONField(
68
+ default=list,
69
+ blank=True,
70
+ help_text="List of recipient addresses (Bcc)"
71
+ )
72
+
73
+ # Content
74
+ subject = models.CharField(
75
+ max_length=512,
76
+ null=True,
77
+ blank=True,
78
+ help_text="Email subject"
79
+ )
80
+ body_text = models.TextField(
81
+ null=True,
82
+ blank=True,
83
+ help_text="Plain text body"
84
+ )
85
+ body_html = models.TextField(
86
+ null=True,
87
+ blank=True,
88
+ help_text="HTML body"
89
+ )
90
+
91
+ # Template support (simple; FK can be added later)
92
+ template_name = models.CharField(
93
+ max_length=255,
94
+ null=True,
95
+ blank=True,
96
+ help_text="Optional EmailTemplate name used to render this message"
97
+ )
98
+ template_context = models.JSONField(
99
+ default=dict,
100
+ blank=True,
101
+ help_text="Context used when rendering a template"
102
+ )
103
+
104
+ # Delivery status
105
+ status = models.CharField(
106
+ max_length=32,
107
+ choices=STATUS_CHOICES,
108
+ default=STATUS_QUEUED,
109
+ db_index=True,
110
+ help_text="Current delivery status"
111
+ )
112
+ status_reason = models.TextField(
113
+ null=True,
114
+ blank=True,
115
+ help_text="Details or raw payload for bounces/complaints/errors"
116
+ )
117
+
118
+ metadata = models.JSONField(
119
+ default=dict,
120
+ blank=True,
121
+ help_text="Arbitrary metadata for downstream processing/auditing"
122
+ )
123
+
124
+ class Meta:
125
+ db_table = "aws_sent_message"
126
+ indexes = [
127
+ models.Index(fields=["modified"]),
128
+ models.Index(fields=["status"]),
129
+ models.Index(fields=["ses_message_id"]),
130
+ ]
131
+ ordering = ["-created", "id"]
132
+
133
+ class RestMeta:
134
+ VIEW_PERMS = ["manage_aws"]
135
+ SAVE_PERMS = ["manage_aws"]
136
+ DELETE_PERMS = ["manage_aws"]
137
+ SEARCH_FIELDS = ["subject", "ses_message_id"]
138
+ GRAPHS = {
139
+ "basic": {
140
+ "fields": [
141
+ "id",
142
+ "mailbox",
143
+ "ses_message_id",
144
+ "subject",
145
+ "to_addresses",
146
+ "status",
147
+ "created",
148
+ ],
149
+ "graphs": {"mailbox": "basic"}
150
+ },
151
+ "default": {
152
+ "fields": [
153
+ "id",
154
+ "mailbox",
155
+ "ses_message_id",
156
+ "to_addresses",
157
+ "cc_addresses",
158
+ "bcc_addresses",
159
+ "subject",
160
+ "body_text",
161
+ "body_html",
162
+ "template_name",
163
+ "template_context",
164
+ "status",
165
+ "status_reason",
166
+ "metadata",
167
+ "created",
168
+ "modified",
169
+ ],
170
+ "graphs": {"mailbox": "basic"}
171
+ },
172
+ }
173
+
174
+ def __str__(self) -> str:
175
+ return self.subject or self.ses_message_id or f"SentMessage {self.pk}"
@@ -0,0 +1,7 @@
1
+ from .s3 import *
2
+ from .email import *
3
+ from .email_ops import *
4
+ from .messages import *
5
+ from .sns import *
6
+ from .send import *
7
+ from .templates import *
@@ -0,0 +1,33 @@
1
+ from mojo import decorators as md
2
+ from mojo.apps.aws.models import EmailDomain, Mailbox
3
+
4
+
5
+ """
6
+ AWS Email REST Handlers
7
+
8
+ Endpoints:
9
+ - Domain CRUD:
10
+ - GET/POST/PUT/DELETE /aws/email/domain
11
+ - GET/POST/PUT/DELETE /aws/email/domain/<int:pk>
12
+
13
+ - Mailbox CRUD:
14
+ - GET/POST/PUT/DELETE /aws/email/mailbox
15
+ - GET/POST/PUT/DELETE /aws/email/mailbox/<int:pk>
16
+
17
+ These handlers delegate to the models' on_rest_request, which uses RestMeta for
18
+ permission checks, graphs, and default CRUD behavior.
19
+ """
20
+
21
+
22
+ @md.URL('email/domain')
23
+ @md.URL('email/domain/<int:pk>')
24
+ @md.requires_perms("manage_aws")
25
+ def on_email_domain(request, pk=None):
26
+ return EmailDomain.on_rest_request(request, pk)
27
+
28
+
29
+ @md.URL('email/mailbox')
30
+ @md.URL('email/mailbox/<int:pk>')
31
+ @md.requires_perms("manage_aws")
32
+ def on_mailbox(request, pk=None):
33
+ return Mailbox.on_rest_request(request, pk)
@@ -0,0 +1,183 @@
1
+ from typing import Dict, Any
2
+
3
+ from mojo import decorators as md
4
+ from mojo import JsonResponse
5
+ from mojo.helpers import logit
6
+
7
+ # Use the new email_ops service
8
+ from mojo.apps.aws.services.email_ops import (
9
+ onboard_email_domain,
10
+ audit_email_domain,
11
+ reconcile_email_domain,
12
+ generate_audit_recommendations,
13
+ EmailDomainNotFound,
14
+ InvalidConfiguration,
15
+ )
16
+
17
+ logger = logit.get_logger("email", "email.log")
18
+
19
+
20
+ def _get_json(request) -> Dict[str, Any]:
21
+ return getattr(request, "DATA", {}) or {}
22
+
23
+
24
+ @md.URL("email/domain/<int:pk>/onboard")
25
+ @md.requires_perms("manage_aws")
26
+ def on_email_domain_onboard(request, pk: int):
27
+ """
28
+ Kick off domain onboarding:
29
+ - Request SES domain verification + DKIM tokens
30
+ - Compute required DNS records (manual or automated via GoDaddy if requested)
31
+ - Ensure SNS topics + notification mappings
32
+ - Optionally enable receiving (catch-all → S3 + SNS)
33
+ - Optionally enable MAIL FROM (returns DNS to add)
34
+ """
35
+ if request.method != "POST":
36
+ return JsonResponse({"error": "Method not allowed"}, status=405)
37
+
38
+ payload = _get_json(request)
39
+
40
+ try:
41
+ result = onboard_email_domain(
42
+ domain_pk=pk,
43
+ region=payload.get("region"),
44
+ receiving_enabled=payload.get("receiving_enabled"),
45
+ s3_bucket=payload.get("s3_inbound_bucket"),
46
+ s3_prefix=payload.get("s3_inbound_prefix"),
47
+ ensure_mail_from=bool(payload.get("ensure_mail_from", False)),
48
+ mail_from_subdomain=payload.get("mail_from_subdomain", "feedback"),
49
+ dns_mode=payload.get("dns_mode"),
50
+ endpoints=payload.get("endpoints") or {
51
+ "bounce": payload.get("bounce_endpoint"),
52
+ "complaint": payload.get("complaint_endpoint"),
53
+ "delivery": payload.get("delivery_endpoint"),
54
+ "inbound": payload.get("inbound_endpoint"),
55
+ },
56
+ access_key=payload.get("aws_access_key"),
57
+ secret_key=payload.get("aws_secret_key"),
58
+ godaddy_key=payload.get("godaddy_key"),
59
+ godaddy_secret=payload.get("godaddy_secret"),
60
+ )
61
+
62
+ return JsonResponse({
63
+ "status": True,
64
+ "data": {
65
+ "domain": result.domain,
66
+ "region": result.region,
67
+ "dns_records": result.dns_records,
68
+ "dkim_tokens": result.dkim_tokens,
69
+ "topic_arns": result.topic_arns,
70
+ "receipt_rule": result.receipt_rule,
71
+ "rule_set": result.rule_set,
72
+ "notes": result.notes,
73
+ }
74
+ })
75
+ except EmailDomainNotFound:
76
+ return JsonResponse({"error": "EmailDomain not found", "code": 404}, status=404)
77
+ except InvalidConfiguration as e:
78
+ return JsonResponse({"error": str(e)}, status=400)
79
+ except Exception as e:
80
+ logger.error(f"onboard error for domain pk={pk}: {e}")
81
+ return JsonResponse({"error": str(e)}, status=500)
82
+
83
+
84
+ @md.URL("email/domain/<int:pk>/audit")
85
+ @md.requires_perms("manage_aws")
86
+ def on_email_domain_audit(request, pk: int):
87
+ """
88
+ Audit SES/SNS/S3 configuration for the domain and return a drift report.
89
+ Uses the model configuration to compute desired receiving.
90
+ """
91
+ if request.method not in ("GET", "POST"):
92
+ return JsonResponse({"error": "Method not allowed"}, status=405)
93
+
94
+ payload = _get_json(request) if request.method == "POST" else {}
95
+
96
+ try:
97
+ result = audit_email_domain(
98
+ domain_pk=pk,
99
+ region=payload.get("region"),
100
+ access_key=payload.get("aws_access_key"),
101
+ secret_key=payload.get("aws_secret_key"),
102
+ rule_set=payload.get("rule_set"),
103
+ rule_name=payload.get("rule_name"),
104
+ )
105
+
106
+ return JsonResponse({
107
+ "status": True,
108
+ "data": {
109
+ "domain": result.domain,
110
+ "region": result.region,
111
+ "status": result.status,
112
+ "audit_pass": result.audit_pass,
113
+ "checks": result.checks,
114
+ "items": [
115
+ {
116
+ "resource": it.resource,
117
+ "desired": it.desired,
118
+ "current": it.current,
119
+ "status": it.status
120
+ } for it in result.items
121
+ ],
122
+ "recommendations": generate_audit_recommendations(result.report)
123
+ }
124
+ })
125
+ except EmailDomainNotFound:
126
+ return JsonResponse({"error": "EmailDomain not found", "code": 404}, status=404)
127
+ except Exception as e:
128
+ logger.error(f"audit error for domain pk={pk}: {e}")
129
+ return JsonResponse({"error": str(e)}, status=500)
130
+
131
+
132
+ @md.URL("email/domain/<int:pk>/reconcile")
133
+ @md.requires_perms("manage_aws")
134
+ def on_email_domain_reconcile(request, pk: int):
135
+ """
136
+ Attempt to reconcile SES/SNS for the domain:
137
+ - Ensure SNS topics and notification mappings
138
+ - Ensure receiving catch-all rule (if receiving_enabled)
139
+ - Optionally configure MAIL FROM
140
+ Does not modify DNS; use onboarding + DNS mode or apply manually.
141
+ """
142
+ if request.method != "POST":
143
+ return JsonResponse({"error": "Method not allowed"}, status=405)
144
+
145
+ payload = _get_json(request)
146
+
147
+ try:
148
+ result = reconcile_email_domain(
149
+ domain_pk=pk,
150
+ region=payload.get("region"),
151
+ receiving_enabled=payload.get("receiving_enabled"),
152
+ s3_bucket=payload.get("s3_inbound_bucket"),
153
+ s3_prefix=payload.get("s3_inbound_prefix"),
154
+ ensure_mail_from=bool(payload.get("ensure_mail_from", False)),
155
+ mail_from_subdomain=payload.get("mail_from_subdomain", "feedback"),
156
+ endpoints=payload.get("endpoints") or {
157
+ "bounce": payload.get("bounce_endpoint"),
158
+ "complaint": payload.get("complaint_endpoint"),
159
+ "delivery": payload.get("delivery_endpoint"),
160
+ "inbound": payload.get("inbound_endpoint"),
161
+ },
162
+ access_key=payload.get("aws_access_key"),
163
+ secret_key=payload.get("aws_secret_key"),
164
+ )
165
+
166
+ return JsonResponse({
167
+ "status": True,
168
+ "data": {
169
+ "domain": result.domain,
170
+ "region": result.region,
171
+ "topic_arns": result.topic_arns,
172
+ "receipt_rule": result.receipt_rule,
173
+ "rule_set": result.rule_set,
174
+ "notes": result.notes,
175
+ }
176
+ })
177
+ except EmailDomainNotFound:
178
+ return JsonResponse({"error": "EmailDomain not found", "code": 404}, status=404)
179
+ except InvalidConfiguration as e:
180
+ return JsonResponse({"error": str(e)}, status=400)
181
+ except Exception as e:
182
+ logger.error(f"reconcile error for domain pk={pk}: {e}")
183
+ return JsonResponse({"error": str(e)}, status=500)