Exchange (EWS)
Crate: adapter-ews · Capabilities: calendars, tasks, contacts
Exchange Web Services is the older SOAP/XML API for on-premises Exchange and older Microsoft 365 tenants — used where Graph isn’t available.
Protocol
Section titled “Protocol”SOAP over HTTPS. Requests are XML envelopes (soap.rs builds them,
mapping.rs parses the responses):
- Autodiscover: the endpoint can be discovered from an email address.
- Sync:
SyncFolderItemsreturns changes for a folder with a sync state token. The adapter first does an id-only probe to learn the change counts cheaply, then fetches item bodies withGetItem.
Authentication
Section titled “Authentication”Basic auth (username/password) over TLS, or NTLM depending on the server. The endpoint is discovered or user-supplied.
Quirks
Section titled “Quirks”- Folder-complete events. EWS keeps a per-folder in-memory view of
every item it has seen, so its event read is folder-complete: it
emits the full set with
ChangeSet.complete = true, and the host stores an unbounded cache window. This is what fixed a class of “event in a new month doesn’t appear” bugs — the sync cookie is folder-wide, so a range-filtered emit would miss unchanged items in newly-viewed ranges. - Recurring masters always pass the folder filter; recurrence shapes
are enriched via
GetItem. - ChangeKey churn. An edited item keeps its item id but rotates the
ChangeKeyembedded in the composite id, so the cache purges the whole native group before re-inserting (avoids stale duplicates). - An exception carries its own content. A changed occurrence is an item of
its own on the server, with its own subject, body, location and reminder. The
read used to build its row from the SERIES and fetch the exception’s item
only to keep one boolean from it (
cancelled), so the occurrence appeared under the series’ subject and a save wrote that subject back (decision 58a, measured in live round 5). The per-occurrenceGetItemnow keeps the item (ModifiedOccurrence::own) andmapping::override_eventbuilds the row from it through the sameto_eventevery other row goes through; only what the SERIES owns — the calendar, the colour, the slot in the pattern — still comes from the master. An item that cannot be read, or that answers for another slot, leaves the row inheriting the series’ content and says so in the log: an inherited value is wrong, a guessed one would be worse. - An all-day exception names its slot by its day. EWS reports an
all-day item’s instants, an occurrence’s
OriginalStartamong them, as midnight in the item’s own zone — mostly: after Aperio’s own write of an Outlook item (UTC midnights, no zone) Exchange keeps the old zone’s midnights and labels them UTC (live round 3). The read samples the day 13:45 into it, in the zone Exchange names — the start zone, read as the series’ zone is, UTC for a series made without one (all_day_zone, decision 217) — which is exact for a midnight that zone names, and the intended day for a label off by any offset in (−10:15, +13:45], New Zealand’s summer included. It re-anchors the start, the series’ exceptions and the override id’s slot to this device’s local midnight of that day (all_day_anchor,override_slot, decision 215). Where Exchange names no zone the adapter can read, the sample is taken in UTC with the same window. An exception’s own copy, which an update compares with, is read in its series’ zone like its row. With the raw instant, a device more than twelve hours from the mailbox’s zone read the neighbouring day: the views hid it, and the reminders, which honour single changes since decision 214, silenced it. Writing finds the exception by either spelling (names_override), so an id minted before still resolves. These slots are this device’s local midnights, so the events token names the device’s zone as well as the zone translation: a device that moved reads the folder again, and its ids follow (decision 216). Deleting one day of an all-day series reads the server’s slot in the series’ zone before it compares, so a device far from the mailbox’s zone finds the day it names, and a neighbour, a whole day away, never comes within the tolerance. Without a zone it compares the raw instants as before and aborts rather than trust a sampled day. - Exceptions keep their rule field. Editing one changed occurrence writes
to the exception’s own item, which the override id finds from the series
head on every write. That update never sends
DeleteItemField calendar:Recurrence: Exchange refuses it on an exception (ErrorInvalidPropertyDelete) and fails the whole update. The returned event keeps the override id. - An exception cannot pass its neighbours. Exchange refuses to move an
exception onto or past a neighbouring occurrence of its series
(
ErrorOccurrenceCrossingBoundary; Outlook has the same rule). The adapter then detaches it the way Aperio moves any unchanged occurrence on its own: it creates a single at the new time and deletes the exception’s item without a cancellation. The event that comes back is the new single. If the delete fails, the move stands and the failure is logged; a duplicate may remain. - Occurrences are found by their slot. Skipping one occurrence
(
add_event_exdate) probes the series’InstanceIndexes withGetItem. It matches an exception by itsOriginalStart, the slot it fills, not by itsStart, which moves when the exception is moved. - The organizer is listed as an attendee. Exchange puts the organizer
into
RequiredAttendeeswithResponseType“Organizer”; for an appointment made in Outlook it is the only row. The adapter drops that row by its flag (cal_core::attendee::people_from_read), so it also goes when<t:Organizer>names the organizer by an Exchange-internal (EX) address.MyResponseType“Organizer” says the mailbox organizes the item; any other answer makes the eventorganized_elsewhere, even without an organizer address, and only the organizer notifies. “Unknown”, or noMyResponseType, is no answer: then an item with an organizer isorganized_elsewhereand one without is the mailbox’s own. An update whose invitees did not change (keep_attendees) sends nocalendar:RequiredAttendees, so Exchange keeps its own list, the organizer’s row included. One that removed every invitee (clear_attendees) deletesRequiredAttendeesandOptionalAttendees, and with notifying on, Exchange is asked to send the removed a cancellation (not yet measured live). Without this an appointment from Outlook failed to save withErrorInvalidRecipients: Aperio asked to notify, and the only recipient was the sender.
Time zones
Section titled “Time zones”EWS names a recurring series’ zone by a Windows id (W. Europe Standard Time); the rest of Aperio uses tzdata names. The translation lives in
windows_tz.rs, over a table generated from the Unicode CLDR
windowsZones.xml:
- The data is pinned.
crates/adapter-ews/cldr/holds the XML, its Unicode License V3 andSOURCE(release tag, publication date, URLs, sha256).cargo xtask windows-zonesgeneratessrc/windows_tz/windows_zones.rsfrom it; CI runs it with--check. - Reading. A series created without a zone comes back with the start zone
Greenwich Standard Timeand the end zonetzone://Microsoft/Utc; that end zone means no zone. Otherwise the start zone’s id reads as the zone of its default (“001”) row, in tzdata’s canonical spelling (India Standard Time→Asia/Kolkata). Exchange keeps one id for a group of cities on one clock, so a Vienna series reads back as Berlin.UTC, and an id the table does not know (custom definitions, registry-only ids), mean no zone; the series repeats in UTC. - Ids the server knows. A server refuses a save naming an id it does not
know (
ErrorTimeZone, and the whole save fails); Exchange 2019 does not knowSao Tome Standard Time. The adapter asks each server once (GetServerTimeZones) and writes only ids it knows. An unknown one goes out without a zone and is logged. An update without a zone keeps the series’ old zone in Exchange, so an update to an unknown zone leaves the old one there. If the server cannot be asked, or its answer cannot be read, the CLDR ids are written and the next save asks again. - Writing. An all-day series writes no zone (
cal_core::written_series_zone). A zone makes Exchange move an all-day series to that zone’s midnights and stretch it over more days (live test). Any other series’ stored zone goes through the core’s rule for zones (series_clock_zone, thencanonical_zone): no zone, a UTC name or an unknown name writes no zone; any spelling of a zone writes that zone’s id. - Zones Exchange cannot store are written without a zone: CLDR has no id
for them (
Antarctica/Troll), or the id runs another clock in the five years after the pinned release (America/Scoresbysund,Antarctica/Casey,Antarctica/Vostok). The generator’s clock guard finds these; the table lists them. - Updating CLDR. Take
windowsZones.xmlandLICENSEfrom one CLDR release tag, write that tag, its publication date, both URLs and the XML’s sha256 intoSOURCE, runcargo xtask windows-zones, commit. - Cached events follow the translation. The events token the host keeps
is
zt-{translation}:{cookie}, where the translation is the generatedTABLE_ID(hashed from the table’s rows) plusREAD_RULEinwindows_tz.rs. BumpREAD_RULEwhenever an id becomes a series’ zone differently without the table changing. A delta whose token names another translation emits every cached item again, without re-reading Exchange, so no view keeps a zone the old translation read. - Cached items follow the parser. A zone that needs a field older builds
did not read (the end zone) cannot be re-read from the cache. The folder
state records
ITEM_PARSER(api.rs); a state from an older parser is dropped and the folder drained again from scratch, once. Bump it whenever the item parser starts reading a field the read rule uses.
Testing
Section titled “Testing”mockito (or fixture XML) for the SOAP envelopes. Tests cover the
id-only folder-sync probe/drain, the count parsing, and the
folder-complete emit. The zone translation is pinned by
fixtures/windowsZones.json, whose rows are named in the test; the adapter
tests ask the mocked server for its zones once and drain an older parser’s
state again. Live testing
needs an Exchange/365 mailbox that still exposes EWS. The ignored tests
live_test_requests and live_test_requests_round_3 write the requests of
the live zone tests as Aperio builds them, into the directory
APERIO_LIVE_TEST_DIR names; each file’s header comment says which requests
are not Aperio’s rule yet.