Open the mailbox where invoices, applications, claims, or service requests arrive. Pick one attachment and account for every decision between that email and a trusted business record. If you cannot point to the duplicate check, the extraction rule, and the person who owns an exception, you do not have an intake pipeline yet. You have an unattended inbox with extra steps.
Build the pipeline with Microsoft Power Automate, the Office 365 Outlook connector, SharePoint, AI Builder, Microsoft Copilot Studio, and Microsoft Teams. Capture and deduplicate each file before extracting data. Then calibrate confidence gates, separate automated and human decisions, and prove that every retry reuses the original intake record.
Prerequisites and Pipeline Map
If you want to follow along, you will need:
-
A Power Platform environment where you can create a solution-aware cloud flow, because the solution will hold connection references and environment-specific settings.
-
An Outlook mailbox for document intake; use a shared mailbox when the process belongs to a department rather than one employee.
-
A SharePoint document library where you can add metadata columns, a separate intake-registry list, and a separate exception list.
-
An internal HTTP-triggered Azure Function that the solution can call to build a bounded SHA-256 intake key.
-
A trained and published AI Builder document processing model with the fields your process requires.
-
A published Copilot Studio agent only if your documents contain classification or completeness questions that deterministic rules cannot answer.
-
A Teams chat or channel whose reviewers own low-confidence and failed submissions.
-
A labeled document set with approved field values, because your confidence threshold must come from your documents rather than somebody else’s favorite number.
Keep Storage Separate From Reasoning
Build the cloud flow inside a Power Platform solution. Solution-aware flows use connection references, which prevents the design from being tied to one maker’s Outlook or SharePoint connection when you move it into test or production.
The finished control path looks like the diagram below. SharePoint holds both the original evidence and the current processing state; AI components never become the system of record.
Tracing the intake path
The pipeline will only be as reliable as the business rules around it. Stop automation at any decision you cannot validate deterministically, and assign an accountable reviewer to own that exception.
Step 1: Capture and Normalize Outlook Attachments
Create an automated flow with When a new email arrives (V3) for a process mailbox or When a new email arrives in a shared mailbox (V2) for a department. The shared-mailbox connection account needs mailbox access. Set Only with Attachments to Yes; add folder or subject filters only for existing business rules.
In the panel below, change Only with Attachments to Yes and Include Attachments to No before adding per-file retrieval.

Trigger fields to change
Microsoft warns that trigger downloads can time out during attachment-heavy bursts. Keep Include Attachments set to No, loop through metadata, and call Get Attachment (V2) for each accepted file.
Reject Noise Before It Reaches AI
Email signatures often arrive as inline attachments. Inside Apply to each, add a Condition requiring Attachments Is Inline to equal false, as Microsoft’s AI Builder guidance documents.
Apply the rest of the intake controls before saving the file:
-
Accept only the file types your published model supports. AI Builder document processing accepts PDF, JPEG, and PNG inputs.
-
Reject files over 20 MB before prediction, matching the document processing model requirements.
-
Route password-protected PDFs to the exception list because AI Builder requires the password lock to be removed before processing.
-
Require a non-empty attachment array. Microsoft notes that Defender for Office 365 Dynamic Delivery can cause the Outlook trigger to run twice, with the first run containing no attachments.
Warning: A filename extension is a routing hint, not proof of content. Keep your existing email-security and malware controls in front of this flow, and send a rejected file to review without opening it in an AI action.
The output is a validated byte stream plus the connector Message Id, audit-only Internet Message Id, Attachment Id, source mailbox address, and approved extension. Nothing has been committed, so an unsafe file can still stop before storage.
Step 2: Create a Replay-Safe SharePoint Intake Record
A filename cannot tell you whether Power Automate already saw an attachment. Vendors reuse invoice.pdf, and a resend can rename the same document.
Store the bounded key and its raw source identifiers with different jobs:
| Column | Purpose | Example Source |
|---|---|---|
IntakeKey |
Identifies one attachment submission | 64-character lowercase SHA-256 digest |
OutlookMessageID |
Addresses the source message during worker retrieval | Outlook trigger Message Id |
InternetMessageID |
Preserves the RFC message identifier for audit only; never use it as OutlookMessageID |
Outlook Internet Message Id |
AttachmentID |
Addresses the source attachment during worker retrieval | Outlook attachment Id |
OriginalMailboxAddress |
Points retrieval at the shared mailbox; blank only for the connection-owned mailbox | Configured intake mailbox |
ValidatedExtension |
Reuses the approved storage suffix without parsing the name again | pdf, jpg, or png |
OriginalFileName |
Preserves the sender’s attachment name | Outlook attachment name |
ConversationID |
Groups replies into one business case | Outlook Conversation ID |
SchemaVersion |
Records which extraction contract ran | Solution environment variable |
ProcessingStatus |
Shows the registry control state | Reserved, FileCreateFailed, or FileCreated |
FileIdentifier |
Binds the reservation to the created SharePoint file | SharePoint Create file output |
RecoveryRequested |
Authorizes a worker retry on the same reservation | SharePoint Yes/No column, default No |
ProcessingDispatched |
Prevents a second child-flow call | SharePoint Yes/No column, default No |
ReviewDispatched |
Prevents a second review card | SharePoint Yes/No column, default No |
ReviewField |
Names the field that needs a decision | Normalized field contract |
ReviewValue |
Preserves the extracted value | Normalized field contract |
ReviewConfidence |
Preserves the field score | AI Builder output |
FailedRule |
Explains why automation stopped | Validation result |
Build a Bounded Intake Key
Create a solution-aware BuildIntakeKey child flow. Reject null, empty, and whitespace-only inputs, then UTF-8 encode accepted originals without trimming or normalization. Prefix each byte-array length with a four-byte big-endian integer, ordering message before attachment.
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
static string BuildIntakeKey(string internetMessageId, string attachmentId)
{
if (string.IsNullOrWhiteSpace(internetMessageId))
throw new ArgumentException("InternetMessageID is required.");
if (string.IsNullOrWhiteSpace(attachmentId))
throw new ArgumentException("AttachmentID is required.");
byte[] messageBytes = Encoding.UTF8.GetBytes(internetMessageId);
byte[] attachmentBytes = Encoding.UTF8.GetBytes(attachmentId);
byte[] payload = new byte[4 + messageBytes.Length + 4 + attachmentBytes.Length];
int offset = 0;
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(offset, 4), messageBytes.Length);
offset += 4;
messageBytes.CopyTo(payload, offset);
offset += messageBytes.Length;
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(offset, 4), attachmentBytes.Length);
offset += 4;
attachmentBytes.CopyTo(payload, offset);
byte[] hashBytes = SHA256.HashData(payload);
string intakeKey = Convert.ToHexString(hashBytes).ToLowerInvariant();
if (hashBytes.Length != 32 || intakeKey.Length != 64)
throw new InvalidOperationException("The intake key must be 64 lowercase hex characters.");
foreach (char character in intakeKey)
{
bool isLowerHex = (character >= '0' && character <= '9') ||
(character >= 'a' && character <= 'f');
if (!isLowerHex)
throw new InvalidOperationException("The intake key must be 64 lowercase hex characters.");
}
return intakeKey;
}
SHA256.HashData returns 32 bytes; Convert.ToHexString emits uppercase. Call ToLowerInvariant and return only 64 lowercase hex characters.
Call it as Run_BuildIntakeKey inside For_each_attachment and put intakeKey in Compose Compose_IntakeKey. Send any non-64-character result to Catch. The digest fits SharePoint’s 255-character text limit; store raw IDs separately.
Reserve Before Creating the File
Create DocumentIntakeRegistry with a unique text key; SharePoint rejects and indexes duplicates. After extension validation, Reserve_intake creates the item with OutlookMessageID, audit-only InternetMessageID, AttachmentID, OriginalMailboxAddress, ValidatedExtension, remaining audit fields, a blank FileIdentifier, RecoveryRequested=No, and ProcessingStatus=Reserved. A later duplicate becomes IgnoredDuplicate; exhaustion changes the same row to FileCreateFailed.
Give RegistryFileWorker trigger concurrency 1. Every run starts with Get item by registry item ID. Continue only for live Reserved plus blank FileIdentifier, or FileCreateFailed plus blank identifier and RecoveryRequested=Yes. Otherwise stop before connector writes.
Call Get Attachment (V2) with OutlookMessageID as Message Id, AttachmentID as Attachment Id, and persisted OriginalMailboxAddress. Leave the mailbox value blank only when the connection account owns the source. Pass Content Bytes directly to SharePoint Create file as File Content.
Build Compose_StorageFileName from the persisted approved extension:
concat(outputs('Compose_IntakeKey'), '.', outputs('Compose_ValidatedExtension'))
Set Overwrite: No. Bind the returned ID and mark FileCreated. After an uncertain response, look up the digest filename and bind only when its name and stored IntakeKey match this row; otherwise retain FileCreateFailed. Never choose another name.
This path lasts only while Outlook can address the source. Microsoft documents not-found results after message movement or deletion; retention expiry creates the same gap. Failed retrieval leaves the row incomplete, writes a visible exception, and skips Create file. If recovery must survive those events, store bytes in approved staging before intake ends and persist its identifier. Do not stage for a shorter window already covered by mailbox policy.
Put Steps 3 through 5 in a solution-aware child flow named ProcessDocument. Give its manual trigger three text inputs: IntakeKey, FileIdentifier, and SchemaVersion. Create a processor-controller flow with SharePoint When a file is created or modified (properties only) and this trigger condition:
@and(equals(triggerOutputs()?['body/ProcessingStatus/Value'], 'Received'), equals(triggerOutputs()?['body/ProcessingDispatched'], false))
Set ProcessingDispatched to Yes before calling ProcessDocument. Each accepted attachment gets its own controller run, and the replay flow can call the same child flow without creating another file.
Step 3: Extract a Versioned Field Contract With AI Builder
Add the AI Builder Process documents action and select your published model, document type, and attachment content. The action returns each extracted field’s value and confidence score between 0 and 1. It can also return table cells, page numbers, coordinates, and bounding boxes for advanced review experiences.
Use Pages only when the document layout makes the range deterministic. Microsoft recommends the page-range parameter when one known form occupies specific pages, because processing fewer pages can reduce prediction cost and improve performance. Do not hard-code 1 for a packet whose required form moves between page 1 and page 4.
Normalize the dynamic outputs in a Compose action named Compose_NormalizedFields before applying validation rules. A sample result looks like this:
[
{
"field": "InvoiceNumber",
"value": "INV-1042",
"confidence": 0.93,
"sourcePage": 1,
"rulePassed": true
},
{
"field": "InvoiceTotal",
"value": "1840.50",
"confidence": 0.81,
"sourcePage": 1,
"rulePassed": true
}
]
This array is your field contract, not a copy of AI Builder’s internal response. Give it a SchemaVersion and keep field names stable across flows, Adaptive Cards, reports, and downstream integrations.
| Field Rule | Why It Exists | Failure Route |
|---|---|---|
InvoiceNumber is required |
Prevents an untraceable payable | Human review |
InvoiceTotal parses as decimal |
Stops a text value from reaching finance | Human review |
InvoiceDate is a valid date |
Prevents a malformed posting date | Human review |
VendorID exists in the vendor list |
Blocks an unknown payee from automatic processing | Business exception |
AI confidence does not replace these validation rules. A model can confidently extract 1804.50 when the approved invoice total is 1840.50. Validate the extracted value independently of the confidence score.
Step 4: Calibrate the Confidence Gate
Create a decimal environment variable named DocumentIntake_MinConfidence and reference it from the flow. Power Automate environment variables separate configuration from flow logic, so test and production can use validated values without editing every condition.
Values such as 0.80 and 0.85 are useful examples while you wire the branch. They are not production defaults. Choose the threshold by running your labeled document set through the published model and comparing extracted values with approved values field by field.
Add Initialize variable, name it MinConfidence, choose Float, and select the DocumentIntake_MinConfidence environment-variable value. Then add Filter array, set From to outputs('Compose_NormalizedFields'), and paste this advanced-mode condition:
@or(empty(coalesce(item()?['value'], '')), equals(item()?['rulePassed'], false), less(float(item()?['confidence']), variables('MinConfidence')))
At 0.85, the sample array returns only InvoiceTotal because its confidence is 0.81. Send @equals(length(body('Filter_array')), 0) to the automatic path; any returned object goes to review.
The routing logic should resemble the following diagram. Notice that a rule failure bypasses the automatic path even when every confidence score is high.
Routing by confidence
Compare Pass and Review Errors
Track four outcomes during calibration:
| Outcome | What It Tells You |
|---|---|
| Correct automatic pass | The threshold allowed useful straight-through processing |
| Incorrect automatic pass | The threshold or business rules are too permissive |
| Correct review route | The gate protected the downstream record |
| Unnecessary review route | The threshold is creating avoidable manual work |
Reality Check: Raising the threshold reduces risky automatic passes and increases the review queue. Lowering it does the reverse. Your labeled documents decide where that tradeoff belongs.
Repeat the evaluation for each required field and document layout. A single document-level average can hide one dangerously weak field, so route on the minimum required-field confidence or on field-specific thresholds when the business impact differs.
Step 5: Use Copilot Studio for Ambiguity, Not Control
AI Builder should remain the first extraction path for a stable form. Invoke Copilot Studio when the file requires semantic judgment: classify an unfamiliar document, decide which required information is missing from a free-form letter, or explain why the content does not fit a known category.
Publish the agent, add the Microsoft Copilot Studio connector, and choose Execute Agent and wait. The action accepts a message and attachment parameters under its advanced inputs. Pass the normalized field array, the expected schema version, and either the supported document content or carefully normalized text.
Give the agent a narrow response contract. For example:
{
"documentType": "invoice",
"requiresReview": true,
"missingFields": ["PurchaseOrderNumber"],
"reason": "No purchase order reference was found in the document."
}
Rename the agent action Execute_Agent_and_wait. Add Parse JSON after it and select the action’s lastResponse dynamic value for Content. The connector documents lastResponse as the final text activity and responses as the array of returned text activities. Use responses only when your contract intentionally aggregates several activities; this design expects one JSON object, so parsing the array would change the contract. Paste this schema:
The selected action below shows where Content and Schema appear in the Power Automate designer. The live flow uses a smaller response contract; replace that example schema with the stricter document-intake contract that follows.

Parse JSON input and schema
{
"type": "object",
"properties": {
"documentType": { "type": "string" },
"requiresReview": { "type": "boolean" },
"missingFields": { "type": "array", "items": { "type": "string" } },
"reason": { "type": "string" }
},
"required": ["documentType", "requiresReview", "missingFields", "reason"],
"additionalProperties": false
}
Initialize an Array variable named SupportedDocumentTypes with the categories your process accepts. Route this condition to ReviewRequired:
@or(not(contains(variables('SupportedDocumentTypes'), body('Parse_JSON')?['documentType'])), equals(body('Parse_JSON')?['requiresReview'], true), greater(length(body('Parse_JSON')?['missingFields']), 0))
Configure the same metadata update to run after Parse_JSON has failed or has timed out. A malformed response or missing required property now reaches review without giving the agent control over storage, deduplication, or an irreversible transaction.
That separation makes the failure path understandable. When a record lands in review, you can tell whether a rule failed, a score missed its threshold, or the agent returned an invalid classification.
Step 6: Dispatch Human Review Through Teams
Do not make the intake flow sit on Post adaptive card and wait for a response. That action pauses its flow run until somebody responds. Instead, let the intake flow save ReviewRequired metadata and finish. Create a second review-dispatcher flow that triggers on the SharePoint status change, waits for the reviewer, and writes the correction back.
Keep Review Runs Independent
Use SharePoint When a file is created or modified (properties only) for the dispatcher. Add this trigger condition under Settings:
@and(equals(triggerOutputs()?['body/ProcessingStatus/Value'], 'ReviewRequired'), equals(triggerOutputs()?['body/ReviewDispatched'], false))
Make Update file properties set ReviewDispatched to Yes before posting the card. Then paste this Adaptive Card JSON payload into the Teams action and replace the displayed values only by changing the corresponding SharePoint column names:
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{ "type": "TextBlock", "weight": "Bolder", "text": "Document review" },
{ "type": "TextBlock", "wrap": true, "text": "Field: @{triggerOutputs()?['body/ReviewField']}\nValue: @{triggerOutputs()?['body/ReviewValue']}\nConfidence: @{triggerOutputs()?['body/ReviewConfidence']}\nRule: @{triggerOutputs()?['body/FailedRule']}" },
{ "type": "Input.Text", "id": "correctedValue", "label": "Correction" }
],
"actions": [
{ "type": "Action.OpenUrl", "title": "Open document", "url": "@{triggerOutputs()?['body/{Link}']}" },
{ "type": "Action.Submit", "title": "Approve", "data": { "decision": "Approve", "intakeKey": "@{triggerOutputs()?['body/IntakeKey']}" } },
{ "type": "Action.Submit", "title": "Correct", "data": { "decision": "Correct", "intakeKey": "@{triggerOutputs()?['body/IntakeKey']}" } },
{ "type": "Action.Submit", "title": "Reject", "data": { "decision": "Reject", "intakeKey": "@{triggerOutputs()?['body/IntakeKey']}" } }
]
}
The submitted data object must contain the original IntakeKey and one decision. Giving each review item its own dispatcher run prevents one unanswered card from holding unrelated documents behind it.
If you process multiple attachments inside one email run, Apply to each executes sequentially by default. Enable a modest concurrency value only after the bounded-key service and atomic registry reservation pass their race tests. Microsoft’s parallel execution guidance supports 1 to 50 parallel iterations, but a higher value adds connector pressure and does not guarantee a faster flow.
When the reviewer submits the card, update the normalized field values, record reviewer identity and review time, and set the status to Corrected or Rejected. Keep the original AI output intact in audit metadata so you can measure where the model is failing instead of training on a corrected value with no history.
Step 7: Add Retries, Exceptions, and Teams Alerts
Put state-changing actions in a Try scope. Run Catch after failure, timeout, or skip, never success.
Use a connector-free harness to verify Try and Catch.

Test harness scopes
Expand Try to confirm Duplicate Check precedes Confidence Gate.

Expanded test branches
Confirm the example AND gate uses 0.85 overall and 0.90 for invoices.

Confidence gate conditions
Verify the three selected Catch states and cleared success state.

Catch run-after settings
Inside Catch, filter result('Try') twice. For failed or timed-out actions, use:
@or(equals(item()?['status'], 'Failed'), equals(item()?['status'], 'TimedOut'))
For skipped actions, use:
@equals(item()?['status'], 'Skipped')
Iterate both arrays and log every result instead of first(...) with:
item()?['name'] item()?['status'] item()?['code'] item()?['outputs']
Keep complete outputs and never read root error. Map outputs.body only after observing the connector schema. Log skips separately. A named nested scope such as Try_AI_Builder needs its own result() call.
Use exponential retry only for transient connector failures. Password locks, unsupported types, invalid agent responses, and rule rejections need review, not another identical request.
Log Before You Alert
Write one exception row per failed or timed-out result, and separate rows for skipped actions:
-
IntakeKey, flow name, run ID, action name, action status, action code, and the complete outputs object. -
The SharePoint file identifier plus links to the file and Power Automate run.
-
Retry count, final status, and the person or queue responsible for resolution.
-
A
ReplayRequestedflag andResolutionStatuschoice that an operator can set after fixing the cause.
Post Teams only after committing the exception row. SharePoint remains the record if Teams is unavailable.
Create a replay-controller flow with SharePoint When an item is created or modified and this trigger condition:
@and(equals(triggerOutputs()?['body/ReplayRequested'], true), equals(triggerOutputs()?['body/ResolutionStatus/Value'], 'Open'))
Set ResolutionStatus=ReplayQueued and ReplayRequested=No, then inspect the registry. For Reserved or FileCreateFailed without an ID, set RecoveryRequested=Yes on that row and stop. The worker receives only its item ID, re-reads live state, retrieves with persisted connector fields, and sends Content Bytes to the digest filename. Only FileCreated with an ID can call ProcessDocument. An unavailable Outlook source retains the failed row and updates its exception.
Quick Win: Reuse the same IntakeKey during replay. A repaired run should update the failed record, never create a fresh file that hides the first attempt.
After logging, Terminate with an explicit failure status so dashboards cannot report an unprocessed document as successful.
Step 8: Test Every Branch Before Production
Define expected SharePoint state before tuning. The cloud flow run view proves each control point. A harness covers routing; bound nonproduction tests cover connectors and nested scopes.
| Test Document | Expected Result |
|---|---|
| Supported file with correct high-confidence fields | One file and one completed record |
| Correct fields below the example threshold | One review card and no automatic commit |
| High-confidence field that violates a business rule | Business exception despite the score |
| Same attachment delivered twice | One registry/file pair; the losing run logs IgnoredDuplicate |
Create file fails after reservation and the email run ends |
Recovery starts the worker with only the registry item ID; persisted Outlook fields produce one registry row and one digest-named file |
Two distinct attachments both named invoice.pdf |
Two digest-named files with OriginalFileName preserved and no overwrite |
| Password-locked or oversized file | Intake exception before AI processing |
Malformed Copilot Studio lastResponse |
ReviewRequired with the response preserved |
Run the labeled set at 0.80, 0.85, and the measured threshold; compare incorrect passes with unnecessary reviews.
Repeat an ID pair for the same lowercase 64-character key; change either ID for a different key. Test non-ASCII, surrounding whitespace, and concurrent duplicates. The last case must leave one registry/file pair.
Force Create file to fail after reservation and let the email run finish. Start recovery with only the registry item ID. Verify Get item supplies OutlookMessageID, AttachmentID, OriginalMailboxAddress, and ValidatedExtension; Get Attachment (V2) supplies the Content Bytes consumed by Create file. Before ProcessDocument, assert exactly one row for IntakeKey and one digest-named file. A moved, deleted, or expired source must create a visible exception unless approved staging is bound.
Run the Pipeline as a Business Process
Move the solution through each environment with variables for SharePoint, review destination, schema version, and thresholds. Export it with connection references, each mapped to a service-owned account.
Watch the pipeline with operational measures you can act on:
-
Automatic-pass, review, correction, rejection, and duplicate rates.
-
Field-level accuracy against reviewed documents.
-
Time from Outlook receipt to completed or rejected status.
-
Exceptions by action, document type, and sender.
-
AI Builder pages processed and Copilot Studio calls per accepted document.
A production run leaves one registry item, one digest-named file with the original name, a versioned extraction record, and a visible exception owner. The serialized worker recovers from persisted state while Outlook exposes the message, or from approved staging when policy requires longer. Raise volume only after labeled and replay tests preserve those counts.