← back to Ventura Claw Leads
yolo tick 2: /unsubscribe handler — RFC 8058 one-click + landing page
9c3866d58db72c5a2bbbbec1ef2d929ea513f431 · 2026-05-06 17:01:45 -0700 · Steve Abrams
routes/public.js: GET /unsubscribe?t=TOKEN renders the landing page (idempotent
— 2nd click is a no-op). POST /unsubscribe is the RFC 8058 one-click endpoint
that Gmail/Outlook hit automatically when the user taps the 'Unsubscribe' button.
processUnsubscribe() consumes the token (already idempotent in lib/compliance:
consumed_at uses COALESCE) and writes to comms_suppression with reason=
'unsubscribe' and source='unsubscribe_link'.
server.js: mounts a path-specific req.skipCsrf=true for /unsubscribe BEFORE
csrfMiddleware so RFC 8058 POSTs from mail clients (no browser session, no CSRF
token) can land. The HMAC-derived token is the bearer auth.
views/public/unsubscribe.ejs: confirmation page showing identifier + campaign
context. Per-reason error copy (invalid / no_token).
Test 2026-05-06 local: token mint → GET landing → comms_suppression row
written → second click idempotent (200, no error). One-click POST returns
{ ok: true } with proper Content-Type. Cleanup verified.
Files touched
M routes/public.jsM server.jsA views/public/unsubscribe.ejs
Diff
commit 9c3866d58db72c5a2bbbbec1ef2d929ea513f431
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 17:01:45 2026 -0700
yolo tick 2: /unsubscribe handler — RFC 8058 one-click + landing page
routes/public.js: GET /unsubscribe?t=TOKEN renders the landing page (idempotent
— 2nd click is a no-op). POST /unsubscribe is the RFC 8058 one-click endpoint
that Gmail/Outlook hit automatically when the user taps the 'Unsubscribe' button.
processUnsubscribe() consumes the token (already idempotent in lib/compliance:
consumed_at uses COALESCE) and writes to comms_suppression with reason=
'unsubscribe' and source='unsubscribe_link'.
server.js: mounts a path-specific req.skipCsrf=true for /unsubscribe BEFORE
csrfMiddleware so RFC 8058 POSTs from mail clients (no browser session, no CSRF
token) can land. The HMAC-derived token is the bearer auth.
views/public/unsubscribe.ejs: confirmation page showing identifier + campaign
context. Per-reason error copy (invalid / no_token).
Test 2026-05-06 local: token mint → GET landing → comms_suppression row
written → second click idempotent (200, no error). One-click POST returns
{ ok: true } with proper Content-Type. Cleanup verified.
---
routes/public.js | 51 ++++++++++++++++++++++++++++++++++++++++++++
server.js | 3 +++
views/public/unsubscribe.ejs | 23 ++++++++++++++++++++
3 files changed, 77 insertions(+)
diff --git a/routes/public.js b/routes/public.js
index 94eae29..8cc454c 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -176,6 +176,57 @@ router.get('/api/businesses.geo', async (req, res, next) => {
} catch (err) { next(err); }
});
+// Unsubscribe — both GET (landing page for humans clicking the link) and
+// POST (RFC 8058 one-click unsubscribe via List-Unsubscribe-Post header).
+// Token consumption + suppression-list write is idempotent: hitting the
+// link twice doesn't error, second time is a no-op.
+const compliance = require('../lib/compliance');
+
+async function processUnsubscribe(token) {
+ if (!token) return { ok: false, reason: 'no_token' };
+ const consumed = await compliance.consumeUnsubscribeToken(token);
+ if (!consumed) return { ok: false, reason: 'invalid' };
+ // consumed_at may have been already set by a prior click — that's fine.
+ await compliance.addSuppression({
+ channel: consumed.channel,
+ identifier: consumed.identifier,
+ reason: 'unsubscribe',
+ source: 'unsubscribe_link',
+ businessId: consumed.business_id || null,
+ notes: 'campaign:' + (consumed.campaign || 'unknown')
+ });
+ return { ok: true, identifier: consumed.identifier, campaign: consumed.campaign };
+}
+
+router.get('/unsubscribe', async (req, res, next) => {
+ try {
+ const token = String(req.query.t || '').trim();
+ if (!token) {
+ return res.status(400).render('public/unsubscribe', {
+ title: 'Unsubscribe', ok: false, reason: 'no_token', identifier: null, campaign: null
+ });
+ }
+ const r = await processUnsubscribe(token);
+ res.render('public/unsubscribe', {
+ title: r.ok ? 'Unsubscribed' : 'Unsubscribe', ...r,
+ identifier: r.identifier || null, campaign: r.campaign || null,
+ reason: r.reason || null
+ });
+ } catch (err) { next(err); }
+});
+
+// One-click — RFC 8058. Email clients POST automatically when the user
+// clicks the "Unsubscribe" button surfaced by Gmail/Outlook. No CSRF check
+// because the token IS the bearer auth and the request comes from the
+// recipient's mail client, not their browser session.
+router.post('/unsubscribe', async (req, res, next) => {
+ try {
+ const token = String(req.query.t || req.body.t || '').trim();
+ const r = await processUnsubscribe(token);
+ res.json({ ok: r.ok, reason: r.reason || null });
+ } catch (err) { next(err); }
+});
+
router.get('/healthz', async (req, res) => {
try {
await db.query('SELECT 1');
diff --git a/server.js b/server.js
index 5a6b27f..b671e23 100644
--- a/server.js
+++ b/server.js
@@ -79,6 +79,9 @@ app.use((req, res, next) => {
// Auth + CSRF (CSRF runs after attachBusiness so locals are available in error templates).
app.use(auth.attachBusiness);
+// RFC 8058 one-click unsubscribe — email clients POST without browser session.
+// The HMAC-derived token IS bearer auth; bypass CSRF.
+app.use('/unsubscribe', (req, res, next) => { req.skipCsrf = true; next(); });
app.use(csrfMiddleware);
const contactLimiter = rateLimit({
diff --git a/views/public/unsubscribe.ejs b/views/public/unsubscribe.ejs
new file mode 100644
index 0000000..32b2e76
--- /dev/null
+++ b/views/public/unsubscribe.ejs
@@ -0,0 +1,23 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="text-align:center;max-width:560px">
+ <% if (ok) { %>
+ <p class="kicker">Unsubscribed</p>
+ <h1 class="display-sm">You're off the list.</h1>
+ <p class="lede" style="margin:0 auto"><strong><%= identifier %></strong> won't receive any further <%= campaign === 'lead_delivery' ? 'lead-delivery emails' : (campaign === 'claim_invite' ? 'claim invitations' : 'emails') %> from Ventura Claw.</p>
+ <p class="muted" style="margin-top:24px;font-size:13px">Changed your mind? Email <a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a> to be re-subscribed.</p>
+ <p style="margin-top:24px"><a href="/" class="btn btn-ghost btn-sm">← Back to Ventura Claw</a></p>
+ <% } else { %>
+ <p class="kicker">Unsubscribe</p>
+ <h1 class="display-sm">We couldn't process that link.</h1>
+ <p class="lede" style="margin:0 auto"><%
+ if (reason === 'invalid') { %>The token is malformed or doesn't match a record we issued.<%
+ } else if (reason === 'no_token') { %>This link is missing its token. Use the unsubscribe link from a recent email.<%
+ } else { %>Something went wrong. Try the link from a recent email, or email <a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a>.<%
+ } %></p>
+ <p style="margin-top:24px"><a href="/" class="btn btn-ghost btn-sm">← Back to Ventura Claw</a></p>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
← 520e445 yolo tick 1: JSON-LD LocalBusiness + ItemList structured dat
·
back to Ventura Claw Leads
·
yolo tick 3: 46-test npm test suite covering auth, complianc e42956b →