【TypeScript】メールAPI ページネーション完全ガイド — Microsoft Graph API & Gmail API

業務システムでメールを扱う場面は意外と多い。通知メールの一覧取得、特定条件での検索、メールスレッドの表示……いずれもAPIを通じてメールを取得する処理が必要になる。そしてメール取得で必ずぶつかるのがページネーションの壁だ。

メールボックスには数千〜数万件のメッセージが蓄積される。APIはこれを一度に全件返さず、ページ単位で分割して返す。この仕組みを正しく理解していないと「100件しか取れない」「古いメールが取得できない」「無限ループになった」といったトラブルに陥る。

この記事では、Microsoft Graph APIとGmail APIのページネーションを、TypeScriptの実装コード付きで解説する。両APIを並べて比較することで、それぞれの設計思想の違いも見えてくるはずだ。

目次

Microsoft Graph API

Microsoft Graph API のページネーション

基本の仕組み — @odata.nextLink

Microsoft Graph APIでは、レスポンスに次ページが存在する場合、@odata.nextLinkというプロパティに次ページの完全なURLが返ってくる。クライアントはこのURLに対してそのままGETリクエストを送ることで、次のページを取得する。最終ページに到達すると@odata.nextLinkは含まれなくなる。

レスポンスの構造はこうなっている。

// レスポンス例
{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#me/messages",
  "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?$skip=10",
  "value": [
    { "id": "AAMk...", "subject": "会議のお知らせ", ... },
    { "id": "AAMk...", "subject": "請求書送付", ... },
    // ...
  ]
}

ここで重要なのは、@odata.nextLinkのURLをそのまま使うという点だ。URLの中には$skiptoken$skipといったパラメータが埋め込まれているが、これを分解して別のリクエストに組み込んではいけない。Graph APIの公式ドキュメントでも、nextLinkのURL全体をそのままGETに渡すよう明記されている。

$top と $skip の違い

Graph APIのページネーションで混乱しやすいのが、$top$skipの使い分けだ。

$top1ページあたりの取得件数を指定するパラメータだ。メールの場合、デフォルトは10件で最大999件まで指定できる。

エンドポイントによって違う。統一されていない。

messagesの場合 — 1〜1000の範囲でカスタマイズ可能 Microsoft Learnという記述がある。SDKのページングドキュメントでは「999まで」と書かれている。

usersの場合$top=1000を指定するとエラーになり、1〜999の範囲でなければならないと明示されている Microsoft Learn

一般論 — APIによってデフォルトと最大ページサイズが異なる Microsoft Learn。一律の上限値はない。

// 1ページ50件でメールを取得
GET https://graph.microsoft.com/v1.0/me/messages?$top=50

$skip先頭から指定件数をスキップするオフセット型のパラメータだ。「$skip=20」なら21件目から取得を開始する。ただし、メールのようにリアルタイムでデータが増減するリソースに$skipを使うと、ページ遷移中に新着メールが届いた場合にデータの重複や欠落が発生する。

結論として、メール取得では$skipを手動で操作せず、@odata.nextLinkによるサーバー駆動型ページネーションを使うべきだ。$topで件数だけ指定し、あとはnextLinkに任せるのが安全なパターンになる。

Graph APIメール取得の注意事項をまとめ

$search$orderbyは併用できない 結果は送信日時順で返るが、明示的なソート指定は不可。 https://learn.microsoft.com/en-us/graph/search-query-parameter
https://learn.microsoft.com/en-us/answers/questions/656200/graph-api-to-filter-results-on-from-and-subject-an

$search$skipは併用できない ページネーションはnextLinkのみ。 https://learn.microsoft.com/en-us/answers/questions/656200/graph-api-to-filter-results-on-from-and-subject-an

$searchの結果は最大1,000件 https://learn.microsoft.com/en-us/graph/search-query-parameter

$filter$searchは併用できない https://learn.microsoft.com/en-us/graph/query-parameters

$filter$orderbyの併用にはプロパティ順序の制約がある $orderbyのプロパティは$filterにも同じ順序で先に含める必要がある。違反すると"The restriction or sort order is too complex for this operation."エラー。 https://learn.microsoft.com/en-us/graph/api/user-list-messages?view=graph-rest-1.0

toRecipients / ccRecipients / bccRecipients$filterできない fromは可。to/cc/bccで絞りたい場合は$searchを使うしかない。https://learn.microsoft.com/en-us/answers/questions/1665637/how-to-query-emails-by-recipients-email-address

https://learn.microsoft.com/en-us/graph/search-query-parameter?tabs=http

$filterでドメイン部分一致(endsWith)はメッセージのemailAddressで使えない containsは使えるがNOTとの組み合わせ不可。ドメイン除外は$filterでは実現困難。 https://learn.microsoft.com/en-us/answers/questions/1168280/office-365-mail-graph-api-)-is-there-a-way-to-filt

from/emailAddress/address eqが一部の送信者で正しく動かないケースがある startsWithなら動く場合がある。 https://learn.microsoft.com/en-us/answers/questions/2153305/when-filtering-messages-on-ms-graph-rest-api-using

@odata.nextLinkにカスタムHTTPヘッダーは引き継がれない Preferヘッダーなどは毎回明示的に付与する必要がある。 https://learn.microsoft.com/en-us/graph/sdks/paging

$skipはリアルタイムに変化するデータで重複・欠落を起こす nextLinkの$skiptoken(サーバー管理カーソル)を使うべき。 https://learn.microsoft.com/en-us/graph/paging

Graph Explorerで$searchのNOT挙動を検証する手順。

1. Graph Explorerにアクセス

https://developer.microsoft.com/en-us/graph/graph-explorer

ブラウザで開く。

2. サインイン

左上の「Sign in to Graph Explorer」からMicrosoftアカウント(自分のテストアカウントか業務アカウント)でログイン。初回は権限の同意画面が出る。Mail.Read権限が必要なので、出てきたら同意する。権限が足りない場合は左ペインの「Modify permissions」タブからMail.Readを追加する。

3. ベースラインのクエリを試す

まず除外なしで、対象ドメインのメールが何件ヒットするかを確認しておく。GETメソッドで以下を実行。

https://graph.microsoft.com/v1.0/me/messages?$search="participants:example.com"&$select=id,subject,from,receivedDateTime&$top=10

example.comの部分は自分のメールボックスに実在するドメインに置き換える(テスト結果が0件だと検証にならないため)。レスポンスのvalue配列に何件か入っていることを確認する。

4. NOT付きクエリを試す

次に同じドメインを除外する形で実行。

https://graph.microsoft.com/v1.0/me/messages?$search="NOT participants:example.com"&$select=id,subject,from,receivedDateTime&$top=10

このときの結果を3パターンに分類して観察する。

TypeScript実装 — 全件取得

Microsoft Graph APIの全メールを順次取得するTypeScriptコードを見てみよう。ここでは公式SDKの@microsoft/microsoft-graph-clientを使う。

ポイントは3つある。

$selectで必要なフィールドだけに絞っている。メール本文(body)を含めるとレスポンスサイズが跳ね上がるので、一覧取得ではsubjectやfromなど最小限のフィールドに限定するのが定石だ。

@odata.nextLinkの有無でループの終了を判定している。nullチェックだけのシンプルな条件分岐で済む。

nextLinkのURLは加工せずそのままclient.api()に渡している。ここにクエリパラメータを追加したり、URLの一部を書き換えたりしてはいけない。

SDK の PageIterator を使う方法

Graph SDK にはページネーションを自動処理するPageIteratorクラスも用意されている。件数が多い場合のスロットリングや一時停止の制御が組み込まれているため、大量取得には便利だ。

