1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GAS × Gmail × Slack|"運用まで考えた"自動通知の作り方

1
Posted at

大事なメールが、大量の自動返信やメルマガに埋もれる。

商談の申し込みや、取引先からの返信。急ぎで見たいメールほど、気づいたときには半日たっていたりします。受信トレイを1日に何度も見に行って、目で探す。この「目視のパトロール」、地味に消耗しますよね。

この記事では、その解決策をひとつ作ります。

Gmailを数分おきに自動でチェックして、あらかじめ決めたキーワードに当てはまる「重要メールだけ」をSlackに通知する仕組みです。使うのは Google Apps Script(GAS)。Googleが無料で用意している、スプレッドシートやGmailを動かせる仕組みです。サーバーもインストールもいりません。

こんな人向けです

  • 情シスや総務で、日々スプレッドシートやメールと格闘している
  • 「GASでできるらしい」と聞いて調べ始めたけど、途中で詰まった
  • コードは少しなら読めるが、ゼロから書くのは正直しんどい

プログラミングをバリバリやる人向けの記事ではありません。コピペで動くところまでを丁寧に書きます。

所要時間

詰まらなければ30分ほど。Slackの設定に少し戸惑うかもしれませんが、そこも手順で追います。

先に、正直なところ

この記事のコードは「重要メールをSlackに流す」までを確実に動かすものです。ただ、本当に効いてくるのは、動かしたあとです。

というのも、実際に運用を始めると「この差出人も拾いたい」「このメルマガは通知しないで」が必ず出てきます。そこで毎回コードを開いて直すのはつらい。だからこの記事の後半では、通知するかどうかのルールを、コードではなくスプレッドシートで管理するやり方まで作ります。ここが本題です。


この記事で作るものの全体像

流れはシンプルです。

  1. GASが数分おきにGmailをチェックする
  2. 決めたルール(キーワード)に当てはまるメールだけを選ぶ
  3. そのメールをSlackのチャンネルに通知する

図にするとこうなります。

なぜ通知先がSlackなのかというと、現場では「メールは開いていないけどSlackはずっと開いている」ことが多いからです。見る場所を、いつも見ている画面にそろえる。それだけで見逃しはかなり減ります。

Before / Afterで見るとこんなイメージです。


事前準備

作り始める前に、2つだけ用意します。

1. Slackの通知先(Incoming Webhook)を発行する

Slackに外から通知を送るための「受け口のURL」を作ります。

  1. Slackの App管理ページ を開いて「Create New App」→「From scratch」
  2. アプリ名(例:メール監視Bot)と、通知を出したいワークスペースを選ぶ
  3. 左メニューの「Incoming Webhooks」を開き、スイッチをオンにする
  4. 一番下の「Add New Webhook to Workspace」を押し、通知したいチャンネル(例:#営業-重要メール通知)を選ぶ
  5. 発行された https://hooks.slack.com/services/... から始まるURLをコピーする

このURLが、後でコードに貼る「送り先」になります。

⚠️ このURLは、知っている人なら誰でもそのチャンネルに投稿できてしまう鍵です。ブログやチャットに貼らない。人にも渡さない。扱いは記事の最後の「つまずきポイント」でもう一度ふれます。

2. GASプロジェクトを作る

  1. script.google.com を開いて「新しいプロジェクト」
  2. 最初から入っている function myFunction() {} は消してかまいません
  3. あとはここにコードを貼っていくだけです

準備はこれで終わりです。


実装①:まずSlackに1通、通知を送ってみる

いきなり全部を作ると、どこで失敗したのか分からなくなります。だから最初は「GASからSlackに1通送れるか」だけを確かめます。ここが一番つまずきやすいので、先に越えておきます。

次のコードを貼って、SLACK_WEBHOOK_URL を自分のURLに書き換えてください。

function testSlack() {
  const SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/XXXX/XXXX/XXXX'; // ← 自分のURLに置き換える

  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: 'GASからのテスト通知です' })
  });
}

エディタ上部の関数名を testSlack にして「実行」を押します。初回は「承認が必要です」と出るので、自分のGoogleアカウントで許可してください(この画面のつまずき方も、後でまとめて書きます)。

Slackのチャンネルに「GASからのテスト通知です」が届いたら、疎通は完了です。一番の難所を先に越えました!


実装②:Gmailから重要メールを見つけてSlackに流す

疎通できたので、本体を作ります。やることは3つです。

  1. Gmailを検索して、未読メールを取り出す
  2. キーワードで「重要かどうか」を判定する
  3. 重要なものだけSlackに送る

Gmailの検索クエリ

