wraps-email for Python: SES with the DX of a vendor SDK

If you send transactional email from Python, you get two choices and neither is great.

The first is boto3. It works, it's the official thing, and it will happily send an email. But you're programming against the raw SES API: you pick between send_email and send_raw_email yourself, you build the MIME tree yourself when you want an attachment, and you get no inline types — boto3 ships no type information, so autocomplete means installing boto3-stubs[sesv2] as a second package and hoping the stubs are current. The SDK doesn't know anything about email. It knows about AWS.

The second is a vendor SDK. Resend, Postmark, SendGrid — genuinely nice ergonomics, five lines to a sent message, good docs. And now your email lives in someone else's account, at someone else's price, with someone else's reputation attached to your domain.

I wanted the second experience on the first set of infrastructure. So I wrote wraps-email, the first package in wraps-py.

pip install wraps-email      # or: uv add wraps-email
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)

That's a real send through your own SES account. No API key, no vendor, no proxy — the credential chain resolved your AWS identity and the request went straight to email.us-east-1.amazonaws.com.


Why not boto3

The single most surprising implementation decision in this package is that it doesn't depend on boto3. It depends on botocore — and only for two things: the credential chain and SigV4 signing.

Those are the parts you genuinely should not write yourself. Credential resolution has to cover env vars, shared config, SSO, OIDC/web-identity, assume-role, container credentials, and IMDS, with refresh semantics for the temporary ones. SigV4 is a canonicalization spec with a lot of ways to be subtly wrong. Both are solved, and botocore solves them.

Everything above that line is ours, over httpx:

# _transport/signer.py — no network I/O happens here
def sign(*, method, url, headers, body, service, region, creds) -> dict[str, str]:
    aws_request = AWSRequest(method=method, url=url, data=body, headers=headers)
    botocore_creds = Credentials(creds.access_key, creds.secret_key, creds.token)
    SigV4Auth(botocore_creds, service, region).add_auth(aws_request)
    return dict(aws_request.headers)

Signing is a pure function from a credential snapshot to a header dict. That's deliberate. It means the sync client and the async client that's coming can share one signing path and one request-building path — the only thing that differs between them is which httpx client executes the call. The transport layer doesn't know which one it's serving.

The other benefit is types. wraps-email ships a PEP 561 py.typed marker and every public method has an explicit signature, so mypy, ty, and Pyright check your calls out of the box. No stub package, no drift between the stubs and the SDK.


Credentials work the way you already expect

WrapsEmail()                                                    # default chain
WrapsEmail(profile="wraps-dogfood")                             # named profile
WrapsEmail(credentials={"access_key_id": "...", "secret_access_key": "..."})
WrapsEmail(role_arn="arn:aws:iam::123456789012:role/MyRole")    # assume-role / OIDC

The resolution order — explicit static credentials, then role_arn, then named profile, then the default chain — mirrors the JS SDK's priority exactly. That's not an accident. If you run a TypeScript API and a Python worker against the same AWS account, the thing you learn about credentials in one language should transfer to the other without an asterisk.

One small detail I care about more than it probably deserves: when nothing resolves, the error lists every option without ranking them.

No AWS credentials found. Provide credentials via any of:
  - AWS SSO:      aws sso login
  - Access keys:  credentials={'access_key_id': ..., 'secret_access_key': ...}
  - Env vars:     AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
  - Named profile: profile='my-profile' (or AWS_PROFILE)

Error messages that prescribe one auth method are how people end up pasting long-lived access keys into a .env on a laptop that already had SSO configured. Listing the options flat is the honest thing.


The parts where SES is annoying, handled

Attachments. SESv2 has two content shapes: Simple, which takes a subject and bodies, and Raw, which takes a MIME blob you assembled. Attachments require Raw. In boto3 that's your decision and your MIME tree. Here you just pass attachments and the send switches modes underneath:

from wraps.email import Attachment
 
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")],
)

content takes bytes, or a string decoded per encoding. content_type is guessed from the filename when you leave it off. And Bcc always rides the SES envelope rather than the visible headers — which matters precisely because hand-building raw MIME is exactly where a Bcc header gets left in the message and every recipient learns who else got it.

Batch. Sending N independent messages is the most common thing an SES wrapper is asked to do and the place where the failure contract matters most:

result = email.send_batch(entries, 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)

The contract is two-sided on purpose. Malformed input raises before anything is sent — every entry is validated up front, so you can't get halfway through a batch and discover entry 400 was missing a subject. But a failed send never aborts the batch — SES throttled one message, that message comes back as a failed entry and the other 499 still went. Validation is all-or-nothing; delivery is per-message. Results come back aligned to input order, so results[i] is always the outcome of entries[i], which is what lets you write the retry loop without threading correlation ids through it.

Templates and suppression round out the surface — SES-stored templates with CRUD and send_template, and the account-level suppression list, both paginated with a next_token:

email.templates.create(name="welcome", subject="Hi {{name}}", html="<h1>{{name}}</h1>")
email.send_template(template="welcome", from_="you@x.com", to="user@y.com", data={"name": "Sam"})
 
email.suppression.add("bad@example.com", "COMPLAINT")
email.suppression.list(reason="BOUNCE")

Errors that tell you where you are

from wraps.email import SESError, ValidationError, CredentialsError
 
try:
    email.send(...)
except ValidationError as err:
    ...                 # bad input, caught before any AWS call (err.field)
except CredentialsError:
    ...                 # no AWS credentials resolved
except SESError as err:
    ...                 # err.code, err.request_id, err.retryable, err.status

Three exception types, and the split is about when the failure happened. A ValidationError means nothing left the process and err.field tells you which argument was wrong. A CredentialsError means you never got as far as a request. An SESError means AWS answered — and it carries retryable, so your retry logic can branch on the SDK's judgment instead of pattern-matching error strings, plus request_id for when you have to open a support case.


The one wart

email.send(from_="you@yourdomain.com", ...)

from is a Python keyword. Every Python SES library has to do something about this, and the options are all mildly bad: from_, source, sender, or a dict. I picked from_ because it's the convention Python already uses for this exact problem (dataclasses.field, id_, type_), and because it keeps every other parameter name identical to the JS SDK. One trailing underscore is a cheaper tax than a parameter named something SES doesn't call it.

Same reason the import is wraps.email while the distribution is wraps-email — namespace package on PyPI, familiar import in your code.


Where it's going

wraps-py is a uv workspace, and each package inside it publishes to PyPI independently. Today there's one: wraps-email at 0.1.0 — send, batch, attachments, templates, suppression. The roadmap in the repo is the rest of the surface the JS SDK already has: an async client, inbound email, event history, reply threading, and local template rendering.

The async client is the one the architecture was built around. Because signing and request-building are pure and live in _transport, an AsyncWrapsEmail is a client class that awaits httpx.AsyncClient over the same builders — not a parallel implementation that drifts.

pip install wraps-email

MIT licensed, at github.com/wraps-team/wraps-py. Python 3.10+.


I'm building Wraps — email infrastructure that runs in your own AWS account. The TypeScript SDK came first; Python is the second language that kept coming up, usually from teams whose API is Node and whose data pipeline is not.