diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx index 7dff6280f..2a40b96b3 100644 --- a/docs/content/docs/guides/campaigns.mdx +++ b/docs/content/docs/guides/campaigns.mdx @@ -26,7 +26,7 @@ Campaign names take priority over descriptions in the list. Descriptions use the **Campaigns** > **New campaign** opens a four-step flow. A campaign is a first email and, optionally, follow-ups that stop for anyone who replies. 1. **Leads**: name the campaign at the top (left blank, it is named after its first list, or "Campaign" and the date; rename it any time), then pick one or more lead lists. Lists are your [segments](/guides/segments/), shown with how many leads each holds; someone on several lists is emailed once, and the total below counts each lead once. **Import new leads** takes you to Contacts to bring in a CSV and keeps the draft for when you return. Picking no list is fine: **Continue without leads** creates the campaign so you can add leads afterwards. -2. **Emails**: the same editor as the Steps tab, with templates, **Write with AI**, variables, a preview rendered through the send engine and the content score. Add follow-ups here, each sent a number of days after the previous email (and stopping for anyone who replies, when **Stop on reply** is on). **Add the usual two follow-ups** adds two at 3 and 7 days. A follow-up with no subject replies in the same thread. The emails can also be left for the Steps tab, where you can branch them and add A/B variants. +2. **Emails**: the same editor as the Steps tab, with templates, **Write with AI**, variables, a preview rendered through the send engine and the content score. Selecting a template applies both its saved subject and its body. A template without a subject keeps the email's existing subject. Add follow-ups here, each sent a number of days after the previous email (and stopping for anyone who replies, when **Stop on reply** is on). **Add the usual two follow-ups** adds two at 3 and 7 days. A follow-up with no subject replies in the same thread. The emails can also be left for the Steps tab, where you can branch them and add A/B variants. 3. **Schedule**: start at launch or on a date, the timezone, sending days and hours, the mailbox tags to send from, and the daily limit per mailbox. Every mailbox in the pool is listed with what it can send: **Ramping up** while it is still graduating from warmup, **Reduced volume** in a watch or throttled health band, **On hold**, **DNS failing** or **Resting** when it will not send. Tracking, UTM parameters, the unsubscribe header and stop on reply are under **Tracking and replies**. 4. **Review**: the finish date and a day-by-day chart of first emails and follow-ups against what the pool can send, what is setting the pace, and a summary with a link back to each step. diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 219fefc99..a602c02af 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -5,6 +5,10 @@ description: "Connect sender accounts, providers, and how they are assigned to w A mailbox is a sender account you connect to Warmbly. Every warmup message and campaign email goes out through one you own. +## Filtering accounts + +Use the **Tags** dropdown above the mailbox list to show accounts with a particular tag. The selection is kept in the page URL, so refreshing the page or opening a bookmark keeps the filter. Choose **All accounts** to clear it. A tag that no longer exists in the current workspace falls back to **All accounts**. + ## Connecting an account Open **Accounts** and choose **Add account**. Google and Microsoft each have one entry, and choosing it asks how to connect: diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 59f217fbf..ca09e7335 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -1,6 +1,6 @@ import { RiFireLine, RiMoreLine } from "@remixicon/react"; import React, { useEffect, useMemo, useRef } from "react"; -import { useSearchParams } from "@/hooks/useSearchParams"; +import { useSearchParam, useSearchParams } from "@/hooks/useSearchParams"; import toast from "react-hot-toast/headless"; import { useQueryClient } from "@tanstack/react-query"; import useEmails from "@/lib/api/hooks/app/emails/useEmails"; @@ -127,7 +127,8 @@ export default function AddressesPage() { const canView = usePermission("MANAGE_EMAILS"); const [query, setQuery] = React.useState(""); - const [tag, setTag] = React.useState(""); + const [selectedTag, setTag] = useSearchParam("tag"); + const tag = p.user.tags.some((t) => t.id === selectedTag) ? selectedTag : ""; const emailsData = useEmails({ query, tag }); const [selected, setSelected] = React.useState([]); const [view, setView] = React.useState(""); diff --git a/web/src/components/app/campaigns/NewCampaignDialog.tsx b/web/src/components/app/campaigns/NewCampaignDialog.tsx index 88884b033..ce447f123 100644 --- a/web/src/components/app/campaigns/NewCampaignDialog.tsx +++ b/web/src/components/app/campaigns/NewCampaignDialog.tsx @@ -53,6 +53,7 @@ import { } from "./new/draft"; import { draftSignature, freshMeta, loadServerDraft, saveServerDraft, type DraftMeta } from "./new/serverDraft"; import { EmailsStep, LeadsStep, LaunchPlanRail, ReviewStep, ScheduleStep, type EstimateState } from "./new/steps"; +import type { Patch } from "./new/fields"; interface Props { open: boolean; @@ -146,9 +147,12 @@ export function NewCampaignDialog({ open, onClose, draftId = null }: Props) { } }, [open, draftId, defaultTimezone]); - const patch = React.useCallback((p: Partial) => { - if (p.timezone !== undefined) tzTouched.current = true; - setDraft((d) => ({ ...d, ...p })); + const patch = React.useCallback((p) => { + setDraft((d) => { + const update = typeof p === "function" ? p(d) : p; + if (update.timezone !== undefined) tzTouched.current = true; + return { ...d, ...update }; + }); }, []); // Names for the auto name and the review. diff --git a/web/src/components/app/campaigns/new/fields.tsx b/web/src/components/app/campaigns/new/fields.tsx index f8e8ebbe8..594f80dae 100644 --- a/web/src/components/app/campaigns/new/fields.tsx +++ b/web/src/components/app/campaigns/new/fields.tsx @@ -13,7 +13,7 @@ import { followWorkspaceLabel, timezoneOptions } from "@/lib/timezone"; import { cn } from "@/lib/utils"; import { EVERY_DAY_MASK, WEEKDAYS, WEEKDAYS_MASK, daysLabel, fmt12, type Draft } from "./draft"; -export type Patch = (p: Partial) => void; +export type Patch = (p: Partial | ((draft: Draft) => Partial)) => void; export function StepIntro({ title, hint, children }: { title: string; hint?: string; children?: React.ReactNode }) { return ( diff --git a/web/src/components/app/campaigns/new/steps.tsx b/web/src/components/app/campaigns/new/steps.tsx index ad2e78908..d49d08549 100644 --- a/web/src/components/app/campaigns/new/steps.tsx +++ b/web/src/components/app/campaigns/new/steps.tsx @@ -287,7 +287,7 @@ function EmailsEditor({ const index = Math.min(selected, draft.emails.length - 1); const email = draft.emails[index]; const update = (i: number, p: Partial) => - patch({ emails: draft.emails.map((e, idx) => (idx === i ? { ...e, ...p } : e)) }); + patch((d) => ({ emails: d.emails.map((e, idx) => (idx === i ? { ...e, ...p } : e)) })); const add = (wait: number) => { patch({ emails: [...draft.emails, newEmail(wait)] }); setSelected(draft.emails.length); diff --git a/web/src/components/app/campaigns/sequences/templateApplication.test.tsx b/web/src/components/app/campaigns/sequences/templateApplication.test.tsx new file mode 100644 index 000000000..7848c53ad --- /dev/null +++ b/web/src/components/app/campaigns/sequences/templateApplication.test.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; +import SequenceView from "./SequenceView"; +import { EmailsStep } from "../new/steps"; +import { initialDraft, type Draft } from "../new/draft"; +import type { Patch } from "../new/fields"; + +const f = vi.hoisted(() => ({ + save: vi.fn(), + confirm: vi.fn(), + templates: [{ id: "template-id", name: "Cold open", subject: "Hello {{.FirstName}}", body_html: "

