// Cloudflare Pages Function — /api/send-admin-mail // Verstuurt een mail naar de admin van een cluster met een Excel-bijlage van de openstaande (nog niet betaalde) wagens const SENDGRID_API_KEY = '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'; const FROM_EMAIL = 'atp@vanmosseltemplates.nl'; const FROM_NAME = 'Van Mossel – ATP (Dean Malezsewski)'; const REPLY_TO_EMAIL = 'dean.malezsewski@vanmossel.be'; const REPLY_TO_NAME = 'Dean Malezsewski'; function corsHeaders() { return { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Content-Type': 'application/json', }; } function esc(s) { if (!s) return ''; return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function fmtDate(d) { if (!d) return ''; const p = String(d).split('-'); return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : String(d); } // ===================== Minimale XLSX-generator (geen externe libraries) ===================== function crc32(buf) { const table = crc32.table || (crc32.table = (() => { const t = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })()); let crc = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8); return (crc ^ 0xFFFFFFFF) >>> 0; } function strToBytes(str) { return new TextEncoder().encode(str); } function makeZip(files) { const localParts = []; const centralParts = []; let offset = 0; for (const f of files) { const nameBytes = strToBytes(f.name); const data = f.data; const crc = crc32(data); const size = data.length; const localHeader = new Uint8Array(30 + nameBytes.length); const dv = new DataView(localHeader.buffer); dv.setUint32(0, 0x04034b50, true); dv.setUint16(4, 20, true); dv.setUint16(6, 0, true); dv.setUint16(8, 0, true); dv.setUint16(10, 0, true); dv.setUint16(12, 0, true); dv.setUint32(14, crc, true); dv.setUint32(18, size, true); dv.setUint32(22, size, true); dv.setUint16(26, nameBytes.length, true); dv.setUint16(28, 0, true); localHeader.set(nameBytes, 30); localParts.push(localHeader, data); const centralHeader = new Uint8Array(46 + nameBytes.length); const cdv = new DataView(centralHeader.buffer); cdv.setUint32(0, 0x02014b50, true); cdv.setUint16(4, 20, true); cdv.setUint16(6, 20, true); cdv.setUint16(8, 0, true); cdv.setUint16(10, 0, true); cdv.setUint16(12, 0, true); cdv.setUint16(14, 0, true); cdv.setUint32(16, crc, true); cdv.setUint32(20, size, true); cdv.setUint32(24, size, true); cdv.setUint16(28, nameBytes.length, true); cdv.setUint16(30, 0, true); cdv.setUint16(32, 0, true); cdv.setUint16(34, 0, true); cdv.setUint16(36, 0, true); cdv.setUint32(38, 0, true); cdv.setUint32(42, offset, true); centralHeader.set(nameBytes, 46); centralParts.push(centralHeader); offset += localHeader.length + data.length; } const centralSize = centralParts.reduce((s, p) => s + p.length, 0); const centralOffset = offset; const eocd = new Uint8Array(22); const edv = new DataView(eocd.buffer); edv.setUint32(0, 0x06054b50, true); edv.setUint16(4, 0, true); edv.setUint16(6, 0, true); edv.setUint16(8, files.length, true); edv.setUint16(10, files.length, true); edv.setUint32(12, centralSize, true); edv.setUint32(16, centralOffset, true); edv.setUint16(20, 0, true); const totalSize = offset + centralSize + eocd.length; const out = new Uint8Array(totalSize); let pos = 0; for (const p of localParts) { out.set(p, pos); pos += p.length; } for (const p of centralParts) { out.set(p, pos); pos += p.length; } out.set(eocd, pos); return out; } function xmlEsc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function colLetter(n) { let s = ''; n++; while (n > 0) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26); } return s; } function buildSheetXml(headers, rows) { let rowsXml = `${headers.map((h, ci) => `${xmlEsc(h)}`).join('')}`; rows.forEach((row, ri) => { const r = ri + 2; const cells = row.map((val, ci) => { const cellRef = colLetter(ci) + r; if (typeof val === 'number') return `${val}`; return `${xmlEsc(val)}`; }).join(''); rowsXml += `${cells}`; }); return ` ${rowsXml} `; } function buildXlsx(headers, rows, sheetName) { const contentTypes = ` `; const rootRels = ` `; const workbookXml = ` `; const workbookRels = ` `; const sheetXml = buildSheetXml(headers, rows); const files = [ { name: '[Content_Types].xml', data: strToBytes(contentTypes) }, { name: '_rels/.rels', data: strToBytes(rootRels) }, { name: 'xl/workbook.xml', data: strToBytes(workbookXml) }, { name: 'xl/_rels/workbook.xml.rels', data: strToBytes(workbookRels) }, { name: 'xl/worksheets/sheet1.xml', data: strToBytes(sheetXml) }, ]; return makeZip(files); } function bytesToBase64(bytes) { let binary = ''; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)); } return btoa(binary); } // ===================== Handler ===================== export async function onRequest(context) { const { request } = context; if (request.method === 'OPTIONS') return new Response(null, { headers: corsHeaders() }); if (request.method !== 'POST') return new Response(JSON.stringify({ error: 'Methode niet toegestaan' }), { status: 405, headers: corsHeaders() }); let body; try { body = await request.json(); } catch { return new Response(JSON.stringify({ error: 'Ongeldige JSON' }), { status: 400, headers: corsHeaders() }); } const { to_email, cluster, vehicles } = body; const toEmails = String(to_email || '').split(',').map(e => e.trim()).filter(Boolean); const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const validToEmails = toEmails.filter(e => emailRe.test(e)); if (!to_email || validToEmails.length === 0) { return new Response(JSON.stringify({ error: 'Geldig to_email is vereist' }), { status: 400, headers: corsHeaders() }); } if (!Array.isArray(vehicles) || vehicles.length === 0) { return new Response(JSON.stringify({ error: 'vehicles array is vereist' }), { status: 400, headers: corsHeaders() }); } const subject = `Betaalstatus na te kijken — Cluster ${cluster || ''} (${vehicles.length} wagen${vehicles.length === 1 ? '' : 's'})`; // Excel-lijst opbouwen: Chassis, Type, Vestiging, Handelaar, Datum, Prijs (te betalen door handelaar), Betaald, Opmerking const headers = ['Chassis', 'Type', 'Vestiging', 'Handelaar', 'Datum', 'Prijs (te betalen door handelaar)', 'Betaald', 'Opmerking']; const rows = vehicles.map(v => [ v.chassis || '', v.type || '', v.vest || '', v.handNaam || v.hand || '', fmtDate(v.datum), parseFloat(v.akk) || 0, false, '' ]); const xlsxBytes = buildXlsx(headers, rows, 'Betalingen'); const xlsxBase64 = bytesToBase64(xlsxBytes); const fileName = `Openstaande_wagens_${(cluster || 'cluster').replace(/[^a-z0-9]/gi, '_')}.xlsx`; const bodyHtml = `

Beste,

In bijlage vind je een Excel-lijst met de openstaande wagen(s) uit cluster ${esc(cluster || '')} (${vehicles.length} stuk(s)).

Kan je per wagen aanduiden of deze reeds betaald is, en het bestand terugsturen?

Alvast bedankt!

`; const htmlEmail = `
Van Mossel Betaalstatus
${esc(subject)}
${bodyHtml}

Van Mossel Automotive Group — ATP Overname Beheer

`; try { const sgPayload = { personalizations: [{ to: validToEmails.map(e => ({ email: e })), cc: [{ email: REPLY_TO_EMAIL, name: REPLY_TO_NAME }] }], from: { email: FROM_EMAIL, name: FROM_NAME }, reply_to: { email: REPLY_TO_EMAIL, name: REPLY_TO_NAME }, subject: subject, content: [{ type: 'text/html', value: htmlEmail }], attachments: [{ content: xlsxBase64, filename: fileName, type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', disposition: 'attachment' }] }; const sgRes = await fetch('https://api.sendgrid.com/v3/mail/send', { method: 'POST', headers: { 'Authorization': `Bearer ${SENDGRID_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(sgPayload) }); if (sgRes.status === 202) { return new Response(JSON.stringify({ ok: true, sent_to: validToEmails, count: vehicles.length }), { headers: corsHeaders() }); } else { const errText = await sgRes.text(); return new Response(JSON.stringify({ error: 'SendGrid fout', detail: errText }), { status: 500, headers: corsHeaders() }); } } catch (e) { return new Response(JSON.stringify({ error: 'Server error', detail: e.message }), { status: 500, headers: corsHeaders() }); } }