📧

SendGridにサロゲートペアの一部だけを送るとメッセージがBadRequestだけのエラーが返ってくる問題と対処法

2024/02/19に公開

SendGridの利用時に謎のエラーが出るようになった原因

先日職場のアプリケーションでSendGridを使ってメールを送信する箇所の実装のリファクタリングが行われました。このリファクタリングの後からなぜかSendGridを使ってメールを送信した際にエラーになってしまう事態がたまに起きるようになりました。エラーメッセージを確認してもメッセージには"Bad Request"としか書かれていないのでどのように対処すればよいかわかりませんでした。

この問題を職場の同僚が調査したところ次のことが起きていたことがわかりました。

  1. 規定文字数以内に収めるために送信するメッセージをJavascriptのString.slice()によってメッセージを短縮していた。
  2. この時UTF-16[1]でサロゲートペアを使う文字がString.slice()で分割する文字列の境界上に存在した場合にこの文字が前後で分離されて、UTF-16の仕様上不正な文字がメッセージに含まれてしまった。
  3. このメッセージをSendGridのAPIに送信するとエラーメッセージに"Bad Request"しか書かれていない400エラーが返ってきてしまう。

サロゲートペアの基礎知識

Javascriptの内部的な文字列であるUTF-16では通常2バイトで文字を表現します[2]。例えば、a は16進数で006130424e00になります。しかし、2バイトでは2^{16}=65536通りの文字しか表せず、数十万文字あるUnicodeの全ての文字を表現できないため、一部の文字ではどの文字にも割り当てられていない2バイトを2つ組み合わせて1つの文字を表現する場合があります。
この方式をサロゲートペアと呼びます。サロゲートペアで表現される文字としてわかりやすいのが絵文字です。 例えば👍D83DDC4Dの2つで表現されます。
これらのこのことは下記のようにユニコード用のエスケープ文字を使うと確認できます。

console.log('\u0061') // a
console.log('\u3042') // あ
console.log('\u4E00') // 一
console.log('\uD83D\uDC4D') // 👍

Javascriptではサロゲートペアは2文字扱いされる場合が多いようで、 String.length では2文字扱いされますし、String.slice()を実行すると前後の2バイトに分割されてしまいます。
下記のコードをdeveloper toolsのConsoleで実行すると確認できます[3]

'👍'.length // 2
'👍'.slice(0, 1) // '\uD83D'
'👍'.slice(1) // '\uDC4D'

再現コード

サロゲートペアの文字を前後で分割した文字の片方をSendGridに送信するとえBadRequestエラーがレスポンスとして返ってきますが、エラーの理由は書かれていません。
この動作を下記のコードで動作を確認できます。APIキーを環境変数SENDGRID_API_KEYに事前に設定してから実行してください。

import sgMail from '@sendgrid/mail'

sgMail.setApiKey(process.env.SENDGRID_API_KEY);

const msg = {
  to: 'test@example.com',
  from: 'test@example.com',
  subject: 'Hello, World!',
  text: '\uD83D',
};

try {
  await sgMail.send(msg);
} catch (error) {
  console.log(error.response.body.errors[0])
  // { message: 'Bad Request', field: null, help: null }
}
curl -i --request POST \
--url https://api.sendgrid.com/v3/mail/send \
--header 'Authorization: Bearer '$SENDGRID_API_KEY \
--header 'Content-Type: application/json' \
--data '{"from":{"email":"test@example.com"},"subject":"Hello, World!","personalizations":[{"to":[{"email":"test@example.com"}]}],"content":[{"value":"\uD83D","type":"text/plain"}]}'

この問題の回避方法

この問題を回避するためには文字列を分割する際にString.slice()を使うことを避け、絵文字を1文字として扱うことができる分割方法を使う必要があります。例えばJavascriptのスプレッド構文で文字列を使って文字列を分割するとこの問題を避けることができます。ただし、スプレッド構文による文字列の分割では結合文字列に対処できないという問題があります[4]
このため、文字列の分割を行うライブラリを利用する方法が良いようです。

脚注
  1. https://ja.wikipedia.org/wiki/UTF-16 ↩︎

  2. https://jsprimer.net/basic/string-unicode/ ↩︎

  3. (サロゲートペアの片側をconsole.log()で出力するとUTF-8に変換されるためか、(replacement character U+FFFD)が表示されてしまうので期待した結果を見ることができません。) ↩︎

  4. https://qiita.com/amanoese/items/68bb9999829de4323302 ↩︎

コミューン株式会社

Discussion