gas
GmailApi
Gmail_Add-On

Gmail Add-On やってみた

先日 4年ぶりに開催された Tokyo GAS「Tokyo GAS on GCPUG 2018 Spring」 にいってきました。
自分が知らない GAS の知見だらけでとても有意義な時間でした:smiley:
ここで、「Gmail Add-On を GAS で作れるんだよ!」ってことを聞いたので早速触ってみました。

この記事で間違ってることがあればコメント頂けたら幸いです:bow:
あと「こんな Add-On 便利じゃない?」などもコメント頂きたいです:wink:

Gmail Add-On とは

Gmailに拡張機能をつけることができるものです。

このようなマーケットプレイスからインストールすることもできますし、GASで自分で作ることもできます。

公式サンプルをやってみる

公式のサンプル(Quickstart)が用意されているので、やってみます:muscle:

手順はめっちゃ簡単でした。

  1. プロジェクトを作成する(コードはコピペするだけ)
  2. プロジェクトのデプロイIDをコピる
  3. GmailでAdd-Onを適用する

ここの説明は、上の公式を見ても分かりますし、英語が苦手な方は以下で日本語で解説してくれていたので、そっちに譲ります :bow:
コピペとぽちぽちだけなので、エンジニアじゃない方でも出来るかとおもいます:v:

Gmail Add-on 入門 (デベロッパーアドオンの作成とインストール)

この Quickstart はどんな Add-On かというと、現在開いているメールの送信者からきた、過去のメール(正確にはスレッド)を表示してくれるというものです。
Quickstart では最新の5件を表示するようになっています。




クリックすると概要と、そのスレッドへのリンクが表示されます。


アレンジしてみる

せっかくなので、ちょっとアレンジしてみたいと思います。

日頃から Readmine の運用でちょっと、不自由していたことがあったので、それについて考えてみました。

課題

Readmine にはタスクや Issue を管理するチケットというものがあります。
チケットに関するアクション「新規チケットの作成」や「チケット情報の更新」、「チケットの終了」などは全てメールで通知が来るようにしています。

いろんなチケットの新規作成、情報追加、レスポンス(回答)、終了 の通知がごっちゃにくるので、各チケットのステータス把握がなかなか困難になってきます。

↓ Redmineチケットのメール一覧 ↓

「#1 チケット作成」
「#1 チケット情報追加」
「#2 チケット作成」
「#3 チケット作成」
「#2 チケット更新」
「#3 チケット終了」
「#2 チケット情報追加」
「#1 チケット回答」
「#2 チケット回答」
「#1 チケット終了」

さぁ、終了していないチケットはどれでしょう?

みたいになるわけです。

もちろん Redmine で直接見ればいいという話ですが、チケットにアクションがあった際に一番に見るのはメールなので、「もう少しメール画面で何とかできんかな?」というのが、今回の課題です。

解決しましょう、Gmail Add-On で :sunglasses:

どう解決する?

任意のチケットメールを開いているときに、まだ終了していないチケット一覧が右に並べば便利じゃない?
と考えたのでやってみようと思います。

終了したチケットをみつける

さぁ、それではどうやって終了したチケットを見つけましょう。
まぁやることは簡単で、チケットの登録と終了時にはそれぞれ以下のようなメールが届くので、「登録されたが、終了されていない」というメールを探してやればよいです。1

  • チケットの登録
    チケット #[チケット番号] が [名前] によって報告されました。 といった内容のメールが届きます。
  • チケットの終了
    ステータス を [何かしらのステータス] から 終了 に変更 といった内容のメールが届きます。

実装する

公式のサンプル(Quickstart)を改造する形で(結構変えましたが)実装してみました。

ticket_add_on.js
// カードのアイコン
var URL_ICON = 'https://qiita-image-store.s3.amazonaws.com/0/96682/profile-images/1473706957';
// チケットラベル
var LABEL_TICKET = 'Ticket';
// ステータスをチェックするREGEX
var REGEX_STATUS = /ステータス を (.+) から (.+) に変更/;
// チケットのアクションをチェックするREGEX
var REGEX_ACTION = /チケット #([0-9]+) が .+ によって(.+)されました。/;

/**
 * Returns the array of cards that should be rendered for the current
 * e-mail thread. The name of this function is specified in the
 * manifest 'onTriggerFunction' field, indicating that this function
 * runs every time the add-on is started.
 *
 * @param {Object} e data provided by the Gmail UI.
 * @returns {Card[]}
 */
function buildAddOn(e) {
  // 表示しているメールからラベル情報を取得する
  var messageId = e.messageMetadata.messageId;
  var mail = GmailApp.getMessageById(messageId);
  var gmailLabels = mail.getThread().getLabels();

  // 表示しているメールに Ticket のラベルがあれば未終了のチケットを検索してカードを表示する
  var labels = extractLabels(gmailLabels);
  if(labels.indexOf(LABEL_TICKET) >= 0){
    return createTicketCards();
  }

  // Ticket のラベルがなければ空のカードを表示する
  return [createBlankCard('No recent threads from this sender')];
}

/**
 * gmailLabelの配列からラベルの文字列を取り出す
 *
 * @param {gmailLabels} GmailLabel オブジェクト
 */
