From 187485678ad36766adfc2e75c520c855459b136b Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 28 Sep 2023 12:27:04 +1000 Subject: [PATCH] feat: add resend mail transport --- .env.example | 4 +- package-lock.json | 26 ++++- packages/email/mailer.ts | 9 ++ packages/email/package.json | 3 +- packages/email/transports/resend.ts | 145 ++++++++++++++++++++++++++++ packages/email/tsconfig.json | 5 + packages/tsconfig/process-env.d.ts | 4 +- turbo.json | 1 + 8 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 packages/email/transports/resend.ts diff --git a/.env.example b/.env.example index 3dc0985cb..065976bc5 100644 --- a/.env.example +++ b/.env.example @@ -50,7 +50,9 @@ NEXT_PRIVATE_SMTP_SECURE= NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso" # REQUIRED: Defines the email address to use as the from address. NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com" -# OPTIONAL: The API key to use for the MailChannels proxy endpoint. +# OPTIONAL: The API key to use for Resend.com +NEXT_PRIVATE_RESEND_API_KEY= +# OPTIONAL: The API key to use for MailChannels. NEXT_PRIVATE_MAILCHANNELS_API_KEY= # OPTIONAL: The endpoint to use for the MailChannels API if using a proxy. NEXT_PRIVATE_MAILCHANNELS_ENDPOINT= diff --git a/package-lock.json b/package-lock.json index fdd0ae6e7..2904b8297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17178,6 +17178,29 @@ "node": ">=0.10.5" } }, + "node_modules/resend": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-1.1.0.tgz", + "integrity": "sha512-it8TIDVT+/gAiJsUlv2tdHuvzwCCv4Zwu+udDqIm/dIuByQwe68TtFDcPccxqpSVVrNCBxxXLzsdT1tsV+P3GA==", + "dependencies": { + "@react-email/render": "0.0.7", + "type-fest": "3.13.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/resend/node_modules/type-fest": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.0.tgz", + "integrity": "sha512-Gur3yQGM9qiLNs0KPP7LPgeRbio2QTt4xXouobMCarR0/wyW3F+F/+OWwshg3NG0Adon7uQfSZBpB46NfhoF1A==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -19993,7 +20016,8 @@ "dependencies": { "@react-email/components": "^0.0.7", "nodemailer": "^6.9.3", - "react-email": "^1.9.4" + "react-email": "^1.9.4", + "resend": "^1.1.0" }, "devDependencies": { "@documenso/tailwind-config": "*", diff --git a/packages/email/mailer.ts b/packages/email/mailer.ts index 073f96680..cf359e648 100644 --- a/packages/email/mailer.ts +++ b/packages/email/mailer.ts @@ -1,6 +1,7 @@ import { createTransport } from 'nodemailer'; import { MailChannelsTransport } from './transports/mailchannels'; +import { ResendTransport } from './transports/resend'; const getTransport = () => { const transport = process.env.NEXT_PRIVATE_SMTP_TRANSPORT ?? 'smtp-auth'; @@ -14,6 +15,14 @@ const getTransport = () => { ); } + if (transport === 'resend') { + return createTransport( + ResendTransport.makeTransport({ + apiKey: process.env.NEXT_PRIVATE_RESEND_API_KEY || '', + }), + ); + } + if (transport === 'smtp-api') { if (!process.env.NEXT_PRIVATE_SMTP_HOST || !process.env.NEXT_PRIVATE_SMTP_APIKEY) { throw new Error( diff --git a/packages/email/package.json b/packages/email/package.json index 7bc6d9e5a..f9ce13279 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -19,7 +19,8 @@ "dependencies": { "@react-email/components": "^0.0.7", "nodemailer": "^6.9.3", - "react-email": "^1.9.4" + "react-email": "^1.9.4", + "resend": "^1.1.0" }, "devDependencies": { "@documenso/tailwind-config": "*", diff --git a/packages/email/transports/resend.ts b/packages/email/transports/resend.ts new file mode 100644 index 000000000..ab2f0959d --- /dev/null +++ b/packages/email/transports/resend.ts @@ -0,0 +1,145 @@ +import { type SentMessageInfo, type Transport } from 'nodemailer'; +import type Mail from 'nodemailer/lib/mailer'; +import type MailMessage from 'nodemailer/lib/mailer/mail-message'; +import { Resend } from 'resend'; + +const VERSION = '1.0.0'; + +type ResendTransportOptions = { + apiKey: string; +}; + +type ResendResponseError = { + statusCode: number; + name: string; + message: string; +}; + +const isResendResponseError = (error: unknown): error is ResendResponseError => { + // We could use Zod here, but it's not worth the extra bundle size + return ( + typeof error === 'object' && + error !== null && + 'statusCode' in error && + typeof error.statusCode === 'number' && + 'name' in error && + typeof error.name === 'string' && + 'message' in error && + typeof error.message === 'string' + ); +}; + +/** + * Transport for sending email via the Resend SDK. + */ +export class ResendTransport implements Transport { + public name = 'ResendMailTransport'; + public version = VERSION; + + private _client: Resend; + private _options: ResendTransportOptions; + + public static makeTransport(options: Partial) { + return new ResendTransport(options); + } + + constructor(options: Partial) { + const { apiKey = '' } = options; + + this._options = { + apiKey, + }; + + this._client = new Resend(apiKey); + } + + public send(mail: MailMessage, callback: (_err: Error | null, _info: SentMessageInfo) => void) { + if (!mail.data.to || !mail.data.from) { + return callback(new Error('Missing required fields "to" or "from"'), null); + } + + this._client + .sendEmail({ + subject: mail.data.subject ?? '', + from: this.toResendFromAddress(mail.data.from), + to: this.toResendAddresses(mail.data.to), + cc: this.toResendAddresses(mail.data.cc), + bcc: this.toResendAddresses(mail.data.bcc), + html: mail.data.html?.toString() || '', + text: mail.data.text?.toString() || '', + attachments: this.toResendAttachments(mail.data.attachments), + }) + .then((response) => { + if (isResendResponseError(response)) { + throw new Error(`[${response.statusCode}]: ${response.name} ${response.message}`); + } + + callback(null, response); + }) + .catch((error) => { + callback(error, null); + }); + } + + private toResendAddresses(addresses: Mail.Options['to']) { + if (!addresses) { + return []; + } + + if (typeof addresses === 'string') { + return [addresses]; + } + + if (Array.isArray(addresses)) { + return addresses.map((address) => { + if (typeof address === 'string') { + return address; + } + + return address.address; + }); + } + + return [addresses.address]; + } + + private toResendFromAddress(address: Mail.Options['from']) { + if (!address) { + return ''; + } + + if (typeof address === 'string') { + return address; + } + + return `${address.name} <${address.address}>`; + } + + private toResendAttachments(attachments: Mail.Options['attachments']) { + if (!attachments) { + return []; + } + + return attachments.map((attachment) => { + if (!attachment.filename || !attachment.content) { + throw new Error('Attachment is missing filename or content'); + } + + if (typeof attachment.content === 'string') { + return { + filename: attachment.filename, + content: Buffer.from(attachment.content), + }; + } + + if (attachment.content instanceof Buffer) { + return { + filename: attachment.filename, + content: attachment.content, + }; + } + + throw new Error('Attachment content must be a string or a buffer'); + }); + } +} diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json index 893a50e65..d211eb6ff 100644 --- a/packages/email/tsconfig.json +++ b/packages/email/tsconfig.json @@ -1,5 +1,10 @@ { "extends": "@documenso/tsconfig/react-library.json", + "compilerOptions": { + "types": [ + "@documenso/tsconfig/process-env.d.ts", + ] + }, "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"], "exclude": ["dist", "build", "node_modules"] } diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 86c5c7f64..1db1ec36a 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -27,7 +27,9 @@ declare namespace NodeJS { NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS?: string; NEXT_PRIVATE_SIGNING_LOCAL_FILE_ENCODING?: string; - NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api'; + NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'resend' | 'smtp-auth' | 'smtp-api'; + + NEXT_PRIVATE_RESEND_API_KEY?: string; NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string; NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN?: string; diff --git a/turbo.json b/turbo.json index 476efbd2d..0fc3d8f9c 100644 --- a/turbo.json +++ b/turbo.json @@ -50,6 +50,7 @@ "NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS", "NEXT_PRIVATE_SIGNING_LOCAL_FILE_ENCODING", "NEXT_PRIVATE_SMTP_TRANSPORT", + "NEXT_PRIVATE_RESEND_API_KEY", "NEXT_PRIVATE_MAILCHANNELS_API_KEY", "NEXT_PRIVATE_MAILCHANNELS_ENDPOINT", "NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN",