Google Postmaster ToolsのAPIを使用して迷惑メール率データを可視化してみた

# はじめに

Google Postmaster ToolsのAPIを使用して迷惑メール率データを取得し、可視化する方法を説明します。
Postmaster Toolsでも可視化されますが、ドメイン単位で表示されるため、ドメイン毎の比較やまとめて表示することができません。また、最長120日間しかデータが蓄積されないため、それより長い期間で可視化したい場合はデータを退避しておく必要があります。

# Gメールショック！

## "No Auth, No Entry"

2023年10月にアナウンスされたGoogle(Gメール)と米国Yahoo!メールで2024年2月から順次実施する(した)迷惑メール対策強化のことです。
(実際には2024年4月から送信ドメイン認証DMARCの適用が開始されました。)

二社のアナウンスの主な要点は以下のとおりです。

1. 二社に対してメールを送信するメール送信ドメインにおいて、送信ドメイン認証(SPF、DKIM、DMARC)の設定が必要となる
2. 一日に5,000通のメールを送信するメール送信者にはより厳しい送信ドメイン認証の設定が求められる
3. 一斉メール送信する場合は、簡便な購読解除機能の実装が必要となる
4. 受信ユーザーが求めないメールは送信しない(Complaints rate 0.3%未満)

要件を満たせない場合、受信拒否や流量制限等、迷惑メールとして扱われ、Google(Gメール)と米国Yahoo!メール宛にメールが届かなくなるおそれがあり、メール業界では対応を急ぐ動きがありました。
4点目については2024年6月から適用開始とアナウンスされています。

### Google(Gメール)のアナウンス

https://blog.google/products/gmail/gmail-security-authentication-spam-protection/

### 米国Yahoo!のアナウンス

https://blog.postmaster.yahooinc.com/post/730172167494483968/more-secure-less-spam

### 技術要件の詳細

技術要件の詳細については以下のページに記載されています。

https://support.google.com/a/answer/81126

FAQは以下のページです。

https://support.google.com/a/answer/14229414

随時更新されるので、定期的に最新の情報を確認する必要があります。

|要素  |一日5,000通未満の場合  |一日5,000通以上の場合  |
|---|---|---|
|DKIM/SPF  |DKIMまたはSPFを設定  |DKIMとSPFを設定  |
|DMARC  |(なし)  |DMARCを設定すること(p=noneで可)、ダイレクトメールはDKIMまたはSPFでのDMARC認証がPassすること  |
|メッセージ形式 |RFC5322に準拠する形式であること |RFC5322に準拠する形式であること |
|IPアドレスの正・逆引き |IPアドレスに正引き・逆引き(PTR)を設定すること、IPアドレスはホスト名と関連づけること |IPアドレスに正引き・逆引き(PTR)を設定すること、IPアドレスはホスト名と関連づけること |
|TLS |配送時にTLSを使用すること(2024年12月に要件追加) |配送時にTLSを使用すること(2024年12月に要件追加) |
|SPAM送信レート |Postmaster Toolでの直近のSPAM送信レートが0.3%未満であること |Postmaster Toolでの直近のSPAM送信レートが0.3%未満であること |
|ARC |定期的に転送を行う場合はARCヘッダを追加する |定期的に転送を行う場合はARCヘッダを追加する |
|メーリングリスト |List-id:ヘッダを追加する |List-id:ヘッダを追加する |
|購読解除 |(なし) |ワンクリック解除(RFC8058)に対応し、本文中にもその説明を入れること |
|その他 |Googleのドメインを騙らないこと(p=quarantineに設定される) |Googleのドメインを騙らないこと(p=quarantineに設定される) |

2023年12月15日に開催された「Google & 米国Yahoo!の迷惑メール対策強化」に関するイベントで公開されている資料に情報がまとまっています。

https://connpass.com/event/304457/

https://speakerdeck.com/jpaawg/google-and-guo-yahoo-nomi-huo-meru-dui-ce-qiang-hua-nituite

## JPドメインのDMARC普及率

JPRSとの共同研究でJPドメインのDMARC普及率調査を続けています。

https://kitazaki.github.io/dmarc/

これまでのDMARCレコード数の調査で顕著な変化を4回確認しました。

https://note.com/kitazaki/m/m1c77f4ceb818

![1706829129616-KZIDjYM6K8のコピー.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/bb6e215b-8b91-12a5-96ce-c51d341d67d0.jpeg)

- 1回目: 2023年7月12日〜8月12日
- 2回目: 2023年11月22日
- 3回目: 2024年1月18日

1〜3回目はホスティング事業者やISP側で対応していることを確認しました。

- 4回目: 2024年2月1日

4回目はGoogleおよび米国Yahoo!の送信者ガイドライン変更が施行された影響で、これまでで一番顕著な変化を確認しました。(一方で、2024年4月の送信ドメイン認証DMARCの適用では顕著な変化はありませんでした。)
社会的な影響が大きく対応に苦労された方は多かったと思いますが、迷惑メールやなりすましメールによるフィッシング詐欺が多い中、安心・安全なコミュニケーションインフラとしてのメールサービスが健全化されることを期待します。

# SPAM送信レートの把握

SPAM送信レートによる受信制限は2024年6月から開始される予定で、0.3%未満であることが要求されています。常に0.1%未満を維持することを推奨されています。(0.1%未満にしておくとでスパイクしても0.3%未満に収まるため。)

## Postmaster Toolsの設定

Postmaster Toolsにメールで使用するドメインを登録する必要があります。

https://postmaster.google.com/

### 1.Postmaster Toolsでドメインを追加する

Postmaster Toolsにログインして、右下の「＋」ボタンを押します。
メールで使用するドメイン名を入力します。

![スクリーンショット 2024-04-29 17.20.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/e6b901e4-9315-9d85-a6ff-44010c8c612c.png)
![スクリーンショット 2024-04-29 17.21.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/6ee4dc5e-aae8-b70e-6948-391324c35479.png)

### 2.DNSのTXTレコードに認証情報を登録する

使用しているDNSサービスでTXTレコードを設定します。
送信ドメイン認証SPF(SPFレコード)と合わせて登録します。
digコマンドで設定内容を確認します。

```bash
$ dig +short kitazaki.cloudns.cc txt
"google-site-verification=Ap52Pr6pQonU_5F3dSxcHGkH5OURRsEWKU46VIxkyyo"
"v=spf1 ip4:160.16.130.121 -all"
```

### 3.所有権を証明する

正常に確認されると、ドメイン名のステータスが「確認済み」となります。

![スクリーンショット 2024-04-29 17.32.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/973d91b7-c0f9-07d4-f62c-c47fa4f48872.png)

ドメイン名を選択すると、データが表示されます。

![１_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/1d010a60-77a0-f9b6-b71e-a3262b3151fa.png)

選択できる項目と表示期間は以下のとおりです。

- 項目
  - 迷惑メール率
  - IP のレピュテーション
  - ドメインのレピュテーション
  - フィードバック ループ
  - 認証
  - 暗号化
  - 配信エラー

- 表示期間
  - 過去7日間
  - 過去30日間
  - 過去60日間
  - 過去90日間
  - 過去120日間

メールの流通量が少なく、データが存在しない場合は表示されません。

![スクリーンショット 2024-04-29 17.39.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/d3bd244e-7b84-8b4e-8261-8a4cf86e5354.png)

## APIの仕様

Google Cloud ConsoleからPostmaster Tools APIを有効にします。

https://console.cloud.google.com/marketplace/product/google/gmailpostmastertools.googleapis.com

![スクリーンショット 2024-04-30 10.13.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/42bc5168-338e-e4fa-a5d2-ba43e3494ebc.png)

### チュートリアル

「ドキュメント」のリンクからPostmaster Tools APIの概要(チュートリアル)ページ
に移動できます。

![2 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/c5c73977-8b4d-4994-317d-1fa84774ba53.png)

https://developers.google.com/gmail/postmaster

「クイックスタート」にJavaとPythonのサンプルコードが記載されています。(今回はPythonコードを使用します。)

