[node]Gmailリマインダーを作ろうとして悩んだ話

最近自分のはやりで、日常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.

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),
        }
    )
  }
}

無事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 [];
  }
}

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

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

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

考え直しマーカー

ふと

と思いついて、何か作れないかと検討した結果がこれ

間違いは本来削除するのはよくない。間違いは誤情報として集約できれば役立つものだ。ただ誰だって自身の間違いを見たくはない。

  • 間違い情報は間違いと分かる形で残したい
  • 自身の間違い情報は見たくない

と考えた上で、Twitter APIの技術的制約を考えると、

別の個人botアカウントからのリプライで、間違い情報を自身でマーキングすればよいのではないかと考えて試してみた結果がこれ。これだと自身のタイムラインにはマーク情報が直には現れないので、自身の黒歴史に直面しなくてすむ。

第一、過去の行動を今に持ってくるのは過去に引きずられすぎて、精神衛生的によくない。今はあくまで今と将来を考えるのが正しい視点である。自身のアカウントからのリプライではその過去に向かったリプライが最新になってしまってよくないのだ。

という訳でTwitter Bot扱いなのでいろいろ警告も引っかかるが技術的勉強もかねていろいろ改良してみる。

[tsed,express] A cookie associated with a cross-site resource at ~ was set without the `SameSite` attribute.

この前から夜中に暇々にtsedで作っていた API おもちゃもある程度まとまってきて、動かすサーバーも調整していて、先の連休で動くかなーとか思っていたら、本番にデプロイしたら動かない。。。

ローカルで動くが本番で動かない的な話は、ロジックミスではなく設定ミスみたいな話が多いので、気づけば1文字修正で直るが、把握できなければ延々何ヶ月かけても解決しない的な話になりがち。。。(プログラマの徹夜ってのはこういうので起きやすい)

細かく切り分けをやってみると、今度は「デプロイしたAPI側でセッションが保存出来ない(アクセス都度にセッションが変わる)」という現象が見えた。

セッションが保存できないみたいな話はphp案件でも見たことあった話で、これもまたwebサーバーや環境設定的な話で延々時間を食われる予感がする訳で。。。

で、ふとchrome側のコンソールを見ると以下のメッセージが。

A cookie associated with a cross-site resource at http://..... was set without the `SameSite` attribute. It has been blocked, as Chrome now only delivers cookies with cross-site requests if they are set with `SameSite=None` and `Secure`. You can review cookies in developer tools under Application>Storage>Cookies and see more details at https://www.chromestatus.com/feature/5088147346030592 and https://www.chromestatus.com/feature/5633521622188032.

ネット検索してみると、ブラウザのセキュリティ強化で cookie(セッションの識別に使われる)のクロスサイト共有したければ`SameSite=None` and `Secure`の設定が必要との話。

おもちゃのAPIだからユーザ管理や認証の仕組みを最小限(最小限が必ずしも悪い訳では無く、仕組みが単純化するので弱くはない)にしようとしていた訳だが、そこをはしょったら怒られた訳です。結果として`SameSite=None` and `Secure`を設定したら解決。

Server.ts

this.app.raw.use(session({
      secret: process.env.SESSION_SECRET || "",
      resave: true,
      saveUninitialized: true,
      store: store,
      cookie: {
        sameSite: "none",
        secure:true
      }
    }));

いや、こんなのはwebの専門家じゃないとわからんだろう と思いつつも、そのあたりを見越して詳細なエラーメッセージが出るのが今風だなぁと。

でも個人おもちゃ作りで、休日2日くらい食われるのはちともったいない。

Ts.ED は結構気に入った

先に少し書いたが TypeScriptベースのWebアプリフレームワーク Ts.ED https://tsed.io/ はわりと気に入った。

  1. 母体がnode+expressで普及しており、がっちりTypeScriptベース。非同期はレガシープログラマにも比較的とっつきやすい async/await でPromiseもいける。
  2. アノテーションが使われまくりで、Spring Boot等になれていれば感覚が近い
  3. 今風のcliツールでコンポーネントが手軽に(ぶれずに)追加できる
  4. Swagger自動生成も行ける
  5. dbや認証関係もメジャーなコンポーネントが取り込まれている。
  6. サイトの資料もGoogle翻訳で無理なく読めるし、技術肌の人なら読みやすい説明文と思う。

