2026-11-11(水) 15:00

📎 Resend の Inbound で受信メールの添付ファイルを取り出して保存する

ResendNext.js
Resend の Inbound(受信)で受け取ったメールの Webhook から、添付 API でファイルをダウンロードし、Supabase Storage に保存するまでの手順を Next.js のコード例とともに解説します。

目次

前提と注意事項

この記事では以下を前提としています。

  • Next.js 16.4.2(App Router)を使用し、Vercel 上で動かしています。
  • メールの受信には Resend の Inbound(受信)を、添付ファイルの取得には Resend の REST API を使用しています。
  • 保存先には Supabase Storage を使用しています。
  • この記事では Webhook の署名の検証と、受信後のジョブ処理は扱いません。
  • 受信用のドメインは fax.example.com に置き換えています。ご自身の環境のドメインに読み替えてください。

この記事のゴール

複合機から転送された受信メールの添付ファイルを、Resend の API で取得します。そして、サイズの上限を設けてダウンロードし、Supabase Storage に保存するところまでをゴールとします。

構成概要

複合機の「受信 FAX をメールで転送する」機能を使い、転送メールの添付ファイル(PDF や TIFF など)をアプリに取り込みます。全体の流れは以下のとおりです。

システム構成
複合機
    ↓ メール転送
fax.example.com 宛てのメール
    ↓
Resend(Inbound)
    ↓ Webhook(email.received)
アプリ(Next.js)
    ↓ 添付 API
download_url からダウンロード
    ↓
Supabase Storage

受信用のサブドメインを設定する

既存の通常のメールの MX レコードには触らず、受信専用のサブドメインを Resend に追加します。ここでは fax.example.com を使用します。

Resend の独自ドメインの設定手順については、以下の記事で解説しています。

⚙️ Resendで独自ドメインを使うための設定手順Resendで独自ドメインからのメールを送信するために独自ドメインを設定する手順を解説します。ritaiz.com

DNS と Resend のドメインを設定する

Resend のドメインのページに表示される MX レコードを、DNS に追加します。優先度は 10 で、値はページに表示されるものを使用します。

Resend のドメインのページに表示された DKIM の TXT レコードと MX レコード

このサブドメインは受信専用のため、ドメインの設定で Enable Sending を無効にします。DKIM の TXT レコードはそのまま残します。Custom Return-Path は既定の send のままにします。

API キーと Webhook を設定する

添付 API を呼び出すために、受信メールを読む権限を持つ API キーを用意します。権限が足りない場合は、401 や 403 のエラーになります。

次に、Resend のダッシュボードで Webhook を作成します。設定する内容は以下のとおりです。

  • イベント: email.received
  • URL: https://app.example.com/api/webhooks/resend/inbound
Resend のダッシュボードの Webhooks のページ Webhook の作成画面で、URL を入力して email.received を選択した状態

作成後に表示される署名シークレット(whsec_...)を、環境変数 RESEND_INBOUND_WEBHOOK_SECRET に設定します。

Webhook のペイロードを確認する

email.received のペイロードには、メールのメタ情報だけが入ります。本文、ヘッダー、添付ファイルの中身は含まれません。含まれる主な項目は以下のとおりです。

  • data.email_id
  • from、to[]、cc[]、bcc[]、received_for[]
  • message_id、subject
  • attachments[](id、filename、content_type、content_disposition、content_id)

ペイロードの例は以下のとおりです。

email.received のペイロードの例
{
  "type": "email.received",
  "created_at": "2026-11-04T01:23:45.000Z",
  "data": {
    "email_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "from": "複合機 <mfp@example.com>",
    "to": ["office-a7k2@fax.example.com"],
    "received_for": ["office-a7k2@fax.example.com"],
    "subject": "FAX 受信",
    "attachments": [
      { "id": "att_xxx", "filename": "fax_0001.pdf", "content_type": "application/pdf", "content_disposition": "attachment", "content_id": null }
    ]
  }
}

重い処理を Webhook の中で行わないために、Webhook では添付ファイルを取りに行きません。内容を記録して、2xx のステータスを返すだけにします。

添付 API で download_url を取得する

GET https://api.resend.com/emails/receiving/{email_id}/attachments/{attachment_id} を呼び出すと、以下の値が返ります。

  • download_url: 署名付きのダウンロード URL
  • expires_at: 有効期限(1時間で失効します)
  • size: ファイルのサイズ
  • content_type: ファイルの種類

以下の関数で、添付 API を呼び出して必要な値を取り出します。

getReceivedAttachment()
export async function getReceivedAttachment(apiKey: string, emailId: string, attachmentId: string) {
  const url = `https://api.resend.com/emails/receiving/${encodeURIComponent(emailId)}/attachments/${encodeURIComponent(attachmentId)}`;
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${apiKey}` },
    signal: AbortSignal.timeout(30_000),
  });
  if (!res.ok) throw new Error(`attachment API ${res.status}`);
  const body = await res.json();
  return {
    size: typeof body.size === "number" ? body.size : null,
    content_type: typeof body.content_type === "string" ? body.content_type : null,
    download_url: body.download_url as string,
    expires_at: typeof body.expires_at === "string" ? body.expires_at : null,
  };
}

download_url が失効した場合は、同じ API をもう一度呼び出して取り直します。

上限を設けてダウンロードし SHA-256 を計算する

ダウンロードするサイズは、1つの添付ファイルにつき 50 MB までとします。content-length が上限を超えている場合は、ファイルを読み込みません。ヘッダーの値があてにならない場合に備えて、読み込みながら実際のバイト数も数え、上限を超えた時点で打ち切ります。

同じストリームで SHA-256 のハッシュ値も計算し、DB に記録します。同じ添付ファイルを取り直したときに、内容が同じかどうかを確かめられます。

downloadAttachment()
import { createHash } from "node:crypto";
 
export async function downloadAttachment(downloadUrl: string, maxBytes: number) {
  const res = await fetch(downloadUrl, { signal: AbortSignal.timeout(240_000) });
  if (!res.ok || !res.body) throw new Error(`download ${res.status}`);
  const declared = Number(res.headers.get("content-length"));
  if (Number.isFinite(declared) && declared > maxBytes) {
    await res.body.cancel();
    throw new Error("too large");
  }
  const hash = createHash("sha256");
  const chunks: Buffer[] = [];
  let total = 0;
  const reader = res.body.getReader();
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > maxBytes) {
      await reader.cancel();
      throw new Error("too large");
    }
    const chunk = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
    hash.update(chunk);
    chunks.push(chunk);
  }
  return { bytes: Buffer.concat(chunks, total), byteSize: total, sha256: hash.digest("hex") };
}

Supabase Storage に保存する

ダウンロードしたファイルを、private バケット documents に保存します。パスは {tenant_id}/{document_id}/original.{拡張子} の形式にします。拡張子は、添付ファイルの content_type から決めます。

途中で処理が失敗して取り直した場合でも、同じパスに置き直すだけで済むように、upsert: true を指定します。これにより、ファイルが増えることもありません。

Supabase Storage へのアップロード
// 受け付ける形式と、保存するときの拡張子
const EXTENSIONS: Record<string, string> = {
  "application/pdf": "pdf",
  "image/tiff": "tif",
  "image/png": "png",
  "image/jpeg": "jpg",
};
 
// content_type の「;」より前を小文字にしたもの(例:application/pdf)
const contentType = (attachment.content_type ?? "").split(";")[0].trim().toLowerCase();
const extension = EXTENSIONS[contentType];
if (!extension) throw new Error(`unsupported content type: ${contentType}`);
 
const storagePath = `${tenantId}/${documentId}/original.${extension}`;
const { error } = await supabase.storage
  .from("documents")
  .upload(storagePath, downloaded.bytes, { contentType, upsert: true });

attachment は、Webhook のペイロードの attachments[] の1件です。保存するときの contentType にも、同じ値を指定します。

トラブルシューティング

添付 API が 404 Inbound email not found を返す

添付 API の email_id には、自分の DB の行の ID ではなく、Webhook の data.email_id(Resend 側の ID)を渡す必要があります。DB の行の ID を渡すと、このエラーになります。

ロゴや署名の画像が添付として届く

本文から参照されるロゴや署名の画像は、content_disposition が inline で、content_id が付いた添付ファイルとして届きます。これらは FAX の本体ではないため、取り込みの対象から外します。

また、受け付ける形式は application/pdf、image/tiff、image/png、image/jpeg に絞ります。content_type には application/pdf; name=... のようにパラメータが付くことがあります。そのため、; より前の部分を小文字にして比較します。

処理が止まったまま終わらない

止まった接続のために、関数の実行時間の上限まで待ち続けることがあります。これを防ぐために、添付 API の呼び出しとファイルのダウンロードの2つの fetch には、AbortSignal.timeout を付けます。

まとめ

この記事では、Resend の Inbound で受信したメールの添付ファイルを取り出し、Supabase Storage に保存する手順を解説しました。ポイントを整理すると以下のとおりです。

  • 受信専用のサブドメインを Resend に追加し、MX を設定して Enable Sending を無効にします。
  • email.received の Webhook にはメタ情報だけが入るため、記録して 2xx を返すだけにします。
  • 添付 API の email_id には、Webhook の data.email_id を渡します。
  • ダウンロードにはサイズの上限を設け、同じストリームで SHA-256 も計算します。
  • 保存時は upsert: true を指定し、取り直しても同じパスに置き直せるようにします。
  • inline の添付ファイルは取り込みから外し、受け付ける形式を絞ります。

なお、Resend 側の再送の規則、受信メールの保持期間、添付ファイルの上限は、公式ページに記載がありませんでした(2026-11-04 時点)。ご利用の契約プランとダッシュボードで確認してください。

メールで届くファイルをアプリに自動で取り込みたい方にとって参考になれば幸いです。