Un abonat apasă „Dezabonare” în Gmail, iar serverul newsletterului trebuie să-i oprească mesajele fără autentificare, formular suplimentar sau pagină de confirmare. Pentru ca acest flux să funcționeze, un link obișnuit din subsolul emailului nu este suficient: mesajul are nevoie de două headere, de un endpoint HTTPS care acceptă cereri POST și de o semnătură DKIM care protejează configurația.
În proiectul de mai jos construim componenta de dezabonare pentru un newsletter propriu. La final, aplicația va genera un mesaj .eml cu headerele RFC 8058, va primi cererea automată trimisă de furnizorul de email și va marca abonatul ca dezabonat într-o bază SQLite.
Implementarea folosește un token aleatoriu pentru fiecare abonat. Adresa de email nu apare în URL, iar repetarea aceleiași cereri nu reactivează abonamentul și nu produce o eroare.
Separăm linkul vizibil de mecanismul automat al aplicației de email
List-Unsubscribe, definit de RFC 2369, indică una sau mai multe metode prin care destinatarul poate părăsi lista. O valoare bazată pe HTTPS arată astfel:
List-Unsubscribe: <https://newsletter.example.com/u/token-aleatoriu>
Pentru dezabonarea cu un click, RFC 8058 adaugă al doilea header:
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Când utilizatorul confirmă acțiunea din interfața aplicației de email, furnizorul trimite către URL o cerere HTTPS POST. Corpul cererii conține:
List-Unsubscribe=One-Click
Acesta este un mecanism diferit de linkul vizibil „Dezabonare” din conținutul newsletterului. Linkul din mesaj trebuie păstrat și poate conduce la un centru de preferințe, unde utilizatorul alege listele pe care le mai dorește. Endpointul RFC 8058 trebuie, în schimb, să proceseze dezabonarea direct, fără login, CAPTCHA sau o a doua confirmare.
O cerere GET nu trebuie să dezaboneze abonatul. Unele sisteme de securitate deschid automat URL-urile pentru analiză, iar RFC 8058 folosește intenționat metoda POST pentru a evita dezabonările provocate de aceste verificări.
Google solicită mecanismul one-click pentru mesajele promoționale și abonate trimise în volum către conturi Gmail personale. Yahoo include, de asemenea, suportul RFC 8058 în cerințele pentru expeditorii bulk. Existența headerelor nu garantează însă că butonul va fi afișat: furnizorul destinatarului evaluează și autentificarea, reputația și natura mesajului.
Pregătim laboratorul Python și baza cu abonați
Exemplul este construit pentru Ubuntu Server 24.04 LTS, Python 3.12 și Flask 3.1.2. Sunt suficiente un nucleu CPU, 512 MB RAM și aproximativ 100 MB liberi, iar testul local durează circa 30 de minute.
Verifică mediul înainte de instalare:
cat /etc/os-release
python3 --version
curl --version
df -h .
Un rezultat potrivit va conține, cu variații pentru patch-uri și spațiul disponibil:
Ubuntu 24.04 LTS
Python 3.12.x
curl 8.x
Proiectul va avea următoarea structură:
unsubscribe-demo/
├── app.py
├── create_message.py
├── requirements.txt
├── data/
│ └── newsletter.db
└── out/
└── newsletter.eml
app.py expune endpointul de dezabonare. create_message.py înregistrează un abonat de test și construiește mesajul, iar newsletter.db păstrează starea abonamentului.
Pasul 1 — Instalăm Flask într-un mediu izolat
Creăm directoarele și mediul virtual:
mkdir -p unsubscribe-demo/data unsubscribe-demo/out
cd unsubscribe-demo
python3 -m venv .venv
source .venv/bin/activate
Fișierul requirements.txt conține versiunea folosită în proiect:
Flask==3.1.2
Instalarea se face din mediul virtual:
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
Confirmă că aplicația poate importa Flask:
python -c "import flask; print(flask.__version__)"
Exemplul de rezultat este:
3.1.2
Dacă apare No module named 'flask', verifică dacă promptul shellului conține (.venv) și rulează din nou instalarea cu python -m pip, nu cu un executabil pip din alt mediu.
Procesăm doar POST-ul RFC 8058 și păstrăm operația idempotentă
Endpointul nu primește adresa de email. El caută un token lung și aleatoriu, asociat anterior abonatului. Astfel, URL-ul nu divulgă direct date personale și nu poate fi construit simplu prin schimbarea unei adrese.
Pasul 2 — Scriem endpointul care oprește newsletterul
Creează fișierul complet app.py:
from datetime import datetime, timezone
from pathlib import Path
import sqlite3
from flask import Flask, jsonify, request
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "data" / "newsletter.db"
app = Flask(__name__)
def connect_db():
connection = sqlite3.connect(DB_PATH)
connection.row_factory = sqlite3.Row
return connection
def initialize_database():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with connect_db() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS subscribers (
email TEXT NOT NULL UNIQUE,
token TEXT NOT NULL UNIQUE,
subscribed INTEGER NOT NULL DEFAULT 1,
unsubscribed_at TEXT
)
"""
)
@app.get("/healthz")
def health():
return jsonify(status="ok")
@app.post("/u/<token>")
def one_click_unsubscribe(token):
if request.form.get("List-Unsubscribe") != "One-Click":
return jsonify(error="cerere one-click invalidă"), 400
with connect_db() as connection:
subscriber = connection.execute(
"SELECT subscribed FROM subscribers WHERE token = ?",
(token,),
).fetchone()
if subscriber is None:
return jsonify(error="token necunoscut"), 404
if subscriber["subscribed"] == 1:
connection.execute(
"""
UPDATE subscribers
SET subscribed = 0, unsubscribed_at = ?
WHERE token = ?
""",
(datetime.now(timezone.utc).isoformat(), token),
)
return "", 204
initialize_database()
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8000)
Interogările folosesc parametri ?, nu valori concatenate în SQL. Endpointul verifică exact câmpul definit de RFC 8058 și răspunde cu 204 No Content după procesare.
Operația este idempotentă: dacă furnizorul repetă cererea pentru același token, abonatul rămâne dezabonat, iar serverul răspunde tot cu succes. Un token inexistent primește 404, fără să dezvăluie adrese sau alte informații din listă.
Pornește aplicația:
source .venv/bin/activate
python app.py
Outputul de dezvoltare va semăna cu exemplul următor:
* Serving Flask app 'app'
* Running on http://127.0.0.1:8000
În alt terminal, verifică ruta de sănătate:
curl -i http://127.0.0.1:8000/healthz
Trebuie să primești HTTP/1.1 200 OK și un corp JSON cu starea ok. Serverul integrat Flask este potrivit pentru laborator, nu pentru expunere directă pe internet.
Generăm câte un URL distinct pentru fiecare destinatar
Folosirea aceluiași URL pentru toată lista ar împiedica serverul să identifice abonamentul care trebuie oprit. În sens opus, includerea adresei în clar, precum ?email=persoana@example.com, ar expune date în loguri și în istoricul intermediarilor.
Pasul 3 — Construim mesajul cu token și cele două headere
Creează fișierul complet create_message.py:
from email.message import EmailMessage
from email.policy import SMTP
from pathlib import Path
import os
import secrets
import sqlite3
import sys
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "data" / "newsletter.db"
OUT_PATH = BASE_DIR / "out" / "newsletter.eml"
email_address = "abonat@example.net"
base_url = os.environ.get("BASE_URL", "").rstrip("/")
allow_http_test = os.environ.get("ALLOW_HTTP_TEST") == "1"
if not base_url:
sys.exit("Eroare: setează variabila BASE_URL.")
if not base_url.startswith("https://") and not allow_http_test:
sys.exit("Eroare: BASE_URL trebuie să folosească HTTPS.")
with sqlite3.connect(DB_PATH) as connection:
row = connection.execute(
"SELECT token FROM subscribers WHERE email = ?",
(email_address,),
).fetchone()
if row is None:
token = secrets.token_urlsafe(32)
connection.execute(
"""
INSERT INTO subscribers (email, token, subscribed)
VALUES (?, ?, 1)
""",
(email_address, token),
)
else:
token = row[0]
unsubscribe_url = f"{base_url}/u/{token}"
message = EmailMessage()
message["From"] = "Newsletter Exemplu <newsletter@example.com>"
message["To"] = email_address
message["Subject"] = "Ediția de test a newsletterului"
message["List-Unsubscribe"] = f"<{unsubscribe_url}>"
message["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
message.set_content(
"Acesta este un mesaj de test.nn"
"Pentru administrarea abonamentului, folosește opțiunea "
"de dezabonare oferită de aplicația de email.n"
)
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUT_PATH.write_bytes(message.as_bytes(policy=SMTP))
print(f"Mesaj creat: {OUT_PATH}")
print(unsubscribe_url)
În laborator permitem temporar HTTP, deoarece endpointul rulează numai pe 127.0.0.1:
export BASE_URL='http://127.0.0.1:8000'
export ALLOW_HTTP_TEST=1
URL="$(python create_message.py | tail -n 1)"
printf '%sn' "$URL"
Outputul va conține un token diferit de fiecare instalare:
http://127.0.0.1:8000/u/TOKEN_ALEATORIU
Inspectează headerele fără să deschizi mesajul într-un client:
grep -E '^List-Unsubscribe' out/newsletter.eml
Exemplu de rezultat:
List-Unsubscribe: <http://127.0.0.1:8000/u/TOKEN_ALEATORIU>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
HTTP este acceptat aici exclusiv pentru testul local. În mesajele reale, RFC 8058 cere un URI HTTPS.
Simulăm cererea trimisă de Gmail sau Yahoo
Nu trebuie să aștepți afișarea butonului într-o căsuță reală pentru a verifica logica serverului. curl poate reproduce metoda și corpul cererii.
Pasul 4 — Trimitem POST-ul și confirmăm schimbarea din SQLite
Cu serverul Flask pornit, execută:
curl -i
-X POST
--data 'List-Unsubscribe=One-Click'
"$URL"
Rezultatul așteptat este:
HTTP/1.1 204 NO CONTENT
Extrage tokenul din URL și verifică baza:
TOKEN="${URL##*/}"
python - "$TOKEN" <<'PY'
import sqlite3
import sys
token = sys.argv[1]
with sqlite3.connect("data/newsletter.db") as connection:
row = connection.execute(
"SELECT email, subscribed, unsubscribed_at "
"FROM subscribers WHERE token = ?",
(token,),
).fetchone()
if row is None:
raise SystemExit("Abonatul nu a fost găsit.")
print(f"email={row[0]}")
print(f"subscribed={row[1]}")
print(f"unsubscribed_at={row[2]}")
PY
Trebuie să vezi subscribed=0 și un marcaj temporal UTC. Rulează din nou aceeași cerere curl: răspunsul rămâne 204, iar abonatul nu este modificat într-o stare greșită.
Testează și protecția împotriva accesului GET:
curl -i "$URL"
Flask va răspunde:
HTTP/1.1 405 METHOD NOT ALLOWED
Acest rezultat este intenționat. Un scanner care doar deschide URL-ul nu poate dezabona utilizatorul.
Mutăm endpointul pe HTTPS și cerem MTA-ului să semneze headerele
În producție, setează BASE_URL la domeniul real:
export BASE_URL='https://newsletter.example.com'
unset ALLOW_HTTP_TEST
python create_message.py
newsletter.example.com este un domeniu de documentare și trebuie înlocuit cu domeniul controlat de firmă. Endpointul public trebuie publicat în spatele unui server HTTPS cu certificat valid. Nu solicita autentificare, cookie-uri sau execuție JavaScript pentru cererea one-click.
Aplicația care generează mesajul nu înlocuiește autentificarea emailului. Serverul SMTP sau furnizorul de trimitere trebuie să aplice o semnătură DKIM validă și să includă List-Unsubscribe și List-Unsubscribe-Post între headerele acoperite de semnătură. Verifică valoarea h= din DKIM-Signature în sursa mesajului livrat.
SPF, DKIM și DMARC trebuie configurate separat. Nici List-Unsubscribe, nici certificatul HTTPS nu repară o autentificare email incorectă. Transportul TLS este, de asemenea, o problemă distinctă; pentru monitorizarea lui poți urma ghidul despre configurarea MTA-STS și TLS-RPT.
Trimite întâi către conturi de test controlate de firmă și inspectează mesajul primit, nu doar fișierul local. Confirmă:
- există exact cele două headere;
- URL-ul folosește HTTPS și răspunde la
POST; - DKIM este valid și acoperă headerele de dezabonare;
- tokenul aparține destinatarului corect;
- procesul de selecție a campaniei exclude rândurile cu
subscribed=0; - linkul vizibil de administrare a abonamentului rămâne în mesaj.
Nu folosi butonul afișat de Gmail sau Yahoo drept unic test. Furnizorul poate decide să nu-l arate chiar dacă sintaxa este corectă. Verificarea tehnică pornește din sursa brută a mesajului și continuă cu endpointul și baza de date.
În același timp, nu încuraja destinatarii să apese „Dezabonare” în mesaje evident frauduloase, deoarece linkul din corp poate conduce la alt site. Pentru astfel de situații este mai sigură verificarea emailului suspect fără accesarea linkurilor.
Localizăm erorile care rup fluxul one-click
HTTP/1.1 400 BAD REQUEST împreună cu mesajul cerere one-click invalidă arată că lipsește corpul exact. Repetă testul cu:
curl -i -X POST
--data 'List-Unsubscribe=One-Click'
"$URL"
HTTP/1.1 404 NOT FOUND și token necunoscut indică faptul că URL-ul nu corespunde bazei active. Regenerează mesajul pentru abonatul respectiv; nu înlocui manual tokenul.
HTTP/1.1 405 METHOD NOT ALLOWED este corect pentru GET, dar greșit dacă apare la cererea POST. Verifică dacă ruta este declarată cu @app.post și dacă proxy-ul transmite metoda fără să o transforme.
Mesajul Eroare: BASE_URL trebuie să folosească HTTPS. apare când încerci să generezi o configurație de producție cu HTTP. Configurează domeniul cu certificat valid; folosește ALLOW_HTTP_TEST=1 numai pe 127.0.0.1.
Eroarea curl: (60) SSL certificate problem arată că certificatul endpointului nu poate fi validat. Repară lanțul de certificate pe server. Nu publica recomandări de test cu curl -k, deoarece acestea ar ascunde exact problema care trebuie corectată.
Dacă headerele există în .eml, dar lipsesc din mesajul livrat, verifică serviciul SMTP sau platforma de newsletter. Unele sisteme reconstruiesc mesajul și pot elimina headerele personalizate. Testul relevant este întotdeauna sursa mesajului ajuns în căsuța destinatarului.
Ștergem laboratorul fără să atingem lista reală
Oprește serverul Flask cu Ctrl+C. Pentru a elimina complet proiectul local:
deactivate
cd ..
rm -rf unsubscribe-demo
Avertisment: comanda șterge baza SQLite și toate tokenurile din laborator. Nu o rula într-un director care conține lista reală sau alte fișiere.
În producție, nu șterge rândurile dezabonate imediat. Sistemul de expediere are nevoie de o evidență de suprimare pentru a nu reintroduce accidental adresa într-o campanie ulterioară. Politica de păstrare a datelor trebuie stabilită separat, în funcție de temeiul și obligațiile aplicabile organizației.
Înaintea primei campanii, bifează fiecare control:
- ☐ Fiecare abonat are un token aleatoriu și distinct.
- ☐ Adresa de email nu apare în URL-ul de dezabonare.
- ☐
List-Unsubscribeconține un URL HTTPS complet. - ☐
List-Unsubscribe-Postare valoarea exactăList-Unsubscribe=One-Click. - ☐ Cererea
POSTrăspunde cu succes fără login sau confirmare suplimentară. - ☐ O cerere
GETnu modifică abonamentul. - ☐ Repetarea aceluiași
POSTeste sigură. - ☐ DKIM validează și acoperă ambele headere.
- ☐ Generatorul campaniei exclude abonații cu
subscribed=0. - ☐ Mesajul păstrează și opțiunea vizibilă de administrare a abonamentului.
Întrebări frecvente
Este suficient un link „Dezabonare” în subsolul newsletterului?
Nu. Linkul vizibil trebuie păstrat, dar mecanismul one-click folosește separat headerele List-Unsubscribe și List-Unsubscribe-Post, plus un endpoint HTTPS care acceptă POST.
De ce endpointul nu trebuie să dezaboneze prin GET?
Sistemele de securitate pot deschide automat URL-urile din mesaje. Procesarea exclusivă prin POST împiedică o simplă verificare automată să oprească abonamentul.
De ce nu apare imediat butonul în Gmail?
Sintaxa corectă nu obligă furnizorul să afișeze butonul. Sunt evaluate și autentificarea mesajului, reputația expeditorului și tipul emailului.
Poate endpointul să ceară autentificare sau CAPTCHA?
Nu pentru fluxul RFC 8058. Cererea one-click trebuie procesată fără login, cookie-uri, JavaScript sau o confirmare suplimentară.
Surse
- RFC 8058: Signaling One-Click Functionality for List Email Headers — <>
- RFC 2369: The Use of URLs as Meta-Syntax for Core Mail List Commands — <>
- Email sender guidelines — Google Workspace Admin Help — <>
- Sender Best Practices — Yahoo Sender Hub — <>
- Flask Documentation — <>
- sqlite3 — DB-API 2.0 interface for SQLite databases — <>

















































