Cloud RunからSendGridでメールを送って、配信結果をCloud Loggingに残してみた

Cloud RunからSendGridでメールを送って、配信結果をCloud Loggingに残してみた

Google CloudにはSESに当たるサービスがないため、Cloud RunからSendGridのWeb APIでメールを送り、配信や開封の結果をEvent WebhookでCloud Loggingに残してみました。受信側では署名を検証し、SendGrid以外からのリクエストを拒否しています。
2026.11.04

こんにちは、ゲームソリューション部のsoraです。
今回は、Cloud RunからSendGridのWeb APIでメールを送り、配信結果をEvent WebhookでCloud Runに受けて、Cloud Loggingに残してみたことについて書いていきます。

背景

AWSにはAmazon SESがありますが、Google CloudにはSESに当たるメール送信のサービスがありません。
Compute EngineのVMからメールを送る方法のドキュメントでは、サードパーティのメールサービスを使う方法が紹介されています。

SendGrid、Mailgun、Mailjet などの信頼できるサードパーティのメール プロバイダを利用すると、IP レピュテーションのスコア が向上します。

https://docs.cloud.google.com/compute/docs/tutorials/sending-mail?hl=ja

同じページには、VPCネットワークの外への宛先TCPポート25の接続はブロックされることも書かれています。

今回はこの中からSendGridを使います。
SendGridはSMTPでも送れますが、今回はWeb API(HTTPS)で送ります。
Web APIはSendGridが用意しているHTTPSのエンドポイントを呼ぶだけなので、証明書などを自分で用意する必要はありません。

また、SendGridでは、送ったメールが届いたか、開かれたかといった配信の履歴を、Activity Feedの画面で見られます。
ただ、見られる期間はEssentialsプランで3日、Proプランで7日と短いです。

https://docs.sendgrid.com/ui/analytics-and-reporting/email-activity-feed

長く残したい場合や、バウンスした宛先をアプリで無効にするといった処理に使いたい場合は、Event Webhookで自分の環境にイベントを送ってもらう形になります。
今回は、そのイベントをCloud Loggingに残すところまでを試します。

構成

今回構築したのは以下の構成です。

送信側のCloud RunからSendGridのWeb APIでメールを送ります。
SendGridは、配信や開封などのイベントが起きると、Event Webhookで受信側のCloud Runに署名付きでPOSTしてきます。
受信側は署名を検証し、イベントを1件ずつ標準出力に書き、Cloud Loggingに残します。

主なコンポーネント

役割 コンポーネント
メールの送信 Cloud Run(送信側、認証が必要、Go 1.27.3、sendgrid-go 3.16.1)
配信結果の受信とログの書き込み Cloud Run(受信側、認証なしで公開、Go 1.27.3、sendgrid-go 3.16.1)
SendGridのAPIキーの保管 Secret Manager
コンテナイメージの置き場所 Artifact Registry
イベントの保存・検索 Cloud Logging
メールの送信・イベントの通知 SendGrid(Web API v3、Event Webhook)

送信側と受信側を別々のCloud Runにしているのは、公開するかどうかがサービスごとの設定で、1つにまとめると送信の機能まで公開になってしまうためです。
APIキーを読めるのも送信側だけにしています。

構築

Google Cloudのリソースは、Terraformで作成しました。
Terraformのコードの説明は省略します。

Event Webhookの設定に受信側のURLが必要で、受信側には公開鍵が必要なため、受信側のデプロイ → Event Webhookの作成 → 公開鍵を入れて受信側を更新、の順で進めています。

APIキーの作成

送信元のアドレスは、ドメイン認証ではなく、Single Sender Verificationで自分のアドレスを登録しています。
送り先も自分のアドレスです。

APIキーは、「Settings」→「API Keys」→「Create API Key」から作成します。
権限は「Custom Access」を選びます。

「Custom Access」を選ぶと、すべての項目が「No Access」の状態で一覧が出てきます。
今回はメールを送るだけなので、「Mail Send」だけを「Full Access」にします。

