Imperal Docs
Recipes

Recipe — send an email

A complete, copy-paste-runnable recipe for sending email

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
from .schemas import SendEmailParams

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

ext = Extension(
    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 --tool send_email --args '{
  "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"
}'

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 chain step), 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