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 iswraps-email.from_(notfrom, 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:
- 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. - The right region. SES identities are per-region. A domain verified in
eu-west-1does not exist inus-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. - 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:
WrapsEmail(region="eu-west-1")AWS_REGIONAWS_DEFAULT_REGION- the active profile's
regionin~/.aws/config us-east-1as 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fc1fafd3928dc66cb4a90c96eb7108e4922fdeaabdab649de4deaff184edd53a
|
|
| MD5 |
5d07f18fe66dbb048c0e6526da6f253a
|
|
| BLAKE2b-256 |
e845af1a9f0bf5c0942d8bd4b7675bcb47eb536e86318179cfca43c7388fcc5b
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6cbe925eb52aa66e71dd4f3c9d9466e9a134e91290e325db47f2b8c7a3fb93e2
|
|
| MD5 |
6c3970c850da4638278f5451eff9d62e
|
|
| BLAKE2b-256 |
ee50115b21f9ad80d70230ee46a176ccb2626c6091e4579698b00fc8402634dd
|