When a contact is unsubscribed, the system now enforces it across the entire platform:
- Community removal - user is removed from the community
- Post hiding - all their community posts are bulk-hidden
- Email suppression - marketing emails are silently suppressed at the Mailer level
- Transactional emails still deliver - magic links, login emails, confirmations bypass suppression
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.
For GHL automations or external systems to trigger unsubscribes programmatically.
Setup:
- Add
unsubscribe_webhook_secretto the tenant'ssettingsJSON in admin Site Settings (or via DB) - 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 successfully401 {"error": "unauthorized"}- bad or missing secret404 {"error": "contact_not_found"}- no matching contact400 {"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"}'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>
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)
When a contact with a linked user account is unsubscribed, an Oban job runs asynchronously:
- Community removal -
joined_community_atis set tonilon theirCommunityProfile, effectively removing them from the community - 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).
Every call to Mailer.deliver/1 or Mailer.deliver/2 now checks:
- Is this email marked as transactional? (has
x-pewpros-transactional: trueheader)- Yes -> deliver normally (magic links, login emails, confirmations)
- No -> check if the recipient is an unsubscribed contact
- Look up the recipient email in the
contactstable - If
contact.status == "unsubscribed"-> return{:ok, :suppressed}instead of delivering - 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/1without 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.
The core function. Sets status to "unsubscribed", logs an "unsubscribed" activity, and enqueues the UnsubscribeWorker if the contact has a linked user.
Convenience wrapper. Looks up the contact by email (case-insensitive), then calls unsubscribe_contact/1. Returns {:error, :not_found} if no contact exists.
Convenience wrapper. Looks up the contact by metadata->>'ghl_contact_id', then calls unsubscribe_contact/1. Returns {:error, :not_found} if no contact exists.
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.
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.
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.
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.