Gmailについて考える

Eメールを使用する時、メールサーバーを自前で運用している猛者達もいるだろうけど、多くの人は、Gmail、Outlookなどのサービスを使っているだろう。僕は、主にGmailを使っている。Webブラウザでhttps://mail.google.comにアクセスしログインすれば、メールの閲覧や送信ができる。殆どそれで問題はない。だけれど僕はGmailもEmacsから使いたい。そんな事を考えていたけれど、設定が面倒な事や、そもそもGmail自体にも不満はなかったので、今まで手が出ないでいた。

Emacsでメールを送受信するためのメールクライアントは幾つかある。Mewなども候補になったけれど、今回はGnusを使う事にした。Gnusは元はニュースリーダーではあるものの、バックエンドにIMAP4を使用できるため、メールリーダーとしても使用できる。基本的にはこの記事を参考に設定し、いくつか変更したり、必要な関数を追加したりしている。

https://qiita.com/waiseiningenchokon/items/faca52752c2f78284ecd

gmail.el

(require 'smtpmail)
(require 'cl)
(require 'gnus-gmail-oauth)
(require 'gnus-x-gm-raw)
(require 'oauth2)
(require 'nnir)
(require 'url)

(defvar gmail-current-oauth2-application nil)
(defvar gmail-current-email nil)
(defvar gmail-current-token nil)
(defvar gmail-base-token nil)

(defvar gmail-current-authorize-url nil
  "Gmail用認証URL

通常はnilを設定する。必要に応じてこの変数をシャドーイングし使用する。")

(defun gmail-oauth2-param-get (sym)
  (alist-get sym
	     (cdr (car gmail-current-oauth2-application))))

(defun gmail-base-token-param-get (sym)
  (alist-get sym gmail-base-token))

(defun gmail-token-param-get (sym)
  (alist-get sym gmail-current-token))

(defun gmail-convert-json-to-elisp ()
  (interactive)
  (let ((data (json-read-from-string (buffer-string)))
	(buf (get-buffer-create "*Google OAuth2 Parameters*")))
    (prin1 data buf)
    (display-buffer buf)))

(defun gmail-format-authorize-url ()
  (format
   (string-join '("%s"
		  "?redirect_uri=urn:ietf:wg:oauth:2.0:oob"
		  "&scope=https://mail.google.com/"
		  "&response_type=code"
		  "&client_id=%s"))
   (gmail-oauth2-param-get 'auth_uri)
   (gmail-oauth2-param-get 'client_id)))

(defun gmail-login ()
  (interactive)
  (let ((gmail-authorize-url (gmail-format-authorize-url)))
    (browse-url gmail-authorize-url)))

(defun gmail-get-access-token-from-code (code)
  (interactive "sCode: ")
  (make-process
   :name "*Gmail Get Access Token From Code*"
   :buffer "*Gmail Get Access Token From Code*"
   :command
   `("curl" ,(gmail-oauth2-param-get 'token_uri)
     "--request" "POST"
     "--header" "Content-Type: application/x-www-form-urlencoded"
     "--data"
     ,(format
       "redirect_uri=urn:ietf:wg:oauth:2.0:oob&grant_type=authorization_code&client_id=%s&client_secret=%s&code=%s"
       (gmail-oauth2-param-get 'client_id)
       (gmail-oauth2-param-get 'client_secret)
       code))))

(defun gmail-get-access-token-from-refres-token ()
  "Gmail API用のアクセストークンをリフレッシュする"
  (interactive)
  (make-process
   :name "*Gmail Refresh Token*"
   :buffer "*Gmail Refresh Token*"
   :command
   `("curl" ,(gmail-oauth2-param-get 'token_uri)
     "--request" "POST"
     "--header" "Content-Type: application/x-www-form-urlencoded"
     "--data"
     ,(format
       "redirect_uri=urn:ietf:wg:oauth:2.0:oob&grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s"
       (gmail-oauth2-param-get 'client_id)
       (gmail-oauth2-param-get 'client_secret)
       (gmail-base-token-param-get 'refresh_token)))
   :sentinel
   (lambda (process event)
     (when (string-equal event "finished\n")
       (setq gmail-current-token (json-read-from-string
				  (with-current-buffer "*Gmail Refresh Token*"
				    (buffer-string))))))))

(defun gnus-gmail-oauth-token ()
  "既存の関数を上書きする"
  (make-oauth2-token :access-token (gmail-token-param-get 'access_token)))

(defun gnus-gmail-oauth2-imap-authenticator (user password)
  "Authenticator for GMail OAuth2.  Use as before-until advice for nnimap-login
See:  https://developers.google.com/gmail/xoauth2_protocol"
  (if (nnimap-capability "AUTH=XOAUTH2")
      (let ((token (gnus-gmail-oauth-token))
	    access-token)
	(setq access-token (oauth2-token-access-token token))
	(if (or (null token)
		(null access-token))
	    nil
	  (let (sequence challenge)
	    (erase-buffer)
	    (setq sequence (nnimap-send-command
			    "AUTHENTICATE XOAUTH2 %s"
			    (base64-encode-string
			     (format "user=%s\001auth=Bearer %s\001\001"
				     (nnimap-quote-specials user)
				     (nnimap-quote-specials access-token)))))
	    (setq challenge (nnimap-wait-for-line "^\\(.*\\)\n"))
	    ;; on successful authentication, first line is capabilities,
	    ;; next line is response
	    (if (string-match "^\\* CAPABILITY" challenge)
		(let (response (nnimap-get-response sequence))
		  (cons t response))
	      ;; send empty response on error
	      (let (response)
		(erase-buffer)
		(process-send-string
		 (get-buffer-process (current-buffer))
		 "\r\n")
		(setq response (nnimap-get-response sequence))
		(nnheader-report 'nnimap "%s"
				 (mapconcat (lambda (a)
					      (format "%s" a))
					    (car response) " "))
		nil)))))))

(setq gnutls-min-prime-bits 1024)
(setq gnutls-algorithm-priority "SECURE128:-VERS-SSL3.0:-VERS-TLS1.3")
(setq gnus-select-method '(nnnil ""))

(setq gnus-secondary-select-methods
      `((nnimap "gmail"
		(nnimap-address "imap.gmail.com")
		(nnimap-user ,gmail-current-email)
		(nnimap-server-port 993)
		(nnimap-stream ssl)
		;; Search
		(nnir-search-engine imap))))

(add-to-list 'nnir-imap-search-arguments '("gmail" . "X-GM-RAW"))
(setq nnir-imap-default-search-key "gmail")
(defadvice nnir-run-imap (before decode-group activate)
  (ad-set-arg 2 (mapcar 'gnus-group-decoded-name (ad-get-arg 2))))

(advice-add 'nnimap-login :before-until #'gnus-gmail-oauth2-imap-authenticator)
(setq gnus-permanently-visible-groups "^nnimap\\+gmail:INBOX\\|^nnimap\\+gmail:\\[Gmail\\]")

(setq gnus-gmail-oauth-client-id (gmail-oauth2-param-get 'client_id))
(setq gnus-gmail-oauth-client-secret (gmail-oauth2-param-get 'client_secret))

;; SMTMP XOAUTH2
(setq smtpmail-smtp-server "smtp.gmail.com"
      smtpmail-smtp-service 587)
(defvar %smtpmail-try-auth-method (symbol-function 'smtpmail-try-auth-method))
(defun smtpmail-try-auth-method (process mech user password)
 (if (eql mech 'xoauth2)
   (let ((token (gnus-gmail-oauth-token))
	  access-token)
     (setq access-token (oauth2-token-access-token token))
     (smtpmail-command-or-throw
      process
      (concat "AUTH XOAUTH2 "
              (base64-encode-string
               (format "user=%s\001auth=Bearer %s\001\001"
                       (nnimap-quote-specials user)
                       (nnimap-quote-specials access-token)) t))
      235))
   (funcall (symbol-value '%smtpmail-try-auth-method) process mech user password)))

(add-to-list 'smtpmail-auth-supported 'xoauth2)

(provide 'gmail)

以下、実装時のメモを書く。

Gmail APIでメールを送信する

Gmail APIを使用したメールの操作はOAuth2によりアクセストークンを取得し、そのアクセストークンを使用してGMail APIを呼び出すことでメールを操作する。今回はこの方法を用いてメールの送信を確認する。

プロジェクトを有効にする

(setq dummy-project-name "DUMMY_PROJECT")
(setq dummy-project-user-id "1")

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

(concat "https://console.cloud.google.com/apis/credentials/consent"
        "?"
        (format "authuser=%s" dummy-project-user-id)
        "?"
        (format "project=%s" dummy-project-name)
        )
https://console.cloud.google.com/apis/credentials/consent?authuser=1?project=DUMMY_PROJECT

この値は重要な値である。今回は以下の値を例として扱うことにする。

NameValue
Client IDDUMMY_CLIENT_ID
Client SecretDUMMY_CLIENT_SECRET
(setq dummy-client-id "DUMMY_CLIENT_ID")
(setq dummy-client-secret "DUMMY_CLIENT_SECRET")

Gmail APIを有効にする

(concat "https://console.cloud.google.com/apis/dashboard"
        "?"
        (format "authuser=%s" dummy-project-user-id)
        "?"
        (format "project=%s" dummy-project-name)
        )
https://console.cloud.google.com/apis/dashboard?authuser=1?project=DUMMY_PROJECT
(concat "https://console.cloud.google.com/apis/api/gmail.googleapis.com/metrics"
        "?"
        (format "authuser=%s" dummy-project-user-id)
        "?"
        (format "project=%s" dummy-project-name)
        )
https://console.cloud.google.com/apis/api/gmail.googleapis.com/metrics?authuser=1?project=DUMMY_PROJECT

OAuth2のアクセストークンを取得する

認可ページへのURLを組み立てる

(concat "https://accounts.google.com/o/oauth2/v2/auth"
	"?"
	"redirect_uri=urn:ietf:wg:oauth:2.0:oob"
	"&"
	"scope=https://mail.google.com/"
	"&"
	"response_type=code"
	"&"
	(format "client_id=%s" dummy-client-id)
	)
https://accounts.google.com/o/oauth2/v2/auth?redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=https://mail.google.com/&response_type=code&client_id=DUMMY_CLIENT_ID
アクセストークンの取得の為のパーミッション画面へのURLを生成する

Webブラウザで認可ページへアクセスし認可を行う

認可が正常に完了するとコードが表示される。 このコードはアクセストークンの初回の生成時に使用する。

(setq dummy-authorization-code "DUMMY_CODE")

OAuth2のアクセストークンを生成する

先ほど生成したコードを利用してアクセストークンを生成する。

:CLIENT_ID := dummy-client-id
:CLIENT_SECRET := dummy-client-secret
:CODE := dummy-authorization-code

POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

redirect_uri=urn:ietf:wg:oauth:2.0:oob&grant_type=authorization_code&code=:CODE&client_id=:CLIENT_ID&client_secret=:CLIENT_SECRET

アクセストークンには有効期限がある。有効期限の切れたアクセストークンは当然API呼び出しに失敗する。 アクセストークンの生成時にリクレッシュトークンも共に返される。 このリクレッシュトークンはアクセストークンを更新し期限を延長するために使用する。

(setq dummy-access-token "DUMMY_ACCESS_TOKEN")
(setq dummy-refresh-token "DUMMY_REFRESH_TOKEN")

期限の切れたOAuth2のアクセストークンをリフレッシュする

:CLIENT_ID := (plist-get gmail-secret-plist :gmail-client-id)
:CLIENT_SECRET := (plist-get gmail-secret-plist :gmail-client-secret)
:REFRESH_TOKEN := (plist-get gmail-secret-plist :gmail-oauth2-refresh-token)

POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

client_id=:CLIENT_ID&client_secret=:CLIENT_SECRET&redirect_uri=urn:ietf:wg:oauth:2.0:oob&grant_type=refresh_token&refresh_token=:REFRESH_TOKEN

メールを送信する

送信するメールを作成する

from email.mime.text import MIMEText

m = MIMEText('This is automated draft mail')
m['To'] = '[email protected]'
m['From'] = '[email protected]'
m['Subject'] = 'succeed!!'

return m.as_string()
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
To: [email protected]
From: [email protected]
Subject: succeed!!

This is automated draft mail

メールのデータをbase64形式にエンコードする

Q29udGVudC1UeXBlOiB0ZXh0L3BsYWluOyBjaGFyc2V0PSJ1cy1hc2NpaSIKTUlNRS1WZXJzaW9u
OiAxLjAKQ29udGVudC1UcmFuc2Zlci1FbmNvZGluZzogN2JpdApUbzogdG9AZXhhbXBsZS5jb20K
RnJvbTogZnJvbUBleGFtcGxlLmNvbQpTdWJqZWN0OiBzdWNjZWVkISEKClRoaXMgaXMgYXV0b21h
dGVkIGRyYWZ0IG1haWw=

上記のデータを生成できればよい。base64コマンドを使用すればbase64形式に エンコードできる。Pythonでbase64形式にエンコードする場合、 base64.b64encode()関数を使用する。

メール送信APIを呼び出す

:USER_ID = me
:ACCESS_TOKEN := dummy-access-token

POST https://gmail.googleapis.com/gmail/v1/users/:USER_ID/messages/send
Content-Type: application/json
Authorization: Bearer :ACCESS_TOKEN

{
  "id": "string",
  "labelIds": [],
  "snippet": "string",
  "payload": {
    "partId": "string",
    "mimeType": "string",
    "filename": "string",
    "headers": [
      {
        "name": "To",
        "value": "[email protected]"
      },
      {
        "name": "From",
        "value": "[email protected]"
      }
    ],
    "body": {
      "attachmentId": "",
      "size": 10,
      "data": "YWJjZGU="
    },
    "parts": []
  },
  "sizeEstimate": 10,
  "raw": "Q29udGVudC1UeXBlOiB0ZXh0L3BsYWluOyBjaGFyc2V0PSJ1cy1hc2NpaSIKTUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UcmFuc2Zlci1FbmNvZGluZzogN2JpdApUbzogdG9AZXhhbXBsZS5jb20KRnJvbTogZnJvbUBleGFtcGxlLmNvbQpTdWJqZWN0OiBzdWNjZWVkISEKClRoaXMgaXMgYXV0b21hdGVkIGRyYWZ0IG1haWw=",
}

スレッドの取得

:USER_ID = me
:ACCESS_TOKEN := gmail-oauth2-access_token

GET https://gmail.googleapis.com/gmail/v1/users/:USER_ID/threads
Content-Type: application/json
Authorization: Bearer :ACCESS_TOKEN

メッセージの一覧を取得する

:USER_ID = me
:ACCESS_TOKEN := gmail-oauth2-access_token

GET https://gmail.googleapis.com/gmail/v1/users/:USER_ID/messages
Content-Type: application/json
Authorization: Bearer :ACCESS_TOKEN