Emails

Typed email templates for Wander marketplace and WanderOS, built with React Email

Installation

1 lines
pnpm add @wandercom/design-system-emails

Requires a React 19 or later runtime. React Email and its rendering dependencies are installed with the package.

Usage

The package exports composeEmail for rendering templates and EmailPreview for displaying rendered HTML in an iframe:

16 lines
import { composeEmail } from '@wandercom/design-system-emails';

const { html, text } = await composeEmail('os.transactional.guest.booking-confirmed', {
  propertyName: 'Desert Modern Retreat',
  propertyImageUrl: 'https://assets.wander.com/p/listing-hero.jpg',
  bookingDates: 'Mar 15 – Mar 20, 2026',
  bookingGuests: 2,
  bookingUrl: 'https://example.com/bookings/abc123',
  unitAddress: '1234 Desert View Dr, Joshua Tree, CA 92252',
  mapsUrl: 'https://maps.google.com/?q=...',
  checkInInstructions: 'Lockbox code is 1234. Check-in after 4pm.',
  companyName: 'Desert Modern Retreat',
  unsubscribeUrl: 'https://example.com/unsubscribe',
  logoSrc: 'https://wander-ds.vercel.app/assets/wander-logomark.png',
  logoDarkSrc: 'https://wander-ds.vercel.app/assets/wander-logomark-dark.png',
});

The function is fully generic — TypeScript infers the correct props type from the template name, so you get autocomplete on both template keys and their required props.

Embedded preview

EmailPreview accepts complete HTML. It does not fetch data or compose a template, so render the email on the server before passing its HTML to the component.

20 lines
import {
  composeEmail,
  EmailPreview,
} from '@wandercom/design-system-emails';

export default async function WelcomeEmailPreview() {
  const { html } = await composeEmail('os.marketing.guest.welcome', {
    guestFirstName: 'Michael',
    companyName: 'Desert Modern Retreat',
    propertyCount: '12',
    locationList: 'Joshua Tree, Sedona, and Big Bear',
    browseUrl: 'https://example.com/properties',
    hostName: 'Sarah',
    unsubscribeUrl: '#',
    logoSrc: 'https://wander-ds.vercel.app/assets/wander-logomark.png',
    logoDarkSrc: 'https://wander-ds.vercel.app/assets/wander-logomark-dark.png',
  });

  return <EmailPreview html={html} title="Welcome email preview" />;
}

The component assigns the HTML to the iframe's srcDoc attribute and applies a restrictive sandbox by default. It does not send an email, execute scripts, or persist the HTML.

When calling composeEmail from a Next.js application, externalize the server renderer as one package. This lets Node resolve React Email and sanitizer dependencies from the package that owns them:

next.config.mjs
5 lines
const nextConfig = {
  serverExternalPackages: ['@wandercom/design-system-emails'],
};

export default nextConfig;

Do not add transitive dependencies such as PostCSS or Prettier to the application to suppress external-package warnings.

Live markup editors

Import the browser-safe preview entry when the HTML is controlled by a client component. For frequently changing HTML, use BufferedEmailPreview. It keeps the current content visible while the latest HTML loads in a hidden frame, then replaces the visible frame when that load completes.

23 lines
'use client';

import { BufferedEmailPreview } from '@wandercom/design-system-emails/preview';
import { useState } from 'react';

export function EmailMarkupEditor() {
  const [html, setHtml] = useState('<h1>Welcome</h1>');

  return (
    <div>
      <textarea
        aria-label="Email HTML"
        onChange={(event) => setHtml(event.currentTarget.value)}
        value={html}
      />
      <BufferedEmailPreview
        html={html}
        loadingFallback={<p>Loading preview...</p>}
        title="Edited email preview"
      />
    </div>
  );
}

The optional loadingFallback appears only while the first frame loads. After that, the current preview remains visible until the latest hidden frame is ready.

The sandbox blocks scripts and form submission by default, but embedded markup can still request remote assets such as images. Sanitize untrusted HTML before placing it in an editor or preview.

