Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions ee/apps/den-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@ BETTER_AUTH_SECRET=replace-with-32-plus-character-secret
DEN_DB_ENCRYPTION_KEY=
BETTER_AUTH_URL=http://localhost:8790
DEN_BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001
LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL=replace-with-loops-template-id
LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL=replace-with-loops-template-id
EMAIL_FROM=OpenWork <[email protected]>
# Transactional email uses Resend when RESEND_API_KEY is set.
RESEND_API_KEY=
# Otherwise, transactional email uses SMTP/Nodemailer when SMTP_HOST is set.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_SECURE=false
# Optional Loops API key for contact/event syncs after signup and subscription.
LOOPS_API_KEY=
PROVISIONER_MODE=daytona
WORKER_URL_TEMPLATE=https://workers.local/{workerId}
WORKER_ACTIVITY_BASE_URL=http://localhost:8790
Expand Down
9 changes: 8 additions & 1 deletion ee/apps/den-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
"@hono/node-server": "^1.13.8",
"@hono/standard-validator": "^0.2.2",
"@hono/swagger-ui": "^0.6.1",
"@openwork/types": "workspace:*",
"@openwork-ee/den-db": "workspace:*",
"@openwork-ee/utils": "workspace:*",
"@openwork/types": "workspace:*",
"@react-email/components": "^1.0.12",
"@react-email/render": "^2.0.7",
"@standard-community/standard-json": "^0.3.5",
"@standard-community/standard-openapi": "^0.2.9",
"@standard-schema/spec": "^1.1.0",
Expand All @@ -26,12 +28,17 @@
"dotenv": "^16.4.5",
"hono": "^4.7.2",
"hono-openapi": "^1.3.0",
"nodemailer": "^8.0.6",
"openapi-types": "^12.1.3",
"react": "^19.2.5",
"resend": "^6.12.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/json-schema": "^7.0.15",
"@types/node": "^20.11.30",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19.2.14",
"tsx": "^4.15.7",
"typescript": "^5.5.4"
}
Expand Down
29 changes: 15 additions & 14 deletions ee/apps/den-api/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { getInitialActiveOrganizationIdForUser } from "./active-organization.js";
import { db } from "./db.js";
import { env } from "./env.js";
import {
sendDenOrganizationInvitationEmail,
sendDenVerificationEmail,
} from "./email.js";
import { syncDenSignupContact } from "./loops.js";
import { sendEmail } from "./utils/email/send-email.js";
import {
DEN_API_KEY_DEFAULT_PREFIX,
DEN_API_KEY_RATE_LIMIT_MAX,
Expand Down Expand Up @@ -183,9 +180,10 @@ export const auth = betterAuth({
expiresIn: 600,
allowedAttempts: 5,
async sendVerificationOTP({ email, otp, type }) {
await sendDenVerificationEmail({
email,
verificationCode: otp,
await sendEmail({
to: email,
template: "verification",
props: { verificationCode: otp },
});
},
}),
Expand All @@ -204,13 +202,16 @@ export const auth = betterAuth({
},
},
async sendInvitationEmail(data) {
await sendDenOrganizationInvitationEmail({
email: data.email,
inviteLink: buildInvitationLink(data.id),
invitedByName: data.inviter.user.name ?? data.inviter.user.email,
invitedByEmail: data.inviter.user.email,
organizationName: data.organization.name,
role: data.role,
await sendEmail({
to: data.email,
template: "organizationInvite",
props: {
inviteLink: buildInvitationLink(data.id),
invitedByName: data.inviter.user.name ?? data.inviter.user.email,
invitedByEmail: data.inviter.user.email,
organizationName: data.organization.name,
role: data.role,
},
});
},
organizationHooks: {
Expand Down
169 changes: 0 additions & 169 deletions ee/apps/den-api/src/email.ts

This file was deleted.

24 changes: 20 additions & 4 deletions ee/apps/den-api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ const EnvSchema = z.object({
GITHUB_CONNECTOR_APP_WEBHOOK_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
EMAIL_FROM: z.string().optional(),
RESEND_API_KEY: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_SECURE: z.string().optional(),
LOOPS_API_KEY: z.string().optional(),
LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL: z.string().optional(),
LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL: z.string().optional(),
OPENWORK_DEV_MODE: z.string().optional(),
PORT: z.string().optional(),
CORS_ORIGINS: z.string().optional(),
Expand Down Expand Up @@ -174,10 +179,21 @@ export const env = {
clientId: optionalString(parsed.GOOGLE_CLIENT_ID),
clientSecret: optionalString(parsed.GOOGLE_CLIENT_SECRET),
},
email: {
from: optionalString(parsed.EMAIL_FROM),
},
resend: {
apiKey: optionalString(parsed.RESEND_API_KEY),
},
smtp: {
host: optionalString(parsed.SMTP_HOST),
port: Number(parsed.SMTP_PORT ?? "587"),
user: optionalString(parsed.SMTP_USER),
pass: optionalString(parsed.SMTP_PASS),
secure: (parsed.SMTP_SECURE ?? "false").toLowerCase() === "true",
},
loops: {
apiKey: optionalString(parsed.LOOPS_API_KEY),
transactionalIdDenVerifyEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL),
transactionalIdDenOrgInviteEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL),
},
port: Number(parsed.PORT ?? "8790"),
workerProxyPort: Number(parsed.WORKER_PROXY_PORT ?? "8789"),
Expand Down
31 changes: 17 additions & 14 deletions ee/apps/den-api/src/routes/org/invitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import type { Hono } from "hono"
import { describeRoute } from "hono-openapi"
import { z } from "zod"
import { db } from "../../db.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"
import { isEmailAllowedForOrganization, listAssignableRoles } from "../../orgs.js"
import { DenEmailSendError, sendEmail } from "../../utils/email/send-email.js"
import type { OrgRouteVariables } from "./shared.js"
import { buildInvitationLink, createInvitationId, ensureInviteManager, idParamSchema, normalizeRoleName } from "./shared.js"

Expand All @@ -27,7 +27,7 @@ const invitationResponseSchema = z.object({

const invitationEmailFailedSchema = z.object({
error: z.literal("invitation_email_failed"),
reason: z.enum(["loops_not_configured", "loops_rejected", "loops_network"]),
reason: z.enum(["email_not_configured", "resend_rejected", "resend_network", "nodemailer_rejected"]),
message: z.string(),
invitationId: denTypeIdSchema("invitation"),
}).meta({ ref: "InvitationEmailFailedError" })
Expand All @@ -49,7 +49,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. 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.",
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 configured email provider 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),
Expand All @@ -58,7 +58,7 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
403: jsonResponse("Only workspace owners and admins can create or resend invitations.", forbiddenSchema),
404: jsonResponse("The organization could not be found.", notFoundSchema),
409: jsonResponse("The email address is outside this workspace's allowed domains.", inviteEmailDomainNotAllowedSchema),
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),
502: jsonResponse("The invitation was saved but the email provider rejected or failed to deliver it. Retry by submitting the same email again.", invitationEmailFailedSchema),
},
}),
requireUserMiddleware,
Expand Down Expand Up @@ -155,13 +155,16 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
}

try {
await sendDenOrganizationInvitationEmail({
email,
inviteLink: buildInvitationLink(invitationId),
invitedByName: user.name ?? user.email ?? "OpenWork",
invitedByEmail: user.email ?? "",
organizationName: payload.organization.name,
role,
await sendEmail({
to: email,
template: "organizationInvite",
props: {
inviteLink: buildInvitationLink(invitationId),
invitedByName: user.name ?? user.email ?? "OpenWork",
invitedByEmail: user.email ?? "",
organizationName: payload.organization.name,
role,
},
})
} catch (error) {
if (error instanceof DenEmailSendError) {
Expand All @@ -177,9 +180,9 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
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"
error.reason === "email_not_configured"
? "The invitation email provider is not configured on this deployment."
: error.reason === "resend_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,
Expand Down
Loading
Loading