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

16 KiB
Raw Blame History

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):

# One-time module install (admin window, local drive)
Install-Module ExchangeOnlineManagement -Scope AllUsers -Force
# 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

# 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

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

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

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)

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

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

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

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

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.

<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>
<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.