feat: add contact-book variable registry for campaign personalization (#359)

* feat: add contact-book variable registry for campaign personalization

* test: include contact-book variables default in service expectation

* fix: address personalization review issues

* fix text

* fix: normalize contact variable access across contact flows

* stuff

* fix
This commit is contained in:
KM Koushik
2026-03-08 00:03:58 +11:00
committed by GitHub
parent d97e445ea0
commit 62e0a1db88
29 changed files with 1564 additions and 406 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ContactBook" ADD COLUMN "variables" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
+1
View File
@@ -296,6 +296,7 @@ model ContactBook {
id String @id @default(cuid())
name String
teamId Int
variables String[] @default([])
properties Json
doubleOptInEnabled Boolean @default(false)
doubleOptInFrom String?
@@ -5,7 +5,7 @@ import { Spinner } from "@usesend/ui/src/spinner";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Editor } from "@usesend/email-editor";
import { use, useState } from "react";
import { use, useMemo, useState } from "react";
import { Campaign } from "@prisma/client";
import {
Select,
@@ -65,7 +65,7 @@ export default function EditCampaignPage({
{ campaignId },
{
enabled: !!campaignId,
}
},
);
if (isLoading) {
@@ -102,7 +102,7 @@ function CampaignEditor({
const utils = api.useUtils();
const [json, setJson] = useState<Record<string, any> | undefined>(
campaign.content ? JSON.parse(campaign.content) : undefined
campaign.content ? JSON.parse(campaign.content) : undefined,
);
const [isSaving, setIsSaving] = useState(false);
const [name, setName] = useState(campaign.name);
@@ -110,10 +110,10 @@ function CampaignEditor({
const [from, setFrom] = useState(campaign.from);
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
const [replyTo, setReplyTo] = useState<string | undefined>(
campaign.replyTo[0]
campaign.replyTo[0],
);
const [previewText, setPreviewText] = useState<string | null>(
campaign.previewText
campaign.previewText,
);
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
@@ -136,13 +136,13 @@ function CampaignEditor({
const deboucedUpdateCampaign = useDebouncedCallback(
updateEditorContent,
1000
1000,
);
const handleFileChange = async (file: File) => {
if (file.size > IMAGE_SIZE_LIMIT) {
throw new Error(
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`,
);
}
@@ -165,8 +165,17 @@ function CampaignEditor({
};
const contactBook = contactBooksQuery.data?.find(
(book) => book.id === contactBookId
(book) => book.id === contactBookId,
);
const editorVariables = useMemo(() => {
const baseVariables = ["email", "firstName", "lastName"];
const registryVariables = contactBook?.variables ?? [];
return Array.from(new Set([...baseVariables, ...registryVariables]));
}, [contactBook]);
const variableSuggestionsHelperText = contactBookId
? undefined
: "Select the contact book for related variable";
return (
<div className="p-4 container mx-auto ">
@@ -196,7 +205,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setName(campaign.name);
},
}
},
);
}}
/>
@@ -251,7 +260,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setSubject(campaign.subject);
},
}
},
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
@@ -291,7 +300,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setFrom(campaign.from);
},
}
},
);
}}
disabled={isApiCampaign}
@@ -327,7 +336,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setReplyTo(campaign.replyTo[0]);
},
}
},
);
}}
disabled={isApiCampaign}
@@ -365,7 +374,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setPreviewText(campaign.previewText ?? "");
},
}
},
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
@@ -397,7 +406,7 @@ function CampaignEditor({
onError: () => {
setContactBookId(campaign.contactBookId);
},
}
},
);
setContactBookId(val);
}}
@@ -435,13 +444,15 @@ function CampaignEditor({
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<Editor
key={`campaign-editor-${contactBookId ?? "none"}-${editorVariables.join(",")}`}
initialContent={json}
onUpdate={(content) => {
setJson(content.getJSON());
setIsSaving(true);
deboucedUpdateCampaign();
}}
variables={["email", "firstName", "lastName"]}
variables={editorVariables}
variableSuggestionsHelperText={variableSuggestionsHelperText}
uploadImage={
campaign.imageUploadSupported ? handleFileChange : undefined
}
@@ -26,6 +26,7 @@ import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import type { ReactNode } from "react";
const contactsSchema = z.object({
contacts: z.string({ required_error: "Contacts are required" }).min(1, {
@@ -35,8 +36,14 @@ const contactsSchema = z.object({
export default function AddContact({
contactBookId,
trigger,
open: controlledOpen,
onOpenChange,
}: {
contactBookId: string;
trigger?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const [open, setOpen] = useState(false);
@@ -50,6 +57,14 @@ export default function AddContact({
});
const utils = api.useUtils();
const dialogTrigger =
trigger ??
(controlledOpen === undefined ? (
<Button>
<Plus className="h-4 w-4 mr-1" />
Add Contacts
</Button>
) : null);
async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
const contactsArray = values.contacts.split(",").map((email) => ({
@@ -64,7 +79,11 @@ export default function AddContact({
{
onSuccess: async () => {
utils.contacts.contacts.invalidate();
if (controlledOpen === undefined) {
setOpen(false);
} else {
onOpenChange?.(false);
}
toast.success("Contacts queued for processing");
},
onError: async (error) => {
@@ -76,15 +95,21 @@ export default function AddContact({
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
open={controlledOpen ?? open}
onOpenChange={(nextOpen) => {
if (controlledOpen === undefined) {
if (nextOpen !== open) {
setOpen(nextOpen);
}
return;
}
onOpenChange?.(nextOpen);
}}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
Add Contacts
</Button>
</DialogTrigger>
{dialogTrigger ? (
<DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
) : null}
<DialogContent>
<DialogHeader>
<DialogTitle>Add new contacts</DialogTitle>
@@ -1,7 +1,8 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, type ReactNode } from "react";
import { api } from "~/trpc/react";
import { getCanonicalContactVariableName } from "~/lib/contact-properties";
import {
Dialog,
DialogContent,
@@ -28,24 +29,41 @@ import { toast } from "@usesend/ui/src/toaster";
interface BulkUploadContactsProps {
contactBookId: string;
contactBookVariables?: string[];
trigger?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
interface ParsedContact {
email: string;
firstName?: string;
lastName?: string;
properties?: Record<string, string>;
subscribed?: boolean;
isValid: boolean;
}
export default function BulkUploadContacts({
contactBookId,
contactBookVariables,
trigger,
open: controlledOpen,
onOpenChange,
}: BulkUploadContactsProps) {
const [open, setOpen] = useState(false);
const [inputText, setInputText] = useState("");
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const dialogTrigger =
trigger ??
(controlledOpen === undefined ? (
<Button variant="outline">
<Upload className="h-4 w-4 mr-1" />
Upload Contacts
</Button>
) : null);
const utils = api.useUtils();
@@ -67,7 +85,11 @@ export default function BulkUploadContacts({
setInputText("");
setError(null);
setProcessing(false);
if (controlledOpen === undefined) {
setOpen(false);
} else {
onOpenChange?.(false);
}
};
const validateEmail = (email: string): boolean => {
@@ -77,11 +99,12 @@ export default function BulkUploadContacts({
const parseContactLine = (
line: string,
isFirstLine: boolean = false,
headers?: string[],
): {
email: string;
firstName?: string;
lastName?: string;
properties?: Record<string, string>;
subscribed?: boolean;
} | null => {
const trimmedLine = line.trim();
@@ -107,27 +130,95 @@ export default function BulkUploadContacts({
if (parts.length === 0 || !parts[0]) return null;
// Check if this is a header row (case-insensitive)
if (isFirstLine) {
const firstPart = parts[0]?.toLowerCase();
if (
firstPart === "email" ||
firstPart === "e-mail" ||
firstPart === "email address"
) {
return null; // Skip header row
}
return null;
}
const email = parts[0]!.toLowerCase();
const getHeader = (index: number) => headers?.[index]?.trim().toLowerCase();
const email = (
headers
? parts[
headers.findIndex((header) => {
const normalized = header.trim().toLowerCase();
return (
normalized === "email" ||
normalized === "e-mail" ||
normalized === "email address"
);
})
]
: parts[0]
)?.toLowerCase();
// Skip if doesn't look like an email
if (!email.includes("@")) return null;
if (!email || !email.includes("@")) return null;
// Parse subscribed value (support CSV export format: Email, First Name, Last Name, Subscribed, ...)
let subscribed: boolean | undefined = undefined;
let firstName: string | undefined = undefined;
let lastName: string | undefined = undefined;
const properties: Record<string, string> = {};
if (headers) {
for (let i = 0; i < parts.length; i++) {
const header = getHeader(i);
if (!header) {
continue;
}
if (
header === "email" ||
header === "e-mail" ||
header === "email address"
) {
continue;
}
if (header === "firstname" || header === "first name") {
firstName = parts[i] || undefined;
continue;
}
if (header === "lastname" || header === "last name") {
lastName = parts[i] || undefined;
continue;
}
if (header === "subscribed") {
const subscribedValue = parts[i]?.toLowerCase();
if (subscribedValue === "yes" || subscribedValue === "true") {
subscribed = true;
} else if (subscribedValue === "no" || subscribedValue === "false") {
subscribed = false;
}
continue;
}
if (parts[i]) {
const propertyKey =
getCanonicalContactVariableName(
headers[i]!.trim(),
contactBookVariables ?? [],
) ?? headers[i]!.trim();
properties[propertyKey] = parts[i]!;
}
}
return {
email,
firstName,
lastName,
subscribed,
properties: Object.keys(properties).length > 0 ? properties : undefined,
};
}
if (parts.length >= 4) {
// Could be: email,firstName,lastName,subscribed
@@ -152,6 +243,7 @@ export default function BulkUploadContacts({
email,
firstName,
lastName,
properties: Object.keys(properties).length > 0 ? properties : undefined,
subscribed,
};
};
@@ -159,9 +251,25 @@ export default function BulkUploadContacts({
const parseContacts = (text: string): ParsedContact[] => {
const lines = text.split("\n");
const contactsMap = new Map<string, ParsedContact>();
const firstLineParts = lines[0]
?.split(",")
.map((part) => part.trim().replace(/^"|"$/g, ""));
const hasEmailHeader =
firstLineParts?.some((part) => {
const normalized = part.toLowerCase();
return (
normalized === "email" ||
normalized === "e-mail" ||
normalized === "email address"
);
}) ?? false;
const hasDataEmail =
firstLineParts?.some((part) => validateEmail(part)) ?? false;
const hasHeader = hasEmailHeader && !hasDataEmail;
const headers = hasHeader ? firstLineParts : undefined;
for (let i = 0; i < lines.length; i++) {
const parsed = parseContactLine(lines[i]!, i === 0);
const parsed = parseContactLine(lines[i]!, headers);
if (parsed) {
// Use email as key to deduplicate
if (!contactsMap.has(parsed.email)) {
@@ -267,6 +375,7 @@ export default function BulkUploadContacts({
email: c.email,
firstName: c.firstName,
lastName: c.lastName,
properties: c.properties,
subscribed: c.subscribed,
})),
});
@@ -293,13 +402,20 @@ export default function BulkUploadContacts({
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="h-4 w-4 mr-1" />
Upload Contacts
</Button>
</DialogTrigger>
<Dialog
open={controlledOpen ?? open}
onOpenChange={(nextOpen) => {
if (controlledOpen === undefined) {
setOpen(nextOpen);
return;
}
onOpenChange?.(nextOpen);
}}
>
{dialogTrigger ? (
<DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
) : null}
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Bulk Upload Contacts</DialogTitle>
@@ -385,7 +501,8 @@ Format: email,firstName,lastName,subscribed (all fields except email are optiona
: "Upload a .txt or .csv file or drag and drop here"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Format: email,firstName,lastName,subscribed (one per line)
Format: email,firstName,lastName,subscribed (+ optional
custom columns)
</p>
</div>
</div>
@@ -26,6 +26,7 @@ import EditContact from "./edit-contact";
import { ResendDoubleOptInConfirmation } from "./resend-double-opt-in-confirmation";
import { Input } from "@usesend/ui/src/input";
import { useDebouncedCallback } from "use-debounce";
import { getContactPropertyValue } from "~/lib/contact-properties";
import {
Tooltip,
TooltipContent,
@@ -72,10 +73,12 @@ export default function ContactList({
contactBookId,
contactBookName,
doubleOptInEnabled,
contactBookVariables,
}: {
contactBookId: string;
contactBookName?: string;
doubleOptInEnabled?: boolean;
contactBookVariables?: string[];
}) {
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
@@ -141,6 +144,7 @@ export default function ContactList({
"Subscribed",
"Unsubscribe Reason",
"Created At",
...(contactBookVariables ?? []),
];
// CSV Rows
@@ -151,6 +155,15 @@ export default function ContactList({
escapeCell(contact.subscribed ? "Yes" : "No"),
escapeCell(contact.unsubscribeReason ?? ""),
escapeCell(contact.createdAt.toISOString()),
...(contactBookVariables ?? []).map((variable) =>
escapeCell(
getContactPropertyValue(
(contact.properties as Record<string, unknown> | undefined) ?? {},
variable,
contactBookVariables ?? [],
) ?? "",
),
),
]);
// Build CSV with UTF-8 BOM
@@ -314,7 +327,10 @@ export default function ContactList({
email={contact.email}
/>
) : null}
<EditContact contact={contact} />
<EditContact
contact={contact}
contactBookVariables={contactBookVariables}
/>
<DeleteContact contact={contact} />
</div>
</TableCell>
@@ -12,7 +12,6 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -20,31 +19,67 @@ import {
} from "@usesend/ui/src/form";
import { api } from "~/trpc/react";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Edit } from "lucide-react";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { Switch } from "@usesend/ui/src/switch";
import { Contact } from "@prisma/client";
import {
getContactPropertyValue,
replaceContactVariableValues,
} from "~/lib/contact-properties";
const contactSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
firstName: z.string().optional(),
lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(),
});
export const EditContact: React.FC<{
contact: Partial<Contact> & { id: string; contactBookId: string };
}> = ({ contact }) => {
contactBookVariables?: string[];
}> = ({ contact, contactBookVariables }) => {
const [open, setOpen] = useState(false);
const updateContactMutation = api.contacts.updateContact.useMutation();
const initialVariableValues = useMemo(() => {
const contactProperties =
contact.properties && typeof contact.properties === "object"
? (contact.properties as Record<string, unknown>)
: {};
return (contactBookVariables ?? []).reduce(
(acc, variable) => {
acc[variable] =
getContactPropertyValue(
contactProperties,
variable,
contactBookVariables ?? [],
) ?? "";
return acc;
},
{} as Record<string, string>,
);
}, [contact.properties, contactBookVariables]);
const [variableValues, setVariableValues] = useState(initialVariableValues);
useEffect(() => {
setVariableValues((prev) =>
Object.keys(initialVariableValues).reduce(
(acc, key) => {
acc[key] = prev[key] ?? initialVariableValues[key] ?? "";
return acc;
},
{} as Record<string, string>,
),
);
}, [initialVariableValues]);
const utils = api.useUtils();
const router = useRouter();
const contactForm = useForm<z.infer<typeof contactSchema>>({
resolver: zodResolver(contactSchema),
@@ -62,6 +97,14 @@ export const EditContact: React.FC<{
contactId: contact.id,
contactBookId: contact.contactBookId,
...values,
properties: replaceContactVariableValues(
(contact.properties as Record<string, unknown> | null | undefined) ??
{},
Object.fromEntries(
Object.entries(variableValues).filter(([, value]) => value.trim()),
),
contactBookVariables ?? [],
),
},
{
onSuccess: async () => {
@@ -72,7 +115,7 @@ export const EditContact: React.FC<{
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -151,6 +194,28 @@ export const EditContact: React.FC<{
</FormItem>
)}
/>
{(contactBookVariables ?? []).map((variable) => {
const variableInputId = `contact-variable-${contact.id}-${variable.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
return (
<FormItem key={variable}>
<FormLabel htmlFor={variableInputId}>{variable}</FormLabel>
<FormControl>
<Input
id={variableInputId}
placeholder={variable}
value={variableValues[variable] ?? ""}
onChange={(e) => {
setVariableValues((prev) => ({
...prev,
[variable]: e.target.value,
}));
}}
/>
</FormControl>
</FormItem>
);
})}
<div className="flex justify-end">
<Button
className=" w-[100px] "
@@ -23,7 +23,8 @@ import {
import { Button } from "@usesend/ui/src/button";
import { Switch } from "@usesend/ui/src/switch";
import { useTheme } from "@usesend/ui";
import { use } from "react";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import {
@@ -35,7 +36,140 @@ import {
Megaphone,
Shield,
ChevronRight,
MoreVertical,
Plus,
Upload,
Edit,
Trash2,
} from "lucide-react";
import EditContactBook from "../edit-contact-book";
import DeleteContactBook from "../delete-contact-book";
function ContactBookDetailActions({
contactBookId,
contactBookName,
contactBookVariables,
}: {
contactBookId: string;
contactBookName?: string;
contactBookVariables?: string[];
}) {
const [open, setOpen] = useState(false);
const [isAddOpen, setIsAddOpen] = useState(false);
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const utils = api.useUtils();
const router = useRouter();
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="default" className="gap-1">
<MoreVertical className="h-4 -ml-2" />
Actions
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 rounded-xl p-1" align="end">
<div className="flex flex-col">
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
setOpen(false);
setIsAddOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add contacts
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
setOpen(false);
setIsBulkUploadOpen(true);
}}
>
<Upload className="mr-2 h-4 w-4" />
Bulk upload
</Button>
{contactBookName ? (
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
setOpen(false);
setIsEditOpen(true);
}}
>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
) : null}
{contactBookName ? (
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg text-red/80 hover:bg-accent hover:text-red"
onClick={() => {
setOpen(false);
setIsDeleteOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
) : null}
</div>
</PopoverContent>
</Popover>
<AddContact
contactBookId={contactBookId}
open={isAddOpen}
onOpenChange={setIsAddOpen}
/>
<BulkUploadContacts
contactBookId={contactBookId}
contactBookVariables={contactBookVariables}
open={isBulkUploadOpen}
onOpenChange={setIsBulkUploadOpen}
/>
{contactBookName ? (
<EditContactBook
contactBook={{
id: contactBookId,
name: contactBookName,
variables: contactBookVariables,
}}
open={isEditOpen}
onOpenChange={setIsEditOpen}
onSuccess={() =>
utils.contacts.getContactBookDetails.invalidate({ contactBookId })
}
/>
) : null}
{contactBookName ? (
<DeleteContactBook
contactBook={{ id: contactBookId, name: contactBookName }}
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
onSuccess={async () => {
await utils.contacts.getContactBookDetails.invalidate({
contactBookId,
});
router.push("/contacts");
}}
/>
) : null}
</>
);
}
export default function ContactsPage({
params,
@@ -132,10 +266,11 @@ export default function ContactsPage({
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex gap-4">
<BulkUploadContacts contactBookId={contactBookId} />
<AddContact contactBookId={contactBookId} />
</div>
<ContactBookDetailActions
contactBookId={contactBookId}
contactBookName={contactBookDetailQuery.data?.name}
contactBookVariables={contactBookDetailQuery.data?.variables}
/>
</div>
<div className="mt-10">
@@ -212,6 +347,23 @@ export default function ContactsPage({
: "--"}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Variables</p>
<div className="flex flex-wrap gap-1">
{(contactBookDetailQuery.data?.variables ?? []).length > 0 ? (
contactBookDetailQuery.data?.variables.map((variable) => (
<span
key={variable}
className="font-mono text-xs bg-muted px-2 py-0.5 rounded"
>
{variable}
</span>
))
) : (
<span className="text-sm text-muted-foreground">--</span>
)}
</div>
</div>
</CardContent>
</Card>
@@ -329,6 +481,7 @@ export default function ContactsPage({
contactBookId={contactBookId}
contactBookName={contactBookDetailQuery.data?.name}
doubleOptInEnabled={contactBookDetailQuery.data?.doubleOptInEnabled}
contactBookVariables={contactBookDetailQuery.data?.variables}
/>
</div>
</div>
@@ -33,6 +33,7 @@ const contactBookSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
}),
variables: z.string().optional(),
});
export default function AddContactBook() {
@@ -51,6 +52,7 @@ export default function AddContactBook() {
resolver: zodResolver(contactBookSchema),
defaultValues: {
name: "",
variables: "",
},
});
@@ -63,6 +65,10 @@ export default function AddContactBook() {
createContactBookMutation.mutate(
{
name: values.name,
variables: values.variables
?.split(",")
.map((variable) => variable.trim())
.filter(Boolean),
},
{
onSuccess: () => {
@@ -71,6 +77,9 @@ export default function AddContactBook() {
setOpen(false);
toast.success("Contact book created successfully");
},
onError: (error) => {
toast.error(error.message);
},
},
);
}
@@ -125,6 +134,25 @@ export default function AddContactBook() {
</FormItem>
)}
/>
<FormField
control={contactBookForm.control}
name="variables"
render={({ field }) => (
<FormItem>
<FormLabel>Variables</FormLabel>
<FormControl>
<Input
placeholder="registrationCode, company, plan"
{...field}
/>
</FormControl>
<FormDescription>
Optional comma-separated variable names for campaign
personalization.
</FormDescription>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
className=" w-[100px]"
@@ -7,10 +7,15 @@ import { ContactBook } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import type { ReactNode } from "react";
export const DeleteContactBook: React.FC<{
contactBook: Partial<ContactBook> & { id: string };
}> = ({ contactBook }) => {
trigger?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void | Promise<void>;
}> = ({ contactBook, trigger, open, onOpenChange, onSuccess }) => {
const deleteContactBookMutation =
api.contacts.deleteContactBook.useMutation();
const utils = api.useUtils();
@@ -34,14 +39,23 @@ export const DeleteContactBook: React.FC<{
contactBookId: contactBook.id,
},
{
onSuccess: () => {
onSuccess: async () => {
utils.contacts.getContactBooks.invalidate();
await onSuccess?.();
toast.success(`Contact book deleted`);
},
},
);
}
const dialogTrigger =
trigger ??
(open === undefined ? (
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
<Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" />
</Button>
) : null);
return (
<DeleteResource
title="Delete Contact Book"
@@ -49,11 +63,9 @@ export const DeleteContactBook: React.FC<{
schema={contactBookSchema}
isLoading={deleteContactBookMutation.isPending}
onConfirm={onContactBookDelete}
trigger={
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
<Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" />
</Button>
}
open={open}
onOpenChange={onOpenChange}
trigger={dialogTrigger}
confirmLabel="Delete Contact Book"
/>
);
@@ -12,6 +12,7 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -24,54 +25,34 @@ import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import type { ReactNode } from "react";
const contactBookSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
variables: z.string().optional(),
});
export const EditContactBook: React.FC<{
contactBook: { id: string; name: string };
}> = ({ contactBook }) => {
contactBook: { id: string; name: string; variables?: string[] };
trigger?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void | Promise<void>;
}> = ({
contactBook,
trigger,
open: controlledOpen,
onOpenChange,
onSuccess,
}) => {
const [open, setOpen] = useState(false);
const updateContactBookMutation =
api.contacts.updateContactBook.useMutation();
const utils = api.useUtils();
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema),
defaultValues: {
name: contactBook.name || "",
},
});
async function onContactBookUpdate(
values: z.infer<typeof contactBookSchema>
) {
updateContactBookMutation.mutate(
{
contactBookId: contactBook.id,
...values,
},
{
onSuccess: async () => {
utils.contacts.getContactBooks.invalidate();
setOpen(false);
toast.success("Contact book updated successfully");
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
const dialogTrigger =
trigger ??
(controlledOpen === undefined ? (
<Button
variant="ghost"
size="sm"
@@ -80,7 +61,63 @@ export const EditContactBook: React.FC<{
>
<Edit className="h-4 w-4 text-foreground/80 hover:text-foreground/70" />
</Button>
</DialogTrigger>
) : null);
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema),
defaultValues: {
name: contactBook.name || "",
variables: (contactBook.variables ?? []).join(", "),
},
});
async function onContactBookUpdate(
values: z.infer<typeof contactBookSchema>,
) {
updateContactBookMutation.mutate(
{
contactBookId: contactBook.id,
name: values.name,
variables: values.variables
?.split(",")
.map((variable) => variable.trim())
.filter(Boolean),
},
{
onSuccess: async () => {
utils.contacts.getContactBooks.invalidate();
await onSuccess?.();
if (controlledOpen === undefined) {
setOpen(false);
} else {
onOpenChange?.(false);
}
toast.success("Contact book updated successfully");
},
onError: async (error) => {
toast.error(error.message);
},
},
);
}
return (
<Dialog
open={controlledOpen ?? open}
onOpenChange={(nextOpen) => {
if (controlledOpen === undefined) {
if (nextOpen !== open) {
setOpen(nextOpen);
}
return;
}
onOpenChange?.(nextOpen);
}}
>
{dialogTrigger ? (
<DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
) : null}
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Contact Book</DialogTitle>
@@ -104,6 +141,25 @@ export const EditContactBook: React.FC<{
</FormItem>
)}
/>
<FormField
control={contactBookForm.control}
name="variables"
render={({ field }) => (
<FormItem>
<FormLabel>Variables</FormLabel>
<FormControl>
<Input
placeholder="registrationCode, company, plan"
{...field}
/>
</FormControl>
<FormDescription>
Comma-separated variable names available in campaigns for
this contact book.
</FormDescription>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
className=" w-[100px]"
+88
View File
@@ -0,0 +1,88 @@
export function getCanonicalContactVariableName(
key: string,
allowedVariables: string[] = [],
) {
const normalizedKey = key.trim().toLowerCase();
return allowedVariables.find(
(variable) => variable.toLowerCase() === normalizedKey,
);
}
export function normalizeContactProperties(
properties?: Record<string, unknown> | null,
allowedVariables: string[] = [],
) {
const normalizedProperties: Record<string, unknown> = {};
for (const [key, value] of Object.entries(properties ?? {})) {
const canonicalKey = getCanonicalContactVariableName(key, allowedVariables);
normalizedProperties[canonicalKey ?? key] = value;
}
return normalizedProperties;
}
export function getContactPropertyValue(
properties: Record<string, unknown> | null | undefined,
key: string,
allowedVariables: string[] = [],
) {
const normalizedKey = key.toLowerCase();
const canonicalKey = getCanonicalContactVariableName(key, allowedVariables);
const propertyKey = Object.keys(properties ?? {}).find((candidate) => {
const normalizedCandidate = candidate.toLowerCase();
return (
normalizedCandidate === normalizedKey ||
normalizedCandidate === canonicalKey?.toLowerCase()
);
});
const propertyValue = propertyKey ? properties?.[propertyKey] : undefined;
if (
typeof propertyValue === "string" ||
typeof propertyValue === "number" ||
typeof propertyValue === "boolean"
) {
return String(propertyValue);
}
return undefined;
}
export function mergeContactProperties(
existingProperties?: Record<string, unknown> | null,
incomingProperties?: Record<string, unknown> | null,
allowedVariables: string[] = [],
) {
return {
...normalizeContactProperties(existingProperties, allowedVariables),
...normalizeContactProperties(incomingProperties, allowedVariables),
};
}
export function replaceContactVariableValues(
existingProperties: Record<string, unknown> | null | undefined,
variableValues: Record<string, unknown>,
allowedVariables: string[] = [],
) {
const normalizedExistingProperties = normalizeContactProperties(
existingProperties,
allowedVariables,
);
for (const key of Object.keys(normalizedExistingProperties)) {
if (getCanonicalContactVariableName(key, allowedVariables)) {
delete normalizedExistingProperties[key];
}
}
return mergeContactProperties(
normalizedExistingProperties,
variableValues,
allowedVariables,
);
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
getContactPropertyValue,
normalizeContactProperties,
replaceContactVariableValues,
} from "~/lib/contact-properties";
describe("contact-properties", () => {
it("normalizes registered property keys to the canonical variable casing", () => {
expect(
normalizeContactProperties(
{
Company: "Acme",
tier: "gold",
PlanName: "Pro",
},
["company", "planName"],
),
).toEqual({
company: "Acme",
tier: "gold",
planName: "Pro",
});
});
it("reads property values case-insensitively for registered variables", () => {
expect(
getContactPropertyValue(
{
Company: "Acme",
},
"company",
["company"],
),
).toBe("Acme");
});
it("replaces registry-backed values while preserving unrelated properties", () => {
expect(
replaceContactVariableValues(
{
Company: "Old Co",
tier: "gold",
notes: "keep me",
},
{
company: "New Co",
},
["company", "plan"],
),
).toEqual({
notes: "keep me",
tier: "gold",
company: "New Co",
});
});
});
@@ -14,6 +14,10 @@ export const ContactBookSchema = z.object({
description: "Custom properties for the contact book",
example: { customField1: "value1" },
}),
variables: z.array(z.string()).openapi({
description: "Allowed personalization variables for contacts in this book",
example: ["registrationCode", "company"],
}),
emoji: z.string().openapi({
description: "The emoji associated with the contact book",
example: "📙",
+6 -2
View File
@@ -21,11 +21,12 @@ export const contactsRouter = createTRPCRouter({
.input(
z.object({
name: z.string(),
variables: z.array(z.string()).optional(),
}),
)
.mutation(async ({ ctx: { team }, input }) => {
const { name } = input;
return contactBookService.createContactBook(team.id, name);
const { name, variables } = input;
return contactBookService.createContactBook(team.id, name, variables);
}),
getContactBookDetails: contactBookProcedure.query(
@@ -53,6 +54,7 @@ export const contactsRouter = createTRPCRouter({
doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(),
variables: z.array(z.string()).optional(),
}),
)
.mutation(async ({ ctx: { contactBook }, input }) => {
@@ -104,6 +106,7 @@ export const contactsRouter = createTRPCRouter({
email: true,
firstName: true,
lastName: true,
properties: true,
subscribed: true,
createdAt: true,
contactBookId: true,
@@ -276,6 +279,7 @@ export const contactsRouter = createTRPCRouter({
email: true,
firstName: true,
lastName: true,
properties: true,
subscribed: true,
unsubscribeReason: true,
createdAt: true,
@@ -56,6 +56,7 @@ function buildContactBook(overrides?: Record<string, unknown>) {
name: "Newsletter",
teamId: 1,
properties: {},
variables: [],
emoji: "📙",
doubleOptInEnabled: true,
doubleOptInFrom: null,
@@ -116,6 +117,7 @@ describe("POST /v1/contactBooks", () => {
expect(mockCreateContactBook).toHaveBeenCalledWith(
1,
"Newsletter",
undefined,
mockTransactionClient,
);
expect(mockUpdateContactBook).not.toHaveBeenCalled();
@@ -169,6 +171,7 @@ describe("POST /v1/contactBooks", () => {
expect(mockCreateContactBook).toHaveBeenCalledWith(
1,
"Product Updates",
undefined,
mockTransactionClient,
);
expect(mockUpdateContactBook).toHaveBeenCalledWith(
@@ -224,6 +227,45 @@ describe("POST /v1/contactBooks", () => {
);
});
it("passes variables through createContactBook and returns them", async () => {
const created = buildContactBook({
id: "cb_4",
name: "Customers",
variables: ["company", "plan"],
});
mockCreateContactBook.mockResolvedValue(created);
const app = getApp();
createContactBookRoute(app);
const response = await app.request("http://localhost/api/v1/contactBooks", {
method: "POST",
headers: {
Authorization: "Bearer test-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Customers",
variables: ["company", "plan"],
}),
});
expect(response.status).toBe(200);
expect(mockCreateContactBook).toHaveBeenCalledWith(
1,
"Customers",
["company", "plan"],
mockTransactionClient,
);
const body = await response.json();
expect(body).toMatchObject({
id: "cb_4",
name: "Customers",
variables: ["company", "plan"],
});
});
it("returns BAD_REQUEST when name is missing", async () => {
const app = getApp();
createContactBookRoute(app);
@@ -23,6 +23,7 @@ const route = createRoute({
doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(),
variables: z.array(z.string()).optional(),
}),
},
},
@@ -54,7 +55,12 @@ function createContactBook(app: PublicAPIApp) {
body.doubleOptInContent !== undefined;
const contactBook = await db.$transaction(async (tx) => {
const created = await createContactBookService(team.id, body.name, tx);
const created = await createContactBookService(
team.id,
body.name,
body.variables,
tx,
);
if (!hasOptionalFields) {
return created;
@@ -77,6 +83,7 @@ function createContactBook(app: PublicAPIApp) {
return c.json({
...contactBook,
properties: contactBook.properties as Record<string, string>,
variables: contactBook.variables,
});
});
}
@@ -28,6 +28,7 @@ function getContactBooks(app: PublicAPIApp) {
const sanitizedContactBooks = contactBooks.map((contactBook) => ({
...contactBook,
properties: contactBook.properties as Record<string, string>,
variables: contactBook.variables,
}));
return c.json(sanitizedContactBooks);
@@ -29,6 +29,7 @@ const route = createRoute({
doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(),
variables: z.array(z.string()).optional(),
}),
},
},
@@ -80,6 +81,7 @@ function updateContactBook(app: PublicAPIApp) {
return c.json({
...updated,
properties: updated.properties as Record<string, string>,
variables: updated.variables,
});
});
}
+164 -37
View File
@@ -2,6 +2,7 @@ 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,
@@ -36,13 +37,88 @@ const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES =
});
const CONTACT_VARIABLE_REGEX =
/\{\{\s*(?:contact\.)?(email|firstName|lastName)(?:,fallback=([^}]+))?\s*\}\}/gi;
/\{\{\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>
) {
return CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES.some((regex) =>
sources.some((source) => (source ? regex.test(source) : false))
sources.some((source) => (source ? regex.test(source) : false)),
);
}
@@ -52,25 +128,38 @@ function replaceUnsubscribePlaceholders(html: string, url: string) {
}, html);
}
function replaceContactVariables(html: string, contact: Contact) {
function replaceContactVariables(
html: string,
contact: Contact,
allowedVariables: string[],
) {
return html.replace(
CONTACT_VARIABLE_REGEX,
(_, key: string, fallback?: string) => {
const valueMap: Record<string, string | null | undefined> = {
email: contact.email,
firstname: contact.firstName,
lastname: contact.lastName,
};
(match: string, key: string, fallback?: string) => {
const normalizedKey = key.toLowerCase();
const contactValue = valueMap[normalizedKey];
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 ?? "";
}
},
);
}
@@ -87,7 +176,7 @@ function sanitizeAddressList(addresses?: string | string[]) {
}
async function prepareCampaignHtml(
campaign: Campaign
campaign: Campaign,
): Promise<{ campaign: Campaign; html: string }> {
if (campaign.content) {
try {
@@ -120,10 +209,12 @@ async function renderCampaignHtmlForContact({
campaign,
contact,
unsubscribeUrl,
allowedVariables,
}: {
campaign: Campaign;
contact: Contact;
unsubscribeUrl: string;
allowedVariables: string[];
}) {
if (campaign.content) {
try {
@@ -135,13 +226,31 @@ async function renderCampaignHtmlForContact({
linkValues[token] = unsubscribeUrl;
}
return renderer.render({
shouldReplaceVariableValues: true,
variableValues: {
const variableValues = createCaseInsensitiveVariableValues({
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
...allowedVariables.reduce(
(acc, variable) => {
const value = getContactReplacementValue({
contact,
key: variable,
allowedVariables,
});
if (value !== undefined) {
acc[variable] = value;
}
return acc;
},
{} as Record<string, string | null | undefined>,
),
});
return renderer.render({
shouldReplaceVariableValues: true,
variableValues,
linkValues,
});
} catch (error) {
@@ -155,7 +264,7 @@ async function renderCampaignHtmlForContact({
}
let html = replaceUnsubscribePlaceholders(campaign.html, unsubscribeUrl);
html = replaceContactVariables(html, contact);
html = replaceContactVariables(html, contact, allowedVariables);
return html;
}
@@ -245,7 +354,7 @@ export async function createCampaignFromApi({
const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
sanitizedContent,
sanitizedHtml
sanitizedHtml,
);
if (!unsubPlaceholderFound) {
@@ -351,7 +460,7 @@ export async function sendCampaign(id: string) {
const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
campaign.content,
html
html,
);
if (!unsubPlaceholderFound) {
@@ -430,7 +539,7 @@ export async function scheduleCampaign({
const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
campaign.content,
html
html,
);
if (!unsubPlaceholderFound) {
throw new UnsendApiError({
@@ -699,6 +808,7 @@ export async function deleteCampaign(id: string, teamId: number) {
type CampaignEmailJob = {
contact: Contact;
campaign: Campaign;
allowedVariables: string[];
emailConfig: {
from: string;
subject: string;
@@ -714,12 +824,12 @@ type CampaignEmailJob = {
};
async function processContactEmail(jobData: CampaignEmailJob) {
const { contact, campaign, emailConfig } = jobData;
const { contact, campaign, emailConfig, allowedVariables } = jobData;
const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
const oneClickUnsubUrl = createOneClickUnsubUrl(
contact.id,
emailConfig.campaignId
emailConfig.campaignId,
);
// Check for suppressed emails before processing
@@ -734,18 +844,18 @@ async function processContactEmail(jobData: CampaignEmailJob) {
const suppressionResults = await SuppressionService.checkMultipleEmails(
allEmailsToCheck,
emailConfig.teamId
emailConfig.teamId,
);
// Filter each field separately
const filteredToEmails = toEmails.filter(
(email) => !suppressionResults[email]
(email) => !suppressionResults[email],
);
const filteredCcEmails = ccEmails.filter(
(email) => !suppressionResults[email]
(email) => !suppressionResults[email],
);
const filteredBccEmails = bccEmails.filter(
(email) => !suppressionResults[email]
(email) => !suppressionResults[email],
);
// Check if the contact's email (TO recipient) is suppressed
@@ -755,6 +865,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
campaign,
contact,
unsubscribeUrl,
allowedVariables,
});
if (isContactSuppressed) {
@@ -765,7 +876,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
campaignId: emailConfig.campaignId,
teamId: emailConfig.teamId,
},
"Contact email is suppressed. Creating suppressed email record."
"Contact email is suppressed. Creating suppressed email record.",
);
const email = await db.email.create({
@@ -821,7 +932,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
campaignId: emailConfig.campaignId,
teamId: emailConfig.teamId,
},
"Some CC recipients were suppressed and filtered out from campaign email."
"Some CC recipients were suppressed and filtered out from campaign email.",
);
}
@@ -833,7 +944,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
campaignId: emailConfig.campaignId,
teamId: emailConfig.teamId,
},
"Some BCC recipients were suppressed and filtered out from campaign email."
"Some BCC recipients were suppressed and filtered out from campaign email.",
);
}
@@ -866,7 +977,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
} catch (error) {
logger.error(
{ err: error },
"Failed to create campaign email record so skipping email sending"
"Failed to create campaign email record so skipping email sending",
);
return;
}
@@ -877,14 +988,14 @@ async function processContactEmail(jobData: CampaignEmailJob) {
emailConfig.teamId,
emailConfig.region,
false,
oneClickUnsubUrl
oneClickUnsubUrl,
);
}
export async function updateCampaignAnalytics(
campaignId: string,
emailStatus: EmailStatus,
hardBounce: boolean = false
hardBounce: boolean = false,
) {
const campaign = await db.campaign.findUnique({
where: { id: campaignId },
@@ -941,7 +1052,7 @@ export class CampaignBatchService {
connection: getRedis(),
prefix: BULL_PREFIX,
skipVersionCheck: true,
}
},
);
static worker = new Worker(
@@ -987,6 +1098,16 @@ export class CampaignBatchService {
const contacts = await db.contact.findMany({ where, ...pagination });
const contactBook = await db.contactBook.findUnique({
where: { id: campaign.contactBookId },
select: { variables: true },
});
const allowedVariables = [
...BUILT_IN_CONTACT_VARIABLES,
...(contactBook?.variables ?? []),
];
if (contacts.length === 0) {
// No more contacts -> mark SENT
await db.campaign.update({
@@ -1019,6 +1140,7 @@ export class CampaignBatchService {
await processContactEmail({
contact,
campaign,
allowedVariables,
emailConfig: {
from: campaign.from,
subject: campaign.subject,
@@ -1041,7 +1163,12 @@ export class CampaignBatchService {
data: { lastCursor: newCursor, lastSentAt: new Date() },
});
}),
{ connection: getRedis(), concurrency: 20, prefix: BULL_PREFIX, skipVersionCheck: true }
{
connection: getRedis(),
concurrency: 20,
prefix: BULL_PREFIX,
skipVersionCheck: true,
},
);
static async queueBatch({
@@ -1066,7 +1193,7 @@ export class CampaignBatchService {
if (elapsedMs < windowMs) {
logger.debug(
{ campaignId, remainingMs: windowMs - elapsedMs },
"Defensive skip enqueue; window not elapsed"
"Defensive skip enqueue; window not elapsed",
);
return;
}
@@ -1074,14 +1201,14 @@ export class CampaignBatchService {
} catch (err) {
logger.warn(
{ err, campaignId },
"Failed defensive window check; proceeding to enqueue"
"Failed defensive window check; proceeding to enqueue",
);
}
await this.batchQueue.add(
`campaign-${campaignId}`,
{ campaignId, teamId },
{ jobId: `campaign-batch:${campaignId}`, ...DEFAULT_QUEUE_OPTIONS }
{ jobId: `campaign-batch:${campaignId}`, ...DEFAULT_QUEUE_OPTIONS },
);
}
}
@@ -1,13 +1,17 @@
import { CampaignStatus } from "@prisma/client";
import { db } from "../db";
import { LimitService } from "./limit-service";
import { UnsendApiError } from "../public-api/api-error";
import {
DEFAULT_DOUBLE_OPT_IN_CONTENT,
DEFAULT_DOUBLE_OPT_IN_SUBJECT,
hasDoubleOptInUrlPlaceholder,
} from "~/lib/constants/double-opt-in";
import { db } from "../db";
import { UnsendApiError } from "../public-api/api-error";
import { validateDomainFromEmail } from "./domain-service";
import { LimitService } from "./limit-service";
import {
normalizeContactBookVariables,
validateContactBookVariables,
} from "./contact-variable-service";
type ContactBookDbClient = Pick<typeof db, "contactBook">;
@@ -22,6 +26,7 @@ export async function getContactBooks(teamId: number, search?: string) {
name: true,
teamId: true,
properties: true,
variables: true,
emoji: true,
createdAt: true,
updatedAt: true,
@@ -39,6 +44,7 @@ export async function getContactBooks(teamId: number, search?: string) {
export async function createContactBook(
teamId: number,
name: string,
variables?: string[],
client: ContactBookDbClient = db,
) {
const { isLimitReached, reason } =
@@ -51,11 +57,23 @@ export async function createContactBook(
});
}
const normalizedVariables = normalizeContactBookVariables(variables);
try {
validateContactBookVariables(normalizedVariables);
} catch (error) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Invalid variables",
});
}
const created = await client.contactBook.create({
data: {
name,
teamId,
properties: {},
variables: normalizedVariables,
doubleOptInEnabled: true,
doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT,
doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT,
@@ -98,6 +116,7 @@ export async function updateContactBook(
name?: string;
properties?: Record<string, string>;
emoji?: string;
variables?: string[];
doubleOptInEnabled?: boolean;
doubleOptInFrom?: string | null;
doubleOptInSubject?: string;
@@ -105,7 +124,39 @@ export async function updateContactBook(
},
client: ContactBookDbClient = db,
) {
const updateData = { ...data };
const restData = { ...data };
delete restData.variables;
const normalizedVariables =
data.variables === undefined
? undefined
: normalizeContactBookVariables(data.variables);
if (normalizedVariables !== undefined) {
try {
validateContactBookVariables(normalizedVariables);
} catch (error) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Invalid variables",
});
}
}
const updateData: {
name?: string;
properties?: Record<string, string>;
emoji?: string;
variables?: string[];
doubleOptInEnabled?: boolean;
doubleOptInSubject?: string;
doubleOptInContent?: string;
} = {
...restData,
...(normalizedVariables !== undefined
? { variables: normalizedVariables }
: {}),
};
if (data.doubleOptInFrom !== undefined) {
const normalizedFrom = data.doubleOptInFrom?.trim() ?? "";
@@ -84,6 +84,7 @@ describe("contact-book-service", () => {
name: "Newsletter",
teamId: 12,
properties: {},
variables: [],
doubleOptInEnabled: true,
doubleOptInSubject: DEFAULT_DOUBLE_OPT_IN_SUBJECT,
doubleOptInContent: DEFAULT_DOUBLE_OPT_IN_CONTENT,
+40 -2
View File
@@ -3,6 +3,10 @@ import {
type ContactPayload,
type ContactWebhookEventType,
} from "@usesend/lib/src/webhook/webhook-events";
import {
mergeContactProperties,
normalizeContactProperties,
} from "~/lib/contact-properties";
import { db } from "../db";
import { ContactQueueService } from "./contact-queue-service";
import { WebhookService } from "./webhook-service";
@@ -29,6 +33,7 @@ export async function addOrUpdateContact(
select: {
doubleOptInEnabled: true,
teamId: true,
variables: true,
},
});
@@ -47,6 +52,7 @@ export async function addOrUpdateContact(
select: {
subscribed: true,
unsubscribeReason: true,
properties: true,
},
});
@@ -75,6 +81,19 @@ export async function addOrUpdateContact(
existingContact === null &&
!isExplicitUnsubscribeRequest;
const normalizedProperties =
contact.properties === undefined
? undefined
: normalizeContactProperties(contact.properties, contactBook.variables);
const mergedProperties =
normalizedProperties === undefined
? undefined
: mergeContactProperties(
(existingContact?.properties as Record<string, unknown> | null) ?? {},
normalizedProperties,
contactBook.variables,
);
const savedContact = await db.contact.upsert({
where: {
contactBookId_email: {
@@ -87,7 +106,7 @@ export async function addOrUpdateContact(
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
properties: contact.properties ?? {},
properties: normalizedProperties ?? {},
subscribed: shouldCreatePendingContact
? false
: (contact.subscribed ?? true),
@@ -100,7 +119,9 @@ export async function addOrUpdateContact(
update: {
firstName: contact.firstName,
lastName: contact.lastName,
properties: contact.properties ?? {},
...(mergedProperties !== undefined
? { properties: mergedProperties }
: {}),
...(subscribedValue !== undefined
? {
subscribed: subscribedValue,
@@ -168,12 +189,29 @@ export async function updateContactInContactBook(
return null;
}
const contactBook = await db.contactBook.findUnique({
where: { id: contactBookId },
select: { variables: true },
});
const mergedProperties =
contact.properties === undefined
? undefined
: mergeContactProperties(
(existingContact.properties as Record<string, unknown> | null) ?? {},
contact.properties,
contactBook?.variables ?? [],
);
const updatedContact = await db.contact.update({
where: {
id: contactId,
},
data: {
...contact,
...(mergedProperties !== undefined
? { properties: mergedProperties }
: {}),
...(contact.subscribed !== undefined
? {
unsubscribeReason: contact.subscribed
@@ -14,6 +14,7 @@ const {
contact: {
findFirst: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
},
@@ -53,6 +54,7 @@ vi.mock("~/server/logger/log", () => ({
import {
addOrUpdateContact,
resendDoubleOptInConfirmationInContactBook,
updateContactInContactBook,
} from "~/server/service/contact-service";
const createdAt = new Date("2026-02-08T00:00:00.000Z");
@@ -62,6 +64,7 @@ describe("contact-service addOrUpdateContact", () => {
mockDb.contactBook.findUnique.mockReset();
mockDb.contact.findFirst.mockReset();
mockDb.contact.findUnique.mockReset();
mockDb.contact.update.mockReset();
mockDb.contact.upsert.mockReset();
mockWebhookEmit.mockReset();
mockSendDoubleOptInConfirmationEmail.mockReset();
@@ -233,6 +236,134 @@ describe("contact-service addOrUpdateContact", () => {
);
});
it("canonicalizes registered property keys when creating contacts", async () => {
mockDb.contactBook.findUnique.mockResolvedValue({
doubleOptInEnabled: false,
teamId: 7,
variables: ["company"],
});
mockDb.contact.findUnique.mockResolvedValue(null);
mockDb.contact.upsert.mockResolvedValue({
id: "contact_8",
email: "frank@example.com",
contactBookId: "book_1",
subscribed: true,
properties: { company: "Acme", tier: "gold" },
firstName: null,
lastName: null,
createdAt,
updatedAt: createdAt,
});
await addOrUpdateContact(
"book_1",
{
email: "frank@example.com",
properties: {
Company: "Acme",
tier: "gold",
},
},
7,
);
const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0];
expect(upsertArgs.create.properties).toEqual({
company: "Acme",
tier: "gold",
});
expect(upsertArgs.update.properties).toEqual({
company: "Acme",
tier: "gold",
});
});
it("preserves existing properties when upserting without properties", async () => {
mockDb.contactBook.findUnique.mockResolvedValue({
doubleOptInEnabled: false,
teamId: 7,
variables: ["company"],
});
mockDb.contact.findUnique.mockResolvedValue({
subscribed: true,
unsubscribeReason: null,
properties: {
company: "Acme",
tier: "gold",
},
});
mockDb.contact.upsert.mockResolvedValue({
id: "contact_10",
email: "preserve@example.com",
contactBookId: "book_1",
subscribed: true,
properties: {
company: "Acme",
tier: "gold",
},
firstName: "Updated",
lastName: null,
createdAt,
updatedAt: createdAt,
});
await addOrUpdateContact(
"book_1",
{ email: "preserve@example.com", firstName: "Updated" },
7,
);
const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0];
expect(upsertArgs.update).not.toHaveProperty("properties");
});
it("merges existing properties when upserting with partial properties", async () => {
mockDb.contactBook.findUnique.mockResolvedValue({
doubleOptInEnabled: false,
teamId: 7,
variables: ["company"],
});
mockDb.contact.findUnique.mockResolvedValue({
subscribed: true,
unsubscribeReason: null,
properties: {
Company: "Old Co",
tier: "gold",
},
});
mockDb.contact.upsert.mockResolvedValue({
id: "contact_11",
email: "merge@example.com",
contactBookId: "book_1",
subscribed: true,
properties: {
company: "New Co",
tier: "gold",
},
firstName: null,
lastName: null,
createdAt,
updatedAt: createdAt,
});
await addOrUpdateContact(
"book_1",
{
email: "merge@example.com",
properties: {
company: "New Co",
},
},
7,
);
const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0];
expect(upsertArgs.update.properties).toEqual({
company: "New Co",
tier: "gold",
});
});
it("throws when contact book does not exist", async () => {
mockDb.contactBook.findUnique.mockResolvedValue(null);
@@ -325,4 +456,59 @@ describe("contact-service addOrUpdateContact", () => {
).resolves.toBeNull();
expect(mockSendDoubleOptInConfirmationEmail).not.toHaveBeenCalled();
});
it("merges contact properties on update and canonicalizes registry variables", async () => {
mockDb.contact.findFirst.mockResolvedValue({
id: "contact_9",
email: "grace@example.com",
contactBookId: "book_1",
subscribed: true,
unsubscribeReason: null,
properties: {
Company: "Old Co",
notes: "keep me",
},
createdAt,
updatedAt: createdAt,
});
mockDb.contactBook.findUnique.mockResolvedValue({
variables: ["company", "plan"],
});
mockDb.contact.update.mockResolvedValue({
id: "contact_9",
email: "grace@example.com",
contactBookId: "book_1",
subscribed: true,
unsubscribeReason: null,
properties: {
company: "New Co",
notes: "keep me",
},
createdAt,
updatedAt: createdAt,
});
await updateContactInContactBook(
"contact_9",
"book_1",
{
properties: {
company: "New Co",
},
},
7,
);
expect(mockDb.contact.update).toHaveBeenCalledWith({
where: {
id: "contact_9",
},
data: {
properties: {
company: "New Co",
notes: "keep me",
},
},
});
});
});
@@ -0,0 +1,40 @@
export const CONTACT_VARIABLE_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
const RESERVED_CONTACT_VARIABLES = new Set(["email", "firstname", "lastname"]);
export function normalizeContactBookVariables(variables?: string[]): string[] {
if (!variables) {
return [];
}
const deduped = new Map<string, string>();
for (const variable of variables) {
const trimmed = variable.trim();
if (!trimmed) {
continue;
}
const normalized = trimmed.toLowerCase();
if (!deduped.has(normalized)) {
deduped.set(normalized, trimmed);
}
}
return Array.from(deduped.values());
}
export function validateContactBookVariables(variables: string[]) {
for (const variable of variables) {
if (!CONTACT_VARIABLE_NAME_REGEX.test(variable)) {
throw new Error(
`Variable "${variable}" contains invalid characters. Use only letters, numbers, and underscores.`,
);
}
if (RESERVED_CONTACT_VARIABLES.has(variable.toLowerCase())) {
throw new Error(
`Variable "${variable}" is reserved. Use a different name.`,
);
}
}
}
+7 -1
View File
@@ -69,6 +69,7 @@ export type EditorProps = {
initialContent?: Content;
variables?: Array<string>;
uploadImage?: UploadFn;
variableSuggestionsHelperText?: string;
};
export const Editor: React.FC<EditorProps> = ({
@@ -76,6 +77,7 @@ export const Editor: React.FC<EditorProps> = ({
initialContent,
variables,
uploadImage,
variableSuggestionsHelperText,
}) => {
const menuContainerRef = useRef(null);
@@ -96,7 +98,11 @@ export const Editor: React.FC<EditorProps> = ({
},
},
},
extensions: extensions({ variables, uploadImage }),
extensions: extensions({
variables,
uploadImage,
variableSuggestionsHelperText,
}),
onUpdate: ({ editor }) => {
onUpdate?.(editor);
},
@@ -21,9 +21,11 @@ import { ResizableImageExtension, UploadFn } from "./ImageExtension";
export function extensions({
variables,
uploadImage,
variableSuggestionsHelperText,
}: {
variables?: Array<string>;
uploadImage?: UploadFn;
variableSuggestionsHelperText?: string;
}) {
const extensions = [
StarterKit.configure({
@@ -79,7 +81,10 @@ export function extensions({
ButtonExtension,
GlobalDragHandle,
VariableExtension.configure({
suggestion: getVariableSuggestions(variables),
suggestion: getVariableSuggestions(
variables,
variableSuggestionsHelperText,
),
}),
UnsubscribeFooterExtension,
ResizableImageExtension.configure({ uploadImage }),
+19 -6
View File
@@ -36,7 +36,7 @@ export const VariableList = forwardRef((props: any, ref) => {
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length
(selectedIndex + props.items.length - 1) % props.items.length,
);
return true;
}
@@ -64,7 +64,7 @@ export const VariableList = forwardRef((props: any, ref) => {
onClick={() => selectItem(index)}
className={cn(
"flex w-full space-x-2 rounded-md px-2 py-1 text-left text-sm text-gray-900 hover:bg-gray-100",
index === selectedIndex ? "bg-gray-200" : "bg-white"
index === selectedIndex ? "bg-gray-200" : "bg-white",
)}
>
{item}
@@ -75,6 +75,11 @@ export const VariableList = forwardRef((props: any, ref) => {
No result
</button>
)}
{props.helperText ? (
<div className="px-2 pt-1 text-[11px] text-gray-400">
{props.helperText}
</div>
) : null}
</div>
);
});
@@ -82,14 +87,15 @@ export const VariableList = forwardRef((props: any, ref) => {
VariableList.displayName = "VariableList";
export function getVariableSuggestions(
variables: Array<string> = []
variables: Array<string> = [],
helperText?: string,
): Omit<SuggestionOptions, "editor"> {
return {
items: ({ query }) => {
return variables
.concat(query.length > 0 ? [query] : [])
.filter((item) => item.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5);
.slice(0, 10);
},
render: () => {
@@ -102,6 +108,10 @@ export function getVariableSuggestions(
props,
editor: props.editor,
});
component.updateProps({
...props,
helperText,
});
if (!props.clientRect) {
return;
@@ -119,7 +129,10 @@ export function getVariableSuggestions(
},
onUpdate(props) {
component.updateProps(props);
component.updateProps({
...props,
helperText,
});
if (!props.clientRect) {
return;
@@ -180,7 +193,7 @@ export function VariableComponent(props: NodeViewProps) {
<button
className={cn(
"inline-flex items-center justify-center rounded-md text-sm gap-1 ring-offset-white transition-colors",
"px-2 border border-gray-300 shadow-sm cursor-pointer text-foreground/80"
"px-2 border border-gray-300 shadow-sm cursor-pointer text-foreground/80",
)}
onClick={(e) => {
e.preventDefault();