Skip to content

feat(destinations): add Resend and SendGrid email destinations (JITSU-71)#1377

Merged
absorbb merged 2 commits into
newjitsufrom
vladimir/jitsu-71-resend
Jul 3, 2026
Merged

feat(destinations): add Resend and SendGrid email destinations (JITSU-71)#1377
absorbb merged 2 commits into
newjitsufrom
vladimir/jitsu-71-resend

Conversation

@vklimontovich

@vklimontovich vklimontovich commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What

Adds two cloud destinations that sync Jitsu users into email/marketing tools as contacts:

  • Resendlibs/destination-functions/src/functions/resend-destination.ts
  • SendGrid (Marketing Contacts API) — libs/destination-functions/src/functions/sendgrid-destination.ts

Part of JITSU-71. Postmark was dropped — it has no contacts/audiences API (transactional-only); see the Linear issue.

Behavior

  • identify upserts the contact (create or update, keyed by email), stores name + all other traits as
    properties/custom fields, and reconciles audience/list membership to the desired set.
  • track / page / screen update an existing contact's fields only (never create); driven by fields
    explicitly set on the event.
  • group folds the group id + traits into group_* properties.

Audiences / lists by name, with reconciliation

  • Configured as a comma-separated list of names (not IDs); names are resolved and auto-created.
  • Membership is reconciled: A,B then B,C removes the contact from A. To do this without a Jitsu-side
    cache, the connector records the set it manages on the contact itself (jitsu_managed_audiences /
    jitsu_managed_lists), so it only ever removes memberships it added — never ones added manually or by other tools.
  • Per-event override via a resendAudiences / sendgridLists trait.

Identity

  • Email is the sole identifier; Jitsu userId/anonymousId are stored as properties/custom fields.
  • Optional resolveEmailFromUserId flag caches userId → email so events carrying only a userId can match.

Code organization

  • Shared, API-agnostic helpers live in src/functions/lib/contacts.ts (trait parsing, email resolution,
    managed-set encode/decode, target-name resolution, error classification). Each connector keeps its own API
    I/O — the upsert models differ (Resend GET-then-POST/PATCH + segment endpoints; SendGrid async PUT +
    field-definition management + list DELETE), so a shared base class would leak those differences.
  • Test helpers are shared too: __tests__/lib/mem-store.ts (createMemoryStore) and
    __tests__/lib/email-contacts-test-data.ts (emailContactTestEvents).
  • The API base URL is overridable via a hidden apiBase credential on both (SendGrid still honors the
    Global/EU region when unset).

Testing

Both destinations were verified live against real accounts (create, update-no-duplicate, multi-audience
reconciliation with removal, custom properties, cache-resolved updates, track-never-creates). Env-gated vitest
tests included (TEST_RESEND_DESTINATION_CONFIG / TEST_SENDGRID_DESTINATION_CONFIG); they skip without config.
pnpm --filter @jitsu/destination-functions typecheck and console typecheck pass.

