1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

なぜGmailの自動削除はGmailAppでなくGmail APIで書くのか(Apps Scriptの6分制限)

1
Last updated at Posted at 2026-10-17

この記事は Gmailの自動削除がApps Scriptの6分制限で止まるとき:GmailAppとGmail APIの選び分け からの転載です。


この記事で分かること

  • 件数の多いGmail一括削除で、GmailAppをやめてGmail APIの高度なサービスを選ぶ理由
  • 両者の呼び出し回数の比較
  • どういう条件ならGmailAppのままでよいか

背景: こういう課題があった

古いプロモーションメールを毎日ゴミ箱へ移すApps Scriptが、メールの溜まったアカウントだけ「Exceeded maximum execution time」で止まりました。他のアカウントは毎日完走しています。掃除が必要なアカウントほど、掃除が終わらないという状況です。

Apps Scriptの1回の実行は6分までで、個人もWorkspaceも同じです。トリガーで動く処理の合計にも上限があり、個人は1日90分、Workspaceは1日6時間です。

選択肢の検討

元のスクリプトは GmailApp.search で100スレッドずつ取り、1スレッドごとに hasStarredMessages・getLastMessageDate・getLabels で残すかを確かめていました。どれも呼ぶたびにGmailへの問い合わせなので、100スレッドで約300回です。時間確認はループの先頭だけで、5分手前で始まった1回分が1分を超えると6分を越えます。

Gmail APIの高度なサービスは問い合わせの単位が違います。Messages.list は1ページ最大500件で id と threadId だけを返し、Messages.batchModify は1回に1000件までのIDへラベルを付け外しできます。ゴミ箱は TRASH というシステムラベルです。

アプローチ 一覧の取得 残すかの判定 ゴミ箱へ 回数が比例するもの デメリット
GmailApp search で100スレッドずつ 1スレッドごとに3回問い合わせ moveThreadsToTrash(スレッド単位) スレッド数 溜まったアカウントで回数が膨らむ
Gmail API(採用) Messages.list で500件ずつ 残すスレッドを検索で集め、IDで外す batchModify で TRASH(メール単位、1000件ずつ) ページ数 高度なサービスの追加が要る

古いプロモーション約2,500件と迷惑な送信元のラベル1,200件の模擬データで、Gmailの代わりに呼び出し回数を数える偽物を使って両方を走らせると、GmailAppは11,176回、高度なサービスは19回でした。実際の所要時間ではありませんが、どちらが6分に収まるかは計算するまでもありません。

なぜこのアプローチを選んだか

判断基準は「残す条件を、Gmailの検索演算子で書けるか」の1つです。

状況 向いている書き方 理由
スター・期間・ラベルで残すかが決まる 高度なサービス 残す条件をそのまま検索に書け、IDの突き合わせで済む
何年分も溜まったメールを初めて片づける 高度なサービス 初回が一番重い。毎日の増分では足りても初回で落ちる
本文や添付を読んで残すか決める GmailApp 中身を読む処理は1件ずつになる。1件ごとに時間を確かめ、少量ずつ回す

残す条件を検索で書けるなら、「先に残すスレッドをID集合にしておき、消す対象から外す」形にできます。呼び出し回数はスレッド数ではなくページ数で決まります。

実装例

エディタ左の「サービス」から「Gmail API」を追加して使います。CONFIG を自分のメールボックスに合わせて書き換えてください。期間とスター付きの除外はスクリプトが足すので、queries には含めません。

const CONFIG = {
  retentionDays: 30,
  queries: ['category:promotions', 'label:Blocked'],
  protectedLabelPrefixes: ['領収書', '保存'], // このラベルとその配下が付いたスレッドは残す
};

const PAGE_SIZE = 500; // Messages.list の maxResults 上限
const MODIFY_BATCH = 1000; // batchModify に渡せる ID の上限
const TIME_BUDGET_MS = 4 * 60 * 1000; // 6分の上限に対する余裕
const HARD_STOP_MS = 5 * 60 * 1000; // ゴミ箱へ移す呼び出しもここで打ち切る
const MAX_PER_RUN = 20000; // 1回で移す件数の目安。残りは翌日へ

function cleanup() { run_(false); }
function dryRun() { run_(true); }

function setup() {
  ScriptApp.getProjectTriggers()
    .filter((t) => t.getHandlerFunction() === 'cleanup')
    .forEach((t) => ScriptApp.deleteTrigger(t));
  ScriptApp.newTrigger('cleanup').timeBased().everyDays(1).atHour(4).create();
}

