Ground Truth field notes · defensive security

Home›Email Security›Delivery & mail flow

Deep dive + field case · mail flow automation

One auto-reply for 21 mailboxes, not 21 rules

A team wanted every external email to its 21 mailboxes answered with one standard reply — every message, not just the first from each sender, nobody marked "Away," all replies from one central address. No built-in Exchange or Outlook feature does all five of those at once. Here's the design that does, and why the obvious ones quietly fail each requirement.

"Just put an auto-reply on the mailbox" is one of those requests that sounds like a five-minute job and turns into an architecture decision. The moment you write down what the client actually wants, every built-in feature fails at least one requirement — and the naive fix (one automation per mailbox) scales into a maintenance mess that bites the first time anyone changes the wording. The interesting part of this build wasn't the automation; it was the shape of it.

The one idea worth keeping: when "N mailboxes each need the same behaviour," don't build N copies of anything. Funnel them into one place and decide once.

01Five requirements that kill the obvious options

Write the requirements down first. The feature comparison then makes the decision for you.

The ask, pinned down, was five rules:

  1. External senders only. Internal staff never trigger a reply.
  2. Every email gets the reply, not just the first one from each sender.
  3. Nobody shows as "Away" in Outlook or Teams.
  4. All replies come from one central address (licensing@), not from each person's mailbox.
  5. Leave cover: when a team member is actually on leave, their own out-of-office answers instead; if they're the main recipient, only their OOF goes out.

Line those five up against the mechanisms people reach for, and every row but the last fails somewhere:

OptionExternal onlyEvery emailNo "Away"One central senderVerdict
Out-of-office per mailboxYesNo — once per senderNo — shows AwayNo — each mailboxRejected
Outlook "reply with template" ruleYesNo — once per sessionYesNo — each mailboxRuns only while that PC's Outlook is open
Mail-flow rule aloneYesn/aYesn/aCan only redirect/reject — can't compose a reply
21 flows, one per mailboxYesYesYesYesDuplicate replies when several staff are addressed; 21 flows to maintain
One mail-flow rule + one flowYesYesYesYesChosen

The weakest of the lot is the one teams usually already have: per-user Outlook rules. They run on each person's PC, stop the moment Outlook is closed, and can't be seen or managed centrally. The out-of-office option fails three rules at once (once-per-sender, marks you Away, sends from each mailbox). And a mail-flow rule on its own is a dead end for this job — transport rules can redirect, copy or reject a message, but they cannot generate a reply. You need something that can compose mail. That's the flow's job.

02The pattern: funnel, then decide

One transport rule turns "21 mailboxes" into "one inbox." One flow reads that inbox and replies once.

The redesign that made it work is a single move: a mail-flow rule BCCs a copy of every qualifying external email into one hidden intake mailbox — one copy per message, however many of the team it was sent to. A single flow watches that one inbox, works out which team members were actually addressed, and sends exactly one reply. The team's real mailboxes are never touched.

FUNNEL: N MAILBOXES → ONE INTAKE → ONE REPLY External email sent to any of the team's 21 mailboxes To: staffA, staffB Cc: staffC delivered normally Mail-flow rule external + in scope? skip OOF / bulk / auto BCC one copy → Hidden intake one copy per email, however many were addressed One flow decides & sends one reply from licensing@ The alternative — one flow per mailbox — means 21 flows to maintain and duplicate replies when several staff are on one email. Funnelling makes "exactly one reply per email" free.
The whole design in one picture. The transport rule does the fan-in (every qualifying message becomes one copy in one place); the flow does the thinking (who was addressed, is anyone away, reply once). Splitting it this way is what turns 21-of-everything into one-of-everything.

03How the flow decides

Read the copy, drop what shouldn't get a reply, find who was really addressed, reply once — unless everyone's away.

The transport rule is deliberately dumb; all the judgement lives in the flow. Running on the one intake mailbox, it:

  1. Picks up the copy (polls the intake inbox every minute).
  2. Filters out what must never get a reply: internal senders, no-reply addresses, and anything whose subject marks it as an auto-reply (the loop guard — more on that below).
  3. Works out the main recipients: the team mailboxes on the To line, or on Cc if none are on To.
  4. Reads each one's out-of-office via Microsoft Graph (read-only).
  5. Replies once from licensing@ — unless every main recipient is away, in which case their own out-of-office messages answer and the central reply stays quiet.
