Saltar al contenido

Envía el correo de tu propia app

Un correo transaccional es el que tu app le debe a una persona: un recibo, un enlace de acceso, un restablecimiento de contraseña. Sale de un buzón reservado para eso, o de una dirección de remitente que envía por nuestros servidores de correo, exactamente como lo escribiste. Empieza leyendo lo que tu app ya envió. Esto no envía nada.

import { Colding } from "colding";

const colding = new Colding("cd_your_key");
const page = await colding.sends.list({ kind: "transactional" });
for (const send of page.items) console.log(send.to, send.status);

Cambia la clave por la tuya. kind: "transactional" deja fuera todos los demás tipos de correo. La lista está vacía hasta que tu app envíe el primero.

Ejemplo de salida, para un correo que se envió
[email protected] sent

Mira qué necesitas para enviar uno

Qué necesitas antes

Usa una clave de la primera guía. Cada permiso cubre un tipo de acción, y uno no incluye al otro.

  • El permiso transactional en la clave. Una clave solo con send no envía estos correos, y una clave solo con transactional no envía campañas. Para seguir un correo después, la clave también necesita read.
  • De dónde sale: un buzón reservado para los correos de tu app, con purpose: "transactional" (la siguiente sección muestra cómo definirlo), o una dirección de remitente (la sección Direcciones de remitente).
  • Un plan con correo transaccional. El plan gratuito no lo tiene, y la llamada responde 402 PLAN_GATE.
  • El dueño del workspace debe haber aceptado la política de uso aceptable. Hasta entonces, el envío responde 403 AUP_REQUIRED.
  • Una dirección confirmada desde la que enviar. Que los datos de acceso funcionen prueba la contraseña, no que la dirección sea tuya. Por eso un dueño confirma la dirección de un buzón en la consola. Hasta entonces, la llamada responde 409. Una dirección de remitente no lo necesita: los registros de su dominio ya la prueban.

Mira cómo se conectan los buzones

Un plan de pago deja que un buzón envíe de 200 a 1.000 de estos correos al día, según el plan. La cuenta empieza de nuevo a medianoche, en la zona horaria del workspace.

Mira los límites de cada plan

Reserva un buzón para esto

Todo buzón tiene un solo propósito a la vez, el purpose: cold para campañas y mensajes, o transactional para los correos de tu app. Un buzón transaccional no envía campañas, y el otro no envía los correos de tu app. Si intentas enviar el tipo equivocado desde un buzón, la llamada responde 409.

Define el propósito con mailboxes.update. La clave necesita operate y transactional, y el plan debe incluir correo transaccional:

const mailbox = await colding.mailboxes.update({
  mailboxId,
  purpose: "transactional",
});
console.log(mailbox.purpose);

Imprime transactional. Volver a cold es la misma llamada, y también pide transactional. Un buzón transaccional sigue recibiendo correo, y puedes seguir respondiendo desde él.

Un buzón desde el que salen campañas, o que tiene correos programados, no puede pasar a transaccional. La llamada responde 409, y details.campaigns lista esas campañas con ID y nombre. Quita el buzón de ellas, deja salir sus correos programados o cancélalos, y llama de nuevo.

Un buzón solo de envío no tiene bandeja de entrada. Son los datos de acceso SMTP de un servicio de envío, y siempre es transaccional. Un dueño lo conecta en la consola. Un agente pide esa conexión con un handoff.

Mira cómo se conecta un buzón solo de envío

Pon los correos de tu app en su propio subdominio, como mail.example.com, aparte del dominio de tus campañas. Así es menos probable que los rebotes y las denuncias de spam de una campaña lleguen a tus recibos. La consola lo sugiere cuando los dos tipos comparten un dominio. Es un consejo, no una regla.

Direcciones de remitente

Las direcciones de remitente todavía no están en ningún plan a la venta. Hasta que nuestro equipo active el envío desde nuestros servidores de correo para tu workspace, crear una responde 402 PLAN_GATE con el messageKey serverErrors.hostedTransactionalClosed. Lo activamos para una empresa a la vez: escribe a [email protected].

Una dirección de remitente es la dirección From del correo de tu app, como [email protected]. No tiene contraseña ni inicio de sesión, y está en un dominio de correo tuyo preparado para correo transaccional. No recibe correo.

Una clave no puede crearlas. El dueño las crea en la consola, en Dominios, cuando el dominio está Activo. Un agente se lo pide a un dueño con un handoff manage_sender_addresses. Detrás de la consola hay tres operaciones que solo el dueño puede llamar: create_sender_address, update_sender_address y delete_sender_address. Una clave recibe 403 en ellas.

Toda dirección de remitente tiene un replyTo, la dirección a la que van las respuestas. Nosotros no recibimos el correo del dominio, así que una respuesta a la propia dirección de remitente no llegaría a nadie. Indica una dirección que alguien lea, como la de tu soporte. No puede ser la propia dirección de remitente.

Una clave lee direcciones de remitente con senderAddresses.list y senderAddresses.get. Cambia la clave por la tuya.

const page = await colding.senderAddresses.list();
for (const sender of page.items) console.log(sender.id, sender.address, sender.replyTo);

Cada una tiene id, address, domainId, displayName, replyTo, createdAt y createdBy. El id es el mailboxId que nombra un envío. Acota la lista con domainId. La lista va paginada: devuelve nextCursor como cursor. Una dirección de remitente eliminada no aparece en la lista, y pedirla responde 404.

Para pedir una, crea un handoff, entrega el enlace al dueño y lee el resultado. Cuando el dueño crea la dirección, el handoff queda completed y resultMailboxId es el ID de la nueva dirección de remitente.

const request = await colding.handoffs.create({
  kind: "manage_sender_addresses",
  note: "Add [email protected]. Replies go to [email protected].",
});
console.log(request.url);

// Later, after the owner has opened the link:
const done = await colding.handoffs.get({ handoffId: request.id });
if (done.resultMailboxId) {
  const sender = await colding.senderAddresses.get({ senderAddressId: done.resultMailboxId });
  console.log(sender.address, sender.replyTo);
}

Una dirección desde la que tu app ya envía, como buzón conectado o como buzón solo de envío, no puede ser también una dirección de remitente. Para cambiarla, desconecta primero el buzón. Eso elimina los mensajes guardados aquí para él (el correo en tu proveedor se queda), y la dirección de remitente recibe un ID nuevo, que tu app debe usar en los envíos. Hasta que cambies el ID en la configuración de tu app, un envío que nombre el ID anterior responde 404.

Para enviar desde una dirección de remitente, pasa su id como mailboxId.

Mira cómo funciona el envío desde nuestros servidores

Envía un correo

transactional.send envía un correo a una persona, desde un buzón o una dirección de remitente:

const send = await colding.transactional.send({
  mailboxId: "66666666-6666-4666-8666-666666666666",
  to: "[email protected]",
  subject: "Your sign-in link",
  bodyText: "Open this link to sign in: https://app.example.com/in/abc123",
  intentKey: "sign-in-link-user-42-2030-01-15",
  expiresAt: "2030-01-15T10:15:00Z",
});
console.log(send.id, send.status);

La llamada devuelve el correo con status reserved, que aparece como A punto de salir en la consola. Ya cuenta entre los envíos de hoy, pero todavía no salió. El worker, nuestro proceso de envío en segundo plano, se encarga de enviarlo.

Envía bodyHtml, bodyText o los dos. El correo sale exactamente como lo escribiste: sin firma, sin línea para darse de baja, sin seguimiento de aperturas ni de clics, sin espera entre correos y sin ventana para deshacer. toName define el nombre en la línea Para, y replyTo dice adónde van las respuestas.

Define expiresAt para un código o enlace que deja de servir, con fecha, hora y desfase horario. Si el correo no empezó a salir para entonces, se cancela con el errorCode expired y nunca se envía. Una hora que ya pasó responde 400.

Puedes cancelar un correo que aún no empezó a salir con sends.cancel. Pide el permiso transactional. Una vez que el correo empezó a salir, ya no se puede cancelar, y la llamada responde 409.

Ponle nombre a cada correo

El intentKey es obligatorio. Es tu nombre para un correo, de hasta 255 caracteres, como receipt-order-1042. Usa algo que tu app ya tenga, como el número del pedido.

Envía la misma petición otra vez y recibes el mismo correo de vuelta. No se envía nada más. Esto vale para siempre, no solo durante 24 horas.

const request = {
  mailboxId: "66666666-6666-4666-8666-666666666666",
  to: "[email protected]",
  subject: "Receipt for order 1042",
  bodyText: "Thanks for your order. The receipt is in your account.",
  intentKey: "receipt-order-1042",
};
const first = await colding.transactional.send(request);
const again = await colding.transactional.send(request);
console.log(first.id === again.id);
Ejemplo de salida: la segunda llamada devolvió el primer correo
true

La petición es el buzón, la dirección, el nombre, el asunto, los dos cuerpos, el expiresAt y el replyTo. Arma la petición una vez y envía esa misma copia en cada reintento. Guarda el intentKey antes de enviar, para que una caída no lo pierda.

El mismo intentKey con otra petición se rechaza con 422 IDEMPOTENCY_KEY_REUSED, y no se envía nada. Para enviar un segundo correo a propósito, como un recibo otra vez, usa un intentKey nuevo.

Un reintento se responde antes de comprobar el plan, el pago y el expiresAt. Así que un reintento que llega tarde sigue recibiendo su correo, aunque el pago haya fallado mientras tanto.

Los nombres que empiezan con draft:, warmup: o campaign: son nuestros y se rechazan.

Desde nuestros servidores

Pasa el id de una dirección de remitente como mailboxId. Nada más cambia en la llamada: el mismo intentKey, el mismo cuerpo, el mismo expiresAt. El correo sale de nuestros servidores de correo, así que tu app no necesita un buzón propio para enviarlo.

const send = await colding.transactional.send({
  mailboxId: "77777777-7777-4777-8777-777777777777",
  to: "[email protected]",
  subject: "Your sign-in link",
  bodyText: "Open this link to sign in: https://app.example.com/in/abc123",
  intentKey: "sign-in-link-user-42-2030-01-16",
});
console.log(send.status, send.viaOurServers);
Ejemplo de salida: el estado y luego si salió por nuestros servidores
reserved true

La llamada responde como cualquier envío, con status reserved. viaOurServers es true cuando el correo sale por nuestros servidores y false cuando sale por un buzón que conectaste.

Aquí, sent significa que nuestro servidor de correo recibió el correo y lo está pasando al servidor de la persona. No significa que llegó a la bandeja de entrada. Cuando el servidor de la persona responde, send.delivered avisa de que lo aceptó, o send.bounced avisa de que lo rechazó para siempre. El rebote llega después de sent, y el estado sigue siendo sent.

Hay dos límites. expiresAt solo detiene un correo que nuestro servidor todavía no recibió. Una vez que lo recibe, sigue intentando hasta que el servidor de la persona responda, así que un código puede llegar después de vencido. Y, en casos raros, el servidor de la persona acepta el correo pero su respuesta se pierde por el camino, y nuestro servidor lo envía otra vez: la persona lo recibe dos veces.

Cuando nuestros servidores dicen que no

Una dirección de remitente puede rechazarse por más motivos que un buzón. Ninguno reserva nada, así que la misma petición puede enviarse de nuevo. resetAt dice cuándo.

402 hostedTransactionalClosed
Tu workspace todavía no está habilitado para enviar correo desde nuestros servidores. Nuestro equipo lo habilita para una empresa a la vez: escribe a [email protected].
429 hostedTransactionalDay
Tu workspace ya envió hoy todos los correos que puede enviar desde nuestros servidores. Ese número es solo de tu workspace. Espera hasta resetAt, la medianoche en la zona horaria del workspace.
429 domainRamp
Un dominio nuevo en nuestros servidores empieza con pocos correos al día y puede enviar un poco más cada día que envía. details.limit es el número de hoy. Vuelve a empezar en resetAt, a la medianoche UTC.
429 ipCeiling429 ipShare
Estos son límites de nuestros servidores, no de tu plan. Otras empresas envían por los mismos servidores, y cada una tiene un espacio reservado para sus propios correos. Vuelven a empezar en resetAt, a la medianoche UTC.
429 receiverPace
El proveedor de correo de la persona pidió a nuestro servidor que fuera más despacio, o nuestro equipo detuvo por un tiempo el correo hacia ese proveedor. details.receiver dice cuál. Vuelve a enviar después de resetAt.
409 senderIpNotReady409 mailIpHeld
El problema es de nuestro lado: todavía no hay un servidor nuestro habilitado para correo de apps, o el servidor por el que sale tu correo está en espera. No se envió nada. Envía la misma petición de nuevo más tarde.
409 mailDomainNotSigning
Los registros DNS de tu dominio no pasaron nuestra comprobación, así que no podemos firmar su correo. No sale nada por él hasta que los registros vuelvan a pasar. Corrígelos en la consola, en Dominios.
409 senderDomainUnderReview
Nuestro equipo puso en espera el dominio de la dirección de remitente porque demasiados correos suyos rebotaron o se marcaron como spam. No hay resetAt: no sale nada por el dominio hasta que nuestro equipo lo active de nuevo. details.caseNumber es el número que debes citar. Más abajo en esta página verás cuándo ponemos un dominio en espera y cómo termina la espera.