GmailApp.search() には、Gmailの検索窓に打つのと同じ文字列を渡せます。よく使うのはこのあたりです。

GmailApp.search('is:unread newer_than:1d');        // 未読で、過去1日以内
GmailApp.search('is:unread label:重要');            // 未読で、「重要」ラベルが付いたもの
GmailApp.search('is:unread from:example.co.jp');   // 特定の差出人
GmailApp.search('is:unread subject:見積もり');       // 件名に「見積もり」を含む

今回は「未読・過去1日・まだ通知していないもの」を対象にします。

本体のコード

// 重要ワード(どれかを含めば「重要」)
const KEYWORDS = ['商談', 'お見積り', 'お問い合わせ', 'ご相談'];
// 除外ワード(含んでいたら通知しない)
const EXCLUDE  = ['自動返信', '配信停止', 'キャンペーン'];

function checkImportantMail() {
  const label = getOrCreateLabel('通知済み');

  // 未読・過去1日・まだ通知していないスレッドだけを取得
  const threads = GmailApp.search('is:unread newer_than:1d -label:通知済み', 0, 50);

  threads.forEach(function (thread) {
    thread.getMessages().forEach(function (message) {
      if (!message.isUnread()) return;

      const text = message.getSubject() + ' ' + message.getPlainBody();
      const hit  = KEYWORDS.some(function (w) { return text.indexOf(w) !== -1; });
      const skip = EXCLUDE.some(function (w) { return text.indexOf(w) !== -1; });

      if (hit && !skip) notifySlack(message, thread);
    });

    // 見たスレッドには印を付ける(次回に同じメールをもう一度通知しないため)
    thread.addLabel(label);
  });
}

// Slackに通知する
function notifySlack(message, thread) {
  const SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/XXXX/XXXX/XXXX'; // ← 自分のURL

  const body = message.getPlainBody().replace(/\s+/g, ' ').slice(0, 120);
  const url  = 'https://mail.google.com/mail/u/0/#inbox/' + thread.getId();

  const text =
    ':email: *重要メールが届きました*\n' +
    '*差出人*: ' + message.getFrom() + '\n' +
    '*件名*: '  + message.getSubject() + '\n' +
    '*本文*: '  + body + '\n' +
    '<' + url + '|Gmailで開く>';

  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: text })
  });
}

// 指定した名前のラベルを取得する(無ければ作る)
function getOrCreateLabel(name) {
  return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
}

checkImportantMail を実行すると、KEYWORDS のどれかを含み、かつ EXCLUDE を含まないメールだけがSlackに届きます。差出人・件名・本文の頭・Gmailで開くリンクがまとまって流れる形です。

getPlainBody() は本文が長くなりがちなので、先頭120文字だけ切り出しています。-label:通知済みthread.addLabel の2つが、同じメールを何度も通知しないための仕掛けです。


実装③:数分おきに自動で動かす(トリガー)

今のままだと手で「実行」を押さないと動きません。これを自動にします。GASの「時間主導型トリガー」を使います。

画面から設定する場合

  1. GASエディタ左の時計アイコン(トリガー)を開く
  2. 右下「トリガーを追加」
  3. 実行する関数:checkImportantMail
  4. イベントのソース:時間主導型
  5. トリガーのタイプ:分ベースのタイマー5分おき
  6. 保存

コードで設定する場合

同じことをコードでもできます。こちらは同じトリガーを二重に作らないようにしてあります。

function createTrigger() {
  // 既存の同名トリガーを消してから作る(二重登録の防止)
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === 'checkImportantMail') ScriptApp.deleteTrigger(t);
  });

  ScriptApp.newTrigger('checkImportantMail')
    .timeBased()
    .everyMinutes(5)
    .create();
}

これで、5分おきに勝手にGmailを見て、重要メールだけSlackに流れるようになりました。

ここまでで「動く」ものはできています。多くの記事だと、ここで終わりです。でも、本当に使い続けられるかは、この先で決まります。


ここが本題:検知ルールをスプレッドシートで管理する

実装②では、通知するキーワードをコードの中に直接書きました(KEYWORDSEXCLUDE)。動かすだけならこれで十分です。

でも、しばらく使うと必ずこうなります。

「新しい取引先の名前も拾いたい」
「あの定期メールは通知しないでほしい」

そのたびにコードを開いて、書き換えて、また承認して。これをできるのは、作った本人だけです。作った人が休んだり抜けたりした瞬間、誰も直せなくなる。いわゆる属人化です。せっかく便利にしたのに、別の「めんどう」を生んでしまいます。

そこで、ルールをコードから外に出します。スプレッドシートに置いて、そこを読むようにする。 こうすると、現場の人が表に語句を1行足すだけでルールを変えられます。プログラムを触る必要がありません。

