diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..8d27e7f 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,598 @@ +:root { + --bg: #f3f5f7; + --surface: #ffffff; + --surface-muted: #f8fafc; + --surface-soft: #eef2f6; + --border: #d6dde6; + --border-strong: #c4ced8; + --text: #0f172a; + --muted: #5b6b7d; + --primary: #0f172a; + --accent: #2563eb; + --accent-soft: #dbe8ff; + --success-bg: #eaf7ee; + --success-text: #17603a; + --warning-bg: #fff5e8; + --warning-text: #a65a00; + --danger-bg: #fdeeee; + --danger-text: #b42318; + --idle-bg: #edf1f5; + --idle-text: #4b5f74; + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05); + --shadow-md: 0 12px 30px rgba(15, 23, 42, 0.06); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --container-max: 1320px; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; margin: 0; min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.5; } -.main-wrapper { - display: flex; +a { + color: inherit; + text-decoration: none; +} + +a:hover { + color: inherit; +} + +img { + max-width: 100%; + display: block; +} + +.app-shell { + max-width: var(--container-max); +} + +.app-nav { + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(12px); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; +} + +.brand-mark { + width: 2.25rem; + height: 2.25rem; + border-radius: 10px; + background: var(--primary); + color: #fff; + display: inline-flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; -} - -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; -} - -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); + font-size: 0.8rem; font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; + letter-spacing: 0.06em; } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; +.brand-copy small, +.nav-note, +.helper-text, +.form-text, +.meta-copy, +.section-subtitle, +.page-eyebrow, +.footer-copy, +.overline { + color: var(--muted); } -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.table td { - background: #fff; - padding: 1rem; - border: none; -} - -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { +.brand-copy strong { display: block; - margin-bottom: 0.5rem; + font-size: 0.95rem; + line-height: 1.15; +} + +.btn { + border-radius: var(--radius-sm); font-weight: 600; + letter-spacing: 0.01em; +} + +.btn-primary { + background: var(--primary); + border-color: var(--primary); +} + +.btn-primary:hover, +.btn-primary:focus { + background: #020617; + border-color: #020617; +} + +.btn-outline-secondary { + border-color: var(--border-strong); + color: var(--text); +} + +.btn-outline-secondary:hover, +.btn-outline-secondary:focus { + background: #fff; + border-color: var(--primary); + color: var(--primary); +} + +.hero-grid { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.hero-card, +.section-card, +.metric-card, +.message-item, +.stat-chip, +.detail-block, +.mail-preview, +.quick-step, +.account-row { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.hero-card, +.section-card, +.mail-preview, +.detail-block { + padding: 1.25rem; +} + +.hero-title { + font-size: clamp(1.9rem, 3vw, 2.65rem); + line-height: 1.05; + letter-spacing: -0.04em; + margin: 0 0 0.9rem; +} + +.section-title { + font-size: 1.1rem; + margin: 0; +} + +.page-eyebrow, +.overline { + text-transform: uppercase; + font-size: 0.73rem; + letter-spacing: 0.08em; + font-weight: 700; +} + +.hero-copy p, +.section-subtitle { + max-width: 56rem; + margin-bottom: 0; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.1rem; +} + +.metrics-grid { + display: grid; + gap: 0.9rem; +} + +.metric-card { + padding: 1rem; +} + +.metric-label { + color: var(--muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 700; +} + +.metric-value { + font-size: 1.55rem; + font-weight: 700; + margin-top: 0.35rem; + letter-spacing: -0.03em; +} + +.metric-hint { + margin-top: 0.35rem; + color: var(--muted); font-size: 0.9rem; } -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; +.surface-muted { + background: var(--surface-muted); +} + +.form-label { + font-size: 0.88rem; + font-weight: 600; + margin-bottom: 0.4rem; +} + +.form-control, +.form-select { + border-radius: var(--radius-sm); + border-color: var(--border-strong); background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; + padding-top: 0.7rem; + padding-bottom: 0.7rem; + font-size: 0.95rem; } -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); +.form-control:focus, +.form-select:focus, +.message-search input:focus, +.btn:focus, +.nav-link:focus, +.list-link:focus { + border-color: var(--accent); + box-shadow: 0 0 0 0.25rem rgba(37, 99, 235, 0.12); } -.header-container { - display: flex; - justify-content: space-between; +.form-check-input:checked { + background-color: var(--primary); + border-color: var(--primary); +} + +.validation-note { + font-size: 0.82rem; + color: var(--danger-text); + margin-top: 0.35rem; +} + +.inline-note { + display: inline-flex; align-items: center; + gap: 0.45rem; + background: var(--surface-soft); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.35rem 0.7rem; + font-size: 0.84rem; + color: var(--muted); } -.header-links { - display: flex; - gap: 1rem; +.stack-sm > * + * { + margin-top: 0.75rem; } -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); -} - -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; -} - -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; -} - -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; +.stack-md > * + * { margin-top: 1rem; } -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; +.status-badge, +.soft-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + padding: 0.32rem 0.65rem; + border: 1px solid transparent; } -.webhook-url { - font-size: 0.85em; - color: #555; +.status-success { + background: var(--success-bg); + color: var(--success-text); + border-color: #cce7d4; +} + +.status-warning { + background: var(--warning-bg); + color: var(--warning-text); + border-color: #f3dfbe; +} + +.status-danger { + background: var(--danger-bg); + color: var(--danger-text); + border-color: #f2ceca; +} + +.status-idle { + background: var(--idle-bg); + color: var(--idle-text); + border-color: #d9e2ea; +} + +.soft-badge { + background: var(--surface-soft); + border-color: var(--border); + color: var(--muted); +} + +.table-shell { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.table { + margin-bottom: 0; +} + +.table thead th { + background: var(--surface-muted); + border-bottom-color: var(--border); + color: var(--muted); + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 700; +} + +.table td, +.table th { + padding: 0.95rem 1rem; + vertical-align: middle; +} + +.table tbody tr:hover { + background: rgba(15, 23, 42, 0.025); +} + +.account-meta, +.meta-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.meta-list { + flex-direction: column; + gap: 0.6rem; +} + +.meta-list strong { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + display: block; + margin-bottom: 0.15rem; +} + +.quick-steps { + display: grid; + gap: 0.75rem; +} + +.quick-step { + padding: 0.95rem 1rem; +} + +.quick-step h3 { + margin: 0 0 0.3rem; + font-size: 0.98rem; +} + +.quick-step p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.mailbox-shell { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 1rem; +} + +.mailbox-list { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.message-search { + margin-bottom: 0.85rem; +} + +.message-search input { + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 0.72rem 0.9rem; + font-size: 0.95rem; +} + +.message-item { + display: block; + padding: 0.9rem 1rem; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} + +.message-item:hover { + border-color: var(--primary); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.message-item.active { + border-color: var(--primary); + background: #f8fbff; +} + +.message-subject { + font-weight: 700; + margin: 0 0 0.25rem; +} + +.message-from, +.message-date, +.message-preview, +.empty-copy, +.help-list li, +.kicker { + color: var(--muted); +} + +.message-from, +.message-date { + font-size: 0.86rem; +} + +.message-preview { + font-size: 0.88rem; margin-top: 0.5rem; } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); +.message-body { + white-space: pre-wrap; + word-break: break-word; + font-size: 0.95rem; + line-height: 1.65; } -.history-table { - width: 100%; +.message-header-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 1rem; } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.detail-block { + padding: 0.95rem 1rem; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.detail-block strong { + display: block; + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 0.2rem; } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; -} - -.no-messages { +.empty-panel { text-align: center; - color: #777; -} \ No newline at end of file + padding: 2rem 1.2rem; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--surface-muted); +} + +.nav-pills .nav-link { + border-radius: 999px; + color: var(--muted); + border: 1px solid transparent; + font-weight: 600; +} + +.nav-pills .nav-link.active { + background: var(--primary); + color: #fff; +} + +.nav-pills .nav-link.disabled { + border-color: var(--border); + color: var(--muted); + background: var(--surface-muted); +} + +.toast-shell { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1080; +} + +.toast { + border-radius: var(--radius-md); + border: 1px solid var(--border); + box-shadow: var(--shadow-md); +} + +.footer-copy { + font-size: 0.9rem; +} + +.help-list { + margin: 0; + padding-left: 1rem; +} + +.help-list li + li { + margin-top: 0.4rem; +} + +@media (max-width: 991.98px) { + .hero-grid, + .mailbox-shell, + .message-header-grid { + grid-template-columns: 1fr; + } + + .app-nav { + padding: 0.85rem 0.95rem; + } +} + +@media (max-width: 767.98px) { + .hero-card, + .section-card, + .mail-preview, + .detail-block { + padding: 1rem; + } + + .table-shell { + border: 0; + overflow: visible; + } + + .table thead { + display: none; + } + + .table, + .table tbody, + .table tr, + .table td { + display: block; + width: 100%; + } + + .table tbody tr { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); + margin-bottom: 0.85rem; + } + + .table td { + padding: 0.85rem 1rem 0; + border: 0; + } + + .table td:last-child { + padding-bottom: 1rem; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..d962ab0 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,54 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); - - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - }; - - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; - - appendMessage(message, 'visitor'); - chatInput.value = ''; - - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) + if (window.bootstrap) { + document.querySelectorAll('.toast').forEach((toastEl) => { + const toast = new bootstrap.Toast(toastEl, { + delay: 4200, }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + toast.show(); + }); + } + + const searchInput = document.querySelector('[data-mail-search]'); + const messageItems = Array.from(document.querySelectorAll('[data-mail-item]')); + const searchEmpty = document.querySelector('[data-mail-empty]'); + + if (searchInput && messageItems.length) { + const applyFilter = () => { + const term = searchInput.value.trim().toLowerCase(); + let visibleCount = 0; + + messageItems.forEach((item) => { + const haystack = (item.getAttribute('data-search') || '').toLowerCase(); + const matches = term === '' || haystack.includes(term); + item.classList.toggle('d-none', !matches); + if (matches) { + visibleCount += 1; + } + }); + + if (searchEmpty) { + searchEmpty.classList.toggle('d-none', visibleCount > 0); + } + }; + + searchInput.addEventListener('input', applyFilter); + applyFilter(); + } + + document.querySelectorAll('[data-security-select]').forEach((select) => { + const portInput = document.querySelector(select.getAttribute('data-port-target')); + if (!portInput) { + return; } + + select.addEventListener('change', () => { + const currentValue = portInput.value.trim(); + if (select.value === 'ssl' && (currentValue === '' || currentValue === '110')) { + portInput.value = '995'; + } + if (select.value === 'plain' && (currentValue === '' || currentValue === '995')) { + portInput.value = '110'; + } + }); }); }); diff --git a/assets/pasted-20260524-070702-ebe35df4.png b/assets/pasted-20260524-070702-ebe35df4.png new file mode 100644 index 0000000..6319fe1 Binary files /dev/null and b/assets/pasted-20260524-070702-ebe35df4.png differ diff --git a/db/config.php b/db/config.php index f135f1f..2192d13 100644 --- a/db/config.php +++ b/db/config.php @@ -1,21 +1,108 @@ DB_HOST, + 'port' => DB_PORT, + 'name' => DB_NAME, + 'user' => DB_USER, + 'has_password' => DB_PASS !== '', + ]; } -function db() { - static $pdo; - if (!$pdo) { - $pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); - } - return $pdo; +function db_dsn(?string $database = null): string +{ + $dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';charset=utf8mb4'; + + if ($database !== null && $database !== '') { + $dsn .= ';dbname=' . $database; + } + + return $dsn; +} + +function db_options(): array +{ + return [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; +} + +function db(): PDO +{ + static $pdo; + + if (!$pdo) { + try { + $pdo = new PDO(db_dsn(DB_NAME), DB_USER, DB_PASS, db_options()); + } catch (Throwable $exception) { + throw new RuntimeException(db_human_error($exception), 0, $exception); + } + } + + return $pdo; +} + +function db_server(): PDO +{ + try { + return new PDO(db_dsn(null), DB_USER, DB_PASS, db_options()); + } catch (Throwable $exception) { + throw new RuntimeException(db_human_error($exception), 0, $exception); + } +} + +function db_initialize(string $migrationFile): void +{ + $server = db_server(); + $databaseName = str_replace('`', '``', DB_NAME); + $server->exec("CREATE DATABASE IF NOT EXISTS `{$databaseName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + + $pdo = new PDO(db_dsn(DB_NAME), DB_USER, DB_PASS, db_options()); + $sql = file_get_contents($migrationFile); + + if ($sql === false) { + throw new RuntimeException('Ne mogu pročitati SQL migraciju za mailbox tablicu.'); + } + + $pdo->exec($sql); +} + +function db_human_error(Throwable $exception): string +{ + $message = trim($exception->getMessage()); + $messageLower = strtolower($message); + + if (str_contains($messageLower, 'unknown database')) { + return 'MySQL radi, ali baza `' . DB_NAME . '` još ne postoji. Pokreni `xampp-setup.php` ili je kreiraj u phpMyAdminu. Detalj: ' . $message; + } + + if (str_contains($messageLower, 'access denied')) { + return 'MySQL je dostupan, ali korisničko ime ili lozinka nisu prihvaćeni. Provjeri `db/config.php`. Detalj: ' . $message; + } + + if ( + str_contains($messageLower, 'connection refused') + || str_contains($messageLower, 'no such file or directory') + || str_contains($messageLower, "can't connect") + || str_contains($messageLower, 'sqlstate[hy000] [2002]') + ) { + return 'Ne mogu se spojiti na MySQL na ' . DB_HOST . ':' . DB_PORT . '. Pokreni MySQL u XAMPP-u i pokušaj ponovno. Detalj: ' . $message; + } + + return $message !== '' ? $message : 'Nepoznata greška pri spajanju na bazu.'; } diff --git a/db/migrations/20260524_create_mail_accounts.sql b/db/migrations/20260524_create_mail_accounts.sql new file mode 100644 index 0000000..76d1c98 --- /dev/null +++ b/db/migrations/20260524_create_mail_accounts.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS mail_accounts ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + label VARCHAR(120) NOT NULL, + email_address VARCHAR(190) DEFAULT NULL, + pop3_host VARCHAR(190) NOT NULL, + pop3_port SMALLINT UNSIGNED NOT NULL DEFAULT 110, + security_mode VARCHAR(20) NOT NULL DEFAULT 'plain', + username VARCHAR(190) NOT NULL, + password_ciphertext TEXT NOT NULL, + sync_limit SMALLINT UNSIGNED NOT NULL DEFAULT 15, + leave_on_server TINYINT(1) NOT NULL DEFAULT 1, + last_status VARCHAR(255) DEFAULT NULL, + last_message_count INT UNSIGNED NOT NULL DEFAULT 0, + last_sync_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_mail_accounts_created_at (created_at), + INDEX idx_mail_accounts_last_sync_at (last_sync_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..bccc9a2 --- /dev/null +++ b/healthz.php @@ -0,0 +1,30 @@ + 'ok', + 'db' => false, + 'timestamp' => gmdate(DATE_ATOM), +]; + +try { + $pdo = app_db(); + + if (!$pdo) { + throw new RuntimeException(app_db_error() ?: 'Database connection is not available.'); + } + + $statement = $pdo->prepare('SELECT 1'); + $statement->execute(); + $status['db'] = true; +} catch (Throwable $exception) { + $status['status'] = 'degraded'; +} + +http_response_code($status['status'] === 'ok' ? 200 : 503); +header('Content-Type: application/json; charset=utf-8'); +echo json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); diff --git a/includes/app.php b/includes/app.php new file mode 100644 index 0000000..2866b31 --- /dev/null +++ b/includes/app.php @@ -0,0 +1,378 @@ +getMessage(); + } + + return $pdo; +} + +function app_db_error(): ?string +{ + return $GLOBALS['APP_DB_ERROR'] ?? null; +} + +function ensure_mail_schema(): bool +{ + static $ensured = false; + + if ($ensured) { + return app_db() instanceof PDO; + } + + $ensured = true; + $pdo = app_db(); + + if (!$pdo) { + return false; + } + + $migrationFile = __DIR__ . '/../db/migrations/20260524_create_mail_accounts.sql'; + + try { + $sql = file_get_contents($migrationFile); + + if ($sql === false) { + throw new RuntimeException('Unable to read the mailbox migration file.'); + } + + $pdo->exec($sql); + + return true; + } catch (Throwable $exception) { + $GLOBALS['APP_DB_ERROR'] = $exception->getMessage(); + + return false; + } +} + +function app_boot(): void +{ + ensure_mail_schema(); +} + +function db_ready(): bool +{ + return app_db() instanceof PDO; +} + +function flash(string $type, string $message): void +{ + $_SESSION['flash'] = [ + 'type' => $type, + 'message' => $message, + ]; +} + +function pull_flash(): ?array +{ + if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) { + return null; + } + + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); + + return $flash; +} + +function mail_cipher_key(): string +{ + return hash('sha256', DB_HOST . '|' . DB_NAME . '|' . DB_USER . '|' . DB_PASS, true); +} + +function encrypt_secret(string $plaintext): string +{ + $cipher = 'aes-256-cbc'; + $ivLength = openssl_cipher_iv_length($cipher); + $iv = random_bytes($ivLength); + $encrypted = openssl_encrypt($plaintext, $cipher, mail_cipher_key(), OPENSSL_RAW_DATA, $iv); + + if ($encrypted === false) { + throw new RuntimeException('Unable to securely store the POP3 password.'); + } + + return base64_encode($iv . $encrypted); +} + +function decrypt_secret(string $ciphertext): string +{ + $decoded = base64_decode($ciphertext, true); + + if ($decoded === false) { + return ''; + } + + $cipher = 'aes-256-cbc'; + $ivLength = openssl_cipher_iv_length($cipher); + $iv = substr($decoded, 0, $ivLength); + $payload = substr($decoded, $ivLength); + $decrypted = openssl_decrypt($payload, $cipher, mail_cipher_key(), OPENSSL_RAW_DATA, $iv); + + return $decrypted === false ? '' : $decrypted; +} + +function default_mail_account_input(): array +{ + return [ + 'label' => '', + 'email_address' => '', + 'pop3_host' => '127.0.0.1', + 'pop3_port' => 110, + 'security_mode' => 'plain', + 'username' => '', + 'password' => '', + 'sync_limit' => 15, + 'leave_on_server' => 1, + ]; +} + +function validate_mail_account_input(array $input): array +{ + $clean = [ + 'label' => trim((string) ($input['label'] ?? '')), + 'email_address' => trim((string) ($input['email_address'] ?? '')), + 'pop3_host' => trim((string) ($input['pop3_host'] ?? '')), + 'pop3_port' => (int) ($input['pop3_port'] ?? 110), + 'security_mode' => in_array(($input['security_mode'] ?? 'plain'), ['plain', 'ssl'], true) ? (string) $input['security_mode'] : 'plain', + 'username' => trim((string) ($input['username'] ?? '')), + 'password' => trim((string) ($input['password'] ?? '')), + 'sync_limit' => (int) ($input['sync_limit'] ?? 15), + 'leave_on_server' => isset($input['leave_on_server']) ? 1 : 0, + ]; + + $errors = []; + + if ($clean['label'] === '' || strlen($clean['label']) < 2) { + $errors['label'] = 'Unesite naziv mailboxa (najmanje 2 znaka).'; + } + + if ($clean['email_address'] !== '' && !filter_var($clean['email_address'], FILTER_VALIDATE_EMAIL)) { + $errors['email_address'] = 'Email adresa nije ispravna.'; + } + + if ($clean['pop3_host'] === '' || strlen($clean['pop3_host']) < 2) { + $errors['pop3_host'] = 'POP3 host je obavezan.'; + } + + if ($clean['pop3_port'] < 1 || $clean['pop3_port'] > 65535) { + $errors['pop3_port'] = 'POP3 port mora biti između 1 i 65535.'; + } + + if ($clean['username'] === '') { + $errors['username'] = 'Korisničko ime je obavezno.'; + } + + if ($clean['password'] === '') { + $errors['password'] = 'Lozinka je obavezna.'; + } + + if ($clean['sync_limit'] < 5 || $clean['sync_limit'] > 50) { + $errors['sync_limit'] = 'Prikaži između 5 i 50 poruka po sinkronizaciji.'; + } + + return [$clean, $errors]; +} + +function save_mail_account(array $data): int +{ + $pdo = app_db(); + + if (!$pdo) { + throw new RuntimeException('Baza trenutno nije dostupna.'); + } + + $statement = $pdo->prepare( + 'INSERT INTO mail_accounts (label, email_address, pop3_host, pop3_port, security_mode, username, password_ciphertext, sync_limit, leave_on_server, last_status) + VALUES (:label, :email_address, :pop3_host, :pop3_port, :security_mode, :username, :password_ciphertext, :sync_limit, :leave_on_server, :last_status)' + ); + + $statement->bindValue(':label', $data['label']); + $statement->bindValue(':email_address', $data['email_address'] !== '' ? $data['email_address'] : null, PDO::PARAM_STR); + $statement->bindValue(':pop3_host', $data['pop3_host']); + $statement->bindValue(':pop3_port', (int) $data['pop3_port'], PDO::PARAM_INT); + $statement->bindValue(':security_mode', $data['security_mode']); + $statement->bindValue(':username', $data['username']); + $statement->bindValue(':password_ciphertext', encrypt_secret($data['password'])); + $statement->bindValue(':sync_limit', (int) $data['sync_limit'], PDO::PARAM_INT); + $statement->bindValue(':leave_on_server', (int) $data['leave_on_server'], PDO::PARAM_INT); + $statement->bindValue(':last_status', 'Ready to connect'); + $statement->execute(); + + return (int) $pdo->lastInsertId(); +} + +function get_mail_accounts(): array +{ + $pdo = app_db(); + + if (!$pdo) { + return []; + } + + $statement = $pdo->prepare( + 'SELECT id, label, email_address, pop3_host, pop3_port, security_mode, username, sync_limit, leave_on_server, last_status, last_message_count, last_sync_at, created_at, updated_at + FROM mail_accounts + ORDER BY created_at DESC, id DESC' + ); + $statement->execute(); + + return $statement->fetchAll() ?: []; +} + +function find_mail_account(int $id): ?array +{ + $pdo = app_db(); + + if (!$pdo) { + return null; + } + + $statement = $pdo->prepare( + 'SELECT id, label, email_address, pop3_host, pop3_port, security_mode, username, password_ciphertext, sync_limit, leave_on_server, last_status, last_message_count, last_sync_at, created_at, updated_at + FROM mail_accounts + WHERE id = :id + LIMIT 1' + ); + $statement->bindValue(':id', $id, PDO::PARAM_INT); + $statement->execute(); + + $account = $statement->fetch(); + + return $account ?: null; +} + +function update_mail_account_sync(int $id, string $status, int $messageCount): void +{ + $pdo = app_db(); + + if (!$pdo) { + return; + } + + $statement = $pdo->prepare( + 'UPDATE mail_accounts + SET last_status = :last_status, + last_message_count = :last_message_count, + last_sync_at = NOW() + WHERE id = :id' + ); + $statement->bindValue(':last_status', substr($status, 0, 255)); + $statement->bindValue(':last_message_count', max(0, $messageCount), PDO::PARAM_INT); + $statement->bindValue(':id', $id, PDO::PARAM_INT); + $statement->execute(); +} + +function format_datetime(?string $value, string $fallback = 'Not yet'): string +{ + if (!$value) { + return $fallback; + } + + try { + return (new DateTimeImmutable($value))->format('M j, Y · H:i'); + } catch (Throwable $exception) { + return $fallback; + } +} + +function status_tone(?string $status): string +{ + $value = strtolower(trim((string) $status)); + + if ($value === '') { + return 'status-idle'; + } + + if (str_contains($value, 'fail') || str_contains($value, 'error')) { + return 'status-danger'; + } + + if (str_contains($value, 'empty')) { + return 'status-warning'; + } + + if (str_contains($value, 'connected') || str_contains($value, 'ready')) { + return 'status-success'; + } + + return 'status-idle'; +} + +function security_label(string $mode): string +{ + return $mode === 'ssl' ? 'SSL/TLS' : 'Plain'; +} + +function truncate_text(string $text, int $length = 160): string +{ + $text = trim(preg_replace('/\s+/', ' ', $text) ?? $text); + + if ($text === '') { + return ''; + } + + if (function_exists('iconv_strlen') && function_exists('iconv_substr')) { + $currentLength = iconv_strlen($text, 'UTF-8'); + + if ($currentLength !== false && $currentLength > $length) { + return rtrim((string) iconv_substr($text, 0, $length, 'UTF-8')) . '…'; + } + } + + return strlen($text) > $length ? rtrim(substr($text, 0, $length)) . '…' : $text; +} diff --git a/includes/pop3_client.php b/includes/pop3_client.php new file mode 100644 index 0000000..123031d --- /dev/null +++ b/includes/pop3_client.php @@ -0,0 +1,342 @@ +host = $host; + $this->port = $port; + $this->security = $security; + $this->timeout = $timeout; + } + + public function connect(): void + { + $target = ($this->security === 'ssl' ? 'ssl://' : '') . $this->host; + $errno = 0; + $errstr = ''; + $stream = @fsockopen($target, $this->port, $errno, $errstr, $this->timeout); + + if (!is_resource($stream)) { + throw new RuntimeException(sprintf('Ne mogu otvoriti POP3 vezu prema %s:%d.', $this->host, $this->port)); + } + + stream_set_timeout($stream, $this->timeout); + $this->stream = $stream; + + $greeting = $this->readLine(); + + if (stripos($greeting, '+OK') !== 0) { + throw new RuntimeException('POP3 server je odbio početni pozdrav.'); + } + } + + public function login(string $username, string $password): void + { + $this->simpleCommand('USER ' . $username, 'Korisničko ime nije prihvaćeno na POP3 serveru.'); + $this->simpleCommand('PASS ' . $password, 'Lozinka nije prihvaćena na POP3 serveru.'); + } + + public function stat(): array + { + $response = $this->simpleCommand('STAT', 'Ne mogu očitati stanje inboxa.'); + + if (preg_match('/^\+OK\s+(\d+)\s+(\d+)/', $response, $matches)) { + return [ + 'count' => (int) $matches[1], + 'size' => (int) $matches[2], + ]; + } + + return ['count' => 0, 'size' => 0]; + } + + public function fetchRecent(int $limit = 15): array + { + $stats = $this->stat(); + $count = $stats['count']; + + if ($count <= 0) { + return []; + } + + $messages = []; + $start = max(1, $count - $limit + 1); + + for ($number = $count; $number >= $start; $number--) { + try { + $raw = $this->multilineCommand('TOP ' . $number . ' 18', 'Ne mogu dohvatiti pregled poruke.'); + } catch (Throwable $exception) { + $raw = $this->multilineCommand('RETR ' . $number, 'Ne mogu preuzeti poruku s POP3 servera.'); + } + + $messages[] = $this->parseMessage($number, $raw, false); + } + + return $messages; + } + + public function fetchMessage(int $number): array + { + $raw = $this->multilineCommand('RETR ' . $number, 'Ne mogu otvoriti traženu poruku.'); + + return $this->parseMessage($number, $raw, true); + } + + public function quit(): void + { + if (is_resource($this->stream)) { + try { + $this->simpleCommand('QUIT'); + } catch (Throwable $exception) { + // ignore close failures + } + + fclose($this->stream); + $this->stream = null; + } + } + + public function __destruct() + { + $this->quit(); + } + + private function parseMessage(int $number, string $rawMessage, bool $includeBody): array + { + [$headerText, $bodyText] = self::splitMessage($rawMessage); + $headers = self::parseHeaders($headerText); + $decodedBody = self::extractBodyText($headers, $bodyText); + $normalizedBody = trim(preg_replace("/ +?| /", " +", $decodedBody) ?? $decodedBody); + $preview = truncate_text($normalizedBody !== '' ? $normalizedBody : 'Nema pregleda za ovu poruku.', 180); + + return [ + 'number' => $number, + 'subject' => self::headerValue($headers, 'subject', '(Bez naslova)'), + 'from' => self::headerValue($headers, 'from', 'Nepoznati pošiljatelj'), + 'date' => self::headerValue($headers, 'date', ''), + 'message_id' => self::headerValue($headers, 'message-id', 'POP3-' . $number), + 'preview' => $preview !== '' ? $preview : 'Nema pregleda za ovu poruku.', + 'body_text' => $includeBody ? ($normalizedBody !== '' ? $normalizedBody : 'Poruka nema tekstualni sadržaj za prikaz.') : '', + ]; + } + + private static function splitMessage(string $rawMessage): array + { + $parts = preg_split("/ ? + ? +/", $rawMessage, 2); + + return [ + $parts[0] ?? '', + $parts[1] ?? '', + ]; + } + + public static function parseHeaders(string $headerText): array + { + $headers = []; + $current = null; + $lines = preg_split("/ ? +/", $headerText) ?: []; + + foreach ($lines as $line) { + if ($line === '') { + continue; + } + + if (preg_match('/^[ ]+/', $line) === 1 && $current !== null) { + $headers[$current] .= ' ' . trim($line); + continue; + } + + $parts = explode(':', $line, 2); + + if (count($parts) !== 2) { + continue; + } + + $current = strtolower(trim($parts[0])); + $headers[$current] = trim($parts[1]); + } + + return $headers; + } + + private static function extractBodyText(array $headers, string $body): string + { + $contentType = strtolower((string) ($headers['content-type'] ?? 'text/plain; charset=UTF-8')); + $encoding = strtolower((string) ($headers['content-transfer-encoding'] ?? '')); + $charset = 'UTF-8'; + + if (preg_match('/charset="?([^";]+)"?/i', $contentType, $charsetMatch) === 1) { + $charset = trim($charsetMatch[1]); + } + + if (str_starts_with($contentType, 'multipart/') && preg_match('/boundary="?([^";]+)"?/i', $contentType, $boundaryMatch) === 1) { + $boundary = $boundaryMatch[1]; + $delimiter = '--' . $boundary; + $parts = explode($delimiter, $body); + $plain = ''; + $html = ''; + + foreach ($parts as $part) { + $part = ltrim($part, " +"); + $part = preg_replace('/--\s*$/', '', $part) ?? $part; + + if (trim($part) === '') { + continue; + } + + [$partHeadersText, $partBody] = self::splitMessage($part); + $partHeaders = self::parseHeaders($partHeadersText); + $partText = trim(self::extractBodyText($partHeaders, $partBody)); + $partType = strtolower((string) ($partHeaders['content-type'] ?? 'text/plain')); + + if ($partText === '') { + continue; + } + + if (str_contains($partType, 'text/plain')) { + return $partText; + } + + if ($plain === '') { + $plain = $partText; + } + + if ($html === '' && str_contains($partType, 'text/html')) { + $html = $partText; + } + } + + return $plain !== '' ? $plain : $html; + } + + $decoded = self::decodeBody($body, $encoding); + + if ($charset !== '' && strtoupper($charset) !== 'UTF-8' && function_exists('iconv')) { + $converted = @iconv($charset, 'UTF-8//IGNORE', $decoded); + + if ($converted !== false) { + $decoded = $converted; + } + } + + if (str_contains($contentType, 'text/html')) { + $decoded = html_entity_decode(strip_tags($decoded), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + return trim($decoded); + } + + private static function decodeBody(string $body, string $encoding): string + { + return match ($encoding) { + 'base64' => base64_decode($body, true) ?: $body, + 'quoted-printable' => quoted_printable_decode($body), + default => $body, + }; + } + + private static function headerValue(array $headers, string $key, string $fallback): string + { + $value = trim((string) ($headers[$key] ?? '')); + + if ($value === '') { + return $fallback; + } + + if (function_exists('iconv_mime_decode')) { + $decoded = @iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8'); + + if (is_string($decoded) && $decoded !== '') { + return $decoded; + } + } + + return $value; + } + + private function simpleCommand(string $command, ?string $fallbackMessage = null): string + { + $this->write($command . " +"); + $response = $this->readLine(); + + if (stripos($response, '+OK') !== 0) { + $serverMessage = preg_replace('/^-ERR\s*/i', '', trim($response)) ?: trim($response); + throw new RuntimeException($fallbackMessage ?: ($serverMessage !== '' ? $serverMessage : 'POP3 naredba nije uspjela.')); + } + + return trim($response); + } + + private function multilineCommand(string $command, ?string $fallbackMessage = null): string + { + $this->write($command . " +"); + $response = $this->readLine(); + + if (stripos($response, '+OK') !== 0) { + $serverMessage = preg_replace('/^-ERR\s*/i', '', trim($response)) ?: trim($response); + throw new RuntimeException($fallbackMessage ?: ($serverMessage !== '' ? $serverMessage : 'POP3 naredba nije uspjela.')); + } + + $lines = []; + + while (($line = $this->readLine()) !== '.') { + if (str_starts_with($line, '..')) { + $line = substr($line, 1); + } + + $lines[] = $line; + } + + return implode(" +", $lines); + } + + private function write(string $payload): void + { + if (!is_resource($this->stream)) { + throw new RuntimeException('POP3 veza nije aktivna.'); + } + + fwrite($this->stream, $payload); + } + + private function readLine(): string + { + if (!is_resource($this->stream)) { + throw new RuntimeException('POP3 veza nije aktivna.'); + } + + $line = fgets($this->stream, 8192); + + if ($line === false) { + $meta = stream_get_meta_data($this->stream); + + if (!empty($meta['timed_out'])) { + throw new RuntimeException('POP3 server nije odgovorio na vrijeme.'); + } + + throw new RuntimeException('POP3 server je zatvorio vezu.'); + } + + return rtrim($line, " +"); + } +} diff --git a/index.php b/index.php index 7205f3d..6837311 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,333 @@ strtotime((string) $latestSync)) { + $latestSync = (string) $account['last_sync_at']; + } + } +} + +$pageTitle = project_name() . ' — POP3 mailbox dashboard'; +$projectBaseDescription = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: '')); +$pageDescription = $projectBaseDescription !== '' + ? $projectBaseDescription . ' — Dashboard for POP3 mailbox setup, MySQL account storage, and live inbox access.' + : 'Configure local POP3 mailboxes, store connection settings in MySQL, and open a clean inbox view from one lightweight PHP interface.'; +$projectDescription = $projectBaseDescription !== '' ? $projectBaseDescription : $pageDescription; +$projectImageUrl = project_image_url(); ?> - + - - - New Style - + + + <?= h($pageTitle) ?> + - - - - - - + + - - - - + + - - - - + + + + + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+ +
+
+
+
+ +
+
-
- + +
+ + +
+
+
Initial MVP slice
+

Dodaj POP3 mailbox i odmah otvori inbox iz preglednika.

+

Ova prva verzija pokriva najbitniji tok: spremi POP3 postavke u MySQL, otvori live pregled inboxa i pročitaj poruku bez izlaska iz aplikacije. Dizajn je namjerno čist i lagan za lokalni XAMPP setup.

+ +
+ POP3 read workflow + MySQL account storage + Inbox detail view +
+
+ +
+ + + + + +
+
+
+
+
+
Create / input
+

Dodaj POP3 račun

+
+ Server-side validation · encrypted password at rest +
+ + + + + +
+ +
+ + > +
+
+
+ + > +
+
+
+ + > +
+
+
+ + > +
+
+
+ + +
+
+ + > +
+
+
+ + > +
+
+
+ + > +
+
+
+
+ > + +
+
+
+ + Savjet za lokalni test: host 127.0.0.1, port 110, bez enkripcije. +
+
+
+
+ + + +
+
+
+
+
List
+

Spremljeni mailbox računi

+
+
+ Last sync: + Accounts: +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
MailboxPOP3StatusZadnja sinkronizacijaAkcija
+
+
+
+
:
+
· limit
+
+ +
poruka
+
+
+
Dodano
+
+ Otvori inbox +
+
+ +
+

Još nema spremljenih mailbox računa.

+

Dodaj prvi POP3 račun kako bi dashboard dobio live inbox i detail prikaz poruka.

+
+ Primjer hosta: 127.0.0.1 + Primjer porta: 110 + Sigurnost: plain +
+
+ +
+
+
+ + +
+ + diff --git a/mailbox.php b/mailbox.php new file mode 100644 index 0000000..686ea61 --- /dev/null +++ b/mailbox.php @@ -0,0 +1,279 @@ + 0 ? find_mail_account($accountId) : null; +$flash = pull_flash(); +$messages = []; +$selectedMessage = null; +$selectedNumber = max(0, (int) ($_GET['message'] ?? 0)); +$syncError = null; +$totalRemoteMessages = 0; +$displayedMessages = 0; +$currentStatus = $account ? (string) ($account['last_status'] ?: 'Ready') : 'Ready'; +$currentLastSync = $account['last_sync_at'] ?? null; + +if ($account) { + $client = null; + + try { + $password = decrypt_secret((string) $account['password_ciphertext']); + + if ($password === '') { + throw new RuntimeException('Spremljena lozinka se ne može dešifrirati. Ponovno spremi mailbox račun.'); + } + + $client = new Pop3Client((string) $account['pop3_host'], (int) $account['pop3_port'], (string) $account['security_mode']); + $client->connect(); + $client->login((string) $account['username'], $password); + + $stats = $client->stat(); + $totalRemoteMessages = (int) $stats['count']; + $messages = $client->fetchRecent((int) $account['sync_limit']); + $displayedMessages = count($messages); + + if ($selectedNumber <= 0 && !empty($messages)) { + $selectedNumber = (int) $messages[0]['number']; + } + + if ($selectedNumber > $totalRemoteMessages && !empty($messages)) { + $selectedNumber = (int) $messages[0]['number']; + } + + if ($selectedNumber > 0 && $totalRemoteMessages > 0) { + $selectedMessage = $client->fetchMessage($selectedNumber); + } + + $status = $totalRemoteMessages > 0 ? 'Connected' : 'Connected — empty mailbox'; + update_mail_account_sync($accountId, $status, $totalRemoteMessages); + $currentStatus = $status; + $currentLastSync = gmdate('Y-m-d H:i:s'); + } catch (Throwable $exception) { + $syncError = $exception->getMessage(); + update_mail_account_sync($accountId, 'Sync failed', 0); + $currentStatus = 'Sync failed'; + $currentLastSync = gmdate('Y-m-d H:i:s'); + } finally { + if ($client instanceof Pop3Client) { + $client->quit(); + } + } +} + +$pageLabel = $account ? ($account['label'] . ' — Inbox') : 'Mailbox not found'; +$pageTitle = project_name() . ' — ' . $pageLabel; +$projectBaseDescription = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: '')); +$pageDescription = $projectBaseDescription !== '' + ? $projectBaseDescription . ' — Live POP3 inbox detail with message reading and fetched-list search.' + : 'Read the latest POP3 messages in a clean split-view inbox with server-side search helpers and mailbox status.'; +$projectDescription = $projectBaseDescription !== '' ? $projectBaseDescription : $pageDescription; +$projectImageUrl = project_image_url(); +?> + + + + + + <?= h($pageTitle) ?> + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+ +
+ + + +
+

