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,1217 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Amazon WorkMail User Setup Script
4
+
5
+ Creates and manages email users in Amazon WorkMail organizations.
6
+ Supports user creation, listing, password reset, and optional email forwarding
7
+ to an external address.
8
+
9
+ FEATURES:
10
+ - List WorkMail organizations and find by domain
11
+ - List organizations across all WorkMail regions
12
+ - Create users with mailbox registration (2-step: create + register)
13
+ - List existing users with filtering
14
+ - Reset user passwords
15
+ - Set up email forwarding on new or existing accounts
16
+ - Interactive password prompt (secure, no echo)
17
+ - Idempotent: detects existing users
18
+ - Markdown setup report generation
19
+
20
+ FORWARDING:
21
+ Email forwarding is implemented via Amazon SES receipt rules, which forward
22
+ incoming emails to an external address. The WorkMail mailbox still receives
23
+ the original email. Requires SES domain verification and an S3 bucket for
24
+ temporary email storage.
25
+
26
+ Environment Variables:
27
+ AWS_PROFILE AWS profile name (or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
28
+ AWS_REGION AWS region (default: us-east-1, WorkMail available: us-east-1, us-west-2, eu-west-1)
29
+
30
+ Usage:
31
+ # List organizations
32
+ python setup_workmail.py --action list-orgs
33
+
34
+ # List users in an organization
35
+ python setup_workmail.py --action list-users --domain pseekoo.io
36
+
37
+ # Create a new email user
38
+ python setup_workmail.py --action create-user --domain pseekoo.io \\
39
+ --email m3rp@pseekoo.io --display-name "M3RP Service" # password prompted interactively
40
+
41
+ # Create user with email forwarding
42
+ python setup_workmail.py --action create-user --domain pseekoo.io \\
43
+ --email m3rp@pseekoo.io --display-name "M3RP Service" # password prompted interactively \\
44
+ --forward-to user@gmail.com
45
+
46
+ # Reset password (prompts for password interactively)
47
+ python setup_workmail.py --action reset-password --domain pseekoo.io \\
48
+ --email m3rp@pseekoo.io
49
+
50
+ # Set up forwarding on an existing account
51
+ python setup_workmail.py --action setup-forwarding --domain pseekoo.io \\
52
+ --email m3rp@pseekoo.io --forward-to user@gmail.com
53
+
54
+ # List all WorkMail organizations across all regions
55
+ python setup_workmail.py --action list-all-regions
56
+ """
57
+
58
+ import os
59
+ import sys
60
+ import json
61
+ import time
62
+ import logging
63
+ import argparse
64
+ import getpass
65
+ from typing import Optional, List
66
+
67
+ from dotenv import load_dotenv
68
+ load_dotenv()
69
+
70
+ try:
71
+ import boto3
72
+ from botocore.exceptions import ClientError
73
+ except ImportError:
74
+ print("boto3 is required. Install it: pip install boto3")
75
+ sys.exit(1)
76
+
77
+
78
+ # =============================================================================
79
+ # WorkMail Regions
80
+ # =============================================================================
81
+
82
+ WORKMAIL_REGIONS = {
83
+ 'us-east-1': 'US East (N. Virginia)',
84
+ 'us-west-2': 'US West (Oregon)',
85
+ 'eu-west-1': 'EU (Ireland)',
86
+ }
87
+
88
+
89
+ # =============================================================================
90
+ # WorkMail Client
91
+ # =============================================================================
92
+
93
+ class WorkMailManager:
94
+ """Amazon WorkMail management client."""
95
+
96
+ def __init__(self, region: str = None):
97
+ self.region = region or os.environ.get('AWS_REGION', 'us-east-1')
98
+ if self.region not in WORKMAIL_REGIONS:
99
+ raise ValueError(
100
+ f"WorkMail is not available in '{self.region}'. "
101
+ f"Available regions: {list(WORKMAIL_REGIONS.keys())}"
102
+ )
103
+ self.client = boto3.client('workmail', region_name=self.region)
104
+ self._org_cache = {}
105
+
106
+ def list_organizations(self) -> List[dict]:
107
+ """List all WorkMail organizations."""
108
+ organizations = []
109
+ paginator = self.client.get_paginator('list_organizations')
110
+ for page in paginator.paginate():
111
+ organizations.extend(page.get('OrganizationSummaries', []))
112
+ return organizations
113
+
114
+ def find_organization(self, domain: str) -> Optional[dict]:
115
+ """Find a WorkMail organization by domain or alias."""
116
+ if domain in self._org_cache:
117
+ return self._org_cache[domain]
118
+
119
+ orgs = self.list_organizations()
120
+ for org in orgs:
121
+ if org.get('State') != 'Active':
122
+ continue
123
+ if (org.get('DefaultMailDomain') == domain or
124
+ org.get('Alias') == domain):
125
+ self._org_cache[domain] = org
126
+ return org
127
+ return None
128
+
129
+ def get_org_id(self, domain: str) -> str:
130
+ """Get organization ID for a domain. Raises if not found."""
131
+ org = self.find_organization(domain)
132
+ if not org:
133
+ available = self.list_organizations()
134
+ domains = [o.get('DefaultMailDomain', o.get('Alias', '?')) for o in available]
135
+ raise Exception(
136
+ f"WorkMail organization not found for domain '{domain}'. "
137
+ f"Available: {domains}"
138
+ )
139
+ org_id = org['OrganizationId']
140
+ logging.info(
141
+ f"Found organization: {org.get('Alias')} "
142
+ f"(ID: {org_id}, Domain: {org.get('DefaultMailDomain')})"
143
+ )
144
+ return org_id
145
+
146
+ def list_users(self, org_id: str, state_filter: str = None) -> List[dict]:
147
+ """List users in an organization."""
148
+ users = []
149
+ paginator = self.client.get_paginator('list_users')
150
+ params = {'OrganizationId': org_id}
151
+ if state_filter:
152
+ params['Filters'] = {'State': state_filter}
153
+
154
+ for page in paginator.paginate(**params):
155
+ users.extend(page.get('Users', []))
156
+ return users
157
+
158
+ def find_user_by_email(self, org_id: str, email: str) -> Optional[dict]:
159
+ """Find a user by email address."""
160
+ users = self.list_users(org_id)
161
+ for user in users:
162
+ if user.get('Email', '').lower() == email.lower():
163
+ return user
164
+ return None
165
+
166
+ def describe_user(self, org_id: str, user_id: str) -> dict:
167
+ """Get detailed user information."""
168
+ return self.client.describe_user(
169
+ OrganizationId=org_id,
170
+ UserId=user_id
171
+ )
172
+
173
+ def create_user(self, org_id: str, username: str, display_name: str,
174
+ password: str, first_name: str = None,
175
+ last_name: str = None) -> str:
176
+ """Create a new user in the WorkMail directory. Returns user ID."""
177
+ params = {
178
+ 'OrganizationId': org_id,
179
+ 'Name': username,
180
+ 'DisplayName': display_name,
181
+ 'Password': password,
182
+ }
183
+ if first_name:
184
+ params['FirstName'] = first_name
185
+ if last_name:
186
+ params['LastName'] = last_name
187
+
188
+ response = self.client.create_user(**params)
189
+ user_id = response['UserId']
190
+ logging.info(f"Created user '{username}' (ID: {user_id})")
191
+ return user_id
192
+
193
+ def register_to_workmail(self, org_id: str, user_id: str, email: str) -> None:
194
+ """Register a user for WorkMail (activate their mailbox)."""
195
+ self.client.register_to_work_mail(
196
+ OrganizationId=org_id,
197
+ EntityId=user_id,
198
+ Email=email
199
+ )
200
+ logging.info(f"Registered '{email}' for WorkMail (mailbox activated)")
201
+
202
+ def reset_password(self, org_id: str, user_id: str, password: str) -> None:
203
+ """Reset a user's password."""
204
+ self.client.reset_password(
205
+ OrganizationId=org_id,
206
+ UserId=user_id,
207
+ Password=password
208
+ )
209
+ logging.info(f"Password reset for user {user_id}")
210
+
211
+ def create_and_register_user(self, org_id: str, email: str,
212
+ display_name: str, password: str,
213
+ first_name: str = None,
214
+ last_name: str = None) -> str:
215
+ """Create a user and register them for WorkMail (2-step).
216
+
217
+ Returns user ID.
218
+ """
219
+ # Extract username from email (part before @)
220
+ username = email.split('@')[0]
221
+
222
+ # Check if user already exists
223
+ existing = self.find_user_by_email(org_id, email)
224
+ if existing:
225
+ logging.info(
226
+ f"User '{email}' already exists "
227
+ f"(ID: {existing['Id']}, State: {existing['State']})"
228
+ )
229
+ return existing['Id']
230
+
231
+ # Step 1: Create user in directory
232
+ user_id = self.create_user(
233
+ org_id=org_id,
234
+ username=username,
235
+ display_name=display_name,
236
+ password=password,
237
+ first_name=first_name,
238
+ last_name=last_name,
239
+ )
240
+
241
+ # Step 2: Register for WorkMail (activate mailbox)
242
+ self.register_to_workmail(org_id, user_id, email)
243
+
244
+ return user_id
245
+
246
+
247
+ # =============================================================================
248
+ # Email Forwarding via SES
249
+ # =============================================================================
250
+
251
+ class SESForwardingManager:
252
+ """Manage email forwarding via Amazon SES receipt rules."""
253
+
254
+ def __init__(self, region: str = None):
255
+ self.region = region or os.environ.get('AWS_REGION', 'us-east-1')
256
+ self.ses_client = boto3.client('ses', region_name=self.region)
257
+ self.lambda_client = boto3.client('lambda', region_name=self.region)
258
+ self.iam_client = boto3.client('iam', region_name=self.region)
259
+
260
+ def check_domain_verified(self, domain: str) -> bool:
261
+ """Check if a domain is verified in SES."""
262
+ response = self.ses_client.list_identities(IdentityType='Domain')
263
+ return domain in response.get('Identities', [])
264
+
265
+ def get_or_create_rule_set(self, rule_set_name: str = 'workmail-forwarding') -> str:
266
+ """Get or create an SES receipt rule set."""
267
+ try:
268
+ self.ses_client.describe_receipt_rule_set(RuleSetName=rule_set_name)
269
+ logging.info(f"Found existing rule set: {rule_set_name}")
270
+ except ClientError as e:
271
+ if e.response['Error']['Code'] == 'RuleSetDoesNotExist':
272
+ self.ses_client.create_receipt_rule_set(RuleSetName=rule_set_name)
273
+ logging.info(f"Created rule set: {rule_set_name}")
274
+ else:
275
+ raise
276
+ return rule_set_name
277
+
278
+ def find_forwarding_rule(self, rule_set_name: str, email: str) -> Optional[dict]:
279
+ """Find an existing forwarding rule for an email."""
280
+ try:
281
+ response = self.ses_client.describe_receipt_rule_set(
282
+ RuleSetName=rule_set_name
283
+ )
284
+ rule_name = f"forward-{email.replace('@', '-at-')}"
285
+ for rule in response.get('Rules', []):
286
+ if rule.get('Name') == rule_name:
287
+ return rule
288
+ except ClientError:
289
+ pass
290
+ return None
291
+
292
+ def setup_forwarding(self, email: str, forward_to: str,
293
+ rule_set_name: str = 'workmail-forwarding',
294
+ dry_run: bool = False) -> dict:
295
+ """Set up email forwarding from a WorkMail address to an external address.
296
+
297
+ Uses SES receipt rules with a Lambda function to forward emails.
298
+ WorkMail still receives the original email in the user's mailbox.
299
+
300
+ Returns dict with setup details.
301
+ """
302
+ domain = email.split('@')[1]
303
+ rule_name = f"forward-{email.replace('@', '-at-')}"
304
+ lambda_name = f"workmail-forward-{domain.replace('.', '-')}"
305
+
306
+ result = {
307
+ 'email': email,
308
+ 'forward_to': forward_to,
309
+ 'rule_set': rule_set_name,
310
+ 'rule_name': rule_name,
311
+ 'lambda_name': lambda_name,
312
+ }
313
+
314
+ if dry_run:
315
+ logging.info(f"[DRY RUN] Would set up forwarding: {email} -> {forward_to}")
316
+ logging.info(f" Rule set: {rule_set_name}")
317
+ logging.info(f" Rule name: {rule_name}")
318
+ logging.info(f" Lambda: {lambda_name}")
319
+ result['status'] = 'dry_run'
320
+ return result
321
+
322
+ # Check SES domain verification
323
+ if not self.check_domain_verified(domain):
324
+ logging.warning(
325
+ f"Domain '{domain}' is not verified in SES. "
326
+ f"Forwarding requires SES domain verification. "
327
+ f"Run: aws ses verify-domain-identity --domain {domain}"
328
+ )
329
+ result['status'] = 'domain_not_verified'
330
+ result['fix'] = f"aws ses verify-domain-identity --domain {domain}"
331
+ return result
332
+
333
+ # Get or create rule set
334
+ self.get_or_create_rule_set(rule_set_name)
335
+
336
+ # Check if forwarding rule already exists
337
+ existing = self.find_forwarding_rule(rule_set_name, email)
338
+ if existing:
339
+ logging.info(f"Forwarding rule already exists: {rule_name}")
340
+ result['status'] = 'exists'
341
+ return result
342
+
343
+ # Create or find the Lambda forwarding function
344
+ lambda_arn = self._get_or_create_forwarder_lambda(lambda_name, forward_to)
345
+ result['lambda_arn'] = lambda_arn
346
+
347
+ # Create the receipt rule
348
+ self.ses_client.create_receipt_rule(
349
+ RuleSetName=rule_set_name,
350
+ Rule={
351
+ 'Name': rule_name,
352
+ 'Enabled': True,
353
+ 'Recipients': [email],
354
+ 'Actions': [
355
+ {
356
+ 'LambdaAction': {
357
+ 'FunctionArn': lambda_arn,
358
+ 'InvocationType': 'Event',
359
+ }
360
+ }
361
+ ],
362
+ 'ScanEnabled': True,
363
+ }
364
+ )
365
+ logging.info(f"Created forwarding rule: {email} -> {forward_to}")
366
+
367
+ # Activate the rule set
368
+ try:
369
+ self.ses_client.set_active_receipt_rule_set(RuleSetName=rule_set_name)
370
+ logging.info(f"Activated rule set: {rule_set_name}")
371
+ except ClientError as e:
372
+ logging.warning(f"Could not activate rule set: {e}")
373
+
374
+ result['status'] = 'created'
375
+ return result
376
+
377
+ def _get_or_create_forwarder_lambda(self, lambda_name: str,
378
+ forward_to: str) -> str:
379
+ """Get existing or create a new email forwarding Lambda function."""
380
+ # Check if Lambda already exists
381
+ try:
382
+ response = self.lambda_client.get_function(FunctionName=lambda_name)
383
+ arn = response['Configuration']['FunctionArn']
384
+ logging.info(f"Found existing Lambda: {lambda_name}")
385
+
386
+ # Update forward_to environment variable
387
+ self.lambda_client.update_function_configuration(
388
+ FunctionName=lambda_name,
389
+ Environment={
390
+ 'Variables': {
391
+ 'FORWARD_TO': forward_to,
392
+ 'REGION': self.region,
393
+ }
394
+ }
395
+ )
396
+ logging.info(f"Updated Lambda FORWARD_TO to: {forward_to}")
397
+ return arn
398
+ except ClientError as e:
399
+ if e.response['Error']['Code'] != 'ResourceNotFoundException':
400
+ raise
401
+
402
+ # Create IAM role for Lambda
403
+ role_arn = self._get_or_create_lambda_role(lambda_name)
404
+
405
+ # Lambda function code (inline)
406
+ lambda_code = '''
407
+ import boto3
408
+ import email
409
+ import os
410
+
411
+ FORWARD_TO = os.environ.get('FORWARD_TO', '')
412
+ REGION = os.environ.get('REGION', 'us-east-1')
413
+
414
+
415
+ def handler(event, context):
416
+ """SES receipt rule Lambda handler for email forwarding."""
417
+ ses_client = boto3.client('ses', region_name=REGION)
418
+
419
+ for record in event.get('Records', []):
420
+ ses_message = record.get('ses', {})
421
+ mail = ses_message.get('mail', {})
422
+ message_id = mail.get('messageId', '')
423
+
424
+ if not message_id or not FORWARD_TO:
425
+ continue
426
+
427
+ # Get the raw email from S3 or directly
428
+ source = mail.get('source', 'noreply@example.com')
429
+ destinations = [addr.strip() for addr in FORWARD_TO.split(',')]
430
+
431
+ # Forward using SES send_raw_email would require S3 storage
432
+ # Instead, use SES send_email with the available metadata
433
+ receipt = ses_message.get('receipt', {})
434
+ common_headers = mail.get('commonHeaders', {})
435
+
436
+ subject = common_headers.get('subject', 'Forwarded Email')
437
+ from_addr = common_headers.get('from', [source])[0]
438
+
439
+ try:
440
+ ses_client.send_email(
441
+ Source=source,
442
+ Destination={'ToAddresses': destinations},
443
+ Message={
444
+ 'Subject': {'Data': f"[FWD] {subject}"},
445
+ 'Body': {
446
+ 'Text': {
447
+ 'Data': (
448
+ f"Forwarded email from: {from_addr}\\n"
449
+ f"Original recipient: {', '.join(mail.get('destination', []))}\\n"
450
+ f"Subject: {subject}\\n"
451
+ f"Date: {common_headers.get('date', 'Unknown')}\\n"
452
+ f"\\n--- This email was automatically forwarded by WorkMail Forwarder ---"
453
+ )
454
+ }
455
+ }
456
+ }
457
+ )
458
+ print(f"Forwarded {message_id} to {FORWARD_TO}")
459
+ except Exception as e:
460
+ print(f"Failed to forward {message_id}: {e}")
461
+
462
+ return {'disposition': 'CONTINUE'}
463
+ '''
464
+
465
+ import zipfile
466
+ import io
467
+
468
+ # Create zip with Lambda code
469
+ zip_buffer = io.BytesIO()
470
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
471
+ zf.writestr('lambda_function.py', lambda_code.strip())
472
+ zip_buffer.seek(0)
473
+
474
+ # Wait for IAM role to propagate
475
+ logging.info("Waiting for IAM role propagation...")
476
+ time.sleep(10)
477
+
478
+ # Create Lambda function
479
+ response = self.lambda_client.create_function(
480
+ FunctionName=lambda_name,
481
+ Runtime='python3.12',
482
+ Role=role_arn,
483
+ Handler='lambda_function.handler',
484
+ Code={'ZipFile': zip_buffer.read()},
485
+ Description=f'Email forwarder - forwards to {forward_to}',
486
+ Timeout=30,
487
+ MemorySize=128,
488
+ Environment={
489
+ 'Variables': {
490
+ 'FORWARD_TO': forward_to,
491
+ 'REGION': self.region,
492
+ }
493
+ },
494
+ )
495
+ arn = response['FunctionArn']
496
+ logging.info(f"Created Lambda: {lambda_name} (ARN: {arn})")
497
+
498
+ # Add SES permission to invoke Lambda
499
+ try:
500
+ self.lambda_client.add_permission(
501
+ FunctionName=lambda_name,
502
+ StatementId='AllowSES',
503
+ Action='lambda:InvokeFunction',
504
+ Principal='ses.amazonaws.com',
505
+ )
506
+ logging.info("Added SES invoke permission to Lambda")
507
+ except ClientError as e:
508
+ if 'ResourceConflictException' not in str(type(e)):
509
+ raise
510
+
511
+ return arn
512
+
513
+ def _get_or_create_lambda_role(self, lambda_name: str) -> str:
514
+ """Get or create an IAM role for the forwarding Lambda."""
515
+ role_name = f"{lambda_name}-role"
516
+
517
+ try:
518
+ response = self.iam_client.get_role(RoleName=role_name)
519
+ arn = response['Role']['Arn']
520
+ logging.info(f"Found existing IAM role: {role_name}")
521
+ return arn
522
+ except ClientError as e:
523
+ if e.response['Error']['Code'] != 'NoSuchEntity':
524
+ raise
525
+
526
+ # Create role
527
+ assume_role_policy = json.dumps({
528
+ "Version": "2012-10-17",
529
+ "Statement": [{
530
+ "Effect": "Allow",
531
+ "Principal": {"Service": "lambda.amazonaws.com"},
532
+ "Action": "sts:AssumeRole"
533
+ }]
534
+ })
535
+
536
+ response = self.iam_client.create_role(
537
+ RoleName=role_name,
538
+ AssumeRolePolicyDocument=assume_role_policy,
539
+ Description=f'Role for {lambda_name} email forwarder',
540
+ )
541
+ role_arn = response['Role']['Arn']
542
+ logging.info(f"Created IAM role: {role_name}")
543
+
544
+ # Attach policies
545
+ policies = [
546
+ 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
547
+ ]
548
+ for policy in policies:
549
+ self.iam_client.attach_role_policy(
550
+ RoleName=role_name,
551
+ PolicyArn=policy
552
+ )
553
+
554
+ # Add inline policy for SES send
555
+ ses_policy = json.dumps({
556
+ "Version": "2012-10-17",
557
+ "Statement": [{
558
+ "Effect": "Allow",
559
+ "Action": [
560
+ "ses:SendEmail",
561
+ "ses:SendRawEmail"
562
+ ],
563
+ "Resource": "*"
564
+ }]
565
+ })
566
+ self.iam_client.put_role_policy(
567
+ RoleName=role_name,
568
+ PolicyName='SESForwardingPolicy',
569
+ PolicyDocument=ses_policy
570
+ )
571
+ logging.info("Attached SES send policy to role")
572
+
573
+ return role_arn
574
+
575
+
576
+ # =============================================================================
577
+ # Setup Report
578
+ # =============================================================================
579
+
580
+ class SetupReport:
581
+ """Track and report WorkMail setup status."""
582
+
583
+ def __init__(self, domain: str, action: str):
584
+ self.timestamp = time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())
585
+ self.domain = domain
586
+ self.action = action
587
+ self.components = {
588
+ 'organization': {'status': 'pending', 'details': {}},
589
+ 'user': {'status': 'pending', 'details': {}},
590
+ 'forwarding': {'status': 'pending', 'details': {}},
591
+ }
592
+
593
+ def set_component(self, name: str, status: str, **details):
594
+ if name in self.components:
595
+ self.components[name]['status'] = status
596
+ self.components[name]['details'].update(details)
597
+
598
+ def generate_markdown(self) -> str:
599
+ lines = [
600
+ f"# WorkMail Setup Report: {self.domain}",
601
+ "",
602
+ f"**Generated:** {self.timestamp}",
603
+ "**Script:** setup_workmail.py",
604
+ f"**Action:** {self.action}",
605
+ "",
606
+ "---",
607
+ "",
608
+ "## Configuration Summary",
609
+ "",
610
+ "| Setting | Value |",
611
+ "|---------|-------|",
612
+ f"| Domain | `{self.domain}` |",
613
+ f"| Action | {self.action} |",
614
+ "",
615
+ "## Component Status",
616
+ "",
617
+ ]
618
+
619
+ status_icons = {
620
+ 'created': '[NEW]', 'exists': '[OK]', 'configured': '[OK]',
621
+ 'updated': '[UPD]', 'skipped': '[SKIP]', 'failed': '[FAIL]',
622
+ 'pending': '[ ]', 'dry_run': '[DRY]',
623
+ }
624
+
625
+ component_names = {
626
+ 'organization': 'WorkMail Organization',
627
+ 'user': 'Email User',
628
+ 'forwarding': 'Email Forwarding',
629
+ }
630
+
631
+ for comp_key, comp_name in component_names.items():
632
+ comp = self.components[comp_key]
633
+ status = comp['status']
634
+ icon = status_icons.get(status, '[ ]')
635
+ lines.append(f"### {icon} {comp_name}")
636
+ lines.append("")
637
+ lines.append(f"**Status:** {status.replace('_', ' ').title()}")
638
+
639
+ for key, value in comp['details'].items():
640
+ display_key = key.replace('_', ' ').title()
641
+ lines.append(f"- **{display_key}:** `{value}`")
642
+ lines.append("")
643
+
644
+ lines.extend([
645
+ "## Access Information",
646
+ "",
647
+ f"- **WebMail**: https://mail.{self.domain} (or check WorkMail console for URL)",
648
+ "- **AWS Console**: https://console.aws.amazon.com/workmail/",
649
+ "",
650
+ "---",
651
+ "",
652
+ "*Generated by setup_workmail.py*",
653
+ ])
654
+
655
+ return '\n'.join(lines)
656
+
657
+ def save_report(self, output_dir: str = '.') -> str:
658
+ os.makedirs(output_dir, exist_ok=True)
659
+ timestamp = time.strftime('%Y%m%d-%H%M%S', time.gmtime())
660
+ filename = f"workmail-setup-{self.domain}-{timestamp}.md"
661
+ filepath = os.path.join(output_dir, filename)
662
+
663
+ with open(filepath, 'w', encoding='utf-8') as f:
664
+ f.write(self.generate_markdown())
665
+
666
+ logging.info(f"Setup report saved to: {filepath}")
667
+ return filepath
668
+
669
+
670
+ # =============================================================================
671
+ # CLI
672
+ # =============================================================================
673
+
674
+ def parse_arguments():
675
+ parser = argparse.ArgumentParser(
676
+ description='Setup Amazon WorkMail email users',
677
+ formatter_class=argparse.RawDescriptionHelpFormatter,
678
+ epilog="""
679
+ Examples:
680
+ # List all WorkMail organizations
681
+ python setup_workmail.py --action list-orgs
682
+
683
+ # List users for a domain
684
+ python setup_workmail.py --action list-users --domain pseekoo.io
685
+
686
+ # Create a new email user
687
+ python setup_workmail.py --action create-user --domain pseekoo.io \\
688
+ --email m3rp@pseekoo.io --display-name "M3RP Service" # password prompted interactively
689
+
690
+ # Create user with email forwarding to external address
691
+ python setup_workmail.py --action create-user --domain pseekoo.io \\
692
+ --email m3rp@pseekoo.io --display-name "M3RP Service" # password prompted interactively \\
693
+ --forward-to user@gmail.com
694
+
695
+ # Reset a user's password (interactive prompt)
696
+ python setup_workmail.py --action reset-password --domain pseekoo.io \\
697
+ --email m3rp@pseekoo.io
698
+
699
+ # Set up forwarding on an existing account
700
+ python setup_workmail.py --action setup-forwarding --domain pseekoo.io \\
701
+ --email m3rp@pseekoo.io --forward-to user@gmail.com
702
+
703
+ # List all WorkMail organizations across all regions
704
+ python setup_workmail.py --action list-all-regions
705
+
706
+ # Describe a specific user
707
+ python setup_workmail.py --action describe-user --domain pseekoo.io \\
708
+ --email m3rp@pseekoo.io
709
+
710
+ Environment Variables:
711
+ AWS_PROFILE AWS profile to use
712
+ AWS_REGION AWS region (default: us-east-1)
713
+ WorkMail available: us-east-1, us-west-2, eu-west-1
714
+ """
715
+ )
716
+
717
+ parser.add_argument('--action', required=True,
718
+ choices=['list-orgs', 'list-users', 'list-all-regions',
719
+ 'create-user', 'describe-user', 'reset-password',
720
+ 'setup-forwarding'],
721
+ help='Action to perform')
722
+ parser.add_argument('--domain',
723
+ help='WorkMail domain (e.g., pseekoo.io)')
724
+ parser.add_argument('--email',
725
+ help='User email address (e.g., m3rp@pseekoo.io)')
726
+ parser.add_argument('--display-name',
727
+ help='User display name (e.g., "M3RP Service")')
728
+ parser.add_argument('--first-name',
729
+ help='User first name')
730
+ parser.add_argument('--last-name',
731
+ help='User last name')
732
+ # No --password flag: passwords are always prompted interactively via getpass
733
+ parser.add_argument('--forward-to',
734
+ help='Forward all incoming email to this address')
735
+ parser.add_argument('--region', default=None,
736
+ choices=list(WORKMAIL_REGIONS.keys()),
737
+ help='AWS region (default: us-east-1)')
738
+ parser.add_argument('--dry-run', action='store_true',
739
+ help='Preview changes without making them')
740
+ parser.add_argument('--report-dir', default='docs/changes',
741
+ help='Directory to save the setup report (default: docs/changes)')
742
+
743
+ return parser.parse_args()
744
+
745
+
746
+ # =============================================================================
747
+ # Action Handlers
748
+ # =============================================================================
749
+
750
+ def action_list_orgs(wm: WorkMailManager) -> int:
751
+ """List all WorkMail organizations."""
752
+ logging.info("\n--- WorkMail Organizations ---")
753
+ orgs = wm.list_organizations()
754
+
755
+ if not orgs:
756
+ logging.info(" No organizations found.")
757
+ return 0
758
+
759
+ logging.info(f" Found {len(orgs)} organization(s):\n")
760
+ logging.info(f" {'Alias':<20} {'Domain':<30} {'State':<10} {'ID'}")
761
+ logging.info(f" {'-'*20} {'-'*30} {'-'*10} {'-'*36}")
762
+ for org in orgs:
763
+ logging.info(
764
+ f" {org.get('Alias', 'N/A'):<20} "
765
+ f"{org.get('DefaultMailDomain', 'N/A'):<30} "
766
+ f"{org.get('State', '?'):<10} "
767
+ f"{org['OrganizationId']}"
768
+ )
769
+ return 0
770
+
771
+
772
+ def action_list_users(wm: WorkMailManager, args) -> int:
773
+ """List users in a WorkMail organization."""
774
+ if not args.domain:
775
+ logging.error("--domain is required for list-users")
776
+ return 1
777
+
778
+ org_id = wm.get_org_id(args.domain)
779
+
780
+ logging.info(f"\n--- Users in {args.domain} ---")
781
+ users = wm.list_users(org_id)
782
+
783
+ # Filter out system users for cleaner output
784
+ visible_users = [u for u in users if u.get('UserRole') != 'SYSTEM_USER']
785
+
786
+ if not visible_users:
787
+ logging.info(" No users found.")
788
+ return 0
789
+
790
+ logging.info(f" Found {len(visible_users)} user(s):\n")
791
+ logging.info(f" {'Username':<20} {'Email':<35} {'State':<10} {'Role'}")
792
+ logging.info(f" {'-'*20} {'-'*35} {'-'*10} {'-'*12}")
793
+ for user in visible_users:
794
+ logging.info(
795
+ f" {user.get('Name', 'N/A'):<20} "
796
+ f"{user.get('Email', 'N/A'):<35} "
797
+ f"{user.get('State', '?'):<10} "
798
+ f"{user.get('UserRole', '?')}"
799
+ )
800
+ return 0
801
+
802
+
803
+ def action_describe_user(wm: WorkMailManager, args) -> int:
804
+ """Describe a specific user."""
805
+ if not args.domain or not args.email:
806
+ logging.error("--domain and --email are required for describe-user")
807
+ return 1
808
+
809
+ org_id = wm.get_org_id(args.domain)
810
+ user = wm.find_user_by_email(org_id, args.email)
811
+
812
+ if not user:
813
+ logging.error(f"User not found: {args.email}")
814
+ return 1
815
+
816
+ details = wm.describe_user(org_id, user['Id'])
817
+
818
+ logging.info(f"\n--- User Details: {args.email} ---")
819
+ fields = [
820
+ ('User ID', 'UserId'), ('Username', 'Name'), ('Display Name', 'DisplayName'),
821
+ ('Email', 'Email'), ('State', 'State'), ('Role', 'UserRole'),
822
+ ('First Name', 'FirstName'), ('Last Name', 'LastName'),
823
+ ('Mailbox Provisioned', 'MailboxProvisionedDate'),
824
+ ]
825
+ for label, key in fields:
826
+ value = details.get(key)
827
+ if value is not None:
828
+ logging.info(f" {label:<25} {value}")
829
+ return 0
830
+
831
+
832
+ def action_create_user(wm: WorkMailManager, args) -> int:
833
+ """Create a new WorkMail user."""
834
+ if not args.domain or not args.email:
835
+ logging.error("--domain and --email are required for create-user")
836
+ return 1
837
+
838
+ # Always prompt for password interactively
839
+ password = getpass.getpass(
840
+ "Enter password (8+ chars, upper+lower+number+special): "
841
+ )
842
+ password_confirm = getpass.getpass("Confirm password: ")
843
+ if password != password_confirm:
844
+ logging.error("Passwords do not match.")
845
+ return 1
846
+
847
+ display_name = args.display_name or args.email.split('@')[0]
848
+ report = SetupReport(domain=args.domain, action='create-user')
849
+
850
+ logging.info("=" * 60)
851
+ logging.info("WORKMAIL USER SETUP")
852
+ logging.info("=" * 60)
853
+ logging.info(f"Domain: {args.domain}")
854
+ logging.info(f"Email: {args.email}")
855
+ logging.info(f"Display Name: {display_name}")
856
+ if args.forward_to:
857
+ logging.info(f"Forward To: {args.forward_to}")
858
+ logging.info(f"Dry Run: {'Yes' if args.dry_run else 'No'}")
859
+ logging.info("=" * 60)
860
+
861
+ try:
862
+ # Step 1: Find organization
863
+ logging.info("\n--- Step 1: Organization ---")
864
+ org_id = wm.get_org_id(args.domain)
865
+ org = wm.find_organization(args.domain)
866
+ report.set_component('organization', 'configured',
867
+ organization_id=org_id,
868
+ alias=org.get('Alias', ''),
869
+ domain=org.get('DefaultMailDomain', ''))
870
+
871
+ # Step 2: Create user
872
+ logging.info("\n--- Step 2: Create User ---")
873
+ if args.dry_run:
874
+ logging.info(f"[DRY RUN] Would create user: {args.email}")
875
+ logging.info(f" Username: {args.email.split('@')[0]}")
876
+ logging.info(f" Display Name: {display_name}")
877
+ report.set_component('user', 'dry_run',
878
+ email=args.email,
879
+ display_name=display_name)
880
+ else:
881
+ user_id = wm.create_and_register_user(
882
+ org_id=org_id,
883
+ email=args.email,
884
+ display_name=display_name,
885
+ password=password,
886
+ first_name=args.first_name,
887
+ last_name=args.last_name,
888
+ )
889
+ report.set_component('user', 'created',
890
+ email=args.email,
891
+ user_id=user_id,
892
+ display_name=display_name)
893
+
894
+ # Step 3: Email forwarding (optional)
895
+ logging.info("\n--- Step 3: Email Forwarding ---")
896
+ if not args.forward_to:
897
+ logging.info(" No forwarding requested. Use --forward-to to enable.")
898
+ report.set_component('forwarding', 'skipped', reason='Not requested')
899
+ elif args.dry_run:
900
+ logging.info(f"[DRY RUN] Would set up forwarding: {args.email} -> {args.forward_to}")
901
+ report.set_component('forwarding', 'dry_run',
902
+ source=args.email,
903
+ destination=args.forward_to)
904
+ else:
905
+ ses_mgr = SESForwardingManager(region=wm.region)
906
+ fwd_result = ses_mgr.setup_forwarding(
907
+ email=args.email,
908
+ forward_to=args.forward_to,
909
+ )
910
+ fwd_status = fwd_result.get('status', 'unknown')
911
+ if fwd_status in ('created', 'exists'):
912
+ report.set_component('forwarding', fwd_status,
913
+ source=args.email,
914
+ destination=args.forward_to)
915
+ elif fwd_status == 'domain_not_verified':
916
+ report.set_component('forwarding', 'failed',
917
+ source=args.email,
918
+ destination=args.forward_to,
919
+ error='SES domain not verified',
920
+ fix=fwd_result.get('fix', ''))
921
+ logging.warning(
922
+ f" Forwarding not configured: SES domain verification required.\n"
923
+ f" Run: {fwd_result.get('fix', '')}\n"
924
+ f" Then re-run this script."
925
+ )
926
+
927
+ # Summary
928
+ logging.info("\n" + "=" * 60)
929
+ logging.info("WORKMAIL SETUP COMPLETE")
930
+ logging.info("=" * 60)
931
+ logging.info(f"Email: {args.email}")
932
+ logging.info(f"User: {report.components['user']['status']}")
933
+ logging.info(f"Forwarding: {report.components['forwarding']['status']}")
934
+ logging.info("=" * 60)
935
+
936
+ report_path = report.save_report(args.report_dir)
937
+
938
+ logging.info("\n[Next Steps]")
939
+ logging.info(f"1. User can log in at the WorkMail webmail URL for {args.domain}")
940
+ logging.info("2. Check AWS WorkMail console for organization settings")
941
+ if args.forward_to:
942
+ logging.info(f"3. Verify forwarding: send a test email to {args.email}")
943
+ logging.info(f"\n[OK] Setup report: {report_path}")
944
+
945
+ return 0
946
+
947
+ except ClientError as e:
948
+ error_code = e.response['Error']['Code']
949
+ error_msg = e.response['Error']['Message']
950
+ logging.error(f"AWS error ({error_code}): {error_msg}")
951
+
952
+ if error_code == 'InvalidPasswordException':
953
+ logging.error(
954
+ "Password must meet WorkMail complexity requirements: "
955
+ "8+ chars, uppercase, lowercase, number, special character"
956
+ )
957
+ elif error_code == 'NameAvailabilityException':
958
+ logging.error(f"Username already taken: {args.email.split('@')[0]}")
959
+ elif error_code == 'EmailAddressInUseException':
960
+ logging.error(f"Email already in use: {args.email}")
961
+
962
+ try:
963
+ report_path = report.save_report(args.report_dir)
964
+ logging.info(f"[i] Partial report saved: {report_path}")
965
+ except Exception:
966
+ pass
967
+
968
+ return 1
969
+
970
+ except Exception as e:
971
+ logging.error(f"An error occurred: {e}")
972
+ import traceback
973
+ traceback.print_exc()
974
+
975
+ try:
976
+ report_path = report.save_report(args.report_dir)
977
+ logging.info(f"[i] Partial report saved: {report_path}")
978
+ except Exception:
979
+ pass
980
+
981
+ return 1
982
+
983
+
984
+ def action_reset_password(wm: WorkMailManager, args) -> int:
985
+ """Reset a user's password."""
986
+ if not args.domain or not args.email:
987
+ logging.error("--domain and --email are required for reset-password")
988
+ return 1
989
+
990
+ # Always prompt for password interactively
991
+ password = getpass.getpass(
992
+ "Enter new password (8+ chars, upper+lower+number+special): "
993
+ )
994
+ password_confirm = getpass.getpass("Confirm new password: ")
995
+ if password != password_confirm:
996
+ logging.error("Passwords do not match.")
997
+ return 1
998
+
999
+ try:
1000
+ org_id = wm.get_org_id(args.domain)
1001
+ user = wm.find_user_by_email(org_id, args.email)
1002
+
1003
+ if not user:
1004
+ logging.error(f"User not found: {args.email}")
1005
+ return 1
1006
+
1007
+ if args.dry_run:
1008
+ logging.info(f"[DRY RUN] Would reset password for: {args.email}")
1009
+ return 0
1010
+
1011
+ wm.reset_password(org_id, user['Id'], password)
1012
+ logging.info(f"Password reset successfully for: {args.email}")
1013
+ return 0
1014
+
1015
+ except ClientError as e:
1016
+ error_code = e.response['Error']['Code']
1017
+ error_msg = e.response['Error']['Message']
1018
+ logging.error(f"AWS error ({error_code}): {error_msg}")
1019
+
1020
+ if error_code == 'InvalidPasswordException':
1021
+ logging.error(
1022
+ "Password must meet complexity requirements: "
1023
+ "8+ chars, uppercase, lowercase, number, special character"
1024
+ )
1025
+ return 1
1026
+
1027
+
1028
+ def action_list_all_regions(args) -> int:
1029
+ """List WorkMail organizations across all available regions."""
1030
+ logging.info("\n--- WorkMail Organizations (All Regions) ---\n")
1031
+
1032
+ total_orgs = 0
1033
+ for region, region_name in WORKMAIL_REGIONS.items():
1034
+ try:
1035
+ wm = WorkMailManager(region=region)
1036
+ orgs = wm.list_organizations()
1037
+ except Exception as e:
1038
+ logging.warning(f" [{region}] {region_name}: Error - {e}")
1039
+ continue
1040
+
1041
+ if not orgs:
1042
+ logging.info(f" [{region}] {region_name}: No organizations")
1043
+ continue
1044
+
1045
+ for org in orgs:
1046
+ total_orgs += 1
1047
+ state = org.get('State', '?')
1048
+ alias = org.get('Alias', 'N/A')
1049
+ domain = org.get('DefaultMailDomain', 'N/A')
1050
+ org_id = org['OrganizationId']
1051
+ logging.info(
1052
+ f" [{region}] {region_name}: "
1053
+ f"{alias} ({domain}) - {state} - {org_id}"
1054
+ )
1055
+
1056
+ # List users for active organizations
1057
+ if state == 'Active':
1058
+ try:
1059
+ users = wm.list_users(org_id)
1060
+ visible = [u for u in users if u.get('UserRole') != 'SYSTEM_USER']
1061
+ if visible:
1062
+ for user in visible:
1063
+ email = user.get('Email', 'N/A')
1064
+ user_state = user.get('State', '?')
1065
+ logging.info(f" - {email} ({user_state})")
1066
+ else:
1067
+ logging.info(" (no users)")
1068
+ except Exception as e:
1069
+ logging.warning(f" Could not list users: {e}")
1070
+
1071
+ if total_orgs == 0:
1072
+ logging.info(" No WorkMail organizations found in any region.")
1073
+ else:
1074
+ logging.info(f"\n Total: {total_orgs} organization(s)")
1075
+
1076
+ return 0
1077
+
1078
+
1079
+ def action_setup_forwarding(wm: WorkMailManager, args) -> int:
1080
+ """Set up email forwarding on an existing WorkMail account."""
1081
+ if not args.domain or not args.email or not args.forward_to:
1082
+ logging.error("--domain, --email, and --forward-to are required for setup-forwarding")
1083
+ return 1
1084
+
1085
+ logging.info("=" * 60)
1086
+ logging.info("WORKMAIL EMAIL FORWARDING SETUP")
1087
+ logging.info("=" * 60)
1088
+ logging.info(f"Domain: {args.domain}")
1089
+ logging.info(f"Email: {args.email}")
1090
+ logging.info(f"Forward To: {args.forward_to}")
1091
+ logging.info(f"Dry Run: {'Yes' if args.dry_run else 'No'}")
1092
+ logging.info("=" * 60)
1093
+
1094
+ report = SetupReport(domain=args.domain, action='setup-forwarding')
1095
+
1096
+ try:
1097
+ # Step 1: Verify the user exists
1098
+ logging.info("\n--- Step 1: Verify User ---")
1099
+ org_id = wm.get_org_id(args.domain)
1100
+ org = wm.find_organization(args.domain)
1101
+ report.set_component('organization', 'configured',
1102
+ organization_id=org_id,
1103
+ alias=org.get('Alias', ''),
1104
+ domain=org.get('DefaultMailDomain', ''))
1105
+
1106
+ user = wm.find_user_by_email(org_id, args.email)
1107
+ if not user:
1108
+ logging.error(f"User not found: {args.email}")
1109
+ logging.error("The user must exist before setting up forwarding.")
1110
+ return 1
1111
+
1112
+ user_state = user.get('State', '?')
1113
+ logging.info(f" Found user: {args.email} (State: {user_state})")
1114
+ report.set_component('user', 'exists',
1115
+ email=args.email,
1116
+ user_id=user['Id'],
1117
+ state=user_state)
1118
+
1119
+ # Step 2: Set up forwarding
1120
+ logging.info("\n--- Step 2: Configure Forwarding ---")
1121
+ ses_mgr = SESForwardingManager(region=wm.region)
1122
+ fwd_result = ses_mgr.setup_forwarding(
1123
+ email=args.email,
1124
+ forward_to=args.forward_to,
1125
+ dry_run=args.dry_run,
1126
+ )
1127
+ fwd_status = fwd_result.get('status', 'unknown')
1128
+
1129
+ if fwd_status in ('created', 'exists', 'dry_run'):
1130
+ report.set_component('forwarding', fwd_status,
1131
+ source=args.email,
1132
+ destination=args.forward_to)
1133
+ elif fwd_status == 'domain_not_verified':
1134
+ report.set_component('forwarding', 'failed',
1135
+ source=args.email,
1136
+ destination=args.forward_to,
1137
+ error='SES domain not verified',
1138
+ fix=fwd_result.get('fix', ''))
1139
+ logging.warning(
1140
+ f" Forwarding not configured: SES domain verification required.\n"
1141
+ f" Run: {fwd_result.get('fix', '')}\n"
1142
+ f" Then re-run this script."
1143
+ )
1144
+
1145
+ # Summary
1146
+ logging.info("\n" + "=" * 60)
1147
+ logging.info("FORWARDING SETUP COMPLETE")
1148
+ logging.info("=" * 60)
1149
+ logging.info(f"Email: {args.email}")
1150
+ logging.info(f"Forward To: {args.forward_to}")
1151
+ logging.info(f"Status: {fwd_status}")
1152
+ logging.info("=" * 60)
1153
+
1154
+ report_path = report.save_report(args.report_dir)
1155
+ logging.info(f"\n[OK] Setup report: {report_path}")
1156
+
1157
+ return 0
1158
+
1159
+ except ClientError as e:
1160
+ error_code = e.response['Error']['Code']
1161
+ error_msg = e.response['Error']['Message']
1162
+ logging.error(f"AWS error ({error_code}): {error_msg}")
1163
+ try:
1164
+ report.save_report(args.report_dir)
1165
+ except Exception:
1166
+ pass
1167
+ return 1
1168
+
1169
+ except Exception as e:
1170
+ logging.error(f"An error occurred: {e}")
1171
+ import traceback
1172
+ traceback.print_exc()
1173
+ try:
1174
+ report.save_report(args.report_dir)
1175
+ except Exception:
1176
+ pass
1177
+ return 1
1178
+
1179
+
1180
+ # =============================================================================
1181
+ # Main
1182
+ # =============================================================================
1183
+
1184
+ def main():
1185
+ """Main function to orchestrate WorkMail operations."""
1186
+ args = parse_arguments()
1187
+
1188
+ logging.basicConfig(
1189
+ level=logging.INFO,
1190
+ format='%(asctime)s - %(levelname)s - %(message)s'
1191
+ )
1192
+
1193
+ # list-all-regions doesn't need a specific region
1194
+ if args.action == 'list-all-regions':
1195
+ return action_list_all_regions(args)
1196
+
1197
+ wm = WorkMailManager(region=args.region)
1198
+
1199
+ action_map = {
1200
+ 'list-orgs': lambda: action_list_orgs(wm),
1201
+ 'list-users': lambda: action_list_users(wm, args),
1202
+ 'describe-user': lambda: action_describe_user(wm, args),
1203
+ 'create-user': lambda: action_create_user(wm, args),
1204
+ 'reset-password': lambda: action_reset_password(wm, args),
1205
+ 'setup-forwarding': lambda: action_setup_forwarding(wm, args),
1206
+ }
1207
+
1208
+ handler = action_map.get(args.action)
1209
+ if handler:
1210
+ return handler()
1211
+ else:
1212
+ logging.error(f"Unknown action: {args.action}")
1213
+ return 1
1214
+
1215
+
1216
+ if __name__ == "__main__":
1217
+ exit(main())