import { Buffer } from 'node:buffer'; import type { APIRoute } from 'astro'; import type { EmailAttachment } from '@/lib/email'; import { sendEmail } from '@/lib/email'; import general from '@/content/settings/general.json'; export const prerender = false; const MAX_TOTAL_BYTES = 20 * 1024 * 1024; const ACCEPTED_MIME = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ]); interface SubmissionPayload { fullName: string; email: string; phone: string; workAddress?: string; serviceType: string; message: string; } const SERVICE_TYPE_LABELS: Record = { interior: 'Peinture intérieure', exterior: 'Peinture extérieure', commercial: 'Peinture commerciale', decoration: 'Décoration intérieure', other: 'Autre', }; function isValidPayload(body: unknown): body is SubmissionPayload { if (typeof body !== 'object' || body === null) return false; const b = body as Record; return ( typeof b.fullName === 'string' && b.fullName.trim().length > 0 && typeof b.email === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(b.email) && typeof b.phone === 'string' && b.phone.trim().length > 0 && typeof b.serviceType === 'string' && b.serviceType.length > 0 && typeof b.message === 'string' && b.message.trim().length > 0 ); } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function sanitizeFilename(name: string): string { const base = name.replace(/[/\\]/g, '_').trim() || 'file'; return base.length > 200 ? base.slice(0, 200) : base; } function isFileLike(v: unknown): v is File { return ( typeof v === 'object' && v !== null && 'arrayBuffer' in v && typeof (v as File).name === 'string' && typeof (v as File).size === 'number' && typeof (v as File).type === 'string' ); } function isAcceptedFile(file: File): boolean { if (ACCEPTED_MIME.has(file.type)) return true; if (file.type) return false; return /\.(jpe?g|png|gif|webp|svg|pdf|doc|docx)$/i.test(file.name); } function buildNotificationHtml(data: SubmissionPayload, attachmentNames: string[]): string { const serviceLabel = SERVICE_TYPE_LABELS[data.serviceType] ?? data.serviceType; const workAddressRow = data.workAddress ? `Adresse des travaux${escapeHtml(data.workAddress)}` : ''; const attachmentsSection = attachmentNames.length > 0 ? `

Fichiers joints

    ${attachmentNames.map((n) => `
  • ${escapeHtml(n)}
  • `).join('')}
` : ''; return `

Nouvelle demande de soumission

Cachet Peintres Décorateurs

Vous avez reçu une nouvelle demande de soumission de la part de ${escapeHtml(data.fullName)}.

${workAddressRow}
Nom ${escapeHtml(data.fullName)}
Courriel ${escapeHtml(data.email)}
Téléphone ${escapeHtml(data.phone)}
Type de service ${serviceLabel}

Message

${escapeHtml(data.message)}
${attachmentsSection}
Répondre à ${escapeHtml(data.fullName)}

Cachet Peintres Décorateurs · RBQ 5839 8736 01

`; } export const POST: APIRoute = async ({ request }) => { const contentType = request.headers.get('content-type') ?? ''; if (!contentType.includes('multipart/form-data')) { return new Response(JSON.stringify({ success: false, error: 'Invalid Content-Type.' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } let formData: FormData; try { formData = await request.formData(); } catch { return new Response(JSON.stringify({ success: false, error: 'Invalid request body.' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } const payload: SubmissionPayload = { fullName: String(formData.get('fullName') ?? '').trim(), email: String(formData.get('email') ?? '').trim(), phone: String(formData.get('phone') ?? '').trim(), workAddress: String(formData.get('workAddress') ?? '').trim() || undefined, serviceType: String(formData.get('serviceType') ?? '').trim(), message: String(formData.get('message') ?? '').trim(), }; if (!isValidPayload(payload)) { return new Response(JSON.stringify({ success: false, error: 'Missing or invalid fields.' }), { status: 422, headers: { 'Content-Type': 'application/json' }, }); } const rawFiles = formData.getAll('files'); const files: File[] = []; for (const item of rawFiles) { if (!isFileLike(item) || item.size === 0) continue; if (!isAcceptedFile(item)) { return new Response(JSON.stringify({ success: false, error: 'Unsupported file type.' }), { status: 422, headers: { 'Content-Type': 'application/json' }, }); } files.push(item); } let totalBytes = 0; const attachments: EmailAttachment[] = []; const attachmentNames: string[] = []; for (const file of files) { totalBytes += file.size; if (totalBytes > MAX_TOTAL_BYTES) { return new Response(JSON.stringify({ success: false, error: 'Total upload size exceeds 20 MB.' }), { status: 422, headers: { 'Content-Type': 'application/json' }, }); } const safeName = sanitizeFilename(file.name); attachmentNames.push(safeName); const buf = await file.arrayBuffer(); attachments.push({ filename: safeName, content: Buffer.from(buf).toString('base64'), }); } const apiKey = process.env.RESEND_API_KEY; const fromAddress = general.fromAddressSubmission?.trim(); const toAddress = general.toAddressSubmission?.trim(); if (!apiKey || !fromAddress || !toAddress) { return new Response(JSON.stringify({ success: false, error: 'Server configuration missing.' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } try { await sendEmail(apiKey, { from: fromAddress, to: toAddress, replyTo: payload.email, subject: `Soumission — ${SERVICE_TYPE_LABELS[payload.serviceType] ?? payload.serviceType} — ${payload.fullName}`, html: buildNotificationHtml(payload, attachmentNames), attachments: attachments.length ? attachments : undefined, }); } catch { return new Response(JSON.stringify({ success: false, error: 'Failed to send email.' }), { status: 502, headers: { 'Content-Type': 'application/json' }, }); } return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); };