✉️

KotlinでGmailのAPI経由でメールを取得したり既読フラグを付けたりする

2022/05/16に公開

はじめに

KotlinでGmailのメールを取得して解析したり、既読フラグを付けたりする必要があったので方法をまとめました。
流れとしては、まずGoogle Cloud Platform(GCP)上でGmailへのアクセスが可能なクライアント用OAuthトークンを発行します。
そのOAuthトークンを用いて、任意のGmailアカウントでクライアントを認証しユーザトークンを発行、そのトークンを用いてメール取得などを行います。

Google Cloud Platformでの作業

GCP上で行うことは、自分のアカウントに紐付いたGmail API用のOAuthトークンを発行することです。ここで言う「自分のアカウント」はメールを取得したいアカウントでなくてもOKです。
任意のアカウントでクライアントID用のOAuthトークンを発行し、そのトークンを用いて任意のGmailアカウント(アプリ認証済み)のメールを取得することが可能なアプリケーションを作成します。

クライアント用のOAuthトークン発行

Google Cloud Platformで、API Serviceのページに移動します。

Image description

Image description

Gmail APIを有効にします。
https://console.cloud.google.com/apis/library/gmail.googleapis.com
Image description

認証情報からOAuthトークン(credentials)を発行します。

Image description

Image description

発行したトークン(credentials)はこちらからjsonとしてダウンロードしておきKotlinプロジェクトディレクトリにおいておきます。
Image description

また、作成したアプリケーションは本番に公開、もしくはテストユーザとして使用したいGmailアカウントを登録しておく必要があります。

本番に公開した場合でも、credentialsが知られていなければ他人に悪用されることはありませんのでどちらでもOKだと思います。

これでGoogle Cloud Platform上での作業は終了です。

Kotlinの実装

build.gradle.ktsはこちら

dependencies {
    implementation("com.google.api-client:google-api-client:1.34.1")
    implementation("com.google.oauth-client:google-oauth-client-jetty:1.33.3")
    implementation("com.google.apis:google-api-services-gmail:v1-rev20220404-1.32.1")
}

まずはOAuth認証をする部分を実装します。

/**
   * このアプリケーションに付与する権限の一覧。必要に応じて適切に設定してください
   * https://developers.google.com/resources/api-libraries/documentation/gmail/v1/java/latest/com/google/api/services/gmail/GmailScopes.html
   */
  private val SCOPES = setOf(
    GmailScopes.GMAIL_LABELS,  // メールのラベルを取得する権限
    GmailScopes.GMAIL_READONLY,   // メールを読み取る権限
    GmailScopes.GMAIL_MODIFY,  // メールのラベルの修正や送信なども行える権限
  )

  /**
   * jsonのアプリケーション情報を用いてOAuthユーザ認証を行います。
   */
  fun getCredentials(
    httpTransport: NetHttpTransport,
    jsonFactory: GsonFactory,
    credentialsFilePath: String = "credentials.json",
    tokenDir: String = "tokens"
  ): Credential? {
    // val jsonFactory: GsonFactory = GsonFactory.getDefaultInstance()
    // Googleが提供するHTTPクライアントを生成(これは、通常のNetHttpTransportにGoogle API用のルート証明書が埋め込まれていたりするようです。)
    // val httpTransport = GoogleNetHttpTransport.newTrustedTransport()

    // OAuth認証情報の読み込み
    val inputStream = File(credentialsFilePath).inputStream()
    // json形式の認証情報を読み込む
    val clientSecrets: GoogleClientSecrets = GoogleClientSecrets
      .load(jsonFactory, InputStreamReader(inputStream))

    // 認証フローの設定をする。
    val flow: GoogleAuthorizationCodeFlow = GoogleAuthorizationCodeFlow
      .Builder(httpTransport, jsonFactory, clientSecrets, SCOPES)
      .setDataStoreFactory(FileDataStoreFactory(File(tokenDir))) // トークン保存先ディレクトリの指定
      .setAccessType("offline")  // web applicationの場合はonline, それ以外はoffline
      .build()

    // callback受け取り用URL(localhost:8888)のWebサーバを建てる
    val receiver = LocalServerReceiver.Builder().setPort(8888).build()
    // 認証用を行う(ここでWebサイトが開きユーザにアプリ認証を実行させる)
    return AuthorizationCodeInstalledApp(flow, receiver).authorize("user")
  }

getCredentials()を実行すると、以下のようなアプリケーション認証画面が立ち上がり、適切な権限を許可してアプリケーションにコールバック(localhost:8888)してあげます。

