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

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

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

この記事では、Microsoft Graph APIとGmail APIのページネーションを、TypeScriptの実装コード付きで解説する。両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件まで指定できる。件数を増やせばAPIコール数を減らせるが、レスポンスサイズが大きくなるのでネットワーク状況との兼ね合いになる。

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

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

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

TypeScript実装 — 全件取得

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

import { Client, PageCollection } from "@microsoft/microsoft-graph-client";
import { Message } from "@microsoft/microsoft-graph-types";

async function fetchAllMessages(client: Client): Promise<Message[]> {
  const messages: Message[] = [];

  // 初回リクエスト: $topで1ページあたりの件数を指定
  let response: PageCollection = await client
    .api("/me/messages")
    .top(50)
    .select("id,subject,from,receivedDateTime")
    .orderby("receivedDateTime DESC")
    .get();

  // nextLinkが存在する限りループ
  while (true) {
    // 今のページの結果を蓄積
    messages.push(...(response.value as Message[]));

    // nextLinkがなければ最終ページ
    const nextLink = response["@odata.nextLink"];
    if (!nextLink) break;

    // nextLinkのURLをそのままGETに渡す
    response = await client.api(nextLink).get();
  }

  return messages;
}

ポイントは3つある。

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

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

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

SDK の PageIterator を使う方法

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

import {
  Client,
  PageCollection,
  PageIterator,
  PageIteratorCallback,
} from "@microsoft/microsoft-graph-client";
import { Message } from "@microsoft/microsoft-graph-types";

async function fetchWithPageIterator(client: Client): Promise<Message[]> {
  const messages: Message[] = [];

  const response: PageCollection = await client
    .api("/me/messages")
    .top(50)
    .select("id,subject,from,receivedDateTime")
    .get();

  // コールバックで1件ずつ処理する
  // trueを返すと続行、falseを返すと一時停止
  const callback: PageIteratorCallback = (message: Message) => {
    messages.push(message);
    return true;
  };

  const pageIterator = new PageIterator(client, response, callback);
  await pageIterator.iterate();

  return messages;
}

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エンドポイントとは対照的な設計だ。

Gmail API のページネーション

基本の仕組み — pageToken と nextPageToken

Gmail APIのページネーションはトークンベースだ。users.messages.listのレスポンスにnextPageTokenが含まれていれば次ページが存在する。次のリクエストでpageTokenパラメータにこの値を渡す。

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

Graph APIの@odata.nextLinkがURLを丸ごと返すのに対して、Gmail APIのnextPageTokenはトークン文字列のみを返す。次のリクエストではベースのエンドポイントURLにpageTokenパラメータとして付与する。

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は一覧取得と詳細取得を明確に分離する設計になっている。

TypeScript実装 — 全メッセージID取得

Gmail APIの公式Node.jsクライアントgoogleapisを使った実装を示す。

import { google, gmail_v1 } from "googleapis";
import { OAuth2Client } from "google-auth-library";

interface MessageRef {
  id: string;
  threadId: string;
}

async function fetchAllMessageIds(
  auth: OAuth2Client,
  query?: string
): Promise<MessageRef[]> {
  const gmail = google.gmail({ version: "v1", auth });
  const messages: MessageRef[] = [];
  let pageToken: string | undefined;

  do {
    const response = await gmail.users.messages.list({
      userId: "me",
      maxResults: 500,
      pageToken,
      q: query, // 検索クエリ(任意)
    });

    const data = response.data;
    if (data.messages) {
      for (const msg of data.messages) {
        if (msg.id && msg.threadId) {
          messages.push({ id: msg.id, threadId: msg.threadId });
        }
      }
    }

    pageToken = data.nextPageToken ?? undefined;
  } while (pageToken);

  return messages;
}

do-whileループの構造がポイントだ。初回はpageTokenundefinedなのでパラメータなしでリクエストし、レスポンスのnextPageTokenを次のイテレーションに渡す。nextPageTokenが返らなくなったら全ページ取得完了。

メッセージ詳細をバッチ取得する

一覧で取得したIDをもとに、個々のメッセージの詳細を取得する処理が必要になる。大量のIDに対して1件ずつmessages.getを呼ぶのは非効率なので、並列リクエストで処理速度を上げる。ただしGmail APIにはレートリミットがあるため、同時実行数の制限は必須だ。

import { gmail_v1 } from "googleapis";

interface MessageDetail {
  id: string;
  subject: string;
  from: string;
  date: string;
}

async function fetchMessageDetails(
  gmail: gmail_v1.Gmail,
  messageIds: string[],
  concurrency = 10
): Promise<MessageDetail[]> {
  const results: MessageDetail[] = [];

  // 同時実行数を制限しながら並列リクエスト
  for (let i = 0; i < messageIds.length; i += concurrency) {
    const batch = messageIds.slice(i, i + concurrency);

    const details = await Promise.all(
      batch.map(async (id) => {
        const res = await gmail.users.messages.get({
          userId: "me",
          id,
          format: "metadata",
          metadataHeaders: ["Subject", "From", "Date"],
        });

        const headers = res.data.payload?.headers ?? [];
        const getHeader = (name: string) =>
          headers.find((h) => h.name === name)?.value ?? "";

        return {
          id,
          subject: getHeader("Subject"),
          from: getHeader("From"),
          date: getHeader("Date"),
        };
      })
    );

    results.push(...details);
  }

  return results;
}

format: "metadata"を指定すると、メール本文を除いたヘッダー情報だけが返る。一覧画面の表示に必要な情報であればこれで十分だし、レスポンスサイズも大幅に小さくなる。

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";

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

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

Gmail API のスレッド取得

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

async function fetchThreads(
  auth: OAuth2Client,
  query?: string
): Promise<gmail_v1.Schema$Thread[]> {
  const gmail = google.gmail({ version: "v1", auth });
  const threads: gmail_v1.Schema$Thread[] = [];
  let pageToken: string | undefined;

  do {
    const response = await gmail.users.threads.list({
      userId: "me",
      maxResults: 100,
      pageToken,
      q: query,
    });

    if (response.data.threads) {
      threads.push(...response.data.threads);
    }

    pageToken = response.data.nextPageToken ?? undefined;
  } while (pageToken);

  return threads;
}

// スレッド内の全メッセージを取得
async function fetchThreadDetail(
  gmail: gmail_v1.Gmail,
  threadId: string
): Promise<gmail_v1.Schema$Thread> {
  const response = await gmail.users.threads.get({
    userId: "me",
    id: threadId,
    format: "metadata",
    metadataHeaders: ["Subject", "From", "Date"],
  });

  return response.data;
}

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

両APIの比較

Microsoft Graph APIGmail API
ページネーション方式サーバー駆動型(@odata.nextLink にURL)トークンベース(nextPageToken に文字列)
1ページ件数の指定$top(最大999)maxResults(最大500)
一覧レスポンスの情報量件名・送信者など主要フィールドを含むIDとスレッドIDのみ
スレッド取得conversationIdでクライアント側グルーピングthreads.list / threads.get エンドポイント
検索フィルタOData $filter / $searchqパラメータ(Gmail検索構文)
オフセット指定$skip(非推奨)なし(トークンのみ)
SDK自動ページングPageIterator クラスなし(手動実装)

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

レートリミット対策

どちらの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だけの取得なら全メールボックスのスキャンも現実的な時間で完了する。

まとめ

メールAPIのページネーションは、仕組みさえ理解すればシンプルだ。どちらのAPIも「次ページの情報をレスポンスに含め、それを使って次のリクエストを送る」という基本パターンは共通している。

Microsoft Graph APIは@odata.nextLinkのURLをそのまま使う。$topで件数を指定し、$skipは使わない。SDKのPageIteratorを使えば自動化もできる。

Gmail APIはnextPageTokenpageTokenに渡す。一覧はIDのみが返るので、詳細が必要ならmessages.getを別途呼ぶ。qパラメータでGmail検索構文がそのまま使えるのは強力だ。

どちらを使う場合でも、レートリミットへの対策(指数バックオフ)とレスポンスフィールドの絞り込み($selectformat: "metadata")は忘れずに実装しておこう。

目次