Agents

Microsoft 365 mail

Agents read mail and attachments, write reply drafts in the right conversation, and file and flag messages: in a person's own mailbox, or in shared mailboxes your Exchange admin chooses. A draft connection can never send.

Two ways to connect

ConnectionSigns in asReaches
A person's own mailboxThat person, with Microsoft's sign-inTheir own mailbox, and nothing else
Shared mailboxesThe Stable Baseline Connectors app, with no personOnly the shared mailboxes your Exchange admin lists

Both start the same way: a Microsoft admin approves Stable Baseline once for your organisation, on Microsoft's own page. Microsoft's default settings stop people approving mail access for an app themselves, so every organisation needs this one approval. It adds the Stable Baseline Connectors app to your organisation; nobody creates an app by hand.

A person's own mailbox

  1. Approve once

    In Connectors, add Microsoft 365 mail and choose Approve for your organisation, or send the link to your Microsoft admin. Microsoft shows the app, its publisher and what it asks for. It never asks to send mail.
  2. Choose what agents may do

    Read, Draft or Send.
  3. Sign in with Microsoft

    As the person whose mailbox it is.
  4. Check

    Stable Baseline checks the connection and tells you who it acts as.
ChoiceAgents canMicrosoft permission
ReadRead mail and attachments, list and watch foldersMail.Read
DraftAlso write reply drafts with attachments, move, flag, categorise, mark readMail.ReadWrite
SendAlso send a draft, once a person approves that exact messageMail.ReadWrite and Mail.Send

Send works only for a person your Microsoft admin allowed to send. That permission is given one person at a time, never to the whole organisation, so everyone else's connections stay draft only.

Shared mailboxes: approve, run, check

A shared mailbox such as support@ has no person to sign in, so the app signs in as itself. Exchange Online's own controls limit it to exactly the shared mailboxes you list, and Stable Baseline reads only those.

  1. Approve Stable Baseline for your organisation

    As above, once. If your organisation approved it already, go on.
  2. Show that your organisation controls the Microsoft organisation

    Before the first shared mailbox connection, one person with an admin role in your Microsoft organisation (Global, Privileged Role, Exchange, Application or Cloud Application Administrator) connects their own mailbox once. That is how Stable Baseline knows the Microsoft organisation is yours, so nobody else can point the app at your mailboxes.
  3. List the shared mailboxes

    As an organisation admin in Stable Baseline, open Connectors, add Microsoft 365 mail, choose App sign-in for shared mailboxes (admin), then Read or Draft, and type each shared mailbox's main address: one per line, or separated by commas, semicolons or spaces. The screen reads the addresses back, and makes the commands for exactly those mailboxes, with the app's id filled in.
  4. Run the commands once

    As an Exchange admin, choose where you run them and how you want them. Where: Azure Cloud Shell in your browser, in PowerShell or Bash; or on your computer, in PowerShell on Windows (Windows PowerShell 5.1, or PowerShell 7.6 or later), Command Prompt, or a terminal on macOS or Linux. How: several lines, one line, or a file to download, with the one line that runs it. Copy, paste, press Enter. Azure Cloud Shell signs you in by itself and needs an Azure subscription in your organisation; it opens in the shell you used last, and its prompt starts with PS in PowerShell and ends with $ in Bash. On your own computer, install Microsoft's Exchange Online PowerShell module first and, the first time, paste the app's Object ID into the screen before you copy (Microsoft Entra admin centre, Enterprise applications, Stable Baseline Connectors). Windows marks a downloaded file, and PowerShell will not run a marked file that is not signed: the line the screen gives takes the mark off that one file and allows it for that window only. If the sign-in fails in Azure Cloud Shell, run Connect-ExchangeOnline -Device on its own, then paste the commands again.
  5. Paste back the line they print

    The commands end with one line: your tenant ID and each mailbox's object ID. Paste it into the screen and press Connect.
  6. Check access

    The connection checks each mailbox and says which the app reaches and which not yet. Microsoft takes 30 minutes to 2 hours to apply a new grant, so a mailbox can show Not yet for a while. Press Check access again later, on the connection's page.

The commands are safe to run again. Each step checks first and skips what is already there. Run them again with another mailbox listed, and they add that mailbox and change nothing else. Stable Baseline reads only the mailboxes a connection lists, so a mailbox added later needs a new connection that lists it.

The commands, for review

These are the commands the connection screen fills in, shown here with placeholders so your security team can read them before anyone runs anything. The screen puts in the app's id, the access you chose and your mailboxes; nothing else differs. They check everything before they change anything, and stop at the first problem.

