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 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). 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)

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)

Source distribution for exmailer 1.3.0
File Size Uploaded
exmailer-1.3.0.tar.gz 176.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for exmailer 1.3.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

1.3.0 This release

2 release files

1.2.3

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.5

2 release files

1.0.4

2 release files

1.0.3

2 release files

1.0.2

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page