fix(outlook): dispatch Contacts by item type and harden folder/field assumptions#4147
fix(outlook): dispatch Contacts by item type and harden folder/field assumptions#4147Jan-Kazlouski-elastic wants to merge 8 commits into
Conversation
Relates to elastic/sdh-search#1898 The Exchange Contacts folder can also contain DistributionList (contact group) items, which do not carry the per-contact fields the formatter reads (email_addresses, phone_numbers, company_name, birthday). Accessing those attributes raised 'DistributionList' object has no attribute 'email_addresses' and aborted the entire sync. Read the per-contact fields defensively so a single contact group is indexed by name instead of crashing the sync. Co-authored-by: Cursor <cursoragent@cursor.com>
| # The Contacts folder can also return contact groups (DistributionList), | ||
| # which lack the per-contact fields below. Read them defensively so a | ||
| # single group no longer aborts the whole sync with an AttributeError. |
There was a problem hiding this comment.
Technically this change says different:
If an object returned by contacts does not have some attributes, skip them.
I'm wondering if there are other types of objects that can come here, and will these objects also have display_name?
Also I'm wondering if we should instead check the object type and return formatted documents based on type of incoming object - it will make it easier to comprehend why some objects do not have all fields and which fields are supported per object.
There was a problem hiding this comment.
Good call, agreed on both points — I've reworked this to dispatch on item type instead.
To your questions: a Contacts-folder query in exchangelib returns exactly two item models — Contacts.supported_item_models == (Contact, DistributionList) — and both inherit display_name (plus id/last_modified_time) from the base Item, so the shared fields are always safe. DistributionList just doesn't have the per-contact fields (email_addresses, phone_numbers, company_name, birthday); instead it has members.
Changes in a9fb212:
- Reverted
contact_doc_formatterto strictContactaccess (no moregetattr), so it documents the real Contact schema. - Added a dedicated
distribution_list_doc_formatterthat indexes the group by name and its members' email addresses, typed as"Distribution List". - Dispatch by
isinstancein_fetch_contacts, and the query now fetches the union of both models' fields (incl.members).
The general per-item _format_item guard stays as defense-in-depth for genuinely unexpected shapes, but the contact path is now explicit about which fields belong to which type.
There was a problem hiding this comment.
Update:
Removed _format_item entirely rather than keeping it as defense-in-depth.
…ield assumptions Relates to elastic/sdh-search#1898 Instead of hardening one field at a time, isolate item-shape failures so a single malformed Exchange object no longer aborts the whole sync: - Add a per-item guard (_format_item) around mail, contact, task, calendar, and attachment formatting that skips and logs an item on shape errors (AttributeError/KeyError/ValueError/TypeError) while letting connection-wide errors (transport/SSL/rate-limit/missing mailbox) still fail the sync loudly. - Skip Calendar and Tasks folders on ErrorFolderNotFound, mirroring the existing Contacts and mail-folder handling (shared/resource mailboxes may lack these folders). - Guard the Birthdays calendar branch so a missing start no longer raises on the .split() of a None datetime. - Read the optional LDAP "type" key defensively. Co-authored-by: Cursor <cursoragent@cursor.com>
…getattr Addresses review feedback on #4147: rather than reading contact fields defensively (which silently tolerated any missing attribute), dispatch on the item type returned by the Contacts folder, which exchangelib guarantees is one of Contact or DistributionList. - Restore the strict Contact formatter (documents the real Contact schema). - Add a dedicated distribution_list_doc_formatter that indexes a contact group by name and its members' email addresses, typed as "Distribution List". - Dispatch by isinstance in _fetch_contacts; the general _format_item guard remains as defense-in-depth for genuinely unexpected shapes. - Fetch the union of Contact and DistributionList fields for the folder query. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
| ): | ||
| # Skip instead of aborting if the mailbox has no Calendar folder. | ||
| try: | ||
| folder = account.calendar |
There was a problem hiding this comment.
Sanity check - this does not produce network call, folder.all() does, right?
There was a problem hiding this comment.
Hmm, actually it's the other way around:
account.calendar makes the network call. It is the same as self.root.get_default_folder(Calendar), which calls GetFolder and raises ErrorFolderNotFound.
folder.all() on the other hand is a lazy QuerySet. No network call until it gets iterated, and no ErrorFolderNotFound. So we should be good here.
There was a problem hiding this comment.
Okay, then there is a problem here. exchangelib is not async, and the call to account.calendar is gonna block the whole process until it resolves. Then we'd need to wrap it into asyncio.to_thread or something similar
There was a problem hiding this comment.
This then will have to be done to the methods that do network calls below unfortunately
There was a problem hiding this comment.
Yep. You're right. Done.
| distribution_list=contact, | ||
| timezone=timezone, | ||
| ) | ||
| else: |
There was a problem hiding this comment.
I personally like doing explicit type checks and warn/raise on the case when the type is unexpected.
| """ | ||
| yield content | ||
|
|
||
| def _format_item(self, formatter, item, account): |
There was a problem hiding this comment.
This actually has tendency to actually produce syncs that are successful but return 0 items. I'm not really sure we should do this.
We've tried an approach with this class:
Error Monitor swallows errors up to a certain threshold and then raises an exception if too many errors happen. It also has its downsides.
There was a problem hiding this comment.
Continuing the train of thought from above:
I feel this method attempt to help not raise when formatting objects of unexpected types. E.g. you have an API returning types contact and distribution_list and you format them well, but then for some niche case the customer gets an error because an object of type broadcast_group (imaginary name) is returned and the method breaks.
So instead each formatter method can do type checks and warn or error if type is unexpected.
Warn or error? Not sure. It's all about expectation setting to me.
Warns are good because the sync does not crash
Errors are good because they tell what went wrong immediately, so you will know that something is not being synced.
Because of that Error Monitor will not actually help, but will just hide the problem.
All in all I think it's best to do type checks in formatter methods and warn on unexpected types. I am open to a discussion here, though
There was a problem hiding this comment.
Thanks. Fixed. Now we're raising instead of silently skipping.
…e checks Per review feedback on #4147, the broad _format_item guard swallowed item-shape errors (AttributeError/KeyError/ValueError/TypeError) around every formatter call. That can turn a systematic formatting bug into a "successful" sync that returns 0 items and deletes previously indexed documents. Remove it in favor of explicit, visible handling: - Dispatch _fetch_contacts on item type: Contact -> contact_doc_formatter, DistributionList -> distribution_list_doc_formatter, and warn + skip any unexpected type instead of forcing it through a formatter. - Call the mail/task/calendar/attachment formatters directly again, so a genuine formatting error fails loudly rather than silently emptying the index. The folder guards (ErrorFolderNotFound on Calendar/Tasks), field-level null guards (sender/organizer/attendees/birthday), the optional LDAP type key, and the distribution list formatter remain. Co-authored-by: Cursor <cursoragent@cursor.com>
…skipping The Contacts folder contractually returns only Contact or DistributionList, so an unknown type is a systematic broken-assumption condition, not per-item data variance. Warning and skipping would silently drop a whole category of items; raise a TypeError instead so the sync fails loudly and the gap is fixed in code. Co-authored-by: Cursor <cursoragent@cursor.com>
exchangelib is synchronous, so resolving a distinguished folder (e.g. account.calendar) issues a blocking GetFolder network call. Running it directly in the async get_* methods blocked the event loop until it returned. Wrap the folder resolution in asyncio.to_thread in get_mails, get_calendars, get_child_calendars, get_tasks and get_contacts so it runs off the event loop. ErrorFolderNotFound raised in the thread still propagates out of the await, so the existing skip-and-continue handling is unchanged. Also add tests asserting resolution is offloaded and that the Contacts query requests DistributionList fields (members). Co-authored-by: Cursor <cursoragent@cursor.com>
Related to https://github.com/elastic/sdh-search/issues/1898
Follow-up to #4123. The last month has been a series of "the next malformed item aborts the whole sync" reports, most recently
'DistributionList' object has no attribute 'email_addresses'. This PR fixes that class of failure with explicit, visible handling, and audits the connector for the remaining assumptions of the same shape.Root cause
The Contacts folder returns two item models —
ContactandDistributionList— but the formatter assumed every item was aContactand read per-contact fields off it, so a single contact group aborted the entire sync. The same "unhandled shape aborts everything" gap existed elsewhere: Calendar/Tasks folders that shared and resource mailboxes can legitimately lack, birthday events without a start, and an optional LDAPtypekey.Changes
Contacts formatted by item type.
_fetch_contactsnow dispatches on type: aContactgoes through the strictcontact_doc_formatterand a contact group through a dedicateddistribution_list_doc_formatterthat indexes it by name and its members' email addresses (typed"Distribution List"). The folder query fetches the union of both models' fields (incl.members). The Contacts folder contractually returns only these two models, so any other type raises aTypeErrorrather than being silently skipped — an unknown type is a broken assumption we want surfaced, not a whole category of items quietly dropped. (Thanks @artem-shelkovnikov for the review — explicit type handling over defensivegetattr, and failing on unexpected types.)Note on approach. An earlier revision of this PR wrapped all formatting in a broad
_format_itemguard that caught item-shape errors (AttributeError/KeyError/ValueError/TypeError) and skipped the item. That was dropped: swallowing those errors around every item can turn a systematic bug into a "successful" sync that returns 0 items and deletes previously indexed documents. Instead, known shapes are handled explicitly (type dispatch + targeted field guards) and genuine errors still fail the sync loudly — consistent with the principle established in #4085/#4123.Remaining assumptions found in the audit, now fixed:
ErrorFolderNotFound, mirroring the existing Contacts/mail-folder handling. Shared and resource mailboxes can legitimately lack these folders; previously that aborted the sync (same class as Fix Outlook connector crash on localized Exchange servers (contacts folder) #4065)..split("T")on the formatted datetime, which isNonewhencalendar.startis missing →AttributeError. Now guarded.typekey is read with.get(...)instead ofuser["type"].Test plan
pytest tests/sources/test_outlook.py(65 tests), incl. coverage for:DistributionListtype dispatch and the group formatterTypeErrorErrorFolderNotFoundskips for Calendar / child calendars / Taskstypekeyruff check/ruff format --check