UI overhault
This commit is contained in:
parent
1964098136
commit
a209468616
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/app/db/drizzle';
|
||||||
|
import { emails } from '@/app/db/schema';
|
||||||
|
import { authenticate, getS3Client } from '@/app/lib/utils';
|
||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
|
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { keys, bucket } = await req.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(keys) || keys.length === 0 || !bucket) {
|
||||||
|
return NextResponse.json({ error: 'Invalid parameters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s3 = getS3Client();
|
||||||
|
|
||||||
|
// Delete from S3
|
||||||
|
const deletePromises = keys.map(key =>
|
||||||
|
s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }))
|
||||||
|
.catch(err => {
|
||||||
|
console.error(`Failed to delete ${key} from S3:`, err);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
await db.delete(emails).where(inArray(emails.s3Key, keys));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, count: keys.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting emails:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete emails' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/app/db/drizzle';
|
||||||
|
import { emails } from '@/app/db/schema';
|
||||||
|
import { authenticate } from '@/app/lib/utils';
|
||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { keys } = await req.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(keys) || keys.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Invalid keys' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(emails)
|
||||||
|
.set({ processed: true, processedAt: new Date() })
|
||||||
|
.where(inArray(emails.s3Key, keys));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, count: keys.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking emails as read:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to mark as read' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,11 @@ export async function GET(req: NextRequest) {
|
||||||
const emailList = await db.select({
|
const emailList = await db.select({
|
||||||
key: emails.s3Key,
|
key: emails.s3Key,
|
||||||
subject: emails.subject,
|
subject: emails.subject,
|
||||||
|
from: emails.from,
|
||||||
|
to: emails.to,
|
||||||
date: emails.date,
|
date: emails.date,
|
||||||
|
html: emails.html,
|
||||||
|
raw: emails.raw,
|
||||||
processed: emails.processed,
|
processed: emails.processed,
|
||||||
processedAt: emails.processedAt,
|
processedAt: emails.processedAt,
|
||||||
processedBy: emails.processedBy,
|
processedBy: emails.processedBy,
|
||||||
|
|
@ -26,14 +30,85 @@ export async function GET(req: NextRequest) {
|
||||||
status: emails.status,
|
status: emails.status,
|
||||||
}).from(emails).where(sql`${mailbox} = ANY(${emails.to}) AND ${emails.domainId} = ${domain.id}`);
|
}).from(emails).where(sql`${mailbox} = ANY(${emails.to}) AND ${emails.domainId} = ${domain.id}`);
|
||||||
|
|
||||||
return NextResponse.json(emailList.map(e => ({
|
return NextResponse.json(emailList.map(e => {
|
||||||
|
let preview = '';
|
||||||
|
|
||||||
|
// Check both HTML and raw content for images
|
||||||
|
const htmlContent = e.html || '';
|
||||||
|
const rawContent = e.raw || '';
|
||||||
|
|
||||||
|
// Check for images in HTML
|
||||||
|
const hasImgTags = /<img[^>]+>/i.test(htmlContent);
|
||||||
|
const imageCount = (htmlContent.match(/<img[^>]+>/gi) || []).length;
|
||||||
|
|
||||||
|
// Check for base64 encoded images
|
||||||
|
const hasBase64Images = /data:image\//i.test(htmlContent);
|
||||||
|
|
||||||
|
// Check for image attachments in raw content
|
||||||
|
const hasImageAttachments = /Content-Type:\s*image\//i.test(rawContent);
|
||||||
|
const attachmentCount = (rawContent.match(/Content-Type:\s*image\//gi) || []).length;
|
||||||
|
|
||||||
|
// Check for multipart/related (usually contains embedded images)
|
||||||
|
const isMultipartRelated = /Content-Type:\s*multipart\/related/i.test(rawContent);
|
||||||
|
|
||||||
|
// Extract text content
|
||||||
|
let textContent = '';
|
||||||
|
if (htmlContent) {
|
||||||
|
textContent = htmlContent
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove style blocks
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove script blocks
|
||||||
|
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||||
|
.replace(/@font-face\s*\{[^}]*\}/gi, '') // Remove @font-face definitions
|
||||||
|
.replace(/@media[^{]*\{[\s\S]*?\}/gi, '') // Remove @media queries
|
||||||
|
.replace(/@import[^;]*;/gi, '') // Remove @import statements
|
||||||
|
.replace(/font-family:\s*[^;]+;?/gi, '') // Remove font-family CSS
|
||||||
|
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine preview
|
||||||
|
const totalImages = imageCount + attachmentCount + (hasBase64Images ? 1 : 0);
|
||||||
|
const hasImages = hasImgTags || hasBase64Images || hasImageAttachments || isMultipartRelated;
|
||||||
|
|
||||||
|
if (hasImages && textContent.length < 50) {
|
||||||
|
// Mostly images, little text
|
||||||
|
if (totalImages > 1) {
|
||||||
|
preview = `📷 ${totalImages} Bilder`;
|
||||||
|
} else if (totalImages === 1) {
|
||||||
|
preview = '📷 Bild';
|
||||||
|
} else {
|
||||||
|
preview = '📷 Bild';
|
||||||
|
}
|
||||||
|
} else if (textContent.length > 0) {
|
||||||
|
// Has text content
|
||||||
|
preview = textContent.substring(0, 150);
|
||||||
|
if (textContent.length > 150) preview += '...';
|
||||||
|
} else if (hasImages) {
|
||||||
|
// Has images but couldn't count them properly
|
||||||
|
preview = '📷 Bild(er)';
|
||||||
|
} else if (!htmlContent && rawContent) {
|
||||||
|
// No HTML, check if it's plain text
|
||||||
|
const plainText = rawContent
|
||||||
|
.split('\n\n')
|
||||||
|
.find(line => !line.startsWith('Content-') && !line.startsWith('MIME-') && line.length > 10);
|
||||||
|
if (plainText) {
|
||||||
|
preview = plainText.substring(0, 150);
|
||||||
|
if (plainText.length > 150) preview += '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
key: e.key,
|
key: e.key,
|
||||||
subject: e.subject,
|
subject: e.subject,
|
||||||
|
from: e.from,
|
||||||
|
to: e.to,
|
||||||
|
preview,
|
||||||
date: e.date?.toISOString(),
|
date: e.date?.toISOString(),
|
||||||
processed: e.processed ? 'true' : 'false',
|
processed: e.processed ? 'true' : 'false',
|
||||||
processedAt: e.processedAt?.toISOString() || null,
|
processedAt: e.processedAt?.toISOString() || null,
|
||||||
processedBy: e.processedBy,
|
processedBy: e.processedBy,
|
||||||
queuedTo: e.queuedTo,
|
queuedTo: e.queuedTo,
|
||||||
status: e.status,
|
status: e.status,
|
||||||
})));
|
};
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { authenticate } from '@/app/lib/utils';
|
||||||
|
import { syncAllDomains } from '@/app/lib/sync';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start sync in background (don't wait for completion)
|
||||||
|
syncAllDomains().catch(err => {
|
||||||
|
console.error('Sync failed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Sync started in background'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error starting sync:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to start sync' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { AppHeader } from '@/components/layout/AppHeader';
|
||||||
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
|
import { Breadcrumb } from '@/components/layout/Breadcrumb';
|
||||||
|
import { CardSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
export default function Domains() {
|
export default function Domains() {
|
||||||
const [domains, setDomains] = useState([]);
|
const [domains, setDomains] = useState([]);
|
||||||
|
|
@ -28,32 +32,81 @@ export default function Domains() {
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-100">Loading...</div>;
|
if (loading) {
|
||||||
if (error) return <div className="min-h-screen flex items-center justify-center bg-gray-100 text-red-500">{error}</div>;
|
return (
|
||||||
|
<>
|
||||||
|
<AppHeader />
|
||||||
|
<Sidebar />
|
||||||
|
<div className="ml-64 mt-16 min-h-screen bg-gray-50">
|
||||||
|
<Breadcrumb items={[{ label: 'Domains' }]} />
|
||||||
|
<main id="main-content" className="p-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-gray-800 fade-in">All Domains</h1>
|
||||||
|
<CardSkeleton count={5} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppHeader />
|
||||||
|
<Sidebar />
|
||||||
|
<div className="ml-64 mt-16 min-h-screen bg-gray-50">
|
||||||
|
<Breadcrumb items={[{ label: 'Domains' }]} />
|
||||||
|
<main id="main-content" className="p-8">
|
||||||
|
<div className="max-w-md 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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-gray-100 p-8">
|
<>
|
||||||
<nav className="max-w-4xl mx-auto mb-6 bg-white p-4 rounded-lg shadow-sm">
|
<AppHeader />
|
||||||
<ol className="flex flex-wrap space-x-2 text-sm text-gray-500">
|
<Sidebar />
|
||||||
<li><Link href="/" className="hover:text-blue-600">Home</Link></li>
|
<div className="ml-64 mt-16 min-h-screen bg-gray-50">
|
||||||
<li className="mx-1">/</li>
|
<Breadcrumb items={[{ label: 'Domains' }]} />
|
||||||
<li className="font-semibold text-gray-700">Domains</li>
|
<main id="main-content" className="p-8">
|
||||||
</ol>
|
<h1 className="text-3xl font-bold mb-8 text-gray-800 fade-in">All Domains</h1>
|
||||||
</nav>
|
<ul className="max-w-2xl grid gap-4 fade-in-stagger" role="list">
|
||||||
<h1 className="text-4xl font-bold mb-8 text-center text-gray-800">Domains</h1>
|
|
||||||
<ul className="max-w-md mx-auto grid gap-4">
|
|
||||||
{domains.length === 0 ? (
|
{domains.length === 0 ? (
|
||||||
<li className="p-6 text-center text-gray-500 bg-white rounded-lg shadow-md">No domains found</li>
|
<li className="p-6 text-center text-gray-500 bg-white rounded-lg shadow-md">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
|
</svg>
|
||||||
|
<p>No domains found</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
) : (
|
) : (
|
||||||
domains.map((d: any) => (
|
domains.map((d: any) => (
|
||||||
<li key={d.bucket}>
|
<li key={d.bucket}>
|
||||||
<Link href={`/mailboxes?bucket=${d.bucket}`} className="block p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition hover:bg-blue-50">
|
<Link
|
||||||
|
href={`/mailboxes?bucket=${d.bucket}`}
|
||||||
|
className="block p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-all hover:bg-blue-50 button-hover-lift focus-ring"
|
||||||
|
aria-label={`View mailboxes for ${d.domain}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-lg font-medium text-blue-600">{d.domain}</span>
|
<span className="text-lg font-medium text-blue-600">{d.domain}</span>
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
|
{domains.length} domains loaded
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { AppHeader } from '@/components/layout/AppHeader';
|
||||||
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
|
import { Breadcrumb } from '@/components/layout/Breadcrumb';
|
||||||
|
import { DetailSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
export default function EmailDetail() {
|
export default function EmailDetail() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -50,8 +54,47 @@ export default function EmailDetail() {
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [bucket, key]);
|
}, [bucket, key]);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-100">Loading...</div>;
|
if (loading) {
|
||||||
if (error) return <div className="min-h-screen flex items-center justify-center bg-gray-100 text-red-500">{error}</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', href: `/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox || '')}` },
|
||||||
|
{ label: 'Detail' }
|
||||||
|
]} />
|
||||||
|
<main id="main-content" className="p-8">
|
||||||
|
<DetailSkeleton />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
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', href: `/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox || '')}` },
|
||||||
|
{ label: 'Detail' }
|
||||||
|
]} />
|
||||||
|
<main id="main-content" className="p-8">
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string | null) => {
|
const formatDate = (dateStr: string | null) => {
|
||||||
if (!dateStr) return 'N/A';
|
if (!dateStr) return 'N/A';
|
||||||
|
|
@ -68,23 +111,76 @@ export default function EmailDetail() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-gray-100 p-8">
|
<>
|
||||||
<nav className="max-w-4xl mx-auto mb-6 bg-white p-4 rounded-lg shadow-sm">
|
<AppHeader />
|
||||||
<ol className="flex flex-wrap space-x-2 text-sm text-gray-500">
|
<Sidebar />
|
||||||
<li><Link href="/" className="hover:text-blue-600">Home</Link></li>
|
<div className="ml-64 mt-16 min-h-screen bg-gray-50">
|
||||||
<li className="mx-1">/</li>
|
<Breadcrumb items={[
|
||||||
<li><Link href="/domains" className="hover:text-blue-600">Domains</Link></li>
|
{ label: 'Domains', href: '/domains' },
|
||||||
<li className="mx-1">/</li>
|
{ label: 'Mailboxes', href: `/mailboxes?bucket=${bucket}` },
|
||||||
<li><Link href={`/mailboxes?bucket=${bucket}`} className="hover:text-blue-600">Mailboxes</Link></li>
|
{ label: 'Emails', href: `/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox || '')}` },
|
||||||
<li className="mx-1">/</li>
|
{ label: 'Detail' }
|
||||||
<li><Link href={`/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox || '')}`} className="hover:text-blue-600">Emails</Link></li>
|
]} />
|
||||||
<li className="mx-1">/</li>
|
<main id="main-content">
|
||||||
<li className="font-semibold text-gray-700">Detail</li>
|
{/* Email Header */}
|
||||||
</ol>
|
<div className="bg-white border-b border-gray-200 px-8 py-6">
|
||||||
</nav>
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="max-w-4xl mx-auto bg-white rounded-lg shadow-md p-8">
|
<div className="flex-1">
|
||||||
<h1 className="text-4xl font-bold mb-6 text-gray-800">{email.subject}</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">{email.subject}</h1>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||||
|
<Link
|
||||||
|
href={`/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox || '')}`}
|
||||||
|
className="text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Zurück zur Liste
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{email.status && (
|
||||||
|
<span className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm 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-4 h-4 mr-1.5" 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-4 h-4 mr-1.5" 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.charAt(0).toUpperCase() + email.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto p-8">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 fade-in">
|
||||||
|
{/* Email Meta Info */}
|
||||||
|
<div className="mb-6 pb-6 border-b border-gray-200">
|
||||||
|
<div className="flex items-start gap-4 mb-4">
|
||||||
|
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold text-lg">
|
||||||
|
{email.from ? email.from.charAt(0).toUpperCase() : 'U'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-gray-900 mb-1">{email.from || 'Unknown Sender'}</div>
|
||||||
|
<div className="text-sm text-gray-600">to {email.to || 'Unknown'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technical Details (Collapsible) */}
|
||||||
|
<details className="mb-6">
|
||||||
|
<summary className="text-sm font-medium text-gray-700 cursor-pointer hover:text-gray-900 mb-3">
|
||||||
|
Technische Details anzeigen
|
||||||
|
</summary>
|
||||||
<div className="grid grid-cols-2 gap-4 mb-6 bg-gray-50 p-6 rounded-lg">
|
<div className="grid grid-cols-2 gap-4 mb-6 bg-gray-50 p-6 rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-700 mb-2"><strong>From:</strong> {email.from}</p>
|
<p className="text-gray-700 mb-2"><strong>From:</strong> {email.from}</p>
|
||||||
|
|
@ -115,29 +211,73 @@ export default function EmailDetail() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div className="flex mb-6 space-x-2">
|
<div className="flex mb-6 space-x-2 no-print" role="tablist" aria-label="Email view options">
|
||||||
<button
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'html'}
|
||||||
|
aria-controls="email-content"
|
||||||
|
id="tab-html"
|
||||||
onClick={() => setViewMode('html')}
|
onClick={() => setViewMode('html')}
|
||||||
className={`px-6 py-3 rounded-md font-medium ${viewMode === 'html' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'} hover:bg-blue-700 transition`}
|
className={`px-6 py-3 rounded-lg font-medium transition-all focus-ring ${
|
||||||
|
viewMode === 'html'
|
||||||
|
? 'bg-blue-600 text-white shadow-md'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
HTML
|
HTML
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'raw'}
|
||||||
|
aria-controls="email-content"
|
||||||
|
id="tab-raw"
|
||||||
onClick={() => setViewMode('raw')}
|
onClick={() => setViewMode('raw')}
|
||||||
className={`px-6 py-3 rounded-md font-medium ${viewMode === 'raw' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'} hover:bg-blue-700 transition`}
|
className={`px-6 py-3 rounded-lg font-medium transition-all focus-ring ${
|
||||||
|
viewMode === 'raw'
|
||||||
|
? 'bg-blue-600 text-white shadow-md'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
RAW
|
RAW
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-gray-300 p-6 rounded-lg bg-white overflow-auto max-h-[600px] shadow-inner">
|
|
||||||
|
<div
|
||||||
|
id="email-content"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={viewMode === 'html' ? 'tab-html' : 'tab-raw'}
|
||||||
|
className="border border-gray-300 p-6 rounded-lg bg-white overflow-auto max-h-[600px] shadow-inner email-content"
|
||||||
|
>
|
||||||
{viewMode === 'html' ? (
|
{viewMode === 'html' ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800 no-print">
|
||||||
|
<svg className="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
Displaying HTML content from email
|
||||||
|
</div>
|
||||||
<div dangerouslySetInnerHTML={{ __html: email.html }} className="prose prose-lg max-w-none" />
|
<div dangerouslySetInnerHTML={{ __html: email.html }} className="prose prose-lg max-w-none" />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<pre className="whitespace-pre-wrap text-sm font-mono bg-gray-50 p-4 rounded">{email.raw}</pre>
|
<pre className="whitespace-pre-wrap text-sm font-mono bg-gray-50 p-4 rounded">{email.raw}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
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 {
|
interface Email {
|
||||||
key: string;
|
key: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
from: string;
|
||||||
|
to: string[];
|
||||||
|
preview?: string;
|
||||||
date: string;
|
date: string;
|
||||||
processed: string;
|
processed: string;
|
||||||
processedAt: string | null;
|
processedAt: string | null;
|
||||||
|
|
@ -19,9 +26,14 @@ export default function Emails() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const bucket = searchParams.get('bucket');
|
const bucket = searchParams.get('bucket');
|
||||||
const mailbox = searchParams.get('mailbox');
|
const mailbox = searchParams.get('mailbox');
|
||||||
|
|
||||||
const [emails, setEmails] = useState<Email[]>([]);
|
const [emails, setEmails] = useState<Email[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!bucket || !mailbox) {
|
if (!bucket || !mailbox) {
|
||||||
|
|
@ -44,113 +56,386 @@ export default function Emails() {
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const sorted = data.sort((a: Email, b: Email) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
setEmails(data);
|
||||||
setEmails(sorted);
|
|
||||||
})
|
})
|
||||||
.catch(err => setError(err.message))
|
.catch(err => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [bucket, mailbox]);
|
}, [bucket, mailbox]);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50">Loading...</div>;
|
// Filter and search emails
|
||||||
if (error) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-red-500">{error}</div>;
|
const filteredEmails = useMemo(() => {
|
||||||
|
let result = [...emails];
|
||||||
|
|
||||||
const formatDate = (dateStr: string | null) => {
|
// Apply status filter
|
||||||
if (!dateStr) return '';
|
if (statusFilter !== 'all') {
|
||||||
const date = new Date(dateStr);
|
result = result.filter(e => e.status === statusFilter);
|
||||||
return date.toLocaleString('en-US', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Apply date filter
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
if (dateFilter !== 'all') {
|
||||||
<nav className="w-full mb-8 bg-white p-4 rounded shadow-sm border border-gray-100">
|
const now = new Date();
|
||||||
<ol className="flex flex-wrap space-x-2 text-sm text-gray-500">
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
<li><Link href="/" className="hover:text-blue-600">Home</Link></li>
|
|
||||||
<li className="mx-1">/</li>
|
|
||||||
<li><Link href={`/mailboxes?bucket=${bucket}`} className="hover:text-blue-600">Mailboxes</Link></li>
|
|
||||||
<li className="mx-1">/</li>
|
|
||||||
<li className="font-semibold text-gray-700">Emails</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold mb-8 text-gray-800 break-all">
|
result = result.filter(e => {
|
||||||
{mailbox} <span className="text-gray-400 font-normal text-lg">in {bucket}</span>
|
const emailDate = new Date(e.date);
|
||||||
</h1>
|
const emailDay = new Date(emailDate.getFullYear(), emailDate.getMonth(), emailDate.getDate());
|
||||||
|
const daysDiff = Math.floor((today.getTime() - emailDay.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
{/* Container mit Padding für den "Rahmen um die Tabelle" */}
|
switch (dateFilter) {
|
||||||
<div className="w-full bg-white rounded shadow-sm border border-gray-200 p-2 overflow-hidden">
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{/* table-fixed: Zwingt die Tabelle, die definierten Breiten einzuhalten (kein Überlauf).
|
// Apply search
|
||||||
border-separate + border-spacing-x-2: Erzeugt die "Gaps" zwischen den Spalten.
|
if (searchQuery.trim()) {
|
||||||
*/}
|
const query = searchQuery.toLowerCase();
|
||||||
<table className="w-full table-fixed border-separate border-spacing-x-2 border-spacing-y-1 text-left">
|
result = result.filter(e =>
|
||||||
<thead className="text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
e.subject.toLowerCase().includes(query) ||
|
||||||
<tr>
|
(e.from && e.from.toLowerCase().includes(query)) ||
|
||||||
{/* SUBJECT: Keine Breite definiert (w-auto) -> nimmt den RESTLICHEN Platz */}
|
(e.to && Array.isArray(e.to) && e.to.some(addr => addr.toLowerCase().includes(query)))
|
||||||
<th className="pb-3 border-b border-gray-200 w-[40%]">Subject</th>
|
);
|
||||||
|
}
|
||||||
{/* FESTE BREITEN für den Rest, damit sie nicht zerdrückt werden */}
|
|
||||||
<th className="pb-3 border-b border-gray-200 w-[5%]">Date</th>
|
// Apply sorting
|
||||||
<th className="pb-3 border-b border-gray-200 w-[22%]">Key</th> {/* Breit genug für lange Keys */}
|
if (sortBy === 'newest') {
|
||||||
|
result.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
<th className="pb-3 border-b border-gray-200 w-[12%]">Proc. By</th>
|
} else if (sortBy === 'oldest') {
|
||||||
<th className="pb-3 border-b border-gray-200 w-[11%]">Queued</th>
|
result.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
<th className="pb-3 border-b border-gray-200 w-[5%]">Status</th>
|
} else if (sortBy === 'subject') {
|
||||||
<th className="pb-3 border-b border-gray-200 text-right w-[5%]">Action</th>
|
result.sort((a, b) => a.subject.localeCompare(b.subject));
|
||||||
</tr>
|
}
|
||||||
</thead>
|
|
||||||
<tbody className="text-sm">
|
return result;
|
||||||
{emails.length === 0 ? (
|
}, [emails, statusFilter, dateFilter, searchQuery, sortBy]);
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="py-8 text-center text-gray-500">No emails found</td>
|
// Calculate stats
|
||||||
</tr>
|
const stats = useMemo(() => {
|
||||||
) : emails.map((e: Email) => (
|
const total = emails.length;
|
||||||
<tr key={e.key} className="hover:bg-blue-50 transition-colors group">
|
const delivered = emails.filter(e => e.status === 'delivered').length;
|
||||||
|
const failed = emails.filter(e => e.status === 'failed').length;
|
||||||
{/* 1. Subject: truncate sorgt für "..." am Ende, da die Tabellenbreite fix ist */}
|
const pending = emails.filter(e => !e.status || e.status === 'pending').length;
|
||||||
<td className="py-2 text-gray-900 truncate " title={e.subject}>
|
return { total, delivered, failed, pending };
|
||||||
<div className="truncate">{e.subject}</div>
|
}, [emails]);
|
||||||
</td>
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
<td className="py-2 text-gray-500 text-xs whitespace-nowrap">
|
const date = new Date(dateStr);
|
||||||
{formatDate(e.date)}
|
const now = new Date();
|
||||||
</td>
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
{/* Key: Monospace für exakte Breite, text-xs damit er in die 28rem passt */}
|
|
||||||
<td className="py-2 font-mono text-xs text-gray-600 select-all truncate " title={e.key}>
|
if (days === 0) {
|
||||||
{e.key}
|
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
</td>
|
} else if (days < 7) {
|
||||||
|
return date.toLocaleDateString('de-DE', { weekday: 'short' });
|
||||||
|
} else {
|
||||||
<td className="py-2 text-gray-500 text-xs truncate" title={e.processedBy || ''}>
|
return date.toLocaleDateString('de-DE', { month: 'short', day: 'numeric' });
|
||||||
{e.processedBy ? e.processedBy.split('@')[0] : '-'}
|
}
|
||||||
</td>
|
};
|
||||||
|
|
||||||
<td className="py-2 text-gray-500 text-xs truncate " title={e.queuedTo || ''}>
|
if (error) {
|
||||||
{e.queuedTo || '-'}
|
return (
|
||||||
</td>
|
<>
|
||||||
|
<AppHeader />
|
||||||
<td className="py-2 whitespace-nowrap ">
|
<Sidebar />
|
||||||
{e.status ? (
|
<div className="ml-64 mt-16 min-h-screen bg-gray-50 p-8">
|
||||||
<span className={`text-xs font-medium ${
|
<main id="main-content">
|
||||||
e.status === 'delivered' ? 'text-green-600' :
|
<div className="max-w-4xl mx-auto bg-white rounded-lg shadow-md p-8 fade-in" role="alert">
|
||||||
e.status === 'failed' ? 'text-red-600' :
|
<p className="text-red-600 font-medium text-center">{error}</p>
|
||||||
'text-gray-500'
|
</div>
|
||||||
}`}>
|
</main>
|
||||||
{e.status}
|
</div>
|
||||||
</span>
|
</>
|
||||||
) : <span className="text-gray-300">-</span>}
|
);
|
||||||
</td>
|
}
|
||||||
|
|
||||||
<td className="py-2 text-right font-medium w-fit">
|
return (
|
||||||
<Link href={`/email?bucket=${bucket}&key=${e.key}&mailbox=${encodeURIComponent(mailbox || '')}`} className="text-blue-600 hover:text-blue-800 underline decoration-blue-200 hover:decoration-blue-800">
|
<>
|
||||||
View
|
<AppHeader />
|
||||||
</Link>
|
<Sidebar />
|
||||||
</td>
|
<div className="ml-64 mt-16 min-h-screen bg-gray-50">
|
||||||
</tr>
|
<Breadcrumb items={[
|
||||||
))}
|
{ label: 'Domains', href: '/domains' },
|
||||||
</tbody>
|
{ label: 'Mailboxes', href: `/mailboxes?bucket=${bucket}` },
|
||||||
</table>
|
{ label: 'Emails' }
|
||||||
</div>
|
]} />
|
||||||
</div>
|
<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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
136
app/globals.css
136
app/globals.css
|
|
@ -1,7 +1,137 @@
|
||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
|
@layer base {
|
||||||
li {
|
li {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Better focus visibility */
|
||||||
|
*:focus {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid rgb(59 130 246);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better text rendering */
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen reader only class */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Skeleton Loading Animation */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgb(229 231 235) 0%,
|
||||||
|
rgb(243 244 246) 50%,
|
||||||
|
rgb(229 231 235) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-loading {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced focus ring */
|
||||||
|
.focus-ring:focus-visible {
|
||||||
|
outline: 2px solid rgb(59 130 246);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animation */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered list animation */
|
||||||
|
.fade-in-stagger > *:nth-child(1) { animation: fadeIn 0.3s ease-out 0ms backwards; }
|
||||||
|
.fade-in-stagger > *:nth-child(2) { animation: fadeIn 0.3s ease-out 50ms backwards; }
|
||||||
|
.fade-in-stagger > *:nth-child(3) { animation: fadeIn 0.3s ease-out 100ms backwards; }
|
||||||
|
.fade-in-stagger > *:nth-child(4) { animation: fadeIn 0.3s ease-out 150ms backwards; }
|
||||||
|
.fade-in-stagger > *:nth-child(5) { animation: fadeIn 0.3s ease-out 200ms backwards; }
|
||||||
|
.fade-in-stagger > *:nth-child(n+6) { animation: fadeIn 0.3s ease-out 250ms backwards; }
|
||||||
|
|
||||||
|
/* Button hover effect */
|
||||||
|
.button-hover-lift:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-hover-lift:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Reduce motion for accessibility */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print styles for email content */
|
||||||
|
@media print {
|
||||||
|
nav, button, .no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content a::after {
|
||||||
|
content: " (" attr(href) ")";
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { AppHeader } from '@/components/layout/AppHeader';
|
||||||
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
|
import { Breadcrumb } from '@/components/layout/Breadcrumb';
|
||||||
|
import { CardSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
export default function Mailboxes() {
|
export default function Mailboxes() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -36,34 +40,91 @@ export default function Mailboxes() {
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [bucket]);
|
}, [bucket]);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-100">Loading...</div>;
|
if (loading) {
|
||||||
if (error) return <div className="min-h-screen flex items-center justify-center bg-gray-100 text-red-500">{error}</div>;
|
return (
|
||||||
|
<>
|
||||||
|
<AppHeader />
|
||||||
|
<Sidebar />
|
||||||
|
<div className="ml-64 mt-16 min-h-screen bg-gray-50">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: 'Domains', href: '/domains' },
|
||||||
|
{ label: 'Mailboxes' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<main id="main-content" className="p-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-gray-800 fade-in">Mailboxes for {bucket}</h1>
|
||||||
|
<CardSkeleton count={5} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppHeader />
|
||||||
|
<Sidebar />
|
||||||
|
<div className="ml-64 mt-16 min-h-screen bg-gray-50">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: 'Domains', href: '/domains' },
|
||||||
|
{ label: 'Mailboxes' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<main id="main-content" className="p-8">
|
||||||
|
<div className="max-w-md 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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-gray-100 p-8">
|
<>
|
||||||
<nav className="max-w-4xl mx-auto mb-6 bg-white p-4 rounded-lg shadow-sm">
|
<AppHeader />
|
||||||
<ol className="flex flex-wrap space-x-2 text-sm text-gray-500">
|
<Sidebar />
|
||||||
<li><Link href="/" className="hover:text-blue-600">Home</Link></li>
|
<div className="ml-64 mt-16 min-h-screen bg-gray-50">
|
||||||
<li className="mx-1">/</li>
|
<Breadcrumb
|
||||||
<li><Link href="/domains" className="hover:text-blue-600">Domains</Link></li>
|
items={[
|
||||||
<li className="mx-1">/</li>
|
{ label: 'Domains', href: '/domains' },
|
||||||
<li className="font-semibold text-gray-700">Mailboxes</li>
|
{ label: 'Mailboxes' }
|
||||||
</ol>
|
]}
|
||||||
</nav>
|
/>
|
||||||
<h1 className="text-4xl font-bold mb-8 text-center text-gray-800">Mailboxes for {bucket}</h1>
|
<main id="main-content" className="p-8">
|
||||||
<ul className="max-w-md mx-auto grid gap-4">
|
<h1 className="text-3xl font-bold mb-8 text-gray-800 fade-in">Mailboxes for {bucket}</h1>
|
||||||
|
<ul className="max-w-2xl grid gap-4 fade-in-stagger" role="list">
|
||||||
{mailboxes.length === 0 ? (
|
{mailboxes.length === 0 ? (
|
||||||
<li className="p-6 text-center text-gray-500 bg-white rounded-lg shadow-md">No mailboxes found</li>
|
<li className="p-6 text-center text-gray-500 bg-white rounded-lg shadow-md">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-1.14.76a2 2 0 01-2.22 0l-1.14-.76" />
|
||||||
|
</svg>
|
||||||
|
<p>No mailboxes found</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
) : (
|
) : (
|
||||||
mailboxes.map((m: string) => (
|
mailboxes.map((m: string) => (
|
||||||
<li key={m}>
|
<li key={m}>
|
||||||
<Link href={`/emails?bucket=${bucket}&mailbox=${encodeURIComponent(m)}`} className="block p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition hover:bg-blue-50">
|
<Link
|
||||||
|
href={`/emails?bucket=${bucket}&mailbox=${encodeURIComponent(m)}`}
|
||||||
|
className="block p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-all hover:bg-blue-50 button-hover-lift focus-ring"
|
||||||
|
aria-label={`View emails for ${m}`}
|
||||||
|
>
|
||||||
<span className="text-lg font-medium text-blue-600">{m}</span>
|
<span className="text-lg font-medium text-blue-600">{m}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
|
{mailboxes.length} mailboxes loaded
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
68
app/page.tsx
68
app/page.tsx
|
|
@ -6,44 +6,87 @@ import Link from 'next/link';
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [loggedIn, setLoggedIn] = useState(false);
|
const [loggedIn, setLoggedIn] = useState(false);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem('auth')) setLoggedIn(true);
|
if (localStorage.getItem('auth')) {
|
||||||
|
window.location.href = '/domains';
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch('/api/auth', {
|
const response = await fetch('/api/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const auth = btoa(`admin:${password}`);
|
const auth = btoa(`admin:${password}`);
|
||||||
localStorage.setItem('auth', auth);
|
localStorage.setItem('auth', auth);
|
||||||
setLoggedIn(true);
|
window.location.href = '/domains';
|
||||||
} else {
|
} else {
|
||||||
alert('Wrong password');
|
setError('Falsches Passwort');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Verbindungsfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !loading) {
|
||||||
|
handleLogin();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!loggedIn) {
|
if (!loggedIn) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-blue-50 to-gray-100">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-blue-50 to-gray-100">
|
||||||
<div className="bg-white p-10 rounded-xl shadow-xl w-96">
|
<div className="bg-white p-10 rounded-xl shadow-lg w-96 fade-in">
|
||||||
<h1 className="text-3xl font-bold mb-8 text-center text-gray-800">Login to Mail S3 Admin</h1>
|
<h1 id="login-heading" className="text-3xl font-bold mb-8 text-center text-gray-800">
|
||||||
|
Login to Mail S3 Admin
|
||||||
|
</h1>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleLogin(); }} aria-labelledby="login-heading">
|
||||||
|
<div className="mb-6">
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => { setPassword(e.target.value); setError(''); }}
|
||||||
className="border border-gray-300 p-4 mb-6 w-full rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
onKeyPress={handleKeyPress}
|
||||||
|
className={`border ${error ? 'border-red-500' : 'border-gray-300'} p-4 w-full rounded-lg focus:outline-none focus:ring-2 ${error ? 'focus:ring-red-500' : 'focus:ring-blue-500'} transition-colors`}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? "password-error" : undefined}
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{error && (
|
||||||
|
<p id="password-error" role="alert" className="text-red-600 text-sm mt-2 fade-in">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="submit"
|
||||||
onClick={handleLogin}
|
onClick={handleLogin}
|
||||||
className="bg-blue-600 text-white p-4 rounded-lg w-full hover:bg-blue-700 transition font-medium"
|
disabled={loading}
|
||||||
|
className="bg-blue-600 text-white px-6 py-4 rounded-lg w-full hover:bg-blue-700 transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed button-hover-lift focus-ring"
|
||||||
|
aria-label="Log in to admin panel"
|
||||||
>
|
>
|
||||||
Login
|
{loading ? 'Logging in...' : 'Login'}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -51,12 +94,17 @@ export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-gray-100 p-8">
|
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-gray-100 p-8">
|
||||||
|
<main id="main-content" className="fade-in">
|
||||||
<h1 className="text-4xl font-bold mb-8 text-center text-gray-800">Mail S3 Admin</h1>
|
<h1 className="text-4xl font-bold mb-8 text-center text-gray-800">Mail S3 Admin</h1>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Link href="/domains" className="bg-blue-600 text-white px-8 py-4 rounded-lg hover:bg-blue-700 transition shadow-md font-medium">
|
<Link
|
||||||
|
href="/domains"
|
||||||
|
className="bg-blue-600 text-white px-8 py-4 rounded-lg hover:bg-blue-700 transition-all shadow-md font-medium button-hover-lift focus-ring"
|
||||||
|
>
|
||||||
Go to Domains
|
Go to Domains
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-gray-200 z-50">
|
||||||
|
<div className="flex items-center justify-between h-full px-6">
|
||||||
|
{/* Logo & Title */}
|
||||||
|
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||||
|
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-800">Mail S3 Admin</h1>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Search Bar (global) */}
|
||||||
|
<div className="flex-1 max-w-2xl mx-8">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* User Actions */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||||
|
title="Help"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-medium text-sm">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbProps {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumb({ items }: BreadcrumbProps) {
|
||||||
|
return (
|
||||||
|
<nav className="px-6 py-2 bg-white border-b border-gray-200" aria-label="Breadcrumb">
|
||||||
|
<ol className="flex flex-wrap items-center space-x-2 text-xs text-gray-500">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{index > 0 && (
|
||||||
|
<li aria-hidden="true" className="mx-1 text-gray-400">
|
||||||
|
/
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
{isLast ? (
|
||||||
|
<span className="font-semibold text-gray-700" aria-current="page">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
) : item.href ? (
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="hover:text-blue-600 transition-colors focus-ring rounded"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span>{item.label}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Domain {
|
||||||
|
bucket: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [domains, setDomains] = useState<Domain[]>([]);
|
||||||
|
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
||||||
|
const [totalEmails, setTotalEmails] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch domains from API
|
||||||
|
const auth = localStorage.getItem('auth');
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
fetch('/api/domains', {
|
||||||
|
headers: { Authorization: `Basic ${auth}` }
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setDomains(data);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to fetch domains:', err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleDomain = (bucket: string) => {
|
||||||
|
setExpandedDomains(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(bucket)) {
|
||||||
|
next.delete(bucket);
|
||||||
|
} else {
|
||||||
|
next.add(bucket);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="fixed left-0 top-16 w-64 h-[calc(100vh-4rem)] bg-white border-r border-gray-200 overflow-y-auto">
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="px-2 pt-4">
|
||||||
|
{/* Inbox Overview */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link
|
||||||
|
href="/domains"
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||||
|
pathname === '/domains' ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">All Mail</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domains Section */}
|
||||||
|
<div className="mb-2 px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
|
Domains
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{domains.map((domain, index) => (
|
||||||
|
<div key={`${domain.bucket}-${index}`}>
|
||||||
|
<Link
|
||||||
|
href={`/mailboxes?bucket=${domain.bucket}`}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm flex-1 truncate">{domain.domain}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function Skeleton({ className = '', ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={`skeleton ${className}`} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableSkeleton({ rows = 8 }: { rows?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="w-full" role="status" aria-label="Loading">
|
||||||
|
<table className="w-full table-fixed border-separate text-left">
|
||||||
|
<thead className="text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-3 border-b border-gray-200 w-[40%]">Subject</th>
|
||||||
|
<th className="pb-3 border-b border-gray-200 w-[15%]">Date</th>
|
||||||
|
<th className="pb-3 border-b border-gray-200 w-[22%]">Key</th>
|
||||||
|
<th className="pb-3 border-b border-gray-200 w-[12%]">Proc. By</th>
|
||||||
|
<th className="pb-3 border-b border-gray-200 w-[11%]">Queued</th>
|
||||||
|
<th className="pb-3 border-b border-gray-200 w-[10%]">Status</th>
|
||||||
|
<th className="pb-3 border-b border-gray-200 text-right w-[10%]">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-sm">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-2"><Skeleton className="h-4 w-3/4" /></td>
|
||||||
|
<td className="py-2"><Skeleton className="h-4 w-20" /></td>
|
||||||
|
<td className="py-2"><Skeleton className="h-4 w-32" /></td>
|
||||||
|
<td className="py-2"><Skeleton className="h-4 w-16" /></td>
|
||||||
|
<td className="py-2"><Skeleton className="h-4 w-16" /></td>
|
||||||
|
<td className="py-2"><Skeleton className="h-4 w-16" /></td>
|
||||||
|
<td className="py-2 text-right"><Skeleton className="h-4 w-12 ml-auto" /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<span className="sr-only">Loading emails...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardSkeleton({ count = 5 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto grid gap-4 fade-in-stagger" role="status" aria-label="Loading">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="p-6 bg-white rounded-lg shadow-md">
|
||||||
|
<Skeleton className="h-6 w-3/4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<span className="sr-only">Loading items...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto bg-white rounded-lg shadow-md p-8 fade-in" role="status" aria-label="Loading">
|
||||||
|
<Skeleton className="h-10 w-3/4 mb-6" />
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-6 bg-gray-50 p-6 rounded-lg">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
<span className="sr-only">Loading email details...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE "emails" ADD COLUMN "processed_at" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "emails" ADD COLUMN "processed_by" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "emails" ADD COLUMN "queued_to" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "emails" ADD COLUMN "status" text;
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
{
|
||||||
|
"id": "59c4ca9b-1cae-4819-ab3d-0e6f415a89c0",
|
||||||
|
"prevId": "2aede8c2-3ccd-4a5b-9af8-603ddc03843a",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.domains": {
|
||||||
|
"name": "domains",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"bucket": {
|
||||||
|
"name": "bucket",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"name": "domain",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"domains_bucket_unique": {
|
||||||
|
"name": "domains_bucket_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"bucket"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.emails": {
|
||||||
|
"name": "emails",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"domain_id": {
|
||||||
|
"name": "domain_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"s3_key": {
|
||||||
|
"name": "s3_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"name": "from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"name": "to",
|
||||||
|
"type": "text[]",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"cc": {
|
||||||
|
"name": "cc",
|
||||||
|
"type": "text[]",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bcc": {
|
||||||
|
"name": "bcc",
|
||||||
|
"type": "text[]",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
"name": "html",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"raw": {
|
||||||
|
"name": "raw",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"processed": {
|
||||||
|
"name": "processed",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"processed_at": {
|
||||||
|
"name": "processed_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"processed_by": {
|
||||||
|
"name": "processed_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"queued_to": {
|
||||||
|
"name": "queued_to",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"emails_domain_id_domains_id_fk": {
|
||||||
|
"name": "emails_domain_id_domains_id_fk",
|
||||||
|
"tableFrom": "emails",
|
||||||
|
"tableTo": "domains",
|
||||||
|
"columnsFrom": [
|
||||||
|
"domain_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"emails_s3_key_unique": {
|
||||||
|
"name": "emails_s3_key_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"s3_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,13 @@
|
||||||
"when": 1758060605443,
|
"when": 1758060605443,
|
||||||
"tag": "0000_wonderful_reptil",
|
"tag": "0000_wonderful_reptil",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1766683274756,
|
||||||
|
"tag": "0001_sharp_gabe_jones",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,7 +2,6 @@
|
||||||
"name": "mail-s3-admin",
|
"name": "mail-s3-admin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
|
|
@ -27,7 +26,8 @@
|
||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-markdown": "^10.1.0"
|
"react-markdown": "^10.1.0",
|
||||||
|
"shadcn": "^3.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue