Skip to content

Mail

Outgoing mail, chatter (MailThread), and email templates.

Email templates (mail.template)

Admins manage templates under Settings → Workflows → Email templates. Each template targets a model (res.partner, etc.) and stores a Jinja2 subject plus an Html body (sanitized on save). The editor has:

  • Write — TipTap v3 ribbon toolbar (styles, font, lists, insert image, etc.)
  • HTML source — CodeMirror 6 (language html or jinja), Jinja variable autocomplete (Ctrl+Space)
  • Preview — sanitized HTML; optional preview with record via POST /api/mail/templates/preview and a record picker on the Preview tab

Bundled via npm into /web/static/dist/mail_editor.js — run npm install && npm run build:editor after changing editor code. Prefer HTML source for heavy {{ object.* }} editing; the Write tab may normalize markup around placeholders.

Insert variable — searchable dropdown (type to search when the model has more than 25 fields). Model list: GET /api/mail/templates/models; fields: GET /api/mail/templates/variables?model=….

Legacy Odoo syntax ${object.name} is accepted.

From code on a MailThread record:

partner.send_mail(template, to="user@example.com", extra={"ctx": {"token": "abc"}})
# or
template.send_mail(partner, to="user@example.com")

Rendered mail is queued on mail.message with body_is_html=True and dispatched by the usual cron / Message.dispatch_outgoing().

Html field

Subclass of Text; values pass through pyvelm/html_sanitizer.py on write. Renders with the HTML editor widget by default.

API reference

mail

Mail thread mixin, message model, and outgoing-mail dispatcher.

Three layers ship here:

  1. mail.message — stores every message / log entry, keyed by (model, res_id) so messages associate with any record on any model.

  2. MailThread — mixin: record.message_post(body) records a log entry without sending mail. record.notify(...) records an entry and queues it for SMTP delivery.

  3. MailBackend + Message.dispatch_outgoing() — the queue walker the cron runner calls every tick. Picks up rows where recipient_email is set and state="outgoing", hands them to the configured backend, and transitions the row to sent or failed (with the error captured).

Fields on mail.message

model _name of the owning model (e.g. "res.partner"). res_id Primary key of the owning record. author_id Many2one to res.users (nullable — system messages use None). body Text body (plain text or HTML; no enforcement here). message_type "comment" | "notification" | "email" (default "comment"). subtype Free-form subtype label (e.g. "note", "done"). date UTC naive datetime of posting. recipient_email Optional address; when set, the row is dispatched via SMTP. subject Subject line for outgoing email (falls back to body[:80]). state "outgoing" | "sent" | "failed" (default "outgoing"). error Last dispatch failure (free-form string).

Mail backend selection

PYVELM_MAIL_BACKEND env var picks the implementation:

  • console (default) — logs to stdout. Safe for dev and tests.
  • disabled — silently drops every send. Use this in CI or when you want the dispatcher to mark messages as sent without actually contacting an SMTP server.
  • smtp — talks SMTP to PYVELM_SMTP_HOST/PORT/....

SMTP env knobs:

PYVELM_SMTP_HOST      Hostname (e.g. ``smtp.gmail.com``).
PYVELM_SMTP_PORT      Port (default 587).
PYVELM_SMTP_USER      Username, if the server requires auth.
PYVELM_SMTP_PASSWORD  Password.
PYVELM_SMTP_FROM      From-address. Required for the smtp backend.
PYVELM_SMTP_USE_TLS   ``1`` (default) to STARTTLS, ``0`` to skip.

MailBackend

Bases: Protocol

A pluggable transport for outgoing mail.

Implementations raise on transient failures so the dispatcher can flip the row to state="failed" and surface the reason via error. The protocol is intentionally narrow — one method — because every backend in this file collapses to "deliver this text to that address."

Source code in pyvelm/mail.py
class MailBackend(Protocol):
    """A pluggable transport for outgoing mail.

    Implementations raise on transient failures so the dispatcher can
    flip the row to ``state="failed"`` and surface the reason via
    ``error``. The protocol is intentionally narrow — one method —
    because every backend in this file collapses to "deliver this
    text to that address."
    """

    def send(
        self,
        *,
        to: str,
        subject: str,
        body: str,
        from_addr: str | None = None,
        body_html: str | None = None,
        cc: str | None = None,
        bcc: str | None = None,
        reply_to: str | None = None,
        attachments: list | None = None,
    ) -> None: ...

ConsoleBackend

Logs the would-be send to stdout. Default backend in dev/CI.

Useful for the smoke test + interactive demo because there's no SMTP server to stand up and the operator gets visible feedback that the dispatcher ran.

Source code in pyvelm/mail.py
class ConsoleBackend:
    """Logs the would-be send to stdout. Default backend in dev/CI.

    Useful for the smoke test + interactive demo because there's no
    SMTP server to stand up and the operator gets visible feedback
    that the dispatcher ran.
    """

    def send(
        self,
        *,
        to: str,
        subject: str,
        body: str,
        from_addr: str | None = None,
        body_html: str | None = None,
        cc: str | None = None,
        bcc: str | None = None,
        reply_to: str | None = None,
        attachments: list | None = None,
    ) -> None:
        extras = []
        if cc:
            extras.append(f"cc={cc}")
        if bcc:
            extras.append(f"bcc={bcc}")
        if reply_to:
            extras.append(f"reply-to={reply_to}")
        if attachments:
            extras.append(f"attachments={len(attachments)}")
        extra_str = f" [{' '.join(extras)}]" if extras else ""
        log.info(
            "[mail console] %s%s | %s%s%s",
            from_addr or "<no-from>",
            to,
            subject or "(no subject)",
            " [html]" if body_html else "",
            extra_str,
        )
        payload = body_html or body
        if payload:
            log.info(
                "[mail console] body: %s",
                payload if len(payload) < 200 else payload[:200] + "…",
            )

DisabledBackend

No-op. state rolls to sent without anything happening.

Source code in pyvelm/mail.py
class DisabledBackend:
    """No-op. ``state`` rolls to ``sent`` without anything happening."""

    def send(self, **_kwargs) -> None:
        return None

SmtpBackend

RFC-5321 SMTP transport via the standard-library smtplib.

Config comes from the PYVELM_SMTP_* env vars (see module docstring). The backend opens a fresh connection for each call — fine at typical pyvelm cadences (one tick per minute). A future pooling refinement is on the table if mail volume grows.

Source code in pyvelm/mail.py
class SmtpBackend:
    """RFC-5321 SMTP transport via the standard-library ``smtplib``.

    Config comes from the ``PYVELM_SMTP_*`` env vars (see module
    docstring). The backend opens a fresh connection for each call —
    fine at typical pyvelm cadences (one tick per minute). A future
    pooling refinement is on the table if mail volume grows.
    """

    def __init__(
        self,
        *,
        host: str,
        port: int = 587,
        user: str | None = None,
        password: str | None = None,
        from_addr: str | None = None,
        use_tls: bool = True,
    ) -> None:
        self.host = host
        self.port = port
        self.user = user
        self.password = password
        self.from_addr = from_addr
        self.use_tls = use_tls

    def send(
        self,
        *,
        to: str,
        subject: str,
        body: str,
        from_addr: str | None = None,
        body_html: str | None = None,
        cc: str | None = None,
        bcc: str | None = None,
        reply_to: str | None = None,
        attachments: list | None = None,
    ) -> None:
        to_list = _split_addresses(to)
        if not to_list:
            raise ValueError("SmtpBackend: at least one To address is required")
        cc_list = _split_addresses(cc)
        bcc_list = _split_addresses(bcc)

        msg = EmailMessage()
        msg["Subject"] = subject or "(no subject)"
        msg["From"] = from_addr or self.from_addr or "noreply@pyvelm"
        msg["To"] = ", ".join(to_list)
        if cc_list:
            msg["Cc"] = ", ".join(cc_list)
        if reply_to:
            msg["Reply-To"] = reply_to
        # Bcc is deliberately not added as a header — smtplib's envelope
        # carries it instead, so recipients don't see the blind list.

        plain = body or ""
        if body_html:
            msg.set_content(plain or " ")
            msg.add_alternative(body_html, subtype="html")
        else:
            msg.set_content(plain)

        for att in attachments or []:
            data, name, ctype = _attachment_payload(att)
            if data is None:
                continue
            maintype, _, subtype = ctype.partition("/")
            if not subtype:
                maintype, subtype = "application", "octet-stream"
            msg.add_attachment(
                data, maintype=maintype, subtype=subtype, filename=name
            )

        envelope = to_list + cc_list + bcc_list
        with smtplib.SMTP(self.host, self.port) as conn:
            if self.use_tls:
                conn.starttls()
            if self.user and self.password:
                conn.login(self.user, self.password)
            conn.send_message(msg, to_addrs=envelope)

