mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
56
ee/apps/den-api/scripts/smoke-email-failures.mjs
Normal file
56
ee/apps/den-api/scripts/smoke-email-failures.mjs
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user