import nodemailer from 'nodemailer' import { PDFDocument, StandardFonts, rgb } from 'pdf-lib' /* * Phone-IVR return-instructions email. * Called by the FreePBX IVR middleware (localhost service on the PBX box) AFTER it has * created the M_RMA in iDempiere. Email-only: no iDempiere, no cookie/session. Gated by * the shared secret (X-IVR-Secret) from runtimeConfig.ivr.sharedSecret. The recipient is * resolved by the middleware from the order's own ship-to user (never caller input). * Generates + attaches a return-label PDF (return address with the RMA number). * * Body: { to, cc?, lang?, rmaNos: string[], orderNo?, customerName?, items: [{name,value,qty}] } */ interface Item { name?: string; value?: string; qty?: number } // Static return-address lines, pipe-separated in IVR_RETURN_ADDRESS (runtimeConfig.ivr.returnAddress). // The RMA number is inserted as line 2 so the warehouse can match the parcel. const DEFAULT_RETURN_ADDRESS = 'LogYou GmbH|Mühlenweg 4|35110 Butzbach' const addressLines = (baseRaw: string, rmaNos: string[]) => { const base = String(baseRaw || DEFAULT_RETURN_ADDRESS).split('|').map(s => s.trim()).filter(Boolean) return [base[0] || 'LogYou GmbH', 'RMA ' + rmaNos.join(' / '), ...base.slice(1)] } const esc = (s: any) => String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') // keep only chars the PDF standard font (WinAnsi) can encode const san = (s: any) => String(s == null ? '' : s).replace(/[^\x20-\x7E\xA0-\xFF]/g, ' ') const buildHtmlDE = (rmaNos: string[], orderNo: string, customerName: string, items: Item[], addr: string[]) => { const rmaList = rmaNos.map(esc).join(', ') const rows = items.map(i => ` ${esc(i.value)} ${esc(i.name)} ${esc(i.qty)} `).join('') const addrHtml = addr.map((l, idx) => idx === 0 ? `${esc(l)}` : esc(l)).join('
') return `

Rücksendeanweisungen

RMA ${rmaList}

Guten Tag${customerName ? ' ' + esc(customerName) : ''},

vielen Dank für Ihren Anruf. Ihre Rückgabe wurde unter der/den folgenden RMA-Nummer(n) registriert: ${rmaList}${orderNo ? ' (Bestellung ' + esc(orderNo) + ')' : ''}.

Folgende Artikel werden zurückgesendet:

${rows}
Artikelnr. Bezeichnung Menge

So senden Sie zurück:

  1. Verpacken Sie die Artikel sicher.
  2. Drucken Sie das beigefügte Rücksendeetikett (PDF) und kleben Sie es gut sichtbar auf das Paket.
  3. Senden Sie das Paket an die unten stehende Adresse. Die RMA-Nummer ${rmaList} ist bereits Teil der Adresse.
Rücksendeadresse:
${addrHtml}

Diese E-Mail wurde automatisch versendet. Bitte antworten Sie nicht direkt auf diese E-Mail.

