Gmail API と Microsoft Graph API でメール送信機能を実装する(Node.js)

業務システムやSaaSでメール送信機能を組み込むとき、ユーザーが利用しているメールプロバイダに応じてGmail(Google Workspace)とOutlook(Microsoft 365)の両方に対応する必要が出てくる。

本記事では、Node.js環境で googleapis / google-auth-library@microsoft/microsoft-graph-client / @azure/msal-node を使い、両プロバイダのメール送信を実装する方法を解説する。

目次

使用パッケージと役割

今回扱うパッケージは以下の5つ。

パッケージバージョン役割
google-auth-library^10.6.2Google OAuth2 認証クライアント
googleapis^171.4.0Gmail API を含むGoogle APIラッパー
@azure/msal-node^5.1.2Microsoft Identity Platform(OAuth2)認証
@microsoft/microsoft-graph-client^3.0.7Microsoft Graph API クライアント
@microsoft/microsoft-graph-types^2.43.0Graph API のTypeScript型定義

Google側は google-auth-library で認証トークンを取得し、googleapis 経由でGmail APIを呼ぶ。Microsoft側は @azure/msal-node(MSAL)でトークンを取得し、@microsoft/microsoft-graph-client でGraph APIを呼ぶ。認証ライブラリとAPIクライアントが分離している構造は両者に共通している。

以下の図は、両プロバイダのメール送信における処理フローを示している。

メール送信 処理フロー比較 Google(Gmail API) google-auth-library OAuth2 認証 googleapis (gmail.users.messages) API クライアント RFC 2822 + Base64url エンコード メール形式 送信完了 Microsoft(Graph API) @azure/msal-node OAuth2 認証(MSAL) microsoft-graph-client API クライアント JSON(Message リソース) メール形式 送信完了 両者とも「認証ライブラリ → APIクライアント → 送信」の3ステップ構造 最大の違いはメール本文の形式(RFC 2822 vs JSON)

Google側 OAuth2 認証のセットアップ

Google Cloud Consoleでプロジェクトを作成し、Gmail APIを有効化したあと、OAuth 2.0クライアントIDを発行する。取得した client_idclient_secretredirect_uri を使って認証クライアントを初期化する。

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

const oauth2Client = new OAuth2Client({
  clientId: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  redirectUri: process.env.GOOGLE_REDIRECT_URI,
});

// 認可URLの生成
const authUrl = oauth2Client.generateAuthUrl({
  access_type: "offline",
  scope: ["https://www.googleapis.com/auth/gmail.send"],
});

// コールバックでトークン取得
async function handleCallback(code: string) {
  const { tokens } = await oauth2Client.getToken(code);
  oauth2Client.setCredentials(tokens);
  return tokens;
}

access_type: "offline" を指定するとリフレッシュトークンが返る。これをDBに保存しておけば、ユーザーが再認可しなくてもトークンの自動更新ができる。スコープは gmail.send のみに絞ることで、メールの読み取り権限を不要にできる。最小権限の原則として重要なポイントだ。

Google側 Gmail API でメール送信

Gmail APIの users.messages.send はRFC 2822形式のメールをBase64urlエンコードして渡す必要がある。生のMIMEメッセージを自前で組み立てるのが特徴的だ。

const gmail = google.gmail({ version: "v1", auth: oauth2Client });

