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:
@@ -2,7 +2,6 @@ import { EmailRenderer } from "@usesend/email-editor/src/renderer";
|
||||
import { db } from "../db";
|
||||
import { createHash } from "crypto";
|
||||
import { env } from "~/env";
|
||||
import { getContactPropertyValue } from "~/lib/contact-properties";
|
||||
import {
|
||||
Campaign,
|
||||
Contact,
|
||||
@@ -24,6 +23,12 @@ import {
|
||||
validateApiKeyDomainAccess,
|
||||
validateDomainFromEmail,
|
||||
} from "./domain-service";
|
||||
import {
|
||||
BUILT_IN_CONTACT_VARIABLES,
|
||||
createCaseInsensitiveVariableValues,
|
||||
getContactReplacementValue,
|
||||
replaceContactVariables,
|
||||
} from "../utils/contact-variable-replacement";
|
||||
|
||||
const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [
|
||||
"{{unsend_unsubscribe_url}}",
|
||||
@@ -36,84 +41,6 @@ const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES =
|
||||
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(
|
||||
...sources: Array<string | null | undefined>
|
||||
) {
|
||||
@@ -128,41 +55,6 @@ function replaceUnsubscribePlaceholders(html: string, url: string) {
|
||||
}, 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[]) {
|
||||
if (!addresses) {
|
||||
return [] as string[];
|
||||
@@ -867,6 +759,11 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
unsubscribeUrl,
|
||||
allowedVariables,
|
||||
});
|
||||
const subject = replaceContactVariables(
|
||||
emailConfig.subject,
|
||||
contact,
|
||||
allowedVariables,
|
||||
);
|
||||
|
||||
if (isContactSuppressed) {
|
||||
// Create suppressed email record
|
||||
@@ -886,7 +783,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
cc: ccEmails.length > 0 ? ccEmails : undefined,
|
||||
bcc: bccEmails.length > 0 ? bccEmails : undefined,
|
||||
from: emailConfig.from,
|
||||
subject: emailConfig.subject,
|
||||
subject,
|
||||
html,
|
||||
text: emailConfig.previewText,
|
||||
teamId: emailConfig.teamId,
|
||||
@@ -956,7 +853,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined,
|
||||
bcc: filteredBccEmails.length > 0 ? filteredBccEmails : undefined,
|
||||
from: emailConfig.from,
|
||||
subject: emailConfig.subject,
|
||||
subject,
|
||||
html,
|
||||
text: emailConfig.previewText,
|
||||
teamId: emailConfig.teamId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Job, Queue, Worker } from "bullmq";
|
||||
import { Queue, Worker } from "bullmq";
|
||||
import { env } from "~/env";
|
||||
import { EmailAttachment } from "~/types";
|
||||
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 { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
|
||||
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.
|
||||
|
||||
type QueueEmailJob = TeamJob<{
|
||||
@@ -360,6 +363,32 @@ async function executeEmail(job: QueueEmailJob) {
|
||||
: email.campaignId && email.html
|
||||
? htmlToText(email.html)
|
||||
: 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;
|
||||
|
||||
@@ -404,7 +433,7 @@ async function executeEmail(job: QueueEmailJob) {
|
||||
const messageId = await sendRawEmail({
|
||||
to: email.to,
|
||||
from: email.from,
|
||||
subject: email.subject,
|
||||
subject,
|
||||
replyTo: email.replyTo ?? undefined,
|
||||
bcc: email.bcc,
|
||||
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}}");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user