なぜスポンサー企業がいないのかは知らない。何か問題があるのかもしれぬがそこは知らない。

流行るかどうかはさらに知らない。第一、自分は技術関係ではわりと逆神様なところがあるので(BeOSとかOpenDocとか)なおさら知らない。

流行る前に人に推したもので、当たったと言えるのはHTML5とTypeScriptくらいじゃないだろうか。

まぁ、とりあえずTs.EDは個人的にはとっつきやすくて気に入った。

[Ts.ED,tsed] SyntaxError: Cannot use import statement outside a module

https://akibakokoubou.jp/2020/07/09/nodeでrestのおもちゃを検討中/ のテストデプロイにGCPの App Engineを使っていたのだが、ある時期から SyntaxError: Cannot use import statement outside a module というエラーが出だしてよく分からない状態に。

SyntaxError: Cannot use import statement outside a module
    at wrapSafe (internal/modules/cjs/loader.js:1054:16)
    at Module._compile (internal/modules/cjs/loader.js:1102:27)

ローカル環境ではエラーは出ず、どうもロジックの話ではなく typescript, javascriptの モジュール設定関係の話のようだが、このあたりは話がややこしすぎて訳がわからない。試行錯誤とネット検索しまくりをした結果、 nest で同じ系統のエラーの話と解決が載ってた。 https://masahiro.me/2020/03/nestjs-syntaxerror-cannot-import-statment-outside-a-module/

何かソースのパス関係の話だとするとと見直したら、ああ似たような部位が。

server.ts 内
 
 componentsScan: [
    `./services/**/**.js`,
    `${rootDir}/protocols/*{.ts,.js}` // passport認証関係のコンポーネント登録
  ],
  componentsScan: [
    `./services/**/**.js`,
    process.env.NODE_ENV === 'production' ?
      `dist/protocols/*.js` : 
        `${rootDir}/protocols/*{.ts,.js}` // 書き換え
  ],

に書き換えたら解決(distだけだとローカルで今度は正体不明な単体テストエラーに。。。)。

あと表題の件ではないが SyntaxError: Unexpected token というエラーも。

SyntaxError: Unexpected token {
    at Module._compile (internal/modules/cjs/loader.js:723:23)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:789:10)

こっちは GCP の node のバージョンが異なっていた問題だったみたい (ごく最近までApp Engineの nodeは V.10 だったらしい)

app.yaml 内

# runtime: nodejs10
runtime: nodejs12

これは気づいてみれば、ああ確かに 的な。

[Twitter API,node]APIで返答を追加する

調べた結果だけをさくっと記述。

返答を付けたいTwitterの書込を取得する(例では指定ユーザ自身のタイムライン)

  async getUserTweet(userId?: string, userName?: string, count = 5) {
    const res = await this.twitter.get('statuses/user_timeline', {
      user_id: userId,
      screen_name: userName,
      count: count
    });
    const tweetList: SimpleTweet[] = (<any[]>res).map((tw) => {
      return {
        id_str: tw.id_str,
        id_long: tw.id,
        author: userName || "",
        text: tw.text,
        created_at: tw.created_at
      }
    })
    return tweetList;
  }

取得した書込のid_strを付けてTwitterに書込する

  async postReply(parent: string, message: string) {
    const res: Promise<void> = this.twitter.post('statuses/update', {
      in_reply_to_status_id: parent,  //  idはid_strをstringのままでよい
      status: message,
      auto_populate_reply_metadata: true  // 返答になるらしい
    });
    res.catch(e => {
      console.log(e);
    })
  }

参照するidはid_strを使う。整数として取得するとjavascriptの精度上、id値が丸まって変わってしまう(だから元々id_strが取得出来る)。in_reply_to_status_id: parent はAPI仕様では型は指定されていないが、stringでよいみたい。

