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 ───────────────────────────────────────
    "background": None,      # True / False / None (auto-detect)
}
EMAIL keys reference

Key

Default

Description

host

"localhost"

SMTP server hostname.

port

25

SMTP server port. Use 587 for STARTTLS or 465 for implicit TLS.

username

""

SMTP authentication username. Also accepts the alias user.

password

None

SMTP authentication password.

use_tls

False

Issue a STARTTLS command after connecting. Mutually exclusive with use_ssl.

use_ssl

False

Connect using implicit TLS (SMTP_SSL).

timeout

10

Socket timeout in seconds.

default_sender

"noreply@example.com"

From address used when no explicit sender is passed to send_email(). The alias from is also accepted.

backend

"SMTPBackend"

Delivery backend class name. "ConsoleBackend" prints emails to stdout - useful during development.

fail_silently

False

When True, delivery errors are silently swallowed and send_email() returns False. When False, exceptions propagate to the caller.

background

None

Controls background delivery. See Background Delivery below.

Sending Email

See also

Working project that sends email:

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 True on success or False when fail_silently is 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 html is 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. True forces queueing, False forces synchronous delivery.

  • sender – Override the From address 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 (background absent or None), send_email auto-detects whether a background worker is available:

  1. It calls worker_available(), which checks if the configured Dramatiq broker is a real broker (Redis/RabbitMQ) rather than the StubBroker used in tests.

  2. If a real broker is detected, the email is enqueued via enqueue_email_job() and delivered by the worker.

  3. If no real broker is available, the email falls back to synchronous delivery via the configured backend.

This means zero configuration is needed - if OpenViper can enqueue the message to a configured task broker, emails are queued automatically. If the broker is unavailable while auto-detection is in use, OpenViper falls back to inline delivery instead of dropping the message.

# settings.py - tasks enabled with Redis
TASKS = {
    "enabled": 1,
    "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",
    # background is omitted - auto-detects worker availability
}

With this configuration, calling await send_email(...) automatically queues the email for background delivery.

Explicit control

Set background in settings to force behaviour:

EMAIL = {
    ...
    "background": 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 background is True (or background=True), if no real worker is available at runtime (e.g. the broker 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 start-worker . --queues emails

Or process all queues in a single worker:

openviper viperctl start-worker .

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.

delivery_recipients() list[str]

Return all SMTP recipients (to + cc + bcc), deduplicated.

build_message(data: EmailMessageData) email.message.EmailMessage

Build a stdlib EmailMessage from an EmailMessageData instance. 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 normalized AttachmentData objects. 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 is None.

openviper.core.email.queue

enqueue_email_job(data: EmailMessageData) Any

Serialize data and enqueue a background delivery job on the emails queue.

worker_available() bool

Return True when a real message-queue broker (Redis/RabbitMQ) is active. Returns False for the test StubBroker. Result is cached after the first call.

openviper.core.email.templates

render_template_content(template, context=None, template_dir='templates') tuple[str | None, str | None]

Render a Jinja2 template and return (text, html).

  • .txt β†’ (rendered, None)

  • .html / .htm β†’ (None, rendered)

  • .md / .markdown β†’ (rendered, markdown_to_html(rendered))

render_markdown(markdown_text) str

Convert Markdown to HTML. Uses python-markdown with extra and sane_lists extensions when available, otherwise a minimal built-in fallback.