203 lines
7.1 KiB
Python
203 lines
7.1 KiB
Python
import concurrent.futures
|
|
import functools
|
|
import logging
|
|
import pprint
|
|
import time
|
|
import typing
|
|
|
|
import boto3
|
|
import botocore.client
|
|
|
|
DnsRecord = typing.TypedDict(
|
|
"DnsRecord",
|
|
{
|
|
"purpose": str,
|
|
"type": str,
|
|
"name": str,
|
|
"value": str,
|
|
"priority": typing.Optional[str],
|
|
},
|
|
)
|
|
VerificationStatus = typing.Literal["Pending", "Success", "Failed", "TemporaryFailure", "NotStarted"]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def retry(tries: int = 5, delay: int = 1.5):
|
|
def wrapper(fn):
|
|
@functools.wraps(fn)
|
|
def wrapped(*args, **kwargs):
|
|
nonlocal tries
|
|
while tries:
|
|
try:
|
|
return fn(*args, **kwargs)
|
|
except Exception:
|
|
logger.exception(f'{fn} returned error, retrying {tries} more times')
|
|
time.sleep(delay)
|
|
tries -= 1
|
|
|
|
return wrapped
|
|
|
|
return wrapper
|
|
|
|
|
|
class AwsSesService:
|
|
def __init__(self, client: typing.Optional[botocore.client.BaseClient] = None, timeout: int = 5):
|
|
self.ses = client or boto3.client("ses")
|
|
self.region = self.ses._client_config.region_name
|
|
self.timeout = timeout
|
|
|
|
# @retry()
|
|
def configure_domain_identity(
|
|
self, domain: str, mail_subdomain: typing.Optional[str] = None
|
|
) -> typing.List[DnsRecord]:
|
|
"""
|
|
Creates up mail identities for sending email through a domain.
|
|
|
|
DKIM-signed messages help receiving mail servers validate that a message was not forged or altered in transit.
|
|
|
|
:param domain: domain name to set up email identity
|
|
:return: DNS records that need to be set
|
|
"""
|
|
|
|
res_id = self.ses.verify_domain_identity(Domain=domain)
|
|
id_token = res_id["VerificationToken"]
|
|
|
|
res_dkim = self.ses.verify_domain_dkim(Domain=domain)
|
|
dns_records = [
|
|
{
|
|
"purpose": "identity",
|
|
"type": "TXT",
|
|
"name": domain,
|
|
"value": id_token,
|
|
},
|
|
*[
|
|
{
|
|
"purpose": "dkim",
|
|
"type": "CNAME",
|
|
"name": f"{dkim_token}._domainkey.{domain}",
|
|
"value": f"{dkim_token}.dkim.amazonses.com",
|
|
}
|
|
for dkim_token in res_dkim["DkimTokens"]
|
|
],
|
|
]
|
|
if mail_subdomain:
|
|
more_dns = self.configure_from_domain(domain, mail_subdomain)
|
|
dns_records.extend(more_dns)
|
|
return dns_records
|
|
|
|
@retry()
|
|
def check_dkim_verification_status(self, domain: str) -> VerificationStatus:
|
|
"""
|
|
Returns the DKIM verification status for an email identity.
|
|
|
|
:param domain: domain used to set up the mail identity
|
|
:return: one of {Pending, Success, Failed, TemporaryFailure, NotStarted}
|
|
"""
|
|
res = self.ses.get_identity_dkim_attributes(Identities=[domain])
|
|
return res["DkimAttributes"][domain]["DkimVerificationStatus"]
|
|
|
|
@retry()
|
|
def check_id_verification_status(self, domain: str) -> VerificationStatus:
|
|
"""
|
|
Returns the id verification status of an email identity.
|
|
|
|
:param domain: domain used to set up the mail identity
|
|
:return: one of {Pending, Success, Failed, TemporaryFailure, NotStarted}
|
|
"""
|
|
res = self.ses.get_identity_verification_attributes(Identities=[domain])
|
|
return res["VerificationAttributes"][domain]["VerificationStatus"]
|
|
|
|
def configure_from_domain(self, domain: str, mail_subdomain: str) -> typing.List[DnsRecord]:
|
|
"""
|
|
Allows using `mail_subdomain` as FROM address when sending emails.
|
|
Messages sent through Amazon SES will be marked as originating from your domain instead of a subdomain of amazon.com.
|
|
|
|
:param domain: mail identity
|
|
:param mail_subdomain: a subdomain
|
|
:return: DNS records that need to be set
|
|
"""
|
|
_ = self.ses.set_identity_mail_from_domain(
|
|
Identity=domain,
|
|
MailFromDomain=mail_subdomain,
|
|
BehaviorOnMXFailure="UseDefaultValue", # uses $region.amazonses.com if MX records not present on the mail subdomain
|
|
)
|
|
return [
|
|
{
|
|
"purpose": "mail_from_domain",
|
|
"type": "MX",
|
|
"name": mail_subdomain,
|
|
"value": f"feedback-smtp.{self.region}.amazonses.com",
|
|
"priority": "10",
|
|
},
|
|
{
|
|
"purpose": "mail_from_domain",
|
|
"type": "TXT",
|
|
"name": mail_subdomain,
|
|
"value": f'"v=spf1 include:amazonses.com ~all"',
|
|
},
|
|
]
|
|
|
|
@retry()
|
|
def check_custom_from_domain_status(self, domain: str) -> VerificationStatus:
|
|
"""
|
|
Returns the status of custom email FROM request verification.
|
|
|
|
:param domain: domain used to set up email identity
|
|
:return: one of {Pending, Success, Failed, TemporaryFailure}
|
|
"""
|
|
res = self.ses.get_identity_mail_from_domain_attributes(Identities=[domain])
|
|
return res["MailFromDomainAttributes"][domain].get("MailFromDomainStatus", 'NotStarted')
|
|
|
|
def bulk_check_domain_verification(self, identity_domain: str, timeout: int = 5) -> dict:
|
|
tasks = {
|
|
"status_id": functools.partial(self.check_id_verification_status, identity_domain),
|
|
"status_dkim": functools.partial(self.check_dkim_verification_status, identity_domain),
|
|
"status_custom": functools.partial(self.check_custom_from_domain_status, identity_domain),
|
|
}
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
|
|
futures = [pool.submit(t) for _, t in tasks.items()]
|
|
results = dict(zip(tasks.keys(), [f.result(timeout=timeout) for f in futures]))
|
|
|
|
return {
|
|
**results,
|
|
"identity_domain": identity_domain,
|
|
}
|
|
|
|
@retry()
|
|
def bulk_configure_email_domains(
|
|
self,
|
|
identity_domain: str,
|
|
mail_subdomain: typing.Optional[str] = None,
|
|
timeout: int = 10,
|
|
) -> dict:
|
|
tasks = {
|
|
"dns": functools.partial(self.configure_domain_identity, identity_domain, mail_subdomain),
|
|
}
|
|
|
|
# send all requests in parallel
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
|
|
futures = [pool.submit(t) for _, t in tasks.items()]
|
|
results = dict(zip(tasks.keys(), [f.result(timeout=timeout) for f in futures]))
|
|
return {
|
|
**results,
|
|
"identity_domain": identity_domain,
|
|
"mail_subdomain": mail_subdomain,
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ses = AwsSesService()
|
|
logging.basicConfig(level=logging.INFO)
|
|
# print(ses.bulk_configure_email_domains('abdus100.dev', 'mail.abdus100.dev'))
|
|
# print(ses.check_custom_from_domain_status('abdus101.dev'))
|
|
|
|
tasks = [
|
|
functools.partial(ses.bulk_check_domain_verification, d) for d in ['abdus.dev', 'abdus1.dev', 'abdus2.dev']
|
|
] * 3
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
|
|
futures = [pool.submit(t) for t in tasks]
|
|
results = [f.result(timeout=5) for f in futures]
|
|
pprint.pp(results)
|