Files
AI/playbooks/M365-Graph-Form-Email-Setup.md
T

441 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
|---|---|---|
| `<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.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** → `<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):
```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 <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.
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 `<RECIPIENT>`.
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=<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`
```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: <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
```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 `<form>` (hidden with CSS `position:absolute; left:-9999px;`) plus a status line. `data-email` is the fallback address shown if sending fails.
```html
<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>
```
```html
<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_SECRET` in **every** site's `.env`, then `docker compose up -d` on 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` → bad `MAIL_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>` 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.