Skip to content

Migrate password reset email sending from Gmail SMTP to Resend - #1122

Open
fore0111 wants to merge 5 commits into
developfrom
feat/migrate-reset-email-to-resend
Open

fore0111 wants to merge 5 commits into
developfrom
feat/migrate-reset-email-to-resend

Conversation

@fore0111

@fore0111 fore0111 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

対応Issue

不明

概要

パスワードリセットメールの送信手段を Gmail SMTP から Resend API に移行しました。

  • api/drivers/mail/mail.go を新規追加し、Resend の /emails エンドポイントを叩く mail.Client を実装
    • RESEND_API_KEY / MAIL_FROM_ADDRESS 環境変数を使用
    • RESEND_API_KEY 未設定時は実送信せず、内容をコンソール出力するフォールバック動作にしています(ローカル開発用)
  • password_reset_tokens.go の SendResetEmail から net/smtp を使った Gmail SMTP 送信処理を削除し、mail.Client 経由の送信に置き換え
  • Wire の DI 設定 (di/wire.go / wire_gen.go) に ProvideMailClient を追加し、mail.Client を注入できるように変更
  • あわせてログイン画面の「パスワードを忘れた方はSlackまで」の文言を、パスワードリセット申請ページへのリンクに戻す revert を含めています(以前の文言修正コミットの取り消し)

画面スクリーンショット等

  • URL
    スクリーンショット
image

テスト項目

  • パスワードリセット申請〜Resend経由でのメール送信が正常に行われること
  • RESEND_API_KEY 未設定時にエラーにならずコンソール出力にフォールバックすること
  • E2E (password_reset.spec.ts) がPASSすること
  • ログイン画面から「パスワードを忘れた」リンクでリセット申請ページに遷移できること

備考

fore0111 and others added 3 commits September 5, 2026 16:49
送信元ドメインを用意できなかったため無効化されていたパスワード再設定メールを、
Resend経由での送信に切り替えて復旧させる。

- api/drivers/mail/ を新設し、net/http でResend API(POST /emails)を叩く
  mail.Client インターフェースを実装(APIキー未設定時はコンソール出力にフォールバック)
- password_reset_tokens.go の SendResetEmail を smtp.SendMail から
  mail.Client 経由の送信に置き換え
- di/wire.go に ProvideMailClient を追加(wire_gen.go は make wire-gen が
  現状Go 1.25.5環境で実行できないため手動で追従)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
reset_password/requestページでメールアドレスを送信すると
再設定メール送信の完了表示が出ることを確認する

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

Deploying finansu with  Cloudflare Pages  Cloudflare Pages

Latest commit: 37d6fa7
Status: ✅  Deploy successful!
Preview URL: https://2c1ae011.finansu.pages.dev
Branch Preview URL: https://feat-migrate-reset-email-to.finansu.pages.dev

View logs

@hikahana
hikahana self-requested a review September 7, 2026 16:23

@hikahana hikahana left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code(/code-review, high)による自動レビュー。6件、うち #1(E2Eカバレッジ) と #2(mail送信のサイレント no-op) が要対応です。以下、各行にインラインで記載。

}, 120_000);

