この記事の要点
Gmail API(および Gmail の検索画面)は 1 回のリクエストで返る件数に上限があります。上限とちょうど同じ件数が返ってきたら、それは実数ではなく打ち切りの可能性が高い。
ページトークンを辿るのが正攻法ですが、めくり忘れが起きます。代わりに、検索条件を 互いに素な 2 つ(=条件とその補集合) に割り、両方を上限未満に収めて足す方法を紹介します。数を確定させると同時に、「処理対象」と「保護対象」のリストが手に入るのが利点です。
Gmail の受信トレイ整理を題材にしますが、考え方はページネーションのある API 全般に使えます。
発端:100 件で止まっていた
「受信トレイにある 7 日以上前のメールを数える」という処理を書いて、返ってきたのがこれでした。
Found 100 messages matching 'in:inbox older_than:7d'
📄 PAGINATION: To get the next page, call ... with page_token='...'
100 は maxResults の既定値と一致しています。2 行目のトークンが「まだ続きがある」と言っているのに、1 行目の数字だけを見て「100 件くらいか」と受け取りかけました。エラーは出ず、値も返っているので、静かに間違えるタイプの取りこぼしです。
実際の総数は 188 件でした。
前提:使う検索演算子
Gmail API の q パラメータには、検索画面と同じ演算子がそのまま使えます。
in:inbox older_than:7d
-
older_than/newer_thanの単位はd/m/y - 判定に使われるのは受信日。既読・未読は無関係
除外は演算子の前に - を付けます。
in:inbox older_than:7d -is:starred -is:important -has:attachment
| 除外条件 | 意味 |
|---|---|
-is:starred |
自分でスターを付けたもの |
-is:important |
Gmail が自動で「重要」判定したもの |
-has:attachment |
添付ファイルつき |
-from:@example.co.jp |
特定ドメイン発を除く |
-subject:請求 |
件名に特定語を含むものを除く |
-label:仕事 |
特定ラベルが付いたものを除く |
条件が増えたら括弧でまとめられます。
in:inbox older_than:7d -(is:starred OR is:important OR has:attachment)
本題:補集合に割って数える
maxResults を 500 に上げて逃げることもできますが、上限が変わるだけで問題の構造は同じです。「返ってきた件数が上限に達していないこと」を、数が正しいことの根拠にするほうが確実でした。
そこで条件を 2 本に割ります。
① in:inbox older_than:7d -is:starred -is:important -has:attachment
② in:inbox older_than:7d (is:starred OR is:important OR has:attachment)
①と②は積集合が空で、和が母集合(in:inbox older_than:7d)に一致します。結果は次のとおりでした。
| クエリ | 件数 | 上限(100)未満か |
|---|---|---|
| ① 除外条件を通り抜けたもの | 93 | ✅ |
| ② 除外条件に当たったもの | 95 | ✅ |
| 合計 | 188 | — |
どちらも上限未満なので打ち切られておらず、188 が総数だと確定できます。
Node.js で書くとこの程度です。
const LIMIT = 100;
async function countExact(gmail, q) {
const res = await gmail.users.messages.list({ userId: 'me', q, maxResults: LIMIT });
const messages = res.data.messages ?? [];
if (messages.length >= LIMIT || res.data.nextPageToken) {
throw new Error(`打ち切りの可能性: q="${q}" (${messages.length}件 / 上限${LIMIT})`);
}
return messages;
}
const BASE = 'in:inbox older_than:7d';
const PROTECT = '(is:starred OR is:important OR has:attachment)';
const target = await countExact(gmail, `${BASE} -is:starred -is:important -has:attachment`);
const protected_ = await countExact(gmail, `${BASE} ${PROTECT}`);
console.log(`総数 ${target.length + protected_.length} 件(対象 ${target.length} / 保護 ${protected_.length})`);
ポイントは、上限に達したら黙って先頭 100 件を返すのではなく例外にするところです。messages.length >= LIMIT と nextPageToken の両方を見ているのは、片方だけだと取りこぼす実装差があるためです。
上限に達したら割り方が粗いということなので、-from: や日付でさらに分割します。
副産物として分類が終わっている
数えるために作った ① と ② が、そのまま「処理してよいもの」と「触らないもの」のリストになります。カウント用のクエリと処理用のクエリを別々に書くと、両者がズレたときに気づけません。同じクエリを使い回せる形にしておくほうが安全でした。
処理は削除ではなくラベル+アーカイブ
Gmail のアーカイブは INBOX ラベルを外すだけの操作で、メール本体は残ります。API なら modify で INBOX を removeLabelIds に入れるだけです。
このとき、同時に作業用ラベルを付けておくと巻き戻せます。
label:"整理済み/2026-08"
ラベル名に /(入れ子)や空白を含む場合は引用符で囲みます。囲まないとスラッシュの前後が別トークンとして解釈されることがあります。入れ子を使わず 整理済み2026-08 のようなフラットな名前にすれば、引用符も不要です。
このラベルで検索 → 全選択 → 受信トレイに移動、で一括ロールバックできます。
画面から一括操作する場合の落とし穴
同じ作業を Gmail の UI からやるときは、ヘッダのチェックボックスを押しただけでは現在ページの分しか選択されません。「この検索条件に一致するスレッドをすべて選択」というリンクを追加で押す必要があります。
API の maxResults と同じ話が UI 側にもある、というだけのことでした。
最後に、機械では割れないものが残る
除外条件を通り抜けた 93 件は、実行前に件名と差出人を全部読みました。
たとえば「メンテナンスのお知らせ」は、実施日が過ぎたものとこれからのものが混在します。件名の形が同一なので、演算子では分けられません。しかも期日超過の基準は「今日」であって「受信日」ではないため、受信日の並び順とも一致しません。1 週間前に届いた「あと 5 日で締切」は既に過ぎていて、10 日前に届いた「来月のメンテ」はまだ生きています。
クエリで安全に落とせる範囲を最大化して、残りを目で見る。この線引きを最初に決めておくのが、一括処理をやるときの実務的な落としどころだと思います。
まとめ
- 返却件数が上限と一致したら、実数ではなく打ち切りを疑う
- ページを辿る代わりに、条件とその補集合に割って両方を上限未満に収める
- 割った結果はそのまま「処理対象」と「保護対象」になる
- 破壊的操作は避け、ラベル付け+アーカイブで巻き戻せる形にする
- UI の一括選択にも同じ「見えている分だけ」問題がある
演算子の一覧は Gmail ヘルプ「Gmail で使用できる検索演算子」 にまとまっています。