The goal is simple to state and a little fiddly to build:
When somebody submits a Microsoft Form, take any files they uploaded and email them as real file attachments to a fixed recipient.
The reason it’s fiddly is that Microsoft Forms doesn’t hand you the uploaded files directly. It hands you a chunk of text describing where those files now live in OneDrive. The flow’s job is to follow that trail — read the description, open OneDrive, pull each file out, and stitch the lot together into an Outlook email.
Why this is worth a write-up
Power Automate’s selling line is “no-code, drag-and-drop.” For most simple flows that holds. The moment a form involves files, the convenience evaporates. Microsoft Forms returns the file list not as an array but as a stringified JSON array; the OneDrive connector wants a compound id ({driveId}.{id}) that nothing on the screen tells you to build; Outlook’s Send Email V2 needs a very particular {Name, ContentBytes} attachment shape; and none of those quirks appear in the official documentation. You learn them in order, by repeatedly running flows that fail in slightly different ways.
There are plenty of tutorials online. None of the ones I found walk the whole pipeline satisfactorily. Most either skip the empty-state guard (so the flow breaks the first time a user submits with no files), accept Power Automate’s auto-generated JSON schema verbatim (which is fragile in ways that are never explained), or stop at “save the file to SharePoint” because that’s where the connector’s first happy path ends. The friction of bridging from “a user uploaded a file via Forms” to “the file shows up as an attachment in someone’s inbox” is genuinely underserved.
The intake half of this flow — steps 1–7 below — is where most of the friction lives, and it’ll look very similar in any Power Automate flow that needs to pull uploaded files out of Forms. The final step (the email send) is the part that’s specific to this use case; if you were routing the files somewhere else instead, the early steps would still apply.
Email attachment delivery is the most direct way to push files out of the Microsoft 365 silo. OneDrive can be configured to share externally — most tenants allow it — but a shared link is still a link: the recipient has to click through, the link may be scrubbed by their organisation’s filters, and what you’ve delivered is a pointer rather than the bytes themselves. Attachments put the file directly in the recipient’s inbox; the email itself becomes the audit record. Microsoft Forms only offers the file-upload question type to forms restricted to people inside the organisation; anonymous and external respondents can’t upload at all. Many people read that as a dead end for any “collect files from contractors / suppliers / the public” workflow. Read the other way, it’s the right shape: an authenticated internal user — somebody whose identity you have on record — is the intake gate, and the flow then liberates each file from the tenant silo and delivers it onwards. Without the flow, files are trapped in the uploader’s OneDrive; without the upload restriction you’d have an open file-drop endpoint and a very different set of security questions to answer. The combination is the point.
(GDPR caveat applies, as ever: once a file crosses the tenant boundary you’ve lost some of the control surface — encryption-at-rest, access logging, revocation, MIP labels — that Microsoft’s compliance posture gave you. The alternative is sharing via OneDrive link instead of attachment, which keeps the file under tenant control but adds a click for the recipient and risks their organisation’s link filters stripping it. Pick deliberately.)
This is also the more elegant of two attempts. An earlier version I built worked but leaned harder on manual steps and bespoke handling. The pipeline below is shorter, more legible, and — once you’ve understood the JSON-string-not-array trick — easier to reuse in other shapes. Half of what makes a “Things I Have Learned” entry worth writing is the second attempt, not the first.
The cast of characters
Three Microsoft 365 services do the actual work. Power Automate is the conductor.
- Microsoft Forms — where the user fills in the form and uploads files.
- OneDrive for Business — where Forms automatically deposits the uploaded files.
- Office 365 Outlook — what eventually sends the email.
- Power Automate — the workflow engine that connects them.
All three connectors must authenticate with the same Microsoft 365 account. That matters: the OneDrive the flow reads from has to be the same one Forms wrote to.
What happens, step by step
The flow runs once per form submission. Here is what it does, in order.
1. Wait for a submission (trigger)
The flow sits idle and listens to one specific Microsoft Form via a webhook. The moment a response is submitted, Forms pings Power Automate and the flow wakes up.
2. Get the full response (Get response details)
The webhook only tells the flow “response X just arrived.” To see what was actually answered, the flow makes a follow-up call to Forms asking for the full body of response X. The result is an object where each form question becomes a property, keyed by the question’s internal field id.
3. Build the email body (Compose)
A Compose action produces the text that will go into the email body. Compose is the Power Automate equivalent of a “let” statement — it captures a value once so later actions can refer back to it without recomputing.
In the simplest version, the input is a static string (a placeholder). In a real version, you’d interpolate the form’s textual answers, for example:
Requestor: @{outputs('Get_response_details')?['body/<requestor-question-id>']}
Description: @{outputs('Get_response_details')?['body/<description-question-id>']}
Each <...-question-id> is the random field id of one form question, visible in the dynamic-content picker.
4. Defend against empty uploads (ComposeGuard)
This is the first place the JSON wrinkle bites you, so it’s worth dwelling on.
The form’s file-upload question returns its answer as a stringified JSON array — text that looks like JSON but is delivered as a string. If the user uploaded three files, you get a string describing three files. If they uploaded none, you get an empty string, not an empty array.
Downstream actions need a real array. Feeding them an empty string blows up the run.
ComposeGuard is a one-line if:
if(
empty(outputs('Get_response_details')?['body/<file-question-id>']),
json('[]'),
json(outputs('Get_response_details')?['body/<file-question-id>'])
)
In English: if the field is empty, return an empty array; otherwise, parse the field as JSON and return whatever you got. Either way the next step receives a real array, possibly empty.
5. Start an empty attachments list (Initialize variable)
Create a variable called emailAttachments of type array, initial value []. It starts empty; each iteration of the loop below will push one entry onto it, so by the time the loop finishes it looks like:
[
{ "Name": "photo1.jpg", "ContentBytes": "<bytes>" },
{ "Name": "photo2.jpg", "ContentBytes": "<bytes>" }
]
6. Parse the file list into typed objects (Parse JSON)
Now the flow turns the array from step 4 into a proper, typed list of file descriptors. The schema is:
{
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"link": { "type": "string" },
"id": { "type": "string" },
"type": {},
"size": { "type": "integer" },
"referenceId": { "type": "string" },
"driveId": { "type": "string" },
"status": { "type": "integer" },
"uploadSessionUrl": {}
},
"required": ["name", "link", "id", "size", "referenceId", "driveId", "status"]
}
}
| Property | Type | Meaning |
|---|---|---|
name | string | The original filename. |
link | string | A SharePoint/OneDrive web URL. |
id | string | The file’s id within the drive. |
type | (any) | Reserved by Forms; left untyped on purpose. |
size | integer | Size in bytes. |
referenceId | string | Internal Forms reference. |
driveId | string | The OneDrive that holds the file. |
status | integer | Upload status code. |
uploadSessionUrl | (any) | Usually null; left untyped on purpose. |
type and uploadSessionUrl are declared as {} — meaning “accept any value.” Forms doesn’t emit a consistent type for them; forcing string would fail on perfectly valid responses where the value is null. They’re also intentionally left out of required: nothing downstream actually reads them, and including them in required makes the parse fragile against future connector changes.
This is the “Power Automate doesn’t do types well” pain point in a nutshell — the tightest schema you can write is the one that makes the connector behave, not the one a textbook would draw.
7. Loop over every file (Apply to each)
For each item in the parsed list, three things happen. The loop has concurrency set to 10, so up to ten files are processed at once.
7a. Get file metadata
Ask OneDrive: “give me the metadata for the file at {driveId}.{id}.” That dotted form — drive id, dot, file id — is what the OneDrive metadata call wants. The metadata returns the file’s display name and, importantly, its id in a different encoding — the one the Get file content call needs. The two-step exists because the OneDrive connector uses different id formats for different operations: Forms hands you one form, Get-content needs the other, and the metadata call is the bridge.
7b. Get file content
Using the id from the metadata, fetch the actual bytes of the file. inferContentType is on, so the connector reads each file’s signature and labels it with the right MIME type — which is what lets the same flow carry PDFs, Word documents, spreadsheets, images, or anything else the form allows. Without this, Outlook would attach everything as application/octet-stream and the recipient’s mail client would have to guess at how to open each file.
7c. Append to the attachments array
Push a small object onto emailAttachments:
{
"Name": "<filename from metadata>",
"ContentBytes": "<file bytes>"
}
Outlook’s Send Email V2 action expects exactly this shape, so the loop is essentially translating “OneDrive file” into “Outlook attachment.”
8. Send the email
Once the loop has finished and emailAttachments is fully populated, Outlook sends a single message:
- To: the recipient address (hardcoded in the action; use
;between multiple addresses). - Subject: the subject line.
- Body: the body string from step 3, wrapped in a
<p>tag. - Attachments: the
emailAttachmentsarray. - Importance: Normal.
The shape of the dependency tree
Power Automate runs actions in the order set by their runAfter links, not their visual position. The order here is:
Trigger
→ Get response details
→ Compose
→ ComposeGuard
→ Initialize variable
→ Parse JSON
→ Apply to each (Get metadata → Get content → Append)
→ Send email
Each step waits for the previous one to succeed before running. There is no error-branching in this flow — if any step fails, the run stops there.
Things you’ll need to fill in
When adapting the flow to your own form:
- Form ID. Set on the trigger and on
Get response details. Both must point at the same form. - File-upload question’s field id. Used inside
ComposeGuard. Each question has a random id you can copy from the dynamic-content picker or the raw JSON of any response. - Recipient address. In
Send an email (V2). Multiple recipients separated by;. - Subject line. Same place.
- Email body. Currently a placeholder. To include form answers, replace the Compose input with text that interpolates fields from
Get_response_details.
Closing thought
The hard part of this flow isn’t the steps — it’s knowing they exist. Once you’ve debugged the JSON-string-not-array trick once, every “collect files from a form, deliver them somewhere else” workflow you build afterwards takes minutes instead of hours. That’s what makes the pattern worth writing down, its also an aide-mémoire for myself.