├── .github
├── CODEOWNERS
├── scripts
│ └── pr-title-check.js
└── workflows
│ ├── lint.yml
│ ├── pin-dependencies-check.yml
│ ├── pr-title-check.yml
│ └── tests.yml
├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── biome.jsonc
├── jest.config.ts
├── jest.setup.ts
├── package.json
├── pnpm-lock.yaml
├── readme.md
├── renovate.json
├── src
├── api-keys
│ ├── api-keys.spec.ts
│ ├── api-keys.ts
│ └── interfaces
│ │ ├── api-key.ts
│ │ ├── create-api-key-options.interface.ts
│ │ ├── list-api-keys.interface.ts
│ │ └── remove-api-keys.interface.ts
├── audiences
│ ├── audiences.spec.ts
│ ├── audiences.ts
│ └── interfaces
│ │ ├── audience.ts
│ │ ├── create-audience-options.interface.ts
│ │ ├── get-audience.interface.ts
│ │ ├── list-audiences.interface.ts
│ │ └── remove-audience.interface.ts
├── batch
│ ├── batch.spec.ts
│ ├── batch.ts
│ └── interfaces
│ │ └── create-batch-options.interface.ts
├── broadcasts
│ ├── broadcasts.spec.ts
│ ├── broadcasts.ts
│ └── interfaces
│ │ ├── broadcast.ts
│ │ ├── create-broadcast-options.interface.ts
│ │ ├── get-broadcast.interface.ts
│ │ ├── list-broadcasts.interface.ts
│ │ ├── remove-broadcast.interface.ts
│ │ ├── send-broadcast-options.interface.ts
│ │ └── update-broadcast.interface.ts
├── common
│ ├── interfaces
│ │ ├── domain-api-options.interface.ts
│ │ ├── email-api-options.interface.ts
│ │ ├── get-option.interface.ts
│ │ ├── idempotent-request.interface.ts
│ │ ├── index.ts
│ │ ├── list-option.interface.ts
│ │ ├── patch-option.interface.ts
│ │ ├── post-option.interface.ts
│ │ ├── put-option.interface.ts
│ │ └── require-at-least-one.ts
│ └── utils
│ │ ├── parse-domain-to-api-options.spec.ts
│ │ ├── parse-domain-to-api-options.ts
│ │ ├── parse-email-to-api-options.spec.ts
│ │ └── parse-email-to-api-options.ts
├── contacts
│ ├── contacts.spec.ts
│ ├── contacts.ts
│ └── interfaces
│ │ ├── contact.ts
│ │ ├── create-contact-options.interface.ts
│ │ ├── get-contact.interface.ts
│ │ ├── list-contacts.interface.ts
│ │ ├── remove-contact.interface.ts
│ │ └── update-contact.interface.ts
├── domains
│ ├── domains.spec.ts
│ ├── domains.ts
│ └── interfaces
│ │ ├── create-domain-options.interface.ts
│ │ ├── domain.ts
│ │ ├── get-domain.interface.ts
│ │ ├── list-domains.interface.ts
│ │ ├── remove-domain.interface.ts
│ │ ├── update-domain.interface.ts
│ │ └── verify-domain.interface.ts
├── emails
│ ├── emails.spec.ts
│ ├── emails.ts
│ └── interfaces
│ │ ├── cancel-email-options.interface.ts
│ │ ├── create-email-options.interface.ts
│ │ ├── get-email-options.interface.ts
│ │ └── update-email-options.interface.ts
├── error.ts
├── guards.ts
├── index.ts
├── interfaces.ts
└── resend.ts
└── tsconfig.json
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @resend/engineering
2 |
--------------------------------------------------------------------------------
/.github/scripts/pr-title-check.js:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 |
3 | const eventPath = process.env.GITHUB_EVENT_PATH;
4 | const eventJson = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
5 | const prTitle = eventJson.pull_request.title;
6 |
7 | const isValidType = (title) =>
8 | /^(feat|fix|chore|refactor)(\([a-zA-Z0-9-]+\))?:\s[a-z].*$/.test(title);
9 |
10 | const validateTitle = (title) => {
11 | if (!isValidType(title)) {
12 | console.error(
13 | `PR title does not follow the required format.
14 | example: "type: My PR Title"
15 |
16 | - type: "feat", "fix", "chore", or "refactor"
17 | - First letter of the PR title needs to be lowercased
18 | `,
19 | );
20 | process.exit(1);
21 | }
22 |
23 | console.info('PR title is valid');
24 | };
25 |
26 | validateTitle(prTitle);
27 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | jobs:
8 | lint:
9 | runs-on: buildjet-4vcpu-ubuntu-2204
10 | container:
11 | image: node:20
12 | credentials:
13 | username: ${{ vars.DOCKER_HUB_USERNAME }}
14 | password: ${{ secrets.DOCKER_HUB_API_KEY }}
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 | - name: pnpm setup
19 | uses: pnpm/action-setup@v4
20 | - name: Setup Node
21 | uses: actions/setup-node@v4
22 | with:
23 | node-version: 20
24 | cache: "pnpm"
25 | - name: Install packages
26 | run: pnpm install
27 | - name: Run Lint
28 | run: pnpm lint
29 | env:
30 | SKIP_ENV_VALIDATION: true
31 |
--------------------------------------------------------------------------------
/.github/workflows/pin-dependencies-check.yml:
--------------------------------------------------------------------------------
1 | name: Pin Dependencies Check
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | jobs:
8 | pin-dependencies-check:
9 | runs-on: buildjet-4vcpu-ubuntu-2204
10 | container:
11 | image: node:20
12 | credentials:
13 | username: ${{ vars.DOCKER_HUB_USERNAME }}
14 | password: ${{ secrets.DOCKER_HUB_API_KEY }}
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 | - name: Check for pinned dependencies
19 | run: |
20 | node -e '
21 | const fs = require("fs");
22 | const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
23 | const errors = [];
24 |
25 | function isPinned(version) {
26 | return /^\d+\.\d+\.\d+(-canary\.\d+)?$/.test(version);
27 | }
28 |
29 | for (const [dep, version] of Object.entries(pkg.dependencies || {})) {
30 | if (!isPinned(version)) {
31 | errors.push(`Dependency "${dep}" is not pinned: "${version}"`);
32 | }
33 | }
34 |
35 | for (const [dep, version] of Object.entries(pkg.devDependencies || {})) {
36 | if (!isPinned(version)) {
37 | errors.push(`Dev dependency "${dep}" is not pinned: "${version}"`);
38 | }
39 | }
40 |
41 | if (errors.length > 0) {
42 | console.error(`\n${errors.join("\n")}\n`);
43 | process.exit(1);
44 | } else {
45 | console.log("All dependencies are pinned.");
46 | }
47 | '
48 |
--------------------------------------------------------------------------------
/.github/workflows/pr-title-check.yml:
--------------------------------------------------------------------------------
1 | name: PR Title Check
2 | on:
3 | pull_request:
4 | types: [opened, edited, synchronize]
5 | jobs:
6 | pr-title-check:
7 | runs-on: buildjet-4vcpu-ubuntu-2204
8 | container:
9 | image: node:20
10 | credentials:
11 | username: ${{ vars.DOCKER_HUB_USERNAME }}
12 | password: ${{ secrets.DOCKER_HUB_API_KEY }}
13 | steps:
14 | - name: Checkout code
15 | uses: actions/checkout@v4
16 | - name: Run PR title check
17 | run: |
18 | node .github/scripts/pr-title-check.js
19 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | jobs:
8 | tests:
9 | runs-on: buildjet-4vcpu-ubuntu-2204
10 | container:
11 | image: node:20
12 | credentials:
13 | username: ${{ vars.DOCKER_HUB_USERNAME }}
14 | password: ${{ secrets.DOCKER_HUB_API_KEY }}
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 | - name: pnpm setup
19 | uses: pnpm/action-setup@v4
20 | - name: Setup Node
21 | uses: actions/setup-node@v4
22 | with:
23 | node-version: 20
24 | cache: "pnpm"
25 | - name: Install Doppler CLI
26 | uses: dopplerhq/cli-action@v3
27 | - name: Install packages
28 | run: pnpm install
29 | - name: Run Tests
30 | run: pnpm test
31 | env:
32 | SKIP_ENV_VALIDATION: true
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .yarn/*
4 | !.yarn/releases
5 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "biomejs.biome",
3 | "editor.codeActionsOnSave": {
4 | "source.organizeImports.biome": "explicit"
5 | },
6 | "[css]": {
7 | "editor.defaultFormatter": "biomejs.biome"
8 | },
9 | "[typescriptreact]": {
10 | "editor.defaultFormatter": "biomejs.biome"
11 | },
12 | "[javascript]": {
13 | "editor.defaultFormatter": "biomejs.biome"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Plus Five Five, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/biome.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
3 | "vcs": {
4 | "clientKind": "git",
5 | // use .gitignore to ignore files
6 | "useIgnoreFile": true,
7 | // only run on changed files by default (faster, but happy to remove)
8 | "defaultBranch": "main"
9 | },
10 | "organizeImports": {
11 | "enabled": true
12 | },
13 | "formatter": {
14 | "indentStyle": "space",
15 | "indentWidth": 2,
16 | "lineWidth": 80
17 | },
18 | "javascript": {
19 | "formatter": {
20 | "quoteStyle": "single",
21 | "jsxQuoteStyle": "single"
22 | }
23 | },
24 | "linter": {
25 | "enabled": true,
26 | "rules": {
27 | "recommended": true
28 | }
29 | },
30 | "files": {
31 | "ignore": ["pnpm-lock.yaml"]
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { JestConfigWithTsJest } from 'ts-jest/';
2 |
3 | const config: JestConfigWithTsJest = {
4 | preset: 'ts-jest',
5 | clearMocks: true,
6 | restoreMocks: true,
7 | verbose: true,
8 | testEnvironment: 'node',
9 | setupFiles: ['./jest.setup.ts'],
10 | prettierPath: null,
11 | };
12 |
13 | export default config;
14 |
--------------------------------------------------------------------------------
/jest.setup.ts:
--------------------------------------------------------------------------------
1 | import { enableFetchMocks } from 'jest-fetch-mock';
2 |
3 | enableFetchMocks();
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "resend",
3 | "version": "4.5.2",
4 | "description": "Node.js library for the Resend API",
5 | "main": "./dist/index.js",
6 | "module": "./dist/index.mjs",
7 | "types": "./dist/index.d.ts",
8 | "files": ["dist/**"],
9 | "engines": {
10 | "node": ">=18"
11 | },
12 | "exports": {
13 | ".": {
14 | "import": {
15 | "types": "./dist/index.d.ts",
16 | "default": "./dist/index.mjs"
17 | },
18 | "require": {
19 | "types": "./dist/index.d.ts",
20 | "default": "./dist/index.js"
21 | }
22 | }
23 | },
24 | "scripts": {
25 | "build": "tsup src/index.ts --format esm,cjs --dts",
26 | "test": "jest",
27 | "test:watch": "jest --watch",
28 | "format:apply": "biome check --write .",
29 | "format:check": "biome format .",
30 | "format": "biome format --write .",
31 | "lint": "biome check .",
32 | "prepublishOnly": "pnpm run build"
33 | },
34 | "repository": {
35 | "type": "git",
36 | "url": "git+https://github.com/resendlabs/resend-node.git"
37 | },
38 | "author": "",
39 | "license": "MIT",
40 | "bugs": {
41 | "url": "https://github.com/resendlabs/resend-node/issues"
42 | },
43 | "homepage": "https://github.com/resendlabs/resend-node#readme",
44 | "dependencies": {
45 | "@react-email/render": "1.1.2"
46 | },
47 | "devDependencies": {
48 | "@biomejs/biome": "1.9.4",
49 | "@types/jest": "29.5.14",
50 | "@types/node": "18.19.86",
51 | "@types/react": "19.1.2",
52 | "jest": "29.7.0",
53 | "jest-fetch-mock": "3.0.3",
54 | "ts-jest": "29.3.4",
55 | "ts-node": "10.9.2",
56 | "tsup": "7.2.0",
57 | "typescript": "5.8.3"
58 | },
59 | "packageManager": "pnpm@10.11.1"
60 | }
61 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 | 
3 |
4 |
5 | Quickstart Docs
6 |
7 |
8 |
9 | Framework guides
10 |
11 |
12 | Next.js
14 | - Remix
15 | - Nuxt
16 | - Express
17 | - RedwoodJS
18 | - Hono
19 | - Bun
20 | - Astro
21 |
22 |
23 | # Resend Node.js SDK
24 |
25 | Node.js library for the Resend API.
26 |
27 | ## Install
28 |
29 | ```bash
30 | npm install resend
31 | # or
32 | yarn add resend
33 | ```
34 |
35 | ## Examples
36 |
37 | Send email with:
38 |
39 | - [Node.js](https://github.com/resendlabs/resend-node-example)
40 | - [Next.js (App Router)](https://github.com/resendlabs/resend-nextjs-app-router-example)
41 | - [Next.js (Pages Router)](https://github.com/resendlabs/resend-nextjs-pages-router-example)
42 | - [Express](https://github.com/resendlabs/resend-express-example)
43 |
44 | ## Setup
45 |
46 | First, you need to get an API key, which is available in the [Resend Dashboard](https://resend.com/api-keys).
47 |
48 | ```js
49 | import { Resend } from 'resend';
50 | const resend = new Resend('re_xxxx...xxxxxx');
51 | ```
52 |
53 | ## Usage
54 |
55 | Send your first email:
56 |
57 | ```js
58 | await resend.emails.send({
59 | from: 'you@example.com',
60 | to: 'user@gmail.com',
61 | replyTo: 'you@example.com',
62 | subject: 'hello world',
63 | text: 'it works!',
64 | });
65 | ```
66 |
67 | > [!NOTE]
68 | > In order to send from your own domain, you will first need to verify your domain in the [Resend Dashboard](https://resend.com/domains).
69 |
70 | ## Send email using HTML
71 |
72 | Send an email custom HTML content:
73 |
74 | ```js
75 | await resend.emails.send({
76 | from: 'you@example.com',
77 | to: 'user@gmail.com',
78 | replyTo: 'you@example.com',
79 | subject: 'hello world',
80 | html: 'it works!',
81 | });
82 | ```
83 |
84 | ## Send email using React
85 |
86 | Start by creating your email template as a React component.
87 |
88 | ```jsx
89 | import React from 'react';
90 |
91 | export default function EmailTemplate({ firstName, product }) {
92 | return (
93 |
94 |
Welcome, {firstName}!
95 |
Thanks for trying {product}. We’re thrilled to have you on board.
96 |
97 | );
98 | }
99 | ```
100 |
101 | Then import the template component and pass it to the `react` property.
102 |
103 | ```jsx
104 | import EmailTemplate from '../components/EmailTemplate';
105 |
106 | await resend.emails.send({
107 | from: 'you@example.com',
108 | to: 'user@gmail.com',
109 | replyTo: 'you@example.com',
110 | subject: 'hello world',
111 | react: ,
112 | });
113 | ```
114 |
115 | > [!NOTE]
116 | > If your endpoint is a JS/TS file, render the template (i.e., pass `EmailTemplate({firstName="John", product="MyApp"})` instead of the component).
117 |
118 | ## License
119 |
120 | MIT License
121 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:recommended"]
4 | }
5 |
--------------------------------------------------------------------------------
/src/api-keys/api-keys.spec.ts:
--------------------------------------------------------------------------------
1 | import { enableFetchMocks } from 'jest-fetch-mock';
2 | import type { ErrorResponse } from '../interfaces';
3 | import { Resend } from '../resend';
4 | import type {
5 | CreateApiKeyOptions,
6 | CreateApiKeyResponseSuccess,
7 | } from './interfaces/create-api-key-options.interface';
8 | import type { ListApiKeysResponseSuccess } from './interfaces/list-api-keys.interface';
9 | import type { RemoveApiKeyResponseSuccess } from './interfaces/remove-api-keys.interface';
10 |
11 | enableFetchMocks();
12 |
13 | describe('API Keys', () => {
14 | afterEach(() => fetchMock.resetMocks());
15 |
16 | describe('create', () => {
17 | it('creates an api key', async () => {
18 | const payload: CreateApiKeyOptions = {
19 | name: 'Test',
20 | };
21 | const response: CreateApiKeyResponseSuccess = {
22 | token: 're_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk',
23 | id: '430eed87-632a-4ea6-90db-0aace67ec228',
24 | };
25 |
26 | fetchMock.mockOnce(JSON.stringify(response), {
27 | status: 201,
28 | headers: {
29 | 'content-type': 'application/json',
30 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
31 | },
32 | });
33 |
34 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
35 |
36 | await expect(
37 | resend.apiKeys.create(payload),
38 | ).resolves.toMatchInlineSnapshot(`
39 | {
40 | "data": {
41 | "id": "430eed87-632a-4ea6-90db-0aace67ec228",
42 | "token": "re_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk",
43 | },
44 | "error": null,
45 | }
46 | `);
47 | });
48 |
49 | it('throws error when missing name', async () => {
50 | const payload: CreateApiKeyOptions = {
51 | name: '',
52 | };
53 | const response: ErrorResponse = {
54 | message: 'String must contain at least 1 character(s)',
55 | name: 'validation_error',
56 | };
57 |
58 | fetchMock.mockOnce(JSON.stringify(response), {
59 | status: 422,
60 | headers: {
61 | 'content-type': 'application/json',
62 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
63 | },
64 | });
65 |
66 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
67 |
68 | const result = resend.apiKeys.create(payload);
69 |
70 | await expect(result).resolves.toMatchInlineSnapshot(`
71 | {
72 | "data": null,
73 | "error": {
74 | "message": "String must contain at least 1 character(s)",
75 | "name": "validation_error",
76 | },
77 | }
78 | `);
79 | });
80 |
81 | describe('with access', () => {
82 | it('creates api key with access `full_access`', async () => {
83 | const payload: CreateApiKeyOptions = {
84 | name: 'Test',
85 | permission: 'full_access',
86 | };
87 |
88 | const response: CreateApiKeyResponseSuccess = {
89 | token: 're_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk',
90 | id: '430eed87-632a-4ea6-90db-0aace67ec228',
91 | };
92 |
93 | fetchMock.mockOnce(JSON.stringify(response), {
94 | status: 201,
95 | headers: {
96 | 'content-type': 'application/json',
97 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
98 | },
99 | });
100 |
101 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
102 |
103 | await expect(
104 | resend.apiKeys.create(payload),
105 | ).resolves.toMatchInlineSnapshot(`
106 | {
107 | "data": {
108 | "id": "430eed87-632a-4ea6-90db-0aace67ec228",
109 | "token": "re_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk",
110 | },
111 | "error": null,
112 | }
113 | `);
114 | });
115 |
116 | it('creates api key with access `sending_access`', async () => {
117 | const payload: CreateApiKeyOptions = {
118 | name: 'Test',
119 | permission: 'sending_access',
120 | };
121 | const response: CreateApiKeyResponseSuccess = {
122 | token: 're_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk',
123 | id: '430eed87-632a-4ea6-90db-0aace67ec228',
124 | };
125 |
126 | fetchMock.mockOnce(JSON.stringify(response), {
127 | status: 201,
128 | headers: {
129 | 'content-type': 'application/json',
130 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
131 | },
132 | });
133 |
134 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
135 |
136 | await expect(
137 | resend.apiKeys.create(payload),
138 | ).resolves.toMatchInlineSnapshot(`
139 | {
140 | "data": {
141 | "id": "430eed87-632a-4ea6-90db-0aace67ec228",
142 | "token": "re_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk",
143 | },
144 | "error": null,
145 | }
146 | `);
147 | });
148 |
149 | it('throws error with wrong access', async () => {
150 | const response: ErrorResponse = {
151 | name: 'invalid_access',
152 | message: 'Access must be "full_access" | "sending_access"',
153 | };
154 |
155 | fetchMock.mockOnce(JSON.stringify(response), {
156 | status: 422,
157 | headers: {
158 | 'content-type': 'application/json',
159 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
160 | },
161 | });
162 |
163 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
164 |
165 | const payload: CreateApiKeyOptions = {
166 | name: 'Test',
167 | permission: 'wrong_access' as 'sending_access' | 'full_access',
168 | };
169 |
170 | await expect(
171 | resend.apiKeys.create(payload),
172 | ).resolves.toMatchInlineSnapshot(`
173 | {
174 | "data": null,
175 | "error": {
176 | "message": "Access must be "full_access" | "sending_access"",
177 | "name": "invalid_access",
178 | },
179 | }
180 | `);
181 | });
182 | });
183 |
184 | // describe('restricted by domain', () => {
185 | // it('creates api key restricted by domain', async () => {
186 | // fetchMock.mockOnce(
187 | // JSON.stringify({
188 | // data: {
189 | // token: 're_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk',
190 | // id: '430eed87-632a-4ea6-90db-0aace67ec228',
191 | // },
192 | // error: null,
193 | // }),
194 | // {
195 | // status: 201,
196 | // headers: {
197 | // 'content-type': 'application/json',
198 | // Authorization: 'Bearer re_924b3rjh2387fbewf823',
199 | // },
200 | // },
201 | // );
202 |
203 | // const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
204 |
205 | // await expect(
206 | // resend.apiKeys.create({
207 | // name: 'Test',
208 | // permission: 'sending_access',
209 | // domain_id: '7dfcf219-9900-4169-86f3-801e6d9b935e',
210 | // }),
211 | // ).resolves.toMatchInlineSnapshot(`
212 | // {
213 | // "id": "430eed87-632a-4ea6-90db-0aace67ec228",
214 | // "token": "re_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk",
215 | // }
216 | // `);
217 | // });
218 |
219 | // it('throws error with wrong access', async () => {
220 | // const errorResponse: ErrorResponse = {
221 | // name: 'application_error',
222 | // message: 'Something went wrong',
223 | //
224 | // };
225 |
226 | // fetchMock.mockOnce(
227 | // JSON.stringify(errorResponse),
228 | // {
229 | // status: 500,
230 | // headers: {
231 | // 'content-type': 'application/json',
232 | // Authorization: 'Bearer re_924b3rjh2387fbewf823',
233 | // },
234 | // },
235 | // );
236 |
237 | // const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
238 |
239 | // await expect(
240 | // resend.apiKeys.create({
241 | // name: 'Test',
242 | // permission: 'sending_access',
243 | // domain_id: '1234',
244 | // }),
245 | // ).resolves.toMatchInlineSnapshot(
246 | // `
247 | // {
248 | // "error": {
249 | // "message": "Something went wrong",
250 | // "name": "application_error",
251 | // "statusCode": 500,
252 | // },
253 | // }
254 | // `,
255 | // );
256 | // });
257 | // });
258 | });
259 |
260 | describe('list', () => {
261 | it('lists api keys', async () => {
262 | const response: ListApiKeysResponseSuccess = [
263 | {
264 | id: '5262504e-8ed7-4fac-bd16-0d4be94bc9f2',
265 | name: 'My API Key 1',
266 | created_at: '2023-04-07T20:29:10.666968+00:00',
267 | },
268 | {
269 | id: '98c37b35-1473-4afe-a627-78e975a36fab',
270 | name: 'My API Key 2',
271 | created_at: '2023-04-06T23:09:49.093947+00:00',
272 | },
273 | ];
274 | fetchMock.mockOnce(JSON.stringify(response), {
275 | status: 200,
276 | headers: {
277 | 'content-type': 'application/json',
278 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
279 | },
280 | });
281 |
282 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
283 |
284 | await expect(resend.apiKeys.list()).resolves.toMatchInlineSnapshot(`
285 | {
286 | "data": [
287 | {
288 | "created_at": "2023-04-07T20:29:10.666968+00:00",
289 | "id": "5262504e-8ed7-4fac-bd16-0d4be94bc9f2",
290 | "name": "My API Key 1",
291 | },
292 | {
293 | "created_at": "2023-04-06T23:09:49.093947+00:00",
294 | "id": "98c37b35-1473-4afe-a627-78e975a36fab",
295 | "name": "My API Key 2",
296 | },
297 | ],
298 | "error": null,
299 | }
300 | `);
301 | });
302 | });
303 |
304 | describe('remove', () => {
305 | const id = '5262504e-8ed7-4fac-bd16-0d4be94bc9f2';
306 | const response: RemoveApiKeyResponseSuccess = {};
307 |
308 | it('removes an api key', async () => {
309 | fetchMock.mockOnce(JSON.stringify(response), {
310 | status: 200,
311 | headers: {
312 | 'content-type': 'application/json',
313 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
314 | },
315 | });
316 |
317 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
318 |
319 | await expect(resend.apiKeys.remove(id)).resolves.toMatchInlineSnapshot(`
320 | {
321 | "data": {},
322 | "error": null,
323 | }
324 | `);
325 | });
326 |
327 | it('throws error when missing id', async () => {
328 | const response: ErrorResponse = {
329 | name: 'application_error',
330 | message: 'Something went wrong',
331 | };
332 |
333 | fetchMock.mockOnce(JSON.stringify(response), {
334 | status: 500,
335 | headers: {
336 | 'content-type': 'application/json',
337 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
338 | },
339 | });
340 |
341 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
342 |
343 | const result = resend.apiKeys.remove('');
344 |
345 | await expect(result).resolves.toMatchInlineSnapshot(`
346 | {
347 | "data": null,
348 | "error": {
349 | "message": "Something went wrong",
350 | "name": "application_error",
351 | },
352 | }
353 | `);
354 | });
355 |
356 | it('throws error when wrong id', async () => {
357 | const response: ErrorResponse = {
358 | name: 'not_found',
359 | message: 'API key not found',
360 | };
361 |
362 | fetchMock.mockOnce(JSON.stringify(response), {
363 | status: 404,
364 | headers: {
365 | 'content-type': 'application/json',
366 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
367 | },
368 | });
369 |
370 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
371 |
372 | const result = resend.apiKeys.remove(
373 | '34bd250e-615a-400c-be11-5912572ee15b',
374 | );
375 |
376 | await expect(result).resolves.toMatchInlineSnapshot(`
377 | {
378 | "data": null,
379 | "error": {
380 | "message": "API key not found",
381 | "name": "not_found",
382 | },
383 | }
384 | `);
385 | });
386 | });
387 | });
388 |
--------------------------------------------------------------------------------
/src/api-keys/api-keys.ts:
--------------------------------------------------------------------------------
1 | import type { Resend } from '../resend';
2 | import type {
3 | CreateApiKeyOptions,
4 | CreateApiKeyRequestOptions,
5 | CreateApiKeyResponse,
6 | CreateApiKeyResponseSuccess,
7 | } from './interfaces/create-api-key-options.interface';
8 | import type {
9 | ListApiKeysResponse,
10 | ListApiKeysResponseSuccess,
11 | } from './interfaces/list-api-keys.interface';
12 | import type {
13 | RemoveApiKeyResponse,
14 | RemoveApiKeyResponseSuccess,
15 | } from './interfaces/remove-api-keys.interface';
16 |
17 | export class ApiKeys {
18 | constructor(private readonly resend: Resend) {}
19 |
20 | async create(
21 | payload: CreateApiKeyOptions,
22 | options: CreateApiKeyRequestOptions = {},
23 | ): Promise {
24 | const data = await this.resend.post(
25 | '/api-keys',
26 | payload,
27 | options,
28 | );
29 |
30 | return data;
31 | }
32 |
33 | async list(): Promise {
34 | const data = await this.resend.get('/api-keys');
35 | return data;
36 | }
37 |
38 | async remove(id: string): Promise {
39 | const data = await this.resend.delete(
40 | `/api-keys/${id}`,
41 | );
42 | return data;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/api-keys/interfaces/api-key.ts:
--------------------------------------------------------------------------------
1 | export interface ApiKey {
2 | created_at: string;
3 | id: string;
4 | name: string;
5 | }
6 |
--------------------------------------------------------------------------------
/src/api-keys/interfaces/create-api-key-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { PostOptions } from '../../common/interfaces';
2 | import type { ErrorResponse } from '../../interfaces';
3 |
4 | export interface CreateApiKeyOptions {
5 | name: string;
6 | permission?: 'full_access' | 'sending_access';
7 | domain_id?: string;
8 | }
9 |
10 | export interface CreateApiKeyRequestOptions extends PostOptions {}
11 |
12 | export interface CreateApiKeyResponseSuccess {
13 | token: string;
14 | id: string;
15 | }
16 |
17 | export interface CreateApiKeyResponse {
18 | data: CreateApiKeyResponseSuccess | null;
19 | error: ErrorResponse | null;
20 | }
21 |
--------------------------------------------------------------------------------
/src/api-keys/interfaces/list-api-keys.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { ApiKey } from './api-key';
3 |
4 | export type ListApiKeysResponseSuccess = Pick<
5 | ApiKey,
6 | 'name' | 'id' | 'created_at'
7 | >[];
8 |
9 | export interface ListApiKeysResponse {
10 | data: ListApiKeysResponseSuccess | null;
11 | error: ErrorResponse | null;
12 | }
13 |
--------------------------------------------------------------------------------
/src/api-keys/interfaces/remove-api-keys.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 |
3 | // biome-ignore lint/complexity/noBannedTypes:
4 | export type RemoveApiKeyResponseSuccess = {};
5 |
6 | export interface RemoveApiKeyResponse {
7 | data: RemoveApiKeyResponseSuccess | null;
8 | error: ErrorResponse | null;
9 | }
10 |
--------------------------------------------------------------------------------
/src/audiences/audiences.spec.ts:
--------------------------------------------------------------------------------
1 | import { enableFetchMocks } from 'jest-fetch-mock';
2 | import type { ErrorResponse } from '../interfaces';
3 | import { Resend } from '../resend';
4 | import type {
5 | CreateAudienceOptions,
6 | CreateAudienceResponseSuccess,
7 | } from './interfaces/create-audience-options.interface';
8 | import type { GetAudienceResponseSuccess } from './interfaces/get-audience.interface';
9 | import type { ListAudiencesResponseSuccess } from './interfaces/list-audiences.interface';
10 | import type { RemoveAudiencesResponseSuccess } from './interfaces/remove-audience.interface';
11 |
12 | enableFetchMocks();
13 |
14 | describe('Audiences', () => {
15 | afterEach(() => fetchMock.resetMocks());
16 |
17 | describe('create', () => {
18 | it('creates a audience', async () => {
19 | const payload: CreateAudienceOptions = { name: 'resend.com' };
20 | const response: CreateAudienceResponseSuccess = {
21 | id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
22 | name: 'Resend',
23 | object: 'audience',
24 | };
25 |
26 | fetchMock.mockOnce(JSON.stringify(response), {
27 | status: 200,
28 | headers: {
29 | 'content-type': 'application/json',
30 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
31 | },
32 | });
33 |
34 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
35 | await expect(
36 | resend.audiences.create(payload),
37 | ).resolves.toMatchInlineSnapshot(`
38 | {
39 | "data": {
40 | "id": "3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222",
41 | "name": "Resend",
42 | "object": "audience",
43 | },
44 | "error": null,
45 | }
46 | `);
47 | });
48 |
49 | it('throws error when missing name', async () => {
50 | const payload: CreateAudienceOptions = { name: '' };
51 | const response: ErrorResponse = {
52 | name: 'missing_required_field',
53 | message: 'Missing "name" field',
54 | };
55 |
56 | fetchMock.mockOnce(JSON.stringify(response), {
57 | status: 422,
58 | headers: {
59 | 'content-type': 'application/json',
60 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
61 | },
62 | });
63 |
64 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
65 |
66 | const result = resend.audiences.create(payload);
67 |
68 | await expect(result).resolves.toMatchInlineSnapshot(`
69 | {
70 | "data": null,
71 | "error": {
72 | "message": "Missing "name" field",
73 | "name": "missing_required_field",
74 | },
75 | }
76 | `);
77 | });
78 | });
79 |
80 | describe('list', () => {
81 | it('lists audiences', async () => {
82 | const response: ListAudiencesResponseSuccess = {
83 | object: 'list',
84 | data: [
85 | {
86 | id: 'b6d24b8e-af0b-4c3c-be0c-359bbd97381e',
87 | name: 'resend.com',
88 | created_at: '2023-04-07T23:13:52.669661+00:00',
89 | },
90 | {
91 | id: 'ac7503ac-e027-4aea-94b3-b0acd46f65f9',
92 | name: 'react.email',
93 | created_at: '2023-04-07T23:13:20.417116+00:00',
94 | },
95 | ],
96 | };
97 | fetchMock.mockOnce(JSON.stringify(response), {
98 | status: 200,
99 | headers: {
100 | 'content-type': 'application/json',
101 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
102 | },
103 | });
104 |
105 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
106 |
107 | await expect(resend.audiences.list()).resolves.toMatchInlineSnapshot(`
108 | {
109 | "data": {
110 | "data": [
111 | {
112 | "created_at": "2023-04-07T23:13:52.669661+00:00",
113 | "id": "b6d24b8e-af0b-4c3c-be0c-359bbd97381e",
114 | "name": "resend.com",
115 | },
116 | {
117 | "created_at": "2023-04-07T23:13:20.417116+00:00",
118 | "id": "ac7503ac-e027-4aea-94b3-b0acd46f65f9",
119 | "name": "react.email",
120 | },
121 | ],
122 | "object": "list",
123 | },
124 | "error": null,
125 | }
126 | `);
127 | });
128 | });
129 |
130 | describe('get', () => {
131 | describe('when audience not found', () => {
132 | it('returns error', async () => {
133 | const response: ErrorResponse = {
134 | name: 'not_found',
135 | message: 'Audience not found',
136 | };
137 |
138 | fetchMock.mockOnce(JSON.stringify(response), {
139 | status: 404,
140 | headers: {
141 | 'content-type': 'application/json',
142 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
143 | },
144 | });
145 |
146 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
147 |
148 | const result = resend.audiences.get('1234');
149 |
150 | await expect(result).resolves.toMatchInlineSnapshot(`
151 | {
152 | "data": null,
153 | "error": {
154 | "message": "Audience not found",
155 | "name": "not_found",
156 | },
157 | }
158 | `);
159 | });
160 | });
161 |
162 | it('get audience', async () => {
163 | const response: GetAudienceResponseSuccess = {
164 | object: 'audience',
165 | id: 'fd61172c-cafc-40f5-b049-b45947779a29',
166 | name: 'resend.com',
167 | created_at: '2023-06-21T06:10:36.144Z',
168 | };
169 |
170 | fetchMock.mockOnce(JSON.stringify(response), {
171 | status: 200,
172 | headers: {
173 | 'content-type': 'application/json',
174 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
175 | },
176 | });
177 |
178 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
179 |
180 | await expect(
181 | resend.audiences.get('1234'),
182 | ).resolves.toMatchInlineSnapshot(`
183 | {
184 | "data": {
185 | "created_at": "2023-06-21T06:10:36.144Z",
186 | "id": "fd61172c-cafc-40f5-b049-b45947779a29",
187 | "name": "resend.com",
188 | "object": "audience",
189 | },
190 | "error": null,
191 | }
192 | `);
193 | });
194 | });
195 |
196 | describe('remove', () => {
197 | it('removes a audience', async () => {
198 | const id = '5262504e-8ed7-4fac-bd16-0d4be94bc9f2';
199 | const response: RemoveAudiencesResponseSuccess = {
200 | object: 'audience',
201 | id,
202 | deleted: true,
203 | };
204 | fetchMock.mockOnce(JSON.stringify(response), {
205 | status: 200,
206 | headers: {
207 | 'content-type': 'application/json',
208 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
209 | },
210 | });
211 |
212 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
213 |
214 | await expect(resend.audiences.remove(id)).resolves.toMatchInlineSnapshot(`
215 | {
216 | "data": {
217 | "deleted": true,
218 | "id": "5262504e-8ed7-4fac-bd16-0d4be94bc9f2",
219 | "object": "audience",
220 | },
221 | "error": null,
222 | }
223 | `);
224 | });
225 | });
226 | });
227 |
--------------------------------------------------------------------------------
/src/audiences/audiences.ts:
--------------------------------------------------------------------------------
1 | import type { Resend } from '../resend';
2 | import type {
3 | CreateAudienceOptions,
4 | CreateAudienceRequestOptions,
5 | CreateAudienceResponse,
6 | CreateAudienceResponseSuccess,
7 | } from './interfaces/create-audience-options.interface';
8 | import type {
9 | GetAudienceResponse,
10 | GetAudienceResponseSuccess,
11 | } from './interfaces/get-audience.interface';
12 | import type {
13 | ListAudiencesResponse,
14 | ListAudiencesResponseSuccess,
15 | } from './interfaces/list-audiences.interface';
16 | import type {
17 | RemoveAudiencesResponse,
18 | RemoveAudiencesResponseSuccess,
19 | } from './interfaces/remove-audience.interface';
20 |
21 | export class Audiences {
22 | constructor(private readonly resend: Resend) {}
23 |
24 | async create(
25 | payload: CreateAudienceOptions,
26 | options: CreateAudienceRequestOptions = {},
27 | ): Promise {
28 | const data = await this.resend.post(
29 | '/audiences',
30 | payload,
31 | options,
32 | );
33 | return data;
34 | }
35 |
36 | async list(): Promise {
37 | const data =
38 | await this.resend.get('/audiences');
39 | return data;
40 | }
41 |
42 | async get(id: string): Promise {
43 | const data = await this.resend.get(
44 | `/audiences/${id}`,
45 | );
46 | return data;
47 | }
48 |
49 | async remove(id: string): Promise {
50 | const data = await this.resend.delete(
51 | `/audiences/${id}`,
52 | );
53 | return data;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/audiences/interfaces/audience.ts:
--------------------------------------------------------------------------------
1 | export interface Audience {
2 | created_at: string;
3 | id: string;
4 | name: string;
5 | }
6 |
--------------------------------------------------------------------------------
/src/audiences/interfaces/create-audience-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { PostOptions } from '../../common/interfaces';
2 | import type { ErrorResponse } from '../../interfaces';
3 | import type { Audience } from './audience';
4 |
5 | export interface CreateAudienceOptions {
6 | name: string;
7 | }
8 |
9 | export interface CreateAudienceRequestOptions extends PostOptions {}
10 |
11 | export interface CreateAudienceResponseSuccess
12 | extends Pick {
13 | object: 'audience';
14 | }
15 |
16 | export interface CreateAudienceResponse {
17 | data: CreateAudienceResponseSuccess | null;
18 | error: ErrorResponse | null;
19 | }
20 |
--------------------------------------------------------------------------------
/src/audiences/interfaces/get-audience.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Audience } from './audience';
3 |
4 | export interface GetAudienceResponseSuccess
5 | extends Pick {
6 | object: 'audience';
7 | }
8 |
9 | export interface GetAudienceResponse {
10 | data: GetAudienceResponseSuccess | null;
11 | error: ErrorResponse | null;
12 | }
13 |
--------------------------------------------------------------------------------
/src/audiences/interfaces/list-audiences.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Audience } from './audience';
3 |
4 | export type ListAudiencesResponseSuccess = {
5 | object: 'list';
6 | data: Audience[];
7 | };
8 |
9 | export interface ListAudiencesResponse {
10 | data: ListAudiencesResponseSuccess | null;
11 | error: ErrorResponse | null;
12 | }
13 |
--------------------------------------------------------------------------------
/src/audiences/interfaces/remove-audience.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Audience } from './audience';
3 |
4 | export interface RemoveAudiencesResponseSuccess extends Pick {
5 | object: 'audience';
6 | deleted: boolean;
7 | }
8 |
9 | export interface RemoveAudiencesResponse {
10 | data: RemoveAudiencesResponseSuccess | null;
11 | error: ErrorResponse | null;
12 | }
13 |
--------------------------------------------------------------------------------
/src/batch/batch.spec.ts:
--------------------------------------------------------------------------------
1 | import { enableFetchMocks } from 'jest-fetch-mock';
2 | import { Resend } from '../resend';
3 | import type {
4 | CreateBatchOptions,
5 | CreateBatchSuccessResponse,
6 | } from './interfaces/create-batch-options.interface';
7 |
8 | enableFetchMocks();
9 |
10 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
11 |
12 | describe('Batch', () => {
13 | afterEach(() => fetchMock.resetMocks());
14 |
15 | describe('create', () => {
16 | it('sends multiple emails', async () => {
17 | const payload: CreateBatchOptions = [
18 | {
19 | from: 'bu@resend.com',
20 | to: 'zeno@resend.com',
21 | subject: 'Hello World',
22 | html: 'Hello world
',
23 | },
24 | {
25 | from: 'vitor@resend.com',
26 | to: 'zeno@resend.com',
27 | subject: 'Olá mundo',
28 | html: 'olá mundo
',
29 | },
30 | {
31 | from: 'bu@resend.com',
32 | to: 'vitor@resend.com',
33 | subject: 'Hi there',
34 | html: 'Hi there
',
35 | },
36 | ];
37 | const response: CreateBatchSuccessResponse = {
38 | data: [
39 | { id: 'aabeeefc-bd13-474a-a440-0ee139b3a4cc' },
40 | { id: 'aebe1c6e-30ad-4257-993b-519f5affa626' },
41 | { id: 'b2bc2598-f98b-4da4-86c9-7b32881ef394' },
42 | ],
43 | };
44 | fetchMock.mockOnce(JSON.stringify(response), {
45 | status: 200,
46 | headers: {
47 | 'content-type': 'application/json',
48 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
49 | },
50 | });
51 |
52 | const data = await resend.batch.create(payload);
53 | expect(data).toMatchInlineSnapshot(`
54 | {
55 | "data": {
56 | "data": [
57 | {
58 | "id": "aabeeefc-bd13-474a-a440-0ee139b3a4cc",
59 | },
60 | {
61 | "id": "aebe1c6e-30ad-4257-993b-519f5affa626",
62 | },
63 | {
64 | "id": "b2bc2598-f98b-4da4-86c9-7b32881ef394",
65 | },
66 | ],
67 | },
68 | "error": null,
69 | }
70 | `);
71 | });
72 |
73 | it('does not send the Idempotency-Key header when idempotencyKey is not provided', async () => {
74 | const response: CreateBatchSuccessResponse = {
75 | data: [
76 | {
77 | id: 'not-idempotent-123',
78 | },
79 | ],
80 | };
81 |
82 | fetchMock.mockOnce(JSON.stringify(response), {
83 | status: 200,
84 | headers: {
85 | 'content-type': 'application/json',
86 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
87 | },
88 | });
89 |
90 | const payload: CreateBatchOptions = [
91 | {
92 | from: 'admin@resend.com',
93 | to: 'user@resend.com',
94 | subject: 'Not Idempotent Test',
95 | html: 'Test
',
96 | },
97 | ];
98 |
99 | await resend.batch.create(payload);
100 |
101 | // Inspect the last fetch call and body
102 | const lastCall = fetchMock.mock.calls[0];
103 | expect(lastCall).toBeDefined();
104 |
105 | //@ts-ignore
106 | const hasIdempotencyKey = lastCall[1]?.headers.has('Idempotency-Key');
107 | expect(hasIdempotencyKey).toBeFalsy();
108 |
109 | //@ts-ignore
110 | const usedIdempotencyKey = lastCall[1]?.headers.get('Idempotency-Key');
111 | expect(usedIdempotencyKey).toBeNull();
112 | });
113 |
114 | it('sends the Idempotency-Key header when idempotencyKey is provided', async () => {
115 | const response: CreateBatchSuccessResponse = {
116 | data: [
117 | {
118 | id: 'idempotent-123',
119 | },
120 | ],
121 | };
122 |
123 | fetchMock.mockOnce(JSON.stringify(response), {
124 | status: 200,
125 | headers: {
126 | 'content-type': 'application/json',
127 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
128 | },
129 | });
130 |
131 | const payload: CreateBatchOptions = [
132 | {
133 | from: 'admin@resend.com',
134 | to: 'user@resend.com',
135 | subject: 'Idempotency Test',
136 | html: 'Test
',
137 | },
138 | ];
139 | const idempotencyKey = 'unique-key-123';
140 |
141 | await resend.batch.create(payload, { idempotencyKey });
142 |
143 | // Inspect the last fetch call and body
144 | const lastCall = fetchMock.mock.calls[0];
145 | expect(lastCall).toBeDefined();
146 |
147 | // Check if headers contains Idempotency-Key
148 | // In the mock, headers is an object with key-value pairs
149 | expect(fetchMock.mock.calls[0][1]?.headers).toBeDefined();
150 |
151 | //@ts-ignore
152 | const hasIdempotencyKey = lastCall[1]?.headers.has('Idempotency-Key');
153 | expect(hasIdempotencyKey).toBeTruthy();
154 |
155 | //@ts-ignore
156 | const usedIdempotencyKey = lastCall[1]?.headers.get('Idempotency-Key');
157 | expect(usedIdempotencyKey).toBe(idempotencyKey);
158 | });
159 | });
160 |
161 | describe('send', () => {
162 | it('sends multiple emails', async () => {
163 | const payload = [
164 | {
165 | from: 'bu@resend.com',
166 | to: 'zeno@resend.com',
167 | subject: 'Hello World',
168 | html: 'Hello world
',
169 | },
170 | {
171 | from: 'vitor@resend.com',
172 | to: 'zeno@resend.com',
173 | subject: 'Olá mundo',
174 | html: 'olá mundo
',
175 | },
176 | {
177 | from: 'bu@resend.com',
178 | to: 'vitor@resend.com',
179 | subject: 'Hi there',
180 | html: 'Hi there
',
181 | },
182 | ];
183 | const response: CreateBatchSuccessResponse = {
184 | data: [
185 | { id: 'aabeeefc-bd13-474a-a440-0ee139b3a4cc' },
186 | { id: 'aebe1c6e-30ad-4257-993b-519f5affa626' },
187 | { id: 'b2bc2598-f98b-4da4-86c9-7b32881ef394' },
188 | ],
189 | };
190 |
191 | fetchMock.mockOnce(JSON.stringify(response), {
192 | status: 200,
193 | headers: {
194 | 'content-type': 'application/json',
195 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
196 | },
197 | });
198 |
199 | const data = await resend.batch.send(payload);
200 | expect(data).toMatchInlineSnapshot(`
201 | {
202 | "data": {
203 | "data": [
204 | {
205 | "id": "aabeeefc-bd13-474a-a440-0ee139b3a4cc",
206 | },
207 | {
208 | "id": "aebe1c6e-30ad-4257-993b-519f5affa626",
209 | },
210 | {
211 | "id": "b2bc2598-f98b-4da4-86c9-7b32881ef394",
212 | },
213 | ],
214 | },
215 | "error": null,
216 | }
217 | `);
218 | });
219 |
220 | it('does not send the Idempotency-Key header when idempotencyKey is not provided', async () => {
221 | const response: CreateBatchSuccessResponse = {
222 | data: [
223 | {
224 | id: 'not-idempotent-123',
225 | },
226 | ],
227 | };
228 |
229 | fetchMock.mockOnce(JSON.stringify(response), {
230 | status: 200,
231 | headers: {
232 | 'content-type': 'application/json',
233 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
234 | },
235 | });
236 |
237 | const payload: CreateBatchOptions = [
238 | {
239 | from: 'admin@resend.com',
240 | to: 'user@resend.com',
241 | subject: 'Not Idempotent Test',
242 | html: 'Test
',
243 | },
244 | ];
245 |
246 | await resend.batch.send(payload);
247 |
248 | // Inspect the last fetch call and body
249 | const lastCall = fetchMock.mock.calls[0];
250 | expect(lastCall).toBeDefined();
251 |
252 | //@ts-ignore
253 | const hasIdempotencyKey = lastCall[1]?.headers.has('Idempotency-Key');
254 | expect(hasIdempotencyKey).toBeFalsy();
255 |
256 | //@ts-ignore
257 | const usedIdempotencyKey = lastCall[1]?.headers.get('Idempotency-Key');
258 | expect(usedIdempotencyKey).toBeNull();
259 | });
260 |
261 | it('sends the Idempotency-Key header when idempotencyKey is provided', async () => {
262 | const response: CreateBatchSuccessResponse = {
263 | data: [
264 | {
265 | id: 'idempotent-123',
266 | },
267 | ],
268 | };
269 |
270 | fetchMock.mockOnce(JSON.stringify(response), {
271 | status: 200,
272 | headers: {
273 | 'content-type': 'application/json',
274 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
275 | },
276 | });
277 |
278 | const payload: CreateBatchOptions = [
279 | {
280 | from: 'admin@resend.com',
281 | to: 'user@resend.com',
282 | subject: 'Idempotency Test',
283 | html: 'Test
',
284 | },
285 | ];
286 | const idempotencyKey = 'unique-key-123';
287 |
288 | await resend.batch.send(payload, { idempotencyKey });
289 |
290 | // Inspect the last fetch call and body
291 | const lastCall = fetchMock.mock.calls[0];
292 | expect(lastCall).toBeDefined();
293 |
294 | // Check if headers contains Idempotency-Key
295 | // In the mock, headers is an object with key-value pairs
296 | expect(fetchMock.mock.calls[0][1]?.headers).toBeDefined();
297 |
298 | //@ts-ignore
299 | const hasIdempotencyKey = lastCall[1]?.headers.has('Idempotency-Key');
300 | expect(hasIdempotencyKey).toBeTruthy();
301 |
302 | //@ts-ignore
303 | const usedIdempotencyKey = lastCall[1]?.headers.get('Idempotency-Key');
304 | expect(usedIdempotencyKey).toBe(idempotencyKey);
305 | });
306 | });
307 | });
308 |
--------------------------------------------------------------------------------
/src/batch/batch.ts:
--------------------------------------------------------------------------------
1 | import type * as React from 'react';
2 | import type { EmailApiOptions } from '../common/interfaces/email-api-options.interface';
3 | import { parseEmailToApiOptions } from '../common/utils/parse-email-to-api-options';
4 | import type { Resend } from '../resend';
5 | import type {
6 | CreateBatchOptions,
7 | CreateBatchRequestOptions,
8 | CreateBatchResponse,
9 | CreateBatchSuccessResponse,
10 | } from './interfaces/create-batch-options.interface';
11 |
12 | export class Batch {
13 | private renderAsync?: (component: React.ReactElement) => Promise;
14 | constructor(private readonly resend: Resend) {}
15 |
16 | async send(
17 | payload: CreateBatchOptions,
18 | options: CreateBatchRequestOptions = {},
19 | ): Promise {
20 | return this.create(payload, options);
21 | }
22 |
23 | async create(
24 | payload: CreateBatchOptions,
25 | options: CreateBatchRequestOptions = {},
26 | ): Promise {
27 | const emails: EmailApiOptions[] = [];
28 |
29 | for (const email of payload) {
30 | if (email.react) {
31 | if (!this.renderAsync) {
32 | try {
33 | const { renderAsync } = await import('@react-email/render');
34 | this.renderAsync = renderAsync;
35 | } catch (error) {
36 | throw new Error(
37 | 'Failed to render React component. Make sure to install `@react-email/render`',
38 | );
39 | }
40 | }
41 |
42 | email.html = await this.renderAsync(email.react as React.ReactElement);
43 | email.react = undefined;
44 | }
45 |
46 | emails.push(parseEmailToApiOptions(email));
47 | }
48 |
49 | const data = await this.resend.post(
50 | '/emails/batch',
51 | emails,
52 | options,
53 | );
54 |
55 | return data;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/batch/interfaces/create-batch-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { PostOptions } from '../../common/interfaces';
2 | import type { IdempotentRequest } from '../../common/interfaces/idempotent-request.interface';
3 | import type { CreateEmailOptions } from '../../emails/interfaces/create-email-options.interface';
4 | import type { ErrorResponse } from '../../interfaces';
5 |
6 | export type CreateBatchOptions = CreateEmailOptions[];
7 |
8 | export interface CreateBatchRequestOptions
9 | extends PostOptions,
10 | IdempotentRequest {}
11 |
12 | export interface CreateBatchSuccessResponse {
13 | data: {
14 | /** The ID of the newly created email. */
15 | id: string;
16 | }[];
17 | }
18 |
19 | export interface CreateBatchResponse {
20 | data: CreateBatchSuccessResponse | null;
21 | error: ErrorResponse | null;
22 | }
23 |
--------------------------------------------------------------------------------
/src/broadcasts/broadcasts.spec.ts:
--------------------------------------------------------------------------------
1 | import { enableFetchMocks } from 'jest-fetch-mock';
2 | import type { ErrorResponse } from '../interfaces';
3 | import { Resend } from '../resend';
4 | import type {
5 | CreateBroadcastOptions,
6 | CreateBroadcastResponseSuccess,
7 | } from './interfaces/create-broadcast-options.interface';
8 | import type { GetBroadcastResponseSuccess } from './interfaces/get-broadcast.interface';
9 | import type { ListBroadcastsResponseSuccess } from './interfaces/list-broadcasts.interface';
10 | import type { RemoveBroadcastResponseSuccess } from './interfaces/remove-broadcast.interface';
11 | import type { UpdateBroadcastResponseSuccess } from './interfaces/update-broadcast.interface';
12 |
13 | enableFetchMocks();
14 |
15 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
16 |
17 | describe('Broadcasts', () => {
18 | afterEach(() => fetchMock.resetMocks());
19 |
20 | describe('create', () => {
21 | it('missing `from`', async () => {
22 | const response: ErrorResponse = {
23 | name: 'missing_required_field',
24 | message: 'Missing `from` field.',
25 | };
26 |
27 | fetchMock.mockOnce(JSON.stringify(response), {
28 | status: 422,
29 | headers: {
30 | 'content-type': 'application/json',
31 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
32 | },
33 | });
34 |
35 | const data = await resend.broadcasts.create({} as CreateBroadcastOptions);
36 | expect(data).toMatchInlineSnapshot(`
37 | {
38 | "data": null,
39 | "error": {
40 | "message": "Missing \`from\` field.",
41 | "name": "missing_required_field",
42 | },
43 | }
44 | `);
45 | });
46 |
47 | it('creates broadcast', async () => {
48 | const response: CreateBroadcastResponseSuccess = {
49 | id: '71cdfe68-cf79-473a-a9d7-21f91db6a526',
50 | };
51 | fetchMock.mockOnce(JSON.stringify(response), {
52 | status: 200,
53 | headers: {
54 | 'content-type': 'application/json',
55 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
56 | },
57 | });
58 |
59 | const payload: CreateBroadcastOptions = {
60 | from: 'bu@resend.com',
61 | audienceId: '0192f4ed-c2e9-7112-9c13-b04a043e23ee',
62 | subject: 'Hello World',
63 | html: 'Hello world
',
64 | };
65 |
66 | const data = await resend.broadcasts.create(payload);
67 | expect(data).toMatchInlineSnapshot(`
68 | {
69 | "data": {
70 | "id": "71cdfe68-cf79-473a-a9d7-21f91db6a526",
71 | },
72 | "error": null,
73 | }
74 | `);
75 | });
76 |
77 | it('creates broadcast with multiple recipients', async () => {
78 | const response: CreateBroadcastResponseSuccess = {
79 | id: '124dc0f1-e36c-417c-a65c-e33773abc768',
80 | };
81 |
82 | fetchMock.mockOnce(JSON.stringify(response), {
83 | status: 200,
84 | headers: {
85 | 'content-type': 'application/json',
86 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
87 | },
88 | });
89 |
90 | const payload: CreateBroadcastOptions = {
91 | from: 'admin@resend.com',
92 | audienceId: '0192f4f1-d5f9-7110-8eb5-370552515917',
93 | subject: 'Hello World',
94 | text: 'Hello world',
95 | };
96 | const data = await resend.broadcasts.create(payload);
97 | expect(data).toMatchInlineSnapshot(`
98 | {
99 | "data": {
100 | "id": "124dc0f1-e36c-417c-a65c-e33773abc768",
101 | },
102 | "error": null,
103 | }
104 | `);
105 | });
106 |
107 | it('creates broadcast with multiple replyTo emails', async () => {
108 | const response: CreateBroadcastResponseSuccess = {
109 | id: '124dc0f1-e36c-417c-a65c-e33773abc768',
110 | };
111 |
112 | fetchMock.mockOnce(JSON.stringify(response), {
113 | status: 200,
114 | headers: {
115 | 'content-type': 'application/json',
116 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
117 | },
118 | });
119 |
120 | const payload: CreateBroadcastOptions = {
121 | from: 'admin@resend.com',
122 | audienceId: '0192f4f1-d5f9-7110-8eb5-370552515917',
123 | replyTo: ['foo@resend.com', 'bar@resend.com'],
124 | subject: 'Hello World',
125 | text: 'Hello world',
126 | };
127 |
128 | const data = await resend.broadcasts.create(payload);
129 | expect(data).toMatchInlineSnapshot(`
130 | {
131 | "data": {
132 | "id": "124dc0f1-e36c-417c-a65c-e33773abc768",
133 | },
134 | "error": null,
135 | }
136 | `);
137 | });
138 |
139 | it('throws an error when an ErrorResponse is returned', async () => {
140 | const response: ErrorResponse = {
141 | name: 'invalid_parameter',
142 | message:
143 | 'Invalid `from` field. The email address needs to follow the `email@example.com` or `Name ` format',
144 | };
145 |
146 | fetchMock.mockOnce(JSON.stringify(response), {
147 | status: 422,
148 | headers: {
149 | 'content-type': 'application/json',
150 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
151 | },
152 | });
153 |
154 | const payload: CreateBroadcastOptions = {
155 | from: 'resend.com', // Invalid from address
156 | audienceId: '0192f4f1-d5f9-7110-8eb5-370552515917',
157 | replyTo: ['foo@resend.com', 'bar@resend.com'],
158 | subject: 'Hello World',
159 | text: 'Hello world',
160 | };
161 |
162 | const result = resend.broadcasts.create(payload);
163 |
164 | await expect(result).resolves.toMatchInlineSnapshot(`
165 | {
166 | "data": null,
167 | "error": {
168 | "message": "Invalid \`from\` field. The email address needs to follow the \`email@example.com\` or \`Name \` format",
169 | "name": "invalid_parameter",
170 | },
171 | }
172 | `);
173 | });
174 |
175 | it('returns an error when fetch fails', async () => {
176 | const originalEnv = process.env;
177 | process.env = {
178 | ...originalEnv,
179 | RESEND_BASE_URL: 'http://invalidurl.noturl',
180 | };
181 |
182 | const result = await resend.broadcasts.create({
183 | from: 'example@resend.com',
184 | audienceId: '0192f4f1-d5f9-7110-8eb5-370552515917',
185 | subject: 'Hello World',
186 | text: 'Hello world',
187 | });
188 |
189 | expect(result).toEqual(
190 | expect.objectContaining({
191 | data: null,
192 | error: {
193 | message: 'Unable to fetch data. The request could not be resolved.',
194 | name: 'application_error',
195 | },
196 | }),
197 | );
198 | process.env = originalEnv;
199 | });
200 |
201 | it('returns an error when api responds with text payload', async () => {
202 | fetchMock.mockOnce('local_rate_limited', {
203 | status: 422,
204 | headers: {
205 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
206 | },
207 | });
208 |
209 | const result = await resend.broadcasts.create({
210 | from: 'example@resend.com',
211 | audienceId: '0192f4f1-d5f9-7110-8eb5-370552515917',
212 | subject: 'Hello World',
213 | text: 'Hello world',
214 | });
215 |
216 | expect(result).toEqual(
217 | expect.objectContaining({
218 | data: null,
219 | error: {
220 | message:
221 | 'Internal server error. We are unable to process your request right now, please try again later.',
222 | name: 'application_error',
223 | },
224 | }),
225 | );
226 | });
227 | });
228 |
229 | describe('send', () => {
230 | it('sends a broadcast successfully', async () => {
231 | const randomBroadcastId = 'b01e0de9-7c27-4a53-bf38-2e3f98389a65';
232 | const response = {
233 | id: randomBroadcastId,
234 | };
235 |
236 | fetchMock.mockOnce(JSON.stringify(response), {
237 | status: 200,
238 | headers: {
239 | 'content-type': 'application/json',
240 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
241 | },
242 | });
243 |
244 | const data = await resend.broadcasts.send(randomBroadcastId);
245 |
246 | expect(data).toMatchInlineSnapshot(`
247 | {
248 | "data": {
249 | "id": "b01e0de9-7c27-4a53-bf38-2e3f98389a65",
250 | },
251 | "error": null,
252 | }
253 | `);
254 | });
255 | });
256 |
257 | describe('list', () => {
258 | it('lists broadcasts', async () => {
259 | const response: ListBroadcastsResponseSuccess = {
260 | object: 'list',
261 | data: [
262 | {
263 | id: '49a3999c-0ce1-4ea6-ab68-afcd6dc2e794',
264 | audience_id: '78261eea-8f8b-4381-83c6-79fa7120f1cf',
265 | name: 'broadcast 1',
266 | status: 'draft',
267 | created_at: '2024-11-01T15:13:31.723Z',
268 | scheduled_at: null,
269 | sent_at: null,
270 | },
271 | {
272 | id: '559ac32e-9ef5-46fb-82a1-b76b840c0f7b',
273 | audience_id: '78261eea-8f8b-4381-83c6-79fa7120f1cf',
274 | name: 'broadcast 2',
275 | status: 'sent',
276 | created_at: '2024-12-01T19:32:22.980Z',
277 | scheduled_at: '2024-12-02T19:32:22.980Z',
278 | sent_at: '2024-12-02T19:32:22.980Z',
279 | },
280 | ],
281 | };
282 | fetchMock.mockOnce(JSON.stringify(response), {
283 | status: 200,
284 | headers: {
285 | 'content-type': 'application/json',
286 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
287 | },
288 | });
289 |
290 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
291 |
292 | await expect(resend.broadcasts.list()).resolves.toMatchInlineSnapshot(`
293 | {
294 | "data": {
295 | "data": [
296 | {
297 | "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
298 | "created_at": "2024-11-01T15:13:31.723Z",
299 | "id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794",
300 | "name": "broadcast 1",
301 | "scheduled_at": null,
302 | "sent_at": null,
303 | "status": "draft",
304 | },
305 | {
306 | "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
307 | "created_at": "2024-12-01T19:32:22.980Z",
308 | "id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b",
309 | "name": "broadcast 2",
310 | "scheduled_at": "2024-12-02T19:32:22.980Z",
311 | "sent_at": "2024-12-02T19:32:22.980Z",
312 | "status": "sent",
313 | },
314 | ],
315 | "object": "list",
316 | },
317 | "error": null,
318 | }
319 | `);
320 | });
321 | });
322 |
323 | describe('get', () => {
324 | describe('when broadcast not found', () => {
325 | it('returns error', async () => {
326 | const response: ErrorResponse = {
327 | name: 'not_found',
328 | message: 'Broadcast not found',
329 | };
330 |
331 | fetchMock.mockOnce(JSON.stringify(response), {
332 | status: 404,
333 | headers: {
334 | 'content-type': 'application/json',
335 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
336 | },
337 | });
338 |
339 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
340 |
341 | const result = resend.broadcasts.get(
342 | '559ac32e-9ef5-46fb-82a1-b76b840c0f7b',
343 | );
344 |
345 | await expect(result).resolves.toMatchInlineSnapshot(`
346 | {
347 | "data": null,
348 | "error": {
349 | "message": "Broadcast not found",
350 | "name": "not_found",
351 | },
352 | }
353 | `);
354 | });
355 | });
356 |
357 | it('get broadcast', async () => {
358 | const response: GetBroadcastResponseSuccess = {
359 | object: 'broadcast',
360 | id: '559ac32e-9ef5-46fb-82a1-b76b840c0f7b',
361 | name: 'Announcements',
362 | audience_id: '78261eea-8f8b-4381-83c6-79fa7120f1cf',
363 | from: 'Acme ',
364 | subject: 'hello world',
365 | reply_to: null,
366 | preview_text: 'Check out our latest announcements',
367 | status: 'draft',
368 | created_at: '2024-12-01T19:32:22.980Z',
369 | scheduled_at: null,
370 | sent_at: null,
371 | };
372 |
373 | fetchMock.mockOnce(JSON.stringify(response), {
374 | status: 200,
375 | headers: {
376 | 'content-type': 'application/json',
377 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
378 | },
379 | });
380 |
381 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
382 |
383 | await expect(
384 | resend.broadcasts.get('559ac32e-9ef5-46fb-82a1-b76b840c0f7b'),
385 | ).resolves.toMatchInlineSnapshot(`
386 | {
387 | "data": {
388 | "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
389 | "created_at": "2024-12-01T19:32:22.980Z",
390 | "from": "Acme ",
391 | "id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b",
392 | "name": "Announcements",
393 | "object": "broadcast",
394 | "preview_text": "Check out our latest announcements",
395 | "reply_to": null,
396 | "scheduled_at": null,
397 | "sent_at": null,
398 | "status": "draft",
399 | "subject": "hello world",
400 | },
401 | "error": null,
402 | }
403 | `);
404 | });
405 | });
406 |
407 | describe('remove', () => {
408 | it('removes a broadcast', async () => {
409 | const id = 'b01e0de9-7c27-4a53-bf38-2e3f98389a65';
410 | const response: RemoveBroadcastResponseSuccess = {
411 | object: 'broadcast',
412 | id,
413 | deleted: true,
414 | };
415 | fetchMock.mockOnce(JSON.stringify(response), {
416 | status: 200,
417 | headers: {
418 | 'content-type': 'application/json',
419 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
420 | },
421 | });
422 |
423 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
424 |
425 | await expect(
426 | resend.broadcasts.remove(id),
427 | ).resolves.toMatchInlineSnapshot(`
428 | {
429 | "data": {
430 | "deleted": true,
431 | "id": "b01e0de9-7c27-4a53-bf38-2e3f98389a65",
432 | "object": "broadcast",
433 | },
434 | "error": null,
435 | }
436 | `);
437 | });
438 | });
439 |
440 | describe('update', () => {
441 | it('updates a broadcast', async () => {
442 | const id = 'b01e0de9-7c27-4a53-bf38-2e3f98389a65';
443 | const response: UpdateBroadcastResponseSuccess = { id };
444 | fetchMock.mockOnce(JSON.stringify(response), {
445 | status: 200,
446 | headers: {
447 | 'content-type': 'application/json',
448 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
449 | },
450 | });
451 |
452 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
453 |
454 | await expect(
455 | resend.broadcasts.update(id, { name: 'New Name' }),
456 | ).resolves.toMatchInlineSnapshot(`
457 | {
458 | "data": {
459 | "id": "b01e0de9-7c27-4a53-bf38-2e3f98389a65",
460 | },
461 | "error": null,
462 | }
463 | `);
464 | });
465 | });
466 | });
467 |
--------------------------------------------------------------------------------
/src/broadcasts/broadcasts.ts:
--------------------------------------------------------------------------------
1 | import type * as React from 'react';
2 | import type { Resend } from '../resend';
3 | import type {
4 | CreateBroadcastOptions,
5 | CreateBroadcastRequestOptions,
6 | } from './interfaces/create-broadcast-options.interface';
7 | import type {
8 | GetBroadcastResponse,
9 | GetBroadcastResponseSuccess,
10 | } from './interfaces/get-broadcast.interface';
11 | import type {
12 | ListBroadcastsResponse,
13 | ListBroadcastsResponseSuccess,
14 | } from './interfaces/list-broadcasts.interface';
15 | import type {
16 | RemoveBroadcastResponse,
17 | RemoveBroadcastResponseSuccess,
18 | } from './interfaces/remove-broadcast.interface';
19 | import type {
20 | SendBroadcastOptions,
21 | SendBroadcastResponse,
22 | SendBroadcastResponseSuccess,
23 | } from './interfaces/send-broadcast-options.interface';
24 | import type {
25 | UpdateBroadcastOptions,
26 | UpdateBroadcastResponse,
27 | UpdateBroadcastResponseSuccess,
28 | } from './interfaces/update-broadcast.interface';
29 |
30 | export class Broadcasts {
31 | private renderAsync?: (component: React.ReactElement) => Promise;
32 | constructor(private readonly resend: Resend) {}
33 |
34 | async create(
35 | payload: CreateBroadcastOptions,
36 | options: CreateBroadcastRequestOptions = {},
37 | ): Promise {
38 | if (payload.react) {
39 | if (!this.renderAsync) {
40 | try {
41 | const { renderAsync } = await import('@react-email/render');
42 | this.renderAsync = renderAsync;
43 | } catch (error) {
44 | throw new Error(
45 | 'Failed to render React component. Make sure to install `@react-email/render`',
46 | );
47 | }
48 | }
49 |
50 | payload.html = await this.renderAsync(
51 | payload.react as React.ReactElement,
52 | );
53 | }
54 |
55 | const data = await this.resend.post(
56 | '/broadcasts',
57 | {
58 | name: payload.name,
59 | audience_id: payload.audienceId,
60 | preview_text: payload.previewText,
61 | from: payload.from,
62 | html: payload.html,
63 | reply_to: payload.replyTo,
64 | subject: payload.subject,
65 | text: payload.text,
66 | },
67 | options,
68 | );
69 |
70 | return data;
71 | }
72 |
73 | async send(
74 | id: string,
75 | payload?: SendBroadcastOptions,
76 | ): Promise {
77 | const data = await this.resend.post(
78 | `/broadcasts/${id}/send`,
79 | { scheduled_at: payload?.scheduledAt },
80 | );
81 |
82 | return data;
83 | }
84 |
85 | async list(): Promise {
86 | const data =
87 | await this.resend.get('/broadcasts');
88 | return data;
89 | }
90 |
91 | async get(id: string): Promise {
92 | const data = await this.resend.get(
93 | `/broadcasts/${id}`,
94 | );
95 | return data;
96 | }
97 |
98 | async remove(id: string): Promise {
99 | const data = await this.resend.delete(
100 | `/broadcasts/${id}`,
101 | );
102 | return data;
103 | }
104 |
105 | async update(
106 | id: string,
107 | payload: UpdateBroadcastOptions,
108 | ): Promise {
109 | const data = await this.resend.patch(
110 | `/broadcasts/${id}`,
111 | {
112 | name: payload.name,
113 | audience_id: payload.audienceId,
114 | from: payload.from,
115 | html: payload.html,
116 | text: payload.text,
117 | subject: payload.subject,
118 | reply_to: payload.replyTo,
119 | preview_text: payload.previewText,
120 | },
121 | );
122 | return data;
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/broadcasts/interfaces/broadcast.ts:
--------------------------------------------------------------------------------
1 | export interface Broadcast {
2 | id: string;
3 | name: string;
4 | audience_id: string | null;
5 | from: string | null;
6 | subject: string | null;
7 | reply_to: string[] | null;
8 | preview_text: string | null;
9 | status: 'draft' | 'sent' | 'queued';
10 | created_at: string;
11 | scheduled_at: string | null;
12 | sent_at: string | null;
13 | }
14 |
--------------------------------------------------------------------------------
/src/broadcasts/interfaces/create-broadcast-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type * as React from 'react';
2 | import type { PostOptions } from '../../common/interfaces';
3 | import type { RequireAtLeastOne } from '../../common/interfaces/require-at-least-one';
4 | import type { ErrorResponse } from '../../interfaces';
5 |
6 | interface EmailRenderOptions {
7 | /**
8 | * The React component used to write the message.
9 | *
10 | * @link https://resend.com/docs/api-reference/broadcasts/create#body-parameters
11 | */
12 | react: React.ReactNode;
13 | /**
14 | * The HTML version of the message.
15 | *
16 | * @link https://resend.com/docs/api-reference/broadcasts/create#body-parameters
17 | */
18 | html: string;
19 | /**
20 | * The plain text version of the message.
21 | *
22 | * @link https://resend.com/docs/api-reference/broadcasts/create#body-parameters
23 | */
24 | text: string;
25 | }
26 |
27 | interface CreateBroadcastBaseOptions {
28 | /**
29 | * The name of the broadcast
30 | *
31 | * @link https://resend.com/docs/api-reference/broadcasts/create#body-parameters
32 | */
33 | name?: string;
34 | /**
35 | * The id of the audience you want to send to
36 | *
37 | * @link https://resend.com/docs/api-reference/broadcasts/create#body-parameters
38 | */
39 | audienceId: string;
40 | /**
41 | * A short snippet of text displayed as a preview in recipients' inboxes, often shown below or beside the subject line.
42 | *
43 | * @link https://resend.com/docs/api-reference/broadcasts/create#body-parameters
44 | */
45 | previewText?: string;
46 | /**
47 | * Sender email address. To include a friendly name, use the format `"Your Name "`
48 | *
49 | * @link https://resend.com/docs/api-reference/broadcasts/create#body-parameters
50 | */
51 | from: string;
52 | /**
53 | * Reply-to email address. For multiple addresses, send as an array of strings.
54 | *
55 | * @link https://resend.com/docs/api-reference/broadcasts/create#body-parameters
56 | */
57 | replyTo?: string | string[];
58 | /**
59 | * Email subject.
60 | *
61 | * @link https://resend.com/docs/api-reference/broadcasts/create#body-parameters
62 | */
63 | subject: string;
64 | }
65 |
66 | export type CreateBroadcastOptions = RequireAtLeastOne &
67 | CreateBroadcastBaseOptions;
68 |
69 | export interface CreateBroadcastRequestOptions extends PostOptions {}
70 |
71 | export interface CreateBroadcastResponseSuccess {
72 | /** The ID of the newly sent broadcasts. */
73 | id: string;
74 | }
75 |
76 | export interface CreateBroadcastResponse {
77 | data: CreateBroadcastResponseSuccess | null;
78 | error: ErrorResponse | null;
79 | }
80 |
--------------------------------------------------------------------------------
/src/broadcasts/interfaces/get-broadcast.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Broadcast } from './broadcast';
3 |
4 | export interface GetBroadcastResponseSuccess extends Broadcast {
5 | object: 'broadcast';
6 | }
7 |
8 | export interface GetBroadcastResponse {
9 | data: GetBroadcastResponseSuccess | null;
10 | error: ErrorResponse | null;
11 | }
12 |
--------------------------------------------------------------------------------
/src/broadcasts/interfaces/list-broadcasts.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Broadcast } from './broadcast';
3 |
4 | export type ListBroadcastsResponseSuccess = {
5 | object: 'list';
6 | data: Pick<
7 | Broadcast,
8 | | 'id'
9 | | 'name'
10 | | 'audience_id'
11 | | 'status'
12 | | 'created_at'
13 | | 'scheduled_at'
14 | | 'sent_at'
15 | >[];
16 | };
17 |
18 | export interface ListBroadcastsResponse {
19 | data: ListBroadcastsResponseSuccess | null;
20 | error: ErrorResponse | null;
21 | }
22 |
--------------------------------------------------------------------------------
/src/broadcasts/interfaces/remove-broadcast.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Broadcast } from './broadcast';
3 |
4 | export interface RemoveBroadcastResponseSuccess extends Pick {
5 | object: 'broadcast';
6 | deleted: boolean;
7 | }
8 |
9 | export interface RemoveBroadcastResponse {
10 | data: RemoveBroadcastResponseSuccess | null;
11 | error: ErrorResponse | null;
12 | }
13 |
--------------------------------------------------------------------------------
/src/broadcasts/interfaces/send-broadcast-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { PostOptions } from '../../common/interfaces';
2 | import type { ErrorResponse } from '../../interfaces';
3 |
4 | interface SendBroadcastBaseOptions {
5 | /**
6 | * Schedule email to be sent later.
7 | * The date should be in ISO 8601 format (e.g: 2024-08-05T11:52:01.858Z)
8 | * or relative time (eg: in 2 days).
9 | *
10 | * @link https://resend.com/docs/api-reference/broadcasts/send#body-parameters
11 | */
12 | scheduledAt?: string;
13 | }
14 |
15 | export type SendBroadcastOptions = SendBroadcastBaseOptions;
16 |
17 | export interface SendBroadcastRequestOptions extends PostOptions {}
18 |
19 | export interface SendBroadcastResponseSuccess {
20 | /** The ID of the sent broadcast. */
21 | id: string;
22 | }
23 |
24 | export interface SendBroadcastResponse {
25 | data: SendBroadcastResponseSuccess | null;
26 | error: ErrorResponse | null;
27 | }
28 |
--------------------------------------------------------------------------------
/src/broadcasts/interfaces/update-broadcast.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 |
3 | export interface UpdateBroadcastResponseSuccess {
4 | id: string;
5 | }
6 |
7 | export interface UpdateBroadcastOptions {
8 | name?: string;
9 | audienceId?: string;
10 | from?: string;
11 | html?: string;
12 | text?: string;
13 | subject?: string;
14 | replyTo?: string[];
15 | previewText?: string;
16 | }
17 |
18 | export interface UpdateBroadcastResponse {
19 | data: UpdateBroadcastResponseSuccess | null;
20 | error: ErrorResponse | null;
21 | }
22 |
--------------------------------------------------------------------------------
/src/common/interfaces/domain-api-options.interface.ts:
--------------------------------------------------------------------------------
1 | export interface DomainApiOptions {
2 | name: string;
3 | region?: string;
4 | custom_return_path?: string;
5 | }
6 |
--------------------------------------------------------------------------------
/src/common/interfaces/email-api-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { Attachment } from '../../emails/interfaces/create-email-options.interface';
2 | import type { Tag } from '../../interfaces';
3 |
4 | export interface EmailApiOptions {
5 | from: string;
6 | to: string | string[];
7 | subject: string;
8 | region?: string;
9 | headers?: Record;
10 | html?: string;
11 | text?: string;
12 | bcc?: string | string[];
13 | cc?: string | string[];
14 | reply_to?: string | string[];
15 | scheduled_at?: string;
16 | tags?: Tag[];
17 | attachments?: Attachment[];
18 | }
19 |
--------------------------------------------------------------------------------
/src/common/interfaces/get-option.interface.ts:
--------------------------------------------------------------------------------
1 | export interface GetOptions {
2 | query?: Record;
3 | }
4 |
--------------------------------------------------------------------------------
/src/common/interfaces/idempotent-request.interface.ts:
--------------------------------------------------------------------------------
1 | export interface IdempotentRequest {
2 | /**
3 | * Unique key that ensures the same operation is not processed multiple times.
4 | * Allows for safe retries without duplicating operations.
5 | * If provided, will be sent as the `Idempotency-Key` header.
6 | */
7 | idempotencyKey?: string;
8 | }
9 |
--------------------------------------------------------------------------------
/src/common/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | export * from './get-option.interface';
2 | export * from './list-option.interface';
3 | export * from './post-option.interface';
4 | export * from './put-option.interface';
5 |
--------------------------------------------------------------------------------
/src/common/interfaces/list-option.interface.ts:
--------------------------------------------------------------------------------
1 | export interface List {
2 | readonly object: 'list';
3 | data: T[];
4 | }
5 |
--------------------------------------------------------------------------------
/src/common/interfaces/patch-option.interface.ts:
--------------------------------------------------------------------------------
1 | export interface PatchOptions {
2 | query?: { [key: string]: unknown };
3 | }
4 |
--------------------------------------------------------------------------------
/src/common/interfaces/post-option.interface.ts:
--------------------------------------------------------------------------------
1 | export interface PostOptions {
2 | query?: { [key: string]: unknown };
3 | }
4 |
--------------------------------------------------------------------------------
/src/common/interfaces/put-option.interface.ts:
--------------------------------------------------------------------------------
1 | export interface PutOptions {
2 | query?: { [key: string]: unknown };
3 | }
4 |
--------------------------------------------------------------------------------
/src/common/interfaces/require-at-least-one.ts:
--------------------------------------------------------------------------------
1 | export type RequireAtLeastOne = {
2 | [K in keyof T]-?: Required> &
3 | Partial>>;
4 | }[keyof T];
5 |
--------------------------------------------------------------------------------
/src/common/utils/parse-domain-to-api-options.spec.ts:
--------------------------------------------------------------------------------
1 | import type { CreateDomainOptions } from '../../domains/interfaces/create-domain-options.interface';
2 | import { parseDomainToApiOptions } from './parse-domain-to-api-options';
3 |
4 | describe('parseDomainToApiOptions', () => {
5 | it('should handle minimal domain with only required fields', () => {
6 | const domainPayload: CreateDomainOptions = {
7 | name: 'example.com',
8 | };
9 |
10 | const apiOptions = parseDomainToApiOptions(domainPayload);
11 |
12 | expect(apiOptions).toEqual({
13 | name: 'example.com',
14 | });
15 | });
16 |
17 | it('should properly parse camel case to snake case', () => {
18 | const domainPayload: CreateDomainOptions = {
19 | name: 'example.com',
20 | region: 'us-east-1',
21 | customReturnPath: 'bounce@example.com',
22 | };
23 |
24 | const apiOptions = parseDomainToApiOptions(domainPayload);
25 |
26 | expect(apiOptions).toEqual({
27 | name: 'example.com',
28 | region: 'us-east-1',
29 | custom_return_path: 'bounce@example.com',
30 | });
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/src/common/utils/parse-domain-to-api-options.ts:
--------------------------------------------------------------------------------
1 | import type { CreateDomainOptions } from '../../domains/interfaces/create-domain-options.interface';
2 | import type { DomainApiOptions } from '../interfaces/domain-api-options.interface';
3 |
4 | export function parseDomainToApiOptions(
5 | domain: CreateDomainOptions,
6 | ): DomainApiOptions {
7 | return {
8 | name: domain.name,
9 | region: domain.region,
10 | custom_return_path: domain.customReturnPath,
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/src/common/utils/parse-email-to-api-options.spec.ts:
--------------------------------------------------------------------------------
1 | import type { CreateEmailOptions } from '../../emails/interfaces/create-email-options.interface';
2 | import { parseEmailToApiOptions } from './parse-email-to-api-options';
3 |
4 | describe('parseEmailToApiOptions', () => {
5 | it('should handle minimal email with only required fields', () => {
6 | const emailPayload: CreateEmailOptions = {
7 | from: 'joao@resend.com',
8 | to: 'bu@resend.com',
9 | subject: 'Hey, there!',
10 | html: 'Hey, there!
',
11 | };
12 |
13 | const apiOptions = parseEmailToApiOptions(emailPayload);
14 |
15 | expect(apiOptions).toEqual({
16 | from: 'joao@resend.com',
17 | to: 'bu@resend.com',
18 | subject: 'Hey, there!',
19 | html: 'Hey, there!
',
20 | });
21 | });
22 |
23 | it('should properly parse camel case to snake case', () => {
24 | const emailPayload: CreateEmailOptions = {
25 | from: 'joao@resend.com',
26 | to: 'bu@resend.com',
27 | subject: 'Hey, there!',
28 | html: 'Hey, there!
',
29 | replyTo: 'zeno@resend.com',
30 | scheduledAt: 'in 1 min',
31 | };
32 |
33 | const apiOptions = parseEmailToApiOptions(emailPayload);
34 |
35 | expect(apiOptions).toEqual({
36 | from: 'joao@resend.com',
37 | to: 'bu@resend.com',
38 | subject: 'Hey, there!',
39 | html: 'Hey, there!
',
40 | reply_to: 'zeno@resend.com',
41 | scheduled_at: 'in 1 min',
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/src/common/utils/parse-email-to-api-options.ts:
--------------------------------------------------------------------------------
1 | import type { CreateEmailOptions } from '../../emails/interfaces/create-email-options.interface';
2 | import type { EmailApiOptions } from '../interfaces/email-api-options.interface';
3 |
4 | export function parseEmailToApiOptions(
5 | email: CreateEmailOptions,
6 | ): EmailApiOptions {
7 | return {
8 | attachments: email.attachments,
9 | bcc: email.bcc,
10 | cc: email.cc,
11 | from: email.from,
12 | headers: email.headers,
13 | html: email.html,
14 | reply_to: email.replyTo,
15 | scheduled_at: email.scheduledAt,
16 | subject: email.subject,
17 | tags: email.tags,
18 | text: email.text,
19 | to: email.to,
20 | };
21 | }
22 |
--------------------------------------------------------------------------------
/src/contacts/contacts.spec.ts:
--------------------------------------------------------------------------------
1 | import { enableFetchMocks } from 'jest-fetch-mock';
2 | import type { ErrorResponse } from '../interfaces';
3 | import { Resend } from '../resend';
4 | import type {
5 | CreateContactOptions,
6 | CreateContactResponseSuccess,
7 | } from './interfaces/create-contact-options.interface';
8 | import type {
9 | GetContactOptions,
10 | GetContactResponseSuccess,
11 | } from './interfaces/get-contact.interface';
12 | import type {
13 | ListContactsOptions,
14 | ListContactsResponseSuccess,
15 | } from './interfaces/list-contacts.interface';
16 | import type {
17 | RemoveContactOptions,
18 | RemoveContactsResponseSuccess,
19 | } from './interfaces/remove-contact.interface';
20 | import type { UpdateContactOptions } from './interfaces/update-contact.interface';
21 |
22 | enableFetchMocks();
23 |
24 | describe('Contacts', () => {
25 | afterEach(() => fetchMock.resetMocks());
26 |
27 | describe('create', () => {
28 | it('creates a contact', async () => {
29 | const payload: CreateContactOptions = {
30 | email: 'team@resend.com',
31 | audienceId: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
32 | };
33 | const response: CreateContactResponseSuccess = {
34 | object: 'contact',
35 | id: '3deaccfb-f47f-440a-8875-ea14b1716b43',
36 | };
37 |
38 | fetchMock.mockOnce(JSON.stringify(response), {
39 | status: 200,
40 | headers: {
41 | 'content-type': 'application/json',
42 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
43 | },
44 | });
45 |
46 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
47 | await expect(
48 | resend.contacts.create(payload),
49 | ).resolves.toMatchInlineSnapshot(`
50 | {
51 | "data": {
52 | "id": "3deaccfb-f47f-440a-8875-ea14b1716b43",
53 | "object": "contact",
54 | },
55 | "error": null,
56 | }
57 | `);
58 | });
59 |
60 | it('throws error when missing name', async () => {
61 | const payload: CreateContactOptions = {
62 | email: '',
63 | audienceId: '',
64 | };
65 | const response: ErrorResponse = {
66 | name: 'missing_required_field',
67 | message: 'Missing `email` field.',
68 | };
69 |
70 | fetchMock.mockOnce(JSON.stringify(response), {
71 | status: 422,
72 | headers: {
73 | 'content-type': 'application/json',
74 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
75 | },
76 | });
77 |
78 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
79 |
80 | const result = resend.contacts.create(payload);
81 |
82 | await expect(result).resolves.toMatchInlineSnapshot(`
83 | {
84 | "data": null,
85 | "error": {
86 | "message": "Missing \`email\` field.",
87 | "name": "missing_required_field",
88 | },
89 | }
90 | `);
91 | });
92 | });
93 |
94 | describe('list', () => {
95 | it('lists contacts', async () => {
96 | const options: ListContactsOptions = {
97 | audienceId: 'b6d24b8e-af0b-4c3c-be0c-359bbd97381a',
98 | };
99 | const response: ListContactsResponseSuccess = {
100 | object: 'list',
101 | data: [
102 | {
103 | id: 'b6d24b8e-af0b-4c3c-be0c-359bbd97381e',
104 | email: 'team@resend.com',
105 | created_at: '2023-04-07T23:13:52.669661+00:00',
106 | unsubscribed: false,
107 | first_name: 'John',
108 | last_name: 'Smith',
109 | },
110 | {
111 | id: 'ac7503ac-e027-4aea-94b3-b0acd46f65f9',
112 | email: 'team@react.email',
113 | created_at: '2023-04-07T23:13:20.417116+00:00',
114 | unsubscribed: false,
115 | first_name: 'John',
116 | last_name: 'Smith',
117 | },
118 | ],
119 | };
120 | fetchMock.mockOnce(JSON.stringify(response), {
121 | status: 200,
122 | headers: {
123 | 'content-type': 'application/json',
124 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
125 | },
126 | });
127 |
128 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
129 |
130 | await expect(
131 | resend.contacts.list(options),
132 | ).resolves.toMatchInlineSnapshot(`
133 | {
134 | "data": {
135 | "data": [
136 | {
137 | "created_at": "2023-04-07T23:13:52.669661+00:00",
138 | "email": "team@resend.com",
139 | "first_name": "John",
140 | "id": "b6d24b8e-af0b-4c3c-be0c-359bbd97381e",
141 | "last_name": "Smith",
142 | "unsubscribed": false,
143 | },
144 | {
145 | "created_at": "2023-04-07T23:13:20.417116+00:00",
146 | "email": "team@react.email",
147 | "first_name": "John",
148 | "id": "ac7503ac-e027-4aea-94b3-b0acd46f65f9",
149 | "last_name": "Smith",
150 | "unsubscribed": false,
151 | },
152 | ],
153 | "object": "list",
154 | },
155 | "error": null,
156 | }
157 | `);
158 | });
159 | });
160 |
161 | describe('get', () => {
162 | describe('when contact not found', () => {
163 | it('returns error', async () => {
164 | const response: ErrorResponse = {
165 | name: 'not_found',
166 | message: 'Contact not found',
167 | };
168 |
169 | fetchMock.mockOnce(JSON.stringify(response), {
170 | status: 404,
171 | headers: {
172 | 'content-type': 'application/json',
173 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
174 | },
175 | });
176 |
177 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
178 |
179 | const options: GetContactOptions = {
180 | id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223',
181 | audienceId: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
182 | };
183 | const result = resend.contacts.get(options);
184 |
185 | await expect(result).resolves.toMatchInlineSnapshot(`
186 | {
187 | "data": null,
188 | "error": {
189 | "message": "Contact not found",
190 | "name": "not_found",
191 | },
192 | }
193 | `);
194 | });
195 | });
196 |
197 | it('get contact by id', async () => {
198 | const response: GetContactResponseSuccess = {
199 | object: 'contact',
200 | id: 'fd61172c-cafc-40f5-b049-b45947779a29',
201 | email: 'team@resend.com',
202 | first_name: '',
203 | last_name: '',
204 | created_at: '2024-01-16T18:12:26.514Z',
205 | unsubscribed: false,
206 | };
207 |
208 | fetchMock.mockOnce(JSON.stringify(response), {
209 | status: 200,
210 | headers: {
211 | 'content-type': 'application/json',
212 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
213 | },
214 | });
215 |
216 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
217 | const options: GetContactOptions = {
218 | id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223',
219 | audienceId: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
220 | };
221 | await expect(
222 | resend.contacts.get(options),
223 | ).resolves.toMatchInlineSnapshot(`
224 | {
225 | "data": {
226 | "created_at": "2024-01-16T18:12:26.514Z",
227 | "email": "team@resend.com",
228 | "first_name": "",
229 | "id": "fd61172c-cafc-40f5-b049-b45947779a29",
230 | "last_name": "",
231 | "object": "contact",
232 | "unsubscribed": false,
233 | },
234 | "error": null,
235 | }
236 | `);
237 | });
238 |
239 | it('get contact by email', async () => {
240 | const response: GetContactResponseSuccess = {
241 | object: 'contact',
242 | id: 'fd61172c-cafc-40f5-b049-b45947779a29',
243 | email: 'team@resend.com',
244 | first_name: '',
245 | last_name: '',
246 | created_at: '2024-01-16T18:12:26.514Z',
247 | unsubscribed: false,
248 | };
249 |
250 | fetchMock.mockOnce(JSON.stringify(response), {
251 | status: 200,
252 | headers: {
253 | 'content-type': 'application/json',
254 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
255 | },
256 | });
257 |
258 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
259 | const options: GetContactOptions = {
260 | email: 'team@resend.com',
261 | audienceId: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
262 | };
263 | await expect(
264 | resend.contacts.get(options),
265 | ).resolves.toMatchInlineSnapshot(`
266 | {
267 | "data": {
268 | "created_at": "2024-01-16T18:12:26.514Z",
269 | "email": "team@resend.com",
270 | "first_name": "",
271 | "id": "fd61172c-cafc-40f5-b049-b45947779a29",
272 | "last_name": "",
273 | "object": "contact",
274 | "unsubscribed": false,
275 | },
276 | "error": null,
277 | }
278 | `);
279 | });
280 | });
281 |
282 | describe('update', () => {
283 | it('updates a contact', async () => {
284 | const payload: UpdateContactOptions = {
285 | id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223',
286 | audienceId: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
287 | firstName: 'Bu',
288 | };
289 | const response = {
290 | id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223',
291 | object: 'contact',
292 | };
293 | fetchMock.mockOnce(JSON.stringify(response), {
294 | status: 200,
295 | headers: {
296 | 'content-type': 'application/json',
297 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
298 | },
299 | });
300 |
301 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
302 |
303 | await expect(
304 | resend.contacts.update(payload),
305 | ).resolves.toMatchInlineSnapshot(`
306 | {
307 | "data": {
308 | "id": "3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223",
309 | "object": "contact",
310 | },
311 | "error": null,
312 | }
313 | `);
314 | });
315 | });
316 |
317 | describe('remove', () => {
318 | it('removes a contact by id', async () => {
319 | const response: RemoveContactsResponseSuccess = {
320 | contact: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223',
321 | object: 'contact',
322 | deleted: true,
323 | };
324 | fetchMock.mockOnce(JSON.stringify(response), {
325 | status: 200,
326 | headers: {
327 | 'content-type': 'application/json',
328 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
329 | },
330 | });
331 |
332 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
333 | const options: RemoveContactOptions = {
334 | id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223',
335 | audienceId: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
336 | };
337 | await expect(
338 | resend.contacts.remove(options),
339 | ).resolves.toMatchInlineSnapshot(`
340 | {
341 | "data": {
342 | "contact": "3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223",
343 | "deleted": true,
344 | "object": "contact",
345 | },
346 | "error": null,
347 | }
348 | `);
349 | });
350 |
351 | it('removes a contact by email', async () => {
352 | const response: RemoveContactsResponseSuccess = {
353 | contact: 'acme@example.com',
354 | object: 'contact',
355 | deleted: true,
356 | };
357 | fetchMock.mockOnce(JSON.stringify(response), {
358 | status: 200,
359 | headers: {
360 | 'content-type': 'application/json',
361 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
362 | },
363 | });
364 |
365 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
366 | const options: RemoveContactOptions = {
367 | email: 'acme@example.com',
368 | audienceId: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
369 | };
370 | await expect(
371 | resend.contacts.remove(options),
372 | ).resolves.toMatchInlineSnapshot(`
373 | {
374 | "data": {
375 | "contact": "acme@example.com",
376 | "deleted": true,
377 | "object": "contact",
378 | },
379 | "error": null,
380 | }
381 | `);
382 | });
383 | });
384 | });
385 |
--------------------------------------------------------------------------------
/src/contacts/contacts.ts:
--------------------------------------------------------------------------------
1 | import type { Resend } from '../resend';
2 | import type {
3 | CreateContactOptions,
4 | CreateContactRequestOptions,
5 | CreateContactResponse,
6 | CreateContactResponseSuccess,
7 | } from './interfaces/create-contact-options.interface';
8 | import type {
9 | GetContactOptions,
10 | GetContactResponse,
11 | GetContactResponseSuccess,
12 | } from './interfaces/get-contact.interface';
13 | import type {
14 | ListContactsOptions,
15 | ListContactsResponse,
16 | ListContactsResponseSuccess,
17 | } from './interfaces/list-contacts.interface';
18 | import type {
19 | RemoveContactOptions,
20 | RemoveContactsResponse,
21 | RemoveContactsResponseSuccess,
22 | } from './interfaces/remove-contact.interface';
23 | import type {
24 | UpdateContactOptions,
25 | UpdateContactResponse,
26 | UpdateContactResponseSuccess,
27 | } from './interfaces/update-contact.interface';
28 |
29 | export class Contacts {
30 | constructor(private readonly resend: Resend) {}
31 |
32 | async create(
33 | payload: CreateContactOptions,
34 | options: CreateContactRequestOptions = {},
35 | ): Promise {
36 | const data = await this.resend.post(
37 | `/audiences/${payload.audienceId}/contacts`,
38 | {
39 | unsubscribed: payload.unsubscribed,
40 | email: payload.email,
41 | first_name: payload.firstName,
42 | last_name: payload.lastName,
43 | },
44 | options,
45 | );
46 | return data;
47 | }
48 |
49 | async list(options: ListContactsOptions): Promise {
50 | const data = await this.resend.get(
51 | `/audiences/${options.audienceId}/contacts`,
52 | );
53 | return data;
54 | }
55 |
56 | async get(options: GetContactOptions): Promise {
57 | if (!options.id && !options.email) {
58 | return {
59 | data: null,
60 | error: {
61 | message: 'Missing `id` or `email` field.',
62 | name: 'missing_required_field',
63 | },
64 | };
65 | }
66 |
67 | const data = await this.resend.get(
68 | `/audiences/${options.audienceId}/contacts/${options?.email ? options?.email : options?.id}`,
69 | );
70 | return data;
71 | }
72 |
73 | async update(payload: UpdateContactOptions): Promise {
74 | if (!payload.id && !payload.email) {
75 | return {
76 | data: null,
77 | error: {
78 | message: 'Missing `id` or `email` field.',
79 | name: 'missing_required_field',
80 | },
81 | };
82 | }
83 |
84 | const data = await this.resend.patch(
85 | `/audiences/${payload.audienceId}/contacts/${payload?.email ? payload?.email : payload?.id}`,
86 | {
87 | unsubscribed: payload.unsubscribed,
88 | first_name: payload.firstName,
89 | last_name: payload.lastName,
90 | },
91 | );
92 | return data;
93 | }
94 |
95 | async remove(payload: RemoveContactOptions): Promise {
96 | if (!payload.id && !payload.email) {
97 | return {
98 | data: null,
99 | error: {
100 | message: 'Missing `id` or `email` field.',
101 | name: 'missing_required_field',
102 | },
103 | };
104 | }
105 |
106 | const data = await this.resend.delete(
107 | `/audiences/${payload.audienceId}/contacts/${
108 | payload?.email ? payload?.email : payload?.id
109 | }`,
110 | );
111 | return data;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/contacts/interfaces/contact.ts:
--------------------------------------------------------------------------------
1 | export interface Contact {
2 | created_at: string;
3 | id: string;
4 | email: string;
5 | first_name?: string;
6 | last_name?: string;
7 | unsubscribed: boolean;
8 | }
9 |
--------------------------------------------------------------------------------
/src/contacts/interfaces/create-contact-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { PostOptions } from '../../common/interfaces';
2 | import type { ErrorResponse } from '../../interfaces';
3 | import type { Contact } from './contact';
4 |
5 | export interface CreateContactOptions {
6 | audienceId: string;
7 | email: string;
8 | unsubscribed?: boolean;
9 | firstName?: string;
10 | lastName?: string;
11 | }
12 |
13 | export interface CreateContactRequestOptions extends PostOptions {}
14 |
15 | export interface CreateContactResponseSuccess extends Pick {
16 | object: 'contact';
17 | }
18 |
19 | export interface CreateContactResponse {
20 | data: CreateContactResponseSuccess | null;
21 | error: ErrorResponse | null;
22 | }
23 |
--------------------------------------------------------------------------------
/src/contacts/interfaces/get-contact.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Contact } from './contact';
3 |
4 | export interface GetContactOptions {
5 | audienceId: string;
6 | id?: string;
7 | email?: string;
8 | }
9 |
10 | export interface GetContactResponseSuccess
11 | extends Pick<
12 | Contact,
13 | 'id' | 'email' | 'created_at' | 'first_name' | 'last_name' | 'unsubscribed'
14 | > {
15 | object: 'contact';
16 | }
17 |
18 | export interface GetContactResponse {
19 | data: GetContactResponseSuccess | null;
20 | error: ErrorResponse | null;
21 | }
22 |
--------------------------------------------------------------------------------
/src/contacts/interfaces/list-contacts.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Contact } from './contact';
3 |
4 | export interface ListContactsOptions {
5 | audienceId: string;
6 | }
7 |
8 | export interface ListContactsResponseSuccess {
9 | object: 'list';
10 | data: Contact[];
11 | }
12 |
13 | export interface ListContactsResponse {
14 | data: ListContactsResponseSuccess | null;
15 | error: ErrorResponse | null;
16 | }
17 |
--------------------------------------------------------------------------------
/src/contacts/interfaces/remove-contact.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 |
3 | export type RemoveContactsResponseSuccess = {
4 | object: 'contact';
5 | deleted: boolean;
6 | contact: string;
7 | };
8 |
9 | interface RemoveByOptions {
10 | /**
11 | * The contact id.
12 | *
13 | * @link https://resend.com/docs/api-reference/contacts/delete-contact#body-parameters
14 | */
15 | id?: string;
16 | /**
17 | * The contact email.
18 | *
19 | * @link https://resend.com/docs/api-reference/contacts/delete-contact#body-parameters
20 | */
21 | email?: string;
22 | }
23 |
24 | export interface RemoveContactOptions extends RemoveByOptions {
25 | audienceId: string;
26 | }
27 |
28 | export interface RemoveContactsResponse {
29 | data: RemoveContactsResponseSuccess | null;
30 | error: ErrorResponse | null;
31 | }
32 |
--------------------------------------------------------------------------------
/src/contacts/interfaces/update-contact.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Contact } from './contact';
3 |
4 | interface UpdateContactBaseOptions {
5 | id?: string;
6 | email?: string;
7 | }
8 |
9 | export interface UpdateContactOptions extends UpdateContactBaseOptions {
10 | audienceId: string;
11 | unsubscribed?: boolean;
12 | firstName?: string;
13 | lastName?: string;
14 | }
15 |
16 | export type UpdateContactResponseSuccess = Pick & {
17 | object: 'contact';
18 | };
19 |
20 | export interface UpdateContactResponse {
21 | data: UpdateContactResponseSuccess | null;
22 | error: ErrorResponse | null;
23 | }
24 |
--------------------------------------------------------------------------------
/src/domains/domains.spec.ts:
--------------------------------------------------------------------------------
1 | import { enableFetchMocks } from 'jest-fetch-mock';
2 | import type { ErrorResponse } from '../interfaces';
3 | import { Resend } from '../resend';
4 | import type {
5 | CreateDomainOptions,
6 | CreateDomainResponseSuccess,
7 | } from './interfaces/create-domain-options.interface';
8 | import type { DomainRegion } from './interfaces/domain';
9 | import type { GetDomainResponseSuccess } from './interfaces/get-domain.interface';
10 | import type { ListDomainsResponseSuccess } from './interfaces/list-domains.interface';
11 | import type { RemoveDomainsResponseSuccess } from './interfaces/remove-domain.interface';
12 | import type { UpdateDomainsResponseSuccess } from './interfaces/update-domain.interface';
13 | import type { VerifyDomainsResponseSuccess } from './interfaces/verify-domain.interface';
14 |
15 | enableFetchMocks();
16 |
17 | describe('Domains', () => {
18 | afterEach(() => fetchMock.resetMocks());
19 |
20 | describe('create', () => {
21 | it('creates a domain', async () => {
22 | const response: CreateDomainResponseSuccess = {
23 | id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
24 | name: 'resend.com',
25 | created_at: '2023-04-07T22:48:33.420498+00:00',
26 | status: 'not_started',
27 | records: [
28 | {
29 | record: 'SPF',
30 | name: 'bounces',
31 | type: 'MX',
32 | ttl: 'Auto',
33 | status: 'not_started',
34 | value: 'feedback-smtp.us-east-1.com',
35 | priority: 10,
36 | },
37 | {
38 | record: 'SPF',
39 | name: 'bounces',
40 | value: '"v=spf1 include:com ~all"',
41 | type: 'TXT',
42 | ttl: 'Auto',
43 | status: 'not_started',
44 | },
45 | {
46 | record: 'DKIM',
47 | name: 'nu22pfdfqaxdybogtw3ebaokmalv5mxg._domainkey',
48 | value: 'nu22pfdfqaxdybogtw3ebaokmalv5mxg.dkim.com.',
49 | type: 'CNAME',
50 | status: 'not_started',
51 | ttl: 'Auto',
52 | },
53 | {
54 | record: 'DKIM',
55 | name: 'qklz5ozk742hhql3vmekdu3pr4f5ggsj._domainkey',
56 | value: 'qklz5ozk742hhql3vmekdu3pr4f5ggsj.dkim.com.',
57 | type: 'CNAME',
58 | status: 'not_started',
59 | ttl: 'Auto',
60 | },
61 | {
62 | record: 'DKIM',
63 | name: 'eeaemodxoao5hxwjvhywx4bo5mswjw6v._domainkey',
64 | value: 'eeaemodxoao5hxwjvhywx4bo5mswjw6v.dkim.com.',
65 | type: 'CNAME',
66 | status: 'not_started',
67 | ttl: 'Auto',
68 | },
69 | ],
70 | region: 'us-east-1',
71 | };
72 | fetchMock.mockOnce(JSON.stringify(response), {
73 | status: 200,
74 | headers: {
75 | 'content-type': 'application/json',
76 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
77 | },
78 | });
79 | const payload: CreateDomainOptions = { name: 'resend.com' };
80 |
81 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
82 | await expect(
83 | resend.domains.create(payload),
84 | ).resolves.toMatchInlineSnapshot(`
85 | {
86 | "data": {
87 | "created_at": "2023-04-07T22:48:33.420498+00:00",
88 | "id": "3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222",
89 | "name": "resend.com",
90 | "records": [
91 | {
92 | "name": "bounces",
93 | "priority": 10,
94 | "record": "SPF",
95 | "status": "not_started",
96 | "ttl": "Auto",
97 | "type": "MX",
98 | "value": "feedback-smtp.us-east-1.com",
99 | },
100 | {
101 | "name": "bounces",
102 | "record": "SPF",
103 | "status": "not_started",
104 | "ttl": "Auto",
105 | "type": "TXT",
106 | "value": ""v=spf1 include:com ~all"",
107 | },
108 | {
109 | "name": "nu22pfdfqaxdybogtw3ebaokmalv5mxg._domainkey",
110 | "record": "DKIM",
111 | "status": "not_started",
112 | "ttl": "Auto",
113 | "type": "CNAME",
114 | "value": "nu22pfdfqaxdybogtw3ebaokmalv5mxg.dkim.com.",
115 | },
116 | {
117 | "name": "qklz5ozk742hhql3vmekdu3pr4f5ggsj._domainkey",
118 | "record": "DKIM",
119 | "status": "not_started",
120 | "ttl": "Auto",
121 | "type": "CNAME",
122 | "value": "qklz5ozk742hhql3vmekdu3pr4f5ggsj.dkim.com.",
123 | },
124 | {
125 | "name": "eeaemodxoao5hxwjvhywx4bo5mswjw6v._domainkey",
126 | "record": "DKIM",
127 | "status": "not_started",
128 | "ttl": "Auto",
129 | "type": "CNAME",
130 | "value": "eeaemodxoao5hxwjvhywx4bo5mswjw6v.dkim.com.",
131 | },
132 | ],
133 | "region": "us-east-1",
134 | "status": "not_started",
135 | },
136 | "error": null,
137 | }
138 | `);
139 | });
140 |
141 | it('throws error when missing name', async () => {
142 | const response: ErrorResponse = {
143 | name: 'missing_required_field',
144 | message: 'Missing "name" field',
145 | };
146 |
147 | fetchMock.mockOnce(JSON.stringify(response), {
148 | status: 422,
149 | headers: {
150 | 'content-type': 'application/json',
151 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
152 | },
153 | });
154 |
155 | const payload: CreateDomainOptions = {
156 | name: '',
157 | };
158 |
159 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
160 |
161 | const result = resend.domains.create(payload);
162 |
163 | await expect(result).resolves.toMatchInlineSnapshot(`
164 | {
165 | "data": null,
166 | "error": {
167 | "message": "Missing "name" field",
168 | "name": "missing_required_field",
169 | },
170 | }
171 | `);
172 | });
173 |
174 | describe('with region', () => {
175 | it('creates a domain with region', async () => {
176 | const response: CreateDomainResponseSuccess = {
177 | id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
178 | name: 'resend.com',
179 | created_at: '2023-04-07T22:48:33.420498+00:00',
180 | status: 'not_started',
181 | records: [
182 | {
183 | record: 'SPF',
184 | name: 'bounces',
185 | type: 'MX',
186 | ttl: 'Auto',
187 | status: 'not_started',
188 | value: 'feedback-smtp.eu-west-1.com',
189 | priority: 10,
190 | },
191 | {
192 | record: 'SPF',
193 | name: 'bounces',
194 | value: '"v=spf1 include:com ~all"',
195 | type: 'TXT',
196 | ttl: 'Auto',
197 | status: 'not_started',
198 | },
199 | {
200 | record: 'DKIM',
201 | name: 'nu22pfdfqaxdybogtw3ebaokmalv5mxg._domainkey',
202 | value: 'nu22pfdfqaxdybogtw3ebaokmalv5mxg.dkim.com.',
203 | type: 'CNAME',
204 | status: 'not_started',
205 | ttl: 'Auto',
206 | },
207 | {
208 | record: 'DKIM',
209 | name: 'qklz5ozk742hhql3vmekdu3pr4f5ggsj._domainkey',
210 | value: 'qklz5ozk742hhql3vmekdu3pr4f5ggsj.dkim.com.',
211 | type: 'CNAME',
212 | status: 'not_started',
213 | ttl: 'Auto',
214 | },
215 | {
216 | record: 'DKIM',
217 | name: 'eeaemodxoao5hxwjvhywx4bo5mswjw6v._domainkey',
218 | value: 'eeaemodxoao5hxwjvhywx4bo5mswjw6v.dkim.com.',
219 | type: 'CNAME',
220 | status: 'not_started',
221 | ttl: 'Auto',
222 | },
223 | ],
224 | region: 'eu-west-1',
225 | };
226 | fetchMock.mockOnce(JSON.stringify(response));
227 | const payload: CreateDomainOptions = {
228 | name: 'resend.com',
229 | region: 'eu-west-1',
230 | };
231 |
232 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
233 | await expect(
234 | resend.domains.create(payload),
235 | ).resolves.toMatchInlineSnapshot(`
236 | {
237 | "data": {
238 | "created_at": "2023-04-07T22:48:33.420498+00:00",
239 | "id": "3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222",
240 | "name": "resend.com",
241 | "records": [
242 | {
243 | "name": "bounces",
244 | "priority": 10,
245 | "record": "SPF",
246 | "status": "not_started",
247 | "ttl": "Auto",
248 | "type": "MX",
249 | "value": "feedback-smtp.eu-west-1.com",
250 | },
251 | {
252 | "name": "bounces",
253 | "record": "SPF",
254 | "status": "not_started",
255 | "ttl": "Auto",
256 | "type": "TXT",
257 | "value": ""v=spf1 include:com ~all"",
258 | },
259 | {
260 | "name": "nu22pfdfqaxdybogtw3ebaokmalv5mxg._domainkey",
261 | "record": "DKIM",
262 | "status": "not_started",
263 | "ttl": "Auto",
264 | "type": "CNAME",
265 | "value": "nu22pfdfqaxdybogtw3ebaokmalv5mxg.dkim.com.",
266 | },
267 | {
268 | "name": "qklz5ozk742hhql3vmekdu3pr4f5ggsj._domainkey",
269 | "record": "DKIM",
270 | "status": "not_started",
271 | "ttl": "Auto",
272 | "type": "CNAME",
273 | "value": "qklz5ozk742hhql3vmekdu3pr4f5ggsj.dkim.com.",
274 | },
275 | {
276 | "name": "eeaemodxoao5hxwjvhywx4bo5mswjw6v._domainkey",
277 | "record": "DKIM",
278 | "status": "not_started",
279 | "ttl": "Auto",
280 | "type": "CNAME",
281 | "value": "eeaemodxoao5hxwjvhywx4bo5mswjw6v.dkim.com.",
282 | },
283 | ],
284 | "region": "eu-west-1",
285 | "status": "not_started",
286 | },
287 | "error": null,
288 | }
289 | `);
290 | });
291 |
292 | it('throws error with wrong region', async () => {
293 | const errorResponse: ErrorResponse = {
294 | name: 'invalid_region',
295 | message: 'Region must be "us-east-1" | "eu-west-1" | "sa-east-1"',
296 | };
297 |
298 | fetchMock.mockOnce(JSON.stringify(errorResponse), {
299 | status: 422,
300 | headers: {
301 | 'content-type': 'application/json',
302 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
303 | },
304 | });
305 |
306 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
307 |
308 | const result = resend.domains.create({
309 | name: 'resend.com',
310 | region: 'remote' as DomainRegion,
311 | });
312 |
313 | await expect(result).resolves.toMatchInlineSnapshot(`
314 | {
315 | "data": null,
316 | "error": {
317 | "message": "Region must be "us-east-1" | "eu-west-1" | "sa-east-1"",
318 | "name": "invalid_region",
319 | },
320 | }
321 | `);
322 | });
323 | });
324 |
325 | describe('with customReturnPath', () => {
326 | it('creates a domain with customReturnPath', async () => {
327 | const response: CreateDomainResponseSuccess = {
328 | id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222',
329 | name: 'resend.com',
330 | created_at: '2023-04-07T22:48:33.420498+00:00',
331 | status: 'not_started',
332 | records: [
333 | {
334 | record: 'SPF',
335 | name: 'custom',
336 | type: 'MX',
337 | ttl: 'Auto',
338 | status: 'not_started',
339 | value: 'feedback-smtp.us-east-1.com',
340 | priority: 10,
341 | },
342 | {
343 | record: 'SPF',
344 | name: 'custom',
345 | value: '"v=spf1 include:com ~all"',
346 | type: 'TXT',
347 | ttl: 'Auto',
348 | status: 'not_started',
349 | },
350 | {
351 | record: 'DKIM',
352 | name: 'resend._domainkey',
353 | value: 'nu22pfdfqaxdybogtw3ebaokmalv5mxg.dkim.com.',
354 | type: 'CNAME',
355 | status: 'not_started',
356 | ttl: 'Auto',
357 | },
358 | ],
359 | region: 'us-east-1',
360 | };
361 |
362 | fetchMock.mockOnce(JSON.stringify(response), {
363 | status: 200,
364 | headers: {
365 | 'content-type': 'application/json',
366 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
367 | },
368 | });
369 |
370 | const payload: CreateDomainOptions = {
371 | name: 'resend.com',
372 | customReturnPath: 'custom',
373 | };
374 |
375 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
376 | await expect(
377 | resend.domains.create(payload),
378 | ).resolves.toMatchInlineSnapshot(`
379 | {
380 | "data": {
381 | "created_at": "2023-04-07T22:48:33.420498+00:00",
382 | "id": "3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222",
383 | "name": "resend.com",
384 | "records": [
385 | {
386 | "name": "custom",
387 | "priority": 10,
388 | "record": "SPF",
389 | "status": "not_started",
390 | "ttl": "Auto",
391 | "type": "MX",
392 | "value": "feedback-smtp.us-east-1.com",
393 | },
394 | {
395 | "name": "custom",
396 | "record": "SPF",
397 | "status": "not_started",
398 | "ttl": "Auto",
399 | "type": "TXT",
400 | "value": ""v=spf1 include:com ~all"",
401 | },
402 | {
403 | "name": "resend._domainkey",
404 | "record": "DKIM",
405 | "status": "not_started",
406 | "ttl": "Auto",
407 | "type": "CNAME",
408 | "value": "nu22pfdfqaxdybogtw3ebaokmalv5mxg.dkim.com.",
409 | },
410 | ],
411 | "region": "us-east-1",
412 | "status": "not_started",
413 | },
414 | "error": null,
415 | }
416 | `);
417 | });
418 | });
419 | });
420 |
421 | describe('list', () => {
422 | it('lists domains', async () => {
423 | const response: ListDomainsResponseSuccess = {
424 | data: [
425 | {
426 | id: 'b6d24b8e-af0b-4c3c-be0c-359bbd97381e',
427 | name: 'resend.com',
428 | status: 'not_started',
429 | created_at: '2023-04-07T23:13:52.669661+00:00',
430 | region: 'eu-west-1',
431 | },
432 | {
433 | id: 'ac7503ac-e027-4aea-94b3-b0acd46f65f9',
434 | name: 'react.email',
435 | status: 'not_started',
436 | created_at: '2023-04-07T23:13:20.417116+00:00',
437 | region: 'us-east-1',
438 | },
439 | ],
440 | };
441 | fetchMock.mockOnce(JSON.stringify(response), {
442 | status: 200,
443 | headers: {
444 | 'content-type': 'application/json',
445 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
446 | },
447 | });
448 |
449 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
450 |
451 | await expect(resend.domains.list()).resolves.toMatchInlineSnapshot(`
452 | {
453 | "data": {
454 | "data": [
455 | {
456 | "created_at": "2023-04-07T23:13:52.669661+00:00",
457 | "id": "b6d24b8e-af0b-4c3c-be0c-359bbd97381e",
458 | "name": "resend.com",
459 | "region": "eu-west-1",
460 | "status": "not_started",
461 | },
462 | {
463 | "created_at": "2023-04-07T23:13:20.417116+00:00",
464 | "id": "ac7503ac-e027-4aea-94b3-b0acd46f65f9",
465 | "name": "react.email",
466 | "region": "us-east-1",
467 | "status": "not_started",
468 | },
469 | ],
470 | },
471 | "error": null,
472 | }
473 | `);
474 | });
475 | });
476 |
477 | describe('get', () => {
478 | describe('when domain not found', () => {
479 | it('returns error', async () => {
480 | const response: ErrorResponse = {
481 | name: 'not_found',
482 | message: 'Domain not found',
483 | };
484 |
485 | fetchMock.mockOnce(JSON.stringify(response), {
486 | status: 404,
487 | headers: {
488 | 'content-type': 'application/json',
489 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
490 | },
491 | });
492 |
493 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
494 |
495 | const result = resend.domains.get('1234');
496 |
497 | await expect(result).resolves.toMatchInlineSnapshot(`
498 | {
499 | "data": null,
500 | "error": {
501 | "message": "Domain not found",
502 | "name": "not_found",
503 | },
504 | }
505 | `);
506 | });
507 | });
508 |
509 | it('get domain', async () => {
510 | const response: GetDomainResponseSuccess = {
511 | object: 'domain',
512 | id: 'fd61172c-cafc-40f5-b049-b45947779a29',
513 | name: 'resend.com',
514 | status: 'not_started',
515 | created_at: '2023-06-21T06:10:36.144Z',
516 | region: 'us-east-1',
517 | records: [
518 | {
519 | record: 'SPF',
520 | name: 'bounces.resend.com',
521 | type: 'MX',
522 | ttl: 'Auto',
523 | status: 'not_started',
524 | value: 'feedback-smtp.us-east-1.amazonses.com',
525 | priority: 10,
526 | },
527 | {
528 | record: 'SPF',
529 | name: 'bounces.resend.com',
530 | value: '"v=spf1 include:amazonses.com ~all"',
531 | type: 'TXT',
532 | ttl: 'Auto',
533 | status: 'not_started',
534 | },
535 | {
536 | record: 'DKIM',
537 | name: 'resend._domainkey',
538 | value:
539 | 'p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZDhdsAKs5xdSj7h3v22wjx3WMWWADCHwxfef8U03JUbVM/sNSVuY5mbrdJKUoG6QBdfxsOGzhINmQnT89idjp5GdAUhx/KNpt8hcLXMID4nB0Gbcafn03/z5zEPxPfzVJqQd/UqOtZQcfxN9OrIhLiBsYTbcTBB7EvjCb3wEaBwIDAQAB',
540 | type: 'TXT',
541 | status: 'verified',
542 | ttl: 'Auto',
543 | },
544 | ],
545 | };
546 |
547 | fetchMock.mockOnce(JSON.stringify(response), {
548 | status: 200,
549 | headers: {
550 | 'content-type': 'application/json',
551 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
552 | },
553 | });
554 |
555 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
556 |
557 | await expect(resend.domains.get('1234')).resolves.toMatchInlineSnapshot(`
558 | {
559 | "data": {
560 | "created_at": "2023-06-21T06:10:36.144Z",
561 | "id": "fd61172c-cafc-40f5-b049-b45947779a29",
562 | "name": "resend.com",
563 | "object": "domain",
564 | "records": [
565 | {
566 | "name": "bounces.resend.com",
567 | "priority": 10,
568 | "record": "SPF",
569 | "status": "not_started",
570 | "ttl": "Auto",
571 | "type": "MX",
572 | "value": "feedback-smtp.us-east-1.amazonses.com",
573 | },
574 | {
575 | "name": "bounces.resend.com",
576 | "record": "SPF",
577 | "status": "not_started",
578 | "ttl": "Auto",
579 | "type": "TXT",
580 | "value": ""v=spf1 include:amazonses.com ~all"",
581 | },
582 | {
583 | "name": "resend._domainkey",
584 | "record": "DKIM",
585 | "status": "verified",
586 | "ttl": "Auto",
587 | "type": "TXT",
588 | "value": "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZDhdsAKs5xdSj7h3v22wjx3WMWWADCHwxfef8U03JUbVM/sNSVuY5mbrdJKUoG6QBdfxsOGzhINmQnT89idjp5GdAUhx/KNpt8hcLXMID4nB0Gbcafn03/z5zEPxPfzVJqQd/UqOtZQcfxN9OrIhLiBsYTbcTBB7EvjCb3wEaBwIDAQAB",
589 | },
590 | ],
591 | "region": "us-east-1",
592 | "status": "not_started",
593 | },
594 | "error": null,
595 | }
596 | `);
597 | });
598 | });
599 |
600 | describe('update', () => {
601 | it('update domain click tracking', async () => {
602 | const id = '5262504e-8ed7-4fac-bd16-0d4be94bc9f2';
603 | const response: UpdateDomainsResponseSuccess = {
604 | object: 'domain',
605 | id,
606 | };
607 |
608 | fetchMock.mockOnce(JSON.stringify(response), {
609 | status: 200,
610 | headers: {
611 | 'content-type': 'application/json',
612 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
613 | },
614 | });
615 |
616 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
617 |
618 | await expect(
619 | resend.domains.update({
620 | id,
621 | clickTracking: true,
622 | }),
623 | ).resolves.toMatchInlineSnapshot(`
624 | {
625 | "data": {
626 | "id": "5262504e-8ed7-4fac-bd16-0d4be94bc9f2",
627 | "object": "domain",
628 | },
629 | "error": null,
630 | }
631 | `);
632 | });
633 | });
634 |
635 | describe('verify', () => {
636 | it('verifies a domain', async () => {
637 | const id = '5262504e-8ed7-4fac-bd16-0d4be94bc9f2';
638 | const response: VerifyDomainsResponseSuccess = {
639 | object: 'domain',
640 | id,
641 | };
642 | fetchMock.mockOnce(JSON.stringify(response), {
643 | status: 200,
644 | headers: {
645 | 'content-type': 'application/json',
646 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
647 | },
648 | });
649 |
650 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
651 |
652 | await expect(resend.domains.verify(id)).resolves.toMatchInlineSnapshot(`
653 | {
654 | "data": {
655 | "id": "5262504e-8ed7-4fac-bd16-0d4be94bc9f2",
656 | "object": "domain",
657 | },
658 | "error": null,
659 | }
660 | `);
661 | });
662 | });
663 |
664 | describe('remove', () => {
665 | it('removes a domain', async () => {
666 | const id = '5262504e-8ed7-4fac-bd16-0d4be94bc9f2';
667 | const response: RemoveDomainsResponseSuccess = {
668 | object: 'domain',
669 | id,
670 | deleted: true,
671 | };
672 | fetchMock.mockOnce(JSON.stringify(response), {
673 | status: 200,
674 | headers: {
675 | 'content-type': 'application/json',
676 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
677 | },
678 | });
679 |
680 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
681 |
682 | await expect(resend.domains.remove(id)).resolves.toMatchInlineSnapshot(`
683 | {
684 | "data": {
685 | "deleted": true,
686 | "id": "5262504e-8ed7-4fac-bd16-0d4be94bc9f2",
687 | "object": "domain",
688 | },
689 | "error": null,
690 | }
691 | `);
692 | });
693 | });
694 | });
695 |
--------------------------------------------------------------------------------
/src/domains/domains.ts:
--------------------------------------------------------------------------------
1 | import { parseDomainToApiOptions } from '../common/utils/parse-domain-to-api-options';
2 | import type { Resend } from '../resend';
3 | import type {
4 | CreateDomainOptions,
5 | CreateDomainRequestOptions,
6 | CreateDomainResponse,
7 | CreateDomainResponseSuccess,
8 | } from './interfaces/create-domain-options.interface';
9 | import type {
10 | GetDomainResponse,
11 | GetDomainResponseSuccess,
12 | } from './interfaces/get-domain.interface';
13 | import type {
14 | ListDomainsResponse,
15 | ListDomainsResponseSuccess,
16 | } from './interfaces/list-domains.interface';
17 | import type {
18 | RemoveDomainsResponse,
19 | RemoveDomainsResponseSuccess,
20 | } from './interfaces/remove-domain.interface';
21 | import type {
22 | UpdateDomainsOptions,
23 | UpdateDomainsResponse,
24 | UpdateDomainsResponseSuccess,
25 | } from './interfaces/update-domain.interface';
26 | import type {
27 | VerifyDomainsResponse,
28 | VerifyDomainsResponseSuccess,
29 | } from './interfaces/verify-domain.interface';
30 |
31 | export class Domains {
32 | constructor(private readonly resend: Resend) {}
33 |
34 | async create(
35 | payload: CreateDomainOptions,
36 | options: CreateDomainRequestOptions = {},
37 | ): Promise {
38 | const data = await this.resend.post(
39 | '/domains',
40 | parseDomainToApiOptions(payload),
41 | options,
42 | );
43 | return data;
44 | }
45 |
46 | async list(): Promise {
47 | const data = await this.resend.get('/domains');
48 | return data;
49 | }
50 |
51 | async get(id: string): Promise {
52 | const data = await this.resend.get(
53 | `/domains/${id}`,
54 | );
55 |
56 | return data;
57 | }
58 |
59 | async update(payload: UpdateDomainsOptions): Promise {
60 | const data = await this.resend.patch(
61 | `/domains/${payload.id}`,
62 | {
63 | click_tracking: payload.clickTracking,
64 | open_tracking: payload.openTracking,
65 | tls: payload.tls,
66 | },
67 | );
68 | return data;
69 | }
70 |
71 | async remove(id: string): Promise {
72 | const data = await this.resend.delete(
73 | `/domains/${id}`,
74 | );
75 | return data;
76 | }
77 |
78 | async verify(id: string): Promise {
79 | const data = await this.resend.post(
80 | `/domains/${id}/verify`,
81 | );
82 | return data;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/domains/interfaces/create-domain-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { PostOptions } from '../../common/interfaces';
2 | import type { ErrorResponse } from '../../interfaces';
3 | import type { Domain, DomainRecords, DomainRegion } from './domain';
4 |
5 | export interface CreateDomainOptions {
6 | name: string;
7 | region?: DomainRegion;
8 | customReturnPath?: string;
9 | }
10 |
11 | export interface CreateDomainRequestOptions extends PostOptions {}
12 |
13 | export interface CreateDomainResponseSuccess
14 | extends Pick {
15 | records: DomainRecords[];
16 | }
17 |
18 | export interface CreateDomainResponse {
19 | data: CreateDomainResponseSuccess | null;
20 | error: ErrorResponse | null;
21 | }
22 |
--------------------------------------------------------------------------------
/src/domains/interfaces/domain.ts:
--------------------------------------------------------------------------------
1 | export type DomainRegion =
2 | | 'us-east-1'
3 | | 'eu-west-1'
4 | | 'sa-east-1'
5 | | 'ap-northeast-1';
6 |
7 | export type DomainNameservers =
8 | | 'Amazon Route 53'
9 | | 'Cloudflare'
10 | | 'Digital Ocean'
11 | | 'GoDaddy'
12 | | 'Google Domains'
13 | | 'Namecheap'
14 | | 'Unidentified'
15 | | 'Vercel';
16 |
17 | export type DomainStatus =
18 | | 'pending'
19 | | 'verified'
20 | | 'failed'
21 | | 'temporary_failure'
22 | | 'not_started';
23 |
24 | export type DomainRecords = DomainSpfRecord | DomainDkimRecord;
25 |
26 | export interface DomainSpfRecord {
27 | record: 'SPF';
28 | name: string;
29 | value: string;
30 | type: 'MX' | 'TXT';
31 | ttl: string;
32 | status: DomainStatus;
33 | routing_policy?: string;
34 | priority?: number;
35 | proxy_status?: 'enable' | 'disable';
36 | }
37 |
38 | export interface DomainDkimRecord {
39 | record: 'DKIM';
40 | name: string;
41 | value: string;
42 | type: 'CNAME' | 'TXT';
43 | ttl: string;
44 | status: DomainStatus;
45 | routing_policy?: string;
46 | priority?: number;
47 | proxy_status?: 'enable' | 'disable';
48 | }
49 |
50 | export interface Domain {
51 | id: string;
52 | name: string;
53 | status: DomainStatus;
54 | created_at: string;
55 | region: DomainRegion;
56 | }
57 |
--------------------------------------------------------------------------------
/src/domains/interfaces/get-domain.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Domain, DomainRecords } from './domain';
3 |
4 | export interface GetDomainResponseSuccess
5 | extends Pick {
6 | object: 'domain';
7 | records: DomainRecords[];
8 | }
9 |
10 | export interface GetDomainResponse {
11 | data: GetDomainResponseSuccess | null;
12 | error: ErrorResponse | null;
13 | }
14 |
--------------------------------------------------------------------------------
/src/domains/interfaces/list-domains.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Domain } from './domain';
3 |
4 | export type ListDomainsResponseSuccess = { data: Domain[] };
5 |
6 | export interface ListDomainsResponse {
7 | data: ListDomainsResponseSuccess | null;
8 | error: ErrorResponse | null;
9 | }
10 |
--------------------------------------------------------------------------------
/src/domains/interfaces/remove-domain.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Domain } from './domain';
3 |
4 | export type RemoveDomainsResponseSuccess = Pick & {
5 | object: 'domain';
6 | deleted: boolean;
7 | };
8 |
9 | export interface RemoveDomainsResponse {
10 | data: RemoveDomainsResponseSuccess | null;
11 | error: ErrorResponse | null;
12 | }
13 |
--------------------------------------------------------------------------------
/src/domains/interfaces/update-domain.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Domain } from './domain';
3 |
4 | export interface UpdateDomainsOptions {
5 | id: string;
6 | clickTracking?: boolean;
7 | openTracking?: boolean;
8 | tls?: 'enforced' | 'opportunistic';
9 | }
10 |
11 | export type UpdateDomainsResponseSuccess = Pick & {
12 | object: 'domain';
13 | };
14 |
15 | export interface UpdateDomainsResponse {
16 | data: UpdateDomainsResponseSuccess | null;
17 | error: ErrorResponse | null;
18 | }
19 |
--------------------------------------------------------------------------------
/src/domains/interfaces/verify-domain.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 | import type { Domain } from './domain';
3 |
4 | export type VerifyDomainsResponseSuccess = Pick & {
5 | object: 'domain';
6 | };
7 |
8 | export interface VerifyDomainsResponse {
9 | data: VerifyDomainsResponseSuccess | null;
10 | error: ErrorResponse | null;
11 | }
12 |
--------------------------------------------------------------------------------
/src/emails/emails.spec.ts:
--------------------------------------------------------------------------------
1 | import { enableFetchMocks } from 'jest-fetch-mock';
2 | import type { ErrorResponse } from '../interfaces';
3 | import { Resend } from '../resend';
4 | import type {
5 | CreateEmailOptions,
6 | CreateEmailResponseSuccess,
7 | } from './interfaces/create-email-options.interface';
8 | import type { GetEmailResponseSuccess } from './interfaces/get-email-options.interface';
9 |
10 | enableFetchMocks();
11 |
12 | const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop');
13 |
14 | describe('Emails', () => {
15 | afterEach(() => fetchMock.resetMocks());
16 |
17 | describe('create', () => {
18 | it('sends email', async () => {
19 | const response: ErrorResponse = {
20 | name: 'missing_required_field',
21 | message: 'Missing `from` field.',
22 | };
23 |
24 | fetchMock.mockOnce(JSON.stringify(response), {
25 | status: 422,
26 | headers: {
27 | 'content-type': 'application/json',
28 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
29 | },
30 | });
31 |
32 | const data = await resend.emails.create({} as CreateEmailOptions);
33 |
34 | expect(data).toMatchInlineSnapshot(`
35 | {
36 | "data": null,
37 | "error": {
38 | "message": "Missing \`from\` field.",
39 | "name": "missing_required_field",
40 | },
41 | }
42 | `);
43 | });
44 |
45 | it('does not send the Idempotency-Key header when idempotencyKey is not provided', async () => {
46 | const response: CreateEmailResponseSuccess = {
47 | id: 'not-idempotent-123',
48 | };
49 |
50 | fetchMock.mockOnce(JSON.stringify(response), {
51 | status: 200,
52 | headers: {
53 | 'content-type': 'application/json',
54 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
55 | },
56 | });
57 |
58 | const payload: CreateEmailOptions = {
59 | from: 'admin@resend.com',
60 | to: 'user@resend.com',
61 | subject: 'Not Idempotent Test',
62 | html: 'Test
',
63 | };
64 |
65 | await resend.emails.create(payload);
66 |
67 | // Inspect the last fetch call and body
68 | const lastCall = fetchMock.mock.calls[0];
69 | expect(lastCall).toBeDefined();
70 |
71 | console.log('debug:', lastCall[1]?.headers);
72 | //@ts-ignore
73 | const hasIdempotencyKey = lastCall[1]?.headers.has('Idempotency-Key');
74 | expect(hasIdempotencyKey).toBeFalsy();
75 |
76 | //@ts-ignore
77 | const usedIdempotencyKey = lastCall[1]?.headers.get('Idempotency-Key');
78 | expect(usedIdempotencyKey).toBeNull();
79 | });
80 |
81 | it('sends the Idempotency-Key header when idempotencyKey is provided', async () => {
82 | const response: CreateEmailResponseSuccess = {
83 | id: 'idempotent-123',
84 | };
85 |
86 | fetchMock.mockOnce(JSON.stringify(response), {
87 | status: 200,
88 | headers: {
89 | 'content-type': 'application/json',
90 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
91 | },
92 | });
93 |
94 | const payload: CreateEmailOptions = {
95 | from: 'admin@resend.com',
96 | to: 'user@resend.com',
97 | subject: 'Idempotency Test',
98 | html: 'Test
',
99 | };
100 | const idempotencyKey = 'unique-key-123';
101 |
102 | await resend.emails.create(payload, { idempotencyKey });
103 |
104 | // Inspect the last fetch call and body
105 | const lastCall = fetchMock.mock.calls[0];
106 | expect(lastCall).toBeDefined();
107 |
108 | // Check if headers contains Idempotency-Key
109 | // In the mock, headers is an object with key-value pairs
110 | expect(fetchMock.mock.calls[0][1]?.headers).toBeDefined();
111 |
112 | //@ts-ignore
113 | const hasIdempotencyKey = lastCall[1]?.headers.has('Idempotency-Key');
114 | expect(hasIdempotencyKey).toBeTruthy();
115 |
116 | //@ts-ignore
117 | const usedIdempotencyKey = lastCall[1]?.headers.get('Idempotency-Key');
118 | expect(usedIdempotencyKey).toBe(idempotencyKey);
119 | });
120 | });
121 |
122 | describe('send', () => {
123 | it('sends email', async () => {
124 | const response: CreateEmailResponseSuccess = {
125 | id: '71cdfe68-cf79-473a-a9d7-21f91db6a526',
126 | };
127 | fetchMock.mockOnce(JSON.stringify(response), {
128 | status: 200,
129 | headers: {
130 | 'content-type': 'application/json',
131 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
132 | },
133 | });
134 |
135 | const payload: CreateEmailOptions = {
136 | from: 'bu@resend.com',
137 | to: 'zeno@resend.com',
138 | subject: 'Hello World',
139 | html: 'Hello world
',
140 | };
141 |
142 | const data = await resend.emails.send(payload);
143 | expect(data).toMatchInlineSnapshot(`
144 | {
145 | "data": {
146 | "id": "71cdfe68-cf79-473a-a9d7-21f91db6a526",
147 | },
148 | "error": null,
149 | }
150 | `);
151 | });
152 |
153 | it('sends email with multiple recipients', async () => {
154 | const response: CreateEmailResponseSuccess = {
155 | id: '124dc0f1-e36c-417c-a65c-e33773abc768',
156 | };
157 |
158 | fetchMock.mockOnce(JSON.stringify(response), {
159 | status: 200,
160 | headers: {
161 | 'content-type': 'application/json',
162 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
163 | },
164 | });
165 |
166 | const payload: CreateEmailOptions = {
167 | from: 'admin@resend.com',
168 | to: ['bu@resend.com', 'zeno@resend.com'],
169 | subject: 'Hello World',
170 | text: 'Hello world',
171 | };
172 | const data = await resend.emails.send(payload);
173 | expect(data).toMatchInlineSnapshot(`
174 | {
175 | "data": {
176 | "id": "124dc0f1-e36c-417c-a65c-e33773abc768",
177 | },
178 | "error": null,
179 | }
180 | `);
181 | });
182 |
183 | it('sends email with multiple bcc recipients', async () => {
184 | const response: CreateEmailResponseSuccess = {
185 | id: '124dc0f1-e36c-417c-a65c-e33773abc768',
186 | };
187 |
188 | fetchMock.mockOnce(JSON.stringify(response), {
189 | status: 200,
190 | headers: {
191 | 'content-type': 'application/json',
192 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
193 | },
194 | });
195 |
196 | const payload: CreateEmailOptions = {
197 | from: 'admin@resend.com',
198 | to: 'bu@resend.com',
199 | bcc: ['foo@resend.com', 'bar@resend.com'],
200 | subject: 'Hello World',
201 | text: 'Hello world',
202 | };
203 |
204 | const data = await resend.emails.send(payload);
205 | expect(data).toMatchInlineSnapshot(`
206 | {
207 | "data": {
208 | "id": "124dc0f1-e36c-417c-a65c-e33773abc768",
209 | },
210 | "error": null,
211 | }
212 | `);
213 | });
214 |
215 | it('sends email with multiple cc recipients', async () => {
216 | const response: CreateEmailResponseSuccess = {
217 | id: '124dc0f1-e36c-417c-a65c-e33773abc768',
218 | };
219 |
220 | fetchMock.mockOnce(JSON.stringify(response), {
221 | status: 200,
222 | headers: {
223 | 'content-type': 'application/json',
224 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
225 | },
226 | });
227 |
228 | const payload: CreateEmailOptions = {
229 | from: 'admin@resend.com',
230 | to: 'bu@resend.com',
231 | cc: ['foo@resend.com', 'bar@resend.com'],
232 | subject: 'Hello World',
233 | text: 'Hello world',
234 | };
235 |
236 | const data = await resend.emails.send(payload);
237 | expect(data).toMatchInlineSnapshot(`
238 | {
239 | "data": {
240 | "id": "124dc0f1-e36c-417c-a65c-e33773abc768",
241 | },
242 | "error": null,
243 | }
244 | `);
245 | });
246 |
247 | it('sends email with multiple replyTo emails', async () => {
248 | const response: CreateEmailResponseSuccess = {
249 | id: '124dc0f1-e36c-417c-a65c-e33773abc768',
250 | };
251 |
252 | fetchMock.mockOnce(JSON.stringify(response), {
253 | status: 200,
254 | headers: {
255 | 'content-type': 'application/json',
256 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
257 | },
258 | });
259 |
260 | const payload: CreateEmailOptions = {
261 | from: 'admin@resend.com',
262 | to: 'bu@resend.com',
263 | replyTo: ['foo@resend.com', 'bar@resend.com'],
264 | subject: 'Hello World',
265 | text: 'Hello world',
266 | };
267 |
268 | const data = await resend.emails.send(payload);
269 | expect(data).toMatchInlineSnapshot(`
270 | {
271 | "data": {
272 | "id": "124dc0f1-e36c-417c-a65c-e33773abc768",
273 | },
274 | "error": null,
275 | }
276 | `);
277 | });
278 |
279 | it('can send an email with headers', async () => {
280 | const response: CreateEmailResponseSuccess = {
281 | id: '124dc0f1-e36c-417c-a65c-e33773abc768',
282 | };
283 |
284 | fetchMock.mockOnce(JSON.stringify(response), {
285 | status: 200,
286 | headers: {
287 | 'content-type': 'application/json',
288 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
289 | },
290 | });
291 |
292 | const payload: CreateEmailOptions = {
293 | from: 'admin@resend.com',
294 | headers: {
295 | 'X-Entity-Ref-ID': '123',
296 | },
297 | subject: 'Hello World',
298 | text: 'Hello world',
299 | to: 'bu@resend.com',
300 | };
301 |
302 | const data = await resend.emails.send(payload);
303 | expect(data).toMatchInlineSnapshot(`
304 | {
305 | "data": {
306 | "id": "124dc0f1-e36c-417c-a65c-e33773abc768",
307 | },
308 | "error": null,
309 | }
310 | `);
311 | });
312 |
313 | it('throws an error when an ErrorResponse is returned', async () => {
314 | const response: ErrorResponse = {
315 | name: 'invalid_parameter',
316 | message:
317 | 'Invalid `from` field. The email address needs to follow the `email@example.com` or `Name ` format',
318 | };
319 |
320 | fetchMock.mockOnce(JSON.stringify(response), {
321 | status: 422,
322 | headers: {
323 | 'content-type': 'application/json',
324 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
325 | },
326 | });
327 |
328 | const payload: CreateEmailOptions = {
329 | from: 'resend.com', // Invalid from address
330 | to: 'bu@resend.com',
331 | replyTo: ['foo@resend.com', 'bar@resend.com'],
332 | subject: 'Hello World',
333 | text: 'Hello world',
334 | };
335 |
336 | const result = resend.emails.send(payload);
337 |
338 | await expect(result).resolves.toMatchInlineSnapshot(`
339 | {
340 | "data": null,
341 | "error": {
342 | "message": "Invalid \`from\` field. The email address needs to follow the \`email@example.com\` or \`Name \` format",
343 | "name": "invalid_parameter",
344 | },
345 | }
346 | `);
347 | });
348 |
349 | it('returns an error when fetch fails', async () => {
350 | const originalEnv = process.env;
351 | process.env = {
352 | ...originalEnv,
353 | RESEND_BASE_URL: 'http://invalidurl.noturl',
354 | };
355 |
356 | const result = await resend.emails.send({
357 | from: 'example@resend.com',
358 | to: 'bu@resend.com',
359 | subject: 'Hello World',
360 | text: 'Hello world',
361 | });
362 |
363 | expect(result).toEqual(
364 | expect.objectContaining({
365 | data: null,
366 | error: {
367 | message: 'Unable to fetch data. The request could not be resolved.',
368 | name: 'application_error',
369 | },
370 | }),
371 | );
372 | process.env = originalEnv;
373 | });
374 |
375 | it('returns an error when api responds with text payload', async () => {
376 | fetchMock.mockOnce('local_rate_limited', {
377 | status: 422,
378 | headers: {
379 | Authorization: 'Bearer re_924b3rjh2387fbewf823',
380 | },
381 | });
382 |
383 | const result = await resend.emails.send({
384 | from: 'example@resend.com',
385 | to: 'bu@resend.com',
386 | subject: 'Hello World',
387 | text: 'Hello world',
388 | });
389 |
390 | expect(result).toEqual(
391 | expect.objectContaining({
392 | data: null,
393 | error: {
394 | message:
395 | 'Internal server error. We are unable to process your request right now, please try again later.',
396 | name: 'application_error',
397 | },
398 | }),
399 | );
400 | });
401 | });
402 |
403 | describe('get', () => {
404 | describe('when email not found', () => {
405 | it('returns error', async () => {
406 | const response: ErrorResponse = {
407 | name: 'not_found',
408 | message: 'Email not found',
409 | };
410 |
411 | fetchMock.mockOnce(JSON.stringify(response), {
412 | status: 404,
413 | headers: {
414 | 'content-type': 'application/json',
415 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
416 | },
417 | });
418 |
419 | const result = resend.emails.get(
420 | '61cda979-919d-4b9d-9638-c148b93ff410',
421 | );
422 |
423 | await expect(result).resolves.toMatchInlineSnapshot(`
424 | {
425 | "data": null,
426 | "error": {
427 | "message": "Email not found",
428 | "name": "not_found",
429 | },
430 | }
431 | `);
432 | });
433 | });
434 |
435 | describe('when email found', () => {
436 | it('returns emails with only to', async () => {
437 | const response: GetEmailResponseSuccess = {
438 | object: 'email',
439 | id: '67d9bcdb-5a02-42d7-8da9-0d6feea18cff',
440 | to: ['zeno@resend.com'],
441 | from: 'bu@resend.com',
442 | created_at: '2023-04-07T23:13:52.669661+00:00',
443 | subject: 'Test email',
444 | html: 'hello hello
',
445 | text: null,
446 | bcc: null,
447 | cc: null,
448 | reply_to: null,
449 | last_event: 'delivered',
450 | scheduled_at: null,
451 | };
452 |
453 | fetchMock.mockOnce(JSON.stringify(response), {
454 | status: 200,
455 | headers: {
456 | 'content-type': 'application/json',
457 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
458 | },
459 | });
460 |
461 | await expect(
462 | resend.emails.get('67d9bcdb-5a02-42d7-8da9-0d6feea18cff'),
463 | ).resolves.toMatchInlineSnapshot(`
464 | {
465 | "data": {
466 | "bcc": null,
467 | "cc": null,
468 | "created_at": "2023-04-07T23:13:52.669661+00:00",
469 | "from": "bu@resend.com",
470 | "html": "hello hello
",
471 | "id": "67d9bcdb-5a02-42d7-8da9-0d6feea18cff",
472 | "last_event": "delivered",
473 | "object": "email",
474 | "reply_to": null,
475 | "scheduled_at": null,
476 | "subject": "Test email",
477 | "text": null,
478 | "to": [
479 | "zeno@resend.com",
480 | ],
481 | },
482 | "error": null,
483 | }
484 | `);
485 | });
486 |
487 | it('returns emails with to and multiple cc', async () => {
488 | const response: GetEmailResponseSuccess = {
489 | object: 'email',
490 | id: '67d9bcdb-5a02-42d7-8da9-0d6feea18cff',
491 | to: ['zeno@resend.com'],
492 | from: 'bu@resend.com',
493 | created_at: '2023-04-07T23:13:52.669661+00:00',
494 | subject: 'Test email',
495 | html: 'hello hello
',
496 | text: null,
497 | bcc: null,
498 | cc: ['zeno@resend.com', 'bu@resend.com'],
499 | reply_to: null,
500 | last_event: 'delivered',
501 | scheduled_at: null,
502 | };
503 |
504 | fetchMock.mockOnce(JSON.stringify(response), {
505 | status: 200,
506 | headers: {
507 | 'content-type': 'application/json',
508 | Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop',
509 | },
510 | });
511 |
512 | await expect(
513 | resend.emails.get('67d9bcdb-5a02-42d7-8da9-0d6feea18cff'),
514 | ).resolves.toMatchInlineSnapshot(`
515 | {
516 | "data": {
517 | "bcc": null,
518 | "cc": [
519 | "zeno@resend.com",
520 | "bu@resend.com",
521 | ],
522 | "created_at": "2023-04-07T23:13:52.669661+00:00",
523 | "from": "bu@resend.com",
524 | "html": "hello hello
",
525 | "id": "67d9bcdb-5a02-42d7-8da9-0d6feea18cff",
526 | "last_event": "delivered",
527 | "object": "email",
528 | "reply_to": null,
529 | "scheduled_at": null,
530 | "subject": "Test email",
531 | "text": null,
532 | "to": [
533 | "zeno@resend.com",
534 | ],
535 | },
536 | "error": null,
537 | }
538 | `);
539 | });
540 | });
541 | });
542 | });
543 |
--------------------------------------------------------------------------------
/src/emails/emails.ts:
--------------------------------------------------------------------------------
1 | import type * as React from 'react';
2 | import { parseEmailToApiOptions } from '../common/utils/parse-email-to-api-options';
3 | import type { Resend } from '../resend';
4 | import type {
5 | CancelEmailResponse,
6 | CancelEmailResponseSuccess,
7 | } from './interfaces/cancel-email-options.interface';
8 | import type {
9 | CreateEmailOptions,
10 | CreateEmailRequestOptions,
11 | CreateEmailResponse,
12 | CreateEmailResponseSuccess,
13 | } from './interfaces/create-email-options.interface';
14 | import type {
15 | GetEmailResponse,
16 | GetEmailResponseSuccess,
17 | } from './interfaces/get-email-options.interface';
18 | import type {
19 | UpdateEmailOptions,
20 | UpdateEmailResponse,
21 | UpdateEmailResponseSuccess,
22 | } from './interfaces/update-email-options.interface';
23 |
24 | export class Emails {
25 | private renderAsync?: (component: React.ReactElement) => Promise;
26 | constructor(private readonly resend: Resend) {}
27 |
28 | async send(
29 | payload: CreateEmailOptions,
30 | options: CreateEmailRequestOptions = {},
31 | ) {
32 | return this.create(payload, options);
33 | }
34 |
35 | async create(
36 | payload: CreateEmailOptions,
37 | options: CreateEmailRequestOptions = {},
38 | ): Promise {
39 | if (payload.react) {
40 | if (!this.renderAsync) {
41 | try {
42 | const { renderAsync } = await import('@react-email/render');
43 | this.renderAsync = renderAsync;
44 | } catch (error) {
45 | throw new Error(
46 | 'Failed to render React component. Make sure to install `@react-email/render`',
47 | );
48 | }
49 | }
50 |
51 | payload.html = await this.renderAsync(
52 | payload.react as React.ReactElement,
53 | );
54 | }
55 |
56 | const data = await this.resend.post(
57 | '/emails',
58 | parseEmailToApiOptions(payload),
59 | options,
60 | );
61 |
62 | return data;
63 | }
64 |
65 | async get(id: string): Promise {
66 | const data = await this.resend.get(
67 | `/emails/${id}`,
68 | );
69 |
70 | return data;
71 | }
72 |
73 | async update(payload: UpdateEmailOptions): Promise {
74 | const data = await this.resend.patch(
75 | `/emails/${payload.id}`,
76 | {
77 | scheduled_at: payload.scheduledAt,
78 | },
79 | );
80 | return data;
81 | }
82 |
83 | async cancel(id: string): Promise {
84 | const data = await this.resend.post(
85 | `/emails/${id}/cancel`,
86 | );
87 | return data;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/emails/interfaces/cancel-email-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 |
3 | export interface CancelEmailResponse {
4 | data: CancelEmailResponseSuccess | null;
5 | error: ErrorResponse | null;
6 | }
7 |
8 | export interface CancelEmailResponseSuccess {
9 | object: 'email';
10 | id: string;
11 | }
12 |
--------------------------------------------------------------------------------
/src/emails/interfaces/create-email-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type * as React from 'react';
2 | import type { PostOptions } from '../../common/interfaces';
3 | import type { IdempotentRequest } from '../../common/interfaces/idempotent-request.interface';
4 | import type { RequireAtLeastOne } from '../../common/interfaces/require-at-least-one';
5 | import type { ErrorResponse } from '../../interfaces';
6 |
7 | interface EmailRenderOptions {
8 | /**
9 | * The React component used to write the message.
10 | *
11 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
12 | */
13 | react: React.ReactNode;
14 | /**
15 | * The HTML version of the message.
16 | *
17 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
18 | */
19 | html: string;
20 | /**
21 | * The plain text version of the message.
22 | *
23 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
24 | */
25 | text: string;
26 | }
27 |
28 | interface CreateEmailBaseOptions {
29 | /**
30 | * Filename and content of attachments (max 40mb per email)
31 | *
32 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
33 | */
34 | attachments?: Attachment[];
35 | /**
36 | * Blind carbon copy recipient email address. For multiple addresses, send as an array of strings.
37 | *
38 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
39 | */
40 | bcc?: string | string[];
41 | /**
42 | * Carbon copy recipient email address. For multiple addresses, send as an array of strings.
43 | *
44 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
45 | */
46 | cc?: string | string[];
47 | /**
48 | * Sender email address. To include a friendly name, use the format `"Your Name "`
49 | *
50 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
51 | */
52 | from: string;
53 | /**
54 | * Custom headers to add to the email.
55 | *
56 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
57 | */
58 | headers?: Record;
59 | /**
60 | * Reply-to email address. For multiple addresses, send as an array of strings.
61 | *
62 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
63 | */
64 | replyTo?: string | string[];
65 | /**
66 | * Email subject.
67 | *
68 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
69 | */
70 | subject: string;
71 | /**
72 | * Email tags
73 | *
74 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
75 | */
76 | tags?: Tag[];
77 | /**
78 | * Recipient email address. For multiple addresses, send as an array of strings. Max 50.
79 | *
80 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
81 | */
82 | to: string | string[];
83 | /**
84 | * Schedule email to be sent later.
85 | * The date should be in ISO 8601 format (e.g: 2024-08-05T11:52:01.858Z).
86 | *
87 | * @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
88 | */
89 | scheduledAt?: string;
90 | }
91 |
92 | export type CreateEmailOptions = RequireAtLeastOne &
93 | CreateEmailBaseOptions;
94 |
95 | export interface CreateEmailRequestOptions
96 | extends PostOptions,
97 | IdempotentRequest {}
98 |
99 | export interface CreateEmailResponseSuccess {
100 | /** The ID of the newly created email. */
101 | id: string;
102 | }
103 |
104 | export interface CreateEmailResponse {
105 | data: CreateEmailResponseSuccess | null;
106 | error: ErrorResponse | null;
107 | }
108 |
109 | export interface Attachment {
110 | /** Content of an attached file. */
111 | content?: string | Buffer;
112 | /** Name of attached file. */
113 | filename?: string | false | undefined;
114 | /** Path where the attachment file is hosted */
115 | path?: string;
116 | /** Optional content type for the attachment, if not set will be derived from the filename property */
117 | contentType?: string;
118 | }
119 |
120 | export type Tag = {
121 | /**
122 | * The name of the email tag. It can only contain ASCII letters (a–z, A–Z), numbers (0–9), underscores (_), or dashes (-). It can contain no more than 256 characters.
123 | */
124 | name: string;
125 | /**
126 | * The value of the email tag. It can only contain ASCII letters (a–z, A–Z), numbers (0–9), underscores (_), or dashes (-). It can contain no more than 256 characters.
127 | */
128 | value: string;
129 | };
130 |
--------------------------------------------------------------------------------
/src/emails/interfaces/get-email-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 |
3 | export interface GetEmailResponseSuccess {
4 | bcc: string[] | null;
5 | cc: string[] | null;
6 | created_at: string;
7 | from: string;
8 | html: string | null;
9 | id: string;
10 | last_event:
11 | | 'bounced'
12 | | 'canceled'
13 | | 'clicked'
14 | | 'complained'
15 | | 'delivered'
16 | | 'delivery_delayed'
17 | | 'opened'
18 | | 'queued'
19 | | 'scheduled';
20 | reply_to: string[] | null;
21 | subject: string;
22 | text: string | null;
23 | to: string[];
24 | scheduled_at: string | null;
25 | object: 'email';
26 | }
27 |
28 | export interface GetEmailResponse {
29 | data: GetEmailResponseSuccess | null;
30 | error: ErrorResponse | null;
31 | }
32 |
--------------------------------------------------------------------------------
/src/emails/interfaces/update-email-options.interface.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse } from '../../interfaces';
2 |
3 | export interface UpdateEmailOptions {
4 | id: string;
5 | scheduledAt: string;
6 | }
7 |
8 | export interface UpdateEmailResponseSuccess {
9 | id: string;
10 | object: 'email';
11 | }
12 |
13 | export interface UpdateEmailResponse {
14 | data: UpdateEmailResponseSuccess | null;
15 | error: ErrorResponse | null;
16 | }
17 |
--------------------------------------------------------------------------------
/src/error.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorResponse, RESEND_ERROR_CODE_KEY } from './interfaces';
2 |
3 | export class ResendError extends Error {
4 | public readonly name: RESEND_ERROR_CODE_KEY;
5 |
6 | public constructor(message: string, name: RESEND_ERROR_CODE_KEY) {
7 | super();
8 | this.message = message;
9 | this.name = name;
10 | }
11 |
12 | public static fromResponse(response: ErrorResponse) {
13 | const error = response;
14 |
15 | return new ResendError(error.message, error.name);
16 | }
17 |
18 | public override toString() {
19 | return JSON.stringify(
20 | {
21 | message: this.message,
22 | name: this.name,
23 | },
24 | null,
25 | 2,
26 | );
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/guards.ts:
--------------------------------------------------------------------------------
1 | import { type ErrorResponse, RESEND_ERROR_CODES_BY_KEY } from './interfaces';
2 |
3 | export const isResendErrorResponse = (
4 | response: unknown,
5 | ): response is ErrorResponse => {
6 | if (typeof response !== 'object' || response === null) {
7 | return false;
8 | }
9 |
10 | const error = response as ErrorResponse;
11 |
12 | if (typeof error !== 'object' || error === null) {
13 | return false;
14 | }
15 |
16 | const { message, name } = error;
17 |
18 | return typeof message === 'string' && typeof name === 'string';
19 | };
20 |
21 | /**
22 | * Consider whether to use this stricter version of the type guard.
23 | *
24 | * Right now, it's not used as there is a risk that an API error will not be
25 | * caught due to the API being ahead of the current types.
26 | */
27 | export const isResendErrorResponseStrict = (
28 | response: unknown,
29 | ): response is ErrorResponse => {
30 | if (typeof response !== 'object' || response === null) {
31 | return false;
32 | }
33 |
34 | const error = response as ErrorResponse;
35 |
36 | if (typeof error !== 'object' || error === null) {
37 | return false;
38 | }
39 |
40 | const { message, name } = error;
41 |
42 | return (
43 | typeof message === 'string' &&
44 | typeof name === 'string' &&
45 | name in RESEND_ERROR_CODES_BY_KEY
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { Resend } from './resend';
2 | export { ErrorResponse } from './interfaces';
3 | export * from './api-keys/interfaces/create-api-key-options.interface';
4 | export * from './api-keys/interfaces/list-api-keys.interface';
5 | export * from './api-keys/interfaces/remove-api-keys.interface';
6 | export * from './audiences/interfaces/create-audience-options.interface';
7 | export * from './audiences/interfaces/get-audience.interface';
8 | export * from './audiences/interfaces/list-audiences.interface';
9 | export * from './audiences/interfaces/remove-audience.interface';
10 | export * from './broadcasts/interfaces/create-broadcast-options.interface';
11 | export * from './broadcasts/interfaces/send-broadcast-options.interface';
12 | export * from './batch/interfaces/create-batch-options.interface';
13 | export * from './contacts/interfaces/create-contact-options.interface';
14 | export * from './contacts/interfaces/get-contact.interface';
15 | export * from './contacts/interfaces/list-contacts.interface';
16 | export * from './contacts/interfaces/remove-contact.interface';
17 | export * from './contacts/interfaces/update-contact.interface';
18 | export * from './domains/interfaces/create-domain-options.interface';
19 | export * from './domains/interfaces/get-domain.interface';
20 | export * from './domains/interfaces/list-domains.interface';
21 | export * from './domains/interfaces/remove-domain.interface';
22 | export * from './domains/interfaces/update-domain.interface';
23 | export * from './domains/interfaces/verify-domain.interface';
24 | export * from './emails/interfaces/create-email-options.interface';
25 | export * from './emails/interfaces/get-email-options.interface';
26 |
--------------------------------------------------------------------------------
/src/interfaces.ts:
--------------------------------------------------------------------------------
1 | export const RESEND_ERROR_CODES_BY_KEY = {
2 | missing_required_field: 422,
3 | invalid_idempotency_key: 400,
4 | invalid_idempotent_request: 409,
5 | concurrent_idempotent_requests: 409,
6 | invalid_access: 422,
7 | invalid_parameter: 422,
8 | invalid_region: 422,
9 | rate_limit_exceeded: 429,
10 | missing_api_key: 401,
11 | invalid_api_Key: 403,
12 | invalid_from_address: 403,
13 | validation_error: 403,
14 | not_found: 404,
15 | method_not_allowed: 405,
16 | application_error: 500,
17 | internal_server_error: 500,
18 | } as const;
19 |
20 | export type RESEND_ERROR_CODE_KEY = keyof typeof RESEND_ERROR_CODES_BY_KEY;
21 |
22 | export interface ErrorResponse {
23 | message: string;
24 | name: RESEND_ERROR_CODE_KEY;
25 | }
26 |
27 | export type Tag = { name: string; value: string };
28 |
--------------------------------------------------------------------------------
/src/resend.ts:
--------------------------------------------------------------------------------
1 | import { version } from '../package.json';
2 | import { ApiKeys } from './api-keys/api-keys';
3 | import { Audiences } from './audiences/audiences';
4 | import { Batch } from './batch/batch';
5 | import { Broadcasts } from './broadcasts/broadcasts';
6 | import type { GetOptions, PostOptions, PutOptions } from './common/interfaces';
7 | import type { IdempotentRequest } from './common/interfaces/idempotent-request.interface';
8 | import type { PatchOptions } from './common/interfaces/patch-option.interface';
9 | import { Contacts } from './contacts/contacts';
10 | import { Domains } from './domains/domains';
11 | import { Emails } from './emails/emails';
12 | import type { ErrorResponse } from './interfaces';
13 |
14 | const defaultBaseUrl = 'https://api.resend.com';
15 | const defaultUserAgent = `resend-node:${version}`;
16 | const baseUrl =
17 | typeof process !== 'undefined' && process.env
18 | ? process.env.RESEND_BASE_URL || defaultBaseUrl
19 | : defaultBaseUrl;
20 | const userAgent =
21 | typeof process !== 'undefined' && process.env
22 | ? process.env.RESEND_USER_AGENT || defaultUserAgent
23 | : defaultUserAgent;
24 |
25 | export class Resend {
26 | private readonly headers: Headers;
27 |
28 | readonly apiKeys = new ApiKeys(this);
29 | readonly audiences = new Audiences(this);
30 | readonly batch = new Batch(this);
31 | readonly broadcasts = new Broadcasts(this);
32 | readonly contacts = new Contacts(this);
33 | readonly domains = new Domains(this);
34 | readonly emails = new Emails(this);
35 |
36 | constructor(readonly key?: string) {
37 | if (!key) {
38 | if (typeof process !== 'undefined' && process.env) {
39 | this.key = process.env.RESEND_API_KEY;
40 | }
41 |
42 | if (!this.key) {
43 | throw new Error(
44 | 'Missing API key. Pass it to the constructor `new Resend("re_123")`',
45 | );
46 | }
47 | }
48 |
49 | this.headers = new Headers({
50 | Authorization: `Bearer ${this.key}`,
51 | 'User-Agent': userAgent,
52 | 'Content-Type': 'application/json',
53 | });
54 | }
55 |
56 | async fetchRequest(
57 | path: string,
58 | options = {},
59 | ): Promise<{ data: T | null; error: ErrorResponse | null }> {
60 | try {
61 | const response = await fetch(`${baseUrl}${path}`, options);
62 |
63 | if (!response.ok) {
64 | try {
65 | const rawError = await response.text();
66 | return { data: null, error: JSON.parse(rawError) };
67 | } catch (err) {
68 | if (err instanceof SyntaxError) {
69 | return {
70 | data: null,
71 | error: {
72 | name: 'application_error',
73 | message:
74 | 'Internal server error. We are unable to process your request right now, please try again later.',
75 | },
76 | };
77 | }
78 |
79 | const error: ErrorResponse = {
80 | message: response.statusText,
81 | name: 'application_error',
82 | };
83 |
84 | if (err instanceof Error) {
85 | return { data: null, error: { ...error, message: err.message } };
86 | }
87 |
88 | return { data: null, error };
89 | }
90 | }
91 |
92 | const data = await response.json();
93 | return { data, error: null };
94 | } catch (error) {
95 | return {
96 | data: null,
97 | error: {
98 | name: 'application_error',
99 | message: 'Unable to fetch data. The request could not be resolved.',
100 | },
101 | };
102 | }
103 | }
104 |
105 | async post(
106 | path: string,
107 | entity?: unknown,
108 | options: PostOptions & IdempotentRequest = {},
109 | ) {
110 | const headers = new Headers(this.headers);
111 |
112 | if (options.idempotencyKey) {
113 | headers.set('Idempotency-Key', options.idempotencyKey);
114 | }
115 |
116 | const requestOptions = {
117 | method: 'POST',
118 | headers: headers,
119 | body: JSON.stringify(entity),
120 | ...options,
121 | };
122 |
123 | return this.fetchRequest(path, requestOptions);
124 | }
125 |
126 | async get(path: string, options: GetOptions = {}) {
127 | const requestOptions = {
128 | method: 'GET',
129 | headers: this.headers,
130 | ...options,
131 | };
132 |
133 | return this.fetchRequest(path, requestOptions);
134 | }
135 |
136 | async put(path: string, entity: unknown, options: PutOptions = {}) {
137 | const requestOptions = {
138 | method: 'PUT',
139 | headers: this.headers,
140 | body: JSON.stringify(entity),
141 | ...options,
142 | };
143 |
144 | return this.fetchRequest(path, requestOptions);
145 | }
146 |
147 | async patch(path: string, entity: unknown, options: PatchOptions = {}) {
148 | const requestOptions = {
149 | method: 'PATCH',
150 | headers: this.headers,
151 | body: JSON.stringify(entity),
152 | ...options,
153 | };
154 |
155 | return this.fetchRequest(path, requestOptions);
156 | }
157 |
158 | async delete(path: string, query?: unknown) {
159 | const requestOptions = {
160 | method: 'DELETE',
161 | headers: this.headers,
162 | body: JSON.stringify(query),
163 | };
164 |
165 | return this.fetchRequest(path, requestOptions);
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "alwaysStrict": true,
5 | "esModuleInterop": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "moduleResolution": "node",
8 | "noFallthroughCasesInSwitch": true,
9 | "noUnusedLocals": true,
10 | "noUnusedParameters": true,
11 | "resolveJsonModule": true,
12 | "target": "es6",
13 | "module": "commonjs",
14 | "declaration": true,
15 | "outDir": "build",
16 | "strict": true
17 | },
18 | "include": ["src"]
19 | }
20 |
--------------------------------------------------------------------------------