← back to Norma
app/api/member-email/route.ts
214 lines
/**
* POST /api/member-email
* AI-powered member email generator.
* Generates subject line, HTML body, and CTA based on email type, topic, key points, and tone.
*
* Request body:
* {
* emailType: 'breaking-news' | 'issue-update' | 'education-event',
* topic: string,
* keyPoints: string,
* tone: 'urgent' | 'informative' | 'inspirational' | 'call-to-action',
* orgName?: string,
* orgMission?: string,
* }
*
* Response:
* { subject: string, bodyHtml: string }
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/require-role';
import { checkRateLimit } from '@/lib/rate-limit';
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent`;
const EMAIL_TYPE_LABELS: Record<string, string> = {
'breaking-news': 'Breaking News Alert',
'issue-update': 'Issue Update',
'education-event': 'Education Event Invitation',
};
const TONE_INSTRUCTIONS: Record<string, string> = {
urgent: 'Use an urgent, time-sensitive tone. Convey that immediate action or attention is needed. Short, punchy sentences.',
informative: 'Use a clear, informative tone. Focus on facts and context. Be thorough but concise.',
inspirational: 'Use an uplifting, inspirational tone. Emphasize hope, progress, and the power of collective action.',
'call-to-action': 'Use a strong call-to-action tone. Every paragraph should build toward getting the reader to take a specific action.',
};
const TYPE_CTA_HINTS: Record<string, string> = {
'breaking-news': 'Include a CTA to stay informed, share the news, or take immediate protective/responsive action.',
'issue-update': 'Include a CTA to continue supporting the campaign, share milestones, or join the next phase of advocacy.',
'education-event': 'Include a CTA to register for the event, add it to their calendar, and share with peers.',
};
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
// Rate limit: 8 email generations per minute
const rl = checkRateLimit(request, 8, 60_000, 'member-email');
if (rl.limited) {
return NextResponse.json(
{ error: 'Too many generation requests. Please wait a moment.' },
{ status: 429 },
);
}
try {
const body = await request.json();
const { emailType, topic, keyPoints, tone, orgName, orgMission } = body;
if (!emailType || !topic) {
return NextResponse.json(
{ error: 'emailType and topic are required' },
{ status: 400 },
);
}
const typeLabel = EMAIL_TYPE_LABELS[emailType] || emailType;
const toneInstruction = TONE_INSTRUCTIONS[tone] || TONE_INSTRUCTIONS['informative'];
const ctaHint = TYPE_CTA_HINTS[emailType] || '';
const prompt = `You are an expert nonprofit communications director. Generate a member email for a nonprofit organization.
EMAIL TYPE: ${typeLabel}
TOPIC: ${topic}
${keyPoints ? `KEY POINTS TO INCLUDE:\n${keyPoints}` : ''}
${orgName ? `ORGANIZATION: ${orgName}` : ''}
${orgMission ? `MISSION: ${orgMission}` : ''}
TONE: ${toneInstruction}
${ctaHint}
INSTRUCTIONS:
1. Generate a compelling subject line (max 80 characters)
2. Generate the email body in clean HTML format
3. The body should have:
- An engaging opening paragraph
- 2-3 content paragraphs incorporating the key points
- A clearly marked Call-to-Action section with a button-style link placeholder
- A brief closing
4. Use semantic HTML: <h2>, <p>, <strong>, <ul>/<li> where appropriate
5. Do NOT include <html>, <head>, <body>, or <style> tags — just the inner content
6. Do NOT include email headers (From, To, Date) — just subject and body
RESPOND IN EXACTLY THIS FORMAT (no markdown code fences):
SUBJECT: [your subject line here]
---BODY---
[your HTML body here]`;
// Try Gemini AI first
if (GEMINI_API_KEY) {
try {
const res = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
}),
});
if (res.ok) {
const data = await res.json();
const rawText = data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
// Parse the structured response
const subjectMatch = rawText.match(/SUBJECT:\s*(.+?)(?:\n|---)/);
const bodyMatch = rawText.split('---BODY---');
if (subjectMatch && bodyMatch.length > 1) {
const subject = subjectMatch[1].trim();
let bodyHtml = bodyMatch[1].trim();
// Clean any residual markdown code fences
bodyHtml = bodyHtml.replace(/^```html?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
return NextResponse.json({ subject, bodyHtml });
}
// Fallback: try to extract something usable
const lines = rawText.split('\n');
const subject = lines[0]?.replace(/^(SUBJECT:|Subject:)\s*/i, '').trim() || `${typeLabel}: ${topic}`;
const bodyHtml = lines.slice(1).join('\n').replace(/---BODY---/g, '').trim();
return NextResponse.json({ subject, bodyHtml: bodyHtml || generateFallbackHtml(typeLabel, topic, keyPoints, tone) });
}
} catch (aiErr) {
console.error('[member-email] Gemini error, falling back to template:', (aiErr as Error).message);
}
}
// Fallback: template-based generation
const subject = generateFallbackSubject(emailType, topic);
const bodyHtml = generateFallbackHtml(typeLabel, topic, keyPoints, tone);
return NextResponse.json({ subject, bodyHtml });
} catch (err) {
console.error('[member-email] Error:', (err as Error).message);
return NextResponse.json(
{ error: 'Email generation failed' },
{ status: 500 },
);
}
}
/* ── Fallback template generators ──────────────────────────────────────── */
function generateFallbackSubject(emailType: string, topic: string): string {
const prefix: Record<string, string> = {
'breaking-news': 'BREAKING',
'issue-update': 'Update',
'education-event': 'You\'re Invited',
};
return `${prefix[emailType] || 'Important'}: ${topic}`;
}
function generateFallbackHtml(typeLabel: string, topic: string, keyPoints: string, tone: string): string {
const points = keyPoints
? keyPoints.split('\n').filter(Boolean).map(p => `<li>${p.replace(/^[-*]\s*/, '')}</li>`).join('\n ')
: '';
const toneOpener: Record<string, string> = {
urgent: 'This requires your immediate attention.',
informative: 'We want to keep you informed about an important development.',
inspirational: 'Together, we are making a real difference.',
'call-to-action': 'Your voice matters now more than ever.',
};
const toneCTA: Record<string, string> = {
urgent: 'Act Now',
informative: 'Learn More',
inspirational: 'Join the Movement',
'call-to-action': 'Take Action Today',
};
return `<h2 style="color: #10b981; margin: 0 0 16px;">${typeLabel}</h2>
<p style="font-size: 16px; line-height: 1.6; color: #e0e0e8;">
${toneOpener[tone] || toneOpener['informative']}
</p>
<p style="font-size: 16px; line-height: 1.6; color: #e0e0e8;">
<strong>${topic}</strong> — This is a critical moment for our community and the issues we care about.
We are reaching out to ensure you have the latest information and know how you can make a difference.
</p>
${points ? `<h3 style="color: #f0f0f5; margin: 20px 0 12px;">Key Points</h3>
<ul style="color: #c0c0d0; line-height: 1.8; padding-left: 20px;">
${points}
</ul>` : ''}
<div style="margin: 28px 0; text-align: center;">
<a href="#" style="display: inline-block; padding: 14px 32px; background-color: #10b981; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 700; font-size: 16px;">
${toneCTA[tone] || 'Take Action'}
</a>
</div>
<p style="font-size: 14px; line-height: 1.6; color: #a1a1b5;">
Thank you for being part of our community. Together, we can create meaningful change.
</p>`;
}