Shared mailboxespowershell
# Stable Baseline: give the Stable Baseline Connectors app access to the shared mailboxes below, and to no others.
# Paste all of it into Azure Cloud Shell (PowerShell) or PowerShell 7, signed in as an Exchange admin.
# It checks everything before it changes anything, and skips what is already done: safe to run again.
# It grants Application Mail.ReadWrite (read mail and write drafts) over these mailboxes only.
# It never grants Mail.Send, and it changes nothing in Microsoft Entra.
& {
  # The Stable Baseline Connectors app, the same in every organisation.
  $AppId = '<app id shown in Stable Baseline>'
  # The app's Object ID in your organisation. Left empty, Azure Cloud Shell reads it for you.
  $AppObjectId = ''
  # Read mail and write drafts. Never send.
  $Role = 'Application Mail.ReadWrite'
  # The shared mailboxes, by their main address.
  $Mailboxes = @(
    'support@contoso.com'
    'orders@contoso.com'
  )

  $ErrorActionPreference = 'Stop'
  # Each access has its own mark and its own scope, so each mailbox is in the scope of the highest access asked for
  # it, and in no other:
  #   Draft: CustomAttribute12 = sb-agent-mail-draft, scope 'Stable Baseline draft mailboxes', role Application Mail.ReadWrite.
  #   Read: CustomAttribute12 = sb-agent-mail-read, scope 'Stable Baseline read mailboxes', role Application Mail.Read.
  $Drafts = $Role -eq 'Application Mail.ReadWrite'
  $Mark = if ($Drafts) { 'sb-agent-mail-draft' } else { 'sb-agent-mail-read' }
  $Scope = if ($Drafts) { 'Stable Baseline draft mailboxes' } else { 'Stable Baseline read mailboxes' }
  $Rule = "CustomAttribute12 -eq '$Mark' -and RecipientTypeDetails -eq 'SharedMailbox'"

  # 1. Only the two mail roles are allowed here: never Application Mail.Send, and no full access role.
  if ($Role -notin 'Application Mail.Read', 'Application Mail.ReadWrite') {
    throw 'These commands grant only Application Mail.Read or Application Mail.ReadWrite.'
  }
  if (@($Mailboxes).Count -lt 1) { throw 'List at least one shared mailbox.' }

  # 2. Sign in to Exchange Online, unless this window already is. Azure Cloud Shell signs you in by itself.
  if (-not (Get-Command Get-ConnectionInformation -ErrorAction SilentlyContinue)) {
    throw 'First install Exchange Online PowerShell: Install-Module ExchangeOnlineManagement -Scope CurrentUser'
  }
  # This window's Exchange Online sign-in, if it has one (not a Security & Compliance one).
  $ExchangeSignIn = { Get-ConnectionInformation | Where-Object { $_.State -eq 'Connected' -and "$($_.IsEopSession)" -ne 'True' } }
  if (-not (& $ExchangeSignIn)) {
    # If this fails in Azure Cloud Shell, run Connect-ExchangeOnline -Device first, then paste these commands again.
    Connect-ExchangeOnline -ShowBanner:$false
  }

  # 3. Check each mailbox before anything changes: exactly one shared mailbox has that main address,
  #    and its CustomAttribute12 is empty or one of the two marks.
  $Boxes = foreach ($Address in $Mailboxes) {
    $Found = @(Get-EXOMailbox -PrimarySmtpAddress $Address -Properties CustomAttribute12 -ErrorAction SilentlyContinue)
    if ($Found.Count -ne 1 -or "$($Found[0].PrimarySmtpAddress)" -ne $Address) {
      throw "No mailbox has the main address $Address. Nothing was changed."
    }
    if ($Found[0].RecipientTypeDetails -ne 'SharedMailbox') {
      throw "$Address is not a shared mailbox. A person connects their own mailbox in Stable Baseline. Nothing was changed."
    }
    if ($Found[0].CustomAttribute12 -and $Found[0].CustomAttribute12 -notin 'sb-agent-mail-read', 'sb-agent-mail-draft') {
      throw "$Address already uses CustomAttribute12 for something else. Nothing was changed."
    }
    $Found[0]
  }

  # 4. This access's scope, if it is there already, must be ours: the same rule, and not exclusive.
  $Existing = @(Get-ManagementScope | Where-Object { $_.Name -eq $Scope })
  if ($Existing.Count -gt 0) {
    $Same = ("$($Existing[0].RecipientFilter)" -replace '[\s()]', '') -eq ($Rule -replace '[\s()]', '')
    if (-not $Same -or "$($Existing[0].Exclusive)" -eq 'True') {
      throw "A scope called '$Scope' already exists with another rule: $($Existing[0].RecipientFilter). Nothing was changed."
    }
  }

  # 5. Exchange's pointer to the app your Microsoft admin approved. It names the app and grants nothing.
  $App = Get-ServicePrincipal | Where-Object { $_.AppId -eq $AppId }
  if (-not $App) {
    if (-not $AppObjectId) {
      # Azure Cloud Shell is signed in to Azure already, so it can read the app's Object ID. Elsewhere this reads nothing.
      try { $AppObjectId = (Get-AzADServicePrincipal -ApplicationId $AppId).Id } catch { $AppObjectId = '' }
    }
    if (-not $AppObjectId) {
      throw "Exchange needs the app's Object ID. Paste it into Stable Baseline, then copy these commands again. Nothing was changed."
    }
    $App = New-ServicePrincipal -AppId $AppId -ObjectId $AppObjectId -DisplayName 'Stable Baseline Connectors'
  }

  # 6. Mark each listed mailbox with this access's mark, so this access's scope finds it and the other does not.
  #    A mailbox keeps the highest access any run asked for: Draft raises a mailbox marked for Read, and Read never
  #    lowers one marked for Draft (it says so, and nothing changes for that mailbox). Skipped when marked.
  foreach ($Box in $Boxes) {
    if (-not $Drafts -and $Box.CustomAttribute12 -eq 'sb-agent-mail-draft') {
      Write-Host "$($Box.PrimarySmtpAddress) keeps Draft access from an earlier run: these commands never lower access. Nothing changed for it."
    } elseif ($Box.CustomAttribute12 -ne $Mark) {
      Set-Mailbox -Identity $Box.PrimarySmtpAddress -CustomAttribute12 $Mark
    }
  }

  # 7. The lock: this access's scope matches shared mailboxes carrying its mark, and nothing else. Made once.
  if ($Existing.Count -eq 0) {
    $null = New-ManagementScope -Name $Scope -RecipientRestrictionFilter $Rule
  }

  # 8. The one grant: this access's role, over this access's scope only. Skipped when the app has it already.
  $Held = Test-ServicePrincipalAuthorization -Identity $AppId |
    Where-Object { $_.RoleName -eq $Role -and "$($_.AllowedResourceScope)" -eq $Scope }
  if (-not $Held) {
    $null = New-ManagementRoleAssignment -Name "Stable Baseline $Role" -App $App.ObjectId -Role $Role -CustomResourceScope $Scope
  }

  # 9. What the app may do in each listed mailbox, as Exchange sees it now.
  Write-Host ''
  Write-Host 'What the app may do in each listed mailbox:'
  foreach ($Box in $Boxes) {
    $Roles = @(Test-ServicePrincipalAuthorization -Identity $AppId -Resource $Box.Guid |
      Where-Object { "$($_.InScope)" -eq 'True' } | ForEach-Object { $_.RoleName })
    Write-Host "  $($Box.PrimarySmtpAddress): $(if ($Roles) { $Roles -join ', ' } else { 'nothing yet' })"
  }
  $Marked = "CustomAttribute12 -eq 'sb-agent-mail-read' -or CustomAttribute12 -eq 'sb-agent-mail-draft'"
  $All = @(Get-EXOMailbox -RecipientTypeDetails SharedMailbox -Filter $Marked -ResultSize Unlimited)
  Write-Host "Every mailbox the app can reach: $(($All | ForEach-Object { $_.PrimarySmtpAddress }) -join ', ')"
  Write-Host 'Microsoft takes 30 minutes to 2 hours to apply a new grant. Then press Check access in Stable Baseline.'

  # 10. The line Stable Baseline needs: your tenant ID, then each mailbox's object ID.
  $Tenant = (& $ExchangeSignIn | Select-Object -First 1).TenantID
  $Pairs = foreach ($Box in $Boxes) { "$($Box.PrimarySmtpAddress)".ToLower() + '=' + $Box.ExternalDirectoryObjectId }
  Write-Host ''
  Write-Host 'Copy this line into Stable Baseline:'
  Write-Host ((@("tenant=$Tenant") + $Pairs) -join ', ')
  Write-Host ''
  Write-Host 'When you have finished: Disconnect-ExchangeOnline -Confirm:$false'
}

