Enneo

Contact Channels

Microsoft 365 via App Registration

Connect mailboxes through your own app registration in your Entra ID tenant

Tip

Instead of signing in to Microsoft separately for every mailbox, you register your own app registration in your Entra ID tenant once. Enneo then authenticates as an application ("app-only authorization", OAuth 2.0 client credentials) — with no sign-in per mailbox and no additional licence per mailbox. The connection is created in the Settings → Email settings category and then selected on the mailbox.

This path is worthwhile above all for shared mailboxes (service@, info@, billing@), for which there is no personal signer, and for organizations that want to connect several mailboxes. You give enneo four values for it: tenant ID, application (client) ID, client secret and that secret's expiry date.

Plan 60 to 90 minutes for the initial setup — most of that goes into narrowing the access in Exchange Online PowerShell. Every additional mailbox is a matter of minutes after that.

Info

The classic per-mailbox sign-in via Link account remains available and unchanged, and is still the fastest route for a single mailbox. The app registration is an alternative, not a replacement — see Email.

Prerequisites

  • An Entra ID administrator who may create app registrations and grant admin consent (for example the "Application Administrator" or "Global Administrator" role) — see Register an application.

  • An Exchange Online administrator. Narrowing the access is only possible via PowerShell, and specifically with the ExchangeOnlineManagement module — not with the general Azure PowerShell module:

    Install-Module ExchangeOnlineManagement -Scope CurrentUser
    Connect-ExchangeOnline -UserPrincipalName admin@your-domain.com

    See Connect to Exchange Online PowerShell.

  • The list of mailbox addresses enneo is to read and send from — and, for the Graph route, a mail-enabled security group holding exactly those mailboxes. An ordinary distribution list will not do: the scope filter in step 3 does not evaluate it.

Note

In Azure Cloud Shell the module is already present, but signing in there requires a device code — and -UserPrincipalName cannot be combined with -Device. Use Connect-ExchangeOnline -Device with no further parameters.

Step 1 — Register the application in Entra ID

Create the app registration

In the Microsoft Entra admin center, open Identity → Applications → App registrations → New registration. A name is enough (for example enneo Mail); a redirect URI is not needed for app-only authorization.

Note the tenant ID and the application ID

Both values are on the Overview page of the new registration: Directory (tenant) ID and Application (client) ID.

Create a client secret

Under Certificates & secrets → Client secrets → New client secret, create a secret, choose a description and a validity period, and copy the value immediately.

Note

The secret's value is shown only right after it is created — never again afterwards. Copy the Value column, not the Secret ID column: the two sit side by side and look alike. Also note the expiry date; enneo asks for it.

The portal allows a validity period of at most 24 months; Microsoft recommends less than 12 months. Shorter is safer but means renewing more often — schedule the rotation as a recurring task.

The permissions are assigned under API permissions → Add a permission — each as application permissions, not as delegated permissions.

TransportAPIPermissions
Microsoft Graph (recommended)Microsoft GraphMail.ReadWrite and Mail.Send. Mail.Read is enough only if messages stay untouched after import — see the note below
IMAP / SMTPOffice 365 Exchange OnlineIMAP.AccessAsApp and SMTP.SendAsApp

Note

The IMAP and SMTP permissions are not under Microsoft Graph. You find them in the Add a permission dialog on the APIs my organization uses tab under Office 365 Exchange Online. Anyone looking for them under Graph will not find them — they do not exist there.

Note

Reading is rarely enough. By default enneo marks an imported message as read so that it is not fetched again — and that is already a write, which Mail.Read does not cover. The only setting that works without write access is After import: leave the message untouched on the email account; "mark as read", "move to folder" and "delete" all require Mail.ReadWrite.

Without that right the result is easy to miss: the ticket is created correctly and only the marking fails. The message stays unread, is fetched again on the next run, recognised as a duplicate — and again not marked. The error therefore repeats on every run.

An administrator then grants consent once: the Grant admin consent for <your organization> button. Afterwards, the Status column must read "Granted for …" for every permission.

Warning

For Microsoft Graph in combination with RBAC this consent is only a transitional state: it must be removed again after the role assignment, because it grants access to all mailboxes and thereby cancels the narrowing out. That is not an aside but a step of its own — Finally: remove the tenant-wide consent at the end of step 3.

Step 3 — Narrow the access to the intended mailboxes

Warning

This step is mandatory, not optional. Without narrowing, an application permission applies to every mailbox in the tenant. Without step 3, enneo could therefore technically read every mailbox in your organization — the personal ones included.

Which mechanism applies depends on the transport. Both start with the same command: making the application's service principal known to Exchange Online. Run it once, even if you set up both transports — a second call fails because the object already exists.

