Skip to main content

Framework-agnostic AWS SES wrapper for Python

Project description

sesmio

Tests PyPI Python

Framework-agnostic AWS SES v2 wrapper for Python. Send HTML email with attachments in one call, with typed exceptions, automatic retry, and PII-free logging.

from sesmio import SES

ses = SES(region_name="us-east-1", default_from="no-reply@example.com")

msg_id = ses.send(
    to="user@example.com",
    subject="Welcome",
    html="<p>Hello <b>World</b></p>",
    attachments=[Path("/tmp/invoice.pdf")],
)

Table of contents

Features

  • One import -- from sesmio import SES is all you need
  • Typed exceptions -- MessageRejectedError, ThrottlingError, DailyQuotaExceededError, etc. instead of raw ClientError
  • Automatic retry -- throttling and 5xx errors are retried with exponential backoff + jitter
  • MIME multipart -- text + HTML + attachments built with stdlib email, zero extra deps
  • CRLF injection protection -- header values are validated before sending
  • 10 MB size guard -- message size is checked before the AWS call
  • Sandbox warning -- logs a warning on first send if your account is in sandbox mode
  • PII-free logging -- never logs recipients, subject, or body. Only message_id, region, size, error codes
  • Lazy boto3 client -- no AWS calls at import time; client is created on first send()
  • Thread-safe -- lazy client init uses threading.Lock
  • Framework-agnostic -- works with FastAPI, Flask, Django, Celery, Lambda, or plain scripts

Installation

pip install sesmio

Only runtime dependency: boto3>=1.34.

For development (pytest, moto, ruff, mypy):

pip install sesmio[dev]

Quick start

from sesmio import SES

ses = SES(region_name="us-east-1", default_from="no-reply@example.com")

# HTML email
msg_id = ses.send(
    to="user@example.com",
    subject="Welcome",
    html="<p>Hello!</p>",
)

# Plain text
msg_id = ses.send(
    to="user@example.com",
    subject="Ping",
    text="Hello!",
)

# HTML + explicit plain text
msg_id = ses.send(
    to="user@example.com",
    subject="Welcome",
    html="<p>Hello!</p>",
    text="Hello!",
)

from_ overrides default_from per call:

msg_id = ses.send(
    to="user@example.com",
    from_="billing@example.com",
    subject="Invoice",
    html="<p>Your invoice</p>",
)

Multiple recipients, CC, BCC:

msg_id = ses.send(
    to=["a@example.com", "b@example.com"],
    cc="manager@example.com",
    bcc="audit@example.com",
    subject="Team update",
    html="<p>Hello team</p>",
)

Attachments

Pass a Path (auto-detects filename and MIME type) or a dict:

from pathlib import Path

msg_id = ses.send(
    to="user@example.com",
    subject="Invoice",
    html="<p>See attached</p>",
    attachments=[
        Path("/tmp/invoice.pdf"),
        {"content": csv_bytes, "filename": "data.csv", "content_type": "text/csv"},
    ],
)

Tags

Map to SES EmailTags for tracking via event destinations:

msg_id = ses.send(
    to="user@example.com",
    subject="Welcome",
    html="<p>Hi</p>",
    tags={"campaign": "onboarding", "tier": "free"},
)

Custom headers

msg_id = ses.send(
    to="user@example.com",
    subject="Notification",
    html="<p>Alert</p>",
    headers={"X-Priority": "1", "X-Campaign-Id": "abc123"},
)

Values are checked for CRLF injection before sending.

Minimal IAM policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ses:SendEmail"],
      "Resource": "*"
    }
  ]
}

Add ses:GetAccount if you want sandbox detection to work (optional — the check is non-fatal).

Exceptions and error handling

All exceptions inherit from SesmioError. Never leaks botocore.exceptions.ClientError.

from sesmio import (
    SesmioError,               # base — catch all sesmio errors
    ConfigurationError,        # missing from_/default_from, bad credentials
    ValidationError,           # base for all validation errors
    InvalidRecipientError,     # email fails RFC 5322 validation
    MessageTooLargeError,      # message exceeds 10 MB
    HeaderInjectionError,      # CR or LF in a header value
    SendError,                 # base for SES send errors
    MessageRejectedError,      # SES flagged the message (spam, etc.)
    IdentityNotVerifiedError,  # From identity not verified in SES
    AccountSuspendedError,     # account sending suspended
    SendingPausedError,        # sending paused on config set
    MailFromDomainNotVerifiedError,  # MAIL FROM domain not verified
    RecipientSuppressedError,  # recipient on account suppression list
    ThrottlingError,           # rate limit — retried automatically
    DailyQuotaExceededError,   # 24h quota exhausted — not retried
    ServiceUnavailableError,   # SES 5xx — retried automatically
)