Template keys

Templates use dot-notation keys: {domain}.{category}.{audience}.{template-name}

Marketplace transactional

Guest

TemplateKey
Booking cancelledmarketplace.transactional.guest.booking-cancelled
Email codemarketplace.transactional.guest.email-code
Payment chargedmarketplace.transactional.guest.payment-charged
Payment failedmarketplace.transactional.guest.payment-failed
Pre-reserve confirmedmarketplace.transactional.guest.pre-reserve-confirmed
Pre-reserve expiredmarketplace.transactional.guest.pre-reserve-expired
Pre-reserve launchedmarketplace.transactional.guest.pre-reserve-launched
Receiptmarketplace.transactional.guest.receipt
Trip confirmedmarketplace.transactional.guest.trip-confirmed
Upcoming paymentmarketplace.transactional.guest.upcoming-payment
Upcoming staymarketplace.transactional.guest.upcoming-stay
Verify identitymarketplace.transactional.guest.verify-identity

Operator

TemplateKey
Agreement signedmarketplace.transactional.operator.agreement-signed
Booking conflictmarketplace.transactional.operator.booking-conflict
Monthly statementmarketplace.transactional.operator.monthly-statement
New bookingmarketplace.transactional.operator.new-booking
New booking offermarketplace.transactional.operator.new-booking-offer
New reviewmarketplace.transactional.operator.new-review
Onboarding invitemarketplace.transactional.operator.onboarding-invite
Owner chat messagemarketplace.transactional.operator.owner-chat-message
Payout sentmarketplace.transactional.operator.payout-sent
Post-trip feedbackmarketplace.transactional.operator.post-trip-feedback
Sign agreementmarketplace.transactional.operator.sign-agreement
Upcoming bookingmarketplace.transactional.operator.upcoming-booking
W-9 approvedmarketplace.transactional.operator.w9-approved
W-9 receivedmarketplace.transactional.operator.w9-received
Weekly chat reportmarketplace.transactional.operator.weekly-chat-report

Services

TemplateKey
New task assignedmarketplace.transactional.services.new-task-assigned
Overdue taskmarketplace.transactional.services.overdue-task
Owner dashboard invitemarketplace.transactional.services.owner-dashboard-invite
Vendor portal invitemarketplace.transactional.services.vendor-portal-invite
Vendor task completedmarketplace.transactional.services.vendor-task-completed

WanderOS marketing

Guest

TemplateKey
Abandoned cartos.marketing.guest.abandoned-cart
Extend stayos.marketing.guest.extend-stay
Flash rebookos.marketing.guest.flash-rebook
Last-minute availabilityos.marketing.guest.last-minute-availability
Launch dayos.marketing.guest.launch-day
New homeos.marketing.guest.new-home
New websiteos.marketing.guest.new-website
Past guest offeros.marketing.guest.past-guest-offer
Stay againos.marketing.guest.stay-again
Welcomeos.marketing.guest.welcome

WanderOS transactional

Guest

TemplateKey
Booking cancelledos.transactional.guest.booking-cancelled
Booking confirmedos.transactional.guest.booking-confirmed
Booking updatedos.transactional.guest.booking-updated
Check-in dayos.transactional.guest.check-in-day
Payment failedos.transactional.guest.payment-failed
Payment method updatedos.transactional.guest.payment-method-updated
Payment receivedos.transactional.guest.payment-received
Payment refundedos.transactional.guest.payment-refunded
Pre-tripos.transactional.guest.pre-trip
RTB receivedos.transactional.guest.rtb-received
RTB rejectedos.transactional.guest.rtb-rejected
Upcoming paymentos.transactional.guest.upcoming-payment
Verify emailos.transactional.guest.verify-email

Operator

