← back to NationalPaperHangers

lib/compliance.js

177 lines

// Communications-compliance gate. Fail-closed pre-flight + per-recipient
// scrub utilities. Every outbound send script MUST call assertSendCompliance()
// before its loop and isSuppressed()/recordAudit() per recipient.
//
// Standing rules enforced (from Steve's comms-compliance agent + MEMORY.md):
//   - CAN-SPAM §7(a)(5): physical postal address must appear in every commercial email body.
//   - CAN-SPAM functional opt-out: unsubscribe link must be present + the suppression scrub must run.
//   - CA §17529.5: subject + From accurate, no deceptive headers.
//   - CCPA: opt-outs and deletes must be honored (suppression list).
//   - Steve's rule: per-message audit to PG (`comms_send_audit`); fail-closed if list missing or stale.

const crypto = require('crypto');
const db = require('./db');

const IS_PROD = process.env.NODE_ENV === 'production';

class ComplianceError extends Error {
  constructor(code, msg) { super(msg); this.code = code; }
}

function hasMailingAddress() {
  const a = (process.env.MAILING_ADDRESS || '').trim();
  if (!a) return false;
  if (/TBD|placeholder|TODO|FIXME|localhost/i.test(a)) return false;
  // Crude shape check: at least 3 comma-separated parts (street, city, state-zip).
  return a.split(',').filter(Boolean).length >= 3;
}

function publicUrlOk() {
  const u = (process.env.PUBLIC_URL || '').trim();
  if (!u) return false;
  if (u.includes('localhost') || u.includes('127.0.0.1')) return false;
  if (IS_PROD && !u.startsWith('https://')) return false;
  return true;
}

async function tablesExist() {
  const r = await db.query(
    `SELECT
       to_regclass('public.comms_suppression')   IS NOT NULL AS sup,
       to_regclass('public.comms_send_audit')    IS NOT NULL AS aud,
       to_regclass('public.unsubscribe_tokens')  IS NOT NULL AS tok`
  );
  const row = r.rows[0] || {};
  return !!(row.sup && row.aud && row.tok);
}

// Pre-flight gate. Throws ComplianceError if any prerequisite is missing.
// Call BEFORE entering any send loop. Per-recipient scrub still required after.
async function assertSendCompliance({ campaign }) {
  if (!campaign) throw new ComplianceError('no_campaign', 'campaign label is required');

  if (!hasMailingAddress()) {
    throw new ComplianceError(
      'no_mailing_address',
      'MAILING_ADDRESS env var is missing or placeholder. CAN-SPAM §7(a)(5) requires a real, deliverable street address in every outbound footer. Set it in .env (format: "Studio Name, 123 Real Street, City, ST 12345"), then retry.'
    );
  }
  if (!publicUrlOk()) {
    throw new ComplianceError(
      'public_url_invalid',
      'PUBLIC_URL must be a real https URL in production (or at minimum non-localhost in dev) so unsubscribe + claim links resolve. Currently: ' + (process.env.PUBLIC_URL || '<unset>')
    );
  }
  if (IS_PROD && (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === 'dev-secret-change-me')) {
    throw new ComplianceError('no_session_secret', 'SESSION_SECRET must be set in production');
  }
  if (!process.env.UNSUBSCRIBE_SIGNING_SECRET && !process.env.SESSION_SECRET) {
    throw new ComplianceError('no_unsub_key', 'UNSUBSCRIBE_SIGNING_SECRET (or SESSION_SECRET fallback) must be set');
  }
  const ok = await tablesExist();
  if (!ok) {
    throw new ComplianceError(
      'no_compliance_tables',
      'comms_suppression / comms_send_audit / unsubscribe_tokens tables are missing in `national_paper_hangers`. Apply db/migrations/004_comms_suppression.sql before sending.'
    );
  }
}

// Per-recipient scrub. Returns true if the address is suppressed.
async function isSuppressed({ channel, identifier }) {
  if (!channel || !identifier) return true; // fail-closed on bad input
  const id = String(identifier).trim().toLowerCase();
  const r = await db.query(
    `SELECT 1 FROM comms_suppression WHERE channel=$1 AND identifier=$2 LIMIT 1`,
    [channel, id]
  );
  return r.rowCount > 0;
}

async function addSuppression({ channel, identifier, reason, source, installerId, notes }) {
  await db.query(
    `INSERT INTO comms_suppression (channel, identifier, reason, source, installer_id, notes)
     VALUES ($1, lower($2), $3, $4, $5, $6)
     ON CONFLICT (channel, identifier) DO NOTHING`,
    [channel, identifier, reason, source || null, installerId || null, notes || null]
  );
}

async function recordAudit({ channel, campaign, recipient, installerId, decision, reason, subject, messageId, payload }) {
  await db.query(
    `INSERT INTO comms_send_audit
       (channel, campaign, recipient, installer_id, decision, reason, subject, message_id, payload)
     VALUES ($1,$2,lower($3),$4,$5,$6,$7,$8,$9)`,
    [channel, campaign, recipient, installerId || null, decision, reason || null, subject || null, messageId || null, payload ? JSON.stringify(payload) : null]
  );
}

// Unsubscribe-token helpers. The token is opaque; the row carries the channel,
// identifier, and campaign so the /unsubscribe handler can act without trust
// in URL params.
function unsubKey() {
  return process.env.UNSUBSCRIBE_SIGNING_SECRET || process.env.SESSION_SECRET || 'dev-only-not-for-prod';
}

async function mintUnsubscribeToken({ channel, identifier, campaign, installerId }) {
  const raw = `${channel}:${String(identifier).toLowerCase()}:${campaign}:${Date.now()}:${crypto.randomBytes(8).toString('hex')}`;
  const token = crypto.createHmac('sha256', unsubKey()).update(raw).digest('base64url').slice(0, 32);
  await db.query(
    `INSERT INTO unsubscribe_tokens (token, channel, identifier, campaign, installer_id)
     VALUES ($1,$2,lower($3),$4,$5) ON CONFLICT (token) DO NOTHING`,
    [token, channel, identifier, campaign || null, installerId || null]
  );
  return token;
}

async function consumeUnsubscribeToken(token) {
  if (!token || typeof token !== 'string' || token.length < 16) return null;
  const r = await db.query(
    `UPDATE unsubscribe_tokens
        SET used_at = COALESCE(used_at, now())
      WHERE token = $1
      RETURNING channel, identifier, campaign, installer_id, used_at`,
    [token]
  );
  return r.rows[0] || null;
}

function complianceFooter({ campaign, unsubscribeUrl }) {
  // Plain HTML footer to be appended to every commercial email body.
  // Address comes from MAILING_ADDRESS at runtime — assertSendCompliance() has
  // already verified it's set when this runs.
  const addr = (process.env.MAILING_ADDRESS || '').trim();
  return `<hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
<p style="font-size:11px;color:#888;line-height:1.5">
  This email was sent by National Paper Hangers, ${escapeHtml(addr)}.
  ${unsubscribeUrl ? `<br><a href="${unsubscribeUrl}" style="color:#888">Unsubscribe</a> from ${escapeHtml(campaign || 'these emails')}.` : ''}
</p>`;
}

function listUnsubscribeHeader(unsubscribeUrl) {
  // RFC 2369 + RFC 8058 headers — Gmail / Outlook surface a 1-click button when present.
  if (!unsubscribeUrl) return null;
  return {
    'List-Unsubscribe': `<${unsubscribeUrl}>`,
    'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
  };
}

function escapeHtml(s) {
  return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

module.exports = {
  ComplianceError,
  assertSendCompliance,
  isSuppressed,
  addSuppression,
  recordAudit,
  mintUnsubscribeToken,
  consumeUnsubscribeToken,
  complianceFooter,
  listUnsubscribeHeader,
  hasMailingAddress,
  publicUrlOk
};