Mailbox nije pronađen.

+

Vrati se na dashboard i dodaj POP3 račun da bi otvorio inbox pregled.

+ Idi na dashboard +
+ +
+
+
Detail
+

+

· POP3 : ·

+
+ + Leave on server: + Inbox only in this slice +
+ +
+ +
+ + + + + +
+
+
+
+
List
+

Inbox poruke

+
+ Search in fetched set +
+ + +
+ + + +
+

+ +
+
+
+
+ +
+
+

Nijedna poruka iz dohvaćenog seta ne odgovara trenutnoj pretrazi.

+
+ +
+

Inbox je trenutno prazan.

+

Ako očekuješ poruke, klikni refresh ili provjeri da POP3 server zaista ima mail u sandučiću.

+
+ +
+ +
+
+
+
Read / detail
+

Detalj poruke

+
+ Server-rendered preview +
+ + +
+
+ Subject +
+
+
+ From +
+
+
+ Date / POP3 # +
+
+
+
+
+
+ +
+

Odaberi poruku s lijeve strane.

+

Kad odabereš mail, ovdje ćeš vidjeti subject, sender, datum i tekstualni sadržaj poruke.

+
+ + +
+
Mailbox settings
+
+
+ POP3 endpoint + : · +
+
+ Username + +
+
+ Saved in database + +
+
+
+
+
+ +
+ + + + diff --git a/xampp-setup.php b/xampp-setup.php new file mode 100644 index 0000000..c2b3dcd --- /dev/null +++ b/xampp-setup.php @@ -0,0 +1,199 @@ +getMessage(); + } +} + +app_boot(); + +$dbReady = db_ready(); +$dbError = $setupError ?: app_db_error(); +$pageTitle = project_name() . ' — XAMPP setup'; +$projectBaseDescription = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: '')); +$pageDescription = $projectBaseDescription !== '' + ? $projectBaseDescription . ' — Local XAMPP setup for the POP3 webmail database and first run.' + : 'Prepare the POP3 webmail app for local XAMPP by initializing the MySQL database and verifying the runtime configuration.'; +$projectDescription = $projectBaseDescription !== '' ? $projectBaseDescription : $pageDescription; +$projectImageUrl = project_image_url(); +?> + + + + + + <?= h($pageTitle) ?> + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
Local install
+

Priprema za XAMPP lokalni rad

+

Otvori ovu stranicu unutar htdocs, pokreni Apache i MySQL u XAMPP-u, pa jednim klikom kreiraj bazu i mailbox tablicu. Nakon toga dashboard na index.php radi lokalno na localhost.

+
+ + Otvori dashboard + +
+ + +
+ + Natrag na aplikaciju +
+
+ Apache + MySQL + phpMyAdmin optional + Local URL ready +
+
+ +
+ + + + + + + +
+
+
+
+
3 steps
+

Kako pokrenuti aplikaciju na XAMPP-u

+
+
+
+

1. Kopiraj projekt u htdocs

+

Primjer: xampp/htdocs/pop3-webmail. Lokalni URL će tada biti sličan /index.php.

+
+
+

2. Pokreni Apache i MySQL

+

U XAMPP Control Panelu uključi oba servisa. Bez aktivnog MySQL-a spremanje mailboxa neće raditi.

+
+
+

3. Inicijaliziraj bazu

+

Klikni gumb Kreiraj bazu i tablicu. To će napraviti bazu i tablicu mail_accounts.

+
+
+
+
+ + +
+
+ + +