Handles /contact (demo requests) and /beta (waitlist). Stores every submission in D1 first,
then notifies CONTACT_EMAIL through Microsoft Graph.
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 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.Sendin Entra and scope it in Exchange, the scoping does nothing. Do not grantMail.Sendin Entra ID at all, and if it is already granted, revoke it.
Microsoft 365 admin centre, Teams & groups → Shared mailboxes, add [email protected].
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.
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"
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
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.
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
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.
# 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.
notify() cannot throw. Everything, including message construction, is inside the try.[observability] enabled = true — this is what was missing before.login.microsoftonline.com. Token-endpoint failures log
the error code, never the response body, which echoes request parameters back.btoa(unescape(...)) is gone.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.