0) { usort($mxRecords, fn($a, $b) => ($a['pri'] ?? 9999) <=> ($b['pri'] ?? 9999)); $host = $mxRecords[0]['target'] ?? ''; if ($host !== '') return $host; } $aRecords = @dns_get_record($domain, DNS_A); if (is_array($aRecords) && count($aRecords) > 0) { return $domain; } throw new RuntimeException("MX解決失敗: {$domain}"); } // ─── 送信処理 ──────────────────────────────────────────────── function processQueue(): void { // PHPMailerのオートロード $autoloadPaths = [ CHILD_DIR . '/vendor/autoload.php', dirname(CHILD_DIR) . '/vendor/autoload.php', ]; $autoloaded = false; foreach ($autoloadPaths as $path) { if (is_file($path)) { require_once $path; $autoloaded = true; break; } } if (!$autoloaded) { childLog('ERROR: vendor/autoload.php が見つかりません。composer require phpmailer/phpmailer を実行してください。'); return; } ensureQueueDirs(); $newDir = QUEUE_DIR . '/new'; $curDir = QUEUE_DIR . '/cur'; $deferDir = QUEUE_DIR . '/defer'; $files = listJsonFiles($newDir); if (empty($files)) return; // バッチごとの結果を集約 $batchResults = []; // batch_id => ['master_result_url'=>..., 'results'=>[...]] foreach ($files as $filePath) { $basename = basename($filePath); $curPath = $curDir . '/' . $basename; // new/ → cur/ に移動(送信中) if (!@rename($filePath, $curPath)) { childLog("ERROR: {$basename} の移動に失敗"); continue; } // メタデータ読み込み $json = @file_get_contents($curPath); if ($json === false) { childLog("ERROR: {$basename} の読み込みに失敗"); continue; } $email = json_decode($json, true); if (!is_array($email)) { childLog("ERROR: {$basename} のJSON解析に失敗"); @unlink($curPath); continue; } $outboundId = (int)($email['outbound_id'] ?? 0); $batchId = (string)($email['batch_id'] ?? ''); $masterResultUrl = (string)($email['master_result_url'] ?? ''); $result = processOneEmail($email); $result['outbound_id'] = $outboundId; // 結果をバッチに集約 if ($batchId !== '' && $masterResultUrl !== '') { if (!isset($batchResults[$batchId])) { $batchResults[$batchId] = [ 'master_result_url' => $masterResultUrl, 'child_id' => $email['child_id'] ?? null, 'results' => [], ]; } $batchResults[$batchId]['results'][] = $result; } // 送信結果に応じてファイル処理 if ($result['status'] === 'sent' || $result['status'] === 'failed') { @unlink($curPath); } elseif ($result['status'] === 'deferred') { // cur/ → defer/ に移動 @rename($curPath, $deferDir . '/' . $basename); } } // バッチ結果をマスターにコールバック $conf = loadConf(); foreach ($batchResults as $batchId => $batch) { $payload = [ 'batch_id' => $batchId, 'child_id' => $batch['child_id'], 'results' => $batch['results'], ]; $ok = callbackToMaster($batch['master_result_url'], $conf['api_key'] ?? '', $payload); if (!$ok) { // コールバック失敗 → result_pending/ に保存 $pendingPath = QUEUE_DIR . '/result_pending/' . $batchId . '.json'; file_put_contents($pendingPath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX); childLog("WARN: コールバック失敗、result_pending に保存: {$batchId}"); } else { childLog("OK: コールバック送信完了: {$batchId} ({$batch['master_result_url']})"); } } } function processOneEmail(array $email): array { $outboundId = (int)($email['outbound_id'] ?? 0); $envFrom = (string)($email['envelope_from'] ?? ''); $rcpt = (string)($email['envelope_rcpt'] ?? ''); if ($envFrom === '' || $rcpt === '') { return ['status' => 'failed', 'smtp_code' => 0, 'detail' => 'envelope_from/rcpt が空', 'smtp_log' => null]; } // 宛先ドメイン $atPos = strrpos($rcpt, '@'); if ($atPos === false) { return ['status' => 'failed', 'smtp_code' => 0, 'detail' => 'rcpt にドメインなし', 'smtp_log' => null]; } $rcptDomain = substr($rcpt, $atPos + 1); try { $mxHost = resolveMxHost($rcptDomain); } catch (RuntimeException $e) { return ['status' => 'failed', 'smtp_code' => 0, 'detail' => $e->getMessage(), 'smtp_log' => null]; } // PHPMailer設定 $mail = new PHPMailer\PHPMailer\PHPMailer(true); $smtpDebug = ''; $mail->Debugoutput = function (string $str, int $level) use (&$smtpDebug) { if (strlen($smtpDebug) < 256 * 1024) { $smtpDebug .= $str . "\n"; } }; try { $mail->isSMTP(); $mail->Host = $mxHost; $mail->Port = 25; $mail->SMTPAuth = false; $mail->SMTPSecure = ''; $mail->SMTPAutoTLS = true; $mail->Timeout = 30; $mail->SMTPOptions = [ 'ssl' => [ 'verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true, ], ]; $mail->XMailer = ' '; $mail->SMTPDebug = 2; // HELOホスト名(child.confから取得) $conf = loadConf(); $heloHostname = $conf['helo_hostname'] ?? ''; if ($heloHostname !== '') { $mail->Helo = $heloHostname; } // charset/encoding $sendCharset = (string)($email['send_charset'] ?? 'UTF-8'); $sendEncoding = (string)($email['send_encoding'] ?? 'base64'); if ($sendCharset === '') $sendCharset = 'UTF-8'; if (!in_array($sendEncoding, ['7bit','8bit','quoted-printable','base64'], true)) $sendEncoding = 'base64'; $mail->CharSet = $sendCharset; $mail->Encoding = $sendEncoding; $mail->setFrom($envFrom); $mail->Sender = $envFrom; $mail->addAddress($rcpt); // Message-ID $messageId = (string)($email['message_id'] ?? ''); if ($messageId !== '') { $mail->MessageID = $messageId; } // Subject / Body $subject = (string)($email['subject'] ?? ''); $bodyText = (string)($email['body_text'] ?? ''); $bodyHtml = (string)($email['body_html'] ?? ''); // charset変換 if (strtoupper($sendCharset) !== 'UTF-8') { $subject = @iconv('UTF-8', $sendCharset . '//IGNORE', $subject) ?: $subject; $bodyText = @iconv('UTF-8', $sendCharset . '//IGNORE', $bodyText) ?: $bodyText; $bodyHtml = @iconv('UTF-8', $sendCharset . '//IGNORE', $bodyHtml) ?: $bodyHtml; } $mail->Subject = $subject; if ($bodyHtml !== '') { $mail->isHTML(true); $mail->Body = $bodyHtml; $mail->AltBody = ($bodyText !== '') ? $bodyText : strip_tags($bodyHtml); } else { $mail->isHTML(false); $mail->Body = ($bodyText !== '') ? $bodyText : ''; } // DKIM署名 $dkimDomain = (string)($email['dkim_domain'] ?? ''); $dkimSelector = (string)($email['dkim_selector'] ?? ''); $dkimPrivateKey = (string)($email['dkim_private_key'] ?? ''); if ($dkimDomain !== '' && $dkimSelector !== '' && $dkimPrivateKey !== '') { $mail->DKIM_domain = $dkimDomain; $mail->DKIM_selector = $dkimSelector; $mail->DKIM_private_string = $dkimPrivateKey; $mail->DKIM_identity = $envFrom; } $mail->send(); childLog("SENT: outbound_id={$outboundId} to={$rcpt} via={$mxHost}"); return ['status' => 'sent', 'smtp_code' => 250, 'detail' => 'OK', 'smtp_log' => null]; } catch (Throwable $e) { $err = $e->getMessage(); // SMTPコードをデバッグログから抽出 $smtpCode = 0; if ($smtpDebug !== '' && preg_match_all('/SERVER -> CLIENT:\s*(\d{3})[ -]/m', $smtpDebug, $allMatches)) { foreach (array_reverse($allMatches[1]) as $code) { $c = (int)$code; if ($c >= 400 && $c <= 599) { $smtpCode = $c; break; } } } if ($smtpCode === 0 && preg_match('/\b([45]\d{2})\s+[45]\.\d+\.\d+\b/', $err, $codeMatch)) { $smtpCode = (int)$codeMatch[1]; } // ログ切り詰め $logFull = $smtpDebug . "\n--- ErrorInfo ---\n" . ($mail->ErrorInfo ?? '') . "\n--- Exception ---\n" . $err; if (strlen($logFull) > 64 * 1024) { $logFull = substr($logFull, 0, 64 * 1024) . "\n...[truncated]"; } // 5xx永続エラー → failed、4xx一時エラー → deferred $isPermanent = ($smtpCode >= 500 && $smtpCode <= 599); // キーワードによる永続エラー判定 $permanentKeywords = ['User unknown', 'does not exist', 'Mailbox not found', 'No such user', 'invalid address', 'address rejected']; foreach ($permanentKeywords as $kw) { if (stripos($err, $kw) !== false) { $isPermanent = true; break; } } if ($isPermanent) { childLog("FAILED: outbound_id={$outboundId} smtp_code={$smtpCode} err={$err}"); return ['status' => 'failed', 'smtp_code' => $smtpCode, 'detail' => mb_substr($err, 0, 1024), 'smtp_log' => $logFull]; } else { childLog("DEFERRED: outbound_id={$outboundId} smtp_code={$smtpCode} err={$err}"); return ['status' => 'deferred', 'smtp_code' => $smtpCode, 'detail' => mb_substr($err, 0, 1024), 'smtp_log' => $logFull]; } } } function callbackToMaster(string $url, string $apiKey, array $payload): bool { $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'X-Nextstep-Api-Key: ' . $apiKey, ]); $response = curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($response === false || $httpCode < 200 || $httpCode >= 300) { childLog("CALLBACK FAILED: url={$url} http={$httpCode}"); return false; } return true; } // ─── CLIモード ─────────────────────────────────────────────── if (php_sapi_name() === 'cli') { $opts = getopt('', ['master:', 'process']); // --process: キュー処理 if (array_key_exists('process', $opts)) { ensureQueueDirs(); processQueue(); exit(0); } // 初回起動判定: child.confが存在しない場合はセットアップモード $isFirstRun = !is_file(CONF_FILE); // --master: 手動指定セットアップ / 引数なし初回起動: 自動検出セットアップ if (isset($opts['master']) || ($isFirstRun && !array_key_exists('process', $opts))) { // マスターURL決定 if (isset($opts['master'])) { // 手動指定 $masterUrl = rtrim((string)$opts['master'], '/'); if ($masterUrl === '') { fwrite(STDERR, "使い方: php sender_child.php --master=https://master.example.com\n"); exit(1); } $childUrl = ''; // 後で手動入力 } else { // 自動検出: get_master.php から取得 echo "=== マスターサーバー自動検出 ===\n\n"; $lookupUrl = MASTER_LOOKUP_URL . '?code=' . urlencode(REGISTER_CODE); echo "問い合わせ中: {$lookupUrl}\n"; $ch = curl_init($lookupUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 15); $lookupResponse = curl_exec($ch); $lookupHttpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $lookupError = curl_error($ch); curl_close($ch); if ($lookupResponse === false || $lookupHttpCode < 200 || $lookupHttpCode >= 300) { fwrite(STDERR, "✗ マスター検出失敗 (HTTP {$lookupHttpCode}): {$lookupError}\n"); exit(1); } $lookupData = json_decode((string)$lookupResponse, true); if (!is_array($lookupData) || empty($lookupData['master']) || empty($lookupData['addr'])) { fwrite(STDERR, "✗ マスター検出レスポンスが不正: {$lookupResponse}\n"); exit(1); } $masterUrl = rtrim($lookupData['master'], '/'); $externalIp = $lookupData['addr']; $childUrl = 'http://' . $externalIp . '/sender_child.php'; echo "✓ マスターURL: {$masterUrl}\n"; echo "✓ 外部IP: {$externalIp}\n"; echo "✓ 子サーバーURL: {$childUrl}\n\n"; } echo "=== NEXTSTEP-RELAY 子サーバーセットアップ ===\n\n"; // ディレクトリ作成 ensureQueueDirs(); echo "✓ キューディレクトリ作成完了\n"; // APIキー生成 $apiKey = bin2hex(random_bytes(32)); // child.conf 保存 $conf = [ 'api_key' => $apiKey, 'master_url' => $masterUrl, 'master_result_url' => $masterUrl . '/api/child_result.php', 'child_stuck_seconds' => 300, 'created_at' => date('Y-m-d H:i:s'), ]; saveConf($conf); echo "✓ child.conf 生成完了\n"; echo " APIキー: {$apiKey}\n\n"; // 子サーバーURL: 自動検出できなかった場合は手動入力 if ($childUrl === '') { echo "この子サーバーのURL(sender_child.phpへのHTTPアクセスURL)を入力してください:\n"; echo "例: https://child1.example.com/sender_child.php\n> "; $childUrl = trim((string)fgets(STDIN)); if ($childUrl === '') { fwrite(STDERR, "URLが空です。中断します。\n"); exit(1); } } // マスターへ登録リクエスト echo "マスターサーバーへ登録中...\n"; $registerUrl = $masterUrl . '/api/child_register.php'; $payload = [ 'child_url' => $childUrl, 'api_key' => $apiKey, 'hostname' => gethostname() ?: 'unknown', 'php_version' => PHP_VERSION, ]; $ch = curl_init($registerUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', ]); $response = curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($response === false) { fwrite(STDERR, "✗ マスターへの接続失敗: {$curlError}\n"); exit(1); } $result = json_decode((string)$response, true); if ($httpCode >= 200 && $httpCode < 300 && is_array($result) && ($result['status'] ?? '') === 'ok') { $childId = $result['child_id'] ?? '?'; echo "✓ 登録成功! (child_id={$childId})\n"; echo " {$result['message']}\n\n"; // child_idをconfに保存 $conf['child_id'] = (int)$childId; saveConf($conf); } else { $errMsg = $result['error'] ?? $response; fwrite(STDERR, "✗ 登録失敗 (HTTP {$httpCode}): {$errMsg}\n"); exit(1); } exit(0); } echo "使い方:\n"; echo " php sender_child.php # 初回自動セットアップ\n"; echo " php sender_child.php --master=https://master.example.com # 手動セットアップ\n"; echo " php sender_child.php --process # キュー処理\n"; exit(0); } // ─── HTTPモード ────────────────────────────────────────────── $action = $_GET['action'] ?? ''; $conf = loadConf(); if ($action === '') { jsonResponse(['status' => 'error', 'error' => 'action パラメータが必要です'], 400); exit; } // 認証チェック if (!authenticateRequest($conf)) { jsonResponse(['status' => 'error', 'error' => '認証失敗'], 401); exit; } ensureQueueDirs(); // ─── ?action=check (GET) ───────────────────────────────────── if ($action === 'check') { $stuckSeconds = (int)($conf['child_stuck_seconds'] ?? 300); $now = time(); // cur/ の滞留チェック → defer/ に移動 foreach (listJsonFiles(QUEUE_DIR . '/cur') as $file) { $mtime = @filemtime($file); if ($mtime !== false && ($now - $mtime) > $stuckSeconds) { $basename = basename($file); @rename($file, QUEUE_DIR . '/defer/' . $basename); childLog("STUCK→DEFER: {$basename}"); } } // defer/ → new/ に移動(再送キュー化) foreach (listJsonFiles(QUEUE_DIR . '/defer') as $file) { $basename = basename($file); @rename($file, QUEUE_DIR . '/new/' . $basename); childLog("DEFER→NEW: {$basename}"); } // 未報告結果の収集 $pendingResults = []; $pendingFiles = listJsonFiles(QUEUE_DIR . '/result_pending'); foreach ($pendingFiles as $file) { $json = @file_get_contents($file); if ($json !== false) { $data = json_decode($json, true); if (is_array($data)) { $pendingResults[] = $data; } } // 返却後に削除 @unlink($file); } jsonResponse([ 'status' => 'ok', 'queue' => [ 'new' => countFiles(QUEUE_DIR . '/new'), 'cur' => countFiles(QUEUE_DIR . '/cur'), 'defer' => countFiles(QUEUE_DIR . '/defer'), ], 'php_version' => PHP_VERSION, 'exec_available' => function_exists('exec') && !in_array('exec', array_map('trim', explode(',', ini_get('disable_functions'))), true), 'pending_results' => $pendingResults, ]); exit; } // ─── ?action=send (POST) ───────────────────────────────────── if ($action === 'send') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['status' => 'error', 'error' => 'POST メソッドが必要です'], 405); exit; } $input = json_decode(file_get_contents('php://input'), true); if (!is_array($input)) { jsonResponse(['status' => 'error', 'error' => '不正なJSONボディ'], 400); exit; } $batchId = (string)($input['batch_id'] ?? ''); $masterResultUrl = (string)($input['master_result_url'] ?? ''); $childId = $input['child_id'] ?? $conf['child_id'] ?? null; $emails = $input['emails'] ?? []; if ($batchId === '' || !is_array($emails) || empty($emails)) { jsonResponse(['status' => 'error', 'error' => 'batch_id と emails が必要です'], 400); exit; } // helo_hostname / master_result_url をconfに保存 $heloHostname = (string)($input['helo_hostname'] ?? ''); $confChanged = false; if ($masterResultUrl !== '' && ($conf['master_result_url'] ?? '') !== $masterResultUrl) { $conf['master_result_url'] = $masterResultUrl; $confChanged = true; } if ($heloHostname !== '' && ($conf['helo_hostname'] ?? '') !== $heloHostname) { $conf['helo_hostname'] = $heloHostname; $confChanged = true; } if ($confChanged) { saveConf($conf); } // 各メールを queue/new/{outbound_id}.json に保存 $accepted = 0; foreach ($emails as $em) { $outboundId = (int)($em['outbound_id'] ?? 0); if ($outboundId <= 0) continue; $em['batch_id'] = $batchId; $em['master_result_url'] = $masterResultUrl; $em['child_id'] = $childId; $em['queued_at'] = date('Y-m-d H:i:s'); $filePath = QUEUE_DIR . '/new/' . $outboundId . '.json'; file_put_contents($filePath, json_encode($em, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX); $accepted++; } childLog("ACCEPTED: batch={$batchId} count={$accepted}"); // 受理レスポンスを先に返してからバックグラウンドで送信処理 $response = ['status' => 'ok', 'accepted' => $accepted, 'batch_id' => $batchId]; // exec利用可能ならバックグラウンド起動 $execAvailable = function_exists('exec') && !in_array('exec', array_map('trim', explode(',', ini_get('disable_functions'))), true); if ($execAvailable) { // 先にレスポンスを返す jsonResponse($response); // バックグラウンドで処理起動 // PHP_BINARYはphp-fpmのパスを返す場合があるため/usr/bin/phpを固定使用 $phpBin = '/usr/bin/php'; $script = escapeshellarg(__FILE__); exec("{$phpBin} {$script} --process > /dev/null 2>&1 &"); } else { // exec不可: HTTP接続切断後にインプロセス実行 ignore_user_abort(true); set_time_limit(0); $body = json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); header('Content-Type: application/json; charset=utf-8'); header('Connection: close'); header('Content-Length: ' . strlen($body)); echo $body; if (ob_get_level() > 0) { ob_end_flush(); } flush(); if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } // 送信処理 processQueue(); } exit; } // 不明なアクション jsonResponse(['status' => 'error', 'error' => '不明なアクション: ' . $action], 400);