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 { 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}}");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user