MailThread

Mixin that adds chatter / message-thread capability to any model.

Must appear before BaseModel in the MRO so that its __init__ still delegates upward correctly:

class MyModel(MailThread, BaseModel):
    _name = "my.model"
Source code in pyvelm/mail.py
class MailThread:
    """Mixin that adds chatter / message-thread capability to any model.

    Must appear before `BaseModel` in the MRO so that its `__init__`
    still delegates upward correctly:

        class MyModel(MailThread, BaseModel):
            _name = "my.model"
    """

    def message_post(
        self,
        body: str,
        *,
        message_type: str = "comment",
        subtype: str = "",
        attachment_ids: list[int] | None = None,
    ) -> "Message":
        """Create a new ``mail.message`` log entry for this record.

        The returned message is NOT queued for SMTP — it's just a log
        line. Use ``notify()`` when you want both a log entry and an
        outgoing email.

        ``attachment_ids`` re-points existing ``ir.attachment`` rows
        at the new message (``res_model = "mail.message"``, ``res_id =
        msg.id``). Callers upload the bytes first (via
        ``POST /api/attachment/upload``) and pass the resulting ids in.
        """
        self.ensure_one()
        if "mail.message" not in self.env.registry:
            raise RuntimeError(
                "mail.message model is not loaded — make sure the base "
                "module (which defines it) is installed."
            )
        vals: dict = {
            "model": self._name,
            "res_id": self.id,
            "body": body,
            "message_type": message_type,
            "date": utc_now(),
        }
        if subtype:
            vals["subtype"] = subtype
        if self.env.uid is not None and "res.users" in self.env.registry:
            vals["author_id"] = self.env.uid
        msg = self.env["mail.message"].create(vals)
        _link_attachments(self.env, attachment_ids, "mail.message", msg.id)
        return msg

    def notify(
        self,
        body: str,
        *,
        recipient_email: str,
        subject: str = "",
        message_type: str = "email",
        subtype: str = "",
        attachment_ids: list[int] | None = None,
        cc: str | None = None,
        bcc: str | None = None,
        reply_to: str | None = None,
        body_is_html: bool = False,
    ) -> "Message":
        """Log a message AND queue it for SMTP delivery.

        Same shape as ``message_post`` but additionally sets the
        ``recipient_email`` and ``subject`` fields so the next
        dispatcher tick picks the row up.

        ``attachment_ids`` works the same way as in ``message_post`` —
        the SMTP backend currently doesn't add the bytes to the
        outgoing mail (that's a follow-up), but the linkage is
        recorded so the chatter UI can render them.
        """
        self.ensure_one()
        if "mail.message" not in self.env.registry:
            raise RuntimeError("mail.message model is not loaded")
        vals: dict = {
            "model": self._name,
            "res_id": self.id,
            "body": body,
            "message_type": message_type,
            "date": utc_now(),
            "recipient_email": recipient_email,
            "recipient_cc": (cc or None),
            "recipient_bcc": (bcc or None),
            "reply_to": (reply_to or None),
            "subject": subject or (body[:80] if body else ""),
            "body_is_html": bool(body_is_html),
            "state": "outgoing",
        }
        if subtype:
            vals["subtype"] = subtype
        if self.env.uid is not None and "res.users" in self.env.registry:
            vals["author_id"] = self.env.uid
        msg = self.env["mail.message"].create(vals)
        _link_attachments(self.env, attachment_ids, "mail.message", msg.id)
        return msg

    def send_mail(
        self,
        template,
        *,
        to: str,
        cc: str | None = None,
        bcc: str | None = None,
        reply_to: str | None = None,
        extra: dict | None = None,
        attachment_ids: list[int] | None = None,
    ) -> "Message":
        """Queue email rendered from a ``mail.template`` record."""
        self.ensure_one()
        if hasattr(template, "send_mail"):
            return template.send_mail(
                self,
                to=to,
                cc=cc,
                bcc=bcc,
                reply_to=reply_to,
                extra=extra,
                attachment_ids=attachment_ids,
            )
        if "mail.template" not in self.env.registry:
            raise RuntimeError("mail.template model is not loaded")
        tpl = self.env["mail.template"].browse(int(template))
        if not tpl._ids:
            raise ValueError(f"Unknown mail.template id={template!r}")
        return tpl.send_mail(
            self,
            to=to,
            cc=cc,
            bcc=bcc,
            reply_to=reply_to,
            extra=extra,
            attachment_ids=attachment_ids,
        )

    def _send_rendered_mail(
        self,
        *,
        subject: str,
        body_html: str,
        recipient_email: str,
        cc: str | None = None,
        bcc: str | None = None,
        reply_to: str | None = None,
        template_id: int | None = None,
        attachment_ids: list[int] | None = None,
    ) -> "Message":
        """Internal: queue pre-rendered HTML mail (used by ``mail.template``)."""
        self.ensure_one()
        if "mail.message" not in self.env.registry:
            raise RuntimeError("mail.message model is not loaded")
        vals: dict = {
            "model": self._name,
            "res_id": self.id,
            "body": body_html,
            "body_is_html": True,
            "message_type": "email",
            "date": utc_now(),
            "recipient_email": recipient_email,
            "recipient_cc": (cc or None),
            "recipient_bcc": (bcc or None),
            "reply_to": (reply_to or None),
            "subject": subject or (body_html[:80] if body_html else ""),
            "state": "outgoing",
        }
        if template_id and "mail.template" in self.env.registry:
            vals["template_id"] = template_id
        if self.env.uid is not None and "res.users" in self.env.registry:
            vals["author_id"] = self.env.uid
        msg = self.env["mail.message"].create(vals)
        _link_attachments(self.env, attachment_ids, "mail.message", msg.id)
        return msg

    @property
    def message_ids(self) -> list[int]:
        """Return the ids of all messages attached to this record."""
        self.ensure_one()
        if "mail.message" not in self.env.registry:
            return []
        msgs = self.env["mail.message"].search(
            [
                ("model", "=", self._name),
                ("res_id", "=", self.id),
            ]
        )
        return msgs.ids

