granny-devops 0.4.0__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.
- granny/__init__.py +19 -0
- granny/analyze/__init__.py +6 -0
- granny/analyze/lambdas.py +59 -0
- granny/analyze/vpcs.py +57 -0
- granny/cdn/__init__.py +9 -0
- granny/cdn/bunny.py +231 -0
- granny/cli/__init__.py +0 -0
- granny/cli/analyze.py +66 -0
- granny/cli/cdn.py +210 -0
- granny/cli/create.py +94 -0
- granny/cli/credentials.py +99 -0
- granny/cli/dns.py +290 -0
- granny/cli/docker.py +165 -0
- granny/cli/edge.py +106 -0
- granny/cli/email.py +224 -0
- granny/cli/main.py +98 -0
- granny/cli/serverless.py +278 -0
- granny/cli/storage.py +249 -0
- granny/create/__init__.py +4 -0
- granny/create/auto_certificate.py +1899 -0
- granny/create/cloudfront-security-headers.js +53 -0
- granny/create/manage-dns.sh +321 -0
- granny/create/manage_mailjet_contacts.py +619 -0
- granny/create/registrars.py +363 -0
- granny/create/setup_aws_cloudfront.py +2808 -0
- granny/create/setup_bunny_edge_script.py +923 -0
- granny/create/setup_bunny_storage.py +1719 -0
- granny/create/setup_cognito_identity_pool.py +740 -0
- granny/create/setup_hetzner_bunny.py +1482 -0
- granny/create/setup_mailjet_dns.py +1103 -0
- granny/create/setup_private_cdn.py +547 -0
- granny/create/setup_s3_website.py +1512 -0
- granny/create/setup_scaleway_faas.py +1165 -0
- granny/create/setup_workmail.py +1217 -0
- granny/create/www-redirect-function.js +17 -0
- granny/credentials/__init__.py +15 -0
- granny/credentials/secrets.py +403 -0
- granny/dns/__init__.py +22 -0
- granny/dns/base.py +113 -0
- granny/dns/bunny.py +150 -0
- granny/dns/cloudflare.py +192 -0
- granny/dns/cloudns.py +162 -0
- granny/dns/desec.py +152 -0
- granny/dns/factory.py +72 -0
- granny/dns/hetzner.py +165 -0
- granny/dns/manual.py +64 -0
- granny/dns/records.py +29 -0
- granny/docker/__init__.py +5 -0
- granny/docker/build_base.py +204 -0
- granny/edge/__init__.py +5 -0
- granny/edge/bunny.py +147 -0
- granny/email/__init__.py +7 -0
- granny/email/mailjet.py +119 -0
- granny/email/mailjet_contacts.py +115 -0
- granny/email/ses_forwarding.py +281 -0
- granny/email/workmail.py +145 -0
- granny/report.py +128 -0
- granny/serverless/__init__.py +5 -0
- granny/serverless/scaleway.py +264 -0
- granny/storage/__init__.py +7 -0
- granny/storage/aws.py +113 -0
- granny/storage/bunny.py +98 -0
- granny/storage/hetzner.py +118 -0
- granny_devops-0.4.0.dist-info/METADATA +445 -0
- granny_devops-0.4.0.dist-info/RECORD +68 -0
- granny_devops-0.4.0.dist-info/WHEEL +4 -0
- granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
- granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Mailjet DNS Authentication Setup Script
|
|
4
|
+
|
|
5
|
+
Automates SPF, DKIM, and DMARC DNS record setup for Mailjet email sending.
|
|
6
|
+
Connects to the Mailjet API to retrieve required DNS values, then creates/updates
|
|
7
|
+
TXT records via Cloudflare or Bunny DNS.
|
|
8
|
+
|
|
9
|
+
FEATURES:
|
|
10
|
+
- Registers domain sender (*@domain) in Mailjet
|
|
11
|
+
- Retrieves DKIM public key and SPF include from Mailjet API
|
|
12
|
+
- Smart SPF merge: inserts include:spf.mailjet.com into existing SPF records
|
|
13
|
+
- Upgrades weak SPF qualifiers (?all -> ~all)
|
|
14
|
+
- Creates DKIM TXT record (mailjet._domainkey)
|
|
15
|
+
- Optional DMARC record creation with configurable policy
|
|
16
|
+
- Optional domain ownership verification TXT record
|
|
17
|
+
- Dry-run mode for previewing changes
|
|
18
|
+
- Idempotent: safe to run multiple times
|
|
19
|
+
- Markdown setup report generation
|
|
20
|
+
|
|
21
|
+
SUPPORTED DNS PROVIDERS:
|
|
22
|
+
- Cloudflare (via official SDK)
|
|
23
|
+
- Bunny DNS (via REST API)
|
|
24
|
+
|
|
25
|
+
Environment Variables:
|
|
26
|
+
MAILJET_API_KEY Mailjet API public key
|
|
27
|
+
MAILJET_SECRET_KEY Mailjet API private key
|
|
28
|
+
CLOUDFLARE_API_TOKEN Cloudflare API token (for Cloudflare DNS)
|
|
29
|
+
BUNNY_API_KEY Bunny.net API key (for Bunny DNS)
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
# Preview changes (dry run)
|
|
33
|
+
python setup_mailjet_dns.py --domain example.com --dry-run
|
|
34
|
+
|
|
35
|
+
# Full setup with Cloudflare DNS
|
|
36
|
+
python setup_mailjet_dns.py --domain example.com --dmarc --dmarc-rua admin@example.com
|
|
37
|
+
|
|
38
|
+
# Setup with Bunny DNS
|
|
39
|
+
python setup_mailjet_dns.py --domain example.com --dns-provider bunny --dmarc
|
|
40
|
+
|
|
41
|
+
# Include ownership verification record
|
|
42
|
+
python setup_mailjet_dns.py --domain example.com --ownership-record --dmarc
|
|
43
|
+
|
|
44
|
+
# Check current DNS authentication status
|
|
45
|
+
python setup_mailjet_dns.py --domain example.com --check
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import os
|
|
49
|
+
import time
|
|
50
|
+
import logging
|
|
51
|
+
import argparse
|
|
52
|
+
import requests
|
|
53
|
+
from typing import Optional, List, Tuple
|
|
54
|
+
|
|
55
|
+
from dotenv import load_dotenv
|
|
56
|
+
load_dotenv()
|
|
57
|
+
try:
|
|
58
|
+
from granny.credentials import load_secrets_into_env
|
|
59
|
+
load_secrets_into_env()
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# Optional: Cloudflare SDK
|
|
64
|
+
try:
|
|
65
|
+
from cloudflare import Cloudflare
|
|
66
|
+
except ImportError:
|
|
67
|
+
Cloudflare = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# =============================================================================
|
|
71
|
+
# Mailjet API Client
|
|
72
|
+
# =============================================================================
|
|
73
|
+
|
|
74
|
+
class MailjetClient:
|
|
75
|
+
"""Mailjet REST API v3 client for sender and DNS management."""
|
|
76
|
+
|
|
77
|
+
API_BASE = "https://api.mailjet.com/v3/REST"
|
|
78
|
+
|
|
79
|
+
def __init__(self):
|
|
80
|
+
api_key = os.environ.get("MAILJET_API_KEY")
|
|
81
|
+
secret_key = os.environ.get("MAILJET_SECRET_KEY")
|
|
82
|
+
if not api_key or not secret_key:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"MAILJET_API_KEY and MAILJET_SECRET_KEY environment variables must be set."
|
|
85
|
+
)
|
|
86
|
+
self.auth = (api_key, secret_key)
|
|
87
|
+
self.session = requests.Session()
|
|
88
|
+
self.session.auth = self.auth
|
|
89
|
+
self.session.headers.update({"Content-Type": "application/json"})
|
|
90
|
+
|
|
91
|
+
def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
|
92
|
+
"""Make an API request to Mailjet REST API."""
|
|
93
|
+
url = f"{self.API_BASE}{endpoint}"
|
|
94
|
+
response = getattr(self.session, method.lower())(url, json=data)
|
|
95
|
+
|
|
96
|
+
if response.status_code >= 400:
|
|
97
|
+
raise Exception(
|
|
98
|
+
f"Mailjet API error: {response.status_code} - {response.text}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return response.json() if response.text else {}
|
|
102
|
+
|
|
103
|
+
def find_sender(self, domain: str) -> Optional[dict]:
|
|
104
|
+
"""Find an existing sender for the domain (catch-all *@domain)."""
|
|
105
|
+
result = self._api_request("GET", "/sender")
|
|
106
|
+
catch_all = f"*@{domain}"
|
|
107
|
+
for sender in result.get("Data", []):
|
|
108
|
+
if sender.get("Email", "").lower() == catch_all.lower():
|
|
109
|
+
return sender
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def create_sender(self, domain: str) -> dict:
|
|
113
|
+
"""Create a catch-all sender *@domain for the given domain."""
|
|
114
|
+
catch_all = f"*@{domain}"
|
|
115
|
+
logging.info(f"Creating Mailjet sender: {catch_all}")
|
|
116
|
+
result = self._api_request("POST", "/sender", {"Email": catch_all})
|
|
117
|
+
sender = result.get("Data", [{}])[0]
|
|
118
|
+
logging.info(f"Sender created. ID: {sender.get('ID')}, DNSID: {sender.get('DNSID')}")
|
|
119
|
+
return sender
|
|
120
|
+
|
|
121
|
+
def get_or_create_sender(self, domain: str) -> dict:
|
|
122
|
+
"""Get existing sender or create a new one."""
|
|
123
|
+
sender = self.find_sender(domain)
|
|
124
|
+
if sender:
|
|
125
|
+
logging.info(
|
|
126
|
+
f"Found existing sender: *@{domain} "
|
|
127
|
+
f"(ID: {sender.get('ID')}, Status: {sender.get('Status')})"
|
|
128
|
+
)
|
|
129
|
+
return sender
|
|
130
|
+
return self.create_sender(domain)
|
|
131
|
+
|
|
132
|
+
def get_dns_settings(self, domain: str) -> dict:
|
|
133
|
+
"""Retrieve DNS authentication settings for a domain.
|
|
134
|
+
|
|
135
|
+
Returns dict with keys:
|
|
136
|
+
DKIMRecordName, DKIMRecordValue, DKIMStatus,
|
|
137
|
+
SPFRecordValue, SPFStatus,
|
|
138
|
+
OwnerShipToken, OwnerShipTokenRecordName,
|
|
139
|
+
ID (dns_id)
|
|
140
|
+
"""
|
|
141
|
+
result = self._api_request("GET", f"/dns/{domain}")
|
|
142
|
+
dns_data = result.get("Data", [{}])[0]
|
|
143
|
+
logging.info(f"Retrieved DNS settings for {domain}")
|
|
144
|
+
logging.info(f" DKIM status: {dns_data.get('DKIMStatus')}")
|
|
145
|
+
logging.info(f" SPF status: {dns_data.get('SPFStatus')}")
|
|
146
|
+
return dns_data
|
|
147
|
+
|
|
148
|
+
def check_dns(self, dns_id: str) -> dict:
|
|
149
|
+
"""Trigger Mailjet DNS verification check."""
|
|
150
|
+
logging.info(f"Triggering Mailjet DNS verification (ID: {dns_id})...")
|
|
151
|
+
result = self._api_request("POST", f"/dns/{dns_id}/check", {})
|
|
152
|
+
dns_data = result.get("Data", [{}])[0]
|
|
153
|
+
logging.info(f" DKIM status: {dns_data.get('DKIMStatus')}")
|
|
154
|
+
logging.info(f" SPF status: {dns_data.get('SPFStatus')}")
|
|
155
|
+
return dns_data
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# =============================================================================
|
|
159
|
+
# DNS Providers (TXT record focused)
|
|
160
|
+
# =============================================================================
|
|
161
|
+
|
|
162
|
+
class CloudflareTXTProvider:
|
|
163
|
+
"""Cloudflare DNS provider for TXT record management."""
|
|
164
|
+
|
|
165
|
+
def __init__(self):
|
|
166
|
+
if Cloudflare is None:
|
|
167
|
+
raise ImportError(
|
|
168
|
+
"Cloudflare library not found. Install it: pip install cloudflare"
|
|
169
|
+
)
|
|
170
|
+
token = os.environ.get("CLOUDFLARE_API_TOKEN")
|
|
171
|
+
if not token:
|
|
172
|
+
raise ValueError("CLOUDFLARE_API_TOKEN environment variable not set.")
|
|
173
|
+
self.client = Cloudflare(api_token=token)
|
|
174
|
+
|
|
175
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
176
|
+
"""Get Cloudflare zone ID for the domain."""
|
|
177
|
+
zones = self.client.zones.list(name=zone_name)
|
|
178
|
+
if not zones.result:
|
|
179
|
+
raise Exception(f"Zone not found in Cloudflare: {zone_name}")
|
|
180
|
+
zone_id = zones.result[0].id
|
|
181
|
+
logging.info(f"Found Cloudflare zone: {zone_name} (ID: {zone_id})")
|
|
182
|
+
return zone_id
|
|
183
|
+
|
|
184
|
+
def get_txt_records(self, zone_id: str, name: str) -> List[dict]:
|
|
185
|
+
"""Get all TXT records for a given name."""
|
|
186
|
+
records = self.client.dns.records.list(zone_id=zone_id, name=name, type="TXT")
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
"id": r.id,
|
|
190
|
+
"name": r.name,
|
|
191
|
+
"content": r.content,
|
|
192
|
+
}
|
|
193
|
+
for r in (records.result or [])
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
def create_or_update_txt(self, zone_id: str, name: str, value: str,
|
|
197
|
+
zone_name: str) -> str:
|
|
198
|
+
"""Create or update a TXT record. Returns action taken."""
|
|
199
|
+
full_name = name if name == zone_name or '.' in name else f"{name}.{zone_name}"
|
|
200
|
+
|
|
201
|
+
existing = self.get_txt_records(zone_id, full_name)
|
|
202
|
+
|
|
203
|
+
# For SPF records, find the existing SPF specifically
|
|
204
|
+
is_spf = value.startswith("v=spf1")
|
|
205
|
+
if is_spf:
|
|
206
|
+
for record in existing:
|
|
207
|
+
if record["content"].startswith("v=spf1"):
|
|
208
|
+
if record["content"] == value:
|
|
209
|
+
logging.info(f"TXT record '{full_name}' (SPF) already correct.")
|
|
210
|
+
return "exists"
|
|
211
|
+
self.client.dns.records.update(
|
|
212
|
+
zone_id=zone_id,
|
|
213
|
+
dns_record_id=record["id"],
|
|
214
|
+
name=name,
|
|
215
|
+
type="TXT",
|
|
216
|
+
content=value,
|
|
217
|
+
)
|
|
218
|
+
logging.info(f"TXT record '{full_name}' (SPF) updated.")
|
|
219
|
+
return "updated"
|
|
220
|
+
else:
|
|
221
|
+
# Non-SPF TXT: match by exact content or by name prefix
|
|
222
|
+
for record in existing:
|
|
223
|
+
if record["content"] == value:
|
|
224
|
+
logging.info(f"TXT record '{full_name}' already correct.")
|
|
225
|
+
return "exists"
|
|
226
|
+
# For DKIM/ownership, update if same name exists with different value
|
|
227
|
+
if not record["content"].startswith("v=spf1"):
|
|
228
|
+
# Check if this is the same record type (DKIM key or ownership token)
|
|
229
|
+
is_dkim_name = "_domainkey" in full_name
|
|
230
|
+
is_dmarc_name = full_name.startswith("_dmarc")
|
|
231
|
+
is_ownership = not is_dkim_name and not is_dmarc_name
|
|
232
|
+
record_is_dkim = "DKIM" in record["content"] or "k=rsa" in record["content"]
|
|
233
|
+
|
|
234
|
+
if (is_dkim_name and record_is_dkim) or is_dmarc_name or is_ownership:
|
|
235
|
+
self.client.dns.records.update(
|
|
236
|
+
zone_id=zone_id,
|
|
237
|
+
dns_record_id=record["id"],
|
|
238
|
+
name=name,
|
|
239
|
+
type="TXT",
|
|
240
|
+
content=value,
|
|
241
|
+
)
|
|
242
|
+
logging.info(f"TXT record '{full_name}' updated.")
|
|
243
|
+
return "updated"
|
|
244
|
+
|
|
245
|
+
# Create new record
|
|
246
|
+
self.client.dns.records.create(
|
|
247
|
+
zone_id=zone_id,
|
|
248
|
+
name=name,
|
|
249
|
+
type="TXT",
|
|
250
|
+
content=value,
|
|
251
|
+
proxied=False,
|
|
252
|
+
)
|
|
253
|
+
logging.info(f"TXT record '{full_name}' created.")
|
|
254
|
+
return "created"
|
|
255
|
+
|
|
256
|
+
def delete_txt_record(self, zone_id: str, record_id: str) -> None:
|
|
257
|
+
"""Delete a TXT record by ID."""
|
|
258
|
+
self.client.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
|
259
|
+
logging.info(f"TXT record deleted (ID: {record_id})")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class BunnyTXTProvider:
|
|
263
|
+
"""Bunny DNS provider for TXT record management."""
|
|
264
|
+
|
|
265
|
+
API_BASE = "https://api.bunny.net"
|
|
266
|
+
|
|
267
|
+
RECORD_TYPES = {
|
|
268
|
+
'A': 0,
|
|
269
|
+
'AAAA': 1,
|
|
270
|
+
'CNAME': 2,
|
|
271
|
+
'TXT': 3,
|
|
272
|
+
'MX': 4,
|
|
273
|
+
'Redirect': 5,
|
|
274
|
+
'Flatten': 6,
|
|
275
|
+
'PullZone': 7,
|
|
276
|
+
'SRV': 8,
|
|
277
|
+
'CAA': 9,
|
|
278
|
+
'PTR': 10,
|
|
279
|
+
'Script': 11,
|
|
280
|
+
'NS': 12,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
def __init__(self):
|
|
284
|
+
api_key = os.environ.get("BUNNY_API_KEY")
|
|
285
|
+
if not api_key:
|
|
286
|
+
raise ValueError("BUNNY_API_KEY environment variable not set.")
|
|
287
|
+
self.api_key = api_key
|
|
288
|
+
self.session = requests.Session()
|
|
289
|
+
self.session.headers.update({
|
|
290
|
+
"AccessKey": api_key,
|
|
291
|
+
"Content-Type": "application/json"
|
|
292
|
+
})
|
|
293
|
+
self._zone_cache = {}
|
|
294
|
+
|
|
295
|
+
def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
|
296
|
+
"""Make an API request to Bunny.net API."""
|
|
297
|
+
url = f"{self.API_BASE}{endpoint}"
|
|
298
|
+
response = getattr(self.session, method.lower())(url, json=data)
|
|
299
|
+
|
|
300
|
+
if response.status_code >= 400:
|
|
301
|
+
raise Exception(f"Bunny API error: {response.status_code} - {response.text}")
|
|
302
|
+
|
|
303
|
+
return response.json() if response.text and response.status_code not in [204] else {}
|
|
304
|
+
|
|
305
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
306
|
+
"""Get zone ID, looking up by domain name."""
|
|
307
|
+
if zone_name in self._zone_cache:
|
|
308
|
+
return self._zone_cache[zone_name]
|
|
309
|
+
|
|
310
|
+
result = self._api_request("GET", "/dnszone")
|
|
311
|
+
zones = result.get('Items', []) if isinstance(result, dict) else result
|
|
312
|
+
|
|
313
|
+
for zone in zones:
|
|
314
|
+
if zone.get('Domain') == zone_name:
|
|
315
|
+
zone_id = str(zone.get('Id'))
|
|
316
|
+
self._zone_cache[zone_name] = zone_id
|
|
317
|
+
logging.info(f"Found Bunny DNS zone: {zone_name} (ID: {zone_id})")
|
|
318
|
+
return zone_id
|
|
319
|
+
|
|
320
|
+
raise Exception(f"Zone not found in Bunny DNS: {zone_name}")
|
|
321
|
+
|
|
322
|
+
def _get_zone_records(self, zone_id: str) -> List[dict]:
|
|
323
|
+
"""Get all records for a zone."""
|
|
324
|
+
result = self._api_request("GET", f"/dnszone/{zone_id}")
|
|
325
|
+
return result.get('Records', [])
|
|
326
|
+
|
|
327
|
+
def get_txt_records(self, zone_id: str, name: str) -> List[dict]:
|
|
328
|
+
"""Get all TXT records for a given name."""
|
|
329
|
+
records = self._get_zone_records(zone_id)
|
|
330
|
+
txt_type = self.RECORD_TYPES['TXT']
|
|
331
|
+
return [
|
|
332
|
+
{
|
|
333
|
+
"id": r.get('Id'),
|
|
334
|
+
"name": r.get('Name'),
|
|
335
|
+
"content": r.get('Value'),
|
|
336
|
+
}
|
|
337
|
+
for r in records
|
|
338
|
+
if r.get('Type') == txt_type and r.get('Name') == name
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
def create_or_update_txt(self, zone_id: str, name: str, value: str,
|
|
342
|
+
zone_name: str) -> str:
|
|
343
|
+
"""Create or update a TXT record. Returns action taken."""
|
|
344
|
+
existing = self.get_txt_records(zone_id, name)
|
|
345
|
+
|
|
346
|
+
is_spf = value.startswith("v=spf1")
|
|
347
|
+
if is_spf:
|
|
348
|
+
for record in existing:
|
|
349
|
+
if record["content"].startswith("v=spf1"):
|
|
350
|
+
if record["content"] == value:
|
|
351
|
+
logging.info(f"TXT record '{name}.{zone_name}' (SPF) already correct.")
|
|
352
|
+
return "exists"
|
|
353
|
+
# Delete old SPF, create new
|
|
354
|
+
self._api_request("DELETE", f"/dnszone/{zone_id}/records/{record['id']}")
|
|
355
|
+
logging.info(f"Deleted old SPF record for '{name}.{zone_name}'")
|
|
356
|
+
break
|
|
357
|
+
else:
|
|
358
|
+
for record in existing:
|
|
359
|
+
if record["content"] == value:
|
|
360
|
+
logging.info(f"TXT record '{name}.{zone_name}' already correct.")
|
|
361
|
+
return "exists"
|
|
362
|
+
# Update if same type of record
|
|
363
|
+
if not record["content"].startswith("v=spf1"):
|
|
364
|
+
self._api_request("DELETE", f"/dnszone/{zone_id}/records/{record['id']}")
|
|
365
|
+
logging.info(f"Deleted old TXT record for '{name}.{zone_name}'")
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
# Create new record
|
|
369
|
+
data = {
|
|
370
|
+
"Type": self.RECORD_TYPES['TXT'],
|
|
371
|
+
"Name": name,
|
|
372
|
+
"Value": value,
|
|
373
|
+
"Ttl": 300,
|
|
374
|
+
}
|
|
375
|
+
self._api_request("PUT", f"/dnszone/{zone_id}/records", data)
|
|
376
|
+
logging.info(f"TXT record '{name}.{zone_name}' created.")
|
|
377
|
+
return "created"
|
|
378
|
+
|
|
379
|
+
def delete_txt_record(self, zone_id: str, record_id: str) -> None:
|
|
380
|
+
"""Delete a TXT record by ID."""
|
|
381
|
+
self._api_request("DELETE", f"/dnszone/{zone_id}/records/{record_id}")
|
|
382
|
+
logging.info(f"TXT record deleted (ID: {record_id})")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def get_dns_provider(provider_name: str):
|
|
386
|
+
"""Factory function to get the appropriate DNS provider."""
|
|
387
|
+
providers = {
|
|
388
|
+
'cloudflare': CloudflareTXTProvider,
|
|
389
|
+
'bunny': BunnyTXTProvider,
|
|
390
|
+
}
|
|
391
|
+
if provider_name not in providers:
|
|
392
|
+
raise ValueError(f"Unknown DNS provider: {provider_name}. Choose from: {list(providers.keys())}")
|
|
393
|
+
return providers[provider_name]()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# =============================================================================
|
|
397
|
+
# SPF Record Merge Logic
|
|
398
|
+
# =============================================================================
|
|
399
|
+
|
|
400
|
+
def parse_spf(spf_record: str) -> Tuple[List[str], str]:
|
|
401
|
+
"""Parse an SPF record into mechanisms and qualifier.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
(mechanisms, qualifier) where mechanisms is a list of strings
|
|
405
|
+
like ['include:_spf.google.com', 'ip4:1.2.3.4'] and qualifier
|
|
406
|
+
is the all-mechanism string like '~all' or '-all'.
|
|
407
|
+
"""
|
|
408
|
+
parts = spf_record.strip().split()
|
|
409
|
+
mechanisms = []
|
|
410
|
+
qualifier = "~all" # default
|
|
411
|
+
|
|
412
|
+
for part in parts:
|
|
413
|
+
if part == "v=spf1":
|
|
414
|
+
continue
|
|
415
|
+
if part.endswith("all"):
|
|
416
|
+
qualifier = part
|
|
417
|
+
else:
|
|
418
|
+
mechanisms.append(part)
|
|
419
|
+
|
|
420
|
+
return mechanisms, qualifier
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def merge_spf(existing_spf: Optional[str], target_qualifier: str = "~all") -> Tuple[str, List[str]]:
|
|
424
|
+
"""Merge Mailjet SPF include into existing SPF record.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
(merged_spf_record, list_of_warnings)
|
|
428
|
+
"""
|
|
429
|
+
mailjet_include = "include:spf.mailjet.com"
|
|
430
|
+
warnings = []
|
|
431
|
+
|
|
432
|
+
if not existing_spf:
|
|
433
|
+
record = f"v=spf1 {mailjet_include} {target_qualifier}"
|
|
434
|
+
return record, ["No existing SPF record found. Created new one."]
|
|
435
|
+
|
|
436
|
+
mechanisms, current_qualifier = parse_spf(existing_spf)
|
|
437
|
+
|
|
438
|
+
# Check if Mailjet include already present
|
|
439
|
+
if mailjet_include in mechanisms:
|
|
440
|
+
# Still check qualifier
|
|
441
|
+
if current_qualifier == "?all" and target_qualifier != "?all":
|
|
442
|
+
warnings.append(
|
|
443
|
+
f"Upgrading SPF qualifier from '{current_qualifier}' to '{target_qualifier}' "
|
|
444
|
+
f"(?all means neutral/no policy, {target_qualifier} is recommended)"
|
|
445
|
+
)
|
|
446
|
+
current_qualifier = target_qualifier
|
|
447
|
+
elif current_qualifier != target_qualifier:
|
|
448
|
+
warnings.append(
|
|
449
|
+
f"SPF qualifier is '{current_qualifier}', target is '{target_qualifier}'. "
|
|
450
|
+
f"Keeping existing qualifier."
|
|
451
|
+
)
|
|
452
|
+
# Keep current qualifier unless upgrading from ?all
|
|
453
|
+
record = f"v=spf1 {' '.join(mechanisms)} {current_qualifier}"
|
|
454
|
+
return record, warnings if warnings else ["Mailjet SPF include already present."]
|
|
455
|
+
|
|
456
|
+
# Insert Mailjet include
|
|
457
|
+
mechanisms.append(mailjet_include)
|
|
458
|
+
warnings.append(f"Added '{mailjet_include}' to SPF record.")
|
|
459
|
+
|
|
460
|
+
# Check and optionally upgrade qualifier
|
|
461
|
+
if current_qualifier == "?all" and target_qualifier != "?all":
|
|
462
|
+
warnings.append(
|
|
463
|
+
f"Upgrading SPF qualifier from '?all' to '{target_qualifier}' "
|
|
464
|
+
f"(?all means neutral/no policy, {target_qualifier} provides softfail protection)"
|
|
465
|
+
)
|
|
466
|
+
current_qualifier = target_qualifier
|
|
467
|
+
|
|
468
|
+
record = f"v=spf1 {' '.join(mechanisms)} {current_qualifier}"
|
|
469
|
+
return record, warnings
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def build_dmarc_record(policy: str = "none", rua_email: Optional[str] = None) -> str:
|
|
473
|
+
"""Build a DMARC TXT record value."""
|
|
474
|
+
parts = [f"v=DMARC1; p={policy}"]
|
|
475
|
+
if rua_email:
|
|
476
|
+
parts.append(f"rua=mailto:{rua_email}")
|
|
477
|
+
parts.append("sp=none")
|
|
478
|
+
return "; ".join(parts)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# =============================================================================
|
|
482
|
+
# Setup Report
|
|
483
|
+
# =============================================================================
|
|
484
|
+
|
|
485
|
+
class SetupReport:
|
|
486
|
+
"""Track and report Mailjet DNS setup status."""
|
|
487
|
+
|
|
488
|
+
def __init__(self, domain: str, dns_provider: str):
|
|
489
|
+
self.timestamp = time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())
|
|
490
|
+
self.domain = domain
|
|
491
|
+
self.dns_provider = dns_provider
|
|
492
|
+
self.components = {
|
|
493
|
+
'sender': {'status': 'pending', 'details': {}},
|
|
494
|
+
'spf': {'status': 'pending', 'details': {}},
|
|
495
|
+
'dkim': {'status': 'pending', 'details': {}},
|
|
496
|
+
'dmarc': {'status': 'pending', 'details': {}},
|
|
497
|
+
'ownership': {'status': 'pending', 'details': {}},
|
|
498
|
+
'verification': {'status': 'pending', 'details': {}},
|
|
499
|
+
}
|
|
500
|
+
self.warnings = []
|
|
501
|
+
|
|
502
|
+
def set_component(self, name: str, status: str, **details):
|
|
503
|
+
if name in self.components:
|
|
504
|
+
self.components[name]['status'] = status
|
|
505
|
+
self.components[name]['details'].update(details)
|
|
506
|
+
|
|
507
|
+
def add_warning(self, message: str):
|
|
508
|
+
self.warnings.append(message)
|
|
509
|
+
|
|
510
|
+
def generate_markdown(self) -> str:
|
|
511
|
+
lines = [
|
|
512
|
+
f"# Mailjet DNS Authentication Report: {self.domain}",
|
|
513
|
+
"",
|
|
514
|
+
f"**Generated:** {self.timestamp}",
|
|
515
|
+
"**Script:** setup_mailjet_dns.py",
|
|
516
|
+
f"**DNS Provider:** {self.dns_provider.title()}",
|
|
517
|
+
"",
|
|
518
|
+
"---",
|
|
519
|
+
"",
|
|
520
|
+
"## Configuration Summary",
|
|
521
|
+
"",
|
|
522
|
+
"| Setting | Value |",
|
|
523
|
+
"|---------|-------|",
|
|
524
|
+
f"| Domain | `{self.domain}` |",
|
|
525
|
+
f"| DNS Provider | {self.dns_provider.title()} |",
|
|
526
|
+
f"| Sender | `*@{self.domain}` |",
|
|
527
|
+
"",
|
|
528
|
+
"## Component Status",
|
|
529
|
+
"",
|
|
530
|
+
]
|
|
531
|
+
|
|
532
|
+
status_icons = {
|
|
533
|
+
'created': '[NEW]', 'exists': '[OK]', 'configured': '[OK]',
|
|
534
|
+
'updated': '[UPD]', 'skipped': '[SKIP]', 'failed': '[FAIL]',
|
|
535
|
+
'pending': '[ ]', 'dry_run': '[DRY]',
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
component_names = {
|
|
539
|
+
'sender': 'Mailjet Sender',
|
|
540
|
+
'spf': 'SPF Record',
|
|
541
|
+
'dkim': 'DKIM Record',
|
|
542
|
+
'dmarc': 'DMARC Record',
|
|
543
|
+
'ownership': 'Ownership Verification',
|
|
544
|
+
'verification': 'Mailjet DNS Check',
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for comp_key, comp_name in component_names.items():
|
|
548
|
+
comp = self.components[comp_key]
|
|
549
|
+
status = comp['status']
|
|
550
|
+
icon = status_icons.get(status, '[ ]')
|
|
551
|
+
lines.append(f"### {icon} {comp_name}")
|
|
552
|
+
lines.append("")
|
|
553
|
+
lines.append(f"**Status:** {status.replace('_', ' ').title()}")
|
|
554
|
+
|
|
555
|
+
for key, value in comp['details'].items():
|
|
556
|
+
display_key = key.replace('_', ' ').title()
|
|
557
|
+
if isinstance(value, list):
|
|
558
|
+
lines.append(f"- **{display_key}:**")
|
|
559
|
+
for item in value:
|
|
560
|
+
lines.append(f" - `{item}`")
|
|
561
|
+
else:
|
|
562
|
+
lines.append(f"- **{display_key}:** `{value}`")
|
|
563
|
+
lines.append("")
|
|
564
|
+
|
|
565
|
+
if self.warnings:
|
|
566
|
+
lines.extend([
|
|
567
|
+
"## Warnings",
|
|
568
|
+
"",
|
|
569
|
+
])
|
|
570
|
+
for warning in self.warnings:
|
|
571
|
+
lines.append(f"- {warning}")
|
|
572
|
+
lines.append("")
|
|
573
|
+
|
|
574
|
+
lines.extend([
|
|
575
|
+
"## DNS Records Summary",
|
|
576
|
+
"",
|
|
577
|
+
"| Record | Name | Value |",
|
|
578
|
+
"|--------|------|-------|",
|
|
579
|
+
])
|
|
580
|
+
|
|
581
|
+
for comp_key in ['spf', 'dkim', 'dmarc', 'ownership']:
|
|
582
|
+
comp = self.components[comp_key]
|
|
583
|
+
if comp['status'] not in ['pending', 'skipped']:
|
|
584
|
+
name = comp['details'].get('record_name', '')
|
|
585
|
+
value = comp['details'].get('record_value', '')
|
|
586
|
+
if len(value) > 60:
|
|
587
|
+
value = value[:57] + "..."
|
|
588
|
+
lines.append(f"| TXT | `{name}` | `{value}` |")
|
|
589
|
+
|
|
590
|
+
lines.extend([
|
|
591
|
+
"",
|
|
592
|
+
"## Next Steps",
|
|
593
|
+
"",
|
|
594
|
+
"1. Wait 5-10 minutes for DNS propagation",
|
|
595
|
+
"2. Re-run with `--skip-verify` removed to confirm Mailjet sees the records",
|
|
596
|
+
"3. Check Mailjet dashboard: https://app.mailjet.com/account/sender",
|
|
597
|
+
f"4. Verify with: `dig +short TXT {self.domain}` and `dig +short TXT mailjet._domainkey.{self.domain}`",
|
|
598
|
+
"",
|
|
599
|
+
"---",
|
|
600
|
+
"",
|
|
601
|
+
"*Generated by setup_mailjet_dns.py*",
|
|
602
|
+
])
|
|
603
|
+
|
|
604
|
+
return '\n'.join(lines)
|
|
605
|
+
|
|
606
|
+
def save_report(self, output_dir: str = '.') -> str:
|
|
607
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
608
|
+
timestamp = time.strftime('%Y%m%d-%H%M%S', time.gmtime())
|
|
609
|
+
filename = f"mailjet-dns-setup-{self.domain}-{timestamp}.md"
|
|
610
|
+
filepath = os.path.join(output_dir, filename)
|
|
611
|
+
|
|
612
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
613
|
+
f.write(self.generate_markdown())
|
|
614
|
+
|
|
615
|
+
logging.info(f"Setup report saved to: {filepath}")
|
|
616
|
+
return filepath
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# =============================================================================
|
|
620
|
+
# CLI
|
|
621
|
+
# =============================================================================
|
|
622
|
+
|
|
623
|
+
def parse_arguments():
|
|
624
|
+
parser = argparse.ArgumentParser(
|
|
625
|
+
description='Setup Mailjet DNS authentication (SPF, DKIM, DMARC)',
|
|
626
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
627
|
+
epilog="""
|
|
628
|
+
Examples:
|
|
629
|
+
# Preview changes without modifying DNS
|
|
630
|
+
python setup_mailjet_dns.py --domain example.com --dry-run
|
|
631
|
+
|
|
632
|
+
# Full setup with DMARC (Cloudflare DNS)
|
|
633
|
+
python setup_mailjet_dns.py --domain example.com --dmarc --dmarc-rua admin@example.com
|
|
634
|
+
|
|
635
|
+
# Setup with Bunny DNS
|
|
636
|
+
python setup_mailjet_dns.py --domain example.com --dns-provider bunny --dmarc
|
|
637
|
+
|
|
638
|
+
# Include ownership verification record
|
|
639
|
+
python setup_mailjet_dns.py --domain example.com --ownership-record --dmarc
|
|
640
|
+
|
|
641
|
+
# Strict SPF qualifier
|
|
642
|
+
python setup_mailjet_dns.py --domain example.com --spf-qualifier="-all"
|
|
643
|
+
|
|
644
|
+
# Check current DNS authentication status
|
|
645
|
+
python setup_mailjet_dns.py --domain example.com --check
|
|
646
|
+
|
|
647
|
+
Environment Variables:
|
|
648
|
+
MAILJET_API_KEY Mailjet API public key (from account settings)
|
|
649
|
+
MAILJET_SECRET_KEY Mailjet API private key (from account settings)
|
|
650
|
+
CLOUDFLARE_API_TOKEN Cloudflare API token (for Cloudflare DNS)
|
|
651
|
+
BUNNY_API_KEY Bunny.net API key (for Bunny DNS)
|
|
652
|
+
"""
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
parser.add_argument('--domain', required=True,
|
|
656
|
+
help='Domain to configure (e.g., example.com)')
|
|
657
|
+
parser.add_argument('--dns-provider', choices=['cloudflare', 'bunny'],
|
|
658
|
+
default='cloudflare',
|
|
659
|
+
help='DNS provider (default: cloudflare)')
|
|
660
|
+
parser.add_argument('--dmarc', action='store_true',
|
|
661
|
+
help='Also create a DMARC record')
|
|
662
|
+
parser.add_argument('--dmarc-policy', choices=['none', 'quarantine', 'reject'],
|
|
663
|
+
default='none',
|
|
664
|
+
help='DMARC policy (default: none)')
|
|
665
|
+
parser.add_argument('--dmarc-rua',
|
|
666
|
+
help='DMARC aggregate report email (e.g., admin@example.com)')
|
|
667
|
+
parser.add_argument('--ownership-record', action='store_true',
|
|
668
|
+
help='Also create the ownership verification TXT record')
|
|
669
|
+
parser.add_argument('--spf-qualifier', choices=['~all', '-all', '?all'],
|
|
670
|
+
default='~all',
|
|
671
|
+
help='SPF qualifier for the all mechanism (default: ~all)')
|
|
672
|
+
parser.add_argument('--check', action='store_true',
|
|
673
|
+
help='Check current DNS authentication status without making changes')
|
|
674
|
+
parser.add_argument('--dry-run', action='store_true',
|
|
675
|
+
help='Preview DNS changes without making them')
|
|
676
|
+
parser.add_argument('--skip-verify', action='store_true',
|
|
677
|
+
help='Skip Mailjet DNS check after setup')
|
|
678
|
+
parser.add_argument('--report-dir', default='.',
|
|
679
|
+
help='Directory to save the setup report')
|
|
680
|
+
|
|
681
|
+
return parser.parse_args()
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# =============================================================================
|
|
685
|
+
# Check Mode
|
|
686
|
+
# =============================================================================
|
|
687
|
+
|
|
688
|
+
def run_check(args) -> int:
|
|
689
|
+
"""Check current DNS authentication status for a domain.
|
|
690
|
+
|
|
691
|
+
Queries both Mailjet API and DNS provider to show current state
|
|
692
|
+
of SPF, DKIM, DMARC, and sender configuration.
|
|
693
|
+
"""
|
|
694
|
+
logging.info("=" * 60)
|
|
695
|
+
logging.info("MAILJET DNS AUTHENTICATION CHECK")
|
|
696
|
+
logging.info("=" * 60)
|
|
697
|
+
logging.info(f"Domain: {args.domain}")
|
|
698
|
+
logging.info(f"DNS Provider: {args.dns_provider}")
|
|
699
|
+
logging.info("=" * 60)
|
|
700
|
+
|
|
701
|
+
issues = []
|
|
702
|
+
ok_count = 0
|
|
703
|
+
|
|
704
|
+
try:
|
|
705
|
+
# --- Mailjet API Status ---
|
|
706
|
+
logging.info("\n--- Mailjet API Status ---")
|
|
707
|
+
mailjet = MailjetClient()
|
|
708
|
+
|
|
709
|
+
sender = mailjet.find_sender(args.domain)
|
|
710
|
+
if sender:
|
|
711
|
+
status = sender.get('Status', 'Unknown')
|
|
712
|
+
logging.info(f" Sender: *@{args.domain} (Status: {status})")
|
|
713
|
+
if status == 'Active':
|
|
714
|
+
ok_count += 1
|
|
715
|
+
else:
|
|
716
|
+
issues.append(f"Sender status is '{status}', expected 'Active'")
|
|
717
|
+
else:
|
|
718
|
+
logging.info(f" Sender: *@{args.domain} NOT FOUND")
|
|
719
|
+
issues.append("No catch-all sender registered in Mailjet")
|
|
720
|
+
|
|
721
|
+
dns_settings = mailjet.get_dns_settings(args.domain)
|
|
722
|
+
dkim_status_mj = dns_settings.get('DKIMStatus', 'Unknown')
|
|
723
|
+
spf_status_mj = dns_settings.get('SPFStatus', 'Unknown')
|
|
724
|
+
logging.info(f" Mailjet DKIM status: {dkim_status_mj}")
|
|
725
|
+
logging.info(f" Mailjet SPF status: {spf_status_mj}")
|
|
726
|
+
|
|
727
|
+
if dkim_status_mj == 'OK':
|
|
728
|
+
ok_count += 1
|
|
729
|
+
else:
|
|
730
|
+
issues.append(f"Mailjet reports DKIM status: {dkim_status_mj}")
|
|
731
|
+
|
|
732
|
+
if spf_status_mj == 'OK':
|
|
733
|
+
ok_count += 1
|
|
734
|
+
else:
|
|
735
|
+
issues.append(f"Mailjet reports SPF status: {spf_status_mj}")
|
|
736
|
+
|
|
737
|
+
# --- DNS Records ---
|
|
738
|
+
logging.info("\n--- DNS Records ---")
|
|
739
|
+
dns = get_dns_provider(args.dns_provider)
|
|
740
|
+
zone_id = dns.get_zone_id(args.domain)
|
|
741
|
+
|
|
742
|
+
# SPF
|
|
743
|
+
spf_name = "" if args.dns_provider == "bunny" else args.domain
|
|
744
|
+
spf_records = dns.get_txt_records(zone_id, spf_name)
|
|
745
|
+
spf_found = False
|
|
746
|
+
for record in spf_records:
|
|
747
|
+
if record["content"].startswith("v=spf1"):
|
|
748
|
+
spf_found = True
|
|
749
|
+
logging.info(f" SPF: {record['content']}")
|
|
750
|
+
if "include:spf.mailjet.com" in record["content"]:
|
|
751
|
+
ok_count += 1
|
|
752
|
+
logging.info(" -> Mailjet include: PRESENT")
|
|
753
|
+
else:
|
|
754
|
+
issues.append("SPF record missing 'include:spf.mailjet.com'")
|
|
755
|
+
logging.info(" -> Mailjet include: MISSING")
|
|
756
|
+
|
|
757
|
+
_, qualifier = parse_spf(record["content"])
|
|
758
|
+
if qualifier == "?all":
|
|
759
|
+
issues.append(f"SPF qualifier is '{qualifier}' (neutral/weak). Recommend '~all' or '-all'")
|
|
760
|
+
logging.info(f" -> Qualifier: {qualifier} (WEAK)")
|
|
761
|
+
else:
|
|
762
|
+
logging.info(f" -> Qualifier: {qualifier}")
|
|
763
|
+
break
|
|
764
|
+
|
|
765
|
+
if not spf_found:
|
|
766
|
+
logging.info(" SPF: NOT FOUND")
|
|
767
|
+
issues.append("No SPF record found")
|
|
768
|
+
|
|
769
|
+
# DKIM
|
|
770
|
+
dkim_name_full = dns_settings.get('DKIMRecordName', f'mailjet._domainkey.{args.domain}')
|
|
771
|
+
dkim_name_full = dkim_name_full.rstrip('.') # Remove trailing dot from FQDN
|
|
772
|
+
dkim_subdomain = dkim_name_full
|
|
773
|
+
if dkim_name_full.endswith(f".{args.domain}"):
|
|
774
|
+
dkim_subdomain = dkim_name_full[:-len(f".{args.domain}")]
|
|
775
|
+
dkim_lookup = dkim_subdomain if args.dns_provider == "bunny" else dkim_name_full
|
|
776
|
+
dkim_records = dns.get_txt_records(zone_id, dkim_lookup)
|
|
777
|
+
dkim_found = False
|
|
778
|
+
for record in dkim_records:
|
|
779
|
+
if "k=rsa" in record["content"] or "DKIM" in record["content"]:
|
|
780
|
+
dkim_found = True
|
|
781
|
+
value_preview = record["content"][:80] + "..." if len(record["content"]) > 80 else record["content"]
|
|
782
|
+
logging.info(f" DKIM ({dkim_name_full}): {value_preview}")
|
|
783
|
+
ok_count += 1
|
|
784
|
+
break
|
|
785
|
+
|
|
786
|
+
if not dkim_found:
|
|
787
|
+
logging.info(f" DKIM ({dkim_name_full}): NOT FOUND")
|
|
788
|
+
issues.append(f"No DKIM record found at {dkim_name_full}")
|
|
789
|
+
|
|
790
|
+
# DMARC
|
|
791
|
+
dmarc_lookup = "_dmarc" if args.dns_provider == "bunny" else f"_dmarc.{args.domain}"
|
|
792
|
+
dmarc_records = dns.get_txt_records(zone_id, dmarc_lookup)
|
|
793
|
+
dmarc_found = False
|
|
794
|
+
for record in dmarc_records:
|
|
795
|
+
if record["content"].startswith("v=DMARC1"):
|
|
796
|
+
dmarc_found = True
|
|
797
|
+
logging.info(f" DMARC: {record['content']}")
|
|
798
|
+
ok_count += 1
|
|
799
|
+
break
|
|
800
|
+
|
|
801
|
+
if not dmarc_found:
|
|
802
|
+
logging.info(f" DMARC (_dmarc.{args.domain}): NOT FOUND")
|
|
803
|
+
issues.append("No DMARC record found (recommended for deliverability)")
|
|
804
|
+
|
|
805
|
+
# --- Summary ---
|
|
806
|
+
logging.info("\n" + "=" * 60)
|
|
807
|
+
logging.info("CHECK SUMMARY")
|
|
808
|
+
logging.info("=" * 60)
|
|
809
|
+
logging.info(f" Checks passed: {ok_count}")
|
|
810
|
+
logging.info(f" Issues found: {len(issues)}")
|
|
811
|
+
|
|
812
|
+
if issues:
|
|
813
|
+
logging.info("\n Issues:")
|
|
814
|
+
for i, issue in enumerate(issues, 1):
|
|
815
|
+
logging.info(f" {i}. {issue}")
|
|
816
|
+
|
|
817
|
+
logging.info("\n To fix, run:")
|
|
818
|
+
logging.info(f" python setup_mailjet_dns.py --domain {args.domain} --dmarc")
|
|
819
|
+
else:
|
|
820
|
+
logging.info("\n All checks passed! Email authentication is properly configured.")
|
|
821
|
+
|
|
822
|
+
logging.info("=" * 60)
|
|
823
|
+
return 0 if not issues else 1
|
|
824
|
+
|
|
825
|
+
except Exception as e:
|
|
826
|
+
logging.error(f"Check failed: {e}")
|
|
827
|
+
import traceback
|
|
828
|
+
traceback.print_exc()
|
|
829
|
+
return 1
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# =============================================================================
|
|
833
|
+
# Main Setup
|
|
834
|
+
# =============================================================================
|
|
835
|
+
|
|
836
|
+
def main():
|
|
837
|
+
"""Main function to orchestrate Mailjet DNS authentication setup."""
|
|
838
|
+
args = parse_arguments()
|
|
839
|
+
|
|
840
|
+
logging.basicConfig(
|
|
841
|
+
level=logging.INFO,
|
|
842
|
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
# Check mode: read-only status check
|
|
846
|
+
if args.check:
|
|
847
|
+
return run_check(args)
|
|
848
|
+
|
|
849
|
+
report = SetupReport(domain=args.domain, dns_provider=args.dns_provider)
|
|
850
|
+
|
|
851
|
+
logging.info("=" * 60)
|
|
852
|
+
logging.info("MAILJET DNS AUTHENTICATION SETUP")
|
|
853
|
+
logging.info("=" * 60)
|
|
854
|
+
logging.info(f"Domain: {args.domain}")
|
|
855
|
+
logging.info(f"DNS Provider: {args.dns_provider}")
|
|
856
|
+
logging.info(f"SPF Qualifier: {args.spf_qualifier}")
|
|
857
|
+
logging.info(f"DMARC: {'Yes (policy: ' + args.dmarc_policy + ')' if args.dmarc else 'No'}")
|
|
858
|
+
logging.info(f"Ownership Record: {'Yes' if args.ownership_record else 'No'}")
|
|
859
|
+
logging.info(f"Dry Run: {'Yes' if args.dry_run else 'No'}")
|
|
860
|
+
logging.info("=" * 60)
|
|
861
|
+
|
|
862
|
+
try:
|
|
863
|
+
# =================================================================
|
|
864
|
+
# Step 1: Connect to Mailjet and get/create sender
|
|
865
|
+
# =================================================================
|
|
866
|
+
logging.info("\n--- Step 1: Mailjet Sender ---")
|
|
867
|
+
mailjet = MailjetClient()
|
|
868
|
+
sender = mailjet.get_or_create_sender(args.domain)
|
|
869
|
+
report.set_component('sender', 'configured',
|
|
870
|
+
email=f"*@{args.domain}",
|
|
871
|
+
sender_id=str(sender.get('ID')),
|
|
872
|
+
status=sender.get('Status'))
|
|
873
|
+
|
|
874
|
+
# =================================================================
|
|
875
|
+
# Step 2: Retrieve DNS settings from Mailjet
|
|
876
|
+
# =================================================================
|
|
877
|
+
logging.info("\n--- Step 2: Retrieve DNS Settings ---")
|
|
878
|
+
dns_settings = mailjet.get_dns_settings(args.domain)
|
|
879
|
+
|
|
880
|
+
dkim_name = dns_settings.get('DKIMRecordName', '')
|
|
881
|
+
dkim_value = dns_settings.get('DKIMRecordValue', '')
|
|
882
|
+
spf_value = dns_settings.get('SPFRecordValue', '')
|
|
883
|
+
ownership_token = dns_settings.get('OwnerShipToken', '')
|
|
884
|
+
ownership_record_name = dns_settings.get('OwnerShipTokenRecordName', '')
|
|
885
|
+
dns_id = dns_settings.get('ID')
|
|
886
|
+
|
|
887
|
+
# Extract the subdomain part from the DKIM record name
|
|
888
|
+
# e.g. "mailjet._domainkey.example.com" -> "mailjet._domainkey"
|
|
889
|
+
dkim_subdomain = dkim_name
|
|
890
|
+
if dkim_name.endswith(f".{args.domain}"):
|
|
891
|
+
dkim_subdomain = dkim_name[:-len(f".{args.domain}")]
|
|
892
|
+
|
|
893
|
+
logging.info(f" DKIM record: {dkim_name}")
|
|
894
|
+
logging.info(f" DKIM value: {dkim_value[:60]}..." if len(dkim_value) > 60 else f" DKIM value: {dkim_value}")
|
|
895
|
+
logging.info(f" SPF suggestion: {spf_value}")
|
|
896
|
+
if ownership_token:
|
|
897
|
+
logging.info(f" Ownership token: {ownership_token}")
|
|
898
|
+
|
|
899
|
+
# =================================================================
|
|
900
|
+
# Step 3: Initialize DNS provider
|
|
901
|
+
# =================================================================
|
|
902
|
+
logging.info("\n--- Step 3: DNS Provider ---")
|
|
903
|
+
if args.dry_run:
|
|
904
|
+
logging.info(f"[DRY RUN] Would connect to {args.dns_provider} DNS")
|
|
905
|
+
dns = None
|
|
906
|
+
zone_id = "dry-run"
|
|
907
|
+
else:
|
|
908
|
+
dns = get_dns_provider(args.dns_provider)
|
|
909
|
+
zone_id = dns.get_zone_id(args.domain)
|
|
910
|
+
|
|
911
|
+
# =================================================================
|
|
912
|
+
# Step 4: SPF Record
|
|
913
|
+
# =================================================================
|
|
914
|
+
logging.info("\n--- Step 4: SPF Record ---")
|
|
915
|
+
|
|
916
|
+
if args.dry_run:
|
|
917
|
+
# In dry run, we can't read existing records, show what we'd do
|
|
918
|
+
merged_spf, spf_warnings = merge_spf(None, args.spf_qualifier)
|
|
919
|
+
logging.info(f"[DRY RUN] Would create/update SPF record at '{args.domain}':")
|
|
920
|
+
logging.info(f" Value: {merged_spf}")
|
|
921
|
+
logging.info(" Note: In live run, existing SPF will be merged if present")
|
|
922
|
+
report.set_component('spf', 'dry_run',
|
|
923
|
+
record_name=args.domain,
|
|
924
|
+
record_value=merged_spf)
|
|
925
|
+
else:
|
|
926
|
+
# Read existing TXT records at root to find SPF
|
|
927
|
+
existing_txt = dns.get_txt_records(zone_id, "" if args.dns_provider == "bunny" else args.domain)
|
|
928
|
+
existing_spf = None
|
|
929
|
+
for record in existing_txt:
|
|
930
|
+
if record["content"].startswith("v=spf1"):
|
|
931
|
+
existing_spf = record["content"]
|
|
932
|
+
logging.info(f" Found existing SPF: {existing_spf}")
|
|
933
|
+
break
|
|
934
|
+
|
|
935
|
+
if not existing_spf:
|
|
936
|
+
logging.info(" No existing SPF record found.")
|
|
937
|
+
|
|
938
|
+
merged_spf, spf_warnings = merge_spf(existing_spf, args.spf_qualifier)
|
|
939
|
+
for warning in spf_warnings:
|
|
940
|
+
logging.info(f" SPF: {warning}")
|
|
941
|
+
report.add_warning(warning)
|
|
942
|
+
|
|
943
|
+
logging.info(f" Final SPF: {merged_spf}")
|
|
944
|
+
|
|
945
|
+
# The name for root TXT in Bunny is "" (empty), in Cloudflare it's the domain
|
|
946
|
+
spf_name = "" if args.dns_provider == "bunny" else args.domain
|
|
947
|
+
action = dns.create_or_update_txt(zone_id, spf_name, merged_spf, args.domain)
|
|
948
|
+
report.set_component('spf', action,
|
|
949
|
+
record_name=args.domain,
|
|
950
|
+
record_value=merged_spf)
|
|
951
|
+
|
|
952
|
+
# =================================================================
|
|
953
|
+
# Step 5: DKIM Record
|
|
954
|
+
# =================================================================
|
|
955
|
+
logging.info("\n--- Step 5: DKIM Record ---")
|
|
956
|
+
|
|
957
|
+
if not dkim_value:
|
|
958
|
+
logging.warning(" No DKIM value returned from Mailjet. Skipping DKIM setup.")
|
|
959
|
+
report.set_component('dkim', 'skipped', reason='No DKIM value from Mailjet API')
|
|
960
|
+
elif args.dry_run:
|
|
961
|
+
logging.info("[DRY RUN] Would create DKIM TXT record:")
|
|
962
|
+
logging.info(f" Name: {dkim_name}")
|
|
963
|
+
logging.info(f" Value: {dkim_value[:60]}...")
|
|
964
|
+
report.set_component('dkim', 'dry_run',
|
|
965
|
+
record_name=dkim_name,
|
|
966
|
+
record_value=dkim_value)
|
|
967
|
+
else:
|
|
968
|
+
dkim_record_name = dkim_subdomain if args.dns_provider == "bunny" else dkim_name
|
|
969
|
+
action = dns.create_or_update_txt(zone_id, dkim_record_name, dkim_value, args.domain)
|
|
970
|
+
report.set_component('dkim', action,
|
|
971
|
+
record_name=dkim_name,
|
|
972
|
+
record_value=dkim_value)
|
|
973
|
+
|
|
974
|
+
# =================================================================
|
|
975
|
+
# Step 6: DMARC Record (optional)
|
|
976
|
+
# =================================================================
|
|
977
|
+
logging.info("\n--- Step 6: DMARC Record ---")
|
|
978
|
+
|
|
979
|
+
if not args.dmarc:
|
|
980
|
+
logging.info(" DMARC not requested. Skipping. Use --dmarc to enable.")
|
|
981
|
+
report.set_component('dmarc', 'skipped', reason='Not requested')
|
|
982
|
+
elif args.dry_run:
|
|
983
|
+
dmarc_value = build_dmarc_record(args.dmarc_policy, args.dmarc_rua)
|
|
984
|
+
logging.info("[DRY RUN] Would create DMARC TXT record:")
|
|
985
|
+
logging.info(f" Name: _dmarc.{args.domain}")
|
|
986
|
+
logging.info(f" Value: {dmarc_value}")
|
|
987
|
+
report.set_component('dmarc', 'dry_run',
|
|
988
|
+
record_name=f"_dmarc.{args.domain}",
|
|
989
|
+
record_value=dmarc_value)
|
|
990
|
+
else:
|
|
991
|
+
dmarc_value = build_dmarc_record(args.dmarc_policy, args.dmarc_rua)
|
|
992
|
+
dmarc_name = "_dmarc" if args.dns_provider == "bunny" else f"_dmarc.{args.domain}"
|
|
993
|
+
action = dns.create_or_update_txt(zone_id, dmarc_name, dmarc_value, args.domain)
|
|
994
|
+
report.set_component('dmarc', action,
|
|
995
|
+
record_name=f"_dmarc.{args.domain}",
|
|
996
|
+
record_value=dmarc_value)
|
|
997
|
+
|
|
998
|
+
# =================================================================
|
|
999
|
+
# Step 7: Ownership Verification Record (optional)
|
|
1000
|
+
# =================================================================
|
|
1001
|
+
logging.info("\n--- Step 7: Ownership Verification ---")
|
|
1002
|
+
|
|
1003
|
+
if not args.ownership_record:
|
|
1004
|
+
logging.info(" Ownership record not requested. Use --ownership-record to enable.")
|
|
1005
|
+
report.set_component('ownership', 'skipped', reason='Not requested')
|
|
1006
|
+
elif not ownership_token:
|
|
1007
|
+
logging.warning(" No ownership token returned from Mailjet. Skipping.")
|
|
1008
|
+
report.set_component('ownership', 'skipped', reason='No token from Mailjet API')
|
|
1009
|
+
elif args.dry_run:
|
|
1010
|
+
logging.info("[DRY RUN] Would create ownership TXT record:")
|
|
1011
|
+
logging.info(f" Name: {ownership_record_name or args.domain}")
|
|
1012
|
+
logging.info(f" Value: {ownership_token}")
|
|
1013
|
+
report.set_component('ownership', 'dry_run',
|
|
1014
|
+
record_name=ownership_record_name or args.domain,
|
|
1015
|
+
record_value=ownership_token)
|
|
1016
|
+
else:
|
|
1017
|
+
own_name = "" if args.dns_provider == "bunny" else (ownership_record_name or args.domain)
|
|
1018
|
+
action = dns.create_or_update_txt(zone_id, own_name, ownership_token, args.domain)
|
|
1019
|
+
report.set_component('ownership', action,
|
|
1020
|
+
record_name=ownership_record_name or args.domain,
|
|
1021
|
+
record_value=ownership_token)
|
|
1022
|
+
|
|
1023
|
+
# =================================================================
|
|
1024
|
+
# Step 8: Mailjet DNS Verification
|
|
1025
|
+
# =================================================================
|
|
1026
|
+
logging.info("\n--- Step 8: Mailjet Verification ---")
|
|
1027
|
+
|
|
1028
|
+
if args.dry_run:
|
|
1029
|
+
logging.info("[DRY RUN] Would trigger Mailjet DNS verification check.")
|
|
1030
|
+
report.set_component('verification', 'dry_run')
|
|
1031
|
+
elif args.skip_verify:
|
|
1032
|
+
logging.info(" Verification skipped (--skip-verify).")
|
|
1033
|
+
report.set_component('verification', 'skipped', reason='--skip-verify flag')
|
|
1034
|
+
elif dns_id:
|
|
1035
|
+
logging.info(" Waiting 5 seconds for DNS propagation...")
|
|
1036
|
+
time.sleep(5)
|
|
1037
|
+
check_result = mailjet.check_dns(dns_id)
|
|
1038
|
+
dkim_status = check_result.get('DKIMStatus', 'Unknown')
|
|
1039
|
+
spf_status = check_result.get('SPFStatus', 'Unknown')
|
|
1040
|
+
|
|
1041
|
+
if dkim_status == 'OK' and spf_status == 'OK':
|
|
1042
|
+
report.set_component('verification', 'configured',
|
|
1043
|
+
dkim_status=dkim_status,
|
|
1044
|
+
spf_status=spf_status)
|
|
1045
|
+
else:
|
|
1046
|
+
report.set_component('verification', 'pending',
|
|
1047
|
+
dkim_status=dkim_status,
|
|
1048
|
+
spf_status=spf_status,
|
|
1049
|
+
note='DNS may need more time to propagate. Re-run later.')
|
|
1050
|
+
report.add_warning(
|
|
1051
|
+
f"Verification not yet passing (DKIM: {dkim_status}, SPF: {spf_status}). "
|
|
1052
|
+
"DNS propagation can take up to 10 minutes. Re-run the script to check again."
|
|
1053
|
+
)
|
|
1054
|
+
else:
|
|
1055
|
+
logging.warning(" No DNS ID available. Cannot trigger verification.")
|
|
1056
|
+
report.set_component('verification', 'skipped', reason='No DNS ID')
|
|
1057
|
+
|
|
1058
|
+
# =================================================================
|
|
1059
|
+
# Summary
|
|
1060
|
+
# =================================================================
|
|
1061
|
+
logging.info("\n" + "=" * 60)
|
|
1062
|
+
logging.info("MAILJET DNS SETUP COMPLETE")
|
|
1063
|
+
logging.info("=" * 60)
|
|
1064
|
+
logging.info(f"Domain: {args.domain}")
|
|
1065
|
+
logging.info(f"Sender: *@{args.domain}")
|
|
1066
|
+
|
|
1067
|
+
for comp_key, comp_name in [('spf', 'SPF'), ('dkim', 'DKIM'),
|
|
1068
|
+
('dmarc', 'DMARC'), ('ownership', 'Ownership')]:
|
|
1069
|
+
status = report.components[comp_key]['status']
|
|
1070
|
+
logging.info(f"{comp_name}: {status}")
|
|
1071
|
+
|
|
1072
|
+
logging.info("=" * 60)
|
|
1073
|
+
|
|
1074
|
+
# Save report
|
|
1075
|
+
report_path = report.save_report(args.report_dir)
|
|
1076
|
+
|
|
1077
|
+
# Print next steps
|
|
1078
|
+
logging.info("\n[Next Steps]")
|
|
1079
|
+
logging.info(f"1. Verify DNS propagation: dig +short TXT {args.domain}")
|
|
1080
|
+
logging.info(f"2. Verify DKIM: dig +short TXT mailjet._domainkey.{args.domain}")
|
|
1081
|
+
if args.dmarc:
|
|
1082
|
+
logging.info(f"3. Verify DMARC: dig +short TXT _dmarc.{args.domain}")
|
|
1083
|
+
logging.info("4. Check Mailjet dashboard: https://app.mailjet.com/account/sender")
|
|
1084
|
+
logging.info(f"\n[OK] Setup report: {report_path}")
|
|
1085
|
+
|
|
1086
|
+
return 0
|
|
1087
|
+
|
|
1088
|
+
except Exception as e:
|
|
1089
|
+
logging.error(f"An error occurred: {e}")
|
|
1090
|
+
import traceback
|
|
1091
|
+
traceback.print_exc()
|
|
1092
|
+
|
|
1093
|
+
try:
|
|
1094
|
+
report_path = report.save_report(args.report_dir)
|
|
1095
|
+
logging.info(f"[i] Partial report saved: {report_path}")
|
|
1096
|
+
except Exception:
|
|
1097
|
+
pass
|
|
1098
|
+
|
|
1099
|
+
return 1
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
if __name__ == "__main__":
|
|
1103
|
+
exit(main())
|