Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
186 changes: 186 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,68 @@ 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.
});
});

describe('domains', () => {
Expand Down Expand Up @@ -175,4 +244,121 @@ 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);
expect(result.id).toBe(testContactEmail);
});

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);
expect(result.first_name).toBe('CorsairUpdated');

// 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);
});

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);
});
});
});
159 changes: 159 additions & 0 deletions packages/resend/endpoints/contacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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 {
await ctx.db.contacts.upsertByEntityId(response.id, {
id: response.id,
email: '' as string,
});
} catch (error) {
console.warn('Failed to save contact to database:', error);
}
}

await logEventFromContext(
ctx,
'resend.contacts.create',
{ ...input },
'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: '' as string,
});
} catch (error) {
console.warn('Failed to save contact to database:', error);
}
}

await logEventFromContext(
ctx,
'resend.contacts.get',
{ ...input },
'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 },
'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 {
await ctx.db.contacts.upsertByEntityId(response.id, {
id: response.id,
email: '' as string,
});
} catch (error) {
console.warn('Failed to save contact to database:', error);
}
}

await logEventFromContext(
ctx,
'resend.contacts.update',
{ ...input },
'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',
{ ...input },
'completed',
);
return response;
};
Loading
Loading