UI overhault

This commit is contained in:
knuthtimo-lab 2025-12-29 10:14:41 +01:00
parent 1964098136
commit a209468616
19 changed files with 5585 additions and 313 deletions

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 => {
key: e.key, let preview = '';
subject: e.subject,
date: e.date?.toISOString(), // Check both HTML and raw content for images
processed: e.processed ? 'true' : 'false', const htmlContent = e.html || '';
processedAt: e.processedAt?.toISOString() || null, const rawContent = e.raw || '';
processedBy: e.processedBy,
queuedTo: e.queuedTo, // Check for images in HTML
status: e.status, 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,
subject: e.subject,
from: e.from,
to: e.to,
preview,
date: e.date?.toISOString(),
processed: e.processed ? 'true' : 'false',
processedAt: e.processedAt?.toISOString() || null,
processedBy: e.processedBy,
queuedTo: e.queuedTo,
status: e.status,
};
}));
} }

22
app/api/sync/route.ts Normal file
View File

@ -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 });
}
}

View File

@ -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([]);
@ -16,8 +20,8 @@ export default function Domains() {
return; return;
} }
fetch('/api/domains', { fetch('/api/domains', {
headers: { Authorization: `Basic ${auth}` } headers: { Authorization: `Basic ${auth}` }
}) })
.then(res => { .then(res => {
if (!res.ok) throw new Error('Failed to fetch domains'); if (!res.ok) throw new Error('Failed to fetch domains');
@ -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> {domains.length === 0 ? (
<ul className="max-w-md mx-auto grid gap-4"> <li className="p-6 text-center text-gray-500 bg-white rounded-lg shadow-md">
{domains.length === 0 ? ( <div className="flex flex-col items-center gap-2">
<li className="p-6 text-center text-gray-500 bg-white rounded-lg shadow-md">No domains found</li> <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" />
domains.map((d: any) => ( </svg>
<li key={d.bucket}> <p>No domains found</p>
<Link href={`/mailboxes?bucket=${d.bucket}`} className="block p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition hover:bg-blue-50"> </div>
<span className="text-lg font-medium text-blue-600">{d.domain}</span> </li>
</Link> ) : (
</li> domains.map((d: any) => (
)) <li key={d.bucket}>
)} <Link
</ul> href={`/mailboxes?bucket=${d.bucket}`}
</div> 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>
<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>
</li>
))
)}
</ul>
<div role="status" aria-live="polite" className="sr-only">
{domains.length} domains loaded
</div>
</main>
</div>
</>
); );
} }

View File

