react-email icon indicating copy to clipboard operation
react-email copied to clipboard

i18n (Internationalization and localization)

Open dmitry-zaets opened this issue 2 years ago • 3 comments

Using the library in multi-language projects would be tremendous, and avoid copying/pasting templates but instead use the localization library. Are there any plans to extend the project documentation to showcase the use of i18n libraries to translate the emails into different languages?

dmitry-zaets avatar Feb 01 '23 17:02 dmitry-zaets

That would lovely! This is definitely something that we want to have, but I think we want to nail down a few things first. But feel free to share any thoughts on this and how could we do it, we can release it sooner.

bukinoshita avatar Feb 01 '23 18:02 bukinoshita

I'm using the renderer inside the node.js API, so my initial thought was to use something like prebuilt middleware for i18n (https://github.com/i18next/i18next-http-middleware, for example). But then I realized that the rendering is outside of the API context. So, for now, I've just used plain i18nextpackage inside the react code by initializing i18next and using the t method that the library exports. As emails, in most cases, are the only thing that needs to be translated in the APIs, I assume that the react-email project could have an opinionated implementation (for example, the same i18next) and suggest something like: "Put your translations in this folder... Import this method and call it with a translation key".

But still, I would like to hear your thoughts on how your team would suggest implementing it. If there are any good ideas, I'm happy to do a test implementation and submit a PR with it

dmitry-zaets avatar Feb 01 '23 18:02 dmitry-zaets

I am using @formatjs/intl for translation. Adding the language as parameter I can select which language I want to see with the dev server and allows me to switch between language depending on the applications needs. I would be nice to have an integrated i18n library inside react.email, but this works just a fine for us

import { ThemeColor, ThemeImage } from '$supabase/types';
import { defaultTheme } from './components/default.theme';
import Footer, { Socials } from './components/footer';
import Header from './components/header';
import messages from './components/locale.catering.edit';
import { createIntl } from '@formatjs/intl';
import { Container } from '@react-email/container';
import { Head } from '@react-email/head';
import { Html } from '@react-email/html';
import { Link } from '@react-email/link';
import { Preview } from '@react-email/preview';
import { Text } from '@react-email/text';
import React from 'react';

interface EmailData {
  caterer: {
    emailAddress: string;
    fullName: string;
  };
  organizer: {
    emailAddress: string;
    fullName: string;
  };
  meeting: {
    id: string;
    subject: string;
    start: { date: string; time: string };
    end: { date: string; time: string };
    timeZone: string;
  };
  costPlace: string;
  orders: Array<{ name: string; amount: number; time: string }>;
}

export function getSubject(locale: 'nl' | 'en'): string {
  const intl = createIntl({ messages: messages[locale], locale });
  return intl.formatMessage({ id: 'subject' });
}

