mail-s3-admin/app/emails/page.tsx

442 lines
18 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { AppHeader } from '@/components/layout/AppHeader';
import { Sidebar } from '@/components/layout/Sidebar';
import { Breadcrumb } from '@/components/layout/Breadcrumb';
import { TableSkeleton } from '@/components/ui/Skeleton';
interface Email {
key: string;
subject: string;
from: string;
to: string[];
preview?: string;
date: string;
processed: string;
processedAt: string | null;
processedBy: string | null;
queuedTo: string | null;
status: string | null;
}
export default function Emails() {
const searchParams = useSearchParams();
const bucket = searchParams.get('bucket');
const mailbox = searchParams.get('mailbox');
const [emails, setEmails] = useState<Email[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [dateFilter, setDateFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'subject'>('newest');
useEffect(() => {
if (!bucket || !mailbox) {
setError('Missing parameters');
setLoading(false);
return;
}
const auth = localStorage.getItem('auth');
if (!auth) {
setError('Not authenticated');
setLoading(false);
return;
}
fetch(`/api/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox)}`, {
headers: { Authorization: `Basic ${auth}` }
})
.then(res => {
if (!res.ok) throw new Error('Failed to fetch emails');
return res.json();
})
.then(data => {
setEmails(data);
})
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [bucket, mailbox]);
// Filter and search emails
const filteredEmails = useMemo(() => {
let result = [...emails];
// Apply status filter
if (statusFilter !== 'all') {
result = result.filter(e => e.status === statusFilter);
}
// Apply date filter
if (dateFilter !== 'all') {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
result = result.filter(e => {
const emailDate = new Date(e.date);
const emailDay = new Date(emailDate.getFullYear(), emailDate.getMonth(), emailDate.getDate());
const daysDiff = Math.floor((today.getTime() - emailDay.getTime()) / (1000 * 60 * 60 * 24));
switch (dateFilter) {
case 'today':
return daysDiff === 0;
case 'yesterday':
return daysDiff === 1;
case 'last2days':
return daysDiff <= 1;
case 'last3days':
return daysDiff <= 2;
case '2daysago':
return daysDiff === 2;
case '3daysago':
return daysDiff === 3;
case '4daysago':
return daysDiff === 4;
case '5daysago':
return daysDiff === 5;
case 'lastweek':
return daysDiff <= 7;
case 'lastmonth':
return daysDiff <= 30;
default:
return true;
}
});
}
// Apply search
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(e =>
e.subject.toLowerCase().includes(query) ||
(e.from && e.from.toLowerCase().includes(query)) ||
(e.to && Array.isArray(e.to) && e.to.some(addr => addr.toLowerCase().includes(query)))
);
}
// Apply sorting
if (sortBy === 'newest') {
result.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
} else if (sortBy === 'oldest') {
result.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
} else if (sortBy === 'subject') {
result.sort((a, b) => a.subject.localeCompare(b.subject));
}
return result;
}, [emails, statusFilter, dateFilter, searchQuery, sortBy]);
// Calculate stats
const stats = useMemo(() => {
const total = emails.length;
const delivered = emails.filter(e => e.status === 'delivered').length;
const failed = emails.filter(e => e.status === 'failed').length;
const pending = emails.filter(e => !e.status || e.status === 'pending').length;
return { total, delivered, failed, pending };
}, [emails]);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} else if (days < 7) {
return date.toLocaleDateString('de-DE', { weekday: 'short' });
} else {
return date.toLocaleDateString('de-DE', { month: 'short', day: 'numeric' });
}
};
if (error) {
return (
<>
<AppHeader />
<Sidebar />
<div className="ml-64 mt-16 min-h-screen bg-gray-50 p-8">
<main id="main-content">
<div className="max-w-4xl mx-auto bg-white rounded-lg shadow-md p-8 fade-in" role="alert">
<p className="text-red-600 font-medium text-center">{error}</p>
</div>
</main>
</div>
</>
);
}
return (
<>
<AppHeader />
<Sidebar />
<div className="ml-64 mt-16 min-h-screen bg-gray-50">
<Breadcrumb items={[
{ label: 'Domains', href: '/domains' },
{ label: 'Mailboxes', href: `/mailboxes?bucket=${bucket}` },
{ label: 'Emails' }
]} />
<main id="main-content">
{/* Page Header with Stats */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<h1 className="text-2xl font-bold text-gray-800 mb-4 truncate">
{mailbox}
</h1>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
<div className="text-sm text-gray-600">Total</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{stats.delivered}</div>
<div className="text-sm text-gray-600">Delivered</div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{stats.failed}</div>
<div className="text-sm text-gray-600">Failed</div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{stats.pending}</div>
<div className="text-sm text-gray-600">Pending</div>
</div>
</div>
</div>
{/* Filter & Toolbar */}
<div className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
{/* Search */}
<div className="flex-1 max-w-md">
<div className="relative">
<input
type="search"
placeholder="E-Mails durchsuchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-100 rounded-lg focus:bg-white focus:ring-2 focus:ring-blue-500 focus:outline-none transition-all text-sm"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
{/* Date Filter */}
<div className="flex gap-2 ml-4">
<select
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className="text-sm border border-gray-300 rounded-full px-4 py-1.5 bg-white"
>
<option value="all">Alle Zeiträume</option>
<option value="today">Heute</option>
<option value="yesterday">Gestern</option>
<option value="last2days">Letzte 2 Tage</option>
<option value="last3days">Letzte 3 Tage</option>
<option value="2daysago">Vor 2 Tagen</option>
<option value="3daysago">Vor 3 Tagen</option>
<option value="4daysago">Vor 4 Tagen</option>
<option value="5daysago">Vor 5 Tagen</option>
<option value="lastweek">Letzte Woche</option>
<option value="lastmonth">Letzter Monat</option>
</select>
</div>
{/* Status Filter Chips */}
<div className="flex gap-2 ml-4">
<button
onClick={() => setStatusFilter('all')}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
statusFilter === 'all'
? 'bg-blue-100 text-blue-800'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
Alle
</button>
<button
onClick={() => setStatusFilter('delivered')}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-1 ${
statusFilter === 'delivered'
? 'bg-green-100 text-green-800'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Delivered
</button>
<button
onClick={() => setStatusFilter('failed')}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-1 ${
statusFilter === 'failed'
? 'bg-red-100 text-red-800'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
Failed
</button>
<button
onClick={() => setStatusFilter('pending')}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-1 ${
statusFilter === 'pending'
? 'bg-yellow-100 text-yellow-800'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="w-2 h-2 bg-yellow-500 rounded-full"></span>
Pending
</button>
</div>
{/* Sort & View Options */}
<div className="flex items-center gap-3 ml-4">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="text-sm border border-gray-300 rounded px-3 py-1.5 bg-white"
>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
<option value="subject">Nach Betreff</option>
</select>
</div>
</div>
{/* Email List (Gmail-Style) */}
<div className="bg-white">
{loading ? (
<div className="p-6">
<TableSkeleton rows={10} />
</div>
) : filteredEmails.length === 0 ? (
<div className="p-12 text-center text-gray-500">
<div className="flex flex-col items-center gap-3">
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<p className="text-lg">Keine E-Mails gefunden</p>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="text-blue-600 hover:text-blue-800 text-sm"
>
Suche zurücksetzen
</button>
)}
</div>
</div>
) : (
<>
{filteredEmails.map((email) => (
<div
key={email.key}
className="group flex items-start gap-3 px-6 py-3 border-b border-gray-100 hover:shadow-md hover:bg-gray-50 transition-all"
>
{/* From - breiter und mit Tooltip */}
<div className="w-48 flex-shrink-0 pt-1" title={email.from || 'Unknown'}>
<span className="text-sm text-gray-900 group-hover:text-blue-600 transition-colors block break-words">
{email.from || 'Unknown'}
</span>
</div>
{/* Subject & Preview - klickbar */}
<Link
href={`/email?bucket=${bucket}&key=${email.key}&mailbox=${encodeURIComponent(mailbox || '')}`}
className="flex-1 min-w-0 cursor-pointer"
>
<div className="font-medium text-gray-900 truncate mb-1 hover:text-blue-600">
{email.subject || '(No Subject)'}
</div>
{email.preview && (
<div className="text-sm text-gray-600 truncate">
{email.preview}
</div>
)}
</Link>
{/* S3 Key Column - kopierbar */}
<div className="w-64 flex-shrink-0 pt-1">
<div className="flex items-center gap-2 bg-gray-50 px-3 py-1.5 rounded border border-gray-200">
<code className="text-xs font-semibold text-gray-700 flex-1 truncate" title={email.key}>
{email.key}
</code>
<button
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(email.key);
// Optional: Feedback anzeigen
const btn = e.currentTarget;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>';
setTimeout(() => { btn.innerHTML = originalHTML; }, 1000);
}}
className="p-1 hover:bg-gray-200 rounded transition-colors flex-shrink-0"
title="S3 Key kopieren"
>
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
{/* Status Badge */}
<div className="w-24 flex-shrink-0 pt-1">
{email.status && (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
email.status === 'delivered' ? 'bg-green-100 text-green-800' :
email.status === 'failed' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-700'
}`}>
{email.status === 'delivered' && (
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
{email.status === 'failed' && (
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
{email.status}
</span>
)}
</div>
{/* Date & Time */}
<div className="w-28 text-right text-xs text-gray-600 flex-shrink-0 pt-1">
{new Date(email.date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
<br />
{new Date(email.date).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
))}
</>
)}
</div>
{!loading && (
<div role="status" aria-live="polite" className="sr-only">
{filteredEmails.length} emails loaded
</div>
)}
</main>
</div>
</>
);
}