// logyou.de landing page → ERP lead intake. // Same target as the FluentForm webhook (fulfillment-leads.post.ts): creates an // AD_User sales lead via the iDempiere service token, with the same email dedupe. // // Two callers / auth paths: // 1. Server-to-server: `Authorization: Bearer ` // (runtimeConfig.api.landingLeadToken), timing-safe compare. // 2. Browser: the landing form POSTs directly from logyou.de — allowed when the // Origin header matches the allowlist (see server/utils/landingLeadCors.ts; // preflight answered by landing-leads.options.ts). The browser path is // guarded by a honeypot field `_hp` and a per-IP rate limit. // Anything else → 403. // // After lead creation (and on dedupe, since a returning prospect is still a fresh // inquiry) a plain-text notification mail goes to info@logyou.de via the local // MTA — same transport as server/api/contact/send.post.ts. Mail failure never // fails the request. // // Payload (JSON): { name, company, email, phone, brand_url, volume, weight, items, // integrations: string[], cta_source, locale, _hp? } import { createHash, timingSafeEqual } from 'node:crypto' import { getRequestIP } from 'h3' import nodemailer from 'nodemailer' import { string } from 'alga-js' import errorHandlingHelper from '../../utils/errorHandlingHelper' import { applyLandingLeadCors } from '../../utils/landingLeadCors' // Hash both sides so timingSafeEqual always gets equal-length buffers. const safeEqual = (a: string, b: string): boolean => timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest()) // --- Per-IP rate limit for the browser path: 1 request / 30s / IP. --- // Per-process Map — PM2 runs 2 cluster instances, so the effective allowed rate // doubles (same accepted caveat as authCooldown/notificationBus). const lastRequestAt = new Map() const RATE_WINDOW_MS = 30_000 const SWEEP_THRESHOLD = 500 // lazy cleanup to bound memory const rateLimited = (ip: string): boolean => { const now = Date.now() if (lastRequestAt.size > SWEEP_THRESHOLD) { for (const [k, t] of lastRequestAt) { if (now - t > RATE_WINDOW_MS) lastRequestAt.delete(k) } } const last = lastRequestAt.get(ip) if (last && now - last < RATE_WINDOW_MS) return true lastRequestAt.set(ip, now) return false } // Notification target is intentionally hard-coded, never taken from the // request body — the landing page has no legitimate reason to redirect where // its own lead notifications go. const LANDING_LEAD_NOTIFY_TO = 'info@logyou.de' const escapeHtml = (s: string): string => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)) // Prefixes a scheme onto bare domains ("younex.de", "www.younex.de") so the // mail's link is actually clickable — mirrors the landing form's own // normalization (ContactModal.vue normalizedBrandUrl()). const normalizeUrl = (u: string): string => (/^https?:\/\//i.test(u) ? u : `https://${u}`) // HTML + plain-text notification to the sales inbox (local MTA, like // contact/send.post.ts). Phone/URL/email are rendered as tel:/https:/mailto: // links so the recipient can tap-to-call or open the brand site directly. const sendNotificationMail = async (body: any, integrations: string[], duplicate: boolean) => { const transporter = nodemailer.createTransport({ host: 'localhost', port: 25, secure: false, tls: { rejectUnauthorized: false } }) const name = String(body.name || '').trim() const company = String(body.company || '').trim() const brandUrl = String(body.brand_url || '').trim() const email = String(body.email || '').trim() const phone = String(body.phone || '').trim() const brandUrlHref = brandUrl ? normalizeUrl(brandUrl) : '' const phoneHref = phone ? `tel:${phone.replace(/[^\d+]/g, '')}` : '' const rows: Array<[string, string]> = [ ['Name', name ? escapeHtml(name) : '–'], ['Firma', company ? escapeHtml(company) : '–'], ['Brand-URL', brandUrlHref ? `${escapeHtml(brandUrl)}` : '–'], ['E-Mail', email ? `${escapeHtml(email)}` : '–'], ['Telefon', phoneHref ? `${escapeHtml(phone)}` : '–'], ['Sendungen/Monat', body.volume ? escapeHtml(body.volume) : '–'], ['Gewicht', body.weight ? escapeHtml(body.weight) : '–'], ['Items/Auftrag', body.items ? escapeHtml(body.items) : '–'], ['Systeme', integrations.length ? escapeHtml(integrations.join(', ')) : '–'], ['CTA-Quelle', body.cta_source ? escapeHtml(body.cta_source) : '–'], ['Sprache', body.locale ? escapeHtml(body.locale) : '–'] ] const htmlRows = rows.map(([label, value]) => ` ${escapeHtml(label)} ${value} `).join('') const html = `
Neue Anfrage · logyou.de

${escapeHtml(company || name || email)}

