Skip to main content

Exchange Emailer 🐍

Enterprise-Grade Microsoft Exchange Email & Calendar Client with HTML Template Support

PyPI Python Versions Build Status License

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 Variablesstr.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 HierarchyAuthenticationError, 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). Pass template=None to 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 pass template_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):

  1. Programmatic dictionary passed to ExchangeEmailer(config={...})
  2. Explicit config file path passed to ExchangeEmailer(config_path="...")
  3. Auto-discovered exmailer.json / exmailer.yaml in ./ or ~/.config/exmailer/
  4. 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

exmailer-1.3.0.tar.gz (176.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

exmailer-1.3.0-py3-none-any.whl (41.8 kB view details)

Uploaded Python 3

File details

Details for the file exmailer-1.3.0.tar.gz.

File metadata

  • Download URL: exmailer-1.3.0.tar.gz
  • Upload date:
  • Size: 176.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for exmailer-1.3.0.tar.gz
Algorithm Hash digest
SHA256 2919f2cc8ec1ba88e658ddd826166e6be5b8ec343fa1ee3f51d84dc4e9f5fa3e
MD5 62d5d1499550131fe3814a1041ba15b9
BLAKE2b-256 06244919a4aac27d14cc580b681980057cd4f0bc96bfedc438e780992c642a9b

See more details on using hashes here.

Provenance

The following attestation bundles were made for exmailer-1.3.0.tar.gz:

Publisher: pypi_publish.yml on aerosadegh/exmailer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file exmailer-1.3.0-py3-none-any.whl.

File metadata

  • Download URL: exmailer-1.3.0-py3-none-any.whl
  • Upload date:
  • Size: 41.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for exmailer-1.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 be4f5419b7adbce7db5a339ddad510ad42d19a26a8763f9d661198e344abf8ce
MD5 06a91effd19d3f3abd178099d0b69b96
BLAKE2b-256 81bab50107e8258517914a99b0c466aec4345d91d528858ac0ec7c6fcae8917b

See more details on using hashes here.

Provenance

The following attestation bundles were made for exmailer-1.3.0-py3-none-any.whl:

Publisher: pypi_publish.yml on aerosadegh/exmailer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page