TypeScriptで記述するGMailの送信処理、そして地獄のtypescript-eslint

# Node.jsでGMailのメール送信、そんな処理ですら地獄を体感できる、そうTypeScriptならね

　表題の通り、メール送信処理が必要になったので、無難にGMailのAPIを使うことにした。GMailに関してはネット上にそれなりに情報があるし、公式サイトには認証系の使い方が載っている。素直に作れば何の苦労も無い。あまりにもヌルゲーだ。

　しかしそれでは面白くない。せっかくだからTypeScriptで記述しつつ、typescript-eslint(tslint)で構文チェックも行う縛りプレイでプログラムを書いてみることにした。ネットに転がっているサンプル類は当然JavaScriptで書かれており、TypeScriptのサンプルなど皆無である。その上、typescript-eslintで地獄の蓋を開けようと思う者など存在しないはずだ。


## 作成したプログラムに関して

### 地獄のtypescript-eslintに関して

　風紀委員もびっくりなほど規律に厳しく怒りっぽいtypescript-eslint。彼だか彼女だかに怒られないようにするには、anyを一切使ってはならない。また、戻り値の型を設定しなくても怒られるので、とにかくしつこいぐらいに書く。TypeScriptの自動推論の意味を崩壊させているような気もするが、きっちり書くことによって、想定外のミスを発見することもある。そう馬鹿には出来ないのだ。

　エラー回避設定をしているcamelcaseは、そもそもGoogleが名前にアンダースコアを使っているのでどうにもならない。no-console回避もコンソール出力を必要としているので、所々に入れてある。

　typescript-eslintに関して、今回は小規模なプログラムなのでそれほど大したことは無かったが、自作の[フロントエンドフレームワーク](https://www.npmjs.com/package/javascript-window-framework)に適用したときは、あまりの大量エラーに心が折られそうになった。もちろん泣きそうになりながら全部直した。

### 追加モジュール

通常構成以外に必要なのはgoogleapisなので、

```
npm i googleapis
```

を入れると幸せになれるかもしれない。

### 送信用クラスのソースコード

　できる限り簡潔に書いたつもりなのだが、なんだかんだで結構長くなってしまった。ちなみにGMailのAPIはfromを設定しても、対象アカウントにそのメールアドレスのエイリアスが無いと無視されるらしい。

```gmail-sender.ts
/* eslint-disable @typescript-eslint/camelcase */
import * as fs from "fs";
import * as readline from "readline";
import { google } from "googleapis";
import { OAuth2Client } from "google-auth-library";

/**
 *client_secret.json用インタフェイス
 *
 * @interface GoogleCredentils
 */
interface GoogleCredentils {
  installed?: {
    client_id: string;
    project_id: string;
    auth_uri: string;
    token_uri: string;
    auth_provider_x509_cert_url: string;
    client_secret: string;
    redirect_uris: string[];
  };
}

/**
 *GMailのメール送信用クラス
 *
 * @export
 * @class GMail
 */
export class GMail {
  private oauth2?: OAuth2Client;
  /**
   *認証系の初期化
   *
   * @param {string} secretPath client_secret.jsonのパス
   * @param {string} tokenPath token.jsonのパス(無い場合はそこに作成)
   * @param {boolean} [createFlag] token.jsonが無かった場合、作成するかエラーを返すか
   * @param {string} [scopes] デフォルトで送信用スコープ
   * @returns {Promise<boolean>} true:初期化成功 false:初期化失敗
   * @memberof GMail
   */
  public init(
    secretPath: string,
    tokenPath: string,
    createFlag?: boolean,
    scopes = ["https://www.googleapis.com/auth/gmail.send"]
  ): Promise<boolean> {
    return new Promise(
      async (resolve): Promise<void> => {
        try {
          //認証設定を読み出す
          const buffer = fs.readFileSync(secretPath);
          const installedCredentils = JSON.parse(
            buffer.toString()
          ) as GoogleCredentils;
          const installed = installedCredentils.installed;
          if (installed) {
            //認証用インスタンスの作成
            const oauth2 = new google.auth.OAuth2(
              installed.client_id,
              installed.client_secret,
              installed.redirect_uris[0]
            );
            this.oauth2 = oauth2;
            resolve(await this.bindToken(tokenPath, scopes, createFlag));
          }
        } catch (e) {
          //ファイルの読み出しに失敗
        }
        return resolve(false);
      }
    );
  }
  /**
   *認証に成功しているかどうか
   *
   * @returns {boolean}
   * @memberof GMail
   */
  public isAuthComplite(): boolean {
    const oauth2 = this.oauth2;
    if (oauth2 && oauth2.getAccessToken()) return true;
    return false;
  }
  /**
   *認証情報を結びつける処理
   *
   * @private
   * @param {string} tokenPath
   * @param {string[]} scopes
   * @param {boolean} [createFlag]
   * @returns {Promise<boolean>}
   * @memberof GMail
   */
  private bindToken(
    tokenPath: string,
    scopes: string[],
    createFlag?: boolean
  ): Promise<boolean> {
    return new Promise<boolean>(
      (resolve): void => {
        const oauth2 = this.oauth2;
        if (!oauth2) {
          resolve(false);
          return;
        }

        try {
          //既存の認証データを読み出す
          const buffer2 = fs.readFileSync(tokenPath);
          oauth2.setCredentials(JSON.parse(buffer2.toString()));
          resolve(true);
          return;
        } catch (e) {
          //ファイル読み出し失敗
        }

        //Tokenを作成するか
        if (!createFlag) {
          return resolve(false);
        }

        const authUrl = oauth2.generateAuthUrl({
          access_type: "offline",
          scope: scopes
        });
        // eslint-disable-next-line no-console
        console.log("以下のURLにアクセスし、コードを取得:\n", authUrl);
        const rl = readline.createInterface({
          input: process.stdin,
          output: process.stdout
        });

        rl.question(
          "認証用コードを入力: ",
          async (code): Promise<void> => {
            rl.close();
            //トークンの要求(失敗したらnull)
            const token = await oauth2.getToken(code).catch(
              (): null => {
                return null;
              }
            );
            if (!token || !token.tokens) return resolve(false);
            //成功ならトークンを保存
            oauth2.setCredentials(token.tokens);
            fs.writeFile(
              tokenPath,
              JSON.stringify(token.tokens),
              (err): void => {
                if (err) resolve(false);
                else resolve(true);
              }
            );
          }
        );
      }
    );
  }
  /**
   *メールの送信
   *
   * @param {{
   *     to: string;        宛先
   *     from?: string;     送り元(GMAILの設定が強制される)
   *     subject?: string;  タイトル
   *     body?: string;     本文
   *   }} params
   * @returns {Promise<boolean>}
   * @memberof GMail
   */
  public send(params: {
    to: string;
    from?: string;
    subject?: string;
    body?: string;
  }): Promise<boolean> {
    return new Promise(
      (resolve, reject): void => {
        //oauthの確認
        const oauth2 = this.oauth2;
        if (!oauth2) {
          return reject(false);
        }
        //メールヘッダの作成
        let str =
          'Content-Type: text/plain; charset="UTF-8"\n' +
          "MIME-Version: 1.0\n" +
          "Content-Transfer-Encoding: 7bit\n" +
          `to: ${params.to.replace(/\r?\n/g, "")} \n`; //改行は削除
        //fromを設定しても、送信したユーザのアドレスになる
        if (params.from) str += `from: ${params.from.replace(/\r?\n/g, "")} \n`;
        //タイトル設定
        if (params.subject)
          str += `subject: =?UTF-8?B?${Buffer.from(params.subject).toString(
            "base64"
          )}?= \n`;
        //本文
        if (params.body) str += `\n${params.body}`;
        //送信フォーマットを変換
        const raw = Buffer.from(str)
          .toString("base64")
          .replace(/\+/g, "-")
          .replace(/\//g, "_");
        const gmail = google.gmail("v1");
        gmail.users.messages
          .send({
            auth: oauth2,
            userId: "me",
            requestBody: { raw }
          })
          .then(
            (): void => {
              resolve(true);
            }
          )
          .catch(
            (e): void => {
              reject(e);
            }
          );
      }
    );
  }
}

```

### 利用側のソースコード

　事前に[GCPのコンソール](https://console.cloud.google.com/)からgmailを有効にしたclient_secret.jsonを作成する。初回はtoken.jsonを作成する必要があるので、表示されたURLにアクセスして手動で認証コードを入力する。Webアプリに組み込むような場合も、一回だけはコンソールからinitまでを実行しなければならない。g-suiteの管理者権限があれば初回認証を回避できるのだが、一般アカウントではどうにもならない。

```index.ts
/* eslint-disable no-console */
import { GMail } from "./gmail-sender";

//メイン処理
const main = async (): Promise<void> => {
  const gmail = new GMail();
  const flag = await gmail.init(
    "client_secret.json", //gcp-consoleで作成したclient_secretのパス
    "token.json", //token情報を記憶するファイルのパス(実行時に作成)
    true //(tokenファイルを作成するか、エラーを返すか)
  );
  if (!flag) {
    console.log("初期化失敗");
  } else {
    const flag = await gmail.send({
      to: "to_hoge@hoge@hoge",
      from: "from_hoge@hoge.hoge",
      subject: "テストメール",
      body: "本文\n本文2"
    });
    console.log(flag ? "送信成功" : "送信失敗");
  }
};

//メイン処理を実行
main();
```

# まとめ

　今回の記事は、必要ない人には完全に必要ない内容である。おそらくこの記事は、何事も無かったかのようにスルーされ、泥沼の底に沈み込むことだろう。しかしNode.js+TypeScriptでメールを送信したいと思う奇特な人が、いつか検索に引っかけてくれることを願うばかりである。