THE DECISION, ONCE PER EMAIL Intake copy one per email Drop these internal · no-reply · auto Main recipients To, else Cc Read each OOF Graph, read-only all away? no One reply from licensing@ yes Their own OOF answers central reply stays quiet
The "away" rule is the subtle one. During the pilot the client added that the main To recipient's out-of-office should take priority over CC'd mailboxes — which became a change to one step in the flow, tested the same morning. Centralising the logic is what made that a one-line change instead of twenty-one.

04How the design got here

The first build was one flow per mailbox. It worked for one and fell apart at scale.

Worth showing the wrong turn, because it's the one everyone takes first. The initial build was a single flow watching a single mailbox: check the sender is external, check the mailbox has no out-of-office, reply. Fine for one. Then reality:

The one-line model

A per-item automation that triggers on one mailbox will tempt you to clone it N times. Resist it: a mail-flow rule that BCCs into a single intake mailbox turns "N mailboxes" into "one inbox," and one-reply-per-email falls out for free.

05What we hit along the way

Every one of these cost time during the pilot. They're the reason this is a field note and not a diagram.

SymptomRoot causeFix
Flow ran "successfully" but sent nothingThe test subject contained the word "autoreply" and tripped the loop filterTest with normal subjects; don't let your guard rail eat your test
Out-of-office check silently skipped; recipient addresses came back blankGroupMember.Read.All returns member IDs, not addressesAdd User.ReadBasic.All to resolve IDs to mailboxes
Rule didn't fire for newly added pilot members for over an hourTransport rules cache group membershipSwitch the rule to a direct recipient list (SentTo) while iterating
Rule seemed skipped when a mail gateway was in front of ExchangeGateway mail passes through Exchange twice; the gateway's own rule is skipped on the return passKeep your rule below the gateway rule; prove where it fires with message trace
Send failed 404 ErrorItemNotFoundSending from a shared mailbox needs Full Access, not just Send AsGrant Full Access on licensing@ (takes ~25 min to apply)
"Blank email" reported by the clientIt was the tester's own empty test message arriving in their inboxAlways put text in test bodies; confirm with message trace
A test showed both an OOF and the auto-replyTiming, not logic — the tester's out-of-office was switched off ~70 seconds before the flow ranLeave OOF on for several minutes when testing that branch

Message trace shows which rules matched, never why one didn't

Half of the list above looked like logic bugs and were really environment quirks — gateway double-pass, group-membership cache, permission timing. The only way through is to isolate: change one variable at a time and read the message trace (Get-MessageTraceDetailV2) to confirm where your rule actually fired. The trace tells you what happened to the message; it will never tell you why a rule you expected didn't run.

Myth

N mailboxes that need the same behaviour need N rules or flows.

Reality

Funnel them. One mail-flow rule BCCs every qualifying message into one intake mailbox, and one flow decides once. One wording change, one place to maintain.

Myth

Send As is enough to reply from a shared mailbox in a flow.

Reality

Sending from a shared mailbox needs Full Access too, or the send fails 404 ErrorItemNotFound. Grant both, and allow up to an hour to apply.

06The Exchange-side toolkit

Everything on the Exchange side is scripted and reusable. No secrets, tenant IDs or real user data — fill in one config file per client.

The build above isn't a one-off; it's seven PowerShell scripts that resolve the mailboxes, scan for existing replies, stand up the plumbing, go live, roll back and troubleshoot. The Power Automate flow and the Entra (Graph) app are set up separately. Here's the whole kit:

FileWhat it doesChanges anything?
00-Config.ps1Shared settings (domain, object names, paths), dot-sourced by all scriptsNo
01-Resolve-Mailboxes.ps1Names → Scope.csv via fuzzy name match (ANR), with a report of MULTIPLE / NOT FOUNDNo
02-Scan-ExistingReplies.ps1Finds every existing OOF and reply/forward/Outlook-only rule in scope, with full backupsNo
03-Setup-Prerequisites.ps1Intake mailbox, scope group, flow permissions, and the mail-flow ruleYes (one-time)
04-GoLive.ps1Backup → disable old replies → add to group → widen the rule → verify. Supports -WhatIfYes
05-Rollback.ps1Restores the rule, group, inbox rules and OOF from a go-live backup. Supports -WhatIfYes
06-Troubleshoot.ps1Message trace, rules hit, rule order, in-scope check, OOF view, permissionsNo

