contact worker — setup

Handles /contact (demo requests) and /beta (waitlist). Stores every submission in D1 first, then notifies CONTACT_EMAIL through Microsoft Graph.

Why Graph and not Cloudflare Email Routing

Email Routing was the original transport and it never worked. It has to be the mail authority for the zone, and cordango.com's MX points at Microsoft 365. The two cannot coexist on one domain, so every send failed with E_SENDER_NOT_VERIFIED — silently, because the error was caught and there were no Worker logs. Four submissions accumulated over seven weeks with no notification.

Graph was chosen over an external provider because the notification lands in a Microsoft 365 mailbox either way. Sending through Graph adds no new data processor; adding Postmark or Brevo would, for no privacy gain.

The permission model

The sender is a shared mailbox, [email protected]. No licence, no sign-in.

Mail.Send as an application permission means send as anyone in the tenant. A leaked client secret would let an attacker send as any person in the company. So the grant is scoped in Exchange Online rather than consented org-wide in Entra.

The trap. Entra consent and Exchange RBAC are a UNION, not an intersection. If you grant Mail.Send in Entra and scope it in Exchange, the scoping does nothing. Do not grant Mail.Send in Entra ID at all, and if it is already granted, revoke it.

1. Shared mailbox

Microsoft 365 admin centre, Teams & groups → Shared mailboxes, add [email protected].

2. App registration

Entra admin centre, App registrations → New registration. Single tenant. No redirect URI: this is a daemon and never signs a user in.

Record the Directory (tenant) ID and Application (client) ID.

Under Certificates & secrets → New client secret, take the shortest expiry you are willing to rotate at. Copy the value now; it is shown once.

Under API permissions, add nothing. The default User.Read may stay or go; Mail.Send must NOT be added here.

3. Scope the send in Exchange

Install-Module ExchangeOnlineManagement -Scope CurrentUser
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline          # opens a browser sign-in; sign in as an Exchange administrator

# Pointer to the Entra service principal.
#
# The two IDs come from DIFFERENT pages. An app registration and its service principal are two
# objects with two Object IDs, and Exchange only knows about the second one. Using the app
# registration's Object ID fails with AADServicePrincipalNotFound.
#
#   -AppId     App registrations -> your app -> Application (client) ID
#   -ObjectId  App registrations -> your app -> Overview -> "Managed application in local
#              directory" (click it) -> Object ID.  Equivalently: Enterprise applications ->
#              your app -> Object ID.
New-ServicePrincipal -AppId <APPLICATION_CLIENT_ID> -ObjectId <ENTERPRISE_APP_OBJECT_ID> -DisplayName "cordango-contact"

# A scope holding exactly one mailbox.
New-ManagementScope -Name "cordango-noreply" -RecipientRestrictionFilter "PrimarySmtpAddress -eq '[email protected]'"

New-ManagementRoleAssignment -App <ENTERPRISE_APP_OBJECT_ID> -Role "Application Mail.Send" -CustomResourceScope "cordango-noreply"

If "Managed application in local directory" is empty

Then no service principal exists yet — it is normally created when an app registration is first consented to, and this app is deliberately never consented in Entra. Create it:

Install-Module Microsoft.Graph.Applications -Scope CurrentUser
Connect-MgGraph -Scopes Application.ReadWrite.All
New-MgServicePrincipal -AppId <APPLICATION_CLIENT_ID>
(Get-MgServicePrincipal -Filter "appId eq '<APPLICATION_CLIENT_ID>'").Id   # this is -ObjectId

Connecting

Connect-ExchangeOnline needs an interactive browser sign-in. It does not work in Azure Cloud Shell — there is no browser, so it falls back to the ambient Azure token, which carries no Exchange authority, and fails with OperationStopped: Unauthorized. Run it from a local PowerShell window instead.

If a cmdlet rejects a parameter it should accept, an older copy of the module is loaded in that session. Check which one you actually have:

Get-Module ExchangeOnlineManagement            # loaded in THIS session
Get-Module -ListAvailable ExchangeOnlineManagement | Select-Object Version, ModuleBase

Fix it by starting a clean shell, or Remove-Module ExchangeOnlineManagement then importing again. These commands need the Organization Management role group in Exchange, or Exchange Administrator in Entra.

Verify. InScope must be True for the shared mailbox and False for a real person:

Test-ServicePrincipalAuthorization -Identity "cordango-contact" -Resource "[email protected]" | Format-Table
Test-ServicePrincipalAuthorization -Identity "cordango-contact" -Resource "[email protected]"    | Format-Table

If the second one says True, Mail.Send is still consented in Entra. Remove it there.

Permission changes cache for 30 minutes to 2 hours. Test-ServicePrincipalAuthorization bypasses the cache; a live send does not.

4. Configure and deploy

Put the tenant and client IDs in wrangler.toml under [vars] — neither is confidential. The secret goes in Wrangler secrets and never into the file:

npx wrangler secret put GRAPH_CLIENT_SECRET
npx wrangler deploy

5. Confirm

npx wrangler tail --format pretty

Submit the form. Success is silent. Any failure prints notify failed: with the Graph status or the Entra error code.

Rotating the secret

# new secret in Entra first, then:
npx wrangler secret put GRAPH_CLIENT_SECRET
npx wrangler deploy
# then delete the old secret in Entra

Tokens are cached in-isolate for their full hour, so allow an hour before deleting the old secret, or accept that in-flight isolates get one 401 and retry with a fresh token, which they handle.

What is deliberately true of the code

Database

schema.sql is a fresh install. For the existing database, apply migrations/ in order:

npx wrangler d1 execute cordango-contact --remote --file migrations/001_beta_waitlist.sql

kind is demo or beta. A partial unique index makes a waitlist address single-use while leaving demo requests alone, so somebody can do both.