Skip to content

Commit 6a2f75d

Browse files
committed
feat: add authenticated Resend subscriber sync
1 parent ea878f7 commit 6a2f75d

11 files changed

Lines changed: 340 additions & 19 deletions

File tree

.vercelignore

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Dependencies
2+
node_modules
3+
.pnp
4+
.pnp.js
5+
6+
# Local build and cache output
7+
.next
8+
**/.next
9+
.turbo
10+
**/.turbo
11+
.content-collections
12+
**/.content-collections
13+
coverage
14+
**/coverage
15+
16+
# Test artifacts
17+
test-results
18+
**/test-results
19+
playwright-report
20+
**/playwright-report
21+
blob-report
22+
**/blob-report
23+
*.trace.zip
24+
25+
# Local environment and tooling state
26+
.env
27+
.env.*
28+
.vercel
29+
.DS_Store
30+
*.pem
31+
*.tsbuildinfo
32+
next-env.d.ts
33+
34+
# Local/generated agent artifacts
35+
.artifacts
36+
.claude
37+
.agents/skills
38+
.cursor
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
const mockGetSession = jest.fn()
6+
const mockCreate = jest.fn()
7+
const originalApiKey = process.env.RESEND_API_KEY
8+
9+
process.env.RESEND_API_KEY = 're_test_key'
10+
11+
jest.mock('@repo/auth/auth', () => ({
12+
auth: {
13+
api: {
14+
getSession: mockGetSession
15+
}
16+
}
17+
}))
18+
19+
jest.mock('resend', () => ({
20+
Resend: jest.fn().mockImplementation(() => ({
21+
contacts: {
22+
create: mockCreate
23+
}
24+
}))
25+
}))
26+
27+
jest.mock('next/headers', () => ({
28+
headers: jest.fn()
29+
}))
30+
31+
jest.mock('@/lib/telemetry-server', () => ({
32+
captureServerException: jest.fn()
33+
}))
34+
35+
const { headers } = require('next/headers')
36+
const { POST } = require('../route')
37+
38+
describe('subscribe me route', () => {
39+
afterAll(() => {
40+
if (originalApiKey === undefined) {
41+
delete process.env.RESEND_API_KEY
42+
} else {
43+
process.env.RESEND_API_KEY = originalApiKey
44+
}
45+
})
46+
47+
beforeEach(() => {
48+
jest.clearAllMocks()
49+
headers.mockResolvedValue(new Headers())
50+
})
51+
52+
it('requires an authenticated user', async () => {
53+
mockGetSession.mockResolvedValue(null)
54+
55+
const response = await POST()
56+
57+
expect(response.status).toBe(401)
58+
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
59+
})
60+
61+
it('syncs the current signed-in user email to Resend', async () => {
62+
mockGetSession.mockResolvedValue({
63+
user: {
64+
email: ' test@example.com ',
65+
id: 'user-1'
66+
}
67+
})
68+
mockCreate.mockResolvedValue({ data: { id: 'contact-1' }, error: null })
69+
70+
const response = await POST()
71+
72+
expect(mockCreate).toHaveBeenCalledWith(
73+
expect.objectContaining({
74+
email: 'test@example.com',
75+
properties: expect.objectContaining({
76+
product: 'newsletter',
77+
user_type: 'subscriber'
78+
})
79+
})
80+
)
81+
expect(response.status).toBe(201)
82+
await expect(response.json()).resolves.toEqual({
83+
id: 'contact-1',
84+
status: 'created',
85+
success: true
86+
})
87+
})
88+
89+
it('returns success when the current user already exists in Resend', async () => {
90+
mockGetSession.mockResolvedValue({
91+
user: {
92+
email: 'test@example.com',
93+
id: 'user-1'
94+
}
95+
})
96+
mockCreate.mockResolvedValue({
97+
data: null,
98+
error: {
99+
message: 'Contact already exists',
100+
name: 'validation_error'
101+
}
102+
})
103+
104+
const response = await POST()
105+
106+
expect(response.status).toBe(200)
107+
await expect(response.json()).resolves.toEqual({
108+
status: 'already_exists',
109+
success: true
110+
})
111+
})
112+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { auth } from '@repo/auth/auth'
2+
import { addSubscriberContact } from '@repo/emails/server'
3+
import { headers } from 'next/headers'
4+
import { NextResponse } from 'next/server'
5+
import { captureServerException } from '@/lib/telemetry-server'
6+
7+
/**
8+
* Add the current signed-in user's email to the mailing list audience.
9+
*/
10+
export async function POST() {
11+
try {
12+
const session = await auth.api.getSession({ headers: await headers() })
13+
if (!session?.user?.id) {
14+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
15+
}
16+
17+
const email = session.user.email?.trim()
18+
if (!email) {
19+
return NextResponse.json({ error: 'Current user has no email' }, { status: 400 })
20+
}
21+
22+
const result = await addSubscriberContact(email)
23+
if (!result.success) {
24+
return NextResponse.json({ error: result.error }, { status: result.statusCode })
25+
}
26+
27+
return NextResponse.json(result, { status: result.status === 'created' ? 201 : 200 })
28+
} catch (error) {
29+
captureServerException(error, { route: '/api/subscribe/me' })
30+
return NextResponse.json({ error: 'Failed to add contact' }, { status: 500 })
31+
}
32+
}

apps/web/app/api/subscribe/route.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createResendContactPayload, getResendClient } from '@repo/emails/server'
1+
import { addSubscriberContact } from '@repo/emails/server'
22
import { NextResponse } from 'next/server'
33
import { captureServerException } from '@/lib/telemetry-server'
44

@@ -32,23 +32,12 @@ export async function POST(request: Request) {
3232
return NextResponse.json({ error: 'Missing email' }, { status: 400 })
3333
}
3434

35-
const resend = getResendClient()
36-
if (!resend) {
37-
return NextResponse.json({ error: 'Resend not configured' }, { status: 503 })
35+
const result = await addSubscriberContact(email)
36+
if (!result.success) {
37+
return NextResponse.json({ error: result.error }, { status: result.statusCode })
3838
}
3939

40-
const { error } = await resend.contacts.create(
41-
createResendContactPayload(email, 'subscriber', process.env.RESEND_AUDIENCE_ID)
42-
)
43-
44-
if (error) {
45-
const message = error.message ?? 'Failed to add contact'
46-
const statusCode =
47-
error.name === 'validation_error' || message.toLowerCase().includes('already') ? 400 : 500
48-
return NextResponse.json({ error: message }, { status: statusCode })
49-
}
50-
51-
return NextResponse.json({ success: true }, { status: 201 })
40+
return NextResponse.json(result, { status: result.status === 'created' ? 201 : 200 })
5241
} catch (error) {
5342
captureServerException(error, { route: '/api/subscribe' })
5443
return NextResponse.json({ error: 'Failed to add contact' }, { status: 500 })

apps/web/jest.config.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const customJestConfig = {
2323
'^@repo/design-system/custom/(.*)$': '<rootDir>/../../packages/design-system/src/custom/$1.tsx',
2424
'^@repo/design-system/ui/(.*)$': '<rootDir>/../../packages/design-system/src/ui/$1.tsx',
2525
'^@repo/design-system/icons$': '<rootDir>/../../packages/design-system/src/icons.ts',
26+
'^@repo/emails/server$': '<rootDir>/../../packages/emails/src/server.ts',
2627
'^.*custom/(.*)/src$': '<rootDir>/../../packages/design-system/src/custom/$1.tsx',
2728
'^.*packages/design-system/custom/(.*)/src$':
2829
'<rootDir>/../../packages/design-system/src/custom/$1.tsx',

apps/web/tsconfig.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"content-collections": ["./.content-collections/generated"],
1414
"@repo/*": ["../../packages/*/src"],
1515
"@repo/auth": ["../../packages/auth/src/auth.ts"],
16-
"@repo/auth/*": ["../../packages/auth/src/*"]
16+
"@repo/auth/*": ["../../packages/auth/src/*"],
17+
"@repo/emails": ["../../packages/emails/src/index.ts"],
18+
"@repo/emails/server": ["../../packages/emails/src/server.ts"]
1719
}
1820
},
1921
"include": [

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"test:guide-structure": "node --import tsx --test scripts/guide-structure/__tests__/*.test.ts scripts/validate/__tests__/validate-guides.test.ts",
2626
"typecheck": "turbo typecheck",
2727
"clean": "turbo clean && rm -rf node_modules",
28-
"prepare": "lefthook install",
28+
"prepare": "node scripts/setup/install-lefthook.mjs",
2929
"lefthook:run": "lefthook run pre-commit",
3030
"qmd:setup": "./scripts/setup-qmd.sh",
3131
"qmd:embed": "./scripts/setup-qmd.sh --embed",

packages/emails/src/__tests__/server.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe('@repo/emails/server', () => {
1313
} else {
1414
process.env.RESEND_TOPIC_ID = originalTopicId
1515
}
16+
jest.dontMock('resend')
1617
jest.resetModules()
1718
})
1819

@@ -77,4 +78,60 @@ describe('@repo/emails/server', () => {
7778
topics: [{ id: 'cdb0d440-4825-412a-8152-29681e8155b7', subscription: 'opt_in' }]
7879
})
7980
})
81+
82+
it('adds subscriber contacts through Resend', async () => {
83+
process.env.RESEND_API_KEY = 're_test_key'
84+
const mockCreate = jest.fn().mockResolvedValue({ data: { id: 'contact-1' }, error: null })
85+
jest.doMock('resend', () => ({
86+
Resend: jest.fn().mockImplementation(() => ({
87+
contacts: {
88+
create: mockCreate
89+
}
90+
}))
91+
}))
92+
93+
const { addSubscriberContact } = await import('../server')
94+
95+
await expect(addSubscriberContact(' test@example.com ', ' segment-1 ')).resolves.toEqual({
96+
id: 'contact-1',
97+
status: 'created',
98+
success: true
99+
})
100+
expect(mockCreate).toHaveBeenCalledWith({
101+
email: 'test@example.com',
102+
properties: {
103+
product: 'newsletter',
104+
user_type: 'subscriber',
105+
language: 'en',
106+
source_domain: 'frontendchecklist.io',
107+
brand: 'frontendchecklist'
108+
},
109+
segments: [{ id: 'segment-1' }],
110+
topics: [{ id: 'cdb0d440-4825-412a-8152-29681e8155b7', subscription: 'opt_in' }]
111+
})
112+
})
113+
114+
it('treats existing Resend contacts as a successful sync', async () => {
115+
process.env.RESEND_API_KEY = 're_test_key'
116+
jest.doMock('resend', () => ({
117+
Resend: jest.fn().mockImplementation(() => ({
118+
contacts: {
119+
create: jest.fn().mockResolvedValue({
120+
data: null,
121+
error: {
122+
message: 'Contact already exists',
123+
name: 'validation_error'
124+
}
125+
})
126+
}
127+
}))
128+
}))
129+
130+
const { addSubscriberContact } = await import('../server')
131+
132+
await expect(addSubscriberContact('test@example.com')).resolves.toEqual({
133+
status: 'already_exists',
134+
success: true
135+
})
136+
})
80137
})

0 commit comments

Comments
 (0)