async function sendGmail(
  to: string,
  subject: string,
  body: string
): Promise<string> {
  const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString("base64")}?=`;

  const messageParts = [
    `To: ${to}`,
    `Subject: ${utf8Subject}`,
    "MIME-Version: 1.0",
    'Content-Type: text/plain; charset="UTF-8"',
    "",
    body,
  ];
  const message = messageParts.join("\n");

  const encodedMessage = Buffer.from(message)
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");

  const res = await gmail.users.messages.send({
    userId: "me",
    requestBody: { raw: encodedMessage },
  });

  return res.data.id!;
}

件名に日本語を含む場合、RFC 2047のBエンコーディングで包む必要がある。ここを怠ると受信側で文字化けする。また、Base64urlエンコードは標準のBase64と異なり、+-/_ に置換し、末尾のパディング = を除去する点に注意。

HTML形式のメールを送りたい場合は Content-Typetext/html に変更し、body にHTMLを渡せばよい。添付ファイルを含める場合はmultipart/mixedのMIMEメッセージを構築する必要があり、かなり煩雑になる。

Microsoft側 MSAL で認証

Microsoft側の認証にはMSAL(Microsoft Authentication Library)を使う。

Azure AD(Entra ID)でアプリ登録を行い、クライアントIDとシークレットを取得する。

import * as msal from "@azure/msal-node";

const msalConfig: msal.Configuration = {
  auth: {
    clientId: process.env.AZURE_CLIENT_ID!,
    authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
    clientSecret: process.env.AZURE_CLIENT_SECRET,
  },
};

const cca = new msal.ConfidentialClientApplication(msalConfig);

// 認可コードからトークン取得
async function handleMsCallback(code: string): Promise<msal.AuthenticationResult> {
  const result = await cca.acquireTokenByCode({
    code,
    scopes: ["https://graph.microsoft.com/Mail.Send"],
    redirectUri: process.env.AZURE_REDIRECT_URI!,
  });
  return result;
}

MSALには ConfidentialClientApplication(サーバーサイド向け)と PublicClientApplication(SPAやデスクトップ向け)がある。

ConfidentialClientApplication vs PublicClientApplication ConfidentialClientApplication 実行環境: サーバーサイド(Node.js等) 認証: clientSecret / 証明書を保持 対応フロー: 認可コード / クライアント資格情報 パーミッション: Delegated + Application シークレット漏洩 = 重大インシデント PublicClientApplication 実行環境: SPA / CLI / モバイル 認証: PKCE(シークレットなし) 対応フロー: 認可コード + PKCE / デバイスコード パーミッション: Delegated のみ シークレットなし = 漏洩リスクなし ユースケース例 バックエンドからのメール送信 バッチ処理(ユーザー不在) 管理者権限での組織データ操作 ブラウザSPAからのAPI呼び出し CLIツールでのユーザー認証 デスクトップ / モバイルアプリ 本記事はこちらを使用 シークレットを安全に保管できる環境かどうかが選択基準

バックエンドでメール送信を行う場合は ConfidentialClientApplication を使う

ConfidentialClientApplication は、クライアントシークレット(またはクライアント証明書)を安全に保持できるサーバーサイド環境を前提とした認証クライアントだ。

ConfidentialClientApplication内部的には以下の処理を担っている。

まず、OAuth2の認可コードフローにおいて、認可コードをトークンエンドポイント(https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token)に送信し、アクセストークンとリフレッシュトークンを取得する。

このとき、リクエストにはクライアントIDだけでなくクライアントシークレットも含まれる。

これが「Confidential(秘密を保持できる)」の意味であり、シークレットを安全に保管できないブラウザやモバイルアプリでは使えない。

認可コードフロー(ConfidentialClientApplication) ユーザー バックエンド Azure AD 1 ログインボタンクリック 2 Azure AD 認可URLへリダイレクト 3 ユーザーがログイン・同意 4 認可コードをコールバックURLへ 5 acquireTokenByCode() 認可コード + clientSecret を送信 → トークンエンドポイント /oauth2/v2.0/token 6 アクセストークン + リフレッシュトークン 7 トークンをキャッシュ / DBに保存 以降 acquireTokenSilent で自動更新 8 Graph API /me/sendMail Authorization: Bearer {accessToken} ステップ5でclientSecretを送るのがConfidentialの核心(PKCEとの違い)

Google側との大きな違いとして、MSALはトークンキャッシュを内蔵しており、acquireTokenSilent を呼ぶだけで期限切れトークンの自動更新を処理してくれる。

Google側では oauth2Client.setCredentials でリフレッシュトークンをセットしたうえで、ライブラリ側の自動更新に任せる形になるため、挙動は似ているがAPIの設計思想に差がある。

Microsoft側 Graph API でメール送信

Graph APIのメール送信は /me/sendMail エンドポイントにJSON形式のメッセージオブジェクトをPOSTする。Gmail APIと比べてメール本文の構築が格段にシンプルだ。

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

function getGraphClient(accessToken: string): Client {
  return Client.init({
    authProvider: (done) => {
      done(null, accessToken);
    },
  });
}

async function sendOutlookMail(
  accessToken: string,
  to: string,
  subject: string,
  body: string
): Promise<void> {
  const client = getGraphClient(accessToken);

  const message: Message = {
    subject,
    body: {
      contentType: "text",
      content: body,
    },
    toRecipients: [
      {
        emailAddress: { address: to },
      },
    ],
  };

  await client.api("/me/sendMail").post({
    message,
    saveToSentItems: true,
  });
}

@microsoft/microsoft-graph-types を入れておくと Message 型が使えるので、プロパティの補完とバリデーションが効く。saveToSentItems: true を指定すると送信済みアイテムに保存される(デフォルトは true だが、明示しておくと意図が明確になる)。

RFC 2822のMIMEメッセージを組み立てる必要がないため、HTMLメールや添付ファイルもJSON内のプロパティとして構造的に指定できる。この点はGraph APIの方が開発体験として優れている。

型定義パッケージによるエディタ補完の違い

TypeScriptで開発する場合、型定義の充実度がコーディング体験に直結する。両者の型サポートには明確な差がある。

Microsoft Graph Types の型補完

@microsoft/microsoft-graph-types をインストールすると、Graph APIのリソースに対応する型が一通り使える。メール送信で主に触れる型は以下の通り。

型名役割主なプロパティ
Messageメールメッセージ全体subject, body, toRecipients, ccRecipients, attachments, importance など
ItemBodyメール本文contentType ("text" / "html"), content
Recipient宛先1件emailAddress: { name?, address }
EmailAddressメールアドレスname, address
FileAttachment添付ファイルname, contentType, contentBytes
BodyType本文の形式"text" / "html"

エディタ上では、たとえば message. と入力した時点で subjectbodytoRecipientsimportancehasAttachments などのプロパティ候補が表示される。body の中に入れば contentTypecontent が補完され、contentType にはリテラル型 "text" | "html" が効く。

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

const message: Message = {
  subject: "月次レポート",
  body: {
    contentType: "html", // "text" | "html" のリテラル補完が効く
    content: "<p>添付をご確認ください</p>",
  },
  toRecipients: [
    {
      emailAddress: {
        address: "tanaka@example.com",
        name: "田中太郎", // name はオプショナルと型で明示されている
      },
    },
  ],
  importance: "high", // "low" | "normal" | "high"
};

// 添付ファイルも型付きで構築できる
const attachment: FileAttachment = {
  "@odata.type": "#microsoft.graph.fileAttachment",
  name: "report.pdf",
  contentType: "application/pdf",
  contentBytes: "base64encodedstring...",
};

Message 型には50以上のプロパティが定義されており、APIドキュメントを都度参照しなくても、エディタの補完でどんなフィールドが使えるか把握できる。typoや存在しないプロパティの指定もコンパイル時に検出できるため、実行時エラーの削減に直結する。

Google側(googleapis)の型補完

googleapis パッケージは内部に gmail_v1 名前空間を持っており、APIレスポンスやリクエストの型が定義されている。ただし、メール送信で渡す raw フィールドはBase64urlエンコード済みの文字列なので、メール本文の構造に対して型が効くわけではない。

import { gmail_v1 } from "googleapis";

// レスポンスの型は効く
const res: gmail_v1.Schema$Message = {
  id: "18a1b2c3d4e5f6",
  threadId: "18a1b2c3d4e5f6",
  labelIds: ["SENT"],
  // snippet, payload, internalDate なども補完される
};

// しかし送信時のリクエストは raw: string のみ
const sendParams: gmail_v1.Params$Resource$Users$Messages$Send = {
  userId: "me",
  requestBody: {
    raw: "Base64urlエンコード済み文字列", // ここに型の恩恵はない
  },
};

つまりGoogle側では、APIの呼び出しパラメータやレスポンスには型が効くが、メール本文の構造(宛先、件名、本文、添付ファイル)は自前で組み立てるため型の保護が及ばない。一方Microsoft側は、メール本文の構造自体がJSON=TypeScriptオブジェクトなので、末端のプロパティまで型補完とバリデーションが効く。この差は規模が大きいプロジェクトほど効いてくる。

両者の差異を抽象化する

実際のプロダクトでは、ユーザーのメールプロバイダに応じてGmailとOutlookを切り替える必要がある。共通インターフェースで抽象化しておくと、呼び出し側のコードをプロバイダ非依存にできる。

interface EmailProvider {
  sendMail(params: {
    to: string;
    subject: string;
    body: string;
    html?: boolean;
  }): Promise<{ messageId: string }>;
}

class GmailProvider implements EmailProvider {
  constructor(private oauth2Client: OAuth2Client) {}

  async sendMail(params) {
    const id = await sendGmail(params.to, params.subject, params.body);
    return { messageId: id };
  }
}

class OutlookProvider implements EmailProvider {
  constructor(private accessToken: string) {}

  async sendMail(params) {
    await sendOutlookMail(
      this.accessToken,
      params.to,
      params.subject,
      params.body
    );
    return { messageId: "" }; // Graph APIのsendMailはIDを返さない
  }
}

// 利用側
function getProvider(userProvider: "google" | "microsoft", credentials: any): EmailProvider {
  if (userProvider === "google") {
    return new GmailProvider(credentials.oauth2Client);
  }
  return new OutlookProvider(credentials.accessToken);
}

ここで注意すべき点がある。Graph APIの sendMail はレスポンスボディが空で、送信したメッセージのIDを返さない。送信後のメッセージIDが必要な場合は、sendMail の代わりに下書き作成(/me/messages)→ 送信(/me/messages/{id}/send)の2ステップにする必要がある。Gmail APIは users.messages.send のレスポンスでメッセージIDを返すため、この非対称性をインターフェース設計で吸収する必要がある。

トークン管理で押さえておくべきこと

両プロバイダともOAuth2のアクセストークンには有効期限がある(通常1時間程度)。プロダクション環境では以下の点を必ず考慮する。

リフレッシュトークンの永続化 — アクセストークンが切れたとき、リフレッシュトークンを使って新しいアクセストークンを取得する。リフレッシュトークンはDBに暗号化して保存する。Google側は oauth2Client にリフレッシュトークンをセットしておけば getAccessToken() 呼び出し時に自動更新される。MSAL側は acquireTokenSilent が同等の処理を行う。

トークン失効への対処 — ユーザーがアプリの権限を取り消したり、パスワードを変更すると、リフレッシュトークンが無効化される。この場合は再認可フローにリダイレクトする必要がある。APIから 401invalid_grant エラーが返ったときのハンドリングを必ず実装しておく。

Google側のリフレッシュトークン特有の注意 — Googleは初回認可時のみリフレッシュトークンを返す。再認可時にもリフレッシュトークンを取得したい場合は、generateAuthUrl のパラメータに prompt: "consent" を追加する。

エラーハンドリングの実装パターン

メール送信は外部API呼び出しのため、ネットワークエラーやレートリミットへの対応が不可欠だ。

async function sendMailWithRetry(
  provider: EmailProvider,
  params: { to: string; subject: string; body: string },
  maxRetries = 3
): Promise<{ messageId: string }> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await provider.sendMail(params);
    } catch (error: any) {
      const status = error?.statusCode || error?.code;

      // 認証エラーはリトライしても無駄
      if (status === 401 || status === 403) {
        throw new Error(`認証エラー: 再認可が必要です (${status})`);
      }

      // レートリミット or サーバーエラーはリトライ
      if (status === 429 || (status >= 500 && status < 600)) {
        if (attempt === maxRetries) throw error;
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise((r) => setTimeout(r, delay));
        continue;
      }

      throw error;
    }
  }
  throw new Error("リトライ上限に達しました");
}

Gmail APIは1日あたりの送信数上限(無料アカウントで100通、Google Workspaceで2,000通)、Graph APIはメールボックスあたり10,000通/10分のリミットがある。大量送信が想定される場合はキューイング(BullMQなど)の導入を検討する。

開発・テスト時のスコープ設定

以下に、メール関連で使用頻度の高いスコープをまとめる。

操作Gmail API スコープGraph API パーミッション
送信のみgmail.sendMail.Send
読み取りgmail.readonlyMail.Read
読み書きgmail.modifyMail.ReadWrite
全権限mail.google.com(非推奨)Mail.ReadWrite + Mail.Send

開発中は広めのスコープで動作確認しがちだが、本番リリース前に必要最小限に絞ることを忘れないこと。Googleはスコープが広いとOAuth同意画面の審査が厳しくなり、Microsoftは管理者同意が必要なパーミッション(Application 型)を使うと組織のIT管理者の承認が必要になる。

実装を進めるためのヒント

ここまでの内容を踏まえて、実際にプロジェクトに組み込む際のポイントを整理する。

まず、OAuth2のコールバックエンドポイントを先に実装し、トークン取得・保存の仕組みを確立させる。メール送信のコード自体は比較的シンプルなので、認証周りが安定すればあとは速い。

次に、ローカル開発ではGoogleの「テストユーザー」機能とMicrosoftの「開発者テナント」を活用する。どちらも本番環境のメールボックスを汚さずに動作確認できる。Googleはテストモードのまま最大100人のテストユーザーを登録でき、OAuth同意画面の審査なしで開発を進められる。

最後に、抽象化レイヤーの実装は後回しにしてよい。まずはどちらか一方のプロバイダで動くものを作り、もう片方を追加するタイミングでインターフェースを切り出す方が、過剰な設計を避けられる。YAGNI(You Ain’t Gonna Need It)の原則に従い、必要になった時点で抽象化する方が結果的に良いコードになる。

参考リンク

Google(Gmail API)

Microsoft(Graph API)

業務システムやSaaSでメール送信機能を組み込むとき、ユーザーが利用しているメールプロバイダに応じてGmail(Google Workspace)とOutlook(Microsoft 365)の両方に対応する必要が出てくる。

本記事では、Node.js環境で googleapis / google-auth-library@microsoft/microsoft-graph-client / @azure/msal-node を使い、両プロバイダのメール送信を実装する方法を解説する。

使用パッケージと役割

今回扱うパッケージは以下の5つ。

パッケージバージョン役割
google-auth-library^10.6.2Google OAuth2 認証クライアント
googleapis^171.4.0Gmail API を含むGoogle APIラッパー
@azure/msal-node^5.1.2Microsoft Identity Platform(OAuth2)認証
@microsoft/microsoft-graph-client^3.0.7Microsoft Graph API クライアント
@microsoft/microsoft-graph-types^2.43.0Graph API のTypeScript型定義

Google側は google-auth-library で認証トークンを取得し、googleapis 経由でGmail APIを呼ぶ。Microsoft側は @azure/msal-node(MSAL)でトークンを取得し、@microsoft/microsoft-graph-client でGraph APIを呼ぶ。認証ライブラリとAPIクライアントが分離している構造は両者に共通している。

メール送信 処理フロー比較 Google(Gmail API) google-auth-library OAuth2 認証 googleapis (gmail.users.messages) API クライアント RFC 2822 + Base64url エンコード メール形式 送信完了 Microsoft(Graph API) @azure/msal-node OAuth2 認証(MSAL) microsoft-graph-client API クライアント JSON(Message リソース) メール形式 送信完了 両者とも「認証ライブラリ → APIクライアント → 送信」の3ステップ構造 最大の違いはメール本文の形式(RFC 2822 vs JSON)

Google側:OAuth2 認証のセットアップ

Google Cloud Consoleでプロジェクトを作成し、Gmail APIを有効化したあと、OAuth 2.0クライアントIDを発行する。取得した client_idclient_secretredirect_uri を使って認証クライアントを初期化する。

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

const oauth2Client = new OAuth2Client({
  clientId: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  redirectUri: process.env.GOOGLE_REDIRECT_URI,
});

// 認可URLの生成
const authUrl = oauth2Client.generateAuthUrl({
  access_type: "offline",
  scope: ["https://www.googleapis.com/auth/gmail.send"],
});

// コールバックでトークン取得
async function handleCallback(code: string) {
  const { tokens } = await oauth2Client.getToken(code);
  oauth2Client.setCredentials(tokens);
  return tokens;
}

access_type: "offline" を指定するとリフレッシュトークンが返る。これをDBに保存しておけば、ユーザーが再認可しなくてもトークンの自動更新ができる。スコープは gmail.send のみに絞ることで、メールの読み取り権限を不要にできる。最小権限の原則として重要なポイントだ。

Google側:Gmail API でメール送信

Gmail APIの users.messages.send はRFC 2822形式のメールをBase64urlエンコードして渡す必要がある。生のMIMEメッセージを自前で組み立てるのが特徴的だ。

const gmail = google.gmail({ version: "v1", auth: oauth2Client });

async function sendGmail(
  to: string,
  subject: string,
  body: string
): Promise<string> {
  const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString("base64")}?=`;

  const messageParts = [
    `To: ${to}`,
    `Subject: ${utf8Subject}`,
    "MIME-Version: 1.0",
    'Content-Type: text/plain; charset="UTF-8"',
    "",
    body,
  ];
  const message = messageParts.join("\n");

  const encodedMessage = Buffer.from(message)
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");

  const res = await gmail.users.messages.send({
    userId: "me",
    requestBody: { raw: encodedMessage },
  });

  return res.data.id!;
}