TemplateKey
Booking reviewos.transactional.operator.booking-review
Confirm domain removalos.transactional.operator.confirm-domain-removal
Confirm listing deletionos.transactional.operator.confirm-listing-deletion
Contact us formos.transactional.operator.contact-us-form
Custom domain activeos.transactional.operator.custom-domain-active
Custom domain expiringos.transactional.operator.custom-domain-expiring
Custom domain removedos.transactional.operator.custom-domain-removed
Discoverability trial endingos.transactional.operator.discoverability-trial-ending
Early fraud warningos.transactional.operator.early-fraud-warning
Email codeos.transactional.operator.email-code
Form submissionos.transactional.operator.form
List with us formos.transactional.operator.list-with-us-form
New bookingos.transactional.operator.new-booking
Org inviteos.transactional.operator.org-invite
Payment disputeos.transactional.operator.payment-dispute
Payment receivedos.transactional.operator.payment-received
PMS sync completeos.transactional.operator.pms-sync-complete
RTB receivedos.transactional.operator.rtb-received
Website auditos.transactional.operator.website-audit
Website liveos.transactional.operator.website-live

Generic

  • generic for string or string[] bodies
  • generic.html for sanitized HTML bodies

The generic template is a flexible template for ad-hoc emails that don't need a dedicated template:

16 lines
const { html, text } = await composeEmail('generic', {
  preview: 'Your settings have been updated',
  heading: 'Your settings have been updated',
  body: [
    'We wanted to let you know that your account settings have been updated successfully.',
    'If you did not make this change, please contact our support team immediately.',
  ],
  buttonText: 'View settings',
  buttonHref: 'https://wander.com/settings',
  footer: 'wander',
  logoSrc: 'https://wander-ds.vercel.app/assets/wander-logomark.png',
  logoDarkSrc: 'https://wander-ds.vercel.app/assets/wander-logomark-dark.png',
  companyName: 'Wander',
  marketing: false,
  unsubscribeUrl: 'https://example.com/unsubscribe',
});

The body prop accepts a single string or an array of strings (each rendered as a separate paragraph). The button only renders when both buttonText and buttonHref are provided.

Generic HTML

Use generic.html when the message body comes from trusted application or CMS HTML:

17 lines
const { html, text } = await composeEmail('generic.html', {
  preview: 'Your upcoming stay',
  heading: 'Your upcoming stay',
  bodyHtml: `
    <p>Your stay begins on March 15.</p>
    <img src="https://assets.wander.com/stay.jpg" alt="Desert Modern Retreat" />
    <a data-email-button href="https://example.com/trips/abc123">View trip</a>
  `,
  buttonText: '',
  buttonHref: '',
  footer: 'wander',
  logoSrc: 'https://wander-ds.vercel.app/assets/wander-logomark.png',
  logoDarkSrc: 'https://wander-ds.vercel.app/assets/wander-logomark-dark.png',
  companyName: 'Wander',
  marketing: false,
  unsubscribeUrl: 'https://example.com/unsubscribe',
});

The HTML renderer supports headings, paragraphs, links, images, lists, and basic emphasis. It replaces source styles with email-safe inline styles. Add data-email-button to an anchor to use the primary button style.

Accessibility

Every template renders semantic HTML and a plain-text fallback. Provide meaningful property names and image descriptions because templates use those values for accessible image text. Keep heading order logical when using generic.html.

HTML content and security

The generic.html template removes scripts, event handlers, unsafe URL schemes, unsupported elements, and source CSS. Sanitization does not make untrusted copy, destination URLs, or tracking images trustworthy. Only pass HTML from application-controlled or reviewed CMS sources.

Email client limitations

Email clients apply HTML and CSS inconsistently. Dark mode is best effort, remote fonts may fall back to system fonts, and classic desktop Outlook may not render rounded image corners.

Sending emails

The composeEmail function returns { html, text } — the rendered HTML and plain text versions. Pass these to any email provider:

13 lines
import { composeEmail } from '@wandercom/design-system-emails';

const { html, text } = await composeEmail('os.transactional.guest.booking-confirmed', {
  // ... template props
});

