What you had to write yourself
If you send transactional email from Python and you own your SES account, you have had two options, and both of them cost you something.
Option one is boto3. It works — SESv2's SendEmail is right there, and the credential story is already solved. What isn't solved is everything shaped like an email API. Attachments mean switching the request to Raw content and hand-assembling a MIME message with the stdlib. Bcc handling is on you, and the obvious implementation leaks the Bcc addresses into the message headers. Sending a hundred distinct messages means writing your own concurrency and your own per-message result bookkeeping. Every response comes back as an untyped dict.
Option two is a vendor SDK. You get the ergonomics immediately, and you get them by putting someone else's API key in your environment and someone else's servers in your request path. Your sending reputation, your suppression list, and your delivery data stop being yours.
wraps-email is the third one: the ergonomics of a vendor SDK pointed at your own SES account.
Install and send
The distribution is wraps-email; you import it as wraps.email.
pip install wraps-email
# or
uv add wraps-emailA first send is four required arguments plus at least one body:
from wraps.email import WrapsEmail
email = WrapsEmail(region="us-east-1")
result = email.send(
from_="you@yourdomain.com",
to="user@example.com",
subject="Hello from Python",
html="<h1>It works</h1>",
text="It works",
)
print(result.message_id, result.request_id)Two things about that signature. send()is entirely keyword-only — there is no positional form to get wrong. And the sender field is from_ with a trailing underscore, because from is a Python keyword and cannot be a parameter name. That underscore is the one piece of ugliness the language forces on us, and we'd rather wear it than invent a synonym you have to look up.
Every recipient field — to, cc, bcc, reply_to, from_ — takes a bare string, an EmailAddress with a display name, or a list mixing both:
from wraps.email import EmailAddress, WrapsEmail
email = WrapsEmail(region="us-east-1")
email.send(
from_=EmailAddress(email="you@yourdomain.com", name="Acme Support"),
to=["a@example.com", EmailAddress(email="b@example.com", name="Bee")],
reply_to="support@yourdomain.com",
subject="Hi",
text="hi",
configuration_set_name="wraps-email-default",
tags={"campaign": "welcome"},
)Configuration sets and message tags are pass-through, so the events you already collect keep flowing the way they did before.
There is no Wraps in the request path
This is the part worth being precise about, because it is the whole reason the SDK exists. When you call email.send(...), the request that leaves your process looks like this:
POST https://email.us-east-1.amazonaws.com/v2/email/outbound-emails
Authorization: AWS4-HMAC-SHA256 Credential=AKIA.../20260715/us-east-1/ses/aws4_request, SignedHeaders=...That's it. An SESv2 endpoint in your region, signed with your credentials, scoped to the ses service. There is no Wraps hostname anywhere in the package, no Wraps API key to configure, and no Wraps account required to use it. Install the library and it talks to AWS.
The practical consequence: your SES quota, your sending reputation, your suppression list, and your CloudWatch metrics stay exactly where they were. If Wraps disappeared tomorrow, your sends would keep working, because nothing of ours is between your process and email.us-east-1.amazonaws.com.
Credentials come from the chain you already have
Nothing about auth is bespoke. If your environment can already run aws ses commands, the SDK is configured. Resolution runs in a documented priority order: explicit static credentials, then role_arn assume-role, then a named profile, then the standard chain — environment variables, shared config, SSO, OIDC/web-identity, IMDS, and container credentials.
from wraps.email import WrapsEmail
# whatever the environment already provides:
# env vars, ~/.aws/config, SSO, OIDC, IMDS, ECS task role
email = WrapsEmail(region="us-east-1")
# a named profile
email = WrapsEmail(region="us-east-1", profile="prod")
# assume a role - refreshable STS credentials
email = WrapsEmail(
region="us-east-1",
role_arn="arn:aws:iam::123456789012:role/email-sender",
)
# explicit static credentials
email = WrapsEmail(
region="us-east-1",
credentials={"access_key_id": "AKIA...", "secret_access_key": "..."},
)Temporary credentials refresh on their own. The signer asks for a fresh frozen snapshot on every single request rather than caching one at construction, so a long-lived worker holding a client across an SSO or assume-role expiry keeps signing valid requests instead of failing an hour in.
When nothing resolves, you get a CredentialsError whose message lists every option — SSO, access keys, env vars, a named profile — without ranking them. That's the same rule the Wraps CLI follows. We don't know which auth method your org standardized on, so we don't guess.
The wire layer does no I/O
There are three runtime dependencies: httpx, botocore, and pydantic. Notably not boto3 — and that is an architecture decision, not a footprint one. Botocore is used for exactly two things: SigV4 signing and credential-chain resolution. Both are hard, both are already solved, and neither one needs to own the SES request. No botocore client is constructed for SES at all — the only botocore client this package ever builds is an STS client, and only when you pass role_arn.
Every SES operation lives in an internal _transport package as a pure function that returns a request descriptor — a (method, url, headers, body) tuple. Twelve builders, six parsers, no network:
def build_send_email(
*, region: str, from_address: str, to: list[str], subject: str,
cc=None, bcc=None, reply_to=None, html=None, text=None,
configuration_set_name=None, tags=None,
) -> tuple[str, str, dict[str, str], bytes]:
...
body = json.dumps(payload).encode("utf-8")
url = f"{endpoint(region)}/v2/email/outbound-emails"
headers = {"Content-Type": "application/json"}
return "POST", url, headers, bodySigning is the same shape: a function that takes that tuple plus a credential snapshot and returns signed headers. Which means the client itself is a thin driver. Its entire I/O surface — for sends, batches, template CRUD, and every suppression call — is one method:
def _request(self, method, url, headers, body) -> httpx.Response:
"""Sign a request with SigV4 and send it. Shared by all operations."""
signed = sign(
method=method, url=url, headers=headers, body=body,
service=ses.SERVICE, region=self._region, creds=self._creds.frozen(),
)
return self._http.request(method, url, headers=signed, content=body)We shaped it that way deliberately. An async client is the next thing on the roadmap, and when it lands it changes the last line of that method and nothing else — no second copy of the request builders, no second signing path, no drift between the two over time. A boto3 client would have owned the socket and forced two parallel implementations instead.
To be clear: 0.1.0 is sync only.
There is no AsyncWrapsEmail in this release and nothing you can await. The seam described above is the groundwork for one, not evidence of one. If you're on an event loop today, you'll need a thread-pool executor until the async client ships.
Attachments, and where Bcc goes
Pass attachments and the send switches from SESv2's Simple content to a base64 raw MIME message. You don't opt in; the presence of an attachment is the signal.
from wraps.email import Attachment, WrapsEmail
email = WrapsEmail()
email.send(
from_="you@yourdomain.com",
to="user@example.com",
bcc="audit@yourdomain.com",
subject="Your report",
html="<p>Attached.</p>",
attachments=[
Attachment(
filename="report.csv",
content="a,b\n1,2\n",
content_type="text/csv",
),
],
)Note the bcc in that call. This is the detail hand-rolled MIME gets wrong most often: the assembled message has no Bcc: header at all. The address rides the SES envelope on Destination.BccAddresses instead, which is the only place it can go without every recipient seeing it. There's a test that decodes the raw MIME back through the stdlib parser to assert that header stays absent.
Templates, batches, suppression
SES-stored templates get full CRUD plus a paginated list, and send_template renders them server-side. The template is named by template and its variables go in data — not template_name or template_data, which is what most people reach for first.
from wraps.email import WrapsEmail
email = WrapsEmail()
email.templates.create(
name="welcome",
subject="Hi {{name}}",
html="<h1>Welcome, {{name}}</h1>",
)
email.send_template(
template="welcome",
from_="you@yourdomain.com",
to="user@example.com",
data={"name": "Sam"},
)send_batch takes N distinct messages and returns per-entry results aligned to input order. It does not abort on a partial failure — a rejected address in position 3 doesn't cost you positions 4 through 200. Malformed entries are a different story: those are rejected before anything is sent at all, so you never end up halfway through a batch discovering a typo.
from wraps.email import WrapsEmail
email = WrapsEmail()
result = email.send_batch(
[
{"from_": "you@yourdomain.com", "to": "a@example.com",
"subject": "Hi", "text": "1"},
{"from_": "you@yourdomain.com", "to": "b@example.com",
"subject": "Hi", "text": "2"},
],
max_concurrency=10,
)
print(result.success_count, result.failure_count)
for entry in result.results: # aligned to input order
if not entry.success:
print(entry.index, entry.error_code, entry.error)Suppression is the account-level SES list, not a Wraps-side copy. get() returns None rather than raising when an address isn't suppressed, so a pre-send check is a plain truthiness test:
from wraps.email import WrapsEmail
email = WrapsEmail()
entry = email.suppression.get("bad@example.com")
if entry:
print(f"{entry.email} suppressed: {entry.reason} at {entry.last_update_time}")
else:
print("not suppressed")
email.suppression.add("worse@example.com", "COMPLAINT")
page = email.suppression.list(reason="BOUNCE", page_size=20)
email.suppression.remove("worse@example.com")One inconsistency worth flagging before it bites you: templates.create and templates.update are keyword-only, while templates.get and templates.delete take the name positionally.
Errors you can branch on
Nobody should be parsing exception strings to decide whether to retry. Every failure mode carries structured fields. SESError gives you .code, .status, .request_id, and .retryable, where retryable means 5xx or throttling and nothing else — a 400 for an unverified sender is never going to succeed on retry and won't be marked as though it might. ValidationError carries .field and is raised before any AWS call happens.
from wraps.email import CredentialsError, SESError, ValidationError, WrapsEmail
email = WrapsEmail()
try:
email.send(
from_="you@yourdomain.com",
to="user@example.com",
subject="Hi",
html="<p>Hi</p>",
)
except ValidationError as err:
print("bad input, no AWS call was made:", err.field)
except CredentialsError:
print("no AWS credentials resolved")
except SESError as err:
print(err.code, err.status, err.request_id, err.retryable)except WrapsEmailError does not catch credential failures.
ValidationError, SESError, and BatchError all subclass WrapsEmailError. CredentialsError subclasses plain Exception — it comes from the credential layer, which sits below the email API and fails before an email error is even meaningful. Catch it explicitly, or catch both.
A second sharp edge, since it would be easy to assume otherwise: BatchError is exported but nothing in 0.1.0 raises it. send_batch reports partial failure through its return value, by design. The type is there for callers who want to raise their own.
What's not in 0.1.0
This is a first release and it is narrower than @wraps.dev/email, which has had a lot longer to grow. The send path is at parity — send, batch, attachments, templates, suppression, and the full credential chain all exist in both. Here is what the TypeScript SDK has that Python doesn't:
An async client
The largest gap, and the reason the transport is shaped the way it is. Sync only today.
Inbound email and event history
Reading received mail out of S3 and querying delivery events out of DynamoDB are both TypeScript-only. If you need them from Python today, the events are in your own account — boto3 can read them directly.
Reply threading and signed reply tokens
The HMAC reply-token codec has no Python implementation yet, so a Python agent can't verify a token the TypeScript SDK signed.
send_bulk_template and automatic HTML→text
One template rendered to many destinations in a single SESv2 call isn't wired up — send_batch covers the same ground with independent sends. And if you want a plain-text alternative, pass text yourself; nothing derives it from your HTML. The TypeScript SDK's address validation and CRLF header-injection guard aren't ported either, so sanitize addresses that came from user input before you hand them over.
React Email rendering — and why that one isn't coming
The TypeScript SDK renders React Email components to HTML. There is no Python equivalent to port and we're not going to invent one. Author templates wherever your templating already lives, or store them in SES and use send_template.
There is also no wraps-sms, wraps-client, or MCP server for Python. The wraps package is a PEP 420 implicit namespace — there is no wraps/__init__.py in the wheel — specifically so a future distribution can install wraps/sms/ beside wraps/email/ without either one owning the top-level name. That's plumbing for a package that doesn't exist yet, not a promise about when it will.
How it's built and shipped
The package is managed with uv, linted and formatted with ruff, type-checked with ty, and models every input and result with Pydantic v2. It ships py.typed, so mypy, ty, and Pyright check your call sites and your editor autocompletes the keyword arguments instead of guessing at **kwargs. CI runs the test suite against Python 3.10, 3.11, 3.12, and 3.13 — all four classifiers are tested, not just declared.
There are 17 tests and they all pass. That is a modest suite for a first release and we're not going to dress it up as more than it is. What they do have going for them is where they cut: every one of them intercepts httpx at the transport boundary with respxand asserts on the actual signed HTTP request — the URL, the headers, the JSON body — rather than on a mock of our own code. The attachment tests go one step further and decode the base64 MIME back through the standard library parser.
Publishing runs on PyPI Trusted Publishing over OIDC, so there is no PyPI token stored anywhere in the repo or its secrets. Pushing a email-v* tag re-runs ruff, ty, and the tests, and only then builds and uploads. It felt wrong to hold a long-lived publishing credential at a company whose pitch is that it doesn't hold your credentials either.
Continue reading
One install, your own SES
No API key to provision, no account to create. If your environment already has AWS credentials, you can send in the next five minutes.

