Skip to content

fix: emit webhooks for campaign subscription changes - #417

Merged
KMKoushik merged 2 commits into
mainfrom
codex/fix-campaign-contact-webhooks
Jul 10, 2026
Merged

fix: emit webhooks for campaign subscription changes#417
KMKoushik merged 2 commits into
mainfrom
codex/fix-campaign-contact-webhooks

Conversation

@KMKoushik

@KMKoushik KMKoushik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • emit contact.updated after campaign unsubscribe and re-subscribe state changes
  • preserve bounce/complaint unsubscribe reasons and clear the reason on re-subscribe
  • return the updated contact after unsubscribe
  • cover subscribed and unsubscribed webhook payloads with unit tests

Fixes #413.

Impact

Webhook consumers can now synchronize per-contact suppression state when subscription status changes through campaign links, RFC 8058 one-click unsubscribe, or campaign delivery events. Existing campaign unsubscribe counters remain unchanged.

Verification

  • pnpm --filter=web exec vitest run -c vitest.unit.config.ts src/server/service/contact-service.unit.test.ts (16 passed)
  • pnpm --filter=web exec eslint src/server/service/contact-service.ts src/server/service/campaign-service.ts src/server/service/contact-service.unit.test.ts --max-warnings 0
  • git diff --check

Migration notes

No database migrations or configuration changes.

Screenshots

Not applicable; server-side behavior only.


Summary by cubic

Emit contact.updated webhooks when campaign actions change a contact’s subscription so downstream systems can sync suppression state in real time. Also standardizes unsubscribe reason handling and returns the updated contact on unsubscribe.

  • Bug Fixes

    • Emit contact.updated after unsubscribe and re-subscribe via campaign flows (including RFC 8058 one-click).
    • Preserve bounce/complaint reasons on unsubscribe and clear on re-subscribe.
    • unsubscribeContact now returns the updated contact.
  • Refactors

    • Added updateContactSubscription to centralize subscription updates and webhook emission; used by campaign unsubscribeContact and subscribeContact.

Written for commit 3d78b2a. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved contact subscription handling for both subscribing and unsubscribing, including consistent recording of unsubscribe reasons.
    • Ensure resubscribing correctly clears prior unsubscribe reasons and updates subscription status reliably.
    • Contact subscription changes now trigger consistent update notifications, keeping integrations synchronized, while campaign subscription counters are adjusted correctly.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Campaign unsubscribe and subscribe flows now use a shared contact subscription helper. The helper updates subscription fields, emits a contact.updated webhook, and returns the updated contact. Tests cover both subscribing and unsubscribing, database update arguments, webhook payloads, and returned subscription state.

Possibly related PRs

  • usesend/useSend#350: Also changes subscription state handling around subscribing, unsubscribing, and double opt-in behavior.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR emits contact.updated during campaign unsubscribe and resubscribe flows while preserving campaign counters.
Out of Scope Changes check ✅ Passed The added helper and tests directly support the webhook-emission change and don't introduce unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: emitting webhooks for campaign subscription changes.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
unsend-marketing Ready Ready Preview, Comment Jul 10, 2026 8:49pm

@KMKoushik
KMKoushik marked this pull request as ready for review July 10, 2026 20:42
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 10, 2026

Copy link
Copy Markdown

Deploying usesend with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3d78b2a
Status: ✅  Deploy successful!
Preview URL: https://74c1f7cc.usesend.pages.dev
Branch Preview URL: https://codex-fix-campaign-contact-w.usesend.pages.dev

View logs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/server/service/contact-service.ts (1)

345-367: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

SES hook updates still bypass contact.updated

apps/web/src/server/service/ses-hook-parser.ts still calls these helpers for bounce/complaint handling, so those subscription changes won’t emit contact.updated. If downstream consumers need that event, route this path through updateContactSubscription.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/server/service/contact-service.ts` around lines 345 - 367,
Update unsubscribeContact and subscribeContact to route subscription changes
through updateContactSubscription instead of directly calling db.contact.update,
ensuring SES bounce and complaint handling emits the contact.updated event while
preserving the existing subscribed and unsubscribeReason values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/web/src/server/service/contact-service.ts`:
- Around line 345-367: Update unsubscribeContact and subscribeContact to route
subscription changes through updateContactSubscription instead of directly
calling db.contact.update, ensuring SES bounce and complaint handling emits the
contact.updated event while preserving the existing subscribed and
unsubscribeReason values.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7375a6d7-8ec1-420a-8695-c8af66359259

