Skip to content

feat(dashboard,api-service,shared): gate agent email integration behind Team+ tier fixes NV-7403#10837

Merged
ChmaraX merged 2 commits intonextfrom
nv-7403-gate-agent-email-integration-behind-team-business-tier
Apr 23, 2026
Merged

feat(dashboard,api-service,shared): gate agent email integration behind Team+ tier fixes NV-7403#10837
ChmaraX merged 2 commits intonextfrom
nv-7403-gate-agent-email-integration-behind-team-business-tier

Conversation

@ChmaraX
Copy link
Copy Markdown
Contributor

@ChmaraX ChmaraX commented Apr 23, 2026

Why

Agent email integration depends on domain management (inbound routing, DNS verification, custom domains) which is already locked to the Business (Team+) tier. Allowing FREE/PRO orgs to connect agent email creates a broken experience — the underlying infrastructure simply isn't available to them.

What changed

packages/shared

  • Added AGENT_EMAIL_INTEGRATION to FeatureNameEnum with tier values: false for FREE/PRO, true for BUSINESS/ENTERPRISE/UNLIMITED
  • Added AGENT_EMAIL_INTEGRATION to ProductFeatureKeyEnum and wired it into productFeatureEnabledForServiceLevel
  • Added requiresBusinessTier?: boolean flag to the ConversationalProvider type; set it on the NovuAgent (Novu Email) entry

apps/api

  • add-agent-integration.usecase.ts — Injected CommunityOrganizationRepository; added enforceEmailTier() that checks apiServiceLevel and throws HTTP 402 when AGENT_EMAIL_INTEGRATION is not enabled for the org's tier. Called before any NovuAgent email setup.
  • agents.controller.ts — Added @ProductFeature(ProductFeatureKeyEnum.AGENT_EMAIL_INTEGRATION) to the POST /:identifier/test-email endpoint so the ProductFeatureInterceptor returns 402 for FREE/PRO orgs.

apps/dashboard

  • hooks/use-is-agent-email-available.ts — New hook using useFetchSubscription + getFeatureForTierAsBoolean
  • provider-dropdown.tsx — Locked Novu Email row for lower tiers: disabled styling (opacity-60), hover tooltip with "Team feature" badge + explanation + "Upgrade plan →" link (navigates to billing or self-hosted upgrade URL). Click on the row does nothing; the CTA is inside the tooltip.

Architecture

FREE / PRO org                     BUSINESS+ org
──────────────────────────────     ──────────────────────
Provider dropdown                  Provider dropdown
  └─ Novu Email [disabled]           └─ Novu Email [+ add]
       └─ hover → tooltip                └─ click → links & sets up
            ├─ "Team feature"
            └─ "Upgrade plan →"
                 └─ /settings/billing

POST /agents/:id/integrations       POST /agents/:id/integrations
  └─ NovuAgent provider               └─ NovuAgent provider
       └─ enforceEmailTier()               └─ proceeds normally
            └─ HTTP 402

POST /agents/:id/test-email         POST /agents/:id/test-email
  └─ @ProductFeature guard             └─ proceeds normally
       └─ HTTP 402

Screenshots

Lower tier (FREE/PRO) — row disabled + tooltip on hover
Novu Email row shows Team+ gradient badge, opacity-60. Hovering anywhere on the row shows tooltip with upgrade CTA. Clicking the row does nothing. "Upgrade plan →" in tooltip navigates to billing.

Related

  • Linear: NV-7403
  • Domains page is already Business-tier gated — this change aligns agent email with the same policy

Made with Cursor

What changed

The PR gates the Novu agent email integration behind the Business (Team+) tier by introducing a feature flag, server-side validation, and UI controls. This is necessary because the integration depends on domain management features (DNS verification, custom domains, inbound routing) that are only available in the Business tier and above.

