0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Heroku経由でSendGridを使用したら沼った

Posted at

はじめに

ECサイトで購入ボタンをクリックすると、
設定したメールに購入完了メールが送られるという実装中に沼った話です。

  • 上記のURLを参考にしましたが、私の環境下で起こった話です
    別の環境下によっては問題なく実装できる場合もあります

  • 結論があっさりしており
    確定的な原因を特定できたわけではありません

上記2点ご了承ください

仕様

請求先住所に設定した以下の内容をテンプレートを使ってメールを送信する

  • ユーザ名
  • 注文番号
  • 商品ごとの商品名、個数、小計
  • 購入金額合計

購入処理までの機能フロー

  • 購入ボタンクリック

    • 請求先住所、支払い方法フォーム作成

    • カートIDを取得

    • カートID内の商品を取得

    • カート内に商品がない場合はカートページへ戻る

    • バリデーションエラー時にカート内商品を保持する処理

      • 空の辞書を用意
      • カート内商品をループで回す
      • カート内商品の商品名を変数に格納
      • 商品名から4つ取得する → 小計、商品ID、商品価格、商品個数
    • バリデーションエラーになったらカートページへ戻す(以降 ValidationError→VE)

      • 請求先住所のVE
      • 支払い情報のVE
      • カート内の商品情報
      • 購入予定の合計金額
      • 購入予定の商品の種類
    • トランザクション開始(以降 フォームチェック→FC)

      • FCした請求先住所をDBに保存
      • FCした支払い情報をDBに保存
      • 注文情報をDBに保存
        • 請求先住所 ID
        • 支払い情報 ID
      • OrderItemテーブルで商品情報を管理
        • 注文ID(外部キー)
        • 商品名
        • 商品価格
        • 商品個数
        • 商品ごとの小計
        • 合計購入金額
      • メール送信処理
      • 取得したカートを削除
      • サクセスメッセージを作成
      • リダイレクトで商品一覧ページへ遷移する
  • 商品一覧ページでサクセスメッセージが出力される

    • カートが削除されているため、カート内の商品個数が0になっている

DB設計

cart (カート)

カラム名(論理) カラム名(物理) NOT NULL その他制約 説明
ID id serial True PRIMARY KEY
セッションID session_id char(32) True

order (注文)

カラム名(論理) カラム名(物理) NOT NULL その他制約 説明
ID id serial True PRIMARY KEY
請求先住所 billing_address_id integer True 請求先住所の外部キー
支払い情報 payment_id integer True 支払い情報の外部キー

billing_address (請求先住所)

カラム名(論理) カラム名(物理) NOT NULL その他制約 説明
ID id serial True PRIMARY KEY
last_name char(20) True
first_name char(20) True
ユーザー名 username char(20) True unique
メールアドレス email char(30) False
country char(100) True
州/県 state_prefecture char(100) True
郵便番号 zip char(7) True 今回のみ7桁の郵便番号
住所1 address1 char(255) True 市区町村+番地
住所2 address2 char(255) False マンション、アパート名+部屋番号等
請求先=配送先 same_address boolean False default=False
配送先住所を保存 save_address boolean False default=False

payment (支払い)

カラム名(論理) カラム名(物理) NOT NULL その他制約 説明
ID id serial True PRIMARY KEY
カード名義 card_holder char(32) True
カード番号(ハッシュ化) card_number_hash char(19) True 4桁-4桁-4桁-4桁 間のハイフン含める
カード下4桁 card_last4 char(4) True
有効期限 expiration_date char(5) True MM/YY 形式

order_item (購入商品)

カラム名(論理) カラム名(物理) NOT NULL その他制約 説明
ID id serial True PRIMARY KEY
注文ID order_id integer True 注文情報の外部キー
商品名 name char(30) True 購入時の商品名
価格 price integer True 購入時の商品の価格
個数 quantity integer True 購入時の商品個数
商品ごとの小計 subtotal integer True MM/YY 形式

SendGridをHeroku経由で使えるように設定する

以下の手順でSendGridを設定していきました。

SendGridのアドオン追加

以下のコマンドからHeroku経由でSendGridをアドオンする

