Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions packages/resend/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import 'dotenv/config';
import { makeResendRequest } from './client';
import type {
ContactsCreateResponse,
ContactsDeleteResponse,
ContactsGetResponse,
ContactsListResponse,
ContactsUpdateResponse,
CreateDomainResponse,
DeleteDomainResponse,
EmailsBatchResponse,
EmailsCancelResponse,
GetDomainResponse,
GetEmailResponse,
ListDomainsResponse,
Expand Down Expand Up @@ -69,6 +76,69 @@ describe('Resend API Type Tests', () => {

ResendEndpointOutputSchemas.emailsGet.parse(result);
});

it('emailsBatch returns correct type', async () => {
const response = await makeResendRequest<EmailsBatchResponse>(
'emails/batch',
TEST_API_KEY,
{
method: 'POST',
body: {
emails: [
{
from: TEST_FROM_EMAIL,
to: [TEST_TO_EMAIL],
subject: `Batch test ${Date.now()}-1`,
html: '<p>batch 1</p>',
},
{
from: TEST_FROM_EMAIL,
to: [TEST_TO_EMAIL],
subject: `Batch test ${Date.now()}-2`,
html: '<p>batch 2</p>',
},
],
},
},
);
const result = response;

ResendEndpointOutputSchemas.emailsBatch.parse(result);
expect(Array.isArray(result.data)).toBe(true);
});

it('emailsCancel returns correct type', async () => {
// Create a scheduled email then cancel it — the batch/cancel
// endpoint only accepts scheduled emails, and the per-email
// cancel endpoint uses POST /emails/{id}/cancel.
const scheduledBody: Record<string, unknown> = {
from: 'test@example.com',
to: ['recipient@example.com'],
Comment thread
Dhirenderchoudhary marked this conversation as resolved.
Outdated
subject: 'Test scheduled email',
html: '<p>Test</p>',
text: 'Test',
scheduled_at: new Date(Date.now() + 60_000).toISOString(),
};
const created = await makeResendRequest<SendEmailResponse>(
'emails',
TEST_API_KEY,
{ method: 'POST', body: scheduledBody },
);
const emailId = created.id;
if (!emailId) {
return;
}

const response = await makeResendRequest<EmailsCancelResponse>(
`emails/${emailId}/cancel`,
TEST_API_KEY,
{ method: 'POST' },
Comment thread
greptile-apps[bot] marked this conversation as resolved.
);
const result = response;

ResendEndpointOutputSchemas.emailsCancel.parse(result);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(result.cancelled).toBe(true);
});
});

describe('domains', () => {
Expand Down Expand Up @@ -175,4 +245,125 @@ describe('Resend API Type Tests', () => {
ResendEndpointOutputSchemas.domainsDelete.parse(result);
});
});

describe('contacts', () => {
// Use a unique email per run so re-runs don't collide on the
// Resend-side "contact already exists" path.
const testContactEmail = `corsair-test+${Date.now()}@example.com`;

it('contactsCreate returns correct type', async () => {
const response = await makeResendRequest<ContactsCreateResponse>(
'contacts',
TEST_API_KEY,
{
method: 'POST',
body: {
email: testContactEmail,
first_name: 'Corsair',
last_name: 'Test',
unsubscribed: false,
},
},
);
const result = response;

ResendEndpointOutputSchemas.contactsCreate.parse(result);
// Resend returns only { object, id }; store the ID for cleanup.
expect(result.id).toMatch(/^contact_/);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

it('contactsList returns correct type', async () => {
const response = await makeResendRequest<ContactsListResponse>(
'contacts',
TEST_API_KEY,
{ query: { limit: 10 } },
);
const result = response;

ResendEndpointOutputSchemas.contactsList.parse(result);
expect(Array.isArray(result.data)).toBe(true);
});

it('contactsGet returns correct type', async () => {
// Find the contact we just created via list, by email match.
const list = await makeResendRequest<ContactsListResponse>(
'contacts',
TEST_API_KEY,
{ query: { limit: 100 } },
);
const found = list.data.find((c) => c.email === testContactEmail);
if (!found) {
return;
}

const response = await makeResendRequest<ContactsGetResponse>(
`contacts/${found.id}`,
TEST_API_KEY,
);
const result = response;

ResendEndpointOutputSchemas.contactsGet.parse(result);
expect(result.id).toBe(found.id);
});

it('contactsUpdate returns correct type and persists first_name', async () => {
const list = await makeResendRequest<ContactsListResponse>(
'contacts',
TEST_API_KEY,
{ query: { limit: 100 } },
);
const found = list.data.find((c) => c.email === testContactEmail);
if (!found) {
return;
}

const response = await makeResendRequest<ContactsUpdateResponse>(
`contacts/${found.id}`,
TEST_API_KEY,
{
method: 'PATCH',
body: {
first_name: 'CorsairUpdated',
},
},
);
const result = response;

ResendEndpointOutputSchemas.contactsUpdate.parse(result);
// PATCH returns only { object, id }; verify the id round-trips
// and then fetch the full contact to verify first_name.
expect(result.id).toBe(found.id);

// Fetch the contact from the API to verify persistence.
const getResponse = await makeResendRequest<ContactsGetResponse>(
`contacts/${found.id}`,
TEST_API_KEY,
);
const fetched = getResponse;
expect(fetched.id).toBe(found.id);
expect(fetched.first_name).toBe('CorsairUpdated');
});

it('contactsDelete returns correct type', async () => {
const list = await makeResendRequest<ContactsListResponse>(
'contacts',
TEST_API_KEY,
{ query: { limit: 100 } },
);
const found = list.data.find((c) => c.email === testContactEmail);
if (!found) {
return;
}

const response = await makeResendRequest<ContactsDeleteResponse>(
`contacts/${found.id}`,
TEST_API_KEY,
{ method: 'DELETE' },
);
const result = response;

ResendEndpointOutputSchemas.contactsDelete.parse(result);
expect(result.deleted).toBe(true);
});
});
});
182 changes: 182 additions & 0 deletions packages/resend/endpoints/contacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { logEventFromContext } from 'corsair/core';
import { makeResendRequest } from '../client';
import type { ResendEndpoints } from '../index';
import type { ResendEndpointOutputs } from './types';

