/** * POST /api/offers/quote/send * Body: OfferQuotePayload incl. email { to, cc?, subject, message }. * Builds the branded Angebot PDF ONCE, emails it to the recipient * (from info@logyou.de — quotes are sales conversations, replies land in the * real inbox) and attaches the same PDF to the triggering record: * partner → C_BPartner, lead → Lead_User (the lead page's AttachmentModal target). */ import nodemailer from 'nodemailer' import refreshTokenHelper from '../../../utils/refreshTokenHelper' import errorHandlingHelper from '../../../utils/errorHandlingHelper' import forceLogoutHelper from '../../../utils/forceLogoutHelper' import { generateQuotePdf, BRAND, LEGAL_FOOTER_LINES } from '../../../utils/offers/quotePdf' import { attachToStrapi } from '../../../utils/inbox/strapiAttach' import { saveOfferConditions, conditionsFromQuotePayload } from '../../../utils/offers/offerConditions' import { generateFlyerPdf } from '../../../utils/offers/flyerPdf' import { createSigningRequest, isSigningAvailable, writeSigningFile, sha256Hex } from '../../../utils/offers/contractSigningDb' import { confirmLink, confirmCtaHtml, confirmCtaText, resolveConfirmationExpiry } from '../../../utils/offers/quoteConfirmation' import { logContactActivity, strapiPublicFileUrl } from '../../../utils/offers/contactActivity' import { formatDateHuman } from '../../../utils/offers/contractSigning' const EMAIL_TEXTS: any = { de: { headline: 'Ihr Fulfillment-Angebot', attachmentBox: 'Das Angebot liegt dieser E-Mail als PDF-Anhang bei.', linkOnlyBox: 'Das Angebot steht über den Button oben zum Lesen und Herunterladen als PDF bereit — der Klick ist unverbindlich.', validity: (days: number) => `Gültigkeit: ${days} Tage.`, auto: 'Diese E-Mail wurde automatisch versendet.' }, en: { headline: 'Your fulfillment quote', attachmentBox: 'The quote is attached to this email as a PDF.', linkOnlyBox: 'The quote is available for reading and download as PDF via the button above — clicking it is non-binding.', validity: (days: number) => `Validity: ${days} days.`, auto: 'This email was sent automatically.' } } const generateQuoteEmailHtml = (message: string, language: string, validDays: number, confirmation: { link: string; expiresAt: string } | null = null, attachPdf = true) => { const T = EMAIL_TEXTS[language === 'en' ? 'en' : 'de'] const messageHtml = String(message || '') .replace(/&/g, '&').replace(//g, '>') .replace(/\n/g, '
') return `
${confirmation ? `` : ''}
${T.headline}
LogYou GmbH · logyou.de
${messageHtml}
${confirmCtaHtml(language, confirmation.link, confirmation.expiresAt)}
${attachPdf ? '📎' : '📄'}  ${attachPdf ? T.attachmentBox : T.linkOnlyBox} ${T.validity(validDays)}
${LEGAL_FOOTER_LINES.join('
')}

${T.auto}
` } const handleFunc = async (event: any, authToken: string | null = null) => { const token = authToken ?? await getTokenHelper(event) if (!token) return { status: 401, message: 'Not authenticated' } const body = await readBody(event) if (!body?.customer?.company || !body?.customer?.contactName) { return { status: 400, message: 'Company and contact name are required' } } const toEmail = String(body?.email?.to || '').trim() if (!/.+@.+\..+/.test(toEmail)) { return { status: 400, message: 'A valid recipient email is required' } } const language = body?.meta?.language === 'en' ? 'en' : 'de' const validDays = Number(body?.meta?.validDays) || 7 const source = body.source === 'lead' ? 'lead' : 'partner' const recordIdNum = Number(body.recordId) // Build the PDF once — reused for the email attachment AND the record attachment. const { buffer, filename } = await generateQuotePdf(body) // Online confirmation link (opt-out checkbox in the quote modal; default on). // Same store as contract signing (kind 'quote'); the link lives as long as the // quote is valid and a new quote send supersedes the record's older open link. let confirmation: { token: string; link: string; expiresAt: string } | null = null let confirmWarning = '' if (body?.confirmation?.enabled !== false) { if (!isSigningAvailable()) { confirmWarning = language === 'en' ? 'Online confirmation unavailable (store) — sent without link' : 'Online-Bestätigung nicht verfügbar (Speicher) — ohne Link versendet' } else { try { const expiresAt = resolveConfirmationExpiry(validDays) const { email: _e, ...payloadWithoutEmail } = body const req = createSigningRequest({ kind: 'quote', source, recordId: recordIdNum > 0 ? recordIdNum : 0, recordUu: body.recordUu || undefined, company: String(body.customer.company), contactName: String(body.customer.contactName), email: toEmail, language, payload: payloadWithoutEmail, documents: [{ key: 'offer', title: language === 'en' ? 'Quote' : 'Angebot', filename, sha256: sha256Hex(buffer), size: buffer.length }], createdBy: Number(getCookie(event, 'logship_user_id')) || null, expiresAt }) writeSigningFile(req.token, filename, buffer) confirmation = { token: req.token, link: confirmLink(req.token), expiresAt } } catch (err: any) { console.error('Quote confirmation link creation failed:', err) confirmWarning = language === 'en' ? 'Confirmation link could not be created — sent without it' : 'Bestätigungs-Link konnte nicht erstellt werden — ohne Link versendet' } } } // Optional marketing flyer (opt-in checkbox in the quote modal) — fail-soft. let flyerAttachment: any[] = [] let flyerWarning = '' if (body?.attachments?.flyer === true) { try { const fl = await generateFlyerPdf(language) flyerAttachment = [{ filename: fl.filename, content: fl.buffer, contentType: 'application/pdf' }] } catch (err: any) { console.error('Flyer attachment failed:', err?.message || err) flyerWarning = 'Flyer could not be attached' } } const transporter = nodemailer.createTransport({ host: 'localhost', port: 25, secure: false, tls: { rejectUnauthorized: false } }) // Quote PDF as e-mail attachment: opt-in while the online link is present // (default off — the customer reads/downloads it in the portal); WITHOUT a // link (disabled, store unavailable or creation failed) it is always attached. const attachPdf = !confirmation || body?.confirmation?.attachPdf === true const ccList = String(body?.email?.cc || '') .split(/[,;]/) .map((s: string) => s.trim()) .filter((s: string) => s.length > 0) try { await transporter.sendMail({ from: 'info@logyou.de', to: toEmail, cc: ccList.length > 0 ? ccList : undefined, subject: body?.email?.subject || filename.replace(/\.pdf$/, ''), text: (body?.email?.message || '') + (confirmation ? confirmCtaText(language, confirmation.link, confirmation.expiresAt) : ''), html: generateQuoteEmailHtml(body?.email?.message || '', language, validDays, confirmation, attachPdf), attachments: [ ...(attachPdf ? [{ filename, content: buffer, contentType: 'application/pdf' }] : []), ...flyerAttachment ] }) } catch (err: any) { console.error('Quote email send error:', err) return { status: 500, message: `Failed to send email: ${err.message}` } } // Attach the PDF to the triggering record for later reference — its own // try/catch so an attachment failure never reverts the successful send. let attached = false let attachWarning = '' let attachedUrl = '' try { const tableName = body.source === 'lead' ? 'Lead_User' : 'C_BPartner' const recordId = Number(body.recordId) if (recordId > 0) { const up = await attachToStrapi(event, token, { tableName, recordId, recordUu: body.recordUu || undefined, buffer, filename, mimeType: 'application/pdf' }) attached = true attachedUrl = strapiPublicFileUrl(up.fileUrl) } else { attachWarning = 'No record id — attachment skipped' } } catch (err: any) { console.error('Quote attachment error:', err) attachWarning = 'Email sent, but attaching the PDF to the record failed' } // Persist the just-sent conditions on the record — the next quote/contract // starts from them. Fail-soft: the offer_conditions column may not exist yet. let conditionsSaved = false try { const recordId = Number(body.recordId) if (recordId > 0) { await saveOfferConditions(event, token, source, recordId, 'quote', { ...conditionsFromQuotePayload(body), lastSend: { at: new Date().toISOString(), to: toEmail, filename, confirmationToken: confirmation?.token || null, confirmationExpiresAt: confirmation?.expiresAt || null } }) conditionsSaved = true } } catch (err: any) { console.error('Offer conditions save skipped:', err?.message || err) } // Activity on the record: "Angebot gesendet" with the attached PDF as a document // button (fail-soft — never affects the send result). let activityLogged = false try { if (recordIdNum > 0) { const en = language === 'en' const ccText = ccList.length ? (en ? ` (CC: ${ccList.join(', ')})` : ` (CC: ${ccList.join(', ')})`) : '' const lines = [ en ? `Quote ${filename} e-mailed to ${toEmail}${ccText}.` : `Angebot ${filename} per E-Mail an ${toEmail}${ccText} gesendet.`, en ? `Validity: ${validDays} days.` : `Gültigkeit: ${validDays} Tage.`, confirmation ? (en ? `Online confirmation link (valid until ${formatDateHuman(confirmation.expiresAt, 'en')}${attachPdf ? ', PDF also attached' : ', PDF in portal only'}): ${confirmation.link}` : `Online-Bestätigungs-Link (gültig bis ${formatDateHuman(confirmation.expiresAt, 'de')}${attachPdf ? ', PDF zusätzlich angehängt' : ', PDF nur im Portal'}): ${confirmation.link}`) : '', flyerAttachment.length ? (en ? 'Marketing flyer attached.' : 'Marketing-Flyer angehängt.') : '' ].filter(Boolean) const docs = attachedUrl ? [{ title: en ? 'Quote (PDF)' : 'Angebot (PDF)', url: attachedUrl }] : [] const created = await logContactActivity(event, token, { source, recordId: recordIdNum, type: 'EM', contactEmail: toEmail, description: en ? `Quote sent: ${filename}` : `Angebot gesendet: ${filename}`, comments: lines.join('\n'), documents: docs }) activityLogged = !!created } } catch (err: any) { console.error('Quote activity skipped:', err?.message || err) } return { status: 200, message: 'Quote sent successfully', filename, attached, conditionsSaved, activityLogged, confirmation, attachedToMail: attachPdf, flyerAttached: flyerAttachment.length > 0, warning: [attachWarning, flyerWarning, confirmWarning].filter(Boolean).join(' · ') || undefined } } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { try { let authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })