16 KiB
Website Form → Email via Microsoft Graph
How to add a contact/inquiry form to a static (or any) website that emails submissions through a Microsoft 365 tenant, using a small FastAPI container behind nginx and the Microsoft Graph sendMail API.
One app registration and one sender mailbox can serve any number of sites — each site just sets a different recipient.
Why this approach
mailto:forms are unreliable — they open the visitor's mail app; on machines with no desktop mail client (Mac + webmail, many work PCs) clicking Send does nothing.- Microsoft Graph, not SMTP — Microsoft 365 is retiring basic-auth SMTP (
smtp.office365.comwith a password). Graph with an app registration keeps working. - Shared sender, any recipient — mail is sent from a mailbox in your tenant and delivered to any address (e.g., a client's inbox on another domain). The recipient's mailbox/tenant is never touched.
- Least privilege — the app can send only as one mailbox, enforced with Exchange RBAC for Applications.
Values you'll need
| Placeholder | What it is | Where to find it |
|---|---|---|
<TENANT_ID> |
Your Microsoft 365 tenant (directory) ID | entra.microsoft.com → Overview → Tenant ID, or App registrations → your app → Overview → Directory (tenant) ID |
<CLIENT_ID> |
The app registration's ID | entra.microsoft.com → App registrations → your app → Overview → Application (client) ID |
<CLIENT_SECRET> |
The app's password | App registrations → your app → Certificates & secrets → the secret's Value (shown only once when created — not the "Secret ID") |
<ENTERPRISE_OBJECT_ID> |
The app's service principal ID (used once, for Exchange setup) | entra.microsoft.com → Enterprise applications → search your app → Overview → Object ID (⚠ not the Object ID on the App registration page) |
<SENDER_MAILBOX> |
Mailbox the form sends from, e.g. [email protected] |
admin.microsoft.com → Teams & groups → Shared mailboxes |
<ADMIN_UPN> |
Your admin sign-in, e.g. [email protected] |
Account with Global Administrator or Exchange Administrator role (admin.microsoft.com → Users → Active users → you → Roles) |
<RECIPIENT> |
Where submissions are delivered | The site owner's inbox — any address, any domain |
Tenant and client IDs aren't secret, but keep them out of public repos anyway. The client secret is a credential — it lives only in each site's .env on the server.
One-time Microsoft 365 setup
1. Sender mailbox
admin.microsoft.com → Teams & groups → Shared mailboxes → Add a shared mailbox → e.g. [email protected].
Shared mailboxes need no license. Use a dedicated mailbox rather than a personal one — if the app secret ever leaks, the damage is limited to this address.
2. App registration
entra.microsoft.com → App registrations → New registration
- Name: e.g.
Website Forms - Supported account types: Accounts in this organizational directory only (single tenant)
- Redirect URI: leave blank
- From the Overview page, copy
<TENANT_ID>and<CLIENT_ID>. - Certificates & secrets → New client secret → copy the Value immediately →
<CLIENT_SECRET>. Note the expiry date (max 24 months) and set a reminder. - Do not add
Mail.Sendunder API permissions. That Entra grant allows sending as any mailbox in the tenant and overrides the scoping below.
3. Enterprise app Object ID
entra.microsoft.com → Enterprise applications → search the app name → copy Object ID → <ENTERPRISE_OBJECT_ID>.
4. Restrict the app to the sender mailbox (Exchange RBAC for Applications)
Run in a non-admin Windows PowerShell window (see gotchas below):
# One-time module install (admin window, local drive)
Install-Module ExchangeOnlineManagement -Scope AllUsers -Force
# Non-admin window
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName <ADMIN_UPN>
# One-time per tenant — small tenants ship "dehydrated" and block custom scopes until this runs.
# Can take a while; if it says to try later, wait 15–30 min and reconnect.
Enable-OrganizationCustomization
$AppId = "<CLIENT_ID>"
$ObjectId = "<ENTERPRISE_OBJECT_ID>"
# Register the app with Exchange
New-ServicePrincipal -AppId $AppId -ObjectId $ObjectId -DisplayName "Website Forms"
# Scope that matches only the sender mailbox
New-ManagementScope -Name "Website Forms Mailbox" -RecipientRestrictionFilter "PrimarySmtpAddress -eq '<SENDER_MAILBOX>'"
# Grant send rights limited to that scope
New-ManagementRoleAssignment -App $AppId -Role "Application Mail.Send" -CustomResourceScope "Website Forms Mailbox"
# Verify — expect InScope = True
Test-ServicePrincipalAuthorization -Identity $AppId -Resource <SENDER_MAILBOX>
# Optional: any other mailbox should show InScope = False
Test-ServicePrincipalAuthorization -Identity $AppId -Resource <ADMIN_UPN>
Disconnect-ExchangeOnline
Permissions can take 30 minutes – 2 hours to take effect for Graph.
Recovering values later:
- Forgot the scope/assignment names →
Get-ManagementScope,Get-ManagementRoleAssignment -RoleAssigneeType ServicePrincipal - Forgot which apps are registered with Exchange →
Get-ServicePrincipal - Lost the client secret → you can't view it again; create a new one and update every site's
.env.
Exchange PowerShell gotchas
| Symptom | Cause | Fix |
|---|---|---|
Security warning + Unable to find type [Microsoft.Exchange.Management.RestApiClient.ExchangeEnvironment] |
Module installed with -Scope CurrentUser into a Documents folder redirected to a network share; .NET won't load DLLs from it |
Install with -Scope AllUsers -Force (admin window), delete the network copy, and/or import by full path: Import-Module "C:\Program Files\WindowsPowerShell\Modules\ExchangeOnlineManagement\<version>\ExchangeOnlineManagement.psd1" |
Install-Module -Scope AllUsers silently does nothing |
It sees the CurrentUser copy as already installed | Add -Force |
| Old copy keeps loading | Module already loaded in that window | Close the window, open a new one |
A specified logon session does not exist (MSAL / WAM error 0x80070520) |
Running Connect-ExchangeOnline in an elevated window; -DisableWAM may not help |
Use a non-admin PowerShell window |
AADSTS530035 in Azure Cloud Shell or with -Device |
Security Defaults blocks device-code sign-in | Use local, non-admin PowerShell instead |
UnAuthorized in Cloud Shell |
Cloud Shell's automatic sign-in didn't get an Exchange token | Same — use local PowerShell |
New-ManagementScope "isn't currently allowed… run Enable-OrganizationCustomization" |
Tenant is dehydrated | Run Enable-OrganizationCustomization once and wait |
Per-site setup
No Microsoft 365 changes needed for additional sites — reuse the same app and sender.
- Copy the
backend/folder (code below) into the project. - Add the
backendservice todocker-compose.ymland the/api/block to the nginx config. - Wire the form's JS to
POST /api/inquiryand adjust the schema/email body to the site's fields. - Commit a
.env.example; add.env,__pycache__/,*.pycto.gitignore. - On the server, create
.env(below) with the site's<RECIPIENT>. docker compose up -d --build→docker psshows both containers (healthy).- Submit a test (point
MAIL_TOat yourself first if you don't want to bother the site owner). - Have the recipient check junk and mark the sender address as safe. Make sure SPF/DKIM are set up for your sending domain.
- Keep a private list of which sites use the app — you'll need it when rotating the secret.
.env
# Entra ID app registration
GRAPH_TENANT_ID=<TENANT_ID>
GRAPH_CLIENT_ID=<CLIENT_ID>
GRAPH_CLIENT_SECRET=<CLIENT_SECRET>
# Sender must be in your tenant; recipient can be anywhere
MAIL_SENDER=<SENDER_MAILBOX>
MAIL_TO=<RECIPIENT>
Keep comments on their own lines — inline comments after values can be misread.
Backend code (FastAPI)
backend/
├── Dockerfile
├── requirements.txt
└── app/
├── main.py
├── config.py
├── routers/inquiry.py
├── schemas/inquiry.py
└── utils/graph_mail.py
requirements.txt
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
pydantic[email]==2.10.4
pydantic-settings==2.7.1
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
# Entra ID app registration used to send mail through Microsoft Graph
graph_tenant_id: str
graph_client_id: str
graph_client_secret: str
# Mailbox the inquiry is sent from, and where it is delivered
mail_sender: str
mail_to: str
settings = Settings()
app/main.py
from fastapi import FastAPI
from app.routers import inquiry
# Docs disabled — this API only backs the website form
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
app.include_router(inquiry.router, prefix="/api", tags=["inquiry"])
@app.get("/health")
async def health():
return {"status": "ok"}
app/schemas/inquiry.py (adjust fields per site)
from pydantic import BaseModel, EmailStr, Field
class InquiryCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
email: EmailStr
phone: str = Field(default="", max_length=50)
date: str = Field(default="", max_length=20)
type: str = Field(default="", max_length=200)
guests: str = Field(default="", max_length=20)
message: str = Field(default="", max_length=5000)
# Honeypot — hidden from people, filled in by bots
website: str = ""
app/routers/inquiry.py
import logging
import httpx
from fastapi import APIRouter, HTTPException
from app.schemas.inquiry import InquiryCreate
from app.utils.graph_mail import send_mail
router = APIRouter()
log = logging.getLogger(__name__)
@router.post("/inquiry")
async def create_inquiry(inquiry: InquiryCreate):
# Bots fill the hidden field; pretend success so they don't retry
if inquiry.website:
return {"ok": True}
body = "\n".join([
f"Name: {inquiry.name}",
f"Email: {inquiry.email}",
f"Phone: {inquiry.phone}",
f"Event Date: {inquiry.date}",
f"Type of Event: {inquiry.type}",
f"Planned Guest Count: {inquiry.guests}",
"",
inquiry.message,
])
name = " ".join(inquiry.name.split())
try:
await send_mail(f"Event Inquiry - {name}", body, inquiry.email)
except httpx.HTTPError as e:
detail = e.response.text if isinstance(e, httpx.HTTPStatusError) else str(e)
log.error("Graph sendMail failed: %s", detail)
raise HTTPException(status_code=502, detail="Could not send the request")
return {"ok": True}
app/utils/graph_mail.py
import httpx
from app.config import settings
GRAPH_URL = "https://graph.microsoft.com/v1.0"
async def _get_token(client: httpx.AsyncClient) -> str:
"""Get an app-only access token via the client credentials flow."""
resp = await client.post(
f"https://login.microsoftonline.com/{settings.graph_tenant_id}/oauth2/v2.0/token",
data={
"grant_type": "client_credentials",
"client_id": settings.graph_client_id,
"client_secret": settings.graph_client_secret,
"scope": "https://graph.microsoft.com/.default",
},
)
resp.raise_for_status()
return resp.json()["access_token"]
async def send_mail(subject: str, body: str, reply_to: str) -> None:
"""Send a plain-text email from the configured mailbox.
Reply-To is set to the person who filled in the form so the recipient can
reply directly from their inbox.
"""
async with httpx.AsyncClient(timeout=15) as client:
token = await _get_token(client)
resp = await client.post(
f"{GRAPH_URL}/users/{settings.mail_sender}/sendMail",
headers={"Authorization": f"Bearer {token}"},
json={
"message": {
"subject": subject,
"body": {"contentType": "Text", "content": body},
"toRecipients": [{"emailAddress": {"address": settings.mail_to}}],
"replyTo": [{"emailAddress": {"address": reply_to}}],
},
"saveToSentItems": False,
},
)
resp.raise_for_status()
Docker Compose + nginx
docker-compose.yml additions
services:
nginx:
# ...existing nginx service...
depends_on:
backend:
condition: service_healthy
backend:
build: ./backend
container_name: <site>-backend
env_file: .env
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
cpus: "0.50"
memory: 128M
reservations:
memory: 32M
nginx server {} block addition
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 32k;
}
Front end (form + JS)
Honeypot field inside the <form> (hidden with CSS position:absolute; left:-9999px;) plus a status line. data-email is the fallback address shown if sending fails.
<form id="inquiry-form" data-email="<RECIPIENT>">
<!-- ...fields with name="name", "email", etc... -->
<div class="form-hp" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<button type="submit">Send Request</button>
<p id="inquiry-status" role="status"></p>
</form>
<script>
(function () {
var form = document.getElementById('inquiry-form');
var status = document.getElementById('inquiry-status');
var button = form.querySelector('button[type="submit"]');
form.addEventListener('submit', function (e) {
e.preventDefault();
function v(n) { return form.elements.namedItem(n).value; }
var data = {
name: v('name'),
email: v('email'),
phone: v('phone'),
message: v('message'),
website: v('website')
// ...other fields matching the schema
};
button.disabled = true;
status.textContent = 'Sending…';
fetch('/api/inquiry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(function (res) {
if (!res.ok) throw new Error(res.status);
form.reset();
status.textContent = 'Thank you! Your request has been sent — we\u2019ll be in touch soon.';
}).catch(function () {
status.textContent = 'Sorry, something went wrong. Please email us at ' + form.dataset.email + '.';
}).then(function () {
button.disabled = false;
});
});
})();
</script>
Maintenance & troubleshooting
- Secret expiry (max 24 months): create a new secret on the app, update
GRAPH_CLIENT_SECRETin every site's.env, thendocker compose up -don each. - Logs:
docker logs <site>-backend— Graph errors are logged with Microsoft's full message.ErrorAccessDenied/ 403 → RBAC assignment missing or not yet propagated.401/invalid_client→ wrong or expired client secret.ErrorInvalidRecipients→ badMAIL_TO.
- Adding another sender mailbox: widen the scope filter, e.g.
Set-ManagementScope "Website Forms Mailbox" -RecipientRestrictionFilter "PrimarySmtpAddress -eq '[email protected]' -or PrimarySmtpAddress -eq '[email protected]'" - Deploy conflict
container name "/<site>" is already in use: the existing container was created by a different compose project or a Portainer stack →docker rm -f <site>thendocker compose up -d(or redeploy through Portainer if it's a stack). - Spam: only a honeypot is included. Add nginx
limit_reqif a site starts getting abuse.