diff --git a/package.json b/package.json index 02baeae..51d28e3 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "edge.js": "^6.3.0", "eslint": "^9.38.0", "luxon": "^3.7.2", - "mjml": "^4.16.1", + "mjml": "^4.18.0", "prettier": "^3.6.2", "release-it": "^19.0.5", "sinon": "^21.0.0", @@ -82,11 +82,11 @@ "dependencies": { "@poppinss/macroable": "^1.1.0", "@poppinss/object-builder": "^1.1.0", - "@types/nodemailer": "^7.0.3", + "@types/nodemailer": "^7.0.4", "fastq": "^1.19.1", "ical-generator": "^9.0.0", "ky": "^1.13.0", - "nodemailer": "^7.0.10" + "nodemailer": "^7.0.11" }, "peerDependencies": { "@adonisjs/assembler": "^8.0.0-next.14", diff --git a/src/errors.ts b/src/errors.ts index 829b2b1..b248885 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -9,6 +9,15 @@ import { Exception } from '@adonisjs/core/exceptions' +/** + * The error is raised when one or more configuration + * properties are missing or invalid + */ +export const E_INVALID_CONFIG = class InvalidConfigException extends Exception { + static status = 500 + static code = 'E_INVALID_CONFIG' +} + /** * The error is raised when the transport is unable to * send the email diff --git a/src/transports/base_api_transport.ts b/src/transports/base_api_transport.ts new file mode 100644 index 0000000..34c6b2b --- /dev/null +++ b/src/transports/base_api_transport.ts @@ -0,0 +1,29 @@ +/* + * @adonisjs/mail + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { validateConfig } from '../utils.js' +import type { MailResponse } from '../mail_response.js' +import type { NodeMailerMessage, MailTransportContract } from '../types.js' + +/** + * Base class for HTTP based transports to validate the config + * and normalize the base URL + */ +export abstract class BaseApiTransport< + Config extends { key: string; baseUrl: string }, +> implements MailTransportContract { + constructor( + protected name: string, + protected config: Config + ) { + validateConfig(name, config) + } + + abstract send(message: NodeMailerMessage, config?: any): Promise> +} diff --git a/src/transports/brevo.ts b/src/transports/brevo.ts index 67cb8b9..219c4fe 100644 --- a/src/transports/brevo.ts +++ b/src/transports/brevo.ts @@ -8,19 +8,21 @@ */ import ky from 'ky' -import { type Transport, createTransport } from 'nodemailer' +import { createTransport, type Transport } from 'nodemailer' import type { Address } from 'nodemailer/lib/mailer/index.js' import type MailMessage from 'nodemailer/lib/mailer/mail-message.js' import debug from '../debug.js' import { MailResponse } from '../mail_response.js' +import { BaseApiTransport } from './base_api_transport.js' +import { normalizeBaseUrl } from '../utils.js' import { E_MAIL_TRANSPORT_ERROR } from '../errors.js' + import type { BrevoConfig, NodeMailerMessage, BrevoRuntimeConfig, BrevoSentMessageInfo, - MailTransportContract, } from '../types.js' /** @@ -116,21 +118,21 @@ class NodeMailerTransport implements Transport { * Returns base url for sending emails */ #getBaseUrl(): string { - return this.#config.baseUrl.replace(/\/$/, '') + return normalizeBaseUrl(this.#config.baseUrl) } /** * Send mail */ async send(mail: MailMessage, callback: (err: Error | null, info: BrevoSentMessageInfo) => void) { - const url = `${this.#getBaseUrl()}/smtp/email` - const envelope = mail.message.getEnvelope() - const payload = this.#preparePayload(mail) + try { + const url = `${this.#getBaseUrl()}/smtp/email` + const envelope = mail.message.getEnvelope() + const payload = this.#preparePayload(mail) - debug('brevo email url %s', url) - debug('brevo email payload %O', payload) + debug('brevo email url %s', url) + debug('brevo email payload %O', payload) - try { const response = await ky.post<{ messageId: string }>(url, { headers: { 'Accept': 'application/json', @@ -161,11 +163,9 @@ class NodeMailerTransport implements Transport { /** * Transport for sending emails using the Brevo `/emails/send` API. */ -export class BrevoTransport implements MailTransportContract { - #config: BrevoConfig - +export class BrevoTransport extends BaseApiTransport { constructor(config: BrevoConfig) { - this.#config = config + super('brevo', config) } /** @@ -175,7 +175,7 @@ export class BrevoTransport implements MailTransportContract { message: NodeMailerMessage, config?: BrevoRuntimeConfig ): Promise> { - const brevoTransport = new NodeMailerTransport({ ...this.#config, ...config }) + const brevoTransport = new NodeMailerTransport({ ...this.config, ...config }) const transporter = createTransport(brevoTransport) const brevoResponse = await transporter.sendMail(message) diff --git a/src/transports/mailgun.ts b/src/transports/mailgun.ts index 5c1440b..8768731 100644 --- a/src/transports/mailgun.ts +++ b/src/transports/mailgun.ts @@ -14,14 +14,15 @@ import { type Transport, createTransport } from 'nodemailer' import type MailMessage from 'nodemailer/lib/mailer/mail-message.js' import debug from '../debug.js' -import { streamToBlob } from '../utils.js' +import { streamToBlob, normalizeBaseUrl } from '../utils.js' import { MailResponse } from '../mail_response.js' +import { BaseApiTransport } from './base_api_transport.js' import { E_MAIL_TRANSPORT_ERROR } from '../errors.js' + import type { MailgunConfig, NodeMailerMessage, MailgunRuntimeConfig, - MailTransportContract, MailgunSentMessageInfo, } from '../types.js' @@ -70,8 +71,8 @@ class NodeMailerTransport implements Transport { */ #getBaseUrl(): string { return this.#config.domain - ? `${this.#config.baseUrl.replace(/\/$/, '')}/${this.#config.domain}` - : this.#config.baseUrl.replace(/\/$/, '') + ? `${normalizeBaseUrl(this.#config.baseUrl)}/${this.#config.domain}` + : normalizeBaseUrl(this.#config.baseUrl) } /** @@ -185,14 +186,14 @@ class NodeMailerTransport implements Transport { mail: MailMessage, callback: (err: Error | null, info: MailgunSentMessageInfo) => void ) { - const envelope = mail.message.getEnvelope() - const url = `${this.#getBaseUrl()}/messages.mime` - const form = await this.#createFormData(mail) + try { + const envelope = mail.message.getEnvelope() + const url = `${this.#getBaseUrl()}/messages.mime` + const form = await this.#createFormData(mail) - debug('mailgun mail url %s', url) - debug('mailgun mail envelope %s', envelope) + debug('mailgun mail url %s', url) + debug('mailgun mail envelope %s', envelope) - try { const response = await ky.post<{ id: string }>(url, { headers: { Accept: 'application/json', @@ -223,11 +224,9 @@ class NodeMailerTransport implements Transport { * AdonisJS Mail transport for sending emails using the * Mailgun's `/messages.mime` API endpoint. */ -export class MailgunTransport implements MailTransportContract { - #config: MailgunConfig - +export class MailgunTransport extends BaseApiTransport { constructor(config: MailgunConfig) { - this.#config = config + super('mailgun', config) } /** @@ -237,7 +236,7 @@ export class MailgunTransport implements MailTransportContract { message: NodeMailerMessage, config?: MailgunRuntimeConfig ): Promise> { - const mailgunTransport = new NodeMailerTransport({ ...this.#config, ...config }) + const mailgunTransport = new NodeMailerTransport({ ...this.config, ...config }) const transporter = createTransport(mailgunTransport) const mailgunResponse = await transporter.sendMail(message) diff --git a/src/transports/resend.ts b/src/transports/resend.ts index deb1a25..59f26ed 100644 --- a/src/transports/resend.ts +++ b/src/transports/resend.ts @@ -13,11 +13,13 @@ import type MailMessage from 'nodemailer/lib/mailer/mail-message.js' import debug from '../debug.js' import { MailResponse } from '../mail_response.js' +import { BaseApiTransport } from './base_api_transport.js' +import { normalizeBaseUrl } from '../utils.js' import { E_MAIL_TRANSPORT_ERROR } from '../errors.js' + import type { ResendConfig, NodeMailerMessage, - MailTransportContract, ResendRuntimeConfig, ResendSentMessageInfo, } from '../types.js' @@ -129,7 +131,7 @@ class NodeMailerTransport implements Transport { * Returns the normalized base URL for the API */ #getBaseUrl() { - return this.#config.baseUrl.replace(/\/$/, '') + return normalizeBaseUrl(this.#config.baseUrl) } /** @@ -139,14 +141,14 @@ class NodeMailerTransport implements Transport { mail: MailMessage, callback: (err: Error | null, info: ResendSentMessageInfo) => void ) { - const url = `${this.#getBaseUrl()}/emails` - const envelope = mail.message.getEnvelope() - const payload = this.#preparePayload(mail) + try { + const url = `${this.#getBaseUrl()}/emails` + const envelope = mail.message.getEnvelope() + const payload = this.#preparePayload(mail) - debug('resend mail url "%s"', url) - debug('resend mail payload %O', payload) + debug('resend mail url "%s"', url) + debug('resend mail payload %O', payload) - try { const response = await ky.post<{ id: string }>(url, { json: payload, headers: { @@ -177,11 +179,9 @@ class NodeMailerTransport implements Transport { /** * Transport for sending using the Resend `/emails` API. */ -export class ResendTransport implements MailTransportContract { - #config: ResendConfig - +export class ResendTransport extends BaseApiTransport { constructor(config: ResendConfig) { - this.#config = config + super('resend', config) } /** @@ -191,7 +191,7 @@ export class ResendTransport implements MailTransportContract { message: NodeMailerMessage, config?: ResendRuntimeConfig ): Promise> { - const resendTransport = new NodeMailerTransport({ ...this.#config, ...config }) + const resendTransport = new NodeMailerTransport({ ...this.config, ...config }) const transporter = createTransport(resendTransport) const resendResponse = await transporter.sendMail(message) diff --git a/src/transports/sparkpost.ts b/src/transports/sparkpost.ts index 2d0aa29..17221a1 100644 --- a/src/transports/sparkpost.ts +++ b/src/transports/sparkpost.ts @@ -15,11 +15,13 @@ import type MailMessage from 'nodemailer/lib/mailer/mail-message.js' import debug from '../debug.js' import { MailResponse } from '../mail_response.js' +import { BaseApiTransport } from './base_api_transport.js' +import { normalizeBaseUrl } from '../utils.js' import { E_MAIL_TRANSPORT_ERROR } from '../errors.js' + import type { SparkPostConfig, NodeMailerMessage, - MailTransportContract, SparkPostRuntimeConfig, SparkPostSentMessageInfo, } from '../types.js' @@ -41,7 +43,7 @@ class NodeMailerTransport implements Transport { * Returns base url for sending emails */ #getBaseUrl(): string { - return this.#config.baseUrl.replace(/\/$/, '') + return normalizeBaseUrl(this.#config.baseUrl) } /** @@ -132,17 +134,17 @@ class NodeMailerTransport implements Transport { mail: MailMessage, callback: (err: Error | null, info: SparkPostSentMessageInfo) => void ) { - const url = `${this.#getBaseUrl()}/transmissions` - const options = this.#getOptions(this.#config) - const envelope = mail.message.getEnvelope() - const recipients = this.#getRecipients(mail) + try { + const url = `${this.#getBaseUrl()}/transmissions` + const options = this.#getOptions(this.#config) + const envelope = mail.message.getEnvelope() + const recipients = this.#getRecipients(mail) - debug('sparkpost mail url "%s"', url) - debug('sparkpost mail options %O', options) - debug('sparkpost mail envelope %O', envelope) - debug('sparkpost mail recipients %O', recipients) + debug('sparkpost mail url "%s"', url) + debug('sparkpost mail options %O', options) + debug('sparkpost mail envelope %O', envelope) + debug('sparkpost mail recipients %O', recipients) - try { /** * The sparkpost API doesn't accept the multipart stream and hence we * need to convert the stream to a string @@ -181,11 +183,9 @@ class NodeMailerTransport implements Transport { * AdonisJS mail transport implementation to send emails * using Sparkpost's `/message.mime` API endpoint. */ -export class SparkPostTransport implements MailTransportContract { - #config: SparkPostConfig - +export class SparkPostTransport extends BaseApiTransport { constructor(config: SparkPostConfig) { - this.#config = config + super('sparkpost', config) } /** @@ -195,7 +195,7 @@ export class SparkPostTransport implements MailTransportContract { message: NodeMailerMessage, config?: SparkPostRuntimeConfig ): Promise> { - const nodemailerTransport = new NodeMailerTransport({ ...this.#config, ...config }) + const nodemailerTransport = new NodeMailerTransport({ ...this.config, ...config }) const transporter = createTransport(nodemailerTransport) const sparkPostResponse = await transporter.sendMail(message) diff --git a/src/utils.ts b/src/utils.ts index 6ca63bb..d6ee9a7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,8 @@ * file that was distributed with this source code. */ +import { E_INVALID_CONFIG } from './errors.js' + /** * Convert a stream to a blob */ @@ -20,3 +22,31 @@ export function streamToBlob(stream: NodeJS.ReadableStream, mimeType: string) { .once('error', reject) }) } + +/** + * Normalize the base URL by removing the trailing slash + */ +export function normalizeBaseUrl(baseUrl: string) { + return baseUrl.replace(/\/$/, '') +} + +/** + * Validate transport config to ensure the "key" and + * "baseUrl" are present. + */ +export function validateConfig( + transportName: string, + config: { key: string; baseUrl: string | undefined } +) { + if (!config.key) { + throw new E_INVALID_CONFIG( + `Invalid config for "${transportName}" transport. The "key" property is missing` + ) + } + + if (!config.baseUrl) { + throw new E_INVALID_CONFIG( + `Invalid config for "${transportName}" transport. The "baseUrl" property is missing` + ) + } +} diff --git a/tests/integration/transports/resend.spec.ts b/tests/integration/transports/resend.spec.ts index f93be9a..6c72f29 100644 --- a/tests/integration/transports/resend.spec.ts +++ b/tests/integration/transports/resend.spec.ts @@ -63,4 +63,34 @@ test.group('Resend Transport', () => { assert.deepEqual(email.body.from, process.env.RESEND_FROM_EMAIL) assert.deepEqual(email.body.subject, 'Adonisv6') }) + + test('throw error when key is missing', async ({ assert }) => { + const resend = new ResendTransport({ + baseUrl: process.env.RESEND_BASE_URL!, + } as any) + + const message = new Message() + message.from(process.env.RESEND_FROM_EMAIL!) + message.to(process.env.RESEND_TO_EMAIL!) + + await assert.rejects( + () => resend.send(message.toJSON().message), + 'Resend transport: "key" is not defined' + ) + }) + + test('throw error when baseUrl is missing', async ({ assert }) => { + const resend = new ResendTransport({ + key: process.env.RESEND_API_KEY!, + } as any) + + const message = new Message() + message.from(process.env.RESEND_FROM_EMAIL!) + message.to(process.env.RESEND_TO_EMAIL!) + + await assert.rejects( + () => resend.send(message.toJSON().message), + 'Resend transport: "baseUrl" is not defined' + ) + }) }) diff --git a/tests/unit/define_config.spec.ts b/tests/unit/define_config.spec.ts index 76ad04a..6d694ea 100644 --- a/tests/unit/define_config.spec.ts +++ b/tests/unit/define_config.spec.ts @@ -40,17 +40,17 @@ test.group('Define config', () => { assert.instanceOf(sesFactory(), SESTransport) expectTypeOf(sesFactory()).toMatchTypeOf() - const mailgunProvider = transports.mailgun({ key: '', baseUrl: '', domain: '' }) + const mailgunProvider = transports.mailgun({ key: 'dummy', baseUrl: 'dummy', domain: 'dummy' }) const mailgunFactory = await mailgunProvider.resolver(app) assert.instanceOf(mailgunFactory(), MailgunTransport) expectTypeOf(mailgunFactory()).toMatchTypeOf() - const sparkpostProvider = transports.sparkpost({ key: '', baseUrl: '' }) + const sparkpostProvider = transports.sparkpost({ key: 'dummy', baseUrl: 'dummy' }) const sparkpostFactory = await sparkpostProvider.resolver(app) assert.instanceOf(sparkpostFactory(), SparkPostTransport) expectTypeOf(sparkpostFactory()).toMatchTypeOf() - const resendProvider = transports.resend({ key: '', baseUrl: '' }) + const resendProvider = transports.resend({ key: 'dummy', baseUrl: 'dummy' }) const resendFactory = await resendProvider.resolver(app) assert.instanceOf(resendFactory(), ResendTransport) expectTypeOf(resendFactory()).toMatchTypeOf() @@ -68,9 +68,9 @@ test.group('Define config', () => { secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, }), - mailgun: transports.mailgun({ key: '', baseUrl: '', domain: '' }), - sparkpost: transports.sparkpost({ key: '', baseUrl: '' }), - resend: transports.resend({ key: '', baseUrl: '' }), + mailgun: transports.mailgun({ key: 'dummy', baseUrl: 'dummy', domain: 'dummy' }), + sparkpost: transports.sparkpost({ key: 'dummy', baseUrl: 'dummy' }), + resend: transports.resend({ key: 'dummy', baseUrl: 'dummy' }), }, }) @@ -90,4 +90,20 @@ test.group('Define config', () => { assert.instanceOf(mail.use('sparkpost').transport, SparkPostTransport) assert.instanceOf(mail.use('resend').transport, ResendTransport) }) + + test('throw error when key is missing', async ({ assert }) => { + assert.throws( + // @ts-expect-error + () => new ResendTransport({ baseUrl: 'dummy' }), + 'Invalid config for "resend" transport. The "key" property is missing' + ) + }) + + test('throw error when baseUrl is missing', async ({ assert }) => { + assert.throws( + // @ts-expect-error + () => new ResendTransport({ key: 'dummy' }), + 'Invalid config for "resend" transport. The "baseUrl" property is missing' + ) + }) })