@ -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,76 +111,173 @@ 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">
<div className="grid grid-cols-2 gap-4 mb-6 bg-gray-50 p-6 rounded-lg"> <Link
<div> href={`/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox || '')}`}
<p className="text-gray-700 mb-2"><strong>From:</strong> {email.from}</p> className="text-blue-600 hover:text-blue-800 flex items-center gap-1"
<p className="text-gray-700 mb-2"><strong>To:</strong> {email.to}</p> >
<p className="text-gray-700 mb-2"><strong>S3 Key:</strong> <span className="text-sm break-all">{key}</span></p> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
<div> </svg>
<p className="text-gray-700 mb-2"> Zurück zur Liste
<strong>Processed:</strong> </Link>
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${email.processed === 'true' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}> </div>
{email.processed === 'true' ? 'Yes' : 'No'} </div>
</span> {email.status && (
</p> <span className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium ${
<p className="text-gray-700 mb-2"><strong>Processed At:</strong> {formatDate(email.processedAt)}</p> email.status === 'delivered' ? 'bg-green-100 text-green-800' :
<p className="text-gray-700 mb-2"><strong>Processed By:</strong> {email.processedBy || 'N/A'}</p> email.status === 'failed' ? 'bg-red-100 text-red-800' :
<p className="text-gray-700 mb-2"><strong>Queued To:</strong> {email.queuedTo || 'N/A'}</p> 'bg-gray-100 text-gray-700'
<p className="text-gray-700 mb-2">
<strong>Status:</strong>
{email.status ? (
<span className={`ml-2 px-2 py-1 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-800'
}`}> }`}>
{email.status} {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> </span>
) : 'N/A'} )}
</p> </div>
</div> </div>
</div>
<div className="max-w-5xl mx-auto p-8">
<div className="flex mb-6 space-x-2"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 fade-in">
<button {/* Email Meta Info */}
onClick={() => setViewMode('html')} <div className="mb-6 pb-6 border-b border-gray-200">
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`} <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">
HTML {email.from ? email.from.charAt(0).toUpperCase() : 'U'}
</button> </div>
<button <div className="flex-1">
onClick={() => setViewMode('raw')} <div className="font-semibold text-gray-900 mb-1">{email.from || 'Unknown Sender'}</div>
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`} <div className="text-sm text-gray-600">to {email.to || 'Unknown'}</div>
> </div>
RAW </div>
</button> </div>
</div>
<div className="border border-gray-300 p-6 rounded-lg bg-white overflow-auto max-h-[600px] shadow-inner"> {/* Technical Details (Collapsible) */}
{viewMode === 'html' ? ( <details className="mb-6">
<div dangerouslySetInnerHTML={{ __html: email.html }} className="prose prose-lg max-w-none" /> <summary className="text-sm font-medium text-gray-700 cursor-pointer hover:text-gray-900 mb-3">
) : ( Technische Details anzeigen
<pre className="whitespace-pre-wrap text-sm font-mono bg-gray-50 p-4 rounded">{email.raw}</pre> </summary>
)} <div className="grid grid-cols-2 gap-4 mb-6 bg-gray-50 p-6 rounded-lg">
</div> <div>
</div> <p className="text-gray-700 mb-2"><strong>From:</strong> {email.from}</p>
<p className="text-gray-700 mb-2"><strong>To:</strong> {email.to}</p>
<p className="text-gray-700 mb-2"><strong>S3 Key:</strong> <span className="text-sm break-all">{key}</span></p>
</div>
<div>
<p className="text-gray-700 mb-2">
<strong>Processed:</strong>
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${email.processed === 'true' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
{email.processed === 'true' ? 'Yes' : 'No'}
</span>
</p>
<p className="text-gray-700 mb-2"><strong>Processed At:</strong> {formatDate(email.processedAt)}</p>
<p className="text-gray-700 mb-2"><strong>Processed By:</strong> {email.processedBy || 'N/A'}</p>
<p className="text-gray-700 mb-2"><strong>Queued To:</strong> {email.queuedTo || 'N/A'}</p>
<p className="text-gray-700 mb-2">
<strong>Status:</strong>
{email.status ? (
<span className={`ml-2 px-2 py-1 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-800'
}`}>
{email.status}
</span>
) : 'N/A'}
</p>
</div>
</div>
</details>
<div className="flex mb-6 space-x-2 no-print" role="tablist" aria-label="Email view options">
<button
role="tab"
aria-selected={viewMode === 'html'}
aria-controls="email-content"
id="tab-html"
onClick={() => setViewMode('html')}
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
</span>
</button>
<button
role="tab"
aria-selected={viewMode === 'raw'}
aria-controls="email-content"
id="tab-raw"
onClick={() => setViewMode('raw')}
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
</span>
</button>
</div>
<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' ? (
<>
<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" />
</>
) : (
<pre className="whitespace-pre-wrap text-sm font-mono bg-gray-50 p-4 rounded">{email.raw}</pre>
)}
</div>
</div>
</div>
</main>
</div> </div>
</>
); );
} }

View File

@ -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) {
@ -36,121 +48,394 @@ export default function Emails() {
return; return;
} }
fetch(`/api/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox)}`, { fetch(`/api/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox)}`, {
headers: { Authorization: `Basic ${auth}` } headers: { Authorization: `Basic ${auth}` }
}) })
.then(res => { .then(res => {
if (!res.ok) throw new Error('Failed to fetch emails'); if (!res.ok) throw new Error('Failed to fetch 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') {
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 date = new Date(dateStr);
return date.toLocaleString('en-US', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); 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 ( return (
<div className="min-h-screen bg-gray-50 p-6"> <>
<nav className="w-full mb-8 bg-white p-4 rounded shadow-sm border border-gray-100"> <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={`/mailboxes?bucket=${bucket}`} className="hover:text-blue-600">Mailboxes</Link></li> { label: 'Domains', href: '/domains' },
<li className="mx-1">/</li> { label: 'Mailboxes', href: `/mailboxes?bucket=${bucket}` },
<li className="font-semibold text-gray-700">Emails</li> { label: 'Emails' }
</ol> ]} />
</nav> <main id="main-content">
{/* Page Header with Stats */}
<h1 className="text-2xl font-bold mb-8 text-gray-800 break-all"> <div className="bg-white border-b border-gray-200 px-6 py-4">
{mailbox} <span className="text-gray-400 font-normal text-lg">in {bucket}</span> <h1 className="text-2xl font-bold text-gray-800 mb-4 truncate">
</h1> {mailbox}
</h1>
{/* Container mit Padding für den "Rahmen um die Tabelle" */}
<div className="w-full bg-white rounded shadow-sm border border-gray-200 p-2 overflow-hidden">
{/* table-fixed: Zwingt die Tabelle, die definierten Breiten einzuhalten (kein Überlauf).
border-separate + border-spacing-x-2: Erzeugt die "Gaps" zwischen den Spalten.
*/}
<table className="w-full table-fixed border-separate border-spacing-x-2 border-spacing-y-1 text-left">
<thead className="text-xs font-semibold text-gray-600 uppercase tracking-wider">
<tr>
{/* SUBJECT: Keine Breite definiert (w-auto) -> nimmt den RESTLICHEN Platz */}
<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 */} {/* Stats */}
<th className="pb-3 border-b border-gray-200 w-[5%]">Date</th> <div className="grid grid-cols-4 gap-4">
<th className="pb-3 border-b border-gray-200 w-[22%]">Key</th> {/* Breit genug für lange Keys */} <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>
<th className="pb-3 border-b border-gray-200 w-[12%]">Proc. By</th> {/* Filter & Toolbar */}
<th className="pb-3 border-b border-gray-200 w-[11%]">Queued</th> <div className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
<th className="pb-3 border-b border-gray-200 w-[5%]">Status</th> {/* Search */}
<th className="pb-3 border-b border-gray-200 text-right w-[5%]">Action</th> <div className="flex-1 max-w-md">
</tr> <div className="relative">
</thead> <input
<tbody className="text-sm"> type="search"
{emails.length === 0 ? ( placeholder="E-Mails durchsuchen..."
<tr> value={searchQuery}
<td colSpan={8} className="py-8 text-center text-gray-500">No emails found</td> onChange={(e) => setSearchQuery(e.target.value)}
</tr> 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"
) : emails.map((e: Email) => ( />
<tr key={e.key} className="hover:bg-blue-50 transition-colors group"> <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" />
{/* 1. Subject: truncate sorgt für "..." am Ende, da die Tabellenbreite fix ist */} </svg>
<td className="py-2 text-gray-900 truncate " title={e.subject}> </div>
<div className="truncate">{e.subject}</div> </div>
</td>
<td className="py-2 text-gray-500 text-xs whitespace-nowrap"> {/* Date Filter */}
{formatDate(e.date)} <div className="flex gap-2 ml-4">
</td> <select
value={dateFilter}
{/* Key: Monospace für exakte Breite, text-xs damit er in die 28rem passt */} onChange={(e) => setDateFilter(e.target.value)}
<td className="py-2 font-mono text-xs text-gray-600 select-all truncate " title={e.key}> className="text-sm border border-gray-300 rounded-full px-4 py-1.5 bg-white"
{e.key} >
</td> <option value="all">Alle Zeiträume</option>
<option value="today">Heute</option>
<option value="yesterday">Gestern</option>
<td className="py-2 text-gray-500 text-xs truncate" title={e.processedBy || ''}> <option value="last2days">Letzte 2 Tage</option>
{e.processedBy ? e.processedBy.split('@')[0] : '-'} <option value="last3days">Letzte 3 Tage</option>
</td> <option value="2daysago">Vor 2 Tagen</option>
<option value="3daysago">Vor 3 Tagen</option>
<td className="py-2 text-gray-500 text-xs truncate " title={e.queuedTo || ''}> <option value="4daysago">Vor 4 Tagen</option>
{e.queuedTo || '-'} <option value="5daysago">Vor 5 Tagen</option>
</td> <option value="lastweek">Letzte Woche</option>
<option value="lastmonth">Letzter Monat</option>
<td className="py-2 whitespace-nowrap "> </select>
{e.status ? ( </div>
<span className={`text-xs font-medium ${
e.status === 'delivered' ? 'text-green-600' : {/* Status Filter Chips */}
e.status === 'failed' ? 'text-red-600' : <div className="flex gap-2 ml-4">
'text-gray-500' <button
}`}> onClick={() => setStatusFilter('all')}
{e.status} className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
</span> statusFilter === 'all'
) : <span className="text-gray-300">-</span>} ? 'bg-blue-100 text-blue-800'
</td> : 'text-gray-600 hover:bg-gray-100'
}`}
<td className="py-2 text-right font-medium w-fit"> >
<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"> Alle
View </button>
</Link> <button
</td> onClick={() => setStatusFilter('delivered')}
</tr> className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-1 ${
))} statusFilter === 'delivered'
</tbody> ? 'bg-green-100 text-green-800'
</table> : '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> </div>
</div> </>
); );
} }