El número diario de tu workspace vuelve a empezar a la medianoche en la zona horaria del workspace. El límite diario de un dominio nuevo y los límites de nuestros servidores vuelven a empezar a la medianoche UTC.

Con domainRamp, ipCeiling o ipShare, si tu app ya tiene otra dirección confirmada y conectada para correo transaccional, envía el mismo contenido desde ella, con su propio intentKey. El destinatario verá esa otra dirección como remitente. Si no la tienes, vuelve a enviar la misma petición después de resetAt. Con hostedTransactionalDay, espera hasta resetAt: ese número es solo de tu workspace.

Configura otra forma de enviar antes de necesitarla

Todavía no se pueden adjuntar archivos a un envío.

Configura otra forma de enviar antes de necesitarla

  • Antes de necesitarla, conecta otra dirección para correo transaccional: un buzón reservado para eso o el acceso de tu servicio de envío. Confirma la dirección del remitente. No puede ser la dirección de remitente que usas desde nuestros servidores.
  • Guarda su mailboxId en la configuración de tu app como segundo remitente.
  • Úsala solo después de un 429 que no reservó nada, con details.scope igual a domainRamp, ipCeiling o ipShare. Envía el mismo contenido desde ese mailboxId, con su propio intentKey. Se rechaza un correo distinto con la misma clave. Si se acepta este envío, no lo vuelvas a intentar por la primera vía. Nunca envíes una segunda copia de un correo que ya está sent o unknown.
  • El destinatario verá la segunda dirección como remitente, no tu dirección de remitente en nuestros servidores.
  • hostedTransactionalDay es el número diario solo de tu workspace. En ese caso, espera hasta resetAt.

Síguelo hasta el final

sends.get responde por un correo. sends.list responde por varios, del más nuevo al más antiguo, y kind: "transactional" deja solo estos. Los dos piden read. El estado solo avanza. Estos correos no se pueden programar, así que scheduled nunca aparece:

reserved
A punto de salir. Ya cuenta entre los envíos de hoy y espera su turno.
sending
Saliendo ahora.
sent
El servidor de correo por el que salió lo aceptó: el del proveedor del buzón que conectaste o, en una dirección de remitente, el nuestro. Eso no significa que llegó a la bandeja de entrada.
failed
No salió. El errorCode dice el motivo.
unknown
La conexión se cortó mientras salía, así que no sabemos si el servidor lo recibió. Cuenta como enviado y nunca se envía de nuevo.
cancelled
Se detuvo antes de salir: una persona o una clave lo canceló, o una comprobación lo rechazó cuando llegó su hora. El errorCode dice el motivo.

Para saber cómo terminó cada correo sin preguntar, lee los eventos. Todo envío termina en send.sent, send.failed, send.unknown, send.cancelled o send.bounced, y los correos de campaña también, así que el ejemplo comprueba el kind:

const page = await colding.events.list({
  types: ["send.sent", "send.failed", "send.unknown", "send.cancelled", "send.bounced"],
});
for (const event of page.items) {
  if ("kind" in event.data && event.data.kind === "transactional")
    console.log(event.type, event.subjectId);
}

El subjectId es el ID del correo. Estos correos no tienen send.opened ni send.clicked, porque no llevan seguimiento.

sent significa que el servidor de correo por el que salió lo aceptó: el de tu proveedor, en un buzón que conectaste, o el nuestro, en una dirección de remitente. Ninguno de los dos dice que llegó a la bandeja de entrada. Un rebote puede llegar después. Entonces se dispara send.bounced, y bouncedAt dice cuándo.

Las respuestas llegan a la bandeja de entrada, o a replyTo si defines uno. Un buzón solo de envío no tiene bandeja de entrada, y una dirección de remitente tampoco: las respuestas van a replyTo o, si no lo defines, a la dirección de respuesta con la que se creó la dirección de remitente.

Lee las respuestas en la guía de la bandeja de entrada

Cuando se rechaza o se detiene

Una llamada rechazada no envía nada. Estas son las respuestas que puede recibir un envío:

400 VALIDATION_ERROR
La petición está mal: sin cuerpo, una dirección inválida o un expiresAt que ya pasó.
402 PLAN_GATE
El plan no tiene correo transaccional. Cambiar de plan lo resuelve, y details.upgradeTo dice qué plan lo tiene. En una dirección de remitente, también puede ser que nuestro equipo todavía no haya habilitado nuestros servidores para el workspace.
402 PAYMENT_REQUIRED
El último pago falló. Pagar lo resuelve.
403 FORBIDDEN
La clave no tiene transactional, o un dueño la limitó: a algunos buzones, a un tipo de correo o a un total de correos. details.scope dice cuál.
403 AUP_REQUIRED
El dueño todavía no aceptó la política de uso aceptable.
404 NOT_FOUND
No existe ese buzón ni esa dirección de remitente en este workspace. Uno de otro workspace responde lo mismo.
409 CONFLICT
Algo del buzón, de la dirección o del workspace bloquea este envío. El mensaje dice qué: el buzón no es transaccional, su dirección no está confirmada, el destinatario detuvo los correos de tu app o, en una dirección de remitente, un servidor nuestro no está listo o nuestro equipo puso su dominio en espera.
422 IDEMPOTENCY_KEY_REUSED
Este intentKey ya nombra otro correo.
429 QUOTA_EXCEEDED
El buzón ya envió todos los correos de la app de hoy, o la clave llegó a su límite diario. En una dirección de remitente también puede ser el espacio de nuestros servidores: la sección Desde nuestros servidores lista cada caso. details.scope dice cuál, y resetAt dice cuándo se puede volver a enviar.
429 RATE_LIMIT_EXCEEDED
El proveedor de correo de la persona pidió a nuestro servidor que fuera más despacio. Esto solo ocurre en una dirección de remitente. details.scope es receiverPace, y la sección Desde nuestros servidores lo explica.

La primera guía lista todos los códigos de error y qué hacer con cada uno.

Detenido antes de salir

Una llamada puede salir bien y el correo aun así no salir. Poco antes de entregarlo, el worker mira una vez más. Si algo cambió, cancela el correo con un errorCode y no envía nada:

expired
El expiresAt pasó antes de que el correo empezara a salir.
recipient_suppressed
La dirección entró en la lista de supresión de los correos de la app después de tu llamada.
authority_revoked
La clave o la persona que lo envió ya no puede enviar correos de la app.
key_stream_scopekey_mailbox_scope
Un dueño limitó la clave después de tu llamada, y ya no puede enviar este tipo de correo ni enviar desde este buzón.
payment_failed
Un pago falló después de tu llamada.
workspace_posture
Limitamos el workspace antes de que el correo saliera, o la cuenta que lo envió ya no puede enviar.
workspace_closing
El dueño pidió eliminar el workspace.
sender_unconfirmed
La dirección del buzón no estaba confirmada como del workspace.
mailbox_disconnected
El buzón se desconectó, o la dirección de remitente se eliminó, después de tu llamada.
mail_ip_held
El servidor nuestro por el que saldría el correo quedó en espera después de tu llamada.
domain_not_signing
El dominio de la dirección de remitente no pasó nuestra comprobación después de tu llamada, así que no podemos firmar su correo.
mailbox_paused
El buzón se pausó, o nuestro equipo puso en espera el dominio de la dirección de remitente, después de tu llamada.

failed es distinto: el correo se intentó, y el servidor o el buzón lo rechazó. El errorCode dice el motivo. Para enviarlo de nuevo, usa un intentKey nuevo, porque el anterior sigue nombrando el correo que falló.

relay_refused es cosa nuestra: nuestro servidor de correo rechazó un correo de una dirección de remitente. No es un rebote, nadie entra en la lista de supresión y el correo no cuenta en el número de hoy. Envíalo de nuevo más tarde, con un intentKey nuevo.

Cuándo ponemos un dominio de correo en espera

Vigilamos el correo de tu app que sale por nuestros servidores, un dominio de correo a la vez. Si demasiados correos rebotan o se marcan como spam, ponemos el dominio en espera. Todas sus direcciones de remitente dejan de enviar hasta que nuestro equipo vuelva a activarlas. Los demás dominios de correo y los buzones que conectaste siguen enviando.

Contamos los últimos 7 días. Un rebote cuenta cuando el servidor de correo de la persona rechazó la dirección. Un rebote sin motivo no cuenta, y un retraso de nuestra parte tampoco. Un correo que la persona marca como spam cuenta una vez.

Ponemos un dominio en espera cuando 0,5% de sus correos se marcaron como spam o 10% rebotaron. Con menos de 50 correos en esos días, ninguna tasa cuenta. Para el spam se piden además al menos 3 personas distintas que marcaron un correo, para que una sola persona no pueda poner tu dominio en espera por sí sola. Para los rebotes se piden además al menos 10 rebotes hacia 3 o más dominios de destinatario distintos (lo que va después de la @), para que el servidor de una sola empresa no pueda poner tu dominio en espera por sí solo. Estos son los números de hoy y pueden cambiar.

Un servidor de destino también puede rechazar tu correo por no deseado. Solo ponemos el dominio en espera por eso cuando otros remitentes de nuestros servidores no reciben el mismo rechazo de ese servidor.

Mientras dura la espera, un envío desde cualquier dirección de remitente del dominio responde 409 con senderDomainUnderReview. details.caseNumber es el número de nuestro caso. details.ground dice qué regla puso el dominio en espera: complaint_rate, bounce_rate, policy_bounce_rate o policy_abuse_burst. Un correo que ya esperaba para salir se cancela con mailbox_paused. Tampoco puedes agregar una dirección de remitente en el dominio.

Solo nuestro equipo vuelve a activar los envíos, y el conteo empieza de cero cuando lo hace. Escribe a [email protected] y cita el número del caso.

El workspace recibe una alerta mail_domain_problem cuando empieza la espera (under_review) y otra cuando termina (review_ended), y cada dirección de remitente dispara mailbox.review_changed. La página del dominio en la consola muestra los últimos 7 días y la espera.

También leemos el correo de tu app como un grupo aparte cuando juzgamos el workspace completo. Las tasas son las mismas, pero solo cuentan a partir de 200 correos en esos días. Las marcas de spam siguen pidiendo 3 personas distintas, y los rebotes siguen pidiendo 3 o más dominios de destinatario. Si tu correo las supera, o si un dominio queda en espera en varios días distintos, podemos limitar el workspace. Entonces posture.reasonCode trae transactional_bounce_rate, transactional_complaint_rate o sending_pauses.

Mira las alertas en la guía del workspace

Quién deja de recibirlo

Los correos de tu app tienen su propia lista de supresión. Una dirección puede detener campañas y mensajes (cold en la API), los correos de tu app (transactional) o ambos. Qué detiene a qué depende de lo que volvió y de qué correo se trata:

La dirección rebotó, en cualquier correo
Ambos
Una persona marcó como spam un correo de campaña o mensaje
Solo campañas y mensajes
Una persona marcó como spam un correo de tu app
Ambos
Una persona respondió pidiendo que paren, a un correo de campaña o mensaje
Solo campañas y mensajes
Una persona respondió pidiendo que paren, a un correo de tu app
Ambos

Una baja de tus campañas nunca detiene los correos de tu app.

Ninguna comprobación de la dirección va antes, porque la persona pidió este correo. Solo se lee la lista de supresión, dos veces: cuando haces la llamada y de nuevo poco antes de que salga el correo.

Mira qué detuvo los correos de tu app:

const page = await colding.suppressions.list({ stream: "transactional" });
for (const row of page.items) console.log(row.value, row.source, row.twinSource);

