Skip to content

phase 3 draft creation

github-actions[bot] edited this page Sep 6, 2026 · 4 revisions

Phase 3: Draft Creation & Routing

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.


0. Already Available (Phase 1, no change here)

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.


1. Categories: UpdateMessageCategories

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"] }


2. Move: MoveMessage

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 messageId stops resolving once the move completes - use movedMessageId from 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" }


3. Delta Query: GetMessagesDelta

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 Get

Expected 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).


4. Attachments: GetAttachments / GetAttachment

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 Get

Expected 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).


5. Extended Properties: GetExtendedProperty / SetExtendedProperty

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 Get

Expected output: { "messageId": "...", "propertyId": "String {12345678-1234-1234-1234-123456789012} Name RoutingDepartment", "value": "Finance" } (value: null on read if the property was never set).


6. Copilot Studio Custom Connector Setup

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):

1. UpdateMessageCategories

  • 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"] }

2. MoveMessage

  • messageId: <inboxMessageId>
  • body: mailboxAddress shared@company.com, destinationId archive
  • Expected: { "movedMessageId": "...", "destinationId": "archive" } - copy movedMessageId for later steps

3. GetMessagesDelta

  • mailboxAddress: shared@company.com
  • deltaLink: (leave empty on first call)
  • Expected: { "value": [...], "@odata.deltaLink" or "@odata.nextLink": "..." }

4. GetAttachments

  • messageId: <movedMessageId> (or any message with attachments)
  • mailboxAddress: shared@company.com
  • Expected: array of attachment metadata objects

5. GetAttachment

  • messageId: <movedMessageId>, attachmentId: <id from GetAttachments>
  • mailboxAddress: shared@company.com
  • Expected: the attachment object including contentBytes

6. SetExtendedProperty

  • messageId: <movedMessageId>
  • body: mailboxAddress shared@company.com, propertyGuid 12345678-1234-1234-1234-123456789012, propertyName RoutingDepartment, value Finance
  • Expected: { "messageId": "...", "propertyId": "...", "value": "Finance" }

7. GetExtendedProperty

  • messageId: <movedMessageId>
  • mailboxAddress: shared@company.com, propertyGuid 12345678-1234-1234-1234-123456789012, propertyName RoutingDepartment
  • Expected: { "messageId": "...", "propertyId": "...", "value": "Finance" }

7. Integration Test: End-to-End

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.

  1. Categorize: Call UpdateMessageCategories on a real Inbox message with the department name(s) returned by Phase 2's ClassifyMessage Prompt tool.
  2. Move: Call MoveMessage to route the message into the matching department subfolder; capture movedMessageId.
  3. Verify via delta: Call GetMessagesDelta (fresh, no deltaLink) and confirm the moved message shows up as a change; save the returned @odata.deltaLink for the next incremental check.
  4. Read attachments: Call GetAttachments with movedMessageId, then GetAttachment for the first attachment id returned (if any).
  5. Set extended property: Call SetExtendedProperty with movedMessageId to stash the classification result directly on the message, then GetExtendedProperty to confirm it round-trips.

8. Troubleshooting

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)

9. Next Steps

  • Proceed to Phase 4: Override/Change Classification
  • Wire UpdateMessageCategories and MoveMessage into the Copilot Studio topic that runs after Phase 2's ClassifyMessage Prompt tool
  • Consider persisting each mailbox's latest GetMessagesDelta checkpoint (e.g. in Dataverse) so incremental sync survives across sessions

References


Copyright & License

(c) 2026 Holger Imbery (contact@holgerimbery.blog)

Clone this wiki locally