message_ids property

message_ids: list[int]

Return the ids of all messages attached to this record.

message_post

message_post(body: str, *, message_type: str = 'comment', subtype: str = '', attachment_ids: list[int] | None = None) -> 'Message'

Create a new mail.message log entry for this record.

The returned message is NOT queued for SMTP — it's just a log line. Use notify() when you want both a log entry and an outgoing email.

attachment_ids re-points existing ir.attachment rows at the new message (res_model = "mail.message", res_id = msg.id). Callers upload the bytes first (via POST /api/attachment/upload) and pass the resulting ids in.

Source code in pyvelm/mail.py
def message_post(
    self,
    body: str,
    *,
    message_type: str = "comment",
    subtype: str = "",
    attachment_ids: list[int] | None = None,
) -> "Message":
    """Create a new ``mail.message`` log entry for this record.

    The returned message is NOT queued for SMTP — it's just a log
    line. Use ``notify()`` when you want both a log entry and an
    outgoing email.

    ``attachment_ids`` re-points existing ``ir.attachment`` rows
    at the new message (``res_model = "mail.message"``, ``res_id =
    msg.id``). Callers upload the bytes first (via
    ``POST /api/attachment/upload``) and pass the resulting ids in.
    """
    self.ensure_one()
    if "mail.message" not in self.env.registry:
        raise RuntimeError(
            "mail.message model is not loaded — make sure the base "
            "module (which defines it) is installed."
        )
    vals: dict = {
        "model": self._name,
        "res_id": self.id,
        "body": body,
        "message_type": message_type,
        "date": utc_now(),
    }
    if subtype:
        vals["subtype"] = subtype
    if self.env.uid is not None and "res.users" in self.env.registry:
        vals["author_id"] = self.env.uid
    msg = self.env["mail.message"].create(vals)
    _link_attachments(self.env, attachment_ids, "mail.message", msg.id)
    return msg

