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).
This commit is contained in:
ben
2026-04-17 17:06:52 -07:00
committed by GitHub
parent 0655ee5c76
commit 0002a8e030
4 changed files with 214 additions and 89 deletions

View File

@@ -0,0 +1,56 @@
#!/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,
})
}

View File

@@ -2,6 +2,91 @@ import { env } from "./env.js"
const LOOPS_TRANSACTIONAL_API_URL = "https://app.loops.so/api/v1/transactional"
/**
* Error thrown when a transactional email send fails or is skipped because
* of misconfiguration. Handlers can inspect `.reason` to decide how to
* surface the failure to the caller (e.g. map to an HTTP status).
*/
export class DenEmailSendError extends Error {
readonly reason:
| "loops_not_configured"
| "loops_rejected"
| "loops_network"
readonly template: "verification" | "organization_invite"
readonly recipient: string
readonly detail?: string
constructor(input: {
template: DenEmailSendError["template"]
reason: DenEmailSendError["reason"]
recipient: string
detail?: string
}) {
super(
`[${input.template}] email for ${input.recipient} failed: ${input.reason}${
input.detail ? ` (${input.detail})` : ""
}`,
)
this.name = "DenEmailSendError"
this.reason = input.reason
this.template = input.template
this.recipient = input.recipient
this.detail = input.detail
}
}
async function postLoopsTransactional(input: {
transactionalId: string
email: string
dataVariables: Record<string, string>
template: DenEmailSendError["template"]
}): Promise<void> {
let response: Response
try {
response = await fetch(LOOPS_TRANSACTIONAL_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${env.loops.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
transactionalId: input.transactionalId,
email: input.email,
dataVariables: input.dataVariables,
}),
})
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
throw new DenEmailSendError({
template: input.template,
reason: "loops_network",
recipient: input.email,
detail: message,
})
}
if (response.ok) {
return
}
let detail = `status ${response.status}`
try {
const payload = (await response.json()) as { message?: string }
if (payload.message?.trim()) {
detail = payload.message
}
} catch {
// Ignore invalid upstream payloads.
}
throw new DenEmailSendError({
template: input.template,
reason: "loops_rejected",
recipient: input.email,
detail,
})
}
export async function sendDenVerificationEmail(input: {
email: string
verificationCode: string
@@ -19,45 +104,19 @@ export async function sendDenVerificationEmail(input: {
}
if (!env.loops.apiKey || !env.loops.transactionalIdDenVerifyEmail) {
console.warn(`[auth] verification email skipped for ${email}: Loops is not configured`)
return
}
try {
const response = await fetch(LOOPS_TRANSACTIONAL_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${env.loops.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
transactionalId: env.loops.transactionalIdDenVerifyEmail,
email,
dataVariables: {
verificationCode,
},
}),
throw new DenEmailSendError({
template: "verification",
reason: "loops_not_configured",
recipient: email,
})
if (response.ok) {
return
}
let detail = `status ${response.status}`
try {
const payload = (await response.json()) as { message?: string }
if (payload.message?.trim()) {
detail = payload.message
}
} catch {
// Ignore invalid upstream payloads.
}
console.warn(`[auth] failed to send verification email for ${email}: ${detail}`)
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
console.warn(`[auth] failed to send verification email for ${email}: ${message}`)
}
await postLoopsTransactional({
transactionalId: env.loops.transactionalIdDenVerifyEmail,
email,
dataVariables: { verificationCode },
template: "verification",
})
}
export async function sendDenOrganizationInvitationEmail(input: {
@@ -88,47 +147,23 @@ export async function sendDenOrganizationInvitationEmail(input: {
}
if (!env.loops.apiKey || !env.loops.transactionalIdDenOrgInviteEmail) {
console.warn(`[auth] organization invite email skipped for ${email}: Loops is not configured`)
return
}
try {
const response = await fetch(LOOPS_TRANSACTIONAL_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${env.loops.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
transactionalId: env.loops.transactionalIdDenOrgInviteEmail,
email,
dataVariables: {
inviteLink: input.inviteLink,
invitedByName: input.invitedByName,
invitedByEmail: input.invitedByEmail,
organizationName: input.organizationName,
role: input.role,
},
}),
throw new DenEmailSendError({
template: "organization_invite",
reason: "loops_not_configured",
recipient: email,
})
if (response.ok) {
return
}
let detail = `status ${response.status}`
try {
const payload = (await response.json()) as { message?: string }
if (payload.message?.trim()) {
detail = payload.message
}
} catch {
// Ignore invalid upstream payloads.
}
console.warn(`[auth] failed to send organization invite email for ${email}: ${detail}`)
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
console.warn(`[auth] failed to send organization invite email for ${email}: ${message}`)
}
await postLoopsTransactional({
transactionalId: env.loops.transactionalIdDenOrgInviteEmail,
email,
dataVariables: {
inviteLink: input.inviteLink,
invitedByName: input.invitedByName,
invitedByEmail: input.invitedByEmail,
organizationName: input.organizationName,
role: input.role,
},
template: "organization_invite",
})
}

View File

@@ -5,7 +5,7 @@ import type { Hono } from "hono"
import { describeRoute } from "hono-openapi"
import { z } from "zod"
import { db } from "../../db.js"
import { sendDenOrganizationInvitationEmail } from "../../email.js"
import { DenEmailSendError, sendDenOrganizationInvitationEmail } from "../../email.js"
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { denTypeIdSchema, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js"
import { getOrganizationLimitStatus } from "../../organization-limits.js"
@@ -25,6 +25,13 @@ const invitationResponseSchema = z.object({
expiresAt: z.string().datetime(),
}).meta({ ref: "InvitationResponse" })
const invitationEmailFailedSchema = z.object({
error: z.literal("invitation_email_failed"),
reason: z.enum(["loops_not_configured", "loops_rejected", "loops_network"]),
message: z.string(),
invitationId: denTypeIdSchema("invitation"),
}).meta({ ref: "InvitationEmailFailedError" })
type InvitationId = typeof InvitationTable.$inferSelect.id
const orgInvitationParamsSchema = orgIdParamSchema.extend(idParamSchema("invitationId", "invitation").shape)
@@ -34,7 +41,7 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
describeRoute({
tags: ["Invitations"],
summary: "Create organization invitation",
description: "Creates or refreshes a pending organization invitation for an email address and sends the invite email.",
description: "Creates or refreshes a pending organization invitation for an email address and sends the invite email. Returns 502 when the invitation row is persisted but the email provider (Loops) failed to send; the client should surface the error and give the user a retry affordance.",
responses: {
200: jsonResponse("Existing invitation refreshed successfully.", invitationResponseSchema),
201: jsonResponse("Invitation created successfully.", invitationResponseSchema),
@@ -42,6 +49,7 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
401: jsonResponse("The caller must be signed in to invite organization members.", unauthorizedSchema),
403: jsonResponse("Only workspace owners and admins can create or resend invitations.", forbiddenSchema),
404: jsonResponse("The organization could not be found.", notFoundSchema),
502: jsonResponse("The invitation was saved but the email provider (Loops) rejected or failed to deliver it. Retry by submitting the same email again.", invitationEmailFailedSchema),
},
}),
requireUserMiddleware,
@@ -125,14 +133,40 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
})
}
await sendDenOrganizationInvitationEmail({
email,
inviteLink: buildInvitationLink(invitationId),
invitedByName: user.name ?? user.email ?? "OpenWork",
invitedByEmail: user.email ?? "",
organizationName: payload.organization.name,
role,
})
try {
await sendDenOrganizationInvitationEmail({
email,
inviteLink: buildInvitationLink(invitationId),
invitedByName: user.name ?? user.email ?? "OpenWork",
invitedByEmail: user.email ?? "",
organizationName: payload.organization.name,
role,
})
} catch (error) {
if (error instanceof DenEmailSendError) {
// The invitation row is already persisted (step above). Log at error
// level so operators can grep, and return a 502 so the caller can
// render a real failure instead of a silent success. The invitation
// id is included so the UI can correlate and offer a direct retry.
console.error(
`[auth][invite_email_failed] organization=${payload.organization.id} invitation=${invitationId} email=${email} reason=${error.reason}${error.detail ? ` detail=${error.detail}` : ""}`,
)
return c.json({
error: "invitation_email_failed" as const,
reason: error.reason,
message:
error.reason === "loops_not_configured"
? "The invitation email provider (Loops) is not configured on this deployment."
: error.reason === "loops_network"
? "Could not reach the invitation email provider. The invitation is saved; retry to send again."
: `The invitation email provider rejected the send${error.detail ? `: ${error.detail}` : "."}`,
invitationId,
}, 502)
}
throw error
}
return c.json({ invitationId, email, role, expiresAt }, existingInvitation[0] ? 200 : 201)
},

View File

@@ -70,7 +70,7 @@ services:
start_period: 120s
environment:
CI: "true"
OPENWORK_DEV_MODE: "1"
OPENWORK_DEV_MODE: ${OPENWORK_DEV_MODE:-1}
DATABASE_URL: mysql://root:password@mysql:3306/openwork_den
BETTER_AUTH_SECRET: ${DEN_BETTER_AUTH_SECRET:-dev-den-local-auth-secret-please-override-1234567890}
DEN_DB_ENCRYPTION_KEY: ${DEN_DB_ENCRYPTION_KEY:-dev-den-db-encryption-key-please-change-1234567890}