Order of use:

  1. 01 — resolve the client's name list into addresses; confirm the scope with the client.
  2. 02 — scan for existing replies; decide together what to switch off at go-live. Check Power Automate for stray flows too.
  3. 03 — with 1–2 pilot mailboxes in scope. Build the flow, pilot, test.
  4. Put the final list in Scope.csv; fill the teardown files from the scan.
  5. 04 -WhatIf, then 04. Smoke-test from an external address.
  6. Monitor a day or two; close the change. 05 only if you need to undo.
⇩  Download the toolkit — 7 scripts + config + samples (.zip)

The config file is the only thing you edit per deployment; every script reads it:

00-Config.ps1 — shared settings, dot-sourced by every script. Fill in once per client.

<#
  Central Auto-Reply toolkit - shared settings. Every script dot-sources this file.
  Fill in once per deployment. NEVER put secrets, client secrets or passwords here.
#>

# Tenant / naming
$Domain        = 'contoso.org'                                  # client's primary mail domain
$Prefix        = 'teamautoreply'                                # short name used for all objects

# Objects the solution uses
$FlowAccount   = "svc-automation@$Domain"                       # account that owns the flow + connections
$IntakeMailbox = "$Prefix-intake@$Domain"                       # hidden shared mailbox the flow watches
$ScopeGroup    = "$Prefix-scope@$Domain"                        # mail-enabled security group read by the flow (Graph)
$ReplyFrom     = "team@$Domain"                                 # central address replies are sent from
$RuleName      = "$Prefix - BCC external mail to intake"        # Exchange mail flow rule
$RuleComment   = 'INC0000000 / CHG0000000'                      # ticket / change reference

# In-scope mailboxes: one address per line in Scope.csv (column: Address).
# Build it with 01-Resolve-Mailboxes.ps1 or by hand.
$ScopeCsv      = Join-Path $PSScriptRoot 'Scope.csv'

# Where reports and backups go
$WorkRoot      = 'C:\Temp\CentralAutoReply'

function Get-ScopeAddresses {
    if (-not (Test-Path $ScopeCsv)) { throw "Scope.csv not found at $ScopeCsv" }
    @(Import-Csv $ScopeCsv | ForEach-Object { $_.Address.Trim() } | Where-Object { $_ })
}

function New-WorkFolder ([string]$Name) {
    $p = Join-Path $WorkRoot "$Name-$(Get-Date -Format yyyyMMdd-HHmm)"
    New-Item -ItemType Directory -Path $p -Force | Out-Null
    $p
}

The one script that changes anything structural is 03. It's the entire Exchange build — and the comments in it are the gotchas from the section above, written where they bite:

03-Setup-Prerequisites.ps1 — the whole Exchange build in one script: intake mailbox, scope group, flow permissions, and the mail-flow rule with its loop guards.

<#
  03 - Create the Exchange side of the central auto-reply (run ONCE per deployment)
  Creates: hidden intake mailbox, hidden scope group, flow-account permissions,
           mail flow rule that BCCs external mail for in-scope boxes to the intake.
  Start with 1-2 pilot mailboxes in Scope.csv; widen at go-live with 04-GoLive.ps1.
  Not here (do in Entra): Graph app with MailboxSettings.Read, GroupMember.Read.All,
  User.ReadBasic.All (application, admin-consented). Keep the secret out of every file.
#>
. "$PSScriptRoot\00-Config.ps1"
$Scope = Get-ScopeAddresses

# 1. Intake mailbox - flow watches it; hidden from the GAL
New-Mailbox -Shared -Name "$Prefix-intake" -DisplayName "$Prefix intake" -PrimarySmtpAddress $IntakeMailbox
Set-Mailbox $IntakeMailbox -HiddenFromAddressListsEnabled $true
Add-MailboxPermission $IntakeMailbox -User $FlowAccount -AccessRights FullAccess -AutoMapping $false

# 2. Scope group - the flow reads its members through Graph to decide whose OOF to check
New-DistributionGroup -Name "$Prefix-scope" -Type Security -PrimarySmtpAddress $ScopeGroup -Members $Scope
Set-DistributionGroup $ScopeGroup -HiddenFromAddressListsEnabled $true -RequireSenderAuthenticationEnabled $true
Write-Host "Group object ID for the flow:" -ForegroundColor Cyan
Get-DistributionGroup $ScopeGroup | Select-Object DisplayName, ExternalDirectoryObjectId

# 3. Reply-from mailbox - the flow needs BOTH Full Access and Send As
#    (Send As alone gives 404 ErrorItemNotFound on "Send an email from a shared mailbox (V2)").
#    Allow up to ~60 min for permissions to apply.
Add-MailboxPermission   $ReplyFrom -User $FlowAccount -AccessRights FullAccess -AutoMapping $false
Add-RecipientPermission $ReplyFrom -Trustee $FlowAccount -AccessRights SendAs -Confirm:$false

# 4. Mail flow rule - DIRECT SentTo list (not SentToMemberOf: group changes are cached for hours)
#    Loop guards: OOF messages, Auto-Submitted auto-*, Precedence bulk/list/junk, spam (SCL > 4).
New-TransportRule -Name $RuleName -Comments $RuleComment `
    -FromScope NotInOrganization `
    -SentTo $Scope `
    -BlindCopyTo $IntakeMailbox `
    -ExceptIfMessageTypeMatches OOF `
    -ExceptIfHeaderMatchesMessageHeader 'Auto-Submitted' -ExceptIfHeaderMatchesPatterns '^auto-' `
    -ExceptIfHeaderContainsMessageHeader 'Precedence' -ExceptIfHeaderContainsWords 'bulk','list','junk' `
    -ExceptIfSCLOver 4 -Mode Enforce

# 5. Rule ORDER check. Any rule above ours with StopRuleProcessing = True (e.g. a mail
#    gateway such as Egress / Mimecast that routes mail out and back) must stay ABOVE ours.
#    Ours must sit below it so it fires on the return pass. Never move ours above a gateway rule.
Get-TransportRule | Sort-Object Priority | Format-Table Priority, Name, State, StopRuleProcessing -AutoSize

# 6. Verify
Get-Mailbox $IntakeMailbox | Select-Object DisplayName, RecipientTypeDetails, HiddenFromAddressListsEnabled
Get-DistributionGroupMember $ScopeGroup | Select-Object DisplayName, PrimarySmtpAddress
Get-MailboxPermission $ReplyFrom   | Where-Object User -like "*$FlowAccount*"
Get-RecipientPermission $ReplyFrom | Where-Object Trustee -like "*$FlowAccount*"
Get-TransportRule $RuleName | Format-List Name, State, Priority, FromScope, SentTo, BlindCopyTo, Except*
Write-Host "New mail flow rules can take up to ~30 min to apply." -ForegroundColor Yellow

# Remove everything (test / abandon only):
# Remove-TransportRule $RuleName -Confirm:$false
# Remove-DistributionGroup $ScopeGroup -Confirm:$false
# Remove-Mailbox $IntakeMailbox -Confirm:$false

Direct SentTo list, not SentToMemberOf

The rule takes the scope as an explicit recipient list, not a group, because transport rules cache group membership for hours — new pilot members simply weren't matched. The group still exists; the flow reads it through Graph (instant) to decide whose out-of-office to check. Two different needs, two different mechanisms.

And the read-only troubleshooting helpers — dot-source the file, then call the function you need:

06-Troubleshoot.ps1 — read-only diagnostics: trace a test, see which rules fired, check scope, permissions and rule order.

<#
  06 - Troubleshooting helpers (READ-ONLY). Dot-source, then call the function you need:
     . .\06-Troubleshoot.ps1
     Trace-AutoReply -Subject 'IT check' -Minutes 30
#>
. "$PSScriptRoot\00-Config.ps1"

# Did the rule copy the email to intake, and did a reply go out?
function Trace-AutoReply ([string]$Subject, [int]$Minutes = 60) {
    Get-MessageTraceV2 -StartDate (Get-Date).AddMinutes(-$Minutes) -EndDate (Get-Date) |
        Where-Object Subject -like "*$Subject*" | Sort-Object Received |
        Format-Table @{n='Local';e={$_.Received.ToLocalTime()}}, SenderAddress, RecipientAddress, Subject, Status -AutoSize
}

# Which rules acted on one message? (detail lists ONLY rules that matched - absence = did not match)
function Get-RulesHit ([string]$Recipient, [string]$Subject, [int]$Hours = 2) {
    Get-MessageTraceV2 -RecipientAddress $Recipient -StartDate (Get-Date).AddHours(-$Hours) -EndDate (Get-Date) |
        Where-Object Subject -like "*$Subject*" | ForEach-Object {
            "----- $($_.Received.ToLocalTime())  $($_.Subject)"
            $_ | Get-MessageTraceDetailV2 | Where-Object Event -match 'Transport rule' | Format-Table Date, Event, Detail -Wrap
        }
}

# Rule order - gateway rules with StopRuleProcessing must sit ABOVE ours
function Show-RuleOrder { Get-TransportRule | Sort-Object Priority | Format-Table Priority, Name, State, StopRuleProcessing -AutoSize }

# Is an address actually in scope?
function Test-InScope ([string]$Address) {
    [pscustomobject]@{
        Address = $Address
        InRule  = ((Get-TransportRule $RuleName).SentTo -contains $Address)
        InGroup = ((Get-DistributionGroupMember $ScopeGroup -ResultSize Unlimited).PrimarySmtpAddress -contains $Address)
    }
}

# What will the flow see for this mailbox's out-of-office?
function Get-OofView ([string]$Address) {
    Get-MailboxAutoReplyConfiguration $Address | Select-Object Identity, AutoReplyState, ExternalAudience, StartTime, EndTime
}

# Flow account permissions (404 on send = Full Access missing on reply-from)
function Test-FlowPermissions {
    Get-MailboxPermission   $IntakeMailbox | Where-Object User -like "*$FlowAccount*" | Select-Object Identity, User, AccessRights
    Get-MailboxPermission   $ReplyFrom     | Where-Object User -like "*$FlowAccount*" | Select-Object Identity, User, AccessRights
    Get-RecipientPermission $ReplyFrom     | Where-Object Trustee -like "*$FlowAccount*" | Select-Object Identity, Trustee, AccessRights
}

Write-Host 'Loaded: Trace-AutoReply, Get-RulesHit, Show-RuleOrder, Test-InScope, Get-OofView, Test-FlowPermissions' -ForegroundColor Green

07Reusable lessons

The pattern fits any "shared response for a team" request. These save time on the next one.

# back up the old per-mailbox config before replacing it
Get-MailboxAutoReplyConfiguration [email protected] |
  Export-Clixml .\oof-backup.xml
Get-InboxRule -Mailbox [email protected] |
  Export-Clixml .\inboxrules-backup.xml

# confirm where the rule fires when a gateway is in front of Exchange
Get-MessageTraceDetailV2 -MessageTraceId <id> -RecipientAddress [email protected] |
  Select Date, Event, Detail
  • Goal: one standard reply to every external email across 21 team mailboxes — every message, no "Away," one central sender, with leave cover.
  • Why built-ins fail: out-of-office is once-per-sender and marks you Away; Outlook rules die with the PC; a mail-flow rule can't compose a reply; one-flow-per-mailbox means 21 flows and duplicate replies.
  • Design: one transport rule BCCs qualifying external mail into one hidden intake mailbox; one flow finds the real recipients, checks each out-of-office via Graph, and replies once from the central address — unless everyone's away.
  • Gotchas: loop filter eats "autoreply" test subjects; Graph returns IDs not addresses; transport rules cache group membership; gateways double-pass Exchange; shared-mailbox send needs Full Access.

Sources & further reading

Anonymised from real support work. Client, team, person and ticket details have been changed; the design, the gotchas and the fixes are real.

Filed under
Email Security › Delivery & mail flow
Browse this part of the knowledge base.
Related

Comments

Questions or corrections welcome. Sign in with GitHub to join the thread.