added information on how to do email forms with my m365 tenet
This commit is contained in:
1 parent
e3feec67aa
commit
e88c1c7bab
1 file changed
+440
@@ -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.
|
||||||
Reference in new issue
Block a user