diff --git a/ee/apps/den-api/.env.example b/ee/apps/den-api/.env.example index 16a15d4e8..c59895cc2 100644 --- a/ee/apps/den-api/.env.example +++ b/ee/apps/den-api/.env.example @@ -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 +# 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 diff --git a/ee/apps/den-api/package.json b/ee/apps/den-api/package.json index 07da96ab1..f78bbdfc9 100644 --- a/ee/apps/den-api/package.json +++ b/ee/apps/den-api/package.json @@ -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", @@ -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" } diff --git a/ee/apps/den-api/src/auth.ts b/ee/apps/den-api/src/auth.ts index c9cbf38b4..ae818b48b 100644 --- a/ee/apps/den-api/src/auth.ts +++ b/ee/apps/den-api/src/auth.ts @@ -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, @@ -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 }, }); }, }), @@ -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: { diff --git a/ee/apps/den-api/src/email.ts b/ee/apps/den-api/src/email.ts deleted file mode 100644 index 5ce42ddfe..000000000 --- a/ee/apps/den-api/src/email.ts +++ /dev/null @@ -1,169 +0,0 @@ -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 - template: DenEmailSendError["template"] -}): Promise { - 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 -}) { - const email = input.email.trim() - const verificationCode = input.verificationCode.trim() - - if (!email || !verificationCode) { - return - } - - if (env.devMode) { - console.info(`[auth] dev verification email payload for ${email}: ${JSON.stringify({ verificationCode })}`) - return - } - - if (!env.loops.apiKey || !env.loops.transactionalIdDenVerifyEmail) { - throw new DenEmailSendError({ - template: "verification", - reason: "loops_not_configured", - recipient: email, - }) - } - - await postLoopsTransactional({ - transactionalId: env.loops.transactionalIdDenVerifyEmail, - email, - dataVariables: { verificationCode }, - template: "verification", - }) -} - -export async function sendDenOrganizationInvitationEmail(input: { - email: string - inviteLink: string - invitedByName: string - invitedByEmail: string - organizationName: string - role: string -}) { - const email = input.email.trim() - - if (!email) { - return - } - - if (env.devMode) { - console.info( - `[auth] dev organization invite email payload for ${email}: ${JSON.stringify({ - inviteLink: input.inviteLink, - invitedByName: input.invitedByName, - invitedByEmail: input.invitedByEmail, - organizationName: input.organizationName, - role: input.role, - })}`, - ) - return - } - - if (!env.loops.apiKey || !env.loops.transactionalIdDenOrgInviteEmail) { - throw new DenEmailSendError({ - template: "organization_invite", - reason: "loops_not_configured", - recipient: email, - }) - } - - 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", - }) -} diff --git a/ee/apps/den-api/src/env.ts b/ee/apps/den-api/src/env.ts index e158ed725..80bf60a6b 100644 --- a/ee/apps/den-api/src/env.ts +++ b/ee/apps/den-api/src/env.ts @@ -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(), @@ -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"), diff --git a/ee/apps/den-api/src/routes/org/invitations.ts b/ee/apps/den-api/src/routes/org/invitations.ts index ae20fa064..b5b58fac8 100644 --- a/ee/apps/den-api/src/routes/org/invitations.ts +++ b/ee/apps/den-api/src/routes/org/invitations.ts @@ -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" @@ -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" }) @@ -49,7 +49,7 @@ export function registerOrgInvitationRoutes = { + to: string + template: Template + props: EmailTemplateProps[Template] + subject?: string +} + +export async function sendEmail