こんにちは、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-base
はstyle=”font-size:1rem;line-height:1.5rem;”
に変換される - remをpxに自動変換するといった機能はサポート対象外のため注意
- remを使う場合は、pxに変換してから指定する必要がある
- 例として
- Tailwindで指定されたクラスをHTMLメール用にインラインスタイルに変換するライブラリ
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, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";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の活用を進めていきます。