Skip to content

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:

emailer = ExchangeEmailer(config={
    "domain": "corp",
    "username": "john.doe",
    "password": "secret123",
    "server": "mail.corp.com",
    "email_domain": "corp.com"
})

Method 2: Config file

emailer = ExchangeEmailer(config_path="~/.config/exmailer/config.json")

Method 3: Auto-discovery (looks in default locations)

emailer = ExchangeEmailer()
Source code in exmailer/core.py
def __init__(
    self,
    config_path: str | None = None,
    config: dict[str, Any] | None = None,
    verbose: bool = False,
    log_file: str = "exchange_debug.log",
):
    """
    Initialize the Exchange emailer.

    Args:
        config_path: Path to JSON/YAML configuration file
        config: Direct configuration dictionary (highest priority)
        verbose: Enable verbose logging
        log_file: Debug log path (only written when verbose is True)

    Examples:
        # Method 1: Programmatic config (recommended for scripts)
        ```python
        emailer = ExchangeEmailer(config={
            "domain": "corp",
            "username": "john.doe",
            "password": "secret123",
            "server": "mail.corp.com",
            "email_domain": "corp.com"
        })
        ```

        # Method 2: Config file
        ```python
        emailer = ExchangeEmailer(config_path="~/.config/exmailer/config.json")
        ```

        # Method 3: Auto-discovery (looks in default locations)
        ```python
        emailer = ExchangeEmailer()
        ```
    """
    self.verbose = verbose
    self.config = load_config(config_path=config_path, config_dict=config)
    self._patch_exchangelib_adapter()

    if verbose:  # pragma: no cover
        pkg_logger = logging.getLogger("exmailer")
        if not any(
            isinstance(h, logging.FileHandler) and getattr(h, "baseFilename", None) is not None
            for h in pkg_logger.handlers
        ):
            handler = logging.FileHandler(log_file, encoding="utf-8")
            handler.setFormatter(
                logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
            )
            pkg_logger.addHandler(handler)
        pkg_logger.setLevel(logging.DEBUG)

    self.account = self._connect_to_exchange()

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
def send_email(
    self,
    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.

    Args:
        subject: Email subject
        body: Email body content
        recipients: List of recipient email addresses
        attachments: List of file paths to attach
        cc_recipients: List of CC recipient email addresses
        bcc_recipients: List of BCC recipient email addresses
        template: 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)
        template_vars: Variables to replace in the body and template
            (e.g., {"date": "14/08/1404"})
        importance: Message importance flag ("Low", "Normal", or "High")

    Returns:
        True if email was sent successfully

    Raises:
        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"
        ... )
    """
    try:
        # Get the appropriate template
        ## Apply template variables if provided
        formatted_body = render_body(body, template, template_vars)

        # Create message
        msg = Message(
            account=self.account,
            subject=subject,
            body=HTMLBody(formatted_body),
            to_recipients=[Mailbox(email_address=email) for email in recipients],
            cc_recipients=(
                [Mailbox(email_address=e) for e in cc_recipients] if cc_recipients else []
            ),
            bcc_recipients=(
                [Mailbox(email_address=e) for e in bcc_recipients] if bcc_recipients else []
            ),
            importance=importance,
        )

        # Process attachments
        if attachments:
            validated_attachments = validate_attachments(attachments)
            for attachment in validated_attachments:
                try:
                    with open(attachment["path"], "rb") as f:
                        content = f.read()

                    file_attachment = FileAttachment(
                        name=attachment["name"],
                        content=content,
                        content_type=attachment["content_type"],
                    )
                    msg.attach(file_attachment)
                    logger.info(
                        f"Attached: {attachment['name']} ({attachment['size'] // 1024} KB)"
                    )

                except Exception as e:  # pragma: no cover
                    logger.error(f"Failed to attach {attachment['path']}: {e!s}")
                    if self.verbose:
                        print(f"⚠️ Failed to attach {attachment['path']}: {e!s}")

        # Send email
        save_copy = self.config.get("save_copy", True)

        # Per Microsoft's CreateItem docs, MessageDisposition=SendOnly
        # (save_copy=False) is not supported with delegate access — EWS
        # cannot resolve a destination folder in that combination and
        # returns an opaque error. Force save_copy=True and warn loudly.
        # Callers who need SendOnly on a delegate mailbox must use the
        # MS-recommended workaround: SaveOnly + SendItem (not yet exposed here).
        # Ref: https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem
        if not save_copy and self.account.access_type == DELEGATE:
            logger.warning(
                "save_copy=False (EWS SendOnly) is not supported with "
                "delegate access; forcing save_copy=True. See Microsoft's "
                "CreateItem documentation."
            )
            save_copy = True

        try:
            msg.send(save_copy=save_copy)
        except Exception as e:
            raise SendError(f"Failed to send email: {e!s}") from e  # ← Wrap exception

        logger.info(f"✅ Email sent successfully to {', '.join(recipients)}")
        if self.verbose:  # pragma: no cover
            print(f"✅ Email sent successfully to {', '.join(recipients)}")

        return True

    except Exception as e:
        logger.error(f"❌ Failed to send email: {e!s}")
        if self.verbose:  # pragma: no cover
            print(f"❌ Failed to send email: {e!s}")
        raise
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_vars substitution: Keys in template_vars are substituted into the body (and are also available inside the template) using Python's str.format(). If your body contains literal { or } characters (e.g. inline CSS) and you pass template_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 in template_vars raises SendError.

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 send_invitations for that.

True
send_invitations Literal['all', 'none', 'only_send']

Maps to the EWS SendMeetingInvitations attribute.

  • "all" (default): SendToAllAndSaveCopy — email everyone, save a copy on the organizer's calendar.
  • "none": SendToNone — save silently, no emails sent (useful for automated calendar provisioning).
  • "only_send": SendOnlyToAll — email everyone, do NOT save a copy. Returned item_id will be None — per EWS spec — but uid is still populated so you can correlate the meeting later.
'all'

Returns:

Type Description
MeetingIdentifiers

A MeetingIdentifiers dataclass. Its uid field is always

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
def send_meeting_invite(
    self,
    subject: str,
    start: datetime.datetime,
    end: datetime.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).

    Args:
        subject: The subject or title of the meeting.
        start: A timezone-aware datetime object for the meeting start.
        end: A timezone-aware datetime object for the meeting end.
        body: The HTML or plain text body of the meeting invite.
        required_attendees: Sequence of email addresses for required participants.
        optional_attendees: Sequence of email addresses for optional participants.
        location: The physical or virtual location of the meeting.
        template: The template to wrap the body in (default: English LTR).
        template_vars: Variables for dynamic injection into the template.
        is_response_requested: 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 ``send_invitations``
            for that.
        send_invitations: Maps to the EWS ``SendMeetingInvitations`` attribute.

            - ``"all"`` (default): ``SendToAllAndSaveCopy`` — email everyone,
              save a copy on the organizer's calendar.
            - ``"none"``: ``SendToNone`` — save silently, no emails sent
              (useful for automated calendar provisioning).
            - ``"only_send"``: ``SendOnlyToAll`` — email everyone, do NOT
              save a copy. Returned ``item_id`` will be ``None`` — per EWS
              spec — but ``uid`` is still populated so you can correlate
              the meeting later.

    Returns:
        A ``MeetingIdentifiers`` dataclass. Its ``uid`` field is **always**
        populated; store it as the durable primary key in your database.

    Raises:
        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.
    """
    try:
        formatted_body = render_body(body, template, template_vars)

        # Ensure timezones are attached before sending to EWS
        start_dt = ensure_timezone(start, self.config.get("timezone"))
        end_dt = ensure_timezone(end, self.config.get("timezone"))

        # Generate a UID up-front so we own it in every dispatch mode.
        meeting_uid = self._generate_meeting_uid()

        item = CalendarItem(
            account=self.account,
            folder=self.account.calendar,
            start=start_dt,
            end=end_dt,
            subject=subject,
            body=HTMLBody(formatted_body),
            location=location,
            required_attendees=required_attendees or [],
            optional_attendees=optional_attendees or [],
            is_response_requested=is_response_requested,
            uid=meeting_uid,  # Client-generated UID; server accepts and echoes it.
        )

        # Map the user-facing option to the EWS SendMeetingInvitations enum.
        # Per MS docs, SendOnlyToAll does not return an item ID.
        dispatch_map = {
            "all": (SEND_TO_ALL_AND_SAVE_COPY, True),
            "none": (SEND_TO_NONE, True),
            "only_send": (SEND_ONLY_TO_ALL, False),
        }
        if send_invitations not in dispatch_map:
            raise ValueError(
                f"send_invitations must be one of {list(dispatch_map)}, "
                f"got {send_invitations!r}"
            )
        dispatch_flag, saves_to_calendar = dispatch_map[send_invitations]

        item.save(send_meeting_invitations=dispatch_flag)
        logger.info(
            f"✅ Meeting '{subject}' created (uid={meeting_uid}, mode={dispatch_flag})."
        )

        # Extract item_id + change_key when the item was saved to the
        # organizer's calendar. exchangelib exposes them as string
        # properties on the item (item.id, item.changekey). In only_send
        # mode (SendOnlyToAll), the item is not saved and item.id is None.
        item_id: str | None = None
        change_key: str | None = None
        if saves_to_calendar:
            item_id = item.id
            change_key = item.changekey

        return MeetingIdentifiers(
            uid=meeting_uid,
            item_id=item_id,
            change_key=change_key,
        )

    except Exception as e:
        logger.error(f"❌ Failed to create meeting invite: {e!s}")
        raise SendError(f"Failed to create meeting: {e!s}") from e