件名に日本語を含む場合、RFC 2047のBエンコーディングで包む必要がある。ここを怠ると受信側で文字化けする。また、Base64urlエンコードは標準のBase64と異なり、+-/_ に置換し、末尾のパディング = を除去する点に注意。

HTML形式のメールを送りたい場合は Content-Typetext/html に変更し、body にHTMLを渡せばよい。添付ファイルを含める場合はmultipart/mixedのMIMEメッセージを構築する必要があり、かなり煩雑になる。

Microsoft側:MSAL で認証

Microsoft側の認証にはMSAL(Microsoft Authentication Library)を使う。Azure AD(Entra ID)でアプリ登録を行い、クライアントIDとシークレットを取得する。

import * as msal from "@azure/msal-node";

const msalConfig: msal.Configuration = {
  auth: {
    clientId: process.env.AZURE_CLIENT_ID!,
    authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
    clientSecret: process.env.AZURE_CLIENT_SECRET,
  },
};

const cca = new msal.ConfidentialClientApplication(msalConfig);

// 認可コードからトークン取得
async function handleMsCallback(code: string): Promise<msal.AuthenticationResult> {
  const result = await cca.acquireTokenByCode({
    code,
    scopes: ["https://graph.microsoft.com/Mail.Send"],
    redirectUri: process.env.AZURE_REDIRECT_URI!,
  });
  return result;
}

