django-nativemojo 0.1.15__py3-none-any.whl → 0.1.17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
- django_nativemojo-0.1.17.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/commands/serializer_admin.py +121 -1
- mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
- mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
- mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
- mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
- mojo/apps/account/migrations/0010_group_avatar.py +20 -0
- mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
- mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
- mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
- mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
- mojo/apps/account/models/__init__.py +2 -0
- mojo/apps/account/models/device.py +279 -0
- mojo/apps/account/models/group.py +294 -8
- mojo/apps/account/models/member.py +14 -1
- mojo/apps/account/models/push/__init__.py +4 -0
- mojo/apps/account/models/push/config.py +112 -0
- mojo/apps/account/models/push/delivery.py +93 -0
- mojo/apps/account/models/push/device.py +66 -0
- mojo/apps/account/models/push/template.py +99 -0
- mojo/apps/account/models/user.py +190 -17
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +8 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +95 -5
- mojo/apps/account/services/__init__.py +1 -0
- mojo/apps/account/services/push.py +363 -0
- mojo/apps/aws/migrations/0001_initial.py +206 -0
- mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
- mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
- mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
- mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
- mojo/apps/aws/models/__init__.py +19 -0
- mojo/apps/aws/models/email_attachment.py +99 -0
- mojo/apps/aws/models/email_domain.py +218 -0
- mojo/apps/aws/models/email_template.py +132 -0
- mojo/apps/aws/models/incoming_email.py +197 -0
- mojo/apps/aws/models/mailbox.py +288 -0
- mojo/apps/aws/models/sent_message.py +175 -0
- mojo/apps/aws/rest/__init__.py +6 -0
- mojo/apps/aws/rest/email.py +33 -0
- mojo/apps/aws/rest/email_ops.py +183 -0
- mojo/apps/aws/rest/messages.py +32 -0
- mojo/apps/aws/rest/send.py +101 -0
- mojo/apps/aws/rest/sns.py +403 -0
- mojo/apps/aws/rest/templates.py +19 -0
- mojo/apps/aws/services/__init__.py +32 -0
- mojo/apps/aws/services/email.py +390 -0
- mojo/apps/aws/services/email_ops.py +548 -0
- mojo/apps/docit/__init__.py +6 -0
- mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
- mojo/apps/docit/markdown_plugins/toc.py +12 -0
- mojo/apps/docit/migrations/0001_initial.py +113 -0
- mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
- mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
- mojo/apps/docit/models/__init__.py +17 -0
- mojo/apps/docit/models/asset.py +231 -0
- mojo/apps/docit/models/book.py +227 -0
- mojo/apps/docit/models/page.py +319 -0
- mojo/apps/docit/models/page_revision.py +203 -0
- mojo/apps/docit/rest/__init__.py +10 -0
- mojo/apps/docit/rest/asset.py +17 -0
- mojo/apps/docit/rest/book.py +22 -0
- mojo/apps/docit/rest/page.py +22 -0
- mojo/apps/docit/rest/page_revision.py +17 -0
- mojo/apps/docit/services/__init__.py +11 -0
- mojo/apps/docit/services/docit.py +315 -0
- mojo/apps/docit/services/markdown.py +44 -0
- mojo/apps/fileman/backends/s3.py +209 -0
- mojo/apps/fileman/models/file.py +45 -9
- mojo/apps/fileman/models/manager.py +269 -3
- mojo/apps/incident/migrations/0007_event_uid.py +18 -0
- mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
- mojo/apps/incident/migrations/0009_incident_status.py +18 -0
- mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
- mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
- mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
- mojo/apps/incident/models/__init__.py +1 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/incident.py +2 -0
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -3
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/ticket.py +43 -0
- mojo/apps/jobs/__init__.py +489 -0
- mojo/apps/jobs/adapters.py +24 -0
- mojo/apps/jobs/cli.py +616 -0
- mojo/apps/jobs/daemon.py +370 -0
- mojo/apps/jobs/examples/sample_jobs.py +376 -0
- mojo/apps/jobs/examples/webhook_examples.py +203 -0
- mojo/apps/jobs/handlers/__init__.py +5 -0
- mojo/apps/jobs/handlers/webhook.py +317 -0
- mojo/apps/jobs/job_engine.py +734 -0
- mojo/apps/jobs/keys.py +203 -0
- mojo/apps/jobs/local_queue.py +363 -0
- mojo/apps/jobs/management/__init__.py +3 -0
- mojo/apps/jobs/management/commands/__init__.py +3 -0
- mojo/apps/jobs/manager.py +1327 -0
- mojo/apps/jobs/migrations/0001_initial.py +97 -0
- mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
- mojo/apps/jobs/models/__init__.py +6 -0
- mojo/apps/jobs/models/job.py +441 -0
- mojo/apps/jobs/rest/__init__.py +2 -0
- mojo/apps/jobs/rest/control.py +466 -0
- mojo/apps/jobs/rest/jobs.py +421 -0
- mojo/apps/jobs/scheduler.py +571 -0
- mojo/apps/jobs/services/__init__.py +6 -0
- mojo/apps/jobs/services/job_actions.py +465 -0
- mojo/apps/jobs/settings.py +209 -0
- mojo/apps/logit/models/log.py +3 -0
- mojo/apps/metrics/__init__.py +8 -1
- mojo/apps/metrics/redis_metrics.py +198 -0
- mojo/apps/metrics/rest/__init__.py +3 -0
- mojo/apps/metrics/rest/categories.py +266 -0
- mojo/apps/metrics/rest/helpers.py +48 -0
- mojo/apps/metrics/rest/permissions.py +99 -0
- mojo/apps/metrics/rest/values.py +277 -0
- mojo/apps/metrics/utils.py +17 -0
- mojo/decorators/http.py +40 -1
- mojo/helpers/aws/__init__.py +11 -7
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/location/__init__.py +2 -0
- mojo/helpers/location/countries.py +262 -0
- mojo/helpers/location/geolocation.py +196 -0
- mojo/helpers/logit.py +37 -0
- mojo/helpers/redis/__init__.py +2 -0
- mojo/helpers/redis/adapter.py +606 -0
- mojo/helpers/redis/client.py +48 -0
- mojo/helpers/redis/pool.py +225 -0
- mojo/helpers/request.py +8 -0
- mojo/helpers/response.py +8 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +5 -0
- mojo/models/rest.py +271 -57
- mojo/models/secrets.py +86 -0
- mojo/serializers/__init__.py +16 -10
- mojo/serializers/core/__init__.py +90 -0
- mojo/serializers/core/cache/__init__.py +121 -0
- mojo/serializers/core/cache/backends.py +518 -0
- mojo/serializers/core/cache/base.py +102 -0
- mojo/serializers/core/cache/disabled.py +181 -0
- mojo/serializers/core/cache/memory.py +287 -0
- mojo/serializers/core/cache/redis.py +533 -0
- mojo/serializers/core/cache/utils.py +454 -0
- mojo/serializers/{manager.py → core/manager.py} +53 -4
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +14 -0
- testit/runner.py +23 -6
- django_nativemojo-0.1.15.dist-info/RECORD +0 -234
- mojo/apps/notify/README.md +0 -91
- mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
- mojo/apps/notify/admin.py +0 -52
- mojo/apps/notify/handlers/example_handlers.py +0 -516
- mojo/apps/notify/handlers/ses/__init__.py +0 -25
- mojo/apps/notify/handlers/ses/complaint.py +0 -25
- mojo/apps/notify/handlers/ses/message.py +0 -86
- mojo/apps/notify/management/commands/__init__.py +0 -1
- mojo/apps/notify/management/commands/process_notifications.py +0 -370
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +0 -12
- mojo/apps/notify/models/account.py +0 -128
- mojo/apps/notify/models/attachment.py +0 -24
- mojo/apps/notify/models/bounce.py +0 -68
- mojo/apps/notify/models/complaint.py +0 -40
- mojo/apps/notify/models/inbox.py +0 -113
- mojo/apps/notify/models/inbox_message.py +0 -173
- mojo/apps/notify/models/outbox.py +0 -129
- mojo/apps/notify/models/outbox_message.py +0 -288
- mojo/apps/notify/models/template.py +0 -30
- mojo/apps/notify/providers/aws.py +0 -73
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +0 -2
- mojo/apps/notify/utils/notifications.py +0 -404
- mojo/apps/notify/utils/parsing.py +0 -202
- mojo/apps/notify/utils/render.py +0 -144
- mojo/apps/tasks/README.md +0 -118
- mojo/apps/tasks/__init__.py +0 -44
- mojo/apps/tasks/manager.py +0 -644
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -76
- mojo/apps/tasks/runner.py +0 -439
- mojo/apps/tasks/task.py +0 -99
- mojo/apps/tasks/tq_handlers.py +0 -132
- mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/serializers/advanced/README.md +0 -363
- mojo/serializers/advanced/__init__.py +0 -247
- mojo/serializers/advanced/formats/__init__.py +0 -28
- mojo/serializers/advanced/formats/excel.py +0 -516
- mojo/serializers/advanced/formats/json.py +0 -239
- mojo/serializers/advanced/formats/response.py +0 -485
- mojo/serializers/advanced/serializer.py +0 -568
- mojo/serializers/optimized.py +0 -618
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
- /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
- /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
- /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -0,0 +1,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
|