Skip to content

Commit 8b27ef2

Browse files
committed
feat(contacts): import contacts from CSV exports and export to CSV
POST /contacts/import (MCP import_contacts) upserts up to 1000 rows per request by email. Each row's source status is interpreted with a shared vocabulary (beehiiv Active/Paused/Inactive/Bounced/Pending, Mailchimp cleaned, ...) and a status only gets stricter: nobody is re-subscribed, suppressed addresses stay unsubscribed, bounced and complained rows are suppressed, paused contacts are tagged. Tags and custom fields merge, the earliest signup date wins, imports don't trigger sequences, and subscribed contacts over the plan limit are skipped. The response counts created/updated/unchanged/suppressed rows and lists skipped rows with reasons. GET /contacts/export (MCP export_contacts) returns CSV with status, tags, created_at, suppression reason and custom field columns; it round-trips through the importer. Cells are formula-injection safe. The Contacts page gets an Import dialog (CSV parsed in the browser, column mapping pre-filled from common export headers, chunked upload, report) and an Export button. Refs #38, #79
1 parent 35597ee commit 8b27ef2

27 files changed

Lines changed: 1754 additions & 47 deletions

File tree

‎apps/api/src/contacts/export.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { CustomFields } from "@sendlit/api-contract";
2+
import { and, asc, gt } from "drizzle-orm";
3+
import type { SuppressionReason } from "../config/constants";
4+
import { db } from "../db/client";
5+
import { contacts } from "../db/schema";
6+
import { getActiveSuppressionReasons } from "../delivery-feedback/suppression-queries";
7+
import { normalizeEmail } from "../utils/email";
8+
import { type Contact, listContactsConditions } from "./queries";
9+
import type { ContactFilterWithAggregator } from "./segment";
10+
11+
const PAGE_SIZE = 1000;
12+
13+
/** Quotes a CSV cell (RFC 4180) and defuses spreadsheet formulas: a cell
14+
* starting with = + - @ or a tab/CR is prefixed with `'` (OWASP CSV
15+
* injection guidance). */
16+
export function csvCell(value: unknown): string {
17+
let text = value === null || value === undefined ? "" : String(value);
18+
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
19+
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
20+
}
21+
22+
function exportStatus(contact: Contact, reason: SuppressionReason | undefined) {
23+
if (reason === "complaint") return "complained";
24+
if (reason && reason !== "manual") return "bounced";
25+
return contact.subscribed && !reason ? "subscribed" : "unsubscribed";
26+
}
27+
28+
function customFieldCell(value: CustomFields[string] | undefined): string {
29+
if (value === undefined) return "";
30+
return Array.isArray(value) ? value.join(", ") : String(value);
31+
}
32+
33+
/**
34+
* Every contact of the team, or those matching `filter`, as CSV: email,
35+
* name, status, tags, created_at, suppression_reason, then one column per
36+
* custom field. The header names round-trip through the contact importer.
37+
*/
38+
export async function exportContactsCsv({
39+
teamId,
40+
filter,
41+
}: {
42+
teamId: string;
43+
filter?: ContactFilterWithAggregator[];
44+
}): Promise<{ csv: string; count: number }> {
45+
const rows: { contact: Contact; reason?: SuppressionReason }[] = [];
46+
let lastId: string | undefined;
47+
while (true) {
48+
const page = await db
49+
.select()
50+
.from(contacts)
51+
.where(
52+
and(
53+
listContactsConditions({ teamId, filter }),
54+
lastId ? gt(contacts.id, lastId) : undefined,
55+
),
56+
)
57+
.orderBy(asc(contacts.id))
58+
.limit(PAGE_SIZE);
59+
if (page.length === 0) break;
60+
const reasons = await getActiveSuppressionReasons(
61+
teamId,
62+
page.map((contact) => contact.email),
63+
);
64+
for (const contact of page) {
65+
rows.push({ contact, reason: reasons.get(normalizeEmail(contact.email)) });
66+
}
67+
lastId = page.at(-1)?.id;
68+
if (page.length < PAGE_SIZE) break;
69+
}
70+
71+
const customFieldKeys = [
72+
...new Set(
73+
rows.flatMap(({ contact }) => Object.keys(contact.customFields as CustomFields)),
74+
),
75+
].sort();
76+
const header = [
77+
"email",
78+
"name",
79+
"status",
80+
"tags",
81+
"created_at",
82+
"suppression_reason",
83+
...customFieldKeys,
84+
];
85+
const lines = [header.map(csvCell).join(",")];
86+
for (const { contact, reason } of rows) {
87+
const customFields = contact.customFields as CustomFields;
88+
lines.push(
89+
[
90+
contact.email,
91+
contact.name,
92+
exportStatus(contact, reason),
93+
contact.tags.join(", "),
94+
contact.createdAt?.toISOString(),
95+
reason,
96+
...customFieldKeys.map((key) => customFieldCell(customFields[key])),
97+
]
98+
.map(csvCell)
99+
.join(","),
100+
);
101+
}
102+
return { csv: `${lines.join("\r\n")}\r\n`, count: rows.length };
103+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { beforeEach, describe, expect, it, mock } from "bun:test";
2+
import { eq } from "drizzle-orm";
3+
4+
mock.module("../db/client", async () => {
5+
const { makeTestDb } = await import("../test/db.js");
6+
return { db: await makeTestDb() };
7+
});
8+
9+
let subscribedContactsLimit: number | null = null;
10+
const entitlements = await import("../billing/entitlements");
11+
// mock.module rewrites the live namespace, so keep the real function first.
12+
const realEntitlements = entitlements.getOrganizationEntitlements;
13+
mock.module("../billing/entitlements", () => ({
14+
...entitlements,
15+
getOrganizationEntitlements: async (organizationId: string) => ({
16+
...(await realEntitlements(organizationId)),
17+
subscribedContactsLimit,
18+
}),
19+
}));
20+
21+
const { db } = await import("../db/client");
22+
const { contactCustomFieldValues, contacts } = await import("../db/schema");
23+
const { addOrStrengthenSuppression, getActiveSuppression } = await import(
24+
"../delivery-feedback/suppression-queries"
25+
);
26+
const { seedTeamAndContact, truncateAll } = await import("../test/db");
27+
type TestDb = import("../test/db").TestDb;
28+
const { importContacts } = await import("./import");
29+
const { exportContactsCsv, csvCell } = await import("./export");
30+
31+
const tdb = db as unknown as TestDb;
32+
33+
beforeEach(async () => {
34+
await truncateAll(tdb);
35+
subscribedContactsLimit = null;
36+
});
37+
38+
async function contactByEmail(email: string) {
39+
const [row] = await tdb.select().from(contacts).where(eq(contacts.email, email));
40+
return row;
41+
}
42+
43+
describe("importContacts", () => {
44+
it("maps a beehiiv export's statuses", async () => {
45+
const { team } = await seedTeamAndContact(tdb);
46+
47+
const result = await importContacts({
48+
teamId: team.id,
49+
contacts: [
50+
{ row: 2, email: "Active@Example.com", status: "Active" },
51+
{ row: 3, email: "paused@example.com", status: "Paused" },
52+
{ row: 4, email: "inactive@example.com", status: "Inactive" },
53+
{ row: 5, email: "bounced@example.com", status: "Bounced" },
54+
{ row: 6, email: "pending@example.com", status: "Pending" },
55+
{ row: 7, email: "approval@example.com", status: "Needs Approval" },
56+
{ row: 8, email: "weird@example.com", status: "Frozen" },
57+
{ row: 9, email: "not-an-email", status: "Active" },
58+
{ row: 10, email: "active@example.com", status: "Active" },
59+
],
60+
tags: ["beehiiv"],
61+
});
62+
63+
expect(result).toMatchObject({
64+
created: 4,
65+
updated: 0,
66+
unchanged: 0,
67+
suppressed: 1,
68+
});
69+
expect(result.skipped).toEqual([
70+
{ row: 6, email: "pending@example.com", reason: "pending_confirmation" },
71+
{ row: 7, email: "approval@example.com", reason: "pending_confirmation" },
72+
{ row: 8, email: "weird@example.com", reason: "unknown_status" },
73+
{ row: 9, email: "not-an-email", reason: "invalid_email" },
74+
{ row: 10, email: "active@example.com", reason: "duplicate" },
75+
]);
76+
expect(await contactByEmail("active@example.com")).toMatchObject({
77+
subscribed: true,
78+
tags: ["beehiiv"],
79+
});
80+
expect(await contactByEmail("paused@example.com")).toMatchObject({
81+
subscribed: true,
82+
tags: ["beehiiv", "paused"],
83+
});
84+
expect((await contactByEmail("inactive@example.com")).subscribed).toBe(false);
85+
expect((await contactByEmail("bounced@example.com")).subscribed).toBe(false);
86+
expect((await getActiveSuppression(team.id, "bounced@example.com"))?.reason).toBe(
87+
"hard_bounce",
88+
);
89+
});
90+
91+
it("imports pending contacts unsubscribed when asked", async () => {
92+
const { team } = await seedTeamAndContact(tdb);
93+
const result = await importContacts({
94+
teamId: team.id,
95+
contacts: [{ email: "pending@example.com", status: "Pending" }],
96+
pending: "unsubscribed",
97+
});
98+
expect(result.created).toBe(1);
99+
expect(await contactByEmail("pending@example.com")).toMatchObject({
100+
subscribed: false,
101+
tags: ["pending"],
102+
});
103+
});
104+
105+
it("never re-subscribes and keeps suppressed addresses unsubscribed", async () => {
106+
const { team } = await seedTeamAndContact(tdb);
107+
await importContacts({
108+
teamId: team.id,
109+
contacts: [
110+
{ email: "left@example.com", status: "unsubscribed" },
111+
{ email: "stays@example.com", status: "subscribed" },
112+
],
113+
});
114+
await addOrStrengthenSuppression({
115+
teamId: team.id,
116+
recipientEmail: "complainer@example.com",
117+
reason: "complaint",
118+
actorType: "system",
119+
});
120+
121+
const result = await importContacts({
122+
teamId: team.id,
123+
contacts: [
124+
{ email: "left@example.com", status: "Active" },
125+
{ email: "stays@example.com", status: "cleaned" },
126+
{ email: "complainer@example.com", status: "Active" },
127+
],
128+
});
129+
130+
expect(result).toMatchObject({
131+
created: 1,
132+
updated: 1,
133+
unchanged: 1,
134+
suppressed: 1,
135+
});
136+
expect((await contactByEmail("left@example.com")).subscribed).toBe(false);
137+
expect((await contactByEmail("stays@example.com")).subscribed).toBe(false);
138+
expect((await contactByEmail("complainer@example.com")).subscribed).toBe(false);
139+
});
140+
141+
it("merges tags and custom fields, indexes them and keeps the earliest signup", async () => {
142+
const { team } = await seedTeamAndContact(tdb);
143+
await importContacts({
144+
teamId: team.id,
145+
contacts: [
146+
{
147+
email: "reader@example.com",
148+
tags: ["a"],
149+
customFields: { plan: "free" },
150+
createdAt: "2024-03-01T00:00:00Z",
151+
},
152+
],
153+
});
154+
const result = await importContacts({
155+
teamId: team.id,
156+
contacts: [
157+
{
158+
email: "reader@example.com",
159+
name: "Reader",
160+
tags: ["b"],
161+
customFields: { city: "Pune" },
162+
createdAt: "2023-01-15T00:00:00Z",
163+
},
164+
],
165+
});
166+
167+
expect(result.updated).toBe(1);
168+
const reader = await contactByEmail("reader@example.com");
169+
expect(reader).toMatchObject({
170+
name: "Reader",
171+
tags: ["a", "b"],
172+
customFields: { plan: "free", city: "Pune" },
173+
subscribed: true,
174+
});
175+
expect(reader.createdAt?.toISOString()).toBe("2023-01-15T00:00:00.000Z");
176+
const indexed = await tdb
177+
.select({ key: contactCustomFieldValues.key })
178+
.from(contactCustomFieldValues)
179+
.where(eq(contactCustomFieldValues.contactId, reader.id));
180+
expect(indexed.map((row) => row.key).sort()).toEqual(["city", "plan"]);
181+
182+
const again = await importContacts({
183+
teamId: team.id,
184+
contacts: [{ email: "reader@example.com", tags: ["a"] }],
185+
});
186+
expect(again.unchanged).toBe(1);
187+
});
188+
189+
it("skips new subscribed contacts beyond the plan's limit", async () => {
190+
const { team } = await seedTeamAndContact(tdb);
191+
subscribedContactsLimit = 2;
192+
193+
const result = await importContacts({
194+
teamId: team.id,
195+
contacts: [
196+
{ email: "one@example.com" },
197+
{ email: "two@example.com" },
198+
{ email: "gone@example.com", status: "unsubscribed" },
199+
],
200+
});
201+
202+
// The seeded contact already uses one of the two slots.
203+
expect(result.created).toBe(2);
204+
expect(result.skipped).toEqual([
205+
{ email: "two@example.com", reason: "plan_limit_reached" },
206+
]);
207+
});
208+
});
209+
210+
describe("exportContactsCsv", () => {
211+
it("exports statuses, tags, custom fields and suppressions in importable columns", async () => {
212+
const { team } = await seedTeamAndContact(tdb);
213+
await tdb.delete(contacts).where(eq(contacts.teamId, team.id));
214+
await importContacts({
215+
teamId: team.id,
216+
contacts: [
217+
{
218+
email: "a@example.com",
219+
name: "Ann, Jr.",
220+
tags: ["x", "y"],
221+
customFields: { city: "Pune" },
222+
createdAt: "2024-01-02T03:04:05Z",
223+
},
224+
{ email: "b@example.com", status: "complained" },
225+
{ email: "c@example.com", name: "=HYPERLINK(1)", status: "unsubscribed" },
226+
],
227+
});
228+
229+
const { csv, count } = await exportContactsCsv({ teamId: team.id });
230+
const lines = csv.trimEnd().split("\r\n");
231+
232+
expect(count).toBe(3);
233+
expect(lines[0]).toBe("email,name,status,tags,created_at,suppression_reason,city");
234+
expect(lines).toContain(
235+
'a@example.com,"Ann, Jr.",subscribed,"x, y",2024-01-02T03:04:05.000Z,,Pune',
236+
);
237+
expect(lines.find((line) => line.startsWith("b@"))).toContain(
238+
"b@example.com,,complained,",
239+
);
240+
expect(lines.find((line) => line.startsWith("b@"))).toContain(",complaint,");
241+
expect(lines.find((line) => line.startsWith("c@"))).toContain(
242+
"c@example.com,'=HYPERLINK(1),unsubscribed",
243+
);
244+
});
245+
246+
it("quotes and defuses cells", () => {
247+
expect(csvCell('say "hi"')).toBe('"say ""hi"""');
248+
expect(csvCell("-1+1")).toBe("'-1+1");
249+
expect(csvCell(null)).toBe("");
250+
});
251+
});

0 commit comments

Comments
 (0)