Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/docs/guides/campaign-personalization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<a href="{{usesend_unsubscribe_url}}">Unsubscribe</a>
```

<Warning>
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.
Expand Down Expand Up @@ -152,7 +160,7 @@ curl -X POST https://app.usesend.com/api/v1/campaigns \
"from": "Acme <hello@acme.com>",
"subject": "Welcome to {{company}}",
"contactBookId": "{contactBookId}",
"html": "<p>Hi {{firstName,fallback=there}}, your plan is {{plan}}.</p>"
"html": "<p>Hi {{firstName,fallback=there}}, your plan is {{plan}}.</p><p><a href=\"{{usesend_unsubscribe_url}}\">Unsubscribe</a></p>"
}'
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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";
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/lib/constants/campaign.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Comment on lines +19 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Complete legacy unsubscribe compatibility in structured rendering.

The shared variable-value contract exposes only usesend_unsubscribe_url, while the service claims to preserve unsend_unsubscribe_url. Return both aliases from the helper; keep only the canonical name in editor autocomplete.

  • apps/web/src/lib/constants/campaign.ts#L18-L20: return both unsubscribe variable keys mapped to unsubscribeUrl.
  • apps/web/src/server/service/campaign-service.ts#L119-L143: continue spreading the helper result into variableValues and add a regression test for legacy variables in non-link structured content.
📍 Affects 2 files
  • apps/web/src/lib/constants/campaign.ts#L18-L20 (this comment)
  • apps/web/src/server/service/campaign-service.ts#L119-L143
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/constants/campaign.ts` around lines 18 - 20, The helper
getCampaignUnsubscribeVariableValues must return both canonical and legacy
unsubscribe variable keys, each mapped to unsubscribeUrl, while keeping only the
canonical key in editor autocomplete. In apps/web/src/lib/constants/campaign.ts
lines 18-20, update the helper accordingly; in
apps/web/src/server/service/campaign-service.ts lines 119-143, continue
spreading its result into variableValues and add a regression test covering the
legacy variable in non-link structured content.

89 changes: 89 additions & 0 deletions apps/web/src/lib/constants/campaign.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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}}}`);
});
});
2 changes: 2 additions & 0 deletions apps/web/src/server/service/campaign-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down Expand Up @@ -139,6 +140,7 @@ async function renderCampaignHtmlForContact({
},
{} as Record<string, string | null | undefined>,
),
...getCampaignUnsubscribeVariableValues(unsubscribeUrl),
});

return renderer.render({
Expand Down
Loading