django-nativemojo 0.1.10__py3-none-any.whl → 0.1.16__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_nativemojo-0.1.16.dist-info/METADATA +138 -0
- django_nativemojo-0.1.16.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/__init__.py +5 -0
- mojo/apps/account/management/commands/__init__.py +6 -0
- mojo/apps/account/management/commands/serializer_admin.py +651 -0
- mojo/apps/account/migrations/0004_user_avatar.py +20 -0
- mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
- 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 +281 -0
- mojo/apps/account/models/group.py +319 -15
- mojo/apps/account/models/member.py +29 -5
- 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 +369 -19
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +9 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +100 -6
- 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 +7 -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/s3.py +64 -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/README.md +8 -8
- mojo/apps/fileman/backends/base.py +76 -70
- mojo/apps/fileman/backends/filesystem.py +86 -86
- mojo/apps/fileman/backends/s3.py +409 -108
- mojo/apps/fileman/migrations/0001_initial.py +106 -0
- mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
- mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
- mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
- mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
- mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
- mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
- mojo/apps/fileman/migrations/0008_file_category.py +18 -0
- mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
- mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
- mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
- mojo/apps/fileman/models/__init__.py +1 -5
- mojo/apps/fileman/models/file.py +240 -58
- mojo/apps/fileman/models/manager.py +427 -31
- mojo/apps/fileman/models/rendition.py +118 -0
- mojo/apps/fileman/renderer/__init__.py +111 -0
- mojo/apps/fileman/renderer/audio.py +403 -0
- mojo/apps/fileman/renderer/base.py +205 -0
- mojo/apps/fileman/renderer/document.py +404 -0
- mojo/apps/fileman/renderer/image.py +222 -0
- mojo/apps/fileman/renderer/utils.py +297 -0
- mojo/apps/fileman/renderer/video.py +304 -0
- mojo/apps/fileman/rest/__init__.py +1 -18
- mojo/apps/fileman/rest/upload.py +22 -32
- mojo/apps/fileman/signals.py +58 -0
- mojo/apps/fileman/tasks.py +254 -0
- mojo/apps/fileman/utils/__init__.py +40 -16
- mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
- mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
- 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 +2 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/history.py +36 -0
- mojo/apps/incident/models/incident.py +3 -1
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -1
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/event.py +7 -1
- 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/migrations/0004_alter_log_level.py +18 -0
- mojo/apps/logit/models/log.py +7 -1
- 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 +19 -2
- mojo/decorators/auth.py +6 -1
- mojo/decorators/http.py +47 -3
- mojo/helpers/aws/__init__.py +45 -0
- mojo/helpers/aws/ec2.py +804 -0
- mojo/helpers/aws/iam.py +748 -0
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/s3.py +451 -11
- mojo/helpers/aws/ses.py +483 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/aws/sns.py +461 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/dates.py +18 -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 +14 -2
- mojo/helpers/settings/__init__.py +2 -0
- mojo/helpers/{settings.py → settings/helper.py} +1 -37
- mojo/helpers/settings/parser.py +132 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +10 -0
- mojo/models/rest.py +494 -65
- mojo/models/secrets.py +98 -3
- mojo/serializers/__init__.py +106 -0
- 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/core/manager.py +550 -0
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/examples/settings.py +322 -0
- mojo/serializers/formats/csv.py +393 -0
- mojo/serializers/formats/localizers.py +509 -0
- mojo/serializers/{models.py → simple.py} +38 -15
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +35 -4
- testit/runner.py +23 -6
- django_nativemojo-0.1.10.dist-info/METADATA +0 -96
- django_nativemojo-0.1.10.dist-info/RECORD +0 -194
- mojo/apps/metrics/rest/db.py +0 -0
- 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/bounce.py +0 -0
- 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 -11
- mojo/apps/tasks/manager.py +0 -489
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -62
- mojo/apps/tasks/runner.py +0 -174
- mojo/apps/tasks/tq_handlers.py +0 -14
- mojo/helpers/aws/setup_email.py +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/ws4redis/README.md +0 -174
- mojo/ws4redis/__init__.py +0 -2
- mojo/ws4redis/client.py +0 -283
- mojo/ws4redis/connection.py +0 -327
- mojo/ws4redis/exceptions.py +0 -32
- mojo/ws4redis/redis.py +0 -183
- mojo/ws4redis/servers/base.py +0 -86
- mojo/ws4redis/servers/django.py +0 -171
- mojo/ws4redis/servers/uwsgi.py +0 -63
- mojo/ws4redis/settings.py +0 -45
- mojo/ws4redis/utf8validator.py +0 -128
- mojo/ws4redis/websocket.py +0 -403
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
- /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
- /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/{apps/fileman/rest/__init__ → serializers/formats/__init__.py} +0 -0
@@ -0,0 +1,548 @@
|
|
1
|
+
"""
|
2
|
+
AWS SES Domain Management Service
|
3
|
+
|
4
|
+
This service provides domain onboarding, auditing, and reconciliation functionality
|
5
|
+
for AWS SES email domains. It encapsulates the complex domain setup process and
|
6
|
+
provides user-friendly recommendations for configuration issues.
|
7
|
+
|
8
|
+
Usage:
|
9
|
+
from mojo.apps.aws.services.email_ops import (
|
10
|
+
onboard_email_domain,
|
11
|
+
audit_email_domain,
|
12
|
+
reconcile_email_domain,
|
13
|
+
generate_audit_recommendations
|
14
|
+
)
|
15
|
+
|
16
|
+
# Onboard a new domain
|
17
|
+
result = onboard_email_domain(
|
18
|
+
domain_pk=1,
|
19
|
+
region="us-east-1",
|
20
|
+
receiving_enabled=True,
|
21
|
+
s3_bucket="my-emails",
|
22
|
+
dns_mode="manual"
|
23
|
+
)
|
24
|
+
|
25
|
+
# Audit domain configuration
|
26
|
+
audit = audit_email_domain(domain_pk=1, region="us-east-1")
|
27
|
+
recommendations = generate_audit_recommendations(audit.report)
|
28
|
+
|
29
|
+
# Fix configuration drift
|
30
|
+
reconcile_result = reconcile_email_domain(
|
31
|
+
domain_pk=1,
|
32
|
+
receiving_enabled=True,
|
33
|
+
s3_bucket="my-emails"
|
34
|
+
)
|
35
|
+
"""
|
36
|
+
|
37
|
+
from typing import Dict, Any, Optional, List, NamedTuple
|
38
|
+
from dataclasses import dataclass
|
39
|
+
|
40
|
+
from mojo.apps.aws.models import EmailDomain
|
41
|
+
from mojo.helpers.settings import settings
|
42
|
+
from mojo.helpers import logit
|
43
|
+
|
44
|
+
# Orchestration helpers
|
45
|
+
from mojo.helpers.aws.ses_domain import (
|
46
|
+
onboard_domain,
|
47
|
+
audit_domain_config,
|
48
|
+
reconcile_domain_config,
|
49
|
+
SnsEndpoints,
|
50
|
+
DnsRecord,
|
51
|
+
apply_dns_records_godaddy,
|
52
|
+
)
|
53
|
+
|
54
|
+
logger = logit.get_logger("email", "email.log")
|
55
|
+
|
56
|
+
|
57
|
+
# Exceptions
|
58
|
+
class EmailDomainNotFound(Exception):
|
59
|
+
pass
|
60
|
+
|
61
|
+
class InvalidConfiguration(Exception):
|
62
|
+
pass
|
63
|
+
|
64
|
+
|
65
|
+
# Data structures
|
66
|
+
@dataclass
|
67
|
+
class OnboardResult:
|
68
|
+
domain: str
|
69
|
+
region: str
|
70
|
+
dns_records: List[Dict[str, Any]]
|
71
|
+
dkim_tokens: List[str]
|
72
|
+
topic_arns: Dict[str, str]
|
73
|
+
receipt_rule: Optional[str]
|
74
|
+
rule_set: Optional[str]
|
75
|
+
notes: List[str]
|
76
|
+
|
77
|
+
|
78
|
+
@dataclass
|
79
|
+
class AuditResult:
|
80
|
+
domain: str
|
81
|
+
region: str
|
82
|
+
status: str
|
83
|
+
audit_pass: bool
|
84
|
+
checks: Dict[str, bool]
|
85
|
+
items: List[Any]
|
86
|
+
report: Any # Store original report for recommendations
|
87
|
+
|
88
|
+
|
89
|
+
@dataclass
|
90
|
+
class ReconcileResult:
|
91
|
+
domain: str
|
92
|
+
region: str
|
93
|
+
topic_arns: Dict[str, str]
|
94
|
+
receipt_rule: Optional[str]
|
95
|
+
rule_set: Optional[str]
|
96
|
+
notes: List[str]
|
97
|
+
|
98
|
+
|
99
|
+
# Helper functions
|
100
|
+
def _get_domain(domain_pk: int) -> EmailDomain:
|
101
|
+
"""Get EmailDomain by primary key or raise exception"""
|
102
|
+
try:
|
103
|
+
return EmailDomain.objects.get(pk=domain_pk)
|
104
|
+
except EmailDomain.DoesNotExist:
|
105
|
+
raise EmailDomainNotFound(f"EmailDomain not found with pk={domain_pk}")
|
106
|
+
|
107
|
+
|
108
|
+
def _parse_endpoints(payload: Dict[str, Any]) -> SnsEndpoints:
|
109
|
+
"""Parse SNS endpoints from payload"""
|
110
|
+
ep = payload.get("endpoints") or {}
|
111
|
+
return SnsEndpoints(
|
112
|
+
bounce=ep.get("bounce") or payload.get("bounce_endpoint"),
|
113
|
+
complaint=ep.get("complaint") or payload.get("complaint_endpoint"),
|
114
|
+
delivery=ep.get("delivery") or payload.get("delivery_endpoint"),
|
115
|
+
inbound=ep.get("inbound") or payload.get("inbound_endpoint"),
|
116
|
+
)
|
117
|
+
|
118
|
+
|
119
|
+
def _dns_records_to_dict(records: List[DnsRecord]) -> List[Dict[str, Any]]:
|
120
|
+
"""Convert DnsRecord objects to dictionaries"""
|
121
|
+
return [{"type": r.type, "name": r.name, "value": r.value, "ttl": r.ttl} for r in records]
|
122
|
+
|
123
|
+
|
124
|
+
def _get_aws_credentials(domain: EmailDomain,
|
125
|
+
access_key: Optional[str] = None,
|
126
|
+
secret_key: Optional[str] = None) -> tuple[Optional[str], Optional[str]]:
|
127
|
+
"""Get AWS credentials with domain/settings fallback"""
|
128
|
+
return (
|
129
|
+
access_key or domain.aws_key or getattr(settings, "AWS_KEY", None),
|
130
|
+
secret_key or domain.aws_secret or getattr(settings, "AWS_SECRET", None)
|
131
|
+
)
|
132
|
+
|
133
|
+
|
134
|
+
def generate_audit_recommendations(report) -> List[Dict[str, Any]]:
|
135
|
+
"""Generate user-friendly recommendations based on audit results"""
|
136
|
+
recommendations = []
|
137
|
+
|
138
|
+
for item in report.items:
|
139
|
+
resource = item.resource
|
140
|
+
status = item.status
|
141
|
+
current = str(item.current)
|
142
|
+
|
143
|
+
if status != "conflict":
|
144
|
+
continue
|
145
|
+
|
146
|
+
recommendation = {"resource": resource, "severity": "high", "action": "", "explanation": ""}
|
147
|
+
|
148
|
+
# Check for credential/permission issues
|
149
|
+
if ("AccessDenied" in current or "not authorized" in current or
|
150
|
+
"InvalidSignatureException" in current or "SignatureDoesNotMatch" in current):
|
151
|
+
|
152
|
+
if "ses-smtp-user" in current:
|
153
|
+
recommendation.update({
|
154
|
+
"severity": "critical",
|
155
|
+
"action": "Replace SMTP credentials with full AWS API credentials",
|
156
|
+
"explanation": "You're using SMTP-only credentials that can't manage SES settings. You need AWS API credentials with SES permissions to configure domains."
|
157
|
+
})
|
158
|
+
else:
|
159
|
+
recommendation.update({
|
160
|
+
"severity": "critical",
|
161
|
+
"action": "Fix AWS credentials or permissions",
|
162
|
+
"explanation": "Your AWS credentials are invalid, expired, or don't have the required SES permissions. Check your AWS access key and secret key."
|
163
|
+
})
|
164
|
+
|
165
|
+
# Resource-specific recommendations
|
166
|
+
elif resource == "ses.account.production_access":
|
167
|
+
if not item.desired.get("ProductionAccessEnabled"):
|
168
|
+
recommendation.update({
|
169
|
+
"action": "Request SES production access",
|
170
|
+
"explanation": "Your SES account is in sandbox mode. You can only send to verified email addresses. Request production access through AWS console to send to any email."
|
171
|
+
})
|
172
|
+
|
173
|
+
elif resource == "ses.identity.verification":
|
174
|
+
recommendation.update({
|
175
|
+
"action": "Verify your domain in AWS SES",
|
176
|
+
"explanation": "Add the required DNS TXT record to prove you own this domain. Check AWS SES console for the verification record."
|
177
|
+
})
|
178
|
+
|
179
|
+
elif resource == "ses.identity.dkim":
|
180
|
+
recommendation.update({
|
181
|
+
"action": "Set up DKIM for better email delivery",
|
182
|
+
"explanation": "Add DKIM DNS records to improve email authenticity and delivery rates. This helps prevent emails from being marked as spam."
|
183
|
+
})
|
184
|
+
|
185
|
+
elif resource == "ses.identity.notification_topics":
|
186
|
+
recommendation.update({
|
187
|
+
"severity": "medium",
|
188
|
+
"action": "Configure bounce and complaint handling",
|
189
|
+
"explanation": "Set up SNS topics to track bounced and complained emails. This is required for production email sending."
|
190
|
+
})
|
191
|
+
|
192
|
+
elif "s3_bucket" in resource:
|
193
|
+
recommendation.update({
|
194
|
+
"severity": "medium",
|
195
|
+
"action": "Create or configure S3 bucket for incoming emails",
|
196
|
+
"explanation": "If you want to receive emails, you need an S3 bucket where AWS will store incoming messages."
|
197
|
+
})
|
198
|
+
|
199
|
+
elif "receiving_rule" in resource:
|
200
|
+
recommendation.update({
|
201
|
+
"severity": "low",
|
202
|
+
"action": "Configure email receiving rules",
|
203
|
+
"explanation": "Set up SES rules to automatically process incoming emails and store them in S3."
|
204
|
+
})
|
205
|
+
|
206
|
+
else:
|
207
|
+
recommendation.update({
|
208
|
+
"action": "Review AWS SES configuration",
|
209
|
+
"explanation": "There's a configuration issue that needs attention. Check the AWS SES console for more details."
|
210
|
+
})
|
211
|
+
|
212
|
+
recommendations.append(recommendation)
|
213
|
+
|
214
|
+
# Add overall recommendations based on checks
|
215
|
+
checks = report.checks
|
216
|
+
if not checks.get("ses_verified") and not any(r["resource"] == "ses.identity.verification" for r in recommendations):
|
217
|
+
recommendations.insert(0, {
|
218
|
+
"resource": "domain_verification",
|
219
|
+
"severity": "critical",
|
220
|
+
"action": "Verify your domain ownership first",
|
221
|
+
"explanation": "Before you can send emails, you must prove you own this domain by adding a DNS record. This is the first step in email setup."
|
222
|
+
})
|
223
|
+
|
224
|
+
return recommendations
|
225
|
+
|
226
|
+
|
227
|
+
# Public API
|
228
|
+
def onboard_email_domain(
|
229
|
+
domain_pk: int,
|
230
|
+
*,
|
231
|
+
region: Optional[str] = None,
|
232
|
+
receiving_enabled: Optional[bool] = None,
|
233
|
+
s3_bucket: Optional[str] = None,
|
234
|
+
s3_prefix: Optional[str] = None,
|
235
|
+
ensure_mail_from: bool = False,
|
236
|
+
mail_from_subdomain: str = "feedback",
|
237
|
+
dns_mode: Optional[str] = None,
|
238
|
+
endpoints: Optional[Dict[str, str]] = None,
|
239
|
+
access_key: Optional[str] = None,
|
240
|
+
secret_key: Optional[str] = None,
|
241
|
+
godaddy_key: Optional[str] = None,
|
242
|
+
godaddy_secret: Optional[str] = None,
|
243
|
+
) -> OnboardResult:
|
244
|
+
"""
|
245
|
+
Onboard an email domain for AWS SES.
|
246
|
+
|
247
|
+
Args:
|
248
|
+
domain_pk: Primary key of the EmailDomain to onboard
|
249
|
+
region: AWS region (defaults to domain.region or settings.AWS_REGION)
|
250
|
+
receiving_enabled: Enable email receiving (defaults to domain.receiving_enabled)
|
251
|
+
s3_bucket: S3 bucket for incoming emails
|
252
|
+
s3_prefix: S3 prefix for incoming emails
|
253
|
+
ensure_mail_from: Configure MAIL FROM subdomain
|
254
|
+
mail_from_subdomain: Subdomain for MAIL FROM
|
255
|
+
dns_mode: "manual" or "godaddy" for DNS record application
|
256
|
+
endpoints: SNS endpoint configuration
|
257
|
+
access_key, secret_key: AWS credentials override
|
258
|
+
godaddy_key, godaddy_secret: GoDaddy API credentials for automatic DNS
|
259
|
+
|
260
|
+
Returns:
|
261
|
+
OnboardResult with DNS records, DKIM tokens, and configuration details
|
262
|
+
|
263
|
+
Raises:
|
264
|
+
EmailDomainNotFound: If domain_pk doesn't exist
|
265
|
+
InvalidConfiguration: If receiving_enabled but no s3_bucket
|
266
|
+
"""
|
267
|
+
domain = _get_domain(domain_pk)
|
268
|
+
|
269
|
+
# Resolve configuration with defaults
|
270
|
+
region = region or domain.region or getattr(settings, "AWS_REGION", "us-east-1")
|
271
|
+
receiving_enabled = receiving_enabled if receiving_enabled is not None else domain.receiving_enabled
|
272
|
+
s3_bucket = s3_bucket or domain.s3_inbound_bucket
|
273
|
+
s3_prefix = s3_prefix or domain.s3_inbound_prefix or ""
|
274
|
+
dns_mode = dns_mode or domain.dns_mode or "manual"
|
275
|
+
|
276
|
+
if receiving_enabled and not s3_bucket:
|
277
|
+
raise InvalidConfiguration("s3_bucket is required when receiving_enabled is true")
|
278
|
+
|
279
|
+
# Get AWS credentials
|
280
|
+
access_key_final, secret_key_final = _get_aws_credentials(domain, access_key, secret_key)
|
281
|
+
|
282
|
+
# Parse endpoints
|
283
|
+
sns_endpoints = _parse_endpoints(endpoints or {})
|
284
|
+
|
285
|
+
try:
|
286
|
+
result = onboard_domain(
|
287
|
+
domain=domain.name,
|
288
|
+
region=region,
|
289
|
+
access_key=access_key_final,
|
290
|
+
secret_key=secret_key_final,
|
291
|
+
receiving_enabled=receiving_enabled,
|
292
|
+
s3_bucket=s3_bucket,
|
293
|
+
s3_prefix=s3_prefix,
|
294
|
+
dns_mode=dns_mode,
|
295
|
+
ensure_mail_from=ensure_mail_from,
|
296
|
+
mail_from_subdomain=mail_from_subdomain,
|
297
|
+
endpoints=sns_endpoints,
|
298
|
+
)
|
299
|
+
|
300
|
+
# Apply DNS via GoDaddy if requested
|
301
|
+
if dns_mode == "godaddy" and godaddy_key and godaddy_secret:
|
302
|
+
apply_dns_records_godaddy(
|
303
|
+
domain=domain.name,
|
304
|
+
records=result.dns_records,
|
305
|
+
api_key=godaddy_key,
|
306
|
+
api_secret=godaddy_secret,
|
307
|
+
)
|
308
|
+
result.notes.append("Applied DNS via GoDaddy")
|
309
|
+
elif dns_mode == "godaddy":
|
310
|
+
result.notes.append("DNS mode is GoDaddy but credentials not provided; returning records for manual apply")
|
311
|
+
|
312
|
+
# Update domain configuration
|
313
|
+
updates = {}
|
314
|
+
if domain.region != region:
|
315
|
+
updates["region"] = region
|
316
|
+
if domain.receiving_enabled != receiving_enabled:
|
317
|
+
updates["receiving_enabled"] = receiving_enabled
|
318
|
+
if s3_bucket and domain.s3_inbound_bucket != s3_bucket:
|
319
|
+
updates["s3_inbound_bucket"] = s3_bucket
|
320
|
+
if (s3_prefix or "") != (domain.s3_inbound_prefix or ""):
|
321
|
+
updates["s3_inbound_prefix"] = s3_prefix
|
322
|
+
if dns_mode and domain.dns_mode != dns_mode:
|
323
|
+
updates["dns_mode"] = dns_mode
|
324
|
+
|
325
|
+
if updates:
|
326
|
+
for k, v in updates.items():
|
327
|
+
setattr(domain, k, v)
|
328
|
+
domain.save(update_fields=list(updates.keys()) + ["modified"])
|
329
|
+
|
330
|
+
return OnboardResult(
|
331
|
+
domain=result.domain,
|
332
|
+
region=result.region,
|
333
|
+
dns_records=_dns_records_to_dict(result.dns_records),
|
334
|
+
dkim_tokens=result.dkim_tokens,
|
335
|
+
topic_arns=result.topic_arns,
|
336
|
+
receipt_rule=result.receipt_rule,
|
337
|
+
rule_set=result.rule_set,
|
338
|
+
notes=result.notes,
|
339
|
+
)
|
340
|
+
|
341
|
+
except Exception as e:
|
342
|
+
logger.error(f"onboard error for domain {domain.name}: {e}")
|
343
|
+
raise
|
344
|
+
|
345
|
+
|
346
|
+
def audit_email_domain(
|
347
|
+
domain_pk: int,
|
348
|
+
*,
|
349
|
+
region: Optional[str] = None,
|
350
|
+
access_key: Optional[str] = None,
|
351
|
+
secret_key: Optional[str] = None,
|
352
|
+
rule_set: Optional[str] = None,
|
353
|
+
rule_name: Optional[str] = None,
|
354
|
+
) -> AuditResult:
|
355
|
+
"""
|
356
|
+
Audit AWS SES configuration for an email domain.
|
357
|
+
|
358
|
+
Args:
|
359
|
+
domain_pk: Primary key of the EmailDomain to audit
|
360
|
+
region: AWS region override
|
361
|
+
access_key, secret_key: AWS credentials override
|
362
|
+
rule_set: SES receiving rule set name
|
363
|
+
rule_name: SES receiving rule name
|
364
|
+
|
365
|
+
Returns:
|
366
|
+
AuditResult with configuration status and drift analysis
|
367
|
+
|
368
|
+
Raises:
|
369
|
+
EmailDomainNotFound: If domain_pk doesn't exist
|
370
|
+
"""
|
371
|
+
domain = _get_domain(domain_pk)
|
372
|
+
|
373
|
+
region = region or domain.region or getattr(settings, "AWS_REGION", "us-east-1")
|
374
|
+
access_key_final, secret_key_final = _get_aws_credentials(domain, access_key, secret_key)
|
375
|
+
|
376
|
+
# Configure desired receiving if enabled
|
377
|
+
desired_receiving = None
|
378
|
+
if domain.receiving_enabled and domain.s3_inbound_bucket:
|
379
|
+
desired_receiving = {
|
380
|
+
"bucket": domain.s3_inbound_bucket,
|
381
|
+
"prefix": domain.s3_inbound_prefix or "",
|
382
|
+
"rule_set": rule_set or "mojo-default-receiving",
|
383
|
+
"rule_name": rule_name or f"mojo-{domain.name}-catchall",
|
384
|
+
"inbound_topic_arn": getattr(domain, "sns_topic_inbound_arn", None),
|
385
|
+
}
|
386
|
+
|
387
|
+
try:
|
388
|
+
report = audit_domain_config(
|
389
|
+
domain=domain.name,
|
390
|
+
region=region,
|
391
|
+
access_key=access_key_final,
|
392
|
+
secret_key=secret_key_final,
|
393
|
+
desired_receiving=desired_receiving,
|
394
|
+
desired_topics={
|
395
|
+
"bounce": getattr(domain, "sns_topic_bounce_arn", None),
|
396
|
+
"complaint": getattr(domain, "sns_topic_complaint_arn", None),
|
397
|
+
"delivery": getattr(domain, "sns_topic_delivery_arn", None),
|
398
|
+
},
|
399
|
+
)
|
400
|
+
|
401
|
+
# Update domain status based on audit results
|
402
|
+
can_send = bool(
|
403
|
+
report.checks.get("ses_verified") and
|
404
|
+
report.checks.get("dkim_verified") and
|
405
|
+
report.checks.get("ses_production_access") and
|
406
|
+
report.checks.get("notification_topics_ok")
|
407
|
+
)
|
408
|
+
|
409
|
+
can_recv = False
|
410
|
+
if domain.receiving_enabled:
|
411
|
+
can_recv = bool(
|
412
|
+
report.checks.get("s3_bucket_exists") and
|
413
|
+
report.checks.get("receiving_rule_s3_ok") and
|
414
|
+
report.checks.get("receiving_rule_sns_ok") and
|
415
|
+
report.checks.get("sns_topics_exist") and
|
416
|
+
report.checks.get("sns_subscriptions_confirmed")
|
417
|
+
)
|
418
|
+
|
419
|
+
# Determine status: "verified" if SES domain is verified, "ready" if fully configured, else "missing"
|
420
|
+
if report.checks.get("ses_verified"):
|
421
|
+
new_status = "verified"
|
422
|
+
if report.audit_pass:
|
423
|
+
new_status = "ready"
|
424
|
+
else:
|
425
|
+
new_status = "missing"
|
426
|
+
|
427
|
+
# Track what we're updating for debugging
|
428
|
+
updates = {}
|
429
|
+
if domain.status != new_status:
|
430
|
+
updates["status"] = new_status
|
431
|
+
if domain.can_send != can_send:
|
432
|
+
updates["can_send"] = can_send
|
433
|
+
if domain.can_recv != can_recv:
|
434
|
+
updates["can_recv"] = can_recv
|
435
|
+
|
436
|
+
if updates:
|
437
|
+
logger.info(f"Updating domain {domain.name} (pk={domain_pk}) with changes: {updates}")
|
438
|
+
for k, v in updates.items():
|
439
|
+
setattr(domain, k, v)
|
440
|
+
domain.save(update_fields=list(updates.keys()) + ["modified"])
|
441
|
+
logger.info(f"Successfully updated domain {domain.name} status to '{new_status}'")
|
442
|
+
else:
|
443
|
+
logger.info(f"Domain {domain.name} status unchanged: {domain.status}")
|
444
|
+
|
445
|
+
return AuditResult(
|
446
|
+
domain=report.domain,
|
447
|
+
region=report.region,
|
448
|
+
status=report.status,
|
449
|
+
audit_pass=report.audit_pass,
|
450
|
+
checks=report.checks,
|
451
|
+
items=report.items,
|
452
|
+
report=report # Store original for recommendations
|
453
|
+
)
|
454
|
+
|
455
|
+
except Exception as e:
|
456
|
+
logger.error(f"Audit error for domain {domain.name}: {e}")
|
457
|
+
raise
|
458
|
+
|
459
|
+
|
460
|
+
def reconcile_email_domain(
|
461
|
+
domain_pk: int,
|
462
|
+
*,
|
463
|
+
region: Optional[str] = None,
|
464
|
+
receiving_enabled: Optional[bool] = None,
|
465
|
+
s3_bucket: Optional[str] = None,
|
466
|
+
s3_prefix: Optional[str] = None,
|
467
|
+
ensure_mail_from: bool = False,
|
468
|
+
mail_from_subdomain: str = "feedback",
|
469
|
+
endpoints: Optional[Dict[str, str]] = None,
|
470
|
+
access_key: Optional[str] = None,
|
471
|
+
secret_key: Optional[str] = None,
|
472
|
+
) -> ReconcileResult:
|
473
|
+
"""
|
474
|
+
Reconcile AWS SES/SNS configuration for an email domain.
|
475
|
+
|
476
|
+
Args:
|
477
|
+
domain_pk: Primary key of the EmailDomain to reconcile
|
478
|
+
region: AWS region override
|
479
|
+
receiving_enabled: Enable email receiving
|
480
|
+
s3_bucket: S3 bucket for incoming emails
|
481
|
+
s3_prefix: S3 prefix for incoming emails
|
482
|
+
ensure_mail_from: Configure MAIL FROM subdomain
|
483
|
+
mail_from_subdomain: Subdomain for MAIL FROM
|
484
|
+
endpoints: SNS endpoint configuration
|
485
|
+
access_key, secret_key: AWS credentials override
|
486
|
+
|
487
|
+
Returns:
|
488
|
+
ReconcileResult with applied configuration changes
|
489
|
+
|
490
|
+
Raises:
|
491
|
+
EmailDomainNotFound: If domain_pk doesn't exist
|
492
|
+
InvalidConfiguration: If receiving_enabled but no s3_bucket
|
493
|
+
"""
|
494
|
+
domain = _get_domain(domain_pk)
|
495
|
+
|
496
|
+
region = region or domain.region or getattr(settings, "AWS_REGION", "us-east-1")
|
497
|
+
receiving_enabled = receiving_enabled if receiving_enabled is not None else domain.receiving_enabled
|
498
|
+
s3_bucket = s3_bucket or domain.s3_inbound_bucket
|
499
|
+
s3_prefix = s3_prefix or domain.s3_inbound_prefix or ""
|
500
|
+
|
501
|
+
if receiving_enabled and not s3_bucket:
|
502
|
+
raise InvalidConfiguration("s3_bucket is required when receiving_enabled is true")
|
503
|
+
|
504
|
+
access_key_final, secret_key_final = _get_aws_credentials(domain, access_key, secret_key)
|
505
|
+
sns_endpoints = _parse_endpoints(endpoints or {})
|
506
|
+
|
507
|
+
try:
|
508
|
+
result = reconcile_domain_config(
|
509
|
+
domain=domain.name,
|
510
|
+
region=region,
|
511
|
+
receiving_enabled=receiving_enabled,
|
512
|
+
s3_bucket=s3_bucket,
|
513
|
+
s3_prefix=s3_prefix,
|
514
|
+
endpoints=sns_endpoints,
|
515
|
+
access_key=access_key_final,
|
516
|
+
secret_key=secret_key_final,
|
517
|
+
ensure_mail_from=ensure_mail_from,
|
518
|
+
mail_from_subdomain=mail_from_subdomain,
|
519
|
+
)
|
520
|
+
|
521
|
+
# Update domain configuration
|
522
|
+
updates = {}
|
523
|
+
if domain.region != region:
|
524
|
+
updates["region"] = region
|
525
|
+
if domain.receiving_enabled != receiving_enabled:
|
526
|
+
updates["receiving_enabled"] = receiving_enabled
|
527
|
+
if s3_bucket and domain.s3_inbound_bucket != s3_bucket:
|
528
|
+
updates["s3_inbound_bucket"] = s3_bucket
|
529
|
+
if (s3_prefix or "") != (domain.s3_inbound_prefix or ""):
|
530
|
+
updates["s3_inbound_prefix"] = s3_prefix
|
531
|
+
|
532
|
+
if updates:
|
533
|
+
for k, v in updates.items():
|
534
|
+
setattr(domain, k, v)
|
535
|
+
domain.save(update_fields=list(updates.keys()) + ["modified"])
|
536
|
+
|
537
|
+
return ReconcileResult(
|
538
|
+
domain=domain.name,
|
539
|
+
region=region,
|
540
|
+
topic_arns=result.topic_arns,
|
541
|
+
receipt_rule=result.receipt_rule,
|
542
|
+
rule_set=result.rule_set,
|
543
|
+
notes=result.notes,
|
544
|
+
)
|
545
|
+
|
546
|
+
except Exception as e:
|
547
|
+
logger.error(f"reconcile error for domain {domain.name}: {e}")
|
548
|
+
raise
|
@@ -0,0 +1,25 @@
|
|
1
|
+
from pygments import highlight
|
2
|
+
from pygments.lexers import get_lexer_by_name
|
3
|
+
from pygments.formatters import HtmlFormatter
|
4
|
+
from mistune.util import escape_html
|
5
|
+
|
6
|
+
def _render_fenced_code_with_highlight(renderer, token, state):
|
7
|
+
lang = token['attrs'].get('lang')
|
8
|
+
code = token['text']
|
9
|
+
|
10
|
+
if not lang:
|
11
|
+
return f'<pre><code>{escape_html(code)}</code></pre>\n'
|
12
|
+
try:
|
13
|
+
lexer = get_lexer_by_name(lang, stripall=True)
|
14
|
+
formatter = HtmlFormatter()
|
15
|
+
return highlight(code, lexer, formatter)
|
16
|
+
except Exception:
|
17
|
+
return f'<pre><code class="language-{lang}">{escape_html(code)}</code></pre>\n'
|
18
|
+
|
19
|
+
def plugin_highlight(md):
|
20
|
+
"""
|
21
|
+
A mistune v3 plugin for syntax highlighting of fenced code blocks.
|
22
|
+
"""
|
23
|
+
md.renderer.register('fenced_code', _render_fenced_code_with_highlight)
|
24
|
+
|
25
|
+
plugin = plugin_highlight
|
@@ -0,0 +1,12 @@
|
|
1
|
+
def plugin_toc(md):
|
2
|
+
"""
|
3
|
+
A mistune v3 plugin to support a table of contents placeholder [TOC].
|
4
|
+
This uses a render hook to perform a simple text substitution.
|
5
|
+
"""
|
6
|
+
def before_render_hook(renderer, text, state):
|
7
|
+
# Simple text replacement for the [TOC] placeholder
|
8
|
+
return text.replace('[TOC]', '<div class="toc"></div>')
|
9
|
+
|
10
|
+
md.before_render_hooks.append(before_render_hook)
|
11
|
+
|
12
|
+
plugin = plugin_toc
|