Skip to main content

Tegami

EO principles respected here

Tegami

Tegami (手紙, "letter" in Japanese) is a small, object-oriented Python library for sending email.

You build an email from small objects. You build a way to send it from small objects. Then you put them together.

from tegami import Address, Courier, Email, From, Html, Login, Smtp, Subject, To

courier = Courier(
    Smtp(
        "smtp.example.com",
        587,
        Login("alice", "secret"),
    ),
)

receipt = await courier.send(
    Email(
        From(Address("alice@example.com", "Alice")),
        To(
            Address("bob@example.com"),
            Address("charlie@example.com"),
        ),
        Subject("Hello"),
        Html("<h1>Hello!</h1>"),
    ),
)

print(receipt.id())
  • Async first. Every call that touches the network is await-able.
  • Two transports out of the box. SMTP and SendGrid.
  • Composable. Add retries, timeouts, logging or anything else by wrapping.
  • No surprises. Immutable objects, no global config, one exception type.

Table of Contents

  1. Installation
  2. Quick Start
  3. Building an Email
  4. Sending
  5. Adding Behavior
  6. Error Handling
  7. Extending Tegami
  8. Testing Your Code
  9. Development
  10. How to Report Issues
  11. License

Installation

Tegami requires Python 3.11 or newer.

pip install tegami

Or with uv:

uv add tegami

Quick Start

import asyncio

from tegami import Address, Courier, Email, From, Login, Smtp, Subject, Text, To


async def main() -> None:
    courier = Courier(
        Smtp(
            "smtp.example.com",
            587,
            Login("alice", "secret"),
        ),
    )
    receipt = await courier.send(
        Email(
            From(Address("alice@example.com")),
            To(Address("bob@example.com")),
            Subject("Lunch?"),
            Text("Are you free at noon?"),
        ),
    )
    print(f"Sent as {receipt.id()} to {receipt.recipients()}")


asyncio.run(main())

That is the whole library in one picture:

Email  ──►  Courier  ──►  Transport  ──►  the world
(what)      (how)         (where)

Building an Email

An Email is a list of parts. Every header, body and attachment is a part. Pass them in the order you want them written.

from tegami import Email

email = Email(
    From(...),
    To(...),
    Subject(...),
    Text(...),
    Attachment(...),
)

Tip: put content (Text, Html, Multipart) before attachments.

Emails are immutable. To change one, build a new one.

Addresses

An Address is an email with an optional display name.

from tegami import Address

Address("bob@example.com")                # bob@example.com
Address("bob@example.com", "Bob Smith")   # Bob Smith <bob@example.com>

Address does not validate. When you take addresses from user input, wrap them in Valid. It raises a clear error the moment the address is used.

from tegami import Address, To, Valid

To(Valid(Address(user_input)))

Recipients

Class Header Takes
From From exactly one address
To To one or more addresses
Cc Cc one or more addresses
Bcc Bcc one or more addresses
ReplyTo Reply-To one or more addresses
from tegami import Address, Bcc, Cc, Email, From, ReplyTo, To

Email(
    From(Address("news@example.com", "Example News")),
    To(
        Address("bob@example.com"),
        Address("charlie@example.com"),
    ),
    Cc(
        Address("manager@example.com"),
    ),
    Bcc(
        Address("archive@example.com"),
    ),
    ReplyTo(
        Address("support@example.com"),
    ),
    ...
)

Bcc recipients receive the email, but the Bcc header is stripped before sending. Nobody else sees them.

Subject

from tegami import Subject

Subject("Your invoice for September")

Unicode is fine.

Plain Text and HTML

from tegami import Html, Text

Text("Hello, Bob!")
Html("<h1>Hello, Bob!</h1>")

Use one of them as the body of your email.

Text and HTML Together

Multipart sends both. The mail client shows the best one it can render. Put the plain text first. It is the fallback.

from tegami import Html, Multipart, Text

Multipart(
    Text("Hello, Bob!"),
    Html("<h1>Hello, Bob!</h1>"),
)

Attachments

An Attachment has a file name and a source of bytes.

from pathlib import Path

from tegami import Attachment, Bytes, File

Attachment("invoice.pdf", File(Path("./invoice.pdf")))
Attachment("report.csv", Bytes(b"date,amount\n2024-09-01,42.00\n"))
  • File reads the path only when the email is sent, not when you create it.
  • The MIME type is guessed from the file name.
  • Give it explicitly as a third argument when the guess is not enough:
Attachment("data.bin", Bytes(payload), "application/x-custom")

A complete email with everything:

from pathlib import Path

from tegami import (
    Address,
    Attachment,
    Bcc,
    Email,
    File,
    From,
    Html,
    Multipart,
    ReplyTo,
    Subject,
    Text,
    To,
)

email = Email(
    From(Address("billing@example.com", "Example Billing")),
    To(
        Address("bob@example.com", "Bob Smith"),
    ),
    Bcc(
        Address("archive@example.com"),
    ),
    ReplyTo(
        Address("support@example.com"),
    ),
    Subject("Your invoice for September"),
    Multipart(
        Text("Hi Bob, your invoice is attached."),
        Html("<p>Hi Bob, your invoice is <b>attached</b>.</p>"),
    ),
    Attachment("invoice.pdf", File(Path("invoices/september.pdf"))),
)

Sending

A Courier sends an Email through a transport and gives you a receipt.

from tegami import Courier

courier = Courier(transport)
receipt = await courier.send(email)

SMTP

from tegami import Anonymous, Login, Smtp

Smtp(
    "smtp.example.com",
    587,
    Login("alice", "secret"),
)

Smtp(
    "localhost",
    1025,
    Anonymous(),
)

Use Login for servers that need a user name and password, and Anonymous for those that do not.

TLS is picked by port:

Port Mode
465 implicit TLS
587 STARTTLS
other plain

Each send opens a connection, sends, and closes it. Simple and safe.

SendGrid

from tegami import SendGrid

SendGrid("SG.your-api-key")

SendGrid talks HTTP, so your Email is converted to the JSON the SendGrid v3 Mail Send API expects. Headers, bodies and attachments all map across. You do not need to do anything different.

Receipts

send returns a Receipt, the proof that the provider accepted your email.

receipt = await courier.send(email)

receipt.id()          # message id known to the provider
receipt.recipients()  # addresses the provider accepted
receipt.metadata()    # extra provider data, e.g. the raw SMTP reply

Receipts are immutable and safe to log.


Adding Behavior

Courier implements the Delivery interface. So do Retry and Timeout. Anything that implements Delivery can wrap anything else that does.

from tegami import Courier, Delivery, Smtp

delivery: Delivery = Courier(Smtp(...))

Retry

Try up to N times before giving up. The last error is raised if every attempt fails.

from tegami import Courier, Retry, Smtp

Retry(
    Courier(Smtp(...)),
    3,
)

Retry(
    Courier(Smtp(...)),
    5,
    0.2,
)

The first one makes three attempts one second apart. The second makes five attempts with a pause of 0.2 seconds between them.

Timeout

Give up after a number of seconds.

from tegami import Courier, Smtp, Timeout

Timeout(
    Courier(Smtp(...)),
    10,
)

Stacking Decorators

Wrap in any order. Read it inside out.

from tegami import Courier, Login, Retry, Smtp, Timeout

delivery = Timeout(
    Retry(
        Courier(
            Smtp(
                "smtp.example.com",
                587,
                Login("alice", "secret"),
            ),
        ),
        3,
    ),
    30,
)

receipt = await delivery.send(email)

This one retries three times, and the whole thing must finish within thirty seconds.

Writing Your Own Decorator

Implement Delivery, hold another Delivery, and do your thing around it.

import logging

from tegami import Courier, Delivery, Email, Receipt, Retry, Smtp


class Logged(Delivery):
    def __init__(self, origin: Delivery, log: logging.Logger):
        self.origin = origin
        self.log = log

    async def send(self, email: Email) -> Receipt:
        receipt = await self.origin.send(email)
        self.log.info("Sent %s to %s", receipt.id(), receipt.recipients())
        return receipt


delivery = Logged(
    Retry(
        Courier(Smtp(...)),
        3,
    ),
    logging.getLogger("mail"),
)

Good candidates: logging, rate limits, dry runs, metrics.


Error Handling

Tegami raises only one type: the built-in Exception.

