VISASQ TECHBLOG - ビザスク開発ブログ VISASQ TECHBLOG - ビザスク開発ブログ

CorporateIT

  • GAS
  • gmail
  • slack
  • メール
  • 全社員へのメールを個別にSlackで通知する

    こんにちは、ビザスクのITチーム(情シス)のみともりです。テックブログに連続投稿です。(今更だけどここ開発ブログって書いてあるんだけど情シスが連投していいのか?まぁいいか)

    この記事は Slack Advent Calendar 2019 の12/23のエントリーです。枠が空いているとのことで頑張って書きます!

    メールチェックは面倒

    いきなりですが、メールチェックって面倒じゃないですか?私は面倒です。メールが来たらいい感じにフィルターして必要なものだけSlackに通知してくれたらなーっていつも思ってます。

    ちなみに弊社のワークフローシステムはメール通知のみなんですが、結構見落とします。「Gmailのフィルターをなんとかしろ」とか「Slack公式のMail転送のアプリあるじゃん」という声もありますが、各個人で設定しないとダメじゃないですか。きっとみんな面倒なはずです。各個人で設定しなくても、社員全員がワークフローからの通知をSlackで受け取れたら皆んな幸せになれるはず。

    ということで、とりあえずは私がこれ以上「はやくワークフローで承認しろ」と言われないように、なんとかSlackにいい感じで通知できるようにしてみましょう。

    前提

    • Slackを利用しており、Slack Appをインストールできる
    • G SuiteのGmailを利用しており、管理者権限を持っている
    • 専用のGmailアカウントを一つ用意できる
    • Google Apps Script(GAS)を利用できる

    本当はSlackApp用のNode.jsフレームワークであるBoltでシュシュっとイケイケな基盤を用意してみたかったんですが、多分「いや、そこまで凝ったことしたくないんだけど…」みたいな人が多いと思いますので、お手軽に使えるGASでやってみようと思います。

    あとはまぁ、私がBolt使ったことないっていうのも理由の一つです(実はこれが一番の理由)

    概要

    1. 組織の全ユーザーの受信メールの中から、通知したいメールを専用のGmailアカウントに転送する
    2. 専用アカウントのメールフィルターで、Slackへ通知したいメールにラベルをつける
    3. GASで、ラベルがついたメールの本文を取得して、SlackのAPI経由で通知する(処理したメールはゴミ箱へポイする)

    では早速実際に設定していきましょう。細かい部分は結構省略してるので必要に応じてググってください。

    転送設定の追加

    GSuiteの管理コンソールからGmailの詳細設定を開いて、転送設定に以下のように設定を追加します。これで通知したいメールだけ専用のメールボックスに転送されるようになります。(元の受信者にも届きます)

    ラベルを設定

    専用アカウントにて、通知する対象のメールを受け取ったらラベルをつけるようにフィルターを追加します。メールを受信するとこんな感じになります。

    ここで一つ注意点があります。万が一このフィルターに引っかかるメールを悪意のある第三者から大量に送信されると、Slackへ大量のメッセージが投稿されることになり、最悪の場合Slackが使えなくなる可能性があります。
    Mail Fromは改ざん可能なので、メールタイトルやメール本文の文字列でもマッチさせたうえでラベルを付けるようにしましょう。
    (ちなみにEnvelope Fromではフィルタできないことが多いです)

    GAS作成

    ラベルがついているメールの本文を取得してSlackのAPIで投稿する処理を書きます。ざっくりとした処理の流れは以下の通りです。(コードは最後に貼ります)

    • ラベルがついている全メールを取得して一つずつ以下の処理を実施
    • 受信者アドレスを取得
    • メールアドレスからSlack IDを取得(Slack API)
    • メール本文を整形
    • 対象のSlack IDにDMを送信(Slack API)
    • 処理が終わったメールをゴミ箱に移動する(これで次回の実行時に処理されなくなる)

    Slack Appの作成

    今回、いくつかSlack APIを使うんですが、レガシートークンを使うのはおっかないので、最小限の権限だけ付与したSlack Appを作成し、そのアクセストークンを使うようにします。

    ポイントは、botユーザーを作成するという点です。botユーザーからのDMにすることで、「ああ、こいつからのメンションってことはワークフローからの通知なんだな」とわかりますが、もしなにも設定しないと「Slackbot」からのDMになるので、他のメッセージと混ざって分かりづらくなります。

    Slack Appの作成手順は省略。Qiitaあたりに素晴らしい記事があると思います。(ちなみにSlack Advent Calendar 2019の前日の方がちょうどまとめて頂いているみたいです)

    今回必要なスコープ(botは勝手につく)

    Block Kit Builderで見た目をいい感じにする(オプション)

    記事のタイトルにあった「いい感じ」ってのはこの部分です。別に見た目はどうでもいいよ、って方はスルーしてもらってOKなんですが、メール本文をそのまま通知すると結構見た目が酷いです。ユーザーのことを考えてぜひいい感じにしてあげましょう。

    ざっくり説明すると、Block Kit Builderでレイアウトを作成して、生成されたjsonをGASに貼り付けるだけです。どんなjsonが生成されるかは一番下のコードを参照してください。

    そしてBlock Kit BuilderについてはこれまたQiitaに素晴らしい記事があると思うので頑張って探してください。

    実際どんな感じになるか

    こちらがメール原文です。このままSlackに通知してもいいんですが、URLが長いし、複数のハイフンで区切り行を表現しているのでなんか見た目がイマイチです。

    メール原文(URLはどうにかならなかったか…)

    そしてこちらが整形して実際にSlackに投稿されたメッセージです。まるでサービス公式のSlackAppから通知が来たかのような雰囲気でいい感じじゃないですか?(そう思ってるのは私だけ?)

    SlackAppからの通知になっている

    まとめ

    • なんで今までやってなかったんだろうかと思うくらい簡単
    • とはいえGmailの管理者権限とSlackAppのインストール権限が必要なので情シス以外がやろうと思ったらハードル高め
    • メールの文面をカスタマイズできるなら、いっそのことメール本文にjson文字列を埋め込んでもいいかも

    ビザスクで一緒に働きませんか(豪速球)

    ええ、そうです。オフィシャルなブログに必ず書いてあるアレです。だって人が足りないんですもん、ちょっとだけいいじゃん…。

    ということで

    ビザスクは社員や関係する全ての方のおかげで、社員数も100名に届くという規模の会社に成長してきましたが、まだまだのびしろしかないです。
    是非、以下の会社説明資料をご覧頂き、ビザスクのビジョンに共感して頂けましたらお気軽にご連絡ください!待ってます!

    おわり

    Appendix

    GASのコードです。ログやエラー処理については全く考えていないです。
    でも定時時間外には通知を飛ばさないように配慮しているところは褒められてもいいと思う。

    var TOKEN = 'xoxb-xxxxxxxxxxxxxxxxxxxx'; //アクセストークン
    var OPEN_HOUR = 10; // 始業時刻
    var CLOSE_HOUR = 19; // 終業時刻
    var DAY_SATURDAY = 0;
    var DAY_SUNDAY = 6;
    
    function workflow() {
      var now = new Date();
      var hour = now.getHours();
      if (hour < OPEN_HOUR  || hour >= CLOSE_HOUR ){
        return;
      }
      
      var day_of_week = now.getDay();
      if (day_of_week == DAY_SATURDAY  || day_of_week == DAY_SUNDAY ){
        return;
      }
      
      var label = GmailApp.getUserLabelByName("workflow");
      var threads = label.getThreads();
      for (var i = 0; i < threads.length; i++) {
        var messages = threads[i].getMessages();
        for (var j = 0; j < messages.length; j++){
          var message = messages[j];
          if (!message.isInTrash()) {
            // メールアドレスからSlackIDを検索
            var slack_user = slackLookupByEmail(message.getTo());
            // SlackBotとユーザーとのDMを開く
            var channel_id = slackOpenIm(slack_user.id);
            // メール本文をいい感じに整形する
            var block_kit_message = getBlockKitMessage(message.getPlainBody());
            // 開いたDMにメッセージを投稿する
            slackPostBlockMessage(channel_id, JSON.stringify(block_kit_message));
            //処理が終わったメールはごみ箱に移動する
            message.moveToTrash();
          }
        }
      }
    }
    
    
    function slackLookupByEmail(email) {
        var options = {
            "method": "GET",
            "contentType": "application/x-www-form-urlencoded"
        };
        var response = UrlFetchApp.fetch(encodeURI("https://slack.com/api/users.lookupByEmail?token=" + TOKEN + "&email=" + email), options);
        var content = response.getContentText("UTF-8");
        var res = JSON.parse(content);
        return res.user;
    }
    function slackOpenIm(user_id) {
      var payload = {
        "token": TOKEN,
        "user" : user_id
      }
      var options = {
        "method" : "POST",
        "muteHttpExceptions": true,
        "Content-type": "application/json",
        "payload" : payload
      };
      var response = UrlFetchApp.fetch("https://slack.com/api/im.open", options);
      var content = response.getContentText("UTF-8");
      var res = JSON.parse(content);
      return res.channel.id;
    }
    
    function slackPostBlockMessage(channel_id, blocks)
    {
      var payload = {
        "token": TOKEN,
        "text" : "",
        "blocks" : blocks,
        "channel" : channel_id
      }
      var options = {
        "method" : "POST",
        "muteHttpExceptions": true,
        "Content-type": "application/json",
        "payload" : payload
      };
    
      var response = UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", options);
      var content = response.getContentText("UTF-8");
      
      return;
    }
    
    function getBlockKitMessage(body){
      // メール本文を1行ごとに配列に格納して順番に処理していく
      var arr = body.split(/\r\n|\n/);
      
      var row = 0;
      var param = new Object();
      
      row += 3;
      if(arr[row+1] == "") {
        param['detail'] = arr[row];
      } else {
        param['detail'] = arr[row] + arr[row+1];
        row++;
      }
      row += 3;
      var str_type = arr[row].split(':');
      param['type_key'] = str_type[0].trim();
      param['type_value'] = str_type[1].trim();
      
      row++;
      var str_title = arr[row].split(':');
      param['title_key'] = str_title[0].trim();
      param['title_value'] = str_title[1].trim();
      
      row++;
      var str_applicant = arr[row].split(':');
      param['applicant_key'] = str_applicant[0].trim();
      param['applicant_value'] = str_applicant[1].trim();
      
      row++;
      var str_date = arr[row].split(':');
      param['date_key'] = str_date[0].trim();
      param['date_value'] = str_date[1].trim(); 
      
      row++;
      param['message_key'] = "";
      param['message_value'] = "";
      if (arr[row].match(/-*/) == "") {
        var str_message = arr[row].split(':');
        param['message_key'] = str_message[0].trim();
        param['message_value'] = str_message[1].trim();
        row++;
      }
      
      while (arr[row].match(/-*/) == ""){
        param['message_value'] += arr[row].trim();
        row++;
      }
      
      row += 2;
      param['url_pc'] = arr[row];
      
      row += 3;
      param['url_mobile'] = arr[row];
      
      var block_kit_message = [
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": ":page_facing_up:" + param['detail'] + "\n"
          }
        },
            {
          "type": "divider"
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "*[" + param['type_key'] + "]*\n" + param['type_value']
          }
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "*[" + param['title_key'] + "]*\n" + param['title_value']
          }
        },
        {
          "type": "section",
          "fields": [
            {
              "type": "mrkdwn",
              "text": "*[" + param['applicant_key'] + "]*\n" + param['applicant_value']
            },
            {
              "type": "mrkdwn",
              "text": "*[" + param['date_key'] + "]*\n" + param['date_value']
            }
          ]
        }
     ];
      if (param['message_key'] != "") {
        block_kit_message.push(
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*[" + param['message_key'] + "]*\n" + param['message_value']
            }
          }
        )
      }
      block_kit_message.push(
        {
          "type": "divider"
        }
      );
      block_kit_message.push(
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "```内容を確認するには以下のリンクを押してください。\nPlease check the application detail from link below.```"
          }
        }
      );
      block_kit_message.push(
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": ":computer: *<" + param['url_pc'] + "|PC>*       :iphone: *<" + param['url_mobile'] + "|Mobile>*       :file_folder: *<https://xxx.com/|View All Document>*"
          }
        }
      );
      return block_kit_message;
    }