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
granny/email/__init__.py
ADDED
|
@@ -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"]
|
granny/email/mailjet.py
ADDED
|
@@ -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
|
+
'''
|
granny/email/workmail.py
ADDED
|
@@ -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
|