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,619 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Mailjet Contact Management Script
4
+
5
+ Manage contact status in Mailjet — check, unblock, and list blocked contacts.
6
+ Useful when contacts are excluded from campaigns or blocked from receiving emails.
7
+
8
+ FEATURES:
9
+ - Check contact status (exclusion state, creation date, last activity)
10
+ - Unblock contacts excluded from campaigns
11
+ - List all blocked/excluded contacts
12
+ - Bulk unblock from a file of email addresses
13
+ - Dry-run mode for previewing changes
14
+
15
+ MAILJET CONTACT STATES:
16
+ IsExcludedFromCampaigns: true → Contact is blocked from receiving emails
17
+ IsExcludedFromCampaigns: false → Contact can receive emails normally
18
+
19
+ Environment Variables:
20
+ MAILJET_API_KEY Mailjet API public key
21
+ MAILJET_SECRET_KEY Mailjet API private key
22
+
23
+ Usage:
24
+ # Check a contact's status
25
+ python manage_mailjet_contacts.py --action check --email user@example.com
26
+
27
+ # Unblock a single contact
28
+ python manage_mailjet_contacts.py --action unblock --email user@example.com
29
+
30
+ # List all blocked contacts
31
+ python manage_mailjet_contacts.py --action list-blocked
32
+
33
+ # List all blocked contacts for a specific domain
34
+ python manage_mailjet_contacts.py --action list-blocked --domain example.com
35
+
36
+ # Bulk unblock from file (one email per line)
37
+ python manage_mailjet_contacts.py --action bulk-unblock --file blocked_emails.txt
38
+
39
+ # Dry run (preview without changes)
40
+ python manage_mailjet_contacts.py --action unblock --email user@example.com --dry-run
41
+ """
42
+
43
+ import os
44
+ import logging
45
+ import argparse
46
+ import requests
47
+ from typing import Optional, List
48
+
49
+ from dotenv import load_dotenv
50
+ load_dotenv()
51
+ try:
52
+ from granny.credentials import load_secrets_into_env
53
+ load_secrets_into_env()
54
+ except Exception:
55
+ pass
56
+
57
+
58
+ # =============================================================================
59
+ # Mailjet Contact Client
60
+ # =============================================================================
61
+
62
+ class MailjetContactClient:
63
+ """Mailjet REST API v3 client for contact management."""
64
+
65
+ API_BASE = "https://api.mailjet.com/v3/REST"
66
+
67
+ def __init__(self):
68
+ api_key = os.environ.get("MAILJET_API_KEY")
69
+ secret_key = os.environ.get("MAILJET_SECRET_KEY")
70
+ if not api_key or not secret_key:
71
+ raise ValueError(
72
+ "MAILJET_API_KEY and MAILJET_SECRET_KEY environment variables must be set.\n"
73
+ "Get your keys from: https://app.mailjet.com/account/apikeys"
74
+ )
75
+ self.session = requests.Session()
76
+ self.session.auth = (api_key, secret_key)
77
+ self.session.headers.update({"Content-Type": "application/json"})
78
+
79
+ def _api_request(self, method: str, endpoint: str,
80
+ data: dict = None, params: dict = None) -> dict:
81
+ """Make an API request to Mailjet REST API."""
82
+ url = f"{self.API_BASE}{endpoint}"
83
+ response = getattr(self.session, method.lower())(
84
+ url, json=data, params=params
85
+ )
86
+
87
+ if response.status_code == 404:
88
+ return None
89
+
90
+ if response.status_code >= 400:
91
+ raise Exception(
92
+ f"Mailjet API error: {response.status_code} - {response.text}"
93
+ )
94
+
95
+ return response.json() if response.text else {}
96
+
97
+ def get_contact(self, email: str) -> Optional[dict]:
98
+ """Get contact details by email address.
99
+
100
+ Returns contact dict with keys like:
101
+ - ID, Email, Name, IsExcludedFromCampaigns,
102
+ - CreatedAt, LastActivityAt, DeliveredCount, etc.
103
+ """
104
+ result = self._api_request("GET", f"/contact/{email}")
105
+ if not result:
106
+ return None
107
+ contacts = result.get("Data", [])
108
+ return contacts[0] if contacts else None
109
+
110
+ def unblock_contact(self, email: str) -> dict:
111
+ """Unblock a contact by setting IsExcludedFromCampaigns to false.
112
+
113
+ Returns updated contact data.
114
+ """
115
+ result = self._api_request(
116
+ "PUT",
117
+ f"/contact/{email}",
118
+ data={"IsExcludedFromCampaigns": "false"}
119
+ )
120
+ if not result:
121
+ raise Exception(f"Contact not found: {email}")
122
+ contacts = result.get("Data", [])
123
+ return contacts[0] if contacts else {}
124
+
125
+ def block_contact(self, email: str) -> dict:
126
+ """Block a contact by setting IsExcludedFromCampaigns to true.
127
+
128
+ Returns updated contact data.
129
+ """
130
+ result = self._api_request(
131
+ "PUT",
132
+ f"/contact/{email}",
133
+ data={"IsExcludedFromCampaigns": "true"}
134
+ )
135
+ if not result:
136
+ raise Exception(f"Contact not found: {email}")
137
+ contacts = result.get("Data", [])
138
+ return contacts[0] if contacts else {}
139
+
140
+ def list_contacts(self, limit: int = 1000, offset: int = 0,
141
+ is_excluded: bool = None) -> List[dict]:
142
+ """List contacts with optional exclusion filter.
143
+
144
+ Args:
145
+ limit: Max contacts to return (max 1000 per request)
146
+ offset: Pagination offset
147
+ is_excluded: If True, only excluded contacts; False, only active; None, all
148
+ """
149
+ params = {"Limit": min(limit, 1000), "Offset": offset}
150
+ if is_excluded is not None:
151
+ params["IsExcludedFromCampaigns"] = str(is_excluded).lower()
152
+
153
+ result = self._api_request("GET", "/contact", params=params)
154
+ if not result:
155
+ return []
156
+ return result.get("Data", [])
157
+
158
+ def list_all_excluded_contacts(self, domain: str = None) -> List[dict]:
159
+ """List all contacts excluded from campaigns, with optional domain filter.
160
+
161
+ Handles pagination automatically.
162
+ """
163
+ all_contacts = []
164
+ offset = 0
165
+ batch_size = 1000
166
+
167
+ while True:
168
+ batch = self.list_contacts(
169
+ limit=batch_size, offset=offset, is_excluded=True
170
+ )
171
+ if not batch:
172
+ break
173
+
174
+ if domain:
175
+ batch = [c for c in batch if c.get("Email", "").endswith(f"@{domain}")]
176
+
177
+ all_contacts.extend(batch)
178
+ offset += batch_size
179
+
180
+ if len(batch) < batch_size:
181
+ break
182
+
183
+ return all_contacts
184
+
185
+ def get_message_history(self, contact_id: int, limit: int = 10) -> List[dict]:
186
+ """Get message history for a contact."""
187
+ result = self._api_request(
188
+ "GET", "/message",
189
+ params={"Contact": contact_id, "Limit": limit}
190
+ )
191
+ if not result:
192
+ return []
193
+ return result.get("Data", [])
194
+
195
+ def get_message_event(self, message_id: int) -> Optional[dict]:
196
+ """Get event details for a specific message (bounce reason, etc.)."""
197
+ result = self._api_request("GET", f"/messagehistory/{message_id}")
198
+ if not result:
199
+ return None
200
+ events = result.get("Data", [])
201
+ return events[0] if events else None
202
+
203
+ def delete_contact(self, contact_id: int) -> bool:
204
+ """Delete a contact via the v4 API (clears bounce blocks).
205
+
206
+ The v3 API does not support contact deletion. The v4 API at
207
+ /v4/contacts/{id} permanently removes the contact and its
208
+ bounce history, allowing future emails to be delivered.
209
+ """
210
+ url = f"https://api.mailjet.com/v4/contacts/{contact_id}"
211
+ response = self.session.delete(url)
212
+ if response.status_code == 200:
213
+ return True
214
+ if response.status_code == 404:
215
+ return False
216
+ raise Exception(
217
+ f"Mailjet v4 API error: {response.status_code} - {response.text}"
218
+ )
219
+
220
+
221
+ # =============================================================================
222
+ # CLI
223
+ # =============================================================================
224
+
225
+ def parse_arguments():
226
+ parser = argparse.ArgumentParser(
227
+ description='Manage Mailjet contact status (check, unblock, list blocked)',
228
+ formatter_class=argparse.RawDescriptionHelpFormatter,
229
+ epilog="""
230
+ Examples:
231
+ # Check a contact's status
232
+ python manage_mailjet_contacts.py --action check --email user@example.com
233
+
234
+ # Unblock a blocked contact
235
+ python manage_mailjet_contacts.py --action unblock --email user@example.com
236
+
237
+ # List all blocked/excluded contacts
238
+ python manage_mailjet_contacts.py --action list-blocked
239
+
240
+ # List blocked contacts for a specific domain
241
+ python manage_mailjet_contacts.py --action list-blocked --domain example.com
242
+
243
+ # Bulk unblock from file (one email per line)
244
+ python manage_mailjet_contacts.py --action bulk-unblock --file blocked_emails.txt
245
+
246
+ # Preview unblock without making changes
247
+ python manage_mailjet_contacts.py --action unblock --email user@example.com --dry-run
248
+
249
+ Environment Variables:
250
+ MAILJET_API_KEY Mailjet public API key
251
+ MAILJET_SECRET_KEY Mailjet private/secret key
252
+
253
+ Get API keys at: https://app.mailjet.com/account/apikeys
254
+ """
255
+ )
256
+
257
+ parser.add_argument('--action', required=True,
258
+ choices=['check', 'unblock', 'block', 'clear-bounce',
259
+ 'list-blocked', 'bulk-unblock'],
260
+ help='Action to perform')
261
+ parser.add_argument('--email',
262
+ help='Contact email address')
263
+ parser.add_argument('--domain',
264
+ help='Filter by domain (for list-blocked)')
265
+ parser.add_argument('--file',
266
+ help='File with email addresses, one per line (for bulk-unblock)')
267
+ parser.add_argument('--dry-run', action='store_true',
268
+ help='Preview changes without making them')
269
+
270
+ return parser.parse_args()
271
+
272
+
273
+ # =============================================================================
274
+ # Action Handlers
275
+ # =============================================================================
276
+
277
+ def format_contact_info(contact: dict) -> str:
278
+ """Format contact details for display."""
279
+ lines = []
280
+ excluded = contact.get("IsExcludedFromCampaigns", False)
281
+ status = "BLOCKED (excluded)" if excluded else "ACTIVE"
282
+ status_icon = "[BLOCKED]" if excluded else "[OK]"
283
+
284
+ lines.append(f" {status_icon} {contact.get('Email', 'N/A')}")
285
+ lines.append(f" Status: {status}")
286
+ lines.append(f" Contact ID: {contact.get('ID', 'N/A')}")
287
+ lines.append(f" Name: {contact.get('Name', '(not set)')}")
288
+ lines.append(f" Excluded From Campaigns: {excluded}")
289
+ lines.append(f" Created: {contact.get('CreatedAt', 'N/A')}")
290
+ lines.append(f" Last Activity: {contact.get('LastActivityAt', 'N/A')}")
291
+ lines.append(f" Delivered Count: {contact.get('DeliveredCount', 0)}")
292
+
293
+ return "\n".join(lines)
294
+
295
+
296
+ def format_message_summary(messages: List[dict]) -> str:
297
+ """Format message history summary showing bounce/block status."""
298
+ lines = []
299
+ bounced = [m for m in messages if m.get('Status') == 'hardbounced']
300
+ blocked = [m for m in messages if m.get('Status') == 'blocked']
301
+ sent = [m for m in messages if m.get('Status') in ('sent', 'opened', 'clicked')]
302
+
303
+ lines.append(f" Messages: {len(messages)} total")
304
+ if bounced:
305
+ lines.append(f" Hard Bounced: {len(bounced)} (this causes permanent blocking!)")
306
+ if blocked:
307
+ lines.append(f" Blocked: {len(blocked)} (due to prior hard bounce)")
308
+ if sent:
309
+ lines.append(f" Delivered: {len(sent)}")
310
+
311
+ return "\n".join(lines)
312
+
313
+
314
+ def action_check(client: MailjetContactClient, args) -> int:
315
+ """Check a contact's status."""
316
+ if not args.email:
317
+ logging.error("--email is required for check action")
318
+ return 1
319
+
320
+ logging.info(f"\n--- Mailjet Contact Check: {args.email} ---\n")
321
+
322
+ contact = client.get_contact(args.email)
323
+ if not contact:
324
+ logging.info(f" Contact not found in Mailjet: {args.email}")
325
+ logging.info(" This email has never been added as a Mailjet contact.")
326
+ return 0
327
+
328
+ logging.info(format_contact_info(contact))
329
+
330
+ # Check message history for bounces/blocks
331
+ contact_id = contact.get("ID")
332
+ if contact_id:
333
+ messages = client.get_message_history(contact_id, limit=20)
334
+ if messages:
335
+ logging.info("")
336
+ logging.info(format_message_summary(messages))
337
+
338
+ # Show bounce details
339
+ bounced = [m for m in messages if m.get('Status') == 'hardbounced']
340
+ if bounced:
341
+ event = client.get_message_event(bounced[0]['ID'])
342
+ if event:
343
+ logging.info(f" Bounce reason: {event.get('Comment', 'N/A')}")
344
+ logging.info(f" Bounce state: {event.get('State', 'N/A')}")
345
+
346
+ blocked = [m for m in messages if m.get('Status') == 'blocked']
347
+ if bounced or blocked:
348
+ logging.info("\n This contact is HARD BOUNCE BLOCKED by Mailjet.")
349
+ logging.info(" To clear the bounce and allow emails again, run:")
350
+ logging.info(f" python manage_mailjet_contacts.py --action clear-bounce --email {args.email}")
351
+ return 0
352
+
353
+ excluded = contact.get("IsExcludedFromCampaigns", False)
354
+ if excluded:
355
+ logging.info("\n To unblock (campaign exclusion), run:")
356
+ logging.info(f" python manage_mailjet_contacts.py --action unblock --email {args.email}")
357
+
358
+ return 0
359
+
360
+
361
+ def action_unblock(client: MailjetContactClient, args) -> int:
362
+ """Unblock a single contact."""
363
+ if not args.email:
364
+ logging.error("--email is required for unblock action")
365
+ return 1
366
+
367
+ logging.info(f"\n--- Mailjet Contact Unblock: {args.email} ---\n")
368
+
369
+ # Check current status first
370
+ contact = client.get_contact(args.email)
371
+ if not contact:
372
+ logging.error(f" Contact not found in Mailjet: {args.email}")
373
+ return 1
374
+
375
+ excluded = contact.get("IsExcludedFromCampaigns", False)
376
+ if not excluded:
377
+ logging.info(f" Contact is already active (not blocked): {args.email}")
378
+ return 0
379
+
380
+ logging.info(" Current status: BLOCKED (IsExcludedFromCampaigns=true)")
381
+
382
+ if args.dry_run:
383
+ logging.info(f" [DRY RUN] Would unblock: {args.email}")
384
+ return 0
385
+
386
+ # Unblock
387
+ updated = client.unblock_contact(args.email)
388
+ new_excluded = updated.get("IsExcludedFromCampaigns", True)
389
+
390
+ if not new_excluded:
391
+ logging.info(f" [OK] Successfully unblocked: {args.email}")
392
+ logging.info(" Contact can now receive Mailjet emails.")
393
+ else:
394
+ logging.error(f" [FAIL] Contact still blocked after update: {args.email}")
395
+ return 1
396
+
397
+ return 0
398
+
399
+
400
+ def action_block(client: MailjetContactClient, args) -> int:
401
+ """Block a single contact (exclude from campaigns)."""
402
+ if not args.email:
403
+ logging.error("--email is required for block action")
404
+ return 1
405
+
406
+ logging.info(f"\n--- Mailjet Contact Block: {args.email} ---\n")
407
+
408
+ contact = client.get_contact(args.email)
409
+ if not contact:
410
+ logging.error(f" Contact not found in Mailjet: {args.email}")
411
+ return 1
412
+
413
+ excluded = contact.get("IsExcludedFromCampaigns", False)
414
+ if excluded:
415
+ logging.info(f" Contact is already blocked: {args.email}")
416
+ return 0
417
+
418
+ if args.dry_run:
419
+ logging.info(f" [DRY RUN] Would block: {args.email}")
420
+ return 0
421
+
422
+ updated = client.block_contact(args.email)
423
+ new_excluded = updated.get("IsExcludedFromCampaigns", False)
424
+
425
+ if new_excluded:
426
+ logging.info(f" [OK] Successfully blocked: {args.email}")
427
+ else:
428
+ logging.error(f" [FAIL] Contact still active after update: {args.email}")
429
+ return 1
430
+
431
+ return 0
432
+
433
+
434
+ def action_list_blocked(client: MailjetContactClient, args) -> int:
435
+ """List all blocked/excluded contacts."""
436
+ domain_msg = f" for {args.domain}" if args.domain else ""
437
+ logging.info(f"\n--- Blocked Mailjet Contacts{domain_msg} ---\n")
438
+
439
+ contacts = client.list_all_excluded_contacts(domain=args.domain)
440
+
441
+ if not contacts:
442
+ logging.info(" No blocked contacts found.")
443
+ return 0
444
+
445
+ logging.info(f" Found {len(contacts)} blocked contact(s):\n")
446
+ logging.info(f" {'Email':<40} {'Contact ID':<12} {'Created':<22} {'Delivered'}")
447
+ logging.info(f" {'-'*40} {'-'*12} {'-'*22} {'-'*10}")
448
+
449
+ for contact in contacts:
450
+ logging.info(
451
+ f" {contact.get('Email', 'N/A'):<40} "
452
+ f"{contact.get('ID', 'N/A'):<12} "
453
+ f"{str(contact.get('CreatedAt', 'N/A')):<22} "
454
+ f"{contact.get('DeliveredCount', 0)}"
455
+ )
456
+
457
+ return 0
458
+
459
+
460
+ def action_clear_bounce(client: MailjetContactClient, args) -> int:
461
+ """Clear a hard bounce block by deleting the contact.
462
+
463
+ When Mailjet receives a hard bounce (e.g., "user unknown"), it permanently
464
+ blocks all future emails to that address. The only way to clear this is to
465
+ delete the contact entirely via the v4 API. The next email sent to this
466
+ address will create a fresh contact without bounce history.
467
+ """
468
+ if not args.email:
469
+ logging.error("--email is required for clear-bounce action")
470
+ return 1
471
+
472
+ logging.info(f"\n--- Mailjet Clear Bounce: {args.email} ---\n")
473
+
474
+ contact = client.get_contact(args.email)
475
+ if not contact:
476
+ logging.info(f" Contact not found in Mailjet: {args.email}")
477
+ logging.info(" No bounce to clear — emails should deliver normally.")
478
+ return 0
479
+
480
+ contact_id = contact.get("ID")
481
+ logging.info(format_contact_info(contact))
482
+
483
+ # Check message history for bounce evidence
484
+ messages = client.get_message_history(contact_id, limit=20)
485
+ bounced = [m for m in messages if m.get('Status') == 'hardbounced']
486
+ blocked = [m for m in messages if m.get('Status') == 'blocked']
487
+
488
+ if messages:
489
+ logging.info("")
490
+ logging.info(format_message_summary(messages))
491
+
492
+ if bounced:
493
+ event = client.get_message_event(bounced[0]['ID'])
494
+ if event:
495
+ logging.info(f" Bounce reason: {event.get('Comment', 'N/A')}")
496
+
497
+ if not bounced and not blocked:
498
+ logging.info(f"\n No bounce or block history found for: {args.email}")
499
+ logging.info(" Use --action unblock if the contact is campaign-excluded instead.")
500
+ return 0
501
+
502
+ if args.dry_run:
503
+ logging.info(f"\n [DRY RUN] Would delete contact {contact_id} to clear bounce block")
504
+ return 0
505
+
506
+ # Delete the contact to clear bounce state
507
+ logging.info(f"\n Deleting contact {contact_id} to clear bounce block...")
508
+ deleted = client.delete_contact(contact_id)
509
+
510
+ if deleted:
511
+ logging.info(f" [OK] Contact deleted. Bounce block cleared for: {args.email}")
512
+ logging.info(" Next email to this address will create a fresh contact.")
513
+ else:
514
+ logging.error(f" [FAIL] Could not delete contact: {args.email}")
515
+ return 1
516
+
517
+ return 0
518
+
519
+
520
+ def action_bulk_unblock(client: MailjetContactClient, args) -> int:
521
+ """Bulk unblock contacts from a file."""
522
+ if not args.file:
523
+ logging.error("--file is required for bulk-unblock action")
524
+ return 1
525
+
526
+ if not os.path.exists(args.file):
527
+ logging.error(f"File not found: {args.file}")
528
+ return 1
529
+
530
+ with open(args.file, 'r') as f:
531
+ emails = [line.strip() for line in f if line.strip() and '@' in line]
532
+
533
+ if not emails:
534
+ logging.error(f"No valid email addresses found in: {args.file}")
535
+ return 1
536
+
537
+ logging.info(f"\n--- Bulk Unblock: {len(emails)} contact(s) ---\n")
538
+
539
+ success = 0
540
+ skipped = 0
541
+ failed = 0
542
+ not_found = 0
543
+
544
+ for email in emails:
545
+ contact = client.get_contact(email)
546
+ if not contact:
547
+ logging.info(f" [SKIP] Not found: {email}")
548
+ not_found += 1
549
+ continue
550
+
551
+ excluded = contact.get("IsExcludedFromCampaigns", False)
552
+ if not excluded:
553
+ logging.info(f" [SKIP] Already active: {email}")
554
+ skipped += 1
555
+ continue
556
+
557
+ if args.dry_run:
558
+ logging.info(f" [DRY] Would unblock: {email}")
559
+ success += 1
560
+ continue
561
+
562
+ try:
563
+ updated = client.unblock_contact(email)
564
+ if not updated.get("IsExcludedFromCampaigns", True):
565
+ logging.info(f" [OK] Unblocked: {email}")
566
+ success += 1
567
+ else:
568
+ logging.error(f" [FAIL] Still blocked: {email}")
569
+ failed += 1
570
+ except Exception as e:
571
+ logging.error(f" [FAIL] Error unblocking {email}: {e}")
572
+ failed += 1
573
+
574
+ logging.info("\n--- Summary ---")
575
+ logging.info(f" Unblocked: {success}")
576
+ logging.info(f" Skipped (already active): {skipped}")
577
+ logging.info(f" Not found: {not_found}")
578
+ logging.info(f" Failed: {failed}")
579
+
580
+ return 0 if failed == 0 else 1
581
+
582
+
583
+ # =============================================================================
584
+ # Main
585
+ # =============================================================================
586
+
587
+ def main():
588
+ args = parse_arguments()
589
+
590
+ logging.basicConfig(
591
+ level=logging.INFO,
592
+ format='%(asctime)s - %(levelname)s - %(message)s'
593
+ )
594
+
595
+ try:
596
+ client = MailjetContactClient()
597
+ except ValueError as e:
598
+ logging.error(str(e))
599
+ return 1
600
+
601
+ action_map = {
602
+ 'check': lambda: action_check(client, args),
603
+ 'unblock': lambda: action_unblock(client, args),
604
+ 'block': lambda: action_block(client, args),
605
+ 'clear-bounce': lambda: action_clear_bounce(client, args),
606
+ 'list-blocked': lambda: action_list_blocked(client, args),
607
+ 'bulk-unblock': lambda: action_bulk_unblock(client, args),
608
+ }
609
+
610
+ handler = action_map.get(args.action)
611
+ if handler:
612
+ return handler()
613
+ else:
614
+ logging.error(f"Unknown action: {args.action}")
615
+ return 1
616
+
617
+
618
+ if __name__ == "__main__":
619
+ exit(main())