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

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

https://elements.heroku.com/addons/sendgrid

https://devcenter.heroku.com/articles/sendgrid

https://anymail.dev/en/stable/esps/sendgrid/

https://docs.djangoproject.com/ja/5.1/topics/email/

https://anymail.dev/en/stable/esps/sendgrid/

https://www.tempmail.us.com/ja/google-apps-%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88/google-apps-script%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%A6%E3%83%A1%E3%83%BC%E3%83%AB%E9%80%81%E4%BF%A1%E8%80%85%E3%81%AE%E5%90%8D%E5%89%8D%E3%82%92%E6%8A%BD%E5%87%BA%E3%81%99%E3%82%8B

:::note warn
* 上記の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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/54e79234-1074-4983-825f-82e406713ab6.png)
* 今回は **Compny Name** の部分に個人利用であることを記載
* Webサイトは **X** のURLを記載

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

## APIキーの作成
以下の画像のように
1. Settings→API Keyにアクセス
1. Create API Keyをクリック
1. Full Accessを選択して Create & Viewをクリック

![スクリーンショット 2025-02-20 1.03.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/b1ad85b3-2072-4f52-88bf-7b1fce6a5fe9.png)

![スクリーンショット 2025-02-20 1.05.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/a4dc8b80-9ef7-494b-a2b8-1005406efb45.png)

![スクリーンショット 2025-02-20 1.08.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/10cf3563-32c7-4bfa-acb4-36db3e64c44b.png)


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

![スクリーンショット 2025-02-20 1.09.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/285d042f-3275-4c3c-b021-de49073e72ab.png)

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

![スクリーンショット 2025-02-20 1.12.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/92d25a58-328e-42d0-9786-629c28e2ac2c.png)

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

![スクリーンショット 2025-02-20 1.03.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/13ac1f54-80c5-4242-9a33-f89b4f8bdfd0.png)

![スクリーンショット 2025-02-20 1.24.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/5c6e42e7-d090-4844-a5f1-dfe2b46b06d6.png)

![スクリーンショット 2025-02-20 1.23.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/996b96b5-4689-4133-a924-74cfa3c5ef9d.png)

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

上記で設定したアドレスに認証メールが届くので、
「Verify Single Sender」をクリックして、認証が通ると下記画面が表示される
![スクリーンショット 2025-02-20 1.29.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/a8ee9b48-f147-4661-80b4-caee56d3ce3f.png)

## 環境変数の設定
APIキーの発行後、Herokuの環境変数に設定する。
今回はコマンドから設定していきます。
（-a以降はなくてもOK）
```
heroku config:set SENDGRID_API_KEY=[作成したAPIキー] -a [アプリ名]
```

これでダッシュボードから確認もできます
![スクリーンショット 2025-02-20 1.37.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/62170261-cc15-4eee-98a5-fdf96ee7d910.png)

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

https://docs.djangoproject.com/ja/5.1/topics/email/

https://anymail.dev/en/stable/esps/sendgrid/

https://www.tempmail.us.com/ja/google-apps-%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88/google-apps-script%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%A6%E3%83%A1%E3%83%BC%E3%83%AB%E9%80%81%E4%BF%A1%E8%80%85%E3%81%AE%E5%90%8D%E5%89%8D%E3%82%92%E6%8A%BD%E5%87%BA%E3%81%99%E3%82%8B


まずは`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.py`と`models.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
```

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

:::note alert
私はここで詰まりました。
ずっとSendGrid側のActivityがProcessedのままでした
:::
![スクリーンショット 2025-02-20 2.48.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/0352ff45-788d-4c8c-aea1-265ba48b2e2d.png)

# 原因を考える
:::note warn
結論だけ知りたい方は飛ばしてください
:::
- 環境変数の確認
    - 全て確認済み
- print()でデバック
    - 1行ずつ確認してもエラーになる部分がありませんでした
- dockerを再起動
    - 再起動後、改めて購入ボタンをクリックしてもメールが送られませんでした
- APIキーの再生成
    - 再生成も意味なし
- gmailを不使用にする
    - yahooメール, icloudメールを使用しても意味なし
- pip install django-anymailを再インストール
    - 意味なし

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

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

![スクリーンショット 2025-02-20 3.03.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/5a2f8e75-94c7-4eaa-b9fa-68866c6488fd.png)

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