GmailのメールをGPTで自動分類するシステムの作り方

# 概要
業務やプライベートでGmailを使っている方は多いと思います。沢山のメールが来ると全て読むのは難しくなります。
そこで、Gmailに自動でラベル付けをして分類するシステムを作りました。
実行環境はGAS、ラベル決めはGPTのFunction callingを使用しました。

# 動作説明
- 5分おきに起動して前回の実行時刻以降に受信したメールにラベルを設定する
- メールに設定するラベルは件名と本文からGPTが考える

※以下の画像中のメールは全てテスト用のものです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/766774ed-77d9-49b7-d6fd-7429bc3cb336.png)


# 運用費用
- GASの実行は無料
- GPTの呼び出しはメール1件当たり最大で0.13円ほど
  - GPTに投げるメール本文の文字数上限を減らすとさらに安くすることが可能

# 作成手順
## 1. OpenAI APIのAPIキーを取得する
1. OpenAIダッシュボードにログインする
1. OpenAIのダッシュボードのAPIキーページでAPIキーを発行して控えておく
1. 支払い方法を登録する
    - OpenAIに最近サインアップしたアカウントなら$18分の無料枠があるので不要です
1. OpenAIのダッシュボードの使用制限ページ で料金上限を設定する
    - 支払い方法を登録していないなら不要です
    - ひとまずこの様に設定しました
    - ハードリミットがあるので安心して使えます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/7f702f3d-0806-76f5-f0dc-d1e7867fee33.png)

## 2. GASを書く
1. GASプロジェクトを作成する
    - Google Drive上で右クリックして作成できる
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/a73936ea-dd06-480e-fa74-06c2dfb6175d.png)
1. GASの「プロジェクトの設定」タブを開く
1. スクリプトプロパティにOPENAI APIのAPIキーを設定する
    - 名前は`OPEN_AI_API_KEY`にする
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/b207dff1-559c-e131-89b0-7497c7dae5ee.png)
1. コードを書く
    - 以下のコードをエディターにコピペする
``` Gmailラベル付けくん.js
const LAST_EXEC_SEC_KEY = "lastExecSec"
const OPEN_AI_API_KEY_KEY = "OPEN_AI_API_KEY"
// GPTの利用料金を抑えるためにメール本文が長すぎたら切り詰める
const MAIL_BODY_CHAR_CNT_LIMIT = 300

function timeTriggerHandler() {
  console.log("トリガーでの実行開始")

  try {
    let lastExecSecStr = PropertiesService.getScriptProperties().getProperty(LAST_EXEC_SEC_KEY)
    let lastExecSec = lastExecSecStr == null
      // 初回起動時はとりあえず1日前までを対象にする
      ? getNowInSec() - 86400
      : Number(lastExecSecStr)
    labelRecentEmails(lastExecSec)

    console.log("トリガーでの実行正常終了")
  } catch(e) {
    console.error("トリガーでの実行異常終了: " + e.stack)
  }
  
  // 失敗したものを次の実行に含めてもしょうがないので最終実行時間は毎回記録する
  PropertiesService.getScriptProperties().setProperty(LAST_EXEC_SEC_KEY, String(getNowInSec()))
}

/**
 * @return {Number} 現在時刻をunixtimeで
 */
function getNowInSec() {
  return Math.round(new Date().getTime() / 1000);
}

function labelRecentEmails(termBeginSec) {
  // invervalSec秒前までのスレッドを全て取得
  // new Date().getTime()の戻り値はミリ秒単位であることに注意
  const termEndSec = getNowInSec();
  const query = `after: ${termBeginSec} before: ${termEndSec}`;
  let latestThreads = GmailApp.search(query);

  // ラベルが存在しないスレッドだけを対象にすれば十分
  let noLabeledThreads = latestThreads.filter(th => th.getLabels().length == 0);

  // ラベルの追加が必要かを判断するために既存のラベルを持っておく
  let existLabels = GmailApp.getUserLabels();
  let existLabelNames = existLabels.map(label => label.getName())

  // それぞれのスレッドに対してラベルを付けていく
  let addedLabelNames = new Set();
  let labelErrorMessages = []
  for (let i = 0; i < noLabeledThreads.length; i++) {
    let thread = noLabeledThreads[i];
    // ラベル付のためにはスレッド内の最初のメッセージだけ見れば十分
    let firstMsg = thread.getMessages()[0];

    try {
      // ラベルを考える
      let suggestedLabelName = suggestLabelName(firstMsg.getSubject(), firstMsg.getPlainBody(), existLabelNames);

      let suggestedLabel = existLabels.find(label => label.getName() === suggestedLabelName)
        // 提案されたラベルが存在しないものなら新たに作る必要がある
        ?? GmailApp.createLabel(suggestedLabelName);

      thread.addLabel(suggestedLabel);
      addedLabelNames.add(suggestedLabelName);
    } catch(e) {
      labelErrorMessages.push({subject: firstMsg.getSubject(), error: e})
    }
  }

  // ログを吐く
  var logMsg = "[ラベル付け処理完了] "
  logMsg += `確認対象期間: ${new Date(termBeginSec * 1000).toLocaleString()}~${new Date(termEndSec * 1000).toLocaleString()}, `
  logMsg += `確認対象スレッド数: ${latestThreads.length}, `
  logMsg += `ラベル無しスレッド数: ${noLabeledThreads.length}, `
  logMsg += `付与したラベル: ${addedLabelNames.size == 0 ? "なし" : Array.from(addedLabelNames).join(", ")}`
  console.log(logMsg)

  if (labelErrorMessages.length > 0) {
    throw new Error(`以下のメールのラベル付けに失敗しました。\n${JSON.stringify(labelErrorMessages)}`)
  }
}

/**
 * @param {string} mailSubject
 * @param {string} mailBody
 * @param {string[]} existLabelNames
 * @return {string}
 */
function suggestLabelName(mailSubject, mailBody, existLabelNames) {
  let apiKey = PropertiesService.getScriptProperties().getProperty(OPEN_AI_API_KEY_KEY);
  let trancatedMailBody = mailBody.length > MAIL_BODY_CHAR_CNT_LIMIT
    ? mailBody.substring(0, MAIL_BODY_CHAR_CNT_LIMIT) + "..."
    : mailBody;
  let messages = [
    {
      role: "user",
      // この文字列をインデントするとトークン数が嵩むのでコードとして読みづらいがこのようにしている
      content: `
# 指示
- 次のメールを分類するためのラベルを設定して
- 既存のラベルとして「${existLabelNames.join(",")}」がある
- できるだけ既存のラベルから選ぶこと
- 指定されたメールにふさわしい既存のラベルがない場合は新しく考えること

# メールの件名
${mailSubject}

# メールの本文
${trancatedMailBody}
      `
    }
  ]
  let func = {
    name: "setLabel",
    description: "メールを分類するためのラベルを設定する",
    parameters: {
      type: "object",
      properties: {
        label: {
          type: "string",
          description: `メールに設定するラベル`,
        },
      },
      required: ["label"],
    },
  }

  let args = GptApi.callFunction(apiKey, "gpt-3.5-turbo", messages, func, 50, 0.2)
  return args.label;
}

/**
 * GPTに関数を呼び出すための引数を作ってもらう
 * @param {string} apiKey
 * @param {string} model gpt-3.5-turboかgpt-4
 * @param {Message[]} prompt
 * @param {GPTFunction} func 呼び出してほしい関数
 * @param {number} maxTokens 普通の会話なら500くらいが無難
 * @param {number} tempreture 返答の多様性。0~1
 * @return {{string: any}} キーが引数名で値が引数
 */
function callFunction(apiKey, model, prompt, func, maxTokens, tempreture) {
  const ENDPOINT = 'https://api.openai.com/v1/chat/completions';

  // リクエストのボディを作成
  const requestBody = {
    model: model,
    messages: prompt,
    functions: [func],
    function_call: {
      name: func.name
    },
    max_tokens: maxTokens,
    temperature: tempreture,
  };

  try {
    // リクエストを送信
    const res = UrlFetchApp.fetch(ENDPOINT, {
      method: 'POST',
      headers: {
        Authorization: 'Bearer ' + apiKey,
        // 答えはjsonでほしい
        Accept: 'application/json',
      },
      // これが無いとpayloadがOpenAIのサーバーに読まれない
      contentType: "application/json",
      // これが無いとpayloadがOpenAIのサーバーに読まれない
      payload: JSON.stringify(requestBody),
    });

    const resCode = res.getResponseCode();
    if(resCode !== 200) {
      if(resCode === 429) throw "利用上限に達しました。このAPIの管理者が上限を変更しない限り来月まで使用できません";
      else throw `レスポンスコード: ${resCode}`;
    }

    var resPayloadObj = JSON.parse(res.getContentText())
    if(resPayloadObj.choices.length === 0) return "予期しない原因でAIからの応答が空でした";

    return JSON.parse(resPayloadObj.choices[0].message.function_call.arguments);
  } catch(e) {
    const errorMsg = `APIリクエストに失敗しました。\n${e.stack}`
    console.error(errorMsg);
    console.error(`[error]エラーが起きました。${errorMsg}`);
    return errorMsg;
  }
}
```

## 3. 実行トリガーを設定する
1. GASの「トリガー」タブを開く
1. 以下の様にトリガーを設定する
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/23dc34be-98b4-a68e-ef0f-57bdddd98253.png)

# まとめ
けっこう便利なシステムができました。
Function callingを使うことでGPTの返答にラベル以外の余計な文字列が含まれることを完璧に防ぐことができました。
しばらく運用してみてラベルがこちらの意図通りに付けられているかを様子見します。
この記事を読んでくださったメール整理に困っている方は是非作ってみてはいかがでしょうか？

# 参考ページ
https://openai.com/pricing

https://platform.openai.com/docs/guides/gpt/function-calling