441 lines
16 KiB
Markdown
441 lines
16 KiB
Markdown
# 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.
|