SendGrid の Marketing Campaign API の挙動が変わっていた

クロスワープの大鷲です。

MODD にはメルマガ配信機能機能がありますが、これは SendGrid を利用して実現されています。*1
SendGrid のメルマガ機能は Marketing Campaigns というのですが、最近、この API の挙動が微妙に変わっていることに気が付きました。
なお、ドキュメントに書かれている仕様は変わっていません。変更されたのは、ドキュメントに書かれていない部分の挙動です。

日付項目のチェックが厳密になった

宛先の登録時に、これまでは、C# で以下のようなクラスを作り、これをそのまま JSON にして POST していました。*2

[DataContract]
public class DynamicRecipientTransport :
    DynamicObject
{
    [DataMember(Name = "id", IsRequired = true)]
    public string Id { get; set; }

    [DataMember(Name = "email", IsRequired = true)]
    public string Email { get; set; }

    [DataMember(Name = "first_name")]
    public string FirstName { get; set; }

    [DataMember(Name = "last_name")]
    public string LastName { get; set; }

    [DataMember(Name = "created_at")]
    public long? CreatedAt { get; set; }

    [DataMember(Name = "updated_at")]
    public long? UpdatedAt { get; set; }

    [DataMember(Name = "last_emailed")]
    public long? LastEmailed { get; set; }

    [DataMember(Name = "last_opened")]
    public long? LastOpened { get; set; }

    [DataMember(Name = "last_clicked")]
    public long? LastClicked { get; set; }

    // 略
}

このような型を、GET 時と POST 時で共有していたため*3、POST 時には以下のような JSON が送られていました。

[{
  "id": null,
  "email": "owashi@crosswarp.com",
  "first_name": null,
  "last_name": null,
  "created_at": null,
  "updated_at": null,
  "last_emailed": null,
  "last_opened": null,
  "last_clicked": null
}]

以前はこれで登録できていたのですが、created_at 等の日時項目に対して null を指定するのは不正と判断されるようになったらしく、エラーになるようになりました。
そこで、POST 時にはこれらの項目が JSON に含まれないよう、C# の型定義を以下のように修正しました。

[DataContract]
public class DynamicRecipientTransport :
    DynamicObject
{
    [DataMember(Name = "id", EmitDefaultValue = false)]
    public string Id { get; set; }

    [DataMember(Name = "email", IsRequired = true)]
    public string Email { get; set; }

    [DataMember(Name = "first_name", EmitDefaultValue = false)]
    public string FirstName { get; set; }

    [DataMember(Name = "last_name", EmitDefaultValue = false)]
    public string LastName { get; set; }

    [DataMember(Name = "created_at", EmitDefaultValue = false)]
    public long? CreatedAt { get; set; }

    [DataMember(Name = "updated_at", EmitDefaultValue = false)]
    public long? UpdatedAt { get; set; }

    [DataMember(Name = "last_emailed", EmitDefaultValue = false)]
    public long? LastEmailed { get; set; }

    [DataMember(Name = "last_opened", EmitDefaultValue = false)]
    public long? LastOpened { get; set; }

    [DataMember(Name = "last_clicked", EmitDefaultValue = false)]
    public long? LastClicked { get; set; }
}

DataMember 属性EmitDefaultValue = false を指定することで、null 値が JSON に含まれないようにしています。

変更のない宛先が persisted_recipients に含まれなくなった

SendGrid では、宛先を登録してから、その宛先をリストに登録し、そのリスト(またはリストの一部分であるセグメント)の ID を指定してメルマガを登録する必要があります。
MODD では、新しいメルマガを登録する都度、新しいリストを作成する実装になっています。*4

宛先の登録時に、新規登録されたか、内容が変更された宛先は、その ID が結果の persisted_recipients に返ってきます。宛先をリストに登録する際には、この ID が必要です。
以前は、既に登録済みで内容の変更もないリクエストを投げても、その ID が persisted_recipients に含まれていました。そのため、新規登録か登録済みか、内容に更新があるかないかに関わらず、送りたい宛先を全て登録 API に渡し、その結果を丸ごとリスト登録 API に渡すように実装していました。*5

しかし、最近、登録済みで内容に変更がない宛先については、unmodified_indices にインデックスが含まれるのみで、persistent_recipients に ID が返ってこなくなりました。
そのため、変更のない宛先をリストに登録するためには、別途何らかの方法で ID を用意せねばなりません。

この宛先 ID は、メールアドレスを Base64 エンコードしたものであると、Contacts API のドキュメントに書かれています。
そのため、自分でメールアドレスを Base64 エンコードすることでも、宛先 ID を生成することができます。
しかし一方で、宛先 ID は不透明なものとして扱い、recipient エンドポイントから返されたものをそのまま使うべきであるとも書かれています。つまり、自分で Base64 エンコードして ID を生成するような使い方は推奨されないということです。

ところが、unmodified_indices から、変更されなかった宛先のメールアドレスはわかりますが、ここから API を使って宛先 ID を得るのはなかなか難しいのです。
(ID を含む)宛先情報を検索する API としては、Search with conditionsGet Recipients Matching Search Criteria の 2 つがあるので、このいずれかを使うことになります。
どちらを使うにせよ、事前に宛先にカスタム フィールドを設定してグループ化できるようにしておいて、それを使って検索するのが推奨される方法だと思います。
メールアドレスで検索することもできなくはないですが、Get Recipients Matching Search Criteria では 1 件ごと、Search with conditions でも API の仕様上、15 個までしか条件が指定できませんので、15 件ごとに検索することになってしまいます。
対象が数万件もあると、それだけで大変な数の API コールが必要になってしまって、現実的ではありません。

しかし、MODD では仕様上、カスタムフィールドによるグループ化が難しく、この方法は取れません。
そのため、やむを得ず、メールアドレスを Base64 エンコードすることで ID を生成しています。

error_indices が正しい値を返すようになった

登録できなかった宛先については、error_indices フィールドにインデックスが返されます。
しかし、以下の issue にあるように、このフィールドは以前は正しい値を返さないことがありました。

github.com

現在では、この問題は解消されています。

無料プランの場合の注意事項

SendGrid には無料プランがありますが、このプランでは、登録できる宛先が 2,000 件までに制限されています。
宛先を新規に登録しようとした時に、既に登録済みの宛先と、今回新規に登録しようとする宛先の件数の合計が 2,000 件を超えると、エラー(ステータスコードは 400)になります。

以下は既に 2,000 件登録済みであるところに、さらに 1 件を新規登録しようとした場合のレスポンスです。

{
    "new_count": 0,
    "updated_count": 0,
    "error_count": 1,
    "error_indices": [0],
    "unmodified_indices": [],
    "persisted_recipients": [],
    "errors": [
        {
            "message": "You have reached your limit of contacts. Please delete one or more and try again. To increase your limit of contacts, please upgrade your account (https: //sendgrid.com/billing/change-package)",
            "error_indices": [0]
        }
    ]
}

ところが、既に 2,000 件の宛先が登録されている場合、新規登録ではなく、既に登録済みの内容の更新でも(変更が無くても)エラーになってしまいます。
レスポンスには以下のように、エラーの原因の手掛かりが含まれないため、調査が難航してしまいます。

{
    "new_count": 0,
    "updated_count": 0,
    "error_count": 0,
    "error_indices": [],
    "unmodified_indices": [0],
    "persisted_recipients": [],
    "errors": []
}

登録済みの宛先が 2,000 件未満であれば、更新は正常に行われます。

この挙動はバグと思われるため、報告し、修正を依頼しています。
無料プランでメルマガを利用している場合、この問題が修正されるまでは、登録済みの宛先が 2,000 件に達しないように気をつけた方がよいでしょう。*6

まとめ

はっきりした日付は不明ですが、おそらく、最近、SendGrid 内で、このあたりの機能のリファクタリングが行われたと見られ、関連する複数の挙動が変化していました。
ただ、冒頭にも書いたとおり、(最後の件はバグなので別ですが)いずれも、過去の挙動も現在の挙動も、ドキュメントに書かれた仕様に反しているわけではありません。
MODD では、過去の挙動を元に、明記されていない仕様を推測して実装していました(事前に検知して修正したため、サービスには影響ありませんでした)。
今後はこのような点に注意したいと思います。

*1:当社では SendGrid の米国本社と契約しています。日本での代理店である構造計画研究所や、Microsoft Azure 等を通じて契約している場合は、状況が異なる可能性があります。

*2:カスタム フィールドを含めるために DynamicObject から派生しています。

*3:丁寧にやるならば、GET 時と POST 時で別の型にすべきだと思います。

*4:おそらく、SendGrid はこのような使い方は想定しておらず、一度作ったリストは内容をメンテナンスしながら継続的に使い回す運用が推奨されていると思われます

*5:宛先をリストに登録する際はページングとウェイトが必要です

*6:MODD では開発環境は無料プランを利用しているため発覚しましたが、本番環境では宛先件数無制限の有料プランのため、問題は発生していません。