diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1b7c07b0..c6139c5a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,6 +30,9 @@ jobs: - name: Run E2E run: make run-e2e + env: + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + MAIL_FROM_ADDRESS: ${{ secrets.MAIL_FROM_ADDRESS }} - name: Cleanup E2E containers if: always() diff --git a/api/drivers/mail/mail.go b/api/drivers/mail/mail.go new file mode 100644 index 00000000..5ac83665 --- /dev/null +++ b/api/drivers/mail/mail.go @@ -0,0 +1,81 @@ +package mail + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const resendEndpoint = "https://api.resend.com/emails" + +type Client interface { + SendMail(ctx context.Context, to string, subject string, body string) error +} + +type client struct { + apiKey string + fromAddress string + httpClient *http.Client +} + +// NewMailClient - Resend経由でメールを送信するmail.Clientを生成する +func NewMailClient(apiKey, fromAddress string) Client { + return client{ + apiKey: apiKey, + fromAddress: fromAddress, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +type sendEmailRequest struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Text string `json:"text"` +} + +type resendErrorResponse struct { + Name string `json:"name"` + Message string `json:"message"` +} + +// SendMail - Resend APIを叩いてメールを送信する +func (c client) SendMail(ctx context.Context, to string, subject string, body string) error { + reqBody, err := json.Marshal(sendEmailRequest{ + From: c.fromAddress, + To: []string{to}, + Subject: subject, + Text: body, + }) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(reqBody)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + var resendErr resendErrorResponse + _ = json.Unmarshal(respBody, &resendErr) + return fmt.Errorf("resend api error: status=%d name=%s message=%s", resp.StatusCode, resendErr.Name, resendErr.Message) + } + + _, _ = io.Copy(io.Discard, resp.Body) + + return nil +} diff --git a/api/externals/repository/password_reset_tokens.go b/api/externals/repository/password_reset_tokens.go index 70209c9d..f443064b 100644 --- a/api/externals/repository/password_reset_tokens.go +++ b/api/externals/repository/password_reset_tokens.go @@ -3,17 +3,19 @@ package repository import ( "context" "database/sql" - "net/smtp" + "net/url" "os" "time" "github.com/NUTFes/FinanSu/api/drivers/db" + "github.com/NUTFes/FinanSu/api/drivers/mail" "github.com/NUTFes/FinanSu/api/externals/repository/abstract" ) type passwordResetTokenRepository struct { - client db.Client - crud abstract.Crud + client db.Client + crud abstract.Crud + mailClient mail.Client } type PasswordResetTokenRepository interface { @@ -29,8 +31,8 @@ type PasswordResetTokenRepository interface { SendResetEmail(context.Context, string, string, string, string) error } -func NewPasswordResetTokenRepository(c db.Client, ac abstract.Crud) PasswordResetTokenRepository { - return &passwordResetTokenRepository{c, ac} +func NewPasswordResetTokenRepository(c db.Client, ac abstract.Crud, mc mail.Client) PasswordResetTokenRepository { + return &passwordResetTokenRepository{c, ac, mc} } // 全件取得 @@ -103,35 +105,24 @@ func (pr *passwordResetTokenRepository) DestroyByUserID(c context.Context, userI // リセットメール送信 -func (pr *passwordResetTokenRepository) SendResetEmail(c context.Context,id string, name string, email string, token string) error { - mailSender := os.Getenv("NUTMEG_MAIL_SENDER") - mailPassword := os.Getenv("NUTMEG_MAIL_PASSWORD") - resetPageUrl := os.Getenv("RESET_PASSWORD_URL")+"/" + id + "/?token=" +token - - message := []byte("From: FinanSu <" + mailSender + ">\r\n" + - "Subject: 【FinanSu】パスワード再設定メール\r\n\r\n" + - name + " 様\n\n" + - "情報局 FinanSu 担当です。\r\n\r\n" + - "パスワードの再設定のご依頼を受け付けました。下記の再設定ページにアクセスし、新しいパスワードを設定してください。\n" + - "※パスワードリセットの申請に心当たりがない場合は、以降の対応は不要となります。\n\n" + - resetPageUrl + "\r\n\n" + - "なお、URLの有効期限は本メールが送信されてから60分間とさせていただきます。\r\n\n" + - "どうぞよろしくお願い申し上げます。\r\n\r\n" + - "情報局 FinanSu担当 \r\n\n"+ - "※このメールは送信専用です\r\n") - - smtpHost := "smtp.gmail.com" - smtpPort := "587" - - auth := smtp.PlainAuth("", mailSender, mailPassword, smtpHost) - - emails := []string{email} - - // メール送信 - err := smtp.SendMail(smtpHost + ":" + smtpPort, auth, mailSender, emails, message) +func (pr *passwordResetTokenRepository) SendResetEmail(c context.Context, id string, name string, email string, token string) error { + resetPageURL, err := url.Parse(os.Getenv("RESET_PASSWORD_URL")) if err != nil { return err } + resetPageURL = resetPageURL.JoinPath(id) + resetPageURL.RawQuery = url.Values{"token": {token}}.Encode() + + title := "【FinanSu】パスワード再設定メール" + body := name + " 様\n\n" + + "情報局 FinanSu 担当です。\n\n" + + "パスワードの再設定のご依頼を受け付けました。下記の再設定ページにアクセスし、新しいパスワードを設定してください。\n" + + "※パスワードリセットの申請に心当たりがない場合は、以降の対応は不要となります。\n\n" + + resetPageURL.String() + "\n\n" + + "なお、URLの有効期限は本メールが送信されてから60分間とさせていただきます。\n\n" + + "どうぞよろしくお願い申し上げます。\n\n" + + "情報局 FinanSu担当 \n\n" + + "※このメールは送信専用です\n" - return err + return pr.mailClient.SendMail(c, email, title, body) } diff --git a/api/internals/di/wire.go b/api/internals/di/wire.go index f47149cd..6e72d770 100644 --- a/api/internals/di/wire.go +++ b/api/internals/di/wire.go @@ -4,7 +4,11 @@ package di import ( + "errors" + "os" + "github.com/NUTFes/FinanSu/api/drivers/db" + "github.com/NUTFes/FinanSu/api/drivers/mail" "github.com/NUTFes/FinanSu/api/drivers/mc" "github.com/NUTFes/FinanSu/api/drivers/server" "github.com/NUTFes/FinanSu/api/externals/handler" @@ -33,6 +37,19 @@ func ProvideMinioClient() (mc.Client, error) { return mc.InitMinioClient() } +// ProvideMailClient - MailClientのProvider +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 +} + // ProvideCrud - AbstractCrudのProvider func ProvideCrud(client db.Client) abstract.Crud { return abstract.NewCrud(client) @@ -57,6 +74,7 @@ func InitializeServer() (*ServerComponents, error) { // 基盤Provider群 ProvideDBClient, ProvideMinioClient, + ProvideMailClient, ProvideCrud, // 各層のProviderセット diff --git a/api/internals/di/wire_gen.go b/api/internals/di/wire_gen.go index 32defa46..a41fd3d0 100644 --- a/api/internals/di/wire_gen.go +++ b/api/internals/di/wire_gen.go @@ -7,7 +7,11 @@ package di import ( + "errors" + "os" + "github.com/NUTFes/FinanSu/api/drivers/db" + "github.com/NUTFes/FinanSu/api/drivers/mail" "github.com/NUTFes/FinanSu/api/drivers/mc" "github.com/NUTFes/FinanSu/api/drivers/server" "github.com/NUTFes/FinanSu/api/externals/handler" @@ -61,7 +65,11 @@ func InitializeServer() (*ServerComponents, error) { userRepository := repository.NewUserRepository(client, crud) mailAuthUseCase := usecase.NewMailAuthUseCase(mailAuthRepository, sessionRepository, userRepository, transactionRepository) objectUploadUseCase := usecase.NewObjectUploadUseCase(objectHandleRepository) - passwordResetTokenRepository := repository.NewPasswordResetTokenRepository(client, crud) + mailClient, err := ProvideMailClient() + if err != nil { + return nil, err + } + passwordResetTokenRepository := repository.NewPasswordResetTokenRepository(client, crud, mailClient) passwordResetTokenUseCase := usecase.NewPasswordResetTokenUseCase(passwordResetTokenRepository, userRepository, mailAuthRepository) sponsorRepository := repository.NewSponsorRepository(client, crud) sponsorUseCase := usecase.NewSponsorUseCase(sponsorRepository) @@ -99,6 +107,19 @@ func ProvideMinioClient() (mc.Client, error) { return mc.InitMinioClient() } +// ProvideMailClient - MailClientのProvider +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 +} + // ProvideCrud - AbstractCrudのProvider func ProvideCrud(client db.Client) abstract.Crud { return abstract.NewCrud(client) diff --git a/compose.e2e.yml b/compose.e2e.yml index 7b4bc9f7..5a7411c3 100644 --- a/compose.e2e.yml +++ b/compose.e2e.yml @@ -72,6 +72,8 @@ services: MINIO_SECRET_KEY: password MINIO_USE_SSL: "false" RESET_PASSWORD_URL: "http://view:3000/reset_password" + RESEND_API_KEY: "${RESEND_API_KEY}" + MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS}" command: go run main.go depends_on: seed: diff --git a/mysql/e2e_seed.sql b/mysql/e2e_seed.sql index 4b2a1113..a9af9185 100644 --- a/mysql/e2e_seed.sql +++ b/mysql/e2e_seed.sql @@ -11,3 +11,11 @@ VALUES (1, 2025); INSERT INTO year_periods (year_id, started_at, ended_at) VALUES (1, '2024-11-15 00:00:00', '2025-11-15 00:00:00'); + +INSERT INTO users (id, name, bureau_id, role_id, is_deleted) +VALUES (1, 'E2E Reset User', 1, 1, false); + +-- password: e2e-reset-password (bcrypt hash, ログインテストでは使用しない) +-- email は Resend のシミュレーションアドレス(実配送されず必ず成功レスポンスを返す) +INSERT INTO mail_auth (id, email, password, user_id) +VALUES (1, 'delivered@resend.dev', '$2a$10$L8XaQthkF.YbgJHQSEIsSOntPbtBGp1q1lkv33qZbtuMT0C8h4OPS', 1); diff --git a/view/next-project/e2e/tests/password_reset.spec.ts b/view/next-project/e2e/tests/password_reset.spec.ts new file mode 100644 index 00000000..f98db600 --- /dev/null +++ b/view/next-project/e2e/tests/password_reset.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; + +const apiURL = process.env.API_URL || 'http://api:1323'; + +async function waitForService(url: string) { + const deadline = Date.now() + 120_000; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok) { + return; + } + lastError = new Error(`HTTP ${res.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error(`Service did not become ready: ${url}. Last error: ${String(lastError)}`); +} + +test.beforeAll(async () => { + test.setTimeout(120_000); + await waitForService(`${apiURL}/`); + await waitForService(`${process.env.BASE_URL || 'http://view:3000'}/`); +}); + +test('パスワードリセット申請後に再設定メール送信の完了表示が出る', async ({ page }) => { + // mysql/e2e_seed.sql で登録済みのユーザーのメールアドレス(Resendのシミュレーションアドレス) + const email = 'delivered@resend.dev'; + + await page.goto('/reset_password/request'); + + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/password_reset/request') && response.request().method() === 'POST', + ); + + await page.getByPlaceholder('test@example.com').fill(email); + await page.getByRole('button', { name: '再設定メールの送信' }).click(); + + const resetResponse = await responsePromise; + const resetRequestURL = new URL(resetResponse.url()); + expect(resetRequestURL.searchParams.get('email')).toBe(email); + expect(resetResponse.status()).toBe(200); + expect(await resetResponse.text()).toBe('PasswordResetTokenを送信しました'); + + await expect(page.getByText('再設定メールを送信しました')).toBeVisible(); +}); diff --git a/view/next-project/e2e/tests/signup.spec.ts b/view/next-project/e2e/tests/signup.spec.ts index 732c8090..c55bdc93 100644 --- a/view/next-project/e2e/tests/signup.spec.ts +++ b/view/next-project/e2e/tests/signup.spec.ts @@ -23,9 +23,10 @@ async function waitForService(url: string) { } test.beforeAll(async () => { + test.setTimeout(120_000); await waitForService(`${apiURL}/`); await waitForService(`${process.env.BASE_URL || 'http://view:3000'}/`); -}, 120_000); +}); test('新規登録後に current_user が 404 にならず My Page に遷移する', async ({ page }) => { const currentUserStatuses: number[] = []; diff --git a/view/next-project/src/pages/index.tsx b/view/next-project/src/pages/index.tsx index a0a66100..cdfa6fd6 100644 --- a/view/next-project/src/pages/index.tsx +++ b/view/next-project/src/pages/index.tsx @@ -1,7 +1,7 @@ import Image from 'next/image'; import { useState } from 'react'; -import { PrimaryButton } from '@/components/common'; +import { Link, PrimaryButton } from '@/components/common'; import SignInView from '@components/auth/SignInView'; import SignUpView from '@components/auth/SignUpView'; import LoginLayout from '@components/layout/LoginLayout'; @@ -27,7 +27,7 @@ export default function Home() {
登録がまだの方はこちら
パスワードを忘れた方はSlackまで
+ パスワードを忘れた