← back to Norma

app/api/email-analyzer/subject-lines/generate/route.ts

102 lines

/**
 * POST /api/email-analyzer/subject-lines/generate
 *
 * Generates N candidate subject lines for a given email body, each with a
 * distinct style (curiosity, urgency, value-forward, personal, short).
 * Uses Gemini 2.0 Flash.
 */

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

export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const rl = checkRateLimit(request, 15, 60_000);
  if (rl.limited) {
    return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
  }

  if (!GEMINI_API_KEY) {
    return NextResponse.json({ error: 'Gemini API key not configured' }, { status: 500 });
  }

  try {
    const { emailBody, count = 8, audience, goal } = await request.json();
    if (!emailBody || typeof emailBody !== 'string') {
      return NextResponse.json({ error: 'emailBody is required' }, { status: 400 });
    }

    const plain = emailBody.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 4000);
    const audienceLine = audience ? `\nAudience: ${audience}` : '';
    const goalLine = goal ? `\nGoal: ${goal}` : '';

    const prompt = `You are an expert email marketer for nonprofit advocacy communications.

Generate ${count} distinct subject line options for the email below. Each option MUST use a different persuasion style from this list:
- curiosity (open loop, question)
- urgency (time-bound, deadline)
- value-forward (lead with benefit)
- personal (you / your)
- short-punchy (5 words or fewer)
- specific-number (a concrete stat or ask)
- emotional (empathy, hope, outrage as appropriate)
- news-angle (timely, newsworthy framing)

Constraints per subject: ≤ 65 characters; no ALL-CAPS; no "re:" prefixes; no clickbait or deception.${audienceLine}${goalLine}

EMAIL BODY:
---
${plain}
---

Return STRICT JSON with this exact shape (no markdown, no commentary):
{
  "subjects": [
    {"subject": "...", "style": "curiosity", "reasoning": "one sentence on why this works"},
    ...
  ]
}`;

    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.9, responseMimeType: 'application/json' },
      }),
    });

    if (!res.ok) {
      const errText = await res.text().catch(() => '');
      console.error('[subject-lines/generate] Gemini error:', res.status, errText.slice(0, 300));
      return NextResponse.json({ error: 'Generation failed' }, { status: 502 });
    }

    const data = await res.json();
    const text = data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
    let parsed: { subjects?: Array<{ subject: string; style: string; reasoning: string }> } = {};
    try {
      parsed = JSON.parse(text);
    } catch {
      // best-effort extraction from code fence
      const match = text.match(/\{[\s\S]*\}/);
      if (match) {
        try { parsed = JSON.parse(match[0]); } catch { /* keep empty */ }
      }
    }

    const subjects = Array.isArray(parsed.subjects) ? parsed.subjects.slice(0, count) : [];
    return NextResponse.json({ subjects });
  } catch (err) {
    console.error('[subject-lines/generate] error:', (err as Error).message);
    return NextResponse.json({ error: 'Generation failed' }, { status: 500 });
  }
}