Fix: Campaign subject is now interpolated with contact variables (#397)

* fix(campaign): fixed variables replacement in mail subjects

* improvement(tests): added test cases and respect conventionnal imports
This commit is contained in:
Benoît
2026-05-16 22:42:21 +02:00
committed by GitHub
parent 964bbf96dc
commit 31a49fbdca
4 changed files with 252 additions and 119 deletions
+13 -116
View File
@@ -2,7 +2,6 @@ import { EmailRenderer } from "@usesend/email-editor/src/renderer";
import { db } from "../db"; import { db } from "../db";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { env } from "~/env"; import { env } from "~/env";
import { getContactPropertyValue } from "~/lib/contact-properties";
import { import {
Campaign, Campaign,
Contact, Contact,
@@ -24,6 +23,12 @@ import {
validateApiKeyDomainAccess, validateApiKeyDomainAccess,
validateDomainFromEmail, validateDomainFromEmail,
} from "./domain-service"; } from "./domain-service";
import {
BUILT_IN_CONTACT_VARIABLES,
createCaseInsensitiveVariableValues,
getContactReplacementValue,
replaceContactVariables,
} from "../utils/contact-variable-replacement";
const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [ const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [
"{{unsend_unsubscribe_url}}", "{{unsend_unsubscribe_url}}",
@@ -36,84 +41,6 @@ const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES =
return new RegExp(`\\{\\{\\s*${inner}\\s*\\}}`, "i"); return new RegExp(`\\{\\{\\s*${inner}\\s*\\}}`, "i");
}); });
const CONTACT_VARIABLE_REGEX =
/\{\{\s*(?:contact\.)?([a-zA-Z0-9_]+)(?:,fallback=([^}]+))?\s*\}\}/gi;
const BUILT_IN_CONTACT_VARIABLES = ["email", "firstName", "lastName"] as const;
function getContactReplacementValue({
contact,
key,
allowedVariables,
}: {
contact: Contact;
key: string;
allowedVariables: string[];
}) {
const normalizedKey = key.toLowerCase();
if (normalizedKey === "email") {
return contact.email;
}
if (normalizedKey === "firstname") {
return contact.firstName;
}
if (normalizedKey === "lastname") {
return contact.lastName;
}
const variableMap = new Map(
allowedVariables.map((variable) => [variable.toLowerCase(), variable]),
);
const matchedVariable = variableMap.get(normalizedKey);
if (!matchedVariable) {
return undefined;
}
if (!contact.properties || typeof contact.properties !== "object") {
return undefined;
}
return getContactPropertyValue(
contact.properties as Record<string, unknown>,
matchedVariable,
allowedVariables,
);
}
function createCaseInsensitiveVariableValues(
values: Record<string, string | null | undefined>,
) {
const normalizedValues = Object.entries(values).reduce(
(acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
acc[key.toLowerCase()] = value;
}
return acc;
},
{} as Record<string, string | null>,
);
return new Proxy(normalizedValues, {
get(target, prop, receiver) {
if (typeof prop === "string") {
const exact = Reflect.get(target, prop, receiver);
if (exact !== undefined) {
return exact;
}
return Reflect.get(target, prop.toLowerCase(), receiver);
}
return Reflect.get(target, prop, receiver);
},
}) as Record<string, string | null>;
}
function campaignHasUnsubscribePlaceholder( function campaignHasUnsubscribePlaceholder(
...sources: Array<string | null | undefined> ...sources: Array<string | null | undefined>
) { ) {
@@ -128,41 +55,6 @@ function replaceUnsubscribePlaceholders(html: string, url: string) {
}, html); }, html);
} }
function replaceContactVariables(
html: string,
contact: Contact,
allowedVariables: string[],
) {
return html.replace(
CONTACT_VARIABLE_REGEX,
(match: string, key: string, fallback?: string) => {
const normalizedKey = key.toLowerCase();
const isBuiltIn = BUILT_IN_CONTACT_VARIABLES.some(
(variable) => variable.toLowerCase() === normalizedKey,
);
const isAllowedRegistryVariable = allowedVariables.some(
(variable) => variable.toLowerCase() === normalizedKey,
);
if (!isBuiltIn && !isAllowedRegistryVariable) {
return match;
}
const contactValue = getContactReplacementValue({
contact,
key,
allowedVariables,
});
if (contactValue && contactValue.length > 0) {
return contactValue;
}
return fallback ?? "";
},
);
}
function sanitizeAddressList(addresses?: string | string[]) { function sanitizeAddressList(addresses?: string | string[]) {
if (!addresses) { if (!addresses) {
return [] as string[]; return [] as string[];
@@ -867,6 +759,11 @@ async function processContactEmail(jobData: CampaignEmailJob) {
unsubscribeUrl, unsubscribeUrl,
allowedVariables, allowedVariables,
}); });
const subject = replaceContactVariables(
emailConfig.subject,
contact,
allowedVariables,
);
if (isContactSuppressed) { if (isContactSuppressed) {
// Create suppressed email record // Create suppressed email record
@@ -886,7 +783,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
cc: ccEmails.length > 0 ? ccEmails : undefined, cc: ccEmails.length > 0 ? ccEmails : undefined,
bcc: bccEmails.length > 0 ? bccEmails : undefined, bcc: bccEmails.length > 0 ? bccEmails : undefined,
from: emailConfig.from, from: emailConfig.from,
subject: emailConfig.subject, subject,
html, html,
text: emailConfig.previewText, text: emailConfig.previewText,
teamId: emailConfig.teamId, teamId: emailConfig.teamId,
@@ -956,7 +853,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined, cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined, bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
from: emailConfig.from, from: emailConfig.from,
subject: emailConfig.subject, subject,
html, html,
text: emailConfig.previewText, text: emailConfig.previewText,
teamId: emailConfig.teamId, teamId: emailConfig.teamId,
@@ -1,4 +1,4 @@
import { Job, Queue, Worker } from "bullmq"; import { Queue, Worker } from "bullmq";
import { env } from "~/env"; import { env } from "~/env";
import { EmailAttachment } from "~/types"; import { EmailAttachment } from "~/types";
import { convert as htmlToText } from "html-to-text"; import { convert as htmlToText } from "html-to-text";
@@ -10,7 +10,10 @@ import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
import { logger } from "../logger/log"; import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
import { LimitService } from "./limit-service"; import { LimitService } from "./limit-service";
import { sanitizeCustomHeaders } from "~/server/utils/email-headers"; import {
BUILT_IN_CONTACT_VARIABLES,
replaceContactVariables,
} from "../utils/contact-variable-replacement";
// Notifications about limits are handled inside LimitService. // Notifications about limits are handled inside LimitService.
type QueueEmailJob = TeamJob<{ type QueueEmailJob = TeamJob<{
@@ -360,6 +363,32 @@ async function executeEmail(job: QueueEmailJob) {
: email.campaignId && email.html : email.campaignId && email.html
? htmlToText(email.html) ? htmlToText(email.html)
: undefined; : undefined;
let subject = email.subject;
if (email.campaignId && email.contactId && subject.includes("{{")) {
const contact = await db.contact.findUnique({
where: { id: email.contactId },
include: {
contactBook: {
select: { variables: true },
},
},
});
if (contact) {
subject = replaceContactVariables(subject, contact, [
...BUILT_IN_CONTACT_VARIABLES,
...contact.contactBook.variables,
]);
if (subject !== email.subject) {
await db.email.update({
where: { id: email.id },
data: { subject },
});
}
}
}
let inReplyToMessageId: string | undefined = undefined; let inReplyToMessageId: string | undefined = undefined;
@@ -404,7 +433,7 @@ async function executeEmail(job: QueueEmailJob) {
const messageId = await sendRawEmail({ const messageId = await sendRawEmail({
to: email.to, to: email.to,
from: email.from, from: email.from,
subject: email.subject, subject,
replyTo: email.replyTo ?? undefined, replyTo: email.replyTo ?? undefined,
bcc: email.bcc, bcc: email.bcc,
cc: email.cc, cc: email.cc,
@@ -0,0 +1,120 @@
import { Contact } from "@prisma/client";
import { getContactPropertyValue } from "~/lib/contact-properties";
const CONTACT_VARIABLE_REGEX =
/\{\{\s*(?:contact\.)?([a-zA-Z0-9_]+)(?:\s*,\s*fallback=([^}]+))?\s*\}\}/gi;
export const BUILT_IN_CONTACT_VARIABLES = [
"email",
"firstName",
"lastName",
] as const;
export function getContactReplacementValue({
contact,
key,
allowedVariables,
}: {
contact: Contact;
key: string;
allowedVariables: string[];
}) {
const normalizedKey = key.toLowerCase();
if (normalizedKey === "email") {
return contact.email;
}
if (normalizedKey === "firstname") {
return contact.firstName;
}
if (normalizedKey === "lastname") {
return contact.lastName;
}
const variableMap = new Map(
allowedVariables.map((variable) => [variable.toLowerCase(), variable]),
);
const matchedVariable = variableMap.get(normalizedKey);
if (!matchedVariable) {
return undefined;
}
if (!contact.properties || typeof contact.properties !== "object") {
return undefined;
}
return getContactPropertyValue(
contact.properties as Record<string, unknown>,
matchedVariable,
allowedVariables,
);
}
export function createCaseInsensitiveVariableValues(
values: Record<string, string | null | undefined>,
) {
const normalizedValues = Object.entries(values).reduce(
(acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
acc[key.toLowerCase()] = value;
}
return acc;
},
{} as Record<string, string | null>,
);
// eslint-disable-next-line no-undef
return new Proxy(normalizedValues, {
get(target, prop, receiver) {
if (typeof prop === "string") {
const exact = Reflect.get(target, prop, receiver);
if (exact !== undefined) {
return exact;
}
return Reflect.get(target, prop.toLowerCase(), receiver);
}
return Reflect.get(target, prop, receiver);
},
}) as Record<string, string | null>;
}
export function replaceContactVariables(
value: string,
contact: Contact,
allowedVariables: string[],
) {
return value.replace(
CONTACT_VARIABLE_REGEX,
(match: string, key: string, fallback?: string) => {
const normalizedKey = key.toLowerCase();
const isBuiltIn = BUILT_IN_CONTACT_VARIABLES.some(
(variable) => variable.toLowerCase() === normalizedKey,
);
const isAllowedRegistryVariable = allowedVariables.some(
(variable) => variable.toLowerCase() === normalizedKey,
);
if (!isBuiltIn && !isAllowedRegistryVariable) {
return match;
}
const contactValue = getContactReplacementValue({
contact,
key,
allowedVariables,
});
if (contactValue && contactValue.length > 0) {
return contactValue;
}
return fallback ?? "";
},
);
}
@@ -0,0 +1,87 @@
import { Contact } from "@prisma/client";
import { describe, expect, it } from "vitest";
import {
BUILT_IN_CONTACT_VARIABLES,
replaceContactVariables,
} from "~/server/utils/contact-variable-replacement";
const baseContact = {
id: "contact_1",
firstName: "Benoît",
lastName: "Durand",
email: "benoit@example.com",
subscribed: true,
unsubscribeReason: null,
properties: {
username: "ben",
},
contactBookId: "book_1",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
} satisfies Contact;
describe("replaceContactVariables", () => {
it("replaces built-in contact variables in a subject", () => {
expect(
replaceContactVariables("Hello {{firstName}}", baseContact, [
...BUILT_IN_CONTACT_VARIABLES,
]),
).toBe("Hello Benoît");
});
it("replaces registered custom variables with fallback syntax", () => {
expect(
replaceContactVariables(
"Welcome, {{username,fallback=you}}!",
baseContact,
[...BUILT_IN_CONTACT_VARIABLES, "username"],
),
).toBe("Welcome, ben!");
});
it("uses fallback values and accepts whitespace around fallback", () => {
expect(
replaceContactVariables(
"Welcome, {{missing_variable, fallback=you}}!",
baseContact,
[...BUILT_IN_CONTACT_VARIABLES, "missing_variable"],
),
).toBe("Welcome, you!");
});
it("uses fallback values for nullable built-in variables", () => {
const contact = {
...baseContact,
firstName: null,
} satisfies Contact;
expect(
replaceContactVariables("Hello {{firstName,fallback=you}}", contact, [
...BUILT_IN_CONTACT_VARIABLES,
]),
).toBe("Hello you");
});
it("uses fallback values for prefixed nullable built-in variables", () => {
const contact = {
...baseContact,
firstName: null,
} satisfies Contact;
expect(
replaceContactVariables(
"Hello {{contact.firstName,fallback=you}}",
contact,
[...BUILT_IN_CONTACT_VARIABLES],
),
).toBe("Hello you");
});
it("keeps unknown variables unchanged", () => {
expect(
replaceContactVariables("Hello {{unknown}}", baseContact, [
...BUILT_IN_CONTACT_VARIABLES,
]),
).toBe("Hello {{unknown}}");
});
});