feat: migrate email send to V3 action; deprecate api.py legacy endpoints

- Add /v3/action/email/send router (api_v3_actions_email.py) replacing /util/email/send
- Disable util_email router in registry; register new email action router
- Mark /api/request_jwt and /api/temp_token as deprecated (TODO: remove)
- Guide: add §8 Email Send Action, mark Axonius section EXPIRED, renumber §9-§11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-01 14:44:28 -04:00
parent c378040ad4
commit 44e4f5c4e6
4 changed files with 126 additions and 4 deletions

View File

@@ -114,6 +114,8 @@ async def authenticate_passcode(
# --- JWT Request --- # --- JWT Request ---
# DEPRECATED — no V3 replacement needed; passcode→JWT is the V3 auth pattern (/api/authenticate_passcode).
# No frontend references found. Safe to remove after confirming no live traffic. TODO: remove.
@router.get('/request_jwt', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)]) @router.get('/request_jwt', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
async def request_jwt( async def request_jwt(
x_aether_signing_key: Optional[str] = Header(None, min_length=22, max_length=22), x_aether_signing_key: Optional[str] = Header(None, min_length=22, max_length=22),
@@ -167,6 +169,7 @@ async def request_jwt(
token = sign_jwt(secret_key=signing_key, public_key=x_aether_api_key, ttl=max_ttl, max_renew=max_renew, **payload) token = sign_jwt(secret_key=signing_key, public_key=x_aether_api_key, ttl=max_ttl, max_renew=max_renew, **payload)
return mk_resp(data={ 'jwt': token }) return mk_resp(data={ 'jwt': token })
# DEPRECATED — no active use identified. TODO: remove after confirming no live traffic.
@router.get('/temp_token', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)]) @router.get('/temp_token', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
async def get_api_temp_token( async def get_api_temp_token(
x_aether_api_key: Optional[str] = Header(None), x_aether_api_key: Optional[str] = Header(None),

View File

@@ -0,0 +1,78 @@
"""
Aether API V3 - Email Action Router
-------------------------------------
Handles transactional email sending.
Routes:
POST /send — send a transactional email
Replaces: POST /util/email/send (legacy — see util_email.py)
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query, Response
from pydantic import BaseModel, Field
from app.lib_email import send_email
from app.lib_general_v3 import AccountContext, get_account_context
from app.models.response_models import Resp_Body_Base, mk_resp
log = logging.getLogger(__name__)
router = APIRouter()
class EmailSendRequest(BaseModel):
from_email: str = Field(..., description="Sender email address")
from_name: Optional[str] = None
to_email: str = Field(..., description="Recipient email address")
to_name: Optional[str] = None
cc_email: Optional[str] = None
cc_name: Optional[str] = None
bcc_email: Optional[str] = None
bcc_name: Optional[str] = None
subject: str = Field(..., description="Email subject line")
body_html: str = Field(..., description="HTML email body")
body_text: Optional[str] = None
@router.post('/send', response_model=Resp_Body_Base)
async def action_email_send(
req: EmailSendRequest,
test: bool = Query(False, description="Simulate send without delivering"),
account_ctx: AccountContext = Depends(get_account_context),
response: Response = Response,
):
log.setLevel(logging.INFO)
success = send_email(
from_email=req.from_email,
from_name=req.from_name,
to_email=req.to_email,
to_name=req.to_name,
cc_email=req.cc_email or '',
cc_name=req.cc_name or '',
bcc_email=req.bcc_email or '',
bcc_name=req.bcc_name or '',
subject=req.subject,
body_text=req.body_text,
body_html=req.body_html,
test=test,
)
if success:
status_code = 200
status_message = f'Email sent to <{req.to_email}>.'
else:
status_code = 400
status_message = f'Email failed to send to <{req.to_email}>.'
log.info(status_message)
resp_data = {
'from_email': req.from_email,
'to_email': req.to_email,
'subject': req.subject[:40],
}
return mk_resp(data=resp_data, status_code=status_code, response=response, status_message=status_message)

View File

@@ -5,6 +5,7 @@ from app.routers import (
data_store, data_store,
event_badge_importing, event_badge_importing,
event_importing, event_importing,
api_v3_actions_email,
api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_user, lookup_v3, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_user, lookup_v3,
user, user,
util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
@@ -51,6 +52,7 @@ def setup_routers(app: FastAPI):
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)']) app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)']) app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)']) app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)'])
app.include_router(api_v3_actions_email.router, prefix='/v3/action/email', tags=['Email (V3 Actions)'])
# app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup # app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3']) app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
@@ -63,7 +65,7 @@ def setup_routers(app: FastAPI):
# app.include_router(site.router, tags=['Site'], dependencies=[Depends(DeprecationParams)]) # app.include_router(site.router, tags=['Site'], dependencies=[Depends(DeprecationParams)])
# app.include_router(site_domain.router, tags=['Site Domain'], dependencies=[Depends(DeprecationParams)]) # app.include_router(site_domain.router, tags=['Site Domain'], dependencies=[Depends(DeprecationParams)])
# app.include_router(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)]) # app.include_router(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)])
app.include_router(util_email.router, tags=['Utility: Email']) # app.include_router(util_email.router, tags=['Utility: Email']) # LEGACY (disabled) - superseded by /v3/action/email/send
# app.include_router(websockets.router, tags=['Websockets']) # LEGACY (disabled) - superseded by Websockets V3 # app.include_router(websockets.router, tags=['Websockets']) # LEGACY (disabled) - superseded by Websockets V3
# app.include_router(websockets_redis.router, tags=['Websockets (Redis)']) # LEGACY (disabled) - superseded by Websockets V3 # app.include_router(websockets_redis.router, tags=['Websockets (Redis)']) # LEGACY (disabled) - superseded by Websockets V3
app.include_router(websockets_v3.router, prefix='/v3', tags=['Websockets V3']) app.include_router(websockets_v3.router, prefix='/v3', tags=['Websockets V3'])

View File

@@ -19,12 +19,16 @@ Required for any non-public data (Journals, Badges, Users, etc.).
* **Header:** `x-account-id: <account_id>` * **Header:** `x-account-id: <account_id>`
2. **Administrative Bypass**: For authorized scripts needing global access. 2. **Administrative Bypass**: For authorized scripts needing global access.
* **Header:** `x-no-account-id: bypass` * **Header:** `x-no-account-id: bypass`
* **Scope:** Narrow escape hatch only. Keep it limited to allowlisted bootstrap/public/global-default paths and prefer `x-account-id` or JWT-backed requests everywhere else.
3. **Token Access**: Provide a **JWT** in the query string. 3. **Token Access**: Provide a **JWT** in the query string.
* **Query Param:** `?jwt=<token>` * **Query Param:** `?jwt=<token>`
4. **Important Distinction:** A query parameter named `key` is **not** an account-context bypass signal. 4. **Important Distinction:** A query parameter named `key` is **not** an account-context bypass signal.
* `key` may be used by specific endpoints/business logic, but it must **not** cause the frontend to remove `x-account-id`. * `key` may be used by specific endpoints/business logic, but it must **not** cause the frontend to remove `x-account-id`.
* Only explicit `x-no-account-id: bypass` should strip account context. * Only explicit `x-no-account-id: bypass` should strip account context.
> [!NOTE]
> The `x-no-account-id` path should continue to shrink over time. If you need a new use, document why `x-account-id` or JWT cannot cover it and mark the use as temporary unless it is a hard bootstrap/global-default requirement.
> [!CAUTION] > [!CAUTION]
> **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data. > **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data.
@@ -312,7 +316,42 @@ Frontend guidance:
--- ---
## Axonius Zoom CSV Upload (Temporary — Apr 2026) ## 8. Email Send Action
Send a transactional email via the Aether API.
- **Method:** `POST`
- **Path:** `/v3/action/email/send`
- **Auth:** `x-aether-api-key` + `x-account-id` (or `x-no-account-id` / `?jwt=`)
**Request body:**
```json
{
"from_email": "noreply@example.com",
"from_name": "Example App",
"to_email": "user@example.com",
"to_name": "Alice Smith",
"subject": "Your login link",
"body_html": "<p>Click <a href=\"...\">here</a> to log in.</p>",
"body_text": "Visit ... to log in.",
"cc_email": null,
"bcc_email": null
}
```
**Query params:**
| Parameter | Type | Default | Description |
|---|---|---|---|
| `test` | bool | `false` | Simulate send without delivering |
**Response:** `data` contains `{ from_email, to_email, subject }` (first 40 chars of subject). `400` if delivery failed.
> **Replaces:** `POST /util/email/send` (disabled as of May 2026).
---
## Axonius Zoom CSV Upload (Temporary — Apr 2026, EXPIRED)
Purpose: Staff-only quick upload to upsert Event Person + Event Badge records from a Zoom Events registrant CSV. Purpose: Staff-only quick upload to upsert Event Person + Event Badge records from a Zoom Events registrant CSV.
@@ -531,7 +570,7 @@ Results are automatically scoped to the `x-account-id` provided in the request.
--- ---
## 9. Event Exhibit Tracking Export (Leads Export) ## 10. Event Exhibit Tracking Export (Leads Export)
Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file. Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.
@@ -599,7 +638,7 @@ const url = URL.createObjectURL(blob);
--- ---
## 10. Troubleshooting 403 Forbidden ## 11. Troubleshooting 403 Forbidden
If you receive a 403 on a valid ID: If you receive a 403 on a valid ID:
1. Verify `x-aether-api-key` is correct. 1. Verify `x-aether-api-key` is correct.