New-ServicePrincipal -AppId <application-id> `
  -ObjectId <object-id-from-enterprise-applications> -DisplayName "enneo Mail"

# The ObjectId Exchange now knows the principal by - for the check in the
# note below, and for the IMAP/SMTP tab
(Get-ServicePrincipal -Identity "enneo Mail").ObjectId

Warning

The -ObjectId argument of New-ServicePrincipal expects the object ID from Enterprise applications, not the object ID from App registrations. Both carry the same name and sit in comparable places in the portal. Microsoft explicitly documents that the wrong one of the two causes an authentication failure — and one that does not point at its cause.

Note

What is already assigned to this service principal is shown by Get-ManagementRoleAssignment -RoleAssignee <ObjectId>. Only New-ManagementRoleAssignment has the -App argument; on Get-ManagementRoleAssignment it produces an unknown-parameter error.

For Graph you use RBAC for Applications: a role that only takes effect on a defined scope of recipients. The scope is described by the group from the prerequisites — and its distinguished name is the one argument in this guide that cannot be read off anywhere, only queried:

# Check the group - RecipientTypeDetails must be MailUniversalSecurityGroup
Get-Group "enneo Mailboxes" | Format-List Name,DisplayName,RecipientTypeDetails,DistinguishedName

$groupDN = (Get-Group "enneo Mailboxes").DistinguishedName

Warning

The Name of a group created in the Exchange admin center is not its display name: the EAC appends a timestamp to the name, so the display name enneo Mailboxes becomes a name such as enneo Mailboxes20260101120000. The distinguished name contains the Name. Assembling it "as expected" from the display name therefore points at an object that does not exist — and the scope then matches not a single mailbox, without any error message. Query the DN as shown instead of typing it.

Warning

Do not rename the group afterwards. The scope stores the distinguished name as a string, and the DN contains the group's Name. Renaming therefore rewrites the DN — the stored filter then points at an object that no longer exists, and the scope covers no mailbox at all. Access breaks without a message, at a moment that has nothing to do with this setup any more.

If it has happened, point the filter at the new DN:

$groupDN = (Get-Group "enneo Mailboxes").DistinguishedName
Set-ManagementScope -Identity "enneo Mailboxes Scope" `
  -RecipientRestrictionFilter "MemberOfGroup -eq '$groupDN'"

The display name is not affected — it does not appear in the DN and may change at any time.

# 1. Define the scope via the group
New-ManagementScope -Name "enneo Mailboxes Scope" `
  -RecipientRestrictionFilter "MemberOfGroup -eq '$groupDN'"

# 2. Assign the roles exclusively to that scope - receiving and sending
New-ManagementRoleAssignment -Role "Application Mail.ReadWrite" `
  -App <application-id> -CustomResourceScope "enneo Mailboxes Scope"
New-ManagementRoleAssignment -Role "Application Mail.Send" `
  -App <application-id> -CustomResourceScope "enneo Mailboxes Scope"

# 3. Verify the result - one mailbox inside and one outside the scope
Test-ServicePrincipalAuthorization -Identity <application-id> -Resource service@your-domain.com

On choosing the receiving role, see the note in step 2: Application Mail.Read is enough only if messages are to stay untouched after import.

Test-ServicePrincipalAuthorization is the acceptance test for the role assignment: use it on a mailbox that is meant to be included and on one that is not. Only when the second one is denied is the assignment cut correctly — for the narrowing to actually take effect, the last part of this step is still missing.

Note

Without a group, for a small fixed set. If the scope covers exactly one mailbox and that will not change, a group is not needed:

New-ManagementScope -Name "enneo Mailboxes Scope" `
  -RecipientRestrictionFilter "PrimarySmtpAddress -eq 'service@your-domain.com'"

For several or growing sets the group is the better choice, for an organizational reason: a new mailbox is then added by whoever maintains the group anyway, without knowing this guide. A scope built from addresses would instead have to be changed by an Exchange administrator every time.

Note

In the scope filter, only direct membership in the group counts. Mailboxes that are members through a nested group are outside the scope — and without any error message.

This section deliberately contradicts step 2, and it is the part that makes the narrowing effective at all. The consent was needed for the permission to exist; once the role assignment is in place it becomes harmful, because it applies to all mailboxes and adds up with the scope to their union.

In the app registration, open API permissions and revoke the admin consent for the Graph permissions from step 2 — Mail.ReadWrite and Mail.Send, plus Mail.Read if it is still there from an earlier setup. The permission then comes from the resource-scoped role assignment.

The order is not arbitrary: first the role assignment, then revoke the consent. The other way round leaves a window in which no access is possible.

For IMAP/SMTP this section does not apply: IMAP.AccessAsApp has no RBAC counterpart, so the consent has to stay. The narrowing there is done by the mailbox permission.

Warning

Test-ServicePrincipalAuthorization checks only the RBAC side and knows nothing about the consent. So as long as the tenant-wide consent stands, the command correctly denies a mailbox outside the scope while the application in fact reaches it. The acceptance test from the Graph tab consequently cannot find this mistake.

Verify the end state by its effect instead:

# Prompted for rather than pasted in - see the note below the block
$secret = Read-Host 'Client secret value' -AsSecureString

$body = @{ client_id     = '<application-id>'
           client_secret = [Net.NetworkCredential]::new('', $secret).Password
           scope         = 'https://graph.microsoft.com/.default'
           grant_type    = 'client_credentials' }
