One-click отписка на практике: заголовки List-Unsubscribe и List-Unsubscribe-Post под требования Gmail и Yahoo
С февраля 2024 Gmail и Yahoo требуют рабочую one-click отписку от любого отправителя массовых писем. Разбираем, как Gmail реально парсит List-Unsubscribe и List-Unsubscribe-Post, почему у большинства реализаций она сломана, и собираем POST-эндпоинт, который отрабатывает за пару секунд и не течёт базой адресов.
EvilMail Team3 августа 2026 г.11 мин чтения
Если вы шлёте больше 5000 писем в сутки на Gmail-адреса и у вас нет рабочей пары List-Unsubscribe + List-Unsubscribe-Post, ваши письма уже сейчас частично уезжают в спам, а часть отклоняется прямо на входе с ответом вида 550 5.7.1 ... messages not accepted for policy reasons. Это не прогноз и не best practice из чек-листа маркетолога — это порог допуска, который Google и Yahoo включили в феврале 2024 и ужесточили к июню того же года. К 2026-му он давно перестал быть новостью, но реализации, которые я вижу в проде, всё так же ломаются в деталях: mailto: без HTTPS-варианта, POST-эндпоинт за 302-редиректом, «отписка», которая на самом деле ведёт на форму логина, или заголовок, добавленный шлюзом уже после DKIM-подписи — и потому ею не покрытый.
Правильная позиция: one-click отписка — это часть инфраструктуры доставляемости наравне с SPF, DKIM и DMARC, а не фича на стороне маркетинга. Относиться к ней надо как к API-контракту с Google: есть формат запроса, который присылает их машина, и есть ожидаемый ответ, который обязан отдать ваш эндпоинт. Разберём контракт целиком.
## Что именно требуют Gmail и Yahoo
Порог — 5000 писем в сутки на адреса конкретного провайдера. Отправили 5000+ на @gmail.com за день — вы «bulk sender» в терминах Google, и на вас распространяется полный набор требований. У Yahoo (вместе с AOL) порог и логика те же, механика общая: оба опираются на один и тот же RFC 8058.
Часто путают три связанных, но разных требования:
- Наличие `List-Unsubscribe` в письме — базовый заголовок из RFC 2369, существует с 1998 года.
- Поддержка one-click через List-Unsubscribe-Post — это уже RFC 8058, именно она даёт кнопку «Отписаться» рядом с адресом отправителя.
- Обработка отписки в течение 2 дней (48 часов) — вы обязаны фактически перестать слать в этот срок, а не просто принять запрос.
Плюс отдельный, но связанный порог — доля жалоб на спам. Держите её ниже 0.10% по Google Postmaster Tools и никогда не давайте перевалить за 0.30%. Как только пользователи не находят кнопку отписки, они жмут «В спам» — и вы пробиваете порог жалоб, что бьёт по репутации домена сильнее любого контента.
Формально one-click обязателен только для коммерческих и рекламных массовых рассылок. Транзакционные письма — сброс пароля, чек об оплате, код подтверждения — под требование не подпадают. Но их не стоит смешивать в один поток с промо, и, что важнее, в транзакционные письма
List-Unsubscribe
добавлять не нужно: отписка от квитанции об оплате бессмысленна и только путает провайдера.
## Анатомия двух заголовков
List-Unsubscribe
(RFC 2369) содержит один или несколько методов отписки в угловых скобках через запятую. Для bulk-рассылки вам нужны
Порядок имеет значение: HTTPS-вариант ставьте первым — почтовые провайдеры, поддерживающие RFC 8058, берут именно его для one-click.
mailto:
оставляют вторым как запасной канал для старых клиентов, которые про one-click ничего не знают.
List-Unsubscribe-Post
(RFC 8058) имеет ровно одно валидное значение —
List-Unsubscribe=One-Click
. Никаких вариаций, никакого другого текста. Наличие этого заголовка — сигнал провайдеру: «по HTTPS-URL можно слать POST без участия человека».
Дальше — момент, на котором ломается большинство реализаций. Когда пользователь жмёт кнопку в интерфейсе Gmail, отписку выполняет **не его браузер**, а сервер Google. Он шлёт на ваш HTTPS-URL запрос:
POST /api/unsubscribe?u=TOKEN HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 26
List-Unsubscribe=One-Click
``
Без куки. Без сессии. Без CSRF-токена из вашей формы. Без User-Agent пользователя. Если ваш эндпоинт отписки требует авторизованную сессию или отдаёт форму «подтвердите отписку» — для машины Google это провал, и кнопка либо не покажется, либо один клик ничего не сделает. Эндпоинт обязан отработать полностью на основе одного лишь токена из URL.
Путь one-click отписки
evilmail (отправитель)
List-Unsubscribe
List-Unsubscribe-Post
покрыты DKIM h=
письмо → Gmail
Gmail
1. проверка DKIM/DMARC
2. рендер кнопки
«Отписаться»
эндпоинт /api
HTTPS, без редиректа
отвечает 200
адрес в suppression <48ч
Когда пользователь жмёт кнопку — POST шлёт сервер Google, не браузер:
POST → https://evilmail.pro/api/unsubscribe?u=TOKEN
body: List-Unsubscribe=One-Click
Content-Type: application/x-www-form-urlencoded · без cookie · без редиректа
## Критично: заголовки должны быть под DKIM-подписью
Это самая частая скрытая поломка — «заголовок есть, а кнопки нет». Gmail рендерит one-click только если List-Unsubscribe и List-Unsubscribe-Post входят в тег h= DKIM-подписи. Логика простая: заголовок, не покрытый подписью, мог быть подменён на транзите, а значит доверять URL внутри него нельзя — и Google молча игнорирует one-click, даже если всё остальное идеально.
Проверьте свою DKIM-Signature. Оба заголовка должны быть явно перечислены в h=:
``
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=evilmail.pro; s=mail;
h=from:to:subject:date:list-unsubscribe:list-unsubscribe-post;
bh=...; b=...
Если подписью занимается ваш MTA (Postfix + OpenDKIM, rspamd) или внешний релей, а заголовки List-Unsubscribe добавляет приложение уже после того, как письмо ушло на подпись, — они окажутся снаружи h=. Порядок в конвейере должен быть таким: приложение формирует все заголовки → затем DKIM-подпись. В OpenDKIM за перечень полей отвечает директива SignHeaders; убедитесь, что оба заголовка там есть.
Заодно защититесь от инъекции лишнего заголовка через **oversigning** — перечислите list-unsubscribe в h= дважды. Второе, «пустое» вхождение говорит верификатору: если кто-то на транзите добавит ещё один List-Unsubscribe, подпись сломается, и подделка не пройдёт. В OpenDKIM это OversignHeaders List-Unsubscribe, List-Unsubscribe-Post.
Что покрывает DKIM h=
From: [email protected]
в h=
Subject: летняя рассылка
в h=
List-Unsubscribe: <https://...>, <mailto:...>
в h=
List-Unsubscribe-Post: List-Unsubscribe=One-Click
в h=
X-Gateway-Unsub: добавлен шлюзом ПОСЛЕ подписи
не покрыт h= → Gmail игнорирует one-click
вне h=
## Проектирование POST-эндпоинта отписки
Требования к URL жёсткие и не обсуждаются:
- **Только HTTPS** с валидным сертификатом. Самоподписанный или просроченный — отписка не сработает.
- **Без редиректов.** Многие провайдеры, включая прокси Gmail, ненадёжно следуют за 301/302 на POST. Ваш URL обязан отвечать 2xx напрямую, а не пинать запрос на https://www или на другой путь. Проверьте, что HSTS-редирект http→https не задевает уже-HTTPS-запрос, и что балансировщик не гоняет /api/unsubscribe на /api/unsubscribe/.
- **Быстрый ответ** — уложитесь в пару секунд. Тяжёлую работу (событие в CRM, аналитика) выносите в фон, синхронно делайте только запись в suppression list.
Отдельно про токен в URL. Никогда не кладите туда сырой email или автоинкрементный id подписчика. Сырой адрес — это утечка базы и приглашение к перебору (?u=1, ?u=2, ... — и вы отписали чужих людей или выкачали адреса). Токен должен быть **HMAC-SHA256 от email + list-id, подписанный серверным секретом**. Он неперебираем, не раскрывает адрес и проверяется без обращения к БД по индексу.
Эндпоинт должен быть **идемпотентным**: повторный POST по тому же токену не падает, а спокойно отвечает 200 (адрес уже в списке — ну и хорошо). И один URL обслуживает **и GET, и POST**: POST — машинная one-click отписка от провайдера, GET — живой человек, кликнувший на ссылку в вебмейле без поддержки RFC 8058. Логируйте источник для аналитики, но не блокируйте по User-Agent — у Google он свой, у пользователя свой, и ошибка здесь означает сломанную отписку.
## Референсная реализация
Ниже — route handler для Next.js App Router (src/app/api/unsubscribe/route.ts). Он экспортирует и GET, и POST, проверяет HMAC-токен через constant-time сравнение и делает upsert в suppression-таблицу.
typescript
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "crypto";
import { prisma } from "@/lib/prisma";
const SECRET = process.env.UNSUB_SECRET!; // 64-hex из env, не хардкод
// Токен формируется при отправке письма и кладётся в URL заголовка
export function makeToken(email: string, listId: string): string {
return createHmac("sha256", SECRET)
.update(${email}:${listId}`)
.digest("base64url");
}
function verify(email: string, listId: string, token: string): boolean {
const expected = Buffer.from(makeToken(email, listId));
const got = Buffer.from(token);
return expected.length === got.length && timingSafeEqual(expected, got);
}
async function suppress(email: string, listId: string, src: string) {
await prisma.suppression.upsert({
where: { email_listId: { email, listId } },
update: {}, // идемпотентно: повтор не падает
create: { email, listId, source: src, createdAt: new Date() },
});
}
// POST — машинная one-click отписка от Gmail/Yahoo
export async function POST(req: NextRequest) {
const { searchParams } = new URL(req.url);
const email = searchParams.get("e") ?? "";
const listId = searchParams.get("l") ?? "";
const token = searchParams.get("u") ?? "";
if (!verify(email, listId, token)) {
return new NextResponse("invalid", { status: 400 });
}
await suppress(email, listId, "one-click");
return new NextResponse("unsubscribed", { status: 200 });
}
// GET — живой клик в вебмейле без поддержки RFC 8058
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const email = searchParams.get("e") ?? "";
const listId = searchParams.get("l") ?? "";
const token = searchParams.get("u") ?? "";
if (!verify(email, listId, token)) {
return new NextResponse("Ссылка недействительна", { status: 400 });
}
await suppress(email, listId, "link");
return new NextResponse(
"Вы отписаныБольше писем этой рассылки не будет.
",
{ status: 200, headers: { "content-type": "text/html; charset=utf-8" } },
);
}
``
Вставка заголовков в Nodemailer — через поле list, синхронизированное с DKIM h=:
javascript
const token = makeToken(recipient, "summer-promo");
const url = https://evilmail.pro/api/unsubscribe?e=${encodeURIComponent(recipient)}&l=summer-promo&u=${token};
await transporter.sendMail({
from: "[email protected]",
to: recipient,
subject: "Летняя рассылка",
html: body,
list: {
unsubscribe: [
{ url, comment: "One-Click" },
{ url: "mailto:[email protected]?subject=unsubscribe" },
],
},
headers: { "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" },
dkim: {
domainName: "evilmail.pro",
keySelector: "mail",
privateKey: process.env.DKIM_KEY,
headerFieldNames: "from:to:subject:date:list-unsubscribe:list-unsubscribe-post",
},
});
Обратите внимание на headerFieldNames в блоке dkim — именно он превращается в тег h=. Оба list-unsubscribe-поля там перечислены явно, иначе вернётесь к проблеме из раздела про DKIM.
## Проверка перед выкатом
Самый честный тест — послать письмо себе на Gmail и глазами найти кнопку. Откройте письмо: рядом с адресом отправителя в шапке должна быть ссылка **«Отписаться»**. Если её нет при валидных заголовках — дело в репутации: Gmail показывает кнопку только прогретым отправителям с нормальной историей.
Проверьте сырые заголовки: в Gmail «Show original» / «Показать оригинал», затем грепом убедитесь, что оба заголовка на месте и что dkim=pass. Из командной строки удобно слать тестовое письмо через swaks:
bash
swaks --to [email protected] --from [email protected] \
--server smtp.evilmail.pro --tls \
--header "List-Unsubscribe: , " \
--header "List-Unsubscribe-Post: List-Unsubscribe=One-Click"
Смоделируйте, что делает Google, обычным curl — POST без куки должен вернуть 200:
bash
curl -i -X POST "https://evilmail.pro/api/[email protected]&l=summer-promo&u=TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "List-Unsubscribe=One-Click"
Дальше — mail-tester.com (даёт балл и отдельно флаг наличия List-Unsubscribe) и **Google Postmaster Tools**: следите за долей жалоб и статусом аутентификации по домену. Yahoo и AOL используют ту же RFC 8058 механику, так что рабочая под Gmail пара заголовков закрывает и их. Microsoft (Outlook.com) с 2025 года тоже требует аутентификацию от bulk-отправителей и рекомендует one-click — та же пара заголовков работает и там.
## Чек-лист перед включением bulk-рассылки
- List-Unsubscribe содержит **оба** метода, HTTPS-вариант стоит первым, mailto: — вторым.
- List-Unsubscribe-Post: List-Unsubscribe=One-Click присутствует дословно.
- Оба заголовка перечислены в теге h= DKIM-подписи и покрыты ею (dkim=pass на приёме).
- Настроен oversigning на List-Unsubscribe, чтобы заблокировать инъекцию дубликата.
- URL — только HTTPS, валидный сертификат, отвечает 2xx напрямую, без 301/302.
- POST-эндпоинт работает без куки и сессии, отвечает быстрее двух секунд.
- Токен — HMAC от email + list-id, сравнение через timingSafeEqual; сырого адреса и id в URL нет.
- Один URL обслуживает и GET, и POST; отписка идемпотентна.
- Отписка реально исключает адрес из следующей отправки в пределах 48 часов.
- Транзакционные письма идут отдельным потоком и List-Unsubscribe` не несут.
- Доля жалоб в Postmaster Tools ниже 0.10% и под контролем.