diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 9ae6519b..4527a579 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -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, - matchedVariable, - allowedVariables, - ); -} - -function createCaseInsensitiveVariableValues( - values: Record, -) { - const normalizedValues = Object.entries(values).reduce( - (acc, [key, value]) => { - if (value !== undefined) { - acc[key] = value; - acc[key.toLowerCase()] = value; - } - - return acc; - }, - {} as Record, - ); - - 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; -} - function campaignHasUnsubscribePlaceholder( ...sources: Array ) { @@ -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, diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index fef79422..24f974eb 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -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, diff --git a/apps/web/src/server/utils/contact-variable-replacement.ts b/apps/web/src/server/utils/contact-variable-replacement.ts new file mode 100644 index 00000000..4f37dc89 --- /dev/null +++ b/apps/web/src/server/utils/contact-variable-replacement.ts @@ -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, + matchedVariable, + allowedVariables, + ); +} + +export function createCaseInsensitiveVariableValues( + values: Record, +) { + const normalizedValues = Object.entries(values).reduce( + (acc, [key, value]) => { + if (value !== undefined) { + acc[key] = value; + acc[key.toLowerCase()] = value; + } + + return acc; + }, + {} as Record, + ); + + // 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; +} + +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 ?? ""; + }, + ); +} diff --git a/apps/web/src/server/utils/contact-variable-replacement.unit.test.ts b/apps/web/src/server/utils/contact-variable-replacement.unit.test.ts new file mode 100644 index 00000000..7a2d1bb7 --- /dev/null +++ b/apps/web/src/server/utils/contact-variable-replacement.unit.test.ts @@ -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}}"); + }); +});