0. はじめに
本稿は VR法人HIKKY Advent Calendar 2024 の 7日目の記事です。
昨日の記事は @rakai さんの UniRxではじめるReactive Programming入門 でした。
UniRx、良いOSSですね。
さて、本日の記事は、この令和の世に、メールを受信してWebhookを叩くサーバを立てるという、時代錯誤も甚だしい、だが誰かはやらなければいけない、悲しみのディスティニーの記録です。
おおまかなシステム構成は以下となります。シンプルですね。
1. TL;DR
- メールをフックしてwebサービスに繋げるニーズはある
- 独自のメールボックスなら自由にスクリプトフックできる
- 今メールボックスを自前で建てるなら、DockerMailserverが手軽で堅い
2. なぜこんなことをするのか
メールを起点としてカスタマーサービスのチケットを起票したい。但し新たなメール連動対応のCRMの導入はしない!
という修羅じみた命令を実施する羽目になった時、この記事が役に立つでしょう。
3. セットアップ手順
セットアップ手順は、大きく以下の流れになります。
- 自前のメールサーバを立てる
- メールフックを設定する
「メールサーバなんて楽勝だぜ!」という方は 3.4. まで読み飛ばして問題ないです。
3.1. Dockerのセットアップ
まず最初に、Dockerのセットアップを行います。
また、既にセットアップ済みならスキップして構いません。
Dockerのセットアップは、公式の方法をそのまま行えば大丈夫です。
参考(Ubuntuの例): https://docs.docker.com/engine/install/ubuntu/
UbuntuのDocker環境のセットアップ手順(公式そのままなので折り畳み)
※Dockerセットアップ詳細
過去にdocker関連パッケージをインストール済みの場合、干渉を避けるためにそれらをアンインストールします。
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のリポジトリを取り込む
# 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のインストール
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Dockerのインストールが完了したら、root以外でもdockerを利用できるように設定します。
ユーザーをdockerグループに入れる
※ <USER_NAME>
をログインしているユーザー名に置き換えます。
sudo usermod -aG docker <USER_NAME>
dockerを再起動
sudo service docker restart
これでdocker環境の設定は完了です。
3.1. DockerMailserverのセットアップ
メールサーバ
https://docker-mailserver.github.io/docker-mailserver/latest/usage/
DockerMailserver環境のセットアップ手順(新規性が無い上に超長いので折り畳み)
3.1.1. メールサーバのディレクトリを作成します。
mkdir ~/docker-mailserver
cd ~/docker-mailserver
3.1.2. Docker Mailserver のインストール構成ファイルをダウンロードします
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 行目
hostname: mail.<MY_DOMAIN>
domainname: <MY_DOMAIN>
48-52 行目
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>
を自分のメールサーバに置き換えます。
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/
./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@; ruf=mailto:dmarc.report@; 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の起動
docker compose up -d mailserver
これで設定に問題がなければメールサーバが起動します。
http://mail.<MY_DOMAIN>:9002/
に接続すれば、 roundcubeというメール管理サービスに接続できます。
3.3. メールアカウントの管理
3.3.1. メールアカウントの追加
メールアカウントの追加は以下のコマンドで行います。
docker compose exec mailserver setup email add <USERNAME>@<MY_DOMAIN> <MAIL_PASSWORD>
ここまでで、最低限のメールサーバの立ち上げはできました。
任意のメーラーで設定することにより、メールサーバとして稼働します。
次に、このサーバにメールでフックするスクリプトを仕込んでいきます。
3.4. hookするスクリプト
DockerMailserver は、標準でpythonが入っているので、メールでフックさせるスクリプトは pythonで書きます。
もちろん、別途インストールするのを厭わないのであれば、言語は何でもOKです。
ここでのスクリプトに与えられた要件は 「APIを叩いてJiraに課題を登録する」とします。
3.4.1. スクリプトを実装する
ぴゃーっと実装すると以下のスクリプトになります。
JiraのAPIを叩くコード(超長いので折り畳み)
3.4.1.1. webhookスクリプト本体
標準入力からメールを受け取って Jiraのwebhookを起動するスクリプトです。
Jiraで課題を作成するAPI仕様はこちらになります。
何をやっているか把握するためにメイン処理を追います。
■ 初期化処理
初期化処理と、
API送信先を定義しています。
# 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回以外のローカル転送は即終了して無視することにします。
# Prevent Double Webhook
if message['x-original-to'].endswith('@localhost'):
exit(0)
■ テンプレート読み込み
jsonの構造をテンプレートファイルとして読み込みます。
送信する内容をアレンジする場合はテンプレートを編集しましょう。
# 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())
■ テンプレートへの代入
テンプレートの構造にメールの内容を置き換えています。
# 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する際にヘッダを付与しています。
# 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']
# 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スクリプトの全体のソースコード
#!/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クラウドのドメイン |
■ テンプレートのソースコード
{
"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ファイルのソースコード
[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
これら情報を APIにアクセスする際に BASIC認証として使用することによって、APIが利用可能になります。
■ ファイルの配置
ソースコードを全て同じディレクトリに配置しましょう
~/
└─ docker-mailserver/
└─ dms/
└─ config/
├─ webhook_jira.py
├─ issue-template.json
└─ webhook_jira.ini
というわけで完成したスクリプトにメールを転送していきましょう。
3.4.2 postfix でコマンドにメールを送る許可
cd ~/docker-mailserver
vim 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"
jira: |"LC_CTYPE='C.UTF-8' /tmp/docker-mailserver/webhook_jira.py || true"
devnull: /dev/null
3.3.2 virtual aliases で転送先の設定
ドメインを含むメールをコマンドに送信するには、localhostのエイリアスに転送してやる必要があります。
バーチャルエイリアスで標的アドレスとローカル転送先を紐づけましょう。
この例では、メールボックス自身にもメールを残しつつ、 jira@localhost
にも転送しています。
support@<MY_DOMAIN> \support@<MY_DOMAIN>,jira@localhost
no-reply@vket.com devnull
3.3.3 DockerMailserver の再起動
Docker Compose を再起動すれば再起動になります。
docker-compose stop mailserver
docker-compose up -d mailserver
以上で、設定完了です。
この例では support@<MY_DOMAIN>
宛のメールを受け取ると、 JiraにAPI経由で起票しに行きます。
3.3.a1 付録 スクリプトの雑デバッグ方法
メールフックのスクリプトが正しく動いているか、どうやってデバッグすればよいでしょうか。
毎回メールを送信していると、面倒です。
答えは簡単で、メールのソースをスクリプトに標準入力で入れればOKです。
cat mail_source | LC_CTYPE='C.UTF-8' webhook_jira.py
4. さいごに
長文にお付き合いありがとうございました。
これで、無事カスタマーサポートのメールがJiraに起票されるようになりました。
スパムメールで課題が増えまくって担当者の怒りの声が届くのはまた別の話。
工程はとても長いですが、やることはシンプルなので、一度出来てしまえば完全に理解できるかと思います。
さて、明日は、@IwahanaFuku さんです。こちらも何やら面倒事をスクリプトの力で解決する話題のようです。お楽しみに。
■■■ 宣伝コーナー ■■■
本日からバーチャルマーケット2024Winterが始まります。
メタバース空間で行われるクリエイターのお祭りです。
VRChatというアプリで体験できますので是非遊びに来てください!