348 lines
No EOL
12 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
} |