/**
* 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 `
|
${T.headline}
LogYou GmbH · logyou.de
|
|
${messageHtml}
|
${confirmation ? `| ${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
})