Skip to content
[node]Gmailリマインダーを作ろうとして悩んだ話
web-tips
2020-09-21

最近自分のはやりで、日常PC作業をできるだけ自動化しようとしている。朝のデイリー作業をPowerShellでまとめたりとか。

その中でGmailの通知を個人アプリサーバーあたりに集約して適切に振り分けようと思ったのだが、思ったよりGmailへのプログラム操作が面倒だった。という話。

自分にとっての結論項

  • gmailアクセスはgmailAPIが無難
  • gmail API認証は個人利用なら問題ない
  • 参考としてわかりやすかったのは npm gmail-tester

要件としては、

  • nodejsで軽量にやりたい。
  • 宛先、送り先、サブジェクト、日時くらいが取得できれば十分
  • ネットショップ用とか複数のアカウントがある
  • 無料サービスの範囲でなんとかしたい

有料でよければ、ネットにあるIOT系の通知制御サービス(zapierとか)でまとめるのが悩まなくて済む。でも有料にひっかかった後とかにいろいろ悩むので、勉強かねて作ってみることに(これビジネス的に考えると、金がかかっても即立ち上げ、即フィードバック、即リリースが出来ないとダメな訳で、それだけ考えても自分が起業家やマネージャーに向かないと思い知る訳です)

Gmailへのプログラムからのアクセスにはいくつか方法があり、

  • Gmail APIを叩く
  • GmailにIMAPあたりの設定をしてIMAPクライアントでアクセスする
  • Gmail API+Cloud Pub/SubでGmailの通知だけプッシュさせる

上に上げたzapierとかの外部サービスを使うのも手だし、無理筋ならSeleniumでブラウザ自動制御(ただしGmailとかはおそらくセキュリティがっちりだろうし)とか。でもGmail APIかIMAPあたりならさくさく出来るのではないかと思ったのです。

Nodeで使える部品は、gmail APIを叩くなら、gmail-tester あたり、IMAPを叩くなら imap-simple あたり。ネットで調べると GCP のPub/SubサービスでGmailへの通知を取得する方法もあるとか。

https://developers.google.com/identity/protocols/oauth2?csw=1

でも、Gmailそのものが長くから使われてレガシー的になってきた+結構セキュリティが重要なGoogleの基幹サービスでいろいろガチガチ+仕様や手法が古くからあったのでネットの情報も新旧混じって今は使えない方法も結構ある → どれが今よい方法なのかがよくわからない。。。

GCPからGmail APIを有効へ

認証情報ウィザードから認証情報を作ろうとしたら、

認証には6週間かかるとか、審査料もかかるとか画面に表示が出た。。。

gmail-tester がそのまま用途に合致してたので触ってみたのだが、gmail APIの認証情報を作る段階で、有料とか何が月もかかるとか出てきて「gmailの認証設定結構面倒?」と考えて一旦imap-simpleでIMAP接続を作る方向にしました。

でもこっちはこっちで今時IMAPでGmailをアクセスしようとするとThunderbirdなどのきちんとサポートされているメーラーなら問題はないが、取り急ぎIMAP的なツールだとGmailの設定でセキュリティレベルを下げる設定が必要になるのがわかった。。。

動くはずの呼びだしでIMAPの処理が失敗するので結構悩み込みセキュリティレベル設定の問題にやっとで気づいて、取りあえずつながったけど、今度はgcpのサーバーにデプロイすると「海外からのアクセスでブロックしました」とか結構いやらしいセキュリティ通知が出る。。。

これIMAPままだと後々までこういう通知が来まくってどんどん面倒臭くなる話だなと推察。あきらめてgmail APIで再検討をしなおしました。

一旦諦めた認証情報をいろいろ試してみると規約上は個人利用する分には問題ないとの記述でなんとかこの方向で進めることにする。
https://support.google.com/cloud/answer/9110914#restricted-scopes

Google 翻訳画面

