田中力磨 › 一人塾のGAS自動化 実録 › 広告メール仕分け

Gmailの広告メールをGASとAI(Gemini)で自動振り分け——自信があるものだけ受信箱から外す

受信箱の新着を4時間ごとにAIで判定し、広告と言い切れるものだけ受信箱から外す。結果はシートに残し、同じメールを二度判定しない。

2026-10-05 公開 / 滋賀県彦根市の一人塾「総合学習教室ブリッジ」で実際に動いている仕組みの記録 / Google Apps Script(GAS)

この記事は、教室で動いているコードと運用の記録をもとに、AI(Claude)が書きました。

何を自動化したか

個人経営の塾では、保護者や取引先とのやり取り、決済の確認、仕事で使うツールからの通知と、ネットショップのセール告知や他社の営業メールが、塾長ひとりの受信箱に並ぶ。広告と大事な連絡が混ざっていると、受信箱を開くたびに選り分けから始めることになる。

今は4時間ごとにGoogle Apps Script(GAS)が受信箱の新着を読み、Gemini(GoogleのAI)に広告かどうかを判定させている。自信を持って広告と言えるものだけ「広告・プロモ」ラベルを付けて受信箱から外し(削除ではなくアーカイブ)、広告らしいが自信が無いものは「メール要確認」ラベルを付けて受信箱に残したまま、一覧を通知メールで知らせる。判定の結果はスプレッドシートに1行ずつ残し、同じスレッドを二度判定しない。

仕分けの正解率や減った手間を測った記録は無いので、数字は出さない。書くのは設計と、その設計がどこで安全側に倒れるかである。

仕組み(データの流れ)

定期実行は、4時間ごとに動くディスパッチャー autoEvery4Hours に autoRunSafely('広告メール自動仕分け', gmailAdFilterScan); の1行で相乗りしていて、専用のトリガーは作っていない(トリガー上限20本の壁参照)。

1回の実行では、受信箱から直近3日のスレッドを最大30件取り出し、自動化用のスプレッドシート(ハブ)にある「メール仕訳ログ」シートに記録済みのものを飛ばす。残ったスレッドは、最後のメールの差出人・件名・本文の先頭800文字をGeminiに渡し、isAd(広告か)・confidence(自信の高さ)・reason(理由)をJSONで返させる。応答による扱いは次のとおり。

Geminiの応答Gmailでの扱いログの「判定」欄人への知らせ
isAd が true、confidence が "high"「広告・プロモ」ラベルを付けてアーカイブ広告として自動仕分けなし
isAd が true、confidence が "high" 以外「メール要確認」ラベルだけ付ける(受信箱に残る)要確認(広告の可能性)通知メールの一覧に載る
isAd が true 以外何もしない広告ではないと判定なし
Gemini呼び出しやJSON解析の失敗何もしない書かない(次の回に判定し直す)実行ログ(応答エラーは毎晩のまとめ通知にも。同じ種類の失敗の繰り返しは間を空けて通知される)

検索条件では差出人も件名も絞らず、どこからが広告かの線引きはプロンプトの基準でGeminiに任せる。Googleビジネスプロフィールの月次の実績メールを拾う処理も、コードのコメントにあるとおり同じ「検索は広く・判定はAIで絞る」作りだ。

コード(要点)

入口では、ログのシートと2つのラベルを用意し(無ければ作る)、受信箱を検索する。2つのラベルか既存の「領収書」ラベルが付いたスレッドと、自分だけが送ったスレッドは、検索の段階で外している。

var GMAIL_AD_LABEL_NAME     = '広告・プロモ';
var GMAIL_AD_REVIEW_LABEL   = 'メール要確認';
var GMAIL_AD_LOG_SHEET      = 'メール仕訳ログ';

function gmailAdFilterScan() {
  var sheet = autoSheet(GMAIL_AD_LOG_SHEET, ['判定日時', 'スレッドID', '差出人', '件名', '判定', '理由']);
  var seen = autoSeenSet(sheet, 1);  // スレッドIDで処理済み判定

  var adLabel = GmailApp.getUserLabelByName(GMAIL_AD_LABEL_NAME) || GmailApp.createLabel(GMAIL_AD_LABEL_NAME);
  var reviewLabel = GmailApp.getUserLabelByName(GMAIL_AD_REVIEW_LABEL) || GmailApp.createLabel(GMAIL_AD_REVIEW_LABEL);

  // 自分発信・領収書等は既存ラベルで除外。すでに仕分け済みのスレッドも対象外。
  var threads = GmailApp.search(
    'in:inbox newer_than:3d -label:' + GMAIL_AD_LABEL_NAME + ' -label:' + GMAIL_AD_REVIEW_LABEL
    + ' -label:領収書 -from:me', 0, 30);

  var needsReview = [];
  var archived = 0;

  threads.forEach(function(thread) {
    var id = thread.getId();
    if (seen[id]) return;
    // …(省略)…

検索結果のうち、ログにスレッドIDがあるものはGeminiに送らずに飛ばす。判定済みのまま受信箱に残っているメールも30件の枠を使うため、受信の多い日は3日の範囲のうち前に届いたメールまで手が回らないことがある(その場合も受信箱に残るだけだ)。

autoSheet はハブのシートを取り出し、無ければヘッダー行付きで作る共通関数だ。処理済みの判定は、問い合わせメールの収集などでも使っている autoSeenSet が担う。

/** シートの指定列(0始まり)の値の集合を返す(処理済み判定用) */
function autoSeenSet(sheet, colIndex) {
  var seen = {};
  sheet.getDataRange().getValues().forEach(function(row, i) {
    if (i > 0 && row[colIndex]) seen[String(row[colIndex])] = true;
  });
  return seen;
}

ラベルでの除外に加えてログを持つのは、「広告ではない」と判定したメールのためだ。印を付けずに受信箱へ残すので検索条件では判定済みかどうか区別できず、ログが無ければ3日のあいだ4時間ごとにGeminiへ送り直すことになる。ログは結果にかかわらずスレッドIDを残すので、人がラベルを外したり受信箱に戻したりしたスレッドを、判定し直して上書きすることもない。記録はスレッド単位なので、同じスレッドに後から届いたメールは判定し直さない。

スレッドごとの判定では、プロンプトに広告と広告でないものの基準を並べ、応答の形をJSONで指定している。

    var msg = thread.getMessages()[thread.getMessages().length - 1];
    try {
      var judged = autoGeminiJson([
        '個人経営の学習塾を運営する田中力磨さんに届いたメールです。以下の基準で仕分けてください。',
        '',
        '「広告」=ネットショッピングのセール告知・メルマガ、ポイント・クーポン案内、他社からの営業・売り込みメール、',
        ' サブスクサービスの新機能紹介など、今すぐ対応不要で読まなくても業務に支障がないもの。',
        '「広告ではない」=生徒・保護者・取引先とのやり取り、決済確認・領収書、セキュリティ通知、',
        ' 仕事で使っているツール(GitHub, Claude, Google等)からの重要な通知、自分宛の自動配信レポートなど。',
        '判断に自信が持てない場合は confidence を "low" にしてください(広告らしいが確信がないなら isAd は true、',
        ' 広告ではなさそうなら false)。confidence が "high" のときだけ自動で受信箱から外します。',
        '',
        '差出人: ' + msg.getFrom(),
        '件名: ' + msg.getSubject(),
        '本文冒頭: ' + msg.getPlainBody().slice(0, 800),
        '',
        'JSON形式で回答: {"isAd": true/false, "confidence": "high"/"low", "reason": "一言(20文字以内)"}',
      ].join('\n'));

      var verdict, reason = judged.reason || '';
      if (judged.isAd === true && judged.confidence === 'high') {
        thread.addLabel(adLabel);
        thread.moveToArchive();
        verdict = '広告として自動仕分け';
        archived++;
      } else if (judged.isAd === true && judged.confidence !== 'high') {
        thread.addLabel(reviewLabel);
        verdict = '要確認(広告の可能性)';
        needsReview.push('・' + msg.getFrom() + '\n  件名: ' + msg.getSubject() + '\n  理由: ' + reason);
      } else {
        verdict = '広告ではないと判定';
      }
      sheet.appendRow([new Date(), id, msg.getFrom(), msg.getSubject(), verdict, reason]);
      seen[id] = true;
    } catch (e) {
      Logger.log('メール仕分け失敗: ' + e.message);
    }
  });
  // …(省略)…

autoGeminiJson は、プロンプトの末尾に「出力はJSONのみ」と足して温度0.2で呼び、応答の { 〜 } をパースする共通ヘルパーだ。分岐は === true で比べているので、isAd が文字列の "true" のように崩れて返ってきても広告扱いにはならない。ログへの追記と seen[id] = true は処理が終わってから行うため、Geminiの呼び出しなどで例外が出たスレッドは、3日の検索範囲にあるうちは次の回に判定し直される。

needsReview にためた一覧は、要確認が1件以上あった回だけ、最後に共通関数 autoNotify で1通の通知メールにまとめて送る(宛先はスクリプトプロパティ NOTIFY_EMAIL、未設定なら自分宛)。本文には、広告なら「広告・プロモ」ラベルに付け替えるよう頼む一文が付く。

ハマった点と回避策

失敗がスレッドごとの try/catch に吸われる

スレッドごとの try/catch は1通の失敗で残りの仕分けを止めないためのものだが、catch の中は Logger.log だけで、ディスパッチャーから見ればこの処理は成功で終わる。この教室が2026-07-16にまとめたエラー監視の計画書は「無音は許すが、無記録は許さない」を原則に掲げ、共通ヘルパーを通さずにGeminiを呼んでいたThreads投稿の生成が、失敗を記録に残さないまま投稿を落とす構造だったことも指摘している。

広告仕分けは共通ヘルパーを通しているので、Geminiが候補を返さなかった失敗(HTTPエラーなど)はヘルパーの側で記録され、毎晩のまとめ通知に載る(同じ種類の失敗の繰り返しは間を空けて通知される)。一方、応答からJSONを取り出せなかった失敗は実行ログにしか残らない。どちらの失敗でもメールは受信箱に残り、次の回に判定し直されるので、失敗は安全側に倒れる。JSONを取り出せなかった失敗もまとめ通知に載せたいなら、catch の中で autoAiRecordFailure を呼ぶ(この教室ではまだ入れていない。HTTPエラーはヘルパーの側でも記録されるので、その分は二重に数えられる)。

「迷ったら」の指示と、分岐の条件がずれていた

この記事のためにコードを読み返したところ、食い違いが見つかった。プロンプトは2026-07-13に作ったときから「自信が持てない場合は isAd を false にしたうえで confidence を "low" に」と頼んでいたのに、分岐で「メール要確認」に回すのは isAd が true で confidence が "high" 以外のときだけだった。Geminiが指示どおりに返すほど、判断のつかないメールは「広告ではないと判定」に入り、ラベルも通知も無いまま受信箱に残る。コード冒頭のコメントには、迷うメールは「メール要確認」ラベルを付けて一覧で確認してもらうと書いてあったが、その一覧にはほとんど載らない作りになっていた。

どちらに転んでも受信箱から外れることはないので、実害は「迷ったメールが知らされない」ことにとどまる。直し方は2つ考えられた。分岐を広げて confidence が "low" なら全部を要確認に回すか、プロンプトを分岐に合わせるか。前者は、保護者や取引先からの普通のメールでも迷えば通知が来るので、通知が増えすぎると考えた。2026-09-27に、分岐はそのままにして、プロンプトのほうを分岐に合わせた。自信が持てないときに confidence を "low" にする指示は残し、広告らしいが確信がないなら isAd は true、広告ではなさそうなら false と書き分けたうえで、confidence が "high" のときだけ自動で受信箱から外すことも書き添えた(上に載せたのが直した後のプロンプトだ)。AIへの指示と、その応答を受け取る分岐は、同じ表を見ながら書かないとずれる。なお、ログには今も confidence を書いていないので、「自信を持って広告ではない」と「広告ではなさそうだが迷った」はシートの上では見分けられない。

導入手順

  1. スクリプトプロパティに GEMINI_API_KEY を登録する(通知先を変えるなら NOTIFY_EMAIL、モデルを変えるなら AUTO_GEMINI_TEXT_MODEL も)。このキーは課金を有効にした有料枠で使う。規約(Gemini API 追加利用規約)には、無料枠では送った内容がGoogleの製品やサービスの改善に使われ、人が読むこともあると書かれており、無料枠には個人情報を送らないよう求めている。保護者や取引先のメールを読ませるこの処理は、有料枠にしてから動かす。この教室も有料枠で使っている。
  2. 記録用のスプレッドシートを用意し、autoSheet・autoSeenSet にあたる関数を置く(この教室ではハブを初回に自動で作り、IDをスクリプトプロパティ AUTOMATION_HUB_ID に保存している)。
  3. Geminiの呼び出し(autoGeminiJson にあたるもの)と通知メールの送信(autoNotify にあたるもの)を用意する。Geminiへの送り方はGmail添付の記事の autoGeminiVision と同じ形で、inline_data を付けずに文字だけを送る。
  4. gmailAdFilterScan を置き、検索条件の -label:領収書 を、判定から外したいメールに付けている自分のラベル名に直す(無ければ消す)。
  5. プロンプト冒頭の宛名の行と、「広告」「広告ではない」の基準を、自分の仕事に合わせて書き換える。
  6. GASエディタで gmailAdFilterScan を一度手動で実行し、権限を承認する。初回は直近3日の受信箱(最大30スレッド)が対象になり、自信ありと判定された広告はその場でアーカイブされる。
  7. 「メール仕訳ログ」シートと「広告・プロモ」ラベルの中身を見て、必要なメールが紛れていないか確かめる。紛れていたら受信箱に戻し、プロンプトの基準を直す。
  8. 4時間ごとの時間主導型トリガーに載せる(この教室ではトリガーを増やさず、既存の autoEvery4Hours に1行足している)。

よくある質問

Q. 広告と判定されたメールは消えてしまいますか

消えない。ラベルを付けてアーカイブするだけで、削除も迷惑メールへの移動もしないので、「広告・プロモ」ラベルを開けばいつでも読める。受信箱に戻したスレッドもログにIDが残っているため、次の回にまた外されることはない。

Q. 迷ったメールの一覧はいつ届きますか

仕分けを実行した回(4時間ごと)のうち、要確認が1件以上あった回だけ、通知メールが1通届く。0件の回は何も届かないので、動いているかどうかは「メール仕訳ログ」シートの判定日時で確かめる。

Q. 記録用のシートは増え続けませんか

増え続ける。古い行を消す処理は無く、毎回全行を読んで処理済みの集合を作っている。ほかのログで使っている共通関数 autoTrimOldRows(sheet, days)(1列目の日付で古い行を消す)を足すなら、消す日数は検索範囲の3日より十分長くとる。

筆者の本とnote(有料)で、まとめて読む