Affected areas

  • shared: Added AGENT_EMAIL_INTEGRATION feature flag to FeatureNameEnum and ProductFeatureKeyEnum, enabled for BUSINESS/ENTERPRISE/UNLIMITED tiers and disabled for FREE/PRO. Added requiresBusinessTier optional property to ConversationalProvider type, set to true for Novu Email provider.

  • api: Added tier enforcement in add-agent-integration.usecase.ts by injecting CommunityOrganizationRepository and validating the organization's apiServiceLevel against the feature; returns HTTP 402 (PAYMENT_REQUIRED) for ineligible tiers. Applied @ProductFeature decorator to the test-email endpoint to enforce the same restriction at the controller level.

  • dashboard: Added useIsAgentEmailAvailable() hook to check if the feature is enabled for the current subscription tier. Updated provider-dropdown.tsx to disable the Novu Email row for FREE/PRO organizations, display a "Team+" lock badge, show a tooltip explaining the tier requirement, and provide an "Upgrade plan" link that routes to billing (hosted) or self-hosted upgrade URL.

Key technical decisions

  • Uses HTTP 402 (PAYMENT_REQUIRED) status code to signal tier-based access restrictions at the API level.
  • Leverages the existing @ProductFeature decorator pattern for declarative API endpoint gating.
  • Client-side feature availability check through a dedicated hook rather than inferring from provider metadata alone, ensuring alignment with backend tier validation.

Testing

No new tests are explicitly mentioned. This change would benefit from integration tests verifying that FREE/PRO organizations receive HTTP 402 when attempting to add agent email integration, and that BUSINESS+ organizations can proceed normally.

…nd Team+ tier fixes NV-7403

Email agent integration depends on domain management which is already
Business-tier only. This aligns the agent email channel with that
constraint: FREE/PRO orgs see the option with a locked badge and an
upgrade tooltip; Business+ orgs are fully unblocked.

Made-with: Cursor
@linear
Copy link
Copy Markdown

linear Bot commented Apr 23, 2026

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 23, 2026

Deploy Preview for dashboard-v2-novu-staging ready!

Name Link
🔨 Latest commit d322874
🔍 Latest deploy log https://app.netlify.com/projects/dashboard-v2-novu-staging/deploys/69ea24fa1fc2e70008ba8bdf
😎 Deploy Preview https://deploy-preview-10837.dashboard-v2.novu-staging.co
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

This PR implements tier-based access control for the agent email integration feature by introducing a feature gate across the API and dashboard layers. It adds the feature flag definition, tier mappings, tier-based validation in the backend use case, a decorator on the test-email endpoint, and UI restrictions with upgrade prompts on the dashboard provider dropdown.

Changes

Cohort / File(s) Summary
Feature Flag & Tier Configuration
packages/shared/src/types/billing.ts, packages/shared/src/consts/feature-tiers-constants.ts, packages/shared/src/consts/productFeatureEnabledForServiceLevel.ts
Adds AGENT_EMAIL_INTEGRATION feature flag definition and tier mappings, enabled only for BUSINESS/ENTERPRISE/UNLIMITED service levels.
Provider Configuration
packages/shared/src/consts/providers/conversational-providers.ts
Marks Novu Email provider with requiresBusinessTier: true flag in ConversationalProvider type.
API Backend
apps/api/src/app/agents/agents.controller.ts, apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts
Adds product feature decorator to test-email endpoint and implements tier-based validation that enforces the feature gate by checking organization service level and rejecting non-qualifying tiers with 402 Payment Required.
Dashboard UI & Hooks
apps/dashboard/src/hooks/use-is-agent-email-available.ts, apps/dashboard/src/components/agents/provider-dropdown.tsx
Introduces hook to evaluate agent email feature availability and updates dropdown component to lock/disable Novu Email provider for non-qualifying tiers, displaying Team+ lock badge and offering tier-aware upgrade navigation.

Sequence Diagram