料理でいうと、レシピ本(コード)はそのままで、味付けのメモ(ルール)だけ差し替えられるようにする感覚です。

スプレッドシートの作り方

新しいスプレッドシートを作り、シート名を 検知ルール にして、こんな表を用意します。

種類 語句
重要ワード 商談
重要ワード お見積り
重要ワード お問い合わせ
重要ワード ご相談
除外の差出人 noreply
除外の差出人 info@
除外ワード 自動返信
除外ワード 配信停止
除外ワード キャンペーン

「重要ワード」はどれかを含めば通知。「除外の差出人」と「除外ワード」は、当てはまったら通知しません。現場の人は、この表に行を足すだけでいい。ここがこの仕組みの一番の狙いです。

URL欄のスプレッドシートIDは、シートを開いたときのアドレスの /d//edit の間の文字列です。

https://docs.google.com/spreadsheets/d/【この部分がID】/edit

スプレッドシートを読むコード

コードの中のキーワードを、スプレッドシートから読む形に差し替えます。

const SPREADSHEET_ID  = 'ここにスプレッドシートのIDを貼る';
const RULE_SHEET_NAME = '検知ルール';

// スプレッドシートからルールを読み込む
function getRules() {
  const sheet  = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(RULE_SHEET_NAME);
  const values = sheet.getDataRange().getValues();

  const rules = { include: [], excludeFrom: [], excludeWord: [] };

  for (var i = 1; i < values.length; i++) { // 1行目は見出しなので飛ばす
    var type = String(values[i][0]).trim();
    var word = String(values[i][1]).trim();
    if (!word) continue;

    if (type === '重要ワード')        rules.include.push(word);
    else if (type === '除外の差出人') rules.excludeFrom.push(word);
    else if (type === '除外ワード')   rules.excludeWord.push(word);
  }
  return rules;
}

// メールが重要かどうかを、ルールで判定する
function isImportant(message, rules) {
  const from = message.getFrom();
  const text = message.getSubject() + ' ' + message.getPlainBody();

  // 1. 除外の差出人に当たったら、問答無用で除外
  if (rules.excludeFrom.some(function (w) { return from.indexOf(w) !== -1; })) return false;
  // 2. 除外ワードに当たったら除外
  if (rules.excludeWord.some(function (w) { return text.indexOf(w) !== -1; })) return false;
  // 3. 重要ワードのどれかを含んでいれば「重要」
  return rules.include.some(function (w) { return text.indexOf(w) !== -1; });
}

判定の順番には意味があります。先に「除外」を見て、そのあとに「重要」を見る。こうすると、「商談」という語を含む自動返信メール(例:【自動返信】商談のお申し込みを受け付けました)を、ちゃんと除外できます。重要ワードだけで判定すると、こういう紛らわしいメールまで拾ってしまいます。

完成版(これを貼れば動きます)

実装②〜③とここまでを1つにまとめた完成版です。SLACK_WEBHOOK_URLSPREADSHEET_ID の2か所を自分のものに書き換えれば動きます。

// ===== 設定(ここだけ自分のものに書き換える)=====
const SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/XXXX/XXXX/XXXX';
const SPREADSHEET_ID    = 'ここにスプレッドシートのIDを貼る';
const RULE_SHEET_NAME   = '検知ルール';
const PROCESSED_LABEL   = '通知済み';

// ===== メイン:5分おきにこれが動く =====
function checkImportantMail() {
  const rules = getRules();
  const label = getOrCreateLabel(PROCESSED_LABEL);

  const threads = GmailApp.search('is:unread newer_than:1d -label:' + PROCESSED_LABEL, 0, 50);

  threads.forEach(function (thread) {
    thread.getMessages().forEach(function (message) {
      if (!message.isUnread()) return;
      if (isImportant(message, rules)) notifySlack(message, thread);
    });
    thread.addLabel(label); // 見たスレッドに印を付ける(二重通知の防止)
  });
}

// ===== ルール取得:スプレッドシートを読む =====
function getRules() {
  const sheet  = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(RULE_SHEET_NAME);
  const values = sheet.getDataRange().getValues();
  const rules  = { include: [], excludeFrom: [], excludeWord: [] };

  for (var i = 1; i < values.length; i++) {
    var type = String(values[i][0]).trim();
    var word = String(values[i][1]).trim();
    if (!word) continue;
    if (type === '重要ワード')        rules.include.push(word);
    else if (type === '除外の差出人') rules.excludeFrom.push(word);
    else if (type === '除外ワード')   rules.excludeWord.push(word);
  }
  return rules;
}

