Gmail API でメールを送信する(Java)

Java

G Suiteが2020年6月から 安全性の低いアプリ(LSA : Less Secure App)を制限するようです。
GmailのSMTPサーバーに対してアプリパスワードでのメール送信ができなくなりそうなので、Gmail APIを使ってメールを送信してみました。

OAuth 2.0 クライアントIDファイルをダウンロード

Gmail APIを使うために、OAuth 2.0でGoogleアカウントを認証する必要があります。
Google Cloud Platform でプロジェクトを作成します。

Gmail APIを有効にする

プロジェクトを作成したら APIライブラリ からGmailを検索して有効にします。

OAuth 2.0 クライアントIDを作成する

「認証情報を作成」をクリックします。

使用するAPIは「Gmail API」を選択します。Gmail APIが選択できなければ有効になってないかもしれません。
APIを呼び出す場所はSpring Bootを使用するので「ウェブサーバー(node.js、Tomcatなど)」を選択します。
アクセスするデータの種類は「ユーザーデータ」を選択して「必要な認証情報」をクリックします。

OAuth 同意画面の設定がなければ設定を求められるので、「同意画面を設定」をクリックします。

User Type で外部を選択し、作成をクリックします。

アプリケーション名を決めて設定を保存します。

認証情報の作成ページに戻って入力を続けます。

名前を決めて、承認済みのリダイレクトURIに「http://localhost:8080/callback」を入力します。
複数入力できるので、独自ドメインのURI等があればここに入力します。
ここに入力していないURIを RedirectUri にセットして認証画面にアクセスすると redirect_uri_mismatch エラーになってしまうので注意が必要です。

入力できたら更新をクリックして「OAuthクライアントIDを作成」ボタンが表示されたらクリックします。

ダウンロードをクリックすると「client_id.json」がダウンロードできます。
完了をクリックして終了です。

client_id.json

{
  "web": {
    "client_id": "XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.apps.googleusercontent.com",
    "project_id": "gmail-api-sample-20200504",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_secret": "XXXXXXXXXXXXXXXXXXXXXXXX",
    "redirect_uris": [
      "http://localhost:8080/callback"
    ]
  }
}

サンプルプロジェクト

Spring Bootでサンプルプロジェクトを作成して検証しました。
プロジェクト一式GitHubにアップしています。
https://github.com/wataru218/sample-oauth-gmail

sample-oauth-gmail
├─src
│  └─main
│     ├─java
│     │  └─sample
│     │          Application.java
│     │          GmailController.java
│     │          GmailSender.java
│     │          GoogleApiUtils.java
│     │
│     └─resources
│         │  application.properties
│         │  client_id.json
│         │
│         └─templates
│                 gmail.html
│
└─pom.xml

使用したライブラリ

Mavenの依存関係に Spring MVC、Thymeleaf、Email と、Gmail API を操作するための Gmail API Client Library for Java を追加しています。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.2.6.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>sample</groupId>
	<artifactId>sample-oauth-gmail</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>sample-oauth-gmail</name>
	<description>Demo project for Spring Boot</description>

	<properties>
		<java.version>1.8</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-mail</artifactId>
		</dependency>
		<dependency>
			<groupId>com.google.apis</groupId>
			<artifactId>google-api-services-gmail</artifactId>
			<version>v1-rev20200406-1.30.9</version>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
			<exclusions>
				<exclusion>
					<groupId>org.junit.vintage</groupId>
					<artifactId>junit-vintage-engine</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

GmailController.java

メールを送信するコントローラーです。
/sendmail へアクセスされたらGoogleアカウントの認証情報を読み込み、認証情報がなければGoogleアカウント認証用のURLを作成してリダイレクトしています。

	@GetMapping("/sendmail")
	public String sendmail(Model model) {
		System.out.println("/sendmail");
		try {
			// Google OAutn
			Credential credential = GoogleApiUtils.loadCredential();
			if (credential == null) {
				return "redirect:" + GoogleApiUtils.newAuthorizationUrl(callbackUrl);
			}
			// Send Gmail
			MimeMessage email = createEmail(toAddress, "TEST", "Hello, world!");
			gmailSender.send(email);

			model.addAttribute("message", "sent!");
			return "gmail";
		} catch (Exception e) {
			e.printStackTrace();
		}
		model.addAttribute("message", "error!");
		return "gmail";
	}

リダイレクト先はGoogleアカウントの認証ページです。
Googleアカウントを選択します。

危険な見た目のメッセージが表示されますが、自分で作ったアプリなので問題ありません。
詳細を表示してアプリケーションに移動します。

Gmail API の付与する権限の許可を求めるダイアログが表示されるので許可をクリックします。

付与する権限を確認して許可ボタンをクリックします。

http://localhost:8080/callback に処理が戻ってくるので、Googleアカウントの認証情報を保存して /sendmail へリダイレクトしています。

	@GetMapping("/callback")
	public String callback(@RequestParam String code) throws Exception {
		System.out.println("/callback");
		Credential credential = GoogleApiUtils.createAndStoreCredential(code, callbackUrl);
		if (credential == null) {
			return "error";
		}
		return "redirect:/sendmail";
	}

今度は認証情報があるのでテストメールが送信されます。

認証情報の AccessToken は60分間有効です。有効な間は同じ AccessToken で何度もメール送信できます。
AccessToken が無効になってしまっても、認証時に RefreshToken を取得していれば AccessToken を自動的に再取得してくれるので基本的にいつまでもメール送信できます。

