All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
247 lines
9.7 KiB
TypeScript
247 lines
9.7 KiB
TypeScript
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<string, string> = {
|
|
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<string, unknown>;
|
|
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, '>')
|
|
.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
|
|
? `<tr><td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee;width:40%">Adresse des travaux</td><td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee">${escapeHtml(data.workAddress)}</td></tr>`
|
|
: '';
|
|
const attachmentsSection =
|
|
attachmentNames.length > 0
|
|
? `<div style="padding:16px 20px;background:#fafafa;border-top:1px solid #eee">
|
|
<p style="margin:0;font-size:13px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:0.05em">Fichiers joints</p>
|
|
<ul style="margin:8px 0 0;padding-left:20px;font-size:14px;color:#374151">${attachmentNames.map((n) => `<li>${escapeHtml(n)}</li>`).join('')}</ul>
|
|
</div>`
|
|
: '';
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
|
<body style="margin:0;padding:0;background:#f5f5f5;font-family:system-ui,sans-serif">
|
|
<div style="max-width:600px;margin:0 auto;padding:40px 20px">
|
|
<div style="background:#314732;padding:32px;border-radius:8px 8px 0 0;text-align:center">
|
|
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600">Nouvelle demande de soumission</h1>
|
|
<p style="margin:8px 0 0;color:rgba(255,255,255,0.7);font-size:14px">Cachet Peintres Décorateurs</p>
|
|
</div>
|
|
<div style="background:#ffffff;border-radius:0 0 8px 8px;overflow:hidden">
|
|
<div style="padding:24px;background:#f0f4ef;border-bottom:2px solid #d6e2d4">
|
|
<p style="margin:0;font-size:15px;color:#314732;font-weight:600">
|
|
Vous avez reçu une nouvelle demande de soumission de la part de <strong>${escapeHtml(data.fullName)}</strong>.
|
|
</p>
|
|
</div>
|
|
<table style="width:100%;border-collapse:collapse">
|
|
<tbody>
|
|
<tr>
|
|
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee;width:40%">Nom</td>
|
|
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee;font-weight:600">${escapeHtml(data.fullName)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Courriel</td>
|
|
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee"><a href="mailto:${data.email}" style="color:#314732;font-weight:600">${escapeHtml(data.email)}</a></td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Téléphone</td>
|
|
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee"><a href="tel:${data.phone.replace(/\D/g, '')}" style="color:#314732;font-weight:600">${escapeHtml(data.phone)}</a></td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Type de service</td>
|
|
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee">${serviceLabel}</td>
|
|
</tr>
|
|
${workAddressRow}
|
|
</tbody>
|
|
</table>
|
|
<div style="padding:20px">
|
|
<p style="margin:0 0 8px;font-size:13px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:0.05em">Message</p>
|
|
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:16px;font-size:14px;color:#374151;line-height:1.6;white-space:pre-wrap">${escapeHtml(data.message)}</div>
|
|
</div>
|
|
${attachmentsSection}
|
|
<div style="padding:16px 20px 24px">
|
|
<a href="mailto:${data.email}?subject=Re: Soumission — Cachet Peintres Décorateurs" style="display:inline-block;padding:10px 24px;background:#314732;color:#fff;text-decoration:none;border-radius:4px;font-size:14px;font-weight:700">Répondre à ${escapeHtml(data.fullName)}</a>
|
|
</div>
|
|
</div>
|
|
<p style="margin:24px 0 0;text-align:center;font-size:11px;color:#999">
|
|
Cachet Peintres Décorateurs · RBQ 5839 8736 01
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
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' },
|
|
});
|
|
};
|