### 認証情報の作成

「管理」のリンクから認証情報のページに移動できます。

![スクリーンショット 2024-04-30 11.33.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/f7cad002-6a59-f3af-baef-8588b111189b.png)

「認証情報」のリンクからAPIの認証情報を設定します。

![3 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/369f3c97-7c1d-95be-5510-02915d1924d6.png)

OAuth2.0 クライアントID、または、サービスアカウントを使用した認証方法がありますが、チュートリアルにはOAuth2.0を使用した認証方法について説明されています。(今回はOAuth2.0を使用します。)

「＋認証情報を作成」→「OAuth クライアントID」を選択します。

![4 2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/8aa414a8-ef95-2e48-d416-070c1f6fa0c0.jpeg)

「アプリケーションの種類」は「デスクトップアプリ」を選択します。
「名前」は認証情報の名前を入力します。
「作成」ボタンを押します。

![５ 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/b3fa307a-01b5-8063-fec5-c36273bcd318.png)

OAuthクライアントが作成されます。「JSONをダウンロード」を押してクライアントシークレット(JSONファイル)をcredentials.jsonとして保存します。(Pythonコードで使用します。)

![スクリーンショット 2024-04-30 11.02.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/0e4b50c1-fcd5-f34b-135f-53073ffc653c.png)

### OAuth同意画面の構成

APIとサービスの公開ステータスが「テスト」の場合、テストユーザーを個別に登録する必要があります。
「OAuth同意画面」のリンクから「＋ADD USERS」を選択します。

![5 2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/2f005069-3428-eb2f-2e8c-ac979fc54f45.jpeg)

ユーザーに自身のアカウントを追加して、「保存」を押します。

![スクリーンショット 2024-04-30 11.47.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/27a62098-3c31-fbc3-5477-39b166ae1ab3.png)

### Pythonプログラム

Pythonコードを実行する前に、Python用のGoogleクライアントライブラリをインストールします。

```bash
$ pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib
```

チュートリアルに記載されているPythonコード(メールドメイン一覧を取得する)を作成します。
クライアントシークレット(credentials.json)を同じディレクトリに置きます。

```python:quickstart.py
from __future__ import print_function
import pickle
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient import errors

SCOPES = ['https://www.googleapis.com/auth/postmaster.readonly']

def main():
    creds = None
    if os.path.exists('token.pickle'):
        with open('token.pickle', 'rb') as token:
            creds = pickle.load(token)
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                'credentials.json', SCOPES)
            creds = flow.run_local_server(port=0)
        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)

    service = build('gmailpostmastertools', 'v1', credentials=creds)

    domains = service.domains().list().execute()
    if not domains:
        print('No domains found.')
    else:
        print('Domains:')
        for domain in domains['domains']:
            print(domain)

if __name__ == '__main__':
  main()
```

コマンドコンソールからPythonコードを実行します。

```bash
$ python3 quickstart.py
```

コマンドコンソールにメッセージが表示されて、ブラウザが起動します。(ブラウザが起動しない場合は、ブラウザを起動してコマンドコンソールに表示されたURLを入力します。)

```bash
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=607004970172-j8lofha7ska1d3r0qrqdj9aglpa4ebk9.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A60179%2F&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fpostmaster.readonly&state=TCvigLAKBtBLgW2Uv2CiNRSoz06zeP&access_type=offline
```

「続行」を選択します。

![7_1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/1c94704a-42e9-2e90-d3e4-fd204d22664e.jpeg)

「続行」を選択します。

![6.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/ceab31a0-26c9-54d1-30e8-442d66bbee35.jpeg)

認証が成功すると、ブラウザに「The authentication flow has completed. You may close this window.」と表示され、コマンドコンソールにメールドメイン一覧が表示されます。
2回目以降は認可情報がファイル(token.pickle)に保存されるため認証は不要です。

```bash
Domains:
{'name': 'domains/kitazaki.cloudns.cc', 'createTime': '2023-12-07T06:53:04Z', 'permission': 'OWNER'}
{'name': 'domains/kitazaki2.ddns.net', 'createTime': '2024-04-29T08:21:03Z', 'permission': 'NONE'}
```

