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.2.3.tar.gz (166.5 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.2.3-py3-none-any.whl (35.2 kB view details)

Uploaded Python 3

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

Hashes for exmailer-1.2.3.tar.gz
Algorithm Hash digest
SHA256 5a77bb9c66b8183b03069713a043637ba6f66a444758f32f716e95706beaf47e
MD5 119424e53a972ca6c867832bf57b32f2
BLAKE2b-256 db441c8e39668b46a3bdebc1f4fd6e626f404bb26a80ac778378b992651b3e87

See more details on using hashes here.

Provenance

The following attestation bundles were made for exmailer-1.2.3.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.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

Hashes for exmailer-1.2.3-py3-none-any.whl
Algorithm Hash digest
SHA256 c46740479e8917d25d1226294d28c81f4e5bfde189cbe76011316b8f4b833637
MD5 cae7673e8c006d031c815ceda0ea1743
BLAKE2b-256 30176ace629af5dba426f1c4952b1bfcd5dc3fc3704399dfd38cf7deea112393

See more details on using hashes here.

Provenance

The following attestation bundles were made for exmailer-1.2.3-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