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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

wraps_email-0.2.0.tar.gz (25.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

wraps_email-0.2.0-py3-none-any.whl (25.2 kB view details)

Uploaded Python 3

File details

Details for the file wraps_email-0.2.0.tar.gz.

File metadata

  • Download URL: wraps_email-0.2.0.tar.gz
  • Upload date:
  • Size: 25.7 kB
  • Tags: Source
  • Uploaded using 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}

File hashes

Hashes for wraps_email-0.2.0.tar.gz
Algorithm Hash digest
SHA256 fc1fafd3928dc66cb4a90c96eb7108e4922fdeaabdab649de4deaff184edd53a
MD5 5d07f18fe66dbb048c0e6526da6f253a
BLAKE2b-256 e845af1a9f0bf5c0942d8bd4b7675bcb47eb536e86318179cfca43c7388fcc5b

See more details on using hashes here.

File details

Details for the file wraps_email-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: wraps_email-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 25.2 kB
  • Tags: Python 3
  • Uploaded using 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}

File hashes

Hashes for wraps_email-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6cbe925eb52aa66e71dd4f3c9d9466e9a134e91290e325db47f2b8c7a3fb93e2
MD5 6c3970c850da4638278f5451eff9d62e
BLAKE2b-256 ee50115b21f9ad80d70230ee46a176ccb2626c6091e4579698b00fc8402634dd

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

0.1.0

2 files

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