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.
Files changed (221) hide show
  1. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
  2. django_nativemojo-0.1.17.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/commands/serializer_admin.py +121 -1
  5. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  6. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  7. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  8. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  9. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  10. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  11. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  12. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  13. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  14. mojo/apps/account/models/__init__.py +2 -0
  15. mojo/apps/account/models/device.py +279 -0
  16. mojo/apps/account/models/group.py +294 -8
  17. mojo/apps/account/models/member.py +14 -1
  18. mojo/apps/account/models/push/__init__.py +4 -0
  19. mojo/apps/account/models/push/config.py +112 -0
  20. mojo/apps/account/models/push/delivery.py +93 -0
  21. mojo/apps/account/models/push/device.py +66 -0
  22. mojo/apps/account/models/push/template.py +99 -0
  23. mojo/apps/account/models/user.py +190 -17
  24. mojo/apps/account/rest/__init__.py +2 -0
  25. mojo/apps/account/rest/device.py +39 -0
  26. mojo/apps/account/rest/group.py +8 -0
  27. mojo/apps/account/rest/push.py +187 -0
  28. mojo/apps/account/rest/user.py +95 -5
  29. mojo/apps/account/services/__init__.py +1 -0
  30. mojo/apps/account/services/push.py +363 -0
  31. mojo/apps/aws/migrations/0001_initial.py +206 -0
  32. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  33. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  34. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  35. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  36. mojo/apps/aws/models/__init__.py +19 -0
  37. mojo/apps/aws/models/email_attachment.py +99 -0
  38. mojo/apps/aws/models/email_domain.py +218 -0
  39. mojo/apps/aws/models/email_template.py +132 -0
  40. mojo/apps/aws/models/incoming_email.py +197 -0
  41. mojo/apps/aws/models/mailbox.py +288 -0
  42. mojo/apps/aws/models/sent_message.py +175 -0
  43. mojo/apps/aws/rest/__init__.py +6 -0
  44. mojo/apps/aws/rest/email.py +33 -0
  45. mojo/apps/aws/rest/email_ops.py +183 -0
  46. mojo/apps/aws/rest/messages.py +32 -0
  47. mojo/apps/aws/rest/send.py +101 -0
  48. mojo/apps/aws/rest/sns.py +403 -0
  49. mojo/apps/aws/rest/templates.py +19 -0
  50. mojo/apps/aws/services/__init__.py +32 -0
  51. mojo/apps/aws/services/email.py +390 -0
  52. mojo/apps/aws/services/email_ops.py +548 -0
  53. mojo/apps/docit/__init__.py +6 -0
  54. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  55. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  56. mojo/apps/docit/migrations/0001_initial.py +113 -0
  57. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  58. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  59. mojo/apps/docit/models/__init__.py +17 -0
  60. mojo/apps/docit/models/asset.py +231 -0
  61. mojo/apps/docit/models/book.py +227 -0
  62. mojo/apps/docit/models/page.py +319 -0
  63. mojo/apps/docit/models/page_revision.py +203 -0
  64. mojo/apps/docit/rest/__init__.py +10 -0
  65. mojo/apps/docit/rest/asset.py +17 -0
  66. mojo/apps/docit/rest/book.py +22 -0
  67. mojo/apps/docit/rest/page.py +22 -0
  68. mojo/apps/docit/rest/page_revision.py +17 -0
  69. mojo/apps/docit/services/__init__.py +11 -0
  70. mojo/apps/docit/services/docit.py +315 -0
  71. mojo/apps/docit/services/markdown.py +44 -0
  72. mojo/apps/fileman/backends/s3.py +209 -0
  73. mojo/apps/fileman/models/file.py +45 -9
  74. mojo/apps/fileman/models/manager.py +269 -3
  75. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  76. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  77. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  78. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  79. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  80. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  81. mojo/apps/incident/models/__init__.py +1 -0
  82. mojo/apps/incident/models/event.py +35 -0
  83. mojo/apps/incident/models/incident.py +2 -0
  84. mojo/apps/incident/models/ticket.py +62 -0
  85. mojo/apps/incident/reporter.py +21 -3
  86. mojo/apps/incident/rest/__init__.py +1 -0
  87. mojo/apps/incident/rest/ticket.py +43 -0
  88. mojo/apps/jobs/__init__.py +489 -0
  89. mojo/apps/jobs/adapters.py +24 -0
  90. mojo/apps/jobs/cli.py +616 -0
  91. mojo/apps/jobs/daemon.py +370 -0
  92. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  93. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  94. mojo/apps/jobs/handlers/__init__.py +5 -0
  95. mojo/apps/jobs/handlers/webhook.py +317 -0
  96. mojo/apps/jobs/job_engine.py +734 -0
  97. mojo/apps/jobs/keys.py +203 -0
  98. mojo/apps/jobs/local_queue.py +363 -0
  99. mojo/apps/jobs/management/__init__.py +3 -0
  100. mojo/apps/jobs/management/commands/__init__.py +3 -0
  101. mojo/apps/jobs/manager.py +1327 -0
  102. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  103. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  104. mojo/apps/jobs/models/__init__.py +6 -0
  105. mojo/apps/jobs/models/job.py +441 -0
  106. mojo/apps/jobs/rest/__init__.py +2 -0
  107. mojo/apps/jobs/rest/control.py +466 -0
  108. mojo/apps/jobs/rest/jobs.py +421 -0
  109. mojo/apps/jobs/scheduler.py +571 -0
  110. mojo/apps/jobs/services/__init__.py +6 -0
  111. mojo/apps/jobs/services/job_actions.py +465 -0
  112. mojo/apps/jobs/settings.py +209 -0
  113. mojo/apps/logit/models/log.py +3 -0
  114. mojo/apps/metrics/__init__.py +8 -1
  115. mojo/apps/metrics/redis_metrics.py +198 -0
  116. mojo/apps/metrics/rest/__init__.py +3 -0
  117. mojo/apps/metrics/rest/categories.py +266 -0
  118. mojo/apps/metrics/rest/helpers.py +48 -0
  119. mojo/apps/metrics/rest/permissions.py +99 -0
  120. mojo/apps/metrics/rest/values.py +277 -0
  121. mojo/apps/metrics/utils.py +17 -0
  122. mojo/decorators/http.py +40 -1
  123. mojo/helpers/aws/__init__.py +11 -7
  124. mojo/helpers/aws/inbound_email.py +309 -0
  125. mojo/helpers/aws/kms.py +413 -0
  126. mojo/helpers/aws/ses_domain.py +959 -0
  127. mojo/helpers/crypto/__init__.py +1 -1
  128. mojo/helpers/crypto/utils.py +15 -0
  129. mojo/helpers/location/__init__.py +2 -0
  130. mojo/helpers/location/countries.py +262 -0
  131. mojo/helpers/location/geolocation.py +196 -0
  132. mojo/helpers/logit.py +37 -0
  133. mojo/helpers/redis/__init__.py +2 -0
  134. mojo/helpers/redis/adapter.py +606 -0
  135. mojo/helpers/redis/client.py +48 -0
  136. mojo/helpers/redis/pool.py +225 -0
  137. mojo/helpers/request.py +8 -0
  138. mojo/helpers/response.py +8 -0
  139. mojo/middleware/auth.py +1 -1
  140. mojo/middleware/cors.py +40 -0
  141. mojo/middleware/logging.py +131 -12
  142. mojo/middleware/mojo.py +5 -0
  143. mojo/models/rest.py +271 -57
  144. mojo/models/secrets.py +86 -0
  145. mojo/serializers/__init__.py +16 -10
  146. mojo/serializers/core/__init__.py +90 -0
  147. mojo/serializers/core/cache/__init__.py +121 -0
  148. mojo/serializers/core/cache/backends.py +518 -0
  149. mojo/serializers/core/cache/base.py +102 -0
  150. mojo/serializers/core/cache/disabled.py +181 -0
  151. mojo/serializers/core/cache/memory.py +287 -0
  152. mojo/serializers/core/cache/redis.py +533 -0
  153. mojo/serializers/core/cache/utils.py +454 -0
  154. mojo/serializers/{manager.py → core/manager.py} +53 -4
  155. mojo/serializers/core/serializer.py +475 -0
  156. mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
  157. mojo/serializers/suggested_improvements.md +388 -0
  158. testit/client.py +1 -1
  159. testit/helpers.py +14 -0
  160. testit/runner.py +23 -6
  161. django_nativemojo-0.1.15.dist-info/RECORD +0 -234
  162. mojo/apps/notify/README.md +0 -91
  163. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  164. mojo/apps/notify/admin.py +0 -52
  165. mojo/apps/notify/handlers/example_handlers.py +0 -516
  166. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  167. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  168. mojo/apps/notify/handlers/ses/message.py +0 -86
  169. mojo/apps/notify/management/commands/__init__.py +0 -1
  170. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  171. mojo/apps/notify/mod +0 -0
  172. mojo/apps/notify/models/__init__.py +0 -12
  173. mojo/apps/notify/models/account.py +0 -128
  174. mojo/apps/notify/models/attachment.py +0 -24
  175. mojo/apps/notify/models/bounce.py +0 -68
  176. mojo/apps/notify/models/complaint.py +0 -40
  177. mojo/apps/notify/models/inbox.py +0 -113
  178. mojo/apps/notify/models/inbox_message.py +0 -173
  179. mojo/apps/notify/models/outbox.py +0 -129
  180. mojo/apps/notify/models/outbox_message.py +0 -288
  181. mojo/apps/notify/models/template.py +0 -30
  182. mojo/apps/notify/providers/aws.py +0 -73
  183. mojo/apps/notify/rest/ses.py +0 -0
  184. mojo/apps/notify/utils/__init__.py +0 -2
  185. mojo/apps/notify/utils/notifications.py +0 -404
  186. mojo/apps/notify/utils/parsing.py +0 -202
  187. mojo/apps/notify/utils/render.py +0 -144
  188. mojo/apps/tasks/README.md +0 -118
  189. mojo/apps/tasks/__init__.py +0 -44
  190. mojo/apps/tasks/manager.py +0 -644
  191. mojo/apps/tasks/rest/__init__.py +0 -2
  192. mojo/apps/tasks/rest/hooks.py +0 -0
  193. mojo/apps/tasks/rest/tasks.py +0 -76
  194. mojo/apps/tasks/runner.py +0 -439
  195. mojo/apps/tasks/task.py +0 -99
  196. mojo/apps/tasks/tq_handlers.py +0 -132
  197. mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
  198. mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
  199. mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
  200. mojo/helpers/redis.py +0 -10
  201. mojo/models/meta.py +0 -262
  202. mojo/serializers/advanced/README.md +0 -363
  203. mojo/serializers/advanced/__init__.py +0 -247
  204. mojo/serializers/advanced/formats/__init__.py +0 -28
  205. mojo/serializers/advanced/formats/excel.py +0 -516
  206. mojo/serializers/advanced/formats/json.py +0 -239
  207. mojo/serializers/advanced/formats/response.py +0 -485
  208. mojo/serializers/advanced/serializer.py +0 -568
  209. mojo/serializers/optimized.py +0 -618
  210. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
  213. /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
  214. /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
  215. /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
  216. /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
  217. /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
  218. /mojo/{serializers → rest}/openapi.py +0 -0
  219. /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
  220. /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
  221. /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -0,0 +1,959 @@
