From e354f85cda5e04f84b5cb1814a4fb44c8d38fab2 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Tue, 21 Jul 2026 07:25:32 +1000 Subject: [PATCH 1/3] fix: expose campaign unsubscribe variable --- apps/docs/guides/campaign-personalization.mdx | 13 ++++- .../campaigns/[campaignId]/edit/page.tsx | 11 ++-- apps/web/src/lib/constants/campaign.ts | 34 +++++++++++ .../src/lib/constants/campaign.unit.test.ts | 57 +++++++++++++++++++ .../src/server/service/campaign-service.ts | 14 ++--- 5 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/lib/constants/campaign.ts create mode 100644 apps/web/src/lib/constants/campaign.unit.test.ts diff --git a/apps/docs/guides/campaign-personalization.mdx b/apps/docs/guides/campaign-personalization.mdx index a7cd12e3..30265cba 100644 --- a/apps/docs/guides/campaign-personalization.mdx +++ b/apps/docs/guides/campaign-personalization.mdx @@ -35,9 +35,20 @@ These built-in variables are always available in campaigns: - `{{email}}` - `{{firstName}}` - `{{lastName}}` +- `{{usesend_unsubscribe_url}}` Custom variables come from the contact book's variable list. +Every campaign must include an unsubscribe link. Use the recipient-specific +`{{usesend_unsubscribe_url}}` variable as the link destination: + +```html +Unsubscribe +``` + +The legacy `{{unsend_unsubscribe_url}}` variable remains supported for existing +campaigns. Use `{{usesend_unsubscribe_url}}` for new campaigns. + Custom variables must be added to the contact book before they can be used in a campaign. Variable names can only contain letters, numbers, and underscores. @@ -152,7 +163,7 @@ curl -X POST https://app.usesend.com/api/v1/campaigns \ "from": "Acme ", "subject": "Welcome to {{company}}", "contactBookId": "{contactBookId}", - "html": "

Hi {{firstName,fallback=there}}, your plan is {{plan}}.

" + "html": "

Hi {{firstName,fallback=there}}, your plan is {{plan}}.

Unsubscribe

" }' ``` diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx index 2cef3002..ebbaa5d3 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx @@ -43,6 +43,7 @@ import { } from "@usesend/ui/src/accordion"; import ScheduleCampaign from "../../schedule-campaign"; import { useRouter } from "next/navigation"; +import { getCampaignEditorVariables } from "~/lib/constants/campaign"; const sendSchema = z.object({ confirmation: z.string(), @@ -167,12 +168,10 @@ function CampaignEditor({ const contactBook = contactBooksQuery.data?.find( (book) => book.id === contactBookId, ); - const editorVariables = useMemo(() => { - const baseVariables = ["email", "firstName", "lastName"]; - const registryVariables = contactBook?.variables ?? []; - - return Array.from(new Set([...baseVariables, ...registryVariables])); - }, [contactBook]); + const editorVariables = useMemo( + () => getCampaignEditorVariables(contactBook?.variables), + [contactBook], + ); const variableSuggestionsHelperText = contactBookId ? undefined : "Select the contact book for related variable"; diff --git a/apps/web/src/lib/constants/campaign.ts b/apps/web/src/lib/constants/campaign.ts new file mode 100644 index 00000000..f8707dbc --- /dev/null +++ b/apps/web/src/lib/constants/campaign.ts @@ -0,0 +1,34 @@ +export const CAMPAIGN_UNSUBSCRIBE_VARIABLE = "usesend_unsubscribe_url"; +export const LEGACY_CAMPAIGN_UNSUBSCRIBE_VARIABLE = "unsend_unsubscribe_url"; + +export const CAMPAIGN_UNSUBSCRIBE_VARIABLES = [ + CAMPAIGN_UNSUBSCRIBE_VARIABLE, + LEGACY_CAMPAIGN_UNSUBSCRIBE_VARIABLE, +] as const; + +export const CAMPAIGN_UNSUBSCRIBE_PLACEHOLDER_TOKENS = + CAMPAIGN_UNSUBSCRIBE_VARIABLES.map((variable) => `{{${variable}}}`); + +const CAMPAIGN_EDITOR_BASE_VARIABLES = [ + "email", + "firstName", + "lastName", + CAMPAIGN_UNSUBSCRIBE_VARIABLE, +]; + +export function getCampaignEditorVariables( + contactBookVariables: string[] = [], +) { + return Array.from( + new Set([...CAMPAIGN_EDITOR_BASE_VARIABLES, ...contactBookVariables]), + ); +} + +export function getCampaignUnsubscribeVariableValues(unsubscribeUrl: string) { + return Object.fromEntries( + CAMPAIGN_UNSUBSCRIBE_VARIABLES.map((variable) => [ + variable, + unsubscribeUrl, + ]), + ); +} diff --git a/apps/web/src/lib/constants/campaign.unit.test.ts b/apps/web/src/lib/constants/campaign.unit.test.ts new file mode 100644 index 00000000..6a690824 --- /dev/null +++ b/apps/web/src/lib/constants/campaign.unit.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { EmailRenderer } from "@usesend/email-editor/src/renderer"; +import { + CAMPAIGN_UNSUBSCRIBE_VARIABLE, + getCampaignEditorVariables, + getCampaignUnsubscribeVariableValues, +} from "~/lib/constants/campaign"; + +describe("campaign editor variables", () => { + it("includes the canonical unsubscribe variable", () => { + expect(getCampaignEditorVariables()).toContain( + CAMPAIGN_UNSUBSCRIBE_VARIABLE, + ); + }); + + it("combines built-in and contact book variables without duplicates", () => { + expect(getCampaignEditorVariables(["company", "email", "company"])).toEqual( + [ + "email", + "firstName", + "lastName", + CAMPAIGN_UNSUBSCRIBE_VARIABLE, + "company", + ], + ); + }); + + it("renders an autocomplete unsubscribe variable with the recipient URL", async () => { + const renderer = new EmailRenderer({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "variable", + attrs: { + id: CAMPAIGN_UNSUBSCRIBE_VARIABLE, + name: CAMPAIGN_UNSUBSCRIBE_VARIABLE, + fallback: "", + }, + }, + ], + }, + ], + }); + const unsubscribeUrl = "https://example.com/unsubscribe/recipient"; + + const html = await renderer.render({ + shouldReplaceVariableValues: true, + variableValues: getCampaignUnsubscribeVariableValues(unsubscribeUrl), + }); + + expect(html).toContain(unsubscribeUrl); + expect(html).not.toContain(`{{${CAMPAIGN_UNSUBSCRIBE_VARIABLE}}}`); + }); +}); diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 2d13382f..75c177c1 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -30,14 +30,13 @@ import { replaceContactVariables, } from "../utils/contact-variable-replacement"; import { updateContactSubscription } from "./contact-service"; - -const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [ - "{{unsend_unsubscribe_url}}", - "{{usesend_unsubscribe_url}}", -] as const; +import { + CAMPAIGN_UNSUBSCRIBE_PLACEHOLDER_TOKENS, + getCampaignUnsubscribeVariableValues, +} from "~/lib/constants/campaign"; const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES = - CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.map((placeholder) => { + CAMPAIGN_UNSUBSCRIBE_PLACEHOLDER_TOKENS.map((placeholder) => { const inner = placeholder.replace(/[{}]/g, "").trim(); return new RegExp(`\\{\\{\\s*${inner}\\s*\\}}`, "i"); }); @@ -115,7 +114,7 @@ async function renderCampaignHtmlForContact({ const renderer = new EmailRenderer(jsonContent); const linkValues: Record = {}; - for (const token of CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS) { + for (const token of CAMPAIGN_UNSUBSCRIBE_PLACEHOLDER_TOKENS) { linkValues[token] = unsubscribeUrl; } @@ -139,6 +138,7 @@ async function renderCampaignHtmlForContact({ }, {} as Record, ), + ...getCampaignUnsubscribeVariableValues(unsubscribeUrl), }); return renderer.render({ From ac604097c501cdba8905c0f6b9e1b0b5e19f9584 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Tue, 21 Jul 2026 07:53:13 +1000 Subject: [PATCH 2/3] fix: keep unsubscribe variable canonical --- apps/docs/guides/campaign-personalization.mdx | 3 --- apps/web/src/lib/constants/campaign.ts | 16 +--------------- apps/web/src/server/service/campaign-service.ts | 14 ++++++++------ 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/apps/docs/guides/campaign-personalization.mdx b/apps/docs/guides/campaign-personalization.mdx index 30265cba..b5ab5f04 100644 --- a/apps/docs/guides/campaign-personalization.mdx +++ b/apps/docs/guides/campaign-personalization.mdx @@ -46,9 +46,6 @@ Every campaign must include an unsubscribe link. Use the recipient-specific Unsubscribe ``` -The legacy `{{unsend_unsubscribe_url}}` variable remains supported for existing -campaigns. Use `{{usesend_unsubscribe_url}}` for new campaigns. - Custom variables must be added to the contact book before they can be used in a campaign. Variable names can only contain letters, numbers, and underscores. diff --git a/apps/web/src/lib/constants/campaign.ts b/apps/web/src/lib/constants/campaign.ts index f8707dbc..bc0fa5cf 100644 --- a/apps/web/src/lib/constants/campaign.ts +++ b/apps/web/src/lib/constants/campaign.ts @@ -1,13 +1,4 @@ export const CAMPAIGN_UNSUBSCRIBE_VARIABLE = "usesend_unsubscribe_url"; -export const LEGACY_CAMPAIGN_UNSUBSCRIBE_VARIABLE = "unsend_unsubscribe_url"; - -export const CAMPAIGN_UNSUBSCRIBE_VARIABLES = [ - CAMPAIGN_UNSUBSCRIBE_VARIABLE, - LEGACY_CAMPAIGN_UNSUBSCRIBE_VARIABLE, -] as const; - -export const CAMPAIGN_UNSUBSCRIBE_PLACEHOLDER_TOKENS = - CAMPAIGN_UNSUBSCRIBE_VARIABLES.map((variable) => `{{${variable}}}`); const CAMPAIGN_EDITOR_BASE_VARIABLES = [ "email", @@ -25,10 +16,5 @@ export function getCampaignEditorVariables( } export function getCampaignUnsubscribeVariableValues(unsubscribeUrl: string) { - return Object.fromEntries( - CAMPAIGN_UNSUBSCRIBE_VARIABLES.map((variable) => [ - variable, - unsubscribeUrl, - ]), - ); + return { [CAMPAIGN_UNSUBSCRIBE_VARIABLE]: unsubscribeUrl }; } diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 75c177c1..5a109993 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -30,13 +30,15 @@ import { replaceContactVariables, } from "../utils/contact-variable-replacement"; import { updateContactSubscription } from "./contact-service"; -import { - CAMPAIGN_UNSUBSCRIBE_PLACEHOLDER_TOKENS, - getCampaignUnsubscribeVariableValues, -} from "~/lib/constants/campaign"; +import { getCampaignUnsubscribeVariableValues } from "~/lib/constants/campaign"; + +const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [ + "{{unsend_unsubscribe_url}}", + "{{usesend_unsubscribe_url}}", +] as const; const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES = - CAMPAIGN_UNSUBSCRIBE_PLACEHOLDER_TOKENS.map((placeholder) => { + CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.map((placeholder) => { const inner = placeholder.replace(/[{}]/g, "").trim(); return new RegExp(`\\{\\{\\s*${inner}\\s*\\}}`, "i"); }); @@ -114,7 +116,7 @@ async function renderCampaignHtmlForContact({ const renderer = new EmailRenderer(jsonContent); const linkValues: Record = {}; - for (const token of CAMPAIGN_UNSUBSCRIBE_PLACEHOLDER_TOKENS) { + for (const token of CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS) { linkValues[token] = unsubscribeUrl; } From 8eadf672bd9b69ee2f52293cd66ca466d46db9f2 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Tue, 21 Jul 2026 09:23:32 +1000 Subject: [PATCH 3/3] fix: preserve legacy unsubscribe rendering --- apps/web/src/lib/constants/campaign.ts | 6 ++- .../src/lib/constants/campaign.unit.test.ts | 38 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/constants/campaign.ts b/apps/web/src/lib/constants/campaign.ts index bc0fa5cf..afe959ed 100644 --- a/apps/web/src/lib/constants/campaign.ts +++ b/apps/web/src/lib/constants/campaign.ts @@ -1,4 +1,5 @@ export const CAMPAIGN_UNSUBSCRIBE_VARIABLE = "usesend_unsubscribe_url"; +const LEGACY_CAMPAIGN_UNSUBSCRIBE_VARIABLE = "unsend_unsubscribe_url"; const CAMPAIGN_EDITOR_BASE_VARIABLES = [ "email", @@ -16,5 +17,8 @@ export function getCampaignEditorVariables( } export function getCampaignUnsubscribeVariableValues(unsubscribeUrl: string) { - return { [CAMPAIGN_UNSUBSCRIBE_VARIABLE]: unsubscribeUrl }; + return { + [CAMPAIGN_UNSUBSCRIBE_VARIABLE]: unsubscribeUrl, + [LEGACY_CAMPAIGN_UNSUBSCRIBE_VARIABLE]: unsubscribeUrl, + }; } diff --git a/apps/web/src/lib/constants/campaign.unit.test.ts b/apps/web/src/lib/constants/campaign.unit.test.ts index 6a690824..b4f3d009 100644 --- a/apps/web/src/lib/constants/campaign.unit.test.ts +++ b/apps/web/src/lib/constants/campaign.unit.test.ts @@ -8,9 +8,10 @@ import { describe("campaign editor variables", () => { it("includes the canonical unsubscribe variable", () => { - expect(getCampaignEditorVariables()).toContain( - CAMPAIGN_UNSUBSCRIBE_VARIABLE, - ); + const variables = getCampaignEditorVariables(); + + expect(variables).toContain(CAMPAIGN_UNSUBSCRIBE_VARIABLE); + expect(variables).not.toContain("unsend_unsubscribe_url"); }); it("combines built-in and contact book variables without duplicates", () => { @@ -54,4 +55,35 @@ describe("campaign editor variables", () => { expect(html).toContain(unsubscribeUrl); expect(html).not.toContain(`{{${CAMPAIGN_UNSUBSCRIBE_VARIABLE}}}`); }); + + it("renders a legacy structured unsubscribe variable with the recipient URL", async () => { + const legacyVariable = "unsend_unsubscribe_url"; + const renderer = new EmailRenderer({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "variable", + attrs: { + id: legacyVariable, + name: legacyVariable, + fallback: "", + }, + }, + ], + }, + ], + }); + const unsubscribeUrl = "https://example.com/unsubscribe/recipient"; + + const html = await renderer.render({ + shouldReplaceVariableValues: true, + variableValues: getCampaignUnsubscribeVariableValues(unsubscribeUrl), + }); + + expect(html).toContain(unsubscribeUrl); + expect(html).not.toContain(`{{${legacyVariable}}}`); + }); });