← back to Norma
app/api/email-analyzer/rewrite/route.ts
200 lines
/**
* POST /api/email-analyzer/rewrite
* AI-powered email rewriting via Gemini 2.0 Flash.
* Accepts the original email, current version, chat instruction, and optional tone/paragraph overrides.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/require-role';
import { checkRateLimit } from '@/lib/rate-limit';
import { query } from '@/lib/db';
/**
* Pull active Universal References and format them for the prompt.
* These behave like "Claude memory for the email-analyzer modality" — they
* ride along on every rewrite without per-session setup.
*/
async function buildUniversalRefBlock(): Promise<string> {
try {
const { rows } = await query(
`SELECT title, content, tone, tags
FROM email_analyzer_universal_refs
WHERE is_active = TRUE
ORDER BY created_at DESC
LIMIT 25`,
);
if (rows.length === 0) return '';
const sections = rows.map((r) => {
const tagLine = Array.isArray(r.tags) && r.tags.length ? ` [${r.tags.join(', ')}]` : '';
const toneLine = r.tone ? ` (${r.tone})` : '';
return `### ${r.title}${toneLine}${tagLine}\n${String(r.content).slice(0, 1200)}`;
});
return ['## Universal Email References (always-on)', ...sections].join('\n\n');
} catch (err) {
console.error('[rewrite] universal-refs fetch failed:', (err as Error).message);
return '';
}
}
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const rl = checkRateLimit(request, 10, 60_000);
if (rl.limited) {
return NextResponse.json(
{ error: 'Too many AI requests. Try again later.' },
{ status: 429 },
);
}
try {
const body = await request.json();
const {
originalEmail,
currentVersion,
instruction,
chatHistory = [],
tone,
paragraphCount,
referenceContext,
} = body;
if (!currentVersion || typeof currentVersion !== 'string') {
return NextResponse.json({ error: 'currentVersion is required' }, { status: 400 });
}
if (!instruction || typeof instruction !== 'string') {
return NextResponse.json({ error: 'instruction is required' }, { status: 400 });
}
if (!GEMINI_API_KEY) {
return NextResponse.json({ error: 'Gemini API key not configured' }, { status: 500 });
}
// Build conversation context from chat history (last 10 messages)
const recentHistory = chatHistory.slice(-10);
const historyContext = recentHistory.length > 0
? `\n\nPrevious conversation:\n${recentHistory.map((m: { role: string; content: string }) => `${m.role}: ${m.content}`).join('\n')}`
: '';
const toneInstruction = tone ? `\nApply a ${tone} tone throughout.` : '';
const paragraphInstruction = paragraphCount ? `\nTarget approximately ${paragraphCount} paragraphs.` : '';
// Universal refs always ride along — they're the "modality-level memory".
// Session refs (referenceContext) layer on top of them.
const universalBlock = await buildUniversalRefBlock();
const combinedRefs = [universalBlock, referenceContext].filter(Boolean).join('\n\n');
const referenceBlock = combinedRefs
? `\n\nREFERENCE MATERIALS (use these to enrich the email with relevant facts, data, quotes, and links):\n---\n${combinedRefs}\n---`
: '';
const prompt = `You are an expert email editor and rewriter for nonprofit organizations. You help refine email communications to be more effective.
ORIGINAL EMAIL (for reference):
---
${originalEmail || currentVersion}
---
CURRENT WORKING VERSION:
---
${currentVersion}
---
${historyContext}
USER INSTRUCTION: ${instruction}
${toneInstruction}${paragraphInstruction}${referenceBlock}
RULES:
- Maintain the core message and key information from the current version
- Apply the requested changes precisely
- Preserve any names, dates, URLs, and specific facts from the original
- Return clean HTML suitable for email (use <p>, <strong>, <em>, <ul>, <li>, <a> tags)
- Do NOT include <html>, <head>, <body>, or <style> tags
- Do NOT include email headers, subject lines, or signatures
- Make the writing natural and human-sounding
- Keep the email professional and appropriate for nonprofit communications
- If reference materials are provided, incorporate relevant facts, statistics, quotes, and links from them naturally into the email
- Cite sources from reference materials when appropriate (e.g., "According to [source]...")
Return ONLY the rewritten email body as HTML. Do not include any explanation or commentary.`;
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 errText = await res.text();
console.error('[email-analyzer/rewrite] Gemini error:', res.status, errText);
return NextResponse.json(
{ error: `AI rewrite failed (${res.status})` },
{ status: 502 },
);
}
const data = await res.json();
let rewrittenEmail = data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
// Strip code fences if present
rewrittenEmail = rewrittenEmail.replace(/^```html?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
// Count paragraphs and words
const pCount = (rewrittenEmail.match(/<p[\s>]/gi) || []).length || rewrittenEmail.split(/\n\n+/).length;
const plainText = rewrittenEmail.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const wCount = plainText.split(/\s+/).filter(Boolean).length;
// Detect tone from content
const detectedTone = tone || 'professional';
// Generate a brief summary of changes
const summaryPrompt = `In 8 words or fewer, describe what changed between these two email versions:
BEFORE:
${currentVersion.substring(0, 500)}
AFTER:
${rewrittenEmail.substring(0, 500)}
USER ASKED FOR: ${instruction}
Return ONLY the brief description, nothing else.`;
let summary = instruction.length <= 50 ? instruction : instruction.substring(0, 50) + '...';
try {
const summaryRes = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: summaryPrompt }] }],
generationConfig: { temperature: 0.3, maxOutputTokens: 50 },
}),
});
if (summaryRes.ok) {
const sData = await summaryRes.json();
const sText = sData?.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
if (sText && sText.length < 80) summary = sText;
}
} catch {
// Keep the fallback summary
}
return NextResponse.json({
rewrittenEmail,
summary,
tone: detectedTone,
paragraphCount: pCount,
wordCount: wCount,
});
} catch (err) {
console.error('[email-analyzer/rewrite] error:', (err as Error).message);
return NextResponse.json({ error: 'Email rewrite failed' }, { status: 500 });
}
}