【Spring Boot】メール送信機能と照合

## はじめに
・今回は最近(?)よく見かけるユーザ新規登録時、入力されたメアドにコードを送信する方法とその認証までの記事になります。
・バリデーションなどかけていますが、そこについては説明は省かせていただきます。
[【Spring Boot】Validation(入力チェック)+DB保存までをうまく](https://qiita.com/whats-up-everyone/items/d7907e6bf511b3215eb8)
・DBとの連結方法も省略します。
・今回作成するのは以下のようなもの
↓メアド入力画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3830719/1a0af76f-78c6-4744-758d-f572715dab26.png)

↓認証コードの入力画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3830719/1cff3717-8a12-222b-1a32-a496e615c5a0.png)

↓完了画面(一応)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3830719/ad87263a-ab4a-8079-6f29-d49eb0165b98.png)

↓今回作成するクラス、HTML,CSS等
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3830719/ae5ac485-97fc-8d9f-dc2d-2a5a062d140a.png)


## 概要
#### 環境
・Eclipse(2024)
・Spring Boot 3.3.0 (Maven)
・MariaDB
※メール送信は`Gmail`を使用

#### 内容
・入力されたメアドに認証コードの送信方法
・送信したコードと入力されたコードの照合
・この機能を実際どういう感じに使うのか

## 依存関係の追加
`Maven`
```xml:pom.xml
<!--mail-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
```

## application.propertiesの追加
とりあえずコードを記載しますが、コピペでは動かないので注意(DB連携のコードは省略)
```properties:application.properties
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=yourmail@gmail.com
spring.mail.password=passwordhear
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
```
#### POINT
・最初の2行と最後の2行はコピペでOK
・3,4行目が味噌

　3行目：`yourmail@gmail.com`の部分はメールを送信する側のメアドを入力
 　この場合は`youremail@gmail.com`が入力されたメアドにメールを送信する。

  4行目：ここのパスワードはアプリパスワードを入力する。そのパスワードは以下のリンク先で生成する必要がある。そのパスワードを入力する。
  [Gmailのアプリパスワード生成](https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fmyaccount.google.com%2Fapppasswords&followup=https%3A%2F%2Fmyaccount.google.com%2Fapppasswords&ifkv=AdF4I77S44tu-Clv0ym5XjnAGQTHZhmo0NEj6QqiDYop9QbJk1C2U1xh1qJ8D8QQKt6xAmgCCeetTA&osid=1&passive=1209600&rart=ANgoxcdyggtjT-JysVCfCU8mm6ME9JT-LwbkY4jFnrEBnNFB9dallut6S-CTOVrz6nqsoAO5C6c7itI0bPZJIeYkZpCVlorOTQhHxX56PgJPDdUlMWZRJr0&service=accountsettings&flowName=GlifWebSignIn&flowEntry=ServiceLogin&dsh=S420033131%3A1720330363966086&ddm=0)

## Entityの作成
今回は`Mail.java`という`Entity`を作成します。
↓DBは以下の通りに作成されている前提とします。
|カラム名|データ型|備考|
|---|---|---|
|id|INT|AUTO_INCRIMENTのID|
|email|VARCHAR(50)|メールアドレス|
|authentication_code|VARCHAR(50)|認証コード|
|non_locked|TINYINT|ロック|
|create_at|DATETIME|作成時間|

※ロックとは：認証コードが入力され正しければ1(true)(有効)になる。それまでは0(false)(無効)のものとみなされる。
※作成時間の必要性：簡単に言うと、2回メールを入力され二個の認証コードが生成された場合一番新しい認証コードと照合する必要があるため。

```java:Mail.java
import java.sql.Timestamp;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;

@Entity
@Data
@Table(name = "user_mails")
public class Mail {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Integer id;
	private String email;
	private String authenticationCode;
	private boolean nonLocked = false;
	private Timestamp createdAt;
}
```

#### POINT
今回`create_at`はTimestamp型にしている。

## Repositoryの作成
```java:MailRepository.java
import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.example.web.model.Mail;

public interface MailRepository extends JpaRepository<Mail, Integer> {
	@Query("SELECT a FROM Mail a WHERE a.email = :email ORDER BY a.createdAt DESC")
    List<Mail> findByEmail(@Param("email") String email, Pageable pageable);

    default Optional<Mail> findLatestByEmail(String email) {
        Pageable pageable = PageRequest.of(0, 1, Sort.by(Sort.Direction.DESC, "createdAt"));
        return findByEmail(email, pageable).stream().findFirst();
    }
}
```
#### POINT
Entityを作成する際に記述した通り、重複したメアドがある場合一番最後に登録された日時の認証コードをDBから引っ張ってくる必要があるため、上記のようなコードを書く。

## Validationの作成
今回はすごくシンプルなバリデーションをかけているだけである
```java:MailForm.java
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MailForm {

	@NotBlank(message = "入力必須です。")
	@Email
	private String email;
}
```
## Controllerの作成
今回はServiceの前にControllerの解説をする
```java:AllController.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import com.example.web.form.MailForm;
import com.example.web.model.Mail;
import com.example.web.service.MailService;

@Controller
public class AllController {

    //後に作成するMailServiceの呼び出し
	@Autowired
	private MailService mailService;

	@GetMapping("/")
	public String showHome(MailForm mailForm) {
		return "index";
	}

	@PostMapping("/sendMail")
	public String sendMail(@Validated MailForm mailForm, BindingResult bindingResult) {
		//バリデーションチェック
		if(!bindingResult.hasErrors()) {
			mailService.insertMail(mailForm); ///////////////////////[1]
			return "code_check";
		}else {
			return showHome(mailForm);
		}
	}
	
	@PostMapping("/codeCheck")
	public String codeCheck(
			@RequestParam("email")String email, 
			@RequestParam("code")String code, 
			Model model) {
		
		Mail mail = mailService.selectfindOneByEmail(email);//////////////////[2]
		
		//入力されたメアドが存在しなかった場合
		if(mail == null) {
			model.addAttribute("msg", "メールアドレスまたは認証コードに誤りがあります。");
			return "code_check";
		}
		
		//入力されたメアドのデータの認証コードと照合
		if(mail.getAuthenticationCode().equals(code)) {
			return "complete";
		}else {
			model.addAttribute("msg", "メールアドレスまたは認証コードに誤りがあります。");
			return "code_check";
		}
	}

	@GetMapping("/back")
	public String back(MailForm mailForm) {
		return showHome(mailForm);
	}
}
```

#### POINT
・簡潔に書きます。
　[1]：入力に不備がなければ、`MailService`に入力されたメアドを送信します。
　[2]：入力されたメースアドレスを`MailService`に送り、同じメアドの最新の情報を取ってきてもらいif文で入力された認証コードと照合を行います。

## Serviceの作成
今回、このServiceが味噌です。
```java:MailService.java
import java.sql.Timestamp;
import java.util.Optional;
import java.util.Random;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

import com.example.web.form.MailForm;
import com.example.web.model.Mail;
import com.example.web.repository.MailRepository;

@Service
public class MailService {
	@Autowired
	private MailRepository mailRepository;

	@Autowired
	private JavaMailSender mailSender;

	//入力項目をDBに保存
	public void insertMail(MailForm mailForm) {
		Mail mail = new Mail();
		
		//確定を押した日時を取得
		Timestamp enableDateTime = new Timestamp(System.currentTimeMillis());

		// 認証コードを生成
		String verificationCode = generateVerificationCode();

		// メールを送信
		sendVerificationEmail(mailForm.getEmail(), verificationCode);

		mail.setEmail(mailForm.getEmail());
		mail.setAuthenticationCode(verificationCode);
		mail.setCreatedAt(enableDateTime);
		
		mailRepository.save(mail);
	}

	//認証コードの生成
	private String generateVerificationCode() {
		Random random = new Random();
		int code = 100000 + random.nextInt(900000);
		return String.valueOf(code);
	}

	//メール送信内容
	private void sendVerificationEmail(String to, String code) {
		SimpleMailMessage message = new SimpleMailMessage();
		message.setTo(to);
		message.setSubject("認証コード通知");
		message.setText("以下の認証コードを入力してください。\n\n"
				+ code + "\n\n"
				+ "このメールに覚えのない場合は、メールを削除してください。");
		mailSender.send(message);
	}

     //送信されてきたメールアドレスでrepositoryに送り、最新の情報を取ってくる。
	public Mail selectfindOneByEmail(String email) {
		Optional<Mail> mailOptional = mailRepository.findLatestByEmail(email);
		return mailOptional.orElse(null);
	}

}

```

#### POINT
・`private JavaMailSender mailSender;`これはお決まり構文です。無で入力してください。
・おそらくコメント読んでコードを見れば大体わかるかなと思います。
実際作りながら大体お決まり構文だなと思いました。

