この記事は 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を選ぶ理由と判断基準を解説しました。
Gmailの自動削除がApps Scriptの6分制限で止まるとき:GmailAppとGmail APIの選び分け ではさらに:
- 取りこぼすタイミングの整理(30日を越える境目、上限に達した日、迷惑メール・ゴミ箱が対象外な点)
- 初回実行後にゴミ箱で答え合わせする手順と、利用上限・課金まわりの注意点
- 本文を読む処理が数万件あるときの、Cloud Runへ移す別案