Would your admin rather run a file? Choose File on the connection screen: the same commands, made for your mailboxes, with the one line that runs them where you chose. A file that takes the app's id, the mailboxes and the access as parameters, Set-SharedMailboxAccess.ps1, is the same steps, character for character: ask us for it.

What each step grants

StepWhat it doesWhat it grants
1Allows only Application Mail.Read or Application Mail.ReadWrite, and refuses any other roleNothing
2Signs you in to Exchange Online, unless the window is signed in alreadyNothing
3Checks each mailbox: exactly one shared mailbox has that main address, and its CustomAttribute12 is empty or one of the two marks belowNothing: it only reads
4Checks that the scope for the access you chose is new, or oursNothing: it only reads
5Gives Exchange a pointer to the app you approved (New-ServicePrincipal)Nothing on its own
6Marks each listed mailbox for the access you chose (Set-Mailbox). A mailbox already marked for Draft keeps Draft when you choose ReadNothing on its own
7Makes the lock for that access: a scope that matches shared mailboxes carrying its mark, and nothing else (New-ManagementScope)Nothing on its own: it is the limit
8Gives the app that access's role over that scope only (New-ManagementRoleAssignment)Draft: Application Mail.ReadWrite. Read: Application Mail.Read. In that scope's mailboxes only
9 and 10Shows what the app may do in each mailbox, and prints the line for the connectionNothing: they only read