Example:

from sesmio import SES, MessageRejectedError, DailyQuotaExceededError, ThrottlingError

ses = SES(region_name="us-east-1", default_from="no-reply@example.com")

try:
    ses.send(to="user@example.com", subject="Hello", html="<p>Hi</p>")
except DailyQuotaExceededError:
    print("24h sending quota exhausted — try again tomorrow")
except ThrottlingError:
    print("Rate limited — already retried 3 times with backoff")
except MessageRejectedError as e:
    print(f"Message rejected by SES: {e}")

Sandbox mode

When your SES account is in sandbox mode, only verified email addresses can receive mail. On the first send() call, sesmio checks your account status via ses:GetAccount and logs a warning if sandbox is active:

WARNING sesmio SES account is in sandbox mode in region us-east-1.
Outbound email is restricted to verified addresses only.

The check is non-fatal and only runs once per SES instance.

Logging

sesmio uses logging.getLogger("sesmio"). It never logs PII (no recipients, subjects, or body content).

What it does log:

  • INFO send.successmessage_id, size_bytes, region
  • WARNING send.errorerror_code, region
  • WARNING — sandbox mode on first send
import logging

logging.getLogger("sesmio").setLevel(logging.DEBUG)

Route sesmio logs through your own logger:

import logging
from sesmio import SES

ses = SES(
    region_name="us-east-1",
    logger=logging.getLogger("myapp.email"),
)

Connection options

from sesmio import SES
import boto3

# Automatic — reads AWS_DEFAULT_REGION, credentials from env or ~/.aws/config
ses = SES()

# Explicit region
ses = SES(region_name="eu-west-1")

# Default sender so you don't repeat from_= on every call
ses = SES(region_name="us-east-1", default_from="no-reply@example.com")

# Custom boto3 session (e.g. with a specific profile or assumed role)
session = boto3.Session(profile_name="production")
ses = SES(boto3_session=session)

# Custom retry count (default 3)
ses = SES(region_name="us-east-1", max_retries=5)

Email templates

Build email HTML using composable Python components — no Jinja, no HTML strings, no Node toolchain.

Welcome email example

from sesmio import SES
from sesmio.email import Html, Head, Body, Container, Heading, Text, Hr, Button, Img, Spacer

def welcome_email(name: str, cta_url: str):
    return Html(
        head=Head(title="Welcome!", preview=f"Hi {name}, thanks for signing up"),
        body=Body(
            children=Container(
                children=[
                    Img(src="https://example.com/logo.png", alt="Company logo", width=160),
                    Spacer(height=24),
                    Heading(text=f"Hello {name}!", level=1),
                    Text(text="Thanks for registering on our platform. We're thrilled to have you."),
                    Hr(),
                    Button(href=cta_url, children="Get Started", bg="#4f46e5", color="#ffffff"),
                    Spacer(height=32),
                    Text(text="Questions? Reply to this email — we read every one."),
                ]
            )
        ),
    )

ses = SES(region_name="us-east-1", default_from="no-reply@example.com")
ses.send(
    to="user@example.com",
    subject="Welcome to Example!",
    template=welcome_email("Ana", "https://example.com/start"),
)

render() returns (html, text) — use it directly if you need both:

from sesmio.email import render

html, text = render(welcome_email("Ana", "https://example.com/start"))
print(text)  # clean plain-text version, auto-generated from the tree

Preview locally before sending:

from sesmio.email import render_preview

render_preview(welcome_email("Ana", "https://example.com/start"), "welcome.html")
# Logs: preview.written: file:///…/welcome.html — click to open in browser

Available components

