Skip to main content

wraps-email

Send email via AWS SES from Python — your AWS account, no vendor lock-in.

A thin, typed wrapper over SES: raw html/text, attachments, SES-stored templates, and suppression management. Built on httpx + botocore signing (no boto3), so it stays lightweight and a sync and async client can share one transport core.

pip install wraps-email      # or: uv add wraps-email
from wraps.email import WrapsEmail

email = WrapsEmail()          # region and credentials from your AWS environment
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)

Import as wraps.email; the distribution is wraps-email. from_ (not from, which is a Python keyword) is the sender.

Before your first send

Three things decide whether a send lands, and all three fail with confusing errors when they are wrong:

  1. A verified sender identity. from_ must be an SES identity you have verified. Prefer a domain identity with DKIM over a bare address so mail authenticates at Gmail and Yahoo.
  2. The right region. SES identities are per-region. A domain verified in eu-west-1 does not exist in us-east-1, and sending to the wrong one is reported as "Email address is not verified" — pointing you at verification when the real problem is the region.
  3. The SES sandbox. Every new AWS account starts sandboxed and can only send to verified recipients. Getting out is an AWS support review.

You do not need production access to prove your setup works. Send to the AWS mailbox simulator — AWS pre-verifies it, so it needs no recipient verification and produces a real Delivery event:

from wraps.email import SES_SIMULATOR_SUCCESS

email.send(
    from_="you@yourdomain.com",
    to=SES_SIMULATOR_SUCCESS,      # success@simulator.amazonses.com
    subject="Proving the pipeline",
    text="Hello",
)

When SES rejects a send as unverified, this SDK raises SandboxError with both causes named, the region it used, where that region came from, and the ranked ways out. err.original_message still holds AWS's untouched text.

Region

Omit region and it resolves the way every other AWS tool resolves it, highest priority first:

  1. WrapsEmail(region="eu-west-1")
  2. AWS_REGION
  3. AWS_DEFAULT_REGION
  4. the active profile's region in ~/.aws/config
  5. us-east-1 as a last resort
WrapsEmail()                    # AWS_REGION / AWS_DEFAULT_REGION / profile
WrapsEmail(region="eu-west-1")  # explicit, wins over everything

email.region          # -> "eu-west-1"
email.region_source   # -> "$AWS_REGION" — where it came from

Credentials

Resolved via the standard AWS chain — environment variables, shared config, SSO, OIDC/web-identity, assume-role, and IMDS. Override explicitly when you need to:

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

Credentials are resolved on the first request, not in the constructor, so building a client does no I/O and CredentialsError surfaces from send(). An expired SSO session or an unknown profile raises CredentialsError too, rather than a raw botocore exception.

Sending

Attachments

Providing attachments switches the send to a raw MIME message automatically. content is bytes, or a string decoded per encoding ("utf-8" or "base64"); content_type is guessed from the filename when omitted. Bcc always rides the SES envelope, never the visible headers.

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")],
)

Batch

Send many independent messages concurrently. A failed message never aborts the batch; a malformed entry raises before anything is sent.

result = email.send_batch(
    [
        {"from_": "you@x.com", "to": "a@y.com", "subject": "Hi", "text": "1"},
        {"from_": "you@x.com", "to": "b@y.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)

Templates

Manage SES-stored templates and let SES render them at send time.

email.templates.create(name="welcome", subject="Hi {{name}}", html="<h1>{{name}}</h1>")
email.templates.get("welcome")
email.templates.list(page_size=20)          # .next_token to paginate
email.templates.update(name="welcome", subject="Hey {{name}}", html="<h1>{{name}}</h1>")
email.templates.delete("welcome")

email.send_template(
    template="welcome",
    from_="you@yourdomain.com",
    to="user@example.com",
    data={"name": "Sam"},
)

Suppression

The account-level SES suppression list (bounces and complaints).

email.suppression.add("bad@example.com", "COMPLAINT")
email.suppression.get("bad@example.com")     # -> SuppressionEntry | None
email.suppression.list(reason="BOUNCE")      # .next_token to paginate
email.suppression.remove("bad@example.com")

Errors

Every error derives from WrapsEmailError, so one except WrapsEmailError covers all of them.

from wraps.email import CredentialsError, SandboxError, SESError, ValidationError

try:
    email.send(from_="you@x.com", to="user@y.com", subject="Hi", html="<p>Hi</p>")
except ValidationError as err:
    ...                 # bad input, caught before any AWS call (err.field)
except CredentialsError as err:
    ...                 # no credentials, expired SSO, or an unknown profile
except SandboxError as err:
    ...                 # unverified recipient: SES sandbox, or wrong region
                        # err.original_message is AWS's untouched text
except SESError as err:
    ...                 # err.code, err.request_id, err.retryable, err.status

SandboxError subclasses SESError, so an existing except SESError keeps catching it.

Typed

Ships a PEP 561 py.typed marker; every public method has an explicit typed signature, so mypy / ty / Pyright check your calls and editors autocomplete them.

Status

0.1.0 — email SDK. Inbound (inbox), event history, reply threading, local template rendering, and an async client are planned but not implemented yet. See the repo roadmap.

Every SES request carries a wraps-email-py/<version> user-agent so Wraps traffic is distinguishable from anything else calling SES in your account. The SDK sends no telemetry and phones nothing home.

MIT licensed.

Metadata

Release files for wraps-email 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for wraps-email 0.2.0
File Size Uploaded Provenance
wraps_email-0.2.0.tar.gz 25.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for wraps-email 0.2.0
File Interpreter ABI Platform
wraps_email-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 50.9 kB

Release files / wraps_email-0.2.0.tar.gz

Download URL wraps_email-0.2.0.tar.gz
Size 25.7 kB
Tags Source
SHA-256 checksum
How to use checksums
fc1fafd3928dc66cb4a90c96eb7108e4922fdeaabdab649de4deaff184edd53a
BLAKE2b-256 checksum
How to use checksums
e845af1a9f0bf5c0942d8bd4b7675bcb47eb536e86318179cfca43c7388fcc5b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / wraps_email-0.2.0-py3-none-any.whl

Download URL wraps_email-0.2.0-py3-none-any.whl
Size 25.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6cbe925eb52aa66e71dd4f3c9d9466e9a134e91290e325db47f2b8c7a3fb93e2
BLAKE2b-256 checksum
How to use checksums
ee50115b21f9ad80d70230ee46a176ccb2626c6091e4579698b00fc8402634dd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for wraps-email 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page