El problema de enviar correo desde Genero
Enviar correo desde Genero sin abrir el programa de correo del usuario: el propio programa construye el mensaje y lo envía, desde una dirección de rol (no-responder@...) y con adjuntos. Casi cualquier aplicación acaba necesitándolo: un albarán en PDF, un aviso automático, un informe nocturno.
Genero no trae una librería de correo. Las opciones habituales son:
mailto:o el frontcallcomposeMail. Se ejecutan en el cliente: abren el programa de correo del usuario (Outlook, por ejemplo), y es él quien tiene que adjuntar los ficheros y pulsar Enviar- Un comando del sistema con
RUN(mail,sendmail). Depende de lo que haya instalado en el servidor, y montar la línea de comandos pega asunto y direcciones dentro de una cadena que interpreta la shell: hay que escaparlo todo. - JavaMail con
IMPORT JAVA. Completo, pero añade Java al despliegue. - SMTP directo con
base.Channel(descrito en Ask Reuben #145). Sin dependencias externas y sin shell.
Esta entrada desarrolla la última. Four Js tiene un ejemplo en su repositorio ex_mail.
Cómo es la conversación SMTP
Si hay un servidor de correo con interfaz SMTP (en este caso, un MDaemon que acepta relay por IP desde el servidor de aplicaciones), Genero puede hablar con él directamente: base.Channel.openClientSocket() abre un socket TCP y writeLine()/readLine() hacen el resto. La conversación mínima:
S: 220 smtp.ejemplo.com ESMTP
C: EHLO servidor-app.ejemplo.com
S: 250-smtp.ejemplo.com
S: 250 8BITMIME
C: MAIL FROM:<no-responder@ejemplo.com>
C: RCPT TO:<cliente@ejemplo.com>
C: DATA
S: 354 ...
C: (cabeceras, línea vacía, cuerpo MIME)
C: .
S: 250 OK
C: QUITAbrir el socket y escribir líneas
LET ch = base.Channel.create()
TRY
CALL ch.openClientSocket(SMTP_HOST, SMTP_PORT, "ub", 15)
CATCH
RETURN FALSE -- -8084 no se pudo abrir, -8086 timeout de conexión
END TRY"u" es lectura y escritura; la b evita la traducción de CR/LF en Windows. SMTP exige CRLF en cada línea y writeLine() en UNIX termina con LF, así que el CR se añade a mano:
PRIVATE FUNCTION linea(ch base.Channel, p_texto STRING)
IF p_texto IS NULL THEN
CALL ch.writeLine("\r")
ELSE
CALL ch.writeLine(p_texto || "\r")
END IF
END FUNCTIONEl IF no sobra. En Genero, "" es NULL, y NULL || "\r" da NULL. Sin él, linea(ch, "") escribe solo LF, y justo esa línea vacía es la que separa cabeceras y cuerpo. Es un fallo que no se ve leyendo el código, solo en un volcado de los bytes enviados.
Lecciones de diseño
Sin flush() el comando no sale
Es la trampa más cara. En un canal bidireccional ("u"), writeLine() deja el texto en un búfer. Lo que dice el manual, en base.Channel.flush(), es que con sockets bidireccionales hay que llamarlo para que lo escrito salga. Lo demás es observación de las pruebas, no comportamiento documentado: tras un writeLine(), dataAvailable() seguía devolviendo FALSE indefinidamente, mientras que un readLine() sí obtenía la respuesta, como si vaciara el búfer antes de leer. En la práctica, si escribes un comando y luego sondeas con dataAvailable() sin flush(), el comando no llega al servidor y esperas hasta el timeout.
CALL linea(ch, p_texto)
CALL ch.flush()readLine() no tiene timeout
Si el servidor deja de responder a mitad de conversación sin cerrar el socket, readLine() se queda bloqueado indefinidamente. dataAvailable() devuelve TRUE si hay al menos un byte para leer; sondeando con un límite se evita colgar el programa:
PRIVATE FUNCTION esperar_datos(ch base.Channel) RETURNS BOOLEAN
DEFINE l_limite DATETIME YEAR TO SECOND
LET l_limite = CURRENT YEAR TO SECOND + TIMEOUT_SEG UNITS SECOND
WHILE NOT ch.dataAvailable()
IF CURRENT YEAR TO SECOND >= l_limite THEN
RETURN FALSE
END IF
SLEEP 1
END WHILE
RETURN TRUE
END FUNCTIONTiene dos compromisos:
dataAvailable()garantiza un byte, no una línea, según el manual. Si el servidor manda media línea y se calla, elreadLine()siguiente sí bloquea. Cubre el caso habitual, un servidor mudo, pero no todos.SLEEPsolo admite segundos enteros, y la respuesta casi nunca está lista justo al escribir. Cada comando cuesta en torno a un segundo: contra un servidor de pruebas que respondía al instante, un envío de 9 comandos tardó 10 segundos. Para un envío interactivo es aceptable. Para cientos de correos seguidos, no.
Las respuestas pueden ser multilínea
EHLO responde con varias líneas: 250- indica que sigue y 250 seguido de un espacio que es la última. Si se lee una sola línea, el siguiente comando recibe la respuesta del anterior y todo se desfasa:
IF l_resp.getLength() < 4 OR l_resp.getCharAt(4) <> "-" THEN
EXIT WHILE -- última línea de la respuesta
END IFInyección de CRLF en las direcciones
Aunque no haya shell de por medio, la inyección sigue siendo posible. El remitente y los destinatarios van tal cual en MAIL FROM, en RCPT TO y en las cabeceras. Una dirección con un salto de línea dentro ("uno@ejemplo.com\r\nRCPT TO:<otro@ejemplo.com>") mete un comando SMTP o una cabecera de más. Por eso se rechaza antes de abrir el socket:
PRIVATE FUNCTION sin_saltos(p_s STRING) RETURNS BOOLEAN
IF p_s IS NULL THEN
RETURN TRUE
END IF
RETURN (p_s.getIndexOf("\r", 1) == 0 AND p_s.getIndexOf("\n", 1) == 0)
END FUNCTIONEl asunto no necesita esta comprobación, porque viaja en base64.
Dot-stuffing
El fin del mensaje es una línea con un solo .(punto). Si el cuerpo tiene una línea que empieza por punto, hay que duplicárselo (RFC 5321); si no, el servidor puede tomarla por el final. En los adjuntos en base64 no hace falta, porque el alfabeto base64 no tiene punto.
IF l_lin.getCharAt(1) == "." THEN
LET l_lin = "." || l_lin
END IFNo perder las líneas en blanco del cuerpo
Para recorrer el cuerpo línea a línea, lo natural es base.StringTokenizer.create(cuerpo, "\n"). Pero create() descarta los tokens vacíos: "Hola\n\nSaludos" da 2 tokens y los párrafos llegan pegados. Con createExt() y el último parámetro a TRUE se conservan (3 tokens):
LET tok = base.StringTokenizer.createExt(p_cuerpo, "\n", NULL, TRUE)Bcc no va en las cabeceras
En SMTP, quién recibe el correo lo decide el sobre (RCPT TO), no las cabeceras: To: y Cc: son solo lo que ve el destinatario. Por eso cada dirección, incluida la de copia oculta, lleva su RCPT TO, y la cabecera Bcc: no se escribe nunca. Hablando SMTP directo no hay ningún programa en medio que quite esa cabecera: si la escribes, la ve todo el mundo.
El boundary MIME no puede salir de un DATETIME sin formatear
El separador entre partes MIME tiene que ser único y sin caracteres que obliguen a escaparlo. Un DATETIME convertido a texto por defecto mete espacios, guiones, dos puntos y el punto de la fracción de segundo. Lo más corto es un UUID:
LET l_boundary = "=_" || security.RandomGenerator.CreateUUIDString()El charset declarado tiene que ser el real
El asunto con acentos va como =?UTF-8?B?...?=. Según el manual, util.Strings.base64EncodeFromString() convierte la cadena con el charset del locale de la aplicación, no a UTF-8. Si la aplicación corre en ISO-8859-1 y declaras UTF-8, los acentos llegan rotos. Por eso el charset va en una constante que tiene que coincidir con el locale (en la prueba, LANG=es_ES.UTF-8).
La fecha, en UTC
La cabecera Date: sigue el formato de la RFC 5322. util.Datetime.format() no tiene %z para la zona horaria, y calcular el horario de verano a mano es código que sobra. Con util.Datetime.toUTC(), que usa la zona del sistema, la fecha se escribe en UTC con +0000. Los nombres de día y mes van en inglés y puestos a mano.
Un límite de tamaño antes de conectar
El ejemplo limita a 25 MB el total de adjuntos y lo comprueba con os.Path.size() antes de abrir el socket. Así no subes 40 MB para recibir un rechazo al final. Ten en cuenta que base64 infla el tamaño en torno a un tercio. Al trocearlo en líneas de 76, recorta el final del último trozo: subString() devuelve NULL si el final pasa del largo de la cadena.
Estructura del envío
El diálogo SMTP va en su propia función, que sale con RETURN FALSE en el primer fallo. Quien la llama cierra el canal siempre:
TRY
LET l_ok = dialogo(ch, p_correo, p_adjuntos)
CATCH
LET l_ok = FALSE
END TRY
CALL ch.close()
RETURN l_okEl módulo completo (correo_smtp.4gl, unas 250 líneas) se usa así:
IMPORT FGL correo_smtp
MAIN
DEFINE r_correo correo_smtp.t_correo
DEFINE l_adjuntos DYNAMIC ARRAY OF STRING
LET r_correo.remitente = "no-responder@ejemplo.com"
LET r_correo.destinatarios = "cliente@ejemplo.com"
LET r_correo.bcc = "archivo@ejemplo.com"
LET r_correo.asunto = "Albarán 1234"
LET r_correo.cuerpo = "Buenos días,\n\nSe adjunta el albarán.\n\nUn saludo."
LET l_adjuntos[1] = "/tmp/albaran_1234.pdf"
IF NOT correo_smtp.correo_enviar(r_correo, l_adjuntos) THEN
DISPLAY "No se ha podido enviar el correo"
END IF
END MAINLo que no hace
- Autenticación SMTP ni TLS. Funciona porque el servidor de correo acepta relay desde la IP del servidor de aplicaciones. Si el tuyo exige
AUTHoSTARTTLS, esta versión no sirve tal cual. - Comprobar 8BITMIME. El cuerpo va con
Content-Transfer-Encoding: 8bit, que solo es válido si el servidor anuncia8BITMIMEen la respuesta alEHLO. El código no lo mira: compruébalo una vez en tu servidor o codifica también el cuerpo en base64. - Nombres de adjunto según la norma. Van como
filename="=?UTF-8?B?...?=". La RFC 2047 no permite esa codificación dentro de un valor entre comillas; lo correcto seríafilename*=UTF-8''...(RFC 2231). Los clientes de correo habituales lo aceptan, pero no es lo que dice la norma. - Direcciones con nombre. Las listas se parten por comas y cada trozo va tal cual en
RCPT TO:<...>, así que solo funcionan direcciones simples (a@ejemplo.com). Un"Nombre Apellido" <a@ejemplo.com>, o un nombre que lleve una coma, falla. - Tipos MIME por extensión. Todos los adjuntos van como
application/octet-stream. En código real conviene una tabla por extensión, para que el cliente ofrezca vista previa de PDF e imágenes.
Conclusión
Hablar SMTP directamente desde Genero no necesita nada instalado más allá del propio Genero y deja todo el mensaje bajo control del programa. A cambio, lo que resolvería un programa de correo pasa a ser tuyo: el flush, el CRLF, las respuestas multilínea, el dot-stuffing, el Bcc, la inyección en las direcciones, los timeouts y el charset. Ninguna es difícil, pero dos de ellas, el flush() y el "" que es NULL, solo aparecen al mirar los bytes que salen por el socket.
Fuentes
- Manual Genero BDL 6.00:
base.Channel.openClientSocket,base.Channel.flush,base.Channel.dataAvailable, Line terminators on Windows and UNIX,base.StringTokenizer.create/createExt,util.Strings.base64Encode/base64EncodeFromString,util.Datetime.format/toUTC, Formatting DATETIME values,security.RandomGenerator.CreateUUIDString, instrucciónRUN. - Ask Reuben #145, Sending Mail: https://4js.com/ask-reuben/ig-145/
- Ejemplo oficial de Four Js: https://github.com/FourjsGenero/ex_mail
- RFC 5321 (SMTP, dot-stuffing), RFC 5322 (formato de mensaje y fecha), RFC 2045 (MIME, base64 a 76 caracteres), RFC 2047 y RFC 2231 (codificación de cabeceras y de parámetros).