Skip to content

Conversation

@CarsonF
Copy link
Member

@CarsonF CarsonF commented Jul 10, 2025

Upgrading to v5

v5 revamps everything from the public surface area to the underlying implementation.
It adds support for other React JSX based email components, like react-email,
and allows loading data in templates via Suspense or React Server Components.
It uses nodemailer to do the message compilation & transporting.

Data Loading / Suspense / RSC

This allows templates to do async work.

Suspense

Any Suspense style loading works:

import usePromise from 'react-promise-suspense';

const loadUser = async (id: string) => { /* ... */ };

const ShowUser = ({ userId }) => {
  const user = usePromise(loadUser, [userId]);
  return <>{user.name}</>;
};

React Server Components (RSC)

If using React 19, the components can be async:

const loadUser = async (id: string) => { /* ... */ };

const ShowUser = async ({ userId }) => {
  const user = await loadUser(userId);
  return <>{user.name}</>;
};

MJML

MJML is now optional. To keep using it, you need to add the dependencies explicitly:

yarn add mjml @faire/mjml-react

The MJML components are now re-exported from:

import * as Mj from '@seedcompany/nestjs-email/templates/mjml';

These component types currently don't compile with React 19, but this can be worked around with:

"skipLibCheck": true

A fix for this is pending here: mjml-react#133

Module Registration

The registration methods were renamed to be more idiomatic:

  imports: [
-    EmailModule.forRoot(...)
+    EmailModule.register(...)
     // or
-    EmailModule.forRootAsync(...)
+    EmailModule.registerAsync(...)
  ]

And the configuration structure has changed:

  EmailModule.register({
-    from: 'no-reply@example.com',
-    replyTo: 'helpdesk@example.com',
+    defaultHeaders: {
+      from: 'no-reply@example.com',
+      replyTo: 'helpdesk@example.com',
+    },
  })

Service Rename

The main service has been renamed to better convey it is an actor:

- import { EmailService } from '@seedcompany/nestjs-email';
+ import { MailerService } from '@seedcompany/nestjs-email';

- constructor(private readonly emailService: EmailService) {}
+ constructor(private readonly mailer: MailerService) {}

Rendering and Sending

Rendering now happens lazily when send() is called. For this reason, render was renamed to compose.
Additionally, its parameters have been updated.
send() no longer will compose messages, and only accepts a EmailMessage object.

Messages can be composed with the EmailMessage class or with MailerService.compose

import { EmailMessage } from '@seedcompany/nestjs-email';
import { MailerService } from '@seedcompany/nestjs-email/src';

const mailer: MailerService = ...;

const msg = EmailMessage.from(...);
const msg = mailer.compose(...);

await mailer.send(msg);

// Messages created from the mailer service can be sent directly.
await mailer.compose(...).send();

Headers

Just like render/send before, a to address can be given first, but now any headers can also be given:

compose('user@example.com', ...)
compose({ to: 'user@example.com', cc: 'bar@example.com' }, ...)

All headers can also be omitted and given later:

compose(...)
.withHeaders({ to: 'user@example.com' })

Note that this .withHeaders() was renamed from .with() in v4.

JSX Headers

We now provide a Headers component that can declare any headers.

<Headers
  from="noreply+notification@example.com"
  subject="Some notification"
/>

Headers declared here take precedence over the defaultHeaders declared in the module config.
Headers declared on the EmailMessage take precedence over the headers in the JSX body.

Body

"Body" is now what the given JSX is called.

JSX can now be given directly

compose(<WelcomeMessage userId={id} />)

In case you don't want to use JSX in your file, the component & its props can be passed, just like before, except now
they're in a tuple.

compose([WelcomeMessage, { userId: id }])

Of course, the headers can be declared before the body as well:

compose(
  { cc: 'bar@example.com' },
  <WelcomeMessage userId={id} />
)
compose(
  { cc: 'bar@example.com' },
  [WelcomeMessage, { userId: id }]
)

The body can also be swapped out before sending if needed:

compose(<WelcomeMessage userId={id} />)
  .withBody(<WelcomeMessage userId={id} anotherFlag />)

Plain text only / Bodiless messages

HTML rendering can be skipped completely now as well:

compose({
  to: 'user@example.com',
  text: 'Hello World!'
})

Subject

Previously, the exported <Title> component would automatically set the subject.
Now you need to explicitly set the subject using the <Headers> component:

  <Title>A Notification</Title>
+ <Headers subject="A Notification" />

Or in the message composition:

compose({ subject: "A Notification" })
  // or
  .withHeaders({ subject: "A Notification" })

Text Rendering

The components for controlling text rendering have changed:

- <HideInText>Only in HTML</HideInText>
+ <InHtml>Only in HTML</InHtml>

  <InText>Only in text</InText>

The inText hook has been removed.
Before we rendered React twice, once for HTML and once for text.
This allowed conditional JS logic based on the specific output.
Now that bodies can be async, we don't want to do that work twice.
Templates must declare both text & HTML outputs at the same time.
Internally, we split & prune after the React rendering is done.