The locks

Each access has its own mark, its own scope and its own role, so a mailbox you list for Read is never in the scope that the Draft role is given over.

AccessMark (CustomAttribute12)ScopeRole
Readsb-agent-mail-readStable Baseline read mailboxesApplication Mail.Read
Draftsb-agent-mail-draftStable Baseline draft mailboxesApplication Mail.ReadWrite
  • A scope matches a mailbox only when it is a shared mailbox carrying that scope's mark. A person's own mailbox never matches, even if someone marks it.
  • A mailbox keeps the highest access you asked for it. Choosing Draft for a mailbox marked for Read raises it to Draft. Choosing Read never lowers a mailbox marked for Draft: the commands say so, and change nothing for it.
  • Mailboxes you do not list stay out of reach. Stable Baseline also reads only the mailboxes the connection lists.
  • To take one mailbox out, clear its mark (the last line under Take the access away). To move it from Draft down to Read, clear its mark, then run the commands again with Read and that mailbox. Microsoft takes 30 minutes to 2 hours to apply a change.
  • Nothing in Microsoft Entra changes. No permission is approved for the whole organisation, and Mail.Send is never granted.

Never approve mail permissions for the whole organisation

Never add Mail.Read, Mail.ReadWrite or Mail.Send to the app as application permissions in Microsoft Entra. Microsoft adds an app's Entra permissions to the ones Exchange's scopes give it. An application permission for mail approved in Microsoft Entra would let the app reach every mailbox in your organisation, and the scopes above would limit nothing. Stable Baseline never asks for one: the app sign-in reaches shared mailboxes only through the scopes. If someone approved one for the app, remove it. The approval for your organisation is different: it lets the app act only for a person who signs in.

Microsoft says the same in its own guide, Role Based Access Control for Applications in Exchange Online, in its questions and answers.

Drafts only for shared mailboxes

A shared mailbox connection reads, files and flags mail, and writes reply drafts. It never sends: the commands never grant Mail.Send, the app asks Microsoft for no permission to send on its own, and the connection screen offers no send choice. A person reviews each draft and sends it from Outlook.

Express permission

Microsoft's terms for its mail APIs allow AI processing of mail only with your organisation's express permission. So every Microsoft 365 mail connection asks for it, in its own words (“We give express permission for Stable Baseline's AI features to process this mail”), and the choice is recorded with the connection: who made it, and when. If what we do with mail ever changes, we ask again.

Take the access away

Disconnecting in Stable Baseline stops agents at once. To remove the app's access in Exchange as well, an Exchange admin runs:

Take the access awaypowershell
# Take the Stable Baseline Connectors app's access away again (Exchange Online PowerShell, as an Exchange admin).
# 1. Remove the grants. Run the line for each one you made.
Remove-ManagementRoleAssignment -Identity 'Stable Baseline Application Mail.ReadWrite' -Confirm:$false
Remove-ManagementRoleAssignment -Identity 'Stable Baseline Application Mail.Read' -Confirm:$false
# 2. Remove the scopes, likewise.
Remove-ManagementScope -Identity 'Stable Baseline draft mailboxes' -Confirm:$false
Remove-ManagementScope -Identity 'Stable Baseline read mailboxes' -Confirm:$false
# 3. Remove Exchange's pointer to the app.
Remove-ServicePrincipal -Identity '<app id shown in Stable Baseline>' -Confirm:$false
# 4. Clear the mark on each mailbox. On its own, this takes one mailbox out of reach.
Set-Mailbox -Identity 'support@contoso.com' -CustomAttribute12 $null

Microsoft's own pages