作成したAPIキーは、Secret Managerに入れ、送信側のCloud Runに環境変数として渡しています。
Terraformではシークレットの入れ物だけを作成して、値はコンソールから入力しました。
これはTerraformで値を入れると、stateにAPIキーが平文で残るためです。

受信側のCloud Run

受信側はSendGridから呼ばれるので、Cloud Runの認証はなしにして公開し、代わりにEvent Webhookの署名をアプリで検証します。
SendGridからのリクエストにはGoogle CloudのIDトークンを付けられないので、署名で「SendGridから送られたものか」を確かめる形です。
なお、Cloud RunのURLは最初からHTTPSで呼べるため、受信側でも証明書を用意する必要はありません。

SendGridは、リクエストの本文とタイムスタンプに対してECDSAで署名し、X-Twilio-Email-Event-Webhook-SignatureとX-Twilio-Email-Event-Webhook-Timestampのヘッダーに入れて送信します。

https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features

署名とタイムスタンプの検証

検証には、Twilio SendGridが公開しているGoのライブラリ(sendgrid-go)のhelpers/eventwebhookを使います。

https://github.com/sendgrid/sendgrid-go

署名の検証には受け取ったままの本文を使う必要があるので、JSONに変換する前のバイト列(body)を渡しています。

signature := r.Header.Get(eventwebhook.VerificationHTTPHeader)
timestamp := r.Header.Get(eventwebhook.TimestampHTTPHeader)
if signature == "" || timestamp == "" {
	http.Error(w, "forbidden", http.StatusForbidden)
	return
}

ok, err := eventwebhook.VerifySignature(publicKey, body, signature, timestamp)
if err != nil || !ok {
	http.Error(w, "forbidden", http.StatusForbidden)
	return
}

// 5分より古いもの、1分より先の未来のものは拒否する
sec, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
	http.Error(w, "forbidden", http.StatusForbidden)
	return
}
if age := time.Since(time.Unix(sec, 0)); age > 5*time.Minute || age < -1*time.Minute {
	http.Error(w, "forbidden", http.StatusForbidden)
	return
}
// (ログの出力は一部省略)

publicKeyは、Event Webhookの画面に出る「Verification key」をeventwebhook.ConvertPublicKeyBase64ToECDSAで変換したものです。
公開鍵がまだないあいだは、すべてのリクエストを503で拒否するようにしています。

タイムスタンプの確認は、盗んだリクエストを後から送り直す攻撃(リプレイ)への対策です。
タイムスタンプは署名の対象なので書き換えられず、古いものを拒否すれば送り直されても通りません。
SendGridのドキュメントには許容する時間の記載がないので、Slackの公式ドキュメントで案内されているのと同じ5分にしました(未来のものは時計のずれを考えて1分まで)。

https://api.slack.com/docs/verifying-requests-from-slack

5分以内の送り直しまで防ぐには、イベントのID(sg_event_id)で重複を除く仕組みが別に必要です。

イベントのログ出力

本文はイベントの配列になっていて、1回のリクエストに複数のイベントがまとめて入ってきます。
受信側では、イベントの項目をそのまま1行のJSONにして、標準出力に書いています。

for _, ev := range events {
	eventType, _ := ev["event"].(string)
	attrs := []any{"log_type", "sendgrid_event"}
	for k, v := range ev {
		// (Cloud Loggingが特別に扱うmessageとseverityの名前を変える処理は省略)
		attrs = append(attrs, k, v)
	}
	logger.Info("sendgrid event: "+eventType, attrs...)
}

// 2xxを返さないと、SendGridは再送する
w.WriteHeader(http.StatusNoContent)

loggerはGoのlog/slogのJSONハンドラーで、キーの名前をseverityとmessageに変えています。
Cloud Runでは、標準出力に書いた1行のJSONはjsonPayloadとして取り込まれ、severityはログの重要度、messageは一覧に表示される文字列として扱われます。

https://docs.cloud.google.com/run/docs/logging

ただ、slogは警告の重要度をWARNと出力しますが、Cloud Loggingでの名前はWARNINGなので、置き換えています。

Event Webhookの設定

