Imperal Docs
Recipes

Recipe — send an email

Send an email from a Webbee extension — a complete, copy-paste send_email tool with a Pydantic schema, an async handler, and tests, federal-clean and runnable.

A real, working email-send tool. Copy, paste, ship.

schemas.py
from pydantic import BaseModel, Field
from typing import Literal

class SendEmailParams(BaseModel):
    to: list[str] = Field(
        description="Recipient email addresses. Must be valid RFC 5321 format. At least one required.",
    )
    subject: str = Field(
        description="Subject line — concise, ≤120 chars, no leading 'Re:' (web-kernel handles for replies).",
    )
    body: str = Field(
        description="Plain-text or HTML body. Write the FULL body — never placeholders like '<email body>'.",
    )
    cc: list[str] | None = Field(None, description="Optional CC.")
    bcc: list[str] | None = Field(None, description="Optional BCC.")
    importance: Literal["low", "normal", "high"] = Field("normal", description="Email importance.")
    reply_to_thread_id: str | None = Field(
        None,
        description="Optional thread UUID — set ONLY when replying to an existing thread.",
    )
handlers_chat.py
from imperal_sdk import ChatExtension, ActionResult, sdl
from app import ext              # the Extension declared in app.py
from .schemas import SendEmailParams

# The sent message is an sdl.Entity: core id/title/kind, plus the
# Correspondents facet (people.recipients_to) and MessageState facet
# (comm.direction / comm.sent_at). All handlers — including write/destructive
# ones — return ActionResult wrapping a typed entity, never a bare dict.
class SentMessage(sdl.Entity, sdl.Correspondents, sdl.MessageState):
    thread_id: str | None = None

# A ChatExtension instance is what provides the @chat.function decorator.
chat = ChatExtension(ext, "mail_sender", description="Send email through the user's connected mail provider in plain language.")

@chat.function(
    "send_email",
    description="Send an email message via the user's connected mail account.",
    action_type="write",
    effects=["email.send"],
    data_model=SentMessage,
)
async def send_email(ctx, params: SendEmailParams) -> ActionResult:
    payload = params.model_dump(exclude_none=True)
    sent = await ctx.http.post("/mail/send", json=payload)
    return ActionResult.success(
        SentMessage(
            id=sent["id"],
            title=params.subject,
            recipients_to=params.to,
            direction="outbound",
            thread_id=sent.get("thread_id"),
        ),
        summary=f"Sent to {', '.join(params.to)}.",
    )
app.py
from imperal_sdk import Extension
from . import handlers_chat   # noqa: F401

ext = Extension(
    "mail-sender",
    display_name="Mail Sender",
    description="Send email through the user's connected mail provider in plain language.",
    icon="mail",
    actions_explicit=True,
)

Test it

imperal test takes no flags — it simply runs your tests/ directory under pytest. Write a unit test that calls the handler with a MockContext:

tests/test_send_email.py
import pytest
from imperal_sdk.testing import MockContext

from handlers_chat import send_email
from schemas import SendEmailParams


@pytest.mark.asyncio
async def test_send_email() -> None:
    ctx = MockContext(user_id="user_test_01")
    result = await send_email(
        ctx,
        SendEmailParams(
            to=["sarah@example.com"],
            subject="Q3 plan",
            body="Hey Sarah,\n\nAttached is the Q3 plan. Let me know what you think.\n\n—Alex",
        ),
    )
    assert result.ok
    assert result.data.id           # the SentMessage entity id

Then run the suite:

imperal test

Try it in chat

"email sarah at sarah@example.com about the q3 plan — say it's attached and ask for her thoughts"

The classifier picks send_email, fills params from your message + her email, the SDK validates, your handler runs.

Variations

Add to schema:

attachments: list[dict] | None = Field(
    None,
    description="Optional list of {file_id, filename}. file_id from previous list_files / search_files calls.",
)

The federal way: let the LLM ask for a file by id (from skeleton or a prior tool call), not raw bytes.

Set reply_to_thread_id from the user's prior context. The classifier will fill it from the chain or a list_unread step.

cc: list[str] | None = Field(
    None,
    description="Optional CC. Use this when the user asks to 'loop in' or 'cc' someone.",
)

The LLM will fill this from natural language phrasing.

What you should NOT do

Don't accept raw SMTP credentials in args

Use ctx.secrets (or [BYOLLM](/en/reference/glossary/)-style provider config). Args go to LLMs and audit logs — never raw secrets.

Don't bypass the audit chokepoint

ctx.http auto-audits. Custom HTTP clients (requests, httpx) skip it — discouraged by convention even though no federal validator hard-bans them.

Don't mark this as 'read'

Sending email is a side-effect. action_type='write' is correct.

Where to next

On this page