Files
openwork/ee/apps/den-api/scripts/smoke-email-failures.mjs
ben 0002a8e030 fix(den-api): surface invite email failures instead of silently dropping (#1483)
* fix(den-api): surface invitation email send failures instead of swallowing

Loops failures in sendDenOrganizationInvitationEmail and
sendDenVerificationEmail were being caught and logged at warn level,
so the HTTP handlers still returned 201 'Invitation created' even when
no email ever left the process. Ben observed this with two live
invitations: the DB row was pending, the UI showed it, but one of two
recipients never received the email and clicking resend re-ran the same
silent-failure path.

Root cause (from an explore audit):
- email.ts:129,132 swallowed non-2xx Loops responses and fetch throws.
- invitations.ts awaited the send and unconditionally returned 2xx.
- There is no 'skip email if user already exists' branch anywhere;
  the Slack hypothesis was wrong. The failure mode is provider-side and
  was invisible because of the swallow.

Changes:
- Introduce DenEmailSendError with a stable reason tagged union
  (loops_not_configured | loops_rejected | loops_network).
- sendDenOrganizationInvitationEmail and sendDenVerificationEmail now
  throw DenEmailSendError on failure. Dev-mode short-circuit is
  preserved (still logs the payload and returns cleanly).
- POST /v1/orgs/:orgId/invitations catches DenEmailSendError, logs via
  console.error with a stable [auth][invite_email_failed] prefix
  (greppable across deployments), and returns 502
  invitation_email_failed with a human-readable message and the
  invitationId so the UI can correlate and offer a retry. The row is
  left pending intentionally so the next submit becomes a real resend.
- Document the 502 response in the OpenAPI describeRoute.

Operator note: if LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL is unset the
signup OTP endpoint will now return a real error instead of silently
stranding the user at the OTP screen forever. This is intentional; the
previous behavior was a latent signup-breaking bug.

* fix(den-api): tolerate missing apps/desktop/package.json in Docker build

PR #1476 introduced a build step that reads apps/desktop/package.json to
bake in a default latest-app-version, but packaging/docker/Dockerfile.den
does not ship the Tauri desktop sources. As a result, the den-dev Docker
stack fails to build after the PR landed. Gracefully fall back to 0.0.0
(matching the runtime default) when the file is absent, and allow a
DEN_API_LATEST_APP_VERSION env override so deployers can still pin a
real value.

* test(den-api): add smoke script for invite email failure paths

scripts/smoke-email-failures.mjs exercises the DenEmailSendError paths
against the built dist/ of den-api. Ships with instructions so a
reviewer can rerun it inside the docker-compose den-dev container with
a single command.

Also parameterises OPENWORK_DEV_MODE in the den compose service so the
failure paths can be reached from outside the container when needed
(defaults to 1; override with OPENWORK_DEV_MODE=0 at compose time).
2026-04-17 17:06:52 -07:00

57 lines
1.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Standalone smoke test for the invitation-email failure paths.
*
* Run inside the den-api container (or any environment where the package has
* been built to `dist/`):
*
* docker exec -e OPENWORK_DEV_MODE=0 \
* openwork-den-dev-<id>-den-1 \
* node ee/apps/den-api/scripts/smoke-email-failures.mjs
*
* Expected output:
* [smoke] ok loops_not_configured { reason: 'loops_not_configured', ... }
*
* Add `-e LOOPS_API_KEY=bogus -e LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL=bogus`
* to also reach the `loops_rejected` path (Loops returns 401).
*
* Intentionally side-effect free: no DB writes, no auth.
*/
const { sendDenOrganizationInvitationEmail, DenEmailSendError } = await import(
"../dist/email.js"
)
const recipient = process.argv[2] ?? "smoke-test@example.com"
try {
await sendDenOrganizationInvitationEmail({
email: recipient,
inviteLink: "https://example.com/join?invite=smoke",
invitedByName: "Smoke Test",
invitedByEmail: "smoke@example.com",
organizationName: "Smoke Org",
role: "member",
})
if (process.env.OPENWORK_DEV_MODE === "1" || !process.env.OPENWORK_DEV_MODE) {
console.log("[smoke] ok dev_mode_noop (no email sent, no throw — expected)")
process.exit(0)
}
console.error("[smoke] FAIL: expected throw when Loops is not configured or rejects")
process.exit(1)
} catch (error) {
if (!(error instanceof DenEmailSendError)) {
console.error("[smoke] FAIL: wrong error class:", error)
process.exit(1)
}
console.log(`[smoke] ok ${error.reason}`, {
reason: error.reason,
template: error.template,
recipient: error.recipient,
detail: error.detail,
})
}