View File

@ -1,7 +1,137 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities;
li { @layer base {
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;
}
} }

View File

@ -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>
{mailboxes.length === 0 ? ( <ul className="max-w-2xl grid gap-4 fade-in-stagger" role="list">
<li className="p-6 text-center text-gray-500 bg-white rounded-lg shadow-md">No mailboxes found</li> {mailboxes.length === 0 ? (
) : ( <li className="p-6 text-center text-gray-500 bg-white rounded-lg shadow-md">
mailboxes.map((m: string) => ( <div className="flex flex-col items-center gap-2">
<li key={m}> <svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"> <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" />
<span className="text-lg font-medium text-blue-600">{m}</span> </svg>
</Link> <p>No mailboxes found</p>
</div>
</li> </li>
)) ) : (
)} mailboxes.map((m: string) => (
</ul> <li key={m}>
</div> <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>
</Link>
</li>
))
)}
</ul>
<div role="status" aria-live="polite" className="sr-only">
{mailboxes.length} mailboxes loaded
</div>
</main>
</div>
</>
); );
} }

View File

@ -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 () => {
const response = await fetch('/api/auth', { setError('');
method: 'POST', setLoading(true);
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }), try {
}); const response = await fetch('/api/auth', {
if (response.ok) { method: 'POST',
const auth = btoa(`admin:${password}`); headers: { 'Content-Type': 'application/json' },
localStorage.setItem('auth', auth); body: JSON.stringify({ password }),
setLoggedIn(true); });
} else {
alert('Wrong password'); if (response.ok) {
const auth = btoa(`admin:${password}`);
localStorage.setItem('auth', auth);
window.location.href = '/domains';
} else {
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">
<input Login to Mail S3 Admin
type="password" </h1>
value={password} <form onSubmit={(e) => { e.preventDefault(); handleLogin(); }} aria-labelledby="login-heading">
onChange={e => setPassword(e.target.value)} <div className="mb-6">
className="border border-gray-300 p-4 mb-6 w-full rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" <label htmlFor="password" className="sr-only">
placeholder="Enter password" Password
/> </label>
<button <input
onClick={handleLogin} id="password"
className="bg-blue-600 text-white p-4 rounded-lg w-full hover:bg-blue-700 transition font-medium" type="password"
> value={password}
Login onChange={e => { setPassword(e.target.value); setError(''); }}
</button> 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"
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
type="submit"
onClick={handleLogin}
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"
>
{loading ? 'Logging in...' : 'Login'}
</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">
<h1 className="text-4xl font-bold mb-8 text-center text-gray-800">Mail S3 Admin</h1> <main id="main-content" className="fade-in">
<div className="flex justify-center"> <h1 className="text-4xl font-bold mb-8 text-center text-gray-800">Mail S3 Admin</h1>
<Link href="/domains" className="bg-blue-600 text-white px-8 py-4 rounded-lg hover:bg-blue-700 transition shadow-md font-medium"> <div className="flex justify-center">
Go to Domains <Link
</Link> href="/domains"
</div> 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
</Link>
</div>
</main>
</div> </div>
); );
} }

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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": {}
}
}

View File

@ -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
} }
] ]
} }

4006
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",