令和の時代に自前メールサーバを立ててJiraにWebhookする

# 0. はじめに

本稿は  **VR法人HIKKY Advent Calendar 2024** の 7日目の記事です。

https://qiita.com/advent-calendar/2024/hikky

昨日の記事は @rakai さんの [UniRxではじめるReactive Programming入門](https://qiita.com/rakai/items/5af5b8114dc8c480c8ee) でした。
UniRx、良いOSSですね。

さて、本日の記事は、この令和の世に、メールを受信してWebhookを叩くサーバを立てるという、時代錯誤も甚だしい、だが誰かはやらなければいけない、悲しみのディスティニーの記録です。

おおまかなシステム構成は以下となります。シンプルですね。

![メールフック.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35009/00d5e619-ed84-0f3c-0165-b47ced36337f.png)


# 1. TL;DR

- メールをフックしてwebサービスに繋げるニーズはある
- 独自のメールボックスなら自由にスクリプトフックできる
- 今メールボックスを自前で建てるなら、DockerMailserverが手軽で堅い

# 2. なぜこんなことをするのか

メールを起点としてカスタマーサービスのチケットを起票したい。但し新たなメール連動対応のCRMの導入はしない！
という修羅じみた命令を実施する羽目になった時、この記事が役に立つでしょう。

# 3. セットアップ手順

セットアップ手順は、大きく以下の流れになります。

- 自前のメールサーバを立てる
- メールフックを設定する

「メールサーバなんて楽勝だぜ！」という方は [3.4.](#34-hook%E3%81%99%E3%82%8B%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88) まで読み飛ばして問題ないです。

## 3.1. Dockerのセットアップ

まず最初に、Dockerのセットアップを行います。
また、既にセットアップ済みならスキップして構いません。

Dockerのセットアップは、公式の方法をそのまま行えば大丈夫です。

> 参考（Ubuntuの例）： https://docs.docker.com/engine/install/ubuntu/

<details><summary>UbuntuのDocker環境のセットアップ手順(公式そのままなので折り畳み)</summary>

#### ※Dockerセットアップ詳細

過去にdocker関連パッケージをインストール済みの場合、干渉を避けるためにそれらをアンインストールします。

```shell
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done
```


Ubuntuにdockerのリポジトリを取り込む

```shell
# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
```

Dockerのインストール

```shell
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
---
</details>

Dockerのインストールが完了したら、root以外でもdockerを利用できるように設定します。

ユーザーをdockerグループに入れる
※ `<USER_NAME>` をログインしているユーザー名に置き換えます。

```shell
sudo usermod -aG docker <USER_NAME>
```

dockerを再起動

```shell
sudo service docker restart
```

これでdocker環境の設定は完了です。

## 3.1. DockerMailserverのセットアップ

メールサーバ

> https://docker-mailserver.github.io/docker-mailserver/latest/usage/

<details><summary>DockerMailserver環境のセットアップ手順(新規性が無い上に超長いので折り畳み)</summary>

### 3.1.1. メールサーバのディレクトリを作成します。

```shell
mkdir ~/docker-mailserver
cd ~/docker-mailserver
```

### 3.1.2. Docker Mailserver のインストール構成ファイルをダウンロードします

```shell
DMS_GITHUB_URL='https://raw.githubusercontent.com/docker-mailserver/docker-mailserver/master'
wget "${DMS_GITHUB_URL}/docker-compose.yml"
wget "${DMS_GITHUB_URL}/mailserver.env"
wget "${DMS_GITHUB_URL}/setup.sh"
chmod a+x ./setup.sh
```

必要に応じて設定ファイルを `docker-compose.yml` 編集します。

7~8 行目

```yaml:docker-compose.yml
    hostname: mail.<MY_DOMAIN>
    domainname: <MY_DOMAIN>
```

48-52 行目

```yaml:docker-compose.yml
    environment:
      - ROUNDCUBEMAIL_DB_TYPE=sqlite
      - ROUNDCUBEMAIL_SKIN=elastic
      - ROUNDCUBEMAIL_DEFAULT_HOST=tls://mail.<MY_DOMAIN>
      - ROUNDCUBEMAIL_SMTP_SERVER=tls://mail.<MY_DOMAIN>
```

### 3.1.3. DNSレコードを登録します

外からメールを届くようにするには、まずDNSサーバにレコードを登録しなければなりません。

例：

| name | type | TTL | detail |
|---|---|---|---|
| `mail.<MY_DOMAIN>` | A | 3600 | `***.***.***.***` |
| `<MY_DOMAIN>` | MX | 3600 | 10 `mail.<MY_DOMAIN>` |


- AレコードはMTAがメール(SMTP)サーバを発見するために必要
- MTAは @ 以降を判別して、 MXレコードに対応する SMTPサーバにリレーする

要は「 `<MY_DOMAIN>` 宛のメールは `mail.<MY_DOMAIN>` というメールサーバに聞いてくれ。」という内容です。

より正確な情報は RFC5321とそれに付随するRFCを読んでください

### 3.1.4. 証明書の初回インストール(letsencrypt)

let's encryptでドメインの証明書を取得します。以下コマンドの `mail.<MY_DOMAIN>` を自分のメールサーバに置き換えます。

```shell
cd ~/docker-mailserver
docker run --rm -it \
  -v "${PWD}/docker-data/certbot/certs/:/etc/letsencrypt/" \
  -v "${PWD}/docker-data/certbot/logs/:/var/log/letsencrypt/" \
  -p 80:80 \
  certbot/certbot certonly --standalone -d mail.<MY_DOMAIN>
```

### 3.1.5. PORTの解放

ファイアウォールの設定がある場合は、以下のポートに対して、外部からアクセス可能にする

| type | port | protocol | source | description |
| --- | --- | --- | --- | --- |
| SMTP | 25 | TCP | 0.0.0.0/0 |  |
| SMTPS | 465 | TCP | 0.0.0.0/0 |  |
| SMTP(submission) | 587 | TCP | 0.0.0.0/0 |  |
| IMAPS | 993 | TCP | 0.0.0.0/0 |  |
| HTTP | 80 | TCP | 0.0.0.0/0 |  |

### 3.1.6 DKIM

DKIMとは、送信ドメインを証明する手段のひとつで、DNSを介して署名を検証することにより、受け取り側がメールの改ざんを検知できる仕組みです。

https://docker-mailserver.github.io/docker-mailserver/edge/config/best-practices/dkim/

```shell
./setup.sh config dkim
```

`./docker-data/dms/config/opendkim/keys/<MY_DOMAIN>/mail.txt` にDKIMのキーが作られる

DNSにキーの内容を登録

| レコード名 | タイプ | TTL | 値 |
| --- | --- | --- | --- |
| `mail._domainkey.<MY_DOMAIN>` | TXT | 300 | txtファイルの、ダブルクォーテーションの中身(複数行) |


### 3.1.7 DMARC

DMARCは、正しいメールの送信元から送信されているかを、DNSを介して検証する仕組みで、受け取り側が送信者の偽装を検知できる仕組みです。

https://docker-mailserver.github.io/docker-mailserver/edge/config/best-practices/dmarc/

dmarc.report 宛てのメールを転送する

`./docker-data/dms/config/postfix-virtual.cf`

```
dmarc.report@<MY_DOMAIN> postmaster@<MY_DOMAIN>
```


DNSにキーの内容を登録

| レコード名 | タイプ | TTL | 値 |
| --- | --- | --- | --- |
| `_dmarc.<MY_DOMAIN>.` | TXT | 300 | "v=DMARC1; p=none; rua=mailto:dmarc.report@<MY_DOMAIN>; ruf=mailto:dmarc.report@<MY_DOMAIN>; sp=none; ri=86400" |

### 3.1.8 SPF

SPFとは、メール送信者が正しいIPアドレスから送信されたことを、DNSを介して検証する仕組みです。

https://docker-mailserver.github.io/docker-mailserver/edge/config/best-practices/spf/

DNSにキーの内容を登録（ただしこの例の記述だとSPFレコードを登録する意味はあまりない）

| レコード名 | タイプ | TTL | 値 |
| --- | --- | --- | --- |
| `<MY_DOMAIN>.` | TXT | 300 | "v=spf1 mx ~all" |

### 3.1.9. DockerMailserverの起動

```shell
docker compose up -d mailserver
```

これで設定に問題がなければメールサーバが起動します。

`http://mail.<MY_DOMAIN>:9002/` に接続すれば、 roundcubeというメール管理サービスに接続できます。

---
</details>


## 3.3. メールアカウントの管理

### 3.3.1. メールアカウントの追加

メールアカウントの追加は以下のコマンドで行います。

```shell
docker compose exec mailserver setup email add <USERNAME>@<MY_DOMAIN> <MAIL_PASSWORD>
```

ここまでで、最低限のメールサーバの立ち上げはできました。
任意のメーラーで設定することにより、メールサーバとして稼働します。

次に、このサーバにメールでフックするスクリプトを仕込んでいきます。


## 3.4. hookするスクリプト

DockerMailserver は、標準でpythonが入っているので、メールでフックさせるスクリプトは pythonで書きます。
もちろん、別途インストールするのを厭わないのであれば、言語は何でもOKです。

ここでのスクリプトに与えられた要件は 「APIを叩いてJiraに課題を登録する」とします。

### 3.4.1. スクリプトを実装する

ぴゃーっと実装すると以下のスクリプトになります。

<details><summary>JiraのAPIを叩くコード(超長いので折り畳み)</summary>

#### 3.4.1.1. webhookスクリプト本体

標準入力からメールを受け取って Jiraのwebhookを起動するスクリプトです。

Jiraで課題を作成するAPI仕様はこちらになります。

https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post

何をやっているか把握するためにメイン処理を追います。

##### ■ 初期化処理

初期化処理と、
API送信先を定義しています。

```py
    # Initialize
    parser = argparse.ArgumentParser(description='Mail Webhook')
    url = 'https://<MY_DOMAIN>.atlassian.net/rest/api/3/issue'
    message = email.message_from_file(sys.stdin, policy=policy.default)
```

##### ■ 多重送信防止処理

メールサーバ内部で転送されたメールが何度もこのwebhookを通ることがあるので、最初の1回以外のローカル転送は即終了して無視することにします。

```py
    # Prevent Double Webhook
    if message['x-original-to'].endswith('@localhost'):
        exit(0)
```

##### ■ テンプレート読み込み

jsonの構造をテンプレートファイルとして読み込みます。
送信する内容をアレンジする場合はテンプレートを編集しましょう。

```py
    # Load Request Template
    template_path = os.path.join(os.path.dirname(__file__), 'issue-template.json')
    with open(template_path) as f:
        issue_data = json.loads(f.read())
```

##### ■ テンプレートへの代入

テンプレートの構造にメールの内容を置き換えています。

```py
    # Load Request Template
    template_path = os.path.join(os.path.dirname(__file__), 'issue-template.json')
    with open(template_path) as f:
        issue_data = json.loads(f.read())
    # Edit Issue
    _subject = decode_header_text(message['subject'])
    _body_content = parse_body(message)
    _date = decode_header_text(message['date'])
    _body = _body_content[0]
    issue_data['fields'].update({'summary': "【SupportMail】{0}".format(_subject)})
    issue_data['fields']['description']['content'][0]['content'][0].update({'text': "{0}".format(_subject)})
    issue_data['fields']['description']['content'][1]['content'][1].update({'text': "{0}".format(message['from'])})
    issue_data['fields']['description']['content'][2]['content'][1].update({'text': "{0}".format(_date)})
    issue_data['fields']['description']['content'][3]['content'][0]['content'][0].update({'text': "{0}".format(_body.decode('utf-8'))})
```

この原理を使うと、Jira以外のAPIでも自由にメッセージを作ることができますね。

##### ■ BASIC認証ヘッダの付与

Jira の API は BASIC認証 のヘッダで投稿者を認証するので、 iniファイルに認証情報を記述し、
APIにPOSTする際にヘッダを付与しています。

```py
    # Load Config INI
    config_ini = configparser.ConfigParser()
    config_ini.read(os.path.join(os.path.dirname(__file__), 'webhook_jira.ini'), encoding='utf-8')
    config = config_ini['DEFAULT']
```

```py
    # Build Headers: BASIC Auth
    jira_user = config.get('JiraUser')
    jira_pat = config.get('JiraPAT')
    if jira_user is not None and jira_pat is not None:
        basic_auth_token = base64.b64encode("{0}:{1}".format(jira_user, jira_pat).encode('utf-8')).decode('utf-8')
        headers.update({'Authorization': 'Basic ' + basic_auth_token})
```

---

##### ■ webhookスクリプトの全体のソースコード

```py:docker-data/dms/config/webhook_jira.py
#!/usr/bin/python3
import configparser
import os
import sys
import argparse
import base64
import email
from email import policy
from email.header import decode_header
import json
import urllib.request
import urllib.parse
import time


def get_header_string(message):
    m = message.__str__().replace("\r\n", "\n")
    return m[0:m.find("\n\n")]


def get_sender_ip(message):
    received = message.get_all('received')
    received_from = [s for s in received if s.startswith('from')]
    r = received_from[-1]
    return r[r.find('[') + 1: r.find(']')]


def parse_body(message):
    try:
        body = message.get_body(preferencelist=('plain', 'html'))
        content_type = body.get_content_type()
        charset = body.get_content_charset()
        payload = body.get_payload(decode=True)
    except:
        for part in message.walk():
            if part.get_content_type() == 'text/plain':
                content_type = part.get_content_type()
                payload = part.get_payload()
                charset = part.get_content_charset()
    return payload, content_type, charset


def decode_header_text(message):
    header = ''
    for payload, charset in decode_header(str(message)):
        if type(payload) is bytes:
            if charset:
                header += payload.decode(charset)
            else:
                header += payload.decode()
        elif type(payload) is str:
            header += payload
    return header

def parse_authentication_results(message):
    results = message.get_all('authentication-results')
    for r in results:
        yield list(map(str.strip, r.split(';')))


def parse_dkim(result_detail):
    elem = result_detail.split()
    elem_i = [s for s in elem if s.startswith('header.i=')]
    if len(elem_i) > 0:
        return '{%s : %s}' % (elem_i[0][9:], elem[0][5:])


def parse_spf(result_detail):
    elem = result_detail.split()
    print(elem[0])
    return elem[0][4:]


if __name__ == "__main__":

    # Initialize
    parser = argparse.ArgumentParser(description='Mail Webhook')
    url = 'https://<MY_DOMAIN>.atlassian.net/rest/api/3/issue'
    message = email.message_from_file(sys.stdin, policy=policy.default)

    # Prevent Double Webhook
    if message['x-original-to'].endswith('@localhost'):
        exit(0)

    # Load Config INI
    config_ini = configparser.ConfigParser()
    config_ini.read(os.path.join(os.path.dirname(__file__), 'webhook_jira.ini'), encoding='utf-8')
    config = config_ini['DEFAULT']

    # Load Request Template
    template_path = os.path.join(os.path.dirname(__file__), 'issue-template.json')
    with open(template_path) as f:
        issue_data = json.loads(f.read())
    # Edit Issue
    _subject = decode_header_text(message['subject'])
    _body_content = parse_body(message)
    _date = decode_header_text(message['date'])
    _body = _body_content[0]
    issue_data['fields'].update({'summary': "【SupportMail】{0}".format(_subject)})
    issue_data['fields']['description']['content'][0]['content'][0].update({'text': "{0}".format(_subject)})
    issue_data['fields']['description']['content'][1]['content'][1].update({'text': "{0}".format(message['from'])})
    issue_data['fields']['description']['content'][2]['content'][1].update({'text': "{0}".format(_date)})
    issue_data['fields']['description']['content'][3]['content'][0]['content'][0].update({'text': "{0}".format(_body.decode('utf-8'))})

    # Build Headers
    headers = {
        'Content-Type': 'application/json',
    }
    # Build Headers: BASIC Auth
    jira_user = config.get('JiraUser')
    jira_pat = config.get('JiraPAT')
    if jira_user is not None and jira_pat is not None:
        basic_auth_token = base64.b64encode("{0}:{1}".format(jira_user, jira_pat).encode('utf-8')).decode('utf-8')
        headers.update({'Authorization': 'Basic ' + basic_auth_token})

    # Create API request
    request = urllib.request.Request(url, json.dumps(issue_data).encode(), headers)
    response = urllib.request.urlopen(request)

    # print(response.getcode())
    body = response.read()
    # print(body.decode('utf-8'))
```

コード内の以下を置き換える

| keyword | 説明 |
|---|---|
| `<MY_DOMAIN>` | Jiraクラウドのドメイン |

---

##### ■ テンプレートのソースコード

```json:docker-data/dms/config/issue-template.json
{
    "fields": {
        "project": {
            "key": "<JIRA_PROJECT_KEY>"
        },
        "issuetype": {
            "id": "<JIRA_ISSUE_TYPE>"
        },
        "summary": "【SupportMail】<!--SUBJECT-->",
        "description": {
            "type": "doc",
            "version": 1,
            "content": [
                {
                    "type": "heading",
                    "attrs": {
                        "level": 1
                    },
                    "content": [
                        {
                            "type": "text",
                            "text": "<!--SUBJECT-->"
                        }
                    ]
                },
                {
                    "type": "paragraph",
                    "content": [
                        { "type": "text","text": "From: " },
                        {
                            "type": "text",
                            "text": "<!--FROM-->",
                            "marks": [
                                {
                                    "type": "strong"
                                },
                                {
                                    "type": "em"
                                }
                            ]
                        }

                    ]
                },
                {
                    "type": "paragraph",
                    "content": [
                        { "type": "text","text": "Date: " },
                        {
                            "type": "text",
                            "text": "<!--DATE-->",
                            "marks": [
                                {
                                    "type": "em"
                                }
                            ]
                        }

                    ]
                },
                {
                    "type": "blockquote",
                    "content": [
                        {
                            "type": "paragraph",
                            "content": [
                                {
                                    "text": "<!--BODY-->",
                                    "type": "text"
                                }
                            ]
                        }
                    ]
                }
            ]
        },
        "reporter": {
            "name": "CustomerMail"
        }
    }
}
```

コード内の以下を置き換える

| keyword | 説明 |
|---|---|
| `<JIRA_PROJECT_KEY>` | Jiraのプロジェクトキー `PRJ` などアルファベット大文字 |
| `<JIRA_ISSUE_TYPE>` | Jiraの課題タイプ `10001` など数字になっている |

---

##### ■ iniファイルのソースコード

```ini:docker-data/dms/config/webhook_jira.ini
[DEFAULT]
JiraUser = <JIRA_USER>
JiraPAT = <JIRA_API_TOKEN>
```

コード内の以下を置き換える

| keyword | 説明 |
|---|---|
| `<JIRA_USER>` | Jiraのユーザー メールアドレスである場合が多い |
| `<JIRA_API_TOKEN>` | JiraのAPIトークン |

##### ■ APIトークンを取得方法

先述の通り、JiraのAPIはBASIC認証ヘッダを付与する必要があります。
認証情報としてユーザー名に **メールアドレス** 、パスワードに **APIトークン** を使います。
その際のAPIトークンの発行方法について説明します。

Jiraのアカウントで以下にアクセスして取得する

https://id.atlassian.com/manage-profile/security/api-tokens

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35009/f790762f-2f45-4f7c-bab0-6dfa2fbf5589.png)


これら情報を APIにアクセスする際に BASIC認証として使用することによって、APIが利用可能になります。

---

##### ■ ファイルの配置

ソースコードを全て同じディレクトリに配置しましょう

```
~/
└─ docker-mailserver/
    └─ dms/
        └─ config/
            ├─ webhook_jira.py
            ├─ issue-template.json
            └─ webhook_jira.ini
```

</details>

というわけで完成したスクリプトにメールを転送していきましょう。

### 3.4.2 postfix でコマンドにメールを送る許可

```shell
cd ~/docker-mailserver
vim docker-data/dms/config/postfix-main.cf
```

```ini:docker-data/dms/config/postfix-main.cf
allow_mail_to_commands = alias,forward,include
allow_mail_to_files    = alias,forward,include
default_privs = mail
```

### 3.4.3 local aliases で起動するコマンドの設定

ファイルやコマンドにメールを送信したい場合、localhostのエイリアスを使います。
以下のような書式でエイリアスにWebhook先を追加しましょう。

```
<ローカル宛先>: |"LC_CTYPE='C.UTF-8' /tmp/docker-mailserver/<COMMAND> <OPSTIOPNS> || true"
```

```docker-data/dms/config/postfix-aliases.cf
jira: |"LC_CTYPE='C.UTF-8' /tmp/docker-mailserver/webhook_jira.py || true"
devnull: /dev/null
```

### 3.3.2 virtual aliases で転送先の設定

ドメインを含むメールをコマンドに送信するには、localhostのエイリアスに転送してやる必要があります。
バーチャルエイリアスで標的アドレスとローカル転送先を紐づけましょう。

この例では、メールボックス自身にもメールを残しつつ、 `jira@localhost` にも転送しています。

```docker-data/dms/config/postfix-virtual.cf
support@<MY_DOMAIN> \support@<MY_DOMAIN>,jira@localhost
no-reply@vket.com devnull
```

### 3.3.3 DockerMailserver の再起動

Docker Compose を再起動すれば再起動になります。

```shell
docker-compose stop mailserver
docker-compose up -d mailserver
```

以上で、設定完了です。
この例では `support@<MY_DOMAIN>` 宛のメールを受け取ると、 JiraにAPI経由で起票しに行きます。

---

### 3.3.a1 付録 スクリプトの雑デバッグ方法

メールフックのスクリプトが正しく動いているか、どうやってデバッグすればよいでしょうか。
毎回メールを送信していると、面倒です。
答えは簡単で、メールのソースをスクリプトに標準入力で入れればOKです。

```shell
cat mail_source | LC_CTYPE='C.UTF-8' webhook_jira.py 
```

---

# 4. さいごに

長文にお付き合いありがとうございました。

これで、無事カスタマーサポートのメールがJiraに起票されるようになりました。
スパムメールで課題が増えまくって担当者の怒りの声が届くのはまた別の話。

工程はとても長いですが、やることはシンプルなので、一度出来てしまえば完全に理解できるかと思います。

さて、明日は、@IwahanaFuku さんです。こちらも何やら面倒事をスクリプトの力で解決する話題のようです。お楽しみに。

---

■■■ 宣伝コーナー ■■■

本日からバーチャルマーケット2024Winterが始まります。
メタバース空間で行われるクリエイターのお祭りです。
VRChatというアプリで体験できますので是非遊びに来てください！

https://event.vket.com/