Tutorial
Send email with Python
Send email from Python with the emailit SDK, Django's SMTP backend or smtplib, and verify Emailit webhooks in Flask or Django.
This guide shows how to send email from Python with the official emailit package, how to use SMTP from Django or the standard library instead, and how to verify webhook signatures in Flask and Django.
Prerequisites
- Python 3.7 or later.
- A verified sending domain, for example
acme.com. - An API key. A Sending Only key restricted to your domain is enough.
- Until your workspace has production access, you can only send to the account emails of workspace members.
Install the SDK
pip install emailitThe SDK uses requests under the hood.
Configure your API key
Set the key in the environment of your app server, worker or container:
export EMAILIT_API_KEY=secret_••••••••••••••••••••••••••••••••Read it with os.environ["EMAILIT_API_KEY"], so a missing key fails loudly at startup instead of at the first send.
Send an email
import os
from emailit import EmailitClient
client = EmailitClient(os.environ["EMAILIT_API_KEY"])
email = client.emails.send({
"from": "Acme <hello@acme.com>",
"to": "ada@example.com",
"subject": "Reset your password",
"html": "<p>Use this link to reset your password.</p>",
"text": "Use this link to reset your password.",
})
print(email.id) # em_…Create the client once and reuse it. The same send call accepts cc, bcc, reply_to, attachments, template with variables, scheduled_at and tracking; see Send an email.
Handle errors
from emailit import (
ApiErrorException,
AuthenticationException,
RateLimitException,
UnprocessableEntityException,
)
try:
client.emails.send(message)
except RateLimitException:
# 429: wait and retry, for example from a task queue
raise
except AuthenticationException:
# 401: the API key is missing or invalid
raise
except UnprocessableEntityException as e:
# 422: for example, the from domain isn't verified
print(e.json_body)
except ApiErrorException as e:
print(e.http_status, e.json_body)The SDK is synchronous. In async frameworks such as FastAPI, call it from a regular def endpoint or a background task so it doesn’t block the event loop.
Send with SMTP instead
Django
Django’s built-in SMTP backend works with Emailit. Add this to settings.py:
import os
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.emailit.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "emailit"
EMAIL_HOST_PASSWORD = os.environ["EMAILIT_API_KEY"]
DEFAULT_FROM_EMAIL = "Acme <hello@acme.com>"
SERVER_EMAIL = "alerts@acme.com"Then use Django’s mail functions as usual:
from django.core.mail import send_mail
send_mail(
subject="Reset your password",
message="Use this link to reset your password.",
from_email=None, # uses DEFAULT_FROM_EMAIL
recipient_list=["ada@example.com"],
html_message="<p>Use this link to reset your password.</p>",
)DEFAULT_FROM_EMAIL and SERVER_EMAIL must use a verified sending domain. For implicit TLS on port 465, set EMAIL_PORT = 465 and EMAIL_USE_SSL = True instead of EMAIL_USE_TLS.
Standard library
Without a framework, use smtplib:
import os
import smtplib
from email.message import EmailMessage
message = EmailMessage()
message["From"] = "Acme <hello@acme.com>"
message["To"] = "ada@example.com"
message["Subject"] = "Reset your password"
message.set_content("Use this link to reset your password.")
message.add_alternative("<p>Use this link to reset your password.</p>", subtype="html")
with smtplib.SMTP("smtp.emailit.com", 587) as smtp:
smtp.starttls()
smtp.login("emailit", os.environ["EMAILIT_API_KEY"])
smtp.send_message(message)If your network blocks 587, use port 2525 or 2587 with the same code. See SMTP settings.
Receive webhooks
Create a webhook and store its signing secret in EMAILIT_WEBHOOK_SECRET. Verify the signature against the raw request body with this helper:
import hashlib
import hmac
import os
import time
def is_valid_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
if not signature or not timestamp or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > 300:
return False
secret = os.environ["EMAILIT_WEBHOOK_SECRET"].encode()
expected = hmac.new(secret, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Then call it from your framework’s route:
import json
from flask import Flask, abort, request
from emailit_webhooks import is_valid_signature
app = Flask(__name__)
@app.post("/webhooks/emailit")
def emailit_webhook():
raw_body = request.get_data()
if not is_valid_signature(
raw_body,
request.headers.get("X-Emailit-Signature", ""),
request.headers.get("X-Emailit-Timestamp", ""),
):
abort(401)
for event in json.loads(raw_body): # an array of up to 100 events
if event["type"] == "email.bounced":
address = event["data"]["object"]["to"]
# Stop emailing this address.
return "", 200import json
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from emailit_webhooks import is_valid_signature
@csrf_exempt
@require_POST
def emailit_webhook(request):
if not is_valid_signature(
request.body,
request.headers.get("X-Emailit-Signature", ""),
request.headers.get("X-Emailit-Timestamp", ""),
):
return HttpResponseForbidden("Invalid signature")
for event in json.loads(request.body): # an array of up to 100 events
if event["type"] == "email.complained":
address = event["data"]["object"]["to"]
# Stop emailing this address.
return HttpResponse(status=200)Return a 2xx within 30 seconds; other responses are retried. See Request signature.
Production tips
- Send from a task queue. Use Celery, RQ or your framework’s background tasks for bulk mail, and throttle workers to stay under your sending limits (2 emails per second by default).
- Make retries safe. When a task retries after a timeout, send an
Idempotency-Keyheader (withrequestsorhttpx) so the email isn’t sent twice. See Idempotency. - Deduplicate webhooks. Store each
event_idyou process; failed deliveries are retried and can arrive more than once. - Use one key per environment and load it from the environment or a secrets manager. Never hard-code it in
settings.py.