
# Reemplazar el proveedor de correo electrónico

`PUT /v1/email-provider` · permiso `email-provider:write` · todos los planes

Reemplaza la forma en que el entorno envía correos electrónicos. Puede escribir cinco modos:

- `platform_default`: KeenAuth envía desde su propia dirección. Solo puede establecerse `reply_to`.
- `platform_managed_domain`: KeenAuth envía desde su propio dominio, firmado con DKIM para ese dominio. Añada el dominio con [dominios remitentes de correo electrónico](../email-sender-domains/create.md) y publique antes sus registros DNS; el dominio debe estar `verified`. Establezca `sender_domain_id` y un `from_address` que esté exactamente en ese dominio (no en un subdominio). Solo en entornos de producción. Si el dominio deja de estar verificado más adelante, el correo se envía como `platform_default` hasta que el dominio vuelva a verificarse; la configuración en sí no cambia.
- `customer_smtp`: su servidor SMTP en el puerto `465` (TLS implícito) o `587` (se requiere STARTTLS). El certificado debe ser válido para el nombre de host.
- `customer_api`: su cuenta en `ses`, `sendgrid`, `postmark`, `mailgun` o `brevo`.
- `customer_webhook`: KeenAuth genera el mensaje y lo entrega a uno de sus [endpoints de webhook](../webhook-endpoints/create.md); su aplicación lo envía (consulte [Entrega por webhook](#entrega-por-webhook)). Establezca `webhook_endpoint_id` en un endpoint habilitado de este entorno.

`If-Match` es obligatorio y contiene la versión que leyó (el `ETag` de [obtener](get.md)). Para un entorno que nunca se ha configurado, envíe `If-Match: "0"`; `*` no coincide con él. Una discrepancia devuelve `412`.

La credencial se escribe una sola vez. Envíela en `credentials`; ninguna respuesta la devuelve. Si la omite, la credencial almacenada solo se conserva cuando el modo y el destino (host SMTP, puerto y nombre de usuario, o proveedor) no cambian. En caso contrario, la solicitud falla con `/credentials` `required`.

Un cambio borra el último resultado de la prueba de conexión y escribe un registro de auditoría (`email_provider.updated`) con el modo, el proveedor y solo el identificador del dominio remitente, nunca una dirección ni la credencial. Después de un cambio, ejecute una [prueba de conexión](test.md).

## Solicitud

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `mode` | string | sí | `platform_default`, `platform_managed_domain`, `customer_smtp`, `customer_api` o `customer_webhook`. |
| `sender_domain_id` | string | `platform_managed_domain` | Un dominio remitente `verified` de este entorno (`sdom_…`). No se permite en otros modos. |
| `webhook_endpoint_id` | string | `customer_webhook` | Un [endpoint de webhook](../webhook-endpoints/get.md) habilitado de este entorno (`whe_…`). No se permite en otros modos. |
| `from_address` | string | todos excepto `platform_default` | Dirección remitente simple, como `noreply@example.com`. En `platform_managed_domain`, su dominio debe ser exactamente el dominio remitente. No se permite en `platform_default`. |
| `reply_to` | string | no | Dirección de respuesta simple. |
| `provider` | string | `customer_api` | `ses`, `sendgrid`, `postmark`, `mailgun` o `brevo`. |
| `smtp_host` | string | `customer_smtp` | Nombre DNS con al menos dos etiquetas (sin dirección IP). |
| `smtp_port` | integer | `customer_smtp` | `465` o `587`. |
| `smtp_username` | string | `customer_smtp` | Nombre de usuario de SMTP AUTH, de 1 a 256 caracteres. |
| `credentials` | string | véase arriba | Solo escritura, de 1 a 1024 bytes. `customer_smtp`: la contraseña SMTP. `customer_api`: `<region>:<access key id>:<secret access key>` para `ses`; la clave de API para `sendgrid` y `mailgun` (con el prefijo `eu:` para el endpoint de la UE del proveedor); el token del servidor para `postmark`; la clave de API para `brevo`. |

## Respuesta

`200 OK` con la configuración almacenada (misma forma que [obtener](get.md)) y `ETag` establecido en la nueva versión.

## Errores

| HTTP | código | Cuándo |
|---|---|---|
| 400 | `unknown_field` | El cuerpo tiene un campo desconocido o no es un objeto JSON. |
| 412 | `precondition_failed` | `If-Match` no es la versión almacenada (alguien guardó en el intervalo), o es `*` antes de la primera escritura. |
| 413 | `payload_too_large` | El cuerpo supera los 16 KiB. |
| 428 | `precondition_required` | Falta `If-Match`. |
| 422 | `validation_failed` | Un campo no supera la validación; `errors[]` indica el campo y la regla (véase abajo). |
| 401 | `unauthenticated` | La credencial falta, no es válida o fue revocada. |
| 403 | `insufficient_permissions` | La credencial no tiene el permiso requerido. |
| 429 | `rate_limited` | Se superó el límite de frecuencia; reintente después del período `Retry-After`. |
| 503 | `service_unavailable` | Una dependencia no está disponible temporalmente; reintente después del período `Retry-After`. |

Reglas en `errors[]`:

| Puntero | Regla | Significado |
|---|---|---|
| `/mode` | `required`, `invalid` | El modo falta o es desconocido. |
| `/mode` | `delivery_mode_unavailable` | El modo no puede enviar en esta implementación: `customer_webhook` requiere que la conexión de entrega por webhook esté habilitada, `platform_managed_domain` requiere que la firma DKIM esté habilitada, y los modos de cliente requieren que la entrega saliente esté habilitada. |
| `/mode` | `production_only` | `platform_managed_domain` solo se permite en entornos de producción. |
| `/mode` | `fields_not_allowed` | Se envió un campo que el modo no acepta. `customer_webhook` no acepta `provider`, campos SMTP, `credentials` ni `sender_domain_id`. |
| `/sender_domain_id` | `required`, `invalid` | Se envió `platform_managed_domain` sin un identificador de dominio remitente, o el identificador no es un identificador `sdom_…`. |
| `/sender_domain_id` | `not_found` | Este entorno no tiene un dominio remitente con ese identificador. Un dominio de otro entorno recibe la misma respuesta. |
| `/sender_domain_id` | `not_verified` | El dominio remitente existe pero no está `verified` (registros DNS pendientes, degradado o deshabilitado). |
| `/from_address` | `not_in_sender_domain` | La dirección remitente no está exactamente en el dominio remitente. |
| `/webhook_endpoint_id` | `required`, `invalid` | Se envió `customer_webhook` sin un identificador de endpoint, o el identificador no es un identificador `whe_…`. |
| `/webhook_endpoint_id` | `not_found` | Este entorno no tiene un endpoint de webhook con ese identificador. Un endpoint de otro entorno recibe la misma respuesta. |
| `/webhook_endpoint_id` | `not_enabled` | El endpoint existe pero no está habilitado. |
| `/credentials` | `required` | El modo o el destino cambió y no se envió una credencial nueva. |
| `/credentials` | `invalid` | La credencial está vacía, es demasiado larga, tiene caracteres de control o no coincide con el formato del proveedor. |
| `/from_address`, `/reply_to`, `/provider`, `/smtp_host`, `/smtp_port`, `/smtp_username` | `required`, `invalid` | El campo falta o no es válido para el modo. |

## Entrega por webhook

En el modo `customer_webhook`, KeenAuth no envía el mensaje y nunca recurre a su propio envío. Por cada mensaje que genera (asunto, HTML y texto), publica un evento firmado en el endpoint a través de HTTPS:

- La solicitud es `POST` con `Content-Type: application/json` y los encabezados Standard Webhooks v1 `webhook-id`, `webhook-timestamp` y `webhook-signature`. `webhook-id` es el identificador del mensaje (`msg_…`) y es el mismo en cada intento del mismo mensaje, así que deduplique por ese valor. El tipo de evento no tiene que estar en la suscripción de eventos del endpoint: el endpoint lo recibe porque es el endpoint de entrega.
- Un éxito es cualquier respuesta `2xx`. No se siguen redirecciones y cuentan como fallos. Un intento agota el tiempo de espera a los 10 segundos.
- Un intento fallido se reintenta 15 segundos después, con un máximo de 3 intentos en total. Después del tercer fallo, el mensaje se marca como fallido y no se envía ningún correo. Un endpoint que se elimine o deshabilite después de guardar la configuración hace que el mensaje falle de inmediato, sin intento.
- El servicio de webhook no almacena el contenido del mensaje. Envíelo solo a endpoints de confianza: el cuerpo incluye la dirección del destinatario y cualquier código de inicio de sesión o enlace.

Cuerpo (`schema_version` 1):

```json
{
  "id": "msg_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "type": "email.send_requested",
  "schema_version": 1,
  "environment_id": "env_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "occurred_at": "2026-10-27T09:00:00Z",
  "data": {
    "message_id": "msg_01k7xq3m5r8t9v0w1x2y3z4a5b",
    "family": "email_otp",
    "locale": "en",
    "to": "ada@example.com",
    "from": "auth@example.com",
    "from_name": "Example",
    "reply_to": "support@example.com",
    "subject": "Your sign-in code",
    "html": "<p>Your code is 123456</p>",
    "text": "Your code is 123456",
    "link": "https://example.keenauth.app/...",
    "code": "123456"
  }
}
```

| Campo de `data` | Descripción |
|---|---|
| `message_id`, `family`, `locale` | El identificador del mensaje (igual que el `id` del sobre), la familia de plantillas y el idioma en el que se generó el mensaje. |
| `to` | Dirección del destinatario. |
| `from`, `from_name`, `reply_to` | Presentes solo cuando están establecidos: `from` es el `from_address` de esta configuración. |
| `subject` | Asunto generado. |
| `html`, `text` | Contenido generado; al menos uno está presente. |
| `link`, `code` | Presentes solo cuando el mensaje contiene un enlace de inicio de sesión o un código de un solo uso. |

La firma es `v1,` más el base64 de `HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw body>")`. La clave es el valor de 32 bytes codificado por los 43 caracteres base62 que siguen al prefijo `idp_whsec_` del secreto de firma del endpoint. Verifique el cuerpo de la solicitud sin procesar, antes de analizarlo, y rechace una marca de tiempo que difiera más de 300 segundos de su reloj. Durante una rotación de secretos, `webhook-signature` contiene dos firmas separadas por espacios.

Python:

```python
import base64, hashlib, hmac, time

ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
PREFIX = "idp_whsec_"


def signing_key(secret: str) -> bytes:
    """The HMAC key is the 32-byte value behind the 43 base62 characters after the prefix."""
    body = secret[len(PREFIX):len(PREFIX) + 43]
    n = 0
    for ch in body:
        n = n * 62 + ALPHABET.index(ch)
    return n.to_bytes(32, "big")


def verify(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
    msg_id = headers["webhook-id"]
    ts = headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > tolerance:
        return False
    signed = f"{msg_id}.{ts}.".encode() + raw_body
    mac = hmac.new(signing_key(secret), signed, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(mac).decode()
    # webhook-signature holds one or more space-separated signatures.
    return any(hmac.compare_digest(expected, s) for s in headers["webhook-signature"].split(" "))
```

Node.js:

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const PREFIX = "idp_whsec_";

// The HMAC key is the 32-byte value behind the 43 base62 characters after the prefix.
function signingKey(secret) {
  let n = 0n;
  for (const ch of secret.slice(PREFIX.length, PREFIX.length + 43)) n = n * 62n + BigInt(ALPHABET.indexOf(ch));
  return Buffer.from(n.toString(16).padStart(64, "0"), "hex");
}

export function verify(secret, headers, rawBody, tolerance = 300) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false;
  const mac = createHmac("sha256", signingKey(secret)).update(`${id}.${ts}.`).update(rawBody).digest();
  const expected = Buffer.from(`v1,${mac.toString("base64")}`);
  // webhook-signature holds one or more space-separated signatures.
  return headers["webhook-signature"].split(" ").some((s) => {
    const got = Buffer.from(s);
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

## Ejemplo

```bash
curl -X PUT https://api.eu.keenauth.com/v1/email-provider \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "0"' \
  -d @email-provider.json
```

`email-provider.json`:

```json
{
  "mode": "customer_smtp",
  "from_address": "no-reply@example.com",
  "smtp_host": "smtp.example.com",
  "smtp_port": 587,
  "smtp_username": "mailer",
  "credentials": "<SMTP password>"
}
```

Para entregar mensajes a un endpoint de webhook:

```json
{
  "mode": "customer_webhook",
  "webhook_endpoint_id": "whe_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "from_address": "auth@example.com"
}
```

En este modo no hay conexión con [probar](test.md); pruebe el endpoint con [Probar un endpoint de webhook](../webhook-endpoints/test.md).

Para enviar desde su dominio remitente verificado:

```json
{
  "mode": "platform_managed_domain",
  "sender_domain_id": "sdom_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "from_address": "auth@mail.example.com",
  "reply_to": "support@example.com"
}
```

En este modo no hay conexión con [probar](test.md); compruebe el dominio con [verificar](../email-sender-domains/verify.md) en su lugar.

CLI: `keenauth email-provider put --if-match '"0"' --data @email-provider.json`. Guarde la credencial en un archivo o páselala por la entrada estándar (`--data -`) para que no quede en el historial de su shell.
