<?php
/**
* php-ymap Demo - Unified Entry Point ~ php -S localhost:8000
*/
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once dirname(__FILE__) . '/get.php';
exit;
}
$phpVersion = PHP_VERSION;
$imapLoaded = extension_loaded('imap');
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Live demo of php-ymap - a modern PHP 8.1+ IMAP library. Connect to any IMAP server, search/filter emails, manage message flags, and preview attachments with this lightweight, AJAX-powered interface.">
<title>php-ymap Demo - Modern PHP IMAP Library</title>
<link rel="icon" type="image/x-icon" href="data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=">
<style>
:root {
--bg: #0f172a;
--surface: #172131;
--border: #334155;
--loading-border: #374c6e;
--text: #f1f5f9;
--muted: #cbd5e1;
--accent: #73a6f9;
--accent-hover: #2563eb;
--success: #22c55e;
--error: #ff7979;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scrollbar-gutter: stable; }
body {
background: radial-gradient( circle at 20% 80%, rgba(59, 130, 246, 0.05) 0%, transparent 50% ), radial-gradient( circle at 80% 20%, rgba(139, 92, 246, 0.05) 0%, transparent 50% ), var(--bg);
background-attachment: fixed;
color: var(--text);
line-height: 1.6;
min-height: 100vh;
padding: 2rem;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle at 25% 25%, rgba(59, 130, 246, 0.03) 0%, transparent 50%), radial-gradient(circle at 75% 75%, rgba(139, 92, 246, 0.03) 0%, transparent 50%);
pointer-events: none;
z-index: -1;
}
body.modal-open {
overflow: hidden;
}
.card {
background: var(--surface);
border: 1px solid rgba(51, 65, 85, 0.7);
border-radius: 18px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.35);
transition: border-color 0.3s, box-shadow 0.3s;
}
.imap-card-header {
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
}
.card:hover {
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 25px 55px rgba(15, 23, 42, 0.45);
}
body.imap-loading .card.connection,
body.imap-loading .loading {
border-color: var(--accent);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
animation: pulse-border 2.5s ease-in-out infinite;
}
body.imap-loading .card.connection {
position: relative;
overflow: hidden;
}
body.imap-loading .card.connection::after {
content: '';
position: absolute;
z-index: 1;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(59, 130, 246, 0.1) 50%, transparent 100% );
transform: translateX(-100%);
animation: shimmer 2.5s infinite;
}
header {
margin: 0 auto;
padding: 0 2rem;
max-width: 1200px;
line-height: 1;
}
header h1 {
font-size: 2.25rem;
font-weight: 800;
margin-bottom: 0;
letter-spacing: 0.02em;
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
header a {
border: 0 none;
}
header .subtitle {
color: var(--muted);
margin-bottom: 2rem;
font-size: 1.05rem;
padding-top: .75rem;
margin-top: .75rem;
border-top: 1px solid #88899955;
}
main {
margin: 0 auto;
padding: 0 2rem;
max-width: 1200px;
}
footer nav {
padding: 1rem 0;
display: flex;
justify-content: center;
gap: 1rem;
}
footer nav a {
display: block;
padding: 4px 10px;
color: var(--text);
}
.table-container {
overflow-x: auto;
}
ul li {
padding: .1rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
table thead tr {
border-bottom: 2px solid var(--border);
}
table tbody tr:not(:last-of-type) {
border-bottom: 1px solid var(--border);
}
table th,
table td {
text-align: left;
padding: 0.75rem;
color: var(--accent);
font-weight: 600;
}
table td {
padding: 0.75rem;
color: var(--text);
font-weight: 500;
}
table td code {
background: var(--bg);
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: #a78bfa;
white-space: nowrap;
}
table td[title] {
cursor: help;
}
textarea {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
min-height: 70px;
resize: vertical;
}
.hint {
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.25rem;
}
.form-group { margin-bottom: 1rem; }
label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--muted);
}
input[type="text"],
input[type="password"],
input[type="number"],
input[type="date"],
select,
textarea {
width: 100%;
padding: 0.8rem 1rem;
border: 2px solid transparent;
border-radius: 12px;
color: var(--text);
font-size: 1rem;
position: relative;
z-index: 4;
background: linear-gradient(var(--bg), var(--bg)) padding-box, linear-gradient(135deg, var(--accent), #8b5cf6) border-box;
transition: box-shadow 0.3s;
}
select { cursor: pointer; background-position: 0 0, 0 0; }
select option { background: var(--bg); }
input[type="text"]:focus,
input[type="password"]:focus,
input[type="number"]:focus,
input[type="date"]:focus,
select:focus,
textarea:focus {
outline: none;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.18);
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.form-row .form-group { margin-bottom: 0; }
.form-groups { display: flex; gap: 1rem; }
.form-groups .form-group { flex: 1 auto; }
.form-search-in-row,
.btn-group.submit-buttons {
margin-top: 1rem;
}
.btn-group.submit-buttons {
margin-top: 1.5rem;
padding-top: 1.5rem;
gap: .75rem;
border-top: 1px solid var(--border);
transition: all .3s ease-in-out;
filter: grayscale(0%);
opacity: 1;
}
body.imap-loading .submit-buttons button:disabled { opacity: 1; }
body.imap-loading .btn-group.submit-buttons {
transition: all .3s ease-in;
filter: grayscale(100%);
opacity: .6;
}
hr {
margin: 1rem 0;
line-height: 1;
border-color: #2b384b;
}
.card.connection {
transition: all .3s ease-in;
}
.card.connection > h2 {
margin-bottom: 1rem;
line-height: 1;
font-size: 1.25rem;
}
.section-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--accent);
margin: 1.5rem 0 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
button {
background: var(--surface);
color: var(--text);
padding: 0.85rem 1.6rem;
border: 1px solid rgba(59, 130, 246, 0.4);
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.3s ease-, box-shadow 0.2s ease-in;
}
button:hover { box-shadow: 0 5px 20px rgba(59, 130, 246, 0.2); }
button:disabled { opacity: .6; cursor: not-allowed; }
button[type="submit"] {
background: linear-gradient(135deg, var(--accent), #8b5cf6);
border: none;
color: #fff;
box-shadow: 0 0 18px 6px rgba(59, 130, 246, 0.2);
position: relative;
overflow: hidden;
}
button[type="submit"]::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent );
transition: left 0.6s;
}
button[type="submit"]:hover::before {
left: 100%;
}
a {
line-height: 1.4;
display: inline-block;
border-bottom: 1px dashed;
text-decoration: none;
color: #92bbcb;
}
a:focus,
a:hover,
a:active {
color: #8ccbff;
border-bottom-style: solid;
}
.password-wrapper {
position: relative;
}
.password-toggle {
position: absolute;
right: 0.75rem;
top: 50%;
z-index: 5;
transform: translateY(-50%);
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
padding: 0.25rem;
font-size: 1.1rem;
line-height: 1;
}
.password-toggle:hover { color: var(--accent); background: transparent; }
.action-btn {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.35);
color: var(--text);
padding: 0.5rem 0.9rem;
font-size: 0.85rem;
border-radius: 10px;
transition: background 0.3s;
}
.action-btn:hover,
.action-btn.btn-active {
background: rgba(59, 130, 246, 0.25);
}
.action-btn.secondary {
border-color: rgba(239, 68, 68, 0.5);
color: var(--error);
background: rgba(166, 45, 45, 0.1);
}
.action-btn.secondary:hover {
background: rgba(239, 68, 68, 0.2);
color: #fee2e2;
}
.btn-group { display: flex; gap: 0.5rem; margin-top: .75rem; }
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.alert.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
color: var(--error);
}
.alert.success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
.search-criteria {
background: var(--bg);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.8rem;
color: var(--muted);
margin-top: 0.5rem;
font-family: monospace;
}
.message {
background: var(--surface);
border: 1px solid rgba(51, 65, 85, 0.85);
border-radius: 16px;
padding: 1.25rem 1.5rem 1rem;
margin-bottom: 1.25rem;
position: relative;
overflow: hidden;
border-left: 5px solid transparent;
transition: border-color 0.3s, box-shadow 0.3s;
}
.message::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 0%, rgba(59, 130, 246, 0.08) 100%);
opacity: 0;
transition: opacity 0.3s;
}
.message > * {
position: relative;
z-index: 2;
}
.message.message--read:hover {
border-color: rgba(34, 197, 94, .3);
}
.message:hover::before {
opacity: 1;
}
.message--unread {
border-color: var(--accent);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.03), rgba(39, 36, 36, 0.3));
}
.message--read {
border-color: rgba(59, 130, 246, 0.15);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.message-header > div:first-of-type {
overflow: hidden;
}
.message-header > div:first-of-type .message-subject.collapsed {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message-subject {
font-weight: 600;
font-size: 1.1rem;
color: var(--text);
cursor: pointer;
user-select: none;
}
.message-subject::before {
content: '?';
margin-right: 0.5rem;
height: 12px;
display: inline-block;
font-size: 0.7rem;
transition: transform 0.2s;
}
.message-subject.collapsed::before { transform: rotate(-90deg); }
.message-date-size {
display: flex;
font-size: 0.8rem;
color: var(--muted);
}
.message-date-size span:not(:last-of-type) {
margin-right: .75rem;
padding-right: .75rem;
display: block;
white-space: nowrap;
border-right: 1px solid #9b9191;
}
.message-size { font-weight: 600; white-space: nowrap; }
.message-status-group {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.35rem;
}
.message-status {
position: relative;
display: inline-flex;
font-size: 0.7rem;
font-weight: 600;
padding: 2px 0.9rem 0 1.9rem;
border-radius: 999px;
letter-spacing: 0.05em;
line-height: 1;
border: 1px solid transparent;
min-height: 26px;
align-items: center;
justify-content: center;
}
.message-status::before {
content: '';
position: absolute;
left: 0.8rem;
top: 50%;
transform: translateY(-50%);
width: 1rem;
height: 1rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
}
.message-status.read {
background: rgba(34, 197, 94, 0.12);
color: var(--success);
border-color: rgba(34, 197, 94, 0.4);
}
.message-status.read::before {
content: '?';
font-size: 0.8rem;
transform: translateY(-45%);
}
.message-status.unread {
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
border-color: rgba(59, 130, 246, 0.4);
}
.message-status.unread::before {
content: '?';
font-size: 0.6rem;
transform: translateY(-45%);
}
.message-status.answered {
background: rgba(59, 130, 246, 0.2);
color: #bfdbfe;
border-color: rgba(59, 130, 246, 0.45);
}
.message-status.answered::before {
content: '?';
font-size: 0.8rem;
transform: translateY(-40%) rotate(-45deg);
}
.message-status.unanswered {
background: rgba(148, 163, 184, 0.2);
color: var(--muted);
border-color: rgba(148, 163, 184, 0.4);
}
.message-status.unanswered::before {
content: '?';
font-size: 0.9rem;
transform: translateY(-55%);
}
.message-meta-wrapper {
display: flex;
flex-wrap: wrap;
gap: 1rem;
line-height: 1;
}
.message-meta {
display: flex;
align-items: center;
font-size: 0.875rem;
color: var(--muted);
}
.message-meta strong {
color: var(--text);
font-weight: 500;
}
.message-body {
--muted: var(--text);
font-size: 0.9rem;
color: var(--muted);
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
transition: max-height 0.3s, padding 0.3s, margin 0.3s;
}
.message-body.collapsed {
max-height: 0;
padding: 0;
margin: 0;
border: none;
overflow: hidden;
}
.messages-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.message-actions {
margin-top: 1rem;
padding-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border);
}
.message-actions .action-btn {
padding: 0.5rem 0.9rem;
font-size: 0.8rem;
background: rgba(59, 130, 246, 0.12);
}
.message-actions select {
padding: 0.5rem .75rem;
min-width: 140px;
}
.message-flags {
display: flex;
gap: 0.5rem;
}
.address-link {
margin-left: 8px;
max-width: 200px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
color: inherit;
border-bottom: 1px dashed transparent;
transition: border-color 0.2s, color 0.2s;
}
.address-link:hover {
color: var(--accent);
border-bottom-color: currentColor;
}
.attachments { margin-top: 0.75rem; display: flex; flex-wrap: wrap; gap: 0.5rem; }
.attachment-badge {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
transition: all 0.2s;
cursor: pointer;
}
.attachment-badge:hover {
background: rgba(139, 92, 246, 0.3);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.2);
}
.empty-state {
padding: 2rem;
background: linear-gradient(135deg, rgba(30, 41, 59, 0.5), transparent);
border: 2px dashed rgba(225, 49, 49, .8);
border-radius: 16px;
position: relative;
overflow: hidden;
text-align: center;
}
.empty-state::before {
content: '?';
font-size: 3rem;
display: block;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 0%, rgba(59, 130, 246, 0.03) 100%);
pointer-events: none;
}
code {
background: var(--bg);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.85rem;
color: #a78bfa;
}
.loading {
display: none;
position: fixed;
bottom: 30px;
left: 50%;
white-space: nowrap;
transform: translateX(-50%);
background: var(--surface);
border: 2px solid var(--loading-border);
border-radius: 12px;
padding: 1rem 2rem;
color: var(--muted);
z-index: 1001;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.loading.active { display: flex; align-items: center; gap: 1rem; }
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
.toast {
position: fixed;
bottom: 1.5rem;
right: 2rem;
background: var(--success);
color: white;
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-weight: 500;
opacity: 0;
transform: translateY(1rem);
transition: all 0.3s;
pointer-events: none;
z-index: 1000;
}
.toast.show { opacity: 1; transform: translateY(0); }
body.page-is-scrolled .toast { right: 5.5rem; }
#messagesContainer, #statusContainer { display: none; will-change: auto; }
.modal {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.show {
display: flex;
}
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(11, 13, 18, .8);
backdrop-filter: blur(8px);
opacity: 0;
transition: opacity 0.3s ease-out;
}
.modal.show .modal-overlay {
opacity: 1;
}
.modal.closing .modal-overlay {
opacity: 0;
}
.modal-content {
width: 100%;
max-width: calc(940px - 10rem);
max-height: 80vh;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.95) );
border: 1px solid rgba(59, 130, 246, 0.35);
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.55);
backdrop-filter: blur(18px);
transform: translateY(100%);
opacity: 0;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease-out;
}
.modal.show .modal-content {
transform: translateY(0);
opacity: 1;
}
@keyframes modal-slide-out-left {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-60px); opacity: 0; }
}
@keyframes modal-slide-out-right {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(60px); opacity: 0; }
}
@keyframes modal-slide-in-right {
from { transform: translateX(60px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes modal-slide-in-left {
from { transform: translateX(-60px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.modal-content-inner.slide-out-left { animation: modal-slide-out-left 0.25s forwards ease; }
.modal-content-inner.slide-out-right { animation: modal-slide-out-right 0.25s forwards ease; }
.modal-content-inner.slide-in-right { animation: modal-slide-in-right 0.25s forwards ease; }
.modal-content-inner.slide-in-left { animation: modal-slide-in-left 0.25s forwards ease; }
.modal.closing .modal-content {
transform: translateY(100%);
opacity: 0;
}
.modal-content-inner {
padding: 1.75rem;
overflow-y: auto;
flex: 1;
display: grid;
height: 100%;
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: transparent;
border: none;
color: var(--muted);
font-size: 1.25rem;
cursor: pointer;
}
.modal-close:hover { color: var(--accent); }
.modal-body {
margin-top: 1rem;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 16px;
padding: 1rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 1rem;
white-space: pre-wrap;
line-height: 1.5;
overflow-y: auto;
overflow-wrap: break-word;
}
.modal-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.modal-header h3 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
}
.modal-meta > div {
display: flex;
}
.modal-meta {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: .3rem;
font-size: 0.85rem;
color: var(--muted);
}
.modal-meta strong { min-width: 90px; color: var(--text); }
.modal-nav {
display: flex;
width: 100%;
border-top: 1px solid rgba(59, 130, 246, 0.35);
}
.modal-nav-btn {
flex: 1;
height: 42px;
padding: 0 1.25rem;
background: transparent;
border: none;
border-radius: 0;
border-right: 1px solid rgba(59, 130, 246, 0.35);
color: var(--accent);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.modal-nav-btn:last-child {
border-right: none;
}
.modal-nav-btn:hover:not(:disabled) {
background: rgba(59, 130, 246, 0.15);
color: var(--text);
}
.modal-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.scroll-to-top-container {
padding: 0;
position: fixed;
bottom: 1.5rem;
right: 20px;
z-index: 1005;
}
.scroll-to-top {
width: 50px;
height: 50px;
padding: 0;
border-radius: 50%;
border: none;
color: #fff;
font-size: 1.2rem;
line-height: 1;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.2);
opacity: 0;
transform: translateY(1rem);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
body.page-is-scrolled .scroll-to-top {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.y-toggle-target {
max-height: 2000px;
overflow: hidden;
opacity: 1;
transition: max-height .4s ease-in-out, opacity .4s ease-in-out, padding .4s, margin .4s;
}
.y-toggle-target.toggle-not-active {
max-height: 0;
opacity: 0;
padding: 0;
margin: 0;
overflow: hidden;
}
.yeh b,
.yeh strong {
display: inline-block;
font-size: .9rem;
}
.yeh strong {
margin-top: .2rem;
padding-top: .2rem;
border-top: 1px solid #21497b;
}
.hidden-yeh {
display: none;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse-border {
0%, 100% { box-shadow: 0 0 10px rgba(59, 130, 246, 0.2); }
50% { box-shadow: 0 0 25px rgba(59, 130, 246, 0.5); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
</style>
</head>
<body>
<header>
<h1><a href="./">? php-ymap Demo</a></h1>
<div class="subtitle">
<div style="display:flex;justify-content:space-between;width:100%;">
<span>A lightweight IMAP library for PHP 8.1+</span>
<span style="font-size:14px;">
<span style="color:var(--muted);margin-right:.65rem;font-size:12px;" title="Runtime PHP version">PHP <?= htmlspecialchars($phpVersion, ENT_QUOTES, 'UTF-8') ?></span>
<?= $imapLoaded
? '<span style="color:#fa8933;cursor:help" title="The php-imap extension is deprecated and removed as of PHP 8.4.">ext-imap on</span>'
: '<span style="color:#30dd70;cursor:help" title="Native socket mode">ext-imap off</span>' ?>
</span>
</div>
</div>
</header>
<main class="container" id="app">
<div class="card connection">
<h2>Connection Settings</h2>
<form id="imapForm" data-submit="search">
<div class="form-groups">
<div class="form-group">
<label for="mailbox">Mailbox Path</label>
<div class="form-groups">
<input type="text" id="mailbox" name="mailbox"
value="{imap.gmail.com:993/imap/ssl}INBOX"
placeholder="{imap.gmail.com:993/imap/ssl}INBOX"
required>
<button type="button"
class="action-btn"
data-action="toggleTarget"
data-target="[data-targeted='maps']"
data-toggle-class="toggle-not-active"
data-toggle-class-self="btn-active"
data-toggle-content-self="Close Provider"
data-toggle-callback="formatImapProviders"
data-toggle-callback-once>
IMAP Provider
</button>
</div>
</div>
</div>
<!-- Imap provider -->
<div data-targeted="maps" class="y-toggle-target toggle-not-active">
<pre id="y-known-imap-providers">{
"imapProviders": [
{
"name": "Gmail / Google Workspace",
"host": "imap.gmail.com:993",
"mailbox": "{imap.gmail.com:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "[Gmail]/Sent Mail", "[Gmail]/Drafts", "[Gmail]/Spam", "[Gmail]/Trash"],
"notes": "Requires App Password (2FA enabled) or OAuth2 for Google Workspace"
},{
"name": "Outlook.com / Hotmail / Office 365",
"host": "outlook.office365.com:993",
"mailbox": "{outlook.office365.com:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent", "Drafts", "Junk", "Trash", "Archive"],
"notes": "Works with normal password or App Password"
},{
"name": "Microsoft Exchange (on-prem or hybrid)",
"host": "mail.yourcompany.com:993",
"mailbox": "{mail.yourcompany.com:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent Items", "Drafts", "Junk Email", "Deleted Items"],
"notes": "Replace with your actual domain. Often needs /novalidate-cert"
},{
"name": "iCloud",
"host": "imap.mail.me.com:993",
"mailbox": "{imap.mail.me.com:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent Messages", "Drafts", "Junk", "Trash", "Archive"],
"notes": "Use Apple ID + App-Specific Password"
},{
"name": "Yahoo Mail",
"host": "imap.mail.yahoo.com:993",
"mailbox": "{imap.mail.yahoo.com:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent", "Draft", "Bulk", "Trash", "Archive"],
"notes": "Requires App Password (under Account Security)"
},{
"name": "AOL Mail",
"host": "imap.aol.com:993",
"mailbox": "{imap.aol.com:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent", "Saved", "Spam", "Trash"],
"notes": "Use App Password"
},{
"name": "1&1 IONOS (DE/UK/ES)",
"host": "imap.ionos.de:993",
"mailbox": "{imap.ionos.de:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent", "Drafts", "Spam", "Trash"],
"notes": "Also works: imap.ionos.co.uk, imap.ionos.es, imap.ionos.com"
},{
"name": "Strato",
"host": "imap.strato.de:993",
"mailbox": "{imap.strato.de:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent", "Drafts", "Spam", "Trash"]
},{
"name": "GMX",
"host": "imap.gmx.net:993",
"mailbox": "{imap.gmx.net:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent", "Drafts", "Spam", "Trash"],
"notes": "Also: imap.gmx.com"
},{
"name": "WEB.DE",
"host": "imap.web.de:993",
"mailbox": "{imap.web.de:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Gesendet", "Entwürfe", "Spam", "Papierkorb"]
},{
"name": "hosteurope / PlusServer",
"host": "imap.hosteurope.de:993",
"mailbox": "{imap.hosteurope.de:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent", "Drafts", "Trash"]
},{
"name": "All-inkl",
"host": "imap.all-inkl.com:993",
"mailbox": "{imap.all-inkl.com:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent", "Drafts", "Trash"]
},{
"name": "Zoho Mail",
"host": "imap.zoho.eu:993",
"mailbox": "{imap.zoho.eu:993/imap/ssl/novalidate-cert}INBOX",
"boxes": ["INBOX", "Sent", "Drafts", "Spam", "Trash"],
"notes": "Use imap.zoho.com (US) or imap.zoho.eu / .au / .in depending on region"
}
]}</pre>
</div>
<div class="form-groups">
<div class="form-group">
<label for="username">Username / Email</label>
<input type="text" id="username" name="username"
placeholder="your-email@gmail.com"
autocomplete="username"
required>
</div>
<div class="form-group">
<label for="password">Password / App Password</label>
<div class="password-wrapper yai-password-util">
<input type="password"
id="password"
name="password"
placeholder="Generated app password"
autocomplete="current-password"
required>
<button type="button"
class="password-toggle"
data-action="togglePassword"
title="Toggle password visibility"
data-toggle-content="?">?</button>
</div>
</div>
</div>
<div class="btn-group">
<button type="button" class="action-btn" data-action="saveCredentials">? Save Credentials</button>
<button type="button" class="action-btn" data-action="loadCredentials">? Load Saved</button>
<button type="button" class="action-btn secondary" data-action="clearCredentials">?? Clear Saved</button>
</div>
<div class="section-title">? Filters</div>
<div class="form-row">
<div class="form-group">
<label for="limit">Limit</label>
<input type="number" id="limit" name="limit" value="10" min="1" max="100">
</div>
<div class="form-group">
<label for="read_status">Read Status</label>
<select id="read_status" name="read_status">
<option value="ALL">All</option>
<option value="UNREAD">Unread</option>
<option value="READ">Read</option>
</select>
</div>
<div class="form-group">
<label for="answered_status">Answered Status</label>
<select id="answered_status" name="answered_status">
<option value="ALL">All</option>
<option value="UNANSWERED">Unanswered</option>
<option value="ANSWERED">Answered</option>
</select>
</div>
<div class="form-group">
<label for="date_from">From Date</label>
<input type="date" id="date_from" name="date_from">
</div>
<div class="form-group">
<label for="date_to">To Date</label>
<input type="date" id="date_to" name="date_to">
</div>
</div>
<div class="form-row form-search-in-row">
<div class="form-group">
<label for="search_field">Search In</label>
<select id="search_field" name="search_field">
<option value="ALL">All Fields</option>
<option value="SUBJECT">Subject</option>
<option value="FROM">From</option>
<option value="TO">To</option>
<option value="BODY">Body</option>
</select>
</div>
<div class="form-group" style="grid-column: span 2;">
<label for="search_text">Search Text</label>
<input type="text" id="search_text" name="search_text" placeholder="Enter search term...">
</div>
</div>
<div class="btn-group submit-buttons">
<button type="submit" id="submitBtn" style="flex: 1;">Connect & Search</button>
<button type="button"
data-action="toggleTarget"
data-target=".advanced-options"
data-toggle-class="toggle-not-active"
data-toggle-class-self="btn-active"
data-toggle-tate="inactive"
class="action-btn">
?? More Options
</button>
<button type="button" class="action-btn secondary" data-action="clearFilters">Clear Filters</button>
</div>
<div class="advanced-options y-toggle-target toggle-not-active" id="advancedOptions">
<div class="section-title">Advanced Options</div>
<div class="form-row">
<div class="form-group">
<label for="body_length">Body Preview Length (characters)</label>
<input type="number" id="body_length" name="body_length" value="500" min="50" max="20000">
<div class="hint">Controls how much of the message body is shown in cards. Full text is always available in the modal.</div>
</div>
</div>
<div class="section-title">? Exclusion Filters</div>
<div class="form-row">
<div class="form-group">
<label for="exclude_from">Exclude From (one per line)</label>
<textarea id="exclude_from" name="exclude_from" rows="3" placeholder="noreply@ newsletter@ quora.com"></textarea>
<div class="hint">Pattern matches: "quora.com" excludes all Quora emails</div>
</div>
<div class="form-group">
<label for="exclude_subject">Exclude Subject Contains (one per line)</label>
<textarea id="exclude_subject" name="exclude_subject" rows="3" placeholder="unsubscribe Promotion Newsletter"></textarea>
<div class="hint">Case-insensitive matching</div>
</div>
</div>
</div>
</form>
</div>
<div id="statusContainer"></div>
<div id="messagesContainer">
<div class="messages-header">
<h2 style="font-size: 1.25rem;">Messages</h2>
<div style="display: flex; align-items: center; gap: 1rem;">
<span id="totalSizeDisplay" style="font-size: 0.9rem; color: var(--muted);"></span>
</div>
</div>
<div id="messagesList"></div>
<button type="button"
class="action-btn secondary"
id="loadMoreBtn"
data-action="loadMore"
style="display:none; width:100%; padding: 22px; margin-bottom:1rem;">
Load More
</button>
</div>
<div class="modal" id="messageModal">
<div class="modal-overlay"></div>
<div class="modal-content">
<!-- Default modal content -->
<div class="modal-content-inner">
<button type="button" class="modal-close" data-action="closeModal" aria-label="Close">?</button>
<div class="modal-header"><h3 id="modalSubject"></h3></div>
<div class="modal-meta" id="modalMeta"></div>
<div class="modal-body" id="modalBody"></div>
</div>
<!-- Default modal nav -->
<div class="modal-nav">
<button type="button" class="modal-nav-btn" id="modalPrevBtn" data-action="openPreviousMessage">
<span>?</span> Previous
</button>
<button type="button" class="modal-nav-btn" id="modalNextBtn" data-action="openNextMessage">
Next <span>?</span>
</button>
</div>
</div>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<span>Connecting and fetching messages...</span>
</div>
<!-- Notes -->
<div class="card">
<!-- Usage notes -->
<h3>Usage Notes</h3>
<ul style="color: var(--muted); padding-top: 0.5rem; padding-left: 1.25rem;">
<li>Gmail requires an <a href="https://support.google.com/accounts/answer/185833?hl=en&ref_topic=7189145&sjid=961884463217736990-NA" target="_blank" rel="noopener"><strong>App Password</strong></a> for IMAP access</li>
<li>Enable IMAP in your email settings</li>
<li>This demo uses <code>FT_PEEK</code> so messages won't be marked as read</li>
<li>Click message subjects to expand/collapse the body</li>
</ul>
<hr />
<!-- Technical notes -->
<h3>Technical Notes</h3>
<ul style="color: var(--muted); padding-top: 0.5rem; padding-left: 1.25rem;">
<li>Default connector: <code>SocketsImapConnection</code> (works without <code>ext-imap</code>)</li>
<li><code>ext-imap</code> is optional and can be enabled as fallback</li>
<li><code>htmlBody</code> is only set when a valid <code>text/html</code> MIME part exists</li>
<li><code>bodyPreview</code>/<code>bodyFull</code> may include raw multipart content for non-standard emails</li>
<li>Flags (<code>seen</code>, <code>answered</code>) are loaded from server state and persisted via IMAP flag updates</li>
</ul>
</div>
<!-- Credits -->
<div class="card">
<h3 style="margin-bottom: 0.5rem;">? Powered by YEH</h3>
<p style="color: var(--muted); margin-bottom: 0.75rem;">
This demo uses <strong>YEH</strong> (Yai Event Hub) - a lightweight event delegation library for modern web apps.
</p>
<ul style="color: var(--muted); padding-left: 1.25rem; margin-bottom: 0.75rem;">
<li><a href="https://jsfiddle.net/hb9t3gam/" target="_blank" rel="noopener">YEH on JSF</a> ? YEH toggleTarget Examples on Jsfiddle</li>
<li><a href="https://yaijs.github.io/yai/tabs/Example.html" target="_blank" rel="noopener">YaiTabs Live Demo</a> ? Advanced tab system built on YEH</li>
<li><a href="https://yaijs.github.io/yai/docs/yeh/" target="_blank" rel="noopener">YEH Documentation</a> ? Event delegation made simple</li>
</ul>
<hr />
<button type="button"
data-action="toggleTarget"
data-target=".basic-yeh"
data-toggle-class="hidden-yeh"
data-toggle-class-self="hidden-yeh"
data-toggle-state="inactive"
class="action-btn">
Show me the YEH
</button>
<div class="basic-yeh hidden-yeh">
<code><script type="module"></code>
<div style="padding:.75rem 0rem">
<pre class="yeh" style="margin-bottom:.5rem">import <b>{ YEH }</b> from '<a href="https://cdn.jsdelivr.net/npm/@yaijs/core@1.1.4/yeh/yeh.min.js" target="_blank" rel="noopener"><b>https://cdn.jsdelivr.net/npm/@yaijs/core@1.1.4/yeh/yeh.min.js</b></a>';</pre>
<hr />
<pre class="yeh">new class extends YEH<br>{<br> constructor() {<br/> super({<br> <b>'#app': ['click', 'input', 'submit']</b>,<br> <strong>'window': [{ type: 'scroll', throttle: 240 }]</strong><br/> })<br/> }</pre>
<hr style="margin-left:2rem"/>
<pre class="yeh"> handleClick( <b>e, t, c</b> ) { alert('Say what?') }<br> handleInput() {}<br> handleSubmit() {}<br> handleScroll() {}<br>}()</pre>
</div>
<code></script></code>
</div>
</div>
<!-- Scroll to top -->
<div class="scroll-to-top-container">
<button type="button" class="scroll-to-top" data-action="scrollToTop">?</button>
</div>
</main>
<footer>
<nav>
<a href="https://yaijs.github.io/yai/docs/yeh" target="_blank" rel="noopener">YEH</a>
<a href="https://github.com/yaijs/php-ymap" target="_blank" rel="noopener">YMAP</a>
<a href="https://yaijs.github.io/yai" target="_blank" rel="noopener">YAI</a>
</nav>
</footer>
<div class="toast" id="toast"></div>
<script type="module">
/**
* YEH-based Interactive Handler for php-ymap Demo
* 4 event listener on 2 elements are managing everything
*/
import { YEH } from 'https://cdn.jsdelivr.net/npm/@yaijs/core@1.1.4/yeh/yeh.min.js';
// Imap handler
class ImapHandler extends YEH {
constructor() {
super({
'#app': ['click', 'input', 'submit'],
'window': [{ type: 'scroll', throttle: 240 }],
}, {}, {
autoTargetResolution: true,
})
this.storageKey = 'phpYmapCredentials';
this.messageCache = new Map();
this.messageList = [];
this.currentModalMessageIndex = -1;
this.defaultBodyLength = 5000;
this.lastFetchDuration = null;
this.isModalTransitioning = false;
this.canLoadMore = false;
this.nextBeforeUid = null;
this.isLoadingMore = false;
this.init();
}
init() {
this.loadCredentials();
}
handleScroll(event, target, container) {
document.body.classList.toggle('page-is-scrolled', window.scrollY > 150);
}
handleClick(event, target, container) {
const action = target.dataset.action;
if (action && this[action]) this[action](target, event, container);
}
handleInput(event, target, container) {
const input = target.dataset.input;
if (input && this[input]) this[input](target, event, container);
}
handleSubmit(event, target, container) {
const submit = target.dataset.submit;
if (submit && this[submit]) this[submit](target, event, container);
}
scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// AJAX Search
async search(form, event) {
event.preventDefault();
const submitBtn = document.getElementById('submitBtn');
const loading = document.getElementById('loading');
const statusContainer = document.getElementById('statusContainer');
const messagesContainer = document.getElementById('messagesContainer');
const connectionCard = form.closest('.card');
const now = () => (typeof performance !== 'undefined' && performance.now)
? performance.now()
: Date.now();
const startedAt = now();
this.lastFetchDuration = null;
submitBtn.disabled = true;
loading.classList.add('active');
document.body.classList.add('imap-loading');
statusContainer.style.display = 'none';
messagesContainer.style.display = 'none';
try {
const response = await fetch(window.location.pathname, {
method: 'POST',
body: new FormData(form)
});
const data = await response.json();
const elapsedSeconds = (now() - startedAt) / 1000;
if (data.success) {
this.lastFetchDuration = elapsedSeconds;
this.showStatus('success',
`? Connected! Found ${data.count} of ${data.totalFound} message(s).`,
data.searchCriteria !== 'ALL' ? data.searchCriteria : null
);
this.renderMessages(data.messages, {
append: false,
hasMore: Boolean(data.hasMore),
nextBeforeUid: data.nextBeforeUid ?? null,
});
} else {
this.lastFetchDuration = null;
this.showStatus('error', data.error);
}
} catch (e) {
this.lastFetchDuration = null;
this.showStatus('error', 'Network error: ' + e.message);
} finally {
submitBtn.disabled = false;
loading.classList.remove('active');
document.body.classList.remove('imap-loading');
}
}
showStatus(type, message, criteria = null) {
const container = document.getElementById('statusContainer');
container.style.display = 'block';
container.innerHTML = `
<div class="alert ${type}">
${this.escapeHtml(message)}
${criteria ? `<div class="search-criteria">Query: ${this.escapeHtml(criteria)}</div>` : ''}
</div>
`;
}
renderMessages(messages, options = {}) {
const append = Boolean(options.append);
const hasMore = Boolean(options.hasMore);
const nextBeforeUid = options.nextBeforeUid ?? null;
const container = document.getElementById('messagesContainer');
const list = document.getElementById('messagesList');
const totalSizeDisplay = document.getElementById('totalSizeDisplay');
if (!append) {
this.messageCache = new Map();
this.messageList = [];
}
messages.forEach(msg => {
msg.detailsLoaded = Boolean(msg.detailsLoaded);
const uid = String(msg.uid);
const known = this.messageCache.has(uid);
this.messageCache.set(uid, msg);
if (!known) {
this.messageList.push(uid);
}
});
let totalSize = 0;
this.messageList.forEach(uid => {
const cached = this.messageCache.get(uid);
totalSize += cached?.size || 0;
});
totalSizeDisplay.textContent = this.formatTotalSummary(totalSize, this.messageList.length);
container.style.display = 'block';
if (!append && messages.length === 0) {
list.innerHTML = `
<div class="card empty-state">
<p>No messages found matching your criteria.</p>
</div>
`;
this.updateLoadMoreState(false, null);
this.setMassToggleButtonState('collapsed');
return;
}
const rendered = messages.map(msg => this.renderMessage(msg)).join('');
if (append) {
list.insertAdjacentHTML('beforeend', rendered);
} else {
list.innerHTML = rendered;
this.setMassToggleButtonState('collapsed');
}
this.updateLoadMoreState(hasMore, nextBeforeUid);
}
updateLoadMoreState(canLoadMore, nextBeforeUid) {
this.canLoadMore = canLoadMore;
this.nextBeforeUid = Number.isFinite(Number(nextBeforeUid)) ? Number(nextBeforeUid) : null;
const button = document.getElementById('loadMoreBtn');
if (!button) {
return;
}
if (!this.canLoadMore || !this.nextBeforeUid) {
button.style.display = 'none';
return;
}
button.style.display = '';
button.disabled = false;
button.textContent = 'Load More';
}
async loadMore(target) {
if (this.isLoadingMore) {
return;
}
if (!this.canLoadMore || !this.nextBeforeUid) {
this.showToast('No more messages to load', true);
return;
}
const form = document.getElementById('imapForm');
const payload = new FormData(form);
payload.set('before_uid', String(this.nextBeforeUid));
const loading = document.getElementById('loading');
const loadingText = document.querySelector('#loading span');
const previousText = loadingText ? loadingText.textContent : null;
this.isLoadingMore = true;
target.disabled = true;
target.textContent = 'Loading...';
loading.classList.add('active', 'loading--inline');
document.body.classList.add('imap-loading');
if (loadingText) {
loadingText.textContent = 'Loading older messages...';
}
try {
const response = await fetch(window.location.pathname, {
method: 'POST',
body: payload,
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load more messages');
}
this.renderMessages(data.messages || [], {
append: true,
hasMore: Boolean(data.hasMore),
nextBeforeUid: data.nextBeforeUid ?? null,
});
if (!Array.isArray(data.messages) || data.messages.length === 0) {
this.showToast('No more messages found');
}
} catch (error) {
this.showToast(error.message || 'Failed to load more messages', true);
} finally {
this.isLoadingMore = false;
loading.classList.remove('active', 'loading--inline');
document.body.classList.remove('imap-loading');
if (loadingText && null !== previousText) {
loadingText.textContent = previousText;
}
if (this.canLoadMore) {
target.disabled = false;
target.textContent = 'Load More';
}
}
}
renderMessage(msg) {
const formatAddrs = addrs => addrs.length ? this.renderAddressLinks(addrs) : '-';
const preview = msg.bodyPreview || '';
const classes = ['message'];
if (!msg.seen) {
classes.push('message--unread');
} else {
classes.push('message--read');
}
if (msg.answered) {
classes.push('message--answered');
}
const messageClass = classes.join(' ');
const subjectClass = 'message-subject collapsed';
return `
<div class="${messageClass}" data-uid="${msg.uid}">
<div class="message-header">
<div>
<div class="${subjectClass}" data-action="toggleBody">
${msg.subject ? this.escapeHtml(msg.subject) : '<em style="color:var(--muted)">(No subject)</em>'}
</div>
</div>
<div class="message-date-size">
<span class="message-date">${msg.date || 'Unknown'}</span>
<span class="message-size">${msg.sizeFormatted || '-'}</span>
</div>
</div>
<div class="message-meta-wrapper">
<div class="message-meta">
<strong>From:</strong> ${formatAddrs(msg.from)}
</div>
${msg.cc.length ? `
<div class="message-meta">
<strong>CC:</strong> ${formatAddrs(msg.cc)}
</div>
` : ''}
${msg.replyTo.length ? `
<div class="message-meta">
<strong>Reply-To:</strong> ${formatAddrs(msg.replyTo)}
</div>
` : ''}
<div class="message-meta">
<strong>To:</strong> ${formatAddrs(msg.to)}
</div>
</div>
${msg.attachments.length ? `
<div class="attachments">
${msg.attachments.map(att => `
<span class="attachment-badge">? ${this.escapeHtml(att.filename)} (${att.sizeFormatted})</span>
`).join('')}
</div>
` : ''}
${preview ? `
<div class="message-body collapsed">${this.escapeHtml(preview)}${msg.bodyTruncated ? '...' : ''}</div>
` : ''}
<div class="message-actions">
<div>
<button type="button" class="action-btn" data-action="openModal" data-uid="${msg.uid}">
? View Full Message
</button>
</div>
${this.renderStatusBadges(msg)}
<div class="message-flags">
<select id="toggleReadState-for-${msg.uid}" data-input="toggleReadState" data-uid="${msg.uid}">
<option value="read" ${msg.seen ? 'selected' : ''}>Read</option>
<option value="unread" ${!msg.seen ? 'selected' : ''}>Unread</option>
</select>
<select id="toggleAnswerState-for-${msg.uid}" data-input="toggleAnswerState" data-uid="${msg.uid}">
<option value="answered" ${msg.answered ? 'selected' : ''}>Answered</option>
<option value="unanswered" ${!msg.answered ? 'selected' : ''}>Unanswered</option>
</select>
</div>
</div>
</div>
`;
}
escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
joinAddresses(list = []) {
return list.map(addr => {
if (typeof addr === 'string') {
return addr;
}
const email = addr.email || '';
const name = addr.name || '';
return name ? `${name} <${email}>` : email;
}).join(', ');
}
renderAddressLinks(list = []) {
if (!list.length) {
return '-';
}
return list.map(addr => {
const value = typeof addr === 'string' ? { email: addr, name: null } : addr;
const email = this.escapeHtml(value.email || '');
const name = value.name ? this.escapeHtml(value.name) : null;
const label = name ? `${name} <${email}>` : email;
return `<a href="mailto:${email}" class="address-link">${label}</a>`;
}).join(', ');
}
renderStatusBadges(msg) {
return `
<div class="message-status-group">
<span class="message-status ${msg.seen ? 'read' : 'unread'}">
${msg.seen ? 'Read' : 'Unread'}
</span>
<span class="message-status ${msg.answered ? 'answered' : 'unanswered'}">
${msg.answered ? 'Answered' : 'Unanswered'}
</span>
</div>
`;
}
formatTotalSummary(totalBytes, messageCount) {
const durationLabel = typeof this.lastFetchDuration === 'number'
? `(${this.formatDuration(this.lastFetchDuration)} s) `
: '';
const formattedTotal = this.formatByteSize(totalBytes);
const suffix = messageCount === 1 ? 'message' : 'messages';
return `${durationLabel}Total: ${formattedTotal} (${messageCount} ${suffix})`;
}
formatByteSize(bytes) {
if (bytes >= 1048576) {
return (bytes / 1048576).toFixed(1) + ' MB';
}
if (bytes >= 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
}
return bytes + ' B';
}
formatDuration(seconds) {
const fixed = seconds.toFixed(4);
return fixed.length < 7 ? fixed.padStart(7, '0') : fixed;
}
// Toggle message body (lazy-load details on first expand)
async toggleBody(target) {
let message = target.closest('.message');
if (!message) {
return;
}
const uid = String(message.dataset.uid || '');
const wantsExpand = target.classList.contains('collapsed');
if (wantsExpand) {
const detailsLoaded = await this.ensureMessageDetails(uid);
if (!detailsLoaded) {
return;
}
message = document.querySelector(`.message[data-uid="${uid}"]`);
if (!message) {
return;
}
target = message.querySelector('.message-subject');
if (!target) {
return;
}
}
const messageBody = message.querySelector('.message-body');
if (messageBody) {
messageBody.classList.toggle('collapsed');
target.classList.toggle('collapsed');
}
}
toggleMessages(target) {
if (!this.messageList.length) {
this.showToast('No messages to toggle', true);
return;
}
const shouldExpand = target.dataset.state !== 'expanded';
const bodies = document.querySelectorAll('.message-body');
const subjects = document.querySelectorAll('.message-subject');
bodies.forEach(body => body.classList.toggle('collapsed', !shouldExpand));
subjects.forEach(subject => subject.classList.toggle('collapsed', !shouldExpand));
const nextState = shouldExpand ? 'expanded' : 'collapsed';
this.setMassToggleButtonState(nextState, target);
}
async openModal(target) {
const uid = target.dataset.uid;
if (!uid || !this.messageCache.has(uid)) {
this.showToast('Message not available', true);
return;
}
const detailsLoaded = await this.ensureMessageDetails(uid);
if (!detailsLoaded) {
return;
}
this.showMessageInModal(uid);
}
async ensureMessageDetails(uid) {
const cached = this.messageCache.get(uid);
if (!cached) {
this.showToast('Message not available', true);
return false;
}
if (cached.detailsLoaded) {
return true;
}
const form = document.getElementById('imapForm');
const payload = new FormData(form);
const loading = document.getElementById('loading');
const loadingText = document.querySelector('#loading span');
const previousText = loadingText ? loadingText.textContent : null;
loading.classList.add('active', 'loading--inline');
document.body.classList.add('imap-loading');
if (loadingText) {
loadingText.textContent = 'Loading message details...';
}
try {
const response = await fetch(`${window.location.pathname}?message=${encodeURIComponent(uid)}&details=1`, {
method: 'POST',
body: payload,
});
if (!response.ok) {
throw new Error('Failed to load message details');
}
const data = await response.json();
if (!data.success || !data.message) {
throw new Error(data.error || 'Failed to load message details');
}
data.message.detailsLoaded = true;
this.messageCache.set(uid, data.message);
const card = document.querySelector(`.message[data-uid="${uid}"]`);
if (card) {
card.outerHTML = this.renderMessage(data.message);
}
return true;
} catch (error) {
this.showToast(error.message || 'Failed to load message details', true);
return false;
} finally {
loading.classList.remove('active', 'loading--inline');
document.body.classList.remove('imap-loading');
if (loadingText && null !== previousText) {
loadingText.textContent = previousText;
}
}
}
showMessageInModal(uid, skipEntranceAnimation = false) {
if (!this.messageCache.has(uid)) {
this.showToast('Message not available', true);
return;
}
const modal = document.getElementById('messageModal');
const subjectEl = document.getElementById('modalSubject');
const metaEl = document.getElementById('modalMeta');
const bodyEl = document.getElementById('modalBody');
const msg = this.messageCache.get(uid);
// Update current message index
this.currentModalMessageIndex = this.messageList.indexOf(uid);
subjectEl.textContent = msg.subject || '(No subject)';
metaEl.innerHTML = `
<div><strong>Date:</strong> ${this.escapeHtml(msg.date || 'Unknown')}</div>
<div><strong>From:</strong> ${this.escapeHtml(this.joinAddresses(msg.from))}</div>
<div><strong>To:</strong> ${this.escapeHtml(this.joinAddresses(msg.to))}</div>
${msg.cc?.length ? `<div><strong>CC:</strong> ${this.escapeHtml(this.joinAddresses(msg.cc))}</div>` : ''}
${msg.replyTo?.length ? `<div><strong>Reply-To:</strong> ${this.escapeHtml(this.joinAddresses(msg.replyTo))}</div>` : ''}
<div><strong>UID:</strong> ${this.escapeHtml(String(msg.uid))}</div>
`;
const bodyText = msg.bodyFull || msg.bodyPreview || '';
bodyEl.textContent = bodyText || '(No body content available)';
// Update navigation button states
this.updateModalNavButtons();
if (skipEntranceAnimation) {
if (!modal.classList.contains('show')) {
modal.style.display = 'flex';
document.body.classList.add('modal-open');
modal.classList.add('show');
}
return;
}
modal.style.display = 'flex';
document.body.classList.add('modal-open');
modal.offsetHeight;
requestAnimationFrame(() => {
modal.classList.add('show');
});
}
async animateModalSwap(direction, uid) {
if (this.isModalTransitioning) {
return;
}
const modal = document.getElementById('messageModal');
const inner = modal?.querySelector('.modal-content-inner');
const detailsLoaded = await this.ensureMessageDetails(uid);
if (!detailsLoaded) {
return;
}
if (!inner) {
this.showMessageInModal(uid, true);
return;
}
this.isModalTransitioning = true;
const outClass = direction === 'next' ? 'slide-out-left' : 'slide-out-right';
const inClass = direction === 'next' ? 'slide-in-right' : 'slide-in-left';
await this.runModalAnimation(inner, outClass);
this.showMessageInModal(uid, true);
await this.runModalAnimation(inner, inClass);
this.isModalTransitioning = false;
}
runModalAnimation(element, className) {
return new Promise(resolve => {
if (!className) {
resolve();
return;
}
const cleanup = () => {
element.classList.remove(className);
element.removeEventListener('animationend', cleanup);
resolve();
};
element.addEventListener('animationend', cleanup, { once: true });
element.classList.add(className);
});
}
updateModalNavButtons() {
const prevBtn = document.getElementById('modalPrevBtn');
const nextBtn = document.getElementById('modalNextBtn');
if (prevBtn && nextBtn) {
prevBtn.disabled = this.currentModalMessageIndex <= 0;
nextBtn.disabled = this.currentModalMessageIndex >= this.messageList.length - 1;
}
}
async openPreviousMessage() {
if (this.currentModalMessageIndex > 0) {
const prevUid = this.messageList[this.currentModalMessageIndex - 1];
await this.animateModalSwap('prev', prevUid);
}
}
async openNextMessage() {
if (this.currentModalMessageIndex < this.messageList.length - 1) {
const nextUid = this.messageList[this.currentModalMessageIndex + 1];
await this.animateModalSwap('next', nextUid);
}
}
async toggleReadState(target) {
if (!target) return;
const uid = target.dataset.uid;
if (!uid) return;
const cached = this.messageCache.get(String(uid));
const previousState = cached && cached.seen ? 'read' : 'unread';
const action = target.value === 'read' ? 'mark-read' : 'mark-unread';
await this.applyMessageAction(target, uid, action, previousState);
}
async toggleAnswerState(target) {
if (!target) return;
const uid = target.dataset.uid;
if (!uid) return;
const cached = this.messageCache.get(String(uid));
const previousState = cached && cached.answered ? 'answered' : 'unanswered';
const action = target.value === 'answered' ? 'mark-answered' : 'mark-unanswered';
await this.applyMessageAction(target, uid, action, previousState);
}
async applyMessageAction(target, uid, action, revertValue) {
const form = document.getElementById('imapForm');
const payload = new FormData(form);
const loading = document.getElementById('loading');
const loadingText = document.querySelector('#loading span');
const previousText = loadingText ? loadingText.textContent : null;
target.disabled = true;
loading.classList.add('active', 'loading--inline');
document.body.classList.add('imap-loading');
if (loadingText) {
loadingText.textContent = 'Updating message...';
}
try {
const response = await fetch(`${window.location.pathname}?message=${encodeURIComponent(uid)}&action=${encodeURIComponent(action)}`, {
method: 'POST',
body: payload,
});
if (!response.ok) {
throw new Error('Unable to update message');
}
const data = await response.json();
if (!data.success || !data.message) {
throw new Error(data.error || 'Unable to update message');
}
const cacheKey = String(uid);
const current = this.messageCache.get(cacheKey) || {};
let merged = { ...current, ...data.message };
if (current.detailsLoaded && !data.message.detailsLoaded) {
merged = {
...merged,
bodyPreview: current.bodyPreview,
bodyFull: current.bodyFull,
bodyTruncated: current.bodyTruncated,
htmlBody: current.htmlBody,
attachments: current.attachments,
detailsLoaded: true,
};
} else {
merged.detailsLoaded = Boolean(merged.detailsLoaded);
}
this.messageCache.set(cacheKey, merged);
const card = document.querySelector(`.message[data-uid="${uid}"]`);
if (card) {
card.outerHTML = this.renderMessage(merged);
}
const labels = {
'mark-read': 'Read',
'mark-unread': 'Unread',
'mark-answered': 'Answered',
'mark-unanswered': 'Unanswered',
};
this.showToast(`Marked as ${labels[action] ?? 'updated'}`);
} catch (error) {
target.value = revertValue ?? target.value;
this.showToast(error.message || 'Update failed', true);
} finally {
target.disabled = false;
loading.classList.remove('active', 'loading--inline');
document.body.classList.remove('imap-loading');
if (loadingText && null !== previousText) {
loadingText.textContent = previousText;
}
}
}
closeModal() {
const modal = document.getElementById('messageModal');
modal.classList.add('closing');
setTimeout(() => {
modal.style.display = '';
modal.classList.remove('show', 'closing');
document.body.classList.remove('modal-open');
}, 400);
}
saveCredentials() {
const form = document.getElementById('imapForm');
const creds = {
mailbox: form.mailbox.value,
username: form.username.value,
password: form.password.value,
};
localStorage.setItem(this.storageKey, JSON.stringify(creds));
this.showToast('Credentials saved!');
}
loadCredentials() {
const saved = localStorage.getItem(this.storageKey);
if (saved) {
try {
const creds = JSON.parse(saved);
const form = document.getElementById('imapForm');
if (creds.mailbox) form.mailbox.value = creds.mailbox;
if (creds.username) form.username.value = creds.username;
if (creds.password) form.password.value = creds.password;
this.showToast('Credentials loaded!');
} catch (e) {
this.showToast('Failed to load credentials', true);
}
} else {
this.showToast('No saved credentials found', true);
}
}
clearCredentials() {
localStorage.removeItem(this.storageKey);
this.showToast('Saved credentials cleared');
}
clearFilters() {
const form = document.getElementById('imapForm');
form.limit.value = '10';
form.read_status.value = 'ALL';
form.answered_status.value = 'ALL';
form.date_from.value = '';
form.date_to.value = '';
form.search_field.value = 'ALL';
form.search_text.value = '';
form.exclude_from.value = '';
form.exclude_subject.value = '';
form.body_length.value = this.defaultBodyLength;
this.showToast('Filters cleared');
}
showToast(message, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.style.background = isError ? 'var(--error)' : 'var(--success)';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
}
togglePassword(target, event, container) {
const passwordWrapper = target.parentNode;
if (!passwordWrapper.classList.contains('yai-password-util')) return;
const input = passwordWrapper.querySelector('input[type]');
if (!input) return;
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
if (!target.hasAttribute('data-default-content')) {
target.setAttribute('data-default-content', target.textContent.trim());
}
target.textContent = isPassword ? target.dataset.toggleContent : target.dataset.defaultContent;
}
/**
* Format IMAP providers from JSON to HTML table
* Called when the IMAP providers panel is toggled
*
* @param {Element[]} targets - The toggled elements
* @param {string} newState - 'active' or 'inactive'
* @param {Element} trigger - The button that triggered the toggle
*/
formatImapProviders(targets, newState, trigger) {
if (newState !== 'active') return; // Only format when opening
const pre = document.getElementById('y-known-imap-providers');
if (!pre || pre.dataset.yFormatted) return; // Already formatted
try {
const data = JSON.parse(pre.textContent);
const providers = data.imapProviders || [];
const html = `
<div class="table-container">
<table>
<thead>
<tr> <th>Provider</th> <th>Mailbox String</th> </tr>
</thead>
<tbody>
${providers.map((provider, idx) => `
<tr style="${idx % 2 === 0 ? 'background: rgba(31, 70, 133, 0.19);' : ''}">
<td ${provider.notes
? `title="${this.escapeHtml(provider.notes)}"`
: ''}>${this.escapeHtml(provider.name)}</td>
<td><code>${this.escapeHtml(provider.mailbox)}</code></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
pre.outerHTML = `<div id="y-known-imap-providers" data-y-formatted="true">${html}</div>`;
} catch (e) {
console.error('Failed to parse IMAP providers JSON:', e);
}
}
/**
* Universal toggle handler with YEH auto-targeting
*
* @param {Element} target - The resolved trigger element (YEH auto-targeting)
* @param {Event} event - The original DOM event
* @param {Element} container - The matched container element (#app)
*/
toggleTarget(target, event, container) {
const targetSelector = target.dataset.target;
if (!targetSelector) return;
const config = {
targetAll: target.hasAttribute('data-target-all'),
toggleClass: target.dataset.toggleClass,
toggleContent: target.dataset.toggleContent,
toggleAttribute: target.dataset.toggleAttribute,
toggleAttributeValue: target.dataset.toggleAttributeValue,
toggleClassSelf: target.dataset.toggleClassSelf,
toggleContentSelf: target.dataset.toggleContentSelf,
toggleOnce: target.hasAttribute('data-toggle-once'),
toggleCallback: target.dataset.toggleCallback,
toggleCallbackOnce: target.hasAttribute('data-toggle-callback-once'),
};
const targets = config.targetAll
? Array.from(document.querySelectorAll(targetSelector))
: [document.querySelector(targetSelector)].filter(Boolean);
if (!targets.length) return;
// Initialize or read persistent state
let currentState = target.dataset.toggleState; // 'active' | 'inactive' | undefined
if (!currentState) {
// First click: determine initial state from button's CSS class
if (config.toggleClassSelf) {
currentState = target.classList.contains(config.toggleClassSelf)
? 'active'
: 'inactive';
} else {
currentState = 'inactive'; // Default starting state
}
}
// Determine action based on CURRENT STATE (not CSS class!)
const shouldAdd = currentState === 'inactive';
const newState = shouldAdd ? 'active' : 'inactive';
// Apply operations to target element(s)
targets.forEach(targetEl => {
if (config.toggleClass) {
if (config.targetAll) {
// Unified state based on persistent state
if (shouldAdd) {
targetEl.classList.add(config.toggleClass);
} else {
targetEl.classList.remove(config.toggleClass);
}
} else { // Individual toggle (no state sync needed)
targetEl.classList.toggle(config.toggleClass);
}
}
if (config.toggleContent) {
if (!targetEl.dataset.yOriginalContent) {
targetEl.dataset.yOriginalContent = targetEl.textContent;
}
if (config.targetAll) {
// Unified content based on state
targetEl.textContent = shouldAdd
? config.toggleContent
: targetEl.dataset.yOriginalContent;
} else {
// Individual toggle
const current = targetEl.textContent;
targetEl.textContent = current === config.toggleContent
? targetEl.dataset.yOriginalContent
: config.toggleContent;
}
}
if (config.toggleAttribute) {
if (config.targetAll) {
// Unified attribute state
if (shouldAdd) {
const value = config.toggleAttributeValue || '';
targetEl.setAttribute(config.toggleAttribute, value);
} else {
targetEl.removeAttribute(config.toggleAttribute);
}
} else {
// Individual toggle
if (targetEl.hasAttribute(config.toggleAttribute)) {
targetEl.removeAttribute(config.toggleAttribute);
} else {
const value = config.toggleAttributeValue || '';
targetEl.setAttribute(config.toggleAttribute, value);
}
}
}
});
// Update button state (CSS class for styling)
if (config.toggleClassSelf) {
if (newState === 'active') {
target.classList.add(config.toggleClassSelf);
} else {
target.classList.remove(config.toggleClassSelf);
}
}
// Update button content
if (config.toggleContentSelf) {
if (!target.dataset.yOriginalContentSelf) {
target.dataset.yOriginalContentSelf = target.textContent;
}
target.textContent = newState === 'active'
? config.toggleContentSelf
: target.dataset.yOriginalContentSelf;
}
// PERSIST THE NEW STATE (source of truth!)
target.dataset.toggleState = newState;
// Execute callback if specified (data-attribute approach)
if (config.toggleCallback && typeof this[config.toggleCallback] === 'function') {
const shouldRunCallback = !config.toggleCallbackOnce || !target.dataset.yCallbackExecuted;
if (shouldRunCallback) {
// Call the method with context: (targets, newState, triggerElement)
this[config.toggleCallback](targets, newState, target);
if (config.toggleCallbackOnce) {
target.dataset.yCallbackExecuted = 'true';
}
}
}
// Emit custom event for subscribers (pub/sub approach)
this.emit('yeh:toggle', {
targets,
newState,
previousState: currentState,
trigger: target,
selector: targetSelector,
});
if (config.toggleOnce) {
target.removeAttribute('data-action');
target.disabled = true;
}
}
setMassToggleButtonState(state = 'collapsed', control = null) {
const button = control ?? document.querySelector('[data-action="toggleMessages"]');
if (!button) return;
const isExpanded = state === 'expanded';
button.dataset.state = state;
button.classList.toggle('btn-active', isExpanded);
button.textContent = isExpanded ? 'Collapse All' : 'Expand All';
}
}
// Init
new ImapHandler();
// Demo
document.querySelector('h1 a').href = location.pathname;
</script>
</body>
</html>
|