Every vendor error is caught and re-raised with a clear message. The original error is attached as the cause, so nothing is lost.

try:
    receipt = await delivery.send(email)
except Exception as error:
    print(error)            # Can't deliver email <...> through smtp.example.com:587
    print(error.__cause__)  # the original aiosmtplib or httpx error

Catch it once, at the top of your application.


Extending Tegami

Your Own Attachment Source

Implement Blob to load attachment bytes from anywhere.

from tegami import Attachment, Blob


class S3Object(Blob):
    def __init__(self, bucket: str, key: str):
        self.bucket = bucket
        self.key = key

    def bytes(self) -> bytes:
        body = boto3.client("s3").get_object(Bucket=self.bucket, Key=self.key)["Body"]
        return body.read()


Attachment("report.pdf", S3Object("reports", "2024/september.pdf"))

Your Own Transport

Implement Transport to send through a new provider. You receive a standard email.message.EmailMessage and return a Receipt.

from email.message import EmailMessage

from tegami import Confirmation, Courier, Receipt, Transport


class Postmark(Transport):
    def __init__(self, token: str):
        self.token = token

    async def deliver(self, message: EmailMessage) -> Receipt:
        ...  # call the provider
        return Confirmation(
            provider_id,
            recipients,
            {"status": "queued"},
        )


courier = Courier(Postmark("your-token"))

Everything else, including Retry and Timeout, works with it unchanged.


Testing Your Code

Because everything is an interface, you never need to mock. Write a fake.

from email.message import EmailMessage

from tegami import Confirmation, Courier, Receipt, Transport


class FakeTransport(Transport):
    def __init__(self):
        self.messages: list[EmailMessage] = []

    async def deliver(self, message: EmailMessage) -> Receipt:
        self.messages.append(message)
        return Confirmation("fake-id", ("bob@example.com",), {})


transport = FakeTransport()
await Courier(transport).send(email)

assert transport.messages[0]["Subject"] == "Lunch?"

Development

make sync     # install dependencies with uv
make unit     # run tests with coverage
make lint     # black, flake8 and ruff
make help     # list all commands

Tests run against a local SMTP server and a fake SendGrid API. No internet needed.


How to Report Issues

Enhancements

Open a GitHub issue and label it enhancement. Describe the desired behaviour and why it is useful. No code is required.

Bugs in Code

Open a pull request, not an issue. The PR must contain a test that reproduces the bug and fails against the current code. Mark the test with pytest.mark.skip and a short reason, so CI stays green while the failing case is on record. The fix can arrive in the same or a follow-up PR, which removes the skip. Contributors without push rights fork the repository first.

Bugs Outside Code

If the bug cannot be reproduced with a test (documentation, packaging, CI configuration, and so on), open a GitHub issue and label it bug. Describe the expected and actual behaviour and how to observe it.


License

MIT. See LICENSE.

Metadata

Release files for tegami 0.0.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tegami 0.0.3
File Size Uploaded Provenance
tegami-0.0.3.tar.gz 756.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for tegami 0.0.3
File Interpreter ABI Platform
tegami-0.0.3-py3-none-any.whl Python 3 none any Details

Total release size: 771.6 kB

Release files / tegami-0.0.3.tar.gz

Download URL tegami-0.0.3.tar.gz
Size 756.4 kB
Tags Source
SHA-256 checksum
How to use checksums
f3f39837172f6d7951f1d0523ce13967bec4e0129ba81713d14bff0eccfbb51c
BLAKE2b-256 checksum
How to use checksums
7670e28653a6106a962416d204e3f1fdb77e39c033158859f3fa375a96b9151a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.24 {"installer":{"name":"uv","version":"0.12.24","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}

Release files / tegami-0.0.3-py3-none-any.whl

Download URL tegami-0.0.3-py3-none-any.whl
Size 15.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
31ed85972c1751ed955cd005105af5495693e66262a6f9fd542e43af939c148b
BLAKE2b-256 checksum
How to use checksums
5706958a95f2c3bd04d538242bcd33b82bc01d5c202799eff047af1ad7bc982c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.24 {"installer":{"name":"uv","version":"0.12.24","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}

Security for tegami 0.0.3

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.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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