메인 콘텐츠로 이동하기
  1. Server-Logs/

메일서버 관리 화면 코드 공개 (index.php)

·61 분

더 편하게 읽는 방법 (초보자 추천)

아래 코드는 3,100줄이 넘어서 이 페이지에서 쭉 훑어보기엔 불편할 수 있습니다. 구간마다 목차로 정리되고, 우측 목차에서 원하는 곳을 클릭하면 바로 그 자리로 이동하는 정리된 버전을 따로 만들어뒀습니다. 코드를 직접 뜯어보고 싶으시다면 이쪽을 먼저 열어보시길 추천합니다.

→ 구간별로 점프 가능한 정리 버전 보기 (mailadmin-ui-preview.html)

이 글은 앞서 공개한 메일서버 관리 API 코드(mailapi.py)와 짝을 이루는 웹 화면(프론트엔드) 코드, index.php를 공개하는 글입니다.

API 서버(mailapi.py)가 실제 작업(도메인 추가, 계정 삭제, 파일 저장 등)을 처리한다면, 이 화면은 그 API 서버에 “이렇게 해줘"라고 요청을 보내고 돌아온 결과를 사람이 보는 HTML로 그려주는 역할만 합니다. 프레임워크 없이 순수 PHP로 만들었고, 로그인 화면과 CSRF 방어, 탭마다 나뉜 기능(도메인·계정·별칭·백업·파일 탐색기·접속 IP 차단 등)이 한 파일 안에 들어 있습니다.

코딩을 잘 몰라도 흐름을 따라올 수 있도록 함수·구간마다 한글 주석을 달았습니다. 다만 실제 운영 서버의 로그인 정보와 직결되는 기본값(비밀번호·API 키)은 ***로 가려두었으니, 그대로 복붙해서 쓰기보다는 참고용으로 봐주세요.

이 글의 주석 방침: 로그인 처리(handle_ui_login)와 저장 요청의 CSRF 검사, 파일 탐색기의 허용(file_allow) 동작처럼 보안과 직결되는 부분은 줄마다 설명을 달았습니다. 나머지 함수·구간은 구간 앞에 요약 설명을 달아뒀습니다.

전체 코드는 아래와 같습니다.

<?php
declare(strict_types=1);

// =====================================================================
// 이 코드는 무엇인가? (초보자를 위한 개요)
// =====================================================================
// 자체 메일서버(postfix + dovecot + MariaDB)를 관리하는 화면(프론트엔드)입니다.
// 이 파일 혼자서는 아무 일도 못 하고, 뒷단(백엔드) API 서버(mailapi.py)에
// "도메인 추가해줘", "계정 목록 줘" 같은 요청을 보내고, 돌아온 결과를
// 사람이 보기 좋은 HTML 화면으로 그려주는 역할만 합니다.
//
// - 프레임워크(Laravel, Symfony 등) 없이 순수 PHP로만 만들었습니다.
// - 로그인 화면이 있고(세션 기반), 저장하는 동작(POST)마다 CSRF 토큰을 검사합니다.
// - 탭이 여러 개(대시보드, 도메인, 계정, 별칭, 백업, 파일 탐색기, 접속 IP 등)이고,
//   주소의 ?tab=값으로 어떤 탭인지 정합니다.
// - 화면 하나(이 파일)가 "저장 동작 처리 → 화면에 쓸 데이터 조회 → HTML 그리기"
//   순서로 위에서 아래로 실행됩니다.
//
// ※ 이 글은 학습/공유 목적으로 비밀번호·API 키의 기본값을 ***로 가려서 공개합니다.
//    실제 운영 시에는 이 코드를 그대로 복붙하지 말고, 본인 환경에 맞게 검토 후 사용하세요.
// =====================================================================

session_start();
ini_set('display_errors', '0');
error_reporting(E_ALL);

// --- 설정값 (환경변수로 오버라이드 가능) ---
// 뒷단 API 서버 주소와, 그 서버에 요청할 때 같이 보내는 인증 키.
$API_BASE = getenv('MAILADMIN_UI_API_BASE') ?: 'http://127.0.0.1:18080';
$API_KEY  = getenv('MAILADMIN_UI_API_KEY') ?: '***';   // 서버에서 직접 정하는 값 (공개용으로 가림)

// API 서버의 기능별 주소(경로) 모음. 기능을 추가할 때 여기 한 줄만 더하면 된다.
$ENDPOINTS = [
    'health'             => '/health',
    'domain_add'         => '/domain/add',
    'domain_delete'      => '/domain/delete',
    'domain_set_tempmail' => '/domain/set-tempmail',
    'domain_list'        => '/domain/list',
    'account_add'        => '/account/add',
    'account_delete'     => '/account/delete',
    'account_list'       => '/account/list',
    'account_password'   => '/account/password',
    'account_search'     => '/account/search',
    'account_set_active' => '/account/set-active',
    'account_test_login' => '/account/test-login',
    'account_quota'      => '/account/quota',
    'audit_log'          => '/audit/log',
    'dkim_list'          => '/dkim/list',
    'dkim_public'        => '/dkim/public',
    'dkim_generate'      => '/dkim/generate',
    'backup_list'        => '/backup/list',
    'backup_create'      => '/backup/create',
    'backup_restore'     => '/backup/restore',
    'alias_list'         => '/alias/list',
    'alias_add'          => '/alias/add',
    'alias_delete'       => '/alias/delete',
    'alias_set_active'   => '/alias/set-active',
    'system_status'      => '/system/status',
    'fail2ban_status'    => '/system/fail2ban',
    'cert_status'        => '/system/certs',
    'tempmail_stats'     => '/tempmail/stats',
    'security_checks'    => '/security/checks',
    'log_sources'        => '/logs/sources',
    'log_tail'           => '/logs/tail',
    'fail2ban_unban'     => '/system/fail2ban/unban',
    'file_roots'         => '/file/roots',
    'file_list'          => '/file/list',
    'file_read'          => '/file/read',
    'file_write'         => '/file/write',
    'file_perm'          => '/file/perm',
    'file_allowed'       => '/file/allowed',
    'file_allow'         => '/file/allow',
    'file_allow_remove'  => '/file/allow/remove',
    'connections'        => '/system/connections',
    'fail2ban_ban'       => '/system/fail2ban/ban',
];

$USE_UI_LOGIN = true;
// --- 이 화면 자체의 로그인 계정 (API 키와는 별개) ---
$UI_USERNAME = getenv('MAILADMIN_UI_USER') ?: 'admin';
$UI_PASSWORD = getenv('MAILADMIN_UI_PASS') ?: '***';   // 서버에서 직접 정하는 값 (공개용으로 가림)

// =====================================================================
// 공통 유틸리티 함수
// =====================================================================
// h() : 화면에 값을 찍을 때 항상 거치는 함수. <, >, &, " 같은 글자를 무해한
// 형태로 바꿔서, 사용자가 입력한 값에 HTML 코드를 몰래 넣어도(XSS) 그대로
// 글자로만 보이게 만든다.
function h(string $s): string
{
    return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function highlight_log_line(string $line): string
{
    $escaped = h($line);

    $patterns = [
        '/\b(ban(?:ned)?|block(?:ed)?|attack|intrusion|unauthorized|suspicious|deny|denied|refused)\b/i' => 'log-hl-security',
        '/\b(warn(?:ing)?)\b/i' => 'log-hl-warn',
        '/\b(error|err|fail(?:ed)?|critical|fatal|exception|panic)\b/i' => 'log-hl-error',
    ];

    foreach ($patterns as $pattern => $class) {
        $escaped = preg_replace_callback(
            $pattern,
            function ($m) use ($class) {
                return '<span class="' . $class . '">' . $m[0] . '</span>';
            },
            $escaped
        );
    }

    return $escaped;
}

function mode_from_post(array $p): string
{
    $digit = function (string $who) use ($p): int {
        return (isset($p["pm_{$who}r"]) ? 4 : 0) + (isset($p["pm_{$who}w"]) ? 2 : 0) + (isset($p["pm_{$who}x"]) ? 1 : 0);
    };
    return $digit('u') . $digit('g') . $digit('o');
}

function file_name_style(string $name): string
{
    // 압축파일은 빨간색, .bak 백업 파일은 밝은 연두색, 그 밖에는 기본색
    $low = strtolower($name);
    if (preg_match('/\.(zip|tar|gz|tgz|bz2|tbz2|xz|txz|zst|7z|rar|z|lz4)$/', $low)) {
        return ' style="color:#ef4444;"';
    }
    if (strpos($low, '.bak') !== false) {
        return ' style="color:#a3e635;"';
    }
    return '';
}

function is_post(): bool
{
    return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
}

// csrf_token() / verify_csrf() : 다른 사이트가 내 브라우저를 시켜 몰래 요청을
// 보내는 것(CSRF 공격)을 막는다. 세션마다 무작위 토큰을 하나 만들어 화면에
// 숨겨서 넣어두고, 저장(POST) 요청이 오면 그 토큰이 같이 왔는지 확인한다.
function csrf_token(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function verify_csrf(): bool
{
    $token = $_POST['csrf_token'] ?? '';
    return is_string($token) && hash_equals($_SESSION['csrf_token'] ?? '', $token);
}

function set_flash(string $type, string $message): void
{
    $_SESSION['flash'] = [
        'type' => $type,
        'message' => $message,
    ];
}

function get_flash(): ?array
{
    if (!isset($_SESSION['flash'])) {
        return null;
    }
    $flash = $_SESSION['flash'];
    unset($_SESSION['flash']);
    return $flash;
}

function normalize_domain(string $domain): string
{
    return strtolower(trim($domain));
}

function normalize_email(string $email): string
{
    return strtolower(trim($email));
}

function valid_domain(string $domain): bool
{
    return (bool) preg_match(
        '/^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i',
        $domain
    );
}

function valid_email_addr(string $email): bool
{
    return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}

function normalize_active_input(mixed $value): int
{
    if ($value === '1' || $value === 1 || $value === true || $value === 'true') {
        return 1;
    }
    return 0;
}

function pretty_json(mixed $value): string
{
    return json_encode(
        $value,
        JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
    ) ?: '';
}

function bytes_to_human(int $n): string
{
    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
    $value = (float) $n;
    foreach ($units as $unit) {
        if ($value < 1024.0 || $unit === end($units)) {
            return sprintf('%.2f %s', $value, $unit);
        }
        $value /= 1024.0;
    }
    return $n . ' B';
}

// api_request() / api_get_json() / api_post_json() : 뒷단 API 서버에 실제로
// 요청을 보내는 함수. 모든 요청에 API 키를 헤더로 실어 보내고, 응답이
// JSON이 아니거나 API 서버가 응답을 안 하면 오류로 처리해서 화면이
// 깨지지 않게 한다.
function api_request(string $method, string $url, string $apiKey, ?array $payload = null): array
{
    $ch = curl_init();
    if ($ch === false) {
        return ['ok' => false, 'http_code' => 0, 'json' => null, 'raw' => '', 'error' => 'curl_init_failed'];
    }

    $headers = [
        'Accept: application/json',
        'X-API-Key: ' . $apiKey,
    ];

    $options = [
        CURLOPT_URL => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 3,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
    ];

    if ($payload !== null) {
        $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($json === false) {
            curl_close($ch);
            return ['ok' => false, 'http_code' => 0, 'json' => null, 'raw' => '', 'error' => 'json_encode_failed'];
        }

        $options[CURLOPT_POSTFIELDS] = $json;
        $options[CURLOPT_HTTPHEADER] = [
            'Content-Type: application/json; charset=utf-8',
            'Accept: application/json',
            'X-API-Key: ' . $apiKey,
        ];
    }

    curl_setopt_array($ch, $options);

    $raw = curl_exec($ch);
    $error = curl_error($ch);
    $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($raw === false) {
        return ['ok' => false, 'http_code' => $httpCode, 'json' => null, 'raw' => '', 'error' => $error ?: 'curl_exec_failed'];
    }

    $decoded = json_decode($raw, true);

    return [
        'ok' => ($httpCode >= 200 && $httpCode < 300),
        'http_code' => $httpCode,
        'json' => is_array($decoded) ? $decoded : null,
        'raw' => $raw,
        'error' => null,
    ];
}

function api_get_json(string $url, string $apiKey): array
{
    return api_request('GET', $url, $apiKey, null);
}

function api_post_json(string $url, string $apiKey, array $payload): array
{
    return api_request('POST', $url, $apiKey, $payload);
}

// =====================================================================
// 로그인 (이 화면 자체의 보호 장치)
// =====================================================================
function is_logged_in(bool $useLogin): bool
{
    if (!$useLogin) {
        return true;
    }
    return !empty($_SESSION['ui_logged_in']);
}

// handle_ui_login(): 로그인 화면에서 넘어온 아이디·비밀번호를 확인하는 함수 (전체 라인 주석)
function handle_ui_login(bool $useLogin, string $username, string $password): void
{
    if (!$useLogin || !is_post()) {
        return;   // 로그인을 안 쓰거나, 화면 보기(GET) 요청이면 할 일 없음
    }

    $action = (string)($_POST['action'] ?? '');   // 로그인 폼이 보낸 action 값 확인

    if ($action === 'login') {
        $u = (string)($_POST['ui_username'] ?? '');   // 입력한 아이디
        $p = (string)($_POST['ui_password'] ?? '');    // 입력한 비밀번호

        // hash_equals: 글자를 앞에서부터 하나씩 비교하다 다르면 바로 멈추는 일반 비교(==)와 달리,
        // 항상 같은 시간이 걸리게 비교한다. 그래야 "걸린 시간"만 보고 비밀번호를 한 글자씩
        // 알아내는 공격(타이밍 공격)을 막을 수 있다.
        if (hash_equals($username, $u) && hash_equals($password, $p)) {
            session_regenerate_id(true);      // 로그인 성공 시 세션 ID를 새로 발급 (세션 고정 공격 방지)
            $_SESSION['ui_logged_in'] = true; // 로그인 상태를 세션에 표시
            set_flash('success', '로그인 성공');
        } else {
            $clientIp = $_SERVER['REMOTE_ADDR'] ?? '-';   // 실패한 접속의 IP
            $logLine = sprintf(
                "[%s] MAILADMIN_UI_LOGIN_FAIL ip=%s user=%s\n",   // 시각·IP·시도한 아이디를 한 줄로
                date('Y-m-d H:i:s'),
                $clientIp,
                $u
            );
            @file_put_contents('/var/log/mailadmin-ui-auth.log', $logLine, FILE_APPEND | LOCK_EX);   // 실패 기록 (무차별 대입 탐지용)
            set_flash('error', '로그인 실패');
        }

        header('Location: ' . $_SERVER['PHP_SELF']);
        exit;
    }

    if ($action === 'logout') {
        unset($_SESSION['ui_logged_in']);
        session_regenerate_id(true);
        set_flash('success', '로그아웃 완료');
        header('Location: ' . $_SERVER['PHP_SELF']);
        exit;
    }
}

handle_ui_login($USE_UI_LOGIN, $UI_USERNAME, $UI_PASSWORD);

// =====================================================================
// 저장 동작(POST) 처리
// =====================================================================
// 로그인·로그아웃이 아닌 모든 POST 요청은 먼저 CSRF 토큰부터 검사한다.
// 여기를 통과해야만 아래 switch 문의 각 기능(도메인 추가, 계정 삭제,
// 파일 저장, 차단 등)으로 넘어간다.
if (is_post() && is_logged_in($USE_UI_LOGIN)) {
    $action = (string)($_POST['action'] ?? '');

    if (!in_array($action, ['login', 'logout'], true)) {
        if (!verify_csrf()) {
            set_flash('error', 'CSRF token 검증 실패');
            header('Location: ' . $_SERVER['PHP_SELF']);
            exit;
        }
    }

    // 이 PHP 파일은 입력값 형식만 가볍게 확인하고 API 서버로 그대로 넘긴다.
    // 진짜 보안 검사(경로가 시스템 영역인지, 비밀번호가 맞는지, IP가 지금
    // 접속 중인지 등)는 전부 API 서버(mailapi.py) 쪽에서 다시 한다 —
    // 화면 쪽 검사만 믿지 않는다는 원칙.
    try {
        switch ($action) {
            case 'domain_add':   // 도메인 추가
                $domain = normalize_domain((string)($_POST['domain'] ?? ''));
                if ($domain === '' || !valid_domain($domain)) {
                    throw new RuntimeException('유효한 도메인을 입력해야 합니다.');
                }
                $result = api_post_json($API_BASE . $ENDPOINTS['domain_add'], $API_KEY, ['domain' => $domain]);
                break;

            case 'domain_delete':
                $domain = normalize_domain((string)($_POST['domain'] ?? ''));
                if ($domain === '' || !valid_domain($domain)) {
                    throw new RuntimeException('유효한 도메인을 입력해야 합니다.');
                }
                $result = api_post_json($API_BASE . $ENDPOINTS['domain_delete'], $API_KEY, ['domain' => $domain]);
                break;

            case 'domain_set_tempmail':
                $domain = normalize_domain((string)($_POST['domain'] ?? ''));
                $enabled = normalize_active_input($_POST['enabled'] ?? '0');
                if ($domain === '' || !valid_domain($domain)) {
                    throw new RuntimeException('유효한 도메인을 입력해야 합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['domain_set_tempmail'],
                    $API_KEY,
                    ['domain' => $domain, 'enabled' => $enabled]
                );
                break;

            case 'account_add':
                $email = normalize_email((string)($_POST['email'] ?? ''));
                $password = (string)($_POST['password'] ?? '');
                $active = normalize_active_input($_POST['active'] ?? '1');

                if ($email === '' || !valid_email_addr($email)) {
                    throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
                }
                if ($password === '') {
                    throw new RuntimeException('비밀번호를 입력해야 합니다.');
                }

                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['account_add'],
                    $API_KEY,
                    ['email' => $email, 'password' => $password, 'active' => $active]
                );
                break;

            case 'account_delete':
                $email = normalize_email((string)($_POST['email'] ?? ''));
                if ($email === '' || !valid_email_addr($email)) {
                    throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
                }
                $result = api_post_json($API_BASE . $ENDPOINTS['account_delete'], $API_KEY, ['email' => $email]);
                break;

            case 'account_password':
                $email = normalize_email((string)($_POST['email'] ?? ''));
                $password = (string)($_POST['password'] ?? '');
                if ($email === '' || !valid_email_addr($email)) {
                    throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
                }
                if ($password === '') {
                    throw new RuntimeException('새 비밀번호를 입력해야 합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['account_password'],
                    $API_KEY,
                    ['email' => $email, 'password' => $password]
                );
                break;

            case 'account_set_active':
                $email = normalize_email((string)($_POST['email'] ?? ''));
                $active = normalize_active_input($_POST['active'] ?? '0');
                if ($email === '' || !valid_email_addr($email)) {
                    throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['account_set_active'],
                    $API_KEY,
                    ['email' => $email, 'active' => $active]
                );
                break;

            case 'account_test_login':
                $email = normalize_email((string)($_POST['email'] ?? ''));
                $password = (string)($_POST['password'] ?? '');
                if ($email === '' || !valid_email_addr($email)) {
                    throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
                }
                if ($password === '') {
                    throw new RuntimeException('테스트 비밀번호를 입력해야 합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['account_test_login'],
                    $API_KEY,
                    ['email' => $email, 'password' => $password]
                );
                break;

            case 'dkim_generate':
                $domain = normalize_domain((string)($_POST['domain'] ?? ''));
                $selector = trim((string)($_POST['selector'] ?? 'default'));
                $bits = (int)($_POST['bits'] ?? 2048);
                $force = normalize_active_input($_POST['force'] ?? '0');

                if ($domain === '' || !valid_domain($domain)) {
                    throw new RuntimeException('유효한 도메인을 입력해야 합니다.');
                }
                if ($selector === '') {
                    throw new RuntimeException('selector를 입력해야 합니다.');
                }

                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['dkim_generate'],
                    $API_KEY,
                    ['domain' => $domain, 'selector' => $selector, 'bits' => $bits, 'force' => $force]
                );
                break;

            case 'backup_create':
                $label = trim((string)($_POST['label'] ?? ''));
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['backup_create'],
                    $API_KEY,
                    ['label' => $label]
                );
                break;

            case 'backup_restore':
                $file = trim((string)($_POST['file'] ?? ''));
                if ($file === '') {
                    throw new RuntimeException('복구할 백업 파일을 입력해야 합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['backup_restore'],
                    $API_KEY,
                    ['file' => $file]
                );
                break;

            case 'alias_add':
                $source = normalize_email((string)($_POST['source'] ?? ''));
                $destination = normalize_email((string)($_POST['destination'] ?? ''));
                $active = normalize_active_input($_POST['active'] ?? '1');

                if ($source === '' || !valid_email_addr($source)) {
                    throw new RuntimeException('유효한 원본 이메일(source)을 입력해야 합니다.');
                }
                if ($destination === '' || !valid_email_addr($destination)) {
                    throw new RuntimeException('유효한 목적지 이메일(destination)을 입력해야 합니다.');
                }

                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['alias_add'],
                    $API_KEY,
                    ['source' => $source, 'destination' => $destination, 'active' => $active]
                );
                break;

            case 'alias_delete':
                $aliasId = (int)($_POST['id'] ?? 0);
                if ($aliasId <= 0) {
                    throw new RuntimeException('삭제할 별칭 id가 필요합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['alias_delete'],
                    $API_KEY,
                    ['id' => $aliasId]
                );
                break;

            case 'alias_set_active':
                $aliasId = (int)($_POST['id'] ?? 0);
                $active = normalize_active_input($_POST['active'] ?? '0');
                if ($aliasId <= 0) {
                    throw new RuntimeException('별칭 id가 필요합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['alias_set_active'],
                    $API_KEY,
                    ['id' => $aliasId, 'active' => $active]
                );
                break;

            case 'fail2ban_unban':   // 차단 해제 (IP 형식만 확인하고 API 로 전달)
                $unbanIp = trim((string)($_POST['unban_ip'] ?? ''));
                $unbanJail = trim((string)($_POST['unban_jail'] ?? ''));
                if ($unbanIp === '' || filter_var($unbanIp, FILTER_VALIDATE_IP) === false) {
                    throw new RuntimeException('유효한 IP 주소를 입력해야 합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['fail2ban_unban'],
                    $API_KEY,
                    ['ip' => $unbanIp, 'jail' => $unbanJail]
                );
                break;

            case 'file_write':   // 파일 저장 (허용된 폴더인지는 API 서버가 다시 검사)
                $fwPath = (string)($_POST['path'] ?? '');
                $fwContent = (string)($_POST['content'] ?? '');
                if ($fwPath === '') {
                    throw new RuntimeException('저장할 파일 경로가 필요합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['file_write'],
                    $API_KEY,
                    ['path' => $fwPath, 'content' => $fwContent]
                );
                break;

            case 'fail2ban_ban':   // 접속 중인 IP를 골라 jail 로 차단 보내기
                $banIp = trim((string)($_POST['ban_ip'] ?? ''));
                $banJail = trim((string)($_POST['ban_jail'] ?? ''));
                if ($banIp === '' || filter_var($banIp, FILTER_VALIDATE_IP) === false) {
                    throw new RuntimeException('표에서 IP를 먼저 눌러 고르세요.');
                }
                if ($banJail === '') {
                    throw new RuntimeException('보낼 jail을 고르세요.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['fail2ban_ban'],
                    $API_KEY,
                    ['ip' => $banIp, 'jail' => $banJail]
                );
                break;

            // file_allow : 폴더를 "허용 목록"에 추가하는, 파일 탐색기에서 가장 민감한
            // 동작이다(전체 라인 주석). 비밀번호가 맞는지, 그 경로가 시스템 영역인지는
            // 여기서 판단하지 않고 API 서버로 넘겨서 다시 검사받는다 — 화면(PHP)
            // 쪽 검사만으로는 절대 통과시키지 않는다는 원칙을 여기서도 지킨다.
            case 'file_allow':
                $faPath = (string)($_POST['path'] ?? '');           // 허용할 폴더 경로
                $faPassword = (string)($_POST['password'] ?? '');   // 허용용 비밀번호 (여기선 검증 안 함)
                if ($faPath === '') {
                    throw new RuntimeException('허용할 폴더 경로가 필요합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['file_allow'],   // 실제 비밀번호 검증·경로 검증은 이 API 호출 안에서 일어난다
                    $API_KEY,
                    ['path' => $faPath, 'password' => $faPassword]
                );
                break;

            case 'file_allow_remove':   // 허용 해제 (비밀번호 불필요 — 더 좁아지는 방향이라)
                $farPath = (string)($_POST['path'] ?? '');
                if ($farPath === '') {
                    throw new RuntimeException('해제할 폴더 경로가 필요합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['file_allow_remove'],
                    $API_KEY,
                    ['path' => $farPath]
                );
                break;

            case 'file_perm':   // 권한·소유 변경 (체크박스 → 숫자 변환은 mode_from_post 가 함)
                $fpPath = (string)($_POST['path'] ?? '');
                if ($fpPath === '') {
                    throw new RuntimeException('바꿀 파일 경로가 필요합니다.');
                }
                $result = api_post_json(
                    $API_BASE . $ENDPOINTS['file_perm'],
                    $API_KEY,
                    [
                        'path' => $fpPath,
                        'mode' => mode_from_post($_POST),
                        'owner' => trim((string)($_POST['owner'] ?? '')),
                        'group' => trim((string)($_POST['group'] ?? '')),
                    ]
                );
                break;

            default:
                $result = null;
                break;
        }

        if ($result !== null) {
            if ($result['ok']) {
                if ($action === 'fail2ban_unban' && is_array($result['json'])) {
                    $unbannedFrom = [];
                    foreach (($result['json']['results'] ?? []) as $r) {
                        if ((string)($r['detail'] ?? '') === '1') {
                            $unbannedFrom[] = (string)($r['jail'] ?? '');
                        }
                    }
                    $ipShown = (string)($result['json']['ip'] ?? $unbanIp);
                    if (!empty($unbannedFrom)) {
                        set_flash('success', "{$ipShown} 차단 해제 완료 (" . implode(', ', $unbannedFrom) . ')');
                    } else {
                        set_flash('success', "{$ipShown} — 차단되어 있던 jail이 없었습니다.");
                    }
                } elseif ($action === 'file_perm' && is_array($result['json'])) {
                    set_flash('success', '✅ ' . (string)($result['json']['message'] ?? '변경 완료'));
                } elseif (in_array($action, ['file_allow', 'file_allow_remove'], true) && is_array($result['json'])) {
                    set_flash('success', '✅ ' . (string)($result['json']['message'] ?? '완료'));
                } elseif ($action === 'fail2ban_ban' && is_array($result['json'])) {
                    set_flash('success', '✅ ' . (string)($result['json']['message'] ?? '차단 완료'));
                } else {
                    set_flash('success', pretty_json($result['json'] ?? ['status' => 'ok']));
                }
            } else {
                if (in_array($action, ['file_perm', 'fail2ban_ban', 'file_allow', 'file_allow_remove'], true) && is_array($result['json']) && isset($result['json']['message'])) {
                    set_flash('error', '⚠️ ' . (string)$result['json']['message']);
                } elseif ($result['error']) {
                    set_flash('error', "실패\n" . $result['error']);
                } elseif (is_array($result['json'])) {
                    set_flash('error', "실패\n" . pretty_json($result['json']));
                } else {
                    set_flash('error', "실패\nHTTP {$result['http_code']}\n{$result['raw']}");
                }
            }

            header('Location: ' . $_SERVER['REQUEST_URI']);
            exit;
        }
    } catch (Throwable $e) {
        set_flash('error', $e->getMessage());
        header('Location: ' . $_SERVER['REQUEST_URI']);
        exit;
    }
}

// =====================================================================
// 화면에 필요한 데이터 불러오기
// =====================================================================
// 여기서부터는 "그리기 전에 API 서버한테 물어봐야 할 것들"을 미리 다
// 조회해둔다. 탭마다 필요한 데이터가 달라서, 아래에서 지금 탭이 무엇인지
// 보고 그 탭에만 필요한 추가 조회를 한다(안 쓰는 탭의 데이터까지 매번
// 긁어오지 않기 위함).
$searchQ = trim((string)($_GET['q'] ?? ''));
$searchDomain = trim((string)($_GET['domain'] ?? ''));
$searchActive = trim((string)($_GET['active'] ?? ''));
$quotaDomain = trim((string)($_GET['quota_domain'] ?? ''));
$dkimViewDomain = trim((string)($_GET['dkim_domain'] ?? ''));
$dkimViewSelector = trim((string)($_GET['dkim_selector'] ?? 'default'));
$fmPath = trim((string)($_GET['fpath'] ?? '/'));
$fmFile = trim((string)($_GET['ffile'] ?? ''));
$fmPerm = trim((string)($_GET['fperm'] ?? ''));
$logKey = trim((string)($_GET['logkey'] ?? ''));
$logLinesParam = trim((string)($_GET['loglines'] ?? '100'));
$secLinesParam = trim((string)($_GET['seclines'] ?? '50'));
$activeTab = trim((string)($_GET['tab'] ?? 'dashboard'));

$healthData = null;
$domainsData = null;
$accountsData = null;
$allAccountsData = null;
$quotaData = null;
$allQuotaData = null;
$auditData = null;
$dkimListData = null;
$dkimPublicData = null;
$backupData = null;
$aliasesData = null;
$systemStatusData = null;
$fail2banData = null;
$certData = null;
$tempmailStatsData = null;
$logSourcesData = null;
$securityChecksData = null;
$logTailData = null;
$logTailErr = null;
$fmRootsData = null;
$fmListData = null;
$fmReadData = null;
$fmPermData = null;
$fmPermErr = null;
$fmAllowedData = null;
$fmAllow = trim((string)($_GET['fallow'] ?? ''));
$connData = null;
$connErr = null;

$healthErr = null;
$domainsErr = null;
$accountsErr = null;
$quotaErr = null;
$auditErr = null;
$dkimListErr = null;
$dkimPublicErr = null;
$backupErr = null;
$aliasesErr = null;
$systemStatusErr = null;
$fmListErr = null;
$fmReadErr = null;

if (is_logged_in($USE_UI_LOGIN)) {
    $healthRes = api_get_json($API_BASE . $ENDPOINTS['health'], $API_KEY);
    $healthData = $healthRes['ok'] ? $healthRes['json'] : null;
    $healthErr = $healthRes['ok'] ? null : $healthRes;

    $domainsRes = api_get_json($API_BASE . $ENDPOINTS['domain_list'], $API_KEY);
    $domainsData = $domainsRes['ok'] ? $domainsRes['json'] : null;
    $domainsErr = $domainsRes['ok'] ? null : $domainsRes;

    $allAccountsRes = api_get_json($API_BASE . $ENDPOINTS['account_list'], $API_KEY);
    $allAccountsData = $allAccountsRes['ok'] ? $allAccountsRes['json'] : null;

    $accountUrl = $API_BASE . $ENDPOINTS['account_list'];
    $query = [];
    if ($searchQ !== '') {
        $query[] = 'q=' . urlencode($searchQ);
    }
    if ($searchDomain !== '') {
        $query[] = 'domain=' . urlencode($searchDomain);
    }
    if ($searchActive === '0' || $searchActive === '1') {
        $query[] = 'active=' . urlencode($searchActive);
    }
    if (!empty($query)) {
        $accountUrl = $API_BASE . $ENDPOINTS['account_search'] . '?' . implode('&', $query);
    }

    $accountsRes = api_get_json($accountUrl, $API_KEY);
    $accountsData = $accountsRes['ok'] ? $accountsRes['json'] : null;
    $accountsErr = $accountsRes['ok'] ? null : $accountsRes;

    $allQuotaRes = api_get_json($API_BASE . $ENDPOINTS['account_quota'], $API_KEY);
    $allQuotaData = $allQuotaRes['ok'] ? $allQuotaRes['json'] : null;

    $quotaUrl = $API_BASE . $ENDPOINTS['account_quota'];
    if ($quotaDomain !== '') {
        $quotaUrl .= '?domain=' . urlencode($quotaDomain);
    }
    $quotaRes = api_get_json($quotaUrl, $API_KEY);
    $quotaData = $quotaRes['ok'] ? $quotaRes['json'] : null;
    $quotaErr = $quotaRes['ok'] ? null : $quotaRes;

    $auditRes = api_get_json($API_BASE . $ENDPOINTS['audit_log'] . '?lines=50', $API_KEY);
    $auditData = $auditRes['ok'] ? $auditRes['json'] : null;
    $auditErr = $auditRes['ok'] ? null : $auditRes;

    $dkimListRes = api_get_json($API_BASE . $ENDPOINTS['dkim_list'], $API_KEY);
    $dkimListData = $dkimListRes['ok'] ? $dkimListRes['json'] : null;
    $dkimListErr = $dkimListRes['ok'] ? null : $dkimListRes;

    if ($dkimViewDomain !== '') {
        $dkimPublicRes = api_get_json(
            $API_BASE . $ENDPOINTS['dkim_public'] . '?domain=' . urlencode($dkimViewDomain) . '&selector=' . urlencode($dkimViewSelector),
            $API_KEY
        );
        $dkimPublicData = $dkimPublicRes['ok'] ? $dkimPublicRes['json'] : null;
        $dkimPublicErr = $dkimPublicRes['ok'] ? null : $dkimPublicRes;
    }

    $backupRes = api_get_json($API_BASE . $ENDPOINTS['backup_list'], $API_KEY);
    $backupData = $backupRes['ok'] ? $backupRes['json'] : null;
    $backupErr = $backupRes['ok'] ? null : $backupRes;

    $aliasesRes = api_get_json($API_BASE . $ENDPOINTS['alias_list'], $API_KEY);
    $aliasesData = $aliasesRes['ok'] ? $aliasesRes['json'] : null;
    $aliasesErr = $aliasesRes['ok'] ? null : $aliasesRes;

    $systemStatusRes = api_get_json($API_BASE . $ENDPOINTS['system_status'], $API_KEY);
    $systemStatusData = $systemStatusRes['ok'] ? $systemStatusRes['json'] : null;
    $systemStatusErr = $systemStatusRes['ok'] ? null : $systemStatusRes;

    $fail2banRes = api_get_json($API_BASE . $ENDPOINTS['fail2ban_status'], $API_KEY);
    $fail2banData = $fail2banRes['ok'] ? $fail2banRes['json'] : null;

    $certRes = api_get_json($API_BASE . $ENDPOINTS['cert_status'], $API_KEY);
    $certData = $certRes['ok'] ? $certRes['json'] : null;

    $tempmailStatsRes = api_get_json($API_BASE . $ENDPOINTS['tempmail_stats'], $API_KEY);
    $tempmailStatsData = $tempmailStatsRes['ok'] ? $tempmailStatsRes['json'] : null;

    // 아래 4개 탭은 다른 탭에는 없는 자기만의 데이터가 더 필요해서 따로 조회한다.
    if ($activeTab === 'svclogs') {
        $logSourcesRes = api_get_json($API_BASE . $ENDPOINTS['log_sources'], $API_KEY);
        $logSourcesData = $logSourcesRes['ok'] ? $logSourcesRes['json'] : null;

        if ($logKey !== '') {
            $logLinesQ = ctype_digit($logLinesParam) ? $logLinesParam : '100';
            $logTailRes = api_get_json(
                $API_BASE . $ENDPOINTS['log_tail'] . '?key=' . urlencode($logKey) . '&lines=' . urlencode($logLinesQ),
                $API_KEY
            );
            $logTailData = $logTailRes['ok'] ? $logTailRes['json'] : null;
            $logTailErr = $logTailRes['ok'] ? null : $logTailRes;
        }
    }

    if ($activeTab === 'seccheck') {
        $secLinesQ = ctype_digit($secLinesParam) ? $secLinesParam : '50';
        $securityChecksRes = api_get_json(
            $API_BASE . $ENDPOINTS['security_checks'] . '?lines=' . urlencode($secLinesQ),
            $API_KEY
        );
        $securityChecksData = $securityChecksRes['ok'] ? $securityChecksRes['json'] : null;
    }

    if ($activeTab === 'connips') {
        $connRes = api_get_json($API_BASE . $ENDPOINTS['connections'], $API_KEY);
        $connData = $connRes['ok'] ? $connRes['json'] : null;
        $connErr = $connRes['ok'] ? null : $connRes;
    }

    if ($activeTab === 'files') {
        $fmAllowedRes = api_get_json($API_BASE . $ENDPOINTS['file_allowed'], $API_KEY);
        $fmAllowedData = $fmAllowedRes['ok'] ? $fmAllowedRes['json'] : null;
        $fmRootsRes = api_get_json($API_BASE . $ENDPOINTS['file_roots'], $API_KEY);
        $fmRootsData = $fmRootsRes['ok'] ? $fmRootsRes['json'] : null;

        if ($fmPerm !== '') {
            $fmPermRes = api_get_json(
                $API_BASE . $ENDPOINTS['file_perm'] . '?path=' . urlencode($fmPerm),
                $API_KEY
            );
            $fmPermData = $fmPermRes['ok'] ? $fmPermRes['json'] : null;
            $fmPermErr = $fmPermRes['ok'] ? null : $fmPermRes;
        } elseif ($fmFile !== '') {
            $fmReadRes = api_get_json(
                $API_BASE . $ENDPOINTS['file_read'] . '?path=' . urlencode($fmFile),
                $API_KEY
            );
            $fmReadData = $fmReadRes['ok'] ? $fmReadRes['json'] : null;
            $fmReadErr = $fmReadRes['ok'] ? null : $fmReadRes;
        } else {
            $fmListRes = api_get_json(
                $API_BASE . $ENDPOINTS['file_list'] . '?path=' . urlencode($fmPath),
                $API_KEY
            );
            $fmListData = $fmListRes['ok'] ? $fmListRes['json'] : null;
            $fmListErr = $fmListRes['ok'] ? null : $fmListRes;
        }
    }
}

// 위에서 받아온 원본 데이터를 화면(특히 대시보드 카드)에 바로 쓰기 좋은
// 숫자·요약값으로 미리 계산해둔다.
$statDomainCount = is_array($domainsData['domains'] ?? null) ? count($domainsData['domains']) : null;

$statAccountTotal = null;
$statAccountActive = null;
$statAccountInactive = null;
if (is_array($allAccountsData['accounts'] ?? null)) {
    $statAccountTotal = count($allAccountsData['accounts']);
    $statAccountActive = 0;
    foreach ($allAccountsData['accounts'] as $row) {
        if ((int)($row['active'] ?? 0) === 1) {
            $statAccountActive++;
        }
    }
    $statAccountInactive = $statAccountTotal - $statAccountActive;
}

$statQuotaTotalBytes = null;
$statQuotaTop = [];
if (is_array($allQuotaData['accounts'] ?? null)) {
    $statQuotaTotalBytes = 0;
    $rows = $allQuotaData['accounts'];
    foreach ($rows as $row) {
        $statQuotaTotalBytes += (int)($row['size_bytes'] ?? 0);
    }
    usort($rows, function ($a, $b) {
        return (int)($b['size_bytes'] ?? 0) <=> (int)($a['size_bytes'] ?? 0);
    });
    $statQuotaTop = array_slice($rows, 0, 10);
}

$statDkimSelectorTotal = null;
if (is_array($dkimListData['domains'] ?? null)) {
    $statDkimSelectorTotal = 0;
    foreach ($dkimListData['domains'] as $row) {
        $statDkimSelectorTotal += (int)($row['selector_count'] ?? 0);
    }
}

$statBackupCount = is_array($backupData['backups'] ?? null) ? count($backupData['backups']) : null;

$statAliasCount = is_array($aliasesData['aliases'] ?? null) ? count($aliasesData['aliases']) : null;

$statRecentDomains = [];
if (is_array($domainsData['domains'] ?? null)) {
    $tmp = $domainsData['domains'];
    usort($tmp, function ($a, $b) { return (int)($b['id'] ?? 0) <=> (int)($a['id'] ?? 0); });
    $statRecentDomains = array_slice($tmp, 0, 5);
}

$statAliasCountByDomain = [];
if (is_array($aliasesData['aliases'] ?? null)) {
    foreach ($aliasesData['aliases'] as $row) {
        $d = (string)($row['domain'] ?? '');
        if ($d === '') {
            continue;
        }
        $statAliasCountByDomain[$d] = ($statAliasCountByDomain[$d] ?? 0) + 1;
    }
}

$statRecentAuditLines = [];
$statRecentErrors = [];
$statErrorCount = 0;
$oneHourAgo = time() - 3600;
if (is_array($auditData['lines'] ?? null)) {
    $tail = array_slice($auditData['lines'], -6);
    $tail = array_reverse($tail);
    foreach ($tail as $line) {
        $decoded = json_decode($line, true);
        if (is_array($decoded)) {
            $statRecentAuditLines[] = $decoded;
        }
    }

    foreach (array_reverse($auditData['lines']) as $line) {
        $decoded = json_decode($line, true);
        if (!is_array($decoded)) {
            continue;
        }
        $isError = (($decoded['result'] ?? '') === 'error' || (int)($decoded['status_code'] ?? 200) >= 400);
        $ts = strtotime((string)($decoded['ts'] ?? ''));
        $withinHour = ($ts !== false && $ts >= $oneHourAgo);

        if ($isError && $withinHour) {
            $statErrorCount++;
            if (count($statRecentErrors) < 5) {
                $statRecentErrors[] = $decoded;
            }
        }
    }
}

$statBanCount = 0;
$statBannedJails = [];
if (is_array($fail2banData['jails'] ?? null)) {
    foreach ($fail2banData['jails'] as $jailRow) {
        $c = (int)($jailRow['currently_banned'] ?? 0);
        if ($c > 0) {
            $statBanCount += $c;
            $statBannedJails[] = $jailRow;
        }
    }
}

$BACKUP_STALE_DAYS = 7;
$statBackupStale = false;
$statBackupLastAgeDays = null;
if (is_array($backupData['backups'] ?? null)) {
    // 경고 계산에는 mailadmin-backup-*.tar.gz 백업 파일만 센다(폴더에 섞인 다른 파일은 무시)
    $statBackupFiles = array_values(array_filter($backupData['backups'], function ($bRow) {
        return (bool) preg_match('/^mailadmin-backup-.*\.tar\.gz$/', (string)($bRow['file'] ?? ''));
    }));
    if (empty($statBackupFiles)) {
        $statBackupStale = true;
    } else {
        $latestTs = 0;
        foreach ($statBackupFiles as $bRow) {
            $t = strtotime((string)($bRow['mtime'] ?? ''));
            if ($t !== false && $t > $latestTs) {
                $latestTs = $t;
            }
        }
        if ($latestTs > 0) {
            $statBackupLastAgeDays = (int) floor((time() - $latestTs) / 86400);
            $statBackupStale = $statBackupLastAgeDays >= $BACKUP_STALE_DAYS;
        }
    }
}

$CERT_WARN_DAYS = 14;
$statCertWarnings = [];
if (is_array($certData['certs'] ?? null)) {
    foreach ($certData['certs'] as $certRow) {
        if (isset($certRow['days_remaining']) && (int)$certRow['days_remaining'] <= $CERT_WARN_DAYS) {
            $statCertWarnings[] = $certRow;
        }
    }
}

$TEMPMAIL_ABUSE_WARN_THRESHOLD = 10;
$statTempmailAbuseCount1h = is_array($tempmailStatsData) ? (int)($tempmailStatsData['abuse_count_1h'] ?? 0) : 0;
$statTempmailAbuseSpike = $statTempmailAbuseCount1h >= $TEMPMAIL_ABUSE_WARN_THRESHOLD;

$statNotifTotal = $statErrorCount + $statBanCount + ($statBackupStale ? 1 : 0) + count($statCertWarnings) + ($statTempmailAbuseSpike ? 1 : 0);

$flash = get_flash();
$csrf = csrf_token();
$loggedIn = is_logged_in($USE_UI_LOGIN);

$TABS = [
    'dashboard' => ['label' => '대시보드', 'icon' => 'grid'],
    'domains'   => ['label' => '도메인', 'icon' => 'globe'],
    'accounts'  => ['label' => '계정', 'icon' => 'user'],
    'aliases'   => ['label' => '별칭 (Alias)', 'icon' => 'at-sign'],
    'inactive'  => ['label' => '비활성 계정', 'icon' => 'user-x'],
    'security'  => ['label' => '비밀번호 / 로그인 테스트', 'icon' => 'lock'],
    'quota'     => ['label' => 'Maildir 용량', 'icon' => 'inbox'],
    'dkim'      => ['label' => 'DKIM 관리', 'icon' => 'shield'],
    'backup'    => ['label' => '백업 / 복구', 'icon' => 'archive'],
    'logs'      => ['label' => '로그', 'icon' => 'list'],
    'files'     => ['label' => '파일 탐색기', 'icon' => 'folder'],
    'svclogs'   => ['label' => '서버 로그', 'icon' => 'file-text'],
    'connips'   => ['label' => '접속 IP', 'icon' => 'globe'],
    'seccheck'  => ['label' => '보안 점검', 'icon' => 'shield-alert'],
];
if (!array_key_exists($activeTab, $TABS)) {
    $activeTab = 'dashboard';
}
?>
<!-- =====================================================================
     화면 뼈대 (HTML/CSS)
     여기서부터는 PHP 계산은 거의 끝나고, 모아둔 데이터를 HTML로 그리는
     부분이다. <style> 안의 CSS는 어두운/밝은 테마, 탭, 표, 카드, 알림창
     같은 공통 부품의 모양을 정한다.
===================================================================== -->
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mail Hosting Admin</title>
<style>
    :root {
        --bg: #f1f4f9;
        --panel: #ffffff;
        --ink: #101828;
        --muted: #667085;
        --line: #e4e8ef;
        --accent: #4f46e5;
        --accent-ink: #ffffff;
        --ok: #059669;
        --ok-bg: #ecfdf5;
        --ok-line: #a7f3d0;
        --bad: #dc2626;
        --bad-bg: #fef2f2;
        --bad-line: #fecaca;
        --radius: 14px;
        --input-bg: #ffffff;
        --input-border: #cfd7e3;
        --code-bg: #eef2f7;
        --th-bg: #f9fafb;
        --pill-bg: #f2f4f8;
        --sidebar: #ffffff;
        --sidebar-ink: #344054;
        --sidebar-active: #eef2ff;
        --sidebar-hover: #f2f4f8;
        --sidebar-border: #e4e8ef;
        --brand-ink: #101828;
        --login-bg: #0b1220;
    }
    body.dark {
        --sidebar: #0b1220;
        --sidebar-ink: #cbd5e1;
        --sidebar-active: #1d2942;
        --sidebar-hover: #141d33;
        --sidebar-border: transparent;
        --brand-ink: #ffffff;
        --bg: #0b1220;
        --panel: #121a2b;
        --ink: #e5e9f0;
        --muted: #8a97ab;
        --line: #263042;
        --accent: #6366f1;
        --ok-bg: #052e21;
        --ok-line: #0f4a37;
        --bad-bg: #3a1414;
        --bad-line: #5c1f1f;
        --input-bg: #0f1626;
        --input-border: #2c384d;
        --code-bg: #0f1626;
        --th-bg: #0f1626;
        --pill-bg: #0f1626;
    }
    body.dark input[type="text"],
    body.dark input[type="email"],
    body.dark input[type="password"],
    body.dark input[type="number"],
    body.dark select,
    body.dark textarea {
        background: var(--input-bg);
        border-color: var(--input-border);
        color: var(--ink);
    }
    body.dark th { background: var(--th-bg); color: #aab4c4; }
    body.dark .mono, body.dark pre { background: var(--code-bg); }
    body.dark .quick-btn { background: var(--code-bg); color: var(--ink); }
    body.dark .conn-pill, body.dark .icon-btn, body.dark .user-pill, body.dark .user-dropdown { background: var(--pill-bg); border-color: var(--line); color: var(--ink); }
    body.dark .icon-btn:hover { background: var(--code-bg); }
    body.dark .btn-gray { background: var(--code-bg); color: var(--ink); }
    body.dark .bar-track { background: var(--code-bg); }
    * { box-sizing: border-box; }
    html, body {
        margin: 0;
        height: 100%;
        background: var(--bg);
        color: var(--ink);
        font-family: -apple-system, "Segoe UI", "Noto Sans KR", Arial, sans-serif;
        font-size: 14px;
    }
    a { color: inherit; }

    .login-shell {
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
        background:
            radial-gradient(circle at 20% 20%, rgba(79,70,229,0.18), transparent 45%),
            var(--login-bg);
    }
    .login-card {
        width: 380px;
        background: var(--panel);
        border-radius: var(--radius);
        padding: 32px;
        box-shadow: 0 20px 60px rgba(0,0,0,0.35);
    }
    .login-card h1 {
        font-size: 20px;
        margin: 0 0 4px;
    }
    .login-card .desc { color: var(--muted); margin-bottom: 20px; }

    .app {
        display: flex;
        min-height: 100vh;
        background: var(--bg);
    }
    .sidebar {
        width: 232px;
        flex: 0 0 232px;
        background: var(--sidebar);
        color: var(--sidebar-ink);
        border-right: 1px solid var(--sidebar-border);
        padding: 20px 14px;
        position: sticky;
        top: 0;
        height: 100vh;
        overflow-y: auto;
    }
    .brand {
        display: flex;
        align-items: center;
        gap: 10px;
        padding: 4px 8px 20px;
        color: var(--brand-ink);
        font-weight: 700;
        font-size: 16px;
    }
    .brand .logo {
        width: 30px;
        height: 30px;
        border-radius: 9px;
        background: var(--accent);
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 15px;
    }
    .nav-group { margin-bottom: 16px; }
    .nav-label {
        font-size: 11px;
        text-transform: uppercase;
        letter-spacing: .06em;
        color: var(--muted);
        padding: 6px 10px;
    }
    .nav-item {
        display: flex;
        align-items: center;
        gap: 10px;
        padding: 9px 10px;
        border-radius: 9px;
        text-decoration: none;
        color: var(--sidebar-ink);
        font-size: 13.5px;
        margin-bottom: 2px;
        cursor: pointer;
        border: 0;
        background: transparent;
        width: 100%;
        text-align: left;
    }
    .nav-item:hover { background: var(--sidebar-hover); color: var(--brand-ink); }
    .nav-item.active { background: var(--sidebar-active); color: var(--brand-ink); font-weight: 600; }
    .nav-item .dot {
        width: 6px; height: 6px; border-radius: 50%; background: #475569; flex: 0 0 auto;
    }
    .nav-item.active .dot { background: var(--accent); }

    .main {
        flex: 1;
        min-width: 0;
        padding: 22px 26px 60px;
    }
    .topbar {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 18px;
        gap: 12px;
        flex-wrap: wrap;
    }
    .topbar .page-title { font-size: 22px; font-weight: 700; }
    .topbar .sub { color: var(--muted); font-size: 12.5px; margin-top: 3px; }
    .topbar form button { }

    button, .btn {
        border: 0;
        border-radius: 10px;
        padding: 10px 14px;
        font-size: 13.5px;
        font-weight: 700;
        cursor: pointer;
        display: inline-block;
        text-decoration: none;
    }
    .btn-blue { background: var(--accent); color: #fff; }
    .btn-red { background: var(--bad); color: #fff; }
    .btn-dark { background: #111827; color: #fff; }
    .btn-gray { background: #eef1f6; color: #344054; }

    .alert {
        border-radius: 12px;
        padding: 14px 16px;
        margin-bottom: 16px;
        white-space: pre-wrap;
        word-break: break-word;
        font-size: 13.5px;
    }
    .success { background: var(--ok-bg); border: 1px solid var(--ok-line); color: #065f46; }
    .error { background: var(--bad-bg); border: 1px solid var(--bad-line); color: #991b1b; }

    .mono {
        font-family: Consolas, Monaco, monospace;
        font-size: 12.5px;
        background: #eef2f7;
        border-radius: 6px;
        padding: 2px 6px;
    }

    .tab-panel { display: none; }
    .tab-panel.active { display: block; }

    .card {
        background: var(--panel);
        border-radius: var(--radius);
        padding: 18px;
        box-shadow: 0 1px 2px rgba(16,24,40,0.04), 0 1px 0 rgba(16,24,40,0.04);
        border: 1px solid var(--line);
    }
    .card h2 { margin: 0 0 4px; font-size: 16px; }
    .card .desc { font-size: 12.5px; color: var(--muted); margin-bottom: 14px; }
    .section { margin-top: 18px; }

    .grid { display: grid; gap: 16px; }
    .grid-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
    .grid-3 { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }

    .stat-row { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-bottom: 18px; }
    .stat-card {
        background: var(--panel);
        border: 1px solid var(--line);
        border-radius: var(--radius);
        padding: 16px 18px;
    }
    .stat-card .icon-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
    .icon-badge {
        width: 40px; height: 40px; border-radius: 12px;
        display: flex; align-items: center; justify-content: center;
        flex: 0 0 auto;
    }
    .icon-badge svg { width: 20px; height: 20px; }
    .badge-blue   { background: #e0e7ff; color: #4f46e5; }
    .badge-purple { background: #ede9fe; color: #7c3aed; }
    .badge-sky    { background: #e0f2fe; color: #0284c7; }
    .badge-amber  { background: #fef3c7; color: #d97706; }
    .badge-green  { background: #dcfce7; color: #16a34a; }

    .topbar-left { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
    .conn-pill {
        display: inline-flex; align-items: center; gap: 7px;
        background: #f2f4f8; border: 1px solid var(--line);
        border-radius: 999px; padding: 6px 12px; font-size: 12.5px; color: #344054;
    }
    .conn-pill .dot2 { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
    .icon-btn {
        width: 36px; height: 36px; border-radius: 10px; padding: 0;
        display: inline-flex; align-items: center; justify-content: center;
        background: #fff; border: 1px solid var(--line); color: #475467;
        cursor: pointer;
    }
    .icon-btn:hover { background: #f2f4f8; }
    .icon-btn svg { width: 34px; height: 34px; flex-shrink: 0; }
    .icon-btn, .user-pill, .quick-btn, button { outline: none; }
    .icon-btn:focus-visible, .user-pill:focus-visible, .quick-btn:focus-visible, button:focus-visible {
        box-shadow: 0 0 0 2px var(--accent);
    }
    .topbar-right { display: flex; align-items: center; gap: 10px; }
    .user-menu { position: relative; }
    .user-pill {
        display: inline-flex; align-items: center; gap: 8px;
        background: #fff; border: 1px solid var(--line); border-radius: 10px;
        padding: 7px 12px; font-size: 13.5px; font-weight: 600; cursor: pointer;
    }
    .user-pill .avatar {
        width: 22px; height: 22px; border-radius: 50%; background: var(--accent); color: #fff;
        display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800;
    }
    .user-dropdown {
        display: none;
        position: absolute; right: 0; top: calc(100% + 6px);
        background: #fff; border: 1px solid var(--line); border-radius: 12px;
        box-shadow: 0 12px 32px rgba(16,24,40,0.14);
        min-width: 160px; padding: 6px; z-index: 20;
    }
    .user-dropdown.open { display: block; }
    .user-dropdown form button {
        width: 100%; text-align: left; background: transparent; color: #b42318;
        font-weight: 600; padding: 8px 10px; border-radius: 8px;
    }
    .user-dropdown form button:hover { background: #fef2f2; }

    .quick-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
    .quick-btn {
        display: flex; flex-direction: column; align-items: center; gap: 7px;
        background: #f9fafb; border: 1px solid var(--line); border-radius: 12px;
        padding: 14px 8px; cursor: pointer; font-size: 12px; font-weight: 700; color: #344054;
    }
    .quick-btn:hover { background: #f2f4f8; }
    .quick-btn .icon-badge { width: 34px; height: 34px; margin-bottom: 0; }
    .quick-btn .icon-badge svg { width: 16px; height: 16px; }

    /* 서버 로그 카드 전용 - 한 줄에 10개, 기존 quick-btn보다 작게 */
    .svclog-grid { grid-template-columns: repeat(10, 1fr); gap: 8px; }
    .svclog-grid .quick-btn { padding: 8px 4px; font-size: 10.5px; gap: 4px; }
    .svclog-grid .quick-btn .icon-badge { width: 24px; height: 24px; border-radius: 8px; }
    .svclog-grid .quick-btn .icon-badge svg { width: 12px; height: 12px; }
    .svclog-grid .quick-btn .small { font-size: 9.5px; }
    @media (max-width: 1400px) {
        .svclog-grid { grid-template-columns: repeat(5, 1fr); }
    }
    @media (max-width: 700px) {
        .svclog-grid { grid-template-columns: repeat(2, 1fr); }
    }

    .log-mini-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 12.5px; }
    .log-mini-row:last-child { border-bottom: 0; }
    .log-mini-row .method { font-weight: 800; color: var(--accent); width: 42px; flex: 0 0 auto; }
    .log-mini-row .path { flex: 1; font-family: Consolas, Monaco, monospace; color: #344054; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .log-mini-row .ts { color: var(--muted); flex: 0 0 auto; font-size: 11.5px; }

    .status-dot { display: inline-flex; align-items: center; gap: 6px; }
    .status-dot .d { width: 7px; height: 7px; border-radius: 50%; }
    .status-dot .d.on { background: var(--ok); }
    .status-dot .d.off { background: var(--bad); }

    .log-box { overflow: auto; background: var(--code-bg); padding: 12px; border-radius: 10px; }
    .log-box-sm { max-height: 220px; }
    .log-box-lg { max-height: 520px; }

    .log-hl-error    { background: #dc2626; color: #fff; padding: 1px 3px; border-radius: 3px; font-weight: 700; }
    .log-hl-warn     { background: #d97706; color: #fff; padding: 1px 3px; border-radius: 3px; font-weight: 700; }
    .log-hl-security { background: #7c3aed; color: #fff; padding: 1px 3px; border-radius: 3px; font-weight: 700; }

    @media (max-width: 700px) {
        .quick-grid { grid-template-columns: repeat(2, 1fr); }
    }

    .stat-card .label { color: var(--muted); font-size: 12.5px; margin-bottom: 8px; }
    .stat-card .value { font-size: 24px; font-weight: 800; }
    .stat-card .foot { margin-top: 6px; font-size: 12px; color: var(--muted); }
    .stat-card .foot b { color: var(--ok); }
    .stat-card .foot .bad { color: var(--bad); }

    label { display: block; font-size: 13px; font-weight: 700; margin-bottom: 6px; }
    input[type="text"], input[type="email"], input[type="password"], input[type="number"], select {
        width: 100%;
        padding: 10px 12px;
        border: 1px solid var(--input-border);
        border-radius: 10px;
        margin-bottom: 12px;
        font-size: 13.5px;
        outline: none;
        background: var(--input-bg);
        color: var(--ink);
    }
    input:focus, select:focus { border-color: var(--accent); }

    table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
    th, td { border-bottom: 1px solid var(--line); text-align: left; padding: 9px 8px; vertical-align: top; }
    th { background: var(--th-bg); font-weight: 700; font-size: 12.5px; color: #475467; text-transform: uppercase; letter-spacing: .02em; }
    .status-ok { color: var(--ok); font-weight: 700; }
    .status-bad { color: var(--bad); font-weight: 700; }
    .small { font-size: 12px; color: var(--muted); }
    pre { white-space: pre-wrap; word-break: break-word; margin: 0; }
    .inline-actions { display: flex; gap: 8px; flex-wrap: wrap; }

    .bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 12.5px; }
    .bar-row .name { width: 150px; flex: 0 0 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .bar-track { flex: 1; height: 8px; border-radius: 6px; background: #eef1f6; overflow: hidden; }
    .bar-fill { height: 100%; background: var(--accent); border-radius: 6px; }
    .bar-row .val { width: 80px; text-align: right; color: var(--muted); flex: 0 0 auto; }

    .donut-wrap { display: flex; align-items: center; gap: 20px; }
    .donut-legend { font-size: 13px; }
    .donut-legend .row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
    .donut-legend .swatch { width: 10px; height: 10px; border-radius: 3px; }

    .tempmail-toggle {
        display: inline-flex; align-items: center; gap: 6px;
        padding: 4px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 700;
        border: 1px solid var(--line); background: #f2f4f8; color: #667085; cursor: pointer;
    }
    .tempmail-toggle.on { background: var(--ok-bg); border-color: var(--ok-line); color: #065f46; }

    @media (max-width: 900px) {
        .app { flex-direction: column; }
        .sidebar { width: 100%; flex: none; height: auto; position: static; }
        .main { padding: 16px; }
    }
</style>
</head>
<body>

<!-- 로그인 화면: is_logged_in() 이 false 일 때만 이 블록이 그려지고,
     그 아래 전체 대시보드(else 쪽)는 그려지지 않는다. -->
<?php if (!$loggedIn): ?>
    <div class="login-shell">
        <div class="login-card">
            <h1>Mail Admin 로그인</h1>
            <div class="desc">mailapi.py 전용 관리 UI</div>

            <?php if ($flash): ?>
                <div class="alert <?= h($flash['type']) ?>"><?= h($flash['message']) ?></div>
            <?php endif; ?>

            <form method="post" autocomplete="off">
                <input type="hidden" name="action" value="login">
                <label for="ui_username">UI 계정</label>
                <input type="text" id="ui_username" name="ui_username" required>
                <label for="ui_password">UI 비밀번호</label>
                <input type="password" id="ui_password" name="ui_password" required>
                <button type="submit" class="btn-blue" style="width:100%;">로그인</button>
            </form>
        </div>
    </div>
<?php else: ?>

<div class="app">
    <aside class="sidebar">
        <div class="brand">
            <span class="logo">M</span>
            <span>Mail Admin<div style="font-size:10px; font-weight:400; color:var(--muted);">v1.0.0</div></span>
        </div>

        <nav>
            <div class="nav-group">
                <div class="nav-label">개요</div>
                <button type="button" class="nav-item <?= $activeTab === 'dashboard' ? 'active' : '' ?>" data-tab="dashboard"><span class="dot"></span>대시보드</button>
            </div>

            <div class="nav-group">
                <div class="nav-label">계정 관리</div>
                <button type="button" class="nav-item <?= $activeTab === 'domains' ? 'active' : '' ?>" data-tab="domains"><span class="dot"></span>도메인</button>
                <button type="button" class="nav-item <?= $activeTab === 'accounts' ? 'active' : '' ?>" data-tab="accounts"><span class="dot"></span>계정</button>
                <button type="button" class="nav-item <?= $activeTab === 'aliases' ? 'active' : '' ?>" data-tab="aliases"><span class="dot"></span>별칭 (Alias)</button>
                <button type="button" class="nav-item <?= $activeTab === 'inactive' ? 'active' : '' ?>" data-tab="inactive"><span class="dot"></span>비활성 계정</button>
            </div>

            <div class="nav-group">
                <div class="nav-label">보안 관리</div>
                <button type="button" class="nav-item <?= $activeTab === 'security' ? 'active' : '' ?>" data-tab="security"><span class="dot"></span>비밀번호 / 로그인 테스트</button>
            </div>

            <div class="nav-group">
                <div class="nav-label">메일 관리</div>
                <button type="button" class="nav-item <?= $activeTab === 'quota' ? 'active' : '' ?>" data-tab="quota"><span class="dot"></span>Maildir 용량</button>
                <button type="button" class="nav-item <?= $activeTab === 'dkim' ? 'active' : '' ?>" data-tab="dkim"><span class="dot"></span>DKIM 관리</button>
            </div>

            <div class="nav-group">
                <div class="nav-label">시스템</div>
                <button type="button" class="nav-item <?= $activeTab === 'backup' ? 'active' : '' ?>" data-tab="backup"><span class="dot"></span>백업 / 복구</button>
                <button type="button" class="nav-item <?= $activeTab === 'logs' ? 'active' : '' ?>" data-tab="logs"><span class="dot"></span>로그</button>
                <button type="button" class="nav-item <?= $activeTab === 'files' ? 'active' : '' ?>" data-tab="files"><span class="dot"></span>파일 탐색기</button>
                <button type="button" class="nav-item <?= $activeTab === 'svclogs' ? 'active' : '' ?>" data-tab="svclogs"><span class="dot"></span>서버 로그</button>
                <button type="button" class="nav-item <?= $activeTab === 'connips' ? 'active' : '' ?>" data-tab="connips"><span class="dot"></span>접속 IP</button>
                <button type="button" class="nav-item <?= $activeTab === 'seccheck' ? 'active' : '' ?>" data-tab="seccheck"><span class="dot"></span>보안 점검</button>
            </div>
        </nav>
    </aside>

    <main class="main">
        <div class="topbar">
            <div class="topbar-left">
                <div>
                    <div class="page-title"><?= h($TABS[$activeTab]['label']) ?></div>
                    <div class="sub">Access: <span class="mono">SSH 터널 전용</span></div>
                </div>
                <div class="conn-pill">
                    <span class="dot2"></span>
                    터널 연결됨 · <span class="mono"><?= h(($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . ($_SERVER['SERVER_PORT'] ?? '8081')) ?></span> · <?= h((new DateTime('now', new DateTimeZone('Asia/Seoul')))->format('Y-m-d H:i')) ?>
                </div>
            </div>

            <div class="topbar-right">
                <div class="user-menu">
                    <button type="button" class="icon-btn" id="notifBtn" title="알림" style="position:relative; cursor:pointer;">
                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:17px; height:17px;"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
                        <?php if ($statNotifTotal > 0): ?>
                            <span style="position:absolute; top:-4px; right:-4px; background:#dc2626; color:#fff; font-size:10px; font-weight:800; min-width:18px; height:16px; border-radius:8px; display:flex; align-items:center; justify-content:center; padding:0 3px;"><?= $statNotifTotal > 99 ? '99+' : h((string)$statNotifTotal) ?></span>
                        <?php endif; ?>
                    </button>
                    <div class="user-dropdown" id="notifDropdown" style="min-width:340px; max-height:420px; overflow:auto;">
                        <?php if ($statNotifTotal === 0): ?>
                            <div class="small" style="padding:8px 10px;">알림 없음 — 이상 없음</div>
                        <?php else: ?>

                            <?php if ($statErrorCount > 0): ?>
                                <div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">최근 1시간 내 API 에러 (<?= h((string)$statErrorCount) ?>건)</div>
                                <?php foreach ($statRecentErrors as $errRow): ?>
                                    <div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
                                        <div style="font-weight:700; color:#b42318;"><?= h((string)($errRow['action'] ?? '')) ?> · <?= h((string)($errRow['status_code'] ?? '')) ?></div>
                                        <div class="small" style="margin-top:2px;"><?= h((string)($errRow['detail'] ?? '')) ?></div>
                                    </div>
                                <?php endforeach; ?>
                                <div style="padding:4px 10px 8px;"><a href="#" onclick="switchTab('logs'); return false;" class="small">전체 로그 보기 ›</a></div>
                            <?php endif; ?>

                            <?php if ($statBanCount > 0): ?>
                                <div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">fail2ban 차단중 (<?= h((string)$statBanCount) ?>개 IP)</div>
                                <?php foreach ($statBannedJails as $jailRow): ?>
                                    <div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
                                        <div style="font-weight:700; color:#b45309;"><?= h((string)($jailRow['jail'] ?? '')) ?> — <?= h((string)($jailRow['currently_banned'] ?? '0')) ?>개</div>
                                        <div class="small" style="margin-top:2px;"><?= h(implode(', ', array_slice($jailRow['banned_ips'] ?? [], 0, 5))) ?></div>
                                    </div>
                                <?php endforeach; ?>
                            <?php endif; ?>

                            <?php if ($statBackupStale): ?>
                                <div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">백업</div>
                                <div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
                                    <div style="font-weight:700; color:#b45309;">
                                        <?= $statBackupLastAgeDays === null ? '백업이 한 번도 생성되지 않았습니다' : "마지막 백업이 {$statBackupLastAgeDays}일 전" ?>
                                    </div>
                                    <div style="margin-top:4px;"><a href="#" onclick="switchTab('backup'); return false;" class="small">백업 탭으로 ›</a></div>
                                </div>
                            <?php endif; ?>

                            <?php if (!empty($statCertWarnings)): ?>
                                <div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">인증서 만료 임박</div>
                                <?php foreach ($statCertWarnings as $certRow): ?>
                                    <div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
                                        <div style="font-weight:700; color:#b42318;"><?= h((string)($certRow['domain'] ?? '')) ?></div>
                                        <div class="small" style="margin-top:2px;">
                                            <?php if (isset($certRow['days_remaining'])): ?>
                                                <?= (int)$certRow['days_remaining'] < 0 ? '이미 만료됨' : ((int)$certRow['days_remaining'] . '일 후 만료') ?>
                                            <?php else: ?>
                                                확인 실패
                                            <?php endif; ?>
                                        </div>
                                    </div>
                                <?php endforeach; ?>
                            <?php endif; ?>

                            <?php if ($statTempmailAbuseSpike): ?>
                                <div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">임시메일 어뷰징 급증</div>
                                <div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
                                    <div style="font-weight:700; color:#b42318;">최근 1시간 <?= h((string)$statTempmailAbuseCount1h) ?>건</div>
                                    <div class="small" style="margin-top:2px;">예약어/한도초과/잘못된 토큰 등 어뷰징 시도가 몰리고 있습니다.</div>
                                    <div style="margin-top:4px;"><a href="#" onclick="switchTab('svclogs'); return false;" class="small">서버 로그 탭으로 ›</a></div>
                                </div>
                            <?php endif; ?>

                        <?php endif; ?>

                        <div style="padding:8px 10px; border-top:1px solid var(--line); margin-top:4px;">
                            <a href="#" onclick="switchTab('logs'); return false;" class="small">상세 로그 전체 보기 ›</a>
                        </div>
                    </div>
                </div>

                <button type="button" class="icon-btn" id="themeBtn" title="다크모드 전환" style="cursor:pointer;">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:17px; height:17px;"><circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path></svg>
                </button>

                <div class="user-menu">
                    <button type="button" class="user-pill" id="userMenuBtn">
                        <span class="avatar">
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:13px; height:13px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
                        </span>
                        <?= h($UI_USERNAME) ?>
                    </button>
                    <div class="user-dropdown" id="userDropdown">
                        <form method="post">
                            <input type="hidden" name="action" value="logout">
                            <button type="submit">로그아웃</button>
                        </form>
                    </div>
                </div>
            </div>
        </div>

        <?php if ($flash): ?>
            <div class="alert <?= h($flash['type']) ?>"><?= h($flash['message']) ?></div>
        <?php endif; ?>

        <!-- =====================================================================
             탭 화면들
             $activeTab 값과 같은 탭의 div만 CSS로 보이고(active 클래스), 나머지는
             다 그려지긴 하지만 화면에는 숨겨져 있다(JS 로 탭 전환 시 form 없이도
             바로 보이게 하기 위함). 그래서 탭을 눌러도 페이지 전체가 다시 로드될
             필요가 없다.
        ===================================================================== -->
        <div class="tab-panel <?= $activeTab === 'dashboard' ? 'active' : '' ?>" id="panel-dashboard">

            <div class="grid stat-row">
                <div class="stat-card">
                    <div class="icon-row">
                        <span class="icon-badge badge-blue">
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"></path></svg>
                        </span>
                        <div>
                            <div class="label">도메인</div>
                            <div class="value"><?= $statDomainCount !== null ? h((string)$statDomainCount) : '—' ?></div>
                        </div>
                    </div>
                    <div class="foot">활성: <b><?= $statDomainCount !== null ? h((string)$statDomainCount) : '—' ?></b></div>
                </div>

                <div class="stat-card">
                    <div class="icon-row">
                        <span class="icon-badge badge-purple">
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
                        </span>
                        <div>
                            <div class="label">계정</div>
                            <div class="value"><?= $statAccountTotal !== null ? h((string)$statAccountTotal) : '—' ?></div>
                        </div>
                    </div>
                    <div class="foot">
                        <?php if ($statAccountActive !== null): ?>
                            활성: <b><?= h((string)$statAccountActive) ?></b> · 비활성: <span class="bad"><?= h((string)$statAccountInactive) ?></span>
                        <?php else: ?>
                            GET /account/list
                        <?php endif; ?>
                    </div>
                </div>

                <div class="stat-card">
                    <div class="icon-row">
                        <span class="icon-badge badge-sky">
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="M22 6l-10 7L2 6"></path></svg>
                        </span>
                        <div>
                            <div class="label">별칭</div>
                            <div class="value"><?= $statAliasCount !== null ? h((string)$statAliasCount) : '—' ?></div>
                        </div>
                    </div>
                    <div class="foot">총 별칭 수</div>
                </div>

                <div class="stat-card">
                    <div class="icon-row">
                        <span class="icon-badge badge-amber">
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8V21H3V8"></path><path d="M1 3h22v5H1z"></path><path d="M10 12h4"></path></svg>
                        </span>
                        <div>
                            <div class="label">Maildir 용량</div>
                            <div class="value"><?= $statQuotaTotalBytes !== null ? h(bytes_to_human($statQuotaTotalBytes)) : '—' ?></div>
                        </div>
                    </div>
                    <div class="foot">총 사용량</div>
                </div>

                <div class="stat-card">
                    <div class="icon-row">
                        <span class="icon-badge badge-green">
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
                        </span>
                        <div>
                            <div class="label">DKIM Selectors</div>
                            <div class="value"><?= $statDkimSelectorTotal !== null ? h((string)$statDkimSelectorTotal) : '—' ?></div>
                        </div>
                    </div>
                    <div class="foot">총 Selector 수</div>
                </div>

                <div class="stat-card">
                    <div class="icon-row">
                        <span class="icon-badge badge-amber">
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="M22 6l-10 7L2 6"></path><path d="M2 4l6 6M22 4l-6 6"></path></svg>
                        </span>
                        <div>
                            <div class="label">임시메일 활성 계정</div>
                            <div class="value"><?= $tempmailStatsData ? h((string)($tempmailStatsData['active_accounts'] ?? '0')) : '—' ?></div>
                        </div>
                    </div>
                    <div class="foot">
                        <?php if ($tempmailStatsData): ?>
                            오늘 생성: <b><?= h((string)($tempmailStatsData['created_today'] ?? '0')) ?></b>
                            <?php if ($statTempmailAbuseSpike): ?>
                                · <span class="bad">어뷰징 <?= h((string)$statTempmailAbuseCount1h) ?>건/1시간</span>
                            <?php endif; ?>
                        <?php else: ?>
                            GET /tempmail/stats
                        <?php endif; ?>
                    </div>
                </div>
            </div>

            <div class="grid grid-3">
                <div class="card">
                    <h2>빠른 작업</h2>
                    <div class="desc">자주 쓰는 기능 바로가기</div>
                    <div class="quick-grid">
                        <button type="button" class="quick-btn" onclick="switchTab('domains')">
                            <span class="icon-badge badge-blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg></span>
                            도메인 추가
                        </button>
                        <button type="button" class="quick-btn" onclick="switchTab('accounts')">
                            <span class="icon-badge badge-purple"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="19" y1="8" x2="19" y2="14"></line><line x1="22" y1="11" x2="16" y2="11"></line></svg></span>
                            계정 추가
                        </button>
                        <button type="button" class="quick-btn" onclick="switchTab('security')">
                            <span class="icon-badge badge-amber"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg></span>
                            비밀번호 변경
                        </button>
                        <button type="button" class="quick-btn" onclick="switchTab('security')">
                            <span class="icon-badge badge-sky"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg></span>
                            로그인 테스트
                        </button>
                        <button type="button" class="quick-btn" onclick="switchTab('dkim')">
                            <span class="icon-badge badge-green"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg></span>
                            DKIM 생성
                        </button>
                        <button type="button" class="quick-btn" onclick="switchTab('backup')">
                            <span class="icon-badge badge-blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg></span>
                            백업 생성
                        </button>
                        <button type="button" class="quick-btn" onclick="switchTab('aliases')">
                            <span class="icon-badge badge-sky"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="M22 6l-10 7L2 6"></path></svg></span>
                            별칭 추가
                        </button>
                        <button type="button" class="quick-btn" onclick="switchTab('inactive')">
                            <span class="icon-badge badge-purple"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="17" y1="8" x2="22" y2="13"></line><line x1="22" y1="8" x2="17" y2="13"></line></svg></span>
                            비활성 계정
                        </button>
                    </div>
                </div>

                <div class="card">
                    <h2>최근 API 로그</h2>
                    <div class="desc">
                        최근 요청
                        <a href="#" onclick="switchTab('logs'); return false;" class="small" style="float:right;">더보기 ›</a>
                    </div>
                    <?php if (!empty($statRecentAuditLines)): ?>
                        <?php foreach ($statRecentAuditLines as $row): ?>
                            <div class="log-mini-row">
                                <span class="method"><?= h((string)($row['method'] ?? '')) ?></span>
                                <span class="path"><?= h((string)($row['path'] ?? '')) ?></span>
                                <span class="ts"><?= h(substr((string)($row['ts'] ?? ''), 0, 16)) ?></span>
                            </div>
                        <?php endforeach; ?>
                    <?php else: ?>
                        <div class="small">표시할 로그가 없습니다.</div>
                    <?php endif; ?>
                </div>

                <div class="card">
                    <h2>서버 상태</h2>
                    <div class="desc">systemctl is-active 실시간 조회</div>
                    <?php if ($systemStatusData && isset($systemStatusData['services']) && is_array($systemStatusData['services'])): ?>
                        <?php foreach ($systemStatusData['services'] as $row): ?>
                            <div class="log-mini-row">
                                <span class="path" style="font-family:inherit; text-transform:capitalize;"><?= h((string)($row['service'] ?? '')) ?></span>
                                <span class="status-dot">
                                    <span class="d <?= !empty($row['active']) ? 'on' : 'off' ?>"></span>
                                    <?= !empty($row['active']) ? '정상' : h((string)($row['state'] ?? '오류')) ?>
                                </span>
                            </div>
                        <?php endforeach; ?>
                        <div class="log-mini-row">
                            <span class="path" style="font-family:inherit;">API Server</span>
                            <span class="status-dot"><span class="d <?= $healthData ? 'on' : 'off' ?>"></span><?= $healthData ? '정상' : '오류' ?></span>
                        </div>
                    <?php else: ?>
                        <pre><?= h(pretty_json($systemStatusErr)) ?></pre>
                    <?php endif; ?>
                </div>
            </div>

            <div class="section grid grid-2">
                <div class="card">
                    <h2>최근 도메인</h2>
                    <div class="desc">ID 기준 최근 등록된 도메인 5개<a href="#" onclick="switchTab('domains'); return false;" class="small" style="float:right;">전체 보기 ›</a></div>
                    <?php if (!empty($statRecentDomains)): ?>
                        <table>
                            <thead><tr><th>도메인</th><th>계정 수</th><th>별칭 수</th></tr></thead>
                            <tbody>
                            <?php foreach ($statRecentDomains as $row): ?>
                                <tr>
                                    <td><?= h((string)($row['domain'] ?? '')) ?></td>
                                    <td><?= h((string)($row['user_count'] ?? '0')) ?></td>
                                    <td><?= h((string)($statAliasCountByDomain[(string)($row['domain'] ?? '')] ?? 0)) ?></td>
                                </tr>
                            <?php endforeach; ?>
                            </tbody>
                        </table>
                    <?php else: ?>
                        <div class="small">표시할 도메인이 없습니다.</div>
                    <?php endif; ?>
                </div>

                <div class="card">
                    <h2>Maildir 용량 TOP 10</h2>
                    <div class="desc">전체 계정 중 사용량이 가장 큰 순<a href="#" onclick="switchTab('quota'); return false;" class="small" style="float:right;">전체 보기 ›</a></div>
                    <?php if (!empty($statQuotaTop)):
                        $maxBytes = max(array_map(fn($r) => (int)($r['size_bytes'] ?? 0), $statQuotaTop)) ?: 1;
                        foreach ($statQuotaTop as $row):
                            $pct = min(100, round(((int)($row['size_bytes'] ?? 0) / $maxBytes) * 100));
                    ?>
                        <div class="bar-row">
                            <div class="name"><?= h((string)($row['email'] ?? '')) ?></div>
                            <div class="bar-track"><div class="bar-fill" style="width: <?= (int)$pct ?>%;"></div></div>
                            <div class="val"><?= h((string)($row['size_human'] ?? '')) ?></div>
                        </div>
                    <?php endforeach; else: ?>
                        <div class="small">표시할 데이터가 없습니다.</div>
                    <?php endif; ?>
                </div>
            </div>

            <div class="section card">
                <h2>계정 상태 분포</h2>
                <div class="desc">활성 / 비활성 비율</div>
                <?php if ($statAccountTotal !== null && $statAccountTotal > 0):
                    $activePct = round(($statAccountActive / $statAccountTotal) * 100);
                ?>
                    <div class="donut-wrap">
                        <svg width="120" height="120" viewBox="0 0 42 42">
                            <circle cx="21" cy="21" r="15.9" fill="transparent" stroke="#eef1f6" stroke-width="6"></circle>
                            <circle cx="21" cy="21" r="15.9" fill="transparent" stroke="#4f46e5" stroke-width="6"
                                    stroke-dasharray="<?= (float)$activePct ?> <?= (float)(100 - $activePct) ?>"
                                    stroke-dashoffset="25" transform="rotate(-90 21 21)"></circle>
                            <text x="21" y="19" text-anchor="middle" font-size="7" font-weight="700" fill="#101828"><?= h((string)$statAccountTotal) ?></text>
                            <text x="21" y="26" text-anchor="middle" font-size="3.6" fill="#667085">총 계정</text>
                        </svg>
                        <div class="donut-legend">
                            <div class="row"><span class="swatch" style="background:#4f46e5;"></span>활성 <?= h((string)$statAccountActive) ?>명 (<?= h((string)$activePct) ?>%)</div>
                            <div class="row"><span class="swatch" style="background:#eef1f6;"></span>비활성 <?= h((string)$statAccountInactive) ?>명 (<?= h((string)(100 - $activePct)) ?>%)</div>
                        </div>
                    </div>
                <?php else: ?>
                    <div class="small">표시할 데이터가 없습니다.</div>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'domains' ? 'active' : '' ?>" id="panel-domains">
            <div class="grid grid-2">
                <div class="card">
                    <h2>도메인 추가</h2>
                    <div class="desc">virtual_domains 추가 + /var/mail/vhosts/domain 생성</div>
                    <form method="post" autocomplete="off">
                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                        <input type="hidden" name="action" value="domain_add">
                        <label for="domain_add">도메인</label>
                        <input type="text" id="domain_add" name="domain" placeholder="example.com" required>
                        <button type="submit" class="btn-blue">도메인 추가</button>
                    </form>
                </div>

                <div class="card">
                    <h2>도메인 삭제</h2>
                    <div class="desc">해당 도메인에 계정이 있으면 API가 409 반환</div>
                    <form method="post" autocomplete="off" onsubmit="return appConfirm(this, '정말 도메인을 삭제하시겠습니까?');">
                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                        <input type="hidden" name="action" value="domain_delete">
                        <label for="domain_del">도메인</label>
                        <input type="text" id="domain_del" name="domain" placeholder="example.com" required>
                        <button type="submit" class="btn-red">도메인 삭제</button>
                    </form>
                </div>
            </div>

            <div class="section card">
                <h2>도메인 목록</h2>
                <div class="desc">GET /domain/list — 임시메일 열은 클릭 시 즉시 켜기/끄기 (POST /domain/set-tempmail)</div>
                <?php if ($domainsData && isset($domainsData['domains']) && is_array($domainsData['domains'])): ?>
                    <table>
                        <thead><tr><th>ID</th><th>도메인</th><th>사용자 수</th><th>임시메일</th><th></th></tr></thead>
                        <tbody>
                        <?php foreach ($domainsData['domains'] as $row): ?>
                            <?php $tmEnabled = (int)($row['tempmail_enabled'] ?? 0) === 1; ?>
                            <tr>
                                <td><?= h((string)($row['id'] ?? '')) ?></td>
                                <td><?= h((string)($row['domain'] ?? '')) ?></td>
                                <td><?= h((string)($row['user_count'] ?? '0')) ?></td>
                                <td>
                                    <form method="post" style="margin:0;">
                                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                                        <input type="hidden" name="action" value="domain_set_tempmail">
                                        <input type="hidden" name="domain" value="<?= h((string)($row['domain'] ?? '')) ?>">
                                        <input type="hidden" name="enabled" value="<?= $tmEnabled ? '0' : '1' ?>">
                                        <button type="submit" class="tempmail-toggle <?= $tmEnabled ? 'on' : '' ?>">
                                            <?= $tmEnabled ? '✓ 허용됨' : '허용 안 함' ?>
                                        </button>
                                    </form>
                                </td>
                                <td><a class="small" href="<?= h($_SERVER['PHP_SELF']) . '?tab=accounts&q=&domain=' . urlencode((string)($row['domain'] ?? '')) . '&active=' ?>">이 도메인 계정 보기</a></td>
                            </tr>
                        <?php endforeach; ?>
                        </tbody>
                    </table>
                <?php else: ?>
                    <pre><?= h(pretty_json($domainsErr)) ?></pre>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'accounts' ? 'active' : '' ?>" id="panel-accounts">
            <div class="grid grid-2">
                <div class="card">
                    <h2>계정 추가</h2>
                    <div class="desc">/account/add 호출</div>
                    <form method="post" autocomplete="off">
                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                        <input type="hidden" name="action" value="account_add">
                        <label for="acc_add_email">이메일</label>
                        <input type="email" id="acc_add_email" name="email" placeholder="user@example.com" required>
                        <label for="acc_add_password">비밀번호</label>
                        <input type="password" id="acc_add_password" name="password" required>
                        <label for="acc_add_active">활성 여부</label>
                        <select id="acc_add_active" name="active">
                            <option value="1" selected>1 (active)</option>
                            <option value="0">0 (inactive)</option>
                        </select>
                        <button type="submit" class="btn-blue">계정 추가</button>
                    </form>
                </div>

                <div class="card">
                    <h2>계정 삭제</h2>
                    <div class="desc">DB 삭제 + Maildir 제거</div>
                    <form method="post" autocomplete="off" onsubmit="return appConfirm(this, '정말 계정을 삭제하시겠습니까?');">
                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                        <input type="hidden" name="action" value="account_delete">
                        <label for="acc_del_email">이메일</label>
                        <input type="email" id="acc_del_email" name="email" placeholder="user@example.com" required>
                        <button type="submit" class="btn-red">계정 삭제</button>
                    </form>
                </div>
            </div>

            <div class="section card">
                <h2>계정 검색 / 도메인 필터</h2>
                <div class="desc">이메일 substring / 도메인 / active 필터</div>
                <form method="get" autocomplete="off">
                    <input type="hidden" name="tab" value="accounts">
                    <div class="grid grid-3">
                        <div>
                            <label for="search_q">이메일 검색</label>
                            <input type="text" id="search_q" name="q" value="<?= h($searchQ) ?>" placeholder="shadow, master, phoenix">
                        </div>
                        <div>
                            <label for="search_domain">도메인</label>
                            <input type="text" id="search_domain" name="domain" value="<?= h($searchDomain) ?>" placeholder="osmsn.com">
                        </div>
                        <div>
                            <label for="search_active">활성 여부</label>
                            <select id="search_active" name="active">
                                <option value="">전체</option>
                                <option value="1" <?= $searchActive === '1' ? 'selected' : '' ?>>1 (active)</option>
                                <option value="0" <?= $searchActive === '0' ? 'selected' : '' ?>>0 (inactive)</option>
                            </select>
                        </div>
                    </div>
                    <div class="inline-actions">
                        <button type="submit" class="btn-blue">검색</button>
                        <a href="<?= h($_SERVER['PHP_SELF']) ?>?tab=accounts" class="btn-gray">초기화</a>
                    </div>
                </form>
            </div>

            <div class="section card">
                <h2>계정 목록</h2>
                <div class="desc">GET /account/list 또는 /account/search</div>
                <?php if ($accountsData && isset($accountsData['accounts']) && is_array($accountsData['accounts'])): ?>
                    <table>
                        <thead><tr><th>ID</th><th>이메일</th><th>도메인</th><th>active</th><th>작업</th></tr></thead>
                        <tbody>
                        <?php foreach ($accountsData['accounts'] as $row): ?>
                            <tr>
                                <td><?= h((string)($row['id'] ?? '')) ?></td>
                                <td><?= h((string)($row['email'] ?? '')) ?></td>
                                <td><?= h((string)($row['domain'] ?? '')) ?></td>
                                <td><?= h((string)($row['active'] ?? '')) ?></td>
                                <td>
                                    <div class="inline-actions">
                                        <form method="post" onsubmit="return appConfirm(this, 'active 상태를 변경하시겠습니까?');">
                                            <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                                            <input type="hidden" name="action" value="account_set_active">
                                            <input type="hidden" name="email" value="<?= h((string)($row['email'] ?? '')) ?>">
                                            <input type="hidden" name="active" value="<?= ((string)($row['active'] ?? '1') === '1') ? '0' : '1' ?>">
                                            <button type="submit" class="btn-gray"><?= ((string)($row['active'] ?? '1') === '1') ? '비활성화' : '활성화' ?></button>
                                        </form>
                                        <button type="button" class="btn-dark" onclick="fillPassword('<?= h((string)($row['email'] ?? '')) ?>')">비밀번호</button>
                                        <button type="button" class="btn-blue" onclick="fillTestLogin('<?= h((string)($row['email'] ?? '')) ?>')">로그인 테스트</button>
                                    </div>
                                </td>
                            </tr>
                        <?php endforeach; ?>
                        </tbody>
                    </table>
                <?php else: ?>
                    <pre><?= h(pretty_json($accountsErr)) ?></pre>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'aliases' ? 'active' : '' ?>" id="panel-aliases">
            <div class="grid grid-2">
                <div class="card">
                    <h2>별칭 추가</h2>
                    <div class="desc">/alias/add 호출 — source로 온 메일을 destination으로 전달</div>
                    <form method="post" autocomplete="off">
                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                        <input type="hidden" name="action" value="alias_add">
                        <label for="alias_source">Source (수신 주소)</label>
                        <input type="email" id="alias_source" name="source" placeholder="sales@example.com" required>
                        <label for="alias_destination">Destination (전달 대상)</label>
                        <input type="email" id="alias_destination" name="destination" placeholder="master@example.com" required>
                        <label for="alias_active">활성 여부</label>
                        <select id="alias_active" name="active">
                            <option value="1" selected>1 (active)</option>
                            <option value="0">0 (inactive)</option>
                        </select>
                        <button type="submit" class="btn-blue">별칭 추가</button>
                    </form>
                </div>

                <div class="card">
                    <h2>안내</h2>
                    <div class="desc">별칭(alias) 동작 방식</div>
                    <div class="small">
                        source 주소로 들어온 메일이 destination 주소로 전달된다.
                        같은 source에 여러 destination을 등록하면 동시에 여러 곳으로 전달된다 (분배).
                        source의 도메인은 먼저 도메인 목록에 등록되어 있어야 한다.
                    </div>
                </div>
            </div>

            <div class="section card">
                <h2>별칭 목록</h2>
                <div class="desc">GET /alias/list</div>
                <?php if ($aliasesData && isset($aliasesData['aliases']) && is_array($aliasesData['aliases'])): ?>
                    <table>
                        <thead><tr><th>ID</th><th>Source</th><th>Destination</th><th>도메인</th><th>active</th><th>작업</th></tr></thead>
                        <tbody>
                        <?php foreach ($aliasesData['aliases'] as $row): ?>
                            <tr>
                                <td><?= h((string)($row['id'] ?? '')) ?></td>
                                <td><?= h((string)($row['source'] ?? '')) ?></td>
                                <td><?= h((string)($row['destination'] ?? '')) ?></td>
                                <td><?= h((string)($row['domain'] ?? '')) ?></td>
                                <td><?= h((string)($row['active'] ?? '')) ?></td>
                                <td>
                                    <div class="inline-actions">
                                        <form method="post" onsubmit="return appConfirm(this, 'active 상태를 변경하시겠습니까?');">
                                            <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                                            <input type="hidden" name="action" value="alias_set_active">
                                            <input type="hidden" name="id" value="<?= h((string)($row['id'] ?? '')) ?>">
                                            <input type="hidden" name="active" value="<?= ((string)($row['active'] ?? '1') === '1') ? '0' : '1' ?>">
                                            <button type="submit" class="btn-gray"><?= ((string)($row['active'] ?? '1') === '1') ? '비활성화' : '활성화' ?></button>
                                        </form>
                                        <form method="post" onsubmit="return appConfirm(this, '이 별칭을 삭제하시겠습니까?');">
                                            <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                                            <input type="hidden" name="action" value="alias_delete">
                                            <input type="hidden" name="id" value="<?= h((string)($row['id'] ?? '')) ?>">
                                            <button type="submit" class="btn-red">삭제</button>
                                        </form>
                                    </div>
                                </td>
                            </tr>
                        <?php endforeach; ?>
                        </tbody>
                    </table>
                <?php else: ?>
                    <pre><?= h(pretty_json($aliasesErr)) ?></pre>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'inactive' ? 'active' : '' ?>" id="panel-inactive">
            <div class="card">
                <h2>비활성 계정</h2>
                <div class="desc">GET /account/search?active=0</div>
                <?php
                    $inactiveRows = [];
                    if (is_array($allAccountsData['accounts'] ?? null)) {
                        foreach ($allAccountsData['accounts'] as $row) {
                            if ((int)($row['active'] ?? 1) === 0) {
                                $inactiveRows[] = $row;
                            }
                        }
                    }
                ?>
                <?php if (!empty($inactiveRows)): ?>
                    <table>
                        <thead><tr><th>ID</th><th>이메일</th><th>도메인</th><th>작업</th></tr></thead>
                        <tbody>
                        <?php foreach ($inactiveRows as $row): ?>
                            <tr>
                                <td><?= h((string)($row['id'] ?? '')) ?></td>
                                <td><?= h((string)($row['email'] ?? '')) ?></td>
                                <td><?= h((string)($row['domain'] ?? '')) ?></td>
                                <td>
                                    <form method="post" onsubmit="return appConfirm(this, '이 계정을 다시 활성화하시겠습니까?');">
                                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                                        <input type="hidden" name="action" value="account_set_active">
                                        <input type="hidden" name="email" value="<?= h((string)($row['email'] ?? '')) ?>">
                                        <input type="hidden" name="active" value="1">
                                        <button type="submit" class="btn-blue">활성화</button>
                                    </form>
                                </td>
                            </tr>
                        <?php endforeach; ?>
                        </tbody>
                    </table>
                <?php else: ?>
                    <div class="small">비활성 계정이 없습니다.</div>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'security' ? 'active' : '' ?>" id="panel-security">
            <div class="grid grid-2">
                <div class="card">
                    <h2>비밀번호 변경</h2>
                    <div class="desc">/account/password 호출</div>
                    <form method="post" autocomplete="off" id="passwordForm">
                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                        <input type="hidden" name="action" value="account_password">
                        <label for="pw_email">이메일</label>
                        <input type="email" id="pw_email" name="email" placeholder="user@example.com" required>
                        <label for="pw_new">새 비밀번호</label>
                        <input type="password" id="pw_new" name="password" required>
                        <button type="submit" class="btn-dark">비밀번호 변경</button>
                    </form>
                </div>

                <div class="card">
                    <h2>계정 비활성화 → 로그인 차단 테스트</h2>
                    <div class="desc">실제 doveadm auth test 호출</div>
                    <form method="post" autocomplete="off" id="testLoginForm">
                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                        <input type="hidden" name="action" value="account_test_login">
                        <label for="test_email">이메일</label>
                        <input type="email" id="test_email" name="email" placeholder="user@example.com" required>
                        <label for="test_password">테스트 비밀번호</label>
                        <input type="password" id="test_password" name="password" required>
                        <button type="submit" class="btn-dark">로그인 테스트</button>
                    </form>
                </div>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'quota' ? 'active' : '' ?>" id="panel-quota">
            <div class="card">
                <h2>Maildir 용량 표시</h2>
                <div class="desc">계정별 실제 Maildir 사용량</div>
                <form method="get" autocomplete="off">
                    <input type="hidden" name="tab" value="quota">
                    <label for="quota_domain">도메인 필터</label>
                    <input type="text" id="quota_domain" name="quota_domain" value="<?= h($quotaDomain) ?>" placeholder="osmsn.com">
                    <div class="inline-actions">
                        <button type="submit" class="btn-blue">용량 조회</button>
                        <a href="<?= h($_SERVER['PHP_SELF']) ?>?tab=quota" class="btn-gray">초기화</a>
                    </div>
                </form>

                <?php if ($quotaData && isset($quotaData['accounts']) && is_array($quotaData['accounts'])): ?>
                    <table>
                        <thead><tr><th>이메일</th><th>도메인</th><th>active</th><th>용량</th><th>Maildir</th></tr></thead>
                        <tbody>
                        <?php foreach ($quotaData['accounts'] as $row): ?>
                            <tr>
                                <td><?= h((string)($row['email'] ?? '')) ?></td>
                                <td><?= h((string)($row['domain'] ?? '')) ?></td>
                                <td><?= h((string)($row['active'] ?? '')) ?></td>
                                <td><?= h((string)($row['size_human'] ?? '0 B')) ?></td>
                                <td class="small"><?= h((string)($row['maildir'] ?? '')) ?></td>
                            </tr>
                        <?php endforeach; ?>
                        </tbody>
                    </table>
                <?php else: ?>
                    <pre><?= h(pretty_json($quotaErr)) ?></pre>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'dkim' ? 'active' : '' ?>" id="panel-dkim">
            <div class="card">
                <h2>DKIM 관리</h2>
                <div class="desc">selector 생성 / TXT 조회</div>

                <div class="grid grid-2">
                    <div>
                        <form method="post" autocomplete="off">
                            <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                            <input type="hidden" name="action" value="dkim_generate">
                            <label for="dkim_domain_new">도메인</label>
                            <input type="text" id="dkim_domain_new" name="domain" placeholder="example.com" required>
                            <label for="dkim_selector_new">Selector</label>
                            <input type="text" id="dkim_selector_new" name="selector" value="default" required>
                            <label for="dkim_bits_new">Key bits</label>
                            <input type="number" id="dkim_bits_new" name="bits" value="2048" min="1024" step="1024">
                            <label for="dkim_force_new">덮어쓰기</label>
                            <select id="dkim_force_new" name="force">
                                <option value="0" selected>0 (no)</option>
                                <option value="1">1 (yes)</option>
                            </select>
                            <button type="submit" class="btn-blue">DKIM 생성</button>
                        </form>
                    </div>

                    <div>
                        <form method="get" autocomplete="off">
                            <input type="hidden" name="tab" value="dkim">
                            <label for="dkim_domain_view">조회 도메인</label>
                            <input type="text" id="dkim_domain_view" name="dkim_domain" value="<?= h($dkimViewDomain) ?>" placeholder="example.com">
                            <label for="dkim_selector_view">조회 selector</label>
                            <input type="text" id="dkim_selector_view" name="dkim_selector" value="<?= h($dkimViewSelector) ?>" placeholder="default">
                            <button type="submit" class="btn-dark">TXT 조회</button>
                        </form>

                        <?php if ($dkimPublicData): ?>
                            <pre><?= h(pretty_json($dkimPublicData)) ?></pre>
                        <?php elseif ($dkimPublicErr): ?>
                            <pre><?= h(pretty_json($dkimPublicErr)) ?></pre>
                        <?php endif; ?>
                    </div>
                </div>

                <div class="section">
                    <?php if ($dkimListData && isset($dkimListData['domains']) && is_array($dkimListData['domains'])): ?>
                        <table>
                            <thead><tr><th>도메인</th><th>경로</th><th>selector 수</th><th>selectors</th></tr></thead>
                            <tbody>
                            <?php foreach ($dkimListData['domains'] as $row): ?>
                                <tr>
                                    <td><?= h((string)($row['domain'] ?? '')) ?></td>
                                    <td class="small"><?= h((string)($row['path'] ?? '')) ?></td>
                                    <td><?= h((string)($row['selector_count'] ?? '0')) ?></td>
                                    <td class="small">
                                        <?php foreach (($row['selectors'] ?? []) as $selectorRow): ?>
                                            <?= h((string)($selectorRow['selector'] ?? '')) ?><br>
                                        <?php endforeach; ?>
                                    </td>
                                </tr>
                            <?php endforeach; ?>
                            </tbody>
                        </table>
                    <?php else: ?>
                        <pre><?= h(pretty_json($dkimListErr)) ?></pre>
                    <?php endif; ?>
                </div>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'backup' ? 'active' : '' ?>" id="panel-backup">
            <div class="card">
                <h2>백업 / 복구</h2>
                <div class="desc">DB + Maildir + DKIM + Postfix/Dovecot 설정 백업</div>

                <div class="grid grid-2">
                    <div>
                        <form method="post" autocomplete="off">
                            <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                            <input type="hidden" name="action" value="backup_create">
                            <label for="backup_label">백업 라벨</label>
                            <input type="text" id="backup_label" name="label" placeholder="before-dkim-change">
                            <button type="submit" class="btn-blue">백업 생성</button>
                        </form>
                    </div>
                    <div>
                        <form method="post" autocomplete="off" onsubmit="return appConfirm(this, '정말 복구하시겠습니까? 현재 데이터가 덮어써질 수 있습니다.');">
                            <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                            <input type="hidden" name="action" value="backup_restore">
                            <label for="backup_restore_file">복구 파일명</label>
                            <input type="text" id="backup_restore_file" name="file" placeholder="mailadmin-backup-20260315-120000.tar.gz" required>
                            <button type="submit" class="btn-red">백업 복구</button>
                        </form>
                    </div>
                </div>

                <div class="section">
                    <?php if ($backupData && isset($backupData['backups']) && is_array($backupData['backups'])): ?>
                        <table>
                            <thead><tr><th>파일</th><th>크기</th><th>수정시각</th></tr></thead>
                            <tbody>
                            <?php foreach ($backupData['backups'] as $row): ?>
                                <tr>
                                    <td class="small"><?= h((string)($row['file'] ?? '')) ?></td>
                                    <td><?= h((string)($row['size_human'] ?? '')) ?></td>
                                    <td class="small"><?= h((string)($row['mtime'] ?? '')) ?></td>
                                </tr>
                            <?php endforeach; ?>
                            </tbody>
                        </table>
                    <?php else: ?>
                        <pre><?= h(pretty_json($backupErr)) ?></pre>
                    <?php endif; ?>
                </div>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'logs' ? 'active' : '' ?>" id="panel-logs">
            <div class="card">
                <h2>최근 API 감사로그</h2>
                <div class="desc">GET /audit/log?lines=50</div>
                <?php if ($auditData && isset($auditData['lines']) && is_array($auditData['lines'])): ?>
                    <pre class="log-box log-box-lg"><?= implode("\n", array_map('highlight_log_line', $auditData['lines'])) ?></pre>
                <?php else: ?>
                    <pre><?= h(pretty_json($auditErr)) ?></pre>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'files' ? 'active' : '' ?>" id="panel-files">
            <div class="card">
                <h2>파일 탐색기</h2>
                <div class="desc">
                    <b>「허용된 폴더」</b>에 있는 폴더만 열고 변경할 수 있습니다. 시스템 영역은 안 열립니다.
                    허용된 폴더는 안에서 소유·그룹·권한 변경 가능합니다.
                </div>

                <div class="card" style="margin:14px 0; background:var(--bg-elevated, transparent);">
                    <h3 style="margin:0 0 8px;">허용된 폴더</h3>
                    <?php if ($fmAllowedData && isset($fmAllowedData['allowed'])): ?>
                        <?php if (empty($fmAllowedData['allowed'])): ?>
                            <div class="small" style="margin-bottom:10px;">아직 허용한 폴더가 없습니다.</div>
                        <?php else: ?>
                            <table style="margin-bottom:10px;">
                                <thead><tr><th>경로</th><th></th></tr></thead>
                                <tbody>
                                <?php foreach ($fmAllowedData['allowed'] as $aPath): ?>
                                    <tr>
                                        <td class="mono small"><?= h((string)$aPath) ?></td>
                                        <td>
                                            <form method="post" style="margin:0;" onsubmit="return appConfirm(this, '이 폴더의 허용을 해제하시겠습니까?');">
                                                <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                                                <input type="hidden" name="action" value="file_allow_remove">
                                                <input type="hidden" name="path" value="<?= h((string)$aPath) ?>">
                                                <button type="submit" class="btn-gray">해제</button>
                                            </form>
                                        </td>
                                    </tr>
                                <?php endforeach; ?>
                                </tbody>
                            </table>
                        <?php endif; ?>
                        <?php if (empty($fmAllowedData['password_configured'])): ?>
                            <div class="alert error">비밀번호가 설정되어 있지 않아 새로 허용할 수 없습니다 (서버에 MAILAPI_FM_PASSWORD 설정 필요).</div>
                        <?php endif; ?>
                    <?php else: ?>
                        <div class="small">허용 목록을 불러오지 못했습니다.</div>
                    <?php endif; ?>
                    <form method="post" autocomplete="off" onsubmit="return appConfirm(this, '이 경로를 허용하시겠습니까?');">
                        <input type="text" name="fm_hp_user" autocomplete="username" style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;" tabindex="-1" aria-hidden="true"><input type="password" name="fm_hp_pass" autocomplete="new-password" style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;" tabindex="-1" aria-hidden="true">
                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                        <input type="hidden" name="action" value="file_allow">
                        <div class="grid grid-3">
                            <div><label for="fa_path">경로 직접 입력해서 허용</label><input type="text" id="fa_path" name="path" placeholder="/etc/nginx" autocomplete="off" spellcheck="false"></div>
                            <div><label for="fa_password">비밀번호</label><input type="password" id="fa_password" name="password" autocomplete="new-password"></div>
                            <div style="display:flex; align-items:flex-end;"><button type="submit" class="btn-blue" style="margin-bottom:12px;">허용</button></div>
                        </div>
                    </form>
                </div>

                <div class="inline-actions" style="margin-bottom:14px;">
                    <?php if ($fmRootsData && isset($fmRootsData['roots']) && is_array($fmRootsData['roots'])): ?>
                        <?php foreach ($fmRootsData['roots'] as $rootRow): ?>
                            <a class="btn-gray"
                               href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode((string)($rootRow['path'] ?? '/')) ?>">
                                <?= h((string)($rootRow['label'] ?? '')) ?>
                            </a>
                        <?php endforeach; ?>
                    <?php else: ?>
                        <span class="small">바로가기 목록을 불러오지 못했습니다.</span>
                    <?php endif; ?>
                </div>

                <?php if ($fmAllow !== ''): ?>
                    <?php
                        $faParent = dirname($fmAllow);
                        $faParent = ($faParent === '.' || $faParent === '') ? '/' : $faParent;
                    ?>
                    <div class="small" style="margin-bottom:10px;">
                        <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($faParent) ?>">‹ 목록으로</a>
                        &nbsp;·&nbsp; <span class="mono"><?= h($fmAllow) ?></span>
                    </div>
                    <h3 style="margin:6px 0 4px;">이 폴더 허용</h3>
                    <div class="small" style="margin-bottom:12px;">이 폴더와 그 안의 모든 파일·하위 폴더를 열고 고칠 수 있게 됩니다.</div>
                    <form method="post" autocomplete="off" onsubmit="return appConfirm(this, '이 폴더를 허용하시겠습니까?');">
                        <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                        <input type="hidden" name="action" value="file_allow">
                        <input type="hidden" name="path" value="<?= h($fmAllow) ?>">
                        <input type="text" name="fm_hp_user" autocomplete="username" style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;" tabindex="-1" aria-hidden="true"><input type="password" name="fm_hp_pass" autocomplete="new-password" style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;" tabindex="-1" aria-hidden="true">
                        <div class="grid grid-3">
                            <div><label for="fa2_password">비밀번호</label><input type="password" id="fa2_password" name="password" autocomplete="new-password"></div>
                            <div style="display:flex; align-items:flex-end;">
                                <button type="submit" class="btn-blue" style="margin-bottom:12px;">허용</button>
                                <button type="button" class="btn-gray" style="margin-bottom:12px; margin-left:8px;" onclick="location.href=<?= h(json_encode($_SERVER['PHP_SELF'] . '?tab=files&fpath=' . urlencode($faParent))) ?>">취소</button>
                            </div>
                        </div>
                    </form>

                <?php elseif ($fmPerm !== ''): ?>
                    <?php
                        $fpParent = dirname($fmPerm);
                        $fpParent = ($fpParent === '.' || $fpParent === '') ? '/' : $fpParent;
                    ?>
                    <div class="small" style="margin-bottom:10px;">
                        <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($fpParent) ?>">‹ 목록으로</a>
                        &nbsp;·&nbsp; <span class="mono"><?= h($fmPerm) ?></span>
                    </div>
                    <?php if ($fmPermData && isset($fmPermData['mode_octal'])):
                        $fpIsDir = !empty($fmPermData['is_dir']);
                        $fpMode = (int) octdec((string)$fmPermData['mode_octal']);
                        $fpOwner = (string)($fmPermData['owner'] ?? '');
                        $fpGroup = (string)($fmPermData['group'] ?? '');
                        $fpOwnerOpts = array_values(array_filter((array)($fmPermData['owner_choices'] ?? []), function ($n) use ($fpOwner) { return (string)$n !== $fpOwner; }));
                        $fpGroupOpts = array_values(array_filter((array)($fmPermData['group_choices'] ?? []), function ($n) use ($fpGroup) { return (string)$n !== $fpGroup; }));
                        $fpRows = [
                            ['u', 6, '주인 (' . $fpOwner . ')', false],
                            ['g', 3, '같은 그룹 (' . $fpGroup . ')', false],
                            ['o', 0, '나머지 모두 (쓰기는 줄 수 없음)', true],
                        ];
                    ?>
                        <h3 style="margin:6px 0 4px;">소유·권한 변경 (<?= $fpIsDir ? '폴더' : '파일' ?>)</h3>
                        <div class="small" style="margin-bottom:12px;">
                            지금: <span class="mono"><?= h((string)($fmPermData['mode_str'] ?? '')) ?> (<?= h((string)$fmPermData['mode_octal']) ?>)</span>
                            &nbsp;·&nbsp; 소유 <span class="mono"><?= h($fpOwner) ?>:<?= h($fpGroup) ?></span>
                            <?php if ($fpIsDir): ?><br>이 폴더 하나만 바뀌고, 안에 있는 파일·폴더는 그대로입니다.<?php endif; ?>
                        </div>
                        <?php if (empty($fmPermData['can_change'])): ?>
                            <div class="alert error"><?= h((string)($fmPermData['reason'] ?? '바꿀 수 없습니다.')) ?></div>
                        <?php else: ?>
                        <form method="post" autocomplete="off" onsubmit="return appConfirm(this, '소유·권한을 바꾸시겠습니까?');">
                            <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                            <input type="hidden" name="action" value="file_perm">
                            <input type="hidden" name="path" value="<?= h($fmPerm) ?>">
                            <table style="width:auto; margin-bottom:12px;">
                                <thead><tr><th></th><th>읽기</th><th>쓰기</th><th><?= $fpIsDir ? '들어가기' : '실행' ?></th></tr></thead>
                                <tbody>
                                <?php foreach ($fpRows as [$who, $shift, $label, $otherRow]): ?>
                                    <tr>
                                        <td><?= h($label) ?></td>
                                        <td style="text-align:center;"><input type="checkbox" name="pm_<?= $who ?>r" value="1" style="width:18px; height:18px;" <?= ($fpMode & (4 << $shift)) ? 'checked' : '' ?>></td>
                                        <td style="text-align:center;"><input type="checkbox" name="pm_<?= $who ?>w" value="1" style="width:18px; height:18px;" <?= ($fpMode & (2 << $shift)) ? 'checked' : '' ?> <?= $otherRow ? 'disabled' : '' ?>></td>
                                        <td style="text-align:center;"><input type="checkbox" name="pm_<?= $who ?>x" value="1" style="width:18px; height:18px;" <?= ($fpMode & (1 << $shift)) ? 'checked' : '' ?>></td>
                                    </tr>
                                <?php endforeach; ?>
                                </tbody>
                            </table>
                            <div class="inline-actions" style="margin-bottom:14px; gap:18px;">
                                <label class="small">주인
                                    <select name="owner" style="padding:6px 8px; border:1px solid #cfd7e3; border-radius:8px; min-width:160px;">
                                        <option value="<?= h($fpOwner) ?>" selected><?= h($fpOwner) ?> (지금)</option>
                                        <?php foreach ($fpOwnerOpts as $n): ?><option value="<?= h((string)$n) ?>"><?= h((string)$n) ?></option><?php endforeach; ?>
                                    </select>
                                </label>
                                <label class="small">그룹
                                    <select name="group" style="padding:6px 8px; border:1px solid #cfd7e3; border-radius:8px; min-width:160px;">
                                        <option value="<?= h($fpGroup) ?>" selected><?= h($fpGroup) ?> (지금)</option>
                                        <?php foreach ($fpGroupOpts as $n): ?><option value="<?= h((string)$n) ?>"><?= h((string)$n) ?></option><?php endforeach; ?>
                                    </select>
                                </label>
                            </div>
                            <div class="inline-actions">
                                <button type="submit" class="btn-blue">적용</button>
                                <button type="button" class="btn-gray" onclick="location.href=<?= h(json_encode($_SERVER['PHP_SELF'] . '?tab=files&fpath=' . urlencode($fpParent))) ?>">취소</button>
                            </div>
                        </form>
                        <?php endif; ?>
                    <?php else: ?>
                        <?php if (is_array($fmPermErr['json'] ?? null) && isset($fmPermErr['json']['message'])): ?>
                            <div class="alert error"><?= h((string)$fmPermErr['json']['message']) ?></div>
                        <?php else: ?>
                            <pre><?= h(pretty_json($fmPermErr)) ?></pre>
                        <?php endif; ?>
                    <?php endif; ?>

                <?php elseif ($fmFile !== ''): ?>
                    <?php
                        $fmParentDir = dirname($fmFile);
                        $fmParentDir = ($fmParentDir === '.' || $fmParentDir === '') ? '/' : $fmParentDir;
                    ?>
                    <div class="small" style="margin-bottom:10px;">
                        <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($fmParentDir) ?>">‹ 목록으로</a>
                        &nbsp;·&nbsp; <span class="mono"><?= h($fmFile) ?></span>
                    </div>

                    <?php if ($fmReadErr && is_array($fmReadErr['json'] ?? null) && ($fmReadErr['json']['error'] ?? '') === 'not_allowed'): ?>
                        <div class="alert error"><?= h((string)$fmReadErr['json']['message']) ?></div>
                        <a class="btn-blue" style="text-decoration:none; display:inline-block;" href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fallow=' . urlencode($fmParentDir) ?>">이 폴더 허용하기</a>
                    <?php elseif ($fmReadData && isset($fmReadData['content'])): ?>
                        <?php if (empty($fmReadData['editable'])): ?>
                            <div class="alert error" style="margin-bottom:10px;">파일이 3MB보다 커서 미리보기만 가능하다 (수정/저장 불가).</div>
                        <?php endif; ?>
                        <form method="post" autocomplete="off" id="fmEditForm" onsubmit="return appConfirm(this, '이 파일을 저장하시겠습니까? 저장 전 원본은 자동으로 백업됩니다.');">
                            <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                            <input type="hidden" name="action" value="file_write">
                            <input type="hidden" name="path" value="<?= h($fmFile) ?>">
                            <textarea name="content" id="fmEditor" spellcheck="false" style="width:100%; min-height:480px; font-family:Consolas,Monaco,monospace; font-size:13px; padding:14px; border:1px solid #cfd7e3; border-radius:10px;" <?= empty($fmReadData['editable']) ? 'readonly' : '' ?>><?= h((string)$fmReadData['content']) ?></textarea>
                            <div class="inline-actions" style="margin-top:10px;">
                                <?php if (!empty($fmReadData['editable'])): ?>
                                    <button type="submit" class="btn-blue">저장 (자동 백업 후 덮어쓰기)</button>
                                <?php endif; ?>
                                <span class="small">크기: <?= h((string)($fmReadData['size_bytes'] ?? '0')) ?> bytes</span>
                            </div>
                        </form>
                    <?php else: ?>
                        <pre><?= h(pretty_json($fmReadErr)) ?></pre>
                    <?php endif; ?>

                <?php else: ?>
                    <?php if ($fmListData && isset($fmListData['entries']) && is_array($fmListData['entries'])):
                        $fmCurPath = ($fmListData['path'] ?? '') !== '' ? $fmListData['path'] : '/';
                    ?>
                        <div class="small" style="margin-bottom:10px;">
                            <span class="mono"><?= h($fmCurPath) ?></span>
                            <?php if ($fmCurPath !== '/'): ?>
                                &nbsp;·&nbsp;
                                <?php $fmUpDir = dirname($fmCurPath); $fmUpDir = ($fmUpDir === '.' || $fmUpDir === '') ? '/' : $fmUpDir; ?>
                                <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($fmUpDir) ?>">‹ 상위 폴더</a>
                            <?php endif; ?>
                        </div>
                        <?php if (empty($fmListData['entries'])): ?>
                            <div class="small">빈 폴더입니다.</div>
                        <?php else: ?>
                        <table>
                            <thead><tr><th>이름</th><th>크기</th><th>수정시각</th><th>소유(주인:그룹)</th><th>권한</th><th>변경</th></tr></thead>
                            <tbody>
                            <?php foreach ($fmListData['entries'] as $entryRow):
                                $entryRel = rtrim($fmCurPath, '/') . '/' . (string)($entryRow['name'] ?? '');
                                $entryState = (string)($entryRow['state'] ?? ($entryRow['blocked'] ?? false ? 'deny' : 'write'));
                                $entryDeny = $entryState === 'deny';
                                $entryLocked = $entryState === 'locked';
                            ?>
                                <tr<?= ($entryDeny || $entryLocked) ? ' style="opacity:.6;"' : '' ?>>
                                    <td>
                                        <?php if ($entryDeny): ?>
                                            🔒 <span title="시스템 영역 — 절대 허용 불가"><?= h((string)($entryRow['name'] ?? '')) ?></span>
                                        <?php elseif ($entryLocked && !empty($entryRow['is_dir'])): ?>
                                            🔒 <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($entryRel) ?>" title="허용되지 않은 폴더 — 눌러서 안을 볼 수는 있음"><?= h((string)($entryRow['name'] ?? '')) ?></a>
                                        <?php elseif ($entryLocked): ?>
                                            🔒 <span title="허용되지 않은 폴더의 파일"><?= h((string)($entryRow['name'] ?? '')) ?></span>
                                        <?php elseif (!empty($entryRow['is_dir'])): ?>
                                            📁 <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($entryRel) ?>"><?= h((string)($entryRow['name'] ?? '')) ?></a>
                                        <?php else: ?>
                                            📄 <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&ffile=' . urlencode($entryRel) ?>"<?= file_name_style((string)($entryRow['name'] ?? '')) ?>><?= h((string)($entryRow['name'] ?? '')) ?></a>
                                        <?php endif; ?>
                                    </td>
                                    <td><?= $entryDeny ? '—' : h((string)($entryRow['size_human'] ?? '')) ?></td>
                                    <td class="small"><?= $entryDeny ? '—' : h((string)($entryRow['mtime'] ?? '')) ?></td>
                                    <?php
                                        $epOwner = h((string)($entryRow['owner'] ?? '?')) . ':' . h((string)($entryRow['group'] ?? '?'));
                                        $epMode = h((string)($entryRow['mode_str'] ?? '')) . ' <span class="small">(' . h((string)($entryRow['mode_octal'] ?? '')) . ')</span>';
                                        $epHref = h($_SERVER['PHP_SELF']) . '?tab=files&fperm=' . urlencode($entryRel);
                                        $epLink = 'style="text-decoration:none; color:inherit; border-bottom:1px dashed var(--muted);" title="눌러서 소유·권한 변경"';
                                    ?>
                                    <?php if ($entryDeny): ?>
                                        <td>—</td><td>—</td><td class="small">🔒 접근 불가</td>
                                    <?php elseif ($entryLocked): ?>
                                        <td>—</td><td>—</td>
                                        <td class="small">
                                            🔒 허용 안 됨
                                            <?php if (!empty($entryRow['can_allow'])): ?>
                                                &nbsp;<a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fallow=' . urlencode($entryRel) ?>">허용</a>
                                            <?php endif; ?>
                                        </td>
                                    <?php elseif (!empty($entryRow['can_change'])): ?>
                                        <td><a class="mono small" href="<?= $epHref ?>" <?= $epLink ?>><?= $epOwner ?></a></td>
                                        <td><a class="mono small" href="<?= $epHref ?>" <?= $epLink ?>><?= $epMode ?></a></td>
                                        <td class="small" style="color:var(--ok); font-weight:700;">가능</td>
                                    <?php else: ?>
                                        <td><span class="mono small" style="color:var(--muted);"><?= $epOwner ?></span></td>
                                        <td><?= !empty($entryRow['is_symlink']) ? '<span class="small" style="color:var(--muted);">링크</span>' : '<span class="mono small" style="color:var(--muted);">' . $epMode . '</span>' ?></td>
                                        <td class="small" style="color:var(--muted);" title="<?= h((string)($entryRow['reason'] ?? '')) ?>">불가</td>
                                    <?php endif; ?>
                                </tr>
                            <?php endforeach; ?>
                            </tbody>
                        </table>
                        <?php endif; ?>
                    <?php else: ?>
                        <pre><?= h(pretty_json($fmListErr)) ?></pre>
                    <?php endif; ?>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'svclogs' ? 'active' : '' ?>" id="panel-svclogs">

            <div class="card">
                <h2>현재 차단(Ban) 상태</h2>
                <div class="desc">fail2ban-client 실시간 조회 — 지금 이 순간 차단 중인 IP</div>

                <form method="post" autocomplete="off" style="margin-bottom:16px;" onsubmit="return appConfirm(this, '이 IP의 차단을 해제하시겠습니까?');">
                    <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                    <input type="hidden" name="action" value="fail2ban_unban">
                    <div class="grid grid-3">
                        <div>
                            <label for="unban_ip">차단 해제할 IP</label>
                            <input type="text" id="unban_ip" name="unban_ip" placeholder="1.2.3.4">
                        </div>
                        <div>
                            <label for="unban_jail">Jail (비우면 전체에서 시도)</label>
                            <select id="unban_jail" name="unban_jail">
                                <option value="">전체</option>
                                <?php if ($fail2banData && isset($fail2banData['jails']) && is_array($fail2banData['jails'])): ?>
                                    <?php foreach ($fail2banData['jails'] as $jailRow): ?>
                                        <option value="<?= h((string)($jailRow['jail'] ?? '')) ?>"><?= h((string)($jailRow['jail'] ?? '')) ?></option>
                                    <?php endforeach; ?>
                                <?php endif; ?>
                            </select>
                        </div>
                        <div style="display:flex; align-items:flex-end;">
                            <button type="submit" class="btn-blue" style="margin-bottom:12px;">차단 해제</button>
                        </div>
                    </div>
                </form>

                <?php if ($fail2banData && isset($fail2banData['jails']) && is_array($fail2banData['jails'])): ?>
                    <?php
                        $totalBanned = 0;
                        foreach ($fail2banData['jails'] as $jailRow) {
                            $totalBanned += (int)($jailRow['currently_banned'] ?? 0);
                        }
                    ?>
                    <?php if ($totalBanned === 0): ?>
                        <div class="small">지금 차단 중인 IP가 없습니다.</div>
                    <?php else: ?>
                        <details>
                            <summary style="cursor:pointer; font-weight:700; font-size:13.5px;">
                                현재 <span style="color:var(--bad);"><?= h((string)$totalBanned) ?>개 IP</span> 차단 중 — 클릭해서 목록 보기
                            </summary>
                            <table style="margin-top:10px;">
                                <thead><tr><th>Jail</th><th style="white-space:nowrap; text-align:right;">차단 수</th><th>IP 목록</th></tr></thead>
                                <tbody>
                                <?php foreach ($fail2banData['jails'] as $jailRow): ?>
                                    <?php if ((int)($jailRow['currently_banned'] ?? 0) > 0): ?>
                                        <tr>
                                            <td><b><?= h((string)($jailRow['jail'] ?? '')) ?></b></td>
                                            <td style="text-align:right;"><?= h((string)($jailRow['currently_banned'] ?? '0')) ?></td>
                                            <td class="small mono"><?= h(implode(', ', $jailRow['banned_ips'] ?? [])) ?></td>
                                        </tr>
                                    <?php endif; ?>
                                <?php endforeach; ?>
                                </tbody>
                            </table>
                        </details>
                    <?php endif; ?>
                <?php else: ?>
                    <div class="small">fail2ban 상태를 불러오지 못했습니다.</div>
                <?php endif; ?>
            </div>

            <div class="card section">
                <h2>서버 로그</h2>
                <div class="desc">메일 서버가 실제로 기록 중인 로그 파일을 원클릭으로 조회 (읽기 전용, 파일당 최근 500줄까지)</div>

                <div class="quick-grid svclog-grid">
                    <?php if ($logSourcesData && isset($logSourcesData['sources']) && is_array($logSourcesData['sources'])): ?>
                        <?php foreach ($logSourcesData['sources'] as $srcRow): ?>
                            <?php if (!empty($srcRow['exists'])): ?>
                                <a class="quick-btn" style="text-decoration:none; <?= $logKey === (string)($srcRow['key'] ?? '') ? 'outline:2px solid var(--accent);' : '' ?>"
                                   href="<?= h($_SERVER['PHP_SELF']) . '?tab=svclogs&logkey=' . urlencode((string)($srcRow['key'] ?? '')) . '&loglines=' . urlencode($logLinesParam) ?>">
                                    <span class="icon-badge badge-blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg></span>
                                    <?= h((string)($srcRow['label'] ?? '')) ?>
                                    <span class="small"><?= h((string)($srcRow['size_human'] ?? '')) ?></span>
                                </a>
                            <?php else: ?>
                                <div class="quick-btn" style="opacity:.4; cursor:not-allowed;" title="파일 없음">
                                    <span class="icon-badge badge-blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg></span>
                                    <?= h((string)($srcRow['label'] ?? '')) ?>
                                    <span class="small">파일 없음</span>
                                </div>
                            <?php endif; ?>
                        <?php endforeach; ?>
                    <?php else: ?>
                        <div class="small">로그 목록을 불러오지 못했습니다.</div>
                    <?php endif; ?>
                </div>

                <?php if ($logKey !== ''): ?>
                    <div class="section">
                        <?php if ($logTailData && isset($logTailData['lines'])): ?>
                            <div class="small" style="margin-bottom:10px;">
                                <span class="mono"><?= h((string)($logTailData['path'] ?? '')) ?></span>
                                &nbsp;·&nbsp; 총 <?= h((string)($logTailData['size_bytes'] ?? '0')) ?> bytes
                                &nbsp;·&nbsp;
                                <form method="get" style="display:inline-flex; align-items:center; gap:6px;">
                                    <input type="hidden" name="tab" value="svclogs">
                                    <input type="hidden" name="logkey" value="<?= h($logKey) ?>">
                                    <label for="loglines_sel" class="small" style="margin:0;">줄 수:</label>
                                    <select id="loglines_sel" name="loglines" onchange="this.form.requestSubmit();" style="width:auto; margin:0; padding:4px 8px;">
                                        <?php foreach ([50, 100, 200, 500] as $n): ?>
                                            <option value="<?= $n ?>" <?= (string)$n === $logLinesParam ? 'selected' : '' ?>><?= $n ?>줄</option>
                                        <?php endforeach; ?>
                                    </select>
                                </form>
                                &nbsp;·&nbsp;
                                <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=svclogs&logkey=' . urlencode($logKey) . '&loglines=' . urlencode($logLinesParam) ?>">새로고침</a>
                            </div>
                            <pre class="log-box log-box-lg"><?= implode("\n", array_map('highlight_log_line', $logTailData['lines'])) ?></pre>
                        <?php else: ?>
                            <pre><?= h(pretty_json($logTailErr)) ?></pre>
                        <?php endif; ?>
                    </div>
                <?php else: ?>
                    <div class="small section">위에서 로그를 선택하세요.</div>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'connips' ? 'active' : '' ?>" id="panel-connips">
            <div class="card">
                <h2>접속 IP → 차단 보내기</h2>
                <div class="desc">
                    차단할 IP를 클릭하고 jail을 선택한 다음 차단을 누르세요.
                    선택한 jail의 설정대로 차단됩니다. 내부 주소와 SSH로 접속 중인 IP는 🔒 잠깁니다.
                </div>
                <form method="post" autocomplete="off" style="margin-bottom:16px;" onsubmit="return this.ban_ip.value ? appConfirm(this, '이 IP를 선택한 jail로 보내 차단하시겠습니까?') : false;">
                    <input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
                    <input type="hidden" name="action" value="fail2ban_ban">
                    <div class="grid grid-3">
                        <div>
                            <label for="ban_ip">차단할 IP (아래 표에서 눌러 선택)</label>
                            <input type="text" id="ban_ip" name="ban_ip" readonly placeholder="표에서 IP를 누르세요">
                        </div>
                        <div>
                            <label for="ban_jail">보낼 Jail</label>
                            <select id="ban_jail" name="ban_jail">
                                <?php if ($fail2banData && isset($fail2banData['jails']) && is_array($fail2banData['jails'])): ?>
                                    <?php foreach ($fail2banData['jails'] as $jailRow): ?>
                                        <option value="<?= h((string)($jailRow['jail'] ?? '')) ?>"><?= h((string)($jailRow['jail'] ?? '')) ?></option>
                                    <?php endforeach; ?>
                                <?php endif; ?>
                            </select>
                        </div>
                        <div style="display:flex; align-items:flex-end;">
                            <button type="submit" class="btn-red" style="margin-bottom:12px;">차단</button>
                        </div>
                    </div>
                </form>
                <?php if ($connData && isset($connData['ips']) && is_array($connData['ips'])): ?>
                    <?php if (empty($connData['ips'])): ?>
                        <div class="small">지금 접속 중인 외부 IP가 없습니다.</div>
                    <?php else: ?>
                        <div class="small" style="margin-bottom:10px;">지금 접속 중인 IP <b><?= h((string)($connData['total'] ?? count($connData['ips']))) ?></b>개 (연결 수 많은 순)</div>
                        <div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(230px, 1fr)); gap:8px;">
                        <?php foreach ($connData['ips'] as $cRow):
                            $cIp = (string)($cRow['ip'] ?? '');
                            $cWhy = (string)($cRow['block_reason'] ?? '');
                            $cBanned = (array)($cRow['banned_in'] ?? []);
                            $cPorts = array_map('strval', (array)($cRow['ports'] ?? []));
                        ?>
                            <div style="border:1px solid <?= !empty($cBanned) ? 'var(--bad)' : 'var(--line)' ?>; border-radius:10px; padding:8px 10px; opacity:<?= $cWhy !== '' ? '.7' : '1' ?>; min-width:0;">
                                <?php if ($cWhy !== ''): ?>
                                    <span class="mono" style="font-weight:700; word-break:break-all;" title="<?= h($cWhy) ?>">🔒 <?= h($cIp) ?></span>
                                <?php else: ?>
                                    <a class="mono" href="#" title="눌러서 위 칸에 넣기" style="font-weight:700; word-break:break-all; text-decoration:none; border-bottom:1px dashed var(--muted);"
                                       onclick="document.getElementById('ban_ip').value=<?= h(json_encode($cIp)) ?>; document.getElementById('ban_jail').focus(); return false;"><?= h($cIp) ?></a>
                                <?php endif; ?>
                                <div class="small">연결 <b><?= h((string)($cRow['count'] ?? '')) ?></b> · 포트 <?= h(implode(', ', array_slice($cPorts, 0, 6))) ?><?= count($cPorts) > 6 ? ' …' : '' ?></div>
                                <?php if (!empty($cBanned) || $cWhy !== ''): ?>
                                    <div class="small">
                                        <?php if (!empty($cBanned)): ?><span style="color:var(--bad); font-weight:700;">차단 중: <?= h(implode(', ', array_map('strval', $cBanned))) ?></span><?php endif; ?>
                                        <?php if (!empty($cBanned) && $cWhy !== ''): ?> · <?php endif; ?>
                                        <?= h($cWhy) ?>
                                    </div>
                                <?php endif; ?>
                            </div>
                        <?php endforeach; ?>
                        </div>
                        <?php if ((int)($connData['total'] ?? 0) > (int)($connData['shown'] ?? 0)): ?>
                            <div class="small" style="margin-top:8px;">상위 <?= h((string)$connData['shown']) ?>개만 표시 (전체 <?= h((string)$connData['total']) ?>개)</div>
                        <?php endif; ?>
                    <?php endif; ?>
                <?php else: ?>
                    <div class="small">접속 목록을 불러오지 못했습니다<?= is_array($connErr['json'] ?? null) && isset($connErr['json']['message']) ? ': ' . h((string)$connErr['json']['message']) : '' ?>.</div>
                <?php endif; ?>
            </div>
        </div>

        <div class="tab-panel <?= $activeTab === 'seccheck' ? 'active' : '' ?>" id="panel-seccheck">
            <div class="card">
                <h2>보안 점검</h2>
                <div class="desc">
                    탭에 들어올 때마다 실제 서버에서 진단 명령을 실행해 결과를 보여줍니다 (읽기 전용).
                    각 항목의 힌트를 참고해서 낯선/이상한 게 있는지 눈으로 확인하세요.
                </div>
                <div class="inline-actions" style="margin-bottom:6px; align-items:center;">
                    <form method="get" style="display:inline-flex; align-items:center; gap:6px;">
                        <input type="hidden" name="tab" value="seccheck">
                        <label for="seclines_sel" class="small" style="margin:0;">항목별 줄 수:</label>
                        <select id="seclines_sel" name="seclines" onchange="this.form.requestSubmit();" style="width:auto; margin:0; padding:6px 10px;">
                            <?php foreach ([50, 100, 200, 500] as $n): ?>
                                <option value="<?= $n ?>" <?= (string)$n === $secLinesParam ? 'selected' : '' ?>><?= $n ?>줄</option>
                            <?php endforeach; ?>
                        </select>
                        <button type="submit" class="btn-blue">다시 실행</button>
                    </form>
                </div>

                <?php if ($securityChecksData && isset($securityChecksData['checks']) && is_array($securityChecksData['checks'])): ?>
                    <?php foreach ($securityChecksData['checks'] as $checkRow): ?>
                        <div class="section" style="border-top:1px solid var(--line); padding-top:14px;">
                            <div style="font-weight:800; font-size:14px; margin-bottom:2px;"><?= h((string)($checkRow['label'] ?? '')) ?></div>
                            <div class="small" style="margin-bottom:8px;">⚑ <?= h((string)($checkRow['hint'] ?? '')) ?></div>
                            <pre class="log-box log-box-lg"><?= implode("\n", array_map('highlight_log_line', $checkRow['lines'] ?? [])) ?></pre>
                        </div>
                    <?php endforeach; ?>
                <?php else: ?>
                    <div class="small">보안 점검 결과를 불러오지 못했습니다.</div>
                <?php endif; ?>
            </div>
        </div>

    </main>
</div>

<div id="appConfirmOverlay" style="display:none; position:fixed; inset:0; background:rgba(15,20,30,0.55); z-index:9999; align-items:center; justify-content:center; padding:20px;">
    <div style="background:var(--panel); color:var(--ink); border-radius:14px; padding:24px; max-width:400px; width:100%; box-shadow:0 24px 64px rgba(0,0,0,0.35); border:1px solid var(--line);">
        <div id="appConfirmMsg" style="margin-bottom:22px; font-size:14.5px; line-height:1.55; text-align:center;"></div>
        <div style="display:flex; gap:8px; justify-content:center;">
            <button type="button" class="btn-gray" onclick="appConfirmCancel()">취소</button>
            <button type="button" class="btn-red" onclick="appConfirmOk()">확인</button>
        </div>
    </div>
</div>

<!-- =====================================================================
     화면 동작 스크립트 (JS)
     탭 전환, 비밀번호 칸 자동 채움, 확인 창(appConfirm) 같은 화면 안에서만
     일어나는 동작들이다. 서버에 진짜로 값을 바꾸는 요청(POST)은 전부 위의
     <form> 들이 보내고, 이 스크립트는 그 폼을 채우거나 확인만 받는다.
===================================================================== -->
<script>
function fillPassword(email) {
    switchTab('security');
    const el = document.getElementById('pw_email');
    if (el) {
        el.value = email;
        setTimeout(function () {
            window.scrollTo({ top: document.getElementById('passwordForm').offsetTop - 20, behavior: 'smooth' });
            document.getElementById('pw_new').focus();
        }, 30);
    }
}

function fillTestLogin(email) {
    switchTab('security');
    const el = document.getElementById('test_email');
    if (el) {
        el.value = email;
        setTimeout(function () {
            window.scrollTo({ top: document.getElementById('testLoginForm').offsetTop - 20, behavior: 'smooth' });
            document.getElementById('test_password').focus();
        }, 30);
    }
}

const TABS_NEEDING_RELOAD = ['files', 'svclogs', 'connips', 'seccheck'];
const TAB_LABELS = <?= json_encode(array_map(function ($t) { return $t['label']; }, $TABS), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;

let appConfirmCallback = null;

function appConfirm(form, message) {
    appConfirmCallback = function (ok) {
        if (ok) {
            form.submit();
        }
    };
    document.getElementById('appConfirmMsg').textContent = message;
    document.getElementById('appConfirmOverlay').style.display = 'flex';
    return false;
}

function appConfirmOk() {
    document.getElementById('appConfirmOverlay').style.display = 'none';
    const cb = appConfirmCallback;
    appConfirmCallback = null;
    if (cb) cb(true);
}

function appConfirmCancel() {
    document.getElementById('appConfirmOverlay').style.display = 'none';
    appConfirmCallback = null;
}

document.addEventListener('DOMContentLoaded', function () {
    const overlay = document.getElementById('appConfirmOverlay');
    if (overlay) {
        overlay.addEventListener('click', function (e) {
            if (e.target === overlay) {
                appConfirmCancel();
            }
        });
    }
});

function switchTab(tab) {
    if (TABS_NEEDING_RELOAD.includes(tab)) {
        const url = new URL(window.location.href);
        url.searchParams.set('tab', tab);
        url.searchParams.delete('fpath');
        url.searchParams.delete('ffile');
        url.searchParams.delete('fperm');
        url.searchParams.delete('logkey');
        window.location.href = url.toString();
        return;
    }

    document.querySelectorAll('.tab-panel').forEach(function (el) {
        el.classList.toggle('active', el.id === 'panel-' + tab);
    });
    document.querySelectorAll('.nav-item').forEach(function (el) {
        el.classList.toggle('active', el.getAttribute('data-tab') === tab);
    });
    const url = new URL(window.location.href);
    url.searchParams.set('tab', tab);
    window.history.replaceState({}, '', url);
    const titleEl = document.querySelector('.topbar .page-title');
    if (titleEl && TAB_LABELS[tab]) {
        titleEl.textContent = TAB_LABELS[tab];
    }
}

document.addEventListener('DOMContentLoaded', function () {
    document.querySelectorAll('.nav-item[data-tab]').forEach(function (btn) {
        btn.addEventListener('click', function () {
            switchTab(btn.getAttribute('data-tab'));
        });
    });

    const pwNew = document.getElementById('pw_new');
    if (pwNew) {
        pwNew.addEventListener('keydown', function (e) {
            if (e.key === 'Enter') {
                e.preventDefault();
                document.getElementById('passwordForm').requestSubmit();
            }
        });
    }

    const userMenuBtn = document.getElementById('userMenuBtn');
    const userDropdown = document.getElementById('userDropdown');
    if (userMenuBtn && userDropdown) {
        userMenuBtn.addEventListener('click', function (e) {
            e.stopPropagation();
            userDropdown.classList.toggle('open');
        });
    }

    const notifBtn = document.getElementById('notifBtn');
    const notifDropdown = document.getElementById('notifDropdown');
    if (notifBtn && notifDropdown) {
        notifBtn.addEventListener('click', function (e) {
            e.stopPropagation();
            notifDropdown.classList.toggle('open');
        });
    }

    document.addEventListener('click', function () {
        if (userDropdown) userDropdown.classList.remove('open');
        if (notifDropdown) notifDropdown.classList.remove('open');
    });

    const themeBtn = document.getElementById('themeBtn');
    if (themeBtn) {
        const applyTheme = function (isDark) {
            document.body.classList.toggle('dark', isDark);
            themeBtn.title = isDark ? '라이트모드 전환' : '다크모드 전환';
        };
        applyTheme(localStorage.getItem('mailadmin-theme') === 'dark');
        themeBtn.addEventListener('click', function () {
            const next = !document.body.classList.contains('dark');
            applyTheme(next);
            localStorage.setItem('mailadmin-theme', next ? 'dark' : 'light');
        });
    }

    const fmEditor = document.getElementById('fmEditor');
    const fmEditForm = document.getElementById('fmEditForm');
    if (fmEditor && !fmEditor.readOnly) {
        let fmDirty = false;
        const fmInitial = fmEditor.value;

        fmEditor.addEventListener('input', function () {
            fmDirty = (fmEditor.value !== fmInitial);
        });

        window.addEventListener('beforeunload', function (e) {
            if (fmDirty) {
                e.preventDefault();
                e.returnValue = '';
            }
        });

        if (fmEditForm) {
            fmEditForm.addEventListener('submit', function () {
                fmDirty = false;
            });
        }
    }
});
</script>
</body>
</html>
<?php endif; ?>

간단히 구조만 짚어보면:

  • 로그인 처리 (handle_ui_login): 화면 자체의 로그인(아이디·비밀번호)을 확인합니다. API 키와는 별개의 문입니다.
  • 저장 동작(POST) 처리: switch ($action) 한 곳에서 도메인 추가, 계정 삭제, 파일 저장, IP 차단 같은 모든 “바꾸는 요청"을 나눠서 처리합니다. CSRF 토큰 검사를 반드시 통과해야 합니다.
  • api_request / api_get_json / api_post_json: 뒷단 API 서버(mailapi.py)에 실제로 요청을 보내는 함수입니다. 이 화면의 모든 데이터는 결국 이 함수를 거쳐서 옵니다.
  • 탭 화면들: $activeTab 값에 따라 대시보드, 도메인, 계정, 파일 탐색기, 접속 IP 같은 탭 하나만 보여주고 나머지는 숨깁니다.
  • 파일 탐색기의 허용(file_allow) 동작: 이 화면에서 가장 민감한 기능입니다. 화면(PHP)은 형식만 가볍게 확인하고, 진짜 검사(비밀번호가 맞는지, 경로가 시스템 영역인지)는 전부 API 서버가 다시 합니다.

AI의 도움으로 작성되었습니다.