アプリケーションは受け取ったコールバックに含まれるユーザトークンをもとに、以後Gmailの操作を行っていきます。

Gmail serviceインスタンスの作成

fun main() {
  val jsonFactory: GsonFactory = GsonFactory.getDefaultInstance()
  // Googleが提供するHTTPクライアントを生成(これは、通常のNetHttpTransportにGoogle API用のルート証明書が埋め込まれていたりするようです。)
  val httpTransport = GoogleNetHttpTransport.newTrustedTransport()

  val credential = getCredentials(httpTransport, jsonFactory, "credentials.json", "tokens")
  // Gmail serviceを作成。Gmailの各種操作には以降このserviceを使用します。
  val service: Gmail = Gmail.Builder(httpTransport, jsonFactory, credential)
    .setApplicationName("kotlin-gmail-reader")
    .build()
  readMailAndRemoveUnread(service)  // 下で実装します
}

ここで作成されたserviceインスタンスを用いて以後Gmailの操作が可能となります。

そして、メールを読み込んで既読フラグを付与するプログラムを作成します。

fun readMailAndRemoveUnread(service: Gmail) {
  val user = "me"
  val targetLabel = "UNREAD" // 今回の読み込み対象のラベルは未読メールとします。INBOXやSENT,IMPORTANTなども選べます。
  val labelList = service.users().labels().list(user).execute()
  // ラベル一覧を出力してみます。
  labelList.labels.forEach(::println)
  // UNREADラベルインスタンスを取得します
  val label = labelList.labels.find { it.name == targetLabel } ?: error("$targetLabel label not found")

  // 取得対象のメッセージID一覧を取得する(ここではメールヘッダや本文は取得できない)
  val messageIdList = service.users().messages().list(user).apply {
    labelIds = listOf(label.id)
    pageToken = null  // 初回はnull。続きを取得する場合に指定する。
    includeSpamTrash = true
    maxResults = 100 // メッセージ取得数
  }.execute()

  val mailMessageList = messageIdList.messages.map { messageId ->
    // メッセージIDをもとにメッセージを取得する
    val message = service.users().messages().get(user, messageId.id).execute()

    // メール本文はBase64でエンコードされているのでデコードする
    val body = try {
      // palyloadのbodyにdata要素がある場合
      val massageBase64 = message.payload.body.getValue("data") as CharSequence
      String(BaseEncoding.base64Url().decode(massageBase64), Charset.forName("UTF-8"))
    } catch (e: Exception) {
      if (message.payload.parts.isNotEmpty()) {
        // Base64がpartsというヘッダで複数要素に分割されている場合があるのでここで結合しながらデコードする
        message.payload.parts.filter { it.body.containsKey("data") }
          .map { it.body?.getValue("data") as CharSequence }
          .map { String(BaseEncoding.base64Url().decode(it), Charset.forName("UTF-8")) }
          .joinToString("")
      } else {
        // それ以外の場合は今回は対象外とする(本来はしっかりと処理する必要があるが)
        ""
      }
    }
    // メールのヘッダがこのままでは使いにくいのでMapに変換する
    val headersMap = message.payload.headers.associate { it.name.lowercase(Locale.getDefault()) to it.value }
    // MailMessageは最下部で定義しているdata classです。
    val mailMessage = MailMessage(headersMap.getOrDefault("date", ""), headersMap.getOrDefault("from", ""), headersMap.getOrDefault("subject", ""), body)
    // メール内容を出力する
    println(mailMessage.date + " " + mailMessage.from + " " + mailMessage.subject + " " + mailMessage.body)

    // メールを既読にする(GoogleではUNREADラベルが付与されているものが未読のため、そのUNREADラベルを削除すると既読となる)
    if (message.labelIds?.contains("UNREAD") == true) {
      val batchRemoveUnreadLabel = BatchModifyMessagesRequest().apply {
        removeLabelIds = listOf(label.id)
        ids = listOf(message.id)
      }
      service.users().messages().batchModify(user, batchRemoveUnreadLabel).execute()
    }
    mailMessage
  }
}

data class MailMessage(val date: String, val from: String, val subject: String, val body: String)

以上でメールの取得やメールの既読操作などができました。
同じような操作でメールの送信なども可能になると思います。
Javaの公式ドキュメントはこちらにありますので合わせてご参照いただければと思います。
https://developers.google.com/gmail/api/quickstart/java

Discussion