[object Object]

← 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

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 →