実践 React Emailを使ったHTMLメールの開発・運用

こんにちは、JX通信社でシニアエンジニアをしているSirosuzumeです。

JX通信社の「FASTALERT」には、ユーザーが事前に設定した地域で発生した災害情報を、HTMLメールで受信する機能があります。

HTMLメールには以下のような特徴があり、普段のフロントエンド開発の知識が役に立たないことが多いです。

  • Gmail、Outlook、その他のメーラーでは、使用できるタグやCSSに違いがある
  • CSS 自体が使えないクライアントも存在する

ベストプラクティス的なものを探しても、「tableタグを使ってレイアウトを頑張って調整しよう!」といった辛い現実が待ち受けています。

今回、HTMLメールのコーディングを担当することになり、もっと簡単な方法はないかと探していたところReact Emailというライブラリと巡り合いました。

React Emailは、名前の通りReactを使ってHTMLメールを作成するためのライブラリです。 用意されたコンポーネントを使うことで、Gmail、Apple Mail、Outlookといった主要なメールソフトに対応したHTMLメールを作成できます。

この記事ではReact Emailがどのようなものであるのか、どのようにプロダクトで運用したかを紹介します。

React Emailの機能

簡単にいうと、HTMLメール向けのReactコンポーネントと、そのNext.js製の開発サーバーを提供するライブラリです。

Getting Startedに従ってセットアップを行うと、以下の主要3パッケージを含む開発環境が作成されます。

  • @react-email/components
    • HTMLメール専用に作られたコンポーネント。例えばSection、Rowといったコンポーネントはレンダリングするとtableタグに変換される
  • @react-email/tailwind
    • Tailwindで指定されたクラスをHTMLメール用にインラインスタイルに変換するライブラリ
      • 例としてtext-basestyle=”font-size:1rem;line-height:1.5rem;”に変換される
      • remをpxに自動変換するといった機能はサポート対象外のため注意
        • remを使う場合は、pxに変換してから指定する必要がある
  • react-email
    • Next.jsで作られた開発サーバー
    • いくつかの企業製HTMLメールのexample等が最初から格納されている
    • resendを使用して、作成したメールを任意のアドレスに送る機能等がついている

Example

import { Body, Column, Container, Row, Text } from "@react-email/components";
import { MyTailwind } from "../tailwind";

export type ExampleForBlogEmailProps = {
  id: string;
  title: string;
  description: string;
};

export const ExampleForBlogEmail = ({
  title,
  description,
}: ExampleForBlogEmailProps) => {
  return (
    {/* MyTailwindはremをpxに上書きしたreact-email/componentのTailwindのコンポーネント */}
    <MyTailwind>
      <Body className="bg-slate-200 my-auto mx-auto font-sans px-2 pt-4 pb-16 text-black">
        <Container className="mx-auto py-8 px-4 max-w-[592px] bg-white rounded-lg">
          <Row>
            <Column>
              <Text className="text-base font-bold mb-0">{title}</Text>
              <Text className="text-sm mt-1">{description}</Text>
            </Column>
          </Row>
        </Container>
      </Body>
    </MyTailwind>
  );
};

これをHTMLで出力したものを見ると、tableタグとstyle属性変換されている様子を見ることができます。

<body style="background-color:rgb(226,232,240);margin-top:auto;margin-bottom:auto;margin-left:auto;margin-right:auto;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto, &quot;Helvetica Neue&quot;, Arial, &quot;Noto Sans&quot;, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;padding-left:8px;padding-right:8px;padding-top:16px;padding-bottom:64px;color:rgb(0,0,0)">
  <table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:592px;margin-left:auto;margin-right:auto;padding-top:32px;padding-bottom:32px;padding-left:16px;padding-right:16px;background-color:rgb(255,255,255);border-radius:8px">
    <tbody>
      <tr style="width:100%">
        <td>
          <table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation">
            <tbody style="width:100%">
              <tr style="width:100%">
                <td data-id="__react-email-column">
                  <p style="font-size:16px;line-height:24px;margin:16px 0;font-weight:700;margin-bottom:0px">FASTALERT</p>
                  <p style="font-size:14px;line-height:20px;margin:16px 0;margin-top:4px">FASTALERTはSNS等のビッグデータをAIで即時解析し、防災DXや施設管理に活用することができます。リアルタイムな速報や、過去の災害のデータセットをAPIやウィジェットで提供しています。</p>
                </td>
              </tr>
            </tbody>
          </table>
        </td>
      </tr>
    </tbody>
  </table>
</body>

Node環境以外での運用を考える (Go html/templateの例)

FASTALERT開発チームでは、Go言語でHTMLメールのサーバーを開発し、html/templateのライブラリを使用してHTMLメールを生成しています。

そのため、React Emailで作成したコンポーネントのコードをそのまま使用することはできません。 作成したコンポーネントをhtml/templateに対応した形で出力する必要がありました。

またHTMLメールのデザインが複雑化すると、より細かい粒度のコンポーネントに分割して管理する必要があります。

その場合react-emailの提供する開発サーバーを用いるよりも、Storybookを使用してコンポーネントを管理する方が開発体験が良いのではないかと考えました。

運用の基本方針

html/templateでは埋め込みたい値を{{ value }} のように表現します。

まずは、React Emailで作成したコンポーネントをhtml/templateに変換する方法を考えます。

  • メールのデザイン時はpropsの値をそのまま出力する
  • コンポーネントはContainer/PresenterパターンのPresenterに相当するものを作成し、渡されたPropsをそのまま出力する形で設計する
    • 値の表示に計算が必要名箇所は、極力Go側で計算し、結果を文字列で渡す
  • export時はhtml/templateに対応する形に変換し出力する
  • html/templateのrangeやifに対応する

