134 / Networking

Turn incoming mail into cases

A help desk sends quotations by email (std/smtp) and needs the customer's REPLY to return to the thread of that conversation. For that you read the mailbox (std/imap), parse each message (std/mime) and tie the reply to the original through In-Reply-To / References, which hold the Message-ID the system saved when it sent.

134-imap-correo-entrante.nxSource →
// Nyx by Example: convertir en casos los correos de una casilla y enhebrar la respuesta
// del cliente.
//
// El caso real: una mesa de ayuda manda cotizaciones por correo (std/smtp) y necesita que
// la RESPUESTA del cliente vuelva al hilo de esa conversación. Para eso hay que leer la
// casilla (std/imap), analizar cada mensaje (std/mime) y atar la respuesta al envío por
// `In-Reply-To` / `References`, que contienen el Message-ID que el sistema guardó al enviar.
//
// Lo que esta receta muestra:
//   1. el sondeo real contra un buzón: conexión verificada, LOGIN, punto de avance por UID y marcar
//      leído (la función `sondear`, que compila y enlaza pero NO se llama: un buzón de
//      verdad necesita credenciales y esta receta corre sola bajo `make test-examples`);
//   2. por qué el punto de avance es por UID y no «los no leídos»;
//   3. lo que sí corre acá, sin red: el analizador de respuestas de IMAP y std/mime sobre
//      la respuesta de un servidor, y la decisión de a qué caso pertenece cada mensaje;
//   4. los errores tipados y qué hacer con cada uno.

import "std/imap"
import "std/mime"
import "std/error"

// ── 1. El sondeo real ────────────────────────────────────────────────────
// Se llama cada 30-120 s desde un temporizador (un CRM no necesita IDLE). `punto_v` y
// `punto_uid` los guarda la aplicación entre llamadas: (0, 0) la primera vez.
// Devuelve el punto de avance nuevo para guardar. La clave viene de la configuración, nunca del
// código.
fn sondear(host: String, usuario: String, clave: String, punto_v: int, punto_uid: int, ids_enviados: Array) -> Result<int, Error> {
    // 993 con TLS implícito, VERIFICANDO cadena y nombre contra las CAs del sistema.
    // Para el 143: imap_connect_starttls. Las variantes `_insecure` son solo para
    // desarrollo, y lo dicen en el nombre.
    let c: ImapConn = imap_connect_tls(host, 993, imap_opts())?

    // LOGIN sirve para cPanel/Dovecot y para una clave de aplicación de Gmail. Con OAuth2
    // (Gmail, Microsoft 365) el token lo trae la aplicación: imap_auth_xoauth2(c, usuario,
    // token). Nunca viajan en claro: sobre un canal sin cifrar fallan antes de mandarlas.
    let a: int = imap_login(c, usuario, clave)?
    let mb: ImapMailbox = imap_select(c, "INBOX")?

    // Punto de avance por UID, NO `imap_search_unseen`: `\Seen` es estado compartido con quien abra el
    // buzón desde su teléfono; si la respuesta del cliente se lee ahí primero, UNSEEN ya no
    // la devuelve y el sistema nunca se entera. Aquí se piden «los UID mayores que el último
    // que procesé» y no se toca ninguna marca.
    var v: int = punto_v
    var ultimo: int = punto_uid
    match imap_fetch_since(c, v, ultimo, 50) {
        Result.Ok(r) => {
            v = r.uidvalidity
            for m: ImapMessage in r.messages {
                let destino: String = decidir(m.mail, ids_enviados)
                println("UID " + int_to_string(m.uid) + ": " + destino)
                // m.truncated: un mensaje enorme (o con demasiadas partes) igual trae su
                // asunto, remitente e In-Reply-To; solo falta el cuerpo. Se puede enhebrar.
                // Marcar leído es OPCIONAL y aparte (el protocolo no lo hace solo):
                //   let _s: int = imap_mark_seen(c, m.uid)?
            }
            ultimo = r.last_uid
        }
        Result.Err(e) => {
            if e.kind == "stale" {
                // UIDVALIDITY cambió (se recreó el buzón): los UID guardados ya no
                // significan nada. e.code trae el valor nuevo; se reescanea por Message-ID
                // (que además evita procesar dos veces un mensaje).
                println("el buzón cambió de UIDVALIDITY a " + int_to_string(e.code))
            } else {
                imap_logout(c)
                return Result.Err(e)
            }
        }
    }
    imap_logout(c)
    return Result.Ok(ultimo)
}

// ── 3. La decisión de a qué caso pertenece un mensaje ───────────────────
// No es una respuesta del cliente: un rebote (el correo no llegó) o una autorrespuesta
// («estoy de vacaciones»). Tratarlos como respuesta contamina el hilo y puede armar lazos.
// Si no, se busca el id de algún mensaje que el sistema envió en In-Reply-To o References.
fn decidir(m: MimeMessage, ids_enviados: Array) -> String {
    if m.is_bounce { return "rebote de " + m.bounce.original_message_id + " (no es del cliente)" }
    if m.is_auto_reply { return "autorrespuesta (" + m.auto_kind + "), se ignora" }
    var candidatos: Array = []
    for id: String in m.in_reply_to { candidatos.push(id) }
    for id: String in m.references { candidatos.push(id) }
    for cand: String in candidatos {
        for conocido: String in ids_enviados {
            if cand == conocido { return "respuesta al envío " + conocido + ": «" + m.subject + "»" }
        }
    }
    return "caso nuevo: «" + m.subject + "»"
}

// Lo que un servidor contesta a `UID FETCH 5 (BODY.PEEK[])`: la línea, el literal de N
// bytes (que puede traer cualquier cosa, CRLF incluido) y el cierre.
fn respuesta_de_ejemplo() -> String {
    let correo: String = "From: Cliente <cliente@empresa.cl>\r\n" +
        "To: casos@miempresa.cl\r\n" +
        "Subject: =?UTF-8?Q?Re:_Cotizaci=C3=B3n_123?=\r\n" +
        "Message-ID: <resp-5@empresa.cl>\r\n" +
        "In-Reply-To: <caso-123@nyxerp.cl>\r\n" +
        "References: <inicio@nyxerp.cl> <caso-123@nyxerp.cl>\r\n" +
        "Content-Type: text/plain; charset=utf-8\r\n" +
        "Content-Transfer-Encoding: quoted-printable\r\n\r\n" +
        "Hola, acepto la cotizaci=C3=B3n.\r\n"
    return "* 2 FETCH (UID 5 BODY[] {" + int_to_string(correo.length()) + "}\r\n" + correo + ")\r\n"
}

fn rebote_de_ejemplo() -> String {
    return "From: MAILER-DAEMON@mx.empresa.cl\r\n" +
        "Subject: Undelivered Mail Returned to Sender\r\n" +
        "Message-ID: <rebote-1@mx.empresa.cl>\r\n" +
        "Content-Type: multipart/report; report-type=delivery-status; boundary=\"B\"\r\n\r\n" +
        "--B\r\nContent-Type: text/plain\r\n\r\nNo se pudo entregar.\r\n" +
        "--B\r\nContent-Type: message/delivery-status\r\n\r\n" +
        "Reporting-MTA: dns; mx.empresa.cl\r\n\r\n" +
        "Final-Recipient: rfc822; nadie@empresa.cl\r\nAction: failed\r\nStatus: 5.1.1\r\n" +
        "--B\r\nContent-Type: text/rfc822-headers\r\n\r\n" +
        "Message-ID: <caso-124@nyxerp.cl>\r\nSubject: Cotizacion 124\r\n" +
        "--B--\r\n"
}

fn analizar(correo_crudo: String) -> MimeMessage {
    match mime_parse(correo_crudo, mime_limits()) {
        Result.Ok(m) => { return m }
        Result.Err(e) => {
            println("no es un correo: " + e.kind)
            exit(1)
        }
    }
    return analizar(correo_crudo)
}

fn main() -> int {
    // Los ids que el sistema guardó al ENVIAR (smtp_message_id del mensaje armado).
    let ids_enviados: Array = ["<caso-123@nyxerp.cl>", "<caso-124@nyxerp.cl>"]

    // ── El analizador de IMAP, sin red ───────────────────────────────────
    // La respuesta mezcla una línea, un literal de N bytes y un cierre. imap_parse_response
    // la interpreta: `* 2 FETCH (...)` → verbo FETCH, número 2, una lista con los datos.
    match imap_parse_response(respuesta_de_ejemplo()) {
        Result.Ok(r) => {
            println("respuesta: " + r.name + " del mensaje " + int_to_string(r.num))
            let datos: ImapVal = r.items[0]
            // La lista alterna nombre y valor: UID 5 BODY[] <el correo>.
            let uid: ImapVal = datos.items[1]
            let cuerpo: ImapVal = datos.items[3]
            println("UID " + int_to_string(uid.num) + ", " + int_to_string(cuerpo.text.length()) + " bytes de correo")
            let m: MimeMessage = analizar(cuerpo.text)
            println(decidir(m, ids_enviados))
            println("cuerpo: " + m.text.trim())
        }
        Result.Err(e) => { println("respuesta ilegible: " + e.kind) }
    }

    // Un rebote y un correo sin relación con nada enviado.
    println(decidir(analizar(rebote_de_ejemplo()), ids_enviados))
    println(decidir(analizar("From: x@y.cl\r\nSubject: Hola\r\n\r\nQuiero comprar\r\n"), ids_enviados))

    // ── 4. Qué hacer con cada error ──────────────────────────────────────
    println("")
    println("— qué hacer con cada error —")
    println(explicar(err_new(0, "auth", "rechazada")))
    println(explicar(err_new(62, "tls", "nombre")))
    println(explicar(err_new(110, "timeout", "plazo")))
    println(explicar(err_new(0, "stale", "uidvalidity")))
    println(explicar(err_new(0, "protocol", "respuesta ilegible")))
    println(explicar(err_new(0, "connection", "cortó")))
    return 0
}

// Cada `kind` pide una acción distinta; por eso vale la pena que el error sea tipado.
fn explicar(e: Error) -> String {
    if e.kind == "auth" { return "auth       → revisar usuario y clave (o renovar el token OAuth2)" }
    if e.kind == "tls" { return "tls        → el certificado no verifica: NO bajar la guardia, avisar" }
    if e.kind == "timeout" { return "timeout    → el servidor no contestó: cerrar y reintentar en el próximo sondeo" }
    if e.kind == "stale" { return "stale      → UIDVALIDITY cambió: reescanear por Message-ID" }
    if e.kind == "protocol" { return "protocol   → servidor raro o hostil: la conexión se cerró; no marcar nada y reintentar" }
    if e.kind == "connection" { return "connection → red caída o servidor cortó: reconectar" }
    return "desconocido"
}
Outputstdout
respuesta: FETCH del mensaje 2
UID 5, 349 bytes de correo
respuesta al envío <caso-123@nyxerp.cl>: «Re: Cotización 123»
cuerpo: Hola, acepto la cotización.
rebote de <caso-124@nyxerp.cl> (no es del cliente)
caso nuevo: «Hola»

— qué hacer con cada error —
auth       → revisar usuario y clave (o renovar el token OAuth2)
tls        → el certificado no verifica: NO bajar la guardia, avisar
timeout    → el servidor no contestó: cerrar y reintentar en el próximo sondeo
stale      → UIDVALIDITY cambió: reescanear por Message-ID
protocol   → servidor raro o hostil: la conexión se cerró; no marcar nada y reintentar
connection → red caída o servidor cortó: reconectar

How it works

The real polling against a mailbox is the function sondear: verified connection, LOGIN, a UID checkpoint and mark-as-read. It compiles and links but is NOT called, because a real mailbox needs credentials and the recipe has to run on its own. The checkpoint is by UID, not the unread ones: \Seen is state shared with whoever else opens the mailbox, so a person reading mail would make cases vanish.

What does run, with no network, is IMAP's response parser and std/mime over a server reply, plus the decision of which case each message belongs to: a reply to a sent quotation, a bounce that is not the customer's, or a new case. The last block lists the typed errors and what to do with each — auth, tls (do NOT lower your guard), timeout, stale (UIDVALIDITY changed), protocol and connection.