sequenceDiagram
    actor User
    participant Dashboard
    participant Hook as useIsAgentEmailAvailable
    participant Subscription
    participant FeatureConfig
    participant API
    participant DB as Organization Repo
    
    User->>Dashboard: Select Novu Email provider
    Dashboard->>Hook: Check if agent email available?
    Hook->>Subscription: Fetch subscription data
    Subscription->>FeatureConfig: Get feature enabled for tier?
    FeatureConfig-->>Hook: false (if not Business+)
    Hook-->>Dashboard: Feature not available
    Dashboard-->>User: Show locked state + Upgrade button
    
    alt User attempts API connection
        User->>API: POST add-agent-integration (NovuAgent)
        API->>DB: Get organization
        DB-->>API: Return org with FREE/PRO tier
        API->>FeatureConfig: Validate AGENT_EMAIL_INTEGRATION for tier
        FeatureConfig-->>API: Access denied for tier
        API-->>User: 402 Payment Required
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

@novu/shared, @novu/api-service, @novu/dashboard

Suggested reviewers

  • scopsy
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title follows Conventional Commits format with valid type (feat), valid scopes (dashboard, api-service, shared), and includes the Linear ticket reference (fixes NV-7403), but the ticket should follow the format XXX-XXXX as specified. Update the title to use the correct Linear ticket format: 'feat(dashboard,api,shared): gate agent email integration behind Team+ tier fixes NV-7403' (remove 'api-service' and use 'api' as the valid scope).
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts (1)

76-84: Consider a more descriptive error message for the 402 response.

The 'Payment Required' string is generic and will surface to API consumers/devs without indicating which feature or tier is required. A more specific message (e.g., naming the feature and the required tier) would improve debuggability without leaking sensitive info.

