Exchange Emailer 🐍
ExMailer is a Python library for interacting with Microsoft Exchange Servers (EWS). It handles NTLM authentication, provides a flexible HTML templating engine with first-class RTL/Persian support, and covers both email and calendar operations.
📖 Full Documentation · 🐛 Issue Tracker
Features
- Microsoft Exchange Integration — NTLM (and BASIC) authentication over EWS
- Email & Calendar — send emails and create, update, or cancel meeting invitations
- HTML Templating — built-in Persian (RTL) and English (LTR) templates, plus a registry for custom layouts
- Template Variables —
str.format()-style substitution in both body and template - Attachments — multiple files with automatic MIME-type detection for common formats
- Flexible Configuration — programmatic dict, JSON/YAML config files, or environment variables (layered)
- Timezone-aware Meetings — configurable IANA timezone with startup validation
- CLI Interface — send emails and schedule meetings from the shell
- Secure by Default — system SSL certificate verification (opt-in bypass with loud warning)
- Typed Exception Hierarchy —
AuthenticationError,ExchangeEmailConnectionError,SendError,AttachmentError,ConfigurationError - Scoped Debug Logging — verbose mode logs to a configurable file, without leaking wire-level credentials
Installation
Requires Python 3.11+
pip install exmailer
# Optional extras
pip install exmailer[yaml] # YAML config file support
pip install exmailer[dotenv] # load .env files
Using uv:
uv add exmailer
Quick Start
Python API
from exmailer import ExchangeEmailer, TemplateType
with ExchangeEmailer() as emailer:
# Persian / RTL
emailer.send_email(
subject="گزارش هفتگی",
body="لطفاً گزارش پیوست شده را بررسی نمایید.",
recipients=["manager@company.com"],
template=TemplateType.PERSIAN,
attachments=["./report.pdf"],
)
# English / LTR (default)
emailer.send_email(
subject="Weekly Report",
body="Please find attached.",
recipients=["colleague@company.com"],
template=TemplateType.DEFAULT,
attachments=["./report.pdf"],
)
The default template is
TemplateType.DEFAULT(English LTR). Passtemplate=Noneto send without any wrapper.
Template Variables
Both the body and the template receive the same variables:
emailer.send_email(
subject="System Alert",
body="Node {node_id} reported status: {status}",
recipients=["admin@company.com"],
template_vars={"node_id": "SRV-01", "status": "CRITICAL"},
)
Escaping literal braces: if your body contains inline CSS or JavaScript (e.g.
body { color: red; }) and you passtemplate_vars, escape braces as{{and}}. Unescaped braces cause body substitution to be skipped (with a logged warning); the email is still sent, but placeholders remain literal.
Custom Templates
from exmailer import ExchangeEmailer, register_custom_template
register_custom_template("alert", """
<div style="border: 1px solid #ccc; padding: 20px;">
<h1 style="color: navy;">Company Alert</h1>
{body}
<hr>
<small>Confidential</small>
</div>
""")
with ExchangeEmailer() as emailer:
emailer.send_email(
subject="Server Down",
body="<p>The main database is unreachable.</p>",
recipients=["devops@company.com"],
template="alert",
)
Calendar Meetings
import datetime
from zoneinfo import ZoneInfo
from exmailer import ExchangeEmailer, TemplateType
tz = ZoneInfo("Asia/Tehran")
with ExchangeEmailer() as emailer:
exchange_id = emailer.send_meeting_invite(
subject="Sprint Planning",
start=datetime.datetime(2026, 6, 25, 10, 0, tzinfo=tz),
end=datetime.datetime(2026, 6, 25, 11, 0, tzinfo=tz),
body="<p>Agenda: backlog grooming and sprint goals.</p>",
required_attendees=["team@company.com"],
location="Conference Room A",
template=TemplateType.PERSIAN,
)
# Reschedule (by default only attendees whose data changed are notified)
emailer.update_meeting_invite(
exchange_id=exchange_id,
subject="Sprint Planning (rescheduled)",
start=datetime.datetime(2026, 6, 25, 14, 0, tzinfo=tz),
end=datetime.datetime(2026, 6, 25, 15, 0, tzinfo=tz),
)
# Cancel
emailer.cancel_meeting_invite(exchange_id=exchange_id)
Naive datetimes are accepted — they are stamped with the timezone from your configuration, or with the system's local timezone if none is configured.
CLI
# Email
python3 -m exmailer \
--subject "Weekly Report" \
--body "Report content here" \
--to recipient@company.com \
--attachments ./report.pdf
# Read the body from a file
python3 -m exmailer \
--subject "Maintenance Notice" \
--body @notice.html \
--to all-staff@company.com \
--template persian
# Meeting
python3 -m exmailer --meeting \
--subject "Deploy Sync" \
--start "2026-06-25 10:00" --end "2026-06-25 11:00" \
--to team@company.com \
--location "Conf Room A"
Full CLI reference: docs/user-guide/cli-reference.md.
Configuration
Settings are resolved with the following priority (highest first):
- Programmatic dictionary passed to
ExchangeEmailer(config={...}) - Explicit config file path passed to
ExchangeEmailer(config_path="...") - Auto-discovered
exmailer.json/exmailer.yamlin./or~/.config/exmailer/ - Environment variables (fill any keys still missing after the layers above)
Environment variables
EXCHANGE_DOMAIN="CORP"
EXCHANGE_USER="jdoe"
EXCHANGE_PASS="secret_password"
EXCHANGE_SERVER="mail.corp.com"
EXCHANGE_EMAIL_DOMAIN="corp.com"
EXCHANGE_AUTH_TYPE="NTLM" # or BASIC (default: NTLM)
EXCHANGE_SAVE_COPY="true" # default: true
EXCHANGE_VERIFY_SSL="true" # default: true — set to false for self-signed servers (warns loudly)
JSON / YAML file
{
"domain": "CORP",
"username": "jdoe",
"password": "secret_password",
"server": "mail.corp.com",
"email_domain": "corp.com",
"auth_type": "NTLM",
"save_copy": true,
"verify_ssl": true,
"exchange_build": [15, 1, 2248, 0],
"timezone": "Asia/Tehran"
}
| Key | Required | Default | Description |
|---|---|---|---|
domain |
Yes | — | Active Directory domain (e.g. CORP) |
username |
Yes | — | Account name without domain (e.g. jdoe) |
password |
Yes | — | Account password |
server |
Yes | — | Exchange server hostname |
email_domain |
Yes | — | Email address domain (e.g. corp.com) |
auth_type |
No | NTLM |
NTLM or BASIC |
save_copy |
No | true |
Save a copy of sent items in Sent Items |
verify_ssl |
No | true |
Disable only for trusted internal networks with self-signed certs; a warning is logged |
exchange_build |
No | [15, 1, 2248, 0] |
Exchange server build. Accepts either [15, 1, 2248, 0] (Exchange 2016) or "15.2.986.0" (Exchange 2019). Invalid values fall back to the default with a warning. |
timezone |
No | system local timezone | IANA timezone (e.g. "Asia/Tehran", "UTC") attached to naive datetimes in meeting methods. Invalid names raise ConfigurationError at startup. |
Exception Handling
All ExMailer errors inherit from ExchangeEmailerError, so you can catch them collectively or individually:
from exmailer import (
ExchangeEmailer,
ExchangeEmailerError,
AuthenticationError,
ExchangeEmailConnectionError,
SendError,
AttachmentError,
ConfigurationError,
)
try:
with ExchangeEmailer(config=config) as emailer:
emailer.send_email(subject="Hi", body="Test", recipients=["you@company.com"])
except AuthenticationError as e:
print(f"Bad credentials: {e}")
except ExchangeEmailConnectionError as e:
print(f"Cannot reach Exchange: {e}")
except SendError as e:
print(f"Delivery failed: {e}")
except ExchangeEmailerError as e:
print(f"Other ExMailer error: {e}")
Requirements
- Python 3.11+
- Access to a Microsoft Exchange Server (EWS endpoint)
- Valid domain credentials
Development
git clone https://github.com/aerosadegh/exmailer.git
cd exmailer
uv sync --all-extras
uv run pytest
Runs the full suite (unit tests use mocked EWS — no real Exchange required).
Author
Sadegh Yazdani
License
GNU General Public License v3 (GPLv3)
Metadata
Release files for exmailer 1.3.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 | |
|---|---|---|---|
| exmailer-1.3.0.tar.gz | 176.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| exmailer-1.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 218.5 kB
Release files / exmailer-1.3.0.tar.gz
| Download URL | exmailer-1.3.0.tar.gz |
|---|---|
| Size | 176.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2919f2cc8ec1ba88e658ddd826166e6be5b8ec343fa1ee3f51d84dc4e9f5fa3e
|
|
BLAKE2b-256 checksum How to use checksums |
06244919a4aac27d14cc580b681980057cd4f0bc96bfedc438e780992c642a9b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
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 Jul 5, 2026.
Transparency logRelease files / exmailer-1.3.0-py3-none-any.whl
| Download URL | exmailer-1.3.0-py3-none-any.whl |
|---|---|
| Size | 41.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
be4f5419b7adbce7db5a339ddad510ad42d19a26a8763f9d661198e344abf8ce
|
|
BLAKE2b-256 checksum How to use checksums |
81bab50107e8258517914a99b0c466aec4345d91d528858ac0ec7c6fcae8917b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
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 Jul 5, 2026.
Transparency log