source dice por qué está cada fila. twinSource dice por qué la misma dirección está en la otra lista, o es null cuando no está.

suppressions.add agrega una dirección con stream: "transactional" y pide operate. Quitar una de la lista le toca a un dueño o admin con sesión iniciada en la consola. Ninguna clave puede.

suppression.added se dispara por cada fila nueva, con su stream y su source.

Informes de un servicio de envío

Un buzón solo de envío no tiene bandeja de entrada, así que un rebote no vuelve como correo. El servicio de envío avisa de otra manera: un informe enviado a una dirección nuestra, el webhook de informes del buzón. Leemos Mailgun, Postmark, Resend, SendGrid y Amazon SES. Un informe solo cuenta cuando el servicio demuestra que es suyo y nombra un correo que este buzón envió.

El dueño activa el webhook en la consola y copia su dirección en el servicio. Una clave no puede, porque el webhook guarda o muestra un secreto. La clave lee el resto:

const hook = await colding.mailboxes.reportHook({ mailboxId });
console.log(hook.service, hook.on, hook.lastReportAt);

const reports = await colding.mailboxes.reports({ mailboxId });
for (const report of reports) console.log(report.kind, report.outcome);

reportHook dice si leemos este servicio (supported) y si el webhook está activo (on). También trae la hora del último informe (lastReportAt) y cuántos llegaron en los últimos 30 días, por resultado (counts). Nunca muestra el secreto.

Amazon SES no tiene secreto. El dueño nos da el tema de SNS. El webhook dice cuándo se confirmó la suscripción de AWS y cuál fue el último mensaje que rechazamos, con el motivo (refusal).

reports lista lo que llegó, del más nuevo al más antiguo: 20 por defecto, hasta 100. Cada uno dice si fue un rebote o una denuncia y qué pasó con él. No trae la dirección ni las palabras del servicio. Los informes se guardan 30 días.

El resultado dice qué hicimos:

suppressed
El informe dijo que la dirección ya no existe, o una persona denunció. El destinatario de ese correo está en la lista de supresión.
stamped
Un fallo permanente que no dice que la dirección ya no existe, como un rechazo sobre el mensaje o sobre nosotros. El correo queda marcado como rebotado, y nadie entra en la lista.
no_send
Ningún correo de este buzón coincide con el informe. No cambió nada.
other_recipient
Nombra a alguien a quien el correo no fue. No cambió nada.
not_final
Una demora, o un fallo que el servicio va a reintentar. No cambió nada.

Solo el destinatario del correo al que se refiere el informe entra en la lista. Una dirección nombrada dentro de un informe nunca entra.

El código que lee los informes de cada servicio se escribió a partir de su documentación y todavía no se probó con una cuenta real. Revisa el primer informe con reports antes de confiar en él.

Mira cómo se conecta un buzón solo de envío

Tres protecciones contra el envío doble

Un timeout no te dice si el correo salió. Tres protecciones separadas impiden un segundo. Cada una actúa en un punto distinto, y ninguna reemplaza a las otras.

Una respuesta guardada: la clave de la petición HTTP

Cada escritura del SDK lleva una Idempotency-Key. Durante 24 horas, el mismo workspace, operación, clave y datos reciben la respuesta guardada en lugar de una segunda ejecución. Si falla guardar la respuesta, o si el primer intento queda 15 minutos sin respuesta, la llamada puede ejecutarse de nuevo. Esta protección sola no garantiza un único correo.

La intención de envío: un nombre para un correo

Por eso el intentKey es obligatorio aquí: es la protección que vale para siempre. La misma clave con la misma petición devuelve siempre el mismo correo, mucho después de que la respuesta guardada desaparezca.

El worker toma el correo para sí

Antes de entregar, el worker marca el registro del correo como suyo, una sola vez, así que dos workers no pueden enviarlo a la vez. Si la conexión se corta al final del envío, el correo queda como unknown, cuenta como enviado y nunca se envía de nuevo.

Ninguna de las tres demuestra que el correo llegó. sent significa que el servidor de correo por el que salió lo aceptó: el de tu proveedor o, en una dirección de remitente, el nuestro.

La guía de envío muestra las mismas tres protecciones para borradores