# 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.com` with 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 | |---|---|---| | `` | Your Microsoft 365 tenant (directory) ID | entra.microsoft.com → **Overview** → **Tenant ID**, or App registrations → your app → Overview → **Directory (tenant) ID** | | `` | The app registration's ID | entra.microsoft.com → **App registrations** → your app → Overview → **Application (client) ID** | | `` | The app's password | App registrations → your app → **Certificates & secrets** → the secret's **Value** (shown only once when created — not the "Secret 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) | | `` | Mailbox the form sends from, e.g. `forms@yourdomain.com` | admin.microsoft.com → **Teams & groups** → **Shared mailboxes** | | `` | Your admin sign-in, e.g. `you@yourdomain.com` | Account with **Global Administrator** or **Exchange Administrator** role (admin.microsoft.com → Users → Active users → you → Roles) | | `` | 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. `forms@yourdomain.com`. 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 `` and ``. - **Certificates & secrets** → **New client secret** → copy the **Value** immediately → ``. Note the expiry date (max 24 months) and set a reminder. - **Do not** add `Mail.Send` under **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** → ``. ### 4. Restrict the app to the sender mailbox (Exchange RBAC for Applications) Run in a **non-admin** Windows PowerShell window (see gotchas below): ```powershell # One-time module install (admin window, local drive) Install-Module ExchangeOnlineManagement -Scope AllUsers -Force ``` ```powershell # Non-admin window Import-Module ExchangeOnlineManagement Connect-ExchangeOnline -UserPrincipalName # 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 = "" $ObjectId = "" # 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 ''" # 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 # Optional: any other mailbox should show InScope = False Test-ServicePrincipalAuthorization -Identity $AppId -Resource 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\\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. 1. Copy the `backend/` folder (code below) into the project. 2. Add the `backend` service to `docker-compose.yml` and the `/api/` block to the nginx config. 3. Wire the form's JS to `POST /api/inquiry` and adjust the schema/email body to the site's fields. 4. Commit a `.env.example`; add `.env`, `__pycache__/`, `*.pyc` to `.gitignore`. 5. On the server, create `.env` (below) with the site's ``. 6. `docker compose up -d --build` → `docker ps` shows both containers **(healthy)**. 7. Submit a test (point `MAIL_TO` at yourself first if you don't want to bother the site owner). 8. Have the recipient check junk and mark the sender address as safe. Make sure SPF/DKIM are set up for your sending domain. 9. Keep a private list of which sites use the app — you'll need it when rotating the secret. ### `.env` ```ini # Entra ID app registration GRAPH_TENANT_ID= GRAPH_CLIENT_ID= GRAPH_CLIENT_SECRET= # Sender must be in your tenant; recipient can be anywhere MAIL_SENDER= MAIL_TO= ``` 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` ```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` ```python 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` ```python 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) ```python 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` ```python 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` ```python 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 ```yaml services: nginx: # ...existing nginx service... depends_on: backend: condition: service_healthy backend: build: ./backend container_name: -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 ```nginx 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 `
` (hidden with CSS `position:absolute; left:-9999px;`) plus a status line. `data-email` is the fallback address shown if sending fails. ```html

``` ```html ``` --- ## Maintenance & troubleshooting - **Secret expiry** (max 24 months): create a new secret on the app, update `GRAPH_CLIENT_SECRET` in **every** site's `.env`, then `docker compose up -d` on each. - **Logs:** `docker logs -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` → bad `MAIL_TO`. - **Adding another sender mailbox:** widen the scope filter, e.g. `Set-ManagementScope "Website Forms Mailbox" -RecipientRestrictionFilter "PrimarySmtpAddress -eq 'forms@yourdomain.com' -or PrimarySmtpAddress -eq 'other@yourdomain.com'"` - **Deploy conflict** `container name "/" is already in use`: the existing container was created by a different compose project or a Portainer stack → `docker rm -f ` then `docker compose up -d` (or redeploy through Portainer if it's a stack). - **Spam:** only a honeypot is included. Add nginx `limit_req` if a site starts getting abuse.