export時の出し分け方法の検討

html/templateに対応する形に変換するため、propsの値によって出し分けを行います。以下は、propsの値によって出し分けを行う関数の例です。

export function renderValue(
  /** デザイン時に出力する値 */
  value: string,
  /** export時に出力するPropsの名前 */
  valueName: string,
  /** exportかどうか */
  isExported: boolean,
): string {
  return isExported ? `{{ ${valueName} }}` : value;
}

// 使用例 isExportedをバケツリレーする
const html = render(<FastalertEmail isExported />)

ifや繰り返しへの対応

繰り返しやifの場合も、同様にpropsの値によって出し分けを行います。以下は繰り返しの場合の例です。 childrenには、繰り返しの中で出力するコンポーネントを渡します。

import React from "react";

export type ChildTemplateProps<T> = {
  value: T;
  arrayName: string;
  itemName: string;
  index: number;
  isExported: boolean;
};

export type GoRangeBlockTemplateProps<T> = {
  arrayName: string;
  itemName: string;
  array: T[];
  children: (props: ChildTemplateProps<T>) => React.ReactNode;
  isExported: boolean;
};

export const GoRangeBlockTemplate = <T,>({
  array,
  arrayName,
  itemName,
  children,
  isExported,
}: GoRangeBlockTemplateProps<T>) => {
  // export時は1つだけ出力する
  const arrayForRendering = isExported ? [array[0]] : array;
  return (
    <>
      {isExported && `{{ range $index, ${itemName} := .${arrayName} }}`}
      {arrayForRendering.map((value, index) => (
        <React.Fragment key={JSON.stringify(value)}>
          {/* childrenに空の配列などが入ることがあるため、typeofでfunctionの判定をいれる */}
          {typeof children === "function" &&
            children({ value, itemName, index, isExported, arrayName })}
        </React.Fragment>
      ))}
      {isExported && "{{ end }}"}
    </>
  );
};
// 使い方
<Section>
  <GoRangeBlockTemplate
    array={productions}
    arrayName="Productions"
    itemName="Production"
    isExported={isExported}
  >
    {({ itemName, value: { title, description } }) => (
      <Row>
        <Column>
          <Text className="text-md font-bold mb-0">
            {renderValue(title, `${itemName}.Title`, isExported)}
          </Text>
          <Text className="text-sm mt-1">
            {renderValue(
              description,
              `${itemName}.Description`,
              isExported,
            )}
          </Text>
        </Column>
      </Row>
    )}
  </GoRangeBlockTemplate>
</Section>

上記のようなコンポーネントを作成することで、html/templateに対応する形に変換することができます。

Storybookでの確認

Storybookを使用すれば、通常のフロントエンド開発と同様にコンポーネントのデザインを確認することができます。また、@react-email/componentsが提供するrender関数を使用することで、HTMLメールの出力結果を確認することもできます。以下はHTMLメールの出力結果を確認するためのStorybookの設定例です。

import type { Meta, StoryObj } from "@storybook/react";

import { render } from "@react-email/components";
import { ExampleForBlogEmail } from ".";
import { generateDefaultExampleForBlogProps } from "./index.mock";

export default {
  component: ExampleForBlogEmail,
} as Meta<typeof ExampleForBlogEmail>;

export const Primary: StoryObj<typeof ExampleForBlogEmail> = {
  render: (props) => <ExampleForBlogEmail {...props} />,
  args: {
    ...generateDefaultExampleForBlogProps(),
  },
};

export const Exported: StoryObj<typeof ExampleForBlogEmail> = {
  // pretty: trueを指定することで、インデントを整えて出力できる
  render: (props) => (
    <pre>
      {render(<ExampleForBlogEmail {...props} isExported />, {
        pretty: true,
      })}
    </pre>
  ),
  args: {
    ...generateDefaultExampleForBlogProps(),
  },
};

render関数はstringを返すため、jestやvitestと言ったテストフレームワークを使用して、出力結果をテストすることもできます。

exportの方法

前項に出てきたrender関数を使用してstringに変換したものを、ファイルに出力するスクリプトを組んで実行します。 以下は./dist/example.gohtmlに出力するスクリプトの例です。

import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { render } from "@react-email/components";
import { ExampleForBlogEmail } from "./_components/example-for-blog";
import { generateDefaultExampleForBlogProps } from "./_components/example-for-blog/index.mock";

if (!fs.existsSync("dist")) {
  fs.mkdirSync("dist");
}

const gohtml = render(
  <ExampleForBlogEmail {...generateDefaultExampleForBlogProps()} isExported />,
  {},
);

fs.writeFile(path.join("dist", "example.gohtml"), gohtml, (err) => {
  if (err) {
    console.error(err);
    process.exit(1);
  }
  console.log("File has been created");
});

実行時はtsxライブラリを使うと、簡単に実行できて便利でした。

npx tsx ./export.ts
# またはpackage.jsonにスクリプトを追加して実行する
pnpm run export

まとめ

React Emailを使用することで、HTMLメールのコーディングや管理が簡単になりました。Storybookの導入により、より小さな単位でコンポーネントを管理することができ、開発効率が向上しました。 今後、FASTALERTではメールによる各種情報の配信を増やしていく予定のため、React Emailの活用を進めていきます。