Skip to main content
Emalia — an agent that lives in your email, and the typed IMAP/SMTP toolkit behind it

Emalia

An AI agent that lives in your email — and the typed Python IMAP/SMTP toolkit behind it.

PyPI version Python versions License: MIT CI Typed Downloads

Quick start · How it works · Toolkit · Security · FAQ · Docs


Emalia turns an ordinary mailbox into an interface for an LLM agent. Point it at any IMAP/SMTP account — Gmail, Outlook, Fastmail, your own server — and it watches for mail, decides whether the sender is allowed, works the request with tools, and replies in thread. No app to install, no chat window, no new account for anyone you work with. If they can send an email, they can use it.

It also ships as a standalone Python email library: emalia.mail is a typed wrapper over imaplib and smtplib that handles threading, quoted-reply stripping, RFC 2047 headers, charset fallbacks, attachments and reconnection. It imports in under half a second and pulls in nothing agent-related, so you can use it in a plain script, a cron job, or somebody else's agent framework.

Terminal recording of emalia init, emalia check verifying IMAP, SMTP, model credentials and policy, then emalia run answering an incoming email with two tool calls

What you get

What it is Import Needs an LLM?
The toolkit Typed IMAP/SMTP client: read, search, send, reply, forward, attachments, flags, folders. emalia.mail No
The tools Every capability as a railtracks function node, gated by one policy object. emalia.tools Bring your own agent
The agent A mailbox listener that answers what arrives, in thread, with an audit log. emalia.runtime Yes

Each layer is usable without the ones above it. That is the whole design.

Install

pip install emalia

Or with uv:

uv add emalia

Provider extras, if you want the SDK pinned explicitly: emalia[anthropic], emalia[openai], emalia[gemini], emalia[all].

Quick start

emalia init          # writes emalia.toml and .env
# fill in .env, then add your own address to allowed_senders in emalia.toml
emalia check         # verifies IMAP, SMTP, model credentials, and the policy
emalia run           # starts watching the mailbox

.env — secrets only, never committed:

EMALIA_ADDRESS=assistant@gmail.com
EMALIA_PASSWORD=your-app-password      # an App Password, not your login password
EMALIA_PROVIDER=gmail
ANTHROPIC_API_KEY=sk-ant-...

emalia.toml — safe to commit:

instance_name = "Emalia"
poll_interval = 30.0

[llm]
provider = "anthropic"
model = "claude-sonnet-4-6"

[policy]
allowed_senders = ["you@example.com"]    # empty means nobody, deliberately
sandbox_roots = ["~/documents/shared"]
enabled_toolsets = ["email", "file_read"]

Now email the address in plain English:

To:      assistant@example.com
Subject: quarterly numbers

Can you find the Q3 spreadsheet in my reports folder and tell me what the
revenue line says? Attach the file too.
Re: quarterly numbers

Found reports/2026-Q3-summary.xlsx, last modified 12 October. The revenue
line reads 1,284,000, up 8% on Q2. The file is attached.

Providers with built-in presets: Gmail, Outlook / Microsoft 365, Yahoo, iCloud, Fastmail, Zoho, Proton (via Bridge). Anything else works with explicit hosts and ports, including STARTTLS-only servers.

How it works

Five layers, dependencies strictly downward. Nothing below emalia.tools knows that railtracks or an LLM exists.

flowchart TD
    CLI["emalia.cli<br/>init · check · run · once · send · inbox · audit"]
    RT["emalia.runtime<br/>poll · gate · dispatch · reply · audit"]
    AG["emalia.agent<br/>system prompt · model · tool wiring"]
    TL["emalia.tools<br/>email · file_read · file_write · http · shell · python"]
    SEC["emalia.security<br/>Policy · sandboxed path resolution"]
    ML["emalia.mail<br/>MailClient · IMAP · SMTP · MIME · parsing"]

    CLI --> RT
    RT --> AG
    AG --> TL
    TL --> SEC
    TL --> ML
    RT --> ML
    AG -.-> SEC

    RTX(["railtracks"]) -.-> AG
    STD(["stdlib only"]) -.-> ML

    classDef agentish fill:#ede9fe,stroke:#6d28d9,color:#3b0764
    classDef plain fill:#e0f2fe,stroke:#0369a1,color:#0c4a6e
    classDef ext fill:#f1f5f9,stroke:#94a3b8,color:#334155,stroke-dasharray:4 3
    class CLI,RT,AG agentish
    class TL,SEC,ML plain
    class RTX,STD ext