Return type: send_meeting_invite returns a MeetingIdentifiers dataclass, not a bare ID string. Its uid field is always populated (client-generated before save), while item_id and change_key are None in send_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_torecipient scope: who receives the update email.
  • save_copyorganizer 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 MeetingIdentifiers (recommended — tries item_id first, then falls back to UID lookup if the ID is stale).

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, preserves the existing body.

None
template str | TemplateType | None

The template to use, if body is provided.

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" (default) sends only to attendees whose data changed, minimizing inbox noise. "all" sends the update to every attendee regardless.

'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 save_copy=False.

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
def update_meeting_invite(
    self,
    exchange_id: str | MeetingIdentifiers,
    subject: str,
    start: datetime.datetime,
    end: datetime.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
    =====================  ==========  =========================================

    Args:
        exchange_id: The meeting to update. Accepts either a plain
            Exchange item ID string (legacy) or a ``MeetingIdentifiers``
            (recommended — tries ``item_id`` first, then falls back to
            UID lookup if the ID is stale).
        subject: The new subject of the meeting.
        start: The new start time (timezone-aware).
        end: The new end time (timezone-aware).
        required_attendees: New required attendees, or None to keep existing.
        optional_attendees: New optional attendees, or None to keep existing.
        body: The new HTML body. If ``None``, preserves the existing body.
        template: The template to use, if ``body`` is provided.
        template_vars: Variables for body/template substitution.
        location: The new location, or None to keep existing.
        is_response_requested: Whether to ask for RSVP.
        send_updates_to: ``"changed"`` (default) sends only to attendees
            whose data changed, minimizing inbox noise. ``"all"`` sends
            the update to every attendee regardless.
        save_copy: 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 ``save_copy=False``.

    Returns:
        True on success, False if the identifier is empty.

    Raises:
        SendError: If the update fails or the item cannot be located.
        ValueError: If the subject is empty or the time range is invalid.
    """
    # Empty-identifier guard — preserves legacy string behavior.
    if isinstance(exchange_id, str):
        if not exchange_id:
            logger.warning("Update aborted: `exchange_id` is empty or None.")
            return False
    elif not exchange_id.uid and not exchange_id.item_id:
        raise ValueError("MeetingIdentifiers must have at least one of uid or item_id set.")

    if not subject or not subject.strip():
        raise ValueError("Meeting subject cannot be empty or solely whitespace.")

    if start > end:
        raise ValueError("Meeting start time cannot be after the end time.")

    # Resolve the dispatch flag from the two orthogonal knobs. The map is
    # keyed on (recipient_scope, save_copy) so each concern is visible.
    # This replaces the earlier ternary that overloaded a single bool with
    # both meanings, hiding the (all, no-save) and (changed, save) modes.
    dispatch_map: dict[tuple[str, bool], str] = {
        ("changed", True): SEND_TO_CHANGED_AND_SAVE_COPY,
        ("changed", False): SEND_ONLY_TO_CHANGED,
        ("all", True): SEND_TO_ALL_AND_SAVE_COPY,
        ("all", False): SEND_ONLY_TO_ALL,
    }
    if send_updates_to not in ("all", "changed"):
        raise ValueError(f"send_updates_to must be 'all' or 'changed', got {send_updates_to!r}")
    send_flag = dispatch_map[(send_updates_to, save_copy)]

    try:
        item = self._fetch_calendar_item(exchange_id)

        item.subject = subject
        item.start = ensure_timezone(start, self.config.get("timezone"))
        item.end = ensure_timezone(end, self.config.get("timezone"))
        item.is_response_requested = is_response_requested

        if location is not None:
            item.location = location

        if body is not None:
            item.body = HTMLBody(render_body(body, template, template_vars))

        if required_attendees is not None:
            item.required_attendees = required_attendees

        if optional_attendees is not None:
            item.optional_attendees = optional_attendees

        item.save(send_meeting_invitations=send_flag)
        logger.info(f"✅ Meeting '{subject}' updated successfully. Mode: {send_flag}")
        return True

    except (ValueError, SendError):
        raise
    except Exception as e:
        logger.error(f"Failed to update meeting invite {exchange_id}: {e}", exc_info=True)
        raise SendError(f"Failed to update meeting invite {exchange_id}: {e}") from e

Note — body & template interaction: When body is provided, it is rendered together with the given template and template_vars before being saved to the invite — exactly as in send_email. When body is 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_all bool). send_updates_to chooses the recipient scope"changed" (default, low-noise) or "all". save_copy chooses the organizer-side recordTrue (default) saves a copy to Sent Items, False does not. All four combinations map to the corresponding EWS SendMeetingInvitationsOrCancellations constant.

Note — accepted identifier types: exchange_id accepts either a plain Exchange item ID string (legacy) or a MeetingIdentifiers (recommended). With MeetingIdentifiers, the item is looked up by item_id first, 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_meeting_invite(exchange_id: str | MeetingIdentifiers) -> bool

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 MeetingIdentifiers (recommended, with UID fallback if the item_id is stale).

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
def cancel_meeting_invite(self, exchange_id: str | MeetingIdentifiers) -> bool:
    """Cancel an existing meeting and notify attendees.

    Args:
        exchange_id: The meeting to cancel. Accepts either a plain Exchange
            item ID string (legacy) or a ``MeetingIdentifiers`` (recommended,
            with UID fallback if the item_id is stale).

    Returns:
        True if the meeting was cancelled, False if the identifier is empty.

    Raises:
        SendError: If the cancellation fails.
    """
    # Empty-identifier guard, preserving legacy string behavior.
    if isinstance(exchange_id, str):
        if not exchange_id:
            logger.warning("Cancellation aborted: `exchange_id` is empty or None.")
            return False
    elif not exchange_id.uid and not exchange_id.item_id:
        logger.warning("Cancellation aborted: MeetingIdentifiers has neither uid nor item_id.")
        return False

    try:
        item = self._fetch_calendar_item(exchange_id)
        item.delete(send_meeting_cancellations=SEND_TO_ALL_AND_SAVE_COPY)
        logger.info(f"✅ Meeting {exchange_id} canceled successfully.")
        return True

    except Exception as e:
        logger.error(f"❌ Failed to cancel meeting {exchange_id}: {e!s}")
        raise SendError(f"Failed to cancel meeting: {e!s}") from e

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

MeetingIdentifiers(uid: str, item_id: str | None = None, change_key: str | None = None)

Durable identifiers for a created calendar meeting.

Meetings have multiple identifiers with different lifetimes:

  • uid is 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_id is the EWS ItemId. Fast for direct lookups on the organizer's mailbox, but may be invalidated when Exchange rebalances mailboxes or when items move between folders. None when the meeting was created with send_invitations="only_send" (EWS SendOnlyToAll does not save the item on the organizer's mailbox).
  • change_key is the EWS optimistic-concurrency token. Also None when there is no item_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.