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:
@@ -33,7 +33,7 @@ import logging
|
||||
import smtplib
|
||||
from email.utils import formataddr
|
||||
|
||||
from .email import EmailConfig, _SENT
|
||||
from .email import EmailConfig, _SENT, record_outbound
|
||||
from .email_envelope import build_envelope
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -85,14 +85,23 @@ def send_invite_email(
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
|
||||
message_id = msg["Message-ID"]
|
||||
if not cfg.enabled:
|
||||
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="deferred", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
if not cfg.smtp_host:
|
||||
# Dev fallback: surface the claim URL at INFO so the operator can
|
||||
# complete a claim flow without an SMTP relay. In production
|
||||
# SMTP_HOST is always set per OHM's overlay.
|
||||
log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="deferred", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -105,9 +114,18 @@ def send_invite_email(
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
smtp.quit()
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="sent", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.exception("invite email send failed: to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="failed",
|
||||
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user