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,7 @@
1
+ """Email management — Mailjet + AWS WorkMail."""
2
+
3
+ from granny.email.mailjet import MailjetClient
4
+ from granny.email.mailjet_contacts import MailjetContactClient
5
+ from granny.email.workmail import WorkMailManager
6
+
7
+ __all__ = ["MailjetClient", "MailjetContactClient", "WorkMailManager"]
@@ -0,0 +1,119 @@
1
+ """Mailjet REST API client — sender CRUD, DNS settings, verification.
2
+
3
+ Lifted from ``tools/create/setup_mailjet_dns.py:MailjetClient``.
4
+ Also includes SPF parsing/merging utilities used during DNS auth setup.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ import requests
11
+
12
+ from granny.credentials.secrets import get_secret
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ API_BASE = "https://api.mailjet.com/v3/REST"
17
+
18
+
19
+ class MailjetClient:
20
+ """Mailjet REST API v3 client for sender and DNS management."""
21
+
22
+ def __init__(self) -> None:
23
+ api_key = get_secret("MAILJET_API_KEY")
24
+ secret_key = get_secret("MAILJET_SECRET_KEY")
25
+ if not api_key or not secret_key:
26
+ raise ValueError(
27
+ "MAILJET_API_KEY / MAILJET_SECRET_KEY not set (env or vault)."
28
+ )
29
+ self.session = requests.Session()
30
+ self.session.auth = (api_key, secret_key)
31
+ self.session.headers.update({"Content-Type": "application/json"})
32
+
33
+ def _request(self, method: str, endpoint: str, data: dict | None = None) -> Any:
34
+ url = f"{API_BASE}{endpoint}"
35
+ resp = getattr(self.session, method.lower())(url, json=data, timeout=30)
36
+ if resp.status_code >= 400:
37
+ raise RuntimeError(
38
+ f"Mailjet {method} {endpoint}: {resp.status_code} {resp.text}"
39
+ )
40
+ return resp.json() if resp.text else {}
41
+
42
+ def find_sender(self, domain: str) -> dict | None:
43
+ """Find a catch-all ``*@domain`` sender."""
44
+ result = self._request("GET", "/sender")
45
+ catch_all = f"*@{domain}"
46
+ for sender in result.get("Data", []):
47
+ if sender.get("Email", "").lower() == catch_all.lower():
48
+ return sender
49
+ return None
50
+
51
+ def create_sender(self, domain: str) -> dict:
52
+ catch_all = f"*@{domain}"
53
+ result = self._request("POST", "/sender", {"Email": catch_all})
54
+ sender = result.get("Data", [{}])[0]
55
+ logger.info("Created Mailjet sender %s (ID %s)", catch_all, sender.get("ID"))
56
+ return sender
57
+
58
+ def get_or_create_sender(self, domain: str) -> dict:
59
+ sender = self.find_sender(domain)
60
+ if sender:
61
+ logger.info("Found existing sender *@%s (ID %s)", domain, sender.get("ID"))
62
+ return sender
63
+ return self.create_sender(domain)
64
+
65
+ def get_dns_settings(self, domain: str) -> dict:
66
+ """Retrieve DKIM/SPF/ownership settings from Mailjet API."""
67
+ result = self._request("GET", f"/dns/{domain}")
68
+ return result.get("Data", [{}])[0]
69
+
70
+ def check_dns(self, dns_id: str) -> dict:
71
+ """Trigger a Mailjet DNS verification check."""
72
+ result = self._request("POST", f"/dns/{dns_id}/check", {})
73
+ return result.get("Data", [{}])[0]
74
+
75
+
76
+ # -- SPF utilities (used by setup_mailjet_dns.py and granny email mailjet) -----
77
+
78
+
79
+ def parse_spf(spf_record: str) -> tuple[list[str], str]:
80
+ """Parse an SPF record into (mechanisms, qualifier).
81
+
82
+ Returns e.g. (["include:_spf.google.com", "include:spf.mailjet.com"], "~all").
83
+ """
84
+ parts = spf_record.strip().split()
85
+ mechanisms: list[str] = []
86
+ qualifier = "~all"
87
+ for part in parts:
88
+ if part == "v=spf1":
89
+ continue
90
+ if part.endswith("all"):
91
+ qualifier = part
92
+ else:
93
+ mechanisms.append(part)
94
+ return mechanisms, qualifier
95
+
96
+
97
+ def merge_spf(
98
+ existing_spf: str | None,
99
+ target_qualifier: str = "~all",
100
+ include: str = "include:spf.mailjet.com",
101
+ ) -> tuple[str, list[str]]:
102
+ """Merge a Mailjet include into an existing SPF record.
103
+
104
+ Returns (merged_record, warnings).
105
+ """
106
+ warnings: list[str] = []
107
+ if not existing_spf:
108
+ return f"v=spf1 {include} {target_qualifier}", warnings
109
+
110
+ mechanisms, qualifier = parse_spf(existing_spf)
111
+
112
+ if include not in mechanisms:
113
+ mechanisms.append(include)
114
+
115
+ if qualifier == "?all" and target_qualifier in ("~all", "-all"):
116
+ warnings.append(f"Upgraded SPF qualifier from ?all to {target_qualifier}")
117
+ qualifier = target_qualifier
118
+
119
+ return f"v=spf1 {' '.join(mechanisms)} {qualifier}", warnings
@@ -0,0 +1,115 @@
1
+ """Mailjet contact management — check, block, unblock, bounce clearing.
2
+
3
+ Lifted from ``tools/create/manage_mailjet_contacts.py:MailjetContactClient``.
4
+ """
5
+
6
+ import logging
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ from granny.credentials.secrets import get_secret
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ API_BASE = "https://api.mailjet.com/v3/REST"
16
+
17
+
18
+ class MailjetContactClient:
19
+ """Mailjet REST API client for contact management."""
20
+
21
+ def __init__(self) -> None:
22
+ api_key = get_secret("MAILJET_API_KEY")
23
+ secret_key = get_secret("MAILJET_SECRET_KEY")
24
+ if not api_key or not secret_key:
25
+ raise ValueError(
26
+ "MAILJET_API_KEY / MAILJET_SECRET_KEY not set (env or vault)."
27
+ )
28
+ self.session = requests.Session()
29
+ self.session.auth = (api_key, secret_key)
30
+ self.session.headers.update({"Content-Type": "application/json"})
31
+
32
+ def _request(
33
+ self,
34
+ method: str,
35
+ endpoint: str,
36
+ data: dict | None = None,
37
+ params: dict | None = None,
38
+ ) -> Any:
39
+ url = f"{API_BASE}{endpoint}"
40
+ resp = getattr(self.session, method.lower())(
41
+ url, json=data, params=params, timeout=30
42
+ )
43
+ if resp.status_code == 404:
44
+ return None
45
+ if resp.status_code >= 400:
46
+ raise RuntimeError(
47
+ f"Mailjet {method} {endpoint}: {resp.status_code} {resp.text}"
48
+ )
49
+ return resp.json() if resp.text else {}
50
+
51
+ def get_contact(self, email: str) -> dict | None:
52
+ result = self._request("GET", f"/contact/{email}")
53
+ if not result:
54
+ return None
55
+ contacts = result.get("Data", [])
56
+ return contacts[0] if contacts else None
57
+
58
+ def unblock_contact(self, email: str) -> dict:
59
+ result = self._request(
60
+ "PUT", f"/contact/{email}", data={"IsExcludedFromCampaigns": "false"}
61
+ )
62
+ if not result:
63
+ raise RuntimeError(f"Contact not found: {email}")
64
+ return result.get("Data", [{}])[0]
65
+
66
+ def block_contact(self, email: str) -> dict:
67
+ result = self._request(
68
+ "PUT", f"/contact/{email}", data={"IsExcludedFromCampaigns": "true"}
69
+ )
70
+ if not result:
71
+ raise RuntimeError(f"Contact not found: {email}")
72
+ return result.get("Data", [{}])[0]
73
+
74
+ def list_contacts(
75
+ self, limit: int = 1000, offset: int = 0, is_excluded: bool | None = None
76
+ ) -> list[dict]:
77
+ params: dict[str, Any] = {"Limit": min(limit, 1000), "Offset": offset}
78
+ if is_excluded is not None:
79
+ params["IsExcludedFromCampaigns"] = str(is_excluded).lower()
80
+ result = self._request("GET", "/contact", params=params)
81
+ return result.get("Data", []) if result else []
82
+
83
+ def list_all_excluded(self, domain: str | None = None) -> list[dict]:
84
+ """Paginate through all excluded contacts."""
85
+ all_contacts: list[dict] = []
86
+ offset = 0
87
+ while True:
88
+ batch = self.list_contacts(limit=1000, offset=offset, is_excluded=True)
89
+ if not batch:
90
+ break
91
+ if domain:
92
+ batch = [
93
+ c for c in batch if c.get("Email", "").endswith(f"@{domain}")
94
+ ]
95
+ all_contacts.extend(batch)
96
+ if len(batch) < 1000:
97
+ break
98
+ offset += 1000
99
+ return all_contacts
100
+
101
+ def get_message_history(self, contact_id: int, limit: int = 10) -> list[dict]:
102
+ result = self._request(
103
+ "GET", "/message", params={"Contact": contact_id, "Limit": limit}
104
+ )
105
+ return result.get("Data", []) if result else []
106
+
107
+ def delete_contact(self, contact_id: int) -> bool:
108
+ """Delete via v4 API (clears bounce history)."""
109
+ url = f"https://api.mailjet.com/v4/contacts/{contact_id}"
110
+ resp = self.session.delete(url, timeout=30)
111
+ if resp.status_code == 200:
112
+ return True
113
+ if resp.status_code == 404:
114
+ return False
115
+ raise RuntimeError(f"Mailjet v4 delete: {resp.status_code} {resp.text}")
@@ -0,0 +1,281 @@
1
+ """SES email forwarding manager — Lambda + IAM + SES receipt rules.
2
+
3
+ Lifted from ``tools/create/setup_workmail.py:SESForwardingManager``.
4
+ Sets up email forwarding from a WorkMail address to an external address
5
+ using SES receipt rules with a Lambda function.
6
+ """
7
+
8
+ import io
9
+ import json
10
+ import logging
11
+ import time
12
+ import zipfile
13
+
14
+ import boto3
15
+ from botocore.exceptions import ClientError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SESForwardingManager:
21
+ """Email forwarding via Amazon SES receipt rules + Lambda."""
22
+
23
+ def __init__(self, region: str | None = None) -> None:
24
+ import os
25
+
26
+ region = region or os.environ.get("AWS_REGION", "us-east-1")
27
+ self.region = region
28
+ self.ses = boto3.client("ses", region_name=region)
29
+ self.lam = boto3.client("lambda", region_name=region)
30
+ self.iam = boto3.client("iam", region_name=region)
31
+
32
+ def check_domain_verified(self, domain: str) -> bool:
33
+ resp = self.ses.list_identities(IdentityType="Domain")
34
+ return domain in resp.get("Identities", [])
35
+
36
+ def get_or_create_rule_set(
37
+ self, rule_set_name: str = "workmail-forwarding"
38
+ ) -> str:
39
+ try:
40
+ self.ses.describe_receipt_rule_set(RuleSetName=rule_set_name)
41
+ except ClientError as exc:
42
+ if exc.response["Error"]["Code"] == "RuleSetDoesNotExist":
43
+ self.ses.create_receipt_rule_set(RuleSetName=rule_set_name)
44
+ logger.info("Created SES rule set: %s", rule_set_name)
45
+ else:
46
+ raise
47
+ return rule_set_name
48
+
49
+ def find_forwarding_rule(
50
+ self, rule_set_name: str, email: str
51
+ ) -> dict | None:
52
+ try:
53
+ resp = self.ses.describe_receipt_rule_set(RuleSetName=rule_set_name)
54
+ rule_name = f"forward-{email.replace('@', '-at-')}"
55
+ for rule in resp.get("Rules", []):
56
+ if rule.get("Name") == rule_name:
57
+ return rule
58
+ except ClientError:
59
+ pass
60
+ return None
61
+
62
+ def setup_forwarding(
63
+ self,
64
+ email: str,
65
+ forward_to: str,
66
+ rule_set_name: str = "workmail-forwarding",
67
+ *,
68
+ dry_run: bool = False,
69
+ ) -> dict:
70
+ """Set up SES receipt rule + Lambda forwarder.
71
+
72
+ Returns a status dict. The WorkMail mailbox still receives the original.
73
+ """
74
+ domain = email.split("@")[1]
75
+ rule_name = f"forward-{email.replace('@', '-at-')}"
76
+ lambda_name = f"workmail-forward-{domain.replace('.', '-')}"
77
+ result = {
78
+ "email": email,
79
+ "forward_to": forward_to,
80
+ "rule_set": rule_set_name,
81
+ "rule_name": rule_name,
82
+ "lambda_name": lambda_name,
83
+ }
84
+
85
+ if dry_run:
86
+ result["status"] = "dry_run"
87
+ return result
88
+
89
+ if not self.check_domain_verified(domain):
90
+ result["status"] = "domain_not_verified"
91
+ result["fix"] = f"aws ses verify-domain-identity --domain {domain}"
92
+ return result
93
+
94
+ self.get_or_create_rule_set(rule_set_name)
95
+
96
+ existing = self.find_forwarding_rule(rule_set_name, email)
97
+ if existing:
98
+ result["status"] = "exists"
99
+ return result
100
+
101
+ lambda_arn = self._get_or_create_lambda(lambda_name, forward_to)
102
+ result["lambda_arn"] = lambda_arn
103
+
104
+ self.ses.create_receipt_rule(
105
+ RuleSetName=rule_set_name,
106
+ Rule={
107
+ "Name": rule_name,
108
+ "Enabled": True,
109
+ "Recipients": [email],
110
+ "Actions": [
111
+ {
112
+ "LambdaAction": {
113
+ "FunctionArn": lambda_arn,
114
+ "InvocationType": "Event",
115
+ }
116
+ }
117
+ ],
118
+ "ScanEnabled": True,
119
+ },
120
+ )
121
+ logger.info("Created forwarding rule: %s -> %s", email, forward_to)
122
+
123
+ try:
124
+ self.ses.set_active_receipt_rule_set(RuleSetName=rule_set_name)
125
+ except ClientError as exc:
126
+ logger.warning("Could not activate rule set: %s", exc)
127
+
128
+ result["status"] = "created"
129
+ return result
130
+
131
+ def _get_or_create_lambda(self, lambda_name: str, forward_to: str) -> str:
132
+ try:
133
+ resp = self.lam.get_function(FunctionName=lambda_name)
134
+ arn = resp["Configuration"]["FunctionArn"]
135
+ self.lam.update_function_configuration(
136
+ FunctionName=lambda_name,
137
+ Environment={
138
+ "Variables": {"FORWARD_TO": forward_to, "REGION": self.region}
139
+ },
140
+ )
141
+ return arn
142
+ except ClientError as exc:
143
+ if exc.response["Error"]["Code"] != "ResourceNotFoundException":
144
+ raise
145
+
146
+ role_arn = self._get_or_create_role(lambda_name)
147
+
148
+ code = _FORWARDER_LAMBDA_CODE.strip()
149
+ buf = io.BytesIO()
150
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
151
+ zf.writestr("lambda_function.py", code)
152
+ buf.seek(0)
153
+
154
+ logger.info("Waiting for IAM role propagation...")
155
+ time.sleep(10)
156
+
157
+ resp = self.lam.create_function(
158
+ FunctionName=lambda_name,
159
+ Runtime="python3.12",
160
+ Role=role_arn,
161
+ Handler="lambda_function.handler",
162
+ Code={"ZipFile": buf.read()},
163
+ Description=f"Email forwarder -> {forward_to}",
164
+ Timeout=30,
165
+ MemorySize=128,
166
+ Environment={
167
+ "Variables": {"FORWARD_TO": forward_to, "REGION": self.region}
168
+ },
169
+ )
170
+ arn = resp["FunctionArn"]
171
+ logger.info("Created Lambda %s", lambda_name)
172
+
173
+ try:
174
+ self.lam.add_permission(
175
+ FunctionName=lambda_name,
176
+ StatementId="AllowSES",
177
+ Action="lambda:InvokeFunction",
178
+ Principal="ses.amazonaws.com",
179
+ )
180
+ except ClientError:
181
+ pass
182
+
183
+ return arn
184
+
185
+ def _get_or_create_role(self, lambda_name: str) -> str:
186
+ role_name = f"{lambda_name}-role"
187
+ try:
188
+ resp = self.iam.get_role(RoleName=role_name)
189
+ return resp["Role"]["Arn"]
190
+ except ClientError as exc:
191
+ if exc.response["Error"]["Code"] != "NoSuchEntity":
192
+ raise
193
+
194
+ assume = json.dumps(
195
+ {
196
+ "Version": "2012-10-17",
197
+ "Statement": [
198
+ {
199
+ "Effect": "Allow",
200
+ "Principal": {"Service": "lambda.amazonaws.com"},
201
+ "Action": "sts:AssumeRole",
202
+ }
203
+ ],
204
+ }
205
+ )
206
+ resp = self.iam.create_role(
207
+ RoleName=role_name,
208
+ AssumeRolePolicyDocument=assume,
209
+ Description=f"Role for {lambda_name} forwarder",
210
+ )
211
+ role_arn = resp["Role"]["Arn"]
212
+
213
+ self.iam.attach_role_policy(
214
+ RoleName=role_name,
215
+ PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
216
+ )
217
+
218
+ ses_policy = json.dumps(
219
+ {
220
+ "Version": "2012-10-17",
221
+ "Statement": [
222
+ {
223
+ "Effect": "Allow",
224
+ "Action": ["ses:SendEmail", "ses:SendRawEmail"],
225
+ "Resource": "*",
226
+ }
227
+ ],
228
+ }
229
+ )
230
+ self.iam.put_role_policy(
231
+ RoleName=role_name,
232
+ PolicyName="SESForwardingPolicy",
233
+ PolicyDocument=ses_policy,
234
+ )
235
+ return role_arn
236
+
237
+
238
+ _FORWARDER_LAMBDA_CODE = '''
239
+ import boto3
240
+ import os
241
+
242
+ FORWARD_TO = os.environ.get('FORWARD_TO', '')
243
+ REGION = os.environ.get('REGION', 'us-east-1')
244
+
245
+
246
+ def handler(event, context):
247
+ ses_client = boto3.client('ses', region_name=REGION)
248
+ for record in event.get('Records', []):
249
+ ses_message = record.get('ses', {})
250
+ mail = ses_message.get('mail', {})
251
+ message_id = mail.get('messageId', '')
252
+ if not message_id or not FORWARD_TO:
253
+ continue
254
+ source = mail.get('source', 'noreply@example.com')
255
+ destinations = [addr.strip() for addr in FORWARD_TO.split(',')]
256
+ common_headers = mail.get('commonHeaders', {})
257
+ subject = common_headers.get('subject', 'Forwarded Email')
258
+ from_addr = common_headers.get('from', [source])[0]
259
+ try:
260
+ ses_client.send_email(
261
+ Source=source,
262
+ Destination={'ToAddresses': destinations},
263
+ Message={
264
+ 'Subject': {'Data': f"[FWD] {subject}"},
265
+ 'Body': {
266
+ 'Text': {
267
+ 'Data': (
268
+ f"Forwarded email from: {from_addr}\\n"
269
+ f"Original recipient: {', '.join(mail.get('destination', []))}\\n"
270
+ f"Subject: {subject}\\n"
271
+ f"Date: {common_headers.get('date', 'Unknown')}\\n"
272
+ f"\\n--- Forwarded by WorkMail Forwarder ---"
273
+ )
274
+ }
275
+ }
276
+ }
277
+ )
278
+ except Exception as e:
279
+ print(f"Failed to forward {message_id}: {e}")
280
+ return {'disposition': 'CONTINUE'}
281
+ '''
@@ -0,0 +1,145 @@
1
+ """AWS WorkMail user management — org/user CRUD, password reset.
2
+
3
+ Lifted from ``tools/create/setup_workmail.py:WorkMailManager``.
4
+ """
5
+
6
+ import logging
7
+ from typing import Any
8
+
9
+ import boto3
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ WORKMAIL_REGIONS = {
14
+ "us-east-1": "N. Virginia",
15
+ "us-west-2": "Oregon",
16
+ "eu-west-1": "Ireland",
17
+ }
18
+
19
+
20
+ class WorkMailManager:
21
+ """Amazon WorkMail organization and user management."""
22
+
23
+ def __init__(self, region: str | None = None) -> None:
24
+ import os
25
+
26
+ region = region or os.environ.get("AWS_REGION", "us-east-1")
27
+ if region not in WORKMAIL_REGIONS:
28
+ raise ValueError(
29
+ f"WorkMail not available in {region!r}. "
30
+ f"Choices: {', '.join(WORKMAIL_REGIONS)}"
31
+ )
32
+ self.region = region
33
+ self.client = boto3.client("workmail", region_name=region)
34
+ self._org_cache: dict[str, dict] = {}
35
+
36
+ def list_organizations(self) -> list[dict]:
37
+ orgs: list[dict] = []
38
+ paginator = self.client.get_paginator("list_organizations")
39
+ for page in paginator.paginate():
40
+ orgs.extend(page.get("OrganizationSummaries", []))
41
+ return orgs
42
+
43
+ def find_organization(self, domain: str) -> dict | None:
44
+ if domain in self._org_cache:
45
+ return self._org_cache[domain]
46
+ for org in self.list_organizations():
47
+ if org.get("State") != "Active":
48
+ continue
49
+ if org.get("DefaultMailDomain") == domain or org.get("Alias") == domain:
50
+ self._org_cache[domain] = org
51
+ return org
52
+ return None
53
+
54
+ def get_org_id(self, domain: str) -> str:
55
+ org = self.find_organization(domain)
56
+ if not org:
57
+ available = [
58
+ o.get("DefaultMailDomain", o.get("Alias", "?"))
59
+ for o in self.list_organizations()
60
+ ]
61
+ raise RuntimeError(
62
+ f"WorkMail org not found for {domain!r}. Available: {available}"
63
+ )
64
+ return org["OrganizationId"]
65
+
66
+ def list_users(self, org_id: str) -> list[dict]:
67
+ users: list[dict] = []
68
+ paginator = self.client.get_paginator("list_users")
69
+ for page in paginator.paginate(OrganizationId=org_id):
70
+ users.extend(page.get("Users", []))
71
+ return users
72
+
73
+ def find_user_by_email(self, org_id: str, email: str) -> dict | None:
74
+ for user in self.list_users(org_id):
75
+ if user.get("Email", "").lower() == email.lower():
76
+ return user
77
+ return None
78
+
79
+ def describe_user(self, org_id: str, user_id: str) -> dict:
80
+ return self.client.describe_user(OrganizationId=org_id, UserId=user_id)
81
+
82
+ def create_user(
83
+ self,
84
+ org_id: str,
85
+ username: str,
86
+ display_name: str,
87
+ password: str,
88
+ *,
89
+ first_name: str | None = None,
90
+ last_name: str | None = None,
91
+ ) -> str:
92
+ """Create a user. Returns user ID."""
93
+ params: dict[str, Any] = {
94
+ "OrganizationId": org_id,
95
+ "Name": username,
96
+ "DisplayName": display_name,
97
+ "Password": password,
98
+ }
99
+ if first_name:
100
+ params["FirstName"] = first_name
101
+ if last_name:
102
+ params["LastName"] = last_name
103
+ resp = self.client.create_user(**params)
104
+ user_id = resp["UserId"]
105
+ logger.info("Created user %s (ID %s)", username, user_id)
106
+ return user_id
107
+
108
+ def register_to_workmail(self, org_id: str, user_id: str, email: str) -> None:
109
+ self.client.register_to_work_mail(
110
+ OrganizationId=org_id, EntityId=user_id, Email=email
111
+ )
112
+ logger.info("Registered %s for WorkMail", email)
113
+
114
+ def reset_password(self, org_id: str, user_id: str, password: str) -> None:
115
+ self.client.reset_password(
116
+ OrganizationId=org_id, UserId=user_id, Password=password
117
+ )
118
+ logger.info("Password reset for user %s", user_id)
119
+
120
+ def create_and_register_user(
121
+ self,
122
+ org_id: str,
123
+ email: str,
124
+ display_name: str,
125
+ password: str,
126
+ *,
127
+ first_name: str | None = None,
128
+ last_name: str | None = None,
129
+ ) -> str:
130
+ """Create + register (2-step). Returns user ID. Idempotent."""
131
+ existing = self.find_user_by_email(org_id, email)
132
+ if existing:
133
+ logger.info("User %s already exists (ID %s)", email, existing["Id"])
134
+ return existing["Id"]
135
+ username = email.split("@")[0]
136
+ user_id = self.create_user(
137
+ org_id,
138
+ username,
139
+ display_name,
140
+ password,
141
+ first_name=first_name,
142
+ last_name=last_name,
143
+ )
144
+ self.register_to_workmail(org_id, user_id, email)
145
+ return user_id