// ===== 判定 =====
function isImportant(message, rules) {
  const from = message.getFrom();
  const text = message.getSubject() + ' ' + message.getPlainBody();
  if (rules.excludeFrom.some(function (w) { return from.indexOf(w) !== -1; })) return false;
  if (rules.excludeWord.some(function (w) { return text.indexOf(w) !== -1; })) return false;
  return rules.include.some(function (w) { return text.indexOf(w) !== -1; });
}

// ===== 通知 =====
function notifySlack(message, thread) {
  const body = message.getPlainBody().replace(/\s+/g, ' ').slice(0, 120);
  const url  = 'https://mail.google.com/mail/u/0/#inbox/' + thread.getId();

  const text =
    ':email: *重要メールが届きました*\n' +
    '*差出人*: ' + message.getFrom() + '\n' +
    '*件名*: '  + message.getSubject() + '\n' +
    '*本文*: '  + body + '\n' +
    '<' + url + '|Gmailで開く>';

  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: text })
  });
}

// ===== ラベル取得(無ければ作る)=====
function getOrCreateLabel(name) {
  return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
}

// ===== トリガー登録(最初に1回だけ実行する)=====
function createTrigger() {
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === 'checkImportantMail') ScriptApp.deleteTrigger(t);
  });
  ScriptApp.newTrigger('checkImportantMail').timeBased().everyMinutes(5).create();
}

貼ったら、まず createTrigger を1回だけ実行してトリガーを仕込みます。あとは放っておけば、5分おきに動きます。ルールを変えたいときは、コードではなくスプレッドシートを直すだけです。


つまずきポイントと、運用の注意

作ってみると引っかかりやすいところを、先にまとめておきます。

  • 同じメールが何度も通知されるis:unread だけで検索していると、既読にしない限り毎回ヒットします。今回は「通知済み」ラベルを付けて、次回の検索対象から外しています。ラベルではなく既読化(message.markRead())で運用してもかまいません。
  • 実行の上限がある:GASには1日あたりの実行時間や外部通信の回数に上限があります(無料のGmailアカウントは特に少なめ)。5分おきで重いことをしすぎると引っかかることがあるので、検索件数を絞る(newer_than:1d や取得件数50件)のがおすすめです。正確な数値は公式の Quotas for Google Services を確認してください。
  • Webhook URLをコードに直書きしたくない:URLはチャンネルへの投稿ができる鍵です。コードを人に見せる予定があるなら、GASの「プロジェクトの設定 → スクリプト プロパティ」に入れて、PropertiesService.getScriptProperties().getProperty('SLACK_WEBHOOK_URL') で読み出す形にすると安全です。
  • 社内情報が外に出ないか不安:この仕組みでSlackに送っているのは、あなたが決めた条件のメールの差出人・件名・本文の一部です。何を送るかはコードで完全にコントロールできます。本文を送らず件名とリンクだけにする、といった調整も1行でできます。ここは相手が会社なら最初に決めておくと安心です。
  • 初回実行の承認で止まる:初めて実行すると権限の確認画面が出ます。「詳細」→「(プロジェクト名)に移動」で進めます。自分のアカウントで自分のGmailを読むための確認なので、内容を見たうえで許可してください。
  • 通知が多すぎ/少なすぎ:これはバグではなく、ルールの調整です。多すぎたら除外ワードを足す。拾えていなかったら重要ワードを足す。その調整をスプレッドシートでできるのが、この記事で一番作ってよかったところです。

おわりに

やったことを振り返ります。

  • Before:大事なメールが自動返信に埋もれて、受信トレイを何度も見に行っていた
  • After:重要メールだけが、いつも開いているSlackに流れてくる

しかも、通知するかどうかのルールは、コードを触らずスプレッドシートで直せる。作った本人がいなくても、現場の人が自分で育てていけます。ここまでできれば、毎朝の受信トレイとのにらめっこは卒業できます!

作ってみて、たぶんこう感じる人がいると思います。「動かすより、毎日の運用やルールの更新のほうが地味に大変だ」と。そこを最初から楽になるように設計する話は、また別の記事で書きます。

書いた人

この仕組みは、業務自動化の受託をやる中で「大事な連絡を見逃したくない」という理由で作りました。

普段は、運送会社向けの作業指示書システムのように、毎日の手作業をボタン1つに変える仕事をしています(このツールは先日作って渡したところで、これから現場で使ってもらう段階です)。今回いちばん時間をかけた「ルールを非エンジニアが自分で直せる形にする」ところは、納品したあとに効いてくる部分だと思っています。

同じような困りごとの相談先を探している人がいたら、作ったものをまとめてあります → 結城 葵のポートフォリオ

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?