CSチームからの「Amazon SESのサプレッションリストを調べていただけませんか？」という問い合わせを0にした話

# はじめに
Hubbleでバックエンドエンジニアをしている @alstrocrack と申します。
[Hubble Advent Calendar 2024](https://qiita.com/advent-calendar/2024/hubble)の15日目[^1]の記事です。

[^1]: 平日のみの投稿なので20日ですが、15日目の記事としています。

株式会社Hubbleは、端的に言うと[契約書管理クラウド Hubble](https://hubble-docs.com/)というSaaSを開発しています。このHubbleはお客様に電子メールを送る機会も多々あるのですが、このメールが時たま届かないという事象に遭遇します。これには色んな原因があるのですが、原因の一つにメールバウンス（Ref: [バウンスメール](https://ja.wikipedia.org/wiki/%E3%83%90%E3%82%A6%E3%83%B3%E3%82%B9%E3%83%A1%E3%83%BC%E3%83%AB)）があります。

HubbleはAWSを利用しており、メール送信にはAmazon SESを用いています。そして、Amazon SESでは上述のメールバウンスするとサプレッションリストに登録され、該当のメールアドレスには以降は（そのままでは）メールが送信されないという事象が起こりえます[^2]。

[^2]: これは厳密な説明ではないため、詳しくは別のソースも参照ください。

なので、CS（Customer Success）チームから **「このメールアドレスのお客様からメールが届かないというお問い合わせがありました。メールバウンスしているのではないかと思っているのでAmazon SESのサプレッションリストを確認していただいても良いでしょうか？」** という依頼がDevチームに時々やってきます。

**実はこれは非常に手間がかかる~~やりたくない~~依頼なんですよね。。**

# なぜ非常に手間がかかる依頼なのか？

**（最近AWSのUIが変わって、メールアドレスの削除が以前より簡単になった気がします笑）**

Amazon SESのサプレッションリストには（弊社では）1000個近いメールアドレスが存在しています。その中から **検索なし** で1つのメールアドレスを探さなければいけません（いけなかった）。これは非常に手間がかかる作業です。

それによくよく考えてみると、そもそも

1. CSからDevへの作業依頼
2. Devの調査や操作
3. DevからCSへの完了報告

と部門を跨いだコミュニケーションも多かれ少なかれ発生していることがわかります。

ここで私が考えたことは、
**「そもそもCSチーム自身でサプレッションリストを探したり、該当のメールアドレスを削除できればお互いに楽なのでは？」**

CSチームは自分の範囲内でサプレッションリストを検索したり操作したりできるし、Devチームは手間のかかる作業から解放されるはずです。そこで[Amazon SESのAPIドキュメント(Ruby)](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SESV2/Client.html)をガサガサしてみると良さげなメソッドがいくつかあります。そこで、Slackの`/コマンド`からこのAPIを実行できるようにしようと思いました。

# 実装の内容
Slackの`/コマンド`を用いて、AWS Lambdaの関数のエンドポイントを叩かせて、その関数内でのAPIを実行させるようにしました。
以下は、一部処理を省略したRubyでのAWS Lambda関数の実装です。

```ruby:lambda_function.rb
require "json"
require "net/http"
require "base64"
require "bundler/inline"
require "cgi"

gemfile do
  source "https://rubygems.org"
  gem "rexml"
  gem "aws-sdk-sesv2"
end

SLACK_ENDPOINT = ENV.fetch("SLACK_ENDPOINT", nil)
SLACK_TOKEN = ENV.fetch("SLACK_TOKEN", nil)
SLACK_CHANNEL = ENV.fetch("SLACK_CHANNEL", nil)

module Event
  SEARCH = "search"
  DELETE = "delete"
end

module EmailStatus
  BOUNCE = "BOUNCE"
  COMPLAINT = "COMPLAINT"
end

module Error
  NOT_FOUND = "Not Found the email address"
  OTHER = "Something went wrong"
end

module Message
  module Search
    SUCCESS = "🚀 The email address has been discovered!"
    FAILURE = "😔 We could not find the email address..."
  end

  module Delete
    SUCCESS = "🚀 We have successfully removed the email address from the bounce list!"
    FAILURE = "😔 We Could not remove the email address from the bounce list..."
  end
end

def lambda_handler(event:, context:)
  email = event["email"] # イベントデータからメールアドレスを取得

  case type
  when Event::SEARCH
    res = search(email)
    result_message = res[:is_success] ? Message::Search::SUCCESS : Message::Search::FAILURE
    send_slack(result_message, email, res[:reason])
  when Event::DELETE
    res = delete(email)
    result_message = res[:is_success] ? Message::Delete::SUCCESS : Message::Delete::FAILURE
    send_slack(result_message, email, res[:reason])
  else
    send_slack("That type doesn't exist...", email)
  end
rescue StandardError => e
  puts(e)
end

def search(email)
  result = { is_success: false, reason: "" }
  res = client.get_suppressed_destination({ email_address: email })

  case res.suppressed_destination.reason
  when EmailStatus::BOUNCE
    result[:is_success] = true
    result[:reason] = EmailStatus::BOUNCE
  when EmailStatus::COMPLAINT
    result[:is_success] = true
    result[:reason] = EmailStatus::COMPLAINT
  end
  result
rescue Aws::SESV2::Errors::NotFoundException => e
  { is_success: false, reason: Error::NOT_FOUND }
rescue StandardError => e
  { is_success: false, reason: Error::OTHER }
end

def delete(email)
  client.delete_suppressed_destination({ email_address: email })
  { is_success: true }
rescue Aws::SESV2::Errors::NotFoundException => e
  { is_success: false, reason: Error::NOT_FOUND }
rescue StandardError => e
  { is_success: false, reason: Error::OTHER }
end

def client
  Aws::SESV2::Client.new(region: ENV.fetch("REGION", nil),
                         access_key_id: ENV.fetch("ACCESS_KEY_ID", nil),
                         secret_access_key: ENV.fetch("SECRET_ACCESS_KEY", nil))
end

def send_slack(result_message, email, reason = nil)
  # Slack SDKを使用して任意のメッセージを送信する処理を記述
  # 主題から逸れるため省略
end
```

:::note info
ちなみにRubyでの小ネタですが、RubyでのGem管理にGemfileを用いるのは一般的ですが、上記の実装にもあるように以下のような記述でファイルを分けることなくスッキリ記述できます。（Ref: [GitHub Gist](https://gist.github.com/kenchan/e7afa9ff25fda6b3a57044c2d0deebfc)）
:::


```ruby
require "bundler/inline"

gemfile do
  source "https://rubygems.org"
  gem "rexml"
  gem "aws-sdk-sesv2"
end
```

# 実際の画面の様子

## メールアドレスの検索（Not Found）
（サンプル用に、実際のメールアドレスを表示しています）
![スクリーンショット 2024-12-19 16.13.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/77627828-d1ac-3bd7-904d-14cf2edb85e0.png)

## メールアドレスの検索（Found）& サプレッションリストからの削除
![bounce_list.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/465bb0c2-293c-bf87-777f-e78f6d524fff.png)

## CSチームに使用していただいている様子
![cs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/4eda81ab-b1aa-2190-e5f7-011153a15c5d.png)

# それで結局何が言いたいんですか、ただの自慢ですか？？
違います！（まあ、半分そうかもしれません笑）冗談はさておき、我々のようなリソースに限りのあるスタートアップなどは**定期的に自社の業務を見直して効率化するべしという話**です。Hubbleにjoinしてビックリしたのは各所に[Slack ワークフロー](https://slack.com/intl/ja-jp/help/articles/16583775096083-%E8%87%AA%E5%8B%95%E5%8C%96---Slack-%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%81%A8%E3%81%AF)とや[Zapier](https://zapier.com)などを用いてトイルを解消する試みが多くみられたところです。このように業務効率化しないと、いつまで経っても手間のかかる業務と向き合うハメになります。この**サプレッションリストを洗うタスクには体感で毎回数十分かかっていた&コミュニケーションのリソースも考慮すると大きな業務効率化**だと考えています。

## Bizの業務にも関心を持つべし
エンジニアの三大美徳の中には「怠惰」がありますが、一般的な社会人としては勤勉こそが美徳です。雑談している中で気づいたりしますが、けっこう大変そうな作業を手作業で行っていたりします。**その上作業内容を聞くと、エンジニアは『それって自動化できそうじゃない？』と思うことがあります。** Bizは『そんなことできるの？』ってなったりしますが。そこにも目をつけて前述のZapierなどでの業務効率化や最終的には今回のようなショートコードを書いて、BizもDevもWin-Winになるような施策を打ったり、提案したりするとお互いにコアの業務にさらに集中することができます。

# 最後に
ただし、コード書くのは最終手段です。できるだけ出来合いのものを使って工夫しましょう。結構、コード書くのもめんどくさいです~~し、何なら「勝手にLambda関数増やすなよ、管理すんのがめんどくさいだろ」ってSREエンジニアに言われたりしますから。。。。。~~

次回は @embokoir さんです！