【Google編】GAS × Gemini APIで爆速構築。短時間で作るメール解析&Slack通知ボット

AI
スポンサーリンク

みなさん、こんにちは!業務ハックLabのようです。

あけましておめでとうございます。
正月も関係なく、記事をアップしていきますよ~
はい、前回は設計編ということで、AIによるメール自動トリアージシステムの「設計図」を作りましたね! 今回は、その設計図をGoogle Workspace環境で実装していきます。

年末年始の休暇中に「ちょっと作ってみようかな」と思っている方、今がチャンスですよ!

はじめに:Googleのエコシステムは「速さ」が正義

前回設計した「メール自動トリアージシステム」。 これをGoogle Workspace環境で実装する場合、キーワードは「爆速」です。

Google Apps Script(GAS)はJavaScriptベースの環境であり、JSONデータとの親和性が抜群です。

今回は、無料枠で使える最新モデル「Gemini 3 Flash Preview」をAPI経由で呼び出し、Gmailに来たクレームをSlackに即時通知するボットを作成します。

アーキテクチャと使用ツール

まず、全体像を整理しましょう。

Trigger: Gmail(時間主導型トリガー)

Brain: Gemini 3 Flash Preview API(Google AI Studio)

Database: Google Sheets

Notification: Slack(Incoming Webhook)

はい、シンプルですよね!

Googleのエコシステムは、すべてがGoogleアカウント一つで完結するので、外部サービスとの連携が驚くほど簡単なんです。


事前準備:3つのキーを揃える

実装前に、以下を用意してください。

① Gemini API Key

Google AI Studioで取得します(無料枠あり)。

  1. https://aistudio.google.com/ にアクセス
  2. 「Get API Key」をクリック
  3. 新しいプロジェクトを作成(または既存のプロジェクトを選択)
  4. APIキーをコピーして保存

はい、これで1つ目のキーが手に入りました!

(APIキーは絶対に公開しないように注意してくださいね)


② Slack Webhook URL

通知を送りたいSlackチャンネルのIncoming Webhook URLを取得します。

  1. Slackワークスペースにログイン
  2. https://api.slack.com/apps にアクセス
  3. 「Create New App」→「From scratch」を選択
  4. App Name: 英数字のみで入力(例: mail-triage-bot)
  5. ワークスペースを選択→「Create App」
  6. 左メニューから「Incoming Webhooks」をクリック
  7. 「Activate Incoming Webhooks」をONに切り替える
  8. 画面下部の「Add New Webhook to Workspace」をクリック
  9. 通知を送りたいチャンネルを選択→「許可する」

はい、これで2つ目のキーが手に入りました!

(Webhook URLは秘密情報なので、絶対に公開しないように注意してくださいね)


③ Google Sheets

新規作成し、1行目にヘッダーを記入します。

日時送信者件名感情緊急度カテゴリ要約ドラフト返信

はい、これで準備完了です!


実装:コピペで動くGASコード

では、実際にコードを書いていきましょう。

Google Sheetsを開いて、「拡張機能」→「Apps Script」からスクリプトエディタを開きます。


以下のコードを貼り付けてください。

// ===== 設定項目(ここを自分の値に書き換えてください) =====
const GEMINI_API_KEY = 'ここにGemini API Keyを入力してください';
const SLACK_WEBHOOK_URL = 'ここにSlack Webhook URLを入力してください';
const SPREADSHEET_ID = 'ここにスプレッドシートのIDを入力してください';
const SHEET_NAME = 'シート1';

// 分析対象のメール検索条件
// ⚠️ 重要: 日付制限を入れないと過去の未読メール全てが処理されます!
const SEARCH_QUERY = 'is:unread after:2025/12/30 -label:ai-processed'; 
// after: 今日以降のメールのみ(毎日更新する必要がある)
// -label:ai-processed 処理済みラベルが付いていないもの

// 処理済みラベル名
const PROCESSED_LABEL = 'ai-processed';

// ===== メイン処理 =====
function main() {
  // 1. 処理済みラベルを取得または作成
  let label = GmailApp.getUserLabelByName(PROCESSED_LABEL);
  if (!label) {
    label = GmailApp.createLabel(PROCESSED_LABEL);
  }

  // 2. Gmailからメール取得
  const threads = GmailApp.search(SEARCH_QUERY, 0, 5); // 一度に処理するのは5件まで(無料枠を考慮)
  
  if (threads.length === 0) {
    console.log("未読メールはありませんでした。");
    return;
  }

  const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);

  threads.forEach(thread => {
    const messages = thread.getMessages();
    const lastMessage = messages[messages.length - 1]; // スレッドの最新のメールを対象
    const body = lastMessage.getPlainBody();
    
    // 2. Gemini APIを叩いて分析
    try {
      const result = callGemini(body);
      
      // 3. スプレッドシートに保存
      sheet.appendRow([
        lastMessage.getDate(),
        lastMessage.getFrom(),
        lastMessage.getSubject(),
        result.sentiment,
        result.urgency,
        result.category,
        result.summary,
        result.draft_reply
      ]);

      // 4. 緊急度が高ければSlack通知
      if (result.urgency === 'high') {
        sendSlackAlert(result, lastMessage.getSubject(), lastMessage.getFrom());
      }

      // 5. 処理済みラベルを付けて既読にする
      thread.addLabel(label);
      thread.markRead();

    } catch (e) {
      console.error("エラーが発生しました: " + e.toString());
    }
  });
}

// ===== Gemini API呼び出し関数 =====
function callGemini(emailBody) {
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent?key=${GEMINI_API_KEY}`;
  
  // プロンプト(第1記事目で設計したものを埋め込み)
  const prompt = `
あなたは熟練したカスタマーサポートマネージャーです。
入力された「顧客からのメール」を分析し、以下のルールに従ってJSON形式で結果を出力してください。

# 分析ルール
1. **sentiment**: 顧客の感情を \`positive\`, \`neutral\`, \`negative\`, \`severe\` (激怒・法的脅迫など) の4段階で評価。
2. **urgency**: 対応の緊急度を \`high\`, \`medium\`, \`low\` の3段階で評価。
   - \`high\`: システム障害、セキュリティ事故、解約の示唆、激しいクレーム
   - \`medium\`: 一般的な質問、見積依頼、バグ報告
   - \`low\`: 営業メール、スパム、緊急性のない報告
3. **category**: 内容を分類 (\`technical_issue\`, \`billing\`, \`feature_request\`, \`sales_spam\`, \`other\`)。
4. **summary**: メールの内容を日本語50文字以内で要約。
5. **draft_reply**: 返信メールのドラフト案(日本語)。相手の感情に配慮した丁寧な文章を作成すること。

# 制約事項
- 出力は**JSON形式のみ**とすること。Markdown記法(\`\`\`json など)は含めないでください。
- 余計な解説や挨拶文は一切不要です。

# 入力メール文面
${emailBody}
  `;

  const payload = {
    contents: [{ parts: [{ text: prompt }] }]
  };

  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch(url, options);
  const json = JSON.parse(response.getContentText());
  const resultText = json.candidates[0].content.parts[0].text;

  // 【重要テクニック】AIは "```json ... ```" というマークダウン記法を含めて返してくることが多いため除去する
  const cleanedText = resultText.replace(/```json|```/g, "").trim();

  return JSON.parse(cleanedText);
}

// ===== Slack通知関数 =====
function sendSlackAlert(data, subject, sender) {
  const message = {
    "text": "🚨 緊急クレーム検知",
    "attachments": [
      {
        "color": "#ff0000",
        "title": "AIが高優先度のメールを検出しました",
        "fields": [
          {
            "title": "件名",
            "value": subject,
            "short": false
          },
          {
            "title": "送信者",
            "value": sender,
            "short": false
          },
          {
            "title": "AI要約",
            "value": data.summary,
            "short": false
          },
          {
            "title": "カテゴリ",
            "value": data.category,
            "short": true
          },
          {
            "title": "感情スコア",
            "value": data.sentiment,
            "short": true
          }
        ],
        "footer": "Gmail AI Triage System",
        "ts": Math.floor(Date.now() / 1000)
      }
    ]
  };

  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(message)
  };

  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options);
}

はい、コード全体はこんな感じです!


コードの解説:思考プロセスを見せます

では、このコードがどう動いているのか、順を追って見ていきましょう。

ステップ1:メール取得

const threads = GmailApp.search(SEARCH_QUERY, 0, 5);

GmailApp.search()は、Gmailの検索ボックスと同じ構文が使えます。

今回使用している検索条件を詳しく見てみましょう:

const SEARCH_QUERY = 'is:unread after:2025/12/30 -label:ai-processed';

⚠️ これが超重要なポイントです

この検索条件には3つの制限があります:

  1. is:unread → 未読メールのみ
  2. after:2025/12/30 → 今日以降のメールのみ(日付は毎日更新が必要)
  3. -label:ai-processed → 処理済みラベルが付いていないもの

なぜこの制限が必要なのか?

もしis:unreadだけだと、初回実行時に過去の未読メール全てが処理されてしまいます

例えば、過去に100件の未読メールがあったら、初回実行で100回もGemini APIを呼び出すことになり、無料枠が一瞬で消費されてしまうんですよね!

日付の自動更新について:

この例ではafter:2025/12/30と固定していますが、実運用では以下のように動的に設定するのがベストです:

// 今日の日付を自動取得
const today = Utilities.formatDate(new Date(), 'JST', 'yyyy/MM/dd');
const SEARCH_QUERY = `is:unread after:${today} -label:ai-processed`;

はい、これで日付を毎回手動で更新する必要がなくなります!

処理件数の制限:

ここで一度に5件までと制限しているのは、Gemini 3 Flash Previewの無料枠(1日1,000リクエスト)を考慮しているためです。

その他の検索条件例:

  • “label:顧客対応” → 特定のラベルが付いているものも上記のとおり、1日1,000リクエストまでなので、処理件数は調整が必要ですね
  • “from:support@example.com” → 特定の送信者のみ
  • “subject:緊急” → 件名に「緊急」を含む

ステップ2:最新メールの本文を取得

const messages = thread.getMessages();
const lastMessage = messages[messages.length - 1];
const body = lastMessage.getPlainBody();

メールスレッドには複数のメッセージが含まれることがあるので、最新のメッセージを取得しています。

getPlainBody()を使うことで、HTMLタグなどを除いたテキストのみを取得できます。

ステップ3:Gemini APIで分析

ここが核心部分ですね!

const response = UrlFetchApp.fetch(url, options);
const json = JSON.parse(response.getContentText());
const resultText = json.candidates[0].content.parts[0].text;

Gemini APIのレスポンス構造は、ネストが深いんですよね。

candidates[0].content.parts[0].text と階層を降りていくことで、やっとAIの回答テキストにたどり着きます。

(最初、これがわからなくてめっちゃハマりましたw)

ステップ4:JSONクリーニングの重要性

const cleanedText = resultText.replace(/```json|```/g, "").trim();

はい、ここが超重要です!