await emailProvider.send({
  from: 'Wander <notifications@wander.com>',
  to: guest.email,
  subject: 'Booking confirmed',
  html,
  text,
});

Preview tooling

Choose the preview workflow based on what you are changing. Use the embedded preview to verify a documented example in the design system site. Use the React Email development server when building or comparing templates directly.

Docs rendering path

The embedded docs example uses a server component to call composeEmail, then passes the returned HTML to the package's EmailPreview component. The preview loads that HTML directly through the iframe's srcDoc attribute; no rendering API or client request is involved.

The docs preview does not select recipients, deliver mail, queue jobs, or persist preview data. The explicit props in the MDX example determine what appears in the iframe.

Before starting the docs site, build the design system so the docs app can load the current email package and generated registry:

2 lines
pnpm build:design-system
pnpm dev:docs

Run pnpm build:design-system again after changing an email template, shared email component, package export, dependency, or registry input. Content-only changes to this MDX file normally update through the docs development server without another design system build.

React Email development server

To work on templates without the docs site, start the React Email development server from the email package:

2 lines
cd packages/emails
pnpm dev

The server starts on port 3000 and can use 3001 when port 3000 is already in use. It discovers the template files under packages/emails/emails and renders their default exports.

Some templates are thin wrappers over shared, copy-parameterized bases — for example emails/shared/email-code.tsx and emails/shared/invite.tsx, plus in-place bases such as payment-alert.tsx and w9-notice.tsx. Base files export no default component, so the preview lists only the wrapper templates that render them. Repeated layout blocks like the property hero and invoice table live in packages/emails/components as PropertyCard and InvoiceTable. When changing a shared base or block, check every template that consumes it.

Default props

The React Email development server renders templates without application data. Values shown there come from defaults declared in the template component's parameter destructuring. A template without defaults for a required value may show incomplete or empty content in the local preview.

The package API is stricter. EmailTemplateMap makes every template prop required at the composeEmail boundary, including props that have component defaults. Embedded docs previews should also provide a complete props object so the example matches production usage and remains type-safe.

Use safe, clearly fictional defaults. Do not place access tokens, private URLs, or customer information in template source or MDX preview props.

Troubleshooting

The embedded preview shows a loading indicator while the docs API renders the template. If the request fails, the iframe is replaced by an error message.

  • A missing template value returns an HTTP 400 response
  • An unknown template, incompatible props, or rendering exception returns an HTTP 500 response
  • The docs server terminal contains the underlying render error
  • Missing package, registry, or token modules usually mean pnpm build:design-system was not run before pnpm dev:docs
  • A stale local React Email preview may require restarting pnpm dev after dependency or export changes
  • Remote images require a reachable HTTPS URL and may fail because of network, content security, or host restrictions

Preview security and limitations

Preview tooling is for development and documentation, not for processing untrusted requests. The iframe sandbox prevents email HTML from running scripts in the docs page, but remote images and links can still contact external hosts. Use development-safe asset URLs and avoid sensitive query parameters.

generic.html sanitizes its body before rendering, as described in HTML content and security. Sanitization is not a replacement for trusted content sources, destination URL validation, access control, or provider-side email policy checks.

The preview confirms rendered structure and styling in a browser. It does not replace testing in supported email clients, validating the plain-text result, or sending through the production email provider in a controlled environment.

Types

The package exports these types for use in consuming applications:

9 lines
import type {
  EmailTemplateName,
  EmailTemplateMap,
  ComposeResult,
} from '@wandercom/design-system-emails';
import type {
  BufferedEmailPreviewProps,
  EmailPreviewProps,
} from '@wandercom/design-system-emails/preview';
  • EmailTemplateName — union of all template key strings
  • EmailTemplateMap — maps each template key to its required props
  • ComposeResult{ html: string; text: string }
  • EmailPreviewProps — props accepted by EmailPreview
  • BufferedEmailPreviewProps — props accepted by BufferedEmailPreview, including the optional loadingFallback