# Marketing

Los métodos de sdk.mcp.marketing en JavaScript y Python: comprobación previa al envío, historial por destinatario, consentimiento, rebotes y rutas de envío.

`sdk.mcp.marketing` lleva a tu integración lo mismo que la pantalla de [Marketing](https://os.utilia.ai/dashboard/marketing): preguntar si una campaña está lista **sin intentar enviarla**, explicar qué pasó con **un correo concreto**, saber **quién ha contestado**, dejar constancia de **en qué bandeja cayó** la prueba y, sobre todo, saber **qué se está midiendo de verdad**. Los métodos se reparten en los sub-espacios `campaigns`, `lists`, `deliverability`, `sendingRoutes` y `bounceSettings`, entre otros.

Todo viaja por el carril MCP (`POST /api/mcp/marketing`), que actúa en nombre de una persona: el SDK tiene que estar configurado con [OAuth](https://os.utilia.ai/docs/developers/sdk/oauth), y la organización necesita el aplicativo Marketing en su plan (salvo `sendingRoutes`, que se explica más abajo). Los ejemplos de Python usan la clase asíncrona.

> **Atención: Cambio con efecto sobre integraciones vivas: «marketing:write» ya no envía**
>
> Desde el 5 de septiembre de 2026, `marketing:campaigns:send` y
> `marketing:campaigns:schedule` **han salido** del conjunto OAuth
> `marketing:write`. El texto de consentimiento decía que ese conjunto no incluía
> el envío, y ahora es verdad.
>
> Si tu integración enviaba o programaba campañas apoyándose en `marketing:write`,
> **tiene que volver a pedir autorización** con los dos scopes por su nombre (o
> `marketing:full`). Hasta que lo haga, `campaigns.send`, `campaigns.schedule`,
> `campaigns.retryFailed`, `campaigns.inboxCheckStart` y
> `campaigns.inboxCheckConfirm` llegan denegadas por falta de **scope**, no por
> falta de permiso del usuario.

Así encajan los métodos de esta página en el envío de una campaña:

**Una campaña enviada con garantías**

1. **Comprobar** (campaigns.preflight): Las mismas comprobaciones que bloquean el envío, sin enviar nada.
2. **Probar en buzones** (inboxCheckStart y inboxCheckConfirm · Opcional): Hasta tres buzones propios; una persona declara dónde cayó.
3. **Enviar** (campaigns.send): Exige el scope marketing:campaigns:send.
4. **Seguir cada correo** (recipientTimeline y listReplies): El historial de un correo y quién ha contestado.
5. **Medir sin inventar** (measurementCoverage): Qué señales se miden de verdad antes de dar cifras.

## La regla que gobierna todo este bloque

**La ausencia de medición no es un cero.** Cuando el servidor no puede saber
algo lo dice, y presentarlo como «0» convierte una limitación nuestra en una
afirmación sobre el cliente. Cuatro casos concretos que aparecen en los tipos:

| Lo que llega | Lo que significa | Lo que **no** puede decir |
| - | - | - |
| `reputationScore: null` | No hay muestra suficiente para puntuar | «Tu puntuación es 0» |
| Una señal en `NOT_MEASURED` | No tenemos forma de enterarnos | «No has tenido rebotes» |
| `consent` ausente | Quien pregunta no tiene permiso para verlo | «Esta persona no está suscrita» |
| `automatic: true` | La respuesta la generó una máquina | «Esta persona está interesada» |

## Antes de enviar: `campaigns.preflight`

Ejecuta en modo diagnóstico las **mismas** comprobaciones que bloquean al
programar y al arrancar. Sin ella, la única forma de saber si una campaña
está lista sería intentar enviarla y leer el rechazo, algo inaceptable en una integración: el intento que falla puede haber reservado cupo, y el que no falla
ya ha escrito a la lista entera.

### JavaScript

```typescript
const { data } = await sdk.mcp.marketing.campaigns.preflight('cmp_123');

if (!data.preflight.canSend) {
  const bloqueantes = data.preflight.checks.filter((c) => c.blocking);
  // NO llames a send: el envío se rechazaría igual, después de tocar el cupo.
  throw new Error(`Bloquean: ${bloqueantes.map((c) => c.code).join(', ')}`);
}

// «unknown» significa que esa comprobación NO SE PUDO HACER, no que esté bien.
const sinComprobar = data.preflight.checks.filter((c) => c.status === 'unknown');
```

### Python

```python
previa = await sdk.mcp.marketing.campaigns.preflight("cmp_123")

if not previa.preflight.can_send:
    bloqueantes = [c.code for c in previa.preflight.checks if c.blocking]
    # NO llames a send: el envío se rechazaría igual, después de tocar el cupo.
    raise RuntimeError(f"Bloquean: {', '.join(bloqueantes)}")

# «unknown» significa que esa comprobación NO SE PUDO HACER, no que esté bien.
sin_comprobar = [c for c in previa.preflight.checks if c.status == "unknown"]
```

Es de **solo lectura**: no reserva cupo, no confirma ningún aviso y no autoriza
nada. Las puertas reales se vuelven a aplicar al enviar, así que un `preflight`
en verde no es un salvoconducto. Lo abren los tres permisos del envío en OR:
quien solo puede mirar la campaña también necesita entender por qué no sale.

## Durante y después: historial, respuestas y bandejas

### `campaigns.recipientTimeline(campaignId, sendId)`

El relato de **un** correo: encolado, intentos, aceptación o rechazo con su
causa, exclusión deliberada, rebote, apertura, clic, queja, baja y respuesta.

El `sendId` sale del campo `id` que viaja dentro de `sends[]` en cada fila de
`listRecipients`, y también de `listReplies`. **No** es el identificador del
destinatario ni el del contacto.

> **Atención: ACCEPTED no significa que llegara a nadie**
>
> Un hito `ACCEPTED` acredita que **el servidor de destino se hizo cargo** del
> mensaje. No dice que llegara a la bandeja de entrada, ni que la persona lo
> viera. Un hito con `metadata.derived` se ha **reconstruido** desde las columnas
> del envío porque es anterior a esta versión.
>
> La clave `tech` (aparato, programa de correo, país y enlace pulsado) se **omite**
> sin el permiso `EMAIL_MARKETING_VIEW_RECIPIENT_DETAIL` y el scope
> `marketing:recipients:detail:view`. El servidor nunca devuelve un error por eso.

### `campaigns.listReplies(campaignId, filters)`

Quién ha contestado, con la marca `automatic`. **Una respuesta con
`automatic: true` la generó una máquina** (un aviso de ausencia): no la cuentes
como conversación ni como interés, y un aviso de no entrega no es una respuesta.

Ver que alguien contestó lo gobierna `EMAIL_MARKETING_VIEW`. **Leer lo que dijo**
es otra cosa: el adelanto del texto exige el permiso de interacciones del CRM
**y** el aplicativo CRM en el plan. Cuando no viaja, `snippetIncluded: false` lo
dice, y eso no significa que no haya texto.

### El pack de comprobación en buzones

#### JavaScript

```typescript
// Manda la prueba a hasta TRES buzones propios por el camino real.
const { data: pack } = await sdk.mcp.marketing.campaigns.inboxCheckStart(
  'cmp_123',
  [{ email: 'yo@gmail.com', provider: 'gmail' }],
);

// Después, una PERSONA abre su buzón y declara lo que ve.
await sdk.mcp.marketing.campaigns.inboxCheckConfirm(pack.inboxChecks[0].id, {
  placement: 'INBOX',
  imagesBlockedOk: true,
  // Lo que no se declara queda SIN COMPROBAR: ni aprobado ni suspenso.
});

const { data: hechas } =
  await sdk.mcp.marketing.campaigns.inboxChecksList('cmp_123');
const caducadas = hechas.inboxChecks.filter((c) => c.expired);
```

#### Python

```python
from utilia_sdk.mcp.types import (
    MarketingInboxCheckConfirmInput,
    MarketingInboxCheckMailbox,
)

# Manda la prueba a hasta TRES buzones propios por el camino real.
pack = await sdk.mcp.marketing.campaigns.inbox_check_start(
    "cmp_123",
    [MarketingInboxCheckMailbox(email="yo@gmail.com", provider="gmail")],
)

# Después, una PERSONA abre su buzón y declara lo que ve.
await sdk.mcp.marketing.campaigns.inbox_check_confirm(
    pack.inbox_checks[0].id,
    MarketingInboxCheckConfirmInput(placement="INBOX", imagesBlockedOk=True),
)

hechas = await sdk.mcp.marketing.campaigns.inbox_checks_list("cmp_123")
caducadas = [c for c in hechas.inbox_checks if c.expired]
```

**No concede ningún estado.** Que un correo llegue a la bandeja principal solo
lo puede confirmar una persona abriendo su buzón, y esa confirmación **caduca a
los 90 días** (`expired: true`) porque las reglas de los buzones cambian.
Consume el mismo freno que el envío de prueba y no gasta correos del plan.

## Qué se está midiendo de verdad

`deliverability.measurementCoverage()` devuelve las cinco señales (rebotes,
quejas, aperturas, clics y respuestas) con su estado, su fuente y el motivo
cuando no hay medición. **Es la operación que hay que leer antes de presentar
cualquier cifra de este espacio.**

| Estado | Qué significa |
| - | - |
| `MEASURED` | Hay fuente y la cifra se puede afirmar |
| `PARTIAL` | La cifra es un **suelo** y puede quedarse corta |
| `NOT_MEASURED` | **No tenemos forma de enterarnos.** Un cero aquí no significa nada |
| `NOT_APPLICABLE` | Esa señal no aplica a esta configuración |

`byRoute` («qué mide cada vía de salida») viaja solo con permiso de gestión: su
ausencia es falta de permiso, no ausencia de rutas.

## La puntuación puede no existir

`reputationSummary` y `dashboardSummary` admiten `periodDays` (7, 30 o 90; por
defecto 30) y el período efectivo viaja siempre en la respuesta.

### JavaScript

```typescript
const { data } = await sdk.mcp.marketing.deliverability.reputationSummary({
  periodDays: 30,
});

if (data.reputationScoreAvailable === false) {
  // NO des ninguna cifra: sin muestra el cálculo devolvía siempre 39 por
  // construcción, y no significaba nada.
  const base = data.metrics.reputationBasis;
  console.log(`Hacen falta ${base?.minAccepted} aceptados y hay ${base?.accepted}.`);
} else {
  console.log(`Salud de tus envíos: ${data.metrics.reputationScore} / 100`);
}
```

### Python

```python
from utilia_sdk.mcp.types import MarketingReputationSummaryFilters

panel = await sdk.mcp.marketing.deliverability.reputation_summary(
    MarketingReputationSummaryFilters(periodDays=30)
)

if panel.reputation_score_available is False:
    # NO des ninguna cifra: sin muestra el cálculo devolvía siempre 39.
    base = panel.metrics.reputation_basis
    print(f"Hacen falta {base.min_accepted} aceptados y hay {base.accepted}.")
else:
    print(f"Salud de tus envíos: {panel.metrics.reputation_score} / 100")
```

Las recomendaciones del panel dejaron de ser frases sueltas: llegan como objetos
con su código, la métrica, el valor, el umbral y el tamaño de la muestra, más un
`message` con la redacción única del servidor. Con cero intentos en el período
la respuesta trae exactamente `NO_DATA`, y `ALL_HEALTHY` solo aparece con
muestra por encima del suelo.

## Comprobar una dirección

`deliverability.addressCheck(email)` o `addressCheck([...])` (hasta 500)
devuelve tres bloques independientes: lo que dice **nuestro histórico**, la
**comprobación técnica del dominio** y la **evidencia de suscripción**.

> **Atención: No comprueba que el buzón exista ni que haya autorización**
>
> El veredicto `NO_BLOCKS` significa «no consta ningún impedimento nuestro». **No**
> dice que el buzón exista, ni que haya autorización para escribirle.
>
> Y hay dos ausencias que **no** significan lo mismo: `consentIncluded: false`
> quiere decir que quien pregunta **no tiene permiso** para ver la evidencia
> (hacen falta `EMAIL_MARKETING_LISTS_MANAGE` y el scope `marketing:lists:manage`)
> y entonces la clave `consent` no viaja en ninguna fila; `consent: null` sí viaja
> y quiere decir que **no consta ninguna suscripción**. Una ficha del CRM no es
> una suscripción.

El DNS se consulta una vez por dominio distinto, así que comprobar cien
direcciones del mismo dominio cuesta una sola consulta.

## Control de frecuencia

`deliverability.frequencyCapGet()` y `frequencyCapSet(input)` gobiernan cuántos
correos de campaña puede recibir la misma dirección en una ventana de días.
**Nace apagado en todas las organizaciones**: `enabled: false` significa que hoy
no se excluye a nadie por frecuencia, no que la regla sea infinita.

A quien supere el tope se le **excluye** al construir la audiencia con el motivo
`FREQUENCY_CAP`, y **eso no es un fallo de envío**. Una campaña concreta puede
saltárselo desde `campaigns.update` con `frequencyCapOverride`, explicando por
qué en `frequencyCapOverrideReason`.

Leerlo vale con el permiso de ver Marketing; cambiarlo exige el de gestión de
entregabilidad, porque decide a cuánta gente deja de llegarle el correo.

## Consentimiento, readmisión e importación

| Método | Qué hace | Permiso |
| - | - | - |
| `lists.consentHistory(listId, subscriberId)` | Todos los hechos de consentimiento, del más antiguo al más reciente. Sobrevive a la baja. | `EMAIL_MARKETING_LISTS_MANAGE` |
| `lists.resendConfirmation(listId, subscriberId)` | Reenvía el correo de doble confirmación a quien sigue pendiente. | `EMAIL_MARKETING_EDIT` |
| `lists.reactivateContact(contactId)` | Borra la **baja global** de un contacto del CRM. | `EMAIL_MARKETING_LISTS_MANAGE` |
| `lists.importPreview` / `importStart` / `importStatus` / `importStop` / `importRejected` | Supervisar una importación de suscriptores. | `EMAIL_MARKETING_LISTS_IMPORT` |

- **`reactivateContact` no es `reactivateSubscriber`.** Aquella devuelve a
  alguien que se apartó por inactividad dentro de **una** lista; esta borra la
  baja global y afecta a todas las comunicaciones. Ninguna de las dos crea
  consentimiento: si la persona no lo ha dado de nuevo, no le escribas.
- **El archivo de una importación no se sube por esta vía.** Se sube en la
  pantalla, que devuelve el testigo `previewDataKey` (caduca a los 30 minutos),
  y por integración se recalculan las cifras y se pone en marcha el trabajo con
  ese testigo. Para dar de alta direcciones que ya tienes a mano está
  `lists.addSubscribers`, que sigue exigiendo `importDeclaration`.
- **`importStop` no deshace nada:** quien ya entró se queda en la lista y solo
  se deja de procesar el resto del archivo.
- **`importRejected` exige los dos permisos**, importar **y** exportar: son
  correos de personas que no están en la lista.
- La dirección de red y el navegador del historial de consentimiento solo viajan
  con `CLIENTS_VIEW_SENSITIVE`: son dato personal del interesado, mismo criterio
  que el NIF y el IBAN.

## Rutas de envío: `sendingRoutes`

`sdk.mcp.marketing.sendingRoutes` cubre entera la herramienta transversal
`email_sending_routes` (12 operaciones). Es **por dónde sale** el correo, que no
es lo mismo que quién puede firmarlo: `email_sender_domains` responde a quién
puede firmar (los registros del DNS) y esta herramienta a quién expide el correo.
Un dominio con SPF, DKIM y DMARC en verde y una campaña entera rechazada es
exactamente esa confusión.

### JavaScript

```typescript
// Inspecciona el correo de prueba SIN enviarlo: no sale ni un byte.
const { data: informe } = await sdk.mcp.marketing.sendingRoutes.headerReport(
  'rt_1',
  'marketing@miempresa.com',
);
console.log(informe.headerReport.dkim.signed, informe.headerReport.notes);

// La prueba REAL. `ok` solo significa que el servidor de salida aceptó.
const { data: prueba } = await sdk.mcp.marketing.sendingRoutes.test(
  'rt_1',
  'marketing@miempresa.com',
);
console.log(prueba.testResult.verdicts?.inboxPlacement); // siempre 'UNKNOWN'
```

### Python

```python
# Inspecciona el correo de prueba SIN enviarlo: no sale ni un byte.
informe = await sdk.mcp.marketing.sending_routes.header_report(
    "rt_1", "marketing@miempresa.com"
)
print(informe.header_report.dkim.signed, informe.header_report.notes)

# La prueba REAL. `ok` solo significa que el servidor de salida aceptó.
prueba = await sdk.mcp.marketing.sending_routes.test(
    "rt_1", "marketing@miempresa.com"
)
print(prueba.test_result.verdicts.inbox_placement)  # siempre "UNKNOWN"
```

- **`headerReport` es una lectura**: compone el mismo correo de prueba y lo
  inspecciona en lugar de entregarlo al transporte, así que no consume el freno
  ni gasta reputación. Nunca devuelve claves privadas ni el secreto de la ruta.
  Mientras el dominio no está verificado, la prueba de una ruta sale sin firma y
  sin cabeceras de baja **a propósito**, y las notas lo dicen: ahí
  `dkim.notAttempted` es `true` y `signed: false` no es una avería.
- **`managedRetry`** saca la cuenta gestionada por UTILIA de un estado terminal
  y **dispara una sonda de envío real**. Si la organización ya envía por su
  propia ruta, responde `superseded` y no toca el transporte de nadie. No
  devuelve ninguna credencial.
- **El secreto nunca se devuelve**, ni enmascarado: solo `secretSet`. Se escribe
  con `setCredentials`, que lleva su propio permiso y confirmación.

Esta herramienta está **exenta** de la puerta del aplicativo Marketing, porque
la identidad de envío gobierna también facturas, presupuestos y portal; pero
exige la capacidad de plan «Servidor de envío propio».

## Avisos de rebote: `bounceSettings`

`sdk.mcp.marketing.bounceSettings` cubre entera `marketing_bounce_settings`
(5 operaciones): los dos canales por los que nos enteramos de un correo
devuelto. **Sin ninguno de los dos activo, los rebotes no se miden**:
`delivered` vale lo mismo que `accepted` y un cero en rebotes solo quiere decir
que no estamos mirando.

- `get()` trae la configuración, la telemetría y `webhookUrl`, la dirección que
  hay que pegar en el panel del proveedor. `lastWebhookReceivedAt: null` con el
  webhook encendido significa que **todavía no ha llegado** ningún aviso
  verificado: es lo que distingue «está configurado» de «funciona».
- `testWebhook()` da el veredicto **sin mandar nada fuera**.
- **Solo Mailgun y Resend son verificables.** De SendGrid y Postmark no sabemos
  comprobar la firma, así que sus avisos se rechazan; llegan declarados con
  `verifiable: false` y su motivo escrito.
- `regenerateWebhookSecret()` devuelve el secreto **una sola vez**.

## Cifras que cambian de significado

- **`excluded` y `excludedByReason`** en las estadísticas de campaña: los
  correos que **no salieron a propósito** (baja respetada, rebote definitivo,
  correo desactivado en su ficha, apartado por inactividad, tope de frecuencia)
  ya no cuentan como fallidos. La cifra de fallidos de las campañas ya enviadas
  **bajó** por este cambio, y no porque nada se haya arreglado.
- **`replies`** cuenta personas distintas que han contestado y vale `null`
  —nunca cero— sin buzón sincronizado, con el motivo `NO_MAILBOX_SYNC` en
  `availability.replies`.
- **`listRecipients`** admite `failureCategory` (con `EXCLUDED` para ese grupo)
  y `sendStatusIn`, y el segmento `replied` en `engagement`. Cada fila trae
  dentro de `sends[]` el ciclo de vida del envío y su `id`.
- **`campaigns.list`** ya **no devuelve el contenido** de cada campaña salvo que
  pidas `includeContent: true`. En `get` el valor por defecto sigue siendo `true`.

## Lo que el SDK no cubre

La opción `stopOnReply` de `marketing_automations` —encendida por defecto, para
que una secuencia deje de escribir a quien ya ha contestado— **no tiene método
en el SDK**. Esa herramienta sirve 23 operaciones y la regla del contrato es
cubrirla entera o no cubrirla: media herramienta obliga a mezclar métodos
tipados con llamadas a pelo. Se usa con el cliente MCP de bajo nivel o desde la
pantalla de automatizaciones.

Necesita algún buzón de correo sincronizado en la organización: sin él no se
registran las respuestas y la opción, aunque quede guardada, no puede surtir
efecto.

## Siguiente paso

- [Marketing: API y herramientas MCP](https://os.utilia.ai/docs/developers/api/marketing)
- [Herramientas MCP de Marketing](https://os.utilia.ai/docs/developers/mcp/marketing)
- [Scopes y recursos OAuth](https://os.utilia.ai/docs/developers/oauth/scopes-and-resources)
- [Errores de plan en los SDK](https://os.utilia.ai/docs/developers/sdk/plan-errors)
- [Versiones y compatibilidad de los SDK](https://os.utilia.ai/docs/developers/sdk/versions)
