Initial commit: Portfolio Website
Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
# Email Automation mit Resend & React Email
|
||||
|
||||
**Meta-Description:** Moderne Email Automation. React Email Templates, Resend API, Transactional Emails und Email Sequenzen.
|
||||
|
||||
**Keywords:** Email Automation, Resend, React Email, Transactional Email, Email Templates, Nodemailer, SMTP
|
||||
|
||||
---
|
||||
|
||||
## Einführung
|
||||
|
||||
Email bleibt der wichtigste Kommunikationskanal. Mit **React Email** und **Resend** werden Email-Templates zu First-Class React Components – testbar, typsicher und versioniert.
|
||||
|
||||
---
|
||||
|
||||
## Email Stack 2026
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MODERN EMAIL STACK │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Template Layer: │
|
||||
│ ├── React Email (JSX → HTML) │
|
||||
│ ├── MJML Alternative │
|
||||
│ └── Tailwind Email Support │
|
||||
│ │
|
||||
│ Delivery Layer: │
|
||||
│ ├── Resend (Developer-First) │
|
||||
│ ├── SendGrid │
|
||||
│ ├── Postmark │
|
||||
│ └── AWS SES │
|
||||
│ │
|
||||
│ Features: │
|
||||
│ ├── Transactional (Welcome, Reset, etc.) │
|
||||
│ ├── Marketing (Newsletters) │
|
||||
│ ├── Sequences (Drip Campaigns) │
|
||||
│ └── Webhooks (Delivery Status) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React Email Setup
|
||||
|
||||
```bash
|
||||
npm install @react-email/components react-email resend
|
||||
```
|
||||
|
||||
```typescript
|
||||
// emails/welcome.tsx
|
||||
import {
|
||||
Html,
|
||||
Head,
|
||||
Preview,
|
||||
Body,
|
||||
Container,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
Img,
|
||||
Hr,
|
||||
Link
|
||||
} from '@react-email/components';
|
||||
|
||||
interface WelcomeEmailProps {
|
||||
userName: string;
|
||||
verificationUrl: string;
|
||||
}
|
||||
|
||||
export default function WelcomeEmail({
|
||||
userName,
|
||||
verificationUrl
|
||||
}: WelcomeEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Willkommen bei unserer Plattform, {userName}!</Preview>
|
||||
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Img
|
||||
src="https://example.com/logo.png"
|
||||
width="120"
|
||||
height="40"
|
||||
alt="Logo"
|
||||
style={logo}
|
||||
/>
|
||||
|
||||
<Section style={content}>
|
||||
<Text style={heading}>Willkommen, {userName}! 👋</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
Vielen Dank für Ihre Registrierung. Bitte bestätigen Sie Ihre
|
||||
E-Mail-Adresse, um alle Funktionen nutzen zu können.
|
||||
</Text>
|
||||
|
||||
<Button style={button} href={verificationUrl}>
|
||||
E-Mail bestätigen
|
||||
</Button>
|
||||
|
||||
<Text style={paragraph}>
|
||||
Oder kopieren Sie diesen Link in Ihren Browser:
|
||||
</Text>
|
||||
|
||||
<Text style={link}>{verificationUrl}</Text>
|
||||
</Section>
|
||||
|
||||
<Hr style={hr} />
|
||||
|
||||
<Section style={footer}>
|
||||
<Text style={footerText}>
|
||||
© 2026 Unser Unternehmen. Alle Rechte vorbehalten.
|
||||
</Text>
|
||||
<Link href="https://example.com/unsubscribe" style={footerLink}>
|
||||
Abmelden
|
||||
</Link>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
// Styles
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '20px 0',
|
||||
maxWidth: '600px'
|
||||
};
|
||||
|
||||
const logo = {
|
||||
margin: '0 auto 20px',
|
||||
display: 'block'
|
||||
};
|
||||
|
||||
const content = {
|
||||
padding: '0 40px'
|
||||
};
|
||||
|
||||
const heading = {
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: '20px'
|
||||
};
|
||||
|
||||
const paragraph = {
|
||||
fontSize: '16px',
|
||||
lineHeight: '26px',
|
||||
color: '#484848'
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#5046e5',
|
||||
borderRadius: '8px',
|
||||
color: '#ffffff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'block',
|
||||
padding: '14px 24px',
|
||||
margin: '20px 0'
|
||||
};
|
||||
|
||||
const link = {
|
||||
color: '#5046e5',
|
||||
fontSize: '14px',
|
||||
wordBreak: 'break-all' as const
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e6ebf1',
|
||||
margin: '20px 0'
|
||||
};
|
||||
|
||||
const footer = {
|
||||
padding: '0 40px'
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: '12px',
|
||||
color: '#9ca299'
|
||||
};
|
||||
|
||||
const footerLink = {
|
||||
color: '#9ca299',
|
||||
fontSize: '12px'
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resend Integration
|
||||
|
||||
```typescript
|
||||
// lib/email.ts
|
||||
import { Resend } from 'resend';
|
||||
import WelcomeEmail from '@/emails/welcome';
|
||||
import PasswordResetEmail from '@/emails/password-reset';
|
||||
import OrderConfirmationEmail from '@/emails/order-confirmation';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
// Single Email senden
|
||||
export async function sendWelcomeEmail(
|
||||
to: string,
|
||||
userName: string,
|
||||
verificationUrl: string
|
||||
) {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: 'Unser Team <noreply@example.com>',
|
||||
to,
|
||||
subject: `Willkommen, ${userName}!`,
|
||||
react: WelcomeEmail({ userName, verificationUrl })
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Email send error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Password Reset
|
||||
export async function sendPasswordResetEmail(
|
||||
to: string,
|
||||
resetUrl: string
|
||||
) {
|
||||
return resend.emails.send({
|
||||
from: 'Sicherheit <security@example.com>',
|
||||
to,
|
||||
subject: 'Passwort zurücksetzen',
|
||||
react: PasswordResetEmail({ resetUrl })
|
||||
});
|
||||
}
|
||||
|
||||
// Order Confirmation mit Anhang
|
||||
export async function sendOrderConfirmation(
|
||||
to: string,
|
||||
order: Order,
|
||||
invoicePdf: Buffer
|
||||
) {
|
||||
return resend.emails.send({
|
||||
from: 'Shop <orders@example.com>',
|
||||
to,
|
||||
subject: `Bestellbestätigung #${order.id}`,
|
||||
react: OrderConfirmationEmail({ order }),
|
||||
attachments: [
|
||||
{
|
||||
filename: `Rechnung-${order.id}.pdf`,
|
||||
content: invoicePdf
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Batch Send
|
||||
export async function sendBulkEmails(
|
||||
recipients: Array<{ email: string; data: any }>
|
||||
) {
|
||||
const emails = recipients.map(r => ({
|
||||
from: 'Newsletter <news@example.com>',
|
||||
to: r.email,
|
||||
subject: 'Unser Newsletter',
|
||||
react: NewsletterEmail(r.data)
|
||||
}));
|
||||
|
||||
return resend.batch.send(emails);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email Sequenzen (Drip Campaigns)
|
||||
|
||||
```typescript
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
|
||||
const connection = new IORedis(process.env.REDIS_URL!);
|
||||
const emailQueue = new Queue('email-sequences', { connection });
|
||||
|
||||
// Sequence Definition
|
||||
interface EmailSequence {
|
||||
id: string;
|
||||
name: string;
|
||||
steps: Array<{
|
||||
delay: number; // Millisekunden
|
||||
template: string;
|
||||
subject: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const onboardingSequence: EmailSequence = {
|
||||
id: 'onboarding',
|
||||
name: 'Onboarding Sequence',
|
||||
steps: [
|
||||
{
|
||||
delay: 0,
|
||||
template: 'welcome',
|
||||
subject: 'Willkommen bei uns!'
|
||||
},
|
||||
{
|
||||
delay: 24 * 60 * 60 * 1000, // 1 Tag
|
||||
template: 'getting-started',
|
||||
subject: 'Erste Schritte'
|
||||
},
|
||||
{
|
||||
delay: 3 * 24 * 60 * 60 * 1000, // 3 Tage
|
||||
template: 'tips',
|
||||
subject: '5 Tipps für den Einstieg'
|
||||
},
|
||||
{
|
||||
delay: 7 * 24 * 60 * 60 * 1000, // 7 Tage
|
||||
template: 'feedback',
|
||||
subject: 'Wie gefällt es Ihnen?'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Sequence starten
|
||||
async function startEmailSequence(
|
||||
userId: string,
|
||||
email: string,
|
||||
sequenceId: string,
|
||||
userData: Record<string, any>
|
||||
) {
|
||||
const sequence = getSequenceById(sequenceId);
|
||||
if (!sequence) throw new Error(`Sequence ${sequenceId} not found`);
|
||||
|
||||
// Sequence State speichern
|
||||
await db.emailSequenceState.create({
|
||||
data: {
|
||||
userId,
|
||||
sequenceId,
|
||||
currentStep: 0,
|
||||
status: 'active'
|
||||
}
|
||||
});
|
||||
|
||||
// Jobs für jeden Step einplanen
|
||||
for (let i = 0; i < sequence.steps.length; i++) {
|
||||
const step = sequence.steps[i];
|
||||
|
||||
await emailQueue.add(
|
||||
'send-sequence-email',
|
||||
{
|
||||
userId,
|
||||
email,
|
||||
sequenceId,
|
||||
stepIndex: i,
|
||||
template: step.template,
|
||||
subject: step.subject,
|
||||
userData
|
||||
},
|
||||
{
|
||||
delay: step.delay,
|
||||
jobId: `${userId}-${sequenceId}-${i}`
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Worker für Sequence Emails
|
||||
const worker = new Worker('email-sequences', async (job) => {
|
||||
const { userId, email, sequenceId, stepIndex, template, subject, userData } = job.data;
|
||||
|
||||
// Prüfen ob Sequence noch aktiv
|
||||
const state = await db.emailSequenceState.findFirst({
|
||||
where: { userId, sequenceId }
|
||||
});
|
||||
|
||||
if (state?.status !== 'active') {
|
||||
console.log(`Sequence ${sequenceId} for user ${userId} is not active, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Email senden
|
||||
const EmailComponent = getEmailTemplate(template);
|
||||
|
||||
await resend.emails.send({
|
||||
from: 'Unser Team <team@example.com>',
|
||||
to: email,
|
||||
subject,
|
||||
react: EmailComponent(userData)
|
||||
});
|
||||
|
||||
// State updaten
|
||||
await db.emailSequenceState.update({
|
||||
where: { id: state.id },
|
||||
data: { currentStep: stepIndex + 1 }
|
||||
});
|
||||
|
||||
console.log(`Sent sequence email: ${template} to ${email}`);
|
||||
}, { connection });
|
||||
|
||||
// Sequence abbrechen
|
||||
async function cancelEmailSequence(userId: string, sequenceId: string) {
|
||||
// State updaten
|
||||
await db.emailSequenceState.updateMany({
|
||||
where: { userId, sequenceId },
|
||||
data: { status: 'cancelled' }
|
||||
});
|
||||
|
||||
// Pending Jobs entfernen
|
||||
const jobs = await emailQueue.getJobs(['delayed', 'waiting']);
|
||||
for (const job of jobs) {
|
||||
if (job.data.userId === userId && job.data.sequenceId === sequenceId) {
|
||||
await job.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email Preview & Testing
|
||||
|
||||
```typescript
|
||||
// email-preview.ts (Development)
|
||||
import { render } from '@react-email/render';
|
||||
import WelcomeEmail from '@/emails/welcome';
|
||||
|
||||
// HTML Preview generieren
|
||||
const html = render(WelcomeEmail({
|
||||
userName: 'Max Mustermann',
|
||||
verificationUrl: 'https://example.com/verify?token=abc123'
|
||||
}));
|
||||
|
||||
console.log(html);
|
||||
|
||||
// React Email Dev Server
|
||||
// package.json
|
||||
{
|
||||
"scripts": {
|
||||
"email:dev": "email dev --port 3001"
|
||||
}
|
||||
}
|
||||
|
||||
// Dann: npm run email:dev
|
||||
// Browser: http://localhost:3001
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Email Testing mit Jest
|
||||
import { render } from '@react-email/render';
|
||||
import WelcomeEmail from '@/emails/welcome';
|
||||
|
||||
describe('WelcomeEmail', () => {
|
||||
it('renders with correct user name', () => {
|
||||
const html = render(WelcomeEmail({
|
||||
userName: 'Test User',
|
||||
verificationUrl: 'https://example.com/verify'
|
||||
}));
|
||||
|
||||
expect(html).toContain('Test User');
|
||||
expect(html).toContain('Willkommen');
|
||||
});
|
||||
|
||||
it('includes verification link', () => {
|
||||
const html = render(WelcomeEmail({
|
||||
userName: 'Test User',
|
||||
verificationUrl: 'https://example.com/verify?token=123'
|
||||
}));
|
||||
|
||||
expect(html).toContain('https://example.com/verify?token=123');
|
||||
});
|
||||
|
||||
it('has valid HTML structure', () => {
|
||||
const html = render(WelcomeEmail({
|
||||
userName: 'Test User',
|
||||
verificationUrl: 'https://example.com/verify'
|
||||
}));
|
||||
|
||||
expect(html).toMatch(/<html/);
|
||||
expect(html).toMatch(/<\/html>/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Handling (Delivery Status)
|
||||
|
||||
```typescript
|
||||
// app/api/webhooks/resend/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Webhook } from 'svix';
|
||||
|
||||
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET!;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = await request.text();
|
||||
const headers = {
|
||||
'svix-id': request.headers.get('svix-id')!,
|
||||
'svix-timestamp': request.headers.get('svix-timestamp')!,
|
||||
'svix-signature': request.headers.get('svix-signature')!
|
||||
};
|
||||
|
||||
const wh = new Webhook(webhookSecret);
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = wh.verify(payload, headers);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Event Type handling
|
||||
switch (event.type) {
|
||||
case 'email.sent':
|
||||
await handleEmailSent(event.data);
|
||||
break;
|
||||
|
||||
case 'email.delivered':
|
||||
await handleEmailDelivered(event.data);
|
||||
break;
|
||||
|
||||
case 'email.opened':
|
||||
await handleEmailOpened(event.data);
|
||||
break;
|
||||
|
||||
case 'email.clicked':
|
||||
await handleEmailClicked(event.data);
|
||||
break;
|
||||
|
||||
case 'email.bounced':
|
||||
await handleEmailBounced(event.data);
|
||||
break;
|
||||
|
||||
case 'email.complained':
|
||||
await handleEmailComplained(event.data);
|
||||
break;
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
|
||||
async function handleEmailBounced(data: any) {
|
||||
// Email-Adresse als ungültig markieren
|
||||
await db.user.update({
|
||||
where: { email: data.to },
|
||||
data: { emailStatus: 'bounced' }
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEmailComplained(data: any) {
|
||||
// Spam-Beschwerde: User aus Listen entfernen
|
||||
await db.user.update({
|
||||
where: { email: data.to },
|
||||
data: { marketingOptIn: false }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Moderne Email Automation braucht:
|
||||
|
||||
1. **React Email**: Component-basierte Templates
|
||||
2. **Resend**: Developer-First API
|
||||
3. **Sequences**: Automatisierte Drip Campaigns
|
||||
4. **Webhooks**: Delivery Tracking
|
||||
|
||||
Email als Code – testbar, typsicher, versioniert.
|
||||
|
||||
---
|
||||
|
||||
## Bildprompts
|
||||
|
||||
1. "Email template components assembling like Lego blocks, React Email concept"
|
||||
2. "Email sequence timeline with automated sends, drip campaign visualization"
|
||||
3. "Delivery status tracking with checkmarks, email analytics dashboard"
|
||||
|
||||
---
|
||||
|
||||
## Quellen
|
||||
|
||||
- [React Email Documentation](https://react.email/)
|
||||
- [Resend Documentation](https://resend.com/docs)
|
||||
- [Email Accessibility Guidelines](https://www.emailonacid.com/blog/article/email-development/email-accessibilty-in-2019/)
|
||||
- [Can I Email](https://www.caniemail.com/)
|
||||
Reference in New Issue
Block a user