ゼスト Tech Blog

ゼストは「護りたい。その想いを護る。」をミッションに、在宅医療・介護業界向けのSaaSを開発しています。

CDNだけじゃないCloudflare:Email Workersの使い方と活用例 〜 実践編 〜

前回の記事では、Cloudflare Email Workersの概要について紹介しました。

Email Routingの基本的な設定方法から、Workersと組み合わせることで実現できる柔軟なメール処理について解説しました。

今回は、より実践的なアプリケーションを構築してみたいと思います。 オフィスでよく使われる複合機には、スキャンした文書をメールで送信する機能があります。紙の書類をデジタル化する際に便利な機能ですが、受信したメールから添付ファイルを手動で取り出し、所定のフォルダに保存する作業は意外と手間がかかります。*1

そこで今回は、Cloudflare Email Workersを活用して、メールに添付されたドキュメントを自動的にSharePointへアップロードするアプリケーションを作成します。複合機からスキャンデータを送信するだけで、自動的にSharePointの指定フォルダに保存される仕組みを構築していきましょう。

SharePointの設定

Cloudflare Email WorkersからSharePointにファイルをアップロードするには、Microsoft Graph APIを使用します。まずはMicrosoft Entra管理センターでアプリケーションを登録し、必要な権限を設定しましょう。

アプリケーションの登録

Microsoft Entra管理センターにアクセスし、左側のメニューから「アプリの登録」を選択します。 「新規登録」をクリックし、以下の情報を入力します。

  • 名前: 任意のアプリケーション名(例:Cloudflare-Email-SharePoint
  • サポートされているアカウントの種類: 「この組織ディレクトリのみに含まれるアカウント」を選択
  • リダイレクトURI: 空欄のままで問題ありません

登録が完了すると、アプリケーションの概要ページが表示されます。ここで表示される「アプリケーション(クライアント)ID」と「ディレクトリ(テナント)ID」は、後ほどWorkersの設定で使用するため、メモしておきましょう。

APIアクセス許可の設定

アプリケーションがSharePointにアクセスできるよう、必要な権限を付与します。 左側のメニューから「APIのアクセス許可」を選択し、「アクセス許可の追加」をクリックします。 「Microsoft Graph」を選択し、「アプリケーションの許可」を選びます。検索ボックスに「Sites」と入力し、表示された一覧から「Sites.ReadWrite.All」にチェックを入れて「アクセス許可の追加」をクリックします。

追加後、「〈テナント名〉に管理者の同意を与えます」をクリックして、権限を有効化します。状態が緑色のチェックマークに変わればOKです。

クライアントシークレットの作成

WorkersからAPIを呼び出す際の認証に使用するクライアントシークレットを作成します。 左側のメニューから「証明書とシークレット」を選択し、「新しいクライアントシークレット」をクリックします。

  • 説明: 任意の説明(例:Cloudflare Workers用)
  • 有効期限: 必要に応じて選択(推奨は24ヶ月)

「追加」をクリックすると、シークレットの「値」が表示されます。この値は一度しか表示されないため、必ずこのタイミングでコピーして安全な場所に保管してください。

SharePointのサイトIDとドライブIDの取得

ファイルをアップロードする先のSharePointサイトを特定するために、サイトIDとドライブIDが必要です。これらはMicrosoft Graph APIを使って取得します。 まず、先ほど作成したアプリケーションでアクセストークンを取得します。ターミナルで以下のコマンドを実行してください。

curl -X POST "https://login.microsoftonline.com/{テナントID}/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id={クライアントID}" \
  -d "client_secret={クライアントシークレット}" \
  -d "scope=https://graph.microsoft.com/.default" \
  -d "grant_type=client_credentials"

レスポンスに含まれるaccess_tokenの値をコピーします。 次に、取得したアクセストークンを使ってサイトIDを取得します。

curl -X GET "https://graph.microsoft.com/v1.0/sites/{テナント名}.sharepoint.com:/sites/{サイト名}" \
  -H "Authorization: Bearer {アクセストークン}"

# サイト名が不明な場合は一度すべてのサイト名を確認してください
curl -X GET "https://graph.microsoft.com/v1.0/sites?search=*" \
  -H "Authorization: Bearer {アクセストークン}"

レスポンスに含まれるidがサイトIDです。 続いて、ドライブID(ドキュメントライブラリのID)を取得します。

curl -X GET "https://graph.microsoft.com/v1.0/sites/{サイトID}/drives" \
  -H "Authorization: Bearer {アクセストークン}"

レスポンスには、サイト内のドキュメントライブラリ一覧が返されます。通常は「ドキュメント」という名前のライブラリがデフォルトで存在します。アップロード先のライブラリのidをドライブIDとしてメモしておきましょう。

設定情報のまとめ

ここまでの手順で、以下の5つの情報が揃いました。これらはCloudflare Workersの環境変数として設定します。

項目 環境変数 用途
テナントID TENANT_ID Microsoft Entraのディレクトリを識別
クライアントID CLIENT_ID 登録したアプリケーションを識別
クライアントシークレット CLIENT_SECRET API認証に使用
サイトID SITE_ID アップロード先のSharePointサイトを識別(今回はコードにハードコードします)
ドライブID DRIVE_ID アップロード先のドキュメントライブラリを識別(今回はコードにハードコードします)

次のセクションでは、これらの情報を使ってCloudflare Workersを実装していきます。

Cloudflare Email Workersの実装

それでは、実際のコードを見ていきましょう。まずはコード全体を示し、その後で各部分について詳しく説明します。*2

import PostalMime, { Attachment } from 'postal-mime';

async function getAccessToken(tenantId: string, clientId: string, clientSecret: string): Promise<string | undefined> {
    const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;

    const params = new URLSearchParams({
        client_id: clientId,
        client_secret: clientSecret,
        scope: 'https://graph.microsoft.com/.default',
        grant_type: 'client_credentials',
    });

    const response = await fetch(tokenEndpoint, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: params,
    });

    const data = (await response.json()) as { access_token: string } | null;
    return data?.access_token;
}

const uploadFileToSharePoint = async (attachment: Attachment, accessToken: string, folder: string) => {
    try {
        console.log(`Uploading file: ${attachment.filename}`);
        const url = `https://graph.microsoft.com/v1.0/sites/<サイトID>/drives/<ドライブID>/root:/${folder}/${attachment.filename}:/content`;
        const response = await fetch(url, {
            method: 'PUT',
            headers: {
                Authorization: `Bearer ${accessToken}`,
                'Content-Type': 'application/octet-stream',
            },
            body: attachment.content,
        });

        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
        }
        const result = await response.json();
        console.log('Upload result:', result);
    } catch (error) {
        console.error('Error uploading file to SharePoint:', error);
        throw error;
    }
};

const streamToArrayBuffer = async (stream: ReadableStream<Uint8Array>, streamSize: number): Promise<Uint8Array> => {
    let result = new Uint8Array(streamSize);
    let bytesRead = 0;
    const reader = stream.getReader();
    while (true) {
        const { done, value } = await reader.read();
        if (done) {
            break;
        }
        result.set(value, bytesRead);
        bytesRead += value.length;
    }
    return result;
};

const postAttachmentToSharepoint = async (message: Message, tenantId: string, clientId: string, clientSecret: string) => {
    const accessToken = await getAccessToken(tenantId, clientId, clientSecret);
    if (!accessToken) {
        throw new Error('Failed to obtain access token');
    }
    const toAddress = message.to;
    const folder = toAddress.split('@')[0];
    const rawEmailBuffer = await streamToArrayBuffer(message.raw, message.rawSize);
    const parser = new PostalMime();
    const parsedEmail = await parser.parse(rawEmailBuffer);
    const attachments = parsedEmail.attachments;
    for (const attachment of attachments) {
        await uploadFileToSharePoint(attachment, accessToken, folder);
    }
};

interface MessageContext {
    waitUntil: (promise: Promise<void>) => void;
}

interface Environment {
    TENANT_ID: string;
    CLIENT_ID: string;
    CLIENT_SECRET: string;
}

interface Message {
    from: string;
    to: string;
    headers: Map<string, string>;
    raw: ReadableStream<Uint8Array>;
    rawSize: number;
    forward: (email: string) => Promise<void>;
}

export default {
    async email(message: Message, env: Environment, ctx: MessageContext): Promise<void> {
        ctx.waitUntil(postAttachmentToSharepoint(message, env.TENANT_ID, env.CLIENT_ID, env.CLIENT_SECRET));
    },
};

型定義

interface MessageContext {
    waitUntil: (promise: Promise<void>) => void;
}

interface Environment {
    TENANT_ID: string;
    CLIENT_ID: string;
    CLIENT_SECRET: string;
}

interface Message {
    from: string;
    to: string;
    headers: Map<string, string>;
    raw: ReadableStream<Uint8Array>;
    rawSize: number;
    forward: (email: string) => Promise<void>;
}

TypeScriptの型定義です。Environmentには、Cloudflare Workersの環境変数として設定した認証情報が格納されます。MessageはEmail Workersが受け取るメールオブジェクトの構造を定義しています。

エントリーポイント

export default {
    async email(message: Message, env: Environment, ctx: MessageContext): Promise<void> {
        ctx.waitUntil(postAttachmentToSharepoint(message, env.TENANT_ID, env.CLIENT_ID, env.CLIENT_SECRET));
    },
};

Email Workersのエントリーポイントです。メールを受信するとemail関数が呼び出されます。ctx.waitUntil()を使用することで、レスポンスを返した後もバックグラウンドで処理を継続できます。

アクセストークンの取得