RefreshToken はGoogleアカウントのパスワードを変更したり、50個以上発行した場合、もしくは半年以上使用しなかった場合に無効になるようです。

GoogleApiUtils.java

Gmail API を使用するために必要な処理をまとめたユーティリティクラスです。

下記を参考に AuthorizationCodeInstalledApp や LocalServerReceiver を使用すると、コールバックを待ってフリーズしてしまうので、GoogleAuthorizationCodeFlowクラスを使って必要な処理をまとめています。
https://developers.google.com/api-client-library/java/google-oauth-java-client/oauth2

注意が必要なのが、 client_id.json ファイルが web の設定だと、「.setAccessType(“offline”).setApprovalPrompt(“force”)」としていないと、RefreshToken が取得できずにはまります。

public class GoogleApiUtils {

	private static final String COMMON_USER_ID = "user";
	private static final String CLIENT_ID_JSON = "/client_id.json";
	private static final File CREDENTIALS_DIRECTORY = new File(System.getProperty("user.home"), ".credentials");
	private static final List<String> OAUTH_SCOPES = Collections.singletonList(GmailScopes.GMAIL_COMPOSE);
	private static GoogleAuthorizationCodeFlow FLOW;

	static {
		try {
			FLOW = new GoogleAuthorizationCodeFlow
					.Builder(Utils.getDefaultTransport(), Utils.getDefaultJsonFactory(), loadClientSecrets(), OAUTH_SCOPES)
					.setDataStoreFactory(new FileDataStoreFactory(CREDENTIALS_DIRECTORY))
					.setAccessType("offline")
					.setApprovalPrompt("force")
					.build();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	public static Gmail getGmailClient(String applicationName) throws IOException {
		return new Gmail.Builder(Utils.getDefaultTransport(), Utils.getDefaultJsonFactory(), loadCredential())
				.setApplicationName(applicationName)
				.build();
	}

	public static Credential loadCredential() throws IOException {
		Credential credential = FLOW.loadCredential(COMMON_USER_ID);
		if (credential == null) {
			return null;
		}
		if (credential.getRefreshToken() != null
				|| credential.getExpiresInSeconds() == null
				|| credential.getExpiresInSeconds() > 60) {
			return credential;
		}
		System.out.println("RefreshToken:\t" + credential.getRefreshToken());
		System.out.println("ExpiresInSeconds:\t" + credential.getExpiresInSeconds());
		return null;
	}

	public static String newAuthorizationUrl(String callbackUrl) throws IOException {
		AuthorizationCodeRequestUrl authorizationUrl = FLOW.newAuthorizationUrl().setRedirectUri(callbackUrl);
		String url = authorizationUrl.build();
		return url;
	}

	public static Credential createAndStoreCredential(String code, String callbackUrl) throws IOException {
		TokenResponse tokenResponse = FLOW.newTokenRequest(code).setRedirectUri(callbackUrl).execute();
		System.out.println("Response AccessToken:\t" + tokenResponse.getAccessToken());
		System.out.println("Response ExpiresInSeconds:\t" + tokenResponse.getExpiresInSeconds());
		System.out.println("Response RefreshToken:\t" + tokenResponse.getRefreshToken());
		Credential credential = FLOW.createAndStoreCredential(tokenResponse, COMMON_USER_ID);
		return credential;
	}

	private static GoogleClientSecrets loadClientSecrets() throws IOException {
		String resourceName = CLIENT_ID_JSON;
		InputStream in = GoogleApiUtils.class.getResourceAsStream(resourceName);
		if (in == null) {
			throw new FileNotFoundException("Resource not found: " + resourceName);
		}
		GoogleClientSecrets clientSecrets = GoogleClientSecrets
				.load(Utils.getDefaultJsonFactory(), new InputStreamReader(in, UTF_8));
		return clientSecrets;
	}
}

GmailSender.java

JavaMailSenderImplを継承してGmail送信用クラスを作成しました。

Gmail API を直接実行するなら必要ないクラスかもしれませんが、JavaMailSender を使用して Gmail の SMTPサーバーへメール送信している場合はGmailSenderに置き換えできると思います。

@Component
public class GmailSender extends JavaMailSenderImpl {

	@Override
	protected void doSend(MimeMessage[] mimeMessages, Object[] originalMessages) throws MailException {
		try {
			for (MimeMessage mimeMessage : mimeMessages) {
				Message message = createMessageWithEmail(mimeMessage);
				send(message);
			}
		} catch (IOException | MessagingException e) {
			throw new MailSendException("Error", e);
		}
	}

	protected Message createMessageWithEmail(MimeMessage emailContent) throws IOException, MessagingException {
		ByteArrayOutputStream buffer = new ByteArrayOutputStream();
		emailContent.writeTo(buffer);
		byte[] bytes = buffer.toByteArray();
		String encodedEmail = Base64.encodeBase64URLSafeString(bytes);
		Message message = new Message();
		message.setRaw(encodedEmail);
		return message;
	}

	protected void send(Message content) throws IOException, MessagingException {
		Gmail client = GoogleApiUtils.getGmailClient(Application.NAME);
		Profile profile = client.users().getProfile("me").execute();
		String userId = profile.getEmailAddress();
		client.users().messages().send(userId, content).execute();
	}
}
タイトルとURLをコピーしました