Formulario de contacto (SMTP vía Poste), Quiénes somos e invitación a venir al programa

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
This commit is contained in:
Alex
2026-10-20 16:04:00 +02:00
co-authored by Claude Sonnet 5.5
parent 4be49b0c91
commit 89bb7b4d34
9 changed files with 291 additions and 12 deletions
+1
View File
@@ -2,6 +2,7 @@ FROM nginx:1.27-alpine
RUN apk add --no-cache python3 py3-pillow
COPY public/ /usr/share/nginx/html/
COPY build.py /app/build.py
COPY server.py /app/server.py
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
+19 -1
View File
@@ -157,7 +157,9 @@ page = f'''<!doctype html>
<nav aria-label="Principal">
<a href="#programas">Programas</a>
<a href="#programa">El programa</a>
<a href="#quienes">Quiénes somos</a>
<a href="#seguir">Seguir</a>
<a class="navcta" href="contacto.html">Contacto</a>
</nav>
</header>
@@ -204,6 +206,22 @@ page = f'''<!doctype html>
<ul class="chips" aria-label="Temas">{chips}</ul>
</section>
<section id="quienes" class="who wrap reveal">
<h2>Quiénes somos</h2>
<p>Somos Alejandro y Juan, las dos personas detrás de Anda Que No Sabes Ná: un programa hecho en Granada, de noche, con ganas de aprender de quien se sienta a charlar.</p>
<div class="team">
<div class="person"><b>Alejandro</b><span>Detrás de Anda Que No Sabes Ná</span></div>
<div class="person"><b>Juan</b><span>Detrás de Anda Que No Sabes Ná</span></div>
</div>
</section>
<section class="wrap reveal" aria-labelledby="venir">
<div class="invite">
<div><h2 id="venir">¿Quieres venir al programa?</h2><p>Cuéntanos quién eres y de qué te gustaría hablar.</p></div>
<a class="btn primary" href="contacto.html">Escríbenos</a>
</div>
</section>
<section id="seguir" class="follow wrap reveal">
<h2>Síguenos</h2>
<div class="socials">
@@ -217,7 +235,7 @@ page = f'''<!doctype html>
<footer>
<div class="wrap foot">
<p>© 2026 Anda Que No Sabes Ná · Granada</p>
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="mailto:{MAIL}">Contacto</a></p>
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="contacto.html">Contacto</a></p>
</div>
</footer>
+1
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# Primer build al arrancar y después cada 15 minutos; si el feed falla se conserva la web anterior.
python3 /app/build.py || true
python3 /app/server.py &
( while true; do sleep 900; python3 /app/build.py || true; done ) &
exec nginx -g 'daemon off;'
+5
View File
@@ -9,5 +9,10 @@ server {
location = / { add_header Cache-Control "no-cache"; }
location /img/ { expires 7d; add_header Cache-Control "public"; }
location ~* \.(css)$ { add_header Cache-Control "no-cache"; }
location /api/ {
client_max_body_size 16k;
proxy_pass http://127.0.0.1:8081;
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
}
location / { try_files $uri $uri/ =404; }
}
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contacto · Anda Que No Sabes Ná</title>
<meta name="description" content="¿Quieres venir al programa, proponer un tema o decirnos algo? Escríbenos.">
<meta name="theme-color" content="#06080d">
<link rel="icon" href="img/avatar.jpg">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="top">
<a class="brand" href="/" aria-label="Anda Que No Sabes Ná, inicio"><img src="img/avatar.jpg" alt="" width="40" height="40"><span>Anda que no sabes ná</span></a>
<nav aria-label="Principal"><a href="/">Inicio</a><a href="/#quienes">Quiénes somos</a></nav>
</header>
<main class="legal contact">
<h1>¿Quieres venir al <em>programa</em>?</h1>
<p class="lead-c">Cuéntanos quién eres y de qué te gustaría hablar. También puedes proponer un tema o simplemente escribirnos. Te respondemos por correo.</p>
<form id="f" class="form" novalidate>
<label>Nombre
<input name="nombre" type="text" autocomplete="name" maxlength="100" required>
</label>
<label>Correo electrónico
<input name="email" type="email" autocomplete="email" maxlength="200" required>
</label>
<label>Motivo
<select name="motivo">
<option value="venir">Quiero venir al programa</option>
<option value="tema">Propongo un tema</option>
<option value="otro">Otro asunto</option>
</select>
</label>
<label>Mensaje
<textarea name="mensaje" rows="6" maxlength="3000" required placeholder="Cuéntanos a qué te dedicas y qué te gustaría contar…"></textarea>
</label>
<div class="trap" aria-hidden="true"><label>No rellenes este campo <input name="web" type="text" tabindex="-1" autocomplete="off"></label></div>
<label class="check">
<input name="consent" type="checkbox" required>
<span>He leído la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad</a> y acepto que usen mis datos para responderme.</span>
</label>
<button class="btn primary" type="submit">Enviar mensaje</button>
<p id="st" class="status" role="status" aria-live="polite"></p>
</form>
<noscript><p>Necesitas JavaScript para usar el formulario. Mientras tanto, escríbenos a <a href="mailto:andaquenosabesnada@gmail.com">andaquenosabesnada@gmail.com</a>.</p></noscript>
</main>
<script>
(function () {
var f = document.getElementById('f'), st = document.getElementById('st'), t0 = Date.now(), busy = false;
function say(msg, ok) { st.textContent = msg; st.className = 'status ' + (ok ? 'ok' : 'err'); }
f.addEventListener('submit', function (e) {
e.preventDefault();
if (busy) return;
var d = new FormData(f);
var body = {
nombre: (d.get('nombre') || '').trim(), email: (d.get('email') || '').trim(),
motivo: d.get('motivo'), mensaje: (d.get('mensaje') || '').trim(),
consent: f.consent.checked, web: d.get('web') || '', t: Date.now() - t0
};
if (!body.nombre || !/^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(body.email) || body.mensaje.length < 10 || !body.consent) {
say('Revisa los campos: nombre, correo válido, un mensaje de al menos 10 caracteres y la casilla de privacidad.', false);
return;
}
busy = true; say('Enviando…', true);
fetch('/api/contacto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok && j.ok, j: j }; }); })
.then(function (x) {
if (x.ok) { f.reset(); say('¡Gracias! Hemos recibido tu mensaje y te responderemos pronto.', true); }
else { say((x.j && x.j.error) || 'No se pudo enviar. Escríbenos a andaquenosabesnada@gmail.com.', false); }
})
.catch(function () { say('No se pudo enviar. Escríbenos a andaquenosabesnada@gmail.com.', false); })
.then(function () { busy = false; });
});
})();
</script>
</body>
</html>
+21 -5
View File
@@ -21,7 +21,9 @@
<nav aria-label="Principal">
<a href="#programas">Programas</a>
<a href="#programa">El programa</a>
<a href="#quienes">Quiénes somos</a>
<a href="#seguir">Seguir</a>
<a class="navcta" href="contacto.html">Contacto</a>
</nav>
</header>
@@ -32,7 +34,7 @@
<img class="neon" src="img/avatar.jpg" alt="Anda Que No Sabes Ná" width="220" height="220">
<p class="kicker">Directo desde Granada, de noche</p>
<h1>El programa nocturno<br><em>100% granaíno</em></h1>
<p class="lead">Charlas sin guion sobre lo que sabemos, lo que no, y lo que creemos saber.</p>
<p class="lead">Charlas sobre lo que sabemos, lo que no, y lo que creemos saber.</p>
<div class="cta">
<a class="btn primary" href="https://www.youtube.com/@andaquenosabesna" target="_blank" rel="noopener">Ver en YouTube</a>
<a class="btn" href="#programas">Últimos programas</a>
@@ -121,9 +123,23 @@
<section id="programa" class="about wrap reveal">
<h2>De qué va</h2>
<p>Un programa de noche, con tertulia y sin prisa. Cada semana entra gente distinta, con su oficio y su manía, y se habla de lo que sale: de cómo se educa a un adolescente, de la vida en las calles de El Cairo, de si el árabe es tan antiguo como dicen, de qué tienen en común las religiones o de cómo se hace un truco de cartas.</p>
<ul class="chips" aria-label="Temas">
<li>Educación</li><li>Egipto</li><li>Lengua árabe</li><li>Religiones</li><li>Magia</li><li>Cultura granaína</li><li>Entrevistas</li>
</ul>
<ul class="chips" aria-label="Temas"><li>Egipto</li><li>Curiosidades</li><li>Educación</li><li>Lengua árabe</li><li>Agricultura</li><li>Burocracia</li><li>Comida egipcia</li><li>Economía</li><li>Gastronomía</li><li>Magia</li><li>Cultura granaína</li><li>Entrevistas</li></ul>
</section>
<section id="quienes" class="who wrap reveal">
<h2>Quiénes somos</h2>
<p>Somos Alejandro y Juan, las dos personas detrás de Anda Que No Sabes Ná: un programa hecho en Granada, de noche, con ganas de aprender de quien se sienta a charlar.</p>
<div class="team">
<div class="person"><b>Alejandro</b><span>Detrás de Anda Que No Sabes Ná</span></div>
<div class="person"><b>Juan</b><span>Detrás de Anda Que No Sabes Ná</span></div>
</div>
</section>
<section class="wrap reveal" aria-labelledby="venir">
<div class="invite">
<div><h2 id="venir">¿Quieres venir al programa?</h2><p>Cuéntanos quién eres y de qué te gustaría hablar.</p></div>
<a class="btn primary" href="contacto.html">Escríbenos</a>
</div>
</section>
<section id="seguir" class="follow wrap reveal">
@@ -139,7 +155,7 @@
<footer>
<div class="wrap foot">
<p>© 2026 Anda Que No Sabes Ná · Granada</p>
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="mailto:andaquenosabesnada@gmail.com">Contacto</a></p>
<p class="links"><a href="privacidad.html">Política de privacidad</a><a href="terminos.html">Condiciones del servicio</a><a href="contacto.html">Contacto</a></p>
</div>
</footer>
+10 -6
View File
@@ -28,7 +28,11 @@
<p>Como cualquier servidor web, nuestro servidor puede registrar de forma temporal la dirección IP, la fecha y la página solicitada, únicamente por seguridad y para detectar abusos. No usamos esos registros para identificar a nadie ni los compartimos.</p>
<p>Los enlaces a YouTube, TikTok e Instagram llevan a servicios de terceros, que tienen sus propias políticas de privacidad.</p>
<h2>3. Nuestra aplicación de publicación</h2>
<h2>3. Formulario de contacto</h2>
<p>Si nos escribes desde la página de <a href="contacto.html">contacto</a>, recogemos el nombre, el correo electrónico, el motivo y el mensaje que tú mismo introduces. Los usamos <strong>únicamente para responderte</strong>. La base legal es tu consentimiento, que das al marcar la casilla del formulario.</p>
<p>La web no guarda los mensajes: el formulario los reenvía por correo electrónico, a través de nuestro propio servidor de correo, a la cuenta del programa. Los conservamos solo el tiempo necesario para atender tu consulta y no los cedemos a terceros ni los usamos para otra cosa. El formulario incluye medidas antispam que no identifican a nadie. Puedes pedirnos que borremos tus datos escribiendo al correo de contacto.</p>
<h2>4. Nuestra aplicación de publicación</h2>
<p>Usamos una aplicación de gestión de redes sociales de código abierto (Postiz), instalada en un servidor propio, para programar y publicar el contenido del programa. La aplicación está pensada para uso interno: <strong>solo la utiliza el propietario de las cuentas</strong> y no está abierta al público.</p>
<p>Para funcionar, la aplicación solicita autorización a las cuentas del propio programa y accede a:</p>
<ul>
@@ -38,21 +42,21 @@
</ul>
<p>Los tokens de acceso se guardan en una base de datos de nuestro servidor. <strong>No vendemos, alquilamos ni compartimos estos datos con nadie, no los usamos para publicidad ni los transferimos a terceros.</strong> Usamos los datos solo para publicar y programar el contenido del programa en sus propias cuentas.</p>
<h2>4. Servicios de API de YouTube</h2>
<h2>5. Servicios de API de YouTube</h2>
<p>La aplicación usa los Servicios de API de YouTube. Al utilizarla, aceptas quedar sujeto a los <a href="https://www.youtube.com/t/terms" target="_blank" rel="noopener">Términos del Servicio de YouTube</a>. La política de privacidad de Google está disponible en <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">policies.google.com/privacy</a>.</p>
<p>El uso que hace la aplicación de la información recibida de las API de Google se ajusta a la <a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener">Política de datos de usuario de los Servicios de API de Google</a>, incluidos los requisitos de uso limitado.</p>
<p>Puedes revocar en cualquier momento el acceso de la aplicación a tu cuenta de Google desde <a href="https://myaccount.google.com/connections" target="_blank" rel="noopener">myaccount.google.com/connections</a>.</p>
<h2>5. TikTok e Instagram</h2>
<h2>6. TikTok e Instagram</h2>
<p>El acceso a TikTok se rige también por los <a href="https://www.tiktok.com/legal/page/row/terms-of-service/es" target="_blank" rel="noopener">Términos del Servicio de TikTok</a> y su política de privacidad. El acceso a Instagram, por las condiciones y la política de datos de Meta. Puedes revocar el acceso de la aplicación desde la configuración de seguridad de cada plataforma.</p>
<h2>6. Conservación y eliminación</h2>
<h2>7. Conservación y eliminación</h2>
<p>Conservamos los tokens solo mientras la cuenta esté conectada. Si desconectamos una cuenta o revocas el acceso, el token deja de ser válido y se elimina de nuestra base de datos. Si quieres que borremos cualquier dato asociado a ti, escríbenos al correo de contacto.</p>
<h2>7. Tus derechos</h2>
<h2>8. Tus derechos</h2>
<p>Conforme al Reglamento General de Protección de Datos (RGPD) y a la normativa española, puedes ejercer tus derechos de acceso, rectificación, supresión, oposición, limitación y portabilidad escribiendo a <a href="mailto:andaquenosabesnada@gmail.com">andaquenosabesnada@gmail.com</a>. También puedes reclamar ante la Agencia Española de Protección de Datos (<a href="https://www.aepd.es" target="_blank" rel="noopener">aepd.es</a>).</p>
<h2>8. Cambios</h2>
<h2>9. Cambios</h2>
<p>Si cambiamos esta política, publicaremos la nueva versión en esta misma página con su fecha de actualización.</p>
</main>
</body>
+37
View File
@@ -39,6 +39,7 @@ a { color: inherit; text-decoration: none; }
.brand img { aspect-ratio: 1 / 1; object-fit: cover; border-radius: 50%; box-shadow: 0 0 14px rgba(46, 230, 255, .5); }
.top nav { display: flex; gap: 22px; font-size: .95rem; color: var(--muted); }
.top nav a:hover { color: var(--neon); }
.top nav .navcta { color: var(--neon); border: 1px solid rgba(46, 230, 255, .4); padding: 2px 14px; border-radius: 999px; }
@media (max-width: 640px) { .brand span { display: none; } .top nav { gap: 16px; } }
/* hero */
@@ -174,3 +175,39 @@ footer { margin-top: 40px; border-top: 1px solid var(--line); background: var(--
.legal p, .legal li { color: #c3d3e3; }
.legal a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
.legal .upd { color: var(--muted); font-size: .9rem; }
/* contacto */
.contact h1 em { font-style: normal; color: var(--neon); text-shadow: var(--glow); }
.lead-c { color: #c3d3e3; font-size: 1.08rem; margin-bottom: 28px; }
.form { display: grid; gap: 18px; }
.form label { display: grid; gap: 6px; font-weight: 600; font-size: .95rem; }
.form input[type=text], .form input[type=email], .form select, .form textarea {
width: 100%; padding: 13px 15px; border-radius: 12px; border: 1px solid var(--line);
background: var(--card); color: var(--ink); font: inherit; font-weight: 400; transition: .2s;
}
.form textarea { resize: vertical; min-height: 140px; }
.form input:focus, .form select:focus, .form textarea:focus { outline: none; border-color: var(--neon); box-shadow: 0 0 0 3px rgba(46, 230, 255, .18); }
.form .check { display: flex; align-items: flex-start; gap: 10px; font-weight: 400; color: var(--muted); font-size: .92rem; }
.form .check input { margin-top: 4px; accent-color: var(--neon); width: 18px; height: 18px; flex: none; }
.form .check a { color: var(--neon); text-decoration: underline; text-underline-offset: 3px; }
.form .btn { justify-self: start; cursor: pointer; font: inherit; font-weight: 700; }
.trap { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.status { min-height: 1.4em; margin: 0; font-size: .95rem; }
.status.ok { color: #6ee7a8; }
.status.err { color: #ff8a8a; }
/* quiénes somos + invitación */
.who { max-width: 900px; }
.who > p { color: #c3d3e3; font-size: 1.1rem; max-width: 720px; }
.team { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin-top: 26px; }
.person { padding: 22px 24px; border-radius: var(--radius); border: 1px solid var(--line); background: linear-gradient(135deg, var(--card), var(--bg2)); transition: .3s; }
.person:hover { border-color: var(--neon); box-shadow: 0 0 24px rgba(46, 230, 255, .2); }
.person b { display: block; font-size: 1.3rem; color: var(--neon); text-shadow: 0 0 14px rgba(46, 230, 255, .4); }
.person span { color: var(--muted); font-size: .95rem; }
.invite {
display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap;
padding: clamp(22px, 4vw, 38px); border-radius: 22px; border: 1px solid var(--line);
background: radial-gradient(80% 140% at 0% 0%, rgba(46, 230, 255, .12), transparent 60%), linear-gradient(135deg, var(--card), var(--bg2));
}
.invite h2 { margin: 0 0 4px; }
.invite p { margin: 0; color: #c3d3e3; }
+117
View File
@@ -0,0 +1,117 @@
"""Endpoint del formulario de contacto: POST /api/contacto -> correo por SMTP.
Configuración solo por variables de entorno (nunca en el repo):
SMTP_HOST, SMTP_PORT (587), SMTP_USER, SMTP_PASS, SMTP_TLS_NAME (nombre del certificado, por defecto SMTP_HOST),
MAIL_FROM, MAIL_TO
No guarda los mensajes en disco: solo los reenvía por correo.
"""
import json
import os
import re
import smtplib
import ssl
import sys
import time
from collections import defaultdict, deque
from email.message import EmailMessage
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MOTIVOS = {
'venir': 'Quiero venir al programa',
'tema': 'Propongo un tema',
'otro': 'Otro asunto',
}
EMAIL_RE = re.compile(r'^[^@\s]{1,64}@[^@\s]{1,190}\.[^@\s]{2,}$')
MAX_BODY = 16 * 1024
RATE_MAX, RATE_WINDOW = 5, 3600 # 5 mensajes por IP y hora
hits = defaultdict(deque)
def clean(s, n):
return re.sub(r'[\r\n\t]+', ' ', str(s or '')).strip()[:n]
def rate_ok(ip):
now = time.time()
q = hits[ip]
while q and now - q[0] > RATE_WINDOW:
q.popleft()
if len(q) >= RATE_MAX:
return False
q.append(now)
return True
def send(nombre, email, motivo, mensaje):
host, user, pwd = os.environ.get('SMTP_HOST'), os.environ.get('SMTP_USER'), os.environ.get('SMTP_PASS')
if not (host and user and pwd and os.environ.get('MAIL_TO')):
raise RuntimeError('SMTP no configurado')
msg = EmailMessage()
msg['Subject'] = f'[Web AQNSN] {MOTIVOS[motivo]} · {nombre}'
msg['From'] = os.environ.get('MAIL_FROM', user)
msg['To'] = os.environ['MAIL_TO']
msg['Reply-To'] = email
msg.set_content(f'Motivo: {MOTIVOS[motivo]}\nNombre: {nombre}\nEmail: {email}\n\n{mensaje}\n')
s = smtplib.SMTP(host, int(os.environ.get('SMTP_PORT', '587')), timeout=20)
try:
s.ehlo()
s._host = os.environ.get('SMTP_TLS_NAME', host) # el certificado se valida contra este nombre
s.starttls(context=ssl.create_default_context())
s.ehlo()
s.login(user, pwd)
s.send_message(msg)
finally:
try:
s.quit()
except Exception:
pass
class H(BaseHTTPRequestHandler):
server_version = 'aqnsn'
def log_message(self, fmt, *a): # sin IPs ni contenido en los logs
pass
def reply(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path != '/api/contacto':
return self.reply(404, {'ok': False})
try:
n = int(self.headers.get('Content-Length', '0'))
if n <= 0 or n > MAX_BODY:
return self.reply(413, {'ok': False, 'error': 'Mensaje demasiado largo.'})
d = json.loads(self.rfile.read(n))
except Exception:
return self.reply(400, {'ok': False, 'error': 'Petición no válida.'})
ip = (self.headers.get('X-Forwarded-For') or self.client_address[0]).split(',')[0].strip()
# antispam: campo trampa relleno o envío en menos de 3 s => se responde "ok" sin enviar
if d.get('web') or (isinstance(d.get('t'), (int, float)) and d['t'] < 3000):
return self.reply(200, {'ok': True})
if not rate_ok(ip):
return self.reply(429, {'ok': False, 'error': 'Demasiados mensajes. Inténtalo más tarde.'})
nombre, email = clean(d.get('nombre'), 100), clean(d.get('email'), 200)
motivo = d.get('motivo') if d.get('motivo') in MOTIVOS else 'otro'
mensaje = str(d.get('mensaje') or '').replace('\r', '').strip()[:3000]
if not nombre or not EMAIL_RE.match(email) or len(mensaje) < 10 or d.get('consent') is not True:
return self.reply(400, {'ok': False, 'error': 'Revisa los campos del formulario.'})
try:
send(nombre, email, motivo, mensaje)
except RuntimeError:
return self.reply(503, {'ok': False, 'error': 'El formulario no está disponible ahora mismo.'})
except Exception as exc:
print('error enviando correo:', type(exc).__name__, file=sys.stderr, flush=True)
return self.reply(502, {'ok': False, 'error': 'No se pudo enviar. Inténtalo más tarde.'})
self.reply(200, {'ok': True})
if __name__ == '__main__':
ThreadingHTTPServer(('127.0.0.1', 8081), H).serve_forever()