#### 参考

OAuth同意画面の構成が正しく設定されていない場合、ブラウザの認証画面でエラーが表示されます。

![8.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/ba6320c1-4f6e-f342-355c-3a07f4f9ff49.jpeg)

## メールの指標(統計データ)を取得

メールの指標(統計データ)を取得する方法は2種類あります。

- get: ドメイン名と特定の日付を指定してデータを取得する
- list: ドメイン名を指定してデータ一覧を取得する

listの場合、取得可能なデータを一覧で取得できますが、データが多い場合、pageTokenの情報を指定して複数回実行する必要があります。(1回目のリクエストの応答にpageTokenの情報が含まれ、2回目のリクエストでpageTokenの情報を設定する。)

Postmaster Toolsのデータは2〜3日前の情報が日次で反映されるため、getでメールの指標を取得します。
Pythonコード(traffic_stats.py)を作成します。

```python:traffic_stats.py
from __future__ import print_function
import pickle
import os.path
import json
import sys
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient import errors

SCOPES = ['https://www.googleapis.com/auth/postmaster.readonly']

def main():
    creds = None
    if os.path.exists('token.pickle'):
        with open('token.pickle', 'rb') as token:
            creds = pickle.load(token)
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                'credentials.json', SCOPES)
            creds = flow.run_local_server(port=0)
        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)

    service = build('gmailpostmastertools', 'v1beta1', credentials=creds)

    domain_name = args[1]
    date = args[2]
    get_traffic_stats(service, domain_name, date)
    #list_traffic_stats(service, domain_name)

def get_traffic_stats(service, domain_name, date):
    try:
        query = 'domains/%s/trafficStats/%s' %(domain_name,date)
        traffic_stats = service.domains().trafficStats().get(name=query).execute();
        print(traffic_stats);
        return traffic_stats;
    except errors.HttpError as err:
        print('An error occurred: %s' % err)

def list_traffic_stats(service, domain_name):
    try:
        query = 'domains/' + domain_name
        list_traffic_stats_response = service.domains().trafficStats().list(parent=query).execute();
        print(list_traffic_stats_response);
        return list_traffic_stats_response;
    except errors.HttpError as err:
        print('An error occurred: %s' % err)

if __name__ == '__main__':
  args = sys.argv
  if len(args) >= 3:
    main()
  else:
    print('usage: ' + os.path.basename(__file__) + ' domein_name date')
```

Pythonコードを実行します。コマンドの引数にドメイン名と日付(YYYYMMDD形式)を指定します。

```bash
$ python3 traffic_stats.py "ドメイン名" "日付"

$ python3 traffic_stats.py kitazaki.cloudns.cc 20240301
```

応答データの例です。JSONフォーマットで出力されます。

```bash
{'name': 'domains/kitazaki.cloudns.cc/trafficStats/20240320', 'userReportedSpamRatio': 0.001, 'ipReputations': [{'reputation': 'BAD'}, {'reputation': 'LOW'}, {'reputation': 'MEDIUM', 'sampleIps': ['160.16.130.121'], 'ipCount': '1'}, {'reputation': 'HIGH'}], 'domainReputation': 'MEDIUM', 'spfSuccessRatio': 1, 'dkimSuccessRatio': 1, 'dmarcSuccessRatio': 1, 'inboundEncryptionRatio': 0.999, 'deliveryErrors': [{'errorClass': 'TEMPORARY_ERROR', 'errorType': 'SUSPECTED_SPAM'}]}
```

項目は以下のとおりです。

- name: トラフィック統計情報のリソース名。名前の形式はdomains/"ドメイン"/trafficStats/"日付" 
- userReportedSpamRatio: 受信トレイに送信されたメールに対する、ユーザーからの迷惑メール報告の割合
- ipReputations[]: ドメインのメールサーバーのIPアドレスに関する評価
- domainReputation: ドメインの評価
- spammyFeedbackLoops[]: スパム行為のあるフィードバックループ識別子と個々のスパム率
- spfSuccessRatio: SPFで認証に成功したメールと、SPFでの認証を試みたすべてのメールの比率
- dkimSuccessRatio: DKIMで認証に成功したメールと、DKIMでの認証を試みたすべてのメールの比率
- dmarcSuccessRatio: ドメインから送信されたすべてのメールのうち、SPF、または、DKIMで認証に成功したすべてのメールのうち、DMARCで認証に成功したメールの割合
- outboundEncryptionRatio: 送信したメールがセキュアな手段(TLS)を介して送信された割合
- inboundEncryptionRatio: 受信したメールがセキュアな手段(TLS)を介して受信された割合
- deliveryErrors[]: ドメインの配信エラー

データが存在しない項目は表示されないことに注意が必要です。また、データが存在しない日付の場合、HTTPエラー(400、または、404)応答になります。

## データの可視化

cronで毎日決まった日時にすべてのドメインのメールの指標(統計データ)を取得し、取得したデータの中からuserReportedSpamRatioを抽出してCSVフォーマットで保存します。
すべてのドメインでuserReportedSpamRatio表示した例です。

![1_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/bfaec728-0292-cb26-4ad6-76a88a2e288e.png)


# 2024/5/8追記

## "Token has been expired or revoked."エラーが発生する

プログラムを継続して実行していると、7日後に"Token has been expired or revoked."というエラーが出て失敗するようになります。

```bash
google.auth.exceptions.RefreshError: ('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'})
```

このエラーは、OAuth同意画面が外部ユーザータイプ用に構成され、公開ステータスが「テスト中」のGoogle Cloud Platformプロジェクトには、7日で有効期限が切れる更新トークンが発行されることが原因です。

https://isgs-lab.com/727/

## サービスアカウントを使用する

OAuth 2.0 クライアントID ではなく、サービスアカウントを利用することで解決できます。

https://isgs-lab.com/729/

手順は以下のとおりです。

1. Google Cloud Consoleの「認証情報」で「サービスアカウント」を作成する。
2. サービスアカウントの「キー」を作成する。形式は JSON を選択する。認証情報の入った json ファイルをダウンロードする。(credentials.jsonで保存する。)
3. プログラムが使う認証情報をダウンロードしたjsonファイルに変更する。
4. Postmaster Toolsに登録されているドメインのユーザー管理で、サービスアカウントのメールアドレスにアクセス権を付与する。

![スクリーンショット 2024-05-08 13.42.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/472fee94-cdb5-70ad-cd5a-92962a198257.png)

Pythonコード(traffic_stats.py)を変更します。

```python:traffic_stats.py
from __future__ import print_function
import pickle
import os.path
import json
import sys
from googleapiclient.discovery import build
from google.oauth2 import service_account
from google.auth.transport.requests import Request
from googleapiclient import errors

SCOPES = ['https://www.googleapis.com/auth/postmaster.readonly']

def main():
    creds = service_account.Credentials.from_service_account_file('credentials.json', scopes=SCOPES)

    service = build('gmailpostmastertools', 'v1beta1', credentials=creds)

    domain_name = args[1]
    date = args[2]
    get_traffic_stats(service, domain_name, date)
    #list_traffic_stats(service, domain_name)

def get_traffic_stats(service, domain_name, date):
    try:
        query = 'domains/%s/trafficStats/%s' %(domain_name,date)
        traffic_stats = service.domains().trafficStats().get(name=query).execute();
        print(traffic_stats);
        return traffic_stats;
    except errors.HttpError as err:
        print('An error occurred: %s' % err)

def list_traffic_stats(service, domain_name):
    try:
        query = 'domains/' + domain_name
        list_traffic_stats_response = service.domains().trafficStats().list(parent=query).execute();
        print(list_traffic_stats_response);
        return list_traffic_stats_response;
    except errors.HttpError as err:
        print('An error occurred: %s' % err)

if __name__ == '__main__':
  args = sys.argv
  if len(args) >= 3:
    main()
  else:
    print('usage: ' + os.path.basename(__file__) + ' domein_name date')
```