This is controlled by a data-render-only="text/html" attribute.
This can appear on any element in the render. The attribute will be stripped out before sending,
and the element will be removed if it is the opposite output format.

CarsonF added 3 commits July 10, 2025 08:54
Renamed render() -> compose() since it's not actually rendering now.
This also allows rendering to be async.
CarsonF added 7 commits July 11, 2025 18:22
MJML libs now need to be declared by consumers
- @faire/mjml-react
- mjml
```ts
compose(<Template foo="bar" />).send()
send('me@me.com', <Template foo="bar" />)
```
```ts
send(
  { to: 'me@me.com', 'reply-to': 'bob@me.com' },
  <Template foo="bar" />
)
```
Now messages are created with
```ts
const mailer: EmailService;
const msg = mailer.compose(..);
// or
const msg = EmailMessage.from(...);
```
and are sent with
```ts
const mailer: EmailService;
await mailer.send(msg);
// or if msg is created from mailer...
await mailer.compose(...).send();
```

Now headers are passed first, then the body.
The header can also just be one or more recipients.
The body can be an element (new) or a tuple of a component with its props.
```ts
compose('me@example.com', <Template foo="bar" />);
compose(
  { to: 'me@example.com' },
  [Template, { foo: "bar" }]
);
```
CarsonF added 6 commits July 16, 2025 15:56
I believe this compilation implementation is more robust
than `emailjs`, and the headers are better typed.
Since it already supports SES v2, there is no reason to not use it for transport as well.

Though the transporter is now injectable.
I adapted its logger (bunyan) to one for NestJS too.
```ts
await compose({ to: "", text: "" }).send()
```
CarsonF added 2 commits July 16, 2025 16:24
Now we only render once for React, meaning that components should
only be rendered once. This is more important now that components
can be async and do data loading work.

The context/hooks for "in text only" have been removed.
Components now need to declarativity state that with data attributes.
This is the biggest breaking change here, but I think it is worth it.
Declaring output for both formats at once is very similar to CSS styling,
giving up control of when each of those outputs is used.
Also, I don't think this is that big of a deal. In practice, we didn't
have any JS logic based on this output format.

We then post-process those data attributes from the HTML given to us
by React to split the output for text & HTML.

Helpers <InText> & <InHtml> can be used to ease this.
Or `data-render-only="text/html"` can be declared anywhere.
@CarsonF CarsonF marked this pull request as ready for review July 17, 2025 21:08
@CarsonF CarsonF requested review from a team and Copilot July 21, 2025 14:55
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces a major version upgrade (v5) of the email module that completely redesigns the API around React Server Components and Suspense support. The upgrade replaces MJML as a hard dependency with optional support, switches from SES direct sending to nodemailer-based transport, and introduces lazy rendering with improved composability patterns.

  • Replaces EmailService with MailerService using a more functional composition pattern
  • Implements React Server Components and Suspense support for async data loading in templates
  • Refactors template system to use Headers component instead of Title for subject setting
  • Replaces text/HTML rendering system with DOM-based processing using data-render-only attributes

Reviewed Changes

Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/email/src/transporter.ts Introduces abstract base class for email transport abstraction
packages/email/src/templates/title.tsx Removes legacy Title component and subject collection system
packages/email/src/templates/text-rendering.tsx Replaces context-based text rendering with DOM attribute system
packages/email/src/templates/mjml.tsx Updates MJML exports to include Title and Doc aliases
packages/email/src/templates/index.ts Updates template exports to focus on Headers and rendering components
packages/email/src/templates/headers.tsx Introduces new Headers component for declarative email metadata
packages/email/src/templates/attachment.tsx Removes legacy attachment handling system
packages/email/src/processRenderOnlyElements.ts Implements DOM processing for conditional text/HTML rendering
packages/email/src/message.ts Complete rewrite with EmailMessage and SendableEmailMessage classes
packages/email/src/mailer.service.ts New service implementation with nodemailer integration and async rendering
packages/email/src/logger.ts Adds nodemailer logger adapter for NestJS integration
packages/email/src/index.ts Updates public API exports
packages/email/src/email.service.ts Removes legacy EmailService implementation
packages/email/src/email.options.ts Simplifies configuration options structure
packages/email/src/email.module.ts Refactors module to use ConfigurableModuleBuilder pattern
packages/email/src/email.module.test.ts Updates tests for new API surface
packages/email/package.json Updates dependencies and peer dependencies for v5 changes
packages/email/UPGRADE.md Comprehensive migration guide for v5 upgrade
packages/email/README.md Complete documentation rewrite covering new features
package.json Minor Node.js types version update
Comments suppressed due to low confidence (1)

packages/email/src/mailer.service.ts:164

  • [nitpick] Method name 'sendMessage' is ambiguous since the class already has a public 'send' method. Consider renaming to 'transportMessage' or 'deliverMessage' for clarity.
  private async sendMessage(msg: EmailMessage<RenderedProps>) {

@CarsonF CarsonF merged commit 420a14c into master Aug 4, 2025
2 checks passed
@CarsonF CarsonF deleted the email/v5 branch August 4, 2025 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants