Referencia completa de la API: https://api.mailoo.app/docs/v1
El generador de ofertas es una funcionalidad exclusiva de Market: los asistentes están asociados a una integración MARKET para que puedan resolver productos publicados y listas de precios del mismo catálogo. El almacenamiento de ofertas, SMTP y entrega saliente son autocontenidos (tablas y configuración de correo separadas de otros tipos de integración).
Para los endpoints de lectura de catálogo (tipos, productos, listas de precios), sigue usando ``market.external-read`` como se documenta en market-catalog-external-api{.interpreted-text role="doc"}.
Resumen
- Panel de control: Proyecto → integración Market → pestaña Generador de ofertas --- listar, crear, editar, eliminar generadores; configurar JSON del asistente, IA opcional y SMTP; patrones de URL externa para
pre-form. - API del propietario (JWT / session Bearer): Crear y actualizar generadores y configuración de correo bajo
/api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators…. - API externa (``X-API-Key``): Mismo prefijo de ruta que las rutas externas de Market ---
/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/…--- con alcances separados demarket.external-read. - Alcances:
- ``offer-generator.external-read`` ---
GET …/offer-generators/{generatorId}(JSON del asistente con bloques de catálogo resueltos solo para datos publicados). - ``offer-generator.submit`` ---
POST …/offer-generators/{generatorId}/submissions(valida respuestas, IA opcional, persiste oferta, envía correo). - Persistencia:
OfferGenerator,OfferGeneratorMailSettings,GeneratedOffer,OfferDelivery(Prisma). Nada se escribe enmessagesni enoutboundMailde la integración.
Configuración del asistente (versión 1)
Se almacena como JSON en ``OfferGenerator.wizardConfig``. El último paso debe ser un bloque ``final``.
Estructura del paso
id--- id string estable (referenciado porincludeStepIdsde bloquesai).visibleWhen(opcional) --- ya sea una condición atómica simple o un OR de grupos AND:- Atómica --- objeto
{ "var": "<name>", "equals": "<value>" }o{ "var": "<name>", "oneOf": ["…"] }(exactamente uno deequalsooneOf). - Compuesta ---
{ "or": [ { "and": [ <atom>, … ] }, … ] }. El paso es visible cuando cualquier arrayandcoincide; dentro de un grupo cada átomo debe coincidir (mismas reglas de átomo que arriba). Úsalo cuando varias combinaciones independientes deban mostrar el mismo paso. block--- discriminado portype:- ``text`` ---
bodyMarkdownopcional;fields[]conname,labelopcional,kind(text|textarea|email|phone|boolean|pinGroup),required. ParapinGroup,options[](al menos dos paresvalue/label) es obligatorio; un valor se almacena en la variablenamedel campo. Cada campo puede estableceruseMarkdownBody(booleano, predeterminado false): cuando es true,markdownBody(markdown) es el prompt del campo en lugar delabel; los asistentes preparados incluyenmarkdownBodyHtmlpor campo cuandomarkdownBodyestá establecido. Los campos también pueden adjuntar listas de productos del catálogo (veroffer-generator-field-product-lists{.interpreted-text role="ref"}). - ``choice`` ---
variable;options[]convalue,label,setVariablesmap opcional (el cliente puede fusionar en las variables enviadas). - ``product`` ---
productIds[];showFields;showPriceopcional (predeterminado true). El GET externo embeberesolvedProducts. Cada fila incluye ``images``, ``previewMediaId`` y ``previewImageUrl`` cuando el producto del catálogo los define (mismo contrato de idioma fusionado que la API externa de producto de Market: sinlocales, consulta opcionallocaleenGET …/offer-generators/{id}). Para cada clave enshowFieldsque sea un atributo ``MD_TEXT`` del catálogo en el producto resuelto, el asistente preparado también añade"<key>Html"(HTML desde markdown, con URLs de imágenes de Mailoo normalizadas) junto al valor markdown/string sin procesar. - ``priceList`` ---
priceListId;lineSkuFilteropcional;showFields;showPriceopcional. El GET externo embeberesolvedLines. - ``ai`` ---
promptTemplate(soporta marcadores{{varName}});outputVariableopcional (predeterminadoaiText);includeStepIdsopcional para contexto JSON adicional. Requiere configuración de IA en el generador (ver abajo). - ``final`` ---
customerEmailVariable(debe coincidir con un nombre de variable enviado);submitLabelopcional,bodyMarkdownyfields[]. Los campos finales usan el mismo esquema y reglas de validación quetext.fields[]y se envían en el mismo objetovariables.
Configuración de IA (opcional)
Se almacena en ``OfferGenerator.aiSettings`` como JSON openai-compatible: baseUrl, model y apiKeyEncrypted cifrado (mismo mecanismo de cifrado que los secretos SMTP). La API nunca devuelve la clave; las respuestas GET del propietario exponen solo hasApiKey.
Configuración de correo (obligatoria para entrega exitosa de correo)
PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings --- host, port, secure, user, from, replyTo opcional, notificationEmail opcional (alerta interna tipo BCC), smtpPassword opcional (solo escritura en PUT).
Si la configuración de correo falta o el envío SMTP falla, el envío sigue creando un ``GeneratedOffer`` con estado ``FAILED`` y diagnósticos; las filas de ``OfferDelivery`` registran intentos por canal (``CUSTOMER`` / ``NOTIFICATION``).
API del propietario (Bearer)
GET /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generatorsPOST /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generatorsGET /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}DELETE /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}GET /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings--- devuelve{ data: null }cuando aún no existe fila; de lo contrario los mismos campos no secretos que después delPUT.PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings
El cuerpo de creación POST incluye key (slug por integración), name opcional, status, wizardConfig, aiSettings opcional (apiKey es solo escritura cuando está presente).
API externa (X-API-Key)
Reemplaza los marcadores con tu UID de proyecto, id de integración MARKET e id del generador (CUID).
-
GET {baseUrl}/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}Devuelve
{ data: { id, key, name, status, wizard } }dondewizard.steps[].blockpuede incluirbodyHtml,markdownBodyHtmldel campo,resolvedProductsyresolvedLinespara renderizado preparado. Consulta opcional ``locale`` selecciona el idioma del catálogo para productos embebidos (mismas reglas que el GET externo de MarketGET …/products; predeterminado de la integracióndefaultCatalogLocale, sinoen). -
POST {baseUrl}/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/submissionsCuerpo:
{ "variables": { … } }--- todas las respuestas indexadas por nombres de variable de campo / elección. Respuesta:{ data: { id, status, customerEmail } }constatusCOMPLETEDoFAILED.
Patrón BFF / pre-form
Mantén dos claves restringidas en el servidor (nunca en el navegador):
- Clave de lectura ---
offer-generator.external-read--- el servidor carga el asistente una vez (o lo cachea) y renderiza la UI. - Clave de envío ---
offer-generator.submit--- el servidor envía el JSON devariablesfinal después de validar de tu lado.
Las búsquedas de catálogo desde el cliente siguen pasando por tu BFF usando ``market.external-read`` si necesitas datos de producto en vivo fuera del snapshot resolvedProducts embebido.
Listas de productos en campos {#offer-generator-field-product-lists}
Los campos tipo texto (text.fields[] y final.fields[]) pueden mostrar recomendaciones de productos junto a la entrada. Esto permite a un integrador construir un pre-form enriquecido sin añadir llamadas de catálogo personalizadas para cada campo.
Para campos no booleanos, adjunta products:
{
"name": "useCase",
"label": "What do you need?",
"kind": "textarea",
"required": true,
"products": {
"productIds": ["product_cuid_1", "product_cuid_2"],
"showFields": ["name", "sku"],
"showPrice": true,
"priceKindId": "price_kind_cuid"
}
}
Para campos booleanos, usa listas separadas para cada estado:
{
"name": "installation",
"label": "Include installation?",
"kind": "boolean",
"required": false,
"productsWhenTrue": {
"productIds": ["installation_service_id"],
"showFields": ["name", "sku"],
"showPrice": true,
"priceKindId": "price_kind_cuid"
}
}
En el GET externo, Mailoo prepara estas listas con resolvedProducts solo para productos publicados. Renderiza desde resolvedProducts cuando esté presente, y maneja con gracia cuando está vacío (no publicado, eliminado, filtrado o sin precio actual).
Patrones de UI de integración
La previsualización del panel de Mailoo es intencionalmente cercana a lo que un sitio externo puede implementar. Estructura recomendada para el cliente:
- Carga el asistente preparado desde tu servidor/BFF con
offer-generator.external-ready pasa solo el JSON del asistente al navegador. - Mantén un único objeto
variablesen el estado del navegador. Cada campo, elección, grupo de pins y salida de previsualización de IA escriben en ese objeto por nombre de variable. - Recalcula los pasos visibles desde
visibleWhencada vez quevariablescambie. - Renderiza
bodyHtml/markdownBodyHtmlpreparados cuando estén presentes; de lo contrario renderiza el texto markdown plano como respaldo. - Renderiza
resolvedProducts/resolvedLinesdel asistente preparado, no exponiendo claves API en el navegador. - Al enviar, valida los pasos visibles localmente para UX, luego envía
{ "variables": variables }desde tu servidor/BFF usando la claveoffer-generator.submit.
Helper de visibilidad mínimo:
type Vars = Record<string, string | number | boolean | null>
function asString(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return undefined
}
function atomMatches(
atom: { var: string; equals?: string; oneOf?: string[] },
vars: Vars
): boolean {
const current = asString(vars[atom.var])
if (atom.equals !== undefined) {
return current === asString(atom.equals)
}
if (Array.isArray(atom.oneOf)) {
return current !== undefined && atom.oneOf.includes(current)
}
return false
}
function isVisible(step: { visibleWhen?: any }, vars: Vars): boolean {
const when = step.visibleWhen
if (!when) return true
if (Array.isArray(when.or) && when.or.length > 0) {
return when.or.some(
(group: { and?: unknown }) =>
Array.isArray(group.and) &&
group.and.length > 0 &&
group.and.every((atom: any) => atomMatches(atom, vars))
)
}
return atomMatches(when, vars)
}
const visibleSteps = wizard.steps.filter((step) => isVisible(step, vars))
Los bloques de elección deben actualizar tanto su variable principal como los setVariables opcionales en un solo cambio de estado:
function chooseOption(block: any, option: any) {
setVars((prev) => ({
...prev,
[block.variable]: option.value,
...(option.setVariables ?? {}),
}))
}
Los campos de texto y finales comparten reglas de renderizado:
function renderField(field: any) {
const name = field.name
const value = String(vars[name] ?? '')
if (field.kind === 'boolean') {
return (
<input
type="checkbox"
checked={value.toLowerCase() === 'true'}
onChange={(e) => setVar(name, e.target.checked ? 'true' : 'false')}
/>
)
}
if (field.kind === 'pinGroup') {
return field.options.map((option: any) => (
<button type="button" onClick={() => setVar(name, option.value)}>
{option.label}
</button>
))
}
return (
<input
type={field.kind === 'email' ? 'email' : field.kind === 'phone' ? 'tel' : 'text'}
value={value}
onChange={(e) => setVar(name, e.target.value)}
/>
)
}
Para recomendaciones de productos adjuntas a campos, usa la lista preparada:
function fieldProductsFor(field: any) {
if (field.kind === 'boolean') {
const checked = String(vars[field.name] ?? '').toLowerCase() === 'true'
return checked
? field.productsWhenTrue?.resolvedProducts ?? []
: field.productsWhenFalse?.resolvedProducts ?? []
}
return field.products?.resolvedProducts ?? []
}
Patrón de envío:
async function submitOffer() {
// Browser posts to your own server route. The server route adds X-API-Key.
const res = await fetch('/api/offer-generator/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: vars }),
})
if (!res.ok) throw new Error('Offer submission failed')
return res.json()
}
Notas de seguridad y renderizado
- Nunca expongas claves
offer-generator.external-readnioffer-generator.submital navegador. - Trata
bodyHtmlymarkdownBodyHtmlpreparados como HTML generado por la pipeline del servidor de Mailoo para el asistente que posees. No pases entrada arbitraria de visitantes a través dedangerouslySetInnerHTML. - Mantén la validación local del navegador solo como conveniencia. La API valida las variables enviadas de nuevo, incluyendo campos
textyfinalvisibles, valores de elección, formatos de teléfono/correo ycustomerEmailVariable. - Si la UI permite a los visitantes retroceder y cambiar respuestas anteriores, elimina o recalcula las variables de los pasos ocultos posteriores antes de enviar, o confía en la validación de pasos visibles del servidor para rechazar payloads inconsistentes.
Relacionado
market-catalog-external-api{.interpreted-text role="doc"} --- lecturas de productos y listas de precios.market-catalog-csv{.interpreted-text role="doc"} --- ediciones masivas de catálogo en el panel.outbound-smtp{.interpreted-text role="doc"} --- no se usa para correo del generador de ofertas (el generador de ofertas usa su propia tabla SMTP).