Skip to content

Instantly share code, notes, and snippets.

@jghankins
Last active April 13, 2026 17:57
Show Gist options
  • Select an option

  • Save jghankins/123e424cff1773015454a94057fae47a to your computer and use it in GitHub Desktop.

Select an option

Save jghankins/123e424cff1773015454a94057fae47a to your computer and use it in GitHub Desktop.
Pewpros Unsubscribe Flow - How It Works

Pewpros Unsubscribe Flow

Overview

When a contact is unsubscribed, the system now enforces it across the entire platform:

  1. Community removal - user is removed from the community
  2. Post hiding - all their community posts are bulk-hidden
  3. Email suppression - marketing emails are silently suppressed at the Mailer level
  4. Transactional emails still deliver - magic links, login emails, confirmations bypass suppression

Entry Points (How Unsubscribes Get Triggered)

1. Admin UI - Changing Contact Status

When an admin edits a contact in /admin/contacts/:id/edit and changes the status dropdown to "Unsubscribed", CRM.update_contact/2 detects the status change and enqueues an UnsubscribeWorker via Oban.

No additional admin action is needed. The side effects happen automatically.

2. Authenticated Webhook - POST /webhooks/unsubscribe

For GHL automations or external systems to trigger unsubscribes programmatically.

Setup:

  1. Add unsubscribe_webhook_secret to the tenant's settings JSON in admin Site Settings (or via DB)
  2. Configure the external system to POST to the endpoint with the secret

Request:

POST /webhooks/unsubscribe
Content-Type: application/json
X-Webhook-Secret: <your-secret-from-tenant-settings>

# By email:
{"email": "user@example.com"}

# Or by GHL contact ID:
{"ghl_contact_id": "abc123"}

Responses:

  • 200 {"status": "ok"} - contact unsubscribed successfully
  • 401 {"error": "unauthorized"} - bad or missing secret
  • 404 {"error": "contact_not_found"} - no matching contact
  • 400 {"error": "email or ghl_contact_id required"} - missing identifier

Example curl:

curl -X POST https://yourdomain.com/webhooks/unsubscribe \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Secret: your-secret-here" \
  -d '{"email": "user@example.com"}'

3. RFC 8058 One-Click Unsubscribe (Email Clients)

When a user clicks "Unsubscribe" in Gmail or Apple Mail, the mail client POSTs to POST /unsubscribe?email=user@example.com. This endpoint:

  • Requires no CSRF token (RFC 8058 compliance)
  • Calls CRM.unsubscribe_contact_by_email/1
  • Always returns 200 (RFC 8058 requires this)

The List-Unsubscribe header in outgoing emails now includes the recipient's email in the URL:

List-Unsubscribe: <mailto:unsubscribe@domain>, <https://domain/unsubscribe?email=user%40example.com>

4. GHL Contact Sync (DND Detection)

When a GHL contact has dnd: true or dndSettings.Email.status: "active", the sync maps this to status: "unsubscribed". After the contact is upserted and the user account is linked, CRM.unsubscribe_contact/1 is called to trigger community removal and post hiding.

This works for both:

  • Batch sync via GHLContactSyncWorker
  • Real-time sync via POST /webhooks/ghl (contact.create / contact.update events)

What Happens When Someone Is Unsubscribed

Side Effects (via UnsubscribeWorker)

When a contact with a linked user account is unsubscribed, an Oban job runs asynchronously:

  1. Community removal - joined_community_at is set to nil on their CommunityProfile, effectively removing them from the community
  2. Post hiding - all their community posts are bulk-updated to hidden: true (not deleted, can be unhidden by admin)

If the contact has no linked user_id, no side effects are triggered (there's no community profile to remove).

Email Suppression (via Mailer.deliver/2)

Every call to Mailer.deliver/1 or Mailer.deliver/2 now checks:

  1. Is this email marked as transactional? (has x-pewpros-transactional: true header)
    • Yes -> deliver normally (magic links, login emails, confirmations)
    • No -> check if the recipient is an unsubscribed contact
  2. Look up the recipient email in the contacts table
  3. If contact.status == "unsubscribed" -> return {:ok, :suppressed} instead of delivering
  4. Otherwise -> strip the internal header and deliver normally

What gets suppressed:

  • Community post notification emails
  • Event reminder emails
  • Bulk marketing emails
  • DM digest emails
  • Any email sent through Mailer.deliver/1 without the transactional header

What still delivers:

  • Magic link / login emails (UserNotifier)
  • Account confirmation emails (UserNotifier)
  • Email change confirmation emails (UserNotifier)

All UserNotifier emails include the x-pewpros-transactional: true header automatically.


CRM Context API

CRM.unsubscribe_contact(contact)

The core function. Sets status to "unsubscribed", logs an "unsubscribed" activity, and enqueues the UnsubscribeWorker if the contact has a linked user.

CRM.unsubscribe_contact_by_email(email)

Convenience wrapper. Looks up the contact by email (case-insensitive), then calls unsubscribe_contact/1. Returns {:error, :not_found} if no contact exists.

CRM.unsubscribe_contact_by_ghl_id(ghl_contact_id)

Convenience wrapper. Looks up the contact by metadata->>'ghl_contact_id', then calls unsubscribe_contact/1. Returns {:error, :not_found} if no contact exists.

CRM.update_contact(contact, attrs)

Now automatically detects when status changes to "unsubscribed" and enqueues the UnsubscribeWorker. No caller changes needed - all existing update paths (admin UI, API, sync) get this behavior for free.


Community Context API

Community.hide_posts_by_user(user_id)

Bulk-updates all visible posts (hidden == false) by the given user to hidden: true. Returns {count, nil} tuple from Repo.update_all. Posts are not deleted and can be unhidden by an admin.


Configuration

Webhook Secret

The unsubscribe webhook authenticates via X-Webhook-Secret header. The expected value is stored in the tenant's settings map:

tenant.settings["unsubscribe_webhook_secret"] = "your-secret-here"

Set this in the admin Site Settings page or directly in the database. The webhook will return 401 if this setting is not configured.

Marking Custom Emails as Transactional

If you build a new email sender that should bypass unsubscribe suppression, add the transactional header to the Swoosh email struct before calling Mailer.deliver/1:

email =
  Swoosh.Email.new()
  |> Swoosh.Email.to(recipient)
  |> Swoosh.Email.from(from)
  |> Swoosh.Email.subject("Important account notification")
  |> Swoosh.Email.text_body("...")
  |> Swoosh.Email.header("x-pewpros-transactional", "true")

Pewpros.Mailer.deliver(email)

The x-pewpros-transactional header is stripped before the email is passed to Swoosh/SES - it never reaches the recipient.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment