# Outlook attachments saved automatically: three ways, no VBA

> Outlook rules can't save attachments, and new Outlook has no VBA. Two free Microsoft tools and a 56-line script can, once you guard against three traps.

- Published: 2026-10-19
- Author: 28creation Desk
- Format: Guide
- URL: https://28creation.com/articles/save-outlook-attachments-automatically/

## The short read

**Saving every attachment under its own name lost two of three invoices in our 12-email test.**

- Outlook rules can't save attachments, and the new Outlook for Windows doesn't support VBA.
- A free cloud flow, Power Automate for desktop or a 56-line Python script can, with an inline filter and unique file names.

### What you can do with this

> If your office uses Microsoft 365, build the cloud flow with an Is Inline filter and a date-plus-guid file name. New attachments then land in OneDrive without logos or overwrites.

---

Outlook's rules can move, flag, forward or delete a message, but [none of their actions](https://support.microsoft.com/en-us/office/manage-email-messages-by-using-rules-in-outlook-c24f5dea-9465-4df4-ad17-a50704d66c59) saves an attachment to a folder. The old fix was a VBA macro, and the [new Outlook for Windows doesn't support VBA](https://learn.microsoft.com/en-us/microsoft-365-apps/outlook/get-started/state-of-com-add-ins). If you still drag invoices out of your inbox by hand, three tools without macros can do it for you.

They are a Power Automate cloud flow, Power Automate for desktop and a short Python script. None costs extra for an office already on Microsoft 365, and each fits a different version of Outlook. Each also faces the same three traps: signature logos that pile up as junk, files with the same name that collide, and names Windows refuses.

We built 12 sample emails with those traps in them and ran them through a naive save and through our script. The naive save quietly lost two of three invoices. Below are the results, the exact settings for each tool, and the limits Microsoft documents for them. All limits and license terms were read on October 19, 2026.

## Why the rule and the macro are dead ends

Microsoft's rules page lists what a rule can do: move a message to a folder, delete it, change its importance, flag it or forward it. Saving an attachment is not on the list. Classic Outlook can run extra rule actions only through [third-party add-ins](https://support.microsoft.com/en-us/office/manage-email-messages-by-using-rules-in-outlook-c24f5dea-9465-4df4-ad17-a50704d66c59), and the page says "Microsoft doesn't provide custom action add-ins".

The answers you will find on old forums use a rule with the "Run a script" action, which calls a VBA macro. That action was switched off by a security update. An answer from Microsoft support staff on its Q&A forum [explains how to bring it back](https://learn.microsoft.com/en-us/answers/questions/1332151/how-get-run-script-option-in-outlook-365-mail-rule) with a registry value called `EnableUnsafeClientMailRules`, and the name tells you what Microsoft thinks of it. It also works only in classic Outlook, because the new Outlook for Windows runs [neither VBA nor COM add-ins](https://learn.microsoft.com/en-us/microsoft-365-apps/outlook/get-started/state-of-com-add-ins).

Our view: don't build anything new on that route. It needs a security setting turned off on every PC that runs it, and it dies the day your company moves to the new Outlook.

## Choose the way that fits your Outlook

The right tool depends on two facts: which Outlook you use, and whether your mailbox is a work account in Microsoft 365.

| | Cloud flow | Power Automate for desktop | Python script |
|---|---|---|---|
| Works with | Microsoft 365 work or school mailbox, any Outlook version | [Classic Outlook](https://learn.microsoft.com/en-us/power-automate/desktop-flows/actions-reference/outlook) on a Windows PC; not new Outlook | Any Outlook that saves a message as an .eml file |
| Cost | [Free license](https://learn.microsoft.com/en-us/power-platform/admin/power-automate-licensing/types) for work and school accounts | Free; personal Microsoft accounts [can sign in](https://learn.microsoft.com/en-us/power-automate/desktop-flows/requirements) | Free; needs Python 3 |
| Runs | By itself when mail arrives | When you start it | When you start it |
| Saves to | OneDrive | A folder on the PC | A folder on the PC |
| Best for | New mail, from now on | Clearing a full folder in one run | Mac, new Outlook, the web version |
| Main limit | [Skips messages over 50 MB](https://learn.microsoft.com/en-us/connectors/office365/), or your admin's lower limit | No ARM PCs; scheduling from the cloud needs a premium license | You save each message by hand first |

Many offices will want two of these: the cloud flow for everything that arrives from today, and a one-off sweep for the backlog already sitting in the inbox.

## Learn the three traps from 12 test emails

We built 12 sample emails in standard email format (.eml) with Python's email library, covering what a real inbox throws at a "save every attachment" job.

Three vendors each sent a file called `invoice.pdf`. Two messages carried a signature logo called `image001.png`, and one held a pasted screenshot. One file name, `Q3: "final" report?.pdf`, used characters Windows refuses. The set also had a forwarded email with a contract inside, a resent duplicate, a Swedish file name with å, ä and ö, and a file called `winmail.dat`.

First we ran a naive script that saves every named attachment under its own name into one folder. It wrote 12 files, and 8 were left at the end.

- **Overwrites.** Four writes of `invoice.pdf` left one file. The invoices from vendors B and C were gone, overwritten without a warning, and the survivor was vendor A's resent copy.
- **Junk.** Both signature logos were saved as `image001.png`, and the second overwrote the first. Logos with other names would each add a file.
- **Bad names.** Our sandbox runs Linux, which accepted `Q3: "final" report?.pdf`. On Windows, the colon, the quotes and the question mark are all [reserved characters](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file), and the save would fail.

Then we ran our script, shown in step 3. It saved all 9 real attachments, including the contract inside the forwarded email. It skipped 3 inline images and 1 exact duplicate, and gave every file a name like `2026-10-02_vendor-b.com_invoice.pdf`. A second run over the same emails saved nothing and reported 10 duplicates, so running it twice does no harm.

The skipped images include the pasted screenshot. That is the trade-off in the cloud flow and the script below: a filter that drops signature logos also drops images people paste into the body. If your team mails screenshots that matter, keep inline images and delete the logos later.

## 1. Build a cloud flow that saves each new attachment

This needs a Microsoft 365 work or school account. Power Automate's [free license](https://learn.microsoft.com/en-us/power-platform/admin/power-automate-licensing/types) covers it, because both the [Office 365 Outlook](https://learn.microsoft.com/en-us/connectors/office365/) and [OneDrive for Business](https://learn.microsoft.com/en-us/connectors/onedriveforbusiness/) connectors are standard. The OneDrive connector [needs a work or school account](https://learn.microsoft.com/en-us/connectors/onedriveforbusiness/), not a personal one. We had no Microsoft 365 tenant in our sandbox, so the steps below come from Microsoft's connector reference, not from a flow we ran.

1. In Power Automate, create an automated cloud flow with the trigger **When a new email arrives (V3)** from Office 365 Outlook.
2. Set **Only with Attachments** to Yes and **Include Attachments** to Yes. Narrow it with **Folder**, **From** or **Subject Filter** if only some mail matters, for example `From: invoices@vendor.com`.
3. Add an **Apply to each** loop over the trigger's **Attachments**.
4. Inside the loop, add a **Condition**: **Is Inline** is equal to `false`. It does the same job as the logo filter in our script.
5. Under **If yes**, add OneDrive for Business **Create file**. Pick a **Folder Path** such as `/Email attachments`, set **File Content** to the attachment's **Content**, and set **File Name** to this expression:

```
concat(formatDateTime(utcNow(), 'yyyy-MM-dd'), '_', guid(), '_', item()?['name'])
```

The date is the day the flow ran, in UTC, and it sorts the folder. The [guid()](https://learn.microsoft.com/en-us/azure/logic-apps/expression-functions-reference) adds a randomly generated 36-character code, so two vendors' `invoice.pdf` files can never land on the same name. It makes names long; that is the price of never losing a file. You don't need to clean the name yourself, because Create file [replaces characters OneDrive disallows](https://learn.microsoft.com/en-us/connectors/onedriveforbusiness/) with `_`.

## Watch the gaps Microsoft documents in the cloud flow

The [Office 365 Outlook connector reference](https://learn.microsoft.com/en-us/connectors/office365/) lists these, and each one can leave a gap or a bad file in the folder:

- The trigger "will skip any email that has a total message size greater than the limit put by your Exchange Admin or 50 MB, whichever is less". It may also skip protected emails.
- Forwarded emails attached to a message, calendar items and bounce reports are "not supported by the connector". Attached .eml, .msg and .ics files appear only when they arrive inside a .zip. Our script handled the forwarded contract; this flow will not.
- For digitally signed emails, the trigger output "may contain incorrect attachment content".
- With **Include Attachments** set to Yes, the trigger waits for every download and can time out when many emails with attachments arrive at once. Microsoft's fix: set it to No and fetch each file with the **Get Attachment (V2)** action instead.
- If your company uses Safe Attachments with Dynamic Delivery, the trigger may run twice, the first time with no attachments. The loop in step 3 does nothing on the empty run, so this one costs you nothing.
- Each connection allows 300 calls a minute to Outlook and [100 a minute to OneDrive](https://learn.microsoft.com/en-us/connectors/onedriveforbusiness/).

Our view: this is the right default for a Microsoft 365 office. It runs without anyone's PC switched on, costs nothing extra, and the folder fills as mail arrives. On the free license it [can't be shared](https://learn.microsoft.com/en-us/power-platform/admin/power-automate-licensing/types), so it lives in one person's account. Write down whose.

## 2. Sweep a whole folder with Power Automate for desktop

Power Automate for desktop drives the Outlook app on your PC, so it can work through a whole folder of old messages in one run.

It needs Windows 10 or 11, Home editions included, and [doesn't run on ARM processors](https://learn.microsoft.com/en-us/power-automate/desktop-flows/requirements). Its Outlook actions work only with [classic Outlook](https://learn.microsoft.com/en-us/power-automate/desktop-flows/actions-reference/outlook), not the new one. Installing from the Microsoft Store needs no admin rights; the MSI installer does. We had no Windows PC in this run, so we did not test these steps.

1. Create a desktop flow and add **Launch Outlook**.
2. Add **Retrieve email messages from Outlook**. Set **Account** to your mailbox name, **Mail folder** to the folder (for a subfolder, the full path, such as `Inbox\Invoices`), and **Retrieve** to `Unread email messages only`. Leave **Mark as read** on.
3. In the same action, set **Attachments** to `Save attachments` and **Save attachments into** to a folder that already exists. A missing folder [stops the flow](https://learn.microsoft.com/en-us/power-automate/desktop-flows/actions-reference/outlook) with "Directory for saving attachments not found".
4. Add **Process email messages in Outlook** with the operation `Move email messages to mail folder`, pointing at a folder such as `Inbox\Saved`. The next run then can't pick up the same messages.
5. Add **Close Outlook**, then run the flow.

Microsoft's reference does not say how this action names files, whether it skips inline images, or what it does when two attachments share a name. Before you point it at a full folder, send yourself two emails that each carry a file called `invoice.pdf`, plus one with a signature logo, and look at what lands in the folder.

The other catch is the schedule. With a free personal or work account, the flow runs when you press Run. [Triggering desktop flows from the cloud](https://learn.microsoft.com/en-us/power-automate/desktop-flows/requirements) needs a premium organization account and Windows Pro or Enterprise. That makes this tool best for the backlog, and the cloud flow better for what arrives next week.

## 3. Run a script on messages saved as .eml files

This route works for the new Outlook, Outlook on the web and any computer with Python 3. It is the one we ran end to end.

Its weakness is the first step. Outlook on the web saves a message you select through **More actions** and then **Download**, as [an EML or MSG file](https://support.microsoft.com/en-US/Outlook/mail/save-an-outlook-message-as-a-eml-file-a-pdf-file-or-as-a-draft) in your Downloads folder. The new Outlook uses **More actions** and then **Save as**; Microsoft's page doesn't name the file type, so check that the files end in .eml. The script reads only .eml files.

1. Save the messages as .eml files into one folder, for example `Downloads/mail`.
2. Save the 56-line script below as `save_attachments.py`.
3. Run `python3 save_attachments.py Downloads/mail Downloads/attachments`.

```python
"""Save the attachments from a folder of .eml files, skipping inline images.

Usage: python3 save_attachments.py <folder of .eml files> <output folder>
Names each file YYYY-MM-DD_sender-domain_original-name, never overwrites,
and skips exact duplicates, so it is safe to run again. Standard library only.
"""
import sys, re, hashlib, pathlib, email, email.policy
from email.utils import parsedate_to_datetime, parseaddr

BAD = r'[\\/:*?"<>|\x00-\x1f]'        # characters Windows will not accept in a file name

def clean(name):
    return re.sub(BAD, "_", name).strip(" .")[:150] or "attachment"

def is_inline(part):
    disp = part.get_content_disposition()  # 'inline', 'attachment' or None
    return disp != "attachment" and (disp == "inline" or part["Content-ID"] is not None)

def main(src, out):
    src, out = pathlib.Path(src), pathlib.Path(out)
    out.mkdir(parents=True, exist_ok=True)
    seen = {hashlib.sha256(p.read_bytes()).hexdigest() for p in out.iterdir() if p.is_file()}
    saved = inline = dupes = 0
    for eml in sorted(src.glob("*.eml")):
        msg = email.message_from_bytes(eml.read_bytes(), policy=email.policy.default)
        day = parsedate_to_datetime(msg["Date"]).strftime("%Y-%m-%d") if msg["Date"] else "undated"
        domain = parseaddr(msg["From"] or "")[1].rpartition("@")[2] or "unknown"
        for part in msg.walk():
            if part.is_multipart():
                continue                        # containers, including forwarded emails
            name = part.get_filename()
            if not name and part.get_content_maintype() == "text":
                continue                        # the message body
            if is_inline(part):
                inline += 1
                print(f"  skip inline  {eml.name}: {name or part['Content-ID']}")
                continue
            data = part.get_payload(decode=True) or b""
            digest = hashlib.sha256(data).hexdigest()
            if digest in seen:
                dupes += 1
                print(f"  skip dupe    {eml.name}: {name}")
                continue
            seen.add(digest)
            target = out / clean(f"{day}_{domain}_{name or 'attachment'}")
            n = 2
            while target.exists():
                target = target.with_name(f"{target.stem}_{n}{target.suffix}")
                n += 1
            target.write_bytes(data)
            saved += 1
            print(f"  saved        {target.name}")
    print(f"saved {saved}, inline skipped {inline}, duplicates skipped {dupes}")

if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])
```

### What it printed on our test set

This is its real output on our 12 sample emails, run with Python 3.11 on Linux in under a tenth of a second (three runs took 0.080 to 0.091 seconds):

```
  skip inline  01.eml: image001.png
  saved        2026-10-01_vendor-a.com_invoice.pdf
  saved        2026-10-02_vendor-b.com_invoice.pdf
  saved        2026-10-03_vendor-c.com_invoice.pdf
  skip inline  04.eml: image001.png
  saved        2026-10-05_client.com_report.xlsx
  saved        2026-10-06_client.com_Q3_ _final_ report_.pdf
  saved        2026-10-07_leverantor.se_Faktura_åäö.pdf
  saved        2026-10-08_client.com_contract.pdf
  saved        2026-10-12_builder.com_photo.jpg
  skip inline  10.eml: <shot1>
  skip dupe    11.eml: invoice.pdf
  saved        2026-10-15_client.com_winmail.dat
saved 9, inline skipped 3, duplicates skipped 1
```

### How it decides what to skip

Python's [`get_content_disposition()`](https://docs.python.org/3/library/email.message.html) returns `inline`, `attachment` or nothing. The script treats a part as an inline image when it is marked inline, or when it carries a Content-ID and is not marked as an attachment, which is how our pasted screenshot arrived. A forwarded email counts as a container in Python's library, so [`walk()` goes inside it](https://docs.python.org/3/library/email.message.html) and saves the contract it carries. Duplicates are caught by a fingerprint of the file's content, so a resent invoice is skipped even under a new subject.

Two limits, both from our run. The script saves a `winmail.dat` file as it is and does not unpack what it holds. And we ran the script on Linux only. The name cleaning follows Microsoft's [list of reserved characters](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file), but we did not run it on a Windows PC.

## Check the folder after the first week

Whichever way you pick, a saving job that breaks looks exactly like a quiet week with no invoices. After seven days, count the files in the folder against the emails with attachments in the inbox for the same days. A gap or a broken file points to one of the documented limits: a message over 50 MB, a signed email, an attached email the cloud flow can't read.

If you are still deciding whether attachments are the job worth automating first, a [one-week tally](/articles/noticing-what-to-automate/) will tell you. Our guide to [scheduled scripts that fail silently](/articles/tiny-automation-in-code/) has the checks that make a broken job speak up, and the [weekly report guide](/articles/automate-weekly-report/) uses the same free Power Automate license for its schedule. When the data you need sits in the email text rather than an attachment, our [email-to-Excel guide](/articles/email-data-into-excel/) tests three ways to pull it into a table. More guides like this one are under [automation](/topics/automation/).

Then put the count in your calendar as a quarterly reminder today, before the folder fills up and nobody remembers what should be in it.

## Sources

- Microsoft Support, [Manage email messages by using rules in Outlook](https://support.microsoft.com/en-us/office/manage-email-messages-by-using-rules-in-outlook-c24f5dea-9465-4df4-ad17-a50704d66c59)
- Microsoft Q&A, [How to get the "Run a script" option in Outlook 365 mail rules](https://learn.microsoft.com/en-us/answers/questions/1332151/how-get-run-script-option-in-outlook-365-mail-rule) (July 2023)
- Microsoft Learn, [Identify COM add-ins in your organization](https://learn.microsoft.com/en-us/microsoft-365-apps/outlook/get-started/state-of-com-add-ins)
- Microsoft Learn, [Office 365 Outlook connector reference](https://learn.microsoft.com/en-us/connectors/office365/)
- Microsoft Learn, [OneDrive for Business connector reference](https://learn.microsoft.com/en-us/connectors/onedriveforbusiness/)
- Microsoft Learn, [Types of Power Automate licenses](https://learn.microsoft.com/en-us/power-platform/admin/power-automate-licensing/types) (updated September 22, 2026)
- Microsoft Learn, [Expression functions reference](https://learn.microsoft.com/en-us/azure/logic-apps/expression-functions-reference)
- Microsoft Learn, [Outlook actions reference for Power Automate for desktop](https://learn.microsoft.com/en-us/power-automate/desktop-flows/actions-reference/outlook)
- Microsoft Learn, [Power Automate for desktop prerequisites and limitations](https://learn.microsoft.com/en-us/power-automate/desktop-flows/requirements)
- Microsoft Support, [Save an Outlook message as a .eml file, a PDF file, or as a draft](https://support.microsoft.com/en-US/Outlook/mail/save-an-outlook-message-as-a-eml-file-a-pdf-file-or-as-a-draft)
- Microsoft Learn, [Naming files, paths, and namespaces](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file)
- Python documentation, [email.message: representing an email message](https://docs.python.org/3/library/email.message.html)
- 28creation test, October 19, 2026: 12 sample .eml files built with Python's email library and run through a naive save and the script above; the output is reproduced in step 3
