-
Notifications
You must be signed in to change notification settings - Fork 0
phase 3 draft creation
Objective: Give a Copilot Studio agent the mailbox-routing capabilities it needs after classification - tag a message with categories, move it to a department folder, track changes via delta query, read attachments, and stash routing metadata directly on the Graph message via extended properties. Reply drafting and sending (createReply/createReplyAll/send) were already delivered in Phase 1 - see the "Already Available" section below.
Harness: Same Flask backend service (backend-service/app.py) and
SharedMailboxConnector custom connector as Phase 1 - this phase only adds
new routes/operations to both, no new infrastructure.
These operations already exist and are not duplicated by this phase:
| Capability | Connector Operation | Backend Route |
|---|---|---|
| Create a reply draft (or reply-all) |
CreateDraft (replyAll: true/false) |
POST /api/mailbox/drafts |
| Update a draft before sending | UpdateDraft |
PATCH /api/mailbox/drafts/{draftId} |
| Send an existing draft | SendDraftMessage |
POST /api/mailbox/drafts/{draftId}/send |
CreateDraft intentionally stays a single operation with a replyAll
boolean flag rather than two separate CreateReply/CreateReplyAll
operations - both call the same Graph createReply/createReplyAll action
and PATCH pattern, so splitting them would just duplicate the same backend
logic under two operationIds with no behavioral difference.
Sets (replaces, not merges) a message's Outlook categories - e.g. tag it with a department name right after classification, so the routing decision is visible directly in Outlook, not just in Dataverse's Classification Audit table (Phase 2).
Backend: PATCH /api/mailbox/messages/{messageId}/categories
Graph mapping: PATCH /users/{mailbox}/messages/{id} with body
{"categories": ["Finance", "Urgent"]}
$Body = @{ mailboxAddress = $MailboxAddress; categories = @("Finance", "Urgent") } | ConvertTo-Json
Invoke-RestMethod -Uri "$BackendUrl/api/mailbox/messages/$MessageId/categories" `
-Method Patch -Body $Body -ContentType "application/json"Expected output: { "messageId": "...", "categories": ["Finance", "Urgent"] }
Moves a message to a different mail folder - e.g. route it to a department subfolder after classification.
Backend: POST /api/mailbox/messages/{messageId}/move
Graph mapping: POST /users/{mailbox}/messages/{id}/move with body
{"destinationId": "<folderId or wellKnownName>"}
Gotcha: Graph assigns the moved message a new id in the destination folder. The original
messageIdstops resolving once the move completes - usemovedMessageIdfrom the response for any further operation (categories, extended properties, attachments) on this message.
$Body = @{ mailboxAddress = $MailboxAddress; destinationId = "archive" } | ConvertTo-Json
Invoke-RestMethod -Uri "$BackendUrl/api/mailbox/messages/$MessageId/move" `
-Method Post -Body $Body -ContentType "application/json"Expected output: { "movedMessageId": "...", "destinationId": "archive" }
Graph delta query for change tracking on the Inbox (new/changed/deleted messages since a checkpoint) - for incremental sync use cases.
Backend: GET /api/mailbox/messages/delta
Graph mapping: GET /users/{mailbox}/mailFolders/inbox/messages/delta
This is a new, separate capability from the existing NewMessageReceived
polling trigger (/api/mailbox/messages/poll), which filters on
receivedDateTime gt {since} - a simple timestamp comparison that can miss
edits/deletes and is vulnerable to clock skew. GetMessagesDelta instead
uses Graph's real delta-token protocol. Neither replaces the other; use
NewMessageReceived for the autonomous-agent trigger and GetMessagesDelta
for an explicit incremental-sync call from a topic.
# First call - starts a fresh delta over the Inbox
$Response = Invoke-RestMethod -Uri "$BackendUrl/api/mailbox/messages/delta?mailboxAddress=$MailboxAddress" -Method Get
$Response.value | Select-Object id, subject
# Persist whichever link came back...
$DeltaLink = $Response.'@odata.deltaLink'
if (-not $DeltaLink) { $DeltaLink = $Response.'@odata.nextLink' }
# ...and pass it back on the next call to resume from that checkpoint
$Next = Invoke-RestMethod -Uri "$BackendUrl/api/mailbox/messages/delta?mailboxAddress=$MailboxAddress&deltaLink=$([uri]::EscapeDataString($DeltaLink))" -Method GetExpected output: Graph's raw delta payload - value (array of changed
messages) plus @odata.nextLink (more pages to fetch) or @odata.deltaLink
(caught up - save this as the next checkpoint).
Reads a message's attachments - e.g. to inspect an invoice PDF as part of routing/classification.
Backend: GET /api/mailbox/messages/{messageId}/attachments (list,
metadata only) and GET /api/mailbox/messages/{messageId}/attachments/{attachmentId}
(single attachment, includes base64 contentBytes for file attachments).
Graph mapping: GET /users/{mailbox}/messages/{id}/attachments and
GET /users/{mailbox}/messages/{id}/attachments/{attachmentId}.
$Attachments = Invoke-RestMethod -Uri "$BackendUrl/api/mailbox/messages/$MessageId/attachments?mailboxAddress=$MailboxAddress" -Method Get
$Attachments.value | Select-Object id, name, contentType, size
$AttachmentId = $Attachments.value[0].id
Invoke-RestMethod -Uri "$BackendUrl/api/mailbox/messages/$MessageId/attachments/$AttachmentId`?mailboxAddress=$MailboxAddress" -Method GetExpected output (list): array of { id, name, contentType, size }.
Expected output (single): the same object plus contentBytes (base64)
for file attachments; absent for other attachment types (e.g.
itemAttachment, referenceAttachment).
Stashes or reads internal routing/classification metadata directly on the Graph message object (in addition to, not instead of, the Dataverse Classification Audit table from Phase 2) - useful when the metadata needs to travel with the message itself (e.g. survive a mailbox export, or be visible to another tool reading Graph directly).
Backend: GET/PATCH /api/mailbox/messages/{messageId}/extended-properties
Graph mapping (read): GET /users/{mailbox}/messages/{id}?$expand=singleValueExtendedProperties($filter=id eq 'String {GUID} Name propertyName')
Graph mapping (write): PATCH /users/{mailbox}/messages/{id} with body
{"singleValueExtendedProperties": [{"id": "String {GUID} Name propertyName", "value": "..."}]}
propertyGuid is any stable GUID you choose to namespace the property (Graph
has no bare-name extended properties) - pick one GUID per logical property
and reuse it for both reads and writes. For example:
$PropertyGuid = "12345678-1234-1234-1234-123456789012"
$PropertyName = "RoutingDepartment"
# Write
$Body = @{ mailboxAddress = $MailboxAddress; propertyGuid = $PropertyGuid; propertyName = $PropertyName; value = "Finance" } | ConvertTo-Json
Invoke-RestMethod -Uri "$BackendUrl/api/mailbox/messages/$MessageId/extended-properties" `
-Method Patch -Body $Body -ContentType "application/json"
# Read it back
Invoke-RestMethod -Uri "$BackendUrl/api/mailbox/messages/$MessageId/extended-properties?mailboxAddress=$MailboxAddress&propertyGuid=$PropertyGuid&propertyName=$PropertyName" -Method GetExpected output: { "messageId": "...", "propertyId": "String {12345678-1234-1234-1234-123456789012} Name RoutingDepartment", "value": "Finance" }
(value: null on read if the property was never set).
Add the seven new operations to the connector the same way as Phase 1 (see
docs/wiki/phase-1-mailbox-setup.md, Step 5.3):
regenerate custom-connector/openapi.yaml from the updated
openapi.template.yaml (.\custom-connector\scripts\generate-openapi.ps1),
then re-import/update the connector definition (portal wizard Edit ->
Swagger Editor, or re-run deploy-connector.ps1 for the CLI path).
Test each operation in the connector's Test tab (same authenticated connection as Phase 1 - see Step 5.4):
- messageId:
<inboxMessageId>(from a real Inbox message - see Phase 1 Step 5.5.1) - body: mailboxAddress
shared@company.com, categories["Finance", "Urgent"] -
Expected:
{ "messageId": "...", "categories": ["Finance", "Urgent"] }
- messageId:
<inboxMessageId> - body: mailboxAddress
shared@company.com, destinationIdarchive -
Expected:
{ "movedMessageId": "...", "destinationId": "archive" }- copymovedMessageIdfor later steps
- mailboxAddress:
shared@company.com - deltaLink: (leave empty on first call)
-
Expected:
{ "value": [...], "@odata.deltaLink" or "@odata.nextLink": "..." }
- messageId:
<movedMessageId>(or any message with attachments) - mailboxAddress:
shared@company.com - Expected: array of attachment metadata objects
- messageId:
<movedMessageId>, attachmentId:<id from GetAttachments> - mailboxAddress:
shared@company.com -
Expected: the attachment object including
contentBytes
- messageId:
<movedMessageId> - body: mailboxAddress
shared@company.com, propertyGuid12345678-1234-1234-1234-123456789012, propertyNameRoutingDepartment, valueFinance -
Expected:
{ "messageId": "...", "propertyId": "...", "value": "Finance" }
- messageId:
<movedMessageId> - mailboxAddress:
shared@company.com, propertyGuid12345678-1234-1234-1234-123456789012, propertyNameRoutingDepartment -
Expected:
{ "messageId": "...", "propertyId": "...", "value": "Finance" }
Demonstrates the full routing chain in a Copilot Studio topic (or the PowerShell snippets above, run in order): categorize -> move -> verify via delta -> read attachments -> set extended property.
-
Categorize: Call
UpdateMessageCategorieson a real Inbox message with the department name(s) returned by Phase 2'sClassifyMessagePrompt tool. -
Move: Call
MoveMessageto route the message into the matching department subfolder; capturemovedMessageId. -
Verify via delta: Call
GetMessagesDelta(fresh, nodeltaLink) and confirm the moved message shows up as a change; save the returned@odata.deltaLinkfor the next incremental check. -
Read attachments: Call
GetAttachmentswithmovedMessageId, thenGetAttachmentfor the first attachment id returned (if any). -
Set extended property: Call
SetExtendedPropertywithmovedMessageIdto stash the classification result directly on the message, thenGetExtendedPropertyto confirm it round-trips.
| Issue | Resolution |
|---|---|
MoveMessage response's movedMessageId doesn't work on a later call |
Confirm you're using movedMessageId from the move response, not the original messageId - Graph assigns a new id on move |
GetMessagesDelta with a deltaLink returns a Graph error about an invalid URL |
Make sure the full link (including its query string) is URL-encoded when passed as the deltaLink query parameter - see the PowerShell snippet's [uri]::EscapeDataString(...)
|
GetExtendedProperty returns value: null after a successful SetExtendedProperty |
Confirm propertyGuid and propertyName are byte-for-byte identical between the write and the read - Graph treats the combined id string as an exact match, not a lookup by name alone |
GetAttachment response has no contentBytes |
The attachment isn't a file attachment (e.g. it's an itemAttachment or referenceAttachment) - only file attachments carry contentBytes
|
500 Internal Server Error from UpdateMessageCategories/SetExtendedProperty |
Confirm the PATCH body includes the required fields exactly as documented above - app.py returns a 400 with a clear message if a required field is missing, so a 500 usually means the Graph call itself failed (check bodyPreview-safe backend logs) |
- Proceed to Phase 4: Override/Change Classification
- Wire
UpdateMessageCategoriesandMoveMessageinto the Copilot Studio topic that runs after Phase 2'sClassifyMessagePrompt tool - Consider persisting each mailbox's latest
GetMessagesDeltacheckpoint (e.g. in Dataverse) so incremental sync survives across sessions
- Microsoft Graph Mail API
- Update message (categories)
- Move message
- Get delta (mail)
- List attachments
- Get attachment
- Get open extension or singleValueExtendedProperties
- Update singleValueExtendedProperties
Copyright & License
(c) 2026 Holger Imbery (contact@holgerimbery.blog)