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:
@@ -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),
|
||||||
|
|||||||
78
app/routers/api_v3_actions_email.py
Normal file
78
app/routers/api_v3_actions_email.py
Normal 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)
|
||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user