function extractLabels(gmailLabels){
  var labels = [];
  gmailLabels.forEach(function(label) {
    labels.push(label.getName());
  });
  return labels;
}
/**
 * 表示するチケットの表示カードを作成する
 */
function createTicketCards(){
  // 未終了のチケット(のスレッド)を探す
  var threads = searchUnresolved();
  var cards = [];
  // 見つかった未終了のチケット(のスレッド)を表示用のカードにする
  if (Object.keys(threads).length > 0) {
    Object.keys(threads).forEach(function(key) {
      cards.push(buildRecentThreadCard(key, threads[key]));
    })
  } else {
    // 未終了のチケット(のスレッド)が見つからなければ空のカードを返す
    cards.push(createBlankCard('No recent threads from this sender'));
  }
  return cards;
}
/**
 * 空の表示カードを作成する
 *
 * @param {title} 空の表示カードのタイトル
 */
function createBlankCard(title){
  return CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader()
      .setTitle(title)).build();
}
/**
 * 終了していないチケット(のスレッド)を探す
 * (直近2週間から探す)
 *
 * @returns {tickets} keyをチケットID,ValueをThreadとする連想配列
 */
function searchUnresolved() {
  // 直近2週間から探す
  var recentThreads = GmailApp.search('newer_than:14d AND label:' + LABEL_TICKET);
  return searchUnResolvedTicketThreads(recentThreads);
}
/**
 * 引数で渡したスレッド配列から終了していないチケット(のスレッド)を探す
 *
 * @param {threads} 検索するスレッド配列
 * @returns {tickets} keyをチケットID,ValueをThreadとする連想配列
 */
function searchUnResolvedTicketThreads(threads){

  // 報告されたチケットを探す
  var tickets = searchNewTickets(threads);
  // 報告されたチケットをの中から終了しているものを削除する
  threads.forEach(function(thread) {
    if (!thread.isInChats()) {
      thread.getMessages().forEach(function(message) {
        var body = message.getBody();
        var m1 = REGEX_STATUS.exec(body);
        if (m1 && m1[2] == '終了') {
          var m2 = REGEX_ACTION.exec(body);
          if (m2) {
            delete tickets[m2[1]];
          }
        }
      });      
    }
  });
  return tickets;
}
/**
 * 引数で渡したスレッド配列から報告されたチケット(のスレッド)を探す
 *
 * @param {threads} 検索するスレッド配列
 * @returns {tickets} keyをチケットID,ValueをThreadとする連想配列
 */
function searchNewTickets(threads){
  var tickets = {};
  var t = 0;
  threads.forEach(function(thread) {
    if (!thread.isInChats()) {
    var m = 0;
      thread.getMessages().forEach(function(message) {
        var match = REGEX_ACTION.exec(message.getBody());
        if (match && match[2] == '報告') {
          tickets[match[1]] = thread;
        }
      });
    }
  });
  return tickets;
}

/**
 *  引数で渡したスレッドの表示カードを作る
 *
 *  @param {ticketId} チケットのID
 *  @param {thread} 対象となるスレッド
 */
function buildRecentThreadCard(ticketId, thread) {
  var card = CardService.newCardBuilder();
  var subject = thread.getFirstMessageSubject();
  card.setHeader(CardService.newCardHeader().setTitle(subject).setImageUrl(URL_ICON));
  var section = CardService.newCardSection()
      .setHeader("<font color=\"#1257e0\">Recent thread</font>");
  section.addWidget(CardService.newTextParagraph().setText('Ticket #' + ticketId));
  section.addWidget(CardService.newKeyValue()
    .setTopLabel('Ticket Number')
    .setContent(ticketId));
  section.addWidget(CardService.newKeyValue()
    .setTopLabel('Last updated')
    .setContent(thread.getLastMessageDate().toDateString()));
  var threadLink = CardService.newOpenLink()
    .setUrl('https://mail.google.com/mail/u/0/#inbox/' + thread.getId())
    .setOpenAs(CardService.OpenAs.FULL_SIZE);
  var button = CardService.newTextButton()
    .setText('Open Thread')
    .setOpenLink(threadLink);
  section.addWidget(CardService.newButtonSet().addButton(button));
  card.addSection(section);
  return card.build();
}

Treadを探す条件の記述はこちらで確認できます:v:アリガタイ
Gmail で使用できる検索演算子

動かしてみる

(訳あって実際の Ticket メールで実験できなかったため)テストデータとして上記の チケット登録、チケット終了 の擬似的なメールを自分の Gmail アドレスに送りつけました。

これを見ると #4 だけが終了しているので、1, 2, 3, 5 がまだ生きているチケットとして表示されるのが期待値です。

はい、結果です。


   ↓
   ↓
   ↓




やったね ٩( 'ω' )و

期待通り、右側にまだ終了していないチケットが表示されていることが分かります。

ちなみに、ラベルが Ticket 以外のときは表示しないようにしています。
ラベルによって表示内容を変えてもいいですね:thumbsup:

やってみて

はまりポイントもなく、比較的簡単に Add-On を実装できました。
アイデア次第でかなり仕事の生産性もあげられそうな気がします。
そしてGAS楽しい!!


  1. 厳密にはステータスが[終了]から[登録]にされることがあったり、細かいフォローはしてません。