-
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.
Not to be confused with Phase 2's
DraftEmailBodyrouting block: Phase 2's Prompt tool (phase-2-classification-table.md, Section 4.2) generates an HTML "routing block" inside the reply draft's body - a human-reviewable table of classifications, meant to be read and removed by a reviewer before sending. This phase's routing capabilities (categories, move, extended properties) are a different, machine-actionable mechanism - they act directly on the Graph message/mailbox itself (visible in Outlook, queryable via Graph), independent of whatever draft body is attached to it. The two are complementary, not overlapping: use Phase 2's routing block so a human reviewer sees the routing decision before sending, and this phase's operations so the mailbox itself reflects that decision (tagged, filed, and annotated) regardless of what happens to any draft.
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)