📁

NestJS (Express) で multipart/form-data をパースするときは FieldSizeに気をつけよう

に公開

この記事は Commune Developers Advent Calendar 2025 シリーズ1 の4日目の記事です

発生した問題

Sendgrid の Inbound Email Parse Webhook で受信したメールを NestJS アプリケーションで処理する実装をしていたところ、添付ファイル付きのメールで以下のエラーが発生しました。

Field value too long - email

「何かしらのサイズ上限に達したんだなー」ということは分かりましたが原因がよく分かりません。
メールの中身を見ても 4~5 MB 程度の PDF が添付されているだけで、そこまで大きなメールではありませんでした。
ファイルサイズの制限は特にかけていないはずなのに、なぜこのようなエラーが起きたのでしょうか。

原因:SendGrid は添付ファイルを「ファイル」として送らない設定になっていた

Inbound Email Parse Webhook には Send Raw モードがあります。カスタムヘッダーを読み取りたい場合などにパースされていないメールのソースをそのまま受け取ることができます。今回はこのモードになっていました。

このモードにしている場合、受信したメールの MIME メッセージ全体(ヘッダー、本文、Base64 エンコードされた添付ファイルすべて)が、email という単一のフィールドに格納されて POST されます。

multipart/form-data の構造

multipart/form-data をあまり理解しておらず、原因がよくわからなかったので構造や仕組みを確認してみました。

RFC 7578 に書いてありました
https://datatracker.ietf.org/doc/html/rfc7578#section-4

multipart/form-data は、boundary という区切り文字で複数のパートに分かれています。
例えば今回受信したものの中身を見てみると以下のような感じになってます。(簡略化してます)

Content-Type: multipart/form-data; boundary=xYzZY

--xYzZY
Content-Disposition: form-data; name="from"

sender@example.com
--xYzZY
Content-Disposition: form-data; name="to"

support@example.com
--xYzZY
Content-Disposition: form-data; name="subject"

test subject
--xYzZY
Content-Disposition: form-data; name="email"

Received: from mail.sendgrid.net ...
Content-Type: multipart/mixed; boundary="--XXXX"
From: sender@example.com
To: support@example.com
Subject: test subject
...
(MIMEメッセージ全体)
--xYzZY--

各パートには Content-Disposition ヘッダーがあり、ファイルの場合は filename 属性が付きます。
Send Raw モードではなくデフォルトモードであれば、添付ファイルは以下のように独立したパートとして送られるはずです。

Content-Type: multipart/form-data; boundary=xYzZY

--xYzZY
Content-Disposition: form-data; name="from"

sender@example.com
--xYzZY
Content-Disposition: form-data; name="to"

support@example.com
--xYzZY
Content-Disposition: form-data; name="attachment"; filename="document.pdf"
Content-Type: application/pdf

(PDFのバイナリデータ)
--xYzZY--

一見違いが分かりにくいですが、ポイントは filename 属性があるかないかです。
Send Raw モードでは添付ファイルが MIME メッセージに埋め込まれているため、multipart/form-data としては「テキストフィールド」扱いになります。

// テキストフィールドの場合
Content-Disposition: form-data; name="email"

// ファイルの場合
Content-Disposition: form-data; name="attachment"; filename="document.pdf"

NestJS、もとい Express 内部で使っている multer(内部で busboy というフォームデータのパーサーを使用) は、この filename 属性の有無でパートを分類し、異なるサイズ制限を適用するようです。

filename 分類 デフォルト制限
あり ファイル fileSize: 無制限
なし フィールド fieldSize: 1MB

RFC 7578 自体にはサイズ制限の規定は見つからなかったので、この 1MB という制限は、busboy が「テキストフィールドは小さいはず」という前提で設けているデフォルト値だと思われます。

結局のところ何が起きていたのか

改めて整理すると:

  1. SendGrid が添付ファイル付きメールを受信
  2. MIME メッセージ全体(添付ファイル含む)を email フィールドに格納
  3. filename 属性なしで POST
  4. 受け取った NestJS サーバー側では multer (busboy) が email フィールドを「テキストフィールド」と判定し、1MB 制限を適用
  5. 制限エラーで落ちた

しかも添付ファイルは Base64 エンコードされるため、例えば 5MB のファイルは実際には 6MB以上 になります。(33%程度増加)
これだとデフォルト 1MB 制限には到底収まらないので厳しいですね。

対処法

対処法はシンプルです。fieldSize の上限を引き上げます。SendGrid の最大メールサイズは 30MB なので、それに合わせました。

// NestJS の例
@UseInterceptors(
  FileInterceptor('file', {
    limits: {
        fieldSize: 30 * 1024 * 1024 // 30MB
    }
  })
)
// Express + multer の例
const upload = multer({
  limits: {
    fieldSize: 30 * 1024 * 1024
  }
})

fileSize ではなく fieldSize を設定する点に注意してください。(ちょっと紛らわしいですね)

尚、今回は SendGrid でしたが、外部サービスが大きなデータを filename なしで送ってくるケースは他にもありえそうです。
Webhook を受け取る実装では、相手がどのような形式でデータを送ってくるかを確認し、必要に応じて fieldSize の調整を検討する必要があります。

まとめ

  • multipart/form-data では filename 属性の有無でパートの扱いが変わる
  • NestJS (Express) 内部ではデフォルトで fieldSize の上限が1MBになっている
  • filename なしで送られてくると fieldSize の制限に引っかかる

参考

https://datatracker.ietf.org/doc/html/rfc7578
https://github.com/mscdex/busboy
https://www.twilio.com/docs/sendgrid/for-developers/parsing-email/setting-up-the-inbound-parse-webhook

おまけ: multipart/form-data を送信する側の注意点

今回の件と関係ないですが、逆に multipart/form-data を送信する側になった時も注意点があるので補足です。
SDKが用意されていない公開APIなどにリクエストするときなどに、HTTPクライアント等を使って FormData を送信することがありえると思いますが、このとき Content-Type を手動で設定してはいけないという罠があります。

// NG: boundary が消える
fetch('/api/upload', {
  method: 'POST',
  headers: {
    'Content-Type': 'multipart/form-data' // これを自分で書いてはいけない
  },
  body: formData
})
// OK: Content-Type を設定しない
fetch('/api/upload', {
  method: 'POST',
  body: formData
})

FormData を body に渡すと、自動で Content-Type: multipart/form-data; boundary=XXX... を設定してくれます。手動で Content-Type を上書きすると、この boundary パラメータが消えてしまい、サーバー側でパースできなくなります。

Warning: When using FormData to submit POST requests using XMLHttpRequest or the Fetch API with the multipart/form-data content type (e.g., when uploading files and blobs to the server), do not explicitly set the Content-Type header on the request. Doing so will prevent the browser from being able to set the Content-Type header with the boundary expression it will use to delimit form fields in the request body.

https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects#sending_files_using_a_formdata_object

おわりに

Field value too long というエラーだけをみた時には原因が飲み込めませんでしたが、multipart/form-data の構造を知ると理解がしやすかったです。

では、次回の Commune Developers Advent Calendar 2025 もお楽しみに

Discussion