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
- Installation
- Quick Start
- Building an Email
- Sending
- Adding Behavior
- Error Handling
- Extending Tegami
- Testing Your Code
- Development
- 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"))
Filereads 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.
License
MIT. See LICENSE.
Metadata
Release files for tegami 0.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| tegami-0.0.1.tar.gz | 755.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tegami-0.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 770.1 kB
Release files / tegami-0.0.1.tar.gz
| Download URL | tegami-0.0.1.tar.gz |
|---|---|
| Size | 755.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
68c9415ff769aaf9daea5bd5a4d52e3e369121e7bf345b82dc8dd062ee952c44
|
|
BLAKE2b-256 checksum How to use checksums |
225b3e895ce5596df9d14cafa32ca16eba84ab93d6a7578d0429f619afede251
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","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.1-py3-none-any.whl
| Download URL | tegami-0.0.1-py3-none-any.whl |
|---|---|
| Size | 14.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4c803b9c1c6502679d2adb7bc3322b1eeef8c0cf950d7e80af9682dd6ea622f2
|
|
BLAKE2b-256 checksum How to use checksums |
46f39adc3feaa1db5c581007602d5a0ee008037aba221af97e86da67d94895a3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","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.1
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.