Skip to main content

pyactivesync

A Python client library exclusively for Exchange ActiveSync (EAS) 16.1, implementing enough of MS-ASCMD (the command protocol) and MS-ASWBXML (the binary XML encoding) to talk to a real Exchange server: folder listing, mail sync, item/attachment fetch, sending mail, folder management, moving items, directory search, and push notifications via Ping.

Protocol version

Pyactivesync targets EAS 16.1 exclusively. Every request carries MS-ASProtocolVersion: 16.1; the version is not configurable and the library does not negotiate or fall back to older protocol versions. The server must advertise EAS 16.1 support.

Install

pip install pyactivesync

Usage

from email.message import EmailMessage
from pyactivesync import Client, EmailChange, FolderType, BodyType

with Client(
    server="mail.example.com",
    username=r"CORP\jdoe",       # NTLM-style domain\user, or a plain email -- both work
    password="...",
    device_id="MyApp01",          # caller-provided; persist it yourself for a stable
                                    # device identity across runs -- pyactivesync doesn't
                                    # persist anything to disk on its own
) as client:
    folders = client.list_folders()
    inbox = next(f for f in folders if f.type == FolderType.INBOX)
    drafts = next(f for f in folders if f.type == FolderType.DRAFTS)

    result = client.sync_folder(inbox.id)                              # bootstrap
    result = client.sync_folder(inbox.id, sync_key=result.sync_key)     # Add/Change/Delete

    ping = client.ping([inbox.id, drafts.id], heartbeat=60)
    print(ping.changed_folder_ids)

    for item in result.added:
        print(item.fields.get("Email.Subject"))
        fetched = client.fetch_item(inbox.id, item.server_id, body_type=BodyType.HTML)
        print(fetched.body.data if fetched.body else None)
        for attachment in fetched.attachments:
            data = client.fetch_attachment(attachment.file_reference)

    if result.added:
        # Mutations consume and advance the folder's SyncKey.
        changes = client.apply_email_changes(
            inbox.id,
            result.sync_key,
            [EmailChange(result.added[0].server_id, read=True, flagged=True)],
        )

    msg = EmailMessage()
    msg["To"] = "someone@example.com"
    msg["Subject"] = "hello from pyactivesync"
    msg.set_content("plain text body")
    client.send_mail(msg)

    # Sync Add is EAS 16.1's draft-creation operation. It consumes the
    # Drafts collection's current SyncKey and returns the next key + ServerId.
    draft_sync = client.sync_folder(drafts.id)
    created = client.create_email_draft(drafts.id, draft_sync.sync_key, msg)
    assert created.status == "1" and created.server_id

Use read=False to mark an item unread, flagged=False to clear its follow-up flag, and delete=True to delete it. Pass each returned EmailChangesResult.sync_key into the next mutation or sync request for that folder.

Client is a context manager wrapping one requests.Session -- EAS is stateless HTTP (an auth header plus a PolicyKey header), so unlike an IMAP connection there's no server-side session to tear down; __exit__ just closes the HTTP session. provision() (the device policy handshake) is called lazily on first use if you don't call it explicitly.

Folder and item ids are plain strings ("9", "9:1"), matching EAS's own ServerId format exactly -- there's no synthetic id layer to keep in sync with a local cache.

Command coverage

Command Client method
Provision Client.provision() (also called lazily)
FolderSync Client.list_folders()
Sync Client.sync_folder()
Sync Add Client.create_email_draft() (draft email only)
Sync item mutation Client.apply_email_changes() (read/flag/delete)
GetItemEstimate Client.get_item_estimate()
ItemOperations Fetch (item/body metadata) Client.fetch_item()
ItemOperations Fetch (attachment) Client.fetch_attachment()
SendMail Client.send_mail()
FolderCreate/FolderUpdate/FolderDelete Client.create_folder()/update_folder()/delete_folder()
MoveItems Client.move_item()
Ping Client.ping()
ResolveRecipients Client.resolve_recipients()
Search (GAL) Client.search_gal()
Search (Mailbox, structured) Client.search_mailbox()
Find (GAL/Mailbox free text) Client.find_gal()/Client.find_mailbox()
Settings (Oof get/set) Client.get_oof()/set_oof()

Not implemented: MeetingResponse, ValidateCert, SmartForward/SmartReply. Documented as unimplemented, not silently missing.

Non-goals: an EAS server; Autodiscover (pass a server hostname directly); Calendar/Contacts write operations (read via Sync is supported); NTLM auth (Basic auth only -- requests doesn't do NTLM without an extra dependency); credential storage of any kind (that's the caller's business).

EAS 16.1 supports client-originated Sync Add for draft email only. create_email_draft() therefore expects the Drafts collection and stores the stdlib message as a MIME body, including attachments. It does not provide an IMAP-style arbitrary-folder APPEND: Exchange reports item status 6 for a non-draft email addition. The result always includes the advanced collection SyncKey, the caller-supplied or generated ClientId, and the per-item status; successful additions also include the assigned ServerId. Live EAS 16.1 testing confirmed that Exchange accepts this MIME draft path, including attachment round-trips and read/follow-up flag state.

fetch_item() returns a typed FetchedItem, not a flattened property map. Its ordered bodies and attachments lists preserve repeated EAS containers; body is a convenience alias for the first body. MIME body data is returned as bytes, and each attachment retains its own file reference, display name, size, method, content identifiers, and inline state. Other scalar properties remain available through FetchedItem.fields.

send_mail() accepts an optional client_id= of 1 to 40 characters. When it is omitted, pyactivesync generates a UUID as before. A bridge can persist and supply this identifier before submitting a message, but caller control does not by itself make an ambiguous SendMail safe to retry: duplicate-ClientId behavior is server-dependent, and pyactivesync never automatically retries this non-idempotent command.

search_mailbox() deliberately has no free_text= parameter: full-text Search conditions are known to fail against real Exchange servers with Store.Status=110, a server-side bug in EAS's Search handling rather than a WBXML encoding issue (confirmed by cross-checking the same mailbox's content index through an unrelated protocol, which returns correct results for the same query). Shipping that parameter would just reproduce the failure for every caller.

EAS 16.1's separate Find command is available through find_mailbox() and find_gal(). Both return a FindResult containing the server's status, range, actual number of returned items, reusable search id, and flattened result properties. This is the supported free-text mailbox-search path: unlike the older Search command, Find returned matching mailbox results in live EAS 16.1 testing. FindResult.total is computed from the returned results rather than trusting the server's advisory Find:Total value.

Development

pip install -e '.[dev]'
pytest
ruff check .
mypy pyactivesync tests

Unit tests (WBXML codec against golden byte fixtures, codepage table sanity checks) require no network and run in CI on every push. Live integration tests in tests/test_client_live.py are skipped unless PYACTIVESYNC_TEST_SERVER, PYACTIVESYNC_TEST_USER, and PYACTIVESYNC_TEST_PASSWORD are set, and only ever create/rename/delete objects they create themselves -- pre-existing folders and items are never touched.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

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

pyactivesync-0.13.0-py3-none-any.whl (29.6 kB view details)

Uploaded Python 3

File details

Details for the file pyactivesync-0.13.0-py3-none-any.whl.

File metadata

  • Download URL: pyactivesync-0.13.0-py3-none-any.whl
  • Upload date:
  • Size: 29.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyactivesync-0.13.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cf8d2b2d98868b67c6f714f381ab9aeaa59c8607cfc6ca0ce53df0d04b93a661
MD5 e2f5f46cf69dacf81d9a255a1ba8dfe1
BLAKE2b-256 6c2e677be7c21f276d69c4567a0185d8eba25db7ba82074ca567034239b67615

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyactivesync-0.13.0-py3-none-any.whl:

Publisher: pypi-publish.yml on monperrus/pyactivesync

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

Release history Release notifications | RSS feed

0.17.0

1 file

0.16.0

1 file

0.15.0

1 file

0.14.0

1 file

This release

0.13.0 This release

1 file

0.12.0

1 file

0.11.0

1 file

0.10.0

1 file

0.9.0

1 file

0.8.0

1 file

0.7.0

1 file

0.6.0

1 file

0.5.0

1 file

0.4.0

1 file

0.3.0

1 file

0.2.0

1 file

Supported by

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