Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
81 changes: 81 additions & 0 deletions api/drivers/mail/mail.go
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 23 additions & 32 deletions api/externals/repository/password_reset_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}
}

// 全件取得
Expand Down Expand Up @@ -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)
}
18 changes: 18 additions & 0 deletions api/internals/di/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -57,6 +74,7 @@ func InitializeServer() (*ServerComponents, error) {
// 基盤Provider群
ProvideDBClient,
ProvideMinioClient,
ProvideMailClient,
ProvideCrud,

// 各層のProviderセット
Expand Down
23 changes: 22 additions & 1 deletion api/internals/di/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions compose.e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions mysql/e2e_seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
52 changes: 52 additions & 0 deletions view/next-project/e2e/tests/password_reset.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
3 changes: 2 additions & 1 deletion view/next-project/e2e/tests/signup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
4 changes: 2 additions & 2 deletions view/next-project/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -27,7 +27,7 @@ export default function Home() {
<div className='my-12 flex flex-col items-center justify-center gap-5'>
<p className='text-black-600'>登録がまだの方はこちら</p>
<PrimaryButton onClick={() => setIsMember(!isMember)}>新規登録</PrimaryButton>
<p className='text-black-600'>パスワードを忘れた方はSlackまで</p>
<Link href='/reset_password/request'>パスワードを忘れた</Link>
</div>
</>
);
Expand Down
Loading