function run_(dry) {
  const startedAt = Date.now();
  const outOfTime = () => Date.now() - startedAt > TIME_BUDGET_MS;
  const days = CONFIG.retentionDays;
  const tag = dry ? '[dry-run] ' : '';

  // 1. 残すスレッドを先に全部集める。集めきれなければ何も消さない
  const keep = keepThreadIds_(days, outOfTime);
  if (keep === null) {
    console.log(`${tag}残すスレッドを時間内に集めきれなかったので、何もしません`);
    return;
  }

  // 2. 対象のメール ID を集め、1000件ずつゴミ箱へ
  let total = 0;
  for (const q of CONFIG.queries) {
    const ids = [];
    const full = () => outOfTime() || total + ids.length >= MAX_PER_RUN;
    const finished = listMessages_({ q: `${q} older_than:${days}d -is:starred` }, full, (m) => {
      if (!keep.has(m.threadId)) ids.push(m.id);
    });
    let moved = dry ? ids.length : 0;
    for (let i = 0; !dry && i < ids.length && Date.now() - startedAt < HARD_STOP_MS; i += MODIFY_BATCH) {
      const chunk = ids.slice(i, i + MODIFY_BATCH);
      Gmail.Users.Messages.batchModify({ ids: chunk, addLabelIds: ['TRASH'] }, 'me');
      moved += chunk.length;
    }
    total += moved;
    console.log(`${tag}${q}: ${moved} 件`);
    if (!finished || moved < ids.length) {
      console.log(`${tag}時間か件数の上限に達しました。残りは次回の実行で処理します`);
      break;
    }
  }
  console.log(`${tag}合計 ${total} 件`);
}

// スター付き・期間内・保護ラベルのメールを1通でも含むスレッドの ID 集合
function keepThreadIds_(days, outOfTime) {
  const labels = Gmail.Users.Labels.list('me').labels || [];
  const protectedIds = labels
    .filter((l) => CONFIG.protectedLabelPrefixes.some((p) => l.name === p || l.name.startsWith(p + '/')))
    .map((l) => l.id);
  const searches = [{ q: 'is:starred' }, { q: `newer_than:${days}d` }, ...protectedIds.map((id) => ({ labelIds: [id] }))];

  const keep = new Set();
  for (const params of searches) {
    // ゴミ箱・迷惑メールにある新しい返信やスターも「残す理由」に数える
    const ok = listMessages_({ ...params, includeSpamTrash: true }, outOfTime, (m) => keep.add(m.threadId));
    if (!ok) return null;
  }
  return keep;
}

// 全ページを順にたどり、各メールの {id, threadId} を渡す。途中で止めたら false
function listMessages_(params, shouldStop, onMessage) {
  let pageToken;
  do {
    if (shouldStop()) return false;
    const res = Gmail.Users.Messages.list('me', { ...params, maxResults: PAGE_SIZE, pageToken });
    (res.messages || []).forEach(onMessage);
    pageToken = res.nextPageToken;
  } while (pageToken);
  return true;
}

ポイントは3つです。残す集合を集めきれなければ何も消さないこと、残す理由はゴミ箱と迷惑メールの中まで探すこと、1回に移す量に上限を置くことです。最初は dryRun で件数だけ確かめ、想定の桁なら setup で日次トリガーを作ります。

GmailAppとの違いも押さえておく点です。moveThreadsToTrash はスレッド丸ごとですが、この書き方は検索に当たるメールだけを移すので、消す範囲は狭くなる側に変わります。また appsscript.json で oauthScopes を手書きしている場合、https://mail.google.com/ か gmail.modify を自分で足す必要があります。

まとめ: どういう場面で使うべきか

残す条件を検索演算子で書けるなら高度なサービス、本文を読む必要があるならGmailAppを少量ずつ、が判断の基準です。1件ずつメソッドを呼んで積もる処理は、IDの一覧とまとめて更新するAPIがあるかを先に探すと、6分の壁の手前で片がつくことが多くなります。


さらに深掘りしたい方へ

この記事ではGmail APIを選ぶ理由と判断基準を解説しました。

:page_facing_up: Gmailの自動削除がApps Scriptの6分制限で止まるとき:GmailAppとGmail APIの選び分け ではさらに:

  • 取りこぼすタイミングの整理(30日を越える境目、上限に達した日、迷惑メール・ゴミ箱が対象外な点)
  • 初回実行後にゴミ箱で答え合わせする手順と、利用上限・課金まわりの注意点
  • 本文を読む処理が数万件あるときの、Cloud Runへ移す別案

合同会社playpark について

:link: お問い合わせ | ブログ

1
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?