notify

notify(body: str, *, recipient_email: str, subject: str = '', message_type: str = 'email', subtype: str = '', attachment_ids: list[int] | None = None, cc: str | None = None, bcc: str | None = None, reply_to: str | None = None, body_is_html: bool = False) -> 'Message'

Log a message AND queue it for SMTP delivery.

Same shape as message_post but additionally sets the recipient_email and subject fields so the next dispatcher tick picks the row up.

attachment_ids works the same way as in message_post — the SMTP backend currently doesn't add the bytes to the outgoing mail (that's a follow-up), but the linkage is recorded so the chatter UI can render them.

Source code in pyvelm/mail.py
def notify(
    self,
    body: str,
    *,
    recipient_email: str,
    subject: str = "",
    message_type: str = "email",
    subtype: str = "",
    attachment_ids: list[int] | None = None,
    cc: str | None = None,
    bcc: str | None = None,
    reply_to: str | None = None,
    body_is_html: bool = False,
) -> "Message":
    """Log a message AND queue it for SMTP delivery.

    Same shape as ``message_post`` but additionally sets the
    ``recipient_email`` and ``subject`` fields so the next
    dispatcher tick picks the row up.

    ``attachment_ids`` works the same way as in ``message_post`` —
    the SMTP backend currently doesn't add the bytes to the
    outgoing mail (that's a follow-up), but the linkage is
    recorded so the chatter UI can render them.
    """
    self.ensure_one()
    if "mail.message" not in self.env.registry:
        raise RuntimeError("mail.message model is not loaded")
    vals: dict = {
        "model": self._name,
        "res_id": self.id,
        "body": body,
        "message_type": message_type,
        "date": utc_now(),
        "recipient_email": recipient_email,
        "recipient_cc": (cc or None),
        "recipient_bcc": (bcc or None),
        "reply_to": (reply_to or None),
        "subject": subject or (body[:80] if body else ""),
        "body_is_html": bool(body_is_html),
        "state": "outgoing",
    }
    if subtype:
        vals["subtype"] = subtype
    if self.env.uid is not None and "res.users" in self.env.registry:
        vals["author_id"] = self.env.uid
    msg = self.env["mail.message"].create(vals)
    _link_attachments(self.env, attachment_ids, "mail.message", msg.id)
    return msg

send_mail

send_mail(template, *, to: str, cc: str | None = None, bcc: str | None = None, reply_to: str | None = None, extra: dict | None = None, attachment_ids: list[int] | None = None) -> 'Message'

Queue email rendered from a mail.template record.

Source code in pyvelm/mail.py
def send_mail(
    self,
    template,
    *,
    to: str,
    cc: str | None = None,
    bcc: str | None = None,
    reply_to: str | None = None,
    extra: dict | None = None,
    attachment_ids: list[int] | None = None,
) -> "Message":
    """Queue email rendered from a ``mail.template`` record."""
    self.ensure_one()
    if hasattr(template, "send_mail"):
        return template.send_mail(
            self,
            to=to,
            cc=cc,
            bcc=bcc,
            reply_to=reply_to,
            extra=extra,
            attachment_ids=attachment_ids,
        )
    if "mail.template" not in self.env.registry:
        raise RuntimeError("mail.template model is not loaded")
    tpl = self.env["mail.template"].browse(int(template))
    if not tpl._ids:
        raise ValueError(f"Unknown mail.template id={template!r}")
    return tpl.send_mail(
        self,
        to=to,
        cc=cc,
        bcc=bcc,
        reply_to=reply_to,
        extra=extra,
        attachment_ids=attachment_ids,
    )

