From 44e4f5c4e690094535880b59cae2fdce8b9486b5 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 1 May 2026 14:44:28 -0400 Subject: [PATCH] feat: migrate email send to V3 action; deprecate api.py legacy endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/routers/api.py | 3 + app/routers/api_v3_actions_email.py | 78 +++++++++++++++++++ app/routers/registry.py | 4 +- .../GUIDE__AE_API_V3_for_Frontend.md | 45 ++++++++++- 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 app/routers/api_v3_actions_email.py diff --git a/app/routers/api.py b/app/routers/api.py index 69320d5..18974c4 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -114,6 +114,8 @@ async def authenticate_passcode( # --- 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)]) async def request_jwt( 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) 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)]) async def get_api_temp_token( x_aether_api_key: Optional[str] = Header(None), diff --git a/app/routers/api_v3_actions_email.py b/app/routers/api_v3_actions_email.py new file mode 100644 index 0000000..53d8bd3 --- /dev/null +++ b/app/routers/api_v3_actions_email.py @@ -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) diff --git a/app/routers/registry.py b/app/routers/registry.py index 2b5dbfe..fe73b54 100644 --- a/app/routers/registry.py +++ b/app/routers/registry.py @@ -5,6 +5,7 @@ from app.routers import ( data_store, event_badge_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, user, 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_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_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_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_domain.router, tags=['Site Domain'], 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_redis.router, tags=['Websockets (Redis)']) # LEGACY (disabled) - superseded by Websockets V3 app.include_router(websockets_v3.router, prefix='/v3', tags=['Websockets V3']) diff --git a/documentation/GUIDE__AE_API_V3_for_Frontend.md b/documentation/GUIDE__AE_API_V3_for_Frontend.md index 773dcae..fcdff04 100644 --- a/documentation/GUIDE__AE_API_V3_for_Frontend.md +++ b/documentation/GUIDE__AE_API_V3_for_Frontend.md @@ -19,12 +19,16 @@ Required for any non-public data (Journals, Badges, Users, etc.). * **Header:** `x-account-id: ` 2. **Administrative Bypass**: For authorized scripts needing global access. * **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. * **Query Param:** `?jwt=` 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`. * 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] > **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": "

Click here to log in.

", + "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. @@ -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. @@ -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: 1. Verify `x-aether-api-key` is correct.