通常、Microsoft Graph APIの認証には公式ライブラリであるMSAL(Microsoft Authentication Library)を使用するのが一般的です。しかし、MSALはNode.jsのランタイムに依存しており、Cloudflare Workersの実行環境では利用できません。 そのため、今回はMSALを使わず、OAuth 2.0のクライアント資格情報フローを直接実装してアクセストークンを取得しています。

async function getAccessToken(tenantId: string, clientId: string, clientSecret: string): Promise<string | undefined> {
    const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;

    const params = new URLSearchParams({
        client_id: clientId,
        client_secret: clientSecret,
        scope: 'https://graph.microsoft.com/.default',
        grant_type: 'client_credentials',
    });

    const response = await fetch(tokenEndpoint, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: params,
    });

    const data = (await response.json()) as { access_token: string } | null;
    return data?.access_token;
}

Microsoft Entra IDのトークンエンドポイントに対して、クライアントIDとクライアントシークレットを送信し、アクセストークンを取得しています。

メールのパースとSharePointへのアップロード

const postAttachmentToSharepoint = async (message: Message, tenantId: string, clientId: string, clientSecret: string) => {
    const accessToken = await getAccessToken(tenantId, clientId, clientSecret);
    if (!accessToken) {
        throw new Error('Failed to obtain access token');
    }
    const toAddress = message.to;
    const folder = toAddress.split('@')[0];
    const rawEmailBuffer = await streamToArrayBuffer(message.raw, message.rawSize);
    const parser = new PostalMime();
    const parsedEmail = await parser.parse(rawEmailBuffer);
    const attachments = parsedEmail.attachments;
    for (const attachment of attachments) {
        await uploadFileToSharePoint(attachment, accessToken, folder);
    }
};

メイン処理を行う関数です。以下の流れで処理を行います。

  1. アクセストークンを取得
  2. 宛先メールアドレスからフォルダ名を決定(@より前の部分を使用)
  3. postal-mimeライブラリでメールをパース
  4. 添付ファイルを1つずつSharePointにアップロード

宛先アドレスをフォルダ名として使用することで、例えば sales@example.com 宛のメールは sales フォルダに、support@example.com 宛のメールは support フォルダに自動的に振り分けられます。

SharePointへのファイルアップロード

const uploadFileToSharePoint = async (attachment: Attachment, accessToken: string, folder: string) => {
    try {
        console.log(`Uploading file: ${attachment.filename}`);
        const url = `https://graph.microsoft.com/v1.0/sites/<サイトID>/drives/<ドライブID>/root:/${folder}/${attachment.filename}:/content`;
        const response = await fetch(url, {
            method: 'PUT',
            headers: {
                Authorization: `Bearer ${accessToken}`,
                'Content-Type': 'application/octet-stream',
            },
            body: attachment.content,
        });

        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
        }
        const result = await response.json();
        console.log('Upload result:', result);
    } catch (error) {
        console.error('Error uploading file to SharePoint:', error);
        throw error;
    }
};

Microsoft Graph APIを使用して、SharePointのドキュメントライブラリにファイルをアップロードします。

環境変数の設定

コードで使用する認証情報を、Cloudflare Workersの環境変数として設定します。クライアントシークレットなどの機密情報は、Wranglerのsecretコマンドを使用して安全に保存しましょう。

シークレットの登録 以下のコマンドを実行して、各シークレットを登録します。コマンドを実行すると値の入力を求められるので、対応する値を入力してください。

$ wrangler secret put TENANT_ID
$ wrangler secret put CLIENT_ID
$ wrangler secret put CLIENT_SECRET

登録したシークレットの確認

登録済みのシークレット一覧は、以下のコマンドで確認できます。

$ wrangler secret list

まとめ

今回は、Cloudflare Email Workersを使って、メールに添付されたファイルを自動的にSharePointへアップロードするアプリケーションを構築しました。 複合機でスキャン → メール送信 → Cloudflare Email Workers → SharePointという流れで、紙の書類を自動的にクラウドストレージに保存できる仕組みが完成しました。 今回のポイントを振り返ると以下の通りです。

  • Microsoft Entra IDでアプリケーションを登録し、Graph APIのアクセス権限を設定
  • Cloudflare Workersの制約によりMSALが使えないため、OAuth 2.0のクライアント資格情報フローを直接実装
  • postal-mimeライブラリでメールをパースし、添付ファイルを抽出
  • 宛先メールアドレスに応じてSharePointのフォルダを自動振り分け

この仕組みを応用すれば、メールの件名や送信者に応じた振り分け、アップロード完了時のSlack通知、OCRサービスと連携したテキスト抽出など、さまざまな拡張が可能です。 Cloudflare Email Workersは無料枠も大きく、個人や小規模チームでも気軽に導入できます。ぜひ、日々の業務効率化に活用してみてください。

*1:複合機の機種によっては、直接SharePointにアップロードする機能を提供しているものもあります

*2:コードはTypeScriptで説明するので、プロジェクト作成時の雛形として、TypeScriptを選択してください。