Component Description
Html(head, body, lang="en") Root document element
Head(title, preview, meta) <head> with charset, viewport, and inbox preview text
Body(children, bg="#ffffff") <body> with background color
Container(children, width=600) Centered, fixed-width layout table
Section(children, padding=None) Vertical section (<tr><td>)
Row(children) Table row for multi-column layouts
Column(children, width=None) Table column (<td>)
Heading(text, level=1) <h1><h6>
Text(text) <p> paragraph
Link(href, children) <a> anchor
Button(href, children, bg, color) Bulletproof Outlook-compatible button
Img(src, alt, width, height) Image (alt required)
Hr(color="#e5e7eb") Horizontal rule
Spacer(height=16) Vertical whitespace
Preview(text) Hidden inbox preview text
CodeBlock(code, lang) Preformatted code
Raw(html_string) Raw HTML escape hatch (warns on use)

All text/children string values are HTML-escaped automatically. Use Raw() only for trusted HTML — it bypasses escaping and logs a warning.

Tailwind utilities with raw HTML

Apply Tailwind utility classes to plain HTML without a build step:

ses.send(
    to="user@example.com",
    subject="Hello",
    html="""
    <div class="max-w-lg mx-auto p-6 bg-white">
      <h1 class="text-2xl font-bold text-gray-900">Hello</h1>
      <p class="mt-4 text-gray-600">Email content here.</p>
    </div>
    """,
    tailwind=True,   # resolves ~250 Tailwind classes → inline CSS
)

The built-in subset covers spacing (p-*, m-*, px-*, …), typography (text-*, font-*, leading-*, tracking-*), colors (gray, slate, red, blue, green, amber palettes), sizing (w-*, max-w-*), borders, and shadows. Unknown classes are silently ignored (logged at DEBUG).

Bulk sending

Send to many recipients efficiently — recipients are chunked into groups of 50, processed concurrently, and errors per recipient are captured without aborting the batch.

from sesmio import SES
from sesmio.email import Html, Head, Body, Container, Heading, Text, Button
from sesmio.sender import Recipient

ses = SES(region_name="us-east-1", default_from="no-reply@example.com")

def welcome_template(first_name: str = "there", cta_url: str = "") -> Html:
    return Html(
        Head(title="Welcome!"),
        Body(Container(
            Heading(f"Hi {first_name}!"),
            Text("Thanks for joining us."),
            Button(href=cta_url, children="Get Started"),
        )),
    )

results = ses.bulk(
    welcome_template,          # called with each recipient's args
    recipients=[
        Recipient(to="alice@example.com", args={"first_name": "Alice", "cta_url": "https://example.com/alice"}),
        Recipient(to="bob@example.com",   args={"first_name": "Bob",   "cta_url": "https://example.com/bob"}),
    ],
    subject="Welcome to our platform!",
).send()

for r in results:
    print(r.message_id if r.status == "success" else r.error)

Use a pre-registered SES native template for maximum efficiency (SendBulkEmail):

ses.templates.create("welcome_v1", subject="Welcome, {{name}}!", template="<p>Hi {{name}}</p>")

results = ses.bulk(
    "welcome_v1",  # string → uses SendBulkEmail API
    recipients=[
        Recipient(to="alice@example.com", args={"name": "Alice"}),
        Recipient(to="bob@example.com",   args={"name": "Bob"}),
    ],
    subject="Welcome!",
).send()

Native SES templates

ses.templates.create("welcome", subject="Hello, {{name}}!", template="<p>Hi {{name}}</p>")
ses.templates.send(to="user@example.com", template_name="welcome", data={"name": "Ana"})
ses.templates.update("welcome", subject="Updated subject", template="<p>New body</p>")
ses.templates.delete("welcome")
for t in ses.templates.list():
    print(t.name)

Framework integrations

Flask

pip install sesmio[flask]
from flask import Flask, request
from sesmio.integrations.flask import SESExtension

app = Flask(__name__)
ses = SESExtension(app, default_from="no-reply@example.com")

@app.route("/signup", methods=["POST"])
def signup():
    ses.send(
        to=request.json["email"],
        subject="Welcome!",
        html="<p>Thanks for signing up.</p>",
    )
    return "ok"

Application factory pattern:

ses = SESExtension()

def create_app():
    app = Flask(__name__)
    app.config["SESMIO_REGION"] = "us-east-1"
    app.config["SESMIO_DEFAULT_FROM"] = "no-reply@example.com"
    ses.init_app(app)
    return app

FastAPI

pip install sesmio[fastapi]
from fastapi import FastAPI, Depends
from sesmio import SES
from sesmio.integrations.fastapi import get_ses

app = FastAPI()

@app.post("/signup")
def signup(email: str, ses: SES = Depends(get_ses)):
    msg_id = ses.send(to=email, subject="Welcome!", html="<p>Thanks for signing up.</p>")
    return {"message_id": msg_id}

Set config via environment variables:

export SESMIO_REGION=us-east-1
export SESMIO_DEFAULT_FROM=no-reply@example.com

Django

pip install sesmio[django]
# settings.py
EMAIL_BACKEND = "sesmio.integrations.django.SesmioBackend"
SESMIO = {
    "region_name": "us-east-1",
    "default_from": "no-reply@example.com",
}

# views.py
from django.core.mail import send_mail, EmailMultiAlternatives

# Plain text
send_mail("Hello", "Text body", "from@example.com", ["to@example.com"])

# HTML
msg = EmailMultiAlternatives("Subject", "Text body", "from@example.com", ["to@example.com"])
msg.attach_alternative("<p>HTML body</p>", "text/html")
msg.send()

Releasing to PyPI

First-time setup (once)

  1. Go to PyPI Trusted Publishers and add a publisher:
    • Owner: your GitHub username or org
    • Repository name: sesmio
    • Workflow name: publish.yml
    • Environment name: pypi
  2. In your GitHub repository settings → Environments → create pypi environment.

Publish a release

git tag v0.3.0
git push origin v0.3.0

GitHub Actions runs the full test matrix (ruff, mypy, pytest) and, on success, builds and publishes to PyPI via OIDC — no API token needed. You can also trigger manually via Actions → Publish to PyPI → Run workflow.

Troubleshooting

ConfigurationError: No sender address — pass from_="..." to send() or set default_from when creating the SES instance.

IdentityNotVerifiedError — the from_ address or domain is not verified in SES. Go to the SES console → Verified Identities and add it.

AccountSuspendedError — your SES account has been suspended, usually due to high bounce/complaint rates. Check the SES console and contact AWS support.

DailyQuotaExceededError — you have sent more emails than your account's 24-hour quota allows. Request a quota increase in the SES console.

Sandbox mode — in sandbox mode only verified email addresses can send or receive. Either verify the recipient address or request production access.

InvalidRecipientError — the address failed basic RFC 5322 validation. Check for typos.

HeaderInjectionError — one of your header values (including subject) contains a newline character. Remove \r or \n from the value.

Development

git clone https://github.com/Antonipo/sesmio.git
cd sesmio
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
pytest                          # run all tests (moto mocks AWS)
pytest --cov=sesmio             # with coverage
pytest -k "sandbox"             # run specific tests
ruff check src/ tests/          # lint
ruff format src/ tests/         # format
mypy --strict src/sesmio/       # type check

License

Apache 2.0

Project details


Download files

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

Source Distribution

sesmio-0.3.1.tar.gz (158.7 kB view details)

Uploaded Source

Built Distribution

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

sesmio-0.3.1-py3-none-any.whl (52.1 kB view details)

Uploaded Python 3

File details

Details for the file sesmio-0.3.1.tar.gz.

File metadata

  • Download URL: sesmio-0.3.1.tar.gz
  • Upload date:
  • Size: 158.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for sesmio-0.3.1.tar.gz
Algorithm Hash digest
SHA256 f10f9f9919b2c2f725706d5bb184da1f90b83239c577d1524fcc4856947d45f2
MD5 0896803247326ce7dcc7f0f0823a743f
BLAKE2b-256 405ab30bf1b4a7dddf622b32f1523989fa15b2ad8dc576cfc36f627763fb0396

See more details on using hashes here.

Provenance

The following attestation bundles were made for sesmio-0.3.1.tar.gz:

Publisher: publish.yml on Antonipo/sesmio

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file sesmio-0.3.1-py3-none-any.whl.

File metadata

  • Download URL: sesmio-0.3.1-py3-none-any.whl
  • Upload date:
  • Size: 52.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for sesmio-0.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 118076183977ac6a4e88430b830a489d46478fffd0210862cd958e71b16c4623
MD5 a9e398a8e1f13a24209dbce1bdac78e3
BLAKE2b-256 d853e56460010fc49c5a7615ef669dcdb938b45f438e99bd35af5fdfc4b2357e

See more details on using hashes here.

Provenance

The following attestation bundles were made for sesmio-0.3.1-py3-none-any.whl:

Publisher: publish.yml on Antonipo/sesmio

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page