TL;DR
- Xserver の
POST /mailは 「事前にドメイン認証 TXT (_xserver-verify.{domain}にxserver-verify=<token>) を立てておく」必要がある。 - トークンは
GET /server-info.domain_validation_tokenから取れる。スクリプト 1 本で「既存 TXT を確認 → 必要なら自動投入 → POST /mail」を一気通貫する。 - DNS 反映は数分〜30 分かかるので、
POST /mailがドメイン認証エラーなら 最大 20 分 / 30 秒間隔でリトライ。 - SMTP ホストは契約名 (
myserver.xsrv.jp) ではなく/server-info.hostname(svXXXXX.xserver.jp) を使う。理由は TLS 証明書 CN が*.xserver.jpだから。
なぜメールアカウントを API で作るのか
Laravel から noreply@example.com で送信したい時、選択肢は 3 つ:
- 外部 SaaS (SendGrid / Mailgun) — DNS だけ整えれば早いが、月額が発生する
- Xserver 管理画面で手動作成 — 早いが「PW を発行 →
.envに書く」を毎回手作業 - Xserver API で自動作成 +
.env.production.mail-secretsへの直接書き出し ← 本連載の選択
個人運用なら 3 が最速。一度書いてしまえば dev/staging 用のアカウント追加も同じスクリプトで済む。
既存チェックで idempotent に
POST する前に GET で確認:
existing=$(curl -fsS -H "${AUTH}" "${BASE}/mail")
if echo "${existing}" | jq -e --arg addr "${MAIL_ADDRESS}" \
'.accounts[]? | select(.mail_address == $addr)' > /dev/null; then
echo " ${MAIL_ADDRESS} は既に存在します。"
echo " パスワードリセットが必要なら Xserver 管理画面で変更してください。"
exit 0
fi
jq -e は「マッチしたら exit 0、しなかったら exit 1」を返す。if echo | jq -e で「あったらスキップ」が 1 行で書ける。
ドメイン認証 TXT の仕組み
Xserver の POST /mail は「そのドメインを本当に持っているか」を TXT レコードで確認する仕様。具体的には:
- ホスト:
_xserver-verify.{domain}(例:_xserver-verify.example.com) - 値:
xserver-verify=<token> - token:
GET /server-info.domain_validation_tokenで取れる (サーバ単位で固定)
POST /mail 時点でこの TXT が無い (または値が古い) と {"error": {"message": "ドメイン認証 ..."}} が返る。先に立てておく必要がある。
TXT の状態を 3 パターンに分けて扱う
_xserver-verify.example.com の TXT は次の 3 状態のどれか:
- 最新と一致 — 何もしない
- 値が古い (古い token が残っている) — 上書きはせず、警告だけ
- 未設定 — 新規に
POST /dns
実装:
ensure_verify_txt() {
local domain="$1"
local token
token=$(xs_get "server-info" | jq -r '.domain_validation_token // empty')
if [[ -z "${token}" ]]; then
echo "ERROR: domain_validation_token を取得できませんでした" >&2
return 1
fi
local desired="xserver-verify=${token}"
local host="_xserver-verify.${domain}"
local existing
existing=$(xs_get "dns" | jq -r --arg h "${host}" \
'.records[] | select(.host == $h and .type == "TXT") | .content' | head -1)
if [[ "${existing}" == "${desired}" ]]; then
echo " ✓ ${host} の TXT は最新トークンに一致"
return 0
fi
if [[ -n "${existing}" ]]; then
echo " ⚠ TXT 値が古い。スクリプトでは更新しないので、手動で消してから再実行"
return 0
fi
# 未設定 → 追加
local body
body=$(jq -n --arg d "${domain}" --arg h "${host}" --arg c "${desired}" \
'{domain:$d, host:$h, type:"TXT", content:$c}')
curl -fsS -X POST -H "${AUTH}" -H 'Content-Type: application/json' -d "${body}" "${BASE}/dns" > /dev/null
}
「古い値があったら警告だけ」にしている理由は事故防止。token はサーバ単位で固定なので、本来「古い」状態は起きない。起きてるとすれば「複数サーバを混在運用」「token が再発行された」のどちらかで、自動上書きは怖い。
POST /mail のリトライループ
TXT を立てた直後はまだ DNS 浸透していない。POST /mail がドメイン認証エラーを返す:
{"error": {"message": "ドメイン認証 が完了していません..."}}
これは 時間が経てば直るタイプのエラーなので、リトライで吸収する:
attempt=0
max_attempts=$(( ${MAIL_CREATE_RETRY_MIN:-20} * 60 / 30 )) # 30 秒間隔で N 分
while true; do
attempt=$((attempt + 1))
res=$(curl -sS -X POST -H "${AUTH}" -H 'Content-Type: application/json' -d "${body}" "${BASE}/mail")
if [[ "${res}" != *'"error"'* ]]; then
echo " ✓ 成功 (試行 ${attempt})"; break
fi
# ドメイン認証エラーなら DNS 反映待ち → リトライ
if echo "${res}" | grep -q 'ドメイン認証'; then
if [[ ${attempt} -ge ${max_attempts} ]]; then
echo "ERROR: 20 分待っても通らなかった。手動で確認してください。"
exit 1
fi
echo " 待機中 (${attempt}/${max_attempts}) ..."
sleep 30
continue
fi
# それ以外のエラーは即死
echo "ERROR: 作成に失敗"; exit 1
done
ポイント:
- 「ドメイン認証」エラーだけリトライ対象にする。パスワード不正など他のエラーは即
exit 1。 - リトライ間隔は 30 秒、最大 20 分 (= 40 回)。
MAIL_CREATE_RETRY_MIN環境変数で外から延ばせるようにしておくと、夜間バッチに使い回せる。 - 「ドメイン認証」の判定は
grepで十分。Xserver API のエラーメッセージ仕様が変わったら直す。
パスワードを絶対にログに出さない
POST /mail 成功時のレスポンスにもリクエストエコーで password が混ざる可能性がある (API バージョン依存)。echo する前に必ず sed で潰す:
echo " response: $(echo "${res}" | sed 's/"password":[^,}]*/"password":"***"/g')"
このパターンを採用しておくと、ログを Slack に流す運用に切り替えても安全。Slack やチームの目に PW が映る事故は起きた瞬間に詰むので、最初から「ログに出さない」を仕組みで強制する。
SMTP ホスト名の TLS 落とし穴
MAIL_HOST に何を入れるかは Xserver の場合 罠が一つある:
- 契約名 (
myserver.xsrv.jp) を入れると SMTP 接続時に TLS で死ぬ - 理由: TLS 証明書 CN が
*.xserver.jp(*.xsrv.jpではない) - 必要なのは実サーバのホスト名 (
svXXXXX.xserver.jp)
これは /server-info の hostname フィールドから取れる:
SERVER_HOSTNAME=$(xs_get "server-info" | jq -r '.hostname // empty')
if [[ -z "${SERVER_HOSTNAME}" ]]; then
SERVER_HOSTNAME="${XSERVER_TARGET_SERVER}" # fallback (失敗時のみ)
fi
fallback は念のため。通常運用では hostname フィールドは存在する。
secrets ファイルへの書き出し
最終的に Laravel の .env に貼る形で書き出す:
SECRETS_FILE="${REPO_ROOT}/deploy/.env.production.mail-secrets"
{
echo "MAIL_MAILER=smtp"
echo "MAIL_HOST=${SERVER_HOSTNAME}"
echo "MAIL_PORT=465"
echo "MAIL_ENCRYPTION=ssl"
echo "MAIL_USERNAME=${MAIL_ADDRESS}"
echo "MAIL_PASSWORD=\"${PASSWORD}\""
echo "MAIL_FROM_ADDRESS=\"${MAIL_ADDRESS}\""
} > "${SECRETS_FILE}"
chmod 600 "${SECRETS_FILE}"
第 2 回の xs-db-create.sh と 完全に同じパターン。スクリプト間で書式・場所を揃えておくと、運用者 (人間も AI も) が学習しやすい。
まとめと次回予告
- 「破壊的 POST の前に DNS を整える」二段構えの API は 状態確認 → 自動投入 → リトライで吸収できる。
- 「TXT が古い」のような曖昧状態は自動上書きを避けて警告だけにする。
- パスワードはログにも response にも残さない。secrets はローカルファイル →
.envに手で貼る運用に倒す。
次回は 「idempotent + DRY-RUN」の同じ流儀をサブドメイン作成 / cron 登録 / SSL 監視に適用する。Xserver cron で使える記号の制限など、API の細かい仕様の罠も合わせて扱う。


コメント