diff --git a/docs/docs/cli-commands.md b/docs/docs/cli-commands.md index 6fafdacddafa..5f2d73d0a386 100644 --- a/docs/docs/cli-commands.md +++ b/docs/docs/cli-commands.md @@ -1904,6 +1904,19 @@ In order to use [Netlify Dev](https://www.netlify.com/products/dev/) you need to > Note: To detect the RedwoodJS framework, please use netlify-cli v3.34.0 or greater. +### setup mailer + +This command adds the necessary packages and files to get started using the RedwoodJS mailer. By default it also creates an example mail template which can be skipped with the `--skip-examples` flag. + +``` +yarn redwood setup mailer +``` + +| Arguments & Options | Description | +| :---------------------- | :----------------------------- | +| `--force, -f` | Overwrite existing files | +| `--skip-examples` | Do not include example content, such as a React email template | + ### setup package This command takes a published npm package that you specify, performs some compatibility checks, and then executes its bin script. This allows you to use third-party packages that can provide you with an easy-to-use setup command for the particular functionality they provide. diff --git a/packages/cli/src/commands/setup/mailer/mailer.js b/packages/cli/src/commands/setup/mailer/mailer.js new file mode 100644 index 000000000000..3838ec7f9500 --- /dev/null +++ b/packages/cli/src/commands/setup/mailer/mailer.js @@ -0,0 +1,31 @@ +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' + +export const command = 'mailer' + +export const description = + 'Setup the redwood mailer. This will install the required packages and add the required initial configuration to your redwood app.' + +export const builder = (yargs) => { + yargs + .option('force', { + alias: 'f', + default: false, + description: 'Overwrite existing configuration', + type: 'boolean', + }) + .option('skip-examples', { + default: false, + description: 'Only include required files and exclude any examples', + type: 'boolean', + }) +} + +export const handler = async (options) => { + recordTelemetryAttributes({ + command: 'setup mailer', + force: options.force, + skipExamples: options.skipExamples, + }) + const { handler } = await import('./mailerHandler.js') + return handler(options) +} diff --git a/packages/cli/src/commands/setup/mailer/mailerHandler.js b/packages/cli/src/commands/setup/mailer/mailerHandler.js new file mode 100644 index 000000000000..a25ce2966c18 --- /dev/null +++ b/packages/cli/src/commands/setup/mailer/mailerHandler.js @@ -0,0 +1,119 @@ +import fs from 'fs' +import path from 'path' + +import { Listr } from 'listr2' + +import { addApiPackages } from '@redwoodjs/cli-helpers' +import { errorTelemetry } from '@redwoodjs/telemetry' + +import { getPaths, transformTSToJS, writeFile } from '../../../lib' +import c from '../../../lib/colors' +import { isTypeScriptProject } from '../../../lib/project' + +export const handler = async ({ force, skipExamples }) => { + const projectIsTypescript = isTypeScriptProject() + const redwoodVersion = + require(path.join(getPaths().base, 'package.json')).devDependencies[ + '@redwoodjs/core' + ] ?? 'latest' + + const tasks = new Listr( + [ + { + title: `Adding api/src/lib/mailer.${ + projectIsTypescript ? 'ts' : 'js' + }...`, + task: () => { + const templatePath = path.resolve( + __dirname, + 'templates', + 'mailer.ts.template' + ) + const templateContent = fs.readFileSync(templatePath, { + encoding: 'utf8', + flag: 'r', + }) + + const mailerPath = path.join( + getPaths().api.lib, + `mailer.${projectIsTypescript ? 'ts' : 'js'}` + ) + const mailerContent = projectIsTypescript + ? templateContent + : transformTSToJS(mailerPath, templateContent) + + return writeFile(mailerPath, mailerContent, { + overwriteExisting: force, + }) + }, + }, + { + title: 'Adding api/src/mail directory...', + task: () => { + const mailDir = path.join(getPaths().api.mail) + if (!fs.existsSync(mailDir)) { + fs.mkdirSync(mailDir) + } + }, + }, + { + title: `Adding example ReactEmail mail template`, + skip: () => skipExamples, + task: () => { + const templatePath = path.resolve( + __dirname, + 'templates', + 're-example.tsx.template' + ) + const templateContent = fs.readFileSync(templatePath, { + encoding: 'utf8', + flag: 'r', + }) + + const exampleTemplatePath = path.join( + getPaths().api.mail, + 'Example', + `Example.${projectIsTypescript ? 'tsx' : 'jsx'}` + ) + const exampleTemplateContent = projectIsTypescript + ? templateContent + : transformTSToJS(exampleTemplatePath, templateContent) + + return writeFile(exampleTemplatePath, exampleTemplateContent, { + overwriteExisting: force, + }) + }, + }, + { + // Add production dependencies + ...addApiPackages([ + `@redwoodjs/mailer-core@${redwoodVersion}`, + `@redwoodjs/mailer-handler-nodemailer@${redwoodVersion}`, + `@redwoodjs/mailer-renderer-react-email@${redwoodVersion}`, + `@react-email/components`, // NOTE: Unpinned dependency here + ]), + title: 'Adding production dependencies to your api side...', + }, + { + // Add development dependencies + ...addApiPackages([ + '-D', + `@redwoodjs/mailer-handler-in-memory@${redwoodVersion}`, + `@redwoodjs/mailer-handler-studio@${redwoodVersion}`, + ]), + title: 'Adding development dependencies to your api side...', + }, + ], + { + rendererOptions: { collapseSubtasks: false }, + } + ) + + try { + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/mailer/templates/mailer.ts.template b/packages/cli/src/commands/setup/mailer/templates/mailer.ts.template new file mode 100644 index 000000000000..eac5fed86efc --- /dev/null +++ b/packages/cli/src/commands/setup/mailer/templates/mailer.ts.template @@ -0,0 +1,30 @@ +import { Mailer } from '@redwoodjs/mailer-core' +import { NodemailerMailHandler } from '@redwoodjs/mailer-handler-nodemailer' +import { ReactEmailRenderer } from '@redwoodjs/mailer-renderer-react-email' + +import { logger } from 'src/lib/logger' + +export const mailer = new Mailer({ + handling: { + handlers: { + // TODO: Update this handler config or switch it out for a different handler completely + nodemailer: new NodemailerMailHandler({ + transport: { + host: 'localhost', + port: 4319, + secure: false, + }, + }), + }, + default: 'nodemailer', + }, + + rendering: { + renderers: { + reactEmail: new ReactEmailRenderer(), + }, + default: 'reactEmail', + }, + + logger, +}) diff --git a/packages/cli/src/commands/setup/mailer/templates/re-example.tsx.template b/packages/cli/src/commands/setup/mailer/templates/re-example.tsx.template new file mode 100644 index 000000000000..9a13cf19eb1f --- /dev/null +++ b/packages/cli/src/commands/setup/mailer/templates/re-example.tsx.template @@ -0,0 +1,40 @@ +import React from 'react' + +import { + Html, + Text, + Hr, + Body, + Head, + Tailwind, + Preview, + Container, + Heading, +} from '@react-email/components' + +export function ExampleEmail( + { when }: { when: string } = { when: new Date().toLocaleString() } +) { + return ( + + + An example email + + + + + Example Email + + + This is an example email which you can customise to your needs. + +
+ + Message was sent on {when} + +
+ +
+ + ) +} diff --git a/packages/mailer/core/src/__tests__/mailer.test.ts b/packages/mailer/core/src/__tests__/mailer.test.ts index b7dd65541818..dc10c3d2f869 100644 --- a/packages/mailer/core/src/__tests__/mailer.test.ts +++ b/packages/mailer/core/src/__tests__/mailer.test.ts @@ -325,33 +325,45 @@ describe('Uses the correct modes', () => { expect(console.warn).toBeCalledWith( 'The test handler is null, this will prevent mail from being processed in test mode' ) + }) + + test('development', () => { console.warn.mockClear() - const _mailer2 = new Mailer({ + const _mailer1 = new Mailer({ ...baseConfig, - test: { + development: { when: true, - handler: undefined, + handler: null, }, }) expect(console.warn).toBeCalledWith( - 'The test handler is null, this will prevent mail from being processed in test mode' + 'The development handler is null, this will prevent mail from being processed in development mode' ) }) + }) - test('development', () => { + describe('attempts to use fallback handlers', () => { + beforeAll(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + test('test', () => { console.warn.mockClear() - const _mailer1 = new Mailer({ + const _mailer = new Mailer({ ...baseConfig, - development: { + test: { when: true, - handler: null, + handler: undefined, }, }) expect(console.warn).toBeCalledWith( - 'The development handler is null, this will prevent mail from being processed in development mode' + "Automatically loaded the '@redwoodjs/mailer-handler-in-memory' handler, this will be used to process mail in test mode" ) + }) + + test('development', () => { console.warn.mockClear() - const _mailer2 = new Mailer({ + const _mailer = new Mailer({ ...baseConfig, development: { when: true, @@ -359,7 +371,7 @@ describe('Uses the correct modes', () => { }, }) expect(console.warn).toBeCalledWith( - 'The development handler is null, this will prevent mail from being processed in development mode' + "Automatically loaded the '@redwoodjs/mailer-handler-studio' handler, this will be used to process mail in development mode" ) }) }) @@ -804,7 +816,7 @@ describe('Uses the correct modes', () => { expect(mailerExplicitlyNullTestHandler.getTestHandler()).toBeNull() const mailerNoTestHandlerDefined = new Mailer(baseConfig) - expect(mailerNoTestHandlerDefined.getTestHandler()).toBeNull() + expect(mailerNoTestHandlerDefined.getTestHandler()).not.toBeNull() }) test('getDevelopmentHandler', () => { @@ -835,7 +847,9 @@ describe('Uses the correct modes', () => { ).toBeNull() const mailerNoDevelopmentHandlerDefined = new Mailer(baseConfig) - expect(mailerNoDevelopmentHandlerDefined.getDevelopmentHandler()).toBeNull() + expect( + mailerNoDevelopmentHandlerDefined.getDevelopmentHandler() + ).not.toBeNull() }) test('getDefaultProductionHandler', () => { diff --git a/packages/mailer/core/src/mailer.ts b/packages/mailer/core/src/mailer.ts index 4b565fdf77e3..de689b2cc399 100644 --- a/packages/mailer/core/src/mailer.ts +++ b/packages/mailer/core/src/mailer.ts @@ -1,5 +1,6 @@ import type { Logger } from '@redwoodjs/api/logger' +import type { AbstractMailHandler } from './handler' import type { MailerConfig, MailSendWithoutRenderingOptions, @@ -27,6 +28,11 @@ export class Mailer< public handlers: THandlers public renderers: TRenderers + // TODO: These would be better typed as the specific InMemoryMailHandler and StudioMailHandler classes + // However, that would require a circular dependency between this file and those files + private fallbackTestHandler?: AbstractMailHandler + private fallbackDevelopmentHandler?: AbstractMailHandler + constructor( public config: MailerConfig< THandlers, @@ -59,15 +65,18 @@ export class Mailer< // Validate handlers for test and development modes const testHandlerKey = this.config.test?.handler if (testHandlerKey === undefined) { - // TODO: Attempt to use a default in-memory handler if the required package is installed - // otherwise default to null and log a warning - this.config.test = { - ...this.config.test, - handler: null, + // Attempt to use a default in-memory handler if the required package is installed + try { + this.fallbackTestHandler = + new (require('@redwoodjs/mailer-handler-in-memory').InMemoryMailHandler)() + this.logger.warn( + "Automatically loaded the '@redwoodjs/mailer-handler-in-memory' handler, this will be used to process mail in test mode" + ) + } catch (_error) { + this.logger.warn( + "No test handler specified and could not load the '@redwoodjs/mailer-handler-in-memory' handler automatically, this will prevent mail from being processed in test mode" + ) } - this.logger.warn( - 'The test handler is null, this will prevent mail from being processed in test mode' - ) } else if (testHandlerKey === null) { this.logger.warn( 'The test handler is null, this will prevent mail from being processed in test mode' @@ -81,15 +90,18 @@ export class Mailer< } const developmentHandlerKey = this.config.development?.handler if (developmentHandlerKey === undefined) { - // TODO: Attempt to use a default studio handler if the required package is installed - // otherwise default to null and log a warning - this.config.development = { - ...this.config.development, - handler: null, + // Attempt to use a default studio handler if the required package is installed + try { + this.fallbackDevelopmentHandler = + new (require('@redwoodjs/mailer-handler-studio').StudioMailHandler)() + this.logger.warn( + "Automatically loaded the '@redwoodjs/mailer-handler-studio' handler, this will be used to process mail in development mode" + ) + } catch (_error) { + this.logger.warn( + "No development handler specified and could not load the '@redwoodjs/mailer-handler-studio' handler automatically, this will prevent mail from being processed in development mode" + ) } - this.logger.warn( - 'The development handler is null, this will prevent mail from being processed in development mode' - ) } else if (developmentHandlerKey === null) { this.logger.warn( 'The development handler is null, this will prevent mail from being processed in development mode' @@ -171,13 +183,6 @@ export class Mailer< // Handler is null, which indicates a no-op return {} } - if (handlerKey === undefined) { - throw new Error('No handler specified and no default handler configured') - } - const handler = this.handlers[handlerKey] - if (handler === undefined) { - throw new Error(`No handler found to match '${handlerKey.toString()}'`) - } const completedSendOptions = constructCompleteSendOptions( sendOptions, @@ -210,10 +215,23 @@ export class Mailer< } ) - const defaultedHandlerOptions = { - ...this.config.handling.options?.[handlerKeyForProduction], - ...handlerOptions, + const defaultedHandlerOptions = + handlerKey === undefined + ? handlerOptions + : { + ...this.config.handling.options?.[handlerKey], + ...handlerOptions, + } + + const handler = + handlerKey === undefined + ? this.getDefaultHandler() + : this.handlers[handlerKey] + if (handler === null || handler === undefined) { + // Handler is null or missing, which indicates a no-op + return {} } + const result = await handler.send( renderedContent, completedSendOptions, @@ -260,23 +278,29 @@ export class Mailer< // Handler is null, which indicates a no-op return {} } - if (handlerKey === undefined) { - throw new Error('No handler specified and no default handler configured') - } - const handler = this.handlers[handlerKey] - if (handler === undefined) { - throw new Error(`No handler found to match '${handlerKey.toString()}'`) - } const completedSendOptions = constructCompleteSendOptions( sendOptions, this.defaults ) - const defaultedHandlerOptions = { - ...this.config.handling.options?.[handlerKeyForProduction], - ...handlerOptions, + const defaultedHandlerOptions = + handlerKey === undefined + ? handlerOptions + : { + ...this.config.handling.options?.[handlerKey], + ...handlerOptions, + } + + const handler = + handlerKey === undefined + ? this.getDefaultHandler() + : this.handlers[handlerKey] + if (handler === null || handler === undefined) { + // Handler is null or missing, which indicates a no-op + return {} } + const result = await handler.send( content, completedSendOptions, @@ -322,17 +346,23 @@ export class Mailer< getTestHandler() { const handlerKey = this.config.test?.handler - if (handlerKey === undefined || handlerKey === null) { + if (handlerKey === null) { return handlerKey } + if (handlerKey === undefined) { + return this.fallbackTestHandler + } return this.handlers[handlerKey] } getDevelopmentHandler() { const handlerKey = this.config.development?.handler - if (handlerKey === undefined || handlerKey === null) { + if (handlerKey === null) { return handlerKey } + if (handlerKey === undefined) { + return this.fallbackDevelopmentHandler + } return this.handlers[handlerKey] }