export const create: ResendEndpoints['contactsCreate'] = async (ctx, input) => {
const body: Record<string, unknown> = {
email: input.email,
};
if (input.first_name) body.first_name = input.first_name;
if (input.last_name) body.last_name = input.last_name;
if (input.unsubscribed !== undefined) body.unsubscribed = input.unsubscribed;
if (input.properties) body.properties = input.properties;
if (input.segments) body.segments = input.segments;
if (input.topics) body.topics = input.topics;

const response = await makeResendRequest<
ResendEndpointOutputs['contactsCreate']
>('contacts', ctx.key, {
method: 'POST',
body,
});

if (response.id && ctx.db.contacts) {
try {
// POST /contacts returns only { object, id }; fetch the full
// contact before upserting so the persisted row has email/etc.
const fetched = await makeResendRequest<
ResendEndpointOutputs['contactsGet']
>(`contacts/${response.id}`, ctx.key, { method: 'GET' });
await ctx.db.contacts.upsertByEntityId(response.id, {
id: fetched.id,
email: fetched.email,
first_name: fetched.first_name ?? null,
last_name: fetched.last_name ?? null,
created_at: fetched.created_at ?? null,
unsubscribed: fetched.unsubscribed,
});
} catch (error) {
console.warn('Failed to save contact to database:', error);
}
}

await logEventFromContext(
ctx,
'resend.contacts.create',
{ id: response.id },
'completed',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return response;
};

export const get: ResendEndpoints['contactsGet'] = async (ctx, input) => {
const response = await makeResendRequest<
ResendEndpointOutputs['contactsGet']
>(`contacts/${input.id}`, ctx.key, {
method: 'GET',
});

if (response.id && ctx.db.contacts) {
try {
await ctx.db.contacts.upsertByEntityId(response.id, {
id: response.id,
email: response.email,
first_name: response.first_name ?? null,
last_name: response.last_name ?? null,
created_at: response.created_at ?? null,
unsubscribed: response.unsubscribed,
});
} catch (error) {
console.warn('Failed to save contact to database:', error);
}
}

await logEventFromContext(
ctx,
'resend.contacts.get',
{ id: response.id },
'completed',
);
return response;
};

export const list: ResendEndpoints['contactsList'] = async (ctx, input) => {
const query: Record<string, string | number | undefined> = {};
if (input?.limit) query.limit = input.limit;
if (input?.cursor) query.cursor = input.cursor;

const response = await makeResendRequest<
ResendEndpointOutputs['contactsList']
>('contacts', ctx.key, {
method: 'GET',
query,
});

if (response.data && ctx.db.contacts) {
try {
for (const contact of response.data) {
await ctx.db.contacts.upsertByEntityId(contact.id, {
...contact,
});
}
} catch (error) {
console.warn('Failed to save contacts to database:', error);
}
}

await logEventFromContext(
ctx,
'resend.contacts.list',
input ? { limit: input.limit, cursor: input.cursor } : {},
'completed',
);
return response;
};

export const update: ResendEndpoints['contactsUpdate'] = async (ctx, input) => {
const { id, ...body } = input;

const response = await makeResendRequest<
ResendEndpointOutputs['contactsUpdate']
>(`contacts/${id}`, ctx.key, {
method: 'PATCH',
body,
});

if (response.id && ctx.db.contacts) {
try {
// PATCH /contacts/:id returns only { object, id }; fetch the full
// contact before upserting so the persisted row reflects the
// updated email/name fields.
const fetched = await makeResendRequest<
ResendEndpointOutputs['contactsGet']
>(`contacts/${response.id}`, ctx.key, { method: 'GET' });
await ctx.db.contacts.upsertByEntityId(response.id, {
id: fetched.id,
email: fetched.email,
first_name: fetched.first_name ?? null,
last_name: fetched.last_name ?? null,
created_at: fetched.created_at ?? null,
unsubscribed: fetched.unsubscribed,
});
} catch (error) {
console.warn('Failed to save contact to database:', error);
}
}

await logEventFromContext(
ctx,
'resend.contacts.update',
{ id: response.id },
'completed',
);
return response;
};

export const deleteContact: ResendEndpoints['contactsDelete'] = async (
ctx,
input,
) => {
const response = await makeResendRequest<
ResendEndpointOutputs['contactsDelete']
>(`contacts/${input.id}`, ctx.key, {
method: 'DELETE',
});

if (response.deleted && ctx.db.contacts) {
try {
await ctx.db.contacts.deleteByEntityId(input.id);
} catch (error) {
console.warn('Failed to delete contact from database:', error);
}
}

await logEventFromContext(
ctx,
'resend.contacts.delete',
{ id: input.id },
'completed',
);
return response;
};
Loading
Loading