From c951eeaffda1d31b435f1e8cc5620d3b5ae49366 Mon Sep 17 00:00:00 2001 From: 07rjain <07rjain@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:19:16 +0000 Subject: [PATCH 1/5] fix(email): never send from auth@undefined, fall back to logged codes and surface send failures Self-hosts without RESEND_FROM_DOMAIN sent from auth@undefined, which Resend rejects, while the UI still said a code was sent. Email is now treated as configured only when the API key and sender are both available, login codes are logged otherwise, the verify screen says email is not configured, explicit fromOverride senders keep working without a default domain, and invite APIs report not_configured instead of accepted. --- apps/web/__tests__/unit/email-config.test.ts | 96 +++++++++++++++++++ apps/web/actions/organization/send-invites.ts | 9 +- apps/web/app/(org)/verify-otp/form.tsx | 16 +++- apps/web/app/(org)/verify-otp/page.tsx | 2 + apps/web/app/api/mobile/[...route]/route.ts | 18 ++-- apps/web/app/api/v1/[...route]/route.ts | 9 +- apps/web/content/docs/self-hosting.mdx | 5 +- apps/web/lib/account-deletion-request.ts | 4 +- packages/database/auth/auth-options.ts | 15 ++- packages/database/emails/config.ts | 91 +++++++++++++++--- packages/web-domain/src/Agent.ts | 2 +- 11 files changed, 233 insertions(+), 34 deletions(-) create mode 100644 apps/web/__tests__/unit/email-config.test.ts diff --git a/apps/web/__tests__/unit/email-config.test.ts b/apps/web/__tests__/unit/email-config.test.ts new file mode 100644 index 00000000000..56aeb14796b --- /dev/null +++ b/apps/web/__tests__/unit/email-config.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + env: {} as Record, + build: {} as Record, + send: vi.fn(), +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => mocks.env, + buildEnv: mocks.build, +})); + +vi.mock("resend", () => ({ + Resend: class { + emails = { send: mocks.send }; + }, +})); + +import { getEmailDeliveryStatus, sendEmail } from "@cap/database/emails/config"; + +const message = { + email: "user@example.com", + subject: "Hello", + react: null as unknown as Parameters[0]["react"], +}; + +describe("email config", () => { + beforeEach(() => { + mocks.send.mockReset(); + for (const key of Object.keys(mocks.env)) delete mocks.env[key]; + for (const key of Object.keys(mocks.build)) delete mocks.build[key]; + }); + + it("reports missing RESEND_API_KEY", () => { + const status = getEmailDeliveryStatus(); + expect(status.configured).toBe(false); + expect(status.configured ? "" : status.reason).toContain( + "Email (Resend) is not configured", + ); + }); + + it("treats a missing RESEND_FROM_DOMAIN as not configured on self-hosted builds", async () => { + mocks.env.RESEND_API_KEY = "re_test"; + expect(getEmailDeliveryStatus().configured).toBe(false); + + const result = await sendEmail(message); + expect(mocks.send).not.toHaveBeenCalled(); + expect(result?.error?.name).toBe("email_not_configured"); + }); + + it("never sends from auth@undefined", async () => { + mocks.env.RESEND_API_KEY = "re_test"; + await sendEmail(message); + for (const call of mocks.send.mock.calls) { + expect(call[0].from).not.toContain("undefined"); + } + }); + + it("sends an explicit sender without a default from-domain", async () => { + mocks.env.RESEND_API_KEY = "re_test"; + mocks.send.mockResolvedValue({ data: { id: "email_2" }, error: null }); + + const result = await sendEmail({ + ...message, + fromOverride: "Cap Support ", + }); + + expect(mocks.send.mock.calls[0]?.[0].from).toBe( + "Cap Support ", + ); + expect(result).toEqual({ data: { id: "email_2" }, error: null }); + }); + + it("sends from the configured domain and returns the provider id", async () => { + mocks.env.RESEND_API_KEY = "re_test"; + mocks.env.RESEND_FROM_DOMAIN = "example.com"; + mocks.send.mockResolvedValue({ data: { id: "email_1" }, error: null }); + + const result = await sendEmail(message); + expect(mocks.send.mock.calls[0]?.[0].from).toBe("auth@example.com"); + expect(result).toEqual({ data: { id: "email_1" }, error: null }); + }); + + it("surfaces provider rejections instead of claiming success", async () => { + mocks.env.RESEND_API_KEY = "re_test"; + mocks.env.RESEND_FROM_DOMAIN = "example.com"; + mocks.send.mockResolvedValue({ + data: null, + error: { name: "validation_error", message: "domain not verified" }, + }); + + const result = await sendEmail(message); + expect(result?.error?.message).toBe("domain not verified"); + }); +}); diff --git a/apps/web/actions/organization/send-invites.ts b/apps/web/actions/organization/send-invites.ts index ad3642d4e3b..94c25f97ebf 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -184,9 +184,12 @@ export async function sendOrganizationInvites( }), ); - const failedInvites = inviteRecords.filter( - (_, i) => emailResults[i]?.status === "rejected", - ); + const failedInvites = inviteRecords.filter((_, i) => { + const result = emailResults[i]; + if (!result || result.status === "rejected") return true; + const error = result.value?.error; + return Boolean(error && error.name !== "email_not_configured"); + }); const failedEmails = failedInvites.map((r) => r.email); if (failedInvites.length > 0) { diff --git a/apps/web/app/(org)/verify-otp/form.tsx b/apps/web/app/(org)/verify-otp/form.tsx index ca8a88d9b68..8a9da42966d 100644 --- a/apps/web/app/(org)/verify-otp/form.tsx +++ b/apps/web/app/(org)/verify-otp/form.tsx @@ -16,10 +16,12 @@ export function VerifyOTPForm({ email, next, lastSent, + emailDeliveryConfigured = true, }: { email: string; next?: string; lastSent?: string; + emailDeliveryConfigured?: boolean; }) { const [code, setCode] = useState(["", "", "", "", "", ""]); const [lastResendTime, setLastResendTime] = useState( @@ -171,9 +173,17 @@ export function VerifyOTPForm({

Enter verification code

-

- We sent a 6-digit code to {normalizedEmail} -

+ {emailDeliveryConfigured ? ( +

+ We sent a 6-digit code to {normalizedEmail} +

+ ) : ( +

+ Email (Resend) is not configured on this server. The 6-digit code + for {normalizedEmail} was printed to the server logs. Ask your + administrator, or set RESEND_API_KEY and RESEND_FROM_DOMAIN. +

+ )}
diff --git a/apps/web/app/(org)/verify-otp/page.tsx b/apps/web/app/(org)/verify-otp/page.tsx index 37abfc47456..9c30b0d1ec9 100644 --- a/apps/web/app/(org)/verify-otp/page.tsx +++ b/apps/web/app/(org)/verify-otp/page.tsx @@ -1,4 +1,5 @@ import { getCurrentUser } from "@cap/database/auth/session"; +import { isEmailDeliveryConfigured } from "@cap/database/emails/config"; import { serverEnv } from "@cap/env"; import { redirect } from "next/navigation"; import { Suspense } from "react"; @@ -30,6 +31,7 @@ export default async function VerifyOTPPage(props: { email={searchParams.email?.toLowerCase() ?? ""} next={searchParams.next} lastSent={searchParams.lastSent} + emailDeliveryConfigured={isEmailDeliveryConfigured()} />
diff --git a/apps/web/app/api/mobile/[...route]/route.ts b/apps/web/app/api/mobile/[...route]/route.ts index 6c0320d5fba..474a65b9be4 100644 --- a/apps/web/app/api/mobile/[...route]/route.ts +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import { authOptions } from "@cap/database/auth/auth-options"; import { isEmailAllowedForSignup } from "@cap/database/auth/domain-utils"; import { hashPassword } from "@cap/database/crypto"; -import { sendEmail } from "@cap/database/emails/config"; +import { getEmailDeliveryStatus, sendEmail } from "@cap/database/emails/config"; import { OTPEmail } from "@cap/database/emails/otp-email"; import { nanoId } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; @@ -154,12 +154,11 @@ const hashEmailCode = (code: string) => .digest("hex"); const sendMobileEmailCode = async (email: string, code: string) => { - if (!serverEnv().RESEND_API_KEY) { - if (process.env.NODE_ENV === "production") { - throw new Error("RESEND_API_KEY is required to send mobile email codes"); - } + const emailStatus = getEmailDeliveryStatus(); + if (!emailStatus.configured) { console.log(""); - console.log("Cap mobile verification code"); + console.log("Cap mobile verification code (email delivery disabled)"); + console.log(emailStatus.reason); console.log(`Email: ${email}`); console.log(`Code: ${code}`); console.log("Expires in: 10 minutes"); @@ -167,11 +166,16 @@ const sendMobileEmailCode = async (email: string, code: string) => { return; } - await sendEmail({ + const delivery = await sendEmail({ email, subject: "Your Cap Verification Code", react: OTPEmail({ code, email }), }); + if (delivery?.error) { + throw new Error( + `Failed to send verification email: ${delivery.error.message}`, + ); + } }; const getMobileRedirectUrl = (redirectUri: string) => { diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 5e889e3c47c..0cfb4b5503a 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -4912,8 +4912,15 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( }), idempotencyKey: providerIdempotencyKey, }); - if (delivery?.error) + if (delivery?.error) { + if (delivery.error.name === "email_not_configured") { + return { + ...invitation, + emailDelivery: "not_configured" as const, + }; + } throw new Error(delivery.error.message); + } return { ...invitation, emailDelivery: "accepted" as const, diff --git a/apps/web/content/docs/self-hosting.mdx b/apps/web/content/docs/self-hosting.mdx index 45219a81bd0..29d07467c0c 100644 --- a/apps/web/content/docs/self-hosting.mdx +++ b/apps/web/content/docs/self-hosting.mdx @@ -26,7 +26,7 @@ That's it! Cap will be available at `http://localhost:3000`. The default `docker-compose.yml` includes placeholder secrets for quick local testing. **If you're deploying to a public server or production environment**, you must change these values. See the [Production Checklist](#production-checklist) below. -Login links will appear in the logs since email isn't configured: +Login codes will appear in the logs until email is configured, and the verify screen says "Email (Resend) is not configured": ```bash docker compose logs cap-web ``` @@ -97,7 +97,7 @@ For Coolify users, use `docker-compose.coolify.yml` which includes environment v ## Email Configuration -By default, login links are printed to the server logs. To send emails: +Email is optional. Without it, login codes are printed to the cap-web logs, the verify screen shows "Email (Resend) is not configured", and invite or notification emails are skipped (invite links still work). Cap only sends email when **both** variables below are set; if Resend rejects a message, the error is logged and the sign-in fails instead of claiming a code was sent. To send emails: 1. Create an account at [Resend](https://resend.com) 2. Add these environment variables: @@ -105,6 +105,7 @@ By default, login links are printed to the server logs. To send emails: RESEND_API_KEY=re_xxxxx RESEND_FROM_DOMAIN=yourdomain.com ``` +3. Verify the domain in Resend. Login emails are sent from `auth@RESEND_FROM_DOMAIN`. ## Optional Features diff --git a/apps/web/lib/account-deletion-request.ts b/apps/web/lib/account-deletion-request.ts index 8a21645b71f..3468a026319 100644 --- a/apps/web/lib/account-deletion-request.ts +++ b/apps/web/lib/account-deletion-request.ts @@ -164,7 +164,7 @@ export const createAccountDeletionRequest = async ({ fromOverride: ACCOUNT_DELETION_EMAIL_FROM, idempotencyKey: `account-deletion-${request.id}`, }) - .then(() => true) + .then((delivery) => !delivery?.error) .catch(() => false); return { @@ -264,7 +264,7 @@ export const createMobileContentReport = async ({ fromOverride: ACCOUNT_DELETION_EMAIL_FROM, idempotencyKey: `mobile-content-report-${request.id}`, }) - .then(() => true) + .then((delivery) => !delivery?.error) .catch(() => false); return { diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 8d9c00f7272..f97c5bd78d2 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -11,7 +11,7 @@ import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; import type { Provider } from "next-auth/providers/index"; import WorkOSProvider from "next-auth/providers/workos"; -import { sendEmail } from "../emails/config.ts"; +import { getEmailDeliveryStatus, sendEmail } from "../emails/config.ts"; import { db } from "../index.ts"; import { users } from "../schema.ts"; import { @@ -191,12 +191,14 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { return crypto.randomInt(100000, 1000000).toString(); }, async sendVerificationRequest({ identifier, token }) { - if (!serverEnv().RESEND_API_KEY) { + const emailStatus = getEmailDeliveryStatus(); + if (!emailStatus.configured) { console.log("\n"); console.log( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", ); - console.log("🔐 VERIFICATION CODE (Development Mode)"); + console.log("🔐 VERIFICATION CODE (email delivery disabled)"); + console.log(`ℹ️ ${emailStatus.reason}`); console.log( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", ); @@ -212,11 +214,16 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { } else { const { OTPEmail } = await import("../emails/otp-email"); const email = OTPEmail({ code: token, email: identifier }); - await sendEmail({ + const delivery = await sendEmail({ email: identifier, subject: `Your Cap Verification Code`, react: email, }); + if (delivery?.error) { + throw new Error( + `Failed to send verification email: ${delivery.error.message}`, + ); + } } }, }), diff --git a/packages/database/emails/config.ts b/packages/database/emails/config.ts index a5145c0d7d9..87ce2ea3572 100644 --- a/packages/database/emails/config.ts +++ b/packages/database/emails/config.ts @@ -5,6 +5,46 @@ import { Resend } from "resend"; export const resend = () => serverEnv().RESEND_API_KEY ? new Resend(serverEnv().RESEND_API_KEY) : null; +export const EMAIL_NOT_CONFIGURED_MESSAGE = "Email (Resend) is not configured"; + +export type EmailDeliveryStatus = + | { configured: true } + | { configured: false; reason: string }; + +export function getEmailDeliveryStatus(): EmailDeliveryStatus { + const env = serverEnv(); + if (!env.RESEND_API_KEY) { + return { + configured: false, + reason: `${EMAIL_NOT_CONFIGURED_MESSAGE}: RESEND_API_KEY is not set`, + }; + } + if (!buildEnv.NEXT_PUBLIC_IS_CAP && !env.RESEND_FROM_DOMAIN?.trim()) { + return { + configured: false, + reason: `${EMAIL_NOT_CONFIGURED_MESSAGE}: RESEND_FROM_DOMAIN is not set`, + }; + } + return { configured: true }; +} + +export const isEmailDeliveryConfigured = () => + getEmailDeliveryStatus().configured; + +let warnedEmailNotConfigured = false; + +function warnEmailNotConfigured(reason: string) { + if (warnedEmailNotConfigured) return; + warnedEmailNotConfigured = true; + console.warn( + `[email] ${reason}. Emails will not be sent; login codes are printed to the server log instead.`, + ); +} + +export type SendEmailResult = + | { data: { id: string } | null; error: null } + | { data: null; error: { name: string; message: string } }; + export const sendEmail = async ({ email, subject, @@ -33,22 +73,38 @@ export const sendEmail = async ({ content: Buffer | string; contentType?: string; }[]; -}) => { +}): Promise => { + const env = serverEnv(); const r = resend(); - if (!r) { - return Promise.resolve(); + if (!env.RESEND_API_KEY || !r) { + const reason = `${EMAIL_NOT_CONFIGURED_MESSAGE}: RESEND_API_KEY is not set`; + warnEmailNotConfigured(reason); + return { + data: null, + error: { name: "email_not_configured", message: reason }, + }; } if (marketing && !buildEnv.NEXT_PUBLIC_IS_CAP) return; - let from: string; - - if (fromOverride) from = fromOverride; - else if (marketing) from = "Richie from Cap "; - else if (buildEnv.NEXT_PUBLIC_IS_CAP) - from = "Cap Auth "; - else from = `auth@${serverEnv().RESEND_FROM_DOMAIN}`; + const from = fromOverride + ? fromOverride + : marketing + ? "Richie from Cap " + : buildEnv.NEXT_PUBLIC_IS_CAP + ? "Cap Auth " + : env.RESEND_FROM_DOMAIN?.trim() + ? `auth@${env.RESEND_FROM_DOMAIN.trim()}` + : undefined; + if (!from?.includes("@")) { + const reason = `${EMAIL_NOT_CONFIGURED_MESSAGE}: RESEND_FROM_DOMAIN is not set`; + warnEmailNotConfigured(reason); + return { + data: null, + error: { name: "email_not_configured", message: reason }, + }; + } - return r.emails.send( + const result = await r.emails.send( { from, to: test ? "delivered@resend.dev" : email, @@ -61,4 +117,17 @@ export const sendEmail = async ({ }, idempotencyKey ? { idempotencyKey } : undefined, ); + + if (result.error) { + console.error( + `[email] Resend rejected email "${subject}" from ${from}:`, + result.error.message, + ); + return { + data: null, + error: { name: result.error.name, message: result.error.message }, + }; + } + + return { data: result.data, error: null }; }; diff --git a/packages/web-domain/src/Agent.ts b/packages/web-domain/src/Agent.ts index b09795d80ad..9a4c1f8170f 100644 --- a/packages/web-domain/src/Agent.ts +++ b/packages/web-domain/src/Agent.ts @@ -695,7 +695,7 @@ export const AgentInvite = Schema.Struct({ export const AgentOrganizationInviteResponse = Schema.Struct({ invite: AgentInvite, inviteUrl: Schema.String, - emailDelivery: Schema.Literal("not_requested", "accepted"), + emailDelivery: Schema.Literal("not_requested", "accepted", "not_configured"), requestId: Schema.String, }); From 4068cd12ae6ffbe89534d3b7ed5f7e1a21428f94 Mon Sep 17 00:00:00 2001 From: Rishabh Bothra <07rishabhjain@gmail.com> Date: Fri, 23 Oct 2026 23:36:20 +0000 Subject: [PATCH 2/5] fix(email): report skipped invites and retain hosted sign-in failures --- apps/web/__tests__/unit/auth-options.test.ts | 40 ++++++- apps/web/__tests__/unit/email-config.test.ts | 8 -- .../unit/mobile-email-session.test.ts | 61 ++++++++++- .../unit/organization-invite-delivery.test.ts | 101 ++++++++++++++++++ apps/web/actions/organization/send-invites.ts | 19 +++- .../organization/components/InviteDialog.tsx | 7 +- apps/web/app/(org)/verify-otp/form.tsx | 6 +- apps/web/app/api/mobile/[...route]/route.ts | 5 +- packages/database/auth/auth-options.ts | 8 +- 9 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 apps/web/__tests__/unit/organization-invite-delivery.test.ts diff --git a/apps/web/__tests__/unit/auth-options.test.ts b/apps/web/__tests__/unit/auth-options.test.ts index 3b2ccf11cca..ce8ff722ac7 100644 --- a/apps/web/__tests__/unit/auth-options.test.ts +++ b/apps/web/__tests__/unit/auth-options.test.ts @@ -11,7 +11,8 @@ import type { SQL } from "drizzle-orm"; import { MySqlDialect } from "drizzle-orm/mysql-core"; import type { Account, NextAuthOptions } from "next-auth"; import type { Adapter } from "next-auth/adapters"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { EmailConfig } from "next-auth/providers/email"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; type AuthUser = { id: string; @@ -34,6 +35,7 @@ const mocks = vi.hoisted(() => ({ options: { getSsoIdentity: () => ValidatedSsoIdentity | null }, ) => Adapter >(() => ({})), + isCap: undefined as string | undefined, })); const env = vi.hoisted(() => ({ @@ -57,6 +59,11 @@ const env = vi.hoisted(() => ({ vi.mock("@cap/env", () => ({ serverEnv: () => env, + buildEnv: { + get NEXT_PUBLIC_IS_CAP() { + return mocks.isCap; + }, + }, })); vi.mock("@cap/database", () => ({ @@ -122,6 +129,7 @@ function providerIds(options: NextAuthOptions) { describe("authOptions", () => { beforeEach(() => { + mocks.isCap = undefined; env.APPLE_CLIENT_ID = "so.cap.auth"; env.APPLE_CLIENT_SECRET = "apple-secret"; env.WORKOS_CLIENT_ID = "workos-client"; @@ -160,6 +168,36 @@ describe("authOptions", () => { })); }); + afterEach(() => vi.unstubAllEnvs()); + + it.each([false, true])( + "only logs undelivered codes for self-hosts (Cap Cloud: %j)", + async (isCap) => { + mocks.isCap = isCap ? "true" : undefined; + vi.stubEnv("NODE_ENV", "production"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const provider = authOptions().providers.find( + (provider) => provider.id === "email", + ) as EmailConfig; + const options = provider.options as EmailConfig; + const result = options.sendVerificationRequest({ + identifier: "person@example.com", + token: "123456", + url: "http://localhost/verify", + expires: new Date(), + provider, + theme: {}, + }); + if (isCap) { + await expect(result).rejects.toThrow("RESEND_API_KEY"); + expect(log).not.toHaveBeenCalled(); + } else { + await result; + expect(log).toHaveBeenCalledWith("🔢 Code: 123456"); + } + }, + ); + it("enables Apple when both OAuth credentials are configured", () => { const options = authOptions(); const providers = options.providers.map((provider) => provider.id); diff --git a/apps/web/__tests__/unit/email-config.test.ts b/apps/web/__tests__/unit/email-config.test.ts index 56aeb14796b..562f28be8fc 100644 --- a/apps/web/__tests__/unit/email-config.test.ts +++ b/apps/web/__tests__/unit/email-config.test.ts @@ -49,14 +49,6 @@ describe("email config", () => { expect(result?.error?.name).toBe("email_not_configured"); }); - it("never sends from auth@undefined", async () => { - mocks.env.RESEND_API_KEY = "re_test"; - await sendEmail(message); - for (const call of mocks.send.mock.calls) { - expect(call[0].from).not.toContain("undefined"); - } - }); - it("sends an explicit sender without a default from-domain", async () => { mocks.env.RESEND_API_KEY = "re_test"; mocks.send.mockResolvedValue({ data: { id: "email_2" }, error: null }); diff --git a/apps/web/__tests__/unit/mobile-email-session.test.ts b/apps/web/__tests__/unit/mobile-email-session.test.ts index 8d103a4fb70..c6855f180ca 100644 --- a/apps/web/__tests__/unit/mobile-email-session.test.ts +++ b/apps/web/__tests__/unit/mobile-email-session.test.ts @@ -5,6 +5,7 @@ import { MySqlDialect } from "drizzle-orm/mysql-core"; import type { Context, Layer as EffectLayer } from "effect"; import { afterAll, + afterEach, beforeAll, beforeEach, describe, @@ -18,6 +19,10 @@ type TokenRow = { identifier: string; token: string; expires: Date }; const mocks = vi.hoisted(() => ({ isRateLimited: vi.fn<(ruleId: string, opts?: unknown) => Promise>(), sendEmail: vi.fn<(input: { react: { code: string } }) => Promise>(), + emailStatus: { configured: true } as + | { configured: true } + | { configured: false; reason: string }, + isCap: undefined as string | undefined, tokenRow: null as TokenRow | null, selectBarrier: null as null | { size: number; waiting: (() => void)[] }, apiKeys: [] as string[], @@ -30,7 +35,22 @@ vi.mock("@/lib/rate-limit", async (importOriginal) => ({ ...(await importOriginal()), isRateLimited: mocks.isRateLimited, })); -vi.mock("@cap/database/emails/config", () => ({ sendEmail: mocks.sendEmail })); +vi.mock("@cap/database/emails/config", () => ({ + sendEmail: mocks.sendEmail, + getEmailDeliveryStatus: () => mocks.emailStatus, +})); +vi.mock("@cap/env", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + buildEnv: { + ...original.buildEnv, + get NEXT_PUBLIC_IS_CAP() { + return mocks.isCap; + }, + }, + }; +}); vi.mock("@cap/database/emails/otp-email", () => ({ OTPEmail: (props: { code: string; email: string }) => props, })); @@ -206,12 +226,15 @@ describe("mobile email sessions", () => { beforeEach(() => { mocks.isRateLimited.mockReset().mockResolvedValue(false); mocks.sendEmail.mockReset().mockResolvedValue(undefined); + mocks.emailStatus = { configured: true }; + mocks.isCap = undefined; mocks.tokenRow = null; mocks.selectBarrier = null; mocks.apiKeys = []; }); afterAll(() => mocks.dispose()); + afterEach(() => vi.unstubAllEnvs()); const post = (path: string, body: unknown) => POST( @@ -232,6 +255,42 @@ describe("mobile email sessions", () => { const wrongCode = (code: string) => (code === "123456" ? "654321" : "123456"); + it("logs a usable code for a self-host with email disabled", async () => { + mocks.emailStatus = { + configured: false, + reason: "Email is not configured", + }; + vi.stubEnv("NODE_ENV", "production"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const response = await post("request", { email: "person@example.com" }); + expect(response.status).toBe(200); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + const codeLine = log.mock.calls.find(([line]) => + /^Code: \d{6}$/.test(line), + ); + expect(codeLine).toBeDefined(); + const code = String(codeLine?.[0]).slice("Code: ".length); + expect( + (await post("verify", { email: "person@example.com", code })).status, + ).toBe(200); + }); + + it("fails on Cap Cloud without printing a code when email is disabled", async () => { + mocks.emailStatus = { + configured: false, + reason: "Email is not configured", + }; + mocks.isCap = "true"; + vi.stubEnv("NODE_ENV", "production"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const response = await post("request", { email: "person@example.com" }); + expect(response.status).toBe(500); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + expect( + log.mock.calls.some(([line]) => /^Code: \d{6}$/.test(String(line))), + ).toBe(false); + }); + it("sends a code and signs in with it", async () => { const code = await requestCode(" Person@Example.com "); diff --git a/apps/web/__tests__/unit/organization-invite-delivery.test.ts b/apps/web/__tests__/unit/organization-invite-delivery.test.ts new file mode 100644 index 00000000000..0df89c938da --- /dev/null +++ b/apps/web/__tests__/unit/organization-invite-delivery.test.ts @@ -0,0 +1,101 @@ +import { Organisation } from "@cap/web-domain"; +import type { SQL } from "drizzle-orm"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { sendOrganizationInvites } from "@/actions/organization/send-invites"; + +const mocks = vi.hoisted(() => ({ + sendEmail: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + sequence: 0, +})); + +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => ({ id: "user-1" }), +})); +vi.mock("@cap/database/emails/config", () => ({ sendEmail: mocks.sendEmail })); +vi.mock("@cap/database/emails/organization-invite", () => ({ + OrganizationInvite: (props: unknown) => props, +})); +vi.mock("@cap/database/helpers", () => ({ + nanoId: () => `invite-${++mocks.sequence}`, +})); +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ WEB_URL: "https://cap.example" }), +})); +vi.mock("@/lib/organization-provisioning", () => ({ + provisionOrganizationInvitee: vi.fn(), +})); +vi.mock("@/actions/organization/authorization", () => ({ + requireOrganizationSettingsManager: vi.fn(), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@cap/database", () => { + const tx = { + select: () => ({ + from: () => ({ + where: async () => [], + innerJoin: () => ({ where: async () => [] }), + }), + }), + insert: () => ({ values: mocks.insert }), + }; + return { + db: () => ({ + select: () => ({ + from: () => ({ where: async () => [{ id: "org-1", name: "Team" }] }), + }), + transaction: async (callback: (value: typeof tx) => Promise) => + callback(tx), + delete: () => ({ where: mocks.delete }), + }), + }; +}); + +describe("organization invite delivery", () => { + beforeEach(() => { + mocks.sequence = 0; + mocks.sendEmail.mockReset(); + }); + + it("preserves skipped invite records and reports them separately", async () => { + mocks.sendEmail.mockResolvedValue({ + error: { name: "email_not_configured" }, + }); + const result = await sendOrganizationInvites( + ["person@example.com"], + Organisation.OrganisationId.make("org-1"), + ); + expect(result).toMatchObject({ + failedEmails: [], + skippedEmails: ["person@example.com"], + }); + expect(mocks.insert).toHaveBeenCalledWith([ + expect.objectContaining({ + id: "invite-1", + invitedEmail: "person@example.com", + }), + ]); + expect(mocks.delete).not.toHaveBeenCalled(); + }); + + it("cleans up only failed deliveries in a mixed batch", async () => { + mocks.sendEmail + .mockResolvedValueOnce({ error: { name: "email_not_configured" } }) + .mockResolvedValueOnce({ error: { name: "validation_error" } }) + .mockResolvedValueOnce({ data: { id: "sent" }, error: null }); + const result = await sendOrganizationInvites( + ["skipped@example.com", "failed@example.com", "sent@example.com"], + Organisation.OrganisationId.make("org-1"), + ); + expect(result).toMatchObject({ + failedEmails: ["failed@example.com"], + skippedEmails: ["skipped@example.com"], + }); + const condition = mocks.delete.mock.calls[0]?.[0] as SQL; + expect(new MySqlDialect().sqlToQuery(condition).params).toEqual([ + "invite-2", + ]); + }); +}); diff --git a/apps/web/actions/organization/send-invites.ts b/apps/web/actions/organization/send-invites.ts index 94c25f97ebf..9032647b894 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -88,7 +88,11 @@ export async function sendOrganizationInvites( const validEmails = validInvites.map((invite) => invite.email); if (validEmails.length === 0) { - return { success: true, failedEmails: [] as string[] }; + return { + success: true, + failedEmails: [] as string[], + skippedEmails: [] as string[], + }; } if (options.sendEmailNotifications === false) { @@ -108,7 +112,7 @@ export async function sendOrganizationInvites( revalidatePath("/dashboard/settings/organization"); - return { success: true, failedEmails }; + return { success: true, failedEmails, skippedEmails: [] as string[] }; } const inviteRecords = await db().transaction(async (tx) => { @@ -191,6 +195,15 @@ export async function sendOrganizationInvites( return Boolean(error && error.name !== "email_not_configured"); }); const failedEmails = failedInvites.map((r) => r.email); + const skippedEmails = inviteRecords + .filter((_, i) => { + const result = emailResults[i]; + return ( + result?.status === "fulfilled" && + result.value?.error?.name === "email_not_configured" + ); + }) + .map((record) => record.email); if (failedInvites.length > 0) { try { @@ -216,5 +229,5 @@ export async function sendOrganizationInvites( revalidatePath("/dashboard/settings/organization"); - return { success: true, failedEmails }; + return { success: true, failedEmails, skippedEmails }; } diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx index c7e95947a26..a66e862c722 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx @@ -114,13 +114,18 @@ export const InviteDialog = ({ isOpen, setIsOpen }: InviteDialogProps) => { ); }, onSuccess: (result) => { + if (result.skippedEmails.length > 0) { + toast.warning( + `Invites created but not emailed because email is not configured. Share the invite links with: ${result.skippedEmails.join(", ")}`, + ); + } if (result.failedEmails.length > 0) { toast.warning( sendEmailNotifications ? `Invites sent, but delivery failed for: ${result.failedEmails.join(", ")}` : `Users added, but provisioning failed for: ${result.failedEmails.join(", ")}`, ); - } else { + } else if (result.skippedEmails.length === 0) { toast.success( sendEmailNotifications ? "Invites sent successfully" diff --git a/apps/web/app/(org)/verify-otp/form.tsx b/apps/web/app/(org)/verify-otp/form.tsx index 8a9da42966d..e782d504a1d 100644 --- a/apps/web/app/(org)/verify-otp/form.tsx +++ b/apps/web/app/(org)/verify-otp/form.tsx @@ -135,7 +135,11 @@ export function VerifyOTPForm({ } }, onSuccess: () => { - toast.success("A new code has been sent to your email!"); + toast.success( + emailDeliveryConfigured + ? "A new code has been sent to your email!" + : "A new code was printed to the server logs", + ); setCode(["", "", "", "", "", ""]); inputRefs.current[0]?.focus(); setLastResendTime(Date.now()); diff --git a/apps/web/app/api/mobile/[...route]/route.ts b/apps/web/app/api/mobile/[...route]/route.ts index 868ee16a8fb..a82816b396e 100644 --- a/apps/web/app/api/mobile/[...route]/route.ts +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -7,7 +7,7 @@ import { OTPEmail } from "@cap/database/emails/otp-email"; import { nanoId } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; import { getNewVideoPublic } from "@cap/database/video-sharing-default"; -import { serverEnv } from "@cap/env"; +import { buildEnv, serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; import { Database, @@ -160,6 +160,9 @@ const hashEmailCode = (code: string) => const sendMobileEmailCode = async (email: string, code: string) => { const emailStatus = getEmailDeliveryStatus(); if (!emailStatus.configured) { + if (process.env.NODE_ENV === "production" && buildEnv.NEXT_PUBLIC_IS_CAP) { + throw new Error(emailStatus.reason); + } console.log(""); console.log("Cap mobile verification code (email delivery disabled)"); console.log(emailStatus.reason); diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 2eca18ad3b2..119d6338d33 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { serverEnv } from "@cap/env"; +import { buildEnv, serverEnv } from "@cap/env"; import { User } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import type { NextAuthOptions } from "next-auth"; @@ -261,6 +261,12 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { async sendVerificationRequest({ identifier, token }) { const emailStatus = getEmailDeliveryStatus(); if (!emailStatus.configured) { + if ( + process.env.NODE_ENV === "production" && + buildEnv.NEXT_PUBLIC_IS_CAP + ) { + throw new Error(emailStatus.reason); + } console.log("\n"); console.log( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", From 52989620920e413c4a43578675ce8408aa18bd9b Mon Sep 17 00:00:00 2001 From: Rishabh Bothra <07rishabhjain@gmail.com> Date: Fri, 23 Oct 2026 23:36:39 +0000 Subject: [PATCH 3/5] test(email): preserve schema helpers in invite delivery fixture --- apps/web/__tests__/unit/organization-invite-delivery.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/__tests__/unit/organization-invite-delivery.test.ts b/apps/web/__tests__/unit/organization-invite-delivery.test.ts index 0df89c938da..ad60b129bdb 100644 --- a/apps/web/__tests__/unit/organization-invite-delivery.test.ts +++ b/apps/web/__tests__/unit/organization-invite-delivery.test.ts @@ -19,6 +19,7 @@ vi.mock("@cap/database/emails/organization-invite", () => ({ OrganizationInvite: (props: unknown) => props, })); vi.mock("@cap/database/helpers", () => ({ + nanoIdLength: 15, nanoId: () => `invite-${++mocks.sequence}`, })); vi.mock("@cap/env", () => ({ From 7543750d5a0fa97e07a2fa793f4ded681c2f05f4 Mon Sep 17 00:00:00 2001 From: Rishabh Bothra <07rishabhjain@gmail.com> Date: Sat, 24 Oct 2026 08:47:43 +0000 Subject: [PATCH 4/5] fix(email): report failed invitation and support deliveries accurately Avoid claiming invitations were sent in mixed skipped and failed batches. Propagate returned support email errors to the existing failure handler. Cover resend destinations, invitation notices and support provider errors with regression tests. --- .../__tests__/unit/email-delivery-ui.test.ts | 159 ++++++++++++++++++ .../unit/messenger-support-email.test.ts | 26 +++ .../organization/components/InviteDialog.tsx | 2 +- apps/web/lib/messenger/support-email.ts | 5 +- 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 apps/web/__tests__/unit/email-delivery-ui.test.ts diff --git a/apps/web/__tests__/unit/email-delivery-ui.test.ts b/apps/web/__tests__/unit/email-delivery-ui.test.ts new file mode 100644 index 00000000000..e153c87e982 --- /dev/null +++ b/apps/web/__tests__/unit/email-delivery-ui.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, createElement, type ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { sendOrganizationInvites } from "@/actions/organization/send-invites"; +import { InviteDialog } from "@/app/(org)/dashboard/settings/organization/components/InviteDialog"; +import { VerifyOTPForm } from "@/app/(org)/verify-otp/form"; + +const mocks = vi.hoisted(() => ({ + invites: vi.fn(), + signIn: vi.fn(), + refresh: vi.fn(), + close: vi.fn(), + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +vi.mock("@/actions/organization/send-invites", () => ({ + sendOrganizationInvites: mocks.invites, +})); +vi.mock("@/app/(org)/dashboard/Contexts", () => ({ + useDashboardContext: () => ({ + activeOrganization: { organization: { id: "org-1", name: "Team" } }, + }), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: mocks.refresh }), +})); +vi.mock("next-auth/react", () => ({ signIn: mocks.signIn })); +vi.mock("sonner", () => ({ toast: mocks.toast })); +vi.mock("@cap/utils", async () => await import("@cap/utils/helpers")); +vi.mock("@cap/ui", async () => ({ + ...(await import("../../../../packages/ui/src/components/Button")), + ...(await import("../../../../packages/ui/src/components/Dialog")), + ...(await import("../../../../packages/ui/src/components/Select")), + ...(await import("../../../../packages/ui/src/components/Switch")), + ...(await import("../../../../packages/ui/src/components/input/Input")), + ...(await import("../../../../packages/ui/src/components/icons/LogoBadge")), +})); + +let root: Root; +let container: HTMLDivElement; +let queryClient: QueryClient; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.invites.mockReset(); + mocks.signIn.mockResolvedValue({ ok: true }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + queryClient.clear(); + vi.unstubAllGlobals(); +}); + +async function render(element: ReactElement) { + await act(async () => { + root.render( + createElement(QueryClientProvider, { client: queryClient }, element), + ); + }); +} + +async function clickButton(label: string) { + const button = Array.from(document.querySelectorAll("button")).find( + (element) => element.textContent?.trim() === label, + ); + if (!button) throw new Error(`Missing button: ${label}`); + await act(async () => { + button.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe("verification email delivery messages", () => { + it.each([false, true])( + "reports the correct resend destination when email configured is %s", + async (emailDeliveryConfigured) => { + await render( + createElement(VerifyOTPForm, { + email: "person@example.com", + emailDeliveryConfigured, + }), + ); + expect(container.textContent).toContain( + emailDeliveryConfigured + ? "We sent a 6-digit code" + : "was printed to the server logs", + ); + await clickButton("Didn't receive the code? Resend"); + expect(mocks.signIn).toHaveBeenCalledWith("email", { + email: "person@example.com", + redirect: false, + }); + expect(mocks.toast.success).toHaveBeenCalledExactlyOnceWith( + emailDeliveryConfigured + ? "A new code has been sent to your email!" + : "A new code was printed to the server logs", + ); + }, + ); +}); + +describe("invitation email delivery messages", () => { + it.each([false, true])( + "does not claim skipped emails were sent when delivery failures are %s", + async (hasFailures) => { + mocks.invites.mockResolvedValue({ + success: true, + failedEmails: hasFailures ? ["failed@example.com"] : [], + skippedEmails: ["skipped@example.com"], + }); + await render( + createElement(InviteDialog, { isOpen: true, setIsOpen: mocks.close }), + ); + const input = document.querySelector( + "input[placeholder='name@company.com']", + ); + if (!input) throw new Error("Missing invitation email input"); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set?.call( + input, + hasFailures + ? "skipped@example.com, failed@example.com" + : "skipped@example.com", + ); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await clickButton("Send Invites"); + expect(mocks.invites).toHaveBeenCalledOnce(); + expect(mocks.toast.warning).toHaveBeenCalledWith( + "Invites created but not emailed because email is not configured. Share the invite links with: skipped@example.com", + ); + if (hasFailures) { + expect(mocks.toast.warning).toHaveBeenCalledWith( + "Invite email delivery failed for: failed@example.com", + ); + } + expect(mocks.toast.warning).not.toHaveBeenCalledWith( + expect.stringContaining("Invites sent"), + ); + expect(mocks.toast.success).not.toHaveBeenCalled(); + expect(mocks.close).toHaveBeenCalledWith(false); + expect(mocks.refresh).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/apps/web/__tests__/unit/messenger-support-email.test.ts b/apps/web/__tests__/unit/messenger-support-email.test.ts index 64b48e82857..80f8ab38e46 100644 --- a/apps/web/__tests__/unit/messenger-support-email.test.ts +++ b/apps/web/__tests__/unit/messenger-support-email.test.ts @@ -142,6 +142,32 @@ describe("sendMessengerSupportEmail", () => { expect(valuesMock).not.toHaveBeenCalled(); }); + it.each([ + ["email_not_configured", "Email is not configured"], + ["validation_error", "Sender domain is not verified"], + ])("does not report a returned %s as sent", async (name, message) => { + whereMock.mockReturnValueOnce(mockDb).mockResolvedValueOnce([{ value: 0 }]); + sendEmailMock.mockResolvedValueOnce({ + data: null, + error: { name, message }, + }); + const { sendMessengerSupportEmail } = await import( + "@/lib/messenger/support-email" + ); + + await expect( + sendMessengerSupportEmail({ + user: { + id: User.UserId.make("user-123"), + email: "user@example.com", + }, + conversationId: "conversation-123", + subject: "Upload issue", + message: "Uploads keep failing.", + }), + ).rejects.toThrow(message); + }); + it("does not send when the audit reservation fails", async () => { whereMock.mockReturnValueOnce(mockDb).mockResolvedValueOnce([{ value: 1 }]); valuesMock.mockRejectedValueOnce(new Error("insert failed")); diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx index a66e862c722..fbbfa50538d 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx @@ -122,7 +122,7 @@ export const InviteDialog = ({ isOpen, setIsOpen }: InviteDialogProps) => { if (result.failedEmails.length > 0) { toast.warning( sendEmailNotifications - ? `Invites sent, but delivery failed for: ${result.failedEmails.join(", ")}` + ? `Invite email delivery failed for: ${result.failedEmails.join(", ")}` : `Users added, but provisioning failed for: ${result.failedEmails.join(", ")}`, ); } else if (result.skippedEmails.length === 0) { diff --git a/apps/web/lib/messenger/support-email.ts b/apps/web/lib/messenger/support-email.ts index d09b8219fd7..db10811efc4 100644 --- a/apps/web/lib/messenger/support-email.ts +++ b/apps/web/lib/messenger/support-email.ts @@ -105,7 +105,7 @@ export const sendMessengerSupportEmail = async ({ return reserved; } - await sendEmail({ + const delivery = await sendEmail({ email: SUPPORT_EMAIL_TO, subject: `Messenger support: ${normalizedSubject}`, react: MessengerSupportEmail({ @@ -117,6 +117,9 @@ export const sendMessengerSupportEmail = async ({ replyTo: user.email, fromOverride: SUPPORT_EMAIL_FROM, }); + if (delivery?.error) { + throw new Error(`Support email was not sent: ${delivery.error.message}`); + } return reserved; }; From 22a860ea9c82747708206f1078cd429d8e8d3e52 Mon Sep 17 00:00:00 2001 From: Rishabh Bothra <07rishabhjain@gmail.com> Date: Sat, 24 Oct 2026 08:57:26 +0000 Subject: [PATCH 5/5] fix(email): require an explicit Cap Cloud flag for hosted behavior Treat NEXT_PUBLIC_IS_CAP=false as self-hosted in web and mobile sign-in, email configuration, sender selection and marketing suppression. Cover unset, empty, false and true values while preserving production Cap Cloud email failures. --- apps/web/__tests__/unit/auth-options.test.ts | 8 +-- apps/web/__tests__/unit/email-config.test.ts | 63 ++++++++++++++----- .../unit/mobile-email-session.test.ts | 42 +++++++------ apps/web/app/api/mobile/[...route]/route.ts | 5 +- packages/database/auth/auth-options.ts | 2 +- packages/database/emails/config.ts | 10 ++- 6 files changed, 88 insertions(+), 42 deletions(-) diff --git a/apps/web/__tests__/unit/auth-options.test.ts b/apps/web/__tests__/unit/auth-options.test.ts index ce8ff722ac7..01c5a76efc4 100644 --- a/apps/web/__tests__/unit/auth-options.test.ts +++ b/apps/web/__tests__/unit/auth-options.test.ts @@ -170,10 +170,10 @@ describe("authOptions", () => { afterEach(() => vi.unstubAllEnvs()); - it.each([false, true])( - "only logs undelivered codes for self-hosts (Cap Cloud: %j)", + it.each([undefined, "", "false", "true"])( + "only logs undelivered codes for self-hosts (NEXT_PUBLIC_IS_CAP: %j)", async (isCap) => { - mocks.isCap = isCap ? "true" : undefined; + mocks.isCap = isCap; vi.stubEnv("NODE_ENV", "production"); const log = vi.spyOn(console, "log").mockImplementation(() => {}); const provider = authOptions().providers.find( @@ -188,7 +188,7 @@ describe("authOptions", () => { provider, theme: {}, }); - if (isCap) { + if (isCap === "true") { await expect(result).rejects.toThrow("RESEND_API_KEY"); expect(log).not.toHaveBeenCalled(); } else { diff --git a/apps/web/__tests__/unit/email-config.test.ts b/apps/web/__tests__/unit/email-config.test.ts index 562f28be8fc..cd8f6830f59 100644 --- a/apps/web/__tests__/unit/email-config.test.ts +++ b/apps/web/__tests__/unit/email-config.test.ts @@ -40,14 +40,18 @@ describe("email config", () => { ); }); - it("treats a missing RESEND_FROM_DOMAIN as not configured on self-hosted builds", async () => { - mocks.env.RESEND_API_KEY = "re_test"; - expect(getEmailDeliveryStatus().configured).toBe(false); - - const result = await sendEmail(message); - expect(mocks.send).not.toHaveBeenCalled(); - expect(result?.error?.name).toBe("email_not_configured"); - }); + it.each([undefined, "", "false"])( + "requires a sender domain with NEXT_PUBLIC_IS_CAP=%j", + async (isCap) => { + mocks.build.NEXT_PUBLIC_IS_CAP = isCap; + mocks.env.RESEND_API_KEY = "re_test"; + expect(getEmailDeliveryStatus().configured).toBe(false); + + const result = await sendEmail(message); + expect(mocks.send).not.toHaveBeenCalled(); + expect(result?.error?.name).toBe("email_not_configured"); + }, + ); it("sends an explicit sender without a default from-domain", async () => { mocks.env.RESEND_API_KEY = "re_test"; @@ -64,16 +68,47 @@ describe("email config", () => { expect(result).toEqual({ data: { id: "email_2" }, error: null }); }); - it("sends from the configured domain and returns the provider id", async () => { + it.each([undefined, "false"])( + "sends from the configured domain with NEXT_PUBLIC_IS_CAP=%j", + async (isCap) => { + mocks.build.NEXT_PUBLIC_IS_CAP = isCap; + mocks.env.RESEND_API_KEY = "re_test"; + mocks.env.RESEND_FROM_DOMAIN = "example.com"; + mocks.send.mockResolvedValue({ data: { id: "email_1" }, error: null }); + + const result = await sendEmail(message); + expect(mocks.send.mock.calls[0]?.[0].from).toBe("auth@example.com"); + expect(result).toEqual({ data: { id: "email_1" }, error: null }); + }, + ); + + it("uses the Cap Cloud sender when explicitly enabled", async () => { + mocks.build.NEXT_PUBLIC_IS_CAP = "true"; mocks.env.RESEND_API_KEY = "re_test"; - mocks.env.RESEND_FROM_DOMAIN = "example.com"; - mocks.send.mockResolvedValue({ data: { id: "email_1" }, error: null }); + mocks.send.mockResolvedValue({ data: { id: "cloud_email" }, error: null }); - const result = await sendEmail(message); - expect(mocks.send.mock.calls[0]?.[0].from).toBe("auth@example.com"); - expect(result).toEqual({ data: { id: "email_1" }, error: null }); + expect(getEmailDeliveryStatus().configured).toBe(true); + await sendEmail(message); + + expect(mocks.send.mock.calls[0]?.[0].from).toBe( + "Cap Auth ", + ); }); + it.each([undefined, "false"])( + "skips marketing on self-hosts with NEXT_PUBLIC_IS_CAP=%j", + async (isCap) => { + mocks.build.NEXT_PUBLIC_IS_CAP = isCap; + mocks.env.RESEND_API_KEY = "re_test"; + mocks.env.RESEND_FROM_DOMAIN = "example.com"; + mocks.send.mockResolvedValue({ data: { id: "marketing" }, error: null }); + + await sendEmail({ ...message, marketing: true }); + + expect(mocks.send).not.toHaveBeenCalled(); + }, + ); + it("surfaces provider rejections instead of claiming success", async () => { mocks.env.RESEND_API_KEY = "re_test"; mocks.env.RESEND_FROM_DOMAIN = "example.com"; diff --git a/apps/web/__tests__/unit/mobile-email-session.test.ts b/apps/web/__tests__/unit/mobile-email-session.test.ts index c6855f180ca..9ed1bbad85e 100644 --- a/apps/web/__tests__/unit/mobile-email-session.test.ts +++ b/apps/web/__tests__/unit/mobile-email-session.test.ts @@ -255,25 +255,29 @@ describe("mobile email sessions", () => { const wrongCode = (code: string) => (code === "123456" ? "654321" : "123456"); - it("logs a usable code for a self-host with email disabled", async () => { - mocks.emailStatus = { - configured: false, - reason: "Email is not configured", - }; - vi.stubEnv("NODE_ENV", "production"); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const response = await post("request", { email: "person@example.com" }); - expect(response.status).toBe(200); - expect(mocks.sendEmail).not.toHaveBeenCalled(); - const codeLine = log.mock.calls.find(([line]) => - /^Code: \d{6}$/.test(line), - ); - expect(codeLine).toBeDefined(); - const code = String(codeLine?.[0]).slice("Code: ".length); - expect( - (await post("verify", { email: "person@example.com", code })).status, - ).toBe(200); - }); + it.each([undefined, "", "false"])( + "logs a usable self-host code with NEXT_PUBLIC_IS_CAP=%j and email disabled", + async (isCap) => { + mocks.isCap = isCap; + mocks.emailStatus = { + configured: false, + reason: "Email is not configured", + }; + vi.stubEnv("NODE_ENV", "production"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const response = await post("request", { email: "person@example.com" }); + expect(response.status).toBe(200); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + const codeLine = log.mock.calls.find(([line]) => + /^Code: \d{6}$/.test(line), + ); + expect(codeLine).toBeDefined(); + const code = String(codeLine?.[0]).slice("Code: ".length); + expect( + (await post("verify", { email: "person@example.com", code })).status, + ).toBe(200); + }, + ); it("fails on Cap Cloud without printing a code when email is disabled", async () => { mocks.emailStatus = { diff --git a/apps/web/app/api/mobile/[...route]/route.ts b/apps/web/app/api/mobile/[...route]/route.ts index a82816b396e..b1b3d256281 100644 --- a/apps/web/app/api/mobile/[...route]/route.ts +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -160,7 +160,10 @@ const hashEmailCode = (code: string) => const sendMobileEmailCode = async (email: string, code: string) => { const emailStatus = getEmailDeliveryStatus(); if (!emailStatus.configured) { - if (process.env.NODE_ENV === "production" && buildEnv.NEXT_PUBLIC_IS_CAP) { + if ( + process.env.NODE_ENV === "production" && + buildEnv.NEXT_PUBLIC_IS_CAP === "true" + ) { throw new Error(emailStatus.reason); } console.log(""); diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 119d6338d33..bd2637dc8fa 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -263,7 +263,7 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { if (!emailStatus.configured) { if ( process.env.NODE_ENV === "production" && - buildEnv.NEXT_PUBLIC_IS_CAP + buildEnv.NEXT_PUBLIC_IS_CAP === "true" ) { throw new Error(emailStatus.reason); } diff --git a/packages/database/emails/config.ts b/packages/database/emails/config.ts index 87ce2ea3572..e9069f0d676 100644 --- a/packages/database/emails/config.ts +++ b/packages/database/emails/config.ts @@ -19,7 +19,10 @@ export function getEmailDeliveryStatus(): EmailDeliveryStatus { reason: `${EMAIL_NOT_CONFIGURED_MESSAGE}: RESEND_API_KEY is not set`, }; } - if (!buildEnv.NEXT_PUBLIC_IS_CAP && !env.RESEND_FROM_DOMAIN?.trim()) { + if ( + buildEnv.NEXT_PUBLIC_IS_CAP !== "true" && + !env.RESEND_FROM_DOMAIN?.trim() + ) { return { configured: false, reason: `${EMAIL_NOT_CONFIGURED_MESSAGE}: RESEND_FROM_DOMAIN is not set`, @@ -85,12 +88,13 @@ export const sendEmail = async ({ }; } - if (marketing && !buildEnv.NEXT_PUBLIC_IS_CAP) return; + const isCap = buildEnv.NEXT_PUBLIC_IS_CAP === "true"; + if (marketing && !isCap) return; const from = fromOverride ? fromOverride : marketing ? "Richie from Cap " - : buildEnv.NEXT_PUBLIC_IS_CAP + : isCap ? "Cap Auth " : env.RESEND_FROM_DOMAIN?.trim() ? `auth@${env.RESEND_FROM_DOMAIN.trim()}`