SendGrid-specific: contact import is asynchronous; a just-created custom field is deferred via RetryError until
it propagates, and email is kept as the sole identifier (avoids SendGrid's "provide all identifiers" rejection).
Docs PR in @jitsucom/websites (jitsucom/websites#40).

🤖 Generated with Claude Code

jitsu-code-review[bot]
jitsu-code-review Bot previously approved these changes Jul 1, 2026

@jitsu-code-review jitsu-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the two commits in this PR (HubSpot source registration + new Resend/SendGrid destinations). Overall implementation and wiring look solid; I left two inline comments for edge cases that look correctness-sensitive in production traffic.

Comment thread libs/destination-functions/src/functions/resend-destination.ts Outdated
Comment thread libs/destination-functions/src/functions/sendgrid-destination.ts
jitsu-code-review[bot]
jitsu-code-review Bot previously approved these changes Jul 1, 2026

@jitsu-code-review jitsu-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the new Resend/SendGrid destination implementations, shared contact helpers, and console schema wiring. I found a few correctness risks around list/audience reconciliation and empty-config behavior; details are in inline comments.

Comment thread libs/destination-functions/src/functions/lib/contacts.ts
Comment thread libs/destination-functions/src/functions/sendgrid-destination.ts Outdated
Comment thread libs/destination-functions/src/functions/resend-destination.ts Outdated

@jitsu-code-review jitsu-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the new Resend/SendGrid destination functions, shared contact helpers, and console wiring. I focused on contact upsert/reconciliation paths, error handling, and membership bookkeeping behavior. I found two additional correctness risks around silently skipping connector-managed bookkeeping fields after API errors; details are in inline comments.

Comment thread libs/destination-functions/src/functions/resend-destination.ts Outdated
Comment thread libs/destination-functions/src/functions/sendgrid-destination.ts
jitsu-code-review[bot]
jitsu-code-review Bot previously approved these changes Jul 1, 2026

@jitsu-code-review jitsu-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the new Resend/SendGrid destination implementations plus shared contacts helpers and console wiring. I found two correctness issues around membership reconciliation under retries (details inline).

Comment thread libs/destination-functions/src/functions/resend-destination.ts Outdated
Comment thread libs/destination-functions/src/functions/sendgrid-destination.ts
jitsu-code-review[bot]
jitsu-code-review Bot previously approved these changes Jul 1, 2026

@jitsu-code-review jitsu-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the new Resend/SendGrid destination implementations, shared contacts helpers, and the console/meta wiring for the new destination types. I focused on reconciliation flow and retry/error semantics. I left a few inline comments for correctness risks that can leave stale memberships or drop retryable failures.

Comment thread libs/destination-functions/src/functions/resend-destination.ts Outdated
Comment thread libs/destination-functions/src/functions/sendgrid-destination.ts
Comment thread libs/destination-functions/src/functions/sendgrid-destination.ts Outdated
…-71)

Sync users to Resend and SendGrid as contacts. identify() upserts the
contact and reconciles its audience/list membership; track/page/group
update contact fields only. Audiences and lists are configured by name
(comma-separated, auto-created), and membership is reconciled to the
desired set — audiences the connector added that are no longer listed are
removed. Contacts are keyed by email; userId and anonymousId are stored as
properties.

@jitsu-code-review jitsu-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the new Resend/SendGrid destination implementations, shared contact helpers, and console wiring across the PR range. I found one retry/correctness issue in the SendGrid custom-field fallback path (inline comment).

Comment thread libs/destination-functions/src/functions/sendgrid-destination.ts Outdated
jitsu-code-review[bot]
jitsu-code-review Bot previously approved these changes Jul 1, 2026

@jitsu-code-review jitsu-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the new Resend/SendGrid destination implementations, shared contacts helpers, destination registration, and console schema wiring. I found two actionable issues: one retry-classification gap in SendGrid field-definition fallback and one bookkeeping-correctness risk in Resend when internal managed-audience property definition fails with permanent 4xx.

Comment thread libs/destination-functions/src/functions/sendgrid-destination.ts Outdated
Comment thread libs/destination-functions/src/functions/resend-destination.ts Outdated
@vklimontovich vklimontovich added the deploy:console Auto-deploy console to beta when this PR merges (JITSU-68) label Jul 1, 2026
@absorbb

absorbb commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

few autoreview comments left.

looks a bit complicated but at the same time quite flexible from the beginning
one question:
handling of group events looks different, but that is just how code organized. after a closer look it is the same - right?
It is better to organize code equally to avoid confusion in future

Address remaining auto-review findings and align the two connectors:

- SendGrid ensureCustomField: the fallback field_definitions GET (run when
  the POST fails with a permanent 4xx) was outside classifyError, so a
  transient 429/5xx there escaped as a raw JsonFetchError and Rotor would
  drop the event. Wrap it so transient failures become RetryError.

- Both connectors: a permanent 4xx while defining the *internal*
  jitsu_managed_audiences / jitsu_managed_lists marker was silently skipped,
  letting membership mutate while reconciliation bookkeeping was lost. The
  skip now applies to user-supplied keys only; failing to define the internal
  marker fails/surfaces the event.

- SendGrid: extract a dedicated updateContactFromGroup() (mirroring Resend)
  and a shared applyContactUpdate() helper, so group handling is organized
  identically across both connectors. Group events now also skip when
  groupId is missing, matching Resend. No behavior change for track/page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@absorbb

absorbb commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

On the group handling question: confirmed — the two connectors were functionally the same, just organized differently (Resend had a dedicated updateContactFromGroup; SendGrid folded group into updateContactFromEvent with inline type === "group" branches). Unified in 7b015ee: SendGrid now has its own updateContactFromGroup mirroring Resend, with a shared applyContactUpdate helper for the search→build→PUT sequence. Both connectors now resolve the contact from the user's email (never the group's company traits), fold group_id + group_* fields, update-only/never-create, and skip when groupId is missing.

