Most agencies run certificates through a shared mailbox, and most automation attempts against that mailbox start with the wrong tool: IMAP with an app password, or a licensed service account someone signs in with. The clean way, on Microsoft 365, is app-only access to Microsoft Graph: your process authenticates as an application, reads exactly one mailbox, and no human credential is involved anywhere.
This tutorial sets that up end to end: an app registration, the client credentials flow, scoping access to a single mailbox, and polling for new messages. You need a Microsoft 365 tenant where you, or a cooperative admin, can register applications and administer Exchange Online.
Register the application
Create the registration
In the Microsoft Entra admin center, register a new application. No redirect URI is needed for a background process. Record three values: the directory (tenant) id, the application (client) id, and a client secret created under Certificates & secrets. Treat the secret like any production credential: a secrets manager, not an environment file in a repo.
Grant an application permission
Under API permissions, add a Microsoft Graph application permission (not delegated). For reading mail, the candidates are:
| Permission | What it allows |
|---|---|
Mail.ReadBasic.All | Read mail, excluding bodies and attachments |
Mail.Read | Read mail in all mailboxes |
Mail.ReadWrite | Read, update, and delete mail |
A certificates workflow needs bodies and attachments, so Mail.Read is the realistic floor; take Mail.ReadWrite only if the process will also move or tag messages. Application permissions always require admin consent, granted from the same page. Microsoft's app-only access guide walks the whole flow, including how to do it without the portal.
Scope it to one mailbox
As consented, Mail.Read covers every mailbox in the tenant, which no reasonable admin should accept for a mailbox-reading bot. Exchange Online's RBAC for Applications fixes that: create a management scope that matches only the certificates mailbox, register the service principal with Exchange, and grant the role against that scope.
Connect-ExchangeOnline
New-ManagementScope -Name "Certificates mailbox" `
-RecipientRestrictionFilter "PrimarySmtpAddress -eq 'certificates@yourdomain.com'"
New-ServicePrincipal -AppId <client-id> -ObjectId <service-principal-object-id> `
-DisplayName "certs-intake-bot"
New-ManagementRoleAssignment -App <service-principal-object-id> `
-Role "Application Mail.Read" -CustomResourceScope "Certificates mailbox"
One trap the documentation is explicit about: grants are additive. If the unscoped Mail.Read consent remains in Entra, the union of the two grants is still tenant-wide. Once the Exchange-scoped assignment works, remove the Entra-level Mail.Read grant, then verify what the app can actually reach with Test-ServicePrincipalAuthorization.
Get a token
The client credentials flow is one POST. The scope is always https://graph.microsoft.com/.default, which means "whatever application permissions this app holds":
curl -s -X POST "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "scope=https://graph.microsoft.com/.default"
The response carries a bearer token valid for about an hour; cache it and refresh on expiry rather than requesting one per call. In production, use MSAL or a Graph SDK, which handle token caching for you.
Poll the mailbox
With the token, list messages from the mailbox's inbox. Ask for only the properties you need with $select; it keeps responses small and fast. The List messages reference documents the endpoint and its query parameters.
import requests
GRAPH = "https://graph.microsoft.com/v1.0"
MAILBOX = "certificates@yourdomain.com"
def new_messages(token, since_iso):
url = (
f"{GRAPH}/users/{MAILBOX}/mailFolders/inbox/messages"
f"?$filter=receivedDateTime ge {since_iso}"
f"&$orderby=receivedDateTime"
f"&$select=id,subject,from,receivedDateTime,hasAttachments"
f"&$top=50"
)
headers = {"Authorization": f"Bearer {token}"}
while url:
page = requests.get(url, headers=headers, timeout=30).json()
yield from page.get("value", [])
url = page.get("@odata.nextLink")
Three details from the documentation worth respecting:
- Filter and order together carefully. Properties in
$orderbymust also appear in$filter, in the same order and ahead of any filter-only properties, or Graph returns anInefficientFiltererror. - Follow
@odata.nextLinkverbatim. It already carries your query parameters; do not try to build page URLs from$skipyourself. - Bodies come back as HTML. If your parser wants plain text, request it with the
Prefer: outlook.body-content-type="text"header when fetching a single message.
Track the last receivedDateTime you processed and store each processed message id, so a crash or an overlapping poll never handles the same certificate request twice.
Where this goes next
Reading the mailbox is the easy third. The work after it is matching the request to the right account and in-force policy in the AMS, checking what the holder asked for against the endorsements actually on the policy, and drafting the certificate for a CSR to approve. That checking is the part with E&O consequences, and it is what our COI Agent engagements build. If you are building the intake yourself and want a senior engineer on the harder parts, contact us.