` } const buildText = (rmaNos: string[], orderNo: string, items: Item[], addr: string[]) => `Ihre Rückgabe wurde registriert.\n\nRMA-Nummer(n): ${rmaNos.join(', ')}${orderNo ? '\nBestellung: ' + orderNo : ''}\n\n` + `Artikel:\n${items.map(i => `- ${i.qty}x ${i.name || ''} (${i.value || ''})`).join('\n')}\n\n` + `So senden Sie zurück:\n1. Artikel sicher verpacken.\n2. Beigefügtes Rücksendeetikett (PDF) ausdrucken und auf das Paket kleben.\n3. Paket senden an:\n\n${addr.join('\n')}\n` // Return-label PDF: a printable A4 sheet showing the return address (incl. RMA no), to be // stuck on the parcel. Uses the standard Helvetica font (WinAnsi) — dynamic text sanitized. async function buildLabelPdf (rmaNos: string[], orderNo: string, items: Item[], addr: string[]): Promise { const doc = await PDFDocument.create() const page = doc.addPage([595.28, 841.89]) // A4 const font = await doc.embedFont(StandardFonts.Helvetica) const bold = await doc.embedFont(StandardFonts.HelveticaBold) const { width, height } = page.getSize() const navy = rgb(0.12, 0.22, 0.5) const black = rgb(0, 0, 0) const gray = rgb(0.42, 0.42, 0.42) const lightBlue = rgb(0.92, 0.95, 1) let y = height - 60 page.drawText(san('Rücksendeetikett'), { x: 60, y, size: 24, font: bold, color: navy }) y -= 16 page.drawText('Return label', { x: 60, y, size: 11, font, color: gray }) // RMA highlight box y -= 44 page.drawRectangle({ x: 60, y: y - 6, width: width - 120, height: 42, color: lightBlue, borderColor: navy, borderWidth: 1 }) page.drawText(san('RMA ' + rmaNos.join(' / ')), { x: 76, y: y + 8, size: 20, font: bold, color: navy }) y -= 44 page.drawText(san('Bitte dieses Etikett gut sichtbar außen auf das Paket kleben.'), { x: 60, y, size: 11, font, color: black }) // Address box (shipping-label style) y -= 28 const boxH = 150 const boxTop = y page.drawRectangle({ x: 60, y: boxTop - boxH, width: 330, height: boxH, borderColor: black, borderWidth: 1.2 }) let ay = boxTop - 26 page.drawText(san('Empfänger / An:'), { x: 78, y: ay, size: 10, font, color: gray }) ay -= 28 addr.forEach((ln, idx) => { page.drawText(san(ln), { x: 78, y: ay, size: 15, font: idx === 0 ? bold : font, color: black }) ay -= 24 }) // Order + items y = boxTop - boxH - 36 if (orderNo) { page.drawText(san('Bestellung: ' + orderNo), { x: 60, y, size: 11, font, color: black }); y -= 22 } if (items && items.length) { page.drawText(san('Artikel:'), { x: 60, y, size: 11, font: bold, color: black }); y -= 18 for (const it of items) { const line = '- ' + (it.qty != null ? it.qty + 'x ' : '') + (it.name || '') + (it.value ? ' (' + it.value + ')' : '') page.drawText(san(line).substring(0, 90), { x: 70, y, size: 10, font, color: black }) y -= 16 if (y < 70) break } } page.drawText(san((addr[0] || 'LogYou GmbH') + ' · automatisch erstellt'), { x: 60, y: 40, size: 8, font, color: gray }) const bytes = await doc.save() return Buffer.from(bytes) } export default defineEventHandler(async (event) => { const config = useRuntimeConfig() const secret = config.ivr?.sharedSecret const provided = getHeader(event, 'x-ivr-secret') if (!secret || !provided || provided !== secret) { setResponseStatus(event, 401) return { status: 401, message: 'Unauthorized' } } const body = await readBody(event) const rmaNos: string[] = Array.isArray(body?.rmaNos) ? body.rmaNos.map((x: any) => String(x)) : [] const items: Item[] = Array.isArray(body?.items) ? body.items : [] const orderNo = String(body?.orderNo || '') const customerName = String(body?.customerName || '') let to = String(body?.to || '').trim() const cc = String(body?.cc || '').trim() if (!rmaNos.length) { setResponseStatus(event, 400) return { status: 400, message: 'Missing rmaNos' } } const addr = addressLines(config.ivr?.returnAddress, rmaNos) // No customer email → send to staff (cc) only so the return is still actioned. const ccList: string[] = [] if (!to) { to = cc || 'info@logyou.de' } else if (cc) { ccList.push(cc) } const html = buildHtmlDE(rmaNos, orderNo, customerName, items, addr) const text = buildText(rmaNos, orderNo, items, addr) const subject = `Rücksendeanweisungen – RMA ${rmaNos.join(', ')}` let pdf: Buffer | null = null try { pdf = await buildLabelPdf(rmaNos, orderNo, items, addr) } catch (e: any) { console.error('[ivr return email] PDF build failed:', e?.message || e) } const transporter = nodemailer.createTransport({ host: 'localhost', port: 25, secure: false, tls: { rejectUnauthorized: false } }) const mail: any = { from: 'no-reply@logyou.de', to, subject, text, html } if (ccList.length) mail.cc = ccList.join(', ') if (pdf) mail.attachments = [{ filename: `Ruecksendeetikett-RMA-${rmaNos.join('_')}.pdf`, content: pdf, contentType: 'application/pdf' }] try { await transporter.sendMail(mail) } catch (err: any) { console.error('[ivr return email] send error:', err?.message || err) setResponseStatus(event, 500) return { status: 500, message: 'Failed to send return instructions email' } } return { status: 200, message: 'Return instructions email sent', to, attachedPdf: !!pdf } })