業務システムや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.2 | Google OAuth2 認証クライアント |
googleapis | ^171.4.0 | Gmail API を含むGoogle APIラッパー |
@azure/msal-node | ^5.1.2 | Microsoft Identity Platform(OAuth2)認証 |
@microsoft/microsoft-graph-client | ^3.0.7 | Microsoft Graph API クライアント |
@microsoft/microsoft-graph-types | ^2.43.0 | Graph API のTypeScript型定義 |
Google側は google-auth-library で認証トークンを取得し、googleapis 経由でGmail APIを呼ぶ。Microsoft側は @azure/msal-node(MSAL)でトークンを取得し、@microsoft/microsoft-graph-client でGraph APIを呼ぶ。認証ライブラリとAPIクライアントが分離している構造は両者に共通している。
以下の図は、両プロバイダのメール送信における処理フローを示している。
Google側 OAuth2 認証のセットアップ
Google Cloud Consoleでプロジェクトを作成し、Gmail APIを有効化したあと、OAuth 2.0クライアントIDを発行する。取得した client_id、client_secret、redirect_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-Type を text/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 を使う
ConfidentialClientApplication は、クライアントシークレット(またはクライアント証明書)を安全に保持できるサーバーサイド環境を前提とした認証クライアントだ。
ConfidentialClientApplication内部的には以下の処理を担っている。
まず、OAuth2の認可コードフローにおいて、認可コードをトークンエンドポイント(https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token)に送信し、アクセストークンとリフレッシュトークンを取得する。
このとき、リクエストにはクライアントIDだけでなくクライアントシークレットも含まれる。
これが「Confidential(秘密を保持できる)」の意味であり、シークレットを安全に保管できないブラウザやモバイルアプリでは使えない。
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. と入力した時点で subject、body、toRecipients、importance、hasAttachments などのプロパティ候補が表示される。body の中に入れば contentType と content が補完され、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から 401 や invalid_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.send | Mail.Send |
| 読み取り | gmail.readonly | Mail.Read |
| 読み書き | gmail.modify | Mail.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)
- Gmail API リファレンス — users.messages.send
- Gmail API ガイド — メッセージの送信
- Google Auth Library for Node.js(GitHub)
- googleapis npm パッケージ(GitHub)
- OAuth 2.0 スコープ一覧 — Gmail
Microsoft(Graph API)
- Microsoft Graph — sendMail API リファレンス
- Microsoft Graph — Message リソース型
- MSAL Node.js(GitHub)
- Microsoft Graph JavaScript Client Library(GitHub)
- @microsoft/microsoft-graph-types(npm)
業務システムや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.2 | Google OAuth2 認証クライアント |
googleapis | ^171.4.0 | Gmail API を含むGoogle APIラッパー |
@azure/msal-node | ^5.1.2 | Microsoft Identity Platform(OAuth2)認証 |
@microsoft/microsoft-graph-client | ^3.0.7 | Microsoft Graph API クライアント |
@microsoft/microsoft-graph-types | ^2.43.0 | Graph API のTypeScript型定義 |
Google側は google-auth-library で認証トークンを取得し、googleapis 経由でGmail APIを呼ぶ。Microsoft側は @azure/msal-node(MSAL)でトークンを取得し、@microsoft/microsoft-graph-client でGraph APIを呼ぶ。認証ライブラリとAPIクライアントが分離している構造は両者に共通している。
Google側:OAuth2 認証のセットアップ
Google Cloud Consoleでプロジェクトを作成し、Gmail APIを有効化したあと、OAuth 2.0クライアントIDを発行する。取得した client_id、client_secret、redirect_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-Type を text/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から 401 や invalid_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.send | Mail.Send |
| 読み取り | gmail.readonly | Mail.Read |
| 読み書き | gmail.modify | Mail.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)の原則に従い、必要になった時点で抽象化する方が結果的に良いコードになる。