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,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())
|