Юридична відписка проти технічно правильної: як RFC 8058 зробив CAN-SPAM і GDPR частиною SMTP-контракту
Провайдери зробили половину закону технічно обов'язковою. Розбираємо, чому футерний лінк «Unsubscribe» більше не рятує від спам-папки, як зібрати коректний List-Unsubscribe із One-Click, і чому один HMAC-підписаний ендпоінт закриває і CAN-SPAM, і GDPR.
EvilMail Team21 липня 2026 р.11 хв читання
Ваша розсилка почала осідати в «Промоакціях», а потім і в спамі — приблизно з лютого 2024 року. У футері листа є акуратний лінк Unsubscribe, юрист його благословив, ви нічого не міняли. І саме в цьому проблема: ви нічого не змінили, а Gmail і Yahoo — змінили.
Провайдери перестали довіряти футерному лінку як доказу того, що від вас легко відписатися. Тепер вони хочуть машинно-читану відписку прямо в заголовках листа, яку можна виконати одним POST-запитом без участі вашого сайту. Якщо її немає — кнопка «Відписатися» біля вашого імені відправника просто не намалюється, а роздратований отримувач натисне сусідню кнопку «Це спам». Далі — арифметика репутації, не юриспруденція.
Перш ніж рухатись далі, розділимо три речі, які постійно плутають:
Видимий лінк у тілі листа — те, що бачить людина у футері. Потрібен, але провайдеру він майже нецікавий.
Заголовок `List-Unsubscribe` — машинний, за RFC 2369. Існує з 1998 року.
One-Click за RFC 8058 — заголовок List-Unsubscribe-Post
, який перетворює перший на кнопку, що працює одним POST.
Три різні механізми. Комплаєнс і доставлюваність тримаються на другому й третьому, а не на першому.
Дві машини, які треба задовольнити одночасно
На вашу відписку тиснуть два незалежні джерела, і вони давно зрослися в одну точку.
Перше — закон. У США це CAN-SPAM: модель opt-out. Ви маєте право слати комерційну пошту без попередньої згоди, поки чесно ідентифікуєте себе і даєте робочий спосіб відписатися. У ЄС це GDPR разом з ePrivacy: модель opt-in. Без правової підстави (найчастіше — згоди) ви не маєте права надіслати перший лист узагалі.
Друге — мейлбокс-провайдери. З лютого 2024 Gmail і Yahoo ввели вимоги до bulk-відправників (поріг Gmail — 5000+ листів на день на домен). Серед них — обов'язкова однокликова відписка за RFC 8058 і обробка запиту протягом двох днів. Тобто провайдери фактично зробили частину закону технічно примусовою: тепер вам не FTC виставляє рахунок за погану відписку, а спам-фільтр — щодня і мовчки.
Головний висновок, який заощадить вам тижні роботи: різні закони сходяться в одну технічну точку. CAN-SPAM вимагає opt-out-механізму, GDPR — простого відкликання згоди (Art. 7(3)) і безумовного права заперечити маркетинг (Art. 21(2)). Усі три вимоги задовольняє один і той самий HMAC-підписаний ендпоінт відписки. Різниця лише в дефолтному стані згоди до першого листа — а не в тому, як влаштована сама відписка.
Що конкретно вимагає CAN-SPAM
Без води, самі норми (15 U.S.C. §7704 і правила FTC):
Чесні заголовки. From, Reply-To і Subject не мають вводити в оману. «Re:» на листі, який не є відповіддю, — це порушення.
Фізична поштова адреса відправника всередині листа. Реальна, чинна, поштова.
Позначка реклами, якщо лист рекламний.
Працюючий механізм відписки, дійсний щонайменше 30 днів після відправки. Лінк, який протух через тиждень, — порушення.
Виконати запит протягом 10 робочих днів.
Не можна брати плату за відписку, вимагати будь-яку інформацію крім email-адреси, або змушувати людину проходити більше однієї сторінки.
Остання заборона — та, об яку розбивається половина «юридично коректних» реалізацій. Якщо ваша відписка веде на сторінку логіну, а потім на форму з чекбоксами й кнопкою «Зберегти налаштування» — це вже дві сторінки, і формально ви поза законом.
Штраф індексується щороку і станом на 2024–2025 сягає близько $53 088 за кожен окремий лист-порушення (перевіряйте актуальну цифру FTC на дату відправки — вона росте). Помножте на розмір списку — і стає зрозуміло, чому це не «юридичний фон». І ще раз, головна відмінність: CAN-SPAM не вимагає згоди до відправки. Це opt-out.
Що вимагають GDPR і ePrivacy
Тут дефолт протилежний — opt-in. Ключові норми:
Art. 6 — потрібна правова підстава. Для маркетингу це зазвичай згода (Art. 6(1)(a)), рідше — легітимний інтерес, який ще треба обґрунтувати й пройти balancing test.
Art. 7(3) — відкликати згоду має бути так само просто, як її надати. Якщо підписка була в один клік, а відписка — через дзвінок у підтримку, ви порушуєте цю статтю.
Art. 21(2) — безумовне право заперечити проти прямого маркетингу будь-коли, без пояснення причин.
ePrivacy Directive 2002/58/EC, Art. 13 — «soft opt-in»: наявному клієнту можна слати пропозиції схожих товарів, якщо під час покупки ви дали простий спосіб відмовитися.
Практичні наслідки, які прямо впливають на код:
Передвстановлені галочки згоди заборонені (Recital 32, підтверджено рішенням CJEU у справі Planet49, C-673/17). Чекбокс має бути порожнім за замовчуванням.
Відписка не може вести на форму логіну — інакше ви прив'язали відкликання згоди до наявності акаунта, а це вже не «так само просто».
Треба логувати час і джерело згоди — без цього ви не доведете правову підставу під час перевірки.
І про таймінг: GDPR не дає пільгових «10 днів». Очікується фактично негайне припинення обробки. Штрафи — до €20 млн або 4% глобального річного обороту, залежно від того, що більше.
Анатомія List-Unsubscribe і чому одного заголовка мало
RFC 2369 дозволяє заголовку List-Unsubscribe містити mailto:, https:, або обидва. Роками всі ставили тільки https-лінк — і саме він тепер стріляє в ногу.
Проблема в тому, що провайдери й корпоративні проксі роблять GET-превізит усіх посилань у листі — для антивірусної перевірки й розгортання скорочених URL. Якщо ваша відписка спрацьовує на GET, такий превізит випадково відпише живу людину, яка нічого не тиснула. Тому GET більше не годиться, а провайдери навчилися не малювати кнопку для заголовків, у яких немає надійного POST-механізму.
Його додає RFC 8058. Ось повний коректний набір заголовків:
Присутній List-Unsubscribe-Post: List-Unsubscribe=One-Click — це сигнал провайдеру «можна безпечно POST-ити».
Обидва заголовки List-Unsubscribe і List-Unsubscribe-Post мають бути в списку h= вашого DKIM-підпису. Якщо їх не підписано — Gmail і Yahoo вважають їх підробними і тихо ігнорують одноклік. Це найчастіша прихована помилка: заголовки є, DKIM є, а кнопки немає, бо h= їх не покриває.
Https-ендпоінт приймає тільки POST, працює по HTTPS, без логіну, без CAPTCHA, без підтверджуючої сторінки.
mailto:-варіант має вести на реальну скриньку, яку хтось або щось парсить і перетворює на відписку. Інакше це фікція, і при першому ж скарзі вона впаде.
Реалізація ендпоінта, який не зламається
Ключове непорозуміння: POST від провайдера приходить без користувача. Немає кукі, немає сесії, немає людського user-agent. Gmail сам, на своєму бекенді, шле POST з Content-Type: application/x-www-form-urlencoded і тілом рівно List-Unsubscribe=One-Click. Тому ендпоінт не може ідентифікувати підписника через авторизацію — тільки через підписаний токен у самому URL.
Логіка проста: у токен зашиваємо subscriber_id, list_id і термін дії, підписуємо HMAC-SHA256, кладемо в query. На вході перевіряємо підпис у constant-time, ідемпотентно заносимо в suppression і швидко віддаємо 2xx.
typescript
// src/app/u/one-click/route.ts (Next.js App Router)
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.UNSUB_HMAC_SECRET!; // 64-hex, env-only
function verifyToken(token: string): { sub: number; list: number } | null {
const [payloadB64, sig] = token.split(".");
if (!payloadB64 || !sig) return null;
const expected = createHmac("sha256", SECRET).update(payloadB64).digest();
const got = Buffer.from(sig, "base64url");
// constant-time: інакше витік підпису по timing
if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null;
const { sub, list, exp } = JSON.parse(
Buffer.from(payloadB64, "base64url").toString(),
);
if (Date.now() / 1000 > exp) return null; // токен протух
return { sub, list };
}
export async function POST(req: Request) {
const url = new URL(req.url);
const claim = verifyToken(url.searchParams.get("t") ?? "");
if (!claim) return new Response("bad token", { status: 400 });
const body = new URLSearchParams(await req.text());
if (body.get("List-Unsubscribe") !== "One-Click") {
return new Response("bad body", { status: 400 });
}
// ідемпотентний upsert — повторний POST не ламає нічого
await prisma.suppression.upsert({
where: { emailHash_listId: { emailHash: await hashOf(claim.sub), listId: claim.list } },
update: {},
create: {
emailHash: await hashOf(claim.sub),
listId: claim.list,
reason: "one_click",
sourceIp: getClientIp(req),
},
});
return new Response(null, { status: 200 }); // швидко, без редіректів
}
Два практичні застереження. По-перше, не плутайте превізит із кліком: POST може прилетіти за секунди після відправки — це антиспам-сканер провайдера тисне кнопку «на суху». Робіть операцію ідемпотентною і не надсилайте користувачу «підтвердження відписки» у відповідь на кожен POST. По-друге, той самий токен має обслуговувати і mailto:, і видимий футерний лінк — інакше ви підтримуєте три різні кодошляхи замість одного.
Suppression замість видалення
Відписаний контакт — це запис у suppression-таблиці, а не `DELETE` рядка. Якщо ви видалите людину, наступний імпорт CSV від відділу продажів радісно поверне її назад — і ви знову спамите того, хто відмовився. Це повторне порушення, тепер уже з доказом наміру.
Suppression перевіряється на етапі формування кожної кампанії, до постановки в чергу. Мінімальна схема:
prisma
model Suppression {
id Int @id @default(autoincrement())
emailHash String // sha256(lower(email)) — не зберігаємо сам email зайвий раз
listId Int
reason String // one_click | footer | complaint | manual | fbl
sourceIp String?
createdAt DateTime @default(now())
@@unique([emailHash, listId])
}
Поле reason і createdAt — це ваш доказ комплаєнсу: за запитом регулятора ви показуєте, коли і яким каналом людина відписалася. Окремо заведіть канал fbl: скарги «Це спам» приходять через Feedback Loop провайдерів і мають литися в той самий suppression. Людина, яка натиснула «спам», відписалася — просто грубіше.
Пороги доставлюваності, які роблять усе це не опційним
Конкретні числа, за якими живуть Gmail і Yahoo для bulk-відправників:
Спам-рейт нижче 0.3% за Google Postmaster Tools, цільово менше 0.1%. Перетнули 0.3% — домен деградує швидко.
SPF + DKIM + DMARC з вирівнюванням (alignment). Мінімум p=none, але вирівнювання обов'язкове.
Одноклік за RFC 8058 для bulk, з обробкою запиту протягом 2 днів.
Причинно-наслідковий ланцюг, який варто прибити до стіни: погана відписка → людина не знаходить легкої кнопки → тисне «Це спам» → рейт скарг лізе вгору → домен втрачає репутацію → навіть транзакційні листи йдуть у папку спаму. Тобто правильний List-Unsubscribe — це інструмент захисту репутації домену, а комплаєнс із CAN-SPAM і GDPR ви отримуєте як безкоштовний побічний ефект, а не навпаки.
Чеклист перед відправкою кампанії
[ ] List-Unsubscribe містить іhttps: (POST), іmailto: на реальну скриньку.