Also pushed fixes for the two remaining auto-review threads (SendGrid refetch retry gap; internal managed-marker never skipped on permanent 4xx — both connectors). Typecheck + @jitsu/destination-functions tests pass.

@jitsu-code-review jitsu-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed range 185aa52..7b015ee with focus on correctness/retry behavior in the new Resend/SendGrid destinations, shared contacts helpers, and console wiring.

I did not find additional actionable bugs or security regressions in the current head state. The latest changes address the earlier retry/reconciliation hazards discussed in existing threads (including deferred managed-marker writes and transient-error classification).

Note: local test execution was not possible in this environment because is unavailable.

@jitsu-code-review jitsu-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Follow-up note to correct the prior environment statement: local test execution was not possible because the pnpm CLI is unavailable in this runtime.

@absorbb

absorbb commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@vklimontovich
I think we need to change the logic of syncing audiences before shipping this.
Clients may fill their audiences from various sources, including manually managed lists.
Currently, this PR suggest that Jitsu will remove users from all unknown audiences - that can cause damage beyond what jitsu manages.

I suggest that by default Jitsu must only add audiences and
support a trait that will explicitly tell that user must be deleted from an audience, or add special syntax for existing traits like:
resendAudiences=jitsuUsers,-subcribedUsers

@vklimontovich

Copy link
Copy Markdown
Contributor Author

@absorbb good concern, but this specific damage can't happen — Jitsu already tracks what it manages and never touches anything else. Here's how it works:

On every identify(), Jitsu maintains a contact property jitsu_managed_audiences holding exactly the audience IDs that Jitsu itself has added for that contact. Reconciliation is scoped to that set:

desired   = config audiences (+ per-event resendAudiences override)
managed   = jitsu_managed_audiences on the contact   // only what Jitsu added
toRemove  = managed − desired                        // never includes anything Jitsu didn't add

So an audience that was not added by Jitsu — a manually managed list, another tool, another Jitsu connection — is never in jitsu_managed_audiences, and therefore is never a removal candidate.

Your example: user A is in aud1 (added elsewhere), Jitsu is configured with aud2, aud3. On identify, jitsu_managed_audiences does not contain aud1toRemove excludes aud1A stays in aud1. Jitsu only adds aud2, aud3.

The only case Jitsu removes someone from an audience is when Jitsu itself previously added them and that audience later drops out of the desired set — e.g. config was aud1, aud2, then changed to aud2: the user is removed from aud1 because Jitsu had added it. External memberships are always safe.

Net: 'remove from all unknown audiences' doesn't happen — unknown (non-Jitsu-managed) audiences are exactly the ones we never remove.

If on top of that you'd also like to drop the reconcile-on-omission behavior and make it purely additive (removal only via explicit resendAudiences=jitsuUsers,-subscribedUsers syntax), that's easy to add — but it's not required to prevent the damage you described. Happy to go that route if you prefer it as the default.

@absorbb

absorbb commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@vklimontovich
I see.
It is safe. But it is quite complicated design.
resendAudiences=jitsuUsers,-subscribedUsers approach is:

  • more simple
  • doesn't require a special contact property
  • doesn't require fetching an existing contact (at least for dsts which support upserts)
  • actually allows removing contacts from audiences not managed by Jitsu if client explicitly wants to

I would prefer this approach

@absorbb absorbb merged commit 5777f52 into newjitsu Jul 3, 2026
5 checks passed
@absorbb absorbb deleted the vladimir/jitsu-71-resend branch July 3, 2026 08:34
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

🚀 deploy:console label detected — triggered a beta console deployment to jitsu-cloud-infra. Track it in the deploy runs (newest at top): https://github.com/jitsucom/jitsu-cloud-infra/actions/workflows/deploy.yaml?query=event%3Aworkflow_dispatch

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

Labels

deploy:console Auto-deploy console to beta when this PR merges (JITSU-68)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants