Track and resend organization invitation emails#674
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces platform admin endpoints to list organization invitation email deliveries and resend invitation emails, along with their corresponding database queries, service logic, API models, and integration tests. The review feedback highlights a critical PostgreSQL type mismatch issue in the database repository where custom enum columns are compared directly to text parameters in both the list and count queries. Additionally, it suggests an improvement to update the invitation status to Expired in the database when a resend is attempted on an expired invitation.
| AND ($3::text IS NULL OR i.email_status = $3) | ||
| AND ($4::text IS NULL OR i.status = $4) |
There was a problem hiding this comment.
In PostgreSQL, comparing a custom enum column directly to a parameter explicitly cast to text (e.g., $3::text or $4::text) will result in a type mismatch error (operator does not exist: invitation_email_status = text) because PostgreSQL does not implicitly coerce text to custom enum types.
To prevent runtime database errors, cast the enum columns to text in the comparison.
| AND ($3::text IS NULL OR i.email_status = $3) | |
| AND ($4::text IS NULL OR i.status = $4) | |
| AND ($3::text IS NULL OR i.email_status::text = $3) | |
| AND ($4::text IS NULL OR i.status::text = $4) |
| AND ($3::text IS NULL OR i.email_status = $3) | ||
| AND ($4::text IS NULL OR i.status = $4) |
There was a problem hiding this comment.
Apply the same enum-to-text casting in the count query to avoid type mismatch errors when filtering by email_status or status.
| AND ($3::text IS NULL OR i.email_status = $3) | |
| AND ($4::text IS NULL OR i.status = $4) | |
| AND ($3::text IS NULL OR i.email_status::text = $3) | |
| AND ($4::text IS NULL OR i.status::text = $4) |
| if invitation.expires_at < chrono::Utc::now() { | ||
| return Err(OrganizationError::InvalidParams( | ||
| "Invitation has expired".to_string(), | ||
| )); | ||
| } |
There was a problem hiding this comment.
When a resend attempt is made on an expired invitation, its status in the database should be updated to Expired to maintain data consistency. This mirrors the behavior in accept_invitation_impl where expired invitations are explicitly marked as Expired in the database.
| if invitation.expires_at < chrono::Utc::now() { | |
| return Err(OrganizationError::InvalidParams( | |
| "Invitation has expired".to_string(), | |
| )); | |
| } | |
| if invitation.expires_at < chrono::Utc::now() { | |
| let _ = self | |
| .invitation_repository | |
| .update_status(invitation_id, ports::InvitationStatus::Expired) | |
| .await; | |
| return Err(OrganizationError::InvalidParams( | |
| "Invitation has expired".to_string(), | |
| )); | |
| } |
PR #674 Review — Track and resend organization invitation emailsReviewed the diff, repository SQL, service/route flow, and tests. Overall the change is well-structured (port/adapter separation, sanitized error storage, admin-only routes, and solid test coverage — auth gate, expired/non-pending guards, ordering, filter combinations). A few items worth addressing:
|
de0ea66 to
81f17c0
Compare
81f17c0 to
dd68846
Compare
|
Addressed review feedback:
Local checks:
|
Evrard-Nil
left a comment
There was a problem hiding this comment.
Pulled the branch, reviewed end-to-end, and ran the test suite locally.
Build: cargo check -p services -p api -p database clean.
Tests: All 11 invitation tests pass (5 new resend_invitation_email_* + 5 existing create_invitations_* + 1 email rendering). No regressions.
Scope check: Diff vs. correct base (837c4a5) is ~1210 additions across 11 files — the main..pr-674 apparent removal of thought_signature is just an artifact of the PR being branched before #649 merged, not a real revert. Worth rebasing to get rid of that confusion.
Overall the change is well-structured. Refactoring send_invitation_email from (bool, Option<String>) to a InvitationEmailAttempt struct that threads back the updated OrganizationInvitation is a clean improvement and the resend path reuses it cleanly. Single-query list_email_deliveries with the JOIN — good (avoided the N+1 pattern that bit cloud-api#662). Admin-only gating via AdminUser extension confirmed. Tokens are NOT exposed in the admin response — verified the response model has no token field.
One important thing worth surfacing as a real concern:
🔒 email_last_error privacy surface. sanitize_error() (crates/services/src/email.rs:40) only collapses whitespace and truncates to 1000 chars — it is not a PII scrubber. If Resend ever returns something like \"Invalid recipient: foo@bar.com\" or \"Domain blocked: example.com\", that goes straight into the DB column email_last_error and surfaces via GET /v1/admin/invitation-email-deliveries. Per the privacy rules in CLAUDE.md ("Never log request/response bodies — Even at debug level, unless you're 100% certain they don't contain customer data"), this is a soft violation. This isn't a log strictly, but it's persistent and externally visible. Either:
- Tighten
sanitize_errorto strip email-looking patterns and provider-side identifiers, OR - Document explicitly that this is admin-only inside the TEE perimeter and that acceptable downstream consumers are trusted (and update CLAUDE.md if so).
Non-blocking observations:
-
Subtle behavior change to existing
create_invitationsresponse. Wheninvitations_urlis unset,email_errorwasNone; now it'sSome(\"Invitation URL is not configured\"). The unit test atcrates/services/src/organization/mod.rs:2254was updated to match, but external clients that parsedemail_error == Noneas "success" will see a new string. Worth a one-liner in the PR description / changelog so consumers know. -
No anti-abuse on
/resend. An admin (or compromised admin session) can hit POST…/{id}/resendin a tight loop. Each call performs a network roundtrip to Resend and writes a DB row. Resend's external rate limit will eventually catch this, but we'd churnemail_last_errorrows. Probably fine because admin-only, but a per-invitation cooldown (e.g. "can't resend within 30s of the last attempt") would be defensive. -
TOCTOU between status read and send.
resend_invitation_email_implreads status=Pending, then calls the network-boundsend_invitation_email, then writes results. Between the read and the send, the invitee may have accepted/declined — race is benign (one extra email goes out for an already-accepted invite, status stays Accepted becauserecord_email_*doesn't changestatus). Worth a code comment noting that's intentional. -
List pagination consistency.
list_email_deliveriesissues the rows query and the count query via separate pool acquisitions. Under concurrent writes,totalanddeliveries.len()can briefly disagree. Acceptable for admin observability; calling it out so it's not a surprise later. -
No composite index for the sort.
ORDER BY i.created_at DESC, i.id DESCplus org_id filter would benefit from a composite(organization_id, created_at DESC, id DESC)index when invitation rows grow. Not needed today. -
Email substring search is unindexable.
email ILIKE '%' || $2 || '%'prevents theidx_org_invitations_emailindex from being used; full filter scan only. Acceptable for admin audit. -
Resend endpoint lacks an e2e test. The list endpoint has
admin_invitation_email_deliveries.rs, which is great. The resend endpoint relies on unit tests only. Adding a simple e2e (insert invitation fixture → POST resend → assert email_status changed) would round it out — author's own validation note says local Postgres was refused, so this would also unblock that gate.
LGTM. The two critical things author needs to confirm/address are (a) the email_last_error PII surface (CLAUDE.md privacy rule), and (b) the create_invitations response behavior change in changelog/migration notes. Everything else is polish. Approving.
Summary
Testing
Closes #578