← back to NationalPaperHangers

routes/unsubscribe.js

71 lines

// Public unsubscribe endpoint. CAN-SPAM requires that opt-out be functional
// without requiring login or a per-message reply.
//
// GET  /unsubscribe?token=…   → render confirmation page (and pre-process the opt-out
//                                so the user sees the result on first paint).
// POST /unsubscribe?token=…   → idempotent — adds the recipient to comms_suppression.
//                                Also handles RFC 8058 "List-Unsubscribe-Post" 1-click flow.
//
// Skips CSRF since the One-Click flow can't include a CSRF token (it's a
// mailclient-initiated POST). The HMAC token IS the auth.

const express = require('express');
const compliance = require('../lib/compliance');

const router = express.Router();

router.use((req, res, next) => { req.skipCsrf = true; next(); });

router.get('/', async (req, res, next) => {
  try {
    const token = (req.query.token || '').trim();
    if (!token) {
      return res.status(400).render('public/error', {
        title: 'Invalid unsubscribe link',
        message: 'Missing token. If you got an unwanted email from us, reply to it and we will remove you manually.'
      });
    }
    const row = await compliance.consumeUnsubscribeToken(token);
    if (!row) {
      return res.status(400).render('public/error', {
        title: 'Unsubscribe link invalid',
        message: 'This unsubscribe link is unrecognized. Email info@nationalpaperhangers.com and we will remove you within 24 hours.'
      });
    }
    await compliance.addSuppression({
      channel: row.channel,
      identifier: row.identifier,
      reason: 'unsubscribe',
      source: 'unsub_link',
      installerId: row.installer_id,
      notes: row.campaign ? `campaign=${row.campaign}` : null
    });
    res.render('public/unsubscribed', {
      title: 'Unsubscribed · National Paper Hangers',
      identifier: row.identifier,
      campaign: row.campaign
    });
  } catch (err) { next(err); }
});

// RFC 8058 1-click: mail client POSTs with body `List-Unsubscribe=One-Click`.
router.post('/', async (req, res, next) => {
  try {
    const token = (req.query.token || req.body.token || '').trim();
    if (!token) return res.status(400).json({ error: 'missing_token' });
    const row = await compliance.consumeUnsubscribeToken(token);
    if (!row) return res.status(400).json({ error: 'invalid_token' });
    await compliance.addSuppression({
      channel: row.channel,
      identifier: row.identifier,
      reason: 'unsubscribe',
      source: 'list_unsubscribe_post',
      installerId: row.installer_id,
      notes: row.campaign ? `campaign=${row.campaign}` : null
    });
    return res.status(200).json({ ok: true });
  } catch (err) { next(err); }
});

module.exports = router;