Skip to main content

hawkapi-mail

Email plugin for HawkAPI. SMTP, AWS SES, SendGrid, Mailgun, Resend, Jinja2 templates, persistent outbox with retry, and webhook handlers for delivery/bounce/complaint events.

Install

pip install hawkapi-mail            # SMTP + SendGrid + Mailgun + Resend + outbox
pip install 'hawkapi-mail[ses]'     # adds AWS SES backend

Quickstart

from hawkapi import Depends, HawkAPI
from hawkapi_mail import (
    EmailMessage,
    Mailer,
    SMTPBackend,
    SMTPConfig,
    get_mailer,
    init_mail,
)

app = HawkAPI()
init_mail(
    app,
    backend=SMTPBackend(SMTPConfig(host="smtp.example.com", port=587, start_tls=True,
                                    username="api", password="secret")),
    default_sender="hello@example.com",
)


@app.post("/welcome")
async def welcome(email: str, mail: Mailer = Depends(get_mailer)):
    msg = EmailMessage.build(
        subject="Welcome!", to=email, text="Glad you joined.",
        html="<h1>Glad you joined.</h1>",
    )
    await mail.send(msg)
    return {"ok": True}

Backends

from hawkapi_mail import (
    InMemoryBackend,                                # tests
    SMTPBackend, SMTPConfig,                        # SMTP / SMTPS / STARTTLS
    SESBackend, SESConfig,                          # AWS SES (extras: [ses])
    SendGridBackend, SendGridConfig,                # SendGrid v3 API
    MailgunBackend, MailgunConfig,                  # Mailgun v3 API
    ResendBackend, ResendConfig,                    # Resend HTTP API
)

sendgrid = SendGridBackend(SendGridConfig(api_key="SG.xxx"))
mailgun  = MailgunBackend(MailgunConfig(api_key="key-xxx", domain="mg.example.com"))
resend   = ResendBackend(ResendConfig(api_key="re_xxx"))
ses      = SESBackend(SESConfig(region="eu-west-1"))   # uses boto3 / IAM

All backends share one async send(message) -> SendResult interface; swap them freely.

Templates

from hawkapi_mail import TemplateRenderer

templates = TemplateRenderer(directory="emails/")           # or package=..., or templates={...}
init_mail(app, backend=..., templates=templates, default_sender="hello@example.com")

await mail.send_template(
    "welcome.html",
    text_template="welcome.txt",
    context={"name": "Alice"},
    subject="Welcome",
    to="alice@example.com",
)

Jinja2 with async rendering + HTML autoescape on by default.

Persistent outbox

from hawkapi_mail import SQLiteOutbox, RetryPolicy

outbox = SQLiteOutbox(path="mail.db")
init_mail(
    app,
    backend=sendgrid,
    outbox=outbox,
    retry=RetryPolicy(max_attempts=5, base_seconds=5, max_seconds=3600),
    start_worker=True,     # drains the outbox in the background
)

# Enqueue instead of sending right away:
entry_id = await mail.send(message, deferred=True)

The worker pulls due entries, calls the backend, and on SendError schedules an exponential-backoff retry. After max_attempts the entry is dropped (logged at error level). For tests, swap in MemoryOutbox().

Webhooks

from hawkapi_mail import (
    verify_sendgrid, parse_sendgrid,
    verify_mailgun,  parse_mailgun,
    verify_resend,   parse_resend,
    parse_ses_sns,   confirm_ses_subscription,
)


@app.post("/webhooks/mailgun")
async def mailgun_hook(request):
    form = await request.form()
    verify_mailgun(
        signing_key="…",
        timestamp=form["timestamp"], token=form["token"], signature=form["signature"],
    )
    event = parse_mailgun(await request.body())
    # event.kind ∈ delivered | bounce | complaint | opened | clicked | unsubscribed | other
    return {"ok": True}


@app.post("/webhooks/ses")
async def ses_hook(request):
    body = await request.body()
    if await confirm_ses_subscription(body):       # SNS SubscriptionConfirmation
        return {"confirmed": True}
    for event in parse_ses_sns(body):
        ...
    return {"ok": True}

All providers normalize to a single WebhookEvent(provider, kind, recipient, message_id, timestamp, raw).

Testing

from hawkapi_mail import InMemoryBackend, init_mail

backend = InMemoryBackend()
init_mail(app, backend=backend, default_sender="me@x.com")

# After exercising the app:
assert len(backend.sent) == 1
assert backend.sent[0].subject == "Welcome"
backend.clear()

Development

git clone https://github.com/Hawk-API/hawkapi-mail.git
cd hawkapi-mail
uv sync --extra dev
uv run pytest -q
uv run ruff check . && uv run ruff format --check .
uv run pyright src/

License

MIT.

Metadata

Release files for hawkapi-mail 0.3.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 hawkapi-mail 0.3.0
File Size Uploaded
hawkapi_mail-0.3.0.tar.gz 55.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for hawkapi-mail 0.3.0
File Interpreter ABI Platform
hawkapi_mail-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 78.1 kB

Release files / hawkapi_mail-0.3.0.tar.gz

Download URL hawkapi_mail-0.3.0.tar.gz
Size 55.8 kB
Tags Source
SHA-256 checksum
How to use checksums
18d443492096b113e20f9204ef4663f6a292ba4363db40ef5237d1e08154e260
BLAKE2b-256 checksum
How to use checksums
19e9fe2bac1e601a890bc8f97da89c384c1eb44c57ab3c4e855bd3347afa92b5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 10, 2026.

Transparency log

Release files / hawkapi_mail-0.3.0-py3-none-any.whl

Download URL hawkapi_mail-0.3.0-py3-none-any.whl
Size 22.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
546c3226a287f4d8810fa79efc9933b56c6fc055634a68c670bfaf4020057ae4
BLAKE2b-256 checksum
How to use checksums
92878333001247b4a4e944fff7ba1abc375a98a0a2ad3e7dc3bffdc1471e8f6f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 10, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release 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