${duplicate ? '
⚠ Bereits als Lead vorhanden (Duplikat) — trotzdem eine frische Anfrage.
' : ''} ${htmlRows}

Eingegangen über logyou.de · Antwort direkt per Reply-To an den Interessenten.

` const textLines = [ 'Name: ' + (name || '-'), 'Firma: ' + (company || '-'), 'Brand-URL: ' + (brandUrl || '-'), 'E-Mail: ' + (email || '-'), 'Telefon: ' + (phone || '-'), 'Sendungen/Monat: ' + (body.volume || '-'), 'Gewicht: ' + (body.weight || '-'), 'Items/Auftrag: ' + (body.items || '-'), 'Systeme: ' + (integrations.length ? integrations.join(', ') : '-'), 'CTA-Quelle: ' + (body.cta_source || '-'), 'Sprache: ' + (body.locale || '-') ] if (duplicate) textLines.push('', 'Bereits als Lead vorhanden (Duplikat)') const mail: any = { from: 'no-reply@logyou.de', to: LANDING_LEAD_NOTIFY_TO, subject: `[LogShip Anfrage] ${company || name || email}`, text: textLines.join('\n'), html } if (email) mail.replyTo = email await transporter.sendMail(mail) } export default defineEventHandler(async (event) => { let data: any = {} try { const config = useRuntimeConfig() const originAllowed = applyLandingLeadCors(event) // --- Auth: valid Bearer token (server-to-server) OR allowlisted Origin (browser) --- const expected = config.api.landingLeadToken const auth = getHeader(event, 'authorization') || '' const provided = auth.startsWith('Bearer ') ? auth.slice(7) : '' const serverPath = Boolean(expected && provided && safeEqual(provided, expected)) if (!serverPath && !originAllowed) { setResponseStatus(event, 403) return { ok: false, status: 403, message: 'Forbidden' } } // --- Abuse protection for the public browser path --- if (!serverPath) { const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown' if (rateLimited(ip)) { setResponseStatus(event, 429) return { ok: false, error: 'too_many' } } } const token = config.api.idempieretoken const body = await readBody(event) // Honeypot: bots fill the hidden field → pretend success, do nothing. if (typeof body?._hp === 'string' && body._hp.trim() !== '') { return { ok: true } } // Validate required fields if (!body?.email) { setResponseStatus(event, 400) return { ok: false, status: 400, message: 'Email is required' } } // Client/Org come from the server env only (never from the public payload) const clientId = process.env.DEFAULT_CLIENT_ID || 1000000 const orgId = process.env.DEFAULT_ORG_ID || 1000000 const integrations = (Array.isArray(body.integrations) ? body.integrations : []).filter(Boolean) // Check if a lead/user already exists with this email (same dedupe as fulfillment-leads) const res: any = await $fetch(config.api.url+`/models/ad_user?$filter=${string.urlEncode("eMail eq '"+body.email+"'")}`, { method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer '+token }, }) if(Number(res?.records?.length ?? 0) >= 1) { // Duplicate is not an error for the landing — but a returning prospect // is still a fresh inquiry, so the notification mail goes out anyway. try { await sendNotificationMail(body, integrations, true) } catch (mailErr: any) { console.error('[landing-leads] notification mail failed (duplicate):', mailErr?.message ?? mailErr) } data = { ok: true, duplicate: true, status: 409, message: 'Lead already exists with this email', email: body.email } return data } // Qualification data has no own AD_User fields → readable text in Comments const comments = [ body.company ? 'Firma: ' + body.company : '', body.volume ? 'Paketmenge: ' + body.volume : '', body.weight ? 'Paketgewicht: ' + body.weight : '', body.items ? 'Artikel: ' + body.items : '', integrations.length ? 'Shopsystem: ' + integrations.join(', ') : '', body.cta_source ? 'CTA: ' + body.cta_source : '', body.locale ? 'Sprache: ' + body.locale : '' ].filter(Boolean).join(' | ') // Create new lead const createRes: any = await $fetch(config.api.url+'/models/ad_user', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer '+token }, body: { AD_Client_ID: { id: clientId, tableName: 'AD_Client' }, AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, isActive: true, name: (body.name || '').trim() || body.email, description: 'Shopsystem: ' + integrations.join(', '), eMail: body.email, isFullBPAccess: false, isInPayroll: false, isSalesLead: true, isLocked: false, isNoPasswordReset: false, isExpired: false, isAddMailTextAutomatically: false, isNoExpire: true, isSupportUser: false, isShipTo: true, isBillTo: false, isVendorLead: true, eMailUser: body.email, phone: body.phone, URL: body.brand_url, comments: comments, NotificationType: { id: 'X' }, LeadSource: { id: 'EC' }, LeadStatus: { id: 'N' }, LeadSourceDescription: 'logyou.de Landingpage' + (body.cta_source ? ' – ' + body.cta_source : ''), tableName: 'AD_User' } }) // Success response if(createRes && createRes.id) { try { await sendNotificationMail(body, integrations, false) } catch (mailErr: any) { console.error('[landing-leads] notification mail failed:', mailErr?.message ?? mailErr) } data = { ok: true, status: 201, message: 'Lead created successfully', id: createRes.id, email: body.email } } else { setResponseStatus(event, 500) data = { ok: false, status: 500, message: 'Failed to create lead - no ID returned', email: body.email } } } catch(err: any) { console.error('[landing-leads] lead creation failed:', err?.data ?? err?.message ?? err) setResponseStatus(event, 500) data = errorHandlingHelper(err?.data ?? err) data.ok = false data.message = data.message || 'Internal server error while creating lead' } return data })