├── .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 | ![nodejs-og](https://github.com/user-attachments/assets/7bc8f7c1-1877-4ddd-89f9-4f8d9bc32ed5) 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 | --------------------------------------------------------------------------------