feat: retain mailbox tag filters on refresh and compose campaign wizard template subject and body updates against the latest draft

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Matthew Meszaros
2026-10-07 15:55:51 +00:00
co-authored by Devin AI
parent 88812a814b
commit 72b3620ea0
9 changed files with 226 additions and 8 deletions
+1 -1
View File
@@ -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.
+4
View File
@@ -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:
+3 -2
View File
@@ -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<string>("");
const [tag, setTag] = React.useState<string>("");
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<string[]>([]);
const [view, setView] = React.useState<string>("");
@@ -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<Draft>) => {
if (p.timezone !== undefined) tzTouched.current = true;
setDraft((d) => ({ ...d, ...p }));
const patch = React.useCallback<Patch>((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.
@@ -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<Draft>) => void;
export type Patch = (p: Partial<Draft> | ((draft: Draft) => Partial<Draft>)) => void;
export function StepIntro({ title, hint, children }: { title: string; hint?: string; children?: React.ReactNode }) {
return (
@@ -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<Draft["emails"][number]>) =>
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);
@@ -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: "<p>Template body</p>", 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 }) => <output aria-label="Email body">{html}</output>,
VariableMenu: () => null,
}));
vi.mock("./StepAttachments", () => ({ default: () => null }));
vi.mock("@/components/ui/scroll-strip", () => ({ default: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }));
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 <>
<EmailsStep draft={draft} patch={patch} selected={selected} setSelected={() => undefined} />
<output aria-label="New campaign subject">{draft.emails[selected].subject}</output>
<output aria-label="New campaign body">{draft.emails[selected].body_html}</output>
<output aria-label="New campaign emails">{JSON.stringify(draft.emails)}</output>
</>;
}
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(<NewCampaignEmails />);
await selectTemplate();
expect(screen.getByLabelText("New campaign subject")).toHaveTextContent("Hello {{.FirstName}}");
expect(screen.getByLabelText("New campaign body")).toHaveTextContent("<p>Template body</p>");
});
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: "<p>Original body</p>" };
render(<NewCampaignEmails initial={initial} />);
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("<p>Template body</p>");
});
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(<NewCampaignEmails initial={initial} selected={1} />);
await selectTemplate();
expect(screen.getByLabelText("New campaign subject")).toHaveTextContent("Hello {{.FirstName}}");
expect(screen.getByLabelText("New campaign body")).toHaveTextContent("<p>Template body</p>");
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(<SequenceView campaignId="campaign-id" sequence={step} index={0} />);
await selectTemplate();
expect(screen.getByPlaceholderText("Quick question, {{.FirstName}}")).toHaveValue("Hello {{.FirstName}}");
expect(screen.getByLabelText("Email body")).toHaveTextContent("<p>Template body</p>");
expect(f.confirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(f.save).toHaveBeenCalledWith({ subject: "Hello {{.FirstName}}", body_html: "<p>Template body</p>", body_plain: "Template body" }));
});
it("waits for confirmation before replacing an existing subject and body", async () => {
render(<SequenceView campaignId="campaign-id" sequence={{ ...step, subject: "Original subject", body_html: "<p>Original body</p>" }} index={0} />);
await selectTemplate();
expect(f.confirm).toHaveBeenCalledOnce();
expect(screen.getByPlaceholderText("Quick question, {{.FirstName}}")).toHaveValue("Original subject");
expect(screen.getByLabelText("Email body")).toHaveTextContent("<p>Original body</p>");
act(() => f.confirm.mock.calls[0][1]());
expect(screen.getByPlaceholderText("Quick question, {{.FirstName}}")).toHaveValue("Hello {{.FirstName}}");
expect(screen.getByLabelText("Email body")).toHaveTextContent("<p>Template body</p>");
});
it("keeps an existing subject when the selected template has none", async () => {
f.templates[0].subject = "";
render(<SequenceView campaignId="campaign-id" sequence={{ ...step, subject: "Keep this subject" }} index={0} />);
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("<p>Template body</p>");
});
it("keeps a follow-up's inherited conversation subject and only saves its body", async () => {
render(<SequenceView campaignId="campaign-id" sequence={step} index={1} conversationSubject="Conversation opener" />);
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: "<p>Template body</p>", body_plain: "Template body" }));
});
});
+60
View File
@@ -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 <>
<output aria-label="Tag filter">{tag || "All accounts"}</output>
<button onClick={() => setTag("sending-tag")}>Sending Account</button>
<button onClick={() => setTag("")}>All accounts</button>
<button onClick={() => setParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("mailbox");
next.delete("tab");
return next;
}, { replace: true })}>Consume mailbox link</button>
</>;
}
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(<RouterProvider router={router} />) };
}
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");
});
});
+13
View File
@@ -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];
}