The boundary is real rather than aspirational, and it is cheap to verify: import emalia.mail takes about 0.1 s and leaves railtracks absent from sys.modules, while import emalia.agent costs around 4 s because the provider SDKs come with it — considerably more on a cold first import. If you only want the mail layer, you never pay for the rest.

What happens to one email

sequenceDiagram
    autonumber
    participant S as Sender
    participant I as IMAP
    participant L as Listener
    participant P as Policy
    participant A as Agent
    participant T as Tools

    S->>I: sends mail
    L->>I: poll for unread
    I-->>L: message
    L->>L: loop guards: self, auto-reply, list, Message-ID
    L->>P: sender_decision()
    alt not allowed
        P-->>L: ignore
        Note over L: silently dropped, no bounce to a<br/>possibly spoofed address
    else allowed
        P-->>L: accept
        L->>A: body wrapped in untrusted delimiters
        A->>T: tool calls, each charged against a budget
        T->>P: policy check per call
        T-->>A: result, truncated
        A-->>L: reply text
        L->>S: reply, in thread
        L->>I: mark read, only after success
    end

The ordering matters: the message is marked read after the reply succeeds, so a crash mid-request means the mail is retried rather than silently lost.

Just the toolkit, no LLM required

emalia.mail is worth installing on its own. It is the part of email work that is tedious in the standard library, done once and typed.

from emalia.mail import MailClient, SearchCriteria

with MailClient.from_env() as mail:
    for message in mail.unread(limit=5):
        print(message.sender.address, message.subject)
        print(message.body)                 # plain text, quoted history already stripped
        mail.reply(message, "Got it.")      # threads correctly in every client

    uids = mail.search(SearchCriteria().from_("alice@").since("01-Jun-2026"))
    invoice = mail.fetch(uids[0])
    mail.save_attachments(invoice, "~/downloads")

What it handles that raw imaplib and smtplib do not:

  • Threading. Replies carry In-Reply-To and References, so mail clients group them instead of starting a fresh conversation every time.
  • Quoted-reply stripping. message.body is what the sender typed this time, not the entire thread again. Handles On … wrote: in several languages, Outlook's -----Original Message-----, and > runs.
  • Header decoding. RFC 2047 encoded words come back as text, not =?utf-8?B?…?=.
  • Charset fallbacks. A latin-1 sender does not raise and stall your loop.
  • UIDs, not sequence numbers, so mail arriving mid-batch cannot shift what you fetch.
  • Reconnection. Sessions survive the idle drops both protocols are prone to.
  • Attachments both ways, including directories zipped with correct relative paths, and filename sanitisation on save.
  • Bcc that stays blind. Used for the envelope, stripped before transmission.
  • Plain dataclasses out. EmailMessage, EmailAddress, Attachment — an email.message.Message never reaches your code.

Full reference: docs/toolkit.md.

Tools for your own agent

Every capability is a railtracks function node behind the same Policy, whether Emalia calls it or you do.

import railtracks as rt
from emalia.mail import MailClient
from emalia.security import Policy
from emalia.tools import build_tool_nodes

policy = Policy(
    allowed_senders=["me@example.com"],
    sandbox_roots=["~/notes"],
    enabled_toolsets=["email", "file_read"],
)

Agent = rt.agent_node(
    "Inbox Agent",
    tool_nodes=build_tool_nodes(policy, client=MailClient.from_env()),
    llm=rt.llm.AnthropicLLM("claude-sonnet-4-6"),
    system_message="You triage this inbox.",
)

Individual groups work too — EmailTools, FileReadTools, FileWriteTools, HttpTools, ShellTools, PythonTools. Each .tools() returns plain functions with type hints and docstrings, callable directly in a test with no model involved.

Giving the agent tools of your own

