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
- 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
Requires Python 3.11+
pip install exmailer
# Optional extras
pip install exmailer[yaml] # YAML config file support
pip install exmailer[dotenv] # load .env filesUsing uv:
uv add exmailerfrom exmailer import ExchangeEmailer, TemplateType
with ExchangeEmailer() as emailer:
# Persian / RTL
emailer.send_email(
subject="گزارش هفتگی",
body="لطفاً گزارش پیوست شده را بررسی نمایید.",
recipients=["[email protected]"],
template=TemplateType.PERSIAN,
attachments=["./report.pdf"],
)
# English / LTR (default)
emailer.send_email(
subject="Weekly Report",
body="Please find attached.",
recipients=["[email protected]"],
template=TemplateType.DEFAULT,
attachments=["./report.pdf"],
)The default template is
TemplateType.DEFAULT(English LTR). Passtemplate=Noneto send without any wrapper.
Both the body and the template receive the same variables:
emailer.send_email(
subject="System Alert",
body="Node {node_id} reported status: {status}",
recipients=["[email protected]"],
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.
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=["[email protected]"],
template="alert",
)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=["[email protected]"],
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.
# Email
python3 -m exmailer \
--subject "Weekly Report" \
--body "Report content here" \
--to [email protected] \
--attachments ./report.pdf
# Read the body from a file
python3 -m exmailer \
--subject "Maintenance Notice" \
--body @notice.html \
--to [email protected] \
--template persian
# Meeting
python3 -m exmailer --meeting \
--subject "Deploy Sync" \
--start "2026-06-25 10:00" --end "2026-06-25 11:00" \
--to [email protected] \
--location "Conf Room A"Full CLI reference: docs/user-guide/cli-reference.md.
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)
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){
"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. |
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=["[email protected]"])
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}")- Python 3.11+
- Access to a Microsoft Exchange Server (EWS endpoint)
- Valid domain credentials
git clone https://github.com/aerosadegh/exmailer.git
cd exmailer
uv sync --all-extras
uv run pytestRuns the full suite (unit tests use mocked EWS — no real Exchange required).
Sadegh Yazdani
GNU General Public License v3 (GPLv3)