📥 Commits

Reviewing files that changed from the base of the PR and between 5259c7b and e65e0a0.

📒 Files selected for processing (3)
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/service/contact-service.ts
  • apps/web/src/server/service/contact-service.unit.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/web/src/server/service/campaign-service.unit.test.ts (2)

24-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix the $transaction mock callback type annotation.

ReturnType<typeof vi.fn> resolves to a Mock instance type, not a function signature. The annotation is semantically incorrect even though it works at runtime. Use a proper function type instead.

♻️ Proposed fix
-      $transaction: vi.fn(async (callback: ReturnType<typeof vi.fn>) =>
+      $transaction: vi.fn(async (callback: (tx: typeof mockTx) => Promise<unknown>) =>
         callback(mockTx),
       ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/server/service/campaign-service.unit.test.ts` around lines 24 -
26, Update the $transaction mock in the campaign service unit test to annotate
its callback parameter with a proper callable function type, such as a function
accepting the transaction mock and returning the expected result, instead of
ReturnType<typeof vi.fn>. Preserve the existing callback(mockTx) behavior.

233-254: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the return value and mocking updateContactSubscription return for the subscribe test.

The unsubscribeContact test (lines 204-231) thoroughly asserts the return value and mocks mockUpdateContactSubscription with a resolved value. The subscribeContact test does neither — mockUpdateContactSubscription returns undefined by default and no return value is asserted. If subscribeContact returns or uses the updated contact, this gap could mask regressions.

♻️ Suggested additions
     mockDb.contact.findUnique.mockResolvedValue({
       id: "contact_1",
       contactBookId: "book_1",
       email: "alice@example.com",
       subscribed: false,
     });
+    const updatedContact = { ...mockDb.contact.findUnique.mock.results[0].value, subscribed: true };
+    mockUpdateContactSubscription.mockResolvedValue(updatedContact);
     const id = "contact_1-campaign_1";
     const hash = createHash("sha256").update(`${id}-test-secret`).digest("hex");
 
-    await subscribeContact(id, hash);
+    const result = await subscribeContact(id, hash);
 
     expect(mockUpdateContactSubscription).toHaveBeenCalledWith({
       contactId: "contact_1",
       subscribed: true,
       unsubscribeReason: null,
     });
     expect(mockDb.campaign.update).toHaveBeenCalledWith({
       where: { id: "campaign_1" },
       data: { unsubscribed: { decrement: 1 } },
     });
+    // If subscribeContact returns the updated contact:
+    // expect(result).toBe(updatedContact);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/server/service/campaign-service.unit.test.ts` around lines 233 -
254, Update the “updates the contact through the webhook-emitting service on
re-subscribe” test to mock mockUpdateContactSubscription with the expected
resolved updated-contact value, then capture the result of subscribeContact and
assert it matches that value, mirroring the unsubscribeContact test’s
return-value coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/web/src/server/service/campaign-service.unit.test.ts`:
- Around line 24-26: Update the $transaction mock in the campaign service unit
test to annotate its callback parameter with a proper callable function type,
such as a function accepting the transaction mock and returning the expected
result, instead of ReturnType<typeof vi.fn>. Preserve the existing
callback(mockTx) behavior.
- Around line 233-254: Update the “updates the contact through the
webhook-emitting service on re-subscribe” test to mock
mockUpdateContactSubscription with the expected resolved updated-contact value,
then capture the result of subscribeContact and assert it matches that value,
mirroring the unsubscribeContact test’s return-value coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 062484d6-d49b-4f63-b2ee-0560c9fe1ce3

📥 Commits

Reviewing files that changed from the base of the PR and between e65e0a0 and 3d78b2a.

📒 Files selected for processing (4)
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/service/campaign-service.unit.test.ts
  • apps/web/src/server/service/contact-service.ts
  • apps/web/src/server/service/contact-service.unit.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/web/src/server/service/contact-service.unit.test.ts
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/service/contact-service.ts

@KMKoushik
KMKoushik merged commit 455e6c1 into main Jul 10, 2026
4 checks passed
@KMKoushik
KMKoushik deleted the codex/fix-campaign-contact-webhooks branch July 10, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐞 - Campaign unsubscribe does not emit a contact.updated webhook

1 participant