release: 1.1.0 - YAML config, web server, web auth and UI improvements

This commit is contained in:
2026-10-05 20:48:34 +03:00
parent 47ee7c554a
commit 1cd3cb9a2e
11 changed files with 3412 additions and 448 deletions
+64
View File
@@ -0,0 +1,64 @@
.email-summary {
outline: none;
}
.email-summary:focus-visible {
outline: 2px solid #9ca3af;
outline-offset: -2px;
}
.email-content-wrapper {
position: relative;
width: 100%;
}
.email-content-frame {
display: block;
width: 100%;
min-height: 200px;
border: none;
background: white;
}
.toggle {
display: inline-block;
transition: transform 0.2s ease;
}
.toggle.rotated {
transform: rotate(180deg);
}
.download-icon {
display: inline-block;
padding: 0;
margin: 0;
border: none;
background: transparent;
cursor: pointer;
line-height: 1;
transition:
transform 0.1s ease,
opacity 0.1s ease;
}
.download-icon:hover {
opacity: 0.8;
}
.download-icon:active {
transform: scale(0.85);
opacity: 0.6;
}
.download-icon:focus-visible {
outline: 2px solid #9ca3af;
outline-offset: 2px;
}
+391
View File
@@ -0,0 +1,391 @@
'use strict';
document.addEventListener('DOMContentLoaded', function () {
initializeEmailList();
});
function initializeEmailList() {
var emailItems = document.querySelectorAll('.email-item');
emailItems.forEach(function (item) {
var summary = item.querySelector('.email-summary');
var downloadButton = item.querySelector('.download-icon');
if (summary) {
summary.addEventListener('click', function (event) {
if (event.target.closest('.download-icon')) {
return;
}
toggleEmail(item);
});
summary.addEventListener('keydown', function (event) {
if (
event.key !== 'Enter' &&
event.key !== ' '
) {
return;
}
if (event.target.closest('.download-icon')) {
return;
}
event.preventDefault();
toggleEmail(item);
});
}
if (downloadButton) {
downloadButton.addEventListener('click', function (event) {
event.stopPropagation();
var id = item.dataset.id;
if (!id) {
console.error('Email ID is missing');
return;
}
downloadEmail(event, id);
});
}
});
}
function toggleEmail(item) {
var id = item.dataset.id;
if (!id) {
console.error('Email ID is missing');
return;
}
var summary = item.querySelector('.email-summary');
var detail = item.querySelector('.email-detail');
var toggleIcon = item.querySelector('.toggle');
var iframe = item.querySelector('.email-content-frame');
if (!detail || !iframe) {
console.error(
'Email UI elements are missing for:',
id
);
return;
}
var isHidden = detail.classList.contains('hidden');
if (isHidden) {
detail.classList.remove('hidden');
if (toggleIcon) {
toggleIcon.classList.add('rotated');
}
if (summary) {
summary.setAttribute(
'aria-expanded',
'true'
);
}
loadEmailContent(
id,
iframe
);
} else {
detail.classList.add('hidden');
if (toggleIcon) {
toggleIcon.classList.remove('rotated');
}
if (summary) {
summary.setAttribute(
'aria-expanded',
'false'
);
}
}
}
function loadEmailContent(id, iframe) {
if (iframe.dataset.loaded === 'true') {
resizeIframe(iframe);
return;
}
iframe.dataset.loading = 'true';
fetch(
'/api/view?id=' +
encodeURIComponent(id),
{
method: 'GET',
credentials: 'same-origin'
}
)
.then(function (response) {
if (!response.ok) {
throw new Error(
'HTTP error ' +
response.status
);
}
return response.text();
})
.then(function (data) {
writeEmailToIframe(
iframe,
data
);
iframe.dataset.loaded = 'true';
delete iframe.dataset.loading;
resizeIframe(iframe);
})
.catch(function (error) {
delete iframe.dataset.loading;
writeErrorToIframe(
iframe,
error.message
);
console.error(
'Failed to load email:',
id,
error
);
});
}
function writeEmailToIframe(iframe, emailBody) {
var doc = iframe.contentWindow.document;
doc.open();
doc.write(
'<!DOCTYPE html>' +
'<html>' +
'<head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width, initial-scale=1.0">' +
'<base target="_blank">' +
'<style>' +
'html {' +
'margin: 0 !important;' +
'padding: 0 !important;' +
'background: white !important;' +
'}' +
'body {' +
'margin: 0 !important;' +
'padding: 15px !important;' +
'font-family: monospace !important;' +
'font-size: 12px !important;' +
'line-height: 1.4 !important;' +
'background: white !important;' +
'color: #333 !important;' +
'overflow-wrap: anywhere !important;' +
'}' +
'* {' +
'box-sizing: border-box !important;' +
'max-width: 100% !important;' +
'}' +
'img {' +
'max-width: 100% !important;' +
'height: auto !important;' +
'}' +
'table {' +
'max-width: 100% !important;' +
'}' +
'pre {' +
'margin: 0 !important;' +
'font-family: inherit !important;' +
'white-space: pre-wrap !important;' +
'overflow-wrap: anywhere !important;' +
'}' +
'a {' +
'color: #2563eb !important;' +
'text-decoration: underline !important;' +
'}' +
'a:visited {' +
'color: #7c3aed !important;' +
'}' +
'a:hover {' +
'color: #1d4ed8 !important;' +
'}' +
'</style>' +
'</head>' +
'<body>' +
emailBody +
'</body>' +
'</html>'
);
doc.close();
}
function writeErrorToIframe(iframe, message) {
var doc = iframe.contentWindow.document;
doc.open();
doc.write(
'<!DOCTYPE html>' +
'<html>' +
'<head>' +
'<meta charset="UTF-8">' +
'</head>' +
'<body style="' +
'padding:15px;' +
'font-family:monospace;' +
'font-size:12px;' +
'color:#b91c1c;' +
'">' +
'Error loading email: ' +
escapeHTML(message) +
'</body>' +
'</html>'
);
doc.close();
resizeIframe(iframe);
}
function resizeIframe(iframe) {
if (!iframe || !iframe.contentWindow) {
return;
}
try {
var doc = iframe.contentWindow.document;
if (!doc || !doc.body) {
return;
}
var height = Math.max(
doc.body.scrollHeight,
doc.documentElement
? doc.documentElement.scrollHeight
: 0
);
iframe.style.height =
Math.max(height + 20, 200) +
'px';
if (
!iframe.dataset.resizeObserver &&
typeof ResizeObserver !== 'undefined'
) {
var observer = new ResizeObserver(
function () {
try {
var body =
iframe.contentWindow.document.body;
var html =
iframe.contentWindow.document.documentElement;
var newHeight = Math.max(
body
? body.scrollHeight
: 0,
html
? html.scrollHeight
: 0
);
iframe.style.height =
Math.max(
newHeight + 20,
200
) +
'px';
} catch (error) {
}
}
);
observer.observe(doc.body);
iframe.dataset.resizeObserver =
'true';
}
} catch (error) {
console.warn(
'Cannot resize email iframe:',
error
);
}
}
function downloadEmail(event, id) {
var icon = event.currentTarget;
if (!id) {
return;
}
var originalTransform =
icon.style.transform;
var originalOpacity =
icon.style.opacity;
icon.style.transform =
'scale(0.8)';
icon.style.opacity =
'0.5';
window.location.href =
'/api/download?id=' +
encodeURIComponent(id);
window.setTimeout(
function () {
icon.style.transform =
originalTransform;
icon.style.opacity =
originalOpacity;
},
150
);
}
function escapeHTML(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>{{.Title}}</title>
<script src="/static/tailwind.js"></script>
<link
rel="stylesheet"
href="/static/app.css"
>
</head>
<body class="bg-gray-100">
<div class="max-w-6xl mx-auto bg-white min-h-screen shadow-sm">
<!-- Header -->
<header class="bg-gray-800 text-white px-6 py-4 border-b-4 border-red-600">
<h1 class="text-2xl font-normal">
Fake SMTP Server
</h1>
<p class="text-gray-400 text-sm mt-1">
Email storage
</p>
</header>
<!-- Content -->
<main class="p-4">
{{range .Days}}
<section class="mb-6 border border-gray-200">
<!-- Day header -->
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-base font-semibold text-gray-700">
{{.Date}}
</h2>
<span class="bg-gray-400 text-white px-2 py-0.5 text-xs">
{{len .Emails}} emails
</span>
</div>
<!-- Emails -->
<div class="divide-y divide-gray-100">
{{range .Emails}}
<article
class="email-item"
data-id="{{.ID}}"
>
<!-- Email summary -->
<div
class="email-summary px-4 py-2 hover:bg-gray-50 cursor-pointer flex justify-between items-center"
role="button"
tabindex="0"
aria-expanded="false"
>
<div class="flex-1 flex flex-wrap gap-4 items-baseline">
<div class="font-mono text-sm font-semibold text-gray-700 min-w-[180px]">
{{.From}}
</div>
<div class="font-mono text-xs text-gray-400 min-w-[180px]">
→ {{.To}}
</div>
<div class="text-sm text-gray-600 flex-1">
{{.Subject}}
</div>
</div>
<div class="flex items-center gap-3 text-gray-400 text-xs">
<span class="font-mono">
{{.ModTime.Format "15:04:05"}}
</span>
<span class="font-mono">
{{.Size}} bytes
</span>
<button
type="button"
class="download-icon text-base cursor-pointer hover:text-gray-600"
title="Download EML"
aria-label="Download email"
>
💾
</button>
<span
class="toggle text-xs"
aria-hidden="true"
>
▼
</span>
</div>
</div>
<!-- Email detail -->
<div class="email-detail hidden bg-gray-50 border-t border-gray-200 p-4">
<div class="email-content-wrapper">
<iframe
class="email-content-frame"
title="Email content"
sandbox="allow-same-origin allow-popups"
srcdoc="<html><body style='margin:0;padding:15px;font-family:monospace;font-size:12px;'>Loading...</body></html>"
></iframe>
</div>
</div>
</article>
{{end}}
</div>
</section>
{{else}}
<div class="text-center py-16 text-gray-400">
<div class="text-4xl mb-3">
📭
</div>
<h3 class="text-lg text-gray-500 mb-2">
No emails
</h3>
<p class="text-sm">
No emails have been saved yet
</p>
</div>
{{end}}
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+83
View File
File diff suppressed because one or more lines are too long