Template body

", body_plain: "Template body" }], +})); + +vi.mock("@/lib/api/hooks/app/templates/useTemplates", () => ({ default: () => ({ data: f.templates }) })); +vi.mock("@/lib/api/hooks/app/templates/useCreateTemplate", () => ({ default: () => ({ isPending: false }) })); +vi.mock("@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence", () => ({ default: () => ({ mutateAsync: f.save }) })); +vi.mock("@/lib/api/hooks/app/campaigns/useCampaign", () => ({ default: () => ({ data: undefined }) })); +vi.mock("@/lib/api/hooks/app/campaigns/useTemplatePreview", () => ({ useTemplatePreview: () => ({ mutateAsync: vi.fn() }) })); +vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => ({ show: f.confirm }) })); +vi.mock("@/components/app/flow/dirty", () => ({ useReportDirty: () => undefined })); +vi.mock("./previewContext", () => ({ + useCampaignSenderInboxes: () => ({ inboxes: [] }), + SAMPLE_CONTACT_LABEL: "Sample contact", + contactLabel: () => "Contact", +})); +vi.mock("./RichTextEditor", () => ({ + default: ({ html }: { html: string }) => {html}, + VariableMenu: () => null, +})); +vi.mock("./StepAttachments", () => ({ default: () => null })); +vi.mock("@/components/ui/scroll-strip", () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
})); +vi.mock("../ContentScore", () => ({ default: () => null })); +vi.mock("./PreviewControls", () => ({ PlacementTestButton: () => null, PreviewContactPicker: () => null, PreviewMailboxPicker: () => null, SendTestButton: () => null })); + +const step: Sequence = { + id: "step-id", name: "Opening email", subject: "", body_html: "", body_plain: "", body_code: false, + body_sync: true, thread_reply: true, kind: "email", wait_after: 0, x: 0, y: 0, + created_at: new Date(0), updated_at: new Date(0), +}; + +async function selectTemplate() { + fireEvent.click(screen.getByRole("button", { name: "Templates" })); + fireEvent.click(await screen.findByRole("button", { name: /Cold open/ })); +} + +function NewCampaignEmails({ initial, selected = 0 }: { initial?: Draft; selected?: number }) { + const [draft, setDraft] = React.useState(() => initial ?? initialDraft("UTC")); + const patch: Patch = (p) => setDraft((d) => ({ ...d, ...(typeof p === "function" ? p(d) : p) })); + return <> + undefined} /> + {draft.emails[selected].subject} + {draft.emails[selected].body_html} + {JSON.stringify(draft.emails)} + ; +} + +describe("campaign template application", () => { + beforeEach(() => { + f.save.mockReset().mockResolvedValue(undefined); + f.confirm.mockReset(); + f.templates[0].subject = "Hello {{.FirstName}}"; + }); + + it("retains both template fields in the new campaign wizard's draft", async () => { + render(); + await selectTemplate(); + expect(screen.getByLabelText("New campaign subject")).toHaveTextContent("Hello {{.FirstName}}"); + expect(screen.getByLabelText("New campaign body")).toHaveTextContent("

Template body

"); + }); + + it("replaces both wizard fields after confirmation rather than restoring the old subject", async () => { + const initial = initialDraft("UTC"); + initial.emails[0] = { ...initial.emails[0], subject: "Original subject", body_html: "

Original body

" }; + render(); + await selectTemplate(); + expect(screen.getByLabelText("New campaign subject")).toHaveTextContent("Original subject"); + act(() => f.confirm.mock.calls[0][1]()); + expect(screen.getByLabelText("New campaign subject")).toHaveTextContent("Hello {{.FirstName}}"); + expect(screen.getByLabelText("New campaign body")).toHaveTextContent("

Template body

"); + }); + + it("applies the template only to the selected wizard email, preserving the other draft emails", async () => { + const initial = initialDraft("UTC"); + initial.emails[0].subject = "Keep opener"; + initial.emails.push({ ...initial.emails[0], id: "follow-up", subject: "", wait_after: 3 }); + render(); + await selectTemplate(); + expect(screen.getByLabelText("New campaign subject")).toHaveTextContent("Hello {{.FirstName}}"); + expect(screen.getByLabelText("New campaign body")).toHaveTextContent("

Template body

"); + const emails = JSON.parse(screen.getByLabelText("New campaign emails").textContent!); + expect(emails[0]).toEqual(initial.emails[0]); + expect(emails[1].wait_after).toBe(3); + }); + + it("copies and saves the subject and body together on a new campaign opening step", async () => { + render(); + await selectTemplate(); + expect(screen.getByPlaceholderText("Quick question, {{.FirstName}}")).toHaveValue("Hello {{.FirstName}}"); + expect(screen.getByLabelText("Email body")).toHaveTextContent("

Template body

"); + expect(f.confirm).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(f.save).toHaveBeenCalledWith({ subject: "Hello {{.FirstName}}", body_html: "

Template body

", body_plain: "Template body" })); + }); + + it("waits for confirmation before replacing an existing subject and body", async () => { + render(Original body

" }} index={0} />); + await selectTemplate(); + expect(f.confirm).toHaveBeenCalledOnce(); + expect(screen.getByPlaceholderText("Quick question, {{.FirstName}}")).toHaveValue("Original subject"); + expect(screen.getByLabelText("Email body")).toHaveTextContent("

Original body

"); + act(() => f.confirm.mock.calls[0][1]()); + expect(screen.getByPlaceholderText("Quick question, {{.FirstName}}")).toHaveValue("Hello {{.FirstName}}"); + expect(screen.getByLabelText("Email body")).toHaveTextContent("

Template body

"); + }); + + it("keeps an existing subject when the selected template has none", async () => { + f.templates[0].subject = ""; + render(); + await selectTemplate(); + act(() => f.confirm.mock.calls[0][1]()); + expect(screen.getByPlaceholderText("Quick question, {{.FirstName}}")).toHaveValue("Keep this subject"); + expect(screen.getByLabelText("Email body")).toHaveTextContent("

Template body

"); + }); + + it("keeps a follow-up's inherited conversation subject and only saves its body", async () => { + render(); + await selectTemplate(); + act(() => f.confirm.mock.calls[0][1]()); + expect(screen.queryByPlaceholderText("Quick question, {{.FirstName}}")).not.toBeInTheDocument(); + expect(screen.getByText("Conversation opener")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(f.save).toHaveBeenCalledWith({ body_html: "

Template body

", body_plain: "Template body" })); + }); +}); diff --git a/web/src/hooks/useSearchParams.test.tsx b/web/src/hooks/useSearchParams.test.tsx new file mode 100644 index 000000000..9abbbaab4 --- /dev/null +++ b/web/src/hooks/useSearchParams.test.tsx @@ -0,0 +1,60 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createMemoryHistory, createRootRoute, createRoute, createRouter, Outlet, RouterProvider } from "@tanstack/react-router"; +import { describe, expect, it } from "vitest"; +import { parseSearch, stringifySearch } from "@/lib/routerSearch"; +import { useSearchParam, useSearchParams } from "./useSearchParams"; + +function Filters() { + const [tag, setTag] = useSearchParam("tag"); + const [, setParams] = useSearchParams(); + return <> + {tag || "All accounts"} + + + + ; +} + +async function open(href: string) { + const root = createRootRoute({ component: Outlet }); + const route = createRoute({ getParentRoute: () => root, path: "/app/emails", component: Filters }); + const history = createMemoryHistory({ initialEntries: [href] }); + const router = createRouter({ routeTree: root.addChildren([route]), history, parseSearch, stringifySearch }); + await act(() => router.load()); + return { router, ...render() }; +} + +describe("URL filter selection", () => { + it("keeps a selection through a reload without discarding mailbox links", async () => { + const page = await open("/app/emails?mailbox=mailbox-id&tab=settings"); + expect(screen.getByLabelText("Tag filter")).toHaveTextContent("All accounts"); + fireEvent.click(screen.getByRole("button", { name: "Sending Account" })); + await waitFor(() => expect(page.router.state.location.searchStr).toContain("tag=sending-tag")); + const href = page.router.state.location.href; + expect(href).toContain("mailbox=mailbox-id"); + expect(href).toContain("tab=settings"); + page.unmount(); + + const reloaded = await open(href); + expect(screen.getByLabelText("Tag filter")).toHaveTextContent("sending-tag"); + fireEvent.click(screen.getByRole("button", { name: "Consume mailbox link" })); + await waitFor(() => expect(reloaded.router.state.location.searchStr).toBe("?tag=sending-tag")); + expect(screen.getByLabelText("Tag filter")).toHaveTextContent("sending-tag"); + }); + + it("clears only the chosen filter and preserves unrelated search parameters", async () => { + const page = await open("/app/emails?tag=sending-tag&mailbox=mailbox-id"); + fireEvent.click(screen.getByRole("button", { name: "All accounts" })); + await waitFor(() => expect(page.router.state.location.searchStr).toBe("?mailbox=mailbox-id")); + expect(screen.getByLabelText("Tag filter")).toHaveTextContent("All accounts"); + const href = page.router.state.location.href; + page.unmount(); + await open(href); + expect(screen.getByLabelText("Tag filter")).toHaveTextContent("All accounts"); + }); +}); diff --git a/web/src/hooks/useSearchParams.ts b/web/src/hooks/useSearchParams.ts index 254d08312..004c1b49c 100644 --- a/web/src/hooks/useSearchParams.ts +++ b/web/src/hooks/useSearchParams.ts @@ -33,3 +33,16 @@ export function useSearchParams(): [ return [params, setParams]; } + +export function useSearchParam(key: string): [string, (value: string) => void] { + const [params, setParams] = useSearchParams(); + const setValue = React.useCallback((value: string) => { + setParams((prev) => { + const next = new URLSearchParams(prev); + if (value) next.set(key, value); + else next.delete(key); + return next; + }, { replace: true }); + }, [key, setParams]); + return [params.get(key) ?? "", setValue]; +}