diff --git a/apps/app/package.json b/apps/app/package.json index 12f8f93274..37151965ce 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -62,8 +62,8 @@ "lucide-react": "^0.577.0", "marked": "^17.0.1", "motion": "^12.38.0", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "catalog:", + "react-dom": "catalog:", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.0", "react-router-dom": "^7.14.1", diff --git a/apps/share/package.json b/apps/share/package.json index 1d7083eb05..913b468447 100644 --- a/apps/share/package.json +++ b/apps/share/package.json @@ -15,8 +15,8 @@ "botid": "^1.5.11", "jsonc-parser": "^3.3.1", "next": "16.1.6", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "catalog:", + "react-dom": "catalog:", "sharp": "^0.34.5", "ulid": "^2.3.0", "yaml": "^2.8.1" diff --git a/apps/ui-demo/package.json b/apps/ui-demo/package.json index 2cb9bde604..a17c20bdc1 100644 --- a/apps/ui-demo/package.json +++ b/apps/ui-demo/package.json @@ -11,8 +11,8 @@ }, "dependencies": { "@openwork/ui": "workspace:*", - "react": "19.2.4", - "react-dom": "19.2.4" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { "@types/react": "19.2.14", diff --git a/ee/apps/den-api/.env.example b/ee/apps/den-api/.env.example index 16a15d4e86..c59895cc2c 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 55beb8332b..406dac89e2 100644 --- a/ee/apps/den-api/package.json +++ b/ee/apps/den-api/package.json @@ -6,6 +6,7 @@ "dev": "OPENWORK_DEV_MODE=1 tsx watch src/server.ts", "dev:local": "sh -lc 'OPENWORK_DEV_MODE=1 PORT=${DEN_API_PORT:-8790} tsx watch src/server.ts'", "build": "node ./scripts/build.mjs", + "build:email": "pnpm --filter @openwork/email build", "build:den-db": "pnpm --filter @openwork-ee/den-db build", "seed:demo-org": "pnpm run build:den-db && sh -lc 'DEN_WEB_PORT=${DEN_WEB_PORT:-3005}; OPENWORK_DEV_MODE=${OPENWORK_DEV_MODE:-1} DATABASE_URL=${DATABASE_URL:-mysql://root:password@127.0.0.1:3306/openwork_den} DEN_DB_ENCRYPTION_KEY=${DEN_DB_ENCRYPTION_KEY:-local-dev-db-encryption-key-please-change-1234567890} BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-local-dev-secret-not-for-production-use!!} BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:$DEN_WEB_PORT} tsx scripts/seed-demo-org.ts'", "start": "node dist/server.js" @@ -21,6 +22,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@openwork-ee/den-db": "workspace:*", "@openwork-ee/utils": "workspace:*", + "@openwork/email": "workspace:*", "@openwork/types": "workspace:*", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", diff --git a/ee/apps/den-api/scripts/build.mjs b/ee/apps/den-api/scripts/build.mjs index 2e9bb5b775..5fe9bab9b9 100644 --- a/ee/apps/den-api/scripts/build.mjs +++ b/ee/apps/den-api/scripts/build.mjs @@ -55,5 +55,6 @@ function run(command, args) { process.env.DEN_API_LATEST_APP_VERSION = process.env.DEN_API_LATEST_APP_VERSION || readDesktopVersion() writeGeneratedVersionFile(process.env.DEN_API_LATEST_APP_VERSION) +run(pnpmCommand, ["run", "build:email"]) run(pnpmCommand, ["run", "build:den-db"]) run(pnpmCommand, ["exec", "tsc", "-p", "tsconfig.json"]) diff --git a/ee/apps/den-api/src/auth.ts b/ee/apps/den-api/src/auth.ts index 923818950f..e424567134 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, @@ -228,9 +225,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 }, }); }, }), @@ -249,13 +247,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 5ce42ddfe1..0000000000 --- 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 681ad9c94a..b12dda4253 100644 --- a/ee/apps/den-api/src/env.ts +++ b/ee/apps/den-api/src/env.ts @@ -21,9 +21,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(), @@ -183,10 +188,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, 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 ae20fa0641..b5b58fac8d 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( + input: Omit, "config">, +) { + return sendSharedEmail({ + ...input, + config: { + devMode: env.devMode, + from: env.email.from, + resendApiKey: env.resend.apiKey, + smtp: env.smtp, + }, + }) +} diff --git a/ee/apps/den-api/tsconfig.json b/ee/apps/den-api/tsconfig.json index 21f5aeef55..5915a27f07 100644 --- a/ee/apps/den-api/tsconfig.json +++ b/ee/apps/den-api/tsconfig.json @@ -7,6 +7,7 @@ "outDir": "dist", "strict": true, "esModuleInterop": true, + "jsx": "react-jsx", "skipLibCheck": true, "resolveJsonModule": true }, diff --git a/ee/apps/den-web/package.json b/ee/apps/den-web/package.json index 3a06afb66b..872dea2189 100644 --- a/ee/apps/den-web/package.json +++ b/ee/apps/den-web/package.json @@ -18,8 +18,8 @@ "@tanstack/react-query": "^5.96.2", "lucide-react": "^0.577.0", "next": "16.2.1", - "react": "19.2.4", - "react-dom": "19.2.4" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { "@types/node": "20.12.12", diff --git a/ee/apps/landing/README.md b/ee/apps/landing/README.md index 323a3cc05e..2309aa030d 100644 --- a/ee/apps/landing/README.md +++ b/ee/apps/landing/README.md @@ -11,9 +11,12 @@ - `NEXT_PUBLIC_CAL_URL` - enterprise booking link - `NEXT_PUBLIC_DEN_CHECKOUT_URL` - Polar checkout URL for the Den preorder CTA -- `LOOPS_API_KEY` - Loops API key for feedback/contact submissions -- `LOOPS_TRANSACTIONAL_ID_APP_FEEDBACK` - Loops transactional template ID for app feedback emails -- `LOOPS_INTERNAL_FEEDBACK_EMAIL` - optional override for the internal feedback recipient (defaults to `team@openworklabs.com`) +- `EMAIL_FROM` - sender for feedback emails (for example `OpenWork `) +- `RESEND_API_KEY` - Resend API key for feedback emails +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE` - SMTP fallback for feedback emails when Resend is not configured +- `OPENWORK_FEEDBACK_EMAIL` - optional override for the internal feedback recipient (defaults to `team@openworklabs.com`) +- `LOOPS_API_KEY` - Loops API key for enterprise contact submissions +- `LOOPS_INTERNAL_FEEDBACK_EMAIL` - legacy feedback recipient override, used only when `OPENWORK_FEEDBACK_EMAIL` is not set - `LANDING_FORM_ALLOWED_ORIGINS` - optional comma-separated origin allowlist for feedback/contact form posts ## Deploy (recommended) diff --git a/ee/apps/landing/app/api/app-feedback/route.ts b/ee/apps/landing/app/api/app-feedback/route.ts index bbfb939bc8..649e6c34c7 100644 --- a/ee/apps/landing/app/api/app-feedback/route.ts +++ b/ee/apps/landing/app/api/app-feedback/route.ts @@ -1,4 +1,5 @@ import { buildResponseHeaders, jsonResponse, rateLimitFormRequest, validateAntiSpamFields, validateTrustedOrigin, verifyFormBotProtection } from "../_lib/security"; +import { EmailSendError, sendEmail, type FeedbackEmailProps } from "@openwork/email"; type FeedbackContext = { source?: string; @@ -23,7 +24,6 @@ type FeedbackPayload = { context?: FeedbackContext; }; -const LOOPS_TRANSACTIONAL_API_URL = "https://app.loops.so/api/v1/transactional"; const DEFAULT_INTERNAL_FEEDBACK_EMAIL = "team@openworklabs.com"; function sanitizeValue(value: unknown, maxLength = 240) { @@ -86,21 +86,11 @@ export async function POST(request: Request) { return jsonResponse(request, { error: botProtection.error }, botProtection.status); } - const apiKey = process.env.LOOPS_API_KEY?.trim(); - const transactionalId = - process.env.LOOPS_TRANSACTIONAL_ID_APP_FEEDBACK?.trim(); const internalEmail = + process.env.OPENWORK_FEEDBACK_EMAIL?.trim() || process.env.LOOPS_INTERNAL_FEEDBACK_EMAIL?.trim() || DEFAULT_INTERNAL_FEEDBACK_EMAIL; - if (!apiKey || !transactionalId) { - return jsonResponse( - request, - { error: "App feedback is not configured on this deployment." }, - 500, - ); - } - let payload: FeedbackPayload; try { const raw = await request.text(); @@ -149,62 +139,48 @@ export async function POST(request: Request) { const diagnosticsSummary = formatDiagnosticsSummary(context); const submittedAt = new Date().toISOString(); - if (process.env.NODE_ENV === "development") { - console.log("[DEV] Skipping Loops app feedback email", { - internalEmail, - transactionalId, - message, - name, - email, - context, - }); - return jsonResponse(request, { ok: true }); - } + const feedbackProps = { + name, + email, + message, + source: context.source || "openwork-app", + entrypoint: context.entrypoint || "unknown", + deployment: context.deployment || "desktop", + appVersion: context.appVersion || "unknown", + openworkServerVersion: context.openworkServerVersion || "unknown", + opencodeVersion: context.opencodeVersion || "unknown", + orchestratorVersion: context.orchestratorVersion || "unknown", + opencodeRouterVersion: context.opencodeRouterVersion || "unknown", + osName: context.osName || "unknown", + osVersion: context.osVersion || "", + platform: context.platform || "unknown", + diagnosticsSummary, + submittedAt, + } satisfies FeedbackEmailProps; - const response = await fetch(LOOPS_TRANSACTIONAL_API_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - transactionalId, - email: internalEmail, - dataVariables: { - name, - email, - message, - source: context.source || "openwork-app", - entrypoint: context.entrypoint || "unknown", - deployment: context.deployment || "desktop", - appVersion: context.appVersion || "unknown", - openworkServerVersion: context.openworkServerVersion || "unknown", - opencodeVersion: context.opencodeVersion || "unknown", - orchestratorVersion: context.orchestratorVersion || "unknown", - opencodeRouterVersion: context.opencodeRouterVersion || "unknown", - osName: context.osName || "unknown", - osVersion: context.osVersion || "", - platform: context.platform || "unknown", - diagnosticsSummary, - submittedAt, + try { + await sendEmail({ + to: internalEmail, + template: "feedback", + props: feedbackProps, + config: { + devMode: process.env.NODE_ENV === "development", + from: process.env.EMAIL_FROM?.trim(), + resendApiKey: process.env.RESEND_API_KEY?.trim(), + smtp: { + host: process.env.SMTP_HOST?.trim(), + port: Number(process.env.SMTP_PORT ?? "587"), + user: process.env.SMTP_USER?.trim(), + pass: process.env.SMTP_PASS, + secure: (process.env.SMTP_SECURE ?? "false").toLowerCase() === "true", + }, }, - }), - cache: "no-store", - }); - - if (!response.ok) { - let detail = "Failed to send feedback email."; - - try { - const errorBody = await response.text(); - if (errorBody.trim()) { - detail = errorBody.slice(0, 600); - } - } catch { - // Ignore invalid upstream error bodies. + }); + } catch (error) { + if (error instanceof EmailSendError) { + return jsonResponse(request, { error: error.detail ?? error.message }, 502); } - - return jsonResponse(request, { error: detail }, 502); + throw error; } return jsonResponse(request, { ok: true }); diff --git a/ee/apps/landing/next.config.js b/ee/apps/landing/next.config.js index 129520f056..db01ecc285 100644 --- a/ee/apps/landing/next.config.js +++ b/ee/apps/landing/next.config.js @@ -5,7 +5,7 @@ const mintlifyOrigin = "https://differentai.mintlify.dev"; const nextConfig = { reactStrictMode: true, - transpilePackages: ["@openwork/ui"], + transpilePackages: ["@openwork/email", "@openwork/ui"], async rewrites() { return [ { diff --git a/ee/apps/landing/package.json b/ee/apps/landing/package.json index 64adbe47f4..d5b921dbdd 100644 --- a/ee/apps/landing/package.json +++ b/ee/apps/landing/package.json @@ -4,19 +4,20 @@ "version": "0.0.0", "scripts": { "dev": "OPENWORK_DEV_MODE=1 next dev --hostname 0.0.0.0", - "prebuild": "pnpm --dir ../../../packages/ui build", + "prebuild": "pnpm --dir ../../../packages/email build && pnpm --dir ../../../packages/ui build", "build": "next build", "start": "next start --hostname 0.0.0.0", "lint": "next lint" }, "dependencies": { + "@openwork/email": "workspace:*", "@openwork/ui": "workspace:*", "botid": "^1.5.11", "framer-motion": "^12.35.1", "lucide-react": "^0.577.0", "next": "14.2.35", - "react": "18.2.0", - "react-dom": "18.2.0" + "react": "catalog:react18", + "react-dom": "catalog:react18" }, "devDependencies": { "@types/node": "20.12.12", diff --git a/ee/apps/landing/vercel.json b/ee/apps/landing/vercel.json new file mode 100644 index 0000000000..aa8de6e695 --- /dev/null +++ b/ee/apps/landing/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "buildCommand": "pnpm --dir ../../../packages/email build && pnpm --dir ../../../packages/ui build && pnpm exec next build" +} diff --git a/package.json b/package.json index 3f6483eb45..11f179ebbf 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dev:den:db-push": "sh -lc 'DATABASE_URL=${DATABASE_URL:-mysql://root:password@127.0.0.1:3306/openwork_den} DEN_DB_ENCRYPTION_KEY=${DEN_DB_ENCRYPTION_KEY:-local-dev-db-encryption-key-please-change-1234567890} pnpm --filter @openwork-ee/den-db db:push'", "dev:den:api": "sh -lc 'DEN_WEB_PORT=${DEN_WEB_PORT:-3005}; OPENWORK_APP_PORT=${OPENWORK_APP_PORT:-5173}; DEN_LOCAL_ORIGINS=http://localhost:$DEN_WEB_PORT,http://127.0.0.1:$DEN_WEB_PORT,http://localhost:$OPENWORK_APP_PORT,http://127.0.0.1:$OPENWORK_APP_PORT; OPENWORK_DEV_MODE=1 PORT=${DEN_API_PORT:-8790} DATABASE_URL=${DATABASE_URL:-mysql://root:password@127.0.0.1:3306/openwork_den} DEN_DB_ENCRYPTION_KEY=${DEN_DB_ENCRYPTION_KEY:-local-dev-db-encryption-key-please-change-1234567890} BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-local-dev-secret-not-for-production-use!!} BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:$DEN_WEB_PORT} DEN_BETTER_AUTH_TRUSTED_ORIGINS=${DEN_BETTER_AUTH_TRUSTED_ORIGINS:-$DEN_LOCAL_ORIGINS} CORS_ORIGINS=${CORS_ORIGINS:-$DEN_LOCAL_ORIGINS} PROVISIONER_MODE=${PROVISIONER_MODE:-stub} pnpm --filter @openwork-ee/den-api dev:local'", "dev:den:web": "sh -lc 'DEN_API_PORT=${DEN_API_PORT:-8790}; DEN_WEB_PORT=${DEN_WEB_PORT:-3005}; OPENWORK_DEV_MODE=1 NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_API_KEY= DEN_API_BASE=${DEN_API_BASE:-http://localhost:$DEN_API_PORT} DEN_AUTH_ORIGIN=${DEN_AUTH_ORIGIN:-http://localhost:$DEN_WEB_PORT} DEN_AUTH_FALLBACK_BASE=${DEN_AUTH_FALLBACK_BASE:-http://localhost:$DEN_API_PORT} pnpm --filter @openwork-ee/den-web dev:local'", + "email:dev": "pnpm --filter @openwork/email dev", "dev:den-docker": "bash packaging/docker/den-dev-up.sh", "dev:den:seed-demo": "pnpm --filter @openwork-ee/den-api seed:demo-org", "dev:headless-web": "OPENWORK_DEV_MODE=1 bun scripts/dev-headless-web.ts", diff --git a/packages/email/emails/feedback.tsx b/packages/email/emails/feedback.tsx new file mode 100644 index 0000000000..d55f11eba4 --- /dev/null +++ b/packages/email/emails/feedback.tsx @@ -0,0 +1,24 @@ +import { FeedbackEmail, type FeedbackEmailProps } from "../src/templates/feedback" + +export default function FeedbackPreview(props: FeedbackEmailProps) { + return +} + +FeedbackPreview.PreviewProps = { + name: "Jane Doe", + email: "jane@example.com", + message: "I tried to connect a worker from Settings, but the connection stayed pending after the token was accepted.", + source: "openwork-app", + entrypoint: "settings", + deployment: "desktop", + appVersion: "0.13.5", + openworkServerVersion: "0.13.5", + opencodeVersion: "1.4.9", + orchestratorVersion: "0.13.5", + opencodeRouterVersion: "unknown", + osName: "macOS", + osVersion: "15.4", + platform: "MacIntel", + diagnosticsSummary: "Source: openwork-app\nEntrypoint: settings\nDeployment: desktop\nApp version: 0.13.5\nOS: macOS 15.4\nPlatform: MacIntel", + submittedAt: "2026-05-11T18:30:00.000Z", +} satisfies FeedbackEmailProps diff --git a/packages/email/emails/organization-invite.tsx b/packages/email/emails/organization-invite.tsx new file mode 100644 index 0000000000..1783d088e4 --- /dev/null +++ b/packages/email/emails/organization-invite.tsx @@ -0,0 +1,16 @@ +import { + OrganizationInviteEmail, + type OrganizationInviteEmailProps, +} from "../src/templates/organization-invite" + +export default function OrganizationInvitePreview(props: OrganizationInviteEmailProps) { + return +} + +OrganizationInvitePreview.PreviewProps = { + inviteLink: "https://app.openworklabs.com/join-org?invite=invitation_preview", + invitedByName: "Ada Lovelace", + invitedByEmail: "ada@example.com", + organizationName: "OpenWork Preview", + role: "admin", +} satisfies OrganizationInviteEmailProps diff --git a/packages/email/emails/verification.tsx b/packages/email/emails/verification.tsx new file mode 100644 index 0000000000..6a5cd42923 --- /dev/null +++ b/packages/email/emails/verification.tsx @@ -0,0 +1,9 @@ +import { VerificationEmail, type VerificationEmailProps } from "../src/templates/verification" + +export default function VerificationPreview(props: VerificationEmailProps) { + return +} + +VerificationPreview.PreviewProps = { + verificationCode: "123456", +} satisfies VerificationEmailProps diff --git a/packages/email/package.json b/packages/email/package.json new file mode 100644 index 0000000000..e93a66d4ef --- /dev/null +++ b/packages/email/package.json @@ -0,0 +1,42 @@ +{ + "name": "@openwork/email", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + }, + "./templates": { + "types": "./src/templates/index.ts", + "development": "./src/templates/index.ts", + "default": "./dist/templates/index.js" + } + }, + "files": [ + "dist", + "src", + "emails" + ], + "scripts": { + "build": "tsup", + "dev": "email dev --dir emails" + }, + "dependencies": { + "@react-email/components": "^1.0.12", + "@react-email/render": "^2.0.7", + "nodemailer": "^8.0.6", + "react": "catalog:", + "react-dom": "catalog:", + "react-email": "6.1.1", + "resend": "^6.12.2" + }, + "devDependencies": { + "@react-email/ui": "6.1.1", + "@types/nodemailer": "^8.0.0", + "@types/react": "^19.2.14", + "tsup": "^8.5.0", + "typescript": "^5.6.3" + } +} diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts new file mode 100644 index 0000000000..444d9ddaa2 --- /dev/null +++ b/packages/email/src/index.ts @@ -0,0 +1,17 @@ +export { + EmailSendError, + sendEmail, + type EmailProvider, + type EmailSendConfig, + type SendEmailInput, + type SmtpEmailConfig, +} from "./send-email.js" +export { + emailSubjects, + renderEmailTemplate, + type EmailTemplate, + type EmailTemplateProps, + type FeedbackEmailProps, + type OrganizationInviteEmailProps, + type VerificationEmailProps, +} from "./templates/index.js" diff --git a/packages/email/src/send-email.ts b/packages/email/src/send-email.ts new file mode 100644 index 0000000000..774eed1b61 --- /dev/null +++ b/packages/email/src/send-email.ts @@ -0,0 +1,172 @@ +import { render } from "@react-email/render" +import nodemailer from "nodemailer" +import { Resend } from "resend" +import { emailSubjects, type EmailTemplate, type EmailTemplateProps, renderEmailTemplate } from "./templates/index.js" + +export type EmailProvider = "dev" | "resend" | "nodemailer" + +export type SmtpEmailConfig = { + host?: string + port?: number + user?: string + pass?: string + secure?: boolean +} + +export type EmailSendConfig = { + from?: string + resendApiKey?: string + smtp?: SmtpEmailConfig + devMode?: boolean +} + +export class EmailSendError extends Error { + readonly reason: "email_not_configured" | "resend_rejected" | "resend_network" | "nodemailer_rejected" + readonly template: EmailTemplate + readonly recipient: string + readonly detail?: string + + constructor(input: { + template: EmailTemplate + reason: EmailSendError["reason"] + recipient: string + detail?: string + }) { + super(`[${input.template}] email for ${input.recipient} failed: ${input.reason}${input.detail ? ` (${input.detail})` : ""}`) + this.name = "EmailSendError" + this.reason = input.reason + this.template = input.template + this.recipient = input.recipient + this.detail = input.detail + } +} + +export type SendEmailInput