3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails で「確認メールなし」のメールアドレス検証をサインアップに入れてみた話

3
Posted at

はじめに

通常のサインアップと言えば「登録 → 確認メール送信 → メール内リンクをクリック」というフローが定番ですが、最近はこれがだんだんつらくなってきました。

  • 迷惑メール行きでユーザーが気づかない
  • メール遅延でオンボーディングが途切れる
  • 間違ったアドレスに大量送信して bounce が増える → ドメイン評価が下がる

そこで、メールを一通も送らずに「配信可能なメールアドレスかどうか」を検証してからサインアップを通す構成に変えてみたのでメモします。

やりたいこと

いわゆる Email Verification API を使って、入力されたメールアドレスに対して以下のチェックを行います。

  • Syntax チェック: RFC 準拠かどうか
  • Domain チェック: DNS lookup でドメインが存在するか
  • MX レコード: メールサーバーが設定されているか
  • SMTP チェック: RCPT TO を投げたときにサーバーが受け付けるか

自前で SMTP セッションを書くこともできますが、実運用では Hunter / ZeroBounce などの API を叩くだけにするのが現実的です。 API のレスポンスはだいたい valid / invalid / catch-all / unknown といったステータスで返ってきます。

User モデルのカラム構成

User モデルには、メール検証用のカラムを追加しました。

# users テーブル
t.string   :email_status,            null: false, default: "unknown"
t.datetime :email_verified_at
t.jsonb    :email_verification_meta, null: false, default: {}
  • email_status: "valid", "invalid", "catch_all", "unknown" など
  • email_verified_at: API 的に「OK」と判断できた時创
  • email_verification_meta: 外部 API のレスポンスをそのまま保存

Devise 利用時は :confirmable を外し、email_status == "valid" かつ email_verified_at が入っているかどうかで有効ユーザーかを判定しるようにしています。

EmailVerificationService の実装例

Service Object で外部 API をラップします。

class EmailVerificationService
  Result = Struct.new(
    :status,
    :mx_found,
    :is_catch_all,
    :raw,
    keyword_init: true
  )

  def self.verify(email)
    new(email).verify
  end

  def initialize(email)
    @address = email.to_s.strip
  end

  def verify
    return invalid_result unless valid_syntax?

    api_result = call_verification_api(@address)

    Result.new(
      status: map_status(api_result["status"]),
      mx_found: api_result["mx_found"],
      is_catch_all: api_result["status"] == "catch-all",
      raw: api_result
    )
  end

  private

  def valid_syntax?
    @address.match?(URI::MailTo::EMAIL_REGEXP)
  end

  def invalid_result
    Result.new(status: "invalid", mx_found: false, is_catch_all: false, raw: {})
  end

  def call_verification_api(email)
    # Faraday / HTTParty などで ZeroBounce や Hunter の API を叩く
  end

  def map_status(third_party_status)
    case third_party_status
    when "valid"     then "valid"
    when "invalid"   then "invalid"
    when "catch-all" then "catch_all"
    else                  "unknown"
    end
  end
end

外部サービス側の API は、ZeroBounce のように valid / invalid / catch-all / unknown と MX や did_you_mean などを返してくれます。

サインアップフローへの組み込み

RegistrationsController(もしくは Devise のカスタムコントローラ)で、ざっくり以下のような流れです。

  1. フォームから email を受け取る
  2. EmailVerificationService.verify(email) を実行
  3. status == "invalid" の場合は User を作らずにバリデーションエラー
  4. status == "valid" の場合のみ User を作成し、email_verified_at をセット
  5. catch_all / unknown はプロダクトのポリシーに合わせて許可 / 警告 / 再入力を選ぶ

フロント側で blur 時に Ajax で /email_verifications を叩き、リアルタイムで「OK / NG」を表示するようにすると UX 的にも分かりやすかったです。


まとめ

  • 確認メールに頻らずに、サインアップ時点で deliverable なメールアドレスかどうかをチェックできる
  • 間違ったアドレスや捨てアドレスを入口で引けるので、後続のメール配信エラーが減る
  • メール送信数自体も減るので、評価

「確認メールを送ること=メールアドレス検証」という前提を一度外してみると、Rails でも意外とんとを罫いに置き換えられるので、同じような課題を感じている方の参考になれば幸いです。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?