Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
34 changes: 33 additions & 1 deletion src/broadcasts/broadcasts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ describe('Broadcasts', () => {
`);
});

it('returns an error when fetch fails', async () => {
it('returns an error when fetch fails (with env base URL)', async () => {
const originalEnv = process.env;
process.env = {
...originalEnv,
Expand All @@ -283,6 +283,38 @@ describe('Broadcasts', () => {
process.env = originalEnv;
});

it('returns an error when fetch fails (with client options)', async () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const resendWithInvalidBase = new Resend(
're_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
{ baseUrl: 'http://invalidurl.noturl' },
);

const result = await resendWithInvalidBase.broadcasts.create({
from: 'example@resend.com',
segmentId: '0192f4f1-d5f9-7110-8eb5-370552515917',
subject: 'Hello World',
text: 'Hello world',
});

expect(result).toEqual(
expect.objectContaining({
data: null,
error: {
message: 'Unable to fetch data. The request could not be resolved.',
name: 'application_error',
statusCode: null,
},
}),
);
expect(fetchMock).toHaveBeenCalledWith(
'http://invalidurl.noturl/broadcasts',
expect.objectContaining({
method: 'POST',
headers: expect.any(Headers),
}),
);
});

it('returns an error when api responds with text payload', async () => {
fetchMock.mockOnce('local_rate_limited', {
status: 422,
Expand Down
30 changes: 29 additions & 1 deletion src/emails/emails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ describe('Emails', () => {
`);
});

it('returns an error when fetch fails', async () => {
it('returns an error when fetch fails (with env base URL)', async () => {
const originalEnv = process.env;
process.env = {
...originalEnv,
Expand All @@ -394,6 +394,34 @@ describe('Emails', () => {
process.env = originalEnv;
});

it('returns an error when fetch fails (with client options)', async () => {
const resendWithInvalidBase = new Resend(
're_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
{ baseUrl: 'http://invalidurl.noturl' },
);

const result = await resendWithInvalidBase.emails.send({
from: 'example@resend.com',
to: 'bu@resend.com',
subject: 'Hello World',
text: 'Hello world',
});

expect(result).toEqual(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
expect.objectContaining({
data: null,
error: {
message: 'Unable to fetch data. The request could not be resolved.',
name: 'application_error',
statusCode: null,
},
}),
);
expect(fetchMock.mock.calls[0][0]).toBe(
'http://invalidurl.noturl/emails',
);
});

it('returns an error when api responds with text payload', async () => {
fetchMock.mockOnce('local_rate_limited', {
status: 422,
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export * from './emails/attachments/interfaces';
export * from './emails/interfaces';
export * from './emails/receiving/interfaces';
export type { ErrorResponse, Response } from './interfaces';
export { Resend } from './resend';
export { Resend, type ResendClientOptions } from './resend';
export * from './segments/interfaces';
export * from './templates/interfaces';
export * from './topics/interfaces';
Expand Down
56 changes: 35 additions & 21 deletions src/resend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,25 @@ import { Webhooks } from './webhooks/webhooks';

const defaultBaseUrl = 'https://api.resend.com';
const defaultUserAgent = `resend-node:${version}`;
const baseUrl =
typeof process !== 'undefined' && process.env
? process.env.RESEND_BASE_URL || defaultBaseUrl
: defaultBaseUrl;
const userAgent =
typeof process !== 'undefined' && process.env
? process.env.RESEND_USER_AGENT || defaultUserAgent
: defaultUserAgent;

/** optional options when creating the client instead of env variables */
export type ResendClientOptions = {
baseUrl?: string;
userAgent?: string;
/** custom fetch can be used for tests. */
fetch?: typeof fetch;
};

function getEnv(name: string): string | undefined {
if (typeof process === 'undefined' || !process.env) return undefined;
return process.env[name];
}

export class Resend {
private readonly headers: Headers;
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly _key: string;

readonly apiKeys = new ApiKeys(this);
readonly segments = new Segments(this);
Expand All @@ -45,29 +53,35 @@ export class Resend {
readonly templates = new Templates(this);
readonly topics = new Topics(this);

constructor(readonly key?: string) {
if (!key) {
if (typeof process !== 'undefined' && process.env) {
this.key = process.env.RESEND_API_KEY;
}

if (!this.key) {
throw new Error(
'Missing API key. Pass it to the constructor `new Resend("re_123")`',
);
}
constructor(key?: string, options?: ResendClientOptions) {
const apiKey = key ?? getEnv('RESEND_API_KEY');
if (!apiKey) {
throw new Error(
'Missing API key. Pass it to the constructor `new Resend("re_123")` or set RESEND_API_KEY.',
);
}
this._key = apiKey;

this.baseUrl =
options?.baseUrl ?? (getEnv('RESEND_BASE_URL') || defaultBaseUrl);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Using ?? for baseUrl/userAgent allows empty strings to bypass defaults, causing invalid or unintended request targets instead of safe fallback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/resend.ts, line 66:

<comment>Using `??` for `baseUrl`/`userAgent` allows empty strings to bypass defaults, causing invalid or unintended request targets instead of safe fallback.</comment>

<file context>
@@ -63,9 +63,9 @@ export class Resend {
 
     this.baseUrl =
-      options?.baseUrl || getEnv('RESEND_BASE_URL') || defaultBaseUrl;
+      options?.baseUrl ?? (getEnv('RESEND_BASE_URL') || defaultBaseUrl);
     const userAgent =
-      options?.userAgent || getEnv('RESEND_USER_AGENT') || defaultUserAgent;
</file context>

const userAgent =
options?.userAgent ?? (getEnv('RESEND_USER_AGENT') || defaultUserAgent);
this.fetchImpl = options?.fetch ?? fetch;

this.headers = new Headers({
Authorization: `Bearer ${this.key}`,
Authorization: `Bearer ${apiKey}`,
'User-Agent': userAgent,
'Content-Type': 'application/json',
});
}

get key(): string {
return this._key;
}

async fetchRequest<T>(path: string, options = {}): Promise<Response<T>> {
try {
const response = await fetch(`${baseUrl}${path}`, options);
const response = await this.fetchImpl(`${this.baseUrl}${path}`, options);

if (!response.ok) {
try {
Expand Down
Loading