## HTML,CSSの作成
・メアド入力画面
```html:index.html
<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8">
		<title>メイン画面</title>
		<link rel="stylesheet" th:href="@{/css/index.css}" />
	</head>
	<body>
	
	    <div class="spinner-overlay" id="spinner">
	        <div class="spinner"></div>
	        <div class="loading-text">入力されたメールアドレスに認証コードを送信しています。</div>
	    </div>
	
	    <div class="container">
	        <h1>Send Mail Sample</h1>
			<hr>
	
	        <form th:action="@{/sendMail}" method="post" onsubmit="showSpinner()" th:object="${mailForm}">
	            <p>メールアドレス入力</p>
	            <div th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="error-message"></div>
	            <input type="email" id="email" name="email" th:value="*{email}" required><br>
	
	            <input type="submit" value="送信" class="button">
	        </form>
	    </div>
	
	    <script>
	        function showSpinner() {
	            document.getElementById('spinner').style.display = 'flex';
	        }
	
	        function hideSpinner() {
	            document.getElementById('spinner').style.display = 'none';
	        }
	
	        window.addEventListener('load', function () {
	            hideSpinner();
	        });
	
	        window.addEventListener('beforeunload', function () {
	            showSpinner();
	        });
	    </script>
	
	</body>
</html>
```
※やってみたらわかると思うのですが、これメール送信に少し時間がかかります。その時にほかのボタンが押されたり、少しでもストレスを与えないようにとSpiner機能を付けました。
次の画面に遷移するまで画面の真ん中がクルクルして他のボタン等は触れなくなります。

<details><summary>index.css</summary>

```css:index.css
body {
    font-family: Arial, sans-serif;
    background-color: #f4f4f9;
    margin: 0;
    padding: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
}

.container {
    text-align: center;
    background-color: #fff;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

h1 {
    color: #333;
    margin-bottom: 20px;
}

.error-message {
    color: red;
    font-size: 1em;
    margin-bottom: 10px;
}

p {
    margin: 10px 0;
    color: #333;
}

input[type="email"] {
    width: 80%;
    padding: 10px;
    margin: 10px 0;
    border: 1px solid #ccc;
    border-radius: 4px;
    box-sizing: border-box;
}

.button {
    background-color: #3498db;
    color: white;
    padding: 10px 20px;
    margin: 10px 0;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1em;
}

.button:hover {
    background-color: #2980b9;
}

.spinner-overlay {
    display: none; /* 初期状態で非表示 */
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.5); /* 背景の半透明黒 */
    justify-content: center;
    align-items: center;
    z-index: 9999;
}

.spinner {
    border: 16px solid #f3f3f3; /* Light grey */
    border-top: 16px solid #3498db; /* Blue */
    border-radius: 50%;
    width: 120px;
    height: 120px;
    animation: spin 2s linear infinite;
}

@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

.loading-text {
    color: white;
    margin-top: 20px;
    font-size: 1.5em;
}
```
</details>

・メアドとその認証コードの入力画面
```html:code_check.html
<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8">
		<title>認証チェック</title>
		<link rel="stylesheet" th:href="@{/css/code_check.css}" />
	</head>
	<body>
		<div class="container">
	        <h1>Code Check</h1>
			<hr>
	        <p th:if="${msg}" th:text="${msg}" class="error-message"></p>
	        
	        <form th:action="@{/codeCheck}" method="post">
	            <p>メールアドレス入力</p>
	            <input type="email" id="email" name="email" required><br>
	
	            <p>認証コード入力</p>
	            <input type="text" id="code" name="code" required><br>
	
	            <input type="submit" value="確定" class="button">
	        </form>
	        <form th:action="@{/back}" method="get">
	            <input type="submit" value="戻る" class="button">
	        </form>
	    </div>
</html>
```

<details><summary>code_check.css</summary>

```css:code_check.css
body {
    font-family: Arial, sans-serif;
    background-color: #f4f4f9;
    margin: 0;
    padding: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
}

.container {
    text-align: center;
    background-color: #fff;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

h1 {
    color: #333;
    margin-bottom: 20px;
}

.error-message {
    color: red;
    font-size: 1em;
    margin-bottom: 20px;
}

p {
    margin: 10px 0;
    color: #333;
}

input[type="email"], input[type="text"] {
    width: 80%;
    padding: 10px;
    margin: 10px 0;
    border: 1px solid #ccc;
    border-radius: 4px;
    box-sizing: border-box;
}

.button {
    background-color: #3498db;
    color: white;
    padding: 10px 20px;
    margin: 10px 0;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1em;
}

.button:hover {
    background-color: #2980b9;
}

```
</details>

・完了画面
```html:complete.html
<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8">
		<title>完了画面</title>
	</head>
	<body>
		<div align="center">
			<h1>認証成功！</h1>
			<form th:action="@{/back}" method="get">
				<input type="submit" value="最初へ戻る">
			</form>
		</div>
	</body>
</html>
```

## さいごに
・最後まで長々とお付き合いいただきありがとうございました。
・もし、エラーなどが出た場合は修正願いをよろしくお願いします。