diff --git a/playbooks/M365-Graph-Form-Email-Setup.md b/playbooks/M365-Graph-Form-Email-Setup.md new file mode 100644 index 0000000..0f95455 --- /dev/null +++ b/playbooks/M365-Graph-Form-Email-Setup.md @@ -0,0 +1,440 @@ +# 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.