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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CheckIcon, TriangleAlertIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker";
import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/hooks/use-trigger-survey-picker";
import {
deriveTriggerEndingProblems,
workflowAtom,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import {
WorkflowFieldError,
WorkflowFieldLabel,
} from "@/modules/ee/workflows/components/inspector/workflow-field";
import { useWorkflowNodeFieldFocus } from "@/modules/ee/workflows/hooks/use-workflow-node-field-focus";
import { reconcileEndingCardIds } from "@/modules/ee/workflows/lib/trigger-ending-cards";
import {
useWorkflowSurveyEndings,
useWorkflowSurveyOptions,
} from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker";
} from "@/modules/ee/workflows/hooks/use-trigger-survey-picker";
import { useWorkflowNodeFieldFocus } from "@/modules/ee/workflows/hooks/use-workflow-node-field-focus";
import { reconcileEndingCardIds } from "@/modules/ee/workflows/lib/trigger-ending-cards";
import {
hasBoundTriggerSurveyAtom,
prunedTriggerEndingCardIdsAtom,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { useSetAtom } from "jotai";
import { useEffect } from "react";
import type { TWorkflowDefinition } from "@formbricks/workflows";
import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/hooks/use-trigger-survey-picker";
import { reconcileDefinitionEndingCardIds } from "@/modules/ee/workflows/lib/trigger-ending-cards";
import { useWorkflowSurveyEndings } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker";
import {
prunedTriggerEndingCardIdsAtom,
setWorkflowDefinitionAtom,
Expand Down
30 changes: 0 additions & 30 deletions apps/web/modules/ee/workflows/list/hooks/use-workflows.ts

This file was deleted.

18 changes: 0 additions & 18 deletions apps/web/modules/ee/workflows/list/lib/query.ts

This file was deleted.

53 changes: 0 additions & 53 deletions apps/web/modules/ee/workflows/list/lib/v3-workflows-client.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import { WorkflowCanvas } from "@/modules/ee/workflows/components/canvas/workflo
import { WorkflowInspectorPanel } from "@/modules/ee/workflows/components/inspector/workflow-inspector-panel";
import { WorkflowEmailAuthoringProvider } from "@/modules/ee/workflows/components/workflow-email-authoring-context";
import { useReconcileTriggerEndingCards } from "@/modules/ee/workflows/hooks/use-reconcile-trigger-ending-cards";
import { useWorkflowSurveyOptions } from "@/modules/ee/workflows/hooks/use-trigger-survey-picker";
import { useWorkflowBuilder } from "@/modules/ee/workflows/hooks/use-workflow-builder";
import { useWorkflowNodeUrlSync } from "@/modules/ee/workflows/hooks/use-workflow-node-url-sync";
import { resolveBoundTriggerSurvey } from "@/modules/ee/workflows/lib/bound-survey";
import { useWorkflowSurveyOptions } from "@/modules/ee/workflows/list/hooks/use-trigger-survey-picker";
import { WorkflowBuilderBodyLoading } from "@/modules/ee/workflows/loading";
import { hasBoundTriggerSurveyAtom } from "@/modules/ee/workflows/state/editor";
import type { TWorkflowEmailAuthoringContext } from "@/modules/ee/workflows/types/email-authoring-context";
Expand Down
8 changes: 8 additions & 0 deletions apps/web/modules/email/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,13 @@ export const sendResponseFinishedEmail = async (
return element;
});

// The whitelabel logo is stored as a relative `/storage/...` path, which an email client cannot
// resolve — it has no origin to resolve against, so the `<img>` renders broken. Resolve it to an
// absolute URL here, exactly like every other email sender does.
const logoUrl = organization.whitelabel?.logoUrl
? resolveStorageUrl(organization.whitelabel.logoUrl)
: undefined;

const html = await renderResponseFinishedEmail({
survey,
responseCount,
Expand All @@ -340,6 +347,7 @@ export const sendResponseFinishedEmail = async (
workspaceId,
organization,
elements: elementsWithResolvedUrls,
logoUrl,
t,
...legalProps,
});
Expand Down
88 changes: 88 additions & 0 deletions apps/web/modules/email/response-finished-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { TResponse } from "@formbricks/types/responses";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { sendResponseFinishedEmail } from "./index";

const {
mockRenderResponseFinishedEmail,
mockGetOrganizationByWorkspaceId,
mockGetElementResponseMapping,
mockResolveStorageUrl,
mockGetTranslate,
mockCreateTransport,
} = vi.hoisted(() => ({
mockRenderResponseFinishedEmail: vi.fn(),
mockGetOrganizationByWorkspaceId: vi.fn(),
mockGetElementResponseMapping: vi.fn(),
mockResolveStorageUrl: vi.fn(),
mockGetTranslate: vi.fn(),
mockCreateTransport: vi.fn(() => ({ sendMail: vi.fn() })),
}));

vi.mock("@formbricks/email", () => ({
renderResponseFinishedEmail: mockRenderResponseFinishedEmail,
}));

vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: mockGetOrganizationByWorkspaceId,
}));

