Emalia
An AI agent that lives in your email — and the typed Python IMAP/SMTP toolkit behind it.
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.
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-ToandReferences, so mail clients group them instead of starting a fresh conversation every time. - Quoted-reply stripping.
message.bodyis what the sender typed this time, not the entire thread again. HandlesOn … 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— anemail.message.Messagenever 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_sendersstarts 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_rootsby 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_pythonruns 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.
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)
| File | Size | Uploaded | |
|---|---|---|---|
| emalia-0.1.0.tar.gz | 130.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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