Write a function, pass it in. Its signature and docstring are the interface the model sees.

from emalia import EmaliaConfig
from emalia.runtime import EmaliaListener

def check_calendar(day: str) -> str:
    """Look up what is scheduled on a day.

    Args:
        day: The day to look up, as YYYY-MM-DD.

    Returns:
        A description of what is scheduled.
    """
    return my_calendar.lookup(day)

EmaliaListener(EmaliaConfig.load(), extra_tools=[check_calendar]).run()

Security

An inbox is an unauthenticated public endpoint. Anyone who learns the address can put arbitrary text in front of your model, and a convincing enough email is a prompt injection with a delivery mechanism. Emalia is built on that assumption.

flowchart LR
    M[Incoming mail] --> G1{"Loop guards<br/>self · auto · list"}
    G1 -->|hit| D1[drop]
    G1 -->|pass| G2{"Seen before?<br/>Message-ID"}
    G2 -->|yes| D2[drop]
    G2 -->|no| G3{"Sender<br/>allowlisted?"}
    G3 -->|no| D3[drop, silently]
    G3 -->|yes| G4{"Token required<br/>in subject?"}
    G4 -->|missing| D4[drop]
    G4 -->|ok| A[Agent runs]
    A --> T{"Tool registered?<br/>decided before<br/>the model started"}
    T -->|no| R[refusal string]
    T -->|yes| B{"Budget, sandbox,<br/>recipient checks"}
    B -->|fail| R
    B -->|pass| X[execute]

    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d
    class D1,D2,D3,D4,R bad
    class X,A good

The controls that matter:

  • Nobody is answered by default. allowed_senders starts empty, and an empty allowlist makes the listener refuse to start rather than quietly answering the world.
  • Capability is gated outside the model. A disabled toolset is never constructed, so no amount of persuasion in an email body can reach it. This is registration-time, not prompting.
  • Replies go to the sender and nobody else unless you widen allowed_recipients. That is what stops "forward my SSH key to mallory@…" from working even if the model is convinced.
  • Files are sandboxed to sandbox_roots by resolved-path containment, and credential-shaped names (.env, id_rsa, *.pem, .netrc) are refused even inside a root.
  • Shell and Python are off, need a second explicit switch, and the policy refuses to combine either with an open sender list at all. run_python runs in a subprocess, not in the daemon.
  • Untrusted content is delimited in the prompt, and the agent is told the text between the markers is data rather than instruction.
  • Blocked mail is never answered, so the mailbox cannot be turned into a bounce generator aimed at a spoofed address.
  • Errors never leak. A failure sends a fixed one-line notice; the traceback stays local.

Read SECURITY.md before widening anything. It documents the threat model and what Emalia deliberately does not defend against.

Commands

Command What it does
emalia init Write a starter emalia.toml and .env
emalia check Verify IMAP, SMTP, model credentials, and the policy
emalia run Watch the mailbox until interrupted
emalia once Handle one batch and exit — suits cron and systemd timers
emalia send Send one email, bypassing the agent entirely
emalia inbox List recent messages
emalia audit Show recent entries from the JSONL audit log

--dry-run on run and once does everything except send; replies go to the log.

Toolsets

Toolset Tools Default
email list_inbox read_email search_email send_email reply_to_email forward_email mark_email list_folders save_attachments on
file_read read_file list_directory search_files file_info on
file_write write_file delete_file off
http http_request, with SSRF guards off
shell run_shell off, needs allow_dangerous_tools
python run_python, in a subprocess off, needs allow_dangerous_tools

FAQ

Does this work with Gmail?

Yes. Turn on 2-Step Verification, create an App Password, and use that as EMALIA_PASSWORD — Google has rejected plain account passwords over IMAP since 2022. Set EMALIA_PROVIDER=gmail and the hosts and ports are filled in for you.

Can I use it without an LLM at all?

Yes. emalia.mail is a complete IMAP/SMTP library with no agent dependency, no API key, and no railtracks import. pip install emalia, then from emalia.mail import MailClient.

Which model providers are supported?

