📬

react email を本番環境に導入した

2023/10/23に公開

株式会社CHILLNNという京都のスタートアップでエンジニアをしている KIO です。

HTMLメールの作成に react email を導入しました。
非常に使いやすかったので振り返りがてら記事を書きました。

はじめに

これまで弊社では mjmlを用いてHTMLメールの実装を行なっていました。
mjmlはオンラインエディタや拡張機能などを用意してくれてはいますが、
開発環境での同期的なプレビューができなかったり
独自構文を調べながらの実装がややめんどくさかったり......
現代の拡張機能を最大限活かしたWebフロントエンド開発と比べるとどうしても辛い部分がありました。

HTMLメールをWebフロントエンド開発と同じように快適に実装したい!!

react email で実現できました。

導入した理由は主に👇2つです。

  • react & Typescriptで実装できる(Webフロントエンド開発と同じ環境)
  • プレビュー画面からメールクライアントに送信できる

弊社の場合、リッチなUIを必要としていなかったのでDX向上を重視した選定ができました。

TMI: react email(& resend)の開発者はおそらく日本の方

以降、この記事では導入する際に 辿るであろう流れを簡単にまとめます。
(詳細についてはドキュメントを見ていただいた方が早い & 確実です!)

react email てなに ?

ReactとTypeScriptを使って美しいメールを作成するための、高品質でスタイルのないコンポーネント集です。
ダークモードをサポートし、レスポンシブメールのコーディングの手間を軽減します。
(公式README.mdより引用、抄訳)

この説明の通りなのですが、用意されているコンポーネントを組み合わせていくことで、
簡単にHTMLメールを実装できるツールです。

加えて、

  • プレビュー画面を自動生成
  • プレビュー画面で確認しながら実装
  • プレビュー画面から実際に送信して最終確認

までできます。

開発元である resendはメール送信プラットフォームのサービスを提供しており、実装から本番環境での送信までシームレスな運用も行えます。

開発環境構築

プロジェクトへの追加は非常に簡単です。
自動セットアップの場合はコマンドを実行すれば導入が完了です。

提供されている create-emailを実行することでよしなにやってくれます。

npx create-email@latest

プロジェクトにreact-email-starterディレクトリが作成されます。

react-email-starterの中身
react-email-starter
├── emails
│   ├── notion-magic-link.tsx
│   ├── plaid-verify-identity.tsx
│   ├── stripe-welcome.tsx
│   └── vercel-invite-user.tsx
├── package.json
├── readme.md
└── static
    ├── notion-logo.png
    ├── plaid-logo.png
    ├── plaid.png
    ├── stripe-logo.png
    ├── vercel-arrow.png
    ├── vercel-logo.png
    ├── vercel-team.png

emails以下にメールを実装していきます。
ディレクトリ名はオプションで変更可能です。(詳細)

react-email-starterディレクトリで開発サーバを立ち上げると、確認しながらの実装や実際に送信することが可能になります。

写真右上の「Send」ボタンを押してメールアドレスを入力するだけで実際のメールクライアントで確認できます。便利!!


自動で生成されるプレビュー画面

提供されているコンポーネントと実装例

実装には提供されているコンポーネントを使用します。