$token = (Invoke-RestMethod -Method Post -Body $body `
  -Uri 'https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token').access_token

# 1. Quick check: the roles claim has to stay empty
$p = $token.Split('.')[1].Replace('-','+').Replace('_','/')
$p += '=' * ((4 - $p.Length % 4) % 4)
([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) | ConvertFrom-Json).roles

# 2. Mailbox inside the scope - expect a response carrying an id
Invoke-RestMethod -SkipHttpErrorCheck -Headers @{ Authorization = "Bearer $token" } `
  -Uri 'https://graph.microsoft.com/v1.0/users/service@your-domain.com/mailFolders/inbox'

# 3. Mailbox outside it - expect 403 ErrorAccessDenied
Invoke-RestMethod -SkipHttpErrorCheck -Headers @{ Authorization = "Bearer $token" } `
  -Uri 'https://graph.microsoft.com/v1.0/users/someone.else@your-domain.com/mailFolders/inbox'

If command 1 prints nothing and 3 answers with ErrorAccessDenied, the narrowing is in effect. If command 1 still names Mail.Read, Mail.ReadWrite or Mail.Send, the consent has not been revoked. If 3 returns a response instead of an error, the scope is not taking hold.

The secret is prompted for rather than written into the block: typed command lines go into the PowerShell history, and in Cloud Shell the home directory survives the session once a storage account is attached. A pasted secret would stay there.

-SkipHttpErrorCheck requires PowerShell 7 (present in Cloud Shell); without it the 403 surfaces as a terminating error rather than a readable response.

Note

Do not check only whether a mailbox stays readable. After the revocation the role assignment carries everything the connection needs: fetching, the action after import — mark as read, move, delete — and sending.

The mistake that survives at this point therefore looks like this: reading succeeds and only the marking fails. The assigned role is then Application Mail.Read rather than Application Mail.ReadWrite. The tenant-wide consent had been hiding the difference until now, because it brought both — and it only becomes visible on the first real fetch, not on the connection test and not on the command above.

Step 4 — Enter the connection in enneo

Create the connection

Open Settings → Email and create a new Microsoft 365 connection. A descriptive name helps later when several tenants or applications are involved.

Enter the four values

Tenant ID, application (client) ID, client secret and the secret's expiry date. The expiry date is not validated but stored — enneo displays it so that you can schedule the rotation in time.

Test the connection

With Test connection, enneo fetches a token for your tenant. If that fails, the cause lies in step 1 or 2 — see Troubleshooting. If it succeeds, that still says nothing about access to any single mailbox; step 3 decides that.

Switch the mailbox to the connection

In the email account concerned, set the authorization to the organization's application, select the connection you just created, and choose the transport (Microsoft Graph or IMAP/SMTP). Then verify with the email account's receiving test.

Graph or IMAP/SMTP? A recommendation

Both transports work with app-only authorization. If your tenant leaves the choice open, we recommend Microsoft Graph — for two checkable reasons, not out of taste:

  • Fewer rights when receiving. Graph can be narrowed to a group of mailboxes, and to the role actually needed — Application Mail.ReadWrite, or even Application Mail.Read where messages stay untouched. For IMAP no role exists in RBAC for Applications, so Add-MailboxPermission … -AccessRights FullAccess is all that is left — full access where read access would do. (Sending is not affected: SMTP can be scoped through Application SMTP.SendAsApp as well.)
  • Less effort per mailbox. With Graph, one scope covers the whole group; a new mailbox is added to the group, and that is it. With IMAP, every added mailbox is another PowerShell command and every removed one is another to take back. That is the answer to the question of which route is more convenient with several mailboxes.

On top of that come two sources of error that Graph does not have: the differing scopes in the next section, and the object ID that is easy to confuse in step 3.

Info

This is a recommendation, not a requirement. If your tenant only permits IMAP/SMTP, this guide can be completed entirely that way. Microsoft continues to provide OAuth for IMAP and POP; what is being retired is basic authentication, not OAuth.

IMAP and POP — two different scopes

Warning

For IMAP and POP, the scopes for authorization and for the token are not identical. Using the same value for both produces a configuration that looks correct and still does not work — with no meaningful message. This is the most likely cause of such a case.

RequestProtocolScope
Admin consent / authorizationIMAP, POPhttps://ps.outlook.com/.default
TokenIMAP, POPhttps://outlook.office365.com/.default
Authorization and tokenSMTPhttps://outlook.office365.com/.default

Troubleshooting

Good to know

  • No recurring consent. With app-only authorization there is no refresh token and no expiring user consent. The only recurring task is rotating the client secret before it expires. Enneo displays the recorded expiry date and the last connection error for that.
  • Receiving over Graph requires app-only authorization. In Microsoft's consent model, the consent given during the per-mailbox sign-in only grants the right to send, not to read. Anyone who wants to receive over Graph therefore needs this route — that is a property of the Microsoft model, not a limitation of enneo.
  • One connection, many mailboxes. The same connection can be selected on any number of email accounts. The extent is not decided in enneo but by the narrowing from step 3.
  • Switching is possible at any time. A mailbox connected via sign-in today can be switched to the connection later and back again — the tickets already imported are unaffected.