This commit is contained in:
Flatlogic Bot 2026-05-24 07:14:49 +00:00
parent b250816730
commit f9536ee19b
11 changed files with 2270 additions and 543 deletions

View File

@ -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;
}
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;
}
}

View File

@ -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';
}
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -1,21 +1,108 @@
<?php
// Generated by setup_mariadb_project.sh — database password must come from process env.
declare(strict_types=1);
// Generated by setup_mariadb_project.sh — keep env keys intact and fall back to a local XAMPP profile.
define('DB_HOST', getenv('DB_HOST') ?: '127.0.0.1');
define('DB_NAME', getenv('DB_NAME') ?: 'app_default');
define('DB_USER', getenv('DB_USER') ?: 'app_default');
define('DB_PASS', getenv('DB_PASS') ?: '');
define('DB_NAME', getenv('DB_NAME') ?: 'local_pop3_webmail');
define('DB_USER', getenv('DB_USER') ?: 'root');
if (DB_PASS === '') {
throw new RuntimeException('DB_PASS environment variable is not set.');
$dbPass = getenv('DB_PASS');
define('DB_PASS', $dbPass !== false ? (string) $dbPass : '');
$dbPort = getenv('DB_PORT');
define('DB_PORT', max(1, (int) (($dbPort !== false && $dbPort !== '') ? $dbPort : 3306)));
function db_settings(): array
{
return [
'host' => 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.';
}

View File

@ -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;

30
healthz.php Normal file
View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
app_boot();
$status = [
'status' => '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);

378
includes/app.php Normal file
View File

@ -0,0 +1,378 @@
<?php
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/../db/config.php';
function h(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function project_name(): string
{
$name = $_SERVER['PROJECT_NAME'] ?? getenv('PROJECT_NAME') ?: 'Local POP3 Webmail';
return trim((string) $name);
}
function project_description_default(string $fallback): string
{
$description = $_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: $fallback;
return trim((string) $description);
}
function project_image_url(): string
{
$image = $_SERVER['PROJECT_IMAGE_URL'] ?? getenv('PROJECT_IMAGE_URL') ?: '';
return trim((string) $image);
}
function asset_version(string $relativePath): string
{
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
return (string) (file_exists($fullPath) ? filemtime($fullPath) : time());
}
function app_db(): ?PDO
{
static $pdo = null;
static $attempted = false;
if ($attempted) {
return $pdo;
}
$attempted = true;
try {
$pdo = db();
} catch (Throwable $exception) {
$pdo = null;
$GLOBALS['APP_DB_ERROR'] = $exception->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;
}

342
includes/pop3_client.php Normal file
View File

@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
final class Pop3Client
{
private string $host;
private int $port;
private string $security;
private int $timeout;
/** @var resource|null */
private $stream = null;
public function __construct(string $host, int $port = 110, string $security = 'plain', int $timeout = 12)
{
$this->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, "
");
}
}

453
index.php
View File

@ -1,150 +1,333 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
require_once __DIR__ . '/includes/app.php';
app_boot();
$defaults = default_mail_account_input();
$formData = $defaults;
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_account') {
[$formData, $errors] = validate_mail_account_input($_POST);
if (empty($errors)) {
try {
$accountId = save_mail_account($formData);
flash('success', 'Mailbox je spremljen. Otvaram live inbox pregled.');
header('Location: mailbox.php?id=' . $accountId);
exit;
} catch (Throwable $exception) {
$errors['form'] = 'Spremanje nije uspjelo. Provjeri bazu i pokušaj ponovno.';
}
}
}
$accounts = get_mail_accounts();
$flash = pull_flash();
$dbReady = db_ready();
$dbError = app_db_error();
$latestSync = 'Not yet';
$syncedAccounts = 0;
foreach ($accounts as $account) {
if (!empty($account['last_sync_at'])) {
$syncedAccounts++;
if ($latestSync === 'Not yet' || strtotime((string) $account['last_sync_at']) > 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();
?>
<!doctype html>
<html lang="en">
<html lang="hr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($pageTitle) ?></title>
<meta name="description" content="<?= h($pageDescription) ?>">
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta property="og:description" content="<?= h($projectDescription) ?>">
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<meta property="og:title" content="<?= h($pageTitle) ?>">
<meta property="twitter:title" content="<?= h($pageTitle) ?>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= h(asset_version('assets/css/custom.css')) ?>">
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<?php if ($flash): ?>
<div class="toast-shell">
<div class="toast align-items-center border-0" role="status" aria-live="polite" aria-atomic="true">
<div class="d-flex">
<div class="toast-body"><?= h($flash['message']) ?></div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
<?php endif; ?>
<div class="container app-shell py-4 py-lg-5">
<nav class="navbar app-nav navbar-expand-lg">
<div class="container-fluid px-0">
<a class="navbar-brand d-flex align-items-center gap-3 m-0" href="index.php">
<span class="brand-mark">WM</span>
<span class="brand-copy">
<strong><?= h(project_name()) ?></strong>
<small>Local POP3 workspace</small>
</span>
</a>
<div class="d-flex flex-wrap align-items-center gap-2 ms-auto">
<a class="btn btn-sm btn-outline-secondary" href="#setup">Dodaj račun</a>
<a class="btn btn-sm btn-outline-secondary" href="#accounts">Računi</a>
<a class="btn btn-sm btn-outline-secondary" href="xampp-setup.php">XAMPP setup</a>
<a class="btn btn-sm btn-outline-secondary" href="healthz.php" target="_blank" rel="noopener">Healthz</a>
</div>
</div>
</nav>
<header class="hero-grid">
<section class="hero-card">
<div class="page-eyebrow mb-2">Initial MVP slice</div>
<h1 class="hero-title">Dodaj POP3 mailbox i odmah otvori inbox iz preglednika.</h1>
<p class="section-subtitle">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.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#setup">Konfiguriraj mailbox</a>
<a class="btn btn-outline-secondary" href="#accounts">Pogledaj spremljene račune</a>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<span class="inline-note">POP3 read workflow</span>
<span class="inline-note">MySQL account storage</span>
<span class="inline-note">Inbox detail view</span>
</div>
</section>
<aside class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Configured accounts</div>
<div class="metric-value"><?= h((string) count($accounts)) ?></div>
<div class="metric-hint">Broj mailboxa spremljenih u lokalnoj bazi.</div>
</div>
<div class="metric-card">
<div class="metric-label">Successful syncs</div>
<div class="metric-value"><?= h((string) $syncedAccounts) ?></div>
<div class="metric-hint">Računi koji su već otvoreni kroz inbox ekran.</div>
</div>
<div class="metric-card">
<div class="metric-label">Environment</div>
<div class="metric-value" style="font-size:1.1rem;">PHP <?= h(PHP_VERSION) ?></div>
<div class="metric-hint">Vrijeme: <?= h(gmdate('Y-m-d H:i')) ?> UTC · <a class="text-decoration-underline" href="xampp-setup.php">xampp-setup.php</a> · <a class="text-decoration-underline" href="healthz.php" target="_blank" rel="noopener">/healthz</a></div>
</div>
</aside>
</header>
<?php if (!$dbReady): ?>
<div class="alert alert-danger border-0 mb-4" role="alert">
<strong>Baza nije dostupna.</strong> Forma i lista su prikazane, ali spremanje neće raditi dok se MySQL veza ne podigne.
<div class="small mt-2">Za lokalni XAMPP pokreni Apache + MySQL, zatim otvori <a class="text-decoration-underline" href="xampp-setup.php">xampp-setup.php</a> da automatski kreiraš bazu <strong><?= h(DB_NAME) ?></strong>.</div>
<div class="small mt-2">Trenutna konfiguracija: <?= h(DB_USER) ?>@<?= h(DB_HOST) ?>:<?= h((string) DB_PORT) ?> / <?= h(DB_NAME) ?><?= DB_PASS === '' ? ' · bez lozinke' : ' · lozinka postavljena' ?></div>
<?php if ($dbError): ?>
<div class="small mt-2"><?= h($dbError) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<main class="row g-4">
<section id="setup" class="col-12 col-xl-7">
<div class="section-card">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<div class="overline mb-1">Create / input</div>
<h2 class="section-title">Dodaj POP3 račun</h2>
</div>
<span class="soft-badge">Server-side validation · encrypted password at rest</span>
</div>
<?php if (!empty($errors['form'])): ?>
<div class="alert alert-danger border-0 mb-4" role="alert"><?= h($errors['form']) ?></div>
<?php endif; ?>
<form method="post" class="row g-3" novalidate>
<input type="hidden" name="action" value="save_account">
<div class="col-md-6">
<label for="label" class="form-label">Naziv mailboxa</label>
<input type="text" class="form-control<?= isset($errors['label']) ? ' is-invalid' : '' ?>" id="label" name="label" value="<?= h((string) $formData['label']) ?>" placeholder="npr. Lokalni support" <?= $dbReady ? '' : 'disabled' ?>>
<?php if (isset($errors['label'])): ?><div class="validation-note"><?= h($errors['label']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label for="email_address" class="form-label">Email adresa <span class="helper-text">(opcionalno)</span></label>
<input type="email" class="form-control<?= isset($errors['email_address']) ? ' is-invalid' : '' ?>" id="email_address" name="email_address" value="<?= h((string) $formData['email_address']) ?>" placeholder="mailbox@example.local" <?= $dbReady ? '' : 'disabled' ?>>
<?php if (isset($errors['email_address'])): ?><div class="validation-note"><?= h($errors['email_address']) ?></div><?php endif; ?>
</div>
<div class="col-md-8">
<label for="pop3_host" class="form-label">POP3 host</label>
<input type="text" class="form-control<?= isset($errors['pop3_host']) ? ' is-invalid' : '' ?>" id="pop3_host" name="pop3_host" value="<?= h((string) $formData['pop3_host']) ?>" placeholder="127.0.0.1" <?= $dbReady ? '' : 'disabled' ?>>
<?php if (isset($errors['pop3_host'])): ?><div class="validation-note"><?= h($errors['pop3_host']) ?></div><?php endif; ?>
</div>
<div class="col-md-4">
<label for="pop3_port" class="form-label">Port</label>
<input type="number" class="form-control<?= isset($errors['pop3_port']) ? ' is-invalid' : '' ?>" id="pop3_port" name="pop3_port" value="<?= h((string) $formData['pop3_port']) ?>" min="1" max="65535" <?= $dbReady ? '' : 'disabled' ?>>
<?php if (isset($errors['pop3_port'])): ?><div class="validation-note"><?= h($errors['pop3_port']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label for="security_mode" class="form-label">Sigurnost veze</label>
<select class="form-select" id="security_mode" name="security_mode" data-security-select data-port-target="#pop3_port" <?= $dbReady ? '' : 'disabled' ?>>
<option value="plain" <?= ($formData['security_mode'] ?? '') === 'plain' ? 'selected' : '' ?>>Plain (110)</option>
<option value="ssl" <?= ($formData['security_mode'] ?? '') === 'ssl' ? 'selected' : '' ?>>SSL/TLS (995)</option>
</select>
</div>
<div class="col-md-6">
<label for="sync_limit" class="form-label">Poruka za dohvat</label>
<input type="number" class="form-control<?= isset($errors['sync_limit']) ? ' is-invalid' : '' ?>" id="sync_limit" name="sync_limit" value="<?= h((string) $formData['sync_limit']) ?>" min="5" max="50" <?= $dbReady ? '' : 'disabled' ?>>
<?php if (isset($errors['sync_limit'])): ?><div class="validation-note"><?= h($errors['sync_limit']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label for="username" class="form-label">Korisničko ime</label>
<input type="text" class="form-control<?= isset($errors['username']) ? ' is-invalid' : '' ?>" id="username" name="username" value="<?= h((string) $formData['username']) ?>" placeholder="korisnik ili email" <?= $dbReady ? '' : 'disabled' ?>>
<?php if (isset($errors['username'])): ?><div class="validation-note"><?= h($errors['username']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label for="password" class="form-label">Lozinka</label>
<input type="password" class="form-control<?= isset($errors['password']) ? ' is-invalid' : '' ?>" id="password" name="password" value="<?= h((string) $formData['password']) ?>" placeholder="••••••••" <?= $dbReady ? '' : 'disabled' ?>>
<?php if (isset($errors['password'])): ?><div class="validation-note"><?= h($errors['password']) ?></div><?php endif; ?>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="1" id="leave_on_server" name="leave_on_server" <?= !empty($formData['leave_on_server']) ? 'checked' : '' ?> <?= $dbReady ? '' : 'disabled' ?>>
<label class="form-check-label" for="leave_on_server">Ostavi poruke na serveru (read-only POP3 slice)</label>
</div>
</div>
<div class="col-12 d-flex flex-wrap align-items-center gap-3 pt-2">
<button type="submit" class="btn btn-primary" <?= $dbReady ? '' : 'disabled' ?>>Spremi mailbox</button>
<span class="helper-text">Savjet za lokalni test: host <strong>127.0.0.1</strong>, port <strong>110</strong>, bez enkripcije.</span>
</div>
</form>
</div>
</section>
<aside class="col-12 col-xl-5">
<div class="section-card stack-md h-100">
<div>
<div class="overline mb-1">Confirmation / guide</div>
<h2 class="section-title">Što dobivaš u ovoj isporuci</h2>
</div>
<div class="quick-steps">
<article class="quick-step">
<h3>1. Spremanje računa</h3>
<p>POP3 postavke se validiraju na serveru i spremaju u MySQL, a lozinka se šifrira prije upisa u bazu.</p>
</article>
<article class="quick-step">
<h3>2. Live inbox ekran</h3>
<p>Nakon spremanja otvara se mailbox detalj koji čita najnovije poruke direktno preko POP3 veze.</p>
</article>
<article class="quick-step">
<h3>3. Pregled statusa</h3>
<p>Na dashboardu ostaju vidljivi status zadnje sinkronizacije, broj poruka i brzi linkovi prema svakom inboxu.</p>
</article>
</div>
<div class="surface-muted p-3 rounded-4 border">
<div class="overline mb-2">Next after MVP</div>
<ul class="help-list">
<li>SMTP compose + Sent folder tok.</li>
<li>Lokalni cache headera/poruka za bržu pretragu.</li>
<li>Uređivanje i deaktivacija mailbox računa.</li>
</ul>
</div>
</div>
</aside>
<section id="accounts" class="col-12">
<div class="section-card">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<div class="overline mb-1">List</div>
<h2 class="section-title">Spremljeni mailbox računi</h2>
</div>
<div class="d-flex flex-wrap gap-2">
<span class="soft-badge">Last sync: <?= h($latestSync === 'Not yet' ? $latestSync : format_datetime($latestSync)) ?></span>
<span class="soft-badge">Accounts: <?= h((string) count($accounts)) ?></span>
</div>
</div>
<?php if ($accounts): ?>
<div class="table-shell">
<table class="table">
<thead>
<tr>
<th>Mailbox</th>
<th>POP3</th>
<th>Status</th>
<th>Zadnja sinkronizacija</th>
<th class="text-end">Akcija</th>
</tr>
</thead>
<tbody>
<?php foreach ($accounts as $account): ?>
<tr>
<td>
<div class="fw-semibold"><?= h($account['label']) ?></div>
<div class="small text-secondary"><?= h((string) ($account['email_address'] ?: $account['username'])) ?></div>
</td>
<td>
<div class="fw-semibold"><?= h($account['pop3_host']) ?>:<?= h((string) $account['pop3_port']) ?></div>
<div class="small text-secondary"><?= h(security_label((string) $account['security_mode'])) ?> · limit <?= h((string) $account['sync_limit']) ?></div>
</td>
<td>
<span class="status-badge <?= h(status_tone((string) $account['last_status'])) ?>"><?= h((string) ($account['last_status'] ?: 'Ready')) ?></span>
<div class="small text-secondary mt-1"><?= h((string) $account['last_message_count']) ?> poruka</div>
</td>
<td>
<div class="fw-semibold"><?= h(format_datetime($account['last_sync_at'])) ?></div>
<div class="small text-secondary">Dodano <?= h(format_datetime($account['created_at'])) ?></div>
</td>
<td class="text-end">
<a class="btn btn-sm btn-primary" href="mailbox.php?id=<?= h((string) $account['id']) ?>">Otvori inbox</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="empty-panel">
<h3 class="h5 mb-2">Još nema spremljenih mailbox računa.</h3>
<p class="empty-copy mb-3">Dodaj prvi POP3 račun kako bi dashboard dobio live inbox i detail prikaz poruka.</p>
<div class="d-flex justify-content-center flex-wrap gap-2">
<span class="soft-badge">Primjer hosta: 127.0.0.1</span>
<span class="soft-badge">Primjer porta: 110</span>
<span class="soft-badge">Sigurnost: plain</span>
</div>
</div>
<?php endif; ?>
</div>
</section>
</main>
<footer class="d-flex flex-column flex-lg-row justify-content-between gap-3 pt-4 footer-copy">
<div>Thin slice is ready: create account confirmation redirect list inbox detail.</div>
<div class="d-flex gap-3 flex-wrap">
<a class="text-decoration-underline" href="healthz.php" target="_blank" rel="noopener">Open /healthz</a>
<?php if ($accounts): ?>
<a class="text-decoration-underline" href="mailbox.php?id=<?= h((string) $accounts[0]['id']) ?>">Open latest inbox</a>
<?php endif; ?>
</div>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="assets/js/main.js?v=<?= h(asset_version('assets/js/main.js')) ?>" defer></script>
</body>
</html>

279
mailbox.php Normal file
View File

@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
require_once __DIR__ . '/includes/pop3_client.php';
app_boot();
$accountId = (int) ($_GET['id'] ?? 0);
$account = $accountId > 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();
?>
<!doctype html>
<html lang="hr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($pageTitle) ?></title>
<meta name="description" content="<?= h($pageDescription) ?>">
<?php if ($projectDescription): ?>
<meta property="og:description" content="<?= h($projectDescription) ?>">
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<meta property="og:title" content="<?= h($pageTitle) ?>">
<meta property="twitter:title" content="<?= h($pageTitle) ?>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= h(asset_version('assets/css/custom.css')) ?>">
</head>
<body>
<?php if ($flash): ?>
<div class="toast-shell">
<div class="toast align-items-center border-0" role="status" aria-live="polite" aria-atomic="true">
<div class="d-flex">
<div class="toast-body"><?= h($flash['message']) ?></div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<?php endif; ?>
<div class="container app-shell py-4 py-lg-5">
<nav class="navbar app-nav navbar-expand-lg">
<div class="container-fluid px-0">
<a class="navbar-brand d-flex align-items-center gap-3 m-0" href="index.php">
<span class="brand-mark">WM</span>
<span class="brand-copy">
<strong><?= h(project_name()) ?></strong>
<small>Mailbox detail</small>
</span>
</a>
<div class="d-flex flex-wrap align-items-center gap-2 ms-auto">
<a class="btn btn-sm btn-outline-secondary" href="index.php">Natrag na dashboard</a>
<?php if ($account): ?>
<a class="btn btn-sm btn-primary" href="mailbox.php?id=<?= h((string) $account['id']) ?>">Refresh inbox</a>
<?php endif; ?>
</div>
</div>
</nav>
<?php if (!$account): ?>
<div class="section-card empty-panel">
<h1 class="h4 mb-2">Mailbox nije pronađen.</h1>
<p class="empty-copy mb-3">Vrati se na dashboard i dodaj POP3 račun da bi otvorio inbox pregled.</p>
<a class="btn btn-primary" href="index.php">Idi na dashboard</a>
</div>
<?php else: ?>
<header class="hero-grid mb-4">
<section class="hero-card">
<div class="page-eyebrow mb-2">Detail</div>
<h1 class="hero-title mb-3"><?= h($account['label']) ?></h1>
<p class="section-subtitle"><?= h((string) ($account['email_address'] ?: $account['username'])) ?> · POP3 <?= h($account['pop3_host']) ?>:<?= h((string) $account['pop3_port']) ?> · <?= h(security_label((string) $account['security_mode'])) ?></p>
<div class="d-flex flex-wrap gap-2 mt-3">
<span class="status-badge <?= h(status_tone($currentStatus)) ?>"><?= h($currentStatus) ?></span>
<span class="soft-badge">Leave on server: <?= !empty($account['leave_on_server']) ? 'Yes' : 'No' ?></span>
<span class="soft-badge">Inbox only in this slice</span>
</div>
<ul class="nav nav-pills gap-2 mt-4">
<li class="nav-item"><span class="nav-link active">Inbox</span></li>
<li class="nav-item"><span class="nav-link disabled">Sent · next</span></li>
<li class="nav-item"><span class="nav-link disabled">Compose · next</span></li>
</ul>
</section>
<aside class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Remote messages</div>
<div class="metric-value"><?= h((string) $totalRemoteMessages) ?></div>
<div class="metric-hint">Ukupan broj poruka koje je POP3 prijavio.</div>
</div>
<div class="metric-card">
<div class="metric-label">Displayed now</div>
<div class="metric-value"><?= h((string) $displayedMessages) ?></div>
<div class="metric-hint">Prikazujemo zadnjih <?= h((string) $account['sync_limit']) ?> poruka.</div>
</div>
<div class="metric-card">
<div class="metric-label">Last sync</div>
<div class="metric-value" style="font-size:1.1rem;"><?= h(format_datetime($currentLastSync)) ?></div>
<div class="metric-hint">Status se zapisuje u bazu pri svakom otvaranju inboxa.</div>
</div>
</aside>
</header>
<?php if ($syncError): ?>
<div class="alert alert-danger border-0 mb-4" role="alert">
<strong>Sinkronizacija nije uspjela.</strong> <?= h($syncError) ?>
<div class="small mt-2">Provjeri host, port, sigurnost veze i POP3 korisničke podatke. Za lokalni test često vrijedi 127.0.0.1:110 bez enkripcije.</div>
</div>
<?php endif; ?>
<main class="mailbox-shell">
<section class="section-card">
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
<div>
<div class="overline mb-1">List</div>
<h2 class="section-title">Inbox poruke</h2>
</div>
<span class="soft-badge">Search in fetched set</span>
</div>
<div class="message-search">
<input type="search" placeholder="Pretraži subject, from ili preview…" aria-label="Pretraži poruke" data-mail-search>
</div>
<?php if ($messages): ?>
<div class="mailbox-list">
<?php foreach ($messages as $message): ?>
<?php $isActive = (int) $message['number'] === $selectedNumber; ?>
<a
class="message-item<?= $isActive ? ' active' : '' ?>"
href="mailbox.php?id=<?= h((string) $account['id']) ?>&amp;message=<?= h((string) $message['number']) ?>"
data-mail-item
data-search="<?= h(strtolower($message['subject'] . ' ' . $message['from'] . ' ' . $message['preview'])) ?>"
>
<div class="d-flex justify-content-between gap-3 align-items-start">
<p class="message-subject"><?= h($message['subject']) ?></p>
<span class="message-date"><?= h($message['date'] !== '' ? $message['date'] : ('#' . $message['number'])) ?></span>
</div>
<div class="message-from"><?= h($message['from']) ?></div>
<div class="message-preview"><?= h($message['preview']) ?></div>
</a>
<?php endforeach; ?>
</div>
<div class="empty-panel d-none mt-3" data-mail-empty>
<p class="empty-copy mb-0">Nijedna poruka iz dohvaćenog seta ne odgovara trenutnoj pretrazi.</p>
</div>
<?php else: ?>
<div class="empty-panel">
<h3 class="h6 mb-2">Inbox je trenutno prazan.</h3>
<p class="empty-copy mb-0">Ako očekuješ poruke, klikni refresh ili provjeri da POP3 server zaista ima mail u sandučiću.</p>
</div>
<?php endif; ?>
</section>
<section class="section-card">
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
<div>
<div class="overline mb-1">Read / detail</div>
<h2 class="section-title">Detalj poruke</h2>
</div>
<span class="soft-badge">Server-rendered preview</span>
</div>
<?php if ($selectedMessage): ?>
<div class="message-header-grid">
<div class="detail-block">
<strong>Subject</strong>
<div><?= h($selectedMessage['subject']) ?></div>
</div>
<div class="detail-block">
<strong>From</strong>
<div><?= h($selectedMessage['from']) ?></div>
</div>
<div class="detail-block">
<strong>Date / POP3 #</strong>
<div><?= h($selectedMessage['date'] !== '' ? $selectedMessage['date'] : ('Message #' . $selectedMessage['number'])) ?></div>
</div>
</div>
<article class="mail-preview">
<div class="message-body"><?= nl2br(h($selectedMessage['body_text'])) ?></div>
</article>
<?php else: ?>
<div class="empty-panel">
<h3 class="h6 mb-2">Odaberi poruku s lijeve strane.</h3>
<p class="empty-copy mb-0">Kad odabereš mail, ovdje ćeš vidjeti subject, sender, datum i tekstualni sadržaj poruke.</p>
</div>
<?php endif; ?>
<div class="surface-muted p-3 rounded-4 border mt-3">
<div class="overline mb-2">Mailbox settings</div>
<div class="meta-list">
<div>
<strong>POP3 endpoint</strong>
<span><?= h($account['pop3_host']) ?>:<?= h((string) $account['pop3_port']) ?> · <?= h(security_label((string) $account['security_mode'])) ?></span>
</div>
<div>
<strong>Username</strong>
<span><?= h($account['username']) ?></span>
</div>
<div>
<strong>Saved in database</strong>
<span><?= h(format_datetime($account['created_at'])) ?></span>
</div>
</div>
</div>
</section>
</main>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="assets/js/main.js?v=<?= h(asset_version('assets/js/main.js')) ?>" defer></script>
</body>
</html>

199
xampp-setup.php Normal file
View File

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/app.php';
$settings = db_settings();
$setupError = null;
$setupMessage = null;
$scriptDir = trim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '')), '/.');
$localBaseUrl = 'http://localhost' . ($scriptDir !== '' ? '/' . $scriptDir : '');
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize_database') {
try {
db_initialize(__DIR__ . '/db/migrations/20260524_create_mail_accounts.sql');
flash('success', 'XAMPP baza je spremna. Sada možeš dodati POP3 račun.');
header('Location: index.php');
exit;
} catch (Throwable $exception) {
$setupError = $exception->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();
?>
<!doctype html>
<html lang="hr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($pageTitle) ?></title>
<meta name="description" content="<?= h($pageDescription) ?>">
<meta name="robots" content="noindex, nofollow">
<?php if ($projectDescription): ?>
<meta property="og:description" content="<?= h($projectDescription) ?>">
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<meta property="og:title" content="<?= h($pageTitle) ?>">
<meta property="twitter:title" content="<?= h($pageTitle) ?>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= h(asset_version('assets/css/custom.css')) ?>">
</head>
<body>
<div class="container app-shell py-4 py-lg-5">
<nav class="navbar app-nav navbar-expand-lg">
<div class="container-fluid px-0">
<a class="navbar-brand d-flex align-items-center gap-3 m-0" href="index.php">
<span class="brand-mark">WM</span>
<span class="brand-copy">
<strong><?= h(project_name()) ?></strong>
<small>XAMPP setup</small>
</span>
</a>
<div class="d-flex flex-wrap align-items-center gap-2 ms-auto">
<a class="btn btn-sm btn-outline-secondary" href="index.php">Dashboard</a>
<a class="btn btn-sm btn-outline-secondary" href="healthz.php" target="_blank" rel="noopener">Healthz</a>
</div>
</div>
</nav>
<header class="hero-grid">
<section class="hero-card">
<div class="page-eyebrow mb-2">Local install</div>
<h1 class="hero-title">Priprema za XAMPP lokalni rad</h1>
<p class="section-subtitle">Otvori ovu stranicu unutar <strong>htdocs</strong>, pokreni Apache i MySQL u XAMPP-u, pa jednim klikom kreiraj bazu i mailbox tablicu. Nakon toga dashboard na <code>index.php</code> radi lokalno na <code>localhost</code>.</p>
<div class="hero-actions">
<?php if ($dbReady): ?>
<a class="btn btn-primary" href="index.php">Otvori dashboard</a>
<?php else: ?>
<form method="post" class="d-inline-flex m-0">
<input type="hidden" name="action" value="initialize_database">
<button type="submit" class="btn btn-primary">Kreiraj bazu i tablicu</button>
</form>
<?php endif; ?>
<a class="btn btn-outline-secondary" href="index.php">Natrag na aplikaciju</a>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<span class="inline-note">Apache + MySQL</span>
<span class="inline-note">phpMyAdmin optional</span>
<span class="inline-note">Local URL ready</span>
</div>
</section>
<aside class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Config profile</div>
<div class="metric-value" style="font-size:1.1rem;">PHP <?= h(PHP_VERSION) ?></div>
<div class="metric-hint">MySQL: <?= h($settings['user']) ?>@<?= h($settings['host']) ?>:<?= h((string) $settings['port']) ?></div>
</div>
<div class="metric-card">
<div class="metric-label">Database</div>
<div class="metric-value" style="font-size:1.1rem;"><?= h($settings['name']) ?></div>
<div class="metric-hint">Lozinka: <?= $settings['has_password'] ? 'postavljena' : 'prazna / XAMPP default' ?></div>
</div>
<div class="metric-card">
<div class="metric-label">Open locally</div>
<div class="metric-value" style="font-size:1rem;"><?= h($localBaseUrl !== '' ? $localBaseUrl : 'http://localhost') ?></div>
<div class="metric-hint">Zatim otvori <a class="text-decoration-underline" href="healthz.php" target="_blank" rel="noopener">/healthz</a></div>
</div>
</aside>
</header>
<?php if ($dbReady): ?>
<div class="alert alert-success border-0 mb-4" role="alert">
<strong>Baza je spremna.</strong> Možeš se vratiti na dashboard i dodati prvi POP3 račun.
</div>
<?php else: ?>
<div class="alert alert-warning border-0 mb-4" role="alert">
<strong>Baza još nije spremna.</strong> Ako koristiš tipični XAMPP, ostavi <code>root</code> korisnika i praznu lozinku ili po potrebi prvo prilagodi <code>db/config.php</code>.
<?php if ($dbError): ?>
<div class="small mt-2"><?= h($dbError) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<main class="row g-4">
<section class="col-12 col-xl-7">
<div class="section-card stack-md h-100">
<div>
<div class="overline mb-1">3 steps</div>
<h2 class="section-title">Kako pokrenuti aplikaciju na XAMPP-u</h2>
</div>
<div class="quick-steps">
<article class="quick-step">
<h3>1. Kopiraj projekt u htdocs</h3>
<p>Primjer: <code>xampp/htdocs/pop3-webmail</code>. Lokalni URL će tada biti sličan <code><?= h($localBaseUrl) ?>/index.php</code>.</p>
</article>
<article class="quick-step">
<h3>2. Pokreni Apache i MySQL</h3>
<p>U XAMPP Control Panelu uključi oba servisa. Bez aktivnog MySQL-a spremanje mailboxa neće raditi.</p>
</article>
<article class="quick-step">
<h3>3. Inicijaliziraj bazu</h3>
<p>Klikni gumb <strong>Kreiraj bazu i tablicu</strong>. To će napraviti bazu <code><?= h($settings['name']) ?></code> i tablicu <code>mail_accounts</code>.</p>
</article>
</div>
</div>
</section>
<aside class="col-12 col-xl-5">
<div class="section-card stack-md h-100">
<div>
<div class="overline mb-1">Current config</div>
<h2 class="section-title">Vrijednosti koje aplikacija koristi</h2>
</div>
<div class="surface-muted p-3 rounded-4 border">
<div class="meta-list">
<div>
<strong>DB host</strong>
<span><?= h($settings['host']) ?></span>
</div>
<div>
<strong>DB port</strong>
<span><?= h((string) $settings['port']) ?></span>
</div>
<div>
<strong>DB name</strong>
<span><?= h($settings['name']) ?></span>
</div>
<div>
<strong>DB user</strong>
<span><?= h($settings['user']) ?></span>
</div>
<div>
<strong>DB password</strong>
<span><?= $settings['has_password'] ? 'Configured' : 'Empty (XAMPP default)' ?></span>
</div>
</div>
</div>
<div class="surface-muted p-3 rounded-4 border">
<div class="overline mb-2">Manual fallback</div>
<p class="small mb-2">Ako želiš ručno kroz phpMyAdmin, prvo kreiraj bazu:</p>
<pre class="mb-0"><code>CREATE DATABASE IF NOT EXISTS `<?= h($settings['name']) ?>`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;</code></pre>
<p class="small mt-3 mb-0">Zatim importaj SQL iz <code>db/migrations/20260524_create_mail_accounts.sql</code>.</p>
</div>
</div>
</aside>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
</body>
</html>