PageIteratorのコールバックでfalseを返すとイテレーションが一時停止し、あとからpageIterator.resume()で再開できる。処理の途中でレート制限にかかった場合に、一定時間待ってからリトライするような制御に使える。

conversations と messages の違い

Graph APIでメールのスレッドを取得したい場合、/me/messagesとは別に/me/mailFolders/{id}/messagesや、Outlook固有のconversationIdフィールドを利用する方法がある。

/me/messagesはメールボックス内の全メッセージをフラットに返す。スレッド単位でまとめたい場合は、取得したメッセージのconversationIdでグルーピングする。

import { Message } from "@microsoft/microsoft-graph-types";

function groupByThread(messages: Message[]): Map<string, Message[]> {
  const threads = new Map<string, Message[]>();

  for (const msg of messages) {
    const convId = msg.conversationId ?? "unknown";
    const existing = threads.get(convId) ?? [];
    existing.push(msg);
    threads.set(convId, existing);
  }

  return threads;
}

Graph APIにはスレッド一覧を直接返すエンドポイントがないため、クライアント側でのグルーピングが必要になる。Gmail APIのthreadsエンドポイントとは対照的な設計だ。

Graph APIのメールはフォルダ階層構造で管理されている

/me/mailFolders/inbox/messages          — 受信トレイ
/me/mailFolders/drafts/messages         — 下書き
/me/mailFolders/sentitems/messages      — 送信済み
/me/mailFolders/deleteditems/messages   — ゴミ箱(削除済みアイテム)
/me/mailFolders/junkemail/messages      — 迷惑メール
/me/mailFolders/archive/messages        — アーカイブ
/me/mailFolders/outbox/messages         — 送信トレイ

Gmail API のページネーション

googleapis

googleapisはGoogleが公式提供しているNode.js向けクライアントライブラリで、google.gmail()はその中のGmail APIクライアント生成関数。

主要なメソッド階層

google.gmail()はクライアントインスタンスを返す関数で、引数にversionauthを渡す。versionは現状"v1"一択。authは認証済みのOAuth2Clientや、サービスアカウントの認証情報など。

返ってくるgmailオブジェクトに、Gmail APIの全エンドポイントがメソッドチェーンで生えている。

REST APIのパス構造(users.messages.listなど)がそのままメソッド名として表現されている。

userIdは必須で、"me"を渡すと認証中のユーザー自身を指す。

gmail.users.getProfile({ userId: "me" })
gmail.users.messages.list({ userId: "me", q: "...", maxResults: 100 })
gmail.users.messages.get({ userId: "me", id: "..." })
gmail.users.messages.send({ userId: "me", requestBody: { ... } })
gmail.users.messages.modify({ userId: "me", id: "...", requestBody: { ... } })
gmail.users.messages.trash({ userId: "me", id: "..." })
gmail.users.messages.delete({ userId: "me", id: "..." })
gmail.users.threads.list({ userId: "me", q: "..." })
gmail.users.threads.get({ userId: "me", id: "..." })
gmail.users.labels.list({ userId: "me" })
gmail.users.labels.create({ userId: "me", requestBody: { ... } })
gmail.users.history.list({ userId: "me", startHistoryId: "..." })
gmail.users.drafts.list({ userId: "me" })
gmail.users.settings.filters.list({ userId: "me" })
users.messages
┣ list ........... メッセージ一覧を取得(ID と threadId だけ)
┣ get ............ 1通の詳細を取得(format で粒度を指定)
┣ send ........... 送信
┣ insert ......... 既存メールを直接挿入(インポート用途)
┣ import ......... SPF等チェック付きでインポート
┣ trash .......... ゴミ箱へ
┣ untrash ........ ゴミ箱から戻す
┣ delete ......... 完全削除
┣ batchDelete .... 複数まとめて削除
┣ batchModify .... 複数のラベルをまとめて変更
�apply ┗ modify ........... ラベル付与/削除

gmail.users.messages.list()はGmailのメッセージ一覧を取得するメソッド

パラメータ

パラメータ説明
userIdstring(必須)ユーザーのメールアドレス。"me"で認証中ユーザー
qstringGmail検索構文によるフィルタクエリ
maxResultsnumber1ページあたりの最大件数(デフォルト100、最大500)
pageTokenstring次ページ取得用のトークン
labelIdsstring[]ラベルIDで絞り込み(複数指定はAND)
includeSpamTrashbooleanSPAMとTRASHを含めるか(デフォルトfalse)
list レスポンス
┣ messages[]
┃  ┣ {id, threadId}
┃  ┣ {id, threadId}
┃  ┗ ...
┣ nextPageToken ...... ページング用
┗ resultSizeEstimate

重要な点として、レスポンスに含まれるのはメッセージIDとスレッドIDだけ

件名や送信者などの内容は含まれない。詳細が必要ならmessages.getを別途呼ぶ必要がある。これがGraph APIの/me/messagesとの大きな違い。

listID しか返さないので、本文やヘッダーが欲しければ list → 各IDで get という2段構えになります。

users.messages.get が返すのは 1通分の Message オブジェクト です。format パラメータで中身の詳しさが変わります。

format 別の返却内容

minimal
┣ id
┣ threadId
┣ labelIds[]
┣ snippet
┣ historyId
┣ internalDate
┗ sizeEstimate
   (payload なし/ヘッダーも本文もなし)

metadata
┣ minimal の全部
┗ payload
   ┣ mimeType
   ┣ headers[] ← ここが入る(metadataHeadersで絞れる)
   ┗ ※ body.data は入らない(本文は取れない)

full  ★デフォルト
┣ minimal の全部
┗ payload(MIMEツリー丸ごと)
   ┣ mimeType
   ┣ filename
   ┣ headers[]
   ┣ body
   ┃  ┣ data ........ Base64urlの本文
   ┃  ┗ attachmentId  添付の場合
   ┗ parts[] ........ 子パート(再帰)

raw
┣ minimal の全部
┗ raw ............... RFC822生データ(Base64url文字列1本)
   (payload は基本入らない/自分でMIMEパースする)

Gmail API 基本の仕組み — pageToken と nextPageToken

Gmail APIでは、users.messages.listのレスポンスにnextPageTokenが含まれていれば次ページが存在する。

次のリクエストでpageTokenパラメータにこの値を渡す。Graph APIの@odata.nextLinkが完全なURLを返すのに対して、Gmail APIは不透明なトークン文字列だけを返す点が異なる。

// レスポンス例
{
  "messages": [
    { "id": "18f3a...", "threadId": "18f3a..." },
    { "id": "18f2b...", "threadId": "18f2b..." },
    // ...
  ],
  "nextPageToken": "09876543210987654321",
  "resultSizeEstimate": 342
}

maxResults の使い方

maxResultsは1ページあたりの最大取得件数を指定するパラメータで、Graph APIの$topに相当する。デフォルトは100件、最大500件まで指定可能だ。

GET https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=200

ただし重要な注意点がある。users.messages.listが返すのはメッセージIDとスレッドIDだけだ。件名や本文、送信者などの詳細情報は含まれない。個別のメッセージ内容を取得するには、別途users.messages.getを呼ぶ必要がある。

これはGraph APIとの大きな違いだ。Graph APIの/me/messagesはデフォルトで件名や送信者情報を含む。一方Gmail APIは一覧取得と詳細取得を明確に分離する設計になっている。

q パラメータによる検索フィルタ

Gmail APIのqパラメータはGmailのWeb UIの検索バーと同じ構文が使える。メールのフィルタリングに強力な機能だ。よく使うパターンをいくつか紹介する。

// 送信者で絞り込み
const query1 = "from:notifications@example.com";

// 日付範囲で絞り込み
const query2 = "after:2025/01/01 before:2025/04/01";

// 特定ドメインからのメールを除外
const query3 = "-from:@spam-domain.com";

// 複数ドメインを除外
const query4 = "-from:@marketing.example.com -from:@newsletter.example.com";

// ラベルと未読を組み合わせ
const query5 = "label:work is:unread";

// 件名に特定のキーワードを含む
const query6 = "subject:請求書";

// 添付ファイル付き + 特定サイズ以上
const query7 = "has:attachment larger:5M";

// 組み合わせた実践的な例:
// 今年の未読メールで、社内ドメインを除外し、添付ファイル付き
const practicalQuery =
  "is:unread has:attachment after:2025/01/01 -from:@mycompany.com";

https://developers.google.com/workspace/gmail/api/guides/filtering?hl=ja

ドメイン除外は-from:@domain.comの形式で書く。ハイフン(-)がNOT演算子として機能する。複数ドメインを除外したい場合はスペース区切りで-from:を並べればよい。

このパラメータは、Gmail ウェブ インターフェースと同じ高度な検索構文のほとんどをサポートしています。
https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/list?hl=ja

注意点として、Gmail APIのqパラメータはWeb UIと完全に同じではない。公式ドキュメントにも記載されているが、エイリアスの展開がAPI側では行われないケースがある。Google Workspaceでアカウントのエイリアスを設定している場合、Web UIでは検索にヒットするがAPIでは返らないという差異が生じうる。

Gmail API のスレッド取得

Gmail APIにはスレッド単位で取得する専用エンドポイントusers.threads.listが用意されている。Graph APIのように手動でグルーピングする必要がない。

ページネーションの仕組みはmessages.listと同じで、nextPageToken / pageTokenのパターンで全スレッドを順次取得する。threads.getを呼ぶと、そのスレッドに属する全メッセージがmessages配列に含まれて返ってくる。

実装で気をつけるべきこと

レートリミット対策

どちらのAPIもレートリミットが設定されている。大量のメールを取得する場合、リトライ処理は必須だ。

async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3,
  baseDelay = 1000
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error: unknown) {
      if (attempt === maxRetries) throw error;

      // 429 Too Many Requests の場合はリトライ
      const status = (error as { code?: number }).code;
      if (status !== 429) throw error;

      const delay = baseDelay * Math.pow(2, attempt);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }

  throw new Error("unreachable");
}

指数バックオフ(リトライ間隔を倍々に伸ばしていく方式)は、API連携における基本パターンだ。Graph APIもGmail APIも429ステータスを返した場合はRetry-Afterヘッダーを確認するのがベターだが、指数バックオフで十分対応できることが多い。

Graph API で $skip を避ける理由

繰り返しになるが、メールのような動的に変化するデータに$skipを使うのは危険だ。ページ1を取得した直後に新着メールが届くと、ページ2の先頭が本来ページ1に含まれるはずだったメッセージにずれる。結果として同じメールが重複して取得されたり、一部のメールが抜け落ちたりする。

@odata.nextLink内部で使われる$skiptokenはサーバー側が管理するカーソルであり、$skipのようなオフセットとは仕組みが異なる。データの挿入・削除があっても正しく次のページを指し示す。だから手動で$skipを計算するのではなく、@odata.nextLinkをそのまま使うべきなのだ。

Gmail API で messages.list と messages.get を分離する理由

Gmail APIが一覧取得でIDしか返さない設計は、最初は不便に感じるかもしれない。しかしこの分離にはメリットがある。

一覧取得の段階ではレスポンスが非常に軽い。数千件のメッセージIDを取得しても通信量はわずかだ。そして詳細取得の段階でformatパラメータによって必要な粒度を選べる。ヘッダーだけでいいならmetadata、本文のテキストだけならminimal、完全なデータが要るならfull。用途に応じて最適なリクエストが組める。

また、一覧取得のレスポンスが軽いことでmaxResults: 500を指定しても高速にページネーションが進む。IDだけの取得なら全メールボックスのスキャンも現実的な時間で完了する。

目次