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