1
+ """
2
+ SES Domain Orchestration Helper
3
+
4
+ Purpose:
5
+ - Provide high-level, idempotent operations to onboard and manage an AWS SES domain
6
+ for sending and (optionally) receiving.
7
+ - Leverage existing helpers to avoid duplication:
8
+ - Sending and identity ops via mojo.helpers.aws.ses.EmailSender
9
+ - SNS topics and subscriptions via mojo.helpers.aws.sns.SNSTopic / SNSSubscription
10
+ - S3 bucket helpers via mojo.helpers.aws.s3.S3Bucket (for basic existence checks)
11
+
12
+ Key features (skeleton):
13
+ - Request SES domain verification + DKIM, and compute required DNS records
14
+ - Optionally enable MAIL FROM (DNS records emitted; optional to apply)
15
+ - Create SNS topics for bounce/complaint/delivery/inbound and map identity notifications
16
+ - Enable domain-level catch-all receiving (SES Receipt Rule Set) to S3 + SNS
17
+ - Audit and reconcile routines to detect drift and attempt safe fixes
18
+
19
+ Note:
20
+ - This is a skeleton. Some AWS operations are best-effort; real-world usage needs robust error handling,
21
+ retries, permissions policies, and region/quotas caveats handled at call sites.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from typing import Dict, List, Optional, Literal, Any, Tuple
28
+
29
+ import boto3
30
+ from botocore.exceptions import ClientError
31
+
32
+ from mojo.helpers.aws.client import get_session
33
+ from mojo.helpers.aws.ses import EmailSender
34
+ from mojo.helpers.aws.sns import SNSTopic, SNSSubscription
35
+ from mojo.helpers.aws.s3 import S3Bucket
36
+ from mojo.helpers.settings import settings
37
+ from mojo.helpers import logit
38
+
39
+
40
+ logger = logit.get_logger(__name__)
41
+
42
+ NotificationType = Literal["Bounce", "Complaint", "Delivery"]
43
+ DnsMode = Literal["manual", "route53", "godaddy"]
44
+
45
+ DEFAULT_RULE_SET_NAME = "mojo-default-receiving"
46
+ DEFAULT_TTL = 600
47
+
48
+
49
+ @dataclass
50
+ class DnsRecord:
51
+ type: Literal["TXT", "CNAME", "MX"]
52
+ name: str
53
+ value: str
54
+ ttl: int = DEFAULT_TTL
55
+
56
+
57
+ @dataclass
58
+ class SnsEndpoints:
59
+ bounce: Optional[str] = None
60
+ complaint: Optional[str] = None
61
+ delivery: Optional[str] = None
62
+ inbound: Optional[str] = None
63
+
64
+
65
+ @dataclass
66
+ class OnboardResult:
67
+ domain: str
68
+ region: str
69
+ verification_token: Optional[str] = None
70
+ dkim_tokens: List[str] = field(default_factory=list)
71
+ dns_records: List[DnsRecord] = field(default_factory=list)
72
+ topic_arns: Dict[str, str] = field(default_factory=dict)
73
+ receipt_rule: Optional[str] = None
74
+ rule_set: Optional[str] = None
75
+ notes: List[str] = field(default_factory=list)
76
+
77
+
78
+ @dataclass
79
+ class AuditItem:
80
+ resource: str
81
+ desired: Any
82
+ current: Any
83
+ status: Literal["ok", "drifted", "missing", "conflict"]
84
+
85
+
86
+ @dataclass
87
+ class AuditReport:
88
+ domain: str
89
+ region: str
90
+ status: Literal["ok", "drifted", "conflict"]
91
+ items: List[AuditItem] = field(default_factory=list)
92
+ checks: Dict[str, bool] = field(default_factory=dict)
93
+ audit_pass: bool = False
94
+
95
+
96
+ def _get_ses_client(region: str, access_key: Optional[str], secret_key: Optional[str]):
97
+ session = get_session(
98
+ access_key or settings.AWS_KEY,
99
+ secret_key or settings.AWS_SECRET,
100
+ region or getattr(settings, "AWS_REGION", "us-east-1"),
101
+ )
102
+ return session.client("ses")
103
+
104
+
105
+ def _request_ses_verification_and_dkim(
106
+ domain: str,
107
+ region: str,
108
+ access_key: Optional[str],
109
+ secret_key: Optional[str],
110
+ ) -> Tuple[str, List[str]]:
111
+ """
112
+ Request domain verification (returns TXT token) and DKIM tokens.
113
+ Uses EmailSender for identity verification; DKIM via SES client.
114
+ """
115
+ sender = EmailSender(
116
+ access_key=access_key or settings.AWS_KEY,
117
+ secret_key=secret_key or settings.AWS_SECRET,
118
+ region=region or getattr(settings, "AWS_REGION", "us-east-1"),
119
+ )
120
+ ses = _get_ses_client(region, access_key, secret_key)
121
+
122
+ # Domain verification token
123
+ vr = sender.verify_domain_identity(domain)
124
+ token = vr.get("VerificationToken")
125
+
126
+ # DKIM tokens (3 tokens typical)
127
+ dk = ses.verify_domain_dkim(Domain=domain)
128
+ dkim_tokens = dk.get("DkimTokens", [])
129
+
130
+ return token, dkim_tokens
131
+
132
+
133
+ def build_required_dns_records(
134
+ domain: str,
135
+ region: str,
136
+ verification_token: str,
137
+ dkim_tokens: List[str],
138
+ enable_mail_from: bool = False,
139
+ mail_from_subdomain: str = "feedback",
140
+ ttl: int = DEFAULT_TTL,
141
+ ) -> List[DnsRecord]:
142
+ """
143
+ Build the set of DNS records that must be present for SES domain verification, DKIM,
144
+ and (optionally) MAIL FROM domain.
145
+ """
146
+ records: List[DnsRecord] = []
147
+
148
+ # Domain verification TXT
149
+ records.append(
150
+ DnsRecord(
151
+ type="TXT",
152
+ name=f"_amazonses.{domain}",
153
+ value=verification_token,
154
+ ttl=ttl,
155
+ )
156
+ )
157
+
158
+ # DKIM CNAMEs
159
+ for token in dkim_tokens:
160
+ records.append(
161
+ DnsRecord(
162
+ type="CNAME",
163
+ name=f"{token}._domainkey.{domain}",
164
+ value=f"{token}.dkim.amazonses.com",
165
+ ttl=ttl,
166
+ )
167
+ )
168
+
169
+ if enable_mail_from:
170
+ # MAIL FROM MX + SPF
171
+ mfq = mail_from_subdomain.strip(".")
172
+ records.append(
173
+ DnsRecord(
174
+ type="MX",
175
+ name=f"{mfq}.{domain}",
176
+ value=f"10 feedback-smtp.{region}.amazonses.com",
177
+ ttl=ttl,
178
+ )
179
+ )
180
+ records.append(
181
+ DnsRecord(
182
+ type="TXT",
183
+ name=f"{mfq}.{domain}",
184
+ value="v=spf1 include:amazonses.com ~all",
185
+ ttl=ttl,
186
+ )
187
+ )
188
+
189
+ return records
190
+
191
+
192
+ def ensure_sns_topics_and_subscriptions(
193
+ domain: str,
194
+ endpoints: SnsEndpoints,
195
+ region: str,
196
+ access_key: Optional[str],
197
+ secret_key: Optional[str],
198
+ ) -> Dict[str, str]:
199
+ """
200
+ Ensure SNS topics for bounce/complaint/delivery/inbound.
201
+ If HTTPS endpoints are provided, ensure subscriptions exist.
202
+ Returns topic ARNs by key: bounce, complaint, delivery, inbound.
203
+ """
204
+ topic_arns: Dict[str, str] = {}
205
+ topics = {
206
+ "bounce": f"ses-{domain}-bounce",
207
+ "complaint": f"ses-{domain}-complaint",
208
+ "delivery": f"ses-{domain}-delivery",
209
+ "inbound": f"ses-{domain}-inbound",
210
+ }
211
+
212
+ for key, name in topics.items():
213
+ topic = SNSTopic(name, access_key=access_key, secret_key=secret_key, region=region)
214
+ if not topic.exists:
215
+ topic.create(display_name=name)
216
+ topic_arns[key] = topic.arn
217
+
218
+ # Subscribe HTTPS endpoints if provided
219
+ endpoint = getattr(endpoints, key, None)
220
+ if endpoint:
221
+ sub = SNSSubscription(topic.arn, access_key=access_key, secret_key=secret_key, region=region)
222
+ # idempotent: SNS allows duplicate subscriptions but returns pending conf
223
+ sub.subscribe(protocol="https", endpoint=endpoint, return_subscription_arn=False)
224
+
225
+ return topic_arns
226
+
227
+
228
+ def map_identity_notification_topics(
229
+ domain: str,
230
+ topic_arns: Dict[str, str],
231
+ region: str,
232
+ access_key: Optional[str],
233
+ secret_key: Optional[str],
234
+ ):
235
+ """
236
+ Map SES identity notifications (bounce/complaint/delivery) to SNS topics.
237
+ """
238
+ ses = _get_ses_client(region, access_key, secret_key)
239
+ for notif, key in [("Bounce", "bounce"), ("Complaint", "complaint"), ("Delivery", "delivery")]:
240
+ arn = topic_arns.get(key)
241
+ if not arn:
242
+ continue
243
+ try:
244
+ ses.set_identity_notification_topic(
245
+ Identity=domain,
246
+ NotificationType=notif,
247
+ SnsTopic=arn,
248
+ )
249
+ except ClientError as e:
250
+ logger.error(f"Failed to map {notif} topic for {domain}: {e}")
251
+
252
+
253
+ def set_mail_from_domain(
254
+ domain: str,
255
+ region: str,
256
+ mail_from_subdomain: str = "feedback",
257
+ behavior_on_mx_failure: Literal["UseDefaultValue", "RejectMessage"] = "UseDefaultValue",
258
+ access_key: Optional[str] = None,
259
+ secret_key: Optional[str] = None,
260
+ ):
261
+ """
262
+ Optionally enable/modify MAIL FROM domain on SES identity.
263
+ """
264
+ ses = _get_ses_client(region, access_key, secret_key)
265
+ try:
266
+ ses.set_identity_mail_from_domain(
267
+ Identity=domain,
268
+ MailFromDomain=f"{mail_from_subdomain.strip('.')}.{domain}",
269
+ BehaviorOnMXFailure=behavior_on_mx_failure,
270
+ )
271
+ logger.info(f"MAIL FROM enabled for {domain}")
272
+ except ClientError as e:
273
+ logger.error(f"Failed to configure MAIL FROM for {domain}: {e}")
274
+
275
+
276
+ def ensure_receiving_catch_all(
277
+ domain: str,
278
+ s3_bucket: str,
279
+ s3_prefix: str,
280
+ inbound_topic_arn: str,
281
+ region: str,
282
+ access_key: Optional[str],
283
+ secret_key: Optional[str],
284
+ rule_set_name: str = DEFAULT_RULE_SET_NAME,
285
+ ) -> Tuple[str, str]:
286
+ """
287
+ Ensure a domain-level catch-all SES receipt rule that stores raw emails to S3 and
288
+ publishes to the inbound SNS topic.
289
+
290
+ Returns (rule_set_name, rule_name).
291
+ """
292
+ # Sanity: inbound bucket should exist
293
+ bucket = S3Bucket(s3_bucket)
294
+ if not bucket._check_exists():
295
+ raise ValueError(f"Inbound S3 bucket '{s3_bucket}' does not exist")
296
+
297
+ ses = _get_ses_client(region, access_key, secret_key)
298
+
299
+ # Rule set: create if not present; ensure active if none active.
300
+ existing_sets = ses.list_receipt_rule_sets().get("RuleSets", [])
301
+ set_names = {rs.get("Name") for rs in existing_sets}
302
+ active_set = ses.describe_active_receipt_rule_set().get("Metadata", {}).get("Name")
303
+
304
+ if rule_set_name not in set_names:
305
+ try:
306
+ ses.create_receipt_rule_set(RuleSetName=rule_set_name)
307
+ logger.info(f"Created SES receipt rule set: {rule_set_name}")
308
+ except ClientError as e:
309
+ # Might already exist due to race; re-fetch
310
+ logger.warning(f"Create rule set warning: {e}")
311
+
312
+ # If there is no active set, set ours active
313
+ if not active_set:
314
+ try:
315
+ ses.set_active_receipt_rule_set(RuleSetName=rule_set_name)
316
+ active_set = rule_set_name
317
+ except ClientError as e:
318
+ logger.error(f"Failed to set active rule set: {e}")
319
+ # If active set differs, we still can place rules in our set; SES uses only active one.
320
+ # In production, you might want to switch or merge rules; we report via audit.
321
+
322
+ # Ensure domain-level catch-all rule exists (Recipients can include the domain)
323
+ rule_name = f"mojo-{domain}-catchall"
324
+
325
+ # See if rule exists in our set
326
+ try:
327
+ rs = ses.describe_receipt_rule_set(RuleSetName=rule_set_name)
328
+ existing = [r for r in rs.get("Rules", []) if r.get("Name") == rule_name]
329
+ except ClientError as e:
330
+ logger.error(f"Failed to describe rule set {rule_set_name}: {e}")
331
+ existing = []
332
+
333
+ actions = [
334
+ {
335
+ "S3Action": {
336
+ "BucketName": s3_bucket,
337
+ "ObjectKeyPrefix": s3_prefix or "",
338
+ # "KmsKeyArn": "optional-kms-arn",
339
+ # "TopicArn": inbound_topic_arn, # S3Action TopicArn is optional; we use a separate SNSAction
340
+ }
341
+ },
342
+ {
343
+ "SNSAction": {
344
+ "TopicArn": inbound_topic_arn,
345
+ "Encoding": "UTF-8",
346
+ }
347
+ },
348
+ ]
349
+
350
+ rule_def = {
351
+ "Name": rule_name,
352
+ "Enabled": True,
353
+ "TlsPolicy": "Optional",
354
+ "Recipients": [domain], # domain-level catch-all
355
+ "ScanEnabled": True,
356
+ "Actions": actions,
357
+ }
358
+
359
+ if not existing:
360
+ try:
361
+ ses.create_receipt_rule(
362
+ RuleSetName=rule_set_name,
363
+ Rule=rule_def,
364
+ )
365
+ logger.info(f"Created SES receipt rule {rule_name} in set {rule_set_name}")
366
+ except ClientError as e:
367
+ logger.error(f"Failed to create receipt rule {rule_name}: {e}")
368
+ else:
369
+ # Update to desired shape (best effort)
370
+ try:
371
+ ses.update_receipt_rule(
372
+ RuleSetName=rule_set_name,
373
+ Rule=rule_def,
374
+ )
375
+ logger.info(f"Updated SES receipt rule {rule_name} in set {rule_set_name}")
376
+ except ClientError as e:
377
+ logger.error(f"Failed to update receipt rule {rule_name}: {e}")
378
+
379
+ return rule_set_name, rule_name
380
+
381
+
382
+ def audit_domain_config(
383
+ domain: str,
384
+ region: Optional[str] = None,
385
+ access_key: Optional[str] = None,
386
+ secret_key: Optional[str] = None,
387
+ desired_receiving: Optional[Dict[str, Any]] = None,
388
+ desired_topics: Optional[Dict[str, str]] = None,
389
+ ) -> AuditReport:
390
+ """
391
+ Inspect SES identity verification/DKIM/notifications and receiving rules,
392
+ and produce a boolean checks summary plus detailed items.
393
+
394
+ - desired_receiving: {"bucket": str, "prefix": str, "rule_set": str, "rule_name": str}
395
+ - desired_topics: {"bounce": arn, "complaint": arn, "delivery": arn}
396
+ If not provided, will be derived from the EmailDomain model fields.
397
+ """
398
+ region = region or getattr(settings, "AWS_REGION", "us-east-1")
399
+ ses = _get_ses_client(region, access_key, secret_key)
400
+
401
+ items: List[AuditItem] = []
402
+ checks: Dict[str, bool] = {}
403
+
404
+ # 0) SES account sandbox/production access (region-specific)
405
+ try:
406
+ sesv2 = boto3.client(
407
+ "sesv2",
408
+ aws_access_key_id=access_key or settings.AWS_KEY,
409
+ aws_secret_access_key=secret_key or settings.AWS_SECRET,
410
+ region_name=region,
411
+ )
412
+ acct = sesv2.get_account()
413
+ prod = bool(acct.get("ProductionAccessEnabled", False))
414
+ checks["ses_production_access"] = prod
415
+ items.append(
416
+ AuditItem(
417
+ resource="ses.account.production_access",
418
+ desired={"ProductionAccessEnabled": True},
419
+ current={"ProductionAccessEnabled": prod},
420
+ status="ok" if prod else "drifted",
421
+ )
422
+ )
423
+ except Exception as e:
424
+ checks["ses_production_access"] = False
425
+ items.append(
426
+ AuditItem(
427
+ resource="ses.account.production_access",
428
+ desired={"ProductionAccessEnabled": True},
429
+ current=f"error: {e}",
430
+ status="conflict",
431
+ )
432
+ )
433
+
434
+ # Load configured expectations from EmailDomain when available
435
+ try:
436
+ from mojo.apps.aws.models import EmailDomain as _EmailDomain
437
+ _ed = _EmailDomain.objects.filter(name=domain).first()
438
+ except Exception:
439
+ _ed = None
440
+
441
+ # Derive desired topics from model if not provided
442
+ if desired_topics is None:
443
+ desired_topics = {}
444
+ if _ed:
445
+ desired_topics = {
446
+ "bounce": getattr(_ed, "sns_topic_bounce_arn", None),
447
+ "complaint": getattr(_ed, "sns_topic_complaint_arn", None),
448
+ "delivery": getattr(_ed, "sns_topic_delivery_arn", None),
449
+ }
450
+
451
+ # Derive desired_receiving from model if not provided
452
+ if desired_receiving is None and _ed and getattr(_ed, "receiving_enabled", False) and getattr(_ed, "s3_inbound_bucket", None):
453
+ desired_receiving = {
454
+ "bucket": _ed.s3_inbound_bucket,
455
+ "prefix": _ed.s3_inbound_prefix or "",
456
+ "rule_set": DEFAULT_RULE_SET_NAME,
457
+ "rule_name": f"mojo-{domain}-catchall",
458
+ "inbound_topic_arn": getattr(_ed, "sns_topic_inbound_arn", None),
459
+ }
460
+
461
+ # 1) Identity verification
462
+ try:
463
+ ver = ses.get_identity_verification_attributes(Identities=[domain])
464
+ vstatus = (ver.get("VerificationAttributes", {}).get(domain, {}) or {}).get("VerificationStatus")
465
+ item_status = "ok" if vstatus == "Success" else "drifted"
466
+ items.append(
467
+ AuditItem(
468
+ resource="ses.identity.verification",
469
+ desired="Success",
470
+ current=vstatus,
471
+ status=item_status,
472
+ )
473
+ )
474
+ checks["ses_verified"] = (vstatus == "Success")
475
+ except ClientError as e:
476
+ items.append(
477
+ AuditItem(
478
+ resource="ses.identity.verification",
479
+ desired="Success",
480
+ current=f"error: {e}",
481
+ status="conflict",
482
+ )
483
+ )
484
+ checks["ses_verified"] = False
485
+
486
+ # 2) DKIM attributes
487
+ try:
488
+ dk = ses.get_identity_dkim_attributes(Identities=[domain])
489
+ dkattrs = (dk.get("DkimAttributes", {}) or {}).get(domain, {}) or {}
490
+ current_dkim = {
491
+ "Enabled": dkattrs.get("DkimEnabled"),
492
+ "VerificationStatus": dkattrs.get("DkimVerificationStatus"),
493
+ }
494
+ desired_dkim = {"Enabled": True, "VerificationStatus": "Success"}
495
+ item_status = "ok" if current_dkim == desired_dkim else "drifted"
496
+ items.append(
497
+ AuditItem(
498
+ resource="ses.identity.dkim",
499
+ desired=desired_dkim,
500
+ current=current_dkim,
501
+ status=item_status,
502
+ )
503
+ )
504
+ checks["dkim_verified"] = (current_dkim.get("Enabled") is True and current_dkim.get("VerificationStatus") == "Success")
505
+ except ClientError as e:
506
+ items.append(
507
+ AuditItem(
508
+ resource="ses.identity.dkim",
509
+ desired={"Enabled": True, "VerificationStatus": "Success"},
510
+ current=f"error: {e}",
511
+ status="conflict",
512
+ )
513
+ )
514
+ checks["dkim_verified"] = False
515
+
516
+ # 3) Notification topics mapping (SES identity)
517
+ try:
518
+ na = ses.get_identity_notification_attributes(Identities=[domain])
519
+ cur = (na.get("NotificationAttributes", {}) or {}).get(domain, {}) or {}
520
+ current = {
521
+ "BounceTopic": cur.get("BounceTopic"),
522
+ "ComplaintTopic": cur.get("ComplaintTopic"),
523
+ "DeliveryTopic": cur.get("DeliveryTopic"),
524
+ }
525
+ desired = {
526
+ "BounceTopic": desired_topics.get("bounce"),
527
+ "ComplaintTopic": desired_topics.get("complaint"),
528
+ "DeliveryTopic": desired_topics.get("delivery"),
529
+ }
530
+ mapping_ok = True
531
+ for k in ("BounceTopic", "ComplaintTopic", "DeliveryTopic"):
532
+ # ok only if both are equal (including both None)
533
+ if desired.get(k) != current.get(k):
534
+ mapping_ok = False
535
+ break
536
+ item_status = "ok" if mapping_ok else "drifted"
537
+ items.append(
538
+ AuditItem(
539
+ resource="ses.identity.notification_topics",
540
+ desired=desired,
541
+ current=current,
542
+ status=item_status,
543
+ )
544
+ )
545
+ checks["notification_topics_ok"] = mapping_ok
546
+ except ClientError as e:
547
+ items.append(
548
+ AuditItem(
549
+ resource="ses.identity.notification_topics",
550
+ desired=desired_topics or {},
551
+ current=f"error: {e}",
552
+ status="conflict",
553
+ )
554
+ )
555
+ checks["notification_topics_ok"] = False
556
+
557
+ # 4) Receipt rule (S3 and SNS actions) and S3 bucket existence
558
+ checks["receiving_rule_s3_ok"] = False
559
+ checks["receiving_rule_sns_ok"] = False
560
+ checks["s3_bucket_exists"] = False
561
+ if desired_receiving:
562
+ rs_name = desired_receiving.get("rule_set") or DEFAULT_RULE_SET_NAME
563
+ rule_name = desired_receiving.get("rule_name") or f"mojo-{domain}-catchall"
564
+ want_bucket = desired_receiving.get("bucket")
565
+ want_prefix = desired_receiving.get("prefix") or ""
566
+ want_inbound_arn = desired_receiving.get("inbound_topic_arn")
567
+
568
+ # S3 bucket head check (read-only)
569
+ try:
570
+ s3 = boto3.client(
571
+ "s3",
572
+ aws_access_key_id=access_key or settings.AWS_KEY,
573
+ aws_secret_access_key=secret_key or settings.AWS_SECRET,
574
+ region_name=region,
575
+ )
576
+ s3.head_bucket(Bucket=want_bucket)
577
+ checks["s3_bucket_exists"] = True
578
+ items.append(
579
+ AuditItem(
580
+ resource=f"s3.bucket.exists.{want_bucket}",
581
+ desired={"Exists": True},
582
+ current={"Exists": True},
583
+ status="ok",
584
+ )
585
+ )
586
+ except Exception as e:
587
+ items.append(
588
+ AuditItem(
589
+ resource=f"s3.bucket.exists.{want_bucket}",
590
+ desired={"Exists": True},
591
+ current=f"error: {e}",
592
+ status="missing",
593
+ )
594
+ )
595
+ checks["s3_bucket_exists"] = False
596
+
597
+ try:
598
+ rs = ses.describe_receipt_rule_set(RuleSetName=rs_name)
599
+ rules = {r.get("Name"): r for r in rs.get("Rules", [])}
600
+ current_rule = rules.get(rule_name)
601
+ if current_rule:
602
+ # Pull S3Action and SNSAction
603
+ s3_action = next((a.get("S3Action") for a in current_rule.get("Actions", []) if "S3Action" in a), {}) or {}
604
+ sns_action = next((a.get("SNSAction") for a in current_rule.get("Actions", []) if "SNSAction" in a), {}) or {}
605
+ recipients = current_rule.get("Recipients", []) or []
606
+
607
+ s3_ok = (want_bucket == s3_action.get("BucketName")) and ((want_prefix or "") == (s3_action.get("ObjectKeyPrefix") or ""))
608
+ sns_ok = (want_inbound_arn is None) or (want_inbound_arn == sns_action.get("TopicArn"))
609
+ rec_ok = (domain in recipients)
610
+
611
+ current_view = {
612
+ "Recipients": recipients,
613
+ "BucketName": s3_action.get("BucketName"),
614
+ "ObjectKeyPrefix": s3_action.get("ObjectKeyPrefix"),
615
+ "SnsTopicArn": sns_action.get("TopicArn"),
616
+ }
617
+ desired_view = {
618
+ "Recipients": [domain],
619
+ "BucketName": want_bucket,
620
+ "ObjectKeyPrefix": want_prefix,
621
+ "SnsTopicArn": want_inbound_arn,
622
+ }
623
+
624
+ # S3 comparison item
625
+ items.append(
626
+ AuditItem(
627
+ resource=f"ses.receipt_rule.s3.{rs_name}.{rule_name}",
628
+ desired={"Recipients": [domain], "BucketName": want_bucket, "ObjectKeyPrefix": want_prefix},
629
+ current={"Recipients": recipients, "BucketName": s3_action.get("BucketName"), "ObjectKeyPrefix": s3_action.get("ObjectKeyPrefix")},
630
+ status="ok" if (s3_ok and rec_ok) else "drifted",
631
+ )
632
+ )
633
+ # SNS comparison item
634
+ items.append(
635
+ AuditItem(
636
+ resource=f"ses.receipt_rule.sns.{rs_name}.{rule_name}",
637
+ desired={"SnsTopicArn": want_inbound_arn},
638
+ current={"SnsTopicArn": sns_action.get("TopicArn")},
639
+ status="ok" if sns_ok else "drifted",
640
+ )
641
+ )
642
+
643
+ checks["receiving_rule_s3_ok"] = bool(s3_ok and rec_ok)
644
+ checks["receiving_rule_sns_ok"] = bool(sns_ok)
645
+ else:
646
+ items.append(
647
+ AuditItem(
648
+ resource=f"ses.receipt_rule.{rs_name}.{rule_name}",
649
+ desired={"Recipients": [domain], "BucketName": want_bucket, "ObjectKeyPrefix": want_prefix, "SnsTopicArn": want_inbound_arn},
650
+ current=None,
651
+ status="missing",
652
+ )
653
+ )
654
+ checks["receiving_rule_s3_ok"] = False
655
+ checks["receiving_rule_sns_ok"] = False
656
+ except ClientError as e:
657
+ items.append(
658
+ AuditItem(
659
+ resource=f"ses.receipt_rule.{rs_name}",
660
+ desired=desired_receiving,
661
+ current=f"error: {e}",
662
+ status="conflict",
663
+ )
664
+ )
665
+ checks["receiving_rule_s3_ok"] = False
666
+ checks["receiving_rule_sns_ok"] = False
667
+
668
+ # 5) SNS topics existence and subscription status for configured ARNs
669
+ checks["sns_topics_exist"] = True
670
+ checks["sns_subscriptions_confirmed"] = True
671
+ try:
672
+ sns = boto3.client(
673
+ "sns",
674
+ aws_access_key_id=access_key or settings.AWS_KEY,
675
+ aws_secret_access_key=secret_key or settings.AWS_SECRET,
676
+ region_name=region,
677
+ )
678
+ # Include bounce/complaint/delivery + inbound (from desired_receiving) if present
679
+ topic_map: Dict[str, Optional[str]] = {
680
+ "bounce": desired_topics.get("bounce"),
681
+ "complaint": desired_topics.get("complaint"),
682
+ "delivery": desired_topics.get("delivery"),
683
+ }
684
+ if desired_receiving and desired_receiving.get("inbound_topic_arn"):
685
+ topic_map["inbound"] = desired_receiving.get("inbound_topic_arn")
686
+
687
+ for key, arn in topic_map.items():
688
+ if not arn:
689
+ # If we expect no ARN, treat as OK only if SES mapping is also None (handled above).
690
+ continue
691
+ exists_ok = False
692
+ subs_ok = False
693
+ try:
694
+ sns.get_topic_attributes(TopicArn=arn)
695
+ exists_ok = True
696
+ except Exception as e:
697
+ items.append(
698
+ AuditItem(
699
+ resource=f"sns.topic.exists.{key}",
700
+ desired={"TopicArn": arn},
701
+ current=f"error: {e}",
702
+ status="missing",
703
+ )
704
+ )
705
+ exists_ok = False
706
+
707
+ if exists_ok:
708
+ # Check subscriptions
709
+ try:
710
+ subs = sns.list_subscriptions_by_topic(TopicArn=arn).get("Subscriptions", []) or []
711
+ # Confirm at least one confirmed HTTPS subscription
712
+ confirmed = False
713
+ for s in subs:
714
+ proto = (s.get("Protocol") or "").lower()
715
+ pending = s.get("PendingConfirmation")
716
+ # PendingConfirmation may be 'true'/'false' or boolean
717
+ is_pending = (str(pending).lower() == "true")
718
+ if proto == "https" and not is_pending:
719
+ confirmed = True
720
+ break
721
+ subs_ok = confirmed
722
+ items.append(
723
+ AuditItem(
724
+ resource=f"sns.topic.subscriptions.{key}",
725
+ desired={"ConfirmedHttpsSubscription": True},
726
+ current={"ConfirmedHttpsSubscription": confirmed},
727
+ status="ok" if confirmed else "drifted",
728
+ )
729
+ )
730
+ except Exception as e:
731
+ items.append(
732
+ AuditItem(
733
+ resource=f"sns.topic.subscriptions.{key}",
734
+ desired={"ConfirmedHttpsSubscription": True},
735
+ current=f"error: {e}",
736
+ status="conflict",
737
+ )
738
+ )
739
+ subs_ok = False
740
+
741
+ checks["sns_topics_exist"] = checks["sns_topics_exist"] and exists_ok
742
+ checks["sns_subscriptions_confirmed"] = checks["sns_subscriptions_confirmed"] and subs_ok
743
+
744
+ except Exception:
745
+ # If SNS client init fails, mark as unknown/false
746
+ checks["sns_topics_exist"] = False
747
+ checks["sns_subscriptions_confirmed"] = False
748
+
749
+ # Overall status
750
+ overall = "ok"
751
+ if any(it.status == "conflict" for it in items):
752
+ overall = "conflict"
753
+ elif any(it.status in ("drifted", "missing") for it in items):
754
+ overall = "drifted"
755
+
756
+ return AuditReport(
757
+ domain=domain,
758
+ region=region,
759
+ status=overall,
760
+ items=items,
761
+ checks=checks,
762
+ audit_pass=(overall == "ok"),
763
+ )
764
+
765
+
766
+ def reconcile_domain_config(
767
+ domain: str,
768
+ region: str,
769
+ receiving_enabled: bool,
770
+ s3_bucket: Optional[str],
771
+ s3_prefix: Optional[str],
772
+ endpoints: Optional[SnsEndpoints] = None,
773
+ access_key: Optional[str] = None,
774
+ secret_key: Optional[str] = None,
775
+ ensure_mail_from: bool = False,
776
+ mail_from_subdomain: str = "feedback",
777
+ ) -> OnboardResult:
778
+ """
779
+ Attempt to bring the SES identity into alignment:
780
+ - Ensure SNS topics and notification mappings
781
+ - Ensure domain-level receipt rule (catch-all) if receiving_enabled
782
+ - Optionally enable MAIL FROM
783
+ This does NOT modify DNS. Use build_required_dns_records and your DNS manager (GoDaddy or Route 53) for that.
784
+ """
785
+ endpoints = endpoints or SnsEndpoints()
786
+ result = OnboardResult(domain=domain, region=region)
787
+
788
+ # Ensure SNS topics (and subscriptions if endpoints provided)
789
+ topic_arns = ensure_sns_topics_and_subscriptions(
790
+ domain=domain,
791
+ endpoints=endpoints,
792
+ region=region,
793
+ access_key=access_key,
794
+ secret_key=secret_key,
795
+ )
796
+ result.topic_arns = topic_arns
797
+ # Persist topic ARNs on EmailDomain model if available
798
+ try:
799
+ from mojo.apps.aws.models import EmailDomain as _EmailDomain
800
+ _ed = _EmailDomain.objects.filter(name=domain).first()
801
+ if _ed:
802
+ _updates = {}
803
+ if topic_arns.get("bounce") and getattr(_ed, "sns_topic_bounce_arn", None) != topic_arns["bounce"]:
804
+ _updates["sns_topic_bounce_arn"] = topic_arns["bounce"]
805
+ if topic_arns.get("complaint") and getattr(_ed, "sns_topic_complaint_arn", None) != topic_arns["complaint"]:
806
+ _updates["sns_topic_complaint_arn"] = topic_arns["complaint"]
807
+ if topic_arns.get("delivery") and getattr(_ed, "sns_topic_delivery_arn", None) != topic_arns["delivery"]:
808
+ _updates["sns_topic_delivery_arn"] = topic_arns["delivery"]
809
+ if topic_arns.get("inbound") and getattr(_ed, "sns_topic_inbound_arn", None) != topic_arns["inbound"]:
810
+ _updates["sns_topic_inbound_arn"] = topic_arns["inbound"]
811
+ if _updates:
812
+ for _k, _v in _updates.items():
813
+ setattr(_ed, _k, _v)
814
+ _ed.save(update_fields=list(_updates.keys()) + ["modified"])
815
+ except Exception as _e:
816
+ logger.warning(f"Failed to persist topic ARNs for domain {domain}: {_e}")
817
+
818
+ # Map notifications (bounce/complaint/delivery)
819
+ map_identity_notification_topics(
820
+ domain=domain,
821
+ topic_arns=topic_arns,
822
+ region=region,
823
+ access_key=access_key,
824
+ secret_key=secret_key,
825
+ )
826
+
827
+ # MAIL FROM (optional)
828
+ if ensure_mail_from:
829
+ set_mail_from_domain(
830
+ domain=domain,
831
+ region=region,
832
+ mail_from_subdomain=mail_from_subdomain,
833
+ access_key=access_key,
834
+ secret_key=secret_key,
835
+ )
836
+ result.notes.append("MAIL FROM configured")
837
+
838
+ # Receiving (optional)
839
+ if receiving_enabled:
840
+ if not s3_bucket:
841
+ raise ValueError("receiving_enabled is True, but s3_bucket is not provided")
842
+ rs_name, rule_name = ensure_receiving_catch_all(
843
+ domain=domain,
844
+ s3_bucket=s3_bucket,
845
+ s3_prefix=s3_prefix or "",
846
+ inbound_topic_arn=topic_arns.get("inbound"),
847
+ region=region,
848
+ access_key=access_key,
849
+ secret_key=secret_key,
850
+ )
851
+ result.rule_set = rs_name
852
+ result.receipt_rule = rule_name
853
+ result.notes.append("Receiving catch-all rule ensured")
854
+
855
+ return result
856
+
857
+
858
+ def onboard_domain(
859
+ domain: str,
860
+ region: Optional[str] = None,
861
+ access_key: Optional[str] = None,
862
+ secret_key: Optional[str] = None,
863
+ receiving_enabled: bool = False,
864
+ s3_bucket: Optional[str] = None,
865
+ s3_prefix: str = "",
866
+ dns_mode: DnsMode = "manual",
867
+ ensure_mail_from: bool = False,
868
+ mail_from_subdomain: str = "feedback",
869
+ endpoints: Optional[SnsEndpoints] = None,
870
+ ttl: int = DEFAULT_TTL,
871
+ ) -> OnboardResult:
872
+ """
873
+ High-level "one-step" onboarding orchestrator:
874
+ - Request SES domain verification + DKIM tokens
875
+ - Compute required DNS records (caller applies manually or via GoDaddy/Route 53)
876
+ - Ensure SNS topics and notification mappings
877
+ - Optionally configure MAIL FROM
878
+ - Optionally enable receiving (catch-all → S3 + SNS)
879
+
880
+ Note: This helper does NOT apply DNS to any provider. It returns `dns_records`.
881
+ """
882
+ region = region or getattr(settings, "AWS_REGION", "us-east-1")
883
+ endpoints = endpoints or SnsEndpoints()
884
+
885
+ # Request verification + DKIM
886
+ verification_token, dkim_tokens = _request_ses_verification_and_dkim(
887
+ domain=domain, region=region, access_key=access_key, secret_key=secret_key
888
+ )
889
+
890
+ dns_records = build_required_dns_records(
891
+ domain=domain,
892
+ region=region,
893
+ verification_token=verification_token,
894
+ dkim_tokens=dkim_tokens,
895
+ enable_mail_from=ensure_mail_from,
896
+ mail_from_subdomain=mail_from_subdomain,
897
+ ttl=ttl,
898
+ )
899
+
900
+ # Ensure AWS-side resources (SNS, notifications, receiving)
901
+ recon = reconcile_domain_config(
902
+ domain=domain,
903
+ region=region,
904
+ receiving_enabled=receiving_enabled,
905
+ s3_bucket=s3_bucket,
906
+ s3_prefix=s3_prefix,
907
+ endpoints=endpoints,
908
+ access_key=access_key,
909
+ secret_key=secret_key,
910
+ ensure_mail_from=ensure_mail_from,
911
+ mail_from_subdomain=mail_from_subdomain,
912
+ )
913
+
914
+ return OnboardResult(
915
+ domain=domain,
916
+ region=region,
917
+ verification_token=verification_token,
918
+ dkim_tokens=dkim_tokens,
919
+ dns_records=dns_records,
920
+ topic_arns=recon.topic_arns,
921
+ receipt_rule=recon.receipt_rule,
922
+ rule_set=recon.rule_set,
923
+ notes=recon.notes,
924
+ )
925
+
926
+
927
+ # Optional DNS application helpers (skeletons)
928
+ def apply_dns_records_godaddy(
929
+ domain: str,
930
+ records: List[DnsRecord],
931
+ api_key: str,
932
+ api_secret: str,
933
+ ):
934
+ """
935
+ Apply DNS records using the existing GoDaddy DNSManager helper.
936
+ Caller should pass credentials that map to the domain's registrar account.
937
+ """
938
+ try:
939
+ from mojo.helpers.dns.godaddy import DNSManager # local helper exists
940
+ except Exception as e:
941
+ raise ImportError("GoDaddy DNSManager not available") from e
942
+
943
+ dns = DNSManager(api_key, api_secret)
944
+ if not dns.is_domain_active(domain):
945
+ raise ValueError(f"Domain {domain} is not active in GoDaddy account")
946
+
947
+ for r in records:
948
+ # For GoDaddy, record names are relative to the domain
949
+ # e.g., "_amazonses" for "_amazonses.example.com"
950
+ name = r.name.replace(f".{domain}", "")
951
+ # Some providers want quoted TXT data; GoDaddy accepts raw token for SES
952
+ dns.add_record(
953
+ domain=domain,
954
+ record_type=r.type,
955
+ name=name,
956
+ data=r.value,
957
+ ttl=r.ttl,
958
+ )
959
+ return True