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.
Files changed (68) hide show
  1. granny/__init__.py +19 -0
  2. granny/analyze/__init__.py +6 -0
  3. granny/analyze/lambdas.py +59 -0
  4. granny/analyze/vpcs.py +57 -0
  5. granny/cdn/__init__.py +9 -0
  6. granny/cdn/bunny.py +231 -0
  7. granny/cli/__init__.py +0 -0
  8. granny/cli/analyze.py +66 -0
  9. granny/cli/cdn.py +210 -0
  10. granny/cli/create.py +94 -0
  11. granny/cli/credentials.py +99 -0
  12. granny/cli/dns.py +290 -0
  13. granny/cli/docker.py +165 -0
  14. granny/cli/edge.py +106 -0
  15. granny/cli/email.py +224 -0
  16. granny/cli/main.py +98 -0
  17. granny/cli/serverless.py +278 -0
  18. granny/cli/storage.py +249 -0
  19. granny/create/__init__.py +4 -0
  20. granny/create/auto_certificate.py +1899 -0
  21. granny/create/cloudfront-security-headers.js +53 -0
  22. granny/create/manage-dns.sh +321 -0
  23. granny/create/manage_mailjet_contacts.py +619 -0
  24. granny/create/registrars.py +363 -0
  25. granny/create/setup_aws_cloudfront.py +2808 -0
  26. granny/create/setup_bunny_edge_script.py +923 -0
  27. granny/create/setup_bunny_storage.py +1719 -0
  28. granny/create/setup_cognito_identity_pool.py +740 -0
  29. granny/create/setup_hetzner_bunny.py +1482 -0
  30. granny/create/setup_mailjet_dns.py +1103 -0
  31. granny/create/setup_private_cdn.py +547 -0
  32. granny/create/setup_s3_website.py +1512 -0
  33. granny/create/setup_scaleway_faas.py +1165 -0
  34. granny/create/setup_workmail.py +1217 -0
  35. granny/create/www-redirect-function.js +17 -0
  36. granny/credentials/__init__.py +15 -0
  37. granny/credentials/secrets.py +403 -0
  38. granny/dns/__init__.py +22 -0
  39. granny/dns/base.py +113 -0
  40. granny/dns/bunny.py +150 -0
  41. granny/dns/cloudflare.py +192 -0
  42. granny/dns/cloudns.py +162 -0
  43. granny/dns/desec.py +152 -0
  44. granny/dns/factory.py +72 -0
  45. granny/dns/hetzner.py +165 -0
  46. granny/dns/manual.py +64 -0
  47. granny/dns/records.py +29 -0
  48. granny/docker/__init__.py +5 -0
  49. granny/docker/build_base.py +204 -0
  50. granny/edge/__init__.py +5 -0
  51. granny/edge/bunny.py +147 -0
  52. granny/email/__init__.py +7 -0
  53. granny/email/mailjet.py +119 -0
  54. granny/email/mailjet_contacts.py +115 -0
  55. granny/email/ses_forwarding.py +281 -0
  56. granny/email/workmail.py +145 -0
  57. granny/report.py +128 -0
  58. granny/serverless/__init__.py +5 -0
  59. granny/serverless/scaleway.py +264 -0
  60. granny/storage/__init__.py +7 -0
  61. granny/storage/aws.py +113 -0
  62. granny/storage/bunny.py +98 -0
  63. granny/storage/hetzner.py +118 -0
  64. granny_devops-0.4.0.dist-info/METADATA +445 -0
  65. granny_devops-0.4.0.dist-info/RECORD +68 -0
  66. granny_devops-0.4.0.dist-info/WHEEL +4 -0
  67. granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
  68. 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())