Metadata-Version: 2.5
Name: wraps-email
Version: 0.2.0
Summary: Send email via AWS SES from Python. Raw html/text, attachments, SES-stored templates, and suppression management. Your AWS account, no vendor lock-in.
Project-URL: Homepage, https://github.com/wraps-team/wraps-py/tree/main/packages/email#readme
Project-URL: Repository, https://github.com/wraps-team/wraps-py
Project-URL: Issues, https://github.com/wraps-team/wraps-py/issues
Author: Wraps
License-Expression: MIT
License-File: LICENSE
Keywords: aws,aws-ses,bounce-handling,complaint-handling,deliverability,email,email-api,email-sdk,mailer,send-email,ses,suppression-list,transactional-email,wraps
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: botocore>=1.34
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.7
Description-Content-Type: text/markdown

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

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

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

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

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

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

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

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

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

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

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