heroku addons:create sendgrid:starter

情報を入力する

スクリーンショット 2025-02-20 0.58.51.png

  • 今回は Compny Name の部分に個人利用であることを記載
  • Webサイトは X のURLを記載

これで Get Started! をクリックするとダッシュボードが立ち上がる
以下のコマンドでも立ち上げ可能

heroku addons:open sendgrid

APIキーの作成

以下の画像のように

  1. Settings→API Keyにアクセス
  2. Create API Keyをクリック
  3. Full Accessを選択して Create & Viewをクリック

スクリーンショット 2025-02-20 1.03.30.png

スクリーンショット 2025-02-20 1.05.52.png

スクリーンショット 2025-02-20 1.08.06.png

API Keyが出力されます。コピーしておきましょう。
Doneをクリックすると二度と出力されません。

スクリーンショット 2025-02-20 1.09.51.png

その後、API Keyを作成できる画面に戻りまして
先ほど作成したAPI Keyが出力されています。

スクリーンショット 2025-02-20 1.12.48.png

Senderを設定する

  1. Settings→Sender Authenticationにアクセス
  2. Verify a Single Senderをクリック
  3. メールアドレス等を設定して Create ボタンをクリック
  4. 認証メールが届いて認証を通す

スクリーンショット 2025-02-20 1.03.30.png

スクリーンショット 2025-02-20 1.24.12.png

スクリーンショット 2025-02-20 1.23.42.png

gmail, yahoo, icloudだと警告が出ますが問題なく作成できます。

上記で設定したアドレスに認証メールが届くので、
「Verify Single Sender」をクリックして、認証が通ると下記画面が表示される
スクリーンショット 2025-02-20 1.29.03.png

環境変数の設定

APIキーの発行後、Herokuの環境変数に設定する。
今回はコマンドから設定していきます。
(-a以降はなくてもOK)

heroku config:set SENDGRID_API_KEY=[作成したAPIキー] -a [アプリ名]

これでダッシュボードから確認もできます
スクリーンショット 2025-02-20 1.37.50.png

メール実装

Django公式からAnymailを使用するのが良さげなので
こちらを導入します。

まずはAnymailをインストールします。

pip install django-anymail
pip install django-environ

pip freeze >> requirement.txt

setttings.pyに下記設定を追記します。

settings.py
...
INSTALLED_APPS = [
    # ...
    "anymail",
    # ...
]
...
# SendGridを使用
EMAIL_BACKEND = "anymail.backends.sendgrid.EmailBackend"

ANYMAIL = {
    ...
    # SendGridのAPIキー
    "SENDGRID_API_KEY":  env("SENDGRID_API_KEY")
}

# メールの送信サーバー(SMTPサーバー)
EMAIL_HOST = env.str("EMAIL_HOST")
# SMTP のポート番号
EMAIL_PORT = env.int("EMAIL_PORT")
# TLS の使用
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS")
# SendGrid使用する場合は「apikey」
EMAIL_HOST_USER = env.str("EMAIL_HOST_USER")
# SMTP 認証のパスワード
# SendGrid の場合、SMTP 経由でメールを送信するときは API キーをパスワードとして使用する
EMAIL_HOST_PASSWORD = env.str("EMAIL_HOST_PASSWORD")
# 送信元メールアドレス
DEFAULT_FROM_EMAIL = env.str("DEFAULT_FROM_EMAIL")
.env
# < >←は不要
SENDGRID_API_KEY=<My API Key>
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST=smtp.sendgrid.net
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=apikey
EMAIL_HOST_PASSWORD=<API Key>
DEFAULT_FROM_EMAIL=<My Email>

ここからviews.pymodels.pyを記載

views.py
class OrderView(View):
    def post(self, request):
        # 請求先住所フォームと支払いフォームを生成
        billing_address_form = BillingAddressForm(request.POST)
        payment_form = PaymentForm(request.POST)
        # カートIDを取得、ない場合エラーを返してリダイレクト
        cart = Cart.get_or_create_cart(self.request)
        cart_products = cart.cart_products.select_related("product")

        if not cart_products:
            messages.error(request, "カート内に商品がありません")
            return render(request, "cart/cart_page.html", {
                "billing_address_form": billing_address_form,
                "payment_form": payment_form,
            })

        # エラーになった際にカート内の商品情報を保持すための処理
        product_data = {}
        for cart_product in cart_products:
            product_name = cart_product.product.name
            product_data[product_name] = {
                "subtotal": cart_product.sub_total_price(),
                "id": cart_product.id,
                "price": cart_product.product.price,
                "quantity": cart_product.quantity,
            }
        
        # どちらかがエラーの場合、エラーメッセージと共に元の画面へリダイレクト
        if not billing_address_form.is_valid() or not payment_form.is_valid():
            messages.error(request, "入力に間違いがあります")
            return render(request, "cart/cart_page.html", {
                "billing_address_form": billing_address_form,
                "payment_form": payment_form,
                "product_data": product_data,
                "total_cart_price": cart.total_price,
                "total_type_products": len(product_data),
            })
        
        # トランザクション内でDB処理を一括実行
        with transaction.atomic():
            # フォームチェックした請求先住所をDBに保存
            billing_address = billing_address_form.save()
            # フォームチェックした支払い方法をDBに保存
            payment = payment_form.save()
            # 注文情報をDBに保存
            order = Order.objects.create(
                billing_address=billing_address,
                payment=payment
            )
            # カート内の商品をOrderItemテーブルに保存
            for product_name, product_info in product_data.items():
                order_item = OrderItem.objects.create(
                    order=order,
                    name=product_name,
                    price=product_info["price"],
                    quantity=product_info["quantity"],
                    subtotal=product_info["subtotal"]
                )
            # ここから本題
            # メールを設定していたらメール送信
            try:
                if billing_address.email:
                    order.send_email(
                        username=billing_address.username,
                        order_id=order.id,
                        order_items=product_data,
                        total_price=sum(item["subtotal"] for item in product_data.values()),
                        email=billing_address.email
                    )
                print("メール送信完了")
            except:
                print(("メール送信できませんでした")
            # カートを削除
            Cart.objects.filter(id=cart.id).delete()
        messages.success(request, "ご購入ありがとうございます")

        return redirect(reverse("products:index"))
models.py
from django.core.mail import send_mail

...
def send_email(self, username, order_id, order_items, total_price, email):
        # テンプレート側で使えるように必要な情報をcontextに追加
        context = {
            "username": username,
            "order_id": order_id,
            "order_items": order_items,
            "total_price": total_price
        }

        subject = "【購入確定メール】野球魂"
        text_content = render_to_string("email/order_confirmation.txt", context)  # テキスト版
        html_content = render_to_string("email/order_confirmation.html", context)  # HTML版

        email = send_mail(
            subject=subject,
            message=text_content,
            from_email=env.str("DEFAULT_FROM_EMAIL"),
            recipient_list=[email],
            html_message=html_content, 
            fail_silently=False,
        )

        return True

これでカートに商品を追加して
購入ボタンをクリックして
「メール送信完了」のデバック出力とサクセスメッセージが出力されました!!

私はここで詰まりました。
ずっとSendGrid側のActivityがProcessedのままでした

スクリーンショット 2025-02-20 2.48.51.png

原因を考える

結論だけ知りたい方は飛ばしてください

  • 環境変数の確認
    • 全て確認済み
  • print()でデバック
    • 1行ずつ確認してもエラーになる部分がありませんでした
  • dockerを再起動
    • 再起動後、改めて購入ボタンをクリックしてもメールが送られませんでした
  • APIキーの再生成
    • 再生成も意味なし
  • gmailを不使用にする
    • yahooメール, icloudメールを使用しても意味なし
  • pip install django-anymailを再インストール
    • 意味なし

結論

アカウントを新しく作成し直して新しいAPI Keyを作成しました。
そうするとメールが送信できました。

嬉しくて何回も送ってしまいました!!!!

スクリーンショット 2025-02-20 3.03.45.png

憶測ですが、Heroku経由からSendGridを作成したことが
バグにつながったのではないかと考えております。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?