生成AIは親切心から、JSONデータの周りに ```json というMarkdown記法を付けて返すことがあります。

人間には読みやすいのですが、プログラム(JSON.parse)にとってはエラーの原因になるんですよね。

なので、.replace(/```json|```/g, "") という正規表現を使って、この余計な装飾をクリーニング(除去)しています。

こうした細かい処理をコードでサクッと書けるのが、GASの最大の強みです!

ステップ5:緊急度による分岐

if (result.urgency === 'high') {
  sendGoogleChatAlert(result, lastMessage.getSubject(), lastMessage.getFrom());
}

はい、これぞJSONの真価ですね!

result.urgency"high" かどうかを明確に判定できるので、プログラムによる自動化が可能になります。

もし「なんとなく緊急そうです」というテキストだったら、こんな分岐は書けませんよね。

ステップ6:処理済みラベルで重複防止

thread.addLabel(label);
thread.markRead();

ここが、重複処理を防ぐための重要な仕組みです!

処理が完了したメールスレッドにai-processedというラベルを付けることで、次回実行時に同じメールを再度処理することを防ぎます。

検索条件に-label:ai-processedを入れているので、既にこのラベルが付いているメールは検索対象から除外されるんですよね。

処理の流れ:

  1. 検索条件で処理済みラベルがないメールのみ取得
  2. AIで分析してスプレッドシートに保存
  3. 処理済みラベルを付けて既読にする
  4. 次回実行時は、このメールはスキップされる

はい、これで完璧です!

(このラベル管理をしないと、既読にしたメールを未読に戻したときに再度処理されてしまうので要注意ですね)

トリガー設定:定期実行の自動化

コードを保存したら、次はトリガーを設定します。

  1. スクリプトエディタの左メニューから「トリガー」(時計アイコン)をクリック
  2. 「トリガーを追加」をクリック
  3. 以下のように設定:
    • 実行する関数: main
    • イベントのソース: 時間主導型
    • 時間ベースのトリガータイプ: 分ベースのタイマー
    • 時間の間隔: 10分おき(無料枠を考慮した推奨設定)

はい、これで設定完了です!

あとは放置しておけば、10分ごとに自動でメールをチェックして、分析してくれます。

(無料枠を気にせず使いたい場合は、5分おきでもOKですが、1日制限に注意してくださいね)


実際に動かしてみよう

では、テストメールを送って動作確認してみましょう。

テスト1:緊急クレーム

自分宛に以下のようなメールを送ってみます。

件名: 至急対応願います
本文: 
御社のシステムが3時間も停止しており、業務に重大な支障が出ています。
このままでは損害賠償を請求せざるを得ません。
すぐに責任者から連絡をください。

スクリプトエディタで main() 関数を手動実行すると…

はい、Slackに通知が来ました!スプレッドシートにも、きちんと urgency: high でデータが保存されています。

(初めて動いたときは、めっちゃテンション上がりますよねw)



テスト2:営業メール

次は、営業メールで試してみます。

件名: 新サービスのご案内
本文: 
この度、弊社では新しいクラウドサービスをリリースいたしました。
貴社のビジネスにお役立ていただけると存じます。
詳細は添付の資料をご覧ください。

こちらは urgency: low と判定され、Slackに通知は飛ばずにスプレッドシートにだけ記録されました。

はい、完璧ですね!

トラブルシューティング

実装中によくあるエラーと対処法をまとめておきます。

エラー1:「APIキーが無効です」

APIキーが正しくコピーされていない可能性があります。 前後にスペースが入っていないか確認してください。

エラー2:「JSONのパースに失敗しました」

Geminiが “`json というマークダウン記法を付けて返している可能性があります。 クリーニング処理がちゃんと動いているか確認してください。

エラー3:「スプレッドシートIDが見つかりません」

スプレッドシートのIDは、URLの /d//edit の間の部分です。 例: https://docs.google.com/spreadsheets/d/【ここがID】/edit


まとめと次回予告

わずか数十行のコードで、高度なAIトリアージシステムが完成しました。

GASの魅力は、スピードと柔軟性です。 思いついたアイデアを、すぐにコードに落として試せる。この開発体験は、本当に楽しいんですよね!

そして、Gemini 3 Flash Previewという最新モデルを使うことで、Pro級の推論能力無料枠で体験できるのも大きなメリットです。

しかし、企業利用においては「個人のGoogleアカウントに依存するGAS」は管理上のリスクになることがあります。

次回は、Microsoft 365 × Power Automateを用いた、コードを書かない(ノーコード)、かつ組織管理に適した堅牢な実装方法を解説します!

それでは皆さん、良い業務ハックライフを~

コメント