Email
The openviper.core.email package provides async-native email delivery with
Jinja2 templates, Markdown-to-HTML rendering, file and URL attachments, and
pluggable backends. Emails can be sent immediately or routed to a background
worker queue when the Background Tasks system is enabled.
The single public entry-point is send_email().
from openviper.core.email import send_email
await send_email(
recipients="alice@example.com",
subject="Welcome!",
text="Thanks for signing up.",
)
Settings
Email behaviour is configured through the EMAIL dictionary in your
settings module (settings.py):
# settings.py
EMAIL = {
# ── SMTP connection ───────────────────────────────────────────
"host": "smtp.example.com", # SMTP server hostname (default: "localhost")
"port": 587, # SMTP port (default: 25)
"username": "apikey", # SMTP auth username (optional)
"password": "SG.xxxx", # SMTP auth password (optional)
"use_tls": True, # STARTTLS after connect (default: False)
"use_ssl": False, # Implicit TLS / SMTPS (default: False)
"timeout": 10, # Connection timeout in seconds (default: 10)
# ── Sender ────────────────────────────────────────────────────
"default_sender": "noreply@example.com", # Fallback From address
# "from" is accepted as an alias for "default_sender"
# ── Backend ───────────────────────────────────────────────────
"backend": "SMTPBackend", # "SMTPBackend" (default) or "ConsoleBackend"
# ── Error handling ────────────────────────────────────────────
"fail_silently": False, # Swallow send errors (default: False)
# ── Background delivery ───────────────────────────────────────
"use_background_worker": None, # True / False / None (auto-detect)
}
Key |
Default |
Description |
|---|---|---|
|
|
SMTP server hostname. |
|
|
SMTP server port. Use |
|
|
SMTP authentication username. Also accepts the alias |
|
|
SMTP authentication password. |
|
|
Issue a |
|
|
Connect using implicit TLS ( |
|
|
Socket timeout in seconds. |
|
|
|
|
|
Delivery backend class name. |
|
|
When |
|
|
Controls background delivery. See Background Delivery below. |
Sending Email
See also
Working project that sends email:
examples/ecommerce_clone/ —
send_emailinOrder.after_insertlifecycle hook, full SMTPEMAILsettings
- send_email(recipients, subject, text=None, html=None, cc=None, bcc=None, attachments=None, template=None, context=None, fail_silently=None, background=None, sender=None) Awaitable[bool]
Send an email. Returns
Trueon success orFalsewhenfail_silentlyis enabled and delivery fails.- Parameters:
recipients – One or more recipient addresses. Accepts a single string or a list of strings.
subject – Email subject line.
text – Plain-text body. If omitted and
htmlis provided, a plain-text version is auto-generated by stripping HTML tags.html – HTML body.
cc – Carbon-copy addresses (string or list).
bcc – Blind carbon-copy addresses (string or list).
attachments – List of attachments. See Attachments.
template – Jinja2 template path relative to your templates directory. See Templates.
context – Template context dictionary.
fail_silently – Override the global
EMAIL["fail_silently"]setting for this call.background – Override background delivery for this call.
Trueforces queueing,Falseforces synchronous delivery.sender – Override the
Fromaddress for this call.
Plain text
await send_email(
recipients="user@example.com",
subject="Order shipped",
text="Your order #1234 has been shipped.",
)
HTML
When both text and html are provided the message is sent as
multipart/alternative. When only html is given, a plain-text
fallback is auto-generated by stripping HTML tags:
await send_email(
recipients="user@example.com",
subject="Weekly digest",
html="<h1>Your weekly digest</h1><p>3 new items this week.</p>",
)
Multiple recipients, CC, and BCC
await send_email(
recipients=["alice@example.com", "bob@example.com"],
subject="Team update",
text="Sprint review tomorrow at 10 AM.",
cc="manager@example.com",
bcc=["hr@example.com", "archive@example.com"],
)
Duplicate addresses across recipients, cc, and bcc are
automatically deduplicated at the SMTP transport level.
Custom sender
await send_email(
recipients="support@example.com",
subject="Feedback",
text="Great product!",
sender="feedback@myapp.com",
)
Error handling
By default, delivery errors raise. Use fail_silently to suppress them:
success = await send_email(
recipients="user@example.com",
subject="Notification",
text="Hello",
fail_silently=True,
)
if not success:
logger.warning("Email delivery failed silently")
Templates
Pass a template path (relative to your TEMPLATES_DIR) along with a
context dictionary. The template is rendered with Jinja2.
Plain text template (.txt)
# templates/emails/welcome.txt
# Hello {{ name }}, welcome to {{ site_name }}!
await send_email(
recipients="user@example.com",
subject="Welcome",
template="emails/welcome.txt",
context={"name": "Alice", "site_name": "MyApp"},
)
For .txt templates the rendered output becomes the text body and
html is left as None.
HTML template (.html)
await send_email(
recipients="user@example.com",
subject="Invoice",
template="emails/invoice.html",
context={"order_id": 1234, "total": "$99.00"},
)
For .html / .htm templates the rendered output becomes the html
body. A plain-text fallback is auto-generated.
Markdown template (.md)
Markdown templates are rendered to HTML using
python-markdown (with extra and
sane_lists extensions). Both the raw Markdown (as text) and the
rendered HTML (as html) are included in the message:
# templates/emails/release_notes.md
# # Release {{ version }}
#
# **New features:**
# - {{ feature_1 }}
# - {{ feature_2 }}
await send_email(
recipients="subscribers@example.com",
subject="Release notes",
template="emails/release_notes.md",
context={"version": "2.0", "feature_1": "Dark mode", "feature_2": "Email support"},
)
If python-markdown is not installed, a minimal built-in fallback converts
headings and paragraphs.
Template + explicit body
When you pass both template and an explicit text or html, the
explicit value takes precedence. This lets you override just one part:
await send_email(
recipients="user@example.com",
subject="Welcome",
template="emails/welcome.md",
context={"name": "Alice"},
text="Plain override", # overrides the Markdown text body
)
Attachments
The attachments parameter accepts a list of items in several formats.
All attachments are resolved concurrently for performance.
File path (string or Path)
from pathlib import Path
await send_email(
recipients="user@example.com",
subject="Report",
text="See the attached report.",
attachments=[
"/tmp/report.pdf",
Path("data/export.csv"),
],
)
MIME type is guessed from the file extension.
Tuple — (filename, content) or (filename, content, mimetype)
await send_email(
recipients="user@example.com",
subject="Data",
text="Attached.",
attachments=[
("hello.txt", b"Hello, world!", "text/plain"),
("data.bin", b"\x00\x01\x02"),
],
)
content can be bytes, a file path string, a Path, or an HTTP
URL.
Raw bytes
await send_email(
recipients="user@example.com",
subject="Binary",
text="See attachment.",
attachments=[b"\x89PNG\r\n\x1a\n..."],
)
Named attachment-{index}.bin with application/octet-stream.
Dict — path, url, content, or content_b64
await send_email(
recipients="user@example.com",
subject="Attachments",
text="Multiple formats.",
attachments=[
{"path": "/tmp/invoice.pdf"},
{"url": "https://example.com/logo.png", "filename": "logo.png"},
{"content": "CSV header\nrow1\nrow2", "filename": "data.csv", "mimetype": "text/csv"},
{"content_b64": "SGVsbG8=", "filename": "hello.txt"},
],
)
AttachmentData objects
For programmatic use, pass pre-built AttachmentData instances:
from openviper.core.email import AttachmentData
await send_email(
recipients="user@example.com",
subject="Attachment",
text="See attached.",
attachments=[
AttachmentData(filename="notes.txt", content=b"Notes here", mimetype="text/plain"),
],
)
Attachment limits
Individual attachments (file or URL) are capped at 25 MB by default.
Attachments that exceed the limit raise ValueError at send time.
URL attachments are restricted to http:// and https:// schemes.
Attempts to use other schemes (e.g. file://, ftp://) raise
ValueError.
Background Delivery
When the Background Tasks system is enabled (i.e. a real message broker like Redis or RabbitMQ is configured), emails can be routed to a background worker queue instead of being sent synchronously in the request cycle.
Auto-detection
By default (use_background_worker absent or None), send_email
auto-detects whether a background worker is available:
It calls
worker_available(), which checks if the configured Dramatiq broker is a real broker (Redis/RabbitMQ) rather than theStubBrokerused in tests.If a real broker is detected, the email is enqueued via
enqueue_email_job()and delivered by the worker.If no real broker is available, the email falls back to synchronous delivery via the configured backend.
This means zero configuration is needed — if you have a task worker running, emails are automatically queued. If not, they are sent inline.
# settings.py — tasks enabled with Redis
TASKS = {
"enabled": True,
"broker": "redis",
"broker_url": "redis://localhost:6379/0",
}
EMAIL = {
"host": "smtp.example.com",
"port": 587,
"use_tls": True,
"username": "apikey",
"password": "SG.xxxx",
"default_sender": "noreply@example.com",
# use_background_worker is omitted — auto-detects worker availability
}
With this configuration, calling await send_email(...) automatically
queues the email for background delivery.
Explicit control
Set use_background_worker in settings to force behaviour:
EMAIL = {
...
"use_background_worker": True, # Always attempt background delivery
}
Or override per call:
# Force synchronous delivery for this email
await send_email(
recipients="admin@example.com",
subject="Critical alert",
text="Server is down!",
background=False,
)
# Force background delivery
await send_email(
recipients="user@example.com",
subject="Newsletter",
text="Weekly update...",
background=True,
)
Safety fallback
Even when use_background_worker is True (or background=True),
if no real worker is available at runtime (e.g. Redis is down), the email
falls back to synchronous delivery rather than silently dropping.
Queue configuration
Background emails are routed to the emails queue. To run a dedicated
email worker:
openviper viperctl runworker . --queues emails
Or process all queues in a single worker:
openviper viperctl runworker .
Backends
SMTPBackend
The default backend. Connects to the SMTP server configured in EMAIL
settings and delivers via smtplib. TLS certificate validation uses
Python’s default SSL context.
ConsoleBackend
A development backend that prints the full rendered email (headers + body) to stdout. Useful for local development without an SMTP server:
EMAIL = {
"backend": "ConsoleBackend",
}
API Reference
openviper.core.email.message
- class EmailMessageData
Normalized email payload dataclass.
- Parameters:
recipients – List of recipient addresses.
subject – Email subject.
text – Plain-text body (optional).
html – HTML body (optional).
cc – CC addresses (default:
[]).bcc – BCC addresses (default:
[]).attachments – List of
AttachmentData(default:[]).sender – From address.
- build_message(data: EmailMessageData) email.message.EmailMessage
Build a stdlib
EmailMessagefrom anEmailMessageDatainstance. Handles multipart/alternative assembly and attachment encoding.
openviper.core.email.attachments
- class AttachmentData
@dataclass(slots=True) class AttachmentData: filename: str content: bytes mimetype: str = "application/octet-stream"
- resolve_attachments(attachments) Awaitable[list[AttachmentData]]
Resolve a list of mixed attachment inputs (paths, URLs, dicts, tuples, bytes,
AttachmentData) into normalizedAttachmentDataobjects. Inputs are resolved concurrently.
openviper.core.email.backends
- class EmailSettings
SMTP configuration parsed from
settings.EMAIL.- classmethod from_settings() EmailSettings
Parse and return settings from the current configuration.
- get_backend(name=None) EmailBackend
Return an email backend instance. Falls back to
EMAIL["backend"]from settings if name isNone.
openviper.core.email.queue
- enqueue_email_job(data: EmailMessageData) Any
Serialize data and enqueue a background delivery job on the
emailsqueue.