提供されているコンポーネント一覧
パッケージ名 役割
@react-email/components 下記全てのコンポーネントを一括でダウンロードできる。
@react-email/html コンポーネントをhtmlタグでラップするコンポーネント。言語とコンテンツの方向を指定できる。(左読み、右読み)
@react-email/head メタデータやスタイルの定義をラップする。
@react-email/button ボタン(のように見えるリンク)。メールの世界においてボタンと呼んでいるものは厳密には<a>タグで実装されている。
@react-email/column メール内のコンテンツを縦に区切るコンポーネント。<Row>コンポーネントと組み合わせて使用する。
@react-email/row メール内のコンテンツを横に区切るコンポーネント。
@react-email/container ラップしたコンテンツを中央に配置するコンポーネント。
@react-email/font フォントを設定するコンポーネント。Headコンポーネントでラップする。
@react-email/heading 見出しとなるテキストを定義するコンポーネント。
@react-email/hr コンテンツを区切る仕切り、線。
@react-email/img 画像。.png, .gif, .jpgは指定できる。svgはメールクライアントによっては対応されていない。
@react-email/link ウェブページや電子メールアドレスなど、URLで指定できるあらゆるものへのハイパーリンク。
@react-email/markdown マークダウンを定義できるコンポーネント。
@react-email/preview 受信者の受信トレイに表示されるプレビューテキストが定義できるコンポーネント。にプレビューテキストを通して、メールを開かずに内容を知らせることができる。
@react-email/section <Row><Colomn>のコンテンツや<Text>などをラップし、まとめてスタイリングできるようにするコンポーネント
@react-email/tailwind ラップしたコンポーネントで Tailwind CSSを使えるようにするコンポーネント。
@react-email/text 空白で区切られたテキストを定義できるコンポーネント。

必要なコンポーネントをインストール、インポートして実装していきます。

上記写真「自動で生成されるプレビュー画面」にあるvercel-invite-userの実装例は以下になります。
実装方法はこれを見ることで理解できると思います!

公式のサンプル。実装例。
vercel-invite-user.tsx
import {
  Body,
  Button,
  Column,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Img,
  Link,
  Preview,
  Row,
  Section,
  Tailwind,
  Text,
} from '@react-email/components';
import * as React from 'react';

interface VercelInviteUserEmailProps {
  username?: string;
  userImage?: string;
  invitedByUsername?: string;
  invitedByEmail?: string;
  teamName?: string;
  teamImage?: string;
  inviteLink?: string;
  inviteFromIp?: string;
  inviteFromLocation?: string;
}

const baseUrl = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : '';

export const VercelInviteUserEmail = ({
  username = 'zenorocha',
  userImage = `${baseUrl}/static/vercel-user.png`,
  invitedByUsername = 'bukinoshita',
  invitedByEmail = 'bukinoshita@example.com',
  teamName = 'My Project',
  teamImage = `${baseUrl}/static/vercel-team.png`,
  inviteLink = 'https://vercel.com/teams/invite/foo',
  inviteFromIp = '204.13.186.218',
  inviteFromLocation = 'São Paulo, Brazil',
}: VercelInviteUserEmailProps) => {
  const previewText = `Join ${invitedByUsername} on Vercel`;

  return (
    <Html>
      <Head />
      <Preview>{previewText}</Preview>
      <Tailwind>
        <Body className="bg-white my-auto mx-auto font-sans">
          <Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] w-[465px]">
            <Section className="mt-[32px]">
              <Img
                src={`${baseUrl}/static/vercel-logo.png`}
                width="40"
                height="37"
                alt="Vercel"
                className="my-0 mx-auto"
              />
            </Section>
            <Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
              Join <strong>{teamName}</strong> on <strong>Vercel</strong>
            </Heading>
            <Text className="text-black text-[14px] leading-[24px]">
              Hello {username},
            </Text>
            <Text className="text-black text-[14px] leading-[24px]">
              <strong>bukinoshita</strong> (
              <Link
                href={`mailto:${invitedByEmail}`}
                className="text-blue-600 no-underline"
              >
                {invitedByEmail}
              </Link>
              ) has invited you to the <strong>{teamName}</strong> team on{' '}
              <strong>Vercel</strong>.
            </Text>
            <Section>
              <Row>
                <Column align="right">
                  <Img className="rounded-full" src={userImage} width="64" height="64" />
                </Column>
                <Column align="center">
                  <Img
                    src={`${baseUrl}/static/vercel-arrow.png`}
                    width="12"
                    height="9"
                    alt="invited you to"
                  />
                </Column>
                <Column align="left">
                  <Img className="rounded-full" src={teamImage} width="64" height="64" />
                </Column>
              </Row>
            </Section>
            <Section className="text-center mt-[32px] mb-[32px]">
              <Button
                pX={20}
                pY={12}
                className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center"
                href={inviteLink}
              >
                Join the team
              </Button>
            </Section>
            <Text className="text-black text-[14px] leading-[24px]">
              or copy and paste this URL into your browser:{' '}
              <Link
                href={inviteLink}
                className="text-blue-600 no-underline"
              >
                {inviteLink}
              </Link>
            </Text>
            <Hr className="border border-solid border-[#eaeaea] my-[26px] mx-0 w-full" />
            <Text className="text-[#666666] text-[12px] leading-[24px]">
              This invitation was intended for{' '}
              <span className="text-black">{username} </span>.This invite was sent from{' '}
              <span className="text-black">{inviteFromIp}</span> located in{' '}
              <span className="text-black">{inviteFromLocation}</span>. If you were not
              expecting this invitation, you can ignore this email. If you are
              concerned about your account's safety, please reply to this email to
              get in touch with us.
            </Text>
          </Container>
        </Body>
      </Tailwind>
    </Html>
  );
};

