# A Python Email SDK for Your Own SES

Source: https://wraps.dev/blog/python-email-sdk

Product•9 min read•Wraps Team•July 15, 2026

# A Python Email SDKfor Your Own SES

`wraps-email` landed on PyPI on Monday. It signs SigV4 against SES in your AWS account. There is no Wraps API key and no Wraps server in the request path.

pip install wraps-email

SigV4 straight to SES

Python 3.10 – 3.13

## 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`.

terminal

```
pip install wraps-email
# or
uv add wraps-email
```

A first send is four required arguments plus at least one body:

send.py

```
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:

addresses.py

```
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-tracking",
    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:

the wire

```
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.

credentials.py

```
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:

\_transport/ses.py

```
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, body
```

Signing 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:

client.py

```
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.

attachment.py

```
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.

templates.py

```
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.

batch.py

```
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:

suppression.py

```
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.

errors.py

```
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.

**Update (0.2.0):** fixed. `CredentialsError` now subclasses `WrapsEmailError`, so the single catch really does cover everything. It is also raised from `send()` rather than the constructor — credential resolution is lazy now, so constructing a client does no I/O.

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.

**Update (0.2.0):** `BatchError` is gone. A dead export invited exactly the wrong assumption, so 0.2.0 removed it rather than keep exporting a class nothing raises. `send_batch` still reports partial failure through its return value — that part was always the design.

## 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. A small suite, and what matters is where they cut: every one of them intercepts httpx at the transport boundary with `respx`and 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

[

### Python SDK Reference

Every method, parameter, model, and error type in 0.1.0

](https://wraps.dev/docs/python-sdk-reference)[

### TypeScript SDK Reference

What `@wraps.dev/email` covers that Python doesn't yet

](https://wraps.dev/docs/sdk-reference)[

### Email Quickstart

Standing up SES in your own AWS account from scratch

](https://wraps.dev/docs/quickstart/email)[

### wraps-email on PyPI

Source lives at github.com/wraps-team/wraps-py

](https://pypi.org/project/wraps-email/)

## 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.

pip install wraps-email

[Read the Reference](https://wraps.dev/docs/python-sdk-reference)
