LayerX エンジニアブログ

LayerX の エンジニアブログです。

配信メールのテンプレート管理をSendGirdからgo:embedを用いた方法に変更した話

みなさまこんにちはMDM事業部で金融DXに日々精進している @MasashiSalvadorです。 今回はメールのテンプレート管理法を変更しDX(Developer eXperience)を改善した話をします。

何をやったのか?

  • 顧客へ自動配信するメールのテンプレートの管理をSendGridから自社のGithubリポジトリに移行した。
  • 移行に際し Go 1.16から導入された go:embed (https://pkg.go.dev/embed) 機能を用いた。

お客様にサービスを利用していただくために、メールの配信機能をどんなサービスでも実装するかと思います。MDM事業部の開発しているサービス(公開されているものだと、プロ投資家の方々に不動産案件情報を定期的にお届けするあさどれ不動産 、別のサービスも絶賛開発中です)では仮登録完了をお知らせするメール、登録完了をお知らせするメール、ワンタイムトークンによる認証に用いるトークンをお知らせするメールなどが実装されています。

メールサーバを自前で立てて管理するのは骨が折れるので、SendGrid を利用しています。SendGridは開封・クリック計測の機能、メールの開封をwebhookで通知する機能、顧客リストを管理する機能、キャンペーンメールを予約する機能などがあり、痒いところに手が届く感じに機能が揃っています。

メールのテンプレート機能もその一環で、ユーザはGUIを利用してSendGrid上でHTMLメールを編集しテスト/実配信を行うことが可能です。 画像やボタンの配置なども自由にできるのでGUIはGUIで利点があり、それなりに使い勝手が良いです。

SendGridのDynamicTemplateの編集画面

ところが、このテンプレート機能、非常に便利なのですが、当社のユースケースに合わなかったのです...😿。

なぜやったの?

  1. 開発、ステージング、本番の各環境ごとにテンプレートIDもテンプレートの実体も異なるので、各環境への反映&テストに手間がかかる。
  2. 現状のフェーズではHTMLメールをそこまでリッチに組まないためGUI上で編集できるという機能は(便利だが)オーバースペックだった

MDMのシステムでは環境ごとにSendGridのアカウントを切り分けています(主契約の下に子アカウントを作成した上で、2FAを設定する必要があるため各開発者 * 環境の数分アカウントを作成しています)。 SendGridのテンプレートや、テンプレートを利用するためのAPI Keyは各環境ごとに分かれていました。つまり、開発から本番のメールテンプレートを読み込もうとするとSendGridのAPIは40xのエラーを返します。逆もまたしかりです。

この切り分け自体は妥当なものなのですが、下記の問題が起こっていました。 1. 愚直にやるとメールのテンプレートをソースコード管理できない。 2. 各環境ごとに異なるテンプレートのIDを管理しないといけない(環境変数や設定ファイルに都度設定しないといけない) 3. 文言の変更 → 各環境への反映 → テスト(やPdMの確認)→ 再修正 などの改善プロセスを回すたびにGUIから操作する必要がある。

GUIベースでテンプレートをいじる場合の変更プロセス(基本形)

1.はリリースの際のヒューマンエラーを引き起こし得ます(コピペミスなど)、コピペミスがないかエンジニアはドキドキしないといけません(個人的にはあんまり好きじゃない) 2は1つ新しいメールを追加する度に3つの設定値が増えることになり、チリツモで管理が煩雑化します。 特に3は、テンプレートに埋め込む変数の変更もセットで考えると、ソースコード→GUIの往復が増えてしまい、開発者の集中リソースを削ります。

MDMでは設定値の管理にtomlを用いており、実際は下記のように環境ごとの設定ファイルを管理し、そこにテンプレートのIDを記載しています。

[SENDGRID]
    [SENDGRID.TEMPLATE_ID]
    SIGNUP="d-XXXXXXXXXXXXXXXXXXX"
    INVITATION="d-YYYYYYYYYYYYYYYYYYY"
    ...
    ...
    ADD_HOGEHOGE="d-ZZZZZZZZZZZZ"

configはGitHub - spf13/viper: Go configuration with fangsで読み込んでいます。viper導入前はすべて .env ファイルで管理していたのですが、環境変数が増えすぎ問題が発生してつらかったので開発の途上で変更しました。このお話はまた今度...

どんな解決策があるの?

  1. テンプレートをソースコード管理し、SendGridのテンプレート更新のAPIを用いてCD経由で設定する
  2. テンプレート機能を使うのをやめて、Goのテンプレートでメールの本文を作成する

1も一度は検討しましたが、2を選択しました。 MDMでは各種インフラ設定はterraformで管理され、SREだけでなく開発者もterraformを普通に書く文化があるので、特段問題なく導入できるとは思ったのですが、 メールの文言を少し変更してテストするのに都度terraform applyするような世界線は(かっこいいかもしれませんが)あんまり開発者体験が良いとは思えませんでした。

今回は2.を選択し、Go1.16から導入されたembed機能を用いてテンプレート自体をGoのバイナリに埋めこんで利用することにしました。

実際にどうgo:embedを利用したか

Go 1.16 がリリースされて半年以上が経過していますので、embed機能はすでに各社実運用に載せられているのではないでしょうか。 普段からいろいろな記事を参考にさせていただいていますが、フューチャーさんの技術ブログがとてもわかりやすかったです。 future-architect.github.io

go:embedを用いることで画像、設定ファイル、テキストなど、多様なファイルをバイナリに含めることができ、Goのコード内から簡単に読み出すことができるようになります(embedしない場合に比べて記述量も減らすことができる)、ビルド成果物がシングルバイナリになるので、ただコピーして配布するだけで様々な場所で動かすことができるというGoのポータビリティ面での利点を活かすことができます。

余談ですが、静的ファイルのバイナリへの埋め込みはその昔はgo-bindataがあり、go-bindataの作者がGithubアカウントを突然削除するなどがあり、go-bindataを使っていた部分をgo-assetsに置き換える - PartyIXの記事のようにgo-assetsに置き換えるなどやったなぁなどと改めて懐かしくなりました。

実際のファイル構成とテンプレートの読み込みに関しては

main.go
- lib
    - mail
         - templates/signup_mail.tmpl
                    /reset_password.tmpl
                    /common/footer.tmpl   // 共通フッター
                    /titles.json
         - content.go   // ParseFSでテンプレートを読み込む部分
         - sendgrid.go // SendGridのAPIを叩く部分
         - template_variable.go  // 各テンプレートに埋め込む変数の構造体
 ....

content.go

package mail

import (
    "bytes"
    "context"
    "embed"
    "fmt"
    "text/template"

    "github.com/sendgrid/sendgrid-go"
    "github.com/sendgrid/sendgrid-go/helpers/mail"

    "github.com/mitsui-x/alterna-api/src/lib/env"
    "github.com/mitsui-x/alterna-api/src/lib/log"
)

//go:embed templates/*
var static embed.FS

func GenerateTemplateByTemplateName(tName string, input interface{}) (string, error) {
    templatePath := fmt.Sprintf("templates/%s", tName)
    tmpl, err := template.ParseFS(static, templatePath)
    if err != nil {
        return "", fmt.Errorf("failed to parse template path %s, %w", templatePath, err)
    }

    buf := &bytes.Buffer{}
    err = tmpl.Execute(buf, input)
    if err != nil {
        return "", fmt.Errorf("failed to execute template with input = %v, tName %s, %w", input, tName, err)
    }

        /* 共通部分のハンドリング、ブログ用にシンプルに変えています */
    footerBuf := &bytes.Buffer{}
    tmpl, err = template.ParseFS(static, "templates/footer.tmpl")
    if err != nil {
        return "", fmt.Errorf("failed to parse footer, %w", err)
    }
    err = tmpl.Execute(footerBuf, nil)
    if err != nil {
        return "", fmt.Errorf("failed to execute footer, %w", err)
    }

    return fmt.Sprintf("%s\n%s", buf.String(), footerBuf.String()), nil
}

func Send(ctx context.Context, subject, toAddress, toName string, plaintextContent, htmlContent string) error {
        /* 一部省略 */ 
    apiKey := env.GetVar("SENDGRID.API_KEY")
    client := sendgrid.NewSendClient(apiKey)

    fromEmail := mail.NewEmail(fromName, fromEmailAddress)
    toEmail := mail.NewEmail(toName, toAddress)
        // SingleEmai https://github.com/sendgrid/sendgrid-go のREADMEの先頭に載っている最もシンプルなやり方でメールを送る
    message := mail.NewSingleEmail(fromEmail, subject, toEmail, plaintextContent, htmlContent)
    response, err := client.Send(message)
    if err != nil {
                /* エラー処理 */
    }
    if response.StatusCode >= 300 {
                 /* エラー処理 */
        return err
    }

    return nil
}

signup_mail.tmpl

{{ .LastName }} {{ .FirstName }} 様<br>
<br>
この度はXXXにご登録いただき、誠にありがとうございます。<br>
... 略 ... 

テンプレートに埋め込む変数を定める構造体

package mail

type UserSignUpAtributes struct {
    LastName       string
    FirstName      string
}

実際にメールを送信するときに下記のように呼び出します。

input := &UserSignUpAtributes{ LastName: "MDM", FirstName "太郎" }
content := maillib.GenerateTemplateByTemplateName("signup_mail.tmpl", input) // 本文生成
err = maillib.Send(ctx, subject, toAddress, toName, content, content) // メール送信処理

これにより、メールのテンプレート管理をGithubに寄せることができ、各環境へのリリースも通常のソースコードと同様に扱うことができるようになりました。

おわりに

金融DXを実現するために様々な機能の開発をする必要がありますが、全てをゼロから作り上げるわけにはいかないのも事実で、セキュリティ面や開発者体験なども含めてSaaSを選定し使い倒さねばなりません。人数の少ないフェーズでは小さな認知負荷の削減やCI/CDまわりの開発者体験の向上が全体の生産性を大きく上げることにもつながるため、今後も小さな改善を繰り返していきたいと考えています。

こういった細かな改善も含め、MDMではやりたいことが山のようにあります。絶賛仲間を募集中です!

herp.careers

meetyでカジュアルに色々話させていただくことも出来ますので、もしよければごらんくださいmm

meety.net

ぜひ一緒に、眠れる銭をアクティベイトしましょう!