Skip to content
Open
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
129 changes: 13 additions & 116 deletions apps/web/src/server/service/campaign-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}}",
Expand All @@ -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>
) {
Expand All @@ -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[];
Expand Down Expand Up @@ -867,6 +759,11 @@ async function processContactEmail(jobData: CampaignEmailJob) {
unsubscribeUrl,
allowedVariables,
});
const subject = replaceContactVariables(
emailConfig.subject,
contact,
allowedVariables,
);

if (isContactSuppressed) {
// Create suppressed email record
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 32 additions & 3 deletions apps/web/src/server/service/email-queue-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<{
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
120 changes: 120 additions & 0 deletions apps/web/src/server/utils/contact-variable-replacement.ts
Original file line number Diff line number Diff line change
@@ -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 ?? "";
},
);
}
Loading