Skip to main content

simmail (Python)

The official Python client for simmail: a simulated email world for testing apps and email agents. Wait for the email your app sends, pull out the code or link, get replies from simulated people, and drive the whole world: people, cases, the clock, the World's inbound mail, faults, recipients and webhooks.

Standard library only; Python 3.9 or newer. Full docs: https://www.simmail.dev/docs/

pip install simmail

Setup

The client reads its settings from the environment, or takes them in Simmail(...):

Variable Meaning
SIMMAIL_URL Your universe's server, such as https://c1.simmail.dev (shown on your account page).
SIMMAIL_API_KEY An API key, smk_… (Organization → API keys).
SIMMAIL_UNIVERSE Optional: only needed when the key can reach more than one universe.

Your app sends through simmail's SMTP as usual (the dashboard's Get started shows the settings); this client is for your tests and tools.

import time
from simmail import Simmail

mail = Simmail()

def test_password_reset(client):
    email = f"user-{int(time.time())}@yourcompany.test"
    client.post("/signup", data={"email": email, "password": "pw-123456789"})
    client.post("/forgot-password", data={"email": email})

    msg = mail.mailbox(email).wait_for_email(subject="reset", existing=True, timeout=20)
    assert msg.magic_links, msg.text

A wait only sees mail that arrives after it starts, unless you pass existing=True. Timeouts are seconds, or a duration such as "45s" (at most five minutes).

A fresh address per test

import re

addr = mail.test_address(tag="signup")                 # such as t-k3x9q@inbox.test
client.post("/signup", data={"email": addr.address, "password": "pw-123456789"})

welcome = addr.wait_for_email(subject_regex=re.compile("welcome", re.I), timeout=20)
addr.expect_no_email(within=5, subject="verify")       # no second email
addr.cleanup()                                         # or mail.tag("signup").cleanup()

wait_for_email also takes several addresses or *@domain, count (wait for n emails), since, exclude, body_regex, header, has_attachment, attachment_name and link_domain. When nothing matches, the WaitTimeoutError lists what almost did and why (near_misses).

Objects you can act on

Answers come back as dataclasses with typed fields and methods for what you do next:

from simmail import Simmail, Folder

msg = mail.mailbox("app@yourcompany.test").wait_for_email(sender="dana@harbor.test")
msg.reply("Thanks, Dana. Tuesday works.")      # threaded, from the mailbox it is in
answer = msg.wait_for_reply()                  # Dana's next email in this thread
msg.mark_read()
msg.move(Folder.ARCHIVE)
report = msg.health()                          # for mail your app sent: spam score, links

for m in mail.mailbox("dana@harbor.test").messages(folder=Folder.JUNK):
    print(m.subject, m.is_read)
Object What it has
Message message_id, sender, to, subject, text, html, codes, magic_links, attachments; full(), reply(), wait_for_reply(), wait_until_quiet(), health(), source(), attachment(n), mark_read(), move()
Mailbox address, counts; wait_for_email(), messages() (a generator), list(), send(), inject(), folders(), threads(), set_status()
Persona the person's fields (temperament is a Temperament); update(), pause(), remember(), memory(), relations(), jobs(), run_due(), wait_for(), wait_for_reply(), mailbox()
Case people, story; push(), add_people(), initiate(), regenerate(), edit_story()
Job kind, state, due; run(), skip(), bounce(), reschedule()
TestAddress a fresh address (a Mailbox); wait_for_email(), expect_no_email(), list(), cleanup()
Conversation messages, latest; wait_until_quiet(), reply()
SimEvent kind (an EventType), data, persona_id, message_id; persona()
Account a login; update(), test(), delete()

The API's from is sender (a keyword in Python). Every object still reads like the plain answer (msg["codes"], msg.get("from"), dict(msg)), and .raw / .to_dict() give it as it came.

People, cases and the clock

from simmail import Temperament, LinkHabit, JobKind, EventAlias

dana = mail.personas.create({
    "name": "Dana Reyes",
    "addresses": ["dana@harbor.test"],
    "temperament": Temperament.SKEPTICAL,
    "style": {"links": LinkHabit.WARY},
})

deal = mail.cases.push("deal-1701", {
    "title": "Sale of 1701 Clark Street",
    "people": [{"ref": "seller", "name": "Dana Reyes", "addresses": ["dana@harbor.test"]}],
})
deal.initiate(dana.id, "agent@yourcompany.test", intent="ask about the offer")

mail.clock.advance("2d")              # follow-ups and nudges that were due happen
dana.run_due(kind=JobKind.REPLY)      # or run her pending replies now

event = dana.wait_for(EventAlias.REPLIED, fast=True)   # or "declined", "clicked", "bounced"
talk = msg.wait_until_quiet(quiet=10)                  # the whole conversation, once it settles
print(len(talk.messages), talk.latest.subject)

Chaos, recipients, webhooks and the World

from simmail import FaultScenario, MailboxStatus, StreamKind, EventType

mail.faults.add(FaultScenario.GMAIL_RATE, match="@gmail.com")   # Gmail rate-limits you
mail.recipients.set_status("old-client@harbor.test", MailboxStatus.DISABLED)
mail.webhooks.set([{"url": "https://hooks.yourcompany.test/simmail", "events": [EventType.PERSONA_SENT, "link.*"]}])
mail.world.set({
    "company": {"name": "Acme Realty", "domain": "acme-realty.test"},
    "streams": [{"name": "Website leads", "kind": StreamKind.LEAD, "to": ["sales@acme-realty.test"], "per_day": 6, "enabled": True}],
})

for event in mail.events(since=0):       # the live stream
    if event.kind == EventType.PERSONA_SENT:
        break

Each group (mail.personas, mail.cases, mail.clock, mail.jobs, mail.world, mail.faults, mail.recipients, mail.webhooks, mail.insights, mail.events, mail.accounts, mail.inspect, mail.behaviour) has the calls for that part of the API. The 0.1.0 methods (mail.clock(), mail.advance("2d"), mail.wait_for_email(to), …) still work as before.

Named values

Every setting with fixed choices is an enum: Temperament, Knows, Life, Length, Formality, MailClient, LinkHabit, SpamFilter, Access, Stance, MailboxStatus, BadKind, FailAt, Stranger, Folder, AiMode, MissingSite, LocalLinks, Protocol, Role, PersonaSource, StreamKind, FaultKind, FaultStage, FaultScenario, JobKind, JobState, StoryBy, EventType, EventAlias, ErrorCode. They are str enums equal to the API's strings (Temperament.SKEPTICAL == "skeptical"), so the string works too. They are generated from the server's GET /api/schema; each member has the words the dashboard shows (Temperament.SKEPTICAL.label, .description), and describe("Temperament", value) labels a plain string.

What's covered

Area Calls
Waiting wait_for_email, expect_no_email, wait_for_reply, wait_for_event, wait_for_thread, extract
Test addresses test_address, create_address, tag, list_messages, delete_messages
Mail mailboxes, mailbox, folders, messages, message, raw_message, attachment, update_message, threads, set_mailbox_status, send, inject, signature, set_signature, rescue, retry
Logins accounts, create_account, update_account, delete_account, test_account
People personas, persona, create_persona, update_persona, delete_persona, persona_from_mail, temperaments, memory, add_memory, clear_memory, add_memory_by_external, relations, forget_relations
Cases cases, get_case, push_case, add_case_people, initiate_case, regenerate_case, edit_case_story, delete_case
Clock and jobs clock, advance, freeze, speed, live, jobs, run_due, run_job, skip_job, bounce_job, reschedule_job
Events events (live stream), log
Behaviour ai, set_ai, ai_budget, pace, set_pace, recipients, set_recipients, links, set_links, retention, set_retention, prune_retention, junk_settings, set_junk_settings
Chaos faults, set_faults, fault_scenarios, add_fault_scenario
Inspection health, insights, insight_shares, share_insights, revoke_insight_share
Webhooks and the World webhooks, set_webhooks, world, set_world, send_from_stream
Who and where me, universes, status, connection, connection_cert, schema

Anything else is one mail.request(method, path, body, query) call.

Errors

A failed call raises a SimmailError with the HTTP status, the server's message, its code (an ErrorCode) and request_id. The subclasses say which kind: ValidationError (400, with field and allowed), AuthError (401), PlanLimitError (402), ForbiddenError (403), NotFoundError (404), WaitTimeoutError (408, with near_misses), ConflictError (409, such as a person who decided not to reply), UnexpectedEmailError (409, from expect_no_email, with the email), IdempotencyError (422) and RateLimitedError (429, with retry_after).

Every send and inject carries its own Idempotency-Key, so the client can safely try a write again: GETs and keyed writes are retried after a network error, 502 or 503 (retries, default 2), and an answer the server had already given has .replayed == True. To follow a request in the server's logs, use mail.with_request_id("ci-1234").

from simmail import WaitTimeoutError

try:
    mail.mailbox(email).wait_for_email(subject="verify", timeout=10)
except WaitTimeoutError as e:
    print("nothing arrived", e.near_misses)

License

Apache-2.0

Metadata

Release files for simmail 0.1.1

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

Source distribution (sdist)

Source distribution for simmail 0.1.1
File Size Uploaded
simmail-0.1.1.tar.gz 44.8 kB Details

Built distribution (wheel)

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

Total release size: 81.5 kB

Release files / simmail-0.1.1.tar.gz

Download URL simmail-0.1.1.tar.gz
Size 44.8 kB
Tags Source
SHA-256 checksum
How to use checksums
e6bed70a439a5beff362b45cc6bddbd6e983e48c7344d90dec82e254e5a97468
BLAKE2b-256 checksum
How to use checksums
4e696506ca36644258b0e73b27f145914c3cd188bcc4a1fe6b14a2c8b466642e
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 Oct 14, 2026.

Transparency log

Release files / simmail-0.1.1-py3-none-any.whl

Download URL simmail-0.1.1-py3-none-any.whl
Size 36.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
11f21b2f30bdfab9c8aa2c12b90ab89449040218fb50c8b14f93dc5b87efee28
BLAKE2b-256 checksum
How to use checksums
6726b13de73e9c6c6443d12c3b9e4eecb5baf5f3e6d6d9c12164cc3818811322
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 Oct 14, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

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