第3回: メールアカウント作成と “ドメイン認証 TXT” の自動投入 + リトライ設計

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 つ:

  1. 外部 SaaS (SendGrid / Mailgun) — DNS だけ整えれば早いが、月額が発生する
  2. Xserver 管理画面で手動作成 — 早いが「PW を発行 → .env に書く」を毎回手作業
  3. 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 状態のどれか:

  1. 最新と一致 — 何もしない
  2. 値が古い (古い token が残っている) — 上書きはせず、警告だけ
  3. 未設定 — 新規に 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-infohostname フィールドから取れる:

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 の細かい仕様の罠も合わせて扱う。

コメント

タイトルとURLをコピーしました