added information on how to do email forms with my m365 tenet

This commit is contained in:
derekc committed 2026-10-20 23:56:37 -07:00
1 parent e3feec67aa
commit e88c1c7bab
1 file changed
+440
+440
View File
@@ -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 |
|---|---|---|
| `<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.