Core API Reference¶
The exmailer core module provides the primary interface for interacting with Microsoft Exchange.
ExchangeEmailer Class¶
The main controller for email operations. It is designed to be used as a context manager to ensure connections are properly closed.
Methods¶
__init__(self, config_path: str | None = None, config: dict[str, Any] | None = None, verbose: bool = False, log_file: str = "exchange_debug.log") -> None¶
Initializes the emailer.
| Parameter | Type | Default | Description |
|---|---|---|---|
config_path |
str \| None |
None |
Path to a JSON/YAML config file. If omitted, falls back to config or environment-variable auto-discovery. |
config |
dict[str, Any] \| None |
None |
A configuration dictionary. Used when config_path is not provided. |
verbose |
bool |
False |
Enables debug-level logging to the log file. |
log_file |
str |
"exchange_debug.log" |
Path of the debug log file written when verbose=True. |
ExchangeEmailer ¶
ExchangeEmailer(config_path: str | None = None, config: dict[str, Any] | None = None, verbose: bool = False, log_file: str = 'exchange_debug.log')
Send emails via Microsoft Exchange server with flexible HTML template support.
Initialize the Exchange emailer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config_path
|
str | None
|
Path to JSON/YAML configuration file |
None
|
config
|
dict[str, Any] | None
|
Direct configuration dictionary (highest priority) |
None
|
verbose
|
bool
|
Enable verbose logging |
False
|
log_file
|
str
|
Debug log path (only written when verbose is True) |
'exchange_debug.log'
|
Examples:
Method 1: Programmatic config (recommended for scripts)¶
emailer = ExchangeEmailer(config={
"domain": "corp",
"username": "john.doe",
"password": "secret123",
"server": "mail.corp.com",
"email_domain": "corp.com"
})
Method 2: Config file¶
Method 3: Auto-discovery (looks in default locations)¶
Source code in exmailer/core.py
send_email(...) -> bool¶
Sends an email through the Exchange server.
send_email ¶
send_email(subject: str, body: str, recipients: Sequence[str], attachments: Sequence[str] | None = None, cc_recipients: Sequence[str] | None = None, bcc_recipients: Sequence[str] | None = None, template: str | TemplateType | None = TemplateType.DEFAULT, template_vars: dict[str, Any] | None = None, importance: Literal['Low', 'Normal', 'High'] = 'Normal') -> bool
Send an email with optional attachments.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subject
|
str
|
Email subject |
required |
body
|
str
|
Email body content |
required |
recipients
|
Sequence[str]
|
List of recipient email addresses |
required |
attachments
|
Sequence[str] | None
|
List of file paths to attach |
None
|
cc_recipients
|
Sequence[str] | None
|
List of CC recipient email addresses |
None
|
bcc_recipients
|
Sequence[str] | None
|
List of BCC recipient email addresses |
None
|
template
|
str | TemplateType | None
|
Template to use. Options: - TemplateType.PERSIAN: Persian RTL template - TemplateType.DEFAULT: English LTR template (default) - TemplateType.PLAIN: Plain text (no template) - str: Custom template name registered via register_custom_template() - None: Use plain text (no template) |
DEFAULT
|
template_vars
|
dict[str, Any] | None
|
Variables to replace in the body and template (e.g., {"date": "14/08/1404"}) |
None
|
importance
|
Literal['Low', 'Normal', 'High']
|
Message importance flag ("Low", "Normal", or "High") |
'Normal'
|
Returns:
| Type | Description |
|---|---|
bool
|
True if email was sent successfully |
Raises:
| Type | Description |
|---|---|
SendError
|
If the send request to the Exchange server fails. |
Examples:
>>> # Using built-in Persian template
>>> emailer.send_email(
... subject="سلام",
... body="متن پیام",
... recipients=["user@example.com"],
... template=TemplateType.PERSIAN
... )
>>> # Using built-in English template
>>> emailer.send_email(
... subject="Hello",
... body="Message content",
... recipients=["user@example.com"],
... template=TemplateType.DEFAULT
... )
>>> # Using plain text (no template)
>>> emailer.send_email(
... subject="Hello",
... body="Plain message",
... recipients=["user@example.com"],
... template=None
... )
>>> # Using custom template
>>> emailer.send_email(
... subject="Newsletter",
... body="Content here",
... recipients=["user@example.com"],
... template="my_custom_template"
... )
Source code in exmailer/core.py
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 | |
| Parameter | Type | Default | Description |
|---|---|---|---|
subject |
str |
Required | The email subject line. |
body |
str |
Required | The HTML or plain text body. |
recipients |
list[str] |
Required | List of primary recipient addresses. |
cc_recipients |
list[str] \| None |
None |
List of CC addresses. |
bcc_recipients |
list[str] \| None |
None |
List of BCC addresses. |
attachments |
list[str] \| None |
None |
File paths to attach. Missing/empty files are skipped with a logged warning. |
importance |
"Low" \| "Normal" \| "High" |
"Normal" |
Email importance/priority level. |
template |
TemplateType \| str \| None |
TemplateType.DEFAULT |
The template to wrap the body in. |
template_vars |
dict[str, Any] \| None |
None |
Variables for f-string style replacement. |
Note —
template_varssubstitution: Keys intemplate_varsare substituted into the body (and are also available inside the template) using Python'sstr.format(). If your body contains literal{or}characters (e.g. inline CSS) and you passtemplate_vars, escape them as{{and}}— otherwise substitution for the body is skipped entirely and a warning is logged. A placeholder used by the template that has no matching key intemplate_varsraisesSendError.
send_meeting_invite(...) -> MeetingIdentifiers¶
send_meeting_invite ¶
send_meeting_invite(subject: str, start: datetime, end: datetime, body: str = '', required_attendees: Sequence[str] | None = None, optional_attendees: Sequence[str] | None = None, location: str = '', template: str | TemplateType | None = TemplateType.DEFAULT, template_vars: dict[str, Any] | None = None, is_response_requested: bool = True, send_invitations: Literal['all', 'none', 'only_send'] = 'all') -> MeetingIdentifiers
Create a new meeting in the Exchange calendar and send invites.
A stable iCalendar UID is generated client-side before save, so a
durable identifier is always returned — even when EWS itself does not
return an ItemId (send_invitations="only_send" mode).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subject
|
str
|
The subject or title of the meeting. |
required |
start
|
datetime
|
A timezone-aware datetime object for the meeting start. |
required |
end
|
datetime
|
A timezone-aware datetime object for the meeting end. |
required |
body
|
str
|
The HTML or plain text body of the meeting invite. |
''
|
required_attendees
|
Sequence[str] | None
|
Sequence of email addresses for required participants. |
None
|
optional_attendees
|
Sequence[str] | None
|
Sequence of email addresses for optional participants. |
None
|
location
|
str
|
The physical or virtual location of the meeting. |
''
|
template
|
str | TemplateType | None
|
The template to wrap the body in (default: English LTR). |
DEFAULT
|
template_vars
|
dict[str, Any] | None
|
Variables for dynamic injection into the template. |
None
|
is_response_requested
|
bool
|
If True, marks the invite as expecting an
RSVP. Note: this only sets the flag on the item; it does not
control whether the invite is emailed. Use |
True
|
send_invitations
|
Literal['all', 'none', 'only_send']
|
Maps to the EWS
|
'all'
|
Returns:
| Type | Description |
|---|---|
MeetingIdentifiers
|
A |
MeetingIdentifiers
|
populated; store it as the durable primary key in your database. |
Raises:
| Type | Description |
|---|---|
SendError
|
If the meeting creation or network request fails. |
Note
For meeting requests, the SavedItemFolderId in EWS only
controls where the calendar item is stored — you cannot redirect
where the outgoing meeting request email is saved. That is an EWS
limitation, not an ExMailer one.
Source code in exmailer/core.py
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 | |
Return type:
send_meeting_invitereturns aMeetingIdentifiersdataclass, not a bare ID string. Itsuidfield is always populated (client-generated before save), whileitem_idandchange_keyareNoneinsend_invitations="only_send"mode because EWS does not save the item on the organizer's mailbox.
update_meeting_invite(...) -> bool¶
update_meeting_invite ¶
update_meeting_invite(exchange_id: str | MeetingIdentifiers, subject: str, start: datetime, end: datetime, required_attendees: list[str] | None = None, optional_attendees: list[str] | None = None, body: str | None = None, template: str | TemplateType | None = None, template_vars: dict[str, Any] | None = None, location: str | None = None, is_response_requested: bool = True, send_updates_to: Literal['all', 'changed'] = 'changed', save_copy: bool = True) -> bool
Update an existing meeting invitation in Exchange.
Modifies an existing calendar item. If body or template is
omitted, the existing meeting description is preserved without being
overwritten by a blank template.
The two independent aspects of the update dispatch are exposed as
separate parameters (see also EWS SendMeetingInvitationsOrCancellations):
send_updates_to— recipient scope: who receives the update email.save_copy— organizer state: whether a copy of the update is saved to the organizer's Sent Items.
These correspond to the four EWS dispatch modes as follows:
===================== ========== =========================================
send_updates_to save_copy EWS constant
===================== ========== =========================================
"changed" True SendToChangedAndSaveCopy (default)
"changed" False SendOnlyToChanged
"all" True SendToAllAndSaveCopy
"all" False SendOnlyToAll
===================== ========== =========================================
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exchange_id
|
str | MeetingIdentifiers
|
The meeting to update. Accepts either a plain
Exchange item ID string (legacy) or a |
required |
subject
|
str
|
The new subject of the meeting. |
required |
start
|
datetime
|
The new start time (timezone-aware). |
required |
end
|
datetime
|
The new end time (timezone-aware). |
required |
required_attendees
|
list[str] | None
|
New required attendees, or None to keep existing. |
None
|
optional_attendees
|
list[str] | None
|
New optional attendees, or None to keep existing. |
None
|
body
|
str | None
|
The new HTML body. If |
None
|
template
|
str | TemplateType | None
|
The template to use, if |
None
|
template_vars
|
dict[str, Any] | None
|
Variables for body/template substitution. |
None
|
location
|
str | None
|
The new location, or None to keep existing. |
None
|
is_response_requested
|
bool
|
Whether to ask for RSVP. |
True
|
send_updates_to
|
Literal['all', 'changed']
|
|
'changed'
|
save_copy
|
bool
|
If True (default), save a copy of the update to the
organizer's Sent Items. If False, dispatch without leaving a
record. Saving is the safer default — a lost update on the
network becomes unrecoverable when |
True
|
Returns:
| Type | Description |
|---|---|
bool
|
True on success, False if the identifier is empty. |
Raises:
| Type | Description |
|---|---|
SendError
|
If the update fails or the item cannot be located. |
ValueError
|
If the subject is empty or the time range is invalid. |
Source code in exmailer/core.py
523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 | |
Note — body & template interaction: When
bodyis provided, it is rendered together with the giventemplateandtemplate_varsbefore being saved to the invite — exactly as insend_email. Whenbodyis omitted (None), the existing meeting description is preserved.Note — dispatch parameters: The two independent aspects of the update dispatch are separate parameters (they used to be conflated into a single
force_send_allbool).send_updates_tochooses the recipient scope —"changed"(default, low-noise) or"all".save_copychooses the organizer-side record —True(default) saves a copy to Sent Items,Falsedoes not. All four combinations map to the corresponding EWSSendMeetingInvitationsOrCancellationsconstant.Note — accepted identifier types:
exchange_idaccepts either a plain Exchange item ID string (legacy) or aMeetingIdentifiers(recommended). WithMeetingIdentifiers, the item is looked up byitem_idfirst, then falls back to a UID query if the ID is stale — this handles folder moves and mailbox rebalances.
cancel_meeting_invite(...) -> bool¶
cancel_meeting_invite ¶
Cancel an existing meeting and notify attendees.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exchange_id
|
str | MeetingIdentifiers
|
The meeting to cancel. Accepts either a plain Exchange
item ID string (legacy) or a |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the meeting was cancelled, False if the identifier is empty. |
Raises:
| Type | Description |
|---|---|
SendError
|
If the cancellation fails. |
Source code in exmailer/core.py
Accepts the same str | MeetingIdentifiers types as update_meeting_invite, with the same UID fallback lookup.
close() -> None¶
Releases the EWS connection pool. Called automatically when the instance is
used as a context manager (with ExchangeEmailer() as emailer:), which is the
recommended usage.
MeetingIdentifiers¶
MeetingIdentifiers
dataclass
¶
Durable identifiers for a created calendar meeting.
Meetings have multiple identifiers with different lifetimes:
uidis the iCalendar UID (RFC 5545). It is stable across mailbox moves, server upgrades, and syncs to other systems, and is the same value all attendees see. Use this as the primary key in your database.item_idis the EWSItemId. Fast for direct lookups on the organizer's mailbox, but may be invalidated when Exchange rebalances mailboxes or when items move between folders.Nonewhen the meeting was created withsend_invitations="only_send"(EWSSendOnlyToAlldoes not save the item on the organizer's mailbox).change_keyis the EWS optimistic-concurrency token. AlsoNonewhen there is noitem_id.
Store uid as your source of truth; cache item_id / change_key
as a fast path and refresh them from a UID lookup when they go stale.
A frozen dataclass returned from send_meeting_invite. Meetings have multiple identifiers with different lifetimes; use uid as the primary key in your database, and cache item_id/change_key as a fast-lookup shortcut.
| Field | Type | Lifetime | Notes |
|---|---|---|---|
uid |
str |
Durable across mailbox moves, server upgrades, and cross-system sync. Same value all attendees see (iCal UID). | Always populated — client-generated before save in the format exmailer-<uuid4>@<email_domain>. Store this as the DB primary key. |
item_id |
str \| None |
Ephemeral — may be invalidated when Exchange rebalances mailboxes or when items move between folders. | None when the meeting was created with send_invitations="only_send" (SendOnlyToAll does not save on the organizer's mailbox). |
change_key |
str \| None |
EWS optimistic-concurrency token; advances on every write. | None when item_id is None. |
Recommended DB storage pattern:
from datetime import datetime
from zoneinfo import ZoneInfo
from exmailer import ExchangeEmailer, MeetingIdentifiers
tz = ZoneInfo("Asia/Tehran")
with ExchangeEmailer() as emailer:
ids = emailer.send_meeting_invite(
subject="Kickoff",
start=datetime(2026, 6, 25, 10, 0, tzinfo=tz),
end=datetime(2026, 6, 25, 11, 0, tzinfo=tz),
required_attendees=["team@company.com"],
)
db.meetings.insert({
"uid": ids.uid, # required, primary key
"item_id": ids.item_id, # nullable, fast-path cache
"change_key": ids.change_key, # nullable
})
# Later — reconstruct from the DB row; item_id may be stale but UID isn't
row = db.meetings.find_one({"uid": some_uid})
ident = MeetingIdentifiers(uid=row["uid"], item_id=row["item_id"], change_key=row["change_key"])
emailer.update_meeting_invite(
exchange_id=ident,
subject="Kickoff (rescheduled)",
start=datetime(2026, 6, 26, 10, 0, tzinfo=tz),
end=datetime(2026, 6, 26, 11, 0, tzinfo=tz),
)
Limitation: meetings created with send_invitations="only_send" are not saved on the organizer's calendar, so a later UID lookup will fail with SendError. If you need the organizer to be able to update or cancel the meeting later, use "all" (default) or "none".¶
Template Management¶
register_custom_template(name: str, template_string: str) -> None¶
Registers a new HTML template for global use within the application session.