nodeでRESTのおもちゃを検討中

ちょっと思い立って、遊び用のREST APIのおもちゃを作成中。

前回遊んでいた https://akibakokoubou.jp/2020/05/05/qnap-local-media-player/ はとりあえず不便がない程度には動いていて実用中。

手軽なフレームワークを探していて Ts.ED(tsed) https://tsed.io/ を見つけたのだが、あんまり情報がない。少し調べてみるとnest https://nestjs.com/ とよく似ているっぽい。

偶然見つけたこともあるし、遊び用だしマイナー好きな人なので Ts.ED で少しいじってみる。取り急ぎの部分は一晩で出来たし。

Aging Bookmark

https://github.com/mfukushim/aging-bookmark

ほとんど使わないブックマークを別のフォルダにまとめてしまうChrome拡張。

個人的にブックマークがあまりにまとまらなくなったので必要となって、作るならVue 2 と vuetify 2とTypeScriptの非同期記述の勉強用にと、ごそごそ夜中に作っていたもの。とりあえず Chromeウェブストアにも公開依頼中(認可されるかどうかは知らぬ)

https://chrome.google.com/webstore/detail/%E3%82%A8%E3%83%BC%E3%82%B8%E3%83%B3%E3%82%B0-%E3%83%96%E3%83%83%E3%82%AF%E3%83%9E%E3%83%BC%E3%82%AF/dephihopkgpedlhbklfgkphinmimgppm

IntelliJでのnodejs-expressのdockerリモートデバッグ設定

開発環境前提(セキュリティ関係はなし状態),サーバー側は docker-composeとnodemonで実行

サーバー側のdocker-compose.yml内の nodemon 行にinspect項を追加。port 9229を通す

app:
・・・
  ports:
    - "0.0.0.0:9000:9000"
    - "0.0.0.0:9229:9229"
  command: sh -c "・・・ nodemon --inspect=0.0.0.0:9229 ・・・"

ローカルのIntelliJ側は Run/Debug Configuration で Attach to Node.js/Chrome を選んでデバッグ実行

つながらないときは サーバー側に
Debugger listening on ws://0.0.0.0:9229/b4f8874b-e433-429c-8e61-1xxxxx などのログが出ているので websocket がつながるか別のツールで確認する(wscatなど)

Docker Toolbox for Windows は基本Cドライブ内しかディスク共有できない

今時 Docker Toolbox for Windows を使っているだとかいう突っ込みはなしで。。。

Docker Toolbox for Windowsではプロジェクトは Cドライブ(できればCドライブの文書フォルダ内) に置いたほうがよい。

いろいろ技術的な制約(VirtualBoxの共有設定とか)のため、 ディスク共有を別ドライブに置くことが難しい。出来ない訳ではない。実際にVirtualBoxレベルの設定をぐちゃぐちゃいじってDドライブとかに設定は出来た。ただ全然本道ではないところに時間を食うので避けたほうがよい。実際昔ごちゃごちゃやってそれが出来たというところまで覚えていた状態で、再び似た環境を作ることになって、またごちゃごちゃを思い返していらない時間を使った。。。

できれば
Macを使う→Unix系なのでシェル差異やファイルシステム差異などを気にしなくてよい→自分は宗教上Macは避けている。
Docker for Windowsを使う→Hyper-Vベースなのでうまくいくのかも?→何でも屋をやっていると VirtualBox,VMwareなどをもろもろ使うのでHyper-Vが入れられない。
Linuxを使う→どうしてもOffice系ドキュメントを欲しがるお客がいる&デスクトップ系の小賢いツールを使いたいときがある。

WindowsのCドライブは肥大して訳がわからなくなるので極力別ドライブに入れたい(アプリや文書データ等)という管理をしていると表題の問題に当たっちゃう訳で。。。

Windowsをそろそろ離れたいというところもあるのだけどなぁ(私的利用では中古ノートをChromeBook化したもので困らなかったところはある)