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: "