Skip to content

ACI0106595: Graph mail pull notifications dispatch @removed entries as onCreate/onModify - #121

Open
e-marchand wants to merge 1 commit into
mainfrom
erm/fix/ACI0106595
Open

e-marchand wants to merge 1 commit into
mainfrom
erm/fix/ACI0106595

Conversation

@e-marchand

@e-marchand e-marchand commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Context

ACI0106595: a user running the NetkitNotification_webinar21R4 demo reports that onDelete is sometimes not received for Office 365 mails.

onDelete is actually received. The problem is that NetKit also sends onCreate and onModify for the same deleted mail, in the same dispatch. The demo then adds back a row for that mail, so it looks as if the delete never arrived.

How mail pull mode works

In pull mode (no endPoint), GraphNotification polls the message delta API as three separate streams, one per change type. Each stream has its own deltaLink:

GET /me/mailFolders/inbox/messages/delta?$select=id&changeType=created&$deltatoken=latest
GET /me/mailFolders/inbox/messages/delta?$select=id&changeType=updated&$deltatoken=latest
GET /me/mailFolders/inbox/messages/delta?$select=id&changeType=deleted&$deltatoken=latest

_pollDeltaForChangeType labelled every entry returned by a stream with that stream's change type. It never looked at the @removed annotation.

What the Graph API actually returns

The changeType filter is not strict. The message delta documentation says:

Delta queries for messages can return change events that don't match the filter conditions specified in the initial request. These include:

  • @removed entries with "reason": "deleted" when an item is deleted or moved from the folder.
  • Read/unread state changes.

These events do not originate from changes to the message itself. They are emitted as part of the folder‑level synchronization process that delta tokens rely on. Delta tracking operates at the collection level, not the message‑level, and therefore these events are not filtered out.

So when a mail is deleted (or moved out of the Inbox, which is what Outlook "Delete" does), the created and/or updated streams can also return an entry like this:

{ "@odata.type": "#microsoft.graph.message", "id": "AAMkAD…", "@removed": { "reason": "deleted" } }

Resulting behaviour (before this fix)

For one deleted mail, a single poll could produce:

stream entry dispatched as
created @removed onCreate ❌
updated @removed onModify ❌
deleted @removed onDelete ✅

_NotificationHelper.dispatchCallbacks always calls them in the order onCreate → onModify → onDelete. In the webinar demo:

  1. onCreate: getMail(id) returns Null because the mail is gone, and an empty row with the same id is still pushed to the list.
  2. onModify: the first matching row is replaced.
  3. onDelete: only the first matching row is removed. The duplicate row stays visible.

It's intermittent because Graph doesn't always put the @removed entry in the created/updated streams.

Fix

_pollDeltaForChangeType

An entry carrying @removed is reported as "deleted", whatever stream returned it:

$item.changeType:=($entry["@removed"]#Null) ? "deleted" : $inChangeType

_pollDeltaUsingChangeTypeStreams

The three streams are merged and then cleaned up:

  • An id reported as deleted in this poll, by any stream, is dispatched only as "deleted". Its created/updated entries are dropped.
  • Duplicate (changeType, id) pairs are dropped. The same @removed entry can come back on all three streams.
  • Only change types with a registered callback are kept, through _shouldDispatchPullChangeType. The calendar knownIds path already does this.

After the fix, the same scenario dispatches a single onDelete.

Scope / not changed

  • Calendar pull mode (_pollDeltaUsingKnownIds) already checks @removed and needs no change. Note that the calendarView delta also returns events added/updated outside the startDateTime/endDateTime window as @removed. The window is fixed when start() is called, from that moment to one year later.
  • Push mode (webhook) is not affected. It relies on the changeType sent by Graph in each notification.
  • Read/unread changes on the created stream are still reported as onCreate. The $select=id delta gives no way to tell them apart from new mails. A follow-up could select receivedDateTime in the initial sync and downgrade created entries received before start() to updated.
  • Graph reports a meeting cancelled by its organizer as an update (isCancelled: true), not a delete. Applications should handle that in onModify. That isn't a NetKit issue.

Testing

  • The project compiles with tool4d ({"success":true,"errors":[]}).
  • Not yet tested against a live Microsoft 365 mailbox. To test: start a mail notifier in pull mode with onCreate/onModify/onDelete, receive a mail, then delete it from Outlook. Only onDelete should be called, with that mail's id.

A matching fix for the demo handlers (skip mails getMail can't find, remove every matching row, handle isCancelled events) is on branch fix/notification-delete for the webinar repo.

…lta streams

Microsoft Graph returns @removed entries (deleted or moved messages) and
read/unread changes on every changeType-filtered message delta stream, not
only on changeType=deleted. Each entry was labelled with its stream type, so
a deleted mail could be dispatched to onCreate and onModify as well as
onDelete.

- _pollDeltaForChangeType: an entry with @removed is reported as "deleted"
  whatever stream returned it
- _pollDeltaUsingChangeTypeStreams: merge the streams, report an item deleted
  during the poll only as "deleted", drop duplicate (id, changeType) pairs and
  keep only change types with a registered callback

ACI0106595

[ai]

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant