Mi caja de comentarios es tu cliente de correo

Read in English

Mi blog no tiene formulario de comentarios, ni hay caja de texto, ni captcha, ni un widget de JavaScript de un tercero. Y sin embargo, cualquier lector puede comentar un artículo, responder a otro comentario y recibir un aviso cuando alguien le contesta. El truco es usar el correo electrónico como interfaz de usuario.

Quiero hacer un disclaimer: soy muy consciente de que no todo el mundo tiene las habilidades técnicas para montar un sistema similar al mío, y tampoco busco engañar a nadie suavizando su implementación. Esto va dirigido al 0,1%, a esos locos que abrazan las ideas de una web abierta, universal, donde eres propietario de tus datos. Sin embargo, dejaré las piezas en forma de ejemplos de código para dar los primeros pasos a quien quiera intentarlo.

Te adelanto que no necesitas nada exótico: un buzón de correo, un filtro en tu proveedor, una base de datos, un par de scripts y un cron. Mi sitio está implementado con Django LiveView, con Huey como gestor de tareas en segundo plano, pero los ejemplos estarán en Flask: menos magia, menos líneas y más fácil de traducir a tu framework favorito.

Pero antes tenemos que responder a una pregunta.

¿Por qué email?

No podemos continuar sin enumerar las ventajas que supone respecto a un formulario web tradicional:

  • Evito construir un formulario y un backend con toda la complejidad que conlleva: validación, protección contra bots, mensajes de error, etc.
  • Obtengo un canal de comunicación directo con el lector: puedo avisarle, por correo, cuando alguien le responde, y él puede responderme a mí. Incluso puedo comenzar una conversación por privado si quiero.
  • No pido registros ni cuentas de usuario: el lector no tiene que crear un perfil, ni recordar una contraseña, ni rellenar un captcha.
  • El lector escribe cómodo y desde donde quiera: su cliente de correo favorito, con su corrector ortográfico y sus atajos de teclado, ya sea en el terminal, el móvil o el webmail.
  • El filtro de spam lo pone mi proveedor de correo, que lleva décadas perfeccionándolo. No tengo que reinventar la rueda.
  • Es más seguro recibir un correo que el envío de un formulario web: mi backend puede caer o estar saturado, pero el correo reintentará hasta que el servidor vuelva a estar disponible. No hay pérdida de datos.

Además, no existe un input más simple, conocido y accesible que el correo electrónico.

Hay una razón más de fondo: los comentarios también son contenido, y el principio own your data de la IndieWeb les aplica igualmente. Si viven en un servicio de terceros tipo Disqus y este cierra, cambia sus condiciones o te expulsa, se lleva consigo años de conversación. En mi base de datos y bajo mi dominio, yo soy su custodio. Y este canal convive sin pisarse con las webmentions, que recogen lo que otras webs dicen de mis artículos: el email recoge a todos los demás. Te lo conté en Me pasé a la IndieWeb y esto es lo que aprendí.

Por supuesto, aún así debo mantener una base de datos para almacenar los comentarios y sus relaciones con un mínimo backend que los renderice. No es un sistema sin servidor.

La arquitectura en un minuto

El sistema completo se reduce a cuatro piezas que se comunican entre sí:

sequenceDiagram
    participant L as Lector
    participant B as Buzón de correo
    participant T as Tarea programada
    participant D as Base de datos
    L->>B: Email a comment+article-{uuid}@tu-dominio.com
    B->>B: El filtro lo mueve de la bandeja de entrada a website/comments
    T->>B: Cada 5 minutos lee la carpeta por IMAP
    T->>D: Convierte el correo en un comentario
    T->>B: Borra el correo procesado
    D->>L: El artículo renderiza el comentario con su dirección de respuesta
    opt Respuesta a un comentario
        L->>B: Email a comment+reply-{id}@tu-dominio.com
        T->>L: Correo de aviso al autor del comentario padre
    end
  1. Cada artículo genera una dirección de correo única y la muestra en la página invitando a dejar un comentario.
  2. El lector escribe un correo a esa dirección, con su comentario en el cuerpo del mensaje. Se ignora el asunto y cualquier HTML: solo importa el texto plano.
  3. Un filtro en el proveedor de email mueve esos correos de la bandeja de entrada a una carpeta concreta. Por ejemplo website/comments.
  4. Una tarea programada lee la carpeta anterior por IMAP cada 5 minutos.
  5. Si encuentra un correo, lo convierte en un comentario en la base de datos y después borra el correo procesado.
  6. El comentario se renderiza en la página del artículo con una nueva dirección de correo única para responderle.

