<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EML Reader & Viewer</title>
<!-- Tailwind CSS (Play CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Alpine.js (reaktywność) - ładowany z defer -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-50 h-screen flex flex-col font-sans overflow-hidden" x-data="emlApp()">
<!-- TOPBAR: Adres URL i przycisk pobierania -->
<header class="bg-white border-b border-gray-200 px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-4 shrink-0">
<div class="flex items-center gap-3">
<span class="text-2xl">✉️</span>
<h1 class="text-xl font-bold text-gray-800">EML Viewer</h1>
</div>
<div class="flex w-full sm:w-auto max-w-xl gap-2 grow justify-end">
<input
type="url"
x-model="targetUrl"
placeholder="Wpisz URL do listy plików EML (np. http://localhost:8000/)"
class="border border-gray-300 rounded px-3 py-1.5 text-sm w-full focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
@click="fetchEmlList()"
:disabled="isLoadingList"
class="bg-blue-600 hover:bg-blue-700 text-white font-medium text-sm px-4 py-1.5 rounded transition disabled:opacity-50 whitespace-nowrap"
>
<span x-show="!isLoadingList">Pobierz listę</span>
<span x-show="isLoadingList">Ładowanie...</span>
</button>
</div>
</header>
<!-- GŁÓWNY PANEL: Układ dwukolumnowy -->
<main class="flex flex-1 overflow-hidden">
<!-- LEWA KOLUMNA: Lista plików -->
<section class="w-80 border-r border-gray-200 bg-white flex flex-col shrink-0">
<div class="p-4 border-b border-gray-100 bg-gray-50">
<span class="text-xs font-bold uppercase tracking-wider text-gray-500">Wykryte pliki EML</span>
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-0.5 rounded-full ml-2 font-semibold" x-text="emlFiles.length">0</span>
</div>
<div class="flex-1 overflow-y-auto divide-y divide-gray-100">
<!-- Loader listy -->
<template x-if="isLoadingList">
<div class="p-6 text-center text-sm text-gray-500">Skanowanie strony w poszukiwaniu plików...</div>
</template>
<!-- Brak wyników -->
<template x-if="!isLoadingList && emlFiles.length === 0">
<div class="p-6 text-center text-sm text-gray-400">Brak plików EML. Wpisz URL i kliknij pobieranie.</div>
</template>
<!-- Elementy listy -->
<template x-for="fileUrl in emlFiles" :key="fileUrl">
<button
@click="selectEml(fileUrl)"
class="w-full text-left p-4 hover:bg-blue-50/50 transition flex flex-col gap-1 focus:outline-none focus:bg-blue-50"
:class="selectedEmlUrl === fileUrl ? 'bg-blue-50 border-l-4 border-blue-600 pl-3' : ''"
>
<span class="text-sm font-medium text-gray-700 truncate w-full" x-text="getFileName(fileUrl)"></span>
<span class="text-xs text-gray-400 truncate w-full" x-text="fileUrl"></span>
</button>
</template>
</div>
</section>
<!-- PRAWA KOLUMNA: Podgląd maila -->
<section class="flex-1 bg-white flex flex-col overflow-hidden">
<!-- Loader szczegółów maila -->
<template x-if="isLoadingMail">
<div class="flex-1 flex flex-col items-center justify-center text-gray-500">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 mb-4"></div>
<p>Pobieranie i dekodowanie wiadomości...</p>
</div>
</template>
<!-- Stan początkowy (gdy nic nie wybrano) -->
<template x-if="!isLoadingMail && !selectedEmlContent">
<div class="flex-1 flex flex-col items-center justify-center text-gray-400 p-8 text-center">
<span class="text-5xl mb-4">📂</span>
<p class="text-lg font-medium">Nie wybrano wiadomości</p>
<p class="text-sm max-w-xs mt-1">Wybierz plik EML z listy po lewej stronie, aby wyświetlić jego zawartość.</p>
</div>
</template>
<!-- Widok wiadomości -->
<template x-if="!isLoadingMail && selectedEmlContent">
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Nagłówki wiadomości -->
<div class="p-6 border-b border-gray-200 bg-gray-50/50 shrink-0">
<h2 class="text-xl font-bold text-gray-900 mb-3" x-text="selectedEmlContent.subject || '(Bez tematu)'"></h2>
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
<span class="text-gray-400 font-medium">Od:</span>
<span class="text-gray-800" x-text="formatAddress(selectedEmlContent.from)"></span>
<span class="text-gray-400 font-medium">Do:</span>
<span class="text-gray-800" x-text="formatAddress(selectedEmlContent.to)"></span>
<span class="text-gray-400 font-medium">Data:</span>
<span class="text-gray-800" x-text="selectedEmlContent.date ? new Date(selectedEmlContent.date).toLocaleString() : 'Brak daty'"></span>
</div>
<!-- Załączniki (jeśli istnieją) -->
<template x-if="selectedEmlContent.attachments && selectedEmlContent.attachments.length > 0">
<div class="mt-4 pt-4 border-t border-gray-200">
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider block mb-2">Załączniki</span>
<div class="flex flex-wrap gap-2">
<template x-for="att in selectedEmlContent.attachments" :key="att.filename">
<button
@click="downloadAttachment(att)"
class="bg-white border border-gray-200 hover:border-blue-400 rounded px-3 py-1.5 text-xs font-medium text-gray-700 flex items-center gap-1.5 shadow-sm transition"
>
📎 <span x-text="att.filename || 'Załącznik bez nazwy'"></span>
</button>
</template>
</div>
</div>
</template>
</div>
<!-- Treść wiadomości renderowana w izolowanym iframe -->
<div class="flex-1 bg-white p-4 overflow-hidden flex">
<iframe
class="w-full h-full border-0"
:srcdoc="getEmailBody(selectedEmlContent)"
sandbox="allow-popups allow-popups-to-escape-sandbox"
></iframe>
</div>
</div>
</template>
</section>
</main>
<!-- Powiadomienie o błędzie -->
<template x-if="errorMessage">
<div class="fixed bottom-4 right-4 bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded shadow-lg max-w-md z-50 flex items-start gap-2">
<div class="flex-1">
<p class="font-bold text-sm">Wystąpił błąd</p>
<p class="text-xs mt-1" x-text="errorMessage"></p>
</div>
<button @click="errorMessage = null" class="text-red-500 hover:text-red-700 font-bold text-lg leading-none">×</button>
</div>
</template>
<!-- Standardowy skrypt gwarantujący poprawne wpięcie w cykl życia Alpine.js -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('emlApp', () => ({
targetUrl: 'http://localhost:8000/',
emlFiles: [],
selectedEmlUrl: null,
selectedEmlContent: null,
isLoadingList: false,
isLoadingMail: false,
errorMessage: null,
async fetchEmlList() {
this.isLoadingList = true;
this.errorMessage = null;
this.emlFiles = [];
try {
const response = await fetch(this.targetUrl);
if (!response.ok) {
throw new Error(`Serwer odpowiedział kodem ${response.status}: ${response.statusText}`);
}
const htmlText = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
const links = Array.from(doc.querySelectorAll('a'));
this.emlFiles = links
.map(a => a.getAttribute('href'))
.filter(href => href && href.toLowerCase().endsWith('.eml'))
.map(href => {
return new URL(href, this.targetUrl).href;
});
if (this.emlFiles.length === 0) {
this.errorMessage = "Nie znaleziono żadnych plików .eml na tej stronie. Upewnij się, że serwer zwraca poprawną listę plików w tagach <a href='...'>";
}
} catch (err) {
this.errorMessage = `Błąd podczas pobierania listy: ${err.message}. Zweryfikuj CORS oraz czy URL jest poprawny.`;
console.error(err);
} finally {
this.isLoadingList = false;
}
},
async selectEml(url) {
this.selectedEmlUrl = url;
this.isLoadingMail = true;
this.selectedEmlContent = null;
this.errorMessage = null;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Błąd pobierania pliku: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
// Dynamiczny, asynchroniczny import modułu PostalMime w locie
const { default: PostalMime } = await import('https://cdn.jsdelivr.net/npm/postal-mime@2.2.0/+esm');
const parser = new PostalMime();
const parsedEmail = await parser.parse(arrayBuffer);
this.selectedEmlContent = parsedEmail;
} catch (err) {
this.errorMessage = `Nie udało się załadować wiadomości: ${err.message}`;
console.error(err);
} finally {
this.isLoadingMail = false;
}
},
getFileName(url) {
try {
const decoded = decodeURIComponent(url);
return decoded.substring(decoded.lastIndexOf('/') + 1);
} catch {
return url;
}
},
formatAddress(addressObj) {
if (!addressObj) return '(Brak danych)';
const list = Array.isArray(addressObj) ? addressObj : [addressObj];
return list.map(addr => {
if (addr.name) {
return `${addr.name} <${addr.address}>`;
}
return addr.address;
}).join(', ');
},
getEmailBody(email) {
if (!email) return '';
if (email.html) {
return `<meta http-equiv="Content-Security-Policy" content="script-src 'none';">` + email.html;
}
const escapedText = email.text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
return `<pre style="font-family: sans-serif; white-space: pre-wrap; word-break: break-all;">${escapedText}</pre>`;
},
downloadAttachment(attachment) {
try {
const blob = new Blob([attachment.content], { type: attachment.mimeType });
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = attachment.filename || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch (err) {
this.errorMessage = `Nie udało się pobrać załącznika: ${err.message}`;
}
}
}));
});
</script>
</body>
</html>