🛠️ Suggested change
     if (!allowed) {
-      throw new HttpException('Payment Required', HttpStatus.PAYMENT_REQUIRED);
+      throw new HttpException(
+        'Agent email integration requires the Team plan or higher. Please upgrade your subscription.',
+        HttpStatus.PAYMENT_REQUIRED
+      );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts`
around lines 76 - 84, The 402 uses a generic 'Payment Required' message; update
enforceEmailTier to throw a more descriptive HttpException that names the
feature (FeatureNameEnum.AGENT_EMAIL_INTEGRATION) and the required tier (use
ApiServiceLevelEnum value expected for this feature) so consumers know which
feature/tier is missing; locate enforceEmailTier (calls
organizationRepository.findById and getFeatureForTierAsBoolean) and replace the
generic message with one like "Agent email integration requires <REQUIRED_TIER>
plan; current plan: <current_tier>" (include the actual enum values dynamically)
while keeping the HttpStatus.PAYMENT_REQUIRED status.
apps/dashboard/src/components/agents/provider-dropdown.tsx (1)

386-474: Extract the duplicated "Team+" badge markup.

The gradient-text "Team+" / "Team feature" badge appears twice (lines 387–400 and 439–452) with near-identical inline style blocks. A small local component (e.g., TeamTierBadge) would de-duplicate the markup and centralize the gradient styling. Tailwind arbitrary-value utilities (bg-[linear-gradient(...)] bg-clip-text text-transparent) can also replace the inline style per the dashboard styling guideline to avoid inline styles for values expressible as utilities.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/components/agents/provider-dropdown.tsx` around lines 386
- 474, Create a small presentational component (e.g., TeamTierBadge) to replace
the duplicated gradient "Team+" / "Team feature" badge markup found in the
rowContent block and inside the TooltipContent; extract the repeated structure
(icon RiLockStarLine + gradient text span) into that component and use it in
both places (references: rowContent, TooltipContent, CommandItem render). Move
the inline style to Tailwind utilities (use
bg-[linear-gradient(225deg,_#FF884D_23.17%,_#E300BD_80.17%)] bg-clip-text
text-transparent or bg-clip-text with WebKit equivalents if needed) so no inline
style is used, and ensure the component accepts props for the copy ("Team+" vs
"Team feature") and any sizing classes so it fits both usages without
duplicating markup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/dashboard/src/components/agents/provider-dropdown.tsx`:
- Around line 413-480: The locked rows are rendered with disabled={isBusy ||
isLocked}, which makes them unreachable to keyboard users; remove disabling for
the isLocked case on the CommandItem (keep disabled only for isBusy) and instead
handle the locked case in the onSelect handler: if isLocked, prevent selection
(no-op) and open/focus the tooltip/upgrade CTA (e.g., call setOpen(true) or
focus the TooltipTrigger) so the tooltip and its "Upgrade plan" button become
reachable via keyboard; update handleSelect/onSelect logic to early-return for
locked items and ensure the TooltipTrigger wrapping rowContent remains
focusable.

---

Nitpick comments:
In
`@apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts`:
- Around line 76-84: The 402 uses a generic 'Payment Required' message; update
enforceEmailTier to throw a more descriptive HttpException that names the
feature (FeatureNameEnum.AGENT_EMAIL_INTEGRATION) and the required tier (use
ApiServiceLevelEnum value expected for this feature) so consumers know which
feature/tier is missing; locate enforceEmailTier (calls
organizationRepository.findById and getFeatureForTierAsBoolean) and replace the
generic message with one like "Agent email integration requires <REQUIRED_TIER>
plan; current plan: <current_tier>" (include the actual enum values dynamically)
while keeping the HttpStatus.PAYMENT_REQUIRED status.

In `@apps/dashboard/src/components/agents/provider-dropdown.tsx`:
- Around line 386-474: Create a small presentational component (e.g.,
TeamTierBadge) to replace the duplicated gradient "Team+" / "Team feature" badge
markup found in the rowContent block and inside the TooltipContent; extract the
repeated structure (icon RiLockStarLine + gradient text span) into that
component and use it in both places (references: rowContent, TooltipContent,
CommandItem render). Move the inline style to Tailwind utilities (use
bg-[linear-gradient(225deg,_#FF884D_23.17%,_#E300BD_80.17%)] bg-clip-text
text-transparent or bg-clip-text with WebKit equivalents if needed) so no inline
style is used, and ensure the component accepts props for the copy ("Team+" vs
"Team feature") and any sizing classes so it fits both usages without
duplicating markup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dbfa8af0-4198-40c7-bb8e-0d0fc40933a8

📥 Commits

Reviewing files that changed from the base of the PR and between b58504f and f5678af.

📒 Files selected for processing (8)
  • apps/api/src/app/agents/agents.controller.ts
  • apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts
  • apps/dashboard/src/components/agents/provider-dropdown.tsx
  • apps/dashboard/src/hooks/use-is-agent-email-available.ts
  • packages/shared/src/consts/feature-tiers-constants.ts
  • packages/shared/src/consts/productFeatureEnabledForServiceLevel.ts
  • packages/shared/src/consts/providers/conversational-providers.ts
  • packages/shared/src/types/billing.ts

Comment on lines 413 to 480
return (
<CommandItem
key={itemKey}
value={`${item.displayName} ${item.providerId}${item.integration ? ` ${item.integration.identifier}` : ''}`}
disabled={isBusy}
disabled={isBusy || isLocked}
onSelect={() => {
void handleSelect(item, index);
}}
className={cn(
'flex items-center gap-2 rounded-md p-1',
item.integration?._id === selectedIntegrationId && 'bg-bg-muted'
item.integration?._id === selectedIntegrationId && 'bg-bg-muted',
isLocked && '!pointer-events-auto opacity-60'
)}
>
<div className="flex flex-1 items-center gap-1">
<ProviderIcon
providerId={item.providerId}
providerDisplayName={item.displayName}
className="size-4 shrink-0"
/>
<span className="text-text-sub text-label-xs flex-1 font-medium leading-4">
{item.displayName}
</span>
</div>

{isRowPending && (
<RiLoader4Line className="text-text-soft size-3 shrink-0 animate-spin" aria-hidden />
)}
{!isRowPending && item.integration && item.providerId !== EmailProviderIdEnum.NovuAgent && (
<span className="font-code text-text-sub shrink-0 text-[10px] leading-[15px] tracking-[-0.2px]">
{item.integration.identifier}
</span>
)}
{!isRowPending && !item.integration && (
<RiAddLine className="text-text-soft size-3 shrink-0" />
{isLocked ? (
<Tooltip>
<TooltipTrigger asChild>
{rowContent}
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
variant="light"
size="lg"
className="flex w-64 flex-col items-start gap-3 border border-neutral-100 p-2 shadow-md"
>
<div className="flex items-center gap-1 rounded bg-red-50 px-2 py-1">
<RiLockStarLine className="h-3 w-3 text-pink-600" />
<span
className="text-[10px] font-medium uppercase leading-normal"
style={{
background: 'linear-gradient(225deg, #FF884D 23.17%, #E300BD 80.17%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Team feature
</span>
</div>
<div className="flex flex-col items-start gap-3">
<p className="text-xs text-neutral-500">
Agent email requires the Team plan. Upgrade to connect an inbound email address.
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
if (IS_SELF_HOSTED) {
openInNewTab(
`${SELF_HOSTED_UPGRADE_REDIRECT_URL}?utm_campaign=agent_email_integration`
);
} else {
navigate(`${ROUTES.SETTINGS_BILLING}?utm_source=agent_provider_dropdown`);
}
}}
className="flex items-center gap-1 text-xs font-medium text-neutral-900 hover:underline"
>
Upgrade plan <RiExternalLinkLine className="h-3 w-3" />
</button>
</div>
</TooltipContent>
</Tooltip>
) : (
rowContent
)}
</CommandItem>
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.

⚠️ Potential issue | 🟡 Minor

Keyboard/a11y: locked rows may be unreachable, hiding the upgrade CTA.

Disabled CommandItems are typically skipped by cmdk's keyboard navigation, which means keyboard-only users won't be able to focus the locked row, trigger the tooltip, or reach the "Upgrade plan" button. The !pointer-events-auto class lets mouse hover work but does not help keyboard access.

Consider one of:

  • Keep the row focusable (not disabled) and handle the "no-op" in onSelect/handleSelect only, so the tooltip opens on focus and the upgrade button is reachable via Tab.
  • Or render a standalone always-visible "Upgrade" link/button next to the locked provider (outside the tooltip), so the CTA is always reachable regardless of input modality.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/components/agents/provider-dropdown.tsx` around lines 413
- 480, The locked rows are rendered with disabled={isBusy || isLocked}, which
makes them unreachable to keyboard users; remove disabling for the isLocked case
on the CommandItem (keep disabled only for isBusy) and instead handle the locked
case in the onSelect handler: if isLocked, prevent selection (no-op) and
open/focus the tooltip/upgrade CTA (e.g., call setOpen(true) or focus the
TooltipTrigger) so the tooltip and its "Upgrade plan" button become reachable
via keyboard; update handleSelect/onSelect logic to early-return for locked
items and ensure the TooltipTrigger wrapping rowContent remains focusable.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 23, 2026

Open in StackBlitz

npm i https://pkg.pr.new/novuhq/novu@10837
npm i https://pkg.pr.new/novuhq/novu/@novu/providers@10837
npm i https://pkg.pr.new/novuhq/novu/@novu/shared@10837

commit: d322874

@ChmaraX ChmaraX merged commit 9adf08d into next Apr 23, 2026
36 of 37 checks passed
@ChmaraX ChmaraX deleted the nv-7403-gate-agent-email-integration-behind-team-business-tier branch April 23, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant