One-Click Unsubscribe (Gmail / Yahoo 2024)
RFC 8058 List-Unsubscribe-Post handler. Receives the `List-Unsubscribe=One-Click` form-urlencoded POST that Gmail and Yahoo 2024 mail clients send when the recipient clicks the inline unsubscribe button. Mirrors the public GET handler's side effects.
POST /api/v1/notifications/unsubscribe — 🌐 Public · Rate limit: 10 req / hour
Gmail and Yahoo's 2024 bulk-sender requirements mandate RFC 8058 one-click unsubscribe — a List-Unsubscribe-Post: List-Unsubscribe=One-Click body sent to the URL in the List-Unsubscribe email header. This endpoint accepts that POST and runs the same side effects as the public GET unsubscribe link: suppression list insert + per-event email-channel downgrade + GDPR consent record.
Token is the auth. Gmail's POST carries the recipient identity in the URL's query string (?email=&token=), not the body. The body is form-urlencoded List-Unsubscribe=One-Click. The signed HMAC token is verified server-side; an invalid token returns 200 OK with { success: false, message } (NOT 4xx) to avoid email-enumeration via timing.
Mirrors the GET handler exactly. processUnsubscribe() is shared between GET and POST — the only difference is the source parameter (email_one_click_get vs email_one_click_post) recorded on the ConsentRecord row. Side effects are identical: EmailSuppressionList insert (idempotent) + NotificationPreference.email = false per event + ConsentRecord opt-out row.
v2 scoped tokens differ from v1. v2_user_scope tokens carry userId + scope ('transactional' | 'marketing') directly. When scope === 'marketing', only marketing_campaign email preference is flipped off — transactional events keep flowing per RFC 8058 + GDPR Art. 21 (direct marketing only). v1_email_hex tokens always blanket-disable email across every NotificationDefault row.
Request
Query parameters
| Param | Type | Required | Notes |
|---|---|---|---|
email | string | conditional | Recipient address. For v2_user_scope tokens, server can resolve from userId if omitted. |
token | string | ✓ | Signed token from the email's List-Unsubscribe header URL. Missing → 200 OK with { success: false, message: 'Missing token.' }. |
Headers
| Header | Required | Notes |
|---|---|---|
Content-Type: application/x-www-form-urlencoded | ✓ (per RFC 8058) | Body is List-Unsubscribe=One-Click |
Body
Form-urlencoded List-Unsubscribe=One-Click. The body is not load-bearing — the controller reads email + token from the query string. Body presence is the RFC 8058 signal that the mail client supports one-click.
Response
200 OK — ApiResponseOf<UnsubscribeResponseDto>
Valid token
{
"success": true,
"data": {
"success": true,
"message": "You have been unsubscribed from email notifications."
}
}Invalid / expired token
{
"success": true,
"data": {
"success": false,
"message": "Invalid or expired unsubscribe link."
}
}Missing token
{
"success": false,
"message": "Missing token."
}Errors
| HTTP | Reason |
|---|---|
429 | Rate limit exceeded (10 req / hour — same budget as the GET handler) |
The endpoint deliberately never returns 4xx for missing or invalid params — 2xx with { success: false } is the timing-safe shape used to deter address enumeration.
Side effects
Identical to the GET handler — both call processUnsubscribe() with a different source flag.
- Missing token →
{ success: false, message: 'Missing token.' }. No mutations. - Verify token via
UnsubscribeTokenService.verifyToken(token, email). Returns{ valid, format, userId?, email?, scope?, expired? }. - Resolve target user —
v2_user_scope:userIdfrom token. Ifemailis missing in the query, look upUser.emailby ID.v1_email_hex:emailfrom token. Look upUser.idby email.
- Suppress email —
emailSuppressionList.findUnique({ where: { email } }); if missing, create withreason: 'user_unsubscribe',provider: null(idempotent). - Disable email channel per event — for every row in
NotificationDefault:- If
scope === 'marketing', skip events other thanmarketing_campaign. - Otherwise,
notificationPreference.upsert({ update: { email: false }, create: { email: false, push: true, inApp: true, webPush: true, sms: true } }).
- If
- GDPR consent log — write
ConsentRecordrow withdocumentType: 'notification_preference',accepted: false,preferences.action: 'opt_out',preferences.source: 'email_one_click_post', IP + user-agent captured for Art. 7(1) demonstrability. - Return
{ success: true, message: 'You have been unsubscribed from email notifications.' }.
Code samples
# Simulate Gmail's RFC 8058 POST. Token + email come from the
# List-Unsubscribe header URL embedded in the original email.
curl -X POST 'https://api.bio.re/api/v1/notifications/[email protected]&token=v2.user-uuid.marketing.1719072000.abc123def456' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'List-Unsubscribe=One-Click'type UnsubscribeResponse = {
success?: boolean;
message: string;
};
async function oneClickUnsubscribe(
email: string,
token: string,
): Promise<UnsubscribeResponse> {
const url = new URL('https://api.bio.re/api/v1/notifications/unsubscribe');
url.searchParams.set('email', email);
url.searchParams.set('token', token);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'List-Unsubscribe=One-Click',
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Unsubscribe failed'), {
code: json?.error?.code,
});
}
return json.data;
}List-Unsubscribe: <https://api.bio.re/api/v1/notifications/unsubscribe?email=fan%40example.com&token=v2.user-uuid.marketing.1719072000.abc123def456>, <mailto:[email protected]?subject=unsubscribe&body=token%3D...>
List-Unsubscribe-Post: List-Unsubscribe=One-ClickThe mail client picks the https URL and POSTs the form-urlencoded body. The mailto: fallback is for clients that don't support one-click.
Source
| Source | Path | Lines |
|---|---|---|
| Controller (POST handler) | apps/api-core/src/modules/notification/user-notification.controller.ts | 318–336 (unsubscribeOneClick) |
Controller (shared processUnsubscribe) | apps/api-core/src/modules/notification/user-notification.controller.ts | 338–445 |
| DTO (response) | apps/api-core/src/modules/notification/dto/notification-client-response.dto.ts | UnsubscribeResponseDto |
| Token service | apps/api-core/src/modules/notification/unsubscribe-token.service.ts | verifyToken() — accepts both v1_email_hex and v2_user_scope formats |
| GET handler (paired) | apps/api-core/src/modules/notification/user-notification.controller.ts | 302–316 (unsubscribe) — see Email Unsubscribe (GET) |
| Prisma models | packages/prisma/prisma/schema.prisma | EmailSuppressionList, NotificationDefault, NotificationPreference, User, ConsentRecord |
| Standard | RFC 8058 § 3.1 | One-click unsubscribe (Gmail / Yahoo 2024 bulk-sender requirement) |
| Live response | NOT verified — sourced from controller branches cited above |
Email Unsubscribe (Public)
Public HMAC-token endpoint reachable from email footers without login. Adds the email to the suppression list AND disables the email channel in every event preference for the matching user.
Subscribe to Web Push
Register a W3C PushSubscription with the server. Endpoint-keyed upsert handles browser key regeneration AND device handover (different user on the same browser → reassign).