Y si alguien responde a un comentario, se enviará un correo al autor del comentario padre, avisándole de que alguien le ha respondido.

Ahora vamos a ver más a fondo cada pieza.

Una dirección para cada artículo

La primera pieza es el plus addressing, también llamado subdireccionamiento: casi todos los proveedores de correo (Fastmail, Gmail, Proton...) entregan cualquier correo enviado a usuario+loquesea@dominio.com en el buzón de usuario@dominio.com. No hay que crear ninguna dirección nueva: el sufijo tras el + es gratis e infinito.

Eso me permite codificar el destino del comentario dentro de la propia dirección:

  • comment+article-{uuid}@tu-dominio.com para comentar el artículo con ese identificador.
  • comment+reply-{id}@tu-dominio.com para responder al comentario con ese id.

El lector no tiene que escribir nada raro: la página del artículo tiene un botón "Deja un comentario" que revela la dirección con su enlace mailto:. Un detalle importante: la dirección no está en el HTML inicial, se inyecta al pulsar el botón (en mi caso por websocket, con Django LiveView). Así los scrapers que recolectan direcciones para enviar spam no la ven. Junto a cada comentario hay también un botón "Responder" que revela su dirección comment+reply-{id}@.

En Flask, generar la dirección es una línea en la vista:

@app.route("/blog/<uuid>/")
def article(uuid):
    post = get_article(uuid)
    comment_email = f"comment+article-{uuid}@tu-dominio.com"
    return render_template("article.html", post=post, comment_email=comment_email)

Y en la plantilla, el enlace de toda la vida:

<a href="mailto:{{ comment_email }}">{{ comment_email }}</a>

El asunto del correo se ignora por completo: solo importa el cuerpo. Menos decisiones para el lector.

Un filtro en el proveedor de email

Aquí está la pieza que hace que todo sea cómodo: un filtro en el propio proveedor de correo. Tengo configurada una regla que comprueba con una expresión regular si el destinatario empieza por cierto patrón, y en ese caso mueve el correo a una carpeta concreta:

  • Condición: el destinatario coincide con la regex ^comment\+.
  • Acción: mover a la carpeta website/comments.

Los beneficios son dos. Por un lado, mi bandeja de entrada personal queda limpia: los comentarios no se mezclan con mi correo. Por otro, el script que procesa los comentarios solo necesita leer esa carpeta: nunca toca el resto del buzón. Si un correo está en website/comments es, por definición, un candidato a comentario. Y como el filtro corre en el servidor del proveedor, funciona aunque mi web esté caída: los comentarios se van acumulando en la carpeta, esperando pacientemente.

La tarea que procesa los correos

Cada 5 minutos, aunque podría ser cada pocos segundos si quisiera, una tarea programada se conecta por IMAP a la carpeta, procesa los correos y los convierte en comentarios.

En Flask puedes conseguir lo mismo con APScheduler o con un simple cron que ejecute un script.

Primero, las expresiones regulares que descifran el destinatario:

import re
from email.utils import parseaddr

ARTICLE_RE = re.compile(r"comment\+article-([a-zA-Z0-9-]+)@")
REPLY_RE = re.compile(r"comment\+reply-(\d+)@")

Después, la extracción del cuerpo del mensaje:

def extract_body(msg):
    body = None
    for part in msg.walk():
        if part.get_content_type() == "text/plain":
            payload = part.get_payload(decode=True)
            charset = part.get_content_charset() or "utf-8"
            body = payload.decode(charset, errors="replace")
            break
    if body and "\n-- \n" in body:
        body = body.split("\n-- \n")[0]
    return body.strip() if body else None

Dos decisiones de diseño viven en esta función:

  1. Solo se acepta text/plain. Los correos que únicamente traen HTML se ignoran: no quiero sanear HTML ajeno en mi web, y cualquier cliente de correo sabe enviar texto plano. Pero ojo: texto plano no significa inofensivo. El cuerpo puede traer <script> escrito como texto, así que al pintarlo en la página deja que el motor de plantillas lo escape (Jinja y Django lo hacen por defecto) y nunca lo insertes como HTML crudo. Te lo digo por experiencia: encontré un |safe pintando los comentarios de mi propia plantilla mientras escribía este artículo.
  2. La firma se corta automáticamente. El estándar RFC 3676 define que las firmas se separan con una línea que contiene exactamente --. Todo lo que venga después, fuera: nadie quiere su "Enviado desde mi iPhone" publicado en un blog.

