Recipe — send an email
A complete, copy-paste-runnable recipe for sending email
A real, working email-send tool. Copy, paste, ship.
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.",
)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"),
}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.