Apps Scriptの実行履歴を開くと、1つのアカウントだけ「エラー」が並んでいた。ログの最後は「Exceeded maximum execution time」。同じスクリプトなのに、他のアカウントは毎日おとなしく完走している。
古いプロモーションメールを毎日ゴミ箱へ移すだけのスクリプトだ。落ちたのは、古いメールが一番溜まっていたアカウントだった。掃除が必要なアカウントほど、掃除が終わらない。
この記事は、Apps ScriptでGmailを自動整理していて、メールの多いアカウントだけ時間切れになる人向けだ。結論から言うと、件数の多い一括削除は組み込みのGmailサービス(GmailApp)ではなく、Gmail APIの高度なサービスで書く。1件ずつ状態を問い合わせる書き方をやめ、IDだけをまとめて扱うと、呼び出し回数の桁が変わる。
Gmail APIの通常の利用は追加料金なしと、公式の利用上限のページに書かれている。手順は個人のGmailでもGoogle Workspaceでも同じだ。
なぜ溜まったアカウントだけ6分で落ちるのか
Apps Scriptの1回の実行は6分までで、個人アカウントもWorkspaceも変わらない。トリガーで動く処理の合計にも上限があり、個人アカウントは1日90分、Workspaceは1日6時間だ(Apps Scriptの割り当て)。
元のスクリプトは GmailApp.search で古いプロモーションを100スレッドずつ取り、1スレッドごとに3つを確かめてから消していた。
- スター付きのメールがあるか(
hasStarredMessages) - 最新のメールが30日より新しくないか(
getLastMessageDate) - 残したいラベルが付いていないか(
getLabels)
この3つは、呼ぶたびにGmailへの問い合わせになる。100スレッドなら約300回だ。筆者の環境では、この100スレッド分の処理に数十秒から1分以上かかっていた。
時間の確認はループの先頭だけで、5分を過ぎたら止める作りだった。5分の手前で始まった1回分の処理は、1分以上かかれば6分の上限を越える。毎日の増分が少ないアカウントは5分に届かないので、溜まったアカウントだけが落ちた。1分残しておけば安全のはずが、1回に1分以上かかる処理の前では、その1分が足りない。
Gmail APIの高度なサービスは、問い合わせの単位が違う。Messages.list は1ページ最大500件で、各メールの id と threadId だけを返す(users.messages.list)。Messages.batchModify は1回に1000件までのIDへラベルを付け外しできる(users.messages.batchModify)。ゴミ箱は TRASH というシステムラベルで、APIから付けられる(ラベルの説明)。
残すかどうかは1通ずつ聞かない。先に「残すスレッド」を検索でID集合にしておき、突き合わせて外す。呼び出し回数はスレッド数ではなく、ページ数に比例するようになる。
では、問い合わせの単位を変えると、回数は実際どこまで減るのか? 同じ模擬データで両方の書き方を走らせ、呼び出し回数を数えた。古いプロモーション約2,500件と、迷惑な送信元を振り分けたラベルの1,200件だ。
| GmailApp(1スレッドずつ確認) | 高度なサービス(IDでまとめて) | |
|---|---|---|
| 一覧の取得 | search で100スレッドずつ | Messages.list で500件ずつ |
| 残すかの判定 | 1スレッドごとに3回問い合わせる | 残すスレッドを検索で集め、IDで外す |
| ゴミ箱へ移す | moveThreadsToTrash(スレッド単位) | batchModify で TRASH を付ける(メール単位、1000件ずつ) |
| 模擬データでの呼び出し回数 | 11,176回 | 19回 |
| 回数が比例するもの | スレッド数 | ページ数 |
| 準備 | 不要 | 高度なサービスの追加 |
回数は、Gmailの代わりに呼ばれた回数を数える偽物で実行した値で、実際の所要時間ではない。それでも、呼び出し11,176回(100スレッドずつの処理を37回、1回に数十秒から1分)の側と、呼び出し19回の側のどちらが6分に収まるかは、計算するまでもない。
どちらで書くかの判断基準
分かれ目は「残す条件を、Gmailの検索演算子で書けるか」の1つだ。
| 状況 | 向いている書き方 | 理由 |
|---|---|---|
| スター・期間・ラベルで残すかが決まる | 高度なサービス | 残す条件をそのまま検索に書け、IDの突き合わせで済む |
| 何年分も溜まったメールを初めて片づける | 高度なサービス | 初回が一番重い。毎日の増分では足りても、初回で落ちる |
| 本文や添付を読んで残すか決める | GmailApp | 中身を読む処理は1件ずつになる。1件ごとに時間を確かめ、少量ずつ回す |
迷ったら、先にGmailの検索窓で試す。狙ったメールだけが出る検索文字列を作れれば、その文字列をそのまま、消す対象の条件としてスクリプトに書ける。
導入の手順
1. 高度なサービスを追加する
エディタ左の「サービス」の+から「Gmail API」を追加する。Apps Scriptが自動で作るCloudプロジェクトを使っているなら、APIの有効化もこの操作で済む(高度なサービスの説明)。appsscript.json で書くなら dependencies に次を足す。
"dependencies": {
"enabledAdvancedServices": [{ "userSymbol": "Gmail", "version": "v1", "serviceId": "gmail" }]
}
2. 残すスレッドを先に集めてから消す
CONFIG の3項目を自分のメールボックスに合わせて書き換える。queries は消す対象の検索条件で、検索窓で試した文字列をそのまま入れる。期間とスター付きの除外はスクリプトが足すので、含めなくてよい。protectedLabelPrefixes は残したいラベルの名前だ。
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回に移す量に上限を置く。 2万件なら
batchModifyは20回で、1回50ユニットの計1,000ユニットだ。1ユーザーあたり毎分6,000ユニットの上限に収まる
3. dryRunで件数を確かめる
エディタで dryRun を選んで実行する。初回は権限の承認画面が出る。ログに出る件数が想定の桁に収まっているかを見て、桁が違えば queries を疑う。いきなり cleanup を回してゴミ箱の中で答え合わせをするより、件数だけ先に見るほうがずっと安く済む。
4. 日次トリガーを作る
setup を1回実行すると、毎日4時台に cleanup が動く。既存の同名トリガーを消してから作るので、2回実行しても重複しない。
自動にしても、取りこぼすタイミングがある。
- 実行と実行の間に30日を越えたメールは、翌日の実行まで残る
- 1回で4分か2万件に達したら、残りは翌日以降に回る。初回に数万件溜まっていれば、片づくまで数日かかる。ゴミ箱へ移す途中で5分に達したときも、残りは翌日に回る
- 残す集合を4分で集めきれない日は何も消さない。直近30日のメール(迷惑メール・ゴミ箱を含む)、スター付き、保護ラベルのメールが膨大だと毎日これになるので、実行履歴でログを見る
- 迷惑メールフォルダとゴミ箱の中は、消す対象の検索に入らない(
includeSpamTrashを付けていないため)
5. 後始末
これまでGmailAppで同じ掃除をしていたなら、そのスクリプトのトリガーは止めておく。エディタ左の「トリガー」で確認できる。
初回の実行後は、ゴミ箱を開いて残すはずのメールが入っていないかを見る。ゴミ箱のメールは30日後にGmailが完全に削除するので、確かめられるのはそれまでだ(Gmailヘルプ)。
GmailAppで書いた場合との違い
| 項目 | この書き方では | 注意点 |
|---|---|---|
| 消す単位 | 検索に当たるメールだけをゴミ箱へ移す。GmailAppの moveThreadsToTrash はスレッド丸ごと | 同じスレッドの、検索に当たらない古いメールは残る。消す範囲は狭くなる側 |
| 権限 | Gmail APIのスコープを使う | appsscript.json で oauthScopes を手書きしているなら自動では足されない。batchModify には https://mail.google.com/ か gmail.modify が要る |
| 時間の確認 | 一覧は1ページ取るたび(4分)、ゴミ箱へ移すのは1回ごと(5分)に確かめる | 打ち切った残りは翌日の実行で拾う |
| 利用上限 | Gmail APIの上限(1ユーザーあたり毎分6,000ユニット)で数える | 1回に移す件数に上限を置いて離しておく。プロジェクトの1日8,000万ユニットまでは課金されない。超える分の課金は2026年内に予定と公式が案内していて、料金の詳細は未公表 |
別案:Apps Scriptの外に出す
本文を読んで判定する処理が数万件あるなら、6分の枠そのものから出る手もある。Apps Scriptの処理をCloud Runへ移した例は、GASの6分制限を避けてCloud Runへ移行した記録にまとめた。ゴミ箱へ移すだけのために実行環境をもう1つ増やすのは、さすがに大げさだ。
まとめ
メールが多すぎるせいだと思いきや、6分で落ちた原因は、1スレッドに3回の問い合わせで100スレッド分の処理が1分を超え、ループの先頭だけの時間確認では止めきれなかったことだった。GmailAppで1スレッドに3回聞く書き方は、溜まったアカウントで回数が膨らむ。高度なサービスでIDをまとめて扱えば、回数はページ数で決まる。
残す条件を検索演算子で書けるなら高度なサービス、本文を読む必要があるならGmailAppを少量ずつ、が判断の基準になる。Gmailに限らず一般に、1件ずつメソッドを呼んで積もる処理は、IDの一覧とまとめて更新するAPIがあるかを先に探すと、6分の壁の手前で片がつくことが多い。



