espace-paie-odentas/app/api/staff/bulk-email-stream/route.ts
2025-10-12 17:05:46 +02:00

348 lines
No EOL
12 KiB
TypeScript

// app/api/staff/bulk-email-stream/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
import { emailLogger, EmailType } from "@/lib/emailLoggingService";
interface Recipient {
id: string;
email: string;
organizations: Array<{
id: string;
name: string;
role: string;
structure_api: string | null;
}>;
}
interface BulkEmailRequest {
subject: string;
htmlContent: string;
recipients: Recipient[];
}
export async function POST(request: NextRequest) {
try {
const sb = createSbServer();
// Vérifier que l'utilisateur est authentifié et a le rôle staff
const { data: { user }, error: authError } = await sb.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "Non authentifié" },
{ status: 401 }
);
}
// Vérifier les permissions staff
const { data: staffMember, error: staffError } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (staffError || !staffMember?.is_staff) {
return NextResponse.json(
{ error: "Accès refusé - permissions insuffisantes" },
{ status: 403 }
);
}
const body: BulkEmailRequest = await request.json();
const { subject, htmlContent, recipients } = body;
// Validation des données
if (!subject?.trim() || !htmlContent?.trim() || !recipients?.length) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
if (recipients.length > 100) {
return NextResponse.json(
{ error: "Maximum 100 destinataires par envoi" },
{ status: 400 }
);
}
// Configuration SES
const region = process.env.AWS_REGION || "eu-west-3";
const fromEmail = process.env.AWS_SES_FROM;
if (!fromEmail) {
return NextResponse.json(
{ error: "Configuration SES manquante" },
{ status: 500 }
);
}
// Configuration du streaming response
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const ses = new SESClient({ region });
// Créer le contenu texte simple à partir du HTML
const textContent = htmlContent
.replace(/<[^>]*>/g, '') // Supprimer les balises HTML
.replace(/\s+/g, ' ') // Normaliser les espaces
.trim();
let sentCount = 0;
const failedEmails: Array<{email: string, error: string}> = [];
// Fonction pour envoyer un update
const sendUpdate = (data: any) => {
const chunk = encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
controller.enqueue(chunk);
};
// Envoyer l'état initial
sendUpdate({
type: 'init',
total: recipients.length,
completed: 0,
success: 0,
errors: 0
});
try {
// Configuration optimisée pour limite SES de 14 emails/seconde
// Stratégie : 12 emails par lot avec pause de 1 seconde
// Calcul : 12 emails/sec < 14 emails/sec (marge de sécurité de 2 emails/sec)
// Performance résultante : ~720 emails/minute
const batchSize = 12;
const batchDelayMs = 1000; // 1 seconde entre les lots
for (let i = 0; i < recipients.length; i += batchSize) {
const batch = recipients.slice(i, i + batchSize);
const promises = batch.map(async (recipient, batchIndex) => {
const globalIndex = i + batchIndex;
let logId: string | null = null;
try {
// Validation de l'adresse email
if (!recipient.email || !recipient.email.includes('@') || !recipient.email.includes('.')) {
throw new Error('Adresse email invalide');
}
// Notifier le début de l'envoi
sendUpdate({
type: 'progress',
recipientId: recipient.id,
email: recipient.email,
status: 'sending',
index: globalIndex
});
// Personnaliser le contenu si nécessaire
let personalizedHtml = htmlContent;
let personalizedSubject = subject;
// Créer le log d'email AVANT l'envoi
logId = await emailLogger.logEmail({
senderEmail: fromEmail || 'paie@odentas.fr',
recipientEmail: recipient.email,
subject: personalizedSubject,
htmlContent: personalizedHtml,
textContent: textContent,
emailType: 'bulk_communication' as EmailType,
templateName: 'bulk-email-stream',
templateData: {
recipients_count: recipients.length,
batch_index: Math.floor(globalIndex / batchSize),
global_index: globalIndex
},
emailStatus: 'sending',
organizationId: recipient.organizations?.[0]?.id,
tags: {
email_system: 'bulk_stream',
batch_size: batchSize.toString(),
total_recipients: recipients.length.toString()
},
context: {
recipient_id: recipient.id,
recipient_orgs: recipient.organizations?.map(org => org.name).join(', ') || 'No organizations',
subject_preview: personalizedSubject.substring(0, 100)
}
});
// Formater l'adresse email source correctement
let sourceEmail = fromEmail;
if (fromEmail && !fromEmail.includes('<')) {
// Si fromEmail ne contient pas déjà un format "Nom <email>", on l'ajoute
sourceEmail = `Espace Paie Odentas <${fromEmail}>`;
} else {
// Si fromEmail contient déjà un format, on l'utilise tel quel
sourceEmail = fromEmail;
}
const cmd = new SendEmailCommand({
Destination: {
ToAddresses: [recipient.email]
},
Source: sourceEmail,
Message: {
Subject: {
Data: personalizedSubject,
Charset: "UTF-8"
},
Body: {
Html: {
Data: personalizedHtml,
Charset: "UTF-8"
},
Text: {
Data: textContent,
Charset: "UTF-8"
}
}
}
});
const response = await ses.send(cmd);
const messageId = (response as any)?.MessageId || '';
sentCount++;
// Mettre à jour le log avec le succès
if (logId) {
await emailLogger.updateEmailLog(logId, {
emailStatus: 'sent',
sesMessageId: messageId,
sentAt: new Date()
});
}
// Notifier le succès
sendUpdate({
type: 'progress',
recipientId: recipient.id,
email: recipient.email,
status: 'success',
index: globalIndex,
sentAt: new Date().toISOString(),
messageId: messageId
});
console.log(`Email envoyé avec succès à ${recipient.email} - MessageId: ${messageId}`);
} catch (error: any) {
let errorMessage = 'Erreur SES inconnue';
// Améliorer les messages d'erreur
if (error.message) {
if (error.message.includes('Missing')) {
errorMessage = 'Format email source invalide';
} else if (error.message.includes('InvalidParameterValue')) {
errorMessage = 'Adresse email invalide';
} else if (error.message.includes('MessageRejected')) {
errorMessage = 'Email rejeté par SES';
} else if (error.message.includes('Daily sending quota exceeded')) {
errorMessage = 'Quota journalier dépassé';
} else if (error.message.includes('Sending rate exceeded')) {
errorMessage = 'Limite de débit dépassée';
} else {
errorMessage = error.message;
}
}
// Mettre à jour le log avec l'erreur
if (logId) {
await emailLogger.updateEmailLog(logId, {
emailStatus: 'failed',
failureReason: errorMessage
});
}
failedEmails.push({ email: recipient.email, error: errorMessage });
// Notifier l'erreur
sendUpdate({
type: 'progress',
recipientId: recipient.id,
email: recipient.email,
status: 'error',
index: globalIndex,
error: errorMessage
});
console.error(`Erreur envoi email à ${recipient.email}:`, error);
}
});
await Promise.all(promises);
// Envoyer une mise à jour globale
sendUpdate({
type: 'batch_complete',
completed: Math.min(i + batchSize, recipients.length),
success: sentCount,
errors: failedEmails.length,
total: recipients.length
});
// Pause entre les lots pour respecter les limites SES (14 emails/seconde max)
if (i + batchSize < recipients.length) {
await new Promise(resolve => setTimeout(resolve, batchDelayMs));
}
}
// Log de l'activité pour traçabilité
try {
await sb
.from("email_logs")
.insert({
user_id: user.id,
type: "bulk_email_stream",
subject: subject,
recipients_count: recipients.length,
sent_count: sentCount,
failed_count: failedEmails.length,
failed_emails: failedEmails.length > 0 ? failedEmails : null,
created_at: new Date().toISOString()
});
} catch (logError) {
console.error("Erreur lors de l'enregistrement du log:", logError);
}
// Envoyer le résultat final
sendUpdate({
type: 'complete',
total: recipients.length,
success: sentCount,
errors: failedEmails.length,
failedEmails: failedEmails,
message: `${sentCount} email(s) envoyé(s) avec succès${failedEmails.length > 0 ? `, ${failedEmails.length} échec(s)` : ''}`
});
} catch (error) {
console.error("Erreur dans le streaming d'emails:", error);
sendUpdate({
type: 'error',
error: 'Erreur interne du serveur'
});
} finally {
controller.close();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error) {
console.error("Erreur dans l'API bulk-email-stream:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}