「Settings」→「Mail Settings」→「Event Webhooks」から、「Create new webhook」で作成します。
「Post URL」には、受信側のCloud RunのURLの後ろに/webhookを付けたものを入れます。
これは、受信側がPOST /webhookだけを受け付けるようにしているためです。
「Actions to be posted」は、すべてにチェックを入れます。

署名の設定は、同じ画面の下の「Security features」にあります。
「Enable Signed Event Webhook」をオンにしてSaveし、もう一度開くと「Verification key」が表示されます。
オンにしただけでは鍵は作られず、Saveしたときに作られます。

この公開鍵を、受信側のCloud Runの環境変数に入れて更新します。
一覧の「Signed event」が「Enabled」になっていれば、署名付きで送られてきます。

動作確認

Test Integrationでの確認

まずは、Event Webhookの設定画面の「Test Integration」で、SendGridからテスト用のイベントを送信しました。
受信側は204を返し、署名とタイムスタンプの検証が通りました。

1回のリクエストに、processed、deferred、delivered、open、click、bounceなど11件のイベントがまとめて入っていました。
1件ずつログに書いているので、時刻が同じログが11行並んでいます。

不正なリクエストの確認

次に、署名のないリクエストと、でたらめな署名を付けたリクエストを送ってみました。

# 署名のヘッダーなし
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://sendgrid-receiver-xxxxx-an.a.run.app/webhook -d '[]'

# でたらめな署名
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://sendgrid-receiver-xxxxx-an.a.run.app/webhook \
  -H 'X-Twilio-Email-Event-Webhook-Signature: MEUCIQDfake' \
  -H "X-Twilio-Email-Event-Webhook-Timestamp: $(date +%s)" \
  -d '[]'

どちらも403で拒否されました。
severity>=WARNINGで検索すると、Cloud Runのリクエストのログと、受信側が拒否した理由のログが並んで出てきます。
上のほうの503は、公開鍵を入れる前に送ったリクエストです。

Cloud Runからのメール送信

送信側のCloud Runでは、sendgrid-goでメールを組み立てて送ります。
送信元と送信先は環境変数で渡し、APIキーはSecret Managerから環境変数として受け取っています。

m := mail.NewV3Mail()
m.SetFrom(mail.NewEmail("Cloud Run Sender", cfg.fromEmail))
m.AddCategories("cloudrun-verification")
// Event Webhookのイベントにもそのまま載る値
m.SetCustomArg("request_id", requestID)

p := mail.NewPersonalization()
p.AddTos(mail.NewEmail("", cfg.toEmail))
m.AddPersonalizations(p)

m.Subject = fmt.Sprintf("Cloud Runからのテストメール(%s)", now)
m.AddContent(
	mail.NewContent("text/plain", "..."),
	mail.NewContent("text/html", "..."),
)

// clientは起動時にsendgrid.NewSendClient(APIキー)で作ったもの
resp, err := client.Send(m)

custom_argsに入れたrequest_idは、Event Webhookのイベントにもそのまま載ってきます。
また、開封を検知できるようにHTMLの本文も付けています。
開封は、HTMLの最後にSendGridが入れる1ピクセルの画像が読み込まれたときに検知されます。

https://www.twilio.com/docs/sendgrid/ui/account-and-settings/tracking

送信側は認証が必要なサービスなので、IDトークンを付けて呼びます。

curl -s -X POST -H "Authorization: Bearer $(gcloud auth print-identity-token)" https://sendgrid-sender-xxxxx-an.a.run.app/send
{"request_id":"318919c59110125e","sendgrid_body":"","sendgrid_status_code":202,"x_message_id":"jBYgVCZQQq-QVbe7rOEJDw"}

SendGridのWeb APIは、メールを受け付けると202を返します。
ちなみに、IDトークンを付けずに呼ぶと、リクエストはアプリまで届かず、Cloud Runが403で拒否しました。

届いたメールの確認

届いたメールは、Gmailの迷惑メールのフォルダに入っていました。

送信者の詳細を開くと、送信元と署名元がsendgrid.netになっていて、「sendgrid.net 経由」と表示されています。
Gmailの送信元はSPFで確かめるエンベロープの送信者(Return-Path)のドメイン、署名元はDKIMの署名のドメインです。

Single Sender Verificationで送っているので、どちらもFromのドメインと一致せず、DMARCに通りませんでした。
Fromのドメインは、DMARCのポリシーがp=quarantine(通らなければ隔離)になっているので、迷惑メールのフォルダに入ったという流れです。
SendGridのドメイン認証(Domain Authentication)を設定すると、DKIMの署名と送信元を自分のドメインにでき、「sendgrid.net 経由」の表示も消えると公式ドキュメントに書かれています。

https://www.twilio.com/docs/sendgrid/ui/account-and-settings/how-to-set-up-domain-authentication

また、迷惑メールのフォルダで開いたときは、openのイベントは届きませんでした。
迷惑メールのフォルダでは、開封を検知する画像が読み込まれなかったためと考えられます。
「安全なメール」を押して受信トレイに移し、そこで開くと、openのイベントが届きました。

配信結果の確認

14:49:40に送信したメールについて、14:49:52にprocessedとdeliveredのイベントが受信側に届きました。
openのイベントは、受信トレイで開いたあと、14:54:06に届きました。

送信側と受信側のログを並べると、送信から開封までの流れを1つの画面で確認できます。

deliveredのイベントのresponse(受け取ったメールサーバーの応答)には、250 2.0.0 OK DMARC:Quarantineと出ていて、先ほどのDMARCの判定がここにも出ていました。
openのイベントの中身は以下です。

{
  "category": ["cloudrun-verification"],
  "email": "xxxxx@example.com",
  "event": "open",
  "ip": "74.125.209.39",
  "log_type": "sendgrid_event",
  "message": "sendgrid event: open",
  "request_id": "318919c59110125e",
  "sg_content_type": "html",
  "sg_event_id": "iDmKv-V6T4emsvxR1FztYQ",
  "sg_machine_open": false,
  "sg_message_id": "jBYgVCZQQq-QVbe7rOEJDw.recvd-5bb5b9cb76-h6scr-1-6AEAC775-8.0",
  "timestamp": 1793771636,
  "useragent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)"
}

送信時に入れたrequest_idがそのまま載っているので、どのメールのイベントかを特定できます。
sg_message_idは、送信時に返ってきたx_message_idの後ろに文字が付いた形になっていました。
useragentがGoogleImageProxyになっているのは、Gmailがメールの画像をGoogleのサーバー経由で読み込むためです。

SendGridの画面(「Activity」→「Email Logs」)でも、同じメールの流れを確認できます。

SendGridの画面でイベントが記録された時刻と、受信側に届いた時刻を比べると、どのイベントも約10秒後に届いていました。
また、SendGridの画面には、APIのリクエストを受け付けた「Received」も出ていますが、これはEvent Webhookでは届きませんでした。

補足:Dynamic Templateでの送信

ちなみに、件名と本文をSendGridのDynamic Templateで作って送信する形も試しました。

テンプレートは「Email API」→「Dynamic Templates」で作成し、Code EditorでHTMLを書きます。
値を入れたい場所は{{sent_at}}のように{{}}で囲み、件名(「Settings」の「Subject」)にも使えます。

送信側のコードでは、件名と本文を作る代わりに、テンプレートのID(Template ID)と値を渡すだけです。

m.SetTemplateID(cfg.templateID)
p.SetDynamicTemplateData("sent_at", now)
p.SetDynamicTemplateData("request_id", requestID)

件名と本文に値が入ったメールが届きました。

受信側は何も変えていませんが、最初のメールと同じように、processed、delivered、openのイベントが届きました。
開封を検知する画像も、テンプレートのHTMLに自分で入れなくても、SendGridが入れてくれていました。

最後に

今回は、Cloud RunからSendGridでメールを送り、配信結果をEvent WebhookでCloud Runに受けて、Cloud Loggingで検索できるようにしてみました。
この記事がどなたかの参考になれば幸いです。


SendGridの導入支援はクラスメソッドにお任せください!

クラスメソッドでは、SendGridの導入から運用まで幅広くサポートしています。
メール配信の効率化やパフォーマンス向上に関心がある方は、ぜひお気軽にお問い合わせください。

SendGridの詳細を見る

この記事をシェアする

関連記事