diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts new file mode 100644 index 0000000..89406f0 --- /dev/null +++ b/app/api/auth/route.ts @@ -0,0 +1,9 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest) { + const { password } = await req.json(); + if (password === process.env.APP_PASSWORD) { + return NextResponse.json({ success: true }); + } + return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); +} \ No newline at end of file diff --git a/app/api/domains/route.ts b/app/api/domains/route.ts new file mode 100644 index 0000000..f07ddd5 --- /dev/null +++ b/app/api/domains/route.ts @@ -0,0 +1,11 @@ +import { db } from '@/app/db/drizzle'; +import { domains } from '@/app/db/schema'; +import { authenticate } from '@/app/lib/utils'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const domainList = await db.select({ bucket: domains.bucket, domain: domains.domain }).from(domains); + return NextResponse.json(domainList); +} \ No newline at end of file diff --git a/app/api/email/route.ts b/app/api/email/route.ts new file mode 100644 index 0000000..b3404f2 --- /dev/null +++ b/app/api/email/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/app/db/drizzle'; +import { emails } from '@/app/db/schema'; +import { authenticate, getBody } from '@/app/lib/utils'; +import { eq } from 'drizzle-orm'; +import { CopyObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; +import { getS3Client } from '@/app/lib/utils'; +import nodemailer from 'nodemailer'; +import { Readable } from 'stream'; + +export async function GET(req: NextRequest) { + if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { searchParams } = new URL(req.url); + const bucket = searchParams.get('bucket'); + const key = searchParams.get('key'); + if (!bucket || !key) return NextResponse.json({ error: 'Missing params' }, { status: 400 }); + + const [email] = await db.select().from(emails).where(eq(emails.s3Key, key)); + if (!email) return NextResponse.json({ error: 'Email not found' }, { status: 404 }); + + return NextResponse.json({ + subject: email.subject, + from: email.from, + to: email.to?.join(', '), + html: email.html, + raw: email.raw, + processed: email.processed ? 'true' : 'false', + }); +} + +// PUT: Update processed in S3 and DB +export async function PUT(req: NextRequest) { + if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { bucket, key, processed } = await req.json(); + if (!bucket || !key) return NextResponse.json({ error: 'Missing params' }, { status: 400 }); + + const s3 = getS3Client(); + const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); + const newMeta = { ...head.Metadata, [process.env.PROCESSED_META_KEY!]: processed }; + await s3.send(new CopyObjectCommand({ + Bucket: bucket, + Key: key, + CopySource: `${bucket}/${key}`, + Metadata: newMeta, + MetadataDirective: 'REPLACE' + })); + + await db.update(emails).set({ processed: processed === 'true' }).where(eq(emails.s3Key, key)); + + return NextResponse.json({ success: true }); +} + +// POST: Resend, update in S3 and DB +export async function POST(req: NextRequest) { + if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { bucket, key } = await req.json(); + if (!bucket || !key) return NextResponse.json({ error: 'Missing params' }, { status: 400 }); + + const s3 = getS3Client(); + const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + const raw = await getBody(Body as Readable); + + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: false, + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + tls: { rejectUnauthorized: false } + }); + + try { + await transporter.sendMail({ raw }); + // Update S3 Metadata + const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); + const newMeta = { ...head.Metadata, [process.env.PROCESSED_META_KEY!]: process.env.PROCESSED_META_VALUE! }; + await s3.send(new CopyObjectCommand({ + Bucket: bucket, + Key: key, + CopySource: `${bucket}/${key}`, + Metadata: newMeta, + MetadataDirective: 'REPLACE' + })); + // Update DB + await db.update(emails).set({ processed: true }).where(eq(emails.s3Key, key)); + return NextResponse.json({ message: 'Resent successfully' }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/emails/route.ts b/app/api/emails/route.ts new file mode 100644 index 0000000..3e1d123 --- /dev/null +++ b/app/api/emails/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/app/db/drizzle'; +import { domains, emails } from '@/app/db/schema'; +import { authenticate } from '@/app/lib/utils'; +import { eq, sql } from 'drizzle-orm'; + +export async function GET(req: NextRequest) { + if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { searchParams } = new URL(req.url); + const bucket = searchParams.get('bucket'); + const mailbox = searchParams.get('mailbox')?.toLowerCase(); + if (!bucket || !mailbox) return NextResponse.json({ error: 'Missing params' }, { status: 400 }); + + const [domain] = await db.select().from(domains).where(eq(domains.bucket, bucket)); + if (!domain) return NextResponse.json({ error: 'Domain not found' }, { status: 404 }); + + const emailList = await db.select({ + key: emails.s3Key, + subject: emails.subject, + date: emails.date, + processed: emails.processed, + }).from(emails).where(sql`${mailbox} = ANY(${emails.to}) AND ${emails.domainId} = ${domain.id}`); + + return NextResponse.json(emailList.map(e => ({ key: e.key, subject: e.subject, date: e.date?.toISOString(), processed: e.processed ? 'true' : 'false' }))); +} \ No newline at end of file diff --git a/app/api/mailboxes/route.ts b/app/api/mailboxes/route.ts new file mode 100644 index 0000000..c4dbc4f --- /dev/null +++ b/app/api/mailboxes/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/app/db/drizzle'; +import { domains, emails } from '@/app/db/schema'; +import { authenticate } from '@/app/lib/utils'; +import { eq, sql } from 'drizzle-orm'; + +export async function GET(req: NextRequest) { + if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { searchParams } = new URL(req.url); + const bucket = searchParams.get('bucket'); + if (!bucket) return NextResponse.json({ error: 'Missing bucket' }, { status: 400 }); + + const [domain] = await db.select().from(domains).where(eq(domains.bucket, bucket)); + if (!domain) return NextResponse.json({ error: 'Domain not found' }, { status: 404 }); + + const mailboxData = await db.select({ to: emails.to }).from(emails).where(eq(emails.domainId, domain.id)); + const uniqueMailboxes = new Set(); + mailboxData.forEach(em => em.to?.forEach(r => uniqueMailboxes.add(r.toLowerCase()))); + + return NextResponse.json(Array.from(uniqueMailboxes)); +} \ No newline at end of file diff --git a/app/api/resend-domain/route.ts b/app/api/resend-domain/route.ts new file mode 100644 index 0000000..b5247f6 --- /dev/null +++ b/app/api/resend-domain/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/app/db/drizzle'; +import { domains, emails } from '@/app/db/schema'; +import { authenticate } from '@/app/lib/utils'; +import { eq } from 'drizzle-orm'; + +export async function POST(req: NextRequest) { + if (!authenticate(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { bucket } = await req.json(); + if (!bucket) return NextResponse.json({ error: 'Missing bucket' }, { status: 400 }); + + const [domain] = await db.select().from(domains).where(eq(domains.bucket, bucket)); + if (!domain) return NextResponse.json({ error: 'Domain not found' }, { status: 404 }); + + const unprocessed = await db.select({ s3Key: emails.s3Key }).from(emails).where(eq(emails.processed, false)); + + let count = 0; + for (const em of unprocessed) { + // Call POST /api/email internally for resend (updates DB/S3) + await fetch(`${req.headers.get('origin')}/api/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: req.headers.get('Authorization')! }, + body: JSON.stringify({ bucket, key: em.s3Key }), + }); + count++; + } + + return NextResponse.json({ message: `Resent ${count} emails` }); +} \ No newline at end of file diff --git a/app/db/drizzle.ts b/app/db/drizzle.ts new file mode 100644 index 0000000..8c94268 --- /dev/null +++ b/app/db/drizzle.ts @@ -0,0 +1,8 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import dotenv from 'dotenv'; +dotenv.config({ path: '.env' }); +console.log('DATABASE_URL:', process.env.DATABASE_URL); + +const queryClient = postgres(process.env.DATABASE_URL!); +export const db = drizzle(queryClient); \ No newline at end of file diff --git a/app/db/schema.ts b/app/db/schema.ts new file mode 100644 index 0000000..7063359 --- /dev/null +++ b/app/db/schema.ts @@ -0,0 +1,22 @@ +import { pgTable, serial, text, integer, timestamp, boolean } from 'drizzle-orm/pg-core'; + +export const domains = pgTable('domains', { + id: serial('id').primaryKey(), + bucket: text('bucket').unique().notNull(), + domain: text('domain').notNull(), +}); + +export const emails = pgTable('emails', { + id: serial('id').primaryKey(), + domainId: integer('domain_id').references(() => domains.id).notNull(), + s3Key: text('s3_key').unique().notNull(), + from: text('from'), + to: text('to').array(), + cc: text('cc').array(), + bcc: text('bcc').array(), + subject: text('subject'), + html: text('html'), + raw: text('raw'), + processed: boolean('processed').default(false), + date: timestamp('date'), +}); \ No newline at end of file diff --git a/app/domains/page.tsx b/app/domains/page.tsx new file mode 100644 index 0000000..bbbb2fe --- /dev/null +++ b/app/domains/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; + +export default function Domains() { + const [domains, setDomains] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const auth = localStorage.getItem('auth'); + if (!auth) { + setError('Not authenticated'); + setLoading(false); + return; + } + + fetch('/api/domains', { + headers: { Authorization: `Basic ${auth}` } + }) + .then(res => { + if (!res.ok) throw new Error('Failed to fetch domains'); + return res.json(); + }) + .then(setDomains) + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); + }, []); + + if (loading) return
Loading...
; + if (error) return
{error}
; + + return ( +
+

Domains

+
    + {domains.length === 0 ? ( +
  • No domains found
  • + ) : ( + domains.map((d: any) => ( +
  • + + {d.domain} + +
  • + )) + )} +
+
+ ); +} \ No newline at end of file diff --git a/app/email/page.tsx b/app/email/page.tsx new file mode 100644 index 0000000..4313a67 --- /dev/null +++ b/app/email/page.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import ReactMarkdown from 'react-markdown'; + +export default function EmailDetail() { + const searchParams = useSearchParams(); + const bucket = searchParams.get('bucket'); + const key = searchParams.get('key'); + const [email, setEmail] = useState({ subject: '', from: '', to: '', html: '', raw: '', processed: '' }); + const [viewMode, setViewMode] = useState('html'); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!bucket || !key) { + setError('Missing parameters'); + setLoading(false); + return; + } + const auth = localStorage.getItem('auth'); + if (!auth) { + setError('Not authenticated'); + setLoading(false); + return; + } + + fetch(`/api/email?bucket=${bucket}&key=${key}`, { + headers: { Authorization: `Basic ${auth}` } + }) + .then(res => { + if (!res.ok) throw new Error('Failed to fetch email'); + return res.json(); + }) + .then(setEmail) + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); + }, [bucket, key]); + + if (loading) return
Loading...
; + if (error) return
{error}
; + + return ( +
+
+

{email.subject}

+

From: {email.from}

+

To: {email.to}

+

Processed: {email.processed}

+
+ + +
+
+ {viewMode === 'html' ? ( +
+ {email.html} +
+ ) : ( +
{email.raw}
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/emails/page.tsx b/app/emails/page.tsx new file mode 100644 index 0000000..56c5589 --- /dev/null +++ b/app/emails/page.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; + +interface Email { + key: string; + subject: string; + date: string; + processed: string; +} + +export default function Emails() { + const searchParams = useSearchParams(); + const bucket = searchParams.get('bucket'); + const mailbox = searchParams.get('mailbox'); + const [emails, setEmails] = useState([]); + const [message, setMessage] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!bucket || !mailbox) { + setError('Missing parameters'); + setLoading(false); + return; + } + const auth = localStorage.getItem('auth'); + if (!auth) { + setError('Not authenticated'); + setLoading(false); + return; + } + + fetch(`/api/emails?bucket=${bucket}&mailbox=${encodeURIComponent(mailbox)}`, { + headers: { Authorization: `Basic ${auth}` } + }) + .then(res => { + if (!res.ok) throw new Error('Failed to fetch emails'); + return res.json(); + }) + .then(setEmails) + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); + }, [bucket, mailbox]); + + if (loading) return
Loading...
; + if (error) return
{error}
; + + const handleResendAll = async () => { + const auth = localStorage.getItem('auth'); + if (!auth) return setMessage('Not authenticated'); + + const response = await fetch('/api/resend-domain', { + method: 'POST', + headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ bucket }), + }); + const res = await response.json(); + setMessage(res.message || res.error); + }; + + const handleUpdateProcessed = async (key: string, newValue: boolean) => { + const auth = localStorage.getItem('auth'); + if (!auth) return; + + await fetch('/api/email', { + method: 'PUT', + headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ bucket, key, processed: newValue ? 'true' : 'false' }), + }); + setEmails(emails.map(em => em.key === key ? { ...em, processed: newValue ? 'true' : 'false' } : em)); + }; + + const handleResend = async (key: string) => { + const auth = localStorage.getItem('auth'); + if (!auth) return alert('Not authenticated'); + + const response = await fetch('/api/email', { + method: 'POST', + headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ bucket, key }), + }); + const res = await response.json(); + alert(res.message || res.error); + }; + + return ( +
+

Emails for {mailbox} in {bucket}

+
+ +
+ {message &&

{message}

} +
+ + + + + + + + + + + {emails.map((e: Email) => ( + + + + + + + ))} + +
SubjectDateProcessedActions
{e.subject}{e.date} + handleUpdateProcessed(e.key, e.processed !== 'true')} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + + + View + + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index a2dc41e..bd6213e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,3 @@ -@import "tailwindcss"; - -:root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..ba43a62 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,20 +1,12 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Mail S3 Admin", + description: "Admin App for S3 Emails", }; export default function RootLayout({ @@ -24,11 +16,7 @@ export default function RootLayout({ }>) { return ( - - {children} - + {children} ); -} +} \ No newline at end of file diff --git a/app/lib/cron.ts b/app/lib/cron.ts new file mode 100644 index 0000000..6d4bcea --- /dev/null +++ b/app/lib/cron.ts @@ -0,0 +1,14 @@ +import cron from 'node-cron'; +import { syncAllDomains } from './sync'; + +console.log('Starting Cron Job for S3 Sync...'); + +cron.schedule('0 * * * *', async () => { + console.log('Running Sync...'); + try { + await syncAllDomains(); + console.log('Sync completed.'); + } catch (error) { + console.error('Sync error:', error); + } +}); \ No newline at end of file diff --git a/app/lib/sync.ts b/app/lib/sync.ts new file mode 100644 index 0000000..30dc2f0 --- /dev/null +++ b/app/lib/sync.ts @@ -0,0 +1,94 @@ +import { db } from '@/app/db/drizzle'; +import { domains, emails } from '@/app/db/schema'; +import { getS3Client, getBody } from '@/app/lib/utils'; +import { simpleParser } from 'mailparser'; +import { ListBucketsCommand, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; +import { eq, sql } from 'drizzle-orm'; +import { Readable } from 'stream'; +import pRetry from 'p-retry'; // Für Retry bei Timeouts + +export async function syncAllDomains() { + console.log('Starting syncAllDomains...'); + const s3 = getS3Client(); + const { Buckets } = await pRetry(() => s3.send(new ListBucketsCommand({})), { retries: 3 }); // Retry bei Fail + const domainBuckets = Buckets?.filter(b => b.Name?.endsWith('-emails')) || []; + console.log('Found domain buckets:', domainBuckets.map(b => b.Name).join(', ') || 'None'); + + for (const bucketObj of domainBuckets) { + const bucket = bucketObj.Name!; + const domainName = bucket.replace('-emails', '').replace(/-/g, '.'); + console.log(`Processing bucket: ${bucket} (domain: ${domainName})`); + + // Upsert Domain + const existingDomain = await db.select().from(domains).where(eq(domains.bucket, bucket)).limit(1); + console.log(`Existing domain for ${bucket}: ${existingDomain.length > 0 ? 'Yes (ID: ' + existingDomain[0].id + ')' : 'No'}`); + let domainId; + if (existingDomain.length === 0) { + const [newDomain] = await db.insert(domains).values({ bucket, domain: domainName }).returning({ id: domains.id }); + domainId = newDomain.id; + console.log(`Created new domain ID: ${domainId}`); + } else { + domainId = existingDomain[0].id; + } + + // Sync Emails + await syncEmailsForDomain(domainId, bucket); + } + console.log('syncAllDomains completed.'); +} + +async function syncEmailsForDomain(domainId: number, bucket: string) { + console.log(`Starting syncEmailsForDomain for bucket: ${bucket} (domainId: ${domainId})`); + const s3 = getS3Client(); + const { Contents } = await pRetry(() => s3.send(new ListObjectsV2Command({ Bucket: bucket })), { retries: 3 }); + console.log(`Found objects in bucket: ${Contents?.length || 0}`); + + for (const obj of Contents || []) { + if (!obj.Key) continue; + console.log(`Processing object: ${obj.Key}`); + + // Check if exists + const existing = await db.select().from(emails).where(eq(emails.s3Key, obj.Key!)).limit(1); + console.log(`Existing email for ${obj.Key}: ${existing.length > 0 ? 'Yes' : 'No'}`); + if (existing.length > 0) { + // Update processed if changed + const head = await pRetry(() => s3.send(new HeadObjectCommand({ Bucket: bucket, Key: obj.Key })), { retries: 3 }); + const processed = head.Metadata?.[process.env.PROCESSED_META_KEY!] === process.env.PROCESSED_META_VALUE!; + console.log(`Processed metadata for ${obj.Key}: ${processed} (DB: ${existing[0].processed})`); + if (existing[0].processed !== processed) { + await db.update(emails).set({ processed }).where(eq(emails.s3Key, obj.Key!)); + console.log(`Updated processed for ${obj.Key}`); + } + await new Promise(resolve => setTimeout(resolve, 100)); // Kleine Delay gegen Throttling + continue; + } + + // New: Parse and insert + console.log(`Parsing new email: ${obj.Key}`); + const { Body } = await pRetry(() => s3.send(new GetObjectCommand({ Bucket: bucket, Key: obj.Key })), { retries: 3 }); + const raw = await getBody(Body as Readable); + const parsed = await simpleParser(raw); + const head = await pRetry(() => s3.send(new HeadObjectCommand({ Bucket: bucket, Key: obj.Key })), { retries: 3 }); + + const to = parsed.to ? (Array.isArray(parsed.to) ? parsed.to.flatMap(t => t.value.map(v => v.address?.toLowerCase() || '')) : parsed.to.value.map(v => v.address?.toLowerCase() || '')) : []; + const cc = parsed.cc ? (Array.isArray(parsed.cc) ? parsed.cc.flatMap(c => c.value.map(v => v.address?.toLowerCase() || '')) : parsed.cc.value.map(v => v.address?.toLowerCase() || '')) : []; + const bcc = parsed.bcc ? (Array.isArray(parsed.bcc) ? parsed.bcc.flatMap(b => b.value.map(v => v.address?.toLowerCase() || '')) : parsed.bcc.value.map(v => v.address?.toLowerCase() || '')) : []; + + await db.insert(emails).values({ + domainId, + s3Key: obj.Key!, + from: parsed.from?.value[0].address, + to, + cc, + bcc, + subject: parsed.subject, + html: parsed.html || parsed.textAsHtml, + raw: raw.toString('utf-8'), + processed: head.Metadata?.[process.env.PROCESSED_META_KEY!] === process.env.PROCESSED_META_VALUE!, + date: parsed.date || obj.LastModified, + }); + console.log(`Inserted new email: ${obj.Key}`); + await new Promise(resolve => setTimeout(resolve, 100)); // Delay gegen Throttling + } + console.log(`syncEmailsForDomain completed for bucket: ${bucket}`); +} \ No newline at end of file diff --git a/app/lib/utils.ts b/app/lib/utils.ts new file mode 100644 index 0000000..4e3d231 --- /dev/null +++ b/app/lib/utils.ts @@ -0,0 +1,38 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import { NextRequest } from 'next/server'; +import { Readable } from 'stream'; + +export function getS3Client() { + console.log('Creating S3Client...'); + console.log('AWS_REGION:', process.env.AWS_REGION); + console.log('AWS_ACCESS_KEY_ID:', process.env.AWS_ACCESS_KEY_ID ? 'Set' : 'Not set'); // Maskiere sensible Infos + console.log('AWS_SECRET_ACCESS_KEY:', process.env.AWS_SECRET_ACCESS_KEY ? 'Set' : 'Not set'); + + return new S3Client({ + region: process.env.AWS_REGION, + credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY! }, + httpOptions: { connectTimeout: 60000, timeout: 60000 }, // 60s, verhindert Timeouts + }); +} + +export function authenticate(req: NextRequest) { + const auth = req.headers.get('Authorization'); + console.log('Received Auth Header:', auth); // Logge den Header + console.log('Expected Password:', process.env.APP_PASSWORD); // Logge das Env-Passwort (Achtung: Sensibel, nur für Debug!) + if (!auth || !auth.startsWith('Basic ')) return false; + const [user, pass] = Buffer.from(auth.slice(6), 'base64').toString().split(':'); + return user === 'admin' && pass === process.env.APP_PASSWORD; +} + +export async function getBody(stream: Readable): Promise { + console.log('Getting body from stream...'); + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', chunk => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => { + console.log('Body fetched, size:', Buffer.concat(chunks).length); + resolve(Buffer.concat(chunks)); + }); + }); +} \ No newline at end of file diff --git a/app/mailboxes/page.tsx b/app/mailboxes/page.tsx new file mode 100644 index 0000000..3f4e1f0 --- /dev/null +++ b/app/mailboxes/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; + +export default function Mailboxes() { + const searchParams = useSearchParams(); + const bucket = searchParams.get('bucket'); + const [mailboxes, setMailboxes] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!bucket) { + setError('No bucket specified'); + setLoading(false); + return; + } + const auth = localStorage.getItem('auth'); + if (!auth) { + setError('Not authenticated'); + setLoading(false); + return; + } + + fetch(`/api/mailboxes?bucket=${bucket}`, { + headers: { Authorization: `Basic ${auth}` } + }) + .then(res => { + if (!res.ok) throw new Error('Failed to fetch mailboxes'); + return res.json(); + }) + .then(setMailboxes) + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); + }, [bucket]); + + if (loading) return
Loading...
; + if (error) return
{error}
; + + return ( +
+

Mailboxes for {bucket}

+
    + {mailboxes.length === 0 ? ( +
  • No mailboxes found
  • + ) : ( + mailboxes.map((m: string) => ( +
  • + + {m} + +
  • + )) + )} +
+
+ ); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 21b686d..74a1ed7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,103 +1,62 @@ -import Image from "next/image"; +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+ const [loggedIn, setLoggedIn] = useState(false); + const [password, setPassword] = useState(''); -
- +
+ ); + } + + return ( +
+

Mail S3 Admin

+
+ + Go to Domains + +
); -} +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f87c211 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + mail-admin: + build: . + ports: + - "3000:3000" + environment: + - DATABASE_URL=postgresql://postgres:password@postgres:5433/mydb?schema=public + # ... deine bestehenden Env-Vars (AWS, SMTP, APP_PASSWORD) + depends_on: + - postgres + + postgres: + image: postgres:latest + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: fiesta # Ändere das! + POSTGRES_DB: mydb + ports: + - "5433:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + +volumes: + postgres-data: \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..e86f075 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'drizzle-kit'; +export default defineConfig({ + out: './drizzle', + schema: './app/db/schema.ts', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); \ No newline at end of file diff --git a/package.json b/package.json index 2698211..df4dfe1 100644 --- a/package.json +++ b/package.json @@ -2,22 +2,41 @@ "name": "mail-s3-admin", "version": "0.1.0", "private": true, + "type": "commonjs", "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev", "build": "next build --turbopack", "start": "next start" }, "dependencies": { + "@aws-sdk/client-s3": "^3.888.0", + "dotenv": "^17.2.2", + "drizzle-orm": "^0.44.5", + "email-addresses": "^5.0.0", + "emailjs": "^4.0.3", + "mailparser": "^3.7.4", + "marked": "^16.3.0", + "next": "15.5.3", + "node-cron": "^4.2.1", + "nodemailer": "^7.0.6", + "p-retry": "^7.0.0", + "pg": "^8.16.3", + "postgres": "^3.4.7", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.3" + "react-markdown": "^10.1.0" }, "devDependencies": { - "typescript": "^5", + "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^7.0.1", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4" + "autoprefixer": "^10.4.21", + "drizzle-kit": "^0.31.4", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.13", + "ts-node": "^10.9.2", + "typescript": "^5" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..5ab85cd --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +}; \ No newline at end of file diff --git a/postcss.config.mjs b/postcss.config.mjs deleted file mode 100644 index c7bcb4b..0000000 --- a/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -const config = { - plugins: ["@tailwindcss/postcss"], -}; - -export default config; diff --git a/s3-list.ts b/s3-list.ts new file mode 100644 index 0000000..1b3b092 --- /dev/null +++ b/s3-list.ts @@ -0,0 +1,28 @@ +import dotenv from 'dotenv'; +dotenv.config({ path: '.env' }); + +import { S3Client, ListBucketsCommand } from '@aws-sdk/client-s3'; +import http from 'http'; +import https from 'https'; + +// Aktiviere KeepAlive für stabile Connections (verhindert Timeouts bei Wiederholungen) +http.globalAgent.keepAlive = true; +https.globalAgent.keepAlive = true; + +(async () => { + try { + console.log('Fetching S3 buckets...'); + const s3 = new S3Client({ + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + httpOptions: { connectTimeout: 30000, timeout: 30000 }, // Erhöhte Timeouts (30s) verhindern ETIMEDOUT + }); + const { Buckets } = await s3.send(new ListBucketsCommand({})); + console.log('Buckets:', Buckets?.map(b => b.Name) || 'No buckets found'); + } catch (error) { + console.error('Error listing buckets:', error); + } +})(); \ No newline at end of file diff --git a/sync-test.ts b/sync-test.ts new file mode 100644 index 0000000..b17c0d9 --- /dev/null +++ b/sync-test.ts @@ -0,0 +1,23 @@ +import { syncAllDomains } from './app/lib/sync'; +import dotenv from 'dotenv'; +import http from 'http'; +import https from 'https'; + +http.globalAgent.keepAlive = true; +https.globalAgent.keepAlive = true; +dotenv.config({ path: '.env' }); + +console.log('DATABASE_URL:', process.env.DATABASE_URL); +console.log('AWS_REGION:', process.env.AWS_REGION); +console.log('AWS_ACCESS_KEY_ID:', process.env.AWS_ACCESS_KEY_ID ? 'Set' : 'Not set'); // Maskiere sensible Keys +console.log('AWS_SECRET_ACCESS_KEY:', process.env.AWS_SECRET_ACCESS_KEY ? 'Set' : 'Not set'); + +(async () => { + try { + console.log('Starting sync...'); + await syncAllDomains(); + console.log('Sync done'); + } catch (error) { + console.error('Error:', error); + } +})(); \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..e63dc8a --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,17 @@ +import type { Config } from 'tailwindcss'; + +const config: Config = { + content: [ + './app/**/*.{js,ts,jsx,tsx,mdx}', + './pages/**/*.{js,ts,jsx,tsx,mdx}', + './components/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + // Optionale Erweiterungen, z. B. Farben + }, + }, + plugins: [], +}; + +export default config; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index d8b9323..e23c005 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,9 +18,9 @@ "name": "next" } ], - "paths": { - "@/*": ["./*"] - } + "baseUrl": ".", "paths": { "@/*": ["./*"] + } + }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..809d518 --- /dev/null +++ b/types.d.ts @@ -0,0 +1,46 @@ +declare module 'mailparser' { + export function simpleParser(source: Buffer | string | Readable, options?: any): Promise; + + export interface ParsedMail { + headers: Map; + subject?: string; + from?: AddressObject; + to?: AddressObject | AddressObject[]; + cc?: AddressObject | AddressObject[]; + bcc?: AddressObject | AddressObject[]; + date?: Date; + messageId?: string; + inReplyTo?: string; + replyTo?: AddressObject; + references?: string | string[]; + html?: string | false; + text?: string; + textAsHtml?: string; + attachments: Attachment[]; + } + + export interface AddressObject { + value: Mailbox[]; + html: string; + text: string; + } + + export interface Mailbox { + name: string; + address: string; + } + + export interface Attachment { + type: string; + contentType: string; + partId: string; + filename?: string; + contentDisposition?: string; + checksum: string; + size: number; + headers: Map; + content: Buffer; + cid?: string; + related?: boolean; + } +} \ No newline at end of file