What happened?
Task description written with the help of Claude Code :)
When a contact unsubscribes through a campaign (body link or RFC 8058 one-click), no webhook is emitted. Webhook consumers cannot detect campaign unsubscribes and have no per-contact signal to sync suppression state — only the aggregate campaign.unsubscribed counter changes.
Where
contact.updated is emitted only from contact-service.ts:
addOrUpdateContact → apps/web/src/server/service/contact-service.ts:159
updateContactInContactBook → :225
deleteContactInContactBook (contact.deleted) → :250
- The campaign unsubscribe path bypasses these —
unsubscribeContact() does a raw db.contact.update({ subscribed: false }) with no emitContactEvent:
apps/web/src/server/service/campaign-service.ts:580-622 (update at :600).
- Both the body GET page and the one-click POST route funnel into
unsubscribeContact, so neither emits a webhook.
Suggested fix
Emit contact.updated from unsubscribeContact (and the re-subscribe path) — e.g. callemitContactEvent(contact, "contact.updated", teamId)after the update, or route the change through the existing updateContactInContactBook so the event fires in one place.
Replication Steps
Steps to reproduce
- Register a webhook subscribed to
contact.updated.
- Unsubscribe a contact via the campaign email link (or the one-click header).
- No webhook is delivered, although
contact.subscribed changed to false.
Expected
Unsubscribing (and re-subscribing) a contact should emit contact.updated, consistent with updateContactInContactBook.
Actual
No event emitted; the subscription change is invisible to webhook consumers.
Self hosted or Cloud?
Self hosted
What browsers are you seeing the problem on?
No response
What happened?
Task description written with the help of Claude Code :)
When a contact unsubscribes through a campaign (body link or RFC 8058 one-click), no webhook is emitted. Webhook consumers cannot detect campaign unsubscribes and have no per-contact signal to sync suppression state — only the aggregate campaign.unsubscribed counter changes.
Where
contact.updatedis emitted only fromcontact-service.ts:addOrUpdateContact→apps/web/src/server/service/contact-service.ts:159updateContactInContactBook→:225deleteContactInContactBook(contact.deleted) →:250unsubscribeContact()does a rawdb.contact.update({ subscribed: false })with noemitContactEvent:apps/web/src/server/service/campaign-service.ts:580-622(update at:600).unsubscribeContact, so neither emits a webhook.Suggested fix
Emit
contact.updatedfromunsubscribeContact(and the re-subscribe path) — e.g. callemitContactEvent(contact, "contact.updated", teamId)after the update, or route the change through the existingupdateContactInContactBookso the event fires in one place.Replication Steps
Steps to reproduce
contact.updated.contact.subscribedchanged tofalse.Expected
Unsubscribing (and re-subscribing) a contact should emit
contact.updated, consistent withupdateContactInContactBook.Actual
No event emitted; the subscription change is invisible to webhook consumers.
Self hosted or Cloud?
Self hosted
What browsers are you seeing the problem on?
No response