Build a production-ready document intake pipeline in Power Automate that catches emailed attachments the moment they arrive, saves them to SharePoint with proper metadata, and routes context-rich approval notifications through Teams. This deep-dive lesson covers every edge case — inline image filtering, null-safe approval responses, error handling, and consolidated multi-attachment approvals.

Every organization has a version of this problem. Someone emails a contract, an invoice, a signed form, or a vendor proposal to a shared inbox — and then it disappears. It lands in someone's email, maybe gets forwarded, maybe gets saved to a desktop folder, maybe doesn't get saved at all. The approver who needs to review it doesn't know it arrived. The person who sent it has no idea whether anyone looked at it. Three weeks later someone asks "whatever happened to that NDA we sent over?" and the answer is a collective shrug.
The automation you'll build in this lesson solves that entire problem in a single flow. You're going to wire together three Microsoft 365 connectors — Outlook, SharePoint, and Teams — into a coherent pipeline that catches incoming email attachments the moment they arrive, saves them to a structured SharePoint document library with proper metadata, and fires a contextual approval notification to the right people in Teams. By the time you finish, you'll have a production-ready pattern that you can adapt for contracts, invoices, HR documents, vendor submissions, or anything else your organization receives via email.
What you'll learn:
You should be comfortable with the Power Automate designer and have already built at least one trigger-based flow. You need to understand what Power Automate triggers do conceptually, because this lesson won't re-explain trigger fundamentals — it will show you how to push them into production patterns. You'll also need:
Before touching the designer, map out the data flow. This is advice that separates people who build fragile flows from people who build durable ones: spend fifteen minutes thinking about what data lives where and how it moves between steps.
Here's the shape of what you're building:
The critical architectural decision here is handling the Apply to Each loop correctly. When an email arrives with three attachments, your flow will run the entire inner sequence three times — once per attachment. That means three SharePoint uploads, three Teams notifications, and three approval waits. For most real-world document intake scenarios, that's exactly right behavior. But you need to be conscious of it, especially when the Teams notification and approval are inside the loop, because approvers will get three separate approval requests.
An alternative architecture — processing all attachments first, then sending one consolidated approval — requires composing an array of SharePoint URLs and passing that into a single notification. We'll cover that pattern in the advanced variations section. Start with the per-attachment pattern because it's cleaner to debug and handles the most common case where each attachment is a distinct document.
Note
The Outlook connector's "When a new email arrives" trigger has two variants: one for personal mailboxes and one for shared mailboxes. The shared mailbox version requires that the mailbox be an actual Exchange shared mailbox (not just a distribution list) and that the connection account have "Full Access" permission on that mailbox. If you're setting up a document intake process, use a dedicated shared mailbox like documents@yourcompany.com rather than someone's personal inbox.
Create a new Automated cloud flow. When asked to choose a trigger, search for "When a new email arrives" and select the one that matches your mailbox type. For this lesson we'll use the Office 365 Outlook — When a new email arrives (V3) trigger, which is the current version for personal mailboxes and the most fully-featured.
In the trigger configuration, expand Show advanced options immediately. These settings determine whether your flow even fires for the right emails, and skipping them means your flow either runs on everything or misses things it should catch.
Key trigger settings to configure:
Inbox or, better yet, a dedicated subfolder like Inbox/DocumentSubmissions. Routing to a subfolder using Outlook rules keeps the flow from firing on every email, which matters for licensing and noise.Yes. This is not optional — if you leave it at No, the attachment content will not be available in the trigger outputs, and you'll spend an hour wondering why your file content is empty.Yes. This filters at the trigger level so the flow doesn't even start for emails without attachments.[SUBMIT] that senders include in the subject line. This is a lightweight but effective way to distinguish intentional submissions from people who accidentally email the inbox.Warning
Setting "Only with Attachments" to Yes at the trigger level does not guarantee that the Attachments array in the trigger body will always have items — edge cases exist with inline images embedded in HTML emails, which Outlook sometimes treats as attachments. Always add an explicit condition downstream to verify the attachment array is not empty.
After saving the trigger, use a real email to test it. Send an email with one PDF attachment to the mailbox. Click Test in the designer (top right), select With a recently triggered event, and pick the run. This gives you the actual trigger output to examine — do that before building the rest of the flow. Look at the JSON structure of the trigger body. You'll see fields like from, subject, body, receivedDateTime, and attachments as an array. Each attachment object has name, contentType, and contentBytes (the base64-encoded file content).
Add a Condition action immediately after the trigger. This is your safety net against the inline-image false positive problem described above.
The expression to check whether the attachments array has real items is:
greater(length(triggerOutputs()?['body/attachments']), 0)
Write this in the expression editor of the left-hand condition field, and set it to is equal to true.
In the If No branch, add a Terminate action with status Succeeded and a message like No valid attachments found — flow terminated cleanly. This is better than leaving the No branch empty because it makes the run history readable. When you look at run history six weeks later, a clean termination with a message tells you what happened; an empty branch tells you nothing.
Tip
Some teams add a secondary condition inside the attachment loop to filter by file type — for example, only processing PDFs and Word documents while skipping .jpg or .png files that are likely inline images. The expression contains(items('Apply_to_each')?['contentType'], 'pdf') checks for PDFs. Build the basic flow first, then layer in this refinement once you understand your real-world email traffic.
All subsequent actions go into the If Yes branch.
Before the Apply to Each loop, initialize any variables you'll need inside the loop. Power Automate requires variables to be initialized before the scope where they're used — you can't initialize a variable inside an Apply to Each.
For this flow, initialize two variables:
Variable 1: SharePoint File URL
varSharePointFileURLVariable 2: Document Category
varDocumentCategoryGeneralThe Document Category variable is going to hold a value derived from the email subject line using a keyword match. You'll set it inside the loop using a switch or condition. Initializing it with General as a default means you always have a fallback value even if none of the keyword conditions match.
Add an Apply to each action. For the input, click into the field and select the dynamic content value Attachments from the trigger. Power Automate will warn you that this creates a loop — that's exactly what you want.
Inside the loop, you're going to build the following sequence:
Let's work through each one carefully.
Add a Switch action inside the loop. For the On value, use this expression:
toLower(triggerOutputs()?['body/subject'])
Converting to lowercase makes your keyword matching case-insensitive without having to write multiple cases.
Add three cases:
contains() directly in a Switch On value, so handle this differently. Actually, the better pattern here is to use nested Condition actions instead of Switch, since Switch does exact matching and you need substring matching.Replace the Switch with a Condition chain. Add a Condition action with this expression:
contains(toLower(triggerOutputs()?['body/subject']), 'invoice')
If true: Set variable varDocumentCategory to Invoice
In the No branch, add another Condition:
contains(toLower(triggerOutputs()?['body/subject']), 'contract')
If true: Set variable varDocumentCategory to Contract
If No: Add one more Condition for nda or agreement:
or(contains(toLower(triggerOutputs()?['body/subject']), 'nda'), contains(toLower(triggerOutputs()?['body/subject']), 'agreement'))
If true: Set to NDA/Agreement. If No: leave varDocumentCategory at its initialized value of General.
This nested structure correctly chains your keyword detection without requiring exact matches.
Key insight
This keyword extraction pattern is functional but brittle — it breaks the moment someone sends an invoice without the word "invoice" in the subject. For a production flow that needs reliable categorization, replace this logic with an AI Builder text classification model, or parse the email body for structured keywords. The AI Builder integration article covers exactly this pattern. For the lesson's purposes, keyword matching demonstrates the concept without introducing AI dependencies.
Add a Create file action from the SharePoint connector. This action takes file content and creates a physical file in a document library. Configure it as follows:
/Shared Documents/DocumentIntake (or whatever your library and folder path is — note this is the library's URL-relative path, not the display name)Attachment Name from inside the Apply to Each loop. This is items('Apply_to_each')?['name'] in expression formAttachment Content — this is items('Apply_to_each')?['contentBytes']. This is the base64-encoded file content. The SharePoint connector handles the base64 decoding automatically; do not add a base64ToString() wrapper hereAfter running successfully, the Create file action returns a set of output properties including {Id} (the list item ID), ItemId (also the list item ID, as an integer), Path, and WebUrl. You'll use ItemId in the next step and WebUrl in the Teams notification.
Warning
SharePoint document library folder paths are case-sensitive and must use forward slashes. If you type /shared documents/documentintake when the actual library name is Shared Documents, the action will fail with a path error. Navigate to your library in SharePoint and copy the URL path from the browser — then strip the site URL prefix to get the relative path.
The Create file action uploads the file but doesn't set custom metadata columns. For that, add an Update file properties action from the SharePoint connector immediately after Create file.
ItemId from the Create file action outputFrom from the trigger dynamic content — this is the sender's email addressvarDocumentCategory (the dynamic content pill, not a hardcoded value)This two-step pattern — Create file, then Update file properties — is the correct SharePoint attachment upload sequence. Many people try to set metadata inside the Create file action and discover the custom columns don't appear as options there. The Create file action only knows about the file content, not the library's metadata schema. Update file properties is what lets you write to those custom columns.
After Update file properties, store the SharePoint file URL in your variable:
Add a Set variable action:
varSharePointFileURLLink to item or {WebUrl} from the Create file outputsThis is where many flows fall short — they send a bare-minimum Teams message that says something like "A file was received" with no actionable context. Your approvers should be able to understand the entire submission from the Teams notification alone, without having to click through to SharePoint first.
Before building the Teams message, add a Compose action to build the formatted message body. Using Compose for the message text rather than typing directly into the Teams action makes the message much easier to edit later without touching the action itself.
Add a Compose action and build an expression like this:
concat(
'**New Document Submission**',
'\n\n',
'**From:** ', triggerOutputs()?['body/from'],
'\n',
'**Received:** ', formatDateTime(triggerOutputs()?['body/receivedDateTime'], 'dddd, MMMM d yyyy h:mm tt'),
'\n',
'**Subject:** ', triggerOutputs()?['body/subject'],
'\n',
'**File:** ', items('Apply_to_each')?['name'],
'\n',
'**Category:** ', variables('varDocumentCategory'),
'\n\n',
'**[View in SharePoint](', variables('varSharePointFileURL'), ')**'
)
This produces a well-structured message with a direct link to the file. The approver sees who sent it, when it arrived, what the subject line was, what the file is called, what category it was classified as, and a clickable link to the actual document in SharePoint — all before even clicking anything.
Now add the Post a message in a chat or channel action from Teams connector:
Tip
For higher-stakes approval workflows, consider replacing the basic Teams message with an Adaptive Card that includes inline Approve/Reject buttons. That pattern — where approvers can respond directly in Teams without navigating to Power Automate's approval center — is covered in depth in the adaptive card approvals article. The basic Teams message is sufficient for the pattern we're building here, and you can upgrade it once the pipeline is working end-to-end.
Add a Start and wait for an approval action from the Approvals connector. This action is synchronous within the flow — the flow execution pauses here and resumes only when an approver responds. This is a critical behavior to understand: a Power Automate run can pause for days or weeks waiting for an approval. For most document intake scenarios, that's correct. If you need a timeout/escalation pattern, the approval escalation article covers exactly that.
Configure the approval action:
concat('Document Review: ', items('Apply_to_each')?['name'], ' from ', triggerOutputs()?['body/from'])
varSharePointFileURL — this makes the approval notification in the Approvals center clickable directly to the SharePoint fileView document in SharePointAfter the Start and wait for an approval action completes, you have access to the approval outcome through the Outcome property (which will be Approve or Reject) and the Response summary which includes comments from the approver.
Add a Condition action after the approval step. Set the condition:
outputs('Start_and_wait_for_an_approval')?['body/outcome']
Is equal to: Approve
In the If Yes (Approved) branch:
ApprovedFor the approval confirmation email:
triggerOutputs()?['body/from']concat('Approved: ', items('Apply_to_each')?['name'])concat(
'Your document "', items('Apply_to_each')?['name'], '" has been approved.\n\n',
'Approved by: ', outputs('Start_and_wait_for_an_approval')?['body/responses'][0]?['responder/email'], '\n',
'Response time: ', formatDateTime(outputs('Start_and_wait_for_an_approval')?['body/responses'][0]?['requestDate'], 'dddd MMMM d, yyyy'), '\n\n',
'Comments: ', if(empty(outputs('Start_and_wait_for_an_approval')?['body/responses'][0]?['comments']), 'No comments provided.', outputs('Start_and_wait_for_an_approval')?['body/responses'][0]?['comments']),
'\n\nThe document has been stored in SharePoint: ', variables('varSharePointFileURL')
)
In the If No (Rejected) branch:
RejectedKey insight
Sending the SharePoint URL to the sender even on approval is a meaningful detail. It transforms the sender's mental model from "I sent a file by email" to "I have a document that now lives in SharePoint." Over time, this trains senders to expect that submitted documents go into a managed system — which is exactly the culture shift document intake automation is meant to create.
Production flows fail. Networks hiccup, SharePoint throttles, the attachment content is malformed. Without error handling, your flow dies silently and nobody knows the document wasn't processed. This is not acceptable for a document intake pipeline.
Wrap the Create file action in a scope, and configure the scope's Run after settings for the downstream error notification. Here's the minimal pattern:
After the Create file action (but still inside the Apply to Each loop), add a Condition that checks whether the Create file action succeeded:
equals(actions('Create_file')?['status'], 'Succeeded')
If it failed, send an alert email to a designated admin address and to the original sender explaining that their document could not be processed and they should resubmit. Include the error message:
actions('Create_file')?['error']?['message']
For more sophisticated error handling patterns including retry logic and dead-letter queuing, the error handling article is worth studying after you get this flow working.
After the Apply to Each loop completes (all attachments have been processed), add a final step outside the loop: post a summary message to a separate audit log channel in Teams or update a SharePoint list that tracks email submissions.
Add a Create item action on a SharePoint list called DocumentIntakeLog with columns:
length(triggerOutputs()?['body/attachments']) to get the countutcNow()This audit log is what lets you answer "how many documents came in last month?" or "has the Jones contract been submitted yet?" without digging through run history. It also provides a lightweight compliance record that the submission was received and processed.
The per-attachment loop pattern described above works well when each attachment is a distinct document requiring its own approval. But sometimes an email arrives with five attachments that together constitute a single submission — a contract package, for instance, with the main agreement, exhibits, and a cover sheet. Sending five separate approval requests for that is incorrect behavior.
Here's the alternative architecture:
union() function pattern or an append-to-array variableThe key to this pattern is the append to array variable action. Initialize a variable of type Array before the first loop:
varSharePointURLs = []
Inside the upload loop, after each Create file action, append to the array:
union(variables('varSharePointURLs'), array(outputs('Create_file')?['body/{WebUrl}']))
Then after the loop, use join(variables('varSharePointURLs'), '\n') to format the URL list into the consolidated notification.
This is a more complex flow to build and debug, but it's the correct architecture for multi-document submission packages. See the chaining Microsoft 365 connectors article for more patterns on passing data cleanly between multiple connector actions in a flow.
Build this flow from scratch in your own environment using the following scenario:
Scenario: Your team receives vendor proposals via email to proposals@yourcompany.com. Each proposal arrives as a PDF attached to an email with "Proposal" in the subject line. When a proposal arrives, it should be:
Shared Documents/VendorProposals/[Year] in your SharePoint site#procurement-reviews channel should notify the procurement team with the sender name, subject, and a direct SharePoint linkSpecific challenges to work through:
How do you create the year-based folder path dynamically? Use formatDateTime(triggerOutputs()?['body/receivedDateTime'], 'yyyy') to get the year and concatenate it into the folder path string. Test what happens when the folder doesn't yet exist — SharePoint's Create file action will create missing folders automatically, but only one level deep.
How do you extract just the sender's display name (not their email address) for the Teams message? The from field returns a formatted string like "John Smith" <john.smith@vendor.com>. Use the expression first(split(triggerOutputs()?['body/from'], '<')) to extract the display name portion, then trim whitespace and quotes.
Add a 48-hour reminder: if the approval hasn't been responded to after 48 hours, send a reminder Teams message to the approver. This requires the approval escalation pattern using a parallel branch with a Delay action — implement it.
Symptom: The SharePoint file is created but is 0 bytes. When you open it, it's blank.
Cause: You either didn't set "Include Attachments" to Yes on the trigger, or you're referencing the attachment content from the wrong output.
Fix: Open the trigger settings and confirm Include Attachments is Yes. Inside the Apply to Each loop, make sure your File Content field references Attachment Content (the dynamic content item from within the loop), not anything from outside the loop. If you used an expression, verify it's items('Apply_to_each')?['contentBytes'] and not triggerOutputs()?['body/attachments'][0]?['contentBytes'] (the latter only ever gets the first attachment).
Symptom: Flow run history shows hundreds of runs, most of which terminate on the attachment condition.
Cause: The trigger's "Only with Attachments" filter was left at No.
Fix: Edit the trigger, expand advanced options, set "Only with Attachments" to Yes. Note that changing trigger settings requires all existing runs to complete before the new settings take effect. You may also want to implement a subject line filter at the trigger level to further reduce noise.
Symptom: Create file action throws an error: The specified path '/Shared Documents/NonExistent' does not exist.
Cause: You typed a folder path that doesn't exist and contains more than one level of non-existent folders. Create file creates missing folders only one level deep.
Fix: Pre-create the folder structure in SharePoint, or add a Create new folder action before the Create file action to ensure the target folder exists. For the year-based folder pattern in the exercise, add a Create new folder step each time the flow runs — SharePoint will return an error if the folder already exists, so wrap it in a scope and configure its failure as acceptable using the error handling techniques covered in the linked lesson.
Symptom: The email sent to the original sender after approval shows blank values where the approver name and comments should be.
Cause: The responses array from the approval action is empty or the expression path is wrong. outputs('Start_and_wait_for_an_approval')?['body/responses'][0]?['responder/email'] only works if at least one response exists.
Fix: Always guard approval response expressions with the if(empty(...), 'fallback value', ...) pattern. The approval action's status being Approved or Rejected doesn't guarantee the responses array has the structure you expect — in edge cases like automatic rejection on approval expiry, the array may be empty.
Symptom: Approvers receive approval requests for tiny .png or .gif files that are email signatures or inline images, not actual documents.
Cause: HTML emails embed images as attachments. The trigger with "Only with Attachments" can't distinguish between meaningful attachments and inline image attachments.
Fix: Add a condition at the top of your Apply to Each loop:
and(
not(equals(items('Apply_to_each')?['contentType'], 'image/png')),
not(equals(items('Apply_to_each')?['contentType'], 'image/gif')),
not(equals(items('Apply_to_each')?['contentType'], 'image/jpeg')),
greater(items('Apply_to_each')?['contentBytes'] | length, 1024)
)
The size check (greater than 1024 bytes) is an additional filter that eliminates the tiny tracking pixels that sometimes show up as attachments. If this condition is false, skip to the next iteration — in Power Automate you can't continue a loop directly, but you can wrap the entire loop body in an if-yes branch of this condition and leave the if-no branch empty.
Warning
Filtering by content type alone is not foolproof. Some vendors send important documents as JPEG scans. If your use case involves scanned documents, skip the image content-type filter and rely only on a minimum file size threshold (e.g., greater than 50KB).
Symptom: The Teams channel shows the notification message, but the approver never gets an approval request email and nothing shows in their Power Automate Approvals center.
Cause: The "Assigned to" field in the Start and wait for an approval action contains an email address that doesn't match an active Microsoft 365 user, or it contains a distribution list alias rather than a user principal name.
Fix: Approval requests must go to individual M365 users (or M365 group email aliases in some configurations, but this is unreliable). Use the person's actual UPN (usually their email address). Test by hardcoding your own email first to confirm the approval request arrives, then switch to the approver's email.
You've built a document intake pipeline that spans three Microsoft 365 services and handles the full lifecycle of an emailed attachment: receipt, storage, approval, and confirmation. The specific patterns you should now feel confident applying elsewhere include:
The flow you've built today handles the common case cleanly. Here's where to go next depending on your real-world needs:
If your approval process has multiple stages — for example, a manager approves first, then finance approves — read the multi-stage approval chains article to understand how to chain sequential and parallel approvals without losing the SharePoint context between stages.
If you need routing logic — different approvers depending on the document type, the sender's department, or the attachment's size — the dynamic approval routing article gives you the conditional routing pattern that slots directly into this flow.
If you want to extract data from the attachments themselves — parsing invoice numbers from PDFs, reading table data from Excel files — that's a step up in complexity covered in automating data collection from email attachments. Once you've combined the intake pipeline with data extraction, you're building something that genuinely replaces a category of manual data entry work.
If your flow starts misbehaving in production, the most useful debugging tool is run history combined with Flow Checker. The run history and Flow Checker article covers how to read the execution timeline, inspect input/output at each action, and identify whether a failure is a logic error, an expression error, or a connector error — three very different things with very different fixes.
The pattern you've built isn't just a document intake flow. It's a template for any process that involves receiving external inputs by email, persisting them to a managed system, routing them for human review, and closing the loop with the submitter. Swap out the specifics and you've got the architecture for HR onboarding documents, client deliverable submissions, compliance certifications, vendor invoices, and dozens of other high-value automation targets.