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…
-
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
+
+
+
+
+
= h($flash['message']) ?>
+
+
+
-
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
-
+
+
+
+
+
+
+
+
+
+
+
Baza nije dostupna. Forma i lista su prikazane, ali spremanje neće raditi dok se MySQL veza ne podigne.
+
Za lokalni XAMPP pokreni Apache + MySQL, zatim otvori
xampp-setup.php da automatski kreiraš bazu
= h(DB_NAME) ?> .
+
Trenutna konfiguracija: = h(DB_USER) ?>@= h(DB_HOST) ?>:= h((string) DB_PORT) ?> / = h(DB_NAME) ?>= DB_PASS === '' ? ' · bez lozinke' : ' · lozinka postavljena' ?>
+
+
= h($dbError) ?>
+
+
+
+
+
+
+
+
+
+
Create / input
+
Dodaj POP3 račun
+
+
Server-side validation · encrypted password at rest
+
+
+
+
= h($errors['form']) ?>
+
+
+
+
+
+
+
+
+
+
Confirmation / guide
+
Što dobivaš u ovoj isporuci
+
+
+
+ 1. Spremanje računa
+ POP3 postavke se validiraju na serveru i spremaju u MySQL, a lozinka se šifrira prije upisa u bazu.
+
+
+ 2. Live inbox ekran
+ Nakon spremanja otvara se mailbox detalj koji čita najnovije poruke direktno preko POP3 veze.
+
+
+ 3. Pregled statusa
+ Na dashboardu ostaju vidljivi status zadnje sinkronizacije, broj poruka i brzi linkovi prema svakom inboxu.
+
+
+
+
Next after MVP
+
+ SMTP compose + Sent folder tok.
+ Lokalni cache headera/poruka za bržu pretragu.
+ Uređivanje i deaktivacija mailbox računa.
+
+
+
+
+
+
+
+
+
+
List
+
Spremljeni mailbox računi
+
+
+ Last sync: = h($latestSync === 'Not yet' ? $latestSync : format_datetime($latestSync)) ?>
+ Accounts: = h((string) count($accounts)) ?>
+
+
+
+
+
+
+
+
+ Mailbox
+ POP3
+ Status
+ Zadnja sinkronizacija
+ Akcija
+
+
+
+
+
+
+ = h($account['label']) ?>
+ = h((string) ($account['email_address'] ?: $account['username'])) ?>
+
+
+ = h($account['pop3_host']) ?>:= h((string) $account['pop3_port']) ?>
+ = h(security_label((string) $account['security_mode'])) ?> · limit = h((string) $account['sync_limit']) ?>
+
+
+ = h((string) ($account['last_status'] ?: 'Ready')) ?>
+ = h((string) $account['last_message_count']) ?> poruka
+
+
+ = h(format_datetime($account['last_sync_at'])) ?>
+ Dodano = h(format_datetime($account['created_at'])) ?>
+
+
+ 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) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= h($flash['message']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
Mailbox nije pronađen.
+
Vrati se na dashboard i dodaj POP3 račun da bi otvorio inbox pregled.
+
Idi na dashboard
+
+
+
+
+
+
+
Sinkronizacija nije uspjela. = h($syncError) ?>
+
Provjeri host, port, sigurnost veze i POP3 korisničke podatke. Za lokalni test često vrijedi 127.0.0.1:110 bez enkripcije.
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+ = nl2br(h($selectedMessage['body_text'])) ?>
+
+
+
+
Odaberi poruku s lijeve strane.
+
Kad odabereš mail, ovdje ćeš vidjeti subject, sender, datum i tekstualni sadržaj poruke.
+
+
+
+
+
+
+
+
+
+
+
+
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) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Baza je spremna. Možeš se vratiti na dashboard i dodati prvi POP3 račun.
+
+
+
+
Baza još nije spremna. Ako koristiš tipični XAMPP, ostavi
root korisnika i praznu lozinku ili po potrebi prvo prilagodi
db/config.php.
+
+
= h($dbError) ?>
+
+
+
+
+
+
+
+
+
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 = h($localBaseUrl) ?>/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 = h($settings['name']) ?> i tablicu mail_accounts.
+
+
+
+
+
+
+
+
+
Current config
+
Vrijednosti koje aplikacija koristi
+
+
+
+
Manual fallback
+
Ako želiš ručno kroz phpMyAdmin, prvo kreiraj bazu:
+
CREATE DATABASE IF NOT EXISTS `= h($settings['name']) ?>`
+CHARACTER SET utf8mb4
+COLLATE utf8mb4_unicode_ci;
+
Zatim importaj SQL iz db/migrations/20260524_create_mail_accounts.sql.
+
+
+
+
+
+
+
+