MSALには ConfidentialClientApplication(サーバーサイド向け)と PublicClientApplication(SPAやデスクトップ向け)がある。バックエンドでメール送信を行う場合は ConfidentialClientApplication を使う。

Google側との大きな違いとして、MSALはトークンキャッシュを内蔵しており、acquireTokenSilent を呼ぶだけで期限切れトークンの自動更新を処理してくれる。Google側では oauth2Client.setCredentials でリフレッシュトークンをセットしたうえで、ライブラリ側の自動更新に任せる形になるため、挙動は似ているがAPIの設計思想に差がある。

Microsoft側:Graph API でメール送信

Graph APIのメール送信は /me/sendMail エンドポイントにJSON形式のメッセージオブジェクトをPOSTする。Gmail APIと比べてメール本文の構築が格段にシンプルだ。

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

function getGraphClient(accessToken: string): Client {
  return Client.init({
    authProvider: (done) => {
      done(null, accessToken);
    },
  });
}

async function sendOutlookMail(
  accessToken: string,
  to: string,
  subject: string,
  body: string
): Promise<void> {
  const client = getGraphClient(accessToken);

  const message: Message = {
    subject,
    body: {
      contentType: "text",
      content: body,
    },
    toRecipients: [
      {
        emailAddress: { address: to },
      },
    ],
  };

  await client.api("/me/sendMail").post({
    message,
    saveToSentItems: true,
  });
}

