Clojure で Gmail API を使うサンプル (2021)  (with Spec)

# Gmail APIのドキュメントに Clojure がない
Gmail API は Gmail を利用して様々な自動化を行うための API です。
Python 2 系や Java , Node.js, Ruby などの有名言語はサポートしているようですが (Python 3 はどこ…？) , マイナーな言語は当然のことながらサポートしていません。

とはいえ, Clojure や Scala, TypeScript など夢のある言語でも利用したいので, コードを読んでえいっと翻訳したわけです。



# そーすこーど (小並感)
こちらです。 https://github.com/MokkeMeguru/gmail-clojure

やることとしては, Web サーバに組み込むことを想定した, メールの送信です。
本コードを参考に, 各々のサーバコードに組み込んでください。 (ライブラリではありません)

(2021/02/23 CLI で多少のことができるようにしました。詳しくはREADMEを見てください)
# 準備
Gmail API を使うには, Google Cloud Project で Gmail API を有効化する必要があります。

詳細には, [Python を使い、Gmail API 経由で Gmail の送受信を行う] (https://qiita.com/muuuuuwa/items/822c6cffedb9b3c27e21) を参考にできますが、スコープの追加はおそらく不要です。

簡単に紹介すると, 
1. GCP から Gmail API を有効化
2. 管理 -> 認証情報 -> + 認証情報を作成 -> OAuth クライアント ID
　アプリケーションの種類: デスクトップアプリ
  名前: <project-id>-client-id  (※任意の名前)
3. 認証情報画面の, 追加したクライアント ID -> JSON をダウンロード
　これを, **credential.json** とします。
4. 認証情報画面の, 追加したクライアント ID -> OAuth 同意画面
    * User Type: 外部　(※ここ, 会社などのケースでは, 内部にしてください)
    * アプリ名: <project-name> (※任意)
    * ユーザサポートメール: <your mail address>
    * ディベロッパーの連絡先情報: <your mail address>
    * スコープ: 操作しない
    * テストユーザ: ADD USERS より, 任意の (送信元の) メールアドレスを追加

ダウンロードした **credential.json** は認証情報なので、機密マシマシで保管してください。

# コードの話
やっていることは, Java コードの翻訳が大半です。
差異としては Spec を用いて入力の厳格化をしている部分, 定数部分と変数部分の修正です。

## import library
<details> <summary> ライブラリのインポート </summary> <div>

```clojure
(ns gmail-clojure.core
  (:gen-class)
  (:require [clojure.spec.alpha :as s]
            [clojure.java.io :as io]
            ;; test
            [orchestra.spec.test :as st])
  (:import [com.google.api.services.gmail GmailScopes]
           [com.google.api.client.json JsonFactory]
           [com.google.api.client.json.jackson2 JacksonFactory]
           [com.google.api.client.http.javanet NetHttpTransport]
           [com.google.api.client.googleapis.javanet GoogleNetHttpTransport]
           [com.google.api.client.extensions.java6.auth.oauth2 AuthorizationCodeInstalledApp]
           [com.google.api.client.googleapis.auth.oauth2 GoogleAuthorizationCodeFlow]
           [com.google.api.client.googleapis.auth.oauth2 GoogleAuthorizationCodeFlow$Builder]
           [com.google.api.client.util.store FileDataStoreFactory]
           [com.google.api.client.googleapis.auth.oauth2 GoogleClientSecrets]
           [com.google.api.client.extensions.jetty.auth.oauth2 LocalServerReceiver]
           [com.google.api.services.gmail Gmail$Builder Gmail]
           [java.util Properties]
           [javax.mail.internet MimeMessage]
           [javax.mail Session]
           [javax.mail.internet InternetAddress]
           [javax.mail Message$RecipientType]
           [org.apache.commons.codec.binary Base64]
           [com.google.api.services.gmail.model Message]
           [com.google.api.client.auth.oauth2 Credential]
           [com.google.api.services.gmail.model Label]))
```

</div></details>

## static variables
```clojure
(def json-factory (JacksonFactory/getDefaultInstance))
(def http-transport (GoogleNetHttpTransport/newTrustedTransport))
(def charset "utf-8") ;; 文字コード。 ascii だと日本語の入力なんかがしんどいです。
(def encode "base64")
```
## domain & Spec 
<details> <summary> 変数の Spec と補助関数 </summary> <div>
```clojure
(def email-regex  #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$")
(def net-http-transport? (partial instance? NetHttpTransport))
(def satisfy-email-regex? (partial re-matches email-regex))
(def gmail-api-scope? string?)
(def url? (partial instance? java.net.URL))
(def file-exist? #(-> % io/as-file .exists))
(def credential? (partial instance? Credential))

(def gmail-service? (partial instance? Gmail))
(def mime-message? (partial instance? MimeMessage))
(def gmail-message? (partial instance? Message))

(def google-auth-code-flow? (partial instance? GoogleAuthorizationCodeFlow))

;; resource files
(s/def ::credential-file (s/and url? file-exist?))
(s/def ::tokens-dir (s/and url? file-exist?))

;; google api's settings
(s/def ::http-transport net-http-transport?)
(s/def ::scope gmail-api-scope?)
(s/def ::scopes (s/coll-of ::scope))
(s/def ::application-name string?)
(s/def ::service gmail-service?)
(s/def ::port pos-int?)
(s/def ::auth-code-flow google-auth-code-flow?)

(s/def ::credential credential?)
(s/def ::user-id string?)
(s/def ::gmail-label (partial instance? Label))
(s/def ::gmail-labels (s/coll-of ::gmail-label))
(s/def ::gmail-message gmail-message?)

(s/def ::id string?)
(s/def ::label-id string?)
(s/def ::label-ids (s/coll-of ::label-id))
(s/def ::thread-id string?)
(s/def ::gmail-response (s/keys :req-un [::id ::label-ids ::thread-id]))

;; email contents
(s/def ::address (s/and string? satisfy-email-regex?))

(s/def ::to ::address)
(s/def ::from ::address)
(s/def ::subject string?)
(s/def ::message string?)

(s/def ::mime-message mime-message?)```

</div></details>
## 関数 spec
```clojure
;; 認証情報の獲得
(s/fdef get-credential
  :args (s/cat
         :credential-file ::credential-file
         :tokens-dir ::tokens-dir
         :scopes ::scopes)
  :ret ::credential)

;; 認証情報を用いて, Gmail API を activate する
(s/fdef get-service
  :args (s/cat
         :application-name ::application-name
         :credential ::credential)
  :ret ::service)

;; MIME メッセージの作成
(s/fdef create-message
  :args (s/cat
         :kwargs  (s/keys :req-un [::to
                                   ::from
                                   ::subject
                                   ::message]))
  :ret ::mime-message)

;; MIME メッセージを Gmail API の引数用にエンコードする
(s/fdef create-message-with-email
  :args (s/cat :email-content ::mime-message)
  :ret ::gmail-message)

;; メッセージを送信する
(s/fdef send-message
  :args (s/cat :service ::service
               :user-id ::user-id
               :message ::gmail-message)
  :ret ::gmail-response)

```

# 実装

`#get-credential` は, ファイルの入力が含まれ, 外部へ多く触れる部分なので pre/post 条件を追加しています。
(正確には spec 内で, ファイルがあることを確かめています。)

`#create-message` についても, 外部のデータを入力に用いるので, pre/post 条件をつけて厳格化しています。 (to, from は, **メールアドレス** である必要が在るので追加のバリデーションをかけています)

```clojure
(defn- get-auth-code-flow "
  helper function to obtain google authorization code flow
  "
  [credential-file tokens-dir scopes]
  {:pre [(s/valid? ::credential-file credential-file)
         (s/valid? ::tokens-dir tokens-dir)]
   :post [(s/valid? ::auth-code-flow %)]}
  (with-open [in (io/input-stream credential-file)]
    (let [secrets (GoogleClientSecrets/load json-factory (java.io.InputStreamReader. in))
          file-data-store-factory (FileDataStoreFactory. (io/file tokens-dir))]
      (.. (GoogleAuthorizationCodeFlow$Builder. http-transport json-factory secrets scopes)
          (setDataStoreFactory file-data-store-factory)
          (setAccessType "offline")
          (setApprovalPrompt "force")
          build))))

(defn get-credential "
  get credential info from credential-file and stored secret file
  if secret file is expired or some thing wrong, you need to generate new secret file.
  "
  [credential-file tokens-dir scopes]
  {:pre [(s/valid? ::credential-file credential-file)
         (s/valid? ::tokens-dir tokens-dir)]
   :post [(s/valid? ::credential %)]}
  (let [flow (get-auth-code-flow credential-file tokens-dir scopes)
        credential (-> flow (.loadCredential "user"))
        credential (cond
                     (nil? credential) nil
                     (or (some? (.getRefreshToken credential))
                         (nil? (.getExpiresInSeconds credential))
                         (> (.getExpiresInSeconds credential) 60)) credential
                     :else nil)]
    (when (nil? credential)
      (throw (Exception. "credential file is expired: please re-generate credential file using cli tool. https://github.com/MeguruMokke/gmail_clojure")))
    (println "refresh token: " (.getRefreshToken credential))
    (println "expires in seconds: " (.getExpiresInSeconds credential))
    credential))

(defn get-service "
  get gmail api service's connection with application-name (string)
  "
  [^String application-name credential]
  (.. (Gmail$Builder. http-transport json-factory credential)
      (setApplicationName application-name)
      build))

(defn create-message "
  create email message
  "
  [{:keys [to from subject message]}]
  {:pre [(s/valid? ::to to)
         (s/valid? ::from from)
         (s/valid? ::subject subject)
         (s/valid? ::message message)]
   :post [(s/valid? ::mime-message %)]}
  (let [props (Properties.)
        session (Session/getDefaultInstance props nil)
        email (MimeMessage. session)]
    (doto
     email
      (.setFrom (InternetAddress. from))
      (.addRecipient Message$RecipientType/TO (InternetAddress. to))
      (.setSubject subject charset)
      (.setText message charset))))

(defn create-message-with-email "
  encode email message into gmail api's code
  "
  [email-content]
  (let [binary-content (with-open [xout (java.io.ByteArrayOutputStream.)]
                         (.writeTo email-content xout)
                         (.toByteArray xout))
        encoded-content (Base64/encodeBase64URLSafeString binary-content)
        message (Message.)]
    (doto
     message
      (.setRaw encoded-content))))

(defn send-message "
  send message using google api
  "
  [service user-id message]
  (let [response (-> service
                     .users
                     .messages
                     (.send user-id message)
                     .execute)
        response-map  (into {} response)]
    {:id (get response-map "id")
     :label-ids (-> (get response-map "labelIds") vec)
     :thread-id (get response-map "threadId")}))
```

## 実行例

```clojure
(def application-name "Gmail API usage example")
(def credential-file (io/resource "credentials.json"))
(def tokens-dir (io/resource "tokens"))
(def scopes [GmailScopes/GMAIL_LABELS
             GmailScopes/GMAIL_SEND])
(def user "me")

(def example-message
  "Dear <user>.
Hello, nice to meet you.
Thank you for using our service.
You receive this message from us.
---
Have a nice day!
良い一日を！
---
sended from: xxx.service
owner      : meguru.mokke@gmail.com
")

(def message-map  {:to "meguru.mokke@gmail.com"
                   :from "meguru.mokke@gmail.com"
                   :subject "test"
                   :message example-message})

(defn send-example-message []
  (let [credential  (get-credential credential-file tokens-dir scopes)
        service   (get-service application-name credential)
        user-id user
        email-content (create-message message-map)
        message (create-message-with-email email-content)]
    (send-message service user-id message)))

(defn -main
  [& args]
  (let [message-response (send-example-message)]
    (println "send example message to " (:to message-map))
    (clojure.pprint/pprint message-response)))
```

実行結果

```
send example message to  meguru.mokke@gmail.com
{:id "---------",
 :label-ids ["SENT"],
 :thread-id "------------"}
```

# 感想
こういったよくわからない手探りコードは TDD よりも先に インタープリタで試行錯誤しながら雑にコードを書いて, リファクタリングで仕様を決めたほうが早いですね (TDDやれるような, 先にすべての答えがわかる人には僕の技術力じゃなれませんね…)

また, String 型であっても, 実は更にメールアドレスや文字数制限の制限をかけたい際に, Clojure では Spec を用いて **新しい型を作ることなくバリデーションを追加できる** ので便利ですね。

ただし, **`fdef` は実行環境では active ではない** ので, pre/post 条件を用いて, このあたりの重要箇所はバリデーションをかけていく必要がある点に注意する必要があります。 


あと、Google OAuthの仕様として、**定期的に (refresh を含め) Tokenの有効期限が切れる** ので、少なくとも OAuth を使う場合にはブラウザ経由の認証を定期的に実行する必要があります。
要するに、一度 token を取得して、GKE なんかにデプロイ、後は放置、ということができない、ということです。