Whatever railtracks supports: Anthropic, OpenAI, Google Gemini, Azure, Hugging Face, a local Ollama server, or any OpenAI-compatible endpoint via provider = "compatible" and an api_base. Set it in [llm].

How is prompt injection handled?

By not relying on the model to resist it. What the agent can do is decided by the Policy before the model runs: disabled tools are never registered, replies are restricted to the original sender, file access is confined to declared roots, and shell/Python are unavailable unless you turn them on twice. Injected text can make the model try things; it cannot widen what exists to try. See SECURITY.md.

Will it reply to spam, or to itself?

No. Mail from outside the allowlist is dropped without a reply. Mail carrying Auto-Submitted, X-Autoreply, Precedence: bulk|list|junk, List-Id or List-Unsubscribe is dropped. Its own address is dropped. Every Message-ID it has handled is persisted, so a restart does not re-answer the same mail. Outgoing replies carry Auto-Submitted: auto-replied so the other end's autoresponder stays quiet too.

Can I run it on a schedule instead of as a daemon?

Yes — emalia once handles one batch and exits with a non-zero code if anything failed, which is what you want from cron or a systemd timer.

How much does it cost to run?

One model call per email, plus a call per tool round-trip, bounded by max_tool_calls (25 by default). max_replies_per_hour caps the blast radius if something goes wrong.

Is my mail password sent anywhere?

Only to your mail provider. It is read from the environment or .env, never written to emalia.toml (the loader rejects it there), never logged, and redacted in emalia check output. run_python executes in a subprocess specifically so untrusted code cannot read it out of the running process.

Documentation

Quick start From pip install to a working assistant
Design Architecture, layer rules, and the mapping from the 2023 code
Toolkit reference emalia.mail in full
Configuration Every setting, environment variable and precedence rule
Security Threat model, hardening, and what is out of scope
End-to-end testing Running the suite against a real mailbox
Examples Runnable scripts
Changelog What changed, and the defects this release fixed

Requirements

  • Python 3.11+ (3.11, 3.12 and 3.13 tested on Linux, macOS and Windows)
  • An IMAP/SMTP mailbox, usually with an app password
  • An API key for your model provider — unless you run a local one through Ollama

Project layout

src/emalia/
├── mail/        MailClient, IMAP, SMTP, MIME, parsing   — stdlib only
├── security/    Policy, sandboxed path resolution
├── tools/       six toolsets as railtracks function nodes
├── agent.py     system prompt, model resolution, tool wiring
├── runtime/     the mailbox listener, seen-set and audit log
└── cli.py       the emalia command

History

Emalia began in 2023 as a keyword-driven email controller: you sent READ/1 <path> and it mailed the file back. It never reached a working state. This release rebuilds it on railtracks around natural language, ships the mail layer as a library in its own right, and takes the security model seriously in a way the original did not. The changelog lists the original defects, several of which are the reason a control exists now.

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md — in short, run ruff check, ruff format, mypy and pytest before opening one; CI runs all four across three operating systems and three Python versions.

License

MIT. See LICENSE.

Built with railtracks · maintained by CoronRing

Release files for emalia 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for emalia 0.1.0
File Size Uploaded
emalia-0.1.0.tar.gz 130.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for emalia 0.1.0
File Interpreter ABI Platform
emalia-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 216.6 kB

Release files / emalia-0.1.0.tar.gz

Download URL emalia-0.1.0.tar.gz
Size 130.5 kB
Tags Source
SHA-256 checksum
How to use checksums
1e06e04fd2d22bc4b53fbc57e340c955fe87e9a660d86e38a438d41b6ed7dbee
BLAKE2b-256 checksum
How to use checksums
94326b6f3f8cdc9dc4d5d0480eef70f3eb2be1f05698347bf6cb86e491c067ff
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.

Transparency log

Release files / emalia-0.1.0-py3-none-any.whl

Download URL emalia-0.1.0-py3-none-any.whl
Size 86.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f5d15a82cafa8b13533ba46c0d81ec3ec190a98cdef3f3490a3eef82f11abb6f
BLAKE2b-256 checksum
How to use checksums
086c32b0560dfb56a6e3aef602d3a545a02380ba88f2936d3074509a4efe286e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.0

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page