@microsoft/microsoft-graph-types を入れておくと Message 型が使えるので、プロパティの補完とバリデーションが効く。saveToSentItems: true を指定すると送信済みアイテムに保存される(デフォルトは true だが、明示しておくと意図が明確になる)。

RFC 2822のMIMEメッセージを組み立てる必要がないため、HTMLメールや添付ファイルもJSON内のプロパティとして構造的に指定できる。この点はGraph APIの方が開発体験として優れている。

両者の差異を抽象化する

実際のプロダクトでは、ユーザーのメールプロバイダに応じてGmailとOutlookを切り替える必要がある。共通インターフェースで抽象化しておくと、呼び出し側のコードをプロバイダ非依存にできる。

interface EmailProvider {
  sendMail(params: {
    to: string;
    subject: string;
    body: string;
    html?: boolean;
  }): Promise<{ messageId: string }>;
}

class GmailProvider implements EmailProvider {
  constructor(private oauth2Client: OAuth2Client) {}

  async sendMail(params) {
    const id = await sendGmail(params.to, params.subject, params.body);
    return { messageId: id };
  }
}

class OutlookProvider implements EmailProvider {
  constructor(private accessToken: string) {}

  async sendMail(params) {
    await sendOutlookMail(
      this.accessToken,
      params.to,
      params.subject,
      params.body
    );
    return { messageId: "" }; // Graph APIのsendMailはIDを返さない
  }
}

// 利用側
function getProvider(userProvider: "google" | "microsoft", credentials: any): EmailProvider {
  if (userProvider === "google") {
    return new GmailProvider(credentials.oauth2Client);
  }
  return new OutlookProvider(credentials.accessToken);
}

ここで注意すべき点がある。Graph APIの sendMail はレスポンスボディが空で、送信したメッセージのIDを返さない。送信後のメッセージIDが必要な場合は、sendMail の代わりに下書き作成(/me/messages)→ 送信(/me/messages/{id}/send)の2ステップにする必要がある。Gmail APIは users.messages.send のレスポンスでメッセージIDを返すため、この非対称性をインターフェース設計で吸収する必要がある。

トークン管理で押さえておくべきこと

両プロバイダともOAuth2のアクセストークンには有効期限がある(通常1時間程度)。プロダクション環境では以下の点を必ず考慮する。

リフレッシュトークンの永続化 — アクセストークンが切れたとき、リフレッシュトークンを使って新しいアクセストークンを取得する。リフレッシュトークンはDBに暗号化して保存する。Google側は oauth2Client にリフレッシュトークンをセットしておけば getAccessToken() 呼び出し時に自動更新される。MSAL側は acquireTokenSilent が同等の処理を行う。

トークン失効への対処 — ユーザーがアプリの権限を取り消したり、パスワードを変更すると、リフレッシュトークンが無効化される。この場合は再認可フローにリダイレクトする必要がある。APIから 401invalid_grant エラーが返ったときのハンドリングを必ず実装しておく。

Google側のリフレッシュトークン特有の注意 — Googleは初回認可時のみリフレッシュトークンを返す。再認可時にもリフレッシュトークンを取得したい場合は、generateAuthUrl のパラメータに prompt: "consent" を追加する。

エラーハンドリングの実装パターン

メール送信は外部API呼び出しのため、ネットワークエラーやレートリミットへの対応が不可欠だ。

async function sendMailWithRetry(
  provider: EmailProvider,
  params: { to: string; subject: string; body: string },
  maxRetries = 3
): Promise<{ messageId: string }> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await provider.sendMail(params);
    } catch (error: any) {
      const status = error?.statusCode || error?.code;

      // 認証エラーはリトライしても無駄
      if (status === 401 || status === 403) {
        throw new Error(`認証エラー: 再認可が必要です (${status})`);
      }

      // レートリミット or サーバーエラーはリトライ
      if (status === 429 || (status >= 500 && status < 600)) {
        if (attempt === maxRetries) throw error;
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise((r) => setTimeout(r, delay));
        continue;
      }

      throw error;
    }
  }
  throw new Error("リトライ上限に達しました");
}

Gmail APIは1日あたりの送信数上限(無料アカウントで100通、Google Workspaceで2,000通)、Graph APIはメールボックスあたり10,000通/10分のリミットがある。大量送信が想定される場合はキューイング(BullMQなど)の導入を検討する。

開発・テスト時のスコープ設定

以下に、メール関連で使用頻度の高いスコープをまとめる。

操作Gmail API スコープGraph API パーミッション
送信のみgmail.sendMail.Send
読み取りgmail.readonlyMail.Read
読み書きgmail.modifyMail.ReadWrite
全権限mail.google.com(非推奨)Mail.ReadWrite + Mail.Send

開発中は広めのスコープで動作確認しがちだが、本番リリース前に必要最小限に絞ることを忘れないこと。Googleはスコープが広いとOAuth同意画面の審査が厳しくなり、Microsoftは管理者同意が必要なパーミッション(Application 型)を使うと組織のIT管理者の承認が必要になる。

実装を進めるためのヒント

ここまでの内容を踏まえて、実際にプロジェクトに組み込む際のポイントを整理する。

まず、OAuth2のコールバックエンドポイントを先に実装し、トークン取得・保存の仕組みを確立させる。メール送信のコード自体は比較的シンプルなので、認証周りが安定すればあとは速い。

次に、ローカル開発ではGoogleの「テストユーザー」機能とMicrosoftの「開発者テナント」を活用する。どちらも本番環境のメールボックスを汚さずに動作確認できる。Googleはテストモードのまま最大100人のテストユーザーを登録でき、OAuth同意画面の審査なしで開発を進められる。

最後に、抽象化レイヤーの実装は後回しにしてよい。まずはどちらか一方のプロバイダで動くものを作り、もう片方を追加するタイミングでインターフェースを切り出す方が、過剰な設計を避けられる。YAGNI(You Ain’t Gonna Need It)の原則に従い、必要になった時点で抽象化する方が結果的に良いコードになる。

目次