Con esto, procesar un mensaje es decidir de qué tipo es y guardarlo:

def process_message(msg):
    author_name, author_email = parseaddr(msg.get("From", ""))
    if not author_name:
        author_name = author_email.split("@")[0]
    body = extract_body(msg)
    if body is None:
        return True
    to_address = msg.get("To", "")
    if match := ARTICLE_RE.search(to_address):
        return save_comment(match.group(1), author_name, author_email, body)
    if match := REPLY_RE.search(to_address):
        return save_reply(int(match.group(1)), author_name, author_email, body)
    return False
  1. El nombre del autor sale de la cabecera From. Si el lector no tiene nombre configurado, se usa la parte de su dirección antes de la arroba.
  2. Un correo sin cuerpo útil (vacío o solo HTML) devuelve True: está gestionado y se puede borrar, aunque no genere comentario.
  3. Un correo cuyo destinatario no encaja con ningún patrón devuelve False: se queda en la carpeta y se reintentará en el siguiente ciclo.

Y el bucle que se conecta al buzón:

import email
import imaplib

def fetch_comments():
    mail = imaplib.IMAP4_SSL("imap.fastmail.com")
    mail.login(IMAP_USER, IMAP_PASSWORD)
    mail.select("website/comments")
    _, message_ids = mail.search(None, "ALL")
    for email_id in message_ids[0].split():
        _, data = mail.fetch(email_id, "(RFC822)")
        msg = email.message_from_bytes(data[0][1])
        if process_message(msg):
            mail.store(email_id, "+FLAGS", "\\Deleted")
    mail.expunge()
    mail.logout()

Los correos procesados con éxito se borran de la carpeta, y los que fallan se quedan. La carpeta funciona como una cola de trabajo con reintentos gratis: si la base de datos estaba caída o hubo cualquier error, el siguiente ciclo lo volverá a intentar. Para programar la ejecución, con APScheduler bastan tres líneas:

scheduler = BackgroundScheduler()
scheduler.add_job(fetch_comments, "interval", minutes=5)
scheduler.start()

Notificaciones de respuesta

Cada comentario tiene su propia dirección de respuesta, que se muestra junto al comentario en la página: comment+reply-{id}@. El lector puede pulsar el botón "Responder" y su cliente de correo abrirá un nuevo mensaje con esa dirección en el campo To.

¿Cómo notifico al autor del comentario original que alguien le ha respondido? Muy fácil, su email ya lo tenemos. Esto cierra el círculo de la conversación. Podemos incluirle un enlace al artículo o directamente la dirección de respuesta del nuevo comentario. No tiene ni que pasar por la web.

Un consejo práctico: el aviso saldrá desde una dirección tipo no-reply@tu-dominio.com. Asegúrate de registrarla como identidad de envío autorizada en tu proveedor de correo, o el SMTP rechazará los envíos a destinatarios externos. A mí me pasó, y el error (un 551 que solo aparecía con según qué destinatarios) no fue precisamente fácil de diagnosticar.

¿Cómo me entero yo de que tengo nuevos comentarios?

Yo creé un feed RSS con los últimos comentarios: https://andros.dev/blog/comments/feed/. No es solo para mí: al ser público, cualquier lector curioso puede suscribirse y seguir las conversaciones del blog desde su lector de RSS. Después, con una automatización que ahora no viene al caso, me envío a mí mismo un correo con el cuerpo del comentario además de la dirección de correo de respuesta.

Aburrido pero eficaz.

No todo es perfecto

Todo tiene un coste:

  • No todo el mundo quiere escribirme con su dirección de correo real. Hay diferentes estrategias para lidiar con ello, como las direcciones de correo desechables, pero es incómodo.
  • Hay latencia entre que el lector comenta y el comentario aparece en el artículo. Como no es un chat, tampoco necesita ser inmediato.
  • Para editar, debes hablar conmigo personalmente. Lo cual tiene la ventaja indirecta de que puedo moderar los cambios.

Sin embargo, creo que la recompensa compensa con creces.

Conclusión

Los comentarios viven en tu dominio y no en el silo de un tercero, la experiencia es buena, y está construido sobre protocolos con décadas de servicio que seguirán funcionando cuando la caja de comentarios de moda haya cerrado. Construir para que dure también es una decisión de diseño.

Si montas algo parecido en tu blog, me encantará saberlo. Ya sabes cómo dejarme un comentario.

This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.

Will you buy me a coffee?

This is how I keep writing without ads or paywalls.

Comments

There are no comments yet.

You may also like

Visitors in real time

You are alone: 🐱