Полная справка по API: https://api.mailoo.app/docs/v1
Генератор предложений --- функция, доступная только для Market: мастера привязаны к интеграции MARKET, поэтому они могут работать с опубликованными товарами и прайс-листами из того же каталога. Хранение предложений, SMTP и исходящая доставка --- автономны (отдельные таблицы и почтовые настройки от других типов интеграций).
Для эндпоинтов чтения каталога (типы, товары, прайс-листы) используйте ``market.external-read`` в соответствии с market-catalog-external-api{.interpreted-text role="doc"}.
Обзор
- Панель управления: Проект → интеграция Market → вкладка Генератор предложений --- список, создание, редактирование, удаление генераторов; настройка JSON мастера, опционального ИИ и SMTP; внешние шаблоны URL для
pre-form. - API владельца (JWT / Bearer сессии): Создание и обновление генераторов и почтовых настроек через
/api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators…. - Внешний API (``X-API-Key``): Тот же префикс пути, что и у внешних маршрутов Market ---
/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/…--- с отдельными областями действия отmarket.external-read. - Области действия:
- ``offer-generator.external-read`` ---
GET …/offer-generators/{generatorId}(JSON мастера с блоками каталога, разрешёнными только для опубликованных данных). - ``offer-generator.submit`` ---
POST …/offer-generators/{generatorId}/submissions(валидация ответов, опциональный ИИ, сохранение предложения, отправка письма). - Хранение:
OfferGenerator,OfferGeneratorMailSettings,GeneratedOffer,OfferDelivery(Prisma). Ничего не записывается вmessagesилиoutboundMailинтеграции.
Конфигурация мастера (версия 1)
Хранится как JSON в ``OfferGenerator.wizardConfig``. Последний шаг должен быть блоком ``final``.
Структура шага
id--- стабильный строковый идентификатор (ссылается изincludeStepIdsблоковai).visibleWhen(необязательно) --- одно атомарное условие или OR из AND-групп:- Атомарное --- объект
{ "var": "<name>", "equals": "<value>" }или{ "var": "<name>", "oneOf": ["…"] }(строго одно изequalsилиoneOf). - Составное ---
{ "or": [ { "and": [ <atom>, … ] }, … ] }. Шаг виден, когда любаяand-группа совпадает; внутри группы каждый атом должен совпадать (те же правила атомов). Используйте, когда несколько независимых комбинаций должны показывать один шаг. block--- дискриминируется поtype:- ``text`` --- Опциональный
bodyMarkdown;fields[]сname, опциональнымlabel,kind(text|textarea|email|phone|boolean|pinGroup),required. ДляpinGroupобязателенoptions[](минимум две парыvalue/label); одно значение сохраняется в переменную поляname. Каждое поле может задатьuseMarkdownBody(boolean, по умолчанию false): при truemarkdownBody(markdown) используется как подсказка поля вместоlabel; подготовленные мастера включаютmarkdownBodyHtmlдля каждого поля, гдеmarkdownBodyзадан. Поля могут также содержать списки товаров каталога (см.offer-generator-field-product-lists{.interpreted-text role="ref"}). - ``choice`` ---
variable;options[]сvalue,label, опциональной картойsetVariables(клиент может объединить с отправляемыми переменными). - ``product`` ---
productIds[];showFields; опциональныйshowPrice(по умолчанию true). Внешний GET встраиваетresolvedProducts. Каждая строка включает ``images``, ``previewMediaId`` и ``previewImageUrl``, если каталожный товар их определяет (тот же контракт с объединённой локалью, что и внешний API товаров Market: безlocales, опциональный параметрlocaleвGET …/offer-generators/{id}). Для каждого ключа вshowFields, являющегося каталожным атрибутом ``MD_TEXT`` на разрешённом товаре, подготовленный мастер также добавляет"<key>Html"(HTML из markdown, с нормализованными URL изображений Mailoo) рядом с исходным markdown/строковым значением. - ``priceList`` ---
priceListId; опциональныйlineSkuFilter;showFields; опциональныйshowPrice. Внешний GET встраиваетresolvedLines. - ``ai`` ---
promptTemplate(поддерживает плейсхолдеры{{varName}}); опциональныйoutputVariable(по умолчаниюaiText); опциональныеincludeStepIdsдля дополнительного JSON-контекста. Требует настройки ИИ на генераторе (см. ниже). - ``final`` ---
customerEmailVariable(должна совпадать с именем отправленной переменной); опциональныеsubmitLabel,bodyMarkdownиfields[]. Поля final используют ту же схему и правила валидации, что иtext.fields[], и отправляются в том же объектеvariables.
Настройки ИИ (необязательно)
Хранятся в ``OfferGenerator.aiSettings`` как JSON openai-compatible: baseUrl, model и зашифрованный apiKeyEncrypted (тот же механизм шифрования, что и у SMTP-секретов). API никогда не возвращает ключ; GET-ответы владельца содержат только hasApiKey.
Почтовые настройки (обязательны для успешной доставки e-mail)
PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings --- host, port, secure, user, from, опциональный replyTo, опциональный notificationEmail (внутреннее уведомление в стиле BCC), опциональный smtpPassword (только для записи при PUT).
Если почтовые настройки отсутствуют или SMTP-отправка не удалась, отправка всё равно создаёт ``GeneratedOffer`` со статусом ``FAILED`` и диагностикой; строки ``OfferDelivery`` фиксируют попытки по каждому каналу (``CUSTOMER`` / ``NOTIFICATION``).
API владельца (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--- возвращает{ data: null }если строки ещё нет; иначе те же несекретные поля, что и послеPUT.PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings
Тело POST создания включает key (slug для интеграции), опциональные name, status, wizardConfig, опциональный aiSettings (apiKey --- только для записи при наличии).
Внешний API (X-API-Key)
Замените плейсхолдеры на UID проекта, id интеграции MARKET и id генератора (CUID).
-
GET {baseUrl}/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}Возвращает
{ data: { id, key, name, status, wizard } }гдеwizard.steps[].blockможет содержатьbodyHtml,markdownBodyHtmlполей,resolvedProductsиresolvedLinesдля подготовленного рендеринга. Опциональный параметр ``locale`` выбирает язык каталога для встроенных товаров (те же правила, что и у внешнегоGET …/productsMarket; по умолчанию изdefaultCatalogLocaleинтеграции, иначеen). -
POST {baseUrl}/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/submissionsТело:
{ "variables": { … } }--- все ответы, сгруппированные по именам переменных полей / выбора. Ответ:{ data: { id, status, customerEmail } }соstatusCOMPLETEDилиFAILED.
Паттерн BFF / pre-form
Держите два ограниченных ключа на сервере (никогда в браузере):
- Ключ чтения ---
offer-generator.external-read--- сервер загружает мастер один раз (или кеширует) и рендерит UI. - Ключ отправки ---
offer-generator.submit--- сервер отправляет итоговый JSONvariablesпосле валидации на вашей стороне.
Запросы каталога от клиента по-прежнему проходят через ваш BFF с ``market.external-read``, если вам нужны актуальные данные товаров за пределами встроенного снимка resolvedProducts.
Списки товаров в полях {#offer-generator-field-product-lists}
Текстовые поля (text.fields[] и final.fields[]) могут показывать рекомендации товаров рядом с полем ввода. Это позволяет интегратору построить насыщенную pre-form без индивидуальных запросов к каталогу для каждого поля.
Для не-boolean полей прикрепите 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"
}
}
Для boolean полей используйте отдельные списки для каждого состояния:
{
"name": "installation",
"label": "Include installation?",
"kind": "boolean",
"required": false,
"productsWhenTrue": {
"productIds": ["installation_service_id"],
"showFields": ["name", "sku"],
"showPrice": true,
"priceKindId": "price_kind_cuid"
}
}
При внешнем GET Mailoo подготавливает эти списки с resolvedProducts только для опубликованных товаров. Рендерите из resolvedProducts когда он присутствует, и обрабатывайте случай пустого списка (снят с публикации, удалён, отфильтрован или нет текущей цены).
Паттерны интерфейса интеграции
Предпросмотр мастера в панели управления Mailoo намеренно близок к тому, что может реализовать внешний сайт. Рекомендуемая клиентская структура:
- Загрузите подготовленный мастер с сервера/BFF с
offer-generator.external-readи передайте в браузер только JSON мастера. - Храните единственный объект
variablesв состоянии браузера. Каждое поле, выбор, группа пинов и вывод ИИ записывает в этот объект по имени переменной. - Пересчитывайте видимые шаги из
visibleWhenпри каждом измененииvariables. - Рендерите подготовленные
bodyHtml/markdownBodyHtmlкогда доступны; иначе рендерите обычный markdown как фоллбэк. - Рендерите
resolvedProducts/resolvedLinesиз подготовленного мастера, не раскрывая API-ключи в браузере. - При отправке валидируйте видимые шаги локально для UX, затем отправьте
{ "variables": variables }с сервера/BFF с ключомoffer-generator.submit.
Минимальный хелпер видимости:
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))
Блоки выбора должны обновлять одновременно основную variable и опциональные setVariables:
function chooseOption(block: any, option: any) {
setVars((prev) => ({
...prev,
[block.variable]: option.value,
...(option.setVariables ?? {}),
}))
}
Текстовые и финальные поля имеют общие правила рендеринга:
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)}
/>
)
}
Для рекомендаций товаров, прикреплённых к полям, используйте подготовленный список:
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 ?? []
}
Паттерн отправки:
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()
}
Безопасность и рендеринг
- Никогда не раскрывайте ключи
offer-generator.external-readилиoffer-generator.submitв браузере. - Обрабатывайте подготовленные
bodyHtmlиmarkdownBodyHtmlкак HTML, сгенерированный серверным конвейером Mailoo для принадлежащего вам мастера. Не пропускайте произвольный пользовательский ввод черезdangerouslySetInnerHTML. - Локальная валидация в браузере --- только для удобства UX. API повторно валидирует отправленные переменные, включая видимые поля
textиfinal, значения выбора, форматы телефона/e-mail иcustomerEmailVariable. - Если UI позволяет посетителям возвращаться и менять ранние ответы, удалите или пересчитайте переменные скрытых шагов перед отправкой, или полагайтесь на серверную валидацию видимых шагов для отклонения противоречивых данных.
Связанные материалы
market-catalog-external-api{.interpreted-text role="doc"} --- чтение товаров и прайс-листов.market-catalog-csv{.interpreted-text role="doc"} --- массовое редактирование каталога в панели управления.outbound-smtp{.interpreted-text role="doc"} --- не используется для почты генератора предложений (генератор использует собственную таблицу SMTP).