diff --git a/apps/docs/guides/campaign-personalization.mdx b/apps/docs/guides/campaign-personalization.mdx index a7cd12e3..b5ab5f04 100644 --- a/apps/docs/guides/campaign-personalization.mdx +++ b/apps/docs/guides/campaign-personalization.mdx @@ -35,9 +35,17 @@ 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 +``` + 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 +160,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..afe959ed --- /dev/null +++ b/apps/web/src/lib/constants/campaign.ts @@ -0,0 +1,24 @@ +export const CAMPAIGN_UNSUBSCRIBE_VARIABLE = "usesend_unsubscribe_url"; +const LEGACY_CAMPAIGN_UNSUBSCRIBE_VARIABLE = "unsend_unsubscribe_url"; + +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 { + [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 new file mode 100644 index 00000000..b4f3d009 --- /dev/null +++ b/apps/web/src/lib/constants/campaign.unit.test.ts @@ -0,0 +1,89 @@ +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", () => { + 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", () => { + 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}}}`); + }); + + 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}}}`); + }); +}); diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 2d13382f..5a109993 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -30,6 +30,7 @@ import { replaceContactVariables, } from "../utils/contact-variable-replacement"; import { updateContactSubscription } from "./contact-service"; +import { getCampaignUnsubscribeVariableValues } from "~/lib/constants/campaign"; const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [ "{{unsend_unsubscribe_url}}", @@ -139,6 +140,7 @@ async function renderCampaignHtmlForContact({ }, {} as Record, ), + ...getCampaignUnsubscribeVariableValues(unsubscribeUrl), }); return renderer.render({