diff --git a/apps/marketing/next-env.d.ts b/apps/marketing/next-env.d.ts
index 1b3be08..830fb59 100644
--- a/apps/marketing/next-env.d.ts
+++ b/apps/marketing/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index 1b3be08..830fb59 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx
index 44a83bd..e512763 100644
--- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx
+++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx
@@ -34,6 +34,7 @@ interface ParsedContact {
email: string;
firstName?: string;
lastName?: string;
+ subscribed?: boolean;
isValid: boolean;
}
@@ -76,45 +77,91 @@ export default function BulkUploadContacts({
const parseContactLine = (
line: string,
- ): { email: string; firstName?: string; lastName?: string } | null => {
+ isFirstLine: boolean = false,
+ ): {
+ email: string;
+ firstName?: string;
+ lastName?: string;
+ subscribed?: boolean;
+ } | null => {
const trimmedLine = line.trim();
if (!trimmedLine) return null;
- // Split by comma
- const parts = trimmedLine.split(",").map((s) => s.trim());
+ // Split by comma, handling quoted values
+ const parts: string[] = [];
+ let current = "";
+ let inQuotes = false;
+
+ for (let i = 0; i < trimmedLine.length; i++) {
+ const char = trimmedLine[i];
+ if (char === '"') {
+ inQuotes = !inQuotes;
+ } else if (char === "," && !inQuotes) {
+ parts.push(current.trim());
+ current = "";
+ } else {
+ current += char;
+ }
+ }
+ parts.push(current.trim());
if (parts.length === 0 || !parts[0]) return null;
- const email = parts[0].toLowerCase();
+ // 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
+ }
+ }
+
+ const email = parts[0]!.toLowerCase();
// Skip if doesn't look like an email
if (!email.includes("@")) return null;
- if (parts.length === 1) {
- // Just email
- return { email };
+ // 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;
+
+ if (parts.length >= 4) {
+ // Could be: email,firstName,lastName,subscribed
+ firstName = parts[1] || undefined;
+ lastName = parts[2] || undefined;
+ const subscribedValue = parts[3]?.toLowerCase();
+ if (subscribedValue === "yes" || subscribedValue === "true") {
+ subscribed = true;
+ } else if (subscribedValue === "no" || subscribedValue === "false") {
+ subscribed = false;
+ }
+ } else if (parts.length === 3) {
+ // email,firstName,lastName
+ firstName = parts[1] || undefined;
+ lastName = parts[2] || undefined;
} else if (parts.length === 2) {
// email,firstName
- return {
- email,
- firstName: parts[1] || undefined,
- };
- } else {
- // email,firstName,lastName (ignore anything beyond)
- return {
- email,
- firstName: parts[1] || undefined,
- lastName: parts[2] || undefined,
- };
+ firstName = parts[1] || undefined;
}
+
+ return {
+ email,
+ firstName,
+ lastName,
+ subscribed,
+ };
};
const parseContacts = (text: string): ParsedContact[] => {
const lines = text.split("\n");
const contactsMap = new Map();
- for (const line of lines) {
- const parsed = parseContactLine(line);
+ for (let i = 0; i < lines.length; i++) {
+ const parsed = parseContactLine(lines[i]!, i === 0);
if (parsed) {
// Use email as key to deduplicate
if (!contactsMap.has(parsed.email)) {
@@ -201,8 +248,8 @@ export default function BulkUploadContacts({
return;
}
- if (validContacts.length > 10000) {
- setError("Maximum 10,000 contacts allowed per upload");
+ if (validContacts.length > 50000) {
+ setError("Maximum 50,000 contacts allowed per upload");
setProcessing(false);
return;
}
@@ -220,6 +267,7 @@ export default function BulkUploadContacts({
email: c.email,
firstName: c.firstName,
lastName: c.lastName,
+ subscribed: c.subscribed,
})),
});
} catch {
@@ -255,23 +303,23 @@ export default function BulkUploadContacts({
Bulk Upload Contacts
-
- Upload multiple contacts at once. Supports email only or CSV format
- (email,firstName,lastName).
+
+ Upload multiple contacts at once. Cannot change from unsubscribed to
+ subscribed via upload
- Format: email,firstName,lastName (one per line)
+ Format: email,firstName,lastName,subscribed (one per line)
@@ -359,6 +407,7 @@ Format: email,firstName,lastName (firstName and lastName are optional)`}
Email
First Name
Last Name
+ Subscribed
Status
@@ -378,6 +427,17 @@ Format: email,firstName,lastName (firstName and lastName are optional)`}
-
)}
+
+ {contact.subscribed === undefined ? (
+
+ Default
+
+ ) : contact.subscribed ? (
+ Yes
+ ) : (
+ No
+ )}
+
{contact.isValid ? (
diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
index 709707a..281e54a 100644
--- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
+++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
@@ -32,6 +32,27 @@ import {
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
import { UnsubscribeReason } from "@prisma/client";
+import { Download } from "lucide-react";
+
+function sanitizeFilename(
+ name: string | undefined,
+ fallback = "contacts",
+): string {
+ if (!name) return fallback;
+
+ // Remove or replace unsafe characters:
+ // - Path separators: / \
+ // - Reserved characters: : * ? " < > |
+ // - Control characters (0x00-0x1F, 0x7F)
+ // - Single quotes and backticks
+ const sanitized = name.replace(/[/\\:*?"<>|'\x00-\x1F\x7F]/g, "-").trim();
+
+ // Limit length to prevent excessively long filenames (max 100 chars)
+ const limited = sanitized.slice(0, 100).trim();
+
+ // Return fallback if result is empty after sanitization
+ return limited || fallback;
+}
function getUnsubscribeReason(reason: UnsubscribeReason) {
switch (reason) {
@@ -48,8 +69,10 @@ function getUnsubscribeReason(reason: UnsubscribeReason) {
export default function ContactList({
contactBookId,
+ contactBookName,
}: {
contactBookId: string;
+ contactBookName?: string;
}) {
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
@@ -73,10 +96,80 @@ export default function ContactList({
setSearch(value);
}, 1000);
+ const exportQuery = api.contacts.exportContacts.useQuery(
+ {
+ contactBookId,
+ search: search ?? undefined,
+ subscribed:
+ status === "Subscribed"
+ ? true
+ : status === "Unsubscribed"
+ ? false
+ : undefined,
+ },
+ {
+ enabled: false,
+ },
+ );
+
+ const escapeCell = (str: string): string => {
+ // Wrap in quotes if contains comma, newline, or quote
+ if (str.includes(",") || str.includes("\n") || str.includes('"')) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+ };
+
+ const handleExport = async () => {
+ const result = await exportQuery.refetch();
+ if (!result.data) return;
+
+ // CSV Header
+ const headers = [
+ "Email",
+ "First Name",
+ "Last Name",
+ "Subscribed",
+ "Unsubscribe Reason",
+ "Created At",
+ ];
+
+ // CSV Rows
+ const rows = result.data.map((contact) => [
+ escapeCell(contact.email ?? ""),
+ escapeCell(contact.firstName ?? ""),
+ escapeCell(contact.lastName ?? ""),
+ escapeCell(contact.subscribed ? "Yes" : "No"),
+ escapeCell(contact.unsubscribeReason ?? ""),
+ escapeCell(contact.createdAt.toISOString()),
+ ]);
+
+ // Build CSV with UTF-8 BOM
+ const csvContent = [
+ headers.map(escapeCell).join(","),
+ ...rows.map((row) => row.join(",")),
+ ].join("\n");
+ const blob = new Blob([csvContent], {
+ type: "text/csv;charset=utf-8;",
+ });
+
+ // Download
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ const today = new Date().toISOString().split("T")[0];
+ const safeContactBookName = sanitizeFilename(contactBookName);
+ link.download = `contacts-${safeContactBookName}-${today}.csv`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ };
+
return (
-
+
debouncedSearch(e.target.value)}
/>
-
+
+
+
+
diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
index 1830b4e..57135b4 100644
--- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
+++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
@@ -198,7 +198,10 @@ export default function ContactsPage({
-
+
diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts
index 70258bb..416b291 100644
--- a/apps/web/src/server/api/routers/contacts.ts
+++ b/apps/web/src/server/api/routers/contacts.ts
@@ -129,7 +129,7 @@ export const contactsRouter = createTRPCRouter({
subscribed: z.boolean().optional(),
}),
)
- .max(10000),
+ .max(50000),
}),
)
.mutation(async ({ ctx: { contactBook, team }, input }) => {
@@ -161,4 +161,47 @@ export const contactsRouter = createTRPCRouter({
.mutation(async ({ input }) => {
return contactService.deleteContact(input.contactId);
}),
+
+ exportContacts: contactBookProcedure
+ .input(
+ z.object({
+ subscribed: z.boolean().optional(),
+ search: z.string().optional(),
+ }),
+ )
+ .query(async ({ ctx: { db }, input }) => {
+ const whereConditions: Prisma.ContactFindManyArgs["where"] = {
+ contactBookId: input.contactBookId,
+ ...(input.subscribed !== undefined
+ ? { subscribed: input.subscribed }
+ : {}),
+ ...(input.search
+ ? {
+ OR: [
+ { email: { contains: input.search, mode: "insensitive" } },
+ { firstName: { contains: input.search, mode: "insensitive" } },
+ { lastName: { contains: input.search, mode: "insensitive" } },
+ ],
+ }
+ : {}),
+ };
+
+ const contacts = await db.contact.findMany({
+ where: whereConditions,
+ select: {
+ email: true,
+ firstName: true,
+ lastName: true,
+ subscribed: true,
+ unsubscribeReason: true,
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ take: 100000, // Limit to 100k contacts to prevent memory issues
+ });
+
+ return contacts;
+ }),
});
diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts
index 6d5b082..6d49eec 100644
--- a/apps/web/src/server/service/contact-service.ts
+++ b/apps/web/src/server/service/contact-service.ts
@@ -11,8 +11,32 @@ export type ContactInput = {
export async function addOrUpdateContact(
contactBookId: string,
- contact: ContactInput
+ contact: ContactInput,
) {
+ // Check if contact exists to handle subscribed logic
+ const existingContact = await db.contact.findUnique({
+ where: {
+ contactBookId_email: {
+ contactBookId,
+ email: contact.email,
+ },
+ },
+ select: {
+ subscribed: true,
+ },
+ });
+
+ // Determine subscribed value for update
+ // Only allow Yes→No transitions (allow unsubscribe, prevent re-subscribe)
+ let subscribedValue: boolean | undefined = contact.subscribed;
+ if (existingContact && contact.subscribed !== undefined) {
+ // Block No→Yes (prevent re-subscribe via CSV), allow all other transitions
+ if (!existingContact.subscribed && contact.subscribed) {
+ subscribedValue = undefined; // Block re-subscribe
+ }
+ // All other cases (Yes→No, Yes→Yes, No→No) are allowed naturally
+ }
+
const createdContact = await db.contact.upsert({
where: {
contactBookId_email: {
@@ -26,13 +50,13 @@ export async function addOrUpdateContact(
firstName: contact.firstName,
lastName: contact.lastName,
properties: contact.properties ?? {},
- subscribed: contact.subscribed,
+ subscribed: contact.subscribed ?? true, // Default to subscribed for new contacts
},
update: {
firstName: contact.firstName,
lastName: contact.lastName,
properties: contact.properties ?? {},
- subscribed: contact.subscribed,
+ ...(subscribedValue !== undefined ? { subscribed: subscribedValue } : {}),
},
});
@@ -41,7 +65,7 @@ export async function addOrUpdateContact(
export async function updateContact(
contactId: string,
- contact: Partial
+ contact: Partial,
) {
return db.contact.update({
where: {
@@ -62,7 +86,7 @@ export async function deleteContact(contactId: string) {
export async function bulkAddContacts(
contactBookId: string,
contacts: Array,
- teamId?: number
+ teamId?: number,
) {
await ContactQueueService.addBulkContactJobs(contactBookId, contacts, teamId);