vi.mock("@/lib/responses", () => ({
getElementResponseMapping: mockGetElementResponseMapping,
}));

vi.mock("@/modules/storage/utils", () => ({
resolveStorageUrl: mockResolveStorageUrl,
}));

vi.mock("@/lingodotdev/server", () => ({
getTranslate: mockGetTranslate,
}));

// The real transport would try to reach the SMTP host from .env (localhost:1025, nothing listening).
// Stub it so `sendEmail`'s side effect stays a no-op; the assertions only care about the render call.
vi.mock("nodemailer", () => ({
createTransport: mockCreateTransport,
}));

const survey = { id: "survey1", name: "Survey", variables: [], hiddenFields: {} } as unknown as TSurvey;
const response = { id: "response1", data: {}, variables: {} } as unknown as TResponse;

describe("sendResponseFinishedEmail", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTranslate.mockResolvedValue((key: string) => key);
mockGetElementResponseMapping.mockReturnValue([]);
mockRenderResponseFinishedEmail.mockResolvedValue("<html>rendered</html>");
mockResolveStorageUrl.mockImplementation((url: string) => `https://cdn.example.com${url}`);
});

// This is the exact regression: the organization's whitelabel logo was fetched but never
// threaded into the template, so the notification email always fell back to the hard-coded
// Formbricks logo regardless of what the organization had configured.
test("resolves the organization's whitelabel logo to an absolute URL", async () => {
mockGetOrganizationByWorkspaceId.mockResolvedValue({
id: "org1",
whitelabel: { logoUrl: "/storage/wsp123/public/logo--fid--abc.png" },
});

await sendResponseFinishedEmail("owner@example.com", "en-US", "workspace1", survey, response, 1);

expect(mockResolveStorageUrl).toHaveBeenCalledWith("/storage/wsp123/public/logo--fid--abc.png");
expect(mockRenderResponseFinishedEmail.mock.calls[0][0].logoUrl).toBe(
"https://cdn.example.com/storage/wsp123/public/logo--fid--abc.png"
);
});

test.each([
["no whitelabel object", undefined],
["a whitelabel object with no logo", {}],
])("leaves the logo unset for %s, so the default Formbricks logo applies", async (_label, whitelabel) => {
mockGetOrganizationByWorkspaceId.mockResolvedValue({ id: "org1", whitelabel });

await sendResponseFinishedEmail("owner@example.com", "en-US", "workspace1", survey, response, 1);

expect(mockResolveStorageUrl).not.toHaveBeenCalled();
expect(mockRenderResponseFinishedEmail.mock.calls[0][0].logoUrl).toBeUndefined();
});
});
4 changes: 3 additions & 1 deletion packages/email/emails/survey/response-finished-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ResponseFinishedEmailProps extends TEmailTemplateLegalProps {
readonly workspaceId: string;
readonly organization: TOrganization;
readonly elements: ProcessedResponseElement[]; // Pre-processed data, not a function
readonly logoUrl?: string;
readonly t?: TFunction;
}

Expand All @@ -45,11 +46,12 @@ export function ResponseFinishedEmail({
workspaceId,
organization,
elements,
logoUrl,
t = mockT,
...legalProps
}: ResponseFinishedEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<Container>
<Row>
<Column>
Expand Down
27 changes: 27 additions & 0 deletions packages/email/src/lib/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,33 @@ describe("legal footer", () => {
});
});

describe("custom branding", () => {
test("response-finished notification falls back to the Formbricks logo when no organization logo is set", async () => {
const html = await renderResponseFinishedEmail({
...exampleData.responseFinishedEmail,
elements: responseFinishedElements,
t,
});

expect(html).toContain('data-testid="default-logo-image"');
expect(html).not.toContain('data-testid="logo-image"');
});

test("response-finished notification renders the organization's custom logo when set", async () => {
const customLogoUrl = "https://example.com/custom-logo.png";
const html = await renderResponseFinishedEmail({
...exampleData.responseFinishedEmail,
elements: responseFinishedElements,
logoUrl: customLogoUrl,
t,
});

expect(html).toContain('data-testid="logo-image"');
expect(html).toContain(customLogoUrl);
expect(html).not.toContain('data-testid="default-logo-image"');
});
});

describe("Tailwind render engine", () => {
// `@react-email/tailwind` — not anything configured in this package — decides which
// Tailwind version compiles the template classes. Pin that contract: these utilities
Expand Down
Loading