ただし認証操作時に警告画面が出るがこれを消すにはやはり面倒な審査とお金がいるらしいと解釈。でもなんとか読み込み権限までのoauthの認証ファイルは手に入るな。

最近のGoogle系APIは自前アカウント上でgcpのappサービスに上げる分には自動認証されてあまり考え込まずに済むはずだが、gmailAPIは古いため最近のGoogle系APIと比べてちょっと癖があり、自動認証は通らないっぽい。複数アカウントも操作したいので、結局きちんとoauthを通さないとダメそう。oauth系認証は最近twitterおもちゃを作った関係で大枠は分かる。じゃやっぱり認証情報とアクセストークンという2つの情報をブラウザ経由で必要になるはず。

改めて具体的にoauthのトークン情報の方はどうやって取得するのがよいのか? サーバーサイドで処理したいのに認証操作をどうするかな、と思った時、gmail-tester と辻褄があった。

( from https://www.npmjs.com/package/gmail-tester )
1. Save the Google Cloud Platform OAuth2 Authentication file named credentials.json inside an accessible directory (see instructions below).
2. In terminal, run the following command:
node <node_modules>/gmail-tester/init.js <path-to-credentials.json> <path-to-token.json> <target-email>
<path-to-credentials.json> Is the path to OAuth2 Authentication file.
<path-to-token.json> Is the path to OAuth2 token. If it doesn't exist, the script will create it.
( from https://www.npmjs.com/package/gmail-tester )
1. Save the Google Cloud Platform OAuth2 Authentication file named credentials.json inside an accessible directory (see instructions below).
2. In terminal, run the following command:
node <node_modules>/gmail-tester/init.js <path-to-credentials.json> <path-to-token.json> <target-email>
<path-to-credentials.json> Is the path to OAuth2 Authentication file.
<path-to-token.json> Is the path to OAuth2 token. If it doesn't exist, the script will create it.

gmail-tester はローカルのコマンドでトークン取得を行うユーティリティが付いている。これでトークン情報をブラウザ経由でoatuh認証して取得してくれって話。ああやっぱり考えてあるなと納得。

import {Injectable} from "@tsed/di";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const gmail_tester = require("gmail-tester");

export interface MailTitle {
  from: string,
  subject: string,
  receiver: string,
  date: string
}


@Injectable()
export class GmailApiService {
  public async getMessage(): Promise<MailTitle[]> {

    return await gmail_tester.get_messages(
        "認証ファイル.json",
        "トークンファイル.json",
        {
          after: new Date(2020, 8, 6),
        }
    )
  }
}
import {Injectable} from "@tsed/di";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const gmail_tester = require("gmail-tester");

export interface MailTitle {
  from: string,
  subject: string,
  receiver: string,
  date: string
}


@Injectable()
export class GmailApiService {
  public async getMessage(): Promise<MailTitle[]> {

    return await gmail_tester.get_messages(
        "認証ファイル.json",
        "トークンファイル.json",
        {
          after: new Date(2020, 8, 6),
        }
    )
  }
}

無事gmail-testerで見出しの取得ができたら、ついででpub/subも通したくなった。gmail-testerがどんなふうに認証処理を通しているかを参考にする。認証ファイルと取得したトークンファイルを使ったoauthというのはわかったので、悪さをしない程度の注意しつつ処理を整理。

import {Injectable} from "@tsed/di";
import {FirestoreService} from "./FirestoreService";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {google} = require("googleapis");

export interface MailTitle {
  To: string | undefined;
  From: string | undefined;
  FromAddress: string | undefined;
  Subject: string | undefined;
  Date: string | undefined;
}

@Injectable()
export class GmailApiService {
  constructor(private firestoreService: FirestoreService) {
  }  
/**
   * Gmail API watchを発行して、google のpub/subに送信する
   */
  public async watch(account: string) {
    const gmail_client = await this.getGmailClient(account);
    if (gmail_client) {
      const res = await gmail_client.users.watch({
        userId: `${account}`, //'me',
        requestBody: {
          // Replace with `projects/${PROJECT_ID}/topics/${TOPIC_NAME}`
          topicName: 'projects/xxxxxxxxx/topics/yyyyyyy',
        },
      });
      console.log(`watch res:${JSON.stringify(res)}`)
    }
  }

  /**
   * 内部的にGmailアドレスからAPIインスタンスを取得する
   * @param account
   */
  async getGmailClient(account: string): Promise<any> {
    const setting = await this.firestoreService.getAccount(account);
    if (!setting) {
      return undefined;
    }
    const client = JSON.parse(setting.get("client")).installed;
    const token = JSON.parse(setting.get("token"));
    const oAuth2Client = new google.auth.OAuth2(
        client.client_id,
        client.client_secret,
        client.redirect_uris[0]
    );
    oAuth2Client.setCredentials(token);
    return google.gmail({version: "v1", auth: oAuth2Client});
  }
// 別途コントローラーを付けて、pub/subはそこで受け付けて、historyIdを取得する
  /**
   * Controllerからpubsub通知を受けたら、historyIdから始まる一覧で、見出し,From,Toなどのリストを取得する
   * @param account
   * @param historyId
   */
  public async getMailTitles(account: string, historyId: string): Promise<MailTitle[]> {
    try {
      const gmail_client = await this.getGmailClient(account);
      if (gmail_client) {
        const setting = await this.firestoreService.getAccount(account);
        if (!setting) {
          return [];
        }
        const recentHistoryId = setting.get("recentHistoryId") || (parseInt(historyId) - 200).toString();  //  保存がないなら200くらい引いた値で取得する
        await this.firestoreService.saveAccount(account, "recentHistoryId", historyId)
        const ret = (await this.getHistory(account, gmail_client, recentHistoryId) as any)
        if (ret.history) {
          const history = ret.history;
          await this.firestoreService.saveTestLog(`history${recentHistoryId}`,{a:JSON.stringify(history)} )
          const idList: string[] =
              history.map((h: any) => h.messages.map((k: any) => k.id)).reduce((a: [], c: []) => a.concat(c));
          const titleList = idList.map(async id => {
            const mes = await this.getMes(account, gmail_client, id);
            if (mes) {
              const partsList: { name: string, value: string; }[] = mes.payload.headers;
              const f = partsList.find(value => value.name == "From")
              const f1 = f.value.slice(f.value.lastIndexOf("<")+1,f.value.lastIndexOf(">"));
              await this.firestoreService.saveTestLog(`mailBody${id}`,{ b: JSON.stringify(mes.payload.headers)});
              return {
                Subject: partsList.find(value => value.name == "Subject")?.value,
                To: partsList.find(value => value.name == "To")?.value,
                From: f?.value,
                FromAddress: f1,
                Date: partsList.find(value => value.name == "Date")?.value
              } as MailTitle
            } else return {} as MailTitle
          })
          const ans = await Promise.all(titleList);
          return ans.reduce((previousValue, currentValue) => Object.keys(currentValue).length === 0 ? previousValue : previousValue.concat(currentValue), [] as MailTitle[])
        }
      }
    } catch (e) {
      console.log("getMailTitles:", e)
    }
    return [];
  }
}
import {Injectable} from "@tsed/di";
import {FirestoreService} from "./FirestoreService";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {google} = require("googleapis");

export interface MailTitle {
  To: string | undefined;
  From: string | undefined;
  FromAddress: string | undefined;
  Subject: string | undefined;
  Date: string | undefined;
}

@Injectable()
export class GmailApiService {
  constructor(private firestoreService: FirestoreService) {
  }  
/**
   * Gmail API watchを発行して、google のpub/subに送信する
   */
  public async watch(account: string) {
    const gmail_client = await this.getGmailClient(account);
    if (gmail_client) {
      const res = await gmail_client.users.watch({
        userId: `${account}`, //'me',
        requestBody: {
          // Replace with `projects/${PROJECT_ID}/topics/${TOPIC_NAME}`
          topicName: 'projects/xxxxxxxxx/topics/yyyyyyy',
        },
      });
      console.log(`watch res:${JSON.stringify(res)}`)
    }
  }

  /**
   * 内部的にGmailアドレスからAPIインスタンスを取得する
   * @param account
   */
  async getGmailClient(account: string): Promise<any> {
    const setting = await this.firestoreService.getAccount(account);
    if (!setting) {
      return undefined;
    }
    const client = JSON.parse(setting.get("client")).installed;
    const token = JSON.parse(setting.get("token"));
    const oAuth2Client = new google.auth.OAuth2(
        client.client_id,
        client.client_secret,
        client.redirect_uris[0]
    );
    oAuth2Client.setCredentials(token);
    return google.gmail({version: "v1", auth: oAuth2Client});
  }
// 別途コントローラーを付けて、pub/subはそこで受け付けて、historyIdを取得する
  /**
   * Controllerからpubsub通知を受けたら、historyIdから始まる一覧で、見出し,From,Toなどのリストを取得する
   * @param account
   * @param historyId
   */
  public async getMailTitles(account: string, historyId: string): Promise<MailTitle[]> {
    try {
      const gmail_client = await this.getGmailClient(account);
      if (gmail_client) {
        const setting = await this.firestoreService.getAccount(account);
        if (!setting) {
          return [];
        }
        const recentHistoryId = setting.get("recentHistoryId") || (parseInt(historyId) - 200).toString();  //  保存がないなら200くらい引いた値で取得する
        await this.firestoreService.saveAccount(account, "recentHistoryId", historyId)
        const ret = (await this.getHistory(account, gmail_client, recentHistoryId) as any)
        if (ret.history) {
          const history = ret.history;
          await this.firestoreService.saveTestLog(`history${recentHistoryId}`,{a:JSON.stringify(history)} )
          const idList: string[] =
              history.map((h: any) => h.messages.map((k: any) => k.id)).reduce((a: [], c: []) => a.concat(c));
          const titleList = idList.map(async id => {
            const mes = await this.getMes(account, gmail_client, id);
            if (mes) {
              const partsList: { name: string, value: string; }[] = mes.payload.headers;
              const f = partsList.find(value => value.name == "From")
              const f1 = f.value.slice(f.value.lastIndexOf("<")+1,f.value.lastIndexOf(">"));
              await this.firestoreService.saveTestLog(`mailBody${id}`,{ b: JSON.stringify(mes.payload.headers)});
              return {
                Subject: partsList.find(value => value.name == "Subject")?.value,
                To: partsList.find(value => value.name == "To")?.value,
                From: f?.value,
                FromAddress: f1,
                Date: partsList.find(value => value.name == "Date")?.value
              } as MailTitle
            } else return {} as MailTitle
          })
          const ans = await Promise.all(titleList);
          return ans.reduce((previousValue, currentValue) => Object.keys(currentValue).length === 0 ? previousValue : previousValue.concat(currentValue), [] as MailTitle[])
        }
      }
    } catch (e) {
      console.log("getMailTitles:", e)
    }
    return [];
  }
}

なんとか複数のgmail個人アカウントからpub/subを受けてgmailの見出し類を抽出。別途作ってたslackとの連動と組み合わせてslackにサマリーを通知。

ここまでだとgmailとslack連動をするツールを契約すれば全然時間がかからなかった訳だが、勉強にはなったし、ここからの次への再加工にも全然応用がきくはずなのだ。でも面倒だった。。。

繰り返しではあるが、ビジネスだったらここは有料サービスを使って半日で終わらせるべきところである。道楽だからアリな話だね。