Skip to main content

MailThunder

MIT License Python PyPI

MailThunder is a lightweight and flexible email automation tool for Python. Its provider-agnostic Mail API sends and reads mail the same way whatever the provider; it wraps SMTP and IMAP4 protocols, provides a JSON-based scripting engine and project templates, and makes sending, receiving, and managing email content effortless.

繁體中文 | 简体中文


Table of Contents


Features

  • Provider-agnostic Mail API — One object sends, reads, drafts and deletes mail, behind a provider interface new backends plug into (SMTP and IMAP today)
  • Mail templates — Subject, text and HTML templates with Jinja2-style {{ }}, {% if %} and {% for %}, rendered with the standard library: mail.send(template=..., context=...)
  • Mail events and triggers — mail.on("message_received", handler, filter={...}) with provider-independent events, and mail.watch() to notice new mail by polling or IMAP IDLE
  • Microsoft Graph provider — Send, draft, read and delete Microsoft 365 mail over the Graph API with OAuth2, with polling and webhook triggers
  • Monitoring — An append-only audit log, a health report per provider, and signed outgoing webhooks, all fed by the mail events
  • More providers — Yahoo, iCloud, Zoho and Fastmail presets, and a file provider that keeps mail on disk for dry runs
  • Project mail layer — A project keeps its provider, policy, templates and triggers in mail/, and project_mail() gives its code a ready Mail
  • MailThunder Studio — A local page (python -m je_mail_thunder.studio) for the account, templates, triggers, policies, the project's mail layer and the logs, served by the standard library
  • SMTP support — Send emails over implicit TLS with Gmail (default) or any SMTP provider, or over STARTTLS (Microsoft 365)
  • IMAP4 support — Read, search, and export emails via IMAP4 SSL
  • Attachment handling — Automatically detect MIME types for text, image, audio, and binary files
  • Attachment policy — Check the count, size, extension and MIME type of attachments before a message is sent, with a structured exception for each rule
  • HTML email — Send HTML-formatted emails with attachments
  • JSON scripting engine — Automate email workflows using JSON action files
  • Project templates — Scaffold projects with pre-built keyword and executor templates
  • Socket server — Control MailThunder remotely via TCP socket commands
  • Package manager — Dynamically load Python packages into the scripting executor
  • Environment variable auth — Authenticate via config file or OS environment variables
  • OAuth2 login — SASL XOAUTH2 for Gmail and Microsoft 365, with refresh-token exchange and a token cache in the standard library
  • Authentication objects — PasswordAuth, AppPasswordAuth, OAuth2Auth and XOAUTH2Auth behind one Authentication interface
  • Auto-export — Export all mailbox emails to local files in one call
  • Context manager support — Use with statement for both SMTP and IMAP connections
  • Logging — Built-in logging for all operations

Requirements

  • Python 3.10 or later
  • je_action_core, installed with it: the action executor MailThunder shares with APITestka, LoadDensity and FileAutomation (it uses only the Python standard library)

Installation

Stable release:

pip install je_mail_thunder

Development release:

pip install je_mail_thunder_dev

je_mail_thunder_dev follows the dev branch: CI publishes a new version each time a push to dev passes the tests and changes what the package ships.


Quick Start

Configuration

Before using MailThunder, you need to set up authentication. Create a file named mail_thunder_content.json in your current working directory:

{
  "user": "your_email@gmail.com",
  "password": "your_app_password"
}

Important: If you are using Gmail, you must use an App Password, not your regular Google account password. You also need to enable IMAP in your Gmail settings.

Sending an Email (SMTP)

from je_mail_thunder import SMTPWrapper

with SMTPWrapper() as smtp:
    smtp.later_init()  # Log in using config file or env vars
    smtp.create_message_and_send(
        message_content="Hello from MailThunder!",
        message_setting_dict={
            "Subject": "Test Email",
            "From": "sender@gmail.com",
            "To": "receiver@gmail.com"
        }
    )

Sending an Email with Attachment

from je_mail_thunder import SMTPWrapper

with SMTPWrapper() as smtp:
    smtp.later_init()
    smtp.create_message_with_attach_and_send(
        message_content="Please see the attached file.",
        message_setting_dict={
            "Subject": "Email with Attachment",
            "From": "sender@gmail.com",
            "To": "receiver@gmail.com"
        },
        attach_file="/path/to/file.pdf",
        use_html=False  # Set True if message_content is HTML
    )

Reading Emails (IMAP)

from je_mail_thunder import IMAPWrapper

with IMAPWrapper() as imap:
    imap.later_init()  # Log in
    imap.select_mailbox("INBOX")
    emails = imap.mail_content_list()
    for mail in emails:
        print(f"Subject: {mail['SUBJECT']}")
        print(f"From: {mail['FROM']}")
        print(f"Body: {mail['BODY'][:100]}...")

Exporting All Emails to Files

from je_mail_thunder import IMAPWrapper

with IMAPWrapper() as imap:
    imap.later_init()
    imap.select_mailbox("INBOX")
    imap.output_all_mail_as_file()  # Saves each email as a file named by subject

Core Mail API

Mail is the provider-agnostic API: the same calls send, read, draft and delete mail whatever the account's provider is. It uses the credentials described under Authentication, connects on first use, keeps the connection for the calls that follow, and raises an exception when something fails instead of only logging it.

Sending Mail

from je_mail_thunder import Mail

with Mail() as mail:                       # Gmail, or the provider the OAuth2 settings name
    mail.send(
        to="receiver@example.com",         # one address, several separated by commas, or a list
        cc=["team@example.com"],
        subject="Nightly report",
        text="42 passed, 0 failed.",
        html="<b>42</b> passed, 0 failed.",
        attachments=["report.html"],
    )

With both text and html, the message carries the two as alternatives. The sender is the account's user unless sender= is given; bcc, reply_to and headers are accepted too. Before anything reaches the server the message is checked: at least one recipient, valid addresses, a one-line subject and one-line header values (so a value cannot smuggle in a second header), and the attachments against the attachment policy (Mail(policy=...); 25 MiB of any type by default).

Reading, Drafts and Deleting

from je_mail_thunder import Mail

with Mail() as mail:
    for message in mail.get_messages(folder="INBOX", limit=10, unread_only=True):
        print(message.message_id, message.sender, message.subject)
        for attachment in message.attachments:
            attachment.save("downloads")    # under a name that cannot leave the directory

    message = mail.get_message("4321")      # by the message_id get_messages gave
    mail.create_draft(to="receiver@example.com", subject="Later", text="...")
    mail.delete_message("4321")

get_messages returns an iterator, newest first: messages are fetched one at a time, so a large mailbox is never held in memory, and reading does not mark a message as read. query= takes a search in the provider's own syntax (IMAP SEARCH criteria such as 'FROM "ci@example.com" SINCE 1-Oct-2026'). Each message is a MailMessage with subject, sender, to, cc, reply_to, date, text, html, attachments, headers and message_id.

Accounts and Providers

from je_mail_thunder import AppPasswordAuth, Mail, MailAccount, MailServers

Mail(provider="microsoft")                  # Microsoft 365; login from the config file or the environment
Mail(provider="gmail", auth=AppPasswordAuth("you@gmail.com", "abcd efgh ijkl mnop"))
Mail(account=MailAccount(                   # any other SMTP / IMAP server
    provider="smtp",
    auth=AppPasswordAuth("you@example.com", "..."),
    servers=MailServers(smtp_host="smtp.example.com", imap_host="imap.example.com"),
))
Provider name Sends over Reads over
google (or gmail) SMTP, smtp.gmail.com:465, implicit TLS IMAP, imap.gmail.com
microsoft SMTP, smtp.office365.com:587, STARTTLS IMAP, outlook.office365.com
microsoft_graph Microsoft Graph, https://graph.microsoft.com/v1.0 (OAuth2 only) Microsoft Graph
yahoo, zoho, fastmail SMTP on 465, implicit TLS (smtp.mail.yahoo.com, smtp.zoho.com, smtp.fastmail.com); log in with an app password IMAP (imap.mail.yahoo.com, imap.zoho.com, imap.fastmail.com)
icloud SMTP, smtp.mail.me.com:587, STARTTLS; log in with an app password IMAP, imap.mail.me.com
file nothing is sent: each message is written as an .eml file under mail_outbox/Sent (MAIL_THUNDER_FILE_PROVIDER_DIR names the directory) the .eml files of a folder, e.g. mail_outbox/INBOX
smtp SMTP on the account's MailServers (implicit TLS on 465, or smtp_starttls=True on 587) IMAP on the account's MailServers

Without a provider name, Mail() uses the provider the OAuth2 settings name, else Gmail: the same servers smtp_instance and imap_instance use. Connections always use TLS. A new backend implements MailSender and / or MailStore and is added with register_provider(name, factory); Mail and the code that uses it stay the same.

Errors

Every failure is logged and raised as a MailThunderException subclass (je_mail_thunder.utils.exception.exceptions):

Exception Meaning
MailThunderMessageException the message cannot be sent as it is: no recipient, no sender, an invalid address or header
MailThunderAttachmentException an attachment is missing or breaks the policy
MailThunderAuthenticationException there are no credentials, or the server refused the login
MailThunderConnectionException the server could not be reached, or the connection was lost
MailThunderSendException the server refused the message, or some of its recipients (refused)
MailThunderProviderException base of the two above; also an unknown provider, a refused folder or an unknown message id

A message is never sent twice: a failure while sending is reported, not retried. A connection the server dropped while it sat idle is replaced before the next call.

Moving from the Wrappers

SMTPWrapper, IMAPWrapper, smtp_instance, imap_instance and the MT_smtp_* / MT_imap_* commands keep working as before, so code can move one call at a time:

Wrapper call Mail call
smtp.later_init() / imap.later_init() not needed: Mail logs in on first use
smtp.create_message_and_send(content, settings) mail.send(to=..., subject=..., text=content)
smtp.create_message_with_attach_and_send(content, settings, file, use_html=True) mail.send(to=..., subject=..., html=content, attachments=[file])
imap.select_mailbox("INBOX") then imap.mail_content_list() mail.get_messages(folder="INBOX")
smtp.quit() / imap.quit() mail.close(), or with Mail() as mail:
from je_mail_thunder import Mail, legacy_message, mail_from_wrappers, smtp_instance

# The arguments of create_message_and_send / create_message_with_attach_and_send, as a message
message = legacy_message("Hello", {"Subject": "Hi", "From": "me@gmail.com", "To": "you@example.com"},
                         attach_file="report.pdf", use_html=False)
Mail().send(message)

# Or keep a wrapper that is already logged in, and send through it with the checks Mail adds
smtp_instance.later_init()
mail_from_wrappers(smtp=smtp_instance).send(message)

What differs: Mail raises where the wrapper methods log the error and return None; a message needs at least one valid recipient; attachments are checked against the policy; reading does not mark messages as read and gives MailMessage objects instead of {"SUBJECT": ..., "BODY": ...} dicts.


Authentication

MailThunder logs in with a password or with OAuth2. It reads the JSON config file first, then the environment variables; OAuth2 settings, when present, are used instead of a password.

JSON Config File

Place mail_thunder_content.json in the current working directory:

{
  "user": "your_email@gmail.com",
  "password": "your_app_password"
}

Environment Variables

Set these environment variables before running your script:

from je_mail_thunder import set_mail_thunder_os_environ

set_mail_thunder_os_environ(
    mail_thunder_user="your_email@gmail.com",
    mail_thunder_user_password="your_app_password"
)

Or set them in your shell:

export mail_thunder_user="your_email@gmail.com"
export mail_thunder_user_password="your_app_password"

OAuth2 (Google and Microsoft)

Google and Microsoft are retiring password logins for mail. With OAuth2, MailThunder exchanges a refresh token for a short-lived access token at the provider's token endpoint (standard library only, https required) and logs in with SASL XOAUTH2. Get the client ID, client secret and refresh token once through the provider's consent flow (a Google Cloud OAuth client, or a Microsoft Entra app registration); MailThunder does not run that flow.

In mail_thunder_content.json:

{
  "user": "you@example.com",
  "oauth2": {
    "provider": "microsoft",
    "client_id": "...",
    "client_secret": "...",
    "refresh_token": "...",
    "tenant": "common"
  }
}

Or in the environment: mail_thunder_user plus mail_thunder_oauth2_provider, mail_thunder_oauth2_client_id, mail_thunder_oauth2_client_secret, mail_thunder_oauth2_refresh_token; optionally mail_thunder_oauth2_tenant, mail_thunder_oauth2_scope, mail_thunder_oauth2_token_url, mail_thunder_oauth2_access_token (a token used as given).

Provider SMTP IMAP Scope it asks for
google (default) smtp.gmail.com:465, implicit TLS (SMTPWrapper) imap.gmail.com https://mail.google.com/
microsoft smtp.office365.com:587, STARTTLS (SMTPStartTLSWrapper) outlook.office365.com SMTP.Send, IMAP.AccessAsUser.All on https://outlook.office.com/, offline_access

smtp_instance and imap_instance, and so the MT_smtp_* / MT_imap_* commands, connect to the servers of the provider the settings name, so action files work with Microsoft too. Access tokens are cached and refreshed a minute before they expire. Another provider works with token_url (and scope); create the wrappers with its hosts. Secrets never appear in log lines or error messages.

From Python:

from je_mail_thunder import OAuth2Settings, SMTPStartTLSWrapper, oauth2_token_cache

settings = OAuth2Settings(user="you@contoso.com", provider="microsoft", client_id="...",
                          client_secret="...", refresh_token="...", tenant="contoso.onmicrosoft.com")
with SMTPStartTLSWrapper() as smtp:
    smtp.oauth2_login(settings.user, oauth2_token_cache.access_token(settings))
    smtp.create_message_and_send("Hello", {"Subject": "Hi", "From": settings.user, "To": "friend@example.com"})

Authentication Objects

Each way of logging in is an Authentication object, so the code that connects does not care which one it is given:

Class Logs in with Works for
PasswordAuth(user, password) the account's password SMTP, IMAP
AppPasswordAuth(user, app_password) an app password (Google, Yahoo, iCloud); the spaces it is shown with are dropped SMTP, IMAP
OAuth2Auth(settings) an OAuth2 access token, as Authorization: Bearer ... HTTP APIs
XOAUTH2Auth(settings) the same token, as SASL XOAUTH2 SMTP, IMAP, HTTP APIs
from je_mail_thunder import AppPasswordAuth, OAuth2Settings, SMTPWrapper, XOAUTH2Auth, resolve_authentication

auth = AppPasswordAuth("you@gmail.com", "abcd efgh ijkl mnop")
auth = XOAUTH2Auth(OAuth2Settings(user="you@gmail.com", client_id="...", client_secret="...", refresh_token="..."))
auth = resolve_authentication()   # what the config file or the environment holds, or None

with SMTPWrapper() as smtp:
    auth.login(smtp)              # the same call logs an IMAPWrapper in

auth.login(client) logs an SMTP or IMAP wrapper in, and auth.authorization() gives the value of an HTTP Authorization header. A mechanism that cannot do one of them raises MailThunderAuthenticationException, which MailThunderOAuth2Exception now subclasses. settings is an OAuth2Settings; the token comes from the shared token cache and is refreshed a minute before it expires. resolve_authentication() prefers OAuth2 settings to a password, as the wrappers do. Passwords and tokens never appear in a repr. Give one to Mail(auth=...) or MailAccount(auth=...) to log in with it.


Attachment Policy

An AttachmentPolicy says what a message may carry. validate_attachments checks a message's attachments against it before anything is sent, and raises a structured exception at the first rule that is broken.

from je_mail_thunder import Attachment, AttachmentPolicy, validate_attachments

policy = AttachmentPolicy(
    max_file_size=10 * 1024 * 1024,     # bytes one attachment may have
    max_total_size=20 * 1024 * 1024,    # bytes all attachments may have together
    max_count=5,
    allowed_extensions={"pdf", "csv", "html"},
    allowed_mime_types={"application/pdf", "text/*"},
)
attachments = [Attachment.from_path("report.pdf"), Attachment.from_path("results.csv")]
total_bytes = validate_attachments(attachments, policy)

Every limit is optional: None, the default, lifts it. Extensions are compared without case, with or without the dot, and by the last extension (report.pdf.exe is an .exe). A MIME type may end in /*. DEFAULT_ATTACHMENT_POLICY allows 25 MiB per attachment and per message, of any type.

The checks run in the order count → existence → size → extension → MIME type → total size:

Exception Raised when Attributes
AttachmentCountExceeded there are more attachments than max_count count, limit
AttachmentNotFound a file to attach does not exist path
AttachmentTooLarge one attachment is over max_file_size filename, size, limit
AttachmentTypeNotAllowed an extension or MIME type is not allowed filename, kind, value
TotalAttachmentSizeExceeded together they are over max_total_size size, limit

All of them subclass MailThunderAttachmentException (je_mail_thunder.utils.exception.exceptions). The type checks read the file name, not the content: they stop the wrong file being sent by mistake, not a file renamed on purpose.

Attachment.save(directory) writes an attachment that arrived with a message. Its name is made safe first: directory parts, .., control characters and the characters Windows refuses are removed, so the file cannot land outside directory.

Mail applies its policy to every send and create_draft. create_message_with_attach_and_send of the SMTP wrappers checks its file against the wrapper's attachment_policy (DEFAULT_ATTACHMENT_POLICY; assign another policy, or None to turn the check off): a refused file is logged and the message is not sent.


Mail Templates

A mail template holds a subject, a text body and an HTML body that share one context, so a report mail is written once and sent with different numbers:

from je_mail_thunder import Mail

with Mail() as mail:
    mail.send(
        to="qa@example.com",
        template="test_report",
        context={"project": "APITestka", "passed": 98, "failed": 2, "failures": [{"name": "login"}]},
    )

Mail looks a template up by name in the project's mail/templates/ directory, then in the shared one (~/.je_mail_thunder/templates, or $MAIL_THUNDER_TEMPLATE_DIR). A template is one JSON file, test_report.json (subject / text / html / variables / metadata), or a directory:

mail/templates/test_report/
  subject.txt      [{{ project }}] {{ passed }} passed, {{ failed }} failed
  body.txt         plain-text body
  body.html        HTML body (values are HTML-escaped)
  template.json    {"variables": {"project": {}, "failed": {"default": 0}}, "metadata": {"owner": "qa"}}

The syntax is the part of Jinja2 a mail needs, implemented with the standard library:

Syntax Meaning
{{ user.name }} a value; dots reach into dicts, lists (items.0) and public attributes
{{ name | upper }} a filter: upper, lower, title, trim, length, join(", "), default("x"), safe
{% if failed > 0 %} … {% elif skipped %} … {% else %} … {% endif %} a condition: a value, not, or one comparison (== != < <= > >=)
{% for test in failures %} {{ loop.index }}. {{ test.name }} {% endfor %} a loop, with loop.index, loop.first, loop.last, loop.length
{# note #} a comment

A template only reads the context: nothing in it is evaluated as Python, and in the HTML body every value is HTML-escaped unless it goes through safe. Variables declared in variables are checked before rendering, and TemplateContextError.missing names every absent one; a variable with a default is optional. mail.render("test_report", context) returns the rendered subject, text and HTML without sending, and a field given beside template= (for example subject=) wins over the rendered one. Errors are MailThunderTemplateException subclasses: TemplateNotFound, TemplateSyntaxError, TemplateContextError, TemplateRenderError.

Templates can also be built in code: mail.templates.add(MailTemplate("welcome", subject="Hi {{ name }}", text="...")).


Mail Events and Triggers

Mail reports what happens to mail as events, in the same words for every provider, and a trigger backend watches a folder so that new mail becomes an event too:

from je_mail_thunder import Mail

mail = Mail()

def handle_report(event):
    print(event.message.sender, event.message.subject)

mail.on("message_received", handle_report, filter={"subject": "[TEST]", "has_attachments": True})
mail.watch("INBOX")                  # look every 60 seconds on a background thread
mail.watch("INBOX", idle=True)       # or let the server announce new mail (IMAP IDLE)

@mail.on("message_failed")           # on() is also a decorator
def alert(event):
    print("not sent:", event.error)
Event When
message_received, attachment_received a watched folder has a new message (and once for each of its attachments)
message_sent send handed a message to the provider
message_failed send or create_draft failed, whatever the reason
attachment_rejected an attachment was missing or broke the attachment policy
authentication_failed, connection_failed no credentials or a refused login; an unreachable server or a lost connection

A handler gets a MailEvent (name, message, attachment, error, provider, folder, timestamp, metadata). filter is a mapping of rules (sender, recipient, subject, body, has_attachments, attachment_type, since, until, metadata), a function of the event, or a MailFilter; text rules ignore case, and a compiled regular expression works too. A failure is still raised to the caller, and a handler that raises is logged without stopping anything else.

mail.watch(folder, interval=60, idle=False, start=True, include_existing=False) adds a backend to mail.triggers: IMAPPollingBackend (searches only UIDs above the last one seen), IMAPIdleBackend, or PollingBackend for any other MailStore. The watcher uses a connection of its own, the first look only notes the mail that is already there, and mail.triggers.poll() looks once instead of running a thread. In an action file, MT_mail_poll answers with the events since the previous MT_mail_poll.


Microsoft Graph

The microsoft_graph provider reaches a Microsoft 365 mailbox through the Microsoft Graph API instead of SMTP and IMAP. The Mail calls stay the same; only the provider name changes:

from je_mail_thunder import Mail, OAuth2Auth, OAuth2Settings

auth = OAuth2Auth(OAuth2Settings(
    user="you@contoso.com", provider="microsoft", tenant="contoso.onmicrosoft.com",
    client_id="...", client_secret="...", refresh_token="...",
))
with Mail(provider="microsoft_graph", auth=auth) as mail:
    mail.send(to="qa@example.com", subject="Report", html="<b>42 passed</b>", attachments=["report.pdf"])
    for message in mail.get_messages(limit=10, unread_only=True):
        print(message.sender, message.subject)
  • Choosing it: Mail(provider="microsoft_graph") in code. For Mail() and the MT_mail_* commands, set "mail_provider": "microsoft_graph" in mail_thunder_content.json or the environment variable mail_thunder_mail_provider (it names any registered provider). microsoft keeps meaning SMTP and IMAP.
  • Signing in: OAuth2 only. The app registration needs the delegated permissions Mail.Send and Mail.ReadWrite. When the OAuth2 settings name no scope, the token is asked for with the Graph scopes.
  • Sending: one sendMail request when the attachments are small; otherwise a draft gets each attachment (through an upload session above 3 MiB) and is then sent.
  • Reading: folders are INBOX, Drafts, Sent, Deleted Items, Junk, Archive or a display name, and query= is an OData $filter expression such as from/emailAddress/address eq 'ci@example.com'.
  • Differences from SMTP: a message has one body (the HTML when both are given), custom headers must start with X-, and a sender other than the account needs the send as permission.
  • Triggers: mail.watch() uses GraphPollingBackend. GraphWebhookBackend(provider, "https://your.host/hook") lets Graph announce new mail: its listener binds localhost:9946 behind your HTTPS proxy, answers Graph's validation, and ignores notifications without its secret.

Requests go only to https://graph.microsoft.com, and a refused token is a MailThunderAuthenticationException.


Monitoring

Three listeners turn the mail events into something to look at afterwards. Each is attached to mail.events, and none of them can stop a mail:

from je_mail_thunder import AuditLog, Mail, ProviderHealth, WebhookForwarder

mail = Mail()
audit, health = AuditLog(), ProviderHealth()
audit.attach(mail.events)                 # one JSON line per event in ~/.je_mail_thunder/audit/mail_audit.jsonl
health.attach(mail.events)                # healthy / degraded / down per provider
mail.on("*", WebhookForwarder("https://hooks.example.com/mail", secret="shared-secret"))

print(health.report())                    # [{"provider": "smtp", "state": "healthy", ...}]
print(health.probe(mail.providers))       # ask each provider to connect and log in, now
print(audit.entries(limit=10))
  • AuditLog(path=None, subjects=True) records who sent or received what, and when: addresses, subject, attachment names and sizes, provider, and the error of a failure. Never a body, the content of an attachment or a credential. The file is only appended to (MAIL_THUNDER_AUDIT_FILE moves it) and rotated past 10 MiB.
  • ProviderHealth(failure_threshold=3) is healthy after a success, degraded after a failure and down after failure_threshold failures in a row. Only the provider's own failures count (a refused login, a lost connection, a refused message), not a missing recipient or a refused attachment. probe calls each provider's check().
  • WebhookForwarder(url, secret=None, bodies=False) posts each event as JSON to an https address from a background queue, so a slow receiver never delays send. With a secret, each request carries X-MailThunder-Signature: sha256=<HMAC-SHA256 of the body>. Message bodies are left out unless bodies=True.

Project Mail Layer

An automation project keeps how it mails in its own mail/ directory, and its code asks for a ready Mail without naming a provider. Moving the project to another provider, or trying it without sending anything, is a change in one file:

MyProject/
  mail/
    config.py       # PROVIDER, AUTH or ACCOUNT, ATTACHMENT_POLICY, AUDIT (all optional)
    triggers.py     # register(mail): event handlers and watched folders
    templates/      # the project's mail templates
from je_mail_thunder import project_mail

mail = project_mail()                    # the project in the working directory
mail.send(to="qa@example.com", template="test_report",
          context={"project": "MyProject", "passed": 98, "failed": 2})
mail.triggers.poll()                     # look once for new mail
mail.close()
  • config.py may set PROVIDER (a registered provider name), AUTH or a whole ACCOUNT, ATTACHMENT_POLICY, and AUDIT (True records every mail event in mail/audit.jsonl). Without AUTH the login comes from mail_thunder_content.json or the environment, which keeps credentials out of the project's files.
  • triggers.py defines register(mail), which subscribes the project's handlers (mail.on(...)) and names the folders to watch (mail.watch(...)).
  • templates/ is searched before the shared templates.

create_project_dir() scaffolds the layer with a test_report template and the file provider, so a new project keeps its mail on disk until a real provider is named. project_mail() runs config.py and triggers.py, which are Python files of the project: load only the layer of a project you trust. No action command loads a layer, and describe_mail_layer() lists its files without running them.


MailThunder Studio

MailThunder Studio is a local page for looking at and trying what the mail API is set up to do. It is served by the standard library, needs no extra package, and only talks to the Core Mail API, so it works the same on every provider:

python -m je_mail_thunder.studio                       # the account of the config file or the environment
python -m je_mail_thunder.studio --project MyProject   # the mail layer of a project (runs its mail/config.py and mail/triggers.py)
python -m je_mail_thunder.studio --port 9950 --no-browser

It prints an address such as http://localhost:9947/#token=... and opens it. The page has English and 中文.

Page What it shows and does
Dashboard the account (never its secret), each provider's health, counts, the newest audit entries, and a form that sends a mail
Accounts the account and its servers, the registered providers, and a button that asks every provider to connect and log in
Templates every template with its variables; a context typed in as JSON is rendered without sending
Triggers the events, the subscribed handlers and their filters, the trigger backends, and a button that looks for new mail once
Policies the attachment policy, changeable for as long as Studio runs
Projects the files and templates of the project's mail layer; nothing of it is run
Logs the end of the log file and the audit log
Settings where MailThunder keeps its files, the registered providers and the versions it runs on

Studio is a tool for the person at the keyboard. It speaks plain HTTP, so it serves only a loopback address (localhost, or --host 127.x.x.x) and refuses any other; from another machine, forward the port through SSH (ssh -L 9947:localhost:9947 host). Every API request needs the random token of that run, which travels in the address's fragment and is never sent to a server by the browser; a request with another Host is refused; the page loads nothing from elsewhere and writes everything as text, so nothing that arrived by mail can run in the browser; no answer holds a password or a token; and a mail sent from the page cannot name attachments. Stop it when it is not in use. start_studio(mail) in je_mail_thunder.studio.server starts it from Python.


Scripting Engine

MailThunder includes a JSON-based scripting engine that lets you automate email workflows without writing Python code.

Action JSON Format

Action files use a list of commands. Each command is an array where the first element is the command name and the optional second element contains the arguments:

{
  "mail_thunder": [
    ["command_name"],
    ["command_name", {"key": "value"}],
    ["command_name", ["arg1", "arg2"]]
  ]
}
  • Use a dict {} as the second element for keyword arguments (**kwargs)
  • Use a list [] as the second element for positional arguments (*args)
  • Use only the command name (no second element) for commands with no arguments

Available Script Commands

Command Description Arguments
MT_smtp_later_init Initialize and log in to SMTP None
MT_smtp_create_message_and_send Create and send an email {"message_content": str, "message_setting_dict": dict}
MT_smtp_create_message_with_attach_and_send Create and send an email with attachment {"message_content": str, "message_setting_dict": dict, "attach_file": str, "use_html": bool}
MT_smtp_quit Disconnect from SMTP server (old name smtp_quit still accepted) None
MT_imap_later_init Initialize and log in to IMAP None
MT_imap_select_mailbox Select a mailbox {"mailbox": str, "readonly": bool} (default: INBOX)
MT_imap_search_mailbox Search and get mail details {"search_str": str, "charset": str}
MT_imap_mail_content_list Get all mail content as list {"search_str": str, "charset": str}
MT_imap_output_all_mail_as_file Export all emails to files {"search_str": str, "charset": str}
MT_imap_quit Disconnect from IMAP server None
MT_mail_send Send a message through the configured provider {"to": str or list, "subject": str, "text": str, "html": str, "cc": ..., "bcc": ..., "attachments": [paths], "sender": str, "reply_to": ..., "headers": dict}
MT_mail_create_draft Store a message as a draft the MT_mail_send arguments, plus "folder": str
MT_mail_get_messages Get the messages of a folder, newest first {"folder": str, "limit": int, "unread_only": bool, "query": str} (default: INBOX, no limit)
MT_mail_get_message Get one message by its id {"message_id": str, "folder": str}
MT_mail_delete_message Delete one message by its id {"message_id": str, "folder": str}
MT_mail_close Close the provider connections None
MT_mail_render_template Render a mail template without sending {"template": str, "context": dict}
MT_mail_poll Get the events for the mail that arrived since the last poll of a folder {"folder": str} (default: INBOX)
MT_set_mail_thunder_os_environ Set auth env vars {"mail_thunder_user": str, "mail_thunder_user_password": str}
MT_get_mail_thunder_os_environ Get auth env vars None
MT_add_package_to_executor Load a Python package into executor ["package_name"]

The MT_mail_* commands use the Core Mail API on mail_instance, a Mail() for the account of the config file or the environment. They connect on first use and return JSON-ready values. Its attachment policy is set from Python (mail_instance.policy = AttachmentPolicy(...)), never by an action, so an action file cannot loosen it.

Example — Send a message and read the inbox through the Core Mail API:

{
  "mail_thunder": [
    ["MT_mail_send", {
      "to": "receiver@example.com",
      "subject": "Automated Email",
      "text": "Hello World!",
      "attachments": ["report.html"]
    }],
    ["MT_mail_get_messages", {"limit": 5, "unread_only": true}],
    ["MT_mail_close"]
  ]
}

Example — Send an email via JSON script:

{
  "mail_thunder": [
    ["MT_smtp_later_init"],
    ["MT_smtp_create_message_and_send", {
      "message_content": "Hello World!",
      "message_setting_dict": {
        "Subject": "Automated Email",
        "To": "receiver@gmail.com",
        "From": "sender@gmail.com"
      }
    }],
    ["MT_smtp_quit"]
  ]
}

Example — Read and export all emails:

{
  "mail_thunder": [
    ["MT_imap_later_init"],
    ["MT_imap_select_mailbox"],
    ["MT_imap_output_all_mail_as_file"]
  ]
}

Extending with Custom Commands

You can add your own functions to the scripting executor:

from je_mail_thunder import add_command_to_executor

def my_custom_function(param1, param2):
    print(f"Custom: {param1}, {param2}")

add_command_to_executor({"my_command": my_custom_function})

Then use "my_command" in your JSON action files.

Dynamic Package Loading

Load any installed Python package into the executor at runtime:

{
  "mail_thunder": [
    ["MT_add_package_to_executor", ["os"]],
    ["os_system", ["echo Hello from os.system"]]
  ]
}

This loads all functions, builtins, and classes from the specified package, prefixed with packagename_.

Package gate. Because MT_add_package_to_executor can load os or subprocess, an action file or socket client that names them could run anything. The host program decides what may load:

from je_mail_thunder.utils.executor.action_executor import executor

executor.allow_packages("json")                # these, and their submodules
executor.set_allow_arbitrary_packages(False)   # refuse everything else before importing it

Neither switch is an action command, so an action file cannot open its own gate. A refused package is recorded as an ExecuteActionException in that action's result. Until the host calls either switch, any package still loads but raises a DeprecationWarning: a future release will refuse unlisted packages by default.

Warning: Loading packages like os into the executor can be a security risk. Only load trusted packages and validate all inputs.


Project Templates

MailThunder can scaffold a project with pre-built templates:

from je_mail_thunder import create_project_dir

create_project_dir()  # Creates in current directory
# or
create_project_dir(project_path="/path/to/project", parent_name="MyMailProject")

This creates the following structure:

MyMailProject/
  keyword/
    keyword1.json      # SMTP send email template
    keyword2.json      # IMAP read and export template
    bad_keyword_1.json # Package loading example (security warning)
  executor/
    executor_one_file.py   # Execute a single action file
    executor_folder.py     # Execute all action files in a directory
    executor_bad_file.py   # Bad practice example
  mail/
    config.py              # Provider, attachment policy and audit log of the project
    triggers.py            # register(mail): event handlers and watched folders
    templates/test_report/ # subject.txt, body.txt, body.html, template.json

Command-Line Interface

MailThunder provides a CLI via python -m je_mail_thunder:

# Execute a single JSON action file
python -m je_mail_thunder -e /path/to/action.json

# Execute all JSON action files in a directory
python -m je_mail_thunder -d /path/to/actions/

# Execute a JSON string directly
python -m je_mail_thunder --execute_str '[["MT_smtp_later_init"], ["MT_smtp_quit"]]'

# Create a new project with templates
python -m je_mail_thunder -c /path/to/project
Flag Long Flag Description
-e --execute_file Execute a single JSON action file
-d --execute_dir Execute all JSON action files in a directory
-c --create_project Create a project with templates
--execute_str Execute a JSON string directly

Socket Server

MailThunder includes a TCP socket server that accepts JSON commands remotely:

from je_mail_thunder.utils.socket_server.mail_thunder_socket_server import start_mail_thunder_socket_server

server = start_mail_thunder_socket_server(host="localhost", port=9942)
# Server is now running in a background thread

Sending commands to the server:

import socket
import json

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("localhost", 9942))

# Send an action command
command = json.dumps([["MT_smtp_later_init"], ["MT_smtp_quit"]])
client.send(command.encode("utf-8"))

# Receive response
response = client.recv(8192).decode("utf-8")
print(response)

client.close()

Send "quit_server" to shut down the server.


API Reference

Mail

Mail(provider=None, auth=None, account=None, policy=None, providers=None): the provider-agnostic API. A context manager; nothing connects until the first call.

Method Description
send(message=None, **fields) Check and send a MailMessage, or the message built from fields; returns it
create_draft(message=None, folder=None, **fields) Check a message and store it as a draft; returns its id, or None
get_messages(folder="INBOX", limit=None, unread_only=False, query=None) Iterate over a folder's messages, newest first
get_message(message_id, folder="INBOX") Return one message
delete_message(message_id, folder="INBOX") Delete one message
close() Close the connections; the next call connects again
Name Description
MailMessage(subject, to, cc, bcc, sender, reply_to, text, html, attachments, headers, message_id, date) The provider-independent message; to_dict() gives JSON-ready values
MailAccount(provider="google", auth=None, servers=None) / MailServers(smtp_host, smtp_port, smtp_starttls, imap_host, drafts_folder) Whose mail, on which servers, with which login
MailSender / MailStore / register_provider(name, factory) The provider interfaces and the registry; SMTPProvider and IMAPProvider implement them
mail_instance The Mail() the MT_mail_* commands use
legacy_message(...) / mail_from_wrappers(smtp=None, imap=None, policy=None) Bridges from the wrapper API

SMTPWrapper

Extends smtplib.SMTP_SSL (through SMTPClientMixin). Default host: smtp.gmail.com, default port: 465.

Every SMTP and IMAP connection verifies the server's certificate and host name against the system's trust store (smtplib.SMTP_SSL and imaplib.IMAP4_SSL check neither on their own). A server with a self-signed certificate or a private certificate authority is refused with ssl.SSLCertVerificationError until a CA bundle that holds its certificate is named in the SSL_CERT_FILE (or SSL_CERT_DIR) environment variable.

Method Description
later_init() Log in using config file or environment variables (OAuth2 when configured)
create_message(message_content, message_setting_dict, **kwargs) Create an EmailMessage object
create_message_with_attach(message_content, message_setting_dict, attach_file, use_html=False) Create a MIMEMultipart message with attachment
create_message_and_send(message_content, message_setting_dict, **kwargs) Create and immediately send an email
create_message_with_attach_and_send(message_content, message_setting_dict, attach_file, use_html=False) Create and send an email with attachment
try_to_login_with_env_or_content() Attempt login from config or env vars (OAuth2 settings first, then user and password), returns bool
oauth2_login(user, access_token) Log in with SASL XOAUTH2; raises smtplib.SMTPAuthenticationError when refused
quit() Disconnect and close

Using a different SMTP provider:

from je_mail_thunder import SMTPStartTLSWrapper, SMTPWrapper

# Implicit TLS on another host
smtp = SMTPWrapper(host="smtp.example.com", port=465)
# STARTTLS, e.g. Microsoft 365 (smtp.office365.com:587 is the default)
smtp = SMTPStartTLSWrapper()

SMTPStartTLSWrapper

Extends smtplib.SMTP with the same methods as SMTPWrapper (both mix in SMTPClientMixin). Default host: smtp.office365.com, default port: 587. It upgrades the connection with STARTTLS (certificate and host name verified) before anything else is sent, and refuses a server that does not offer it: the connection is closed and smtplib.SMTPNotSupportedError raised.

IMAPWrapper

Extends imaplib.IMAP4_SSL. Default host: imap.gmail.com. The server's certificate and host name are verified, as for SMTP.

Method Description
later_init() Log in using config file or environment variables (OAuth2 when configured)
select_mailbox(mailbox="INBOX", readonly=False) Select a mailbox, returns bool
search_mailbox(search_str="ALL", charset=None) Search and return raw mail details as list
mail_content_list(search_str="ALL", charset=None) Return parsed mail content as list of dicts
output_all_mail_as_file(search_str="ALL", charset=None) Export all emails to files named by subject; path separators, control characters and : * ? " < > | become _
oauth2_login(user, access_token) Log in with SASL XOAUTH2; raises imaplib.IMAP4.error when refused
quit() Close mailbox and logout

Mail content dict format:

{
    "SUBJECT": "Email subject",
    "FROM": "sender@example.com",
    "TO": "receiver@example.com",
    "BODY": "Email body content..."
}

Executor Functions

Function Description
execute_action(action_list) Execute a list of action commands
execute_files(execute_files_list) Execute multiple JSON action files
add_command_to_executor(command_dict) Add custom functions to the executor
read_action_json(file_path) Read a JSON action file

Utility Functions

Function Description
create_project_dir(project_path, parent_name) Create a project with templates
set_mail_thunder_os_environ(mail_thunder_user, mail_thunder_user_password) Set authentication environment variables
get_mail_thunder_os_environ() Get authentication environment variables
read_output_content() Read mail_thunder_content.json from cwd
write_output_content() Write content data to mail_thunder_content.json
get_dir_files_as_list(path) Get all files in a directory as list
OAuth2Settings(user, provider="google", client_id=None, client_secret=None, refresh_token=None, access_token=None, tenant="common", token_url=None, scope=None) OAuth2 login settings; OAUTH2_PROVIDERS holds the google and microsoft presets
oauth2_token_cache.access_token(settings) A cached access token, refreshed when it is about to expire (refresh_access_token(settings) always asks the endpoint)
resolve_oauth2_settings() The OAuth2 settings from the config file or the environment, or None
xoauth2_string(user, access_token) The SASL XOAUTH2 initial response

Project Structure

MailThunder/
  je_mail_thunder/
    __init__.py              # Public API exports
    __main__.py              # CLI entry point
    attachments/             # Attachment model, AttachmentPolicy and its validator
    auth/                    # Authentication: password, app password, OAuth2, XOAUTH2
    core/                    # Mail (the provider-agnostic API), MailMessage, MailAccount
    providers/               # MailSender / MailStore interfaces, SMTPProvider, IMAPProvider, registry
    templates/               # Mail templates: the template language, MailTemplate, TemplateLoader
    triggers/                # Mail events: filters, dispatcher, polling and IMAP IDLE backends
    monitoring/              # AuditLog and ProviderHealth, fed by the mail events
    studio/                  # MailThunder Studio: the local page, its API and its HTTP server
    smtp/
      smtp_wrapper.py        # SMTPClientMixin, SMTPWrapper, SMTPStartTLSWrapper
    imap/
      imap_wrapper.py        # IMAPWrapper class
    utils/
      exception/             # Custom exceptions and error tags
      executor/              # JSON scripting engine
      file_process/          # File utility functions
      json/                  # JSON file read/write
      json_format/           # JSON formatting
      lazy_instance/         # Lazy clients that connect on first use
      logging/               # Logger instance
      oauth2/                # OAuth2 settings, token refresh, XOAUTH2
      tls/                   # The verifying TLS context of every SMTP / IMAP connection
      package_manager/       # Dynamic package loader
      project/               # Project template scaffolding
      save_mail_user_content/ # Auth config and env var handling
      socket_server/         # TCP socket server
  test/                      # Unit tests
  docs/                      # Sphinx documentation

License

This project is licensed under the MIT License.

Metadata

Release files for je-mail-thunder-dev 0.0.42

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

Source distribution (sdist)

Source distribution for je-mail-thunder-dev 0.0.42
File Size Uploaded
je_mail_thunder_dev-0.0.42.tar.gz 149.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for je-mail-thunder-dev 0.0.42
File Interpreter ABI Platform
je_mail_thunder_dev-0.0.42-py3-none-any.whl Python 3 none any Details

Total release size: 299.5 kB

Release files / je_mail_thunder_dev-0.0.42.tar.gz

Download URL je_mail_thunder_dev-0.0.42.tar.gz
Size 149.9 kB
Tags Source
SHA-256 checksum
How to use checksums
984448578f618d3f7927ab716285241abbfd61ae270ae78e5fe5fdadd08b5a5e
BLAKE2b-256 checksum
How to use checksums
bce96fdb057270b99c8f8ab618217bdd45c6539ad00a45c65589f939ba6221f7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.15

Release files / je_mail_thunder_dev-0.0.42-py3-none-any.whl

Download URL je_mail_thunder_dev-0.0.42-py3-none-any.whl
Size 149.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c14ab95ad01ce3013c25e8f4ee2e6ed7196ed9babed5f2ce4eafe2d205a8a1d3
BLAKE2b-256 checksum
How to use checksums
a809d3e570669baaa6d0e368686e855cdcc6fca430f058c89dbba385b7eb5526
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.15
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