function Email({
  theme = defaultTheme,
  locale = defaultLanguage,
  data = defaultData,
  socials = defaultSocials,
}: {
  theme: { themes_colors: ThemeColor; themes_images: ThemeImage };
  data: EmailData;
  locale: 'nl' | 'en';
  socials: Socials;
}) {
  const intl = createIntl({ messages: messages[locale], locale });

  const defaultTextStyle: React.CSSProperties = {
    margin: '0px',
    color: theme.themes_colors.base_content_color,
    fontFamily: 'tahoma, geneva, sans-serif',
  };

  const tableStyle: React.CSSProperties = {
    border: '0px solid white',
    borderCollapse: 'collapse',
    textAlign: 'center',
    color: theme.themes_colors.base_content_color,
    fontFamily: 'tahoma, geneva, sans-serif',
  };

  return (
    <Html>
      <Head />
      <Preview>{intl.formatMessage({ id: 'subject' })}</Preview>
      <Container style={{ margin: '0px auto', width: '100%', maxWidth: '700px', background: theme.themes_colors.base_100_color }}>
        <Header title={intl.formatMessage({ id: 'headerTitle' })} subtitle={intl.formatMessage({ id: 'title' })} theme={theme} />
        <Text style={{ marginTop: '20px', ...defaultTextStyle }}>{intl.formatMessage({ id: 'salutation' }, { name: data.organizer.fullName })}</Text>
        <br />
        <Text style={defaultTextStyle}>{intl.formatMessage({ id: 'introduction' })}</Text>
        <br />
        <Text style={defaultTextStyle}>
          <b>{intl.formatMessage({ id: 'employee' })}</b> {data.organizer.fullName} (
          <Link href={'mailto:' + data.organizer.emailAddress} style={{ ...defaultTextStyle, textDecoration: 'underline' }}>
            {data.organizer.emailAddress}
          </Link>
          )
        </Text>
        <Text style={defaultTextStyle}>
          <b>{intl.formatMessage({ id: 'orderNumber' })}</b> {data.meeting.id}
        </Text>
        <Text style={defaultTextStyle}>
          <b>{intl.formatMessage({ id: 'costPlate' })}</b> {data.costPlace}
        </Text>
        <br />
        <table style={{ ...tableStyle, width: '100%' }}>
          <thead>
            <tr style={{ backgroundColor: theme.themes_colors.base_300_color }}>
              <th style={tableStyle}>{intl.formatMessage({ id: 'description' })}</th>
              <th style={tableStyle}>{intl.formatMessage({ id: 'amount' })}</th>
              <th style={tableStyle}>{intl.formatMessage({ id: 'time' })}</th>
            </tr>
          </thead>
          <tbody>
            {data.orders.map((order, index) => (
              <tr key={index} style={{ backgroundColor: index % 2 === 1 ? theme.themes_colors.base_200_color : 'white' }}>
                <td style={tableStyle}>{order.name}</td>
                <td style={tableStyle}>{order.amount}</td>
                <td style={tableStyle}>{order.time}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <br />
        <br />
        <Text style={defaultTextStyle}>{intl.formatMessage({ id: 'greetings' })}</Text>
        <Text style={defaultTextStyle}>{process.env.FROM_NAME || 'DARWIN - Smart Building Software Solutions'}</Text>
        <Footer theme={theme} data={socials} />
      </Container>
    </Html>
  );
}

const defaultLanguage = 'nl';
const defaultData: EmailData = {
  meeting: {
    id: '3aa7c73d-836a-43ae-b66f-dfe7937650b9',
    subject: 'test meeting',
    start: { date: '13-02-2023', time: '08:00' },
    end: { date: '13-02-2023', time: '08:30' },
    timeZone: 'Europe/Amsterdam',
  },
  caterer: {
    fullName: 'Hutten Catering',
    emailAddress: '[email protected]',
  },
  organizer: {
    fullName: 'Bastiaan Verhaar',
    emailAddress: '[email protected]',
  },
  costPlace: '123456',
  orders: [
    { name: 'coffee', amount: 2, time: '08:15' },
    { name: 'Appel gebak', amount: 2, time: '08:15' },
  ],
};


export default Email;

bastiaanv avatar Feb 02 '23 14:02 bastiaanv

Going to close this for now, let me know if you want to reopen

bukinoshita avatar Feb 19 '23 18:02 bukinoshita

I am using @formatjs/intl for translation. Adding the language as parameter I can select which language I want to see with the dev server and allows me to switch between language depending on the applications needs. I would be nice to have an integrated i18n library inside react.email, but this works just a fine for us

import { ThemeColor, ThemeImage } from '$supabase/types';
import { defaultTheme } from './components/default.theme';
import Footer, { Socials } from './components/footer';
import Header from './components/header';
import messages from './components/locale.catering.edit';
import { createIntl } from '@formatjs/intl';
import { Container } from '@react-email/container';
import { Head } from '@react-email/head';
import { Html } from '@react-email/html';
import { Link } from '@react-email/link';
import { Preview } from '@react-email/preview';
import { Text } from '@react-email/text';
import React from 'react';

interface EmailData {
  caterer: {
    emailAddress: string;
    fullName: string;
  };
  organizer: {
    emailAddress: string;
    fullName: string;
  };
  meeting: {
    id: string;
    subject: string;
    start: { date: string; time: string };
    end: { date: string; time: string };
    timeZone: string;
  };
  costPlace: string;
  orders: Array<{ name: string; amount: number; time: string }>;
}

export function getSubject(locale: 'nl' | 'en'): string {
  const intl = createIntl({ messages: messages[locale], locale });
  return intl.formatMessage({ id: 'subject' });
}

function Email({
  theme = defaultTheme,
  locale = defaultLanguage,
  data = defaultData,
  socials = defaultSocials,
}: {
  theme: { themes_colors: ThemeColor; themes_images: ThemeImage };
  data: EmailData;
  locale: 'nl' | 'en';
  socials: Socials;
}) {
  const intl = createIntl({ messages: messages[locale], locale });

  const defaultTextStyle: React.CSSProperties = {
    margin: '0px',
    color: theme.themes_colors.base_content_color,
    fontFamily: 'tahoma, geneva, sans-serif',
  };

  const tableStyle: React.CSSProperties = {
    border: '0px solid white',
    borderCollapse: 'collapse',
    textAlign: 'center',
    color: theme.themes_colors.base_content_color,
    fontFamily: 'tahoma, geneva, sans-serif',
  };

  return (
    <Html>
      <Head />
      <Preview>{intl.formatMessage({ id: 'subject' })}</Preview>
      <Container style={{ margin: '0px auto', width: '100%', maxWidth: '700px', background: theme.themes_colors.base_100_color }}>
        <Header title={intl.formatMessage({ id: 'headerTitle' })} subtitle={intl.formatMessage({ id: 'title' })} theme={theme} />
        <Text style={{ marginTop: '20px', ...defaultTextStyle }}>{intl.formatMessage({ id: 'salutation' }, { name: data.organizer.fullName })}</Text>
        <br />
        <Text style={defaultTextStyle}>{intl.formatMessage({ id: 'introduction' })}</Text>
        <br />
        <Text style={defaultTextStyle}>
          <b>{intl.formatMessage({ id: 'employee' })}</b> {data.organizer.fullName} (
          <Link href={'mailto:' + data.organizer.emailAddress} style={{ ...defaultTextStyle, textDecoration: 'underline' }}>
            {data.organizer.emailAddress}
          </Link>
          )
        </Text>
        <Text style={defaultTextStyle}>
          <b>{intl.formatMessage({ id: 'orderNumber' })}</b> {data.meeting.id}
        </Text>
        <Text style={defaultTextStyle}>
          <b>{intl.formatMessage({ id: 'costPlate' })}</b> {data.costPlace}
        </Text>
        <br />
        <table style={{ ...tableStyle, width: '100%' }}>
          <thead>
            <tr style={{ backgroundColor: theme.themes_colors.base_300_color }}>
              <th style={tableStyle}>{intl.formatMessage({ id: 'description' })}</th>
              <th style={tableStyle}>{intl.formatMessage({ id: 'amount' })}</th>
              <th style={tableStyle}>{intl.formatMessage({ id: 'time' })}</th>
            </tr>
          </thead>
          <tbody>
            {data.orders.map((order, index) => (
              <tr key={index} style={{ backgroundColor: index % 2 === 1 ? theme.themes_colors.base_200_color : 'white' }}>
                <td style={tableStyle}>{order.name}</td>
                <td style={tableStyle}>{order.amount}</td>
                <td style={tableStyle}>{order.time}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <br />
        <br />
        <Text style={defaultTextStyle}>{intl.formatMessage({ id: 'greetings' })}</Text>
        <Text style={defaultTextStyle}>{process.env.FROM_NAME || 'DARWIN - Smart Building Software Solutions'}</Text>
        <Footer theme={theme} data={socials} />
      </Container>
    </Html>
  );
}

const defaultLanguage = 'nl';
const defaultData: EmailData = {
  meeting: {
    id: '3aa7c73d-836a-43ae-b66f-dfe7937650b9',
    subject: 'test meeting',
    start: { date: '13-02-2023', time: '08:00' },
    end: { date: '13-02-2023', time: '08:30' },
    timeZone: 'Europe/Amsterdam',
  },
  caterer: {
    fullName: 'Hutten Catering',
    emailAddress: '[email protected]',
  },
  organizer: {
    fullName: 'Bastiaan Verhaar',
    emailAddress: '[email protected]',
  },
  costPlace: '123456',
  orders: [
    { name: 'coffee', amount: 2, time: '08:15' },
    { name: 'Appel gebak', amount: 2, time: '08:15' },
  ],
};


export default Email;

Can you explain to me how I then implement it, that the respective language is displayed? And where does it get the locale from? I have on my side the local in configContext. And when I want to preview the emails it renders under .react-email and not on my nextjs version. I don't really understand the principle

nyxb avatar Sep 12 '23 11:09 nyxb

@bastiaanv this it was insane, saved my day yesterday.

I had a bit of problem loading the messages, I had to change the target to esnext and add in nextjs.config.js:

 webpack(config) {
    config.experiments = { ...config.experiments, topLevelAwait: true }
    return config
  },

But I rather use the "next-intl": "^2.13.2", (the version matters, maybe you could have problem in the latest one) because I could pass all path in the tree of json like: change_email.preview, and still pass another variables defined as template inside of the message.

It was almost the same approach of formartjs actually, next-intl has it as dependency, so it's just more complete.

import {
  Body, Container, Head, Html, Preview, Tailwind
} from "@react-email/components";
import { createTranslator } from "next-intl";
import React from "react";
import { i18n } from "../.react-email/i18n-config";
import { messages } from "../.react-email/src/messages";
import { IEmailDefault } from "../.react-email/src/types/IEmailDefault";
import Header from "../components/Header";
import { mainStyles } from "../styles/styles";

import theme from '../styles/themeJson.json';

export interface IEmailTemplate extends IEmailDefault {
  verificationCode: string
}

export const EmailTemplate: React.FC<Partial<IEmailTemplate>> = ({
  locale = i18n.defaultLocale,
}) => {
  const intl = createTranslator({ messages: messages[locale], locale });

  return (
    <Tailwind
      config={{
        theme
      }}
    >
      <Head />
      <Html lang={locale} dir="ltr">
        <Header />
        <Preview>
          {intl('change_email.preview', { verificationCode: 0 })}
        </Preview>
        <Body style={mainStyles}>
          <Container className="mx-auto mb-16 w-full">
            {/* just some email stuff  */}
          </Container>
        </Body>
      </Html>
    </Tailwind>

  )
};

export default EmailTemplate;

julioflima avatar Jan 31 '24 13:01 julioflima

@julioflima can you please show what's in your import { i18n } from "../.react-email/i18n-config"; file? There is absolutely not much about localization emails =( I just want to understand how you pass and detect locale.

and the second question. Don't you have errors using both next-intl and tailwind?

AlinaKorzun avatar Apr 01 '24 15:04 AlinaKorzun

Working with react-intl FormattedMessage component does not work either

dreinon avatar May 16 '24 14:05 dreinon

I have been working on this enhancement to the boilerplate to include internationalization, feel free to use it while i learn how to upgrade the resend's react-email starter: Screenshot 2024-07-19 at 10 33 31 PM

https://github.com/Pra3t0r5/react-email-intl-starter

It relies on a script to compile email variants based on available internationalization variants. If you don't take into account some standard utils to handle the files, it doesn't add any dependencies.

This code does not includes localization, since we are handling that from our backend data (by knowing the preferred language of each of our users).

As far i know, that is not a goal of react-email either, since you have to handle locales server-side, before actually sending the email.

Feel free to thinker with it to include that feature. I will do it eventually.

Pra3t0r5 avatar Jul 20 '24 01:07 Pra3t0r5