test('パスワードリセット申請後に再設定メール送信の完了表示が出る', async ({ page }) => {
const email = `e2e-reset-${Date.now()}@example.com`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このテストは移行対象コード(api/drivers/mail/mail.go / SendResetEmail)を一度も通りません。

  • e2e-reset-${Date.now()}@example.com は seed されていないため、PasswordResetTokenRequest が userRep.FindByEmail → row.Scan で sql.ErrNoRows を返し、SendResetEmail 到達前に return します。
  • PostPasswordResetRequest はエラー時も HTTP 200 を返し、reset_password/request/index.tsx は レスポンスに関係なく setIsSubmitted(true) するため、Resend 連携が壊れていても/削除されても本テストは常に緑です。

signup.spec.ts のように事前にユーザーを作成(または e2e_seed.sql に対象メールを追加)し、POST のレスポンス内容まで検証してください。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seedに delivered@resend.dev(Resendのテスト用アドレス)のユーザーを追加し、そのアドレスで申請するようにしました。POSTのステータスとレスポンス本文も検証しています。 37d6fa7

Comment thread api/drivers/mail/mail.go Outdated
// SendMail - Resend APIを叩いてメールを送信する
// RESEND_API_KEYが未設定の場合は送信を行わず、内容をコンソールに出力する
func (c client) SendMail(to string, subject string, body string) error {
if c.apiKey == "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RESEND_API_KEY を mail.go 内で読み、空なら黙って console 出力…という制御はやめたいです。secret 設定漏れ時に「送信しました」と表示されつつ無言で no-op になり、旧 SMTP 実装(認証エラーを返す)より退行します。

方針(相談の結果):

  • この if c.apiKey == "" ブロックと console フォールバックは削除。
  • env(RESEND_API_KEY / MAIL_FROM_ADDRESS)の読み取り・検証は wire 側(ProvideMailClient)に持たせ、空なら error を返して起動失敗させる(ProvideMailClient を (mail.Client, error) に)。
  • mail.go は NewMailClient(apiKey, fromAddress string) で値を受け取るだけにし、SendMail から分岐を消す。

併せて新 env を compose.stg.yml / compose.prod.yml に追加し、旧 NUTMEG_MAIL_SENDER / NUTMEG_MAIL_PASSWORD は削除してください(現状どの compose にも新 env が無い)。ローカル/e2e は finansu.env に Resend のテストキーを入れる運用で。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consoleフォールバックを削除し、envの読み取り・検証を ProvideMailClient に移しました。未設定時は起動エラーになります。 37d6fa7
stg/prodのenvについては wire.go 側のコメントに返信しています。

Comment thread api/drivers/mail/mail.go Outdated
}

// NewMailClient - Resend経由でメールを送信するmail.Clientを生成する
func NewMailClient() client {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

公開関数が未公開型 client を返しています(.golangci.yml の revive unexported-return に該当、CI で指摘されます)。
func NewMailClient(apiKey, fromAddress string) Client として interface を返してください(引数化は下のコメント参照)。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewMailClient(apiKey, fromAddress string) Client に変更しました。 37d6fa7

Comment thread api/drivers/mail/mail.go Outdated
return err
}

req, err := http.NewRequest(http.MethodPost, resendEndpoint, bytes.NewReader(reqBody))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • SendResetEmail は context.Context を持っていますが、ここで http.NewRequest を使っており コンテキストが伝播しません。http.NewRequestWithContext に変え、SendMail の I/F にも ctx を追加してください。
  • 成功(2xx)時に resp.Body を読まず Close しているため keep-alive 接続が再利用されません。io.Copy(io.Discard, resp.Body) を挟むと良いです。

影響は小(10s タイムアウト・低頻度)ですが、ついで修正推奨。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

http.NewRequestWithContext に変更し、SendMail のI/Fに ctx を追加しました。成功時は io.Copy(io.Discard, resp.Body) で読み捨てています。 37d6fa7

test.beforeAll(async () => {
await waitForService(`${apiURL}/`);
await waitForService(`${process.env.BASE_URL || 'http://view:3000'}/`);
}, 120_000);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Playwright の test.beforeAll はフック関数(+任意の title)しか受け取らず、第2引数 120_000 は無視されます。
そのためフックは playwright.config.ts の timeout: 60_000 で動作し、waitForService 内部の 120s デッドラインより先に 60s で中断され得ます。フック内で test.setTimeout(120_000) を使ってください。
(signup.spec.ts からのコピー由来なので、そちらも合わせて修正推奨)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test.setTimeout(120_000) に修正しました。signup.spec.ts も同様に修正しています。 37d6fa7

Comment thread view/next-project/src/pages/index.tsx Outdated
import { useState } from 'react';

import { PrimaryButton } from '@/components/common';
import Link from '@/components/common/Link';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

直上で { PrimaryButton } を barrel @/components/common から import しており、barrel は Link も re-export しています(reset_password/request/index.tsx は import { Link } from '@components/common')。
import { PrimaryButton, Link } from '@/components/common'; に統一を。(cosmetic)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import { Link, PrimaryButton } from '@/components/common'; に統一しました。 37d6fa7

Comment thread api/internals/di/wire.go Outdated
}

// ProvideMailClient - MailClientのProvider
func ProvideMailClient() mail.Client {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

env(RESEND_API_KEY / MAIL_FROM_ADDRESS)の読み取り・検証はここに持たせたいです(mail.go 側では読まない)。

func ProvideMailClient() (mail.Client, error) {
	apiKey := os.Getenv("RESEND_API_KEY")
	if apiKey == "" {
		return nil, errors.New("RESEND_API_KEY is not set")
	}
	from := os.Getenv("MAIL_FROM_ADDRESS")
	if from == "" {
		return nil, errors.New("MAIL_FROM_ADDRESS is not set")
	}
	return mail.NewMailClient(apiKey, from), nil
}
  • ProvideMinioClient と同じ (T, error) パターン。wire.Build はそのまま、make gen で wire_gen.go が mailClient, err := ProvideMailClient() に変わります。
  • これで key 未設定なら起動時に落ちる(本番でのサイレント no-op を防止)。console フォールバックは廃止。
  • mail.go は NewMailClient(apiKey, fromAddress string) Client で受け取るだけに。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご提示の形で ProvideMailClient を (mail.Client, error) にし、make gen で wire_gen.go を再生成しました。 37d6fa7

stg/prodのenvについて:どちらも env_file でenvを読み込んでいるので、RESEND_API_KEY はシークレットとしてcomposeには直接書かず、サーバー側のenvファイルに追加する方針にしたいです。問題なければそれで進めます。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

そっちの方でいいか確かに

@hikahana hikahana left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

追加でちょっとだけ

Comment thread api/drivers/mail/mail.go Outdated

if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("resend api error: status=%d body=%s", resp.StatusCode, string(respBody))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

抜きだせそうだからbodyじゃなくてそのなかみのmessageをレスポンスかなー

var e struct{ Name, Message string }
_ = json.Unmarshal(respBody, &e)
return fmt.Errorf("resend api error: status=%d name=%s message=%s", resp.StatusCode, e.Name, e.Message)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resendのエラーレスポンスから name / message を取り出して返すようにしました。 37d6fa7

Comment thread api/drivers/mail/mail.go Outdated
}
defer resp.Body.Close()

if resp.StatusCode >= 300 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

300以上にしてるのって理由ある?
正常系は基本200だけだけどresendだと204でも正常扱いすると勝手ある感じ?特にないなら
200以外 != 200 みたいな感じにしたい。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

特に理由はなかったです。Resendの送信APIは正常時200なので != http.StatusOK にしました。 37d6fa7

name + " 様\n\n" +
"情報局 FinanSu 担当です。\r\n\r\n" +
func (pr *passwordResetTokenRepository) SendResetEmail(c context.Context, id string, name string, email string, token string) error {
resetPageUrl := os.Getenv("RESET_PASSWORD_URL") + "/" + id + "/?token=" + token

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[imo]
ちょっと例だけど、こういうのでパス形成したい感あるな―。
特に+でも問題はないけど個人的に

u, err := url.Parse(os.Getenv("RESET_PASSWORD_URL"))
if err != nil {
    return err
}
u = u.JoinPath(id)                                   // パス結合("//" や欠落スラッシュを正規化)
u.RawQuery = url.Values{"token": {token}}.Encode()   // クエリを自動でパーセントエンコード
resetPageURL := u.String()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

url.Parse → JoinPath → url.Values で組み立てるように変更しました。 37d6fa7

"※このメールは送信専用です\n"

return err
return pr.mailClient.SendMail(email, "【FinanSu】パスワード再設定メール", body)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ここbody作ったなら一応、titleも定義してから渡したいなー

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

title を変数として定義してから渡すようにしました。 37d6fa7

- mail.Clientの環境変数読み取り・検証をProvideMailClientに移し、未設定時は起動失敗させる
- RESEND_API_KEY未設定時のconsoleフォールバックを削除
- NewMailClientがClient interfaceを返すように変更
- SendMailにcontextを追加し、NewRequestWithContextを使用
- ステータス判定を!= 200にし、Resendのエラーからname/messageを抽出
- リセットURLをnet/urlで組み立て、件名を変数化
- E2E: seedユーザー(delivered@resend.dev)で申請し、レスポンスまで検証
- E2E: beforeAllのタイムアウトをtest.setTimeoutで指定
- index.tsxのLink importをbarrelに統一

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants