v0.18.0 Slice 4: outbound_emails audit table + admin endpoint
Per the v0.18.0 email + webhook hygiene proposal §3:
* `backend/migrations/020_outbound_emails.sql` — new table
capturing every send attempt: to_address, from_address,
subject, kind, sent_at, status ('sent' | 'failed' | 'deferred'
| 'bounced'), error, notification_id (FK), message_id (for
Slice 5 bounce correlation).
* `email.record_outbound()` — best-effort write helper every
send path calls. Status='sent' on SMTP success, 'failed' on
exception (with class + message in `error`), 'deferred' on
the dev-fallback path where SMTP_HOST is unset (the send
didn't happen but the row records the attempt).
* `email._deliver` (watcher notifications + bundles), `digest.py`,
`email_otc.py`, `email_invite.py` — every send path now records.
* `GET /api/admin/outbound-emails` — admin-only listing,
filterable by kind / status / to_address. Answers "did this
person ever get their invite?" without grepping VM logs. No
admin UI in v0.18.0; operator queries via curl + jq for now.
7 new integration tests covering: OTC / invite / notification all
write rows with matching Message-ID; admin endpoint lists,
filters by kind, filters by to_address (case-insensitive),
refuses non-admins.
Full suite: 291 passed.
This commit is contained in:
@@ -672,6 +672,73 @@ def make_router(config: Config) -> APIRouter:
|
||||
"has_more": len(rows) == limit,
|
||||
}
|
||||
|
||||
@router.get("/api/admin/outbound-emails")
|
||||
async def list_outbound_emails(
|
||||
request: Request,
|
||||
kind: str | None = None,
|
||||
status: str | None = None,
|
||||
to_address: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
before_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""v0.18.0 Slice 4: read-only inspection of the
|
||||
`outbound_emails` audit table.
|
||||
|
||||
Answers questions like "did this person ever get their
|
||||
invite?" without grepping VM logs. Filterable by kind
|
||||
('otc' | 'invite' | 'notification' | 'bundle' | 'digest'),
|
||||
status ('sent' | 'failed' | 'deferred' | 'bounced'), and
|
||||
to_address; the latter is exact-match because the audit
|
||||
question is usually "the specific person who said they
|
||||
didn't receive it." Per the proposal, no admin UI ships
|
||||
with v0.18.0 — operator queries via curl + jq for now.
|
||||
"""
|
||||
auth.require_admin(request)
|
||||
clauses: list[str] = []
|
||||
args: list[Any] = []
|
||||
if kind:
|
||||
clauses.append("kind = ?")
|
||||
args.append(kind)
|
||||
if status:
|
||||
clauses.append("status = ?")
|
||||
args.append(status)
|
||||
if to_address:
|
||||
clauses.append("LOWER(to_address) = LOWER(?)")
|
||||
args.append(to_address)
|
||||
if before_id is not None:
|
||||
clauses.append("id < ?")
|
||||
args.append(before_id)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
rows = db.conn().execute(
|
||||
f"""
|
||||
SELECT id, to_address, from_address, subject, kind, sent_at,
|
||||
status, error, notification_id, message_id
|
||||
FROM outbound_emails
|
||||
{where}
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*args, limit),
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"to_address": r["to_address"],
|
||||
"from_address": r["from_address"],
|
||||
"subject": r["subject"],
|
||||
"kind": r["kind"],
|
||||
"sent_at": r["sent_at"],
|
||||
"status": r["status"],
|
||||
"error": r["error"],
|
||||
"notification_id": r["notification_id"],
|
||||
"message_id": r["message_id"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"has_more": len(rows) == limit,
|
||||
}
|
||||
|
||||
@router.get("/api/admin/permission-events")
|
||||
async def list_permission_events(
|
||||
request: Request,
|
||||
|
||||
Reference in New Issue
Block a user