export default VercelInviteUserEmail;

インラインでのスタイリング

後述する Tailwind CSS によるスタイリングを使わない場合、インラインスタイルで記述します。

公式のサンプル
import {
  Body,
  Container,
  Head,
  Heading,
  Html,
  Img,
  Link,
  Preview,
  Text,
} from '@react-email/components';
import * as React from 'react';

interface NotionMagicLinkEmailProps {
  loginCode?: string;
}

const baseUrl = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : '';

export const NotionMagicLinkEmail = ({
  loginCode = 'sparo-ndigo-amurt-secan',
}: NotionMagicLinkEmailProps) => (
  <Html>
    <Head />
    <Preview>Log in with this magic link</Preview>
    <Body style={main}>
      <Container style={container}>
        <Heading style={h1}>Login</Heading>
        <Link
          href="https://notion.so"
          target="_blank"
          style={{
            ...link,
            display: 'block',
            marginBottom: '16px',
          }}
        >
          Click here to log in with this magic link
        </Link>
        <Text style={{ ...text, marginBottom: '14px' }}>
          Or, copy and paste this temporary login code:
        </Text>
        <code style={code}>{loginCode}</code>
        <Text
          style={{
            ...text,
            color: '#ababab',
            marginTop: '14px',
            marginBottom: '16px',
          }}
        >
          If you didn&apos;t try to login, you can safely ignore this email.
        </Text>
        <Text
          style={{
            ...text,
            color: '#ababab',
            marginTop: '12px',
            marginBottom: '38px',
          }}
        >
          Hint: You can set a permanent password in Settings & members → My
          account.
        </Text>
        <Img
          src={`${baseUrl}/static/notion-logo.png`}
          width="32"
          height="32"
          alt="Notion's Logo"
        />
        <Text style={footer}>
          <Link
            href="https://notion.so"
            target="_blank"
            style={{ ...link, color: '#898989' }}
          >
            Notion.so
          </Link>
          , the all-in-one-workspace
          <br />
          for your notes, tasks, wikis, and databases.
        </Text>
      </Container>
    </Body>
  </Html>
);

export default NotionMagicLinkEmail;

const main = {
  backgroundColor: '#ffffff',
};

const container = {
  paddingLeft: '12px',
  paddingRight: '12px',
  margin: '0 auto',
};

const h1 = {
  color: '#333',
  fontFamily:
    "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
  fontSize: '24px',
  fontWeight: 'bold',
  margin: '40px 0',
  padding: '0',
};

const link = {
  color: '#2754C5',
  fontFamily:
    "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
  fontSize: '14px',
  textDecoration: 'underline',
};

const text = {
  color: '#333',
  fontFamily:
    "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
  fontSize: '14px',
  margin: '24px 0',
};

const footer = {
  color: '#898989',
  fontFamily:
    "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
  fontSize: '12px',
  lineHeight: '22px',
  marginTop: '12px',
  marginBottom: '24px',
};

const code = {
  display: 'inline-block',
  padding: '16px 4.5%',
  width: '90.5%',
  backgroundColor: '#f4f4f4',
  borderRadius: '5px',
  border: '1px solid #eee',
  color: '#333',
};

https://www.caniemail.com/

Tailwind CSSを用いたスタイリング

react emailでは、tailwindcssを用いてのスタイリングができます。
https://react.email/docs/components/tailwind

<Tailwind />コンポーネントのconfigプロパティに設定を渡す形でカスタマイズ可能です。

公式のサンプル
import { Button } from '@react-email/button';
import { Tailwind } from '@react-email/tailwind';

const Email = () => {
  return (
    <Tailwind
      config={{
        theme: {
          extend: {
            colors: {
              brand: '#007291',
            },
          },
        },
      }}
    >
      <Button
        href="https://example.com"
        className="bg-brand px-3 py-2 font-medium leading-4 text-white"
      >
        Click me
      </Button>
    </Tailwind>
  );
};

HTMLメール(文字列)に変換する

react emailでは、render()が提供されおり、Reactで定義したコンポーネントをHTMLメール(文字列)に変換できます。

使い方は非常に簡単で下記の通り第一引数に変換したいコンポーネントを渡すだけです。

const html = render(<Reactコンポーネント />)
その他に指定できるオプション

prettyplainTextのオプションが用意されています。

prettyを指定すると、人間にとって見やすい形でHTML文字列を出力してくれます。

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="en">
  <p style="font-size:14px;line-height:24px;margin:16px 0">Some title</p>
  <hr style="width:100%;border:none;border-top:1px solid #eaeaea" />
  <a href="https://example.com" target="_blank" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;padding:0px 0px">
    <span>
      <!--[if mso]>
        <i style="letter-spacing: undefinedpx;mso-font-width:-100%;mso-text-raise:0" hidden>&nbsp;</i>
      <![endif]-->
    </span>
    <span style="max-width:100%;display:inline-block;line-height:120%;text-decoration:none;text-transform:none;mso-padding-alt:0px;mso-text-raise:0">Click me</span>
    <span>
      <!--[if mso]>
        <i style="letter-spacing: undefinedpx;mso-font-width:-100%" hidden>&nbsp;</i>
      <![endif]-->
    </span>
  </a>
</html>

plainTextを指定すると、タグやスタイリングなどの情報を除けた文字列が出力されます。

Some title

---

Click me [https://example.com]

公式では、Resend,Nodemailer, SendGrid, Postmark, AWS SES, MailerSend, Plunk の実装例が公開されています。(詳細)

AWS SES公式実装例

下記2つのパッケージをインストールします。

npm install @react-email/render @aws-sdk/client-ses
NodeJS
import { render } from '@react-email/render';
import { SES } from '@aws-sdk/client-ses';
import { Email } from './email';

const ses = new SES({ region: process.env.AWS_SES_REGION })

const emailHtml = render(Email({ url:"https://example.com" }));

const params = {
  Source: 'hoge@example.com',
  Destination: {
    ToAddresses: ['huga@example.com'],
  },
  Message: {
    Body: {
      Html: {
        Charset: 'UTF-8',
        Data: emailHtml,
      },
    },
    Subject: {
      Charset: 'UTF-8',
      Data: 'hello world',
    },
  },
};

await ses.sendEmail(params);

おわりに

React Email は、メール作成を簡単かつ効率的に行える優れたツールです。この記事では、React Email の導入から基本的な使用方法、コンポーネントの実装例、Tailwind CSS を使用したスタイリング、HTMLメールへの変換について簡単にまとめました。

非常に使いやすいですが、まだベータ版のため本番環境に導入するかについては、検討のうえ決定していただければと思います。

最後まで読んでいただき、ありがとうございました!

CHILLNN Tech Blog

Discussion