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)
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file exmailer-1.2.3.tar.gz.
File metadata
- Download URL: exmailer-1.2.3.tar.gz
- Upload date:
- Size: 166.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5a77bb9c66b8183b03069713a043637ba6f66a444758f32f716e95706beaf47e
|
|
| MD5 |
119424e53a972ca6c867832bf57b32f2
|
|
| BLAKE2b-256 |
db441c8e39668b46a3bdebc1f4fd6e626f404bb26a80ac778378b992651b3e87
|
Provenance
The following attestation bundles were made for exmailer-1.2.3.tar.gz:
Publisher:
pypi_publish.yml on aerosadegh/exmailer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
exmailer-1.2.3.tar.gz -
Subject digest:
5a77bb9c66b8183b03069713a043637ba6f66a444758f32f716e95706beaf47e - Sigstore transparency entry: 2072668373
- Sigstore integration time:
-
Permalink:
aerosadegh/exmailer@6971ea6cfca0f4da3e01d1d17989de05ed527815 -
Branch / Tag:
refs/tags/v1.2.3 - Owner: https://github.com/aerosadegh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi_publish.yml@6971ea6cfca0f4da3e01d1d17989de05ed527815 -
Trigger Event:
push
-
Statement type:
File details
Details for the file exmailer-1.2.3-py3-none-any.whl.
File metadata
- Download URL: exmailer-1.2.3-py3-none-any.whl
- Upload date:
- Size: 35.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c46740479e8917d25d1226294d28c81f4e5bfde189cbe76011316b8f4b833637
|
|
| MD5 |
cae7673e8c006d031c815ceda0ea1743
|
|
| BLAKE2b-256 |
30176ace629af5dba426f1c4952b1bfcd5dc3fc3704399dfd38cf7deea112393
|
Provenance
The following attestation bundles were made for exmailer-1.2.3-py3-none-any.whl:
Publisher:
pypi_publish.yml on aerosadegh/exmailer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
exmailer-1.2.3-py3-none-any.whl -
Subject digest:
c46740479e8917d25d1226294d28c81f4e5bfde189cbe76011316b8f4b833637 - Sigstore transparency entry: 2072668413
- Sigstore integration time:
-
Permalink:
aerosadegh/exmailer@6971ea6cfca0f4da3e01d1d17989de05ed527815 -
Branch / Tag:
refs/tags/v1.2.3 - Owner: https://github.com/aerosadegh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi_publish.yml@6971ea6cfca0f4da3e01d1d17989de05ed527815 -
Trigger Event:
push
-
Statement type: