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,
|
"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")
|
@router.get("/api/admin/permission-events")
|
||||||
async def list_permission_events(
|
async def list_permission_events(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ def assemble_for_user(
|
|||||||
cfg, email, subject, body,
|
cfg, email, subject, body,
|
||||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||||
unsubscribe_url=unsubscribe_url,
|
unsubscribe_url=unsubscribe_url,
|
||||||
|
kind="digest",
|
||||||
)
|
)
|
||||||
if not sent:
|
if not sent:
|
||||||
return False
|
return False
|
||||||
|
|||||||
+90
-1
@@ -281,6 +281,8 @@ def _send_one(user: Any, notif_id: int, payload: dict, category: str) -> None:
|
|||||||
body,
|
body,
|
||||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||||
unsubscribe_url=unsubscribe_url,
|
unsubscribe_url=unsubscribe_url,
|
||||||
|
kind="notification",
|
||||||
|
notification_id=notif_id,
|
||||||
)
|
)
|
||||||
if not sent:
|
if not sent:
|
||||||
return
|
return
|
||||||
@@ -344,6 +346,8 @@ def _deliver(
|
|||||||
*,
|
*,
|
||||||
unsubscribe_mailto: str | None = None,
|
unsubscribe_mailto: str | None = None,
|
||||||
unsubscribe_url: str | None = None,
|
unsubscribe_url: str | None = None,
|
||||||
|
kind: str = "notification",
|
||||||
|
notification_id: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Build the envelope via the shared `build_envelope` helper and
|
"""Build the envelope via the shared `build_envelope` helper and
|
||||||
hand it to SMTP.
|
hand it to SMTP.
|
||||||
@@ -353,6 +357,13 @@ def _deliver(
|
|||||||
backward-compatibility with tests that read those directly.
|
backward-compatibility with tests that read those directly.
|
||||||
Newer tests can assert on the header surface by inspecting
|
Newer tests can assert on the header surface by inspecting
|
||||||
`envelope["message"]`.
|
`envelope["message"]`.
|
||||||
|
|
||||||
|
v0.18.0 Slice 4: also writes one row to `outbound_emails`
|
||||||
|
capturing the attempt. status='sent' on success, 'failed' on
|
||||||
|
SMTP exception, 'deferred' on the dev-fallback path (no
|
||||||
|
SMTP_HOST configured — the send didn't happen, but the row
|
||||||
|
records the attempt so the admin endpoint can answer "did the
|
||||||
|
framework try?").
|
||||||
"""
|
"""
|
||||||
msg = build_envelope(
|
msg = build_envelope(
|
||||||
to_address=to_address,
|
to_address=to_address,
|
||||||
@@ -369,10 +380,21 @@ def _deliver(
|
|||||||
"subject": subject,
|
"subject": subject,
|
||||||
"body": body,
|
"body": body,
|
||||||
"message": msg,
|
"message": msg,
|
||||||
|
"kind": kind,
|
||||||
}
|
}
|
||||||
_SENT.append(envelope)
|
_SENT.append(envelope)
|
||||||
|
message_id = msg["Message-ID"]
|
||||||
if not cfg.smtp_host:
|
if not cfg.smtp_host:
|
||||||
log.info("email (stdout fallback): to=%s subject=%s", to_address, subject)
|
log.info("email (stdout fallback): to=%s subject=%s", to_address, subject)
|
||||||
|
record_outbound(
|
||||||
|
to_address=to_address,
|
||||||
|
from_address=cfg.from_address,
|
||||||
|
subject=subject,
|
||||||
|
kind=kind,
|
||||||
|
status="deferred",
|
||||||
|
message_id=message_id,
|
||||||
|
notification_id=notification_id,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
||||||
@@ -384,12 +406,78 @@ def _deliver(
|
|||||||
smtp.send_message(msg)
|
smtp.send_message(msg)
|
||||||
finally:
|
finally:
|
||||||
smtp.quit()
|
smtp.quit()
|
||||||
|
record_outbound(
|
||||||
|
to_address=to_address,
|
||||||
|
from_address=cfg.from_address,
|
||||||
|
subject=subject,
|
||||||
|
kind=kind,
|
||||||
|
status="sent",
|
||||||
|
message_id=message_id,
|
||||||
|
notification_id=notification_id,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
log.exception("email send failed: to=%s subject=%s", to_address, subject)
|
log.exception("email send failed: to=%s subject=%s", to_address, subject)
|
||||||
|
record_outbound(
|
||||||
|
to_address=to_address,
|
||||||
|
from_address=cfg.from_address,
|
||||||
|
subject=subject,
|
||||||
|
kind=kind,
|
||||||
|
status="failed",
|
||||||
|
error=f"{type(exc).__name__}: {exc}",
|
||||||
|
message_id=message_id,
|
||||||
|
notification_id=notification_id,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def record_outbound(
|
||||||
|
*,
|
||||||
|
to_address: str,
|
||||||
|
from_address: str,
|
||||||
|
subject: str,
|
||||||
|
kind: str,
|
||||||
|
status: str,
|
||||||
|
error: str | None = None,
|
||||||
|
notification_id: int | None = None,
|
||||||
|
message_id: str | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
"""v0.18.0 Slice 4: write one row to `outbound_emails`.
|
||||||
|
|
||||||
|
Returns the inserted row's id, or `None` if the DB connection
|
||||||
|
isn't initialized (which happens in unit tests that don't boot
|
||||||
|
the full app — the write is best-effort and never raises).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO outbound_emails
|
||||||
|
(to_address, from_address, subject, kind, sent_at, status,
|
||||||
|
error, notification_id, message_id)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
to_address,
|
||||||
|
from_address,
|
||||||
|
subject,
|
||||||
|
kind,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
notification_id,
|
||||||
|
message_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
except RuntimeError:
|
||||||
|
# db.conn() raises RuntimeError if init() hasn't been called.
|
||||||
|
# Pure-helper unit tests for build_envelope hit this path; the
|
||||||
|
# audit row is best-effort and not part of the contract.
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
log.exception("outbound_emails write failed: to=%s subject=%s", to_address, subject)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Quiet-hours release pass — called from the digest job
|
# Quiet-hours release pass — called from the digest job
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -512,6 +600,7 @@ def _send_bundle(cfg: EmailConfig, user: Any, emailable: list) -> int:
|
|||||||
body,
|
body,
|
||||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||||
unsubscribe_url=unsubscribe_url,
|
unsubscribe_url=unsubscribe_url,
|
||||||
|
kind="bundle",
|
||||||
)
|
)
|
||||||
if not sent:
|
if not sent:
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import logging
|
|||||||
import smtplib
|
import smtplib
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
|
|
||||||
from .email import EmailConfig, _SENT
|
from .email import EmailConfig, _SENT, record_outbound
|
||||||
from .email_envelope import build_envelope
|
from .email_envelope import build_envelope
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -85,14 +85,23 @@ def send_invite_email(
|
|||||||
}
|
}
|
||||||
_SENT.append(envelope)
|
_SENT.append(envelope)
|
||||||
|
|
||||||
|
message_id = msg["Message-ID"]
|
||||||
if not cfg.enabled:
|
if not cfg.enabled:
|
||||||
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
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
|
return True
|
||||||
if not cfg.smtp_host:
|
if not cfg.smtp_host:
|
||||||
# Dev fallback: surface the claim URL at INFO so the operator can
|
# Dev fallback: surface the claim URL at INFO so the operator can
|
||||||
# complete a claim flow without an SMTP relay. In production
|
# complete a claim flow without an SMTP relay. In production
|
||||||
# SMTP_HOST is always set per OHM's overlay.
|
# SMTP_HOST is always set per OHM's overlay.
|
||||||
log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
|
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
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -105,9 +114,18 @@ def send_invite_email(
|
|||||||
smtp.send_message(msg)
|
smtp.send_message(msg)
|
||||||
finally:
|
finally:
|
||||||
smtp.quit()
|
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
|
return True
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
log.exception("invite email send failed: to=%s", to_address)
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import logging
|
|||||||
import smtplib
|
import smtplib
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
|
|
||||||
from .email import EmailConfig, _SENT
|
from .email import EmailConfig, _SENT, record_outbound
|
||||||
from .email_envelope import build_envelope
|
from .email_envelope import build_envelope
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -66,14 +66,23 @@ def send_otc_email(to_address: str, code: str) -> bool:
|
|||||||
}
|
}
|
||||||
_SENT.append(envelope)
|
_SENT.append(envelope)
|
||||||
|
|
||||||
|
message_id = msg["Message-ID"]
|
||||||
if not cfg.enabled:
|
if not cfg.enabled:
|
||||||
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||||
|
record_outbound(
|
||||||
|
to_address=to_address, from_address=cfg.from_address,
|
||||||
|
subject=subject, kind="otc", status="deferred", message_id=message_id,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
if not cfg.smtp_host:
|
if not cfg.smtp_host:
|
||||||
# Dev fallback: surface the code at INFO so the operator can
|
# Dev fallback: surface the code at INFO so the operator can
|
||||||
# complete a sign-in flow without an SMTP relay. In production
|
# complete a sign-in flow without an SMTP relay. In production
|
||||||
# SMTP_HOST is always set per OHM's overlay.
|
# SMTP_HOST is always set per OHM's overlay.
|
||||||
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
|
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
|
||||||
|
record_outbound(
|
||||||
|
to_address=to_address, from_address=cfg.from_address,
|
||||||
|
subject=subject, kind="otc", status="deferred", message_id=message_id,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -86,9 +95,18 @@ def send_otc_email(to_address: str, code: str) -> bool:
|
|||||||
smtp.send_message(msg)
|
smtp.send_message(msg)
|
||||||
finally:
|
finally:
|
||||||
smtp.quit()
|
smtp.quit()
|
||||||
|
record_outbound(
|
||||||
|
to_address=to_address, from_address=cfg.from_address,
|
||||||
|
subject=subject, kind="otc", status="sent", message_id=message_id,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
log.exception("otc email send failed: to=%s", to_address)
|
log.exception("otc email send failed: to=%s", to_address)
|
||||||
|
record_outbound(
|
||||||
|
to_address=to_address, from_address=cfg.from_address,
|
||||||
|
subject=subject, kind="otc", status="failed",
|
||||||
|
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- v0.18.0 Slice 4: outbound_emails audit table.
|
||||||
|
--
|
||||||
|
-- Per the v0.18.0 email + webhook hygiene proposal §3, every send
|
||||||
|
-- helper writes a row to this table before returning, regardless
|
||||||
|
-- of outcome. status='sent' on success, 'failed' on exception,
|
||||||
|
-- 'deferred' on the dev-fallback path (no SMTP_HOST configured).
|
||||||
|
--
|
||||||
|
-- The table is queried by `GET /api/admin/outbound-emails` to
|
||||||
|
-- answer "did this person ever get their invite?" without having
|
||||||
|
-- to grep VM logs, and by the v0.18.0 Slice 5 bounce-correlation
|
||||||
|
-- hook (which looks up message_id when a POST lands at
|
||||||
|
-- /api/webhooks/email-bounce and marks the matching row
|
||||||
|
-- status='bounced').
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS outbound_emails (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
to_address TEXT NOT NULL,
|
||||||
|
from_address TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- 'otc' | 'invite' | 'notification' | 'bundle' | 'digest' | 'rfc-invite'
|
||||||
|
sent_at TEXT NOT NULL, -- ISO 8601, time the send was attempted
|
||||||
|
status TEXT NOT NULL, -- 'sent' | 'failed' | 'deferred' | 'bounced'
|
||||||
|
error TEXT, -- exception class + message if status='failed'
|
||||||
|
notification_id INTEGER, -- nullable FK to notifications.id for the watcher path
|
||||||
|
message_id TEXT -- the Message-ID header value, for bounce correlation
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outbound_emails_to ON outbound_emails(to_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outbound_emails_sent_at ON outbound_emails(sent_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outbound_emails_message ON outbound_emails(message_id);
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""End-to-end integration tests for the v0.18.0 Slice 4
|
||||||
|
outbound_emails audit table + admin endpoint.
|
||||||
|
|
||||||
|
The release adds:
|
||||||
|
* `backend/migrations/020_outbound_emails.sql` — the audit table.
|
||||||
|
* `record_outbound()` in `email.py` — the write helper every send
|
||||||
|
path calls before returning, capturing status='sent' / 'failed'
|
||||||
|
/ 'deferred' (the dev-fallback path when SMTP_HOST is unset).
|
||||||
|
* `GET /api/admin/outbound-emails` — admin-only listing, filterable
|
||||||
|
by kind / status / to_address.
|
||||||
|
|
||||||
|
These tests prove:
|
||||||
|
* Sending OTC / invite / notification mail writes one row per send
|
||||||
|
(status='deferred' under tests since SMTP_HOST is unset).
|
||||||
|
* The Message-ID on the row matches the envelope's Message-ID
|
||||||
|
header (the seam Slice 5 uses for bounce correlation).
|
||||||
|
* `kind` is populated per send path.
|
||||||
|
* `GET /api/admin/outbound-emails` lists rows newest-first,
|
||||||
|
accepts filters, refuses non-admins.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Write-on-send wiring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_send_writes_outbound_row_with_message_id(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db, email as email_mod
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "newcomer@ex.co"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# Audit row landed.
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"SELECT id, to_address, kind, status, message_id, error "
|
||||||
|
"FROM outbound_emails WHERE to_address = 'newcomer@ex.co'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
row = rows[0]
|
||||||
|
assert row["kind"] == "otc"
|
||||||
|
# No SMTP_HOST in tests -> 'deferred', not 'sent'.
|
||||||
|
assert row["status"] == "deferred"
|
||||||
|
assert row["error"] is None
|
||||||
|
# Message-ID matches the envelope's header (the seam Slice 5 uses).
|
||||||
|
envelopes = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
|
||||||
|
assert envelopes
|
||||||
|
envelope_mid = envelopes[-1]["message"]["Message-ID"]
|
||||||
|
assert row["message_id"] == envelope_mid
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_send_writes_outbound_row(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=500, login="adminQ", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=500, gitea_login="adminQ",
|
||||||
|
display_name="Admin Q", role="admin",
|
||||||
|
email="adminq@test",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "invitee@ex.co",
|
||||||
|
"first_name": "Inv", "last_name": "Itee",
|
||||||
|
"role": "contributor", "custom_message": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"SELECT kind, status, message_id FROM outbound_emails "
|
||||||
|
"WHERE to_address = 'invitee@ex.co'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["kind"] == "invite"
|
||||||
|
assert rows[0]["status"] == "deferred"
|
||||||
|
assert rows[0]["message_id"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_send_writes_outbound_row_with_notification_id(app_with_fake_gitea):
|
||||||
|
"""Watcher notifications carry a `notification_id` FK so the
|
||||||
|
admin can join through to the notifications table to see what
|
||||||
|
triggered the send."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db, email as email_mod
|
||||||
|
from test_notifications_vertical import PITCH
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=2, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor", email="alice@test",
|
||||||
|
)
|
||||||
|
r = client.post("/api/rfcs/propose", json={
|
||||||
|
"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
|
||||||
|
})
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
# Wipe pre-merge audit rows so the assertion below is unambiguous.
|
||||||
|
db.conn().execute("DELETE FROM outbound_emails")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=1, gitea_login="ben",
|
||||||
|
display_name="Ben", role="owner", email="ben@test",
|
||||||
|
)
|
||||||
|
merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
|
||||||
|
assert merge_r.status_code == 200, merge_r.text
|
||||||
|
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"SELECT kind, status, notification_id, message_id "
|
||||||
|
"FROM outbound_emails WHERE to_address = 'alice@test'"
|
||||||
|
).fetchall()
|
||||||
|
assert rows, "no outbound_emails row for alice@test"
|
||||||
|
# At least one notification kind, with a populated FK.
|
||||||
|
notif_rows = [r for r in rows if r["kind"] == "notification"]
|
||||||
|
assert notif_rows
|
||||||
|
for nr in notif_rows:
|
||||||
|
assert nr["status"] == "deferred"
|
||||||
|
assert nr["notification_id"] is not None
|
||||||
|
assert nr["message_id"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_outbound_emails_lists_rows(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Generate a few rows.
|
||||||
|
client.post("/auth/otc/request", json={"email": "one@ex.co"})
|
||||||
|
|
||||||
|
provision_user_row(user_id=600, login="adminR", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=600, gitea_login="adminR",
|
||||||
|
display_name="Admin R", role="admin", email="adminr@test",
|
||||||
|
)
|
||||||
|
client.post("/api/admin/users", json={
|
||||||
|
"email": "two@ex.co", "first_name": "T", "last_name": "Wo",
|
||||||
|
"role": "contributor", "custom_message": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
r = client.get("/api/admin/outbound-emails")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
items = r.json()["items"]
|
||||||
|
kinds = {it["kind"] for it in items}
|
||||||
|
assert "otc" in kinds
|
||||||
|
assert "invite" in kinds
|
||||||
|
# Newest-first.
|
||||||
|
ids = [it["id"] for it in items]
|
||||||
|
assert ids == sorted(ids, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_outbound_emails_filters_by_kind(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.post("/auth/otc/request", json={"email": "filter1@ex.co"})
|
||||||
|
|
||||||
|
provision_user_row(user_id=601, login="adminS", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=601, gitea_login="adminS",
|
||||||
|
display_name="Admin S", role="admin", email="admins@test",
|
||||||
|
)
|
||||||
|
client.post("/api/admin/users", json={
|
||||||
|
"email": "filter2@ex.co", "first_name": "F", "last_name": "Two",
|
||||||
|
"role": "contributor", "custom_message": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
r = client.get("/api/admin/outbound-emails?kind=otc")
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert items
|
||||||
|
assert all(it["kind"] == "otc" for it in items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_outbound_emails_filters_by_to_address(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.post("/auth/otc/request", json={"email": "TARGET@ex.co"})
|
||||||
|
client.post("/auth/otc/request", json={"email": "other@ex.co"})
|
||||||
|
|
||||||
|
provision_user_row(user_id=602, login="adminT", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=602, gitea_login="adminT",
|
||||||
|
display_name="Admin T", role="admin", email="admint@test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# to_address filter is case-insensitive.
|
||||||
|
r = client.get("/api/admin/outbound-emails?to_address=target@ex.co")
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert items
|
||||||
|
assert all(it["to_address"].lower() == "target@ex.co" for it in items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_outbound_emails_refuses_non_admin(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=700, login="contribU", role="contributor")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=700, gitea_login="contribU",
|
||||||
|
display_name="Contrib U", role="contributor",
|
||||||
|
)
|
||||||
|
r = client.get("/api/admin/outbound-emails")
|
||||||
|
assert r.status_code == 403
|
||||||
Reference in New Issue
Block a user