mail_template

Backward-compatible re-export of mail.template.

build_mail_template_context

build_mail_template_context(env, *, model: str, record=None, extra: dict[str, Any] | None = None) -> dict[str, Any]

Build the dict passed to Jinja when rendering a template.

Source code in pyvelm/mail_template_render.py
def build_mail_template_context(
    env,
    *,
    model: str,
    record=None,
    extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Build the dict passed to Jinja when rendering a template."""
    ctx: dict[str, Any] = {"ctx": dict(extra or {})}

    if record is not None and hasattr(record, "_ids") and record._ids:
        ctx["object"] = record
    elif model and model in env.registry:
        ctx["object"] = env[model]
    else:
        ctx["object"] = None

    if env.uid is not None and "res.users" in env.registry:
        ctx["user"] = env["res.users"].browse(env.uid)
    else:
        ctx["user"] = env["res.users"](env, ()) if "res.users" in env.registry else None

    company = None
    if "res.company" in env.registry:
        cid = env.company_id
        if cid is not None:
            company = env["res.company"].browse(cid)
        elif ctx.get("user") is not None and getattr(ctx["user"], "_ids", None):
            u = ctx["user"]
            if u._ids and getattr(u, "company_id", None):
                company = env["res.company"].browse(u.company_id.id)
        if company is None or not company._ids:
            rows = env["res.company"].search([], limit=1)
            company = rows if rows._ids else env["res.company"](env, ())
    ctx["company"] = company
    return ctx

render_mail_template_string

render_mail_template_string(source: str, context: dict[str, Any]) -> str

Render source with Jinja2 (sandboxed, auto-escaped).

Source code in pyvelm/mail_template_render.py
def render_mail_template_string(source: str, context: dict[str, Any]) -> str:
    """Render *source* with Jinja2 (sandboxed, auto-escaped)."""
    normalized = _normalize_template_source(source or "")
    if not normalized.strip():
        return ""
    try:
        return _jinja_env.from_string(normalized).render(**context)
    except jinja2.TemplateError as exc:
        raise ValueError(f"Email template syntax error: {exc}") from exc

html_sanitizer

HTML sanitizer for Html field values and mail.template bodies.

The framework stores HTML written by admins (email templates, rich text in chatter etc.). That HTML lands in the DOM via x-html and in outgoing SMTP messages, so anything that would execute code in either context has to be stripped before storage. We deliberately do this at the write boundary (in the field's to_python / to_sql_param) so a malicious payload never reaches the database — even a future bug that bypasses the x-html render path can't reach back to a tainted column.

The allowlist is small and email-shaped: structural tags, common inline formatting, lists, tables, links, images. Everything else is silently dropped (tag stripped; children kept). No external dependency — the parser is pure html.parser. For complex Markdown-flavoured content, swap this for bleach or nh3 at the field level; the public API is one function (:func:sanitize_html).

sanitize_html

sanitize_html(value: str | None) -> str

Return value with disallowed tags / attributes / URL schemes stripped.

None / "" round-trip unchanged. The output is always a str; callers that want :class:markupsafe.Markup can wrap it.

Source code in pyvelm/html_sanitizer.py
def sanitize_html(value: str | None) -> str:
    """Return *value* with disallowed tags / attributes / URL schemes stripped.

    ``None`` / ``""`` round-trip unchanged. The output is **always** a
    `str`; callers that want :class:`markupsafe.Markup` can wrap it.
    """
    if not value:
        return "" if value is not None else ""
    p = _SanitizingParser()
    p.feed(str(value))
    p.close()
    return "".join(p.out)