diff --git a/apps/web/__tests__/unit/auth-options.test.ts b/apps/web/__tests__/unit/auth-options.test.ts index 3b2ccf11cca..01c5a76efc4 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([undefined, "", "false", "true"])( + "only logs undelivered codes for self-hosts (NEXT_PUBLIC_IS_CAP: %j)", + async (isCap) => { + mocks.isCap = isCap; + 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 === "true") { + 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 new file mode 100644 index 00000000000..cd8f6830f59 --- /dev/null +++ b/apps/web/__tests__/unit/email-config.test.ts @@ -0,0 +1,123 @@ +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.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"; + 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.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.send.mockResolvedValue({ data: { id: "cloud_email" }, 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"; + 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/__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/__tests__/unit/mobile-email-session.test.ts b/apps/web/__tests__/unit/mobile-email-session.test.ts index 8d103a4fb70..9ed1bbad85e 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,46 @@ describe("mobile email sessions", () => { const wrongCode = (code: string) => (code === "123456" ? "654321" : "123456"); + 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 = { + 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..ad60b129bdb --- /dev/null +++ b/apps/web/__tests__/unit/organization-invite-delivery.test.ts @@ -0,0 +1,102 @@ +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", () => ({ + nanoIdLength: 15, + 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 ad3642d4e3b..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) => { @@ -184,10 +188,22 @@ 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); + 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 { @@ -213,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..fbbfa50538d 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(", ")}` + ? `Invite email 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 ca8a88d9b68..e782d504a1d 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( @@ -133,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()); @@ -171,9 +177,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 3ebe462020b..b1b3d256281 100644 --- a/apps/web/app/api/mobile/[...route]/route.ts +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -2,12 +2,12 @@ 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"; 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, @@ -158,12 +158,17 @@ 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) { + if ( + process.env.NODE_ENV === "production" && + buildEnv.NEXT_PUBLIC_IS_CAP === "true" + ) { + throw new Error(emailStatus.reason); } 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"); @@ -171,11 +176,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 70cbf08f52d..7ebcf342567 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -4922,8 +4922,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 32e16f0f975..12090c3b42e 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/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; }; diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 0446c990f05..bd2637dc8fa 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"; @@ -12,7 +12,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 { @@ -259,12 +259,20 @@ 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) { + if ( + process.env.NODE_ENV === "production" && + buildEnv.NEXT_PUBLIC_IS_CAP === "true" + ) { + throw new Error(emailStatus.reason); + } console.log("\n"); console.log( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", ); - console.log("🔐 VERIFICATION CODE (Development Mode)"); + console.log("🔐 VERIFICATION CODE (email delivery disabled)"); + console.log(`â„šī¸ ${emailStatus.reason}`); console.log( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", ); @@ -280,11 +288,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..e9069f0d676 100644 --- a/packages/database/emails/config.ts +++ b/packages/database/emails/config.ts @@ -5,6 +5,49 @@ 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 !== "true" && + !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 +76,39 @@ 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 isCap = buildEnv.NEXT_PUBLIC_IS_CAP === "true"; + if (marketing && !isCap) return; + const from = fromOverride + ? fromOverride + : marketing + ? "Richie from Cap " + : isCap + ? "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 +121,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, });