Skip to content

feat(api-service, framework): wire email action buttons to onAction handler fixes NV-7422#11075

Merged
scopsy merged 7 commits into
nextfrom
feat/agent-email-action-buttons
May 12, 2026
Merged

feat(api-service, framework): wire email action buttons to onAction handler fixes NV-7422#11075
scopsy merged 7 commits into
nextfrom
feat/agent-email-action-buttons

Conversation

@scopsy
Copy link
Copy Markdown
Contributor

@scopsy scopsy commented May 11, 2026

What changed? Why was the change needed?

Today, when a Novu Agent sends an email rendered as a <Card> with <Button> action elements, the email HTML produces <a href="#">…</a> and clicks do nothing. Other channels (Slack/Teams/in-app) already deliver clicks through chat.processActionchat.onActionAgentInboundHandler.handleAction → bridge → user's onAction handler — but email had no equivalent path.

Each action button now gets a unique opaque random token in its URL, pointing at a new public API endpoint. All action context (agent/environment/org IDs, recipient address, action id/value, button style) lives server-side in Redis keyed by that token — nothing about the action is recoverable from the URL alone, so corporate email scanners, browser history, proxy logs, and mail archives never see the recipient address or internal Novu identifiers.

The flow is intentionally two-step to defeat email-client URL prefetchers (Outlook Safe Links, Mimecast, etc.):

Email click → GET  /v1/agents/email/actions/preview?t=<opaque-token>
                   ├─ peek (read-only) — does NOT mutate state, so a prefetcher's
                   │  GET can't burn the single-use reservation
                   └─ render confirm HTML
User clicks → POST /v1/agents/email/actions/execute
                   ├─ atomic single-use consume (Redis GETDEL)
                   ├─ chat.processAction({ adapter: emailAdapter, ... })
                   │    └─ existing onAction listener → handleAction → bridge → user's onAction
                   └─ render animated success HTML

The existing cached.chat.onAction(...) registration in ChatSdkService.registerEventHandlers carries the click into AgentInboundHandler.handleAction with the original button's id and value intact — no changes to that handler are needed.

Code areas

chat-adapter-email package

  • NovuEmailAdapterConfig accepts an optional actionUrlBuilder callback (per-button URL minting).
  • card-renderer.tsx pre-walks the card tree, extracts id / value / label / style from each <Button>, resolves URLs asynchronously, then renders the React tree synchronously.
  • adapter.postMessage now mints the local messageId before rendering so token claims bind to the exact email; also tracks the minted ID in the thread resolver when the provider rewrites Message-ID, so the JWT-style sourceMessageId contract holds.

API

  • AgentEmailActionTokenService — mints opaque 32-byte base64url tokens (randomBytes(32)), stores claims server-side via CacheService, exposes peek / consume / release. consume is atomic via Redis GETDEL.
  • AgentEmailActionsController — public GET preview + POST execute, with inline server-rendered HTML pages.
  • ChatSdkService.processEmailAction(claims) — public entrypoint that dispatches via chat.processAction so email flows reuse the existing onAction plumbing. Split into a pre-dispatch phase (config/adapter setup; throws AgentActionPreDispatchError) and the dispatch phase, so only pre-dispatch failures are retryable.
  • ResolvedAgentConfig now carries agentName (populated from agent.name) — used for the confirmation page header and monogram.

Security

  • No PII or internal IDs in the URL. Tokens are random; claims live in Redis. Email scanners and access logs see nothing useful.
  • Single-use via atomic Redis GETDEL — concurrent clicks resolve cleanly (one wins, the rest see "already submitted").
  • Two-step flow. GET preview is read-only; the action only runs after explicit POST. Prefetchers can't auto-execute.
  • HTTPS enforced. API_ROOT_URL is parsed and validated at token-sign time; non-HTTPS schemes throw, except for loopback hosts (localhost / 127.0.0.1 / ::1) so local dev still works.
  • No caching. All HTML responses set Cache-Control: no-store, no-cache, must-revalidate, private, Pragma: no-cache, Expires: 0.
  • 3-day TTL. Configurable via AGENT_EMAIL_ACTION_TOKEN_TTL (seconds).
  • Retry only on pre-dispatch failures. If chat.processAction(...) throws, the agent's onAction may have already executed partial work — re-releasing the token would risk replaying non-idempotent side effects. The release path is gated on a typed AgentActionPreDispatchError raised only by the config/adapter-setup phase.
  • Cache outages surface a retry page, not "already submitted". AgentEmailActionCacheUnavailableError is thrown by peek/consume when Redis is unreachable; the controller renders an HTTP 503 "Try again" page that preserves the token.

UX

  • Per-agent monogram avatar (initials from agent.name, deterministic color from one of 8 palette pairs).
  • Agent name shown above the action label.
  • Style-aware destructive UI<Button style="danger"> renders a red Confirm button + "This action cannot be undone" warning chip. Non-destructive actions skip the warning entirely.
  • Loading state — Confirm button disables, label swaps to "Submitting…" with an inline spinner, prevents double-submit.
  • Smooth in-place swap — submit goes via fetch(); only the .card element is replaced with a fade-out → fade-in transition. Plain form-POST fallback when JS is disabled.
  • Cancel secondary button — attempts window.close(), falls back to a visible hint.
  • Dark mode via @media (prefers-color-scheme: dark), including dark variants of the avatar palette and the danger styling.
  • Relative-time footer — "Sent N min ago by <Agent Name>".

Screenshots

Expand for optional sections

Special notes for your reviewer

  • Verification I haven't done yet (since this is draft): end-to-end run from a real send-test-email against an agent whose onMessage returns a card with <Button id="approve" value="123">Approve</Button>. I'd like to confirm the bridge log shows the click landing in onAction with { id: 'approve', value: '123' }, and visually verify the success-screen animation.
  • agentId plumbing: createChatInstance and buildAdapters now take agentId so the email adapter's actionUrlBuilder closure can mint tokens bound to the right Mongo _id. This is the only signature change to existing internal methods; no public API on ChatSdkService was renamed.
  • Test fixture change: chat-sdk.service.spec.ts bumps 5 manual new ChatSdkService(...) calls to pass an extra {} as any for the AgentEmailActionTokenService constructor argument.
  • HTML templates are inline tagged template literals (no template engine). Each page weighs in around ~5 KB, has <meta name="robots" content="noindex,nofollow" /> and <meta name="referrer" content="no-referrer" />, and the inline JS gracefully no-ops if window.fetch is unavailable.
  • Replay-UX trade-off: when a corporate prefetcher follows the GET preview link, the user later clicking the same link still sees the confirm page (preview is idempotent). On POST, only one client can win the atomic GETDEL; the rest see "already submitted". Prefetchers don't POST, so this is robust in practice.
  • TTL rename: previous (unmerged) commits on this branch used AGENT_EMAIL_ACTION_JWT_TTL. The variable is renamed to AGENT_EMAIL_ACTION_TOKEN_TTL to match the JWT → opaque-token semantic change. No external deployment has ever depended on the old name.

🤖 Generated with Claude Code

Email cards from agents currently render `<Button>` action elements as
links with `href="#"`, so clicks do nothing. Other channels (Slack/Teams/
in-app) already deliver clicks through `chat.processAction` → `onAction`,
but email had no equivalent path.

Each button now gets a unique signed URL pointing at a new public API
endpoint. The flow is two-step to defeat email-client URL prefetchers
(Outlook Safe Links, Mimecast, etc.):

  GET  /v1/agents/email/actions/preview?t=<jwt>  — verify, render confirm HTML
  POST /v1/agents/email/actions/execute          — single-use claim, dispatch

The execute endpoint calls `chat.processAction` with the email adapter,
which fires the existing `chat.onAction` listener and flows through the
unchanged `AgentInboundHandler.handleAction` → bridge → user's `onAction`
handler with the original button's `id` and `value` intact.

Security:
- Audience-scoped JWT (`agent_email_action`) signed with JWT_SECRET
- 30-day TTL (configurable via AGENT_EMAIL_ACTION_JWT_TTL)
- Single-use enforced via Redis SET NX EX keyed on the token's jti
- Two-step flow ensures Safe Links can't auto-execute actions

UX: server-rendered HTML pages with inline CSS animations (confirm,
animated checkmark success, already-submitted, expired/error).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@netlify
Copy link
Copy Markdown

netlify Bot commented May 11, 2026

Deploy Preview for dashboard-v2-novu-staging canceled.

Name Link
🔨 Latest commit cc5a71d
🔍 Latest deploy log https://app.netlify.com/projects/dashboard-v2-novu-staging/deploys/6a02e5832848c900087cf077

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Implements cache-backed opaque single-use email action tokens, signs action URLs via the Email adapter, resolves action links during card render, adds ChatSdk email-action processing, and provides GET /preview and POST /execute HTML endpoints to confirm and execute single-use actions.

Changes

Email Action Link Tokens & Click Processing

Layer / File(s) Summary
Token service: opaque single-use tokens
apps/api/src/app/agents/services/agent-email-action-token.service.ts
Introduces AgentEmailActionTokenService that mints opaque random tokens, stores claims+expiresAt in cache, implements signActionToken, peekActionToken, consumeActionToken (atomic getdel), and releaseActionToken (restore with remaining TTL).
Email Actions Controller: Preview & Execute
apps/api/src/app/agents/agent-email-actions.controller.ts
Adds AgentEmailActionsController with GET /agents/email/actions/preview (peek token → confirmation or expired page) and POST /agents/email/actions/execute (consume token → dispatch via ChatSdk → success/error/already-submitted pages). Includes HTML helpers, safe escaping, avatar/time helpers, inline styles/scripts, and no-cache headers.
Chat SDK: Email Action Integration
apps/api/src/app/agents/services/chat-sdk.service.ts
Injects AgentEmailActionTokenService, threads agentId through cached Chat/adapter creation, adds processEmailAction(claims), extractRecipientFromThreadId, and configures Email adapter actionUrlBuilder to sign tokens containing userIdentifier.
Email adapter types & package entry
packages/chat-adapter-email/src/types.ts, packages/chat-adapter-email/src/index.ts
Adds ActionUrlBuilder and ActionButtonStyle, makes actionUrlBuilder optional on NovuEmailAdapterConfig, and re-exports ActionUrlBuilder from the package entrypoint.
Card rendering with action context
packages/chat-adapter-email/src/card-renderer.tsx
CardNode gains id?; card renderer collects eligible buttons, pre-resolves action URLs using ActionUrlBuilder, and uses resolved hrefs during render. renderCard accepts optional action context.
Message rendering: pass action context
packages/chat-adapter-email/src/message-renderer.ts
RenderInput gains optional actionContext; renderMessage passes actionContext to renderCard when rendering cards.
Email adapter message flow
packages/chat-adapter-email/src/adapter.ts
postMessage generates a local messageId before rendering, passes actionContext when a card and actionUrlBuilder are present, and records the local messageId if provider returns a different sent id.
Module registration & tests
apps/api/src/app/agents/agents.module.ts, apps/api/src/app/agents/services/chat-sdk.service.spec.ts
Registers AgentEmailActionsController and provides AgentEmailActionTokenService in AgentsModule; updates test ChatSdkService instantiations to new constructor arity.
Agent config update
apps/api/src/app/agents/services/agent-config-resolver.service.ts
Adds agentName: string to ResolvedAgentConfig and populates it from agent.name.
Misc
.cursorignore
Adds pnpm-workspace.yaml to ignore patterns.

Sequence Diagram

sequenceDiagram
  participant User as User (Email Client)
  participant Preview as GET /agents/email/actions/preview
  participant Execute as POST /agents/email/actions/execute
  participant TokenService as AgentEmailActionTokenService
  participant ChatSdk as ChatSdkService

  User->>Preview: Click email button (contains opaque token)
  Preview->>TokenService: peekActionToken(token)
  TokenService-->>Preview: claims or null
  Preview-->>User: HTML confirmation page (hidden token)
  User->>Execute: Submit confirmation form
  Execute->>TokenService: consumeActionToken(token)
  TokenService-->>Execute: consumed or null
  Execute->>ChatSdk: processEmailAction(claims)
  ChatSdk-->>Execute: action processed or throws
  Execute-->>User: HTML success / error / already-submitted page
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • novuhq/novu#11039 — Modifies ChatSdkService; changes overlap with email-action processing and adapter wiring in this PR.
  • novuhq/novu#11022 — Related edits to the email adapter and shared rendering/types that intersect with action URL rendering changes.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title follows Conventional Commits format with valid type (feat) and scopes (api-service, framework). Description is lowercase and imperative. Linear ticket reference (NV-7422) is included at the end with 'fixes' prefix.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

@scopsy scopsy changed the title feat(agents): wire email action buttons to onAction handler feat(agents): wire email action buttons to onAction handler (fixes NV-7422) May 11, 2026
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 11, 2026

NV-7422

@scopsy scopsy changed the title feat(agents): wire email action buttons to onAction handler (fixes NV-7422) feat(api-service, framework): wire email action buttons to onAction handler fixes NV-7422 May 11, 2026
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: 3

🤖 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.

Inline comments:
In `@apps/api/src/app/agents/agent-email-actions.controller.ts`:
- Around line 90-110: The token is being irrevocably consumed by
tokenService.claimSingleUse(claims.jti, claims.exp) before the side-effecting
work in chatSdkService.processEmailAction(claims) completes, so transient
failures cause lost actions and false "Already submitted" responses; change the
flow to only claim the single-use token after processEmailAction succeeds (or
implement a claim-with-callback/atomic operation if tokenService supports it),
i.e., move or perform tokenService.claimSingleUse after a successful await of
chatSdkService.processEmailAction(claims), and if the claim itself can fail,
handle that error path to render the appropriate page
(renderAlreadySubmittedPage or renderErrorPage) to avoid losing retries.
- Around line 116-118: sendHtml currently returns HTML without cache headers;
update the sendHtml method (agent-email-actions.controller.ts) to set
Cache-Control: no-store, Pragma: no-cache and Expires: 0 (legacy no-cache) on
the Response before sending, e.g. call res.set(...) (or res.header(...)) with
those headers and then res.status(...).type(...).send(body) so token-bearing
HTML previews are never cached.

In `@packages/chat-adapter-email/src/adapter.ts`:
- Around line 158-169: The code mints a Message-ID via generateMessageId and
uses it in the action URL context passed to renderMessage, but later code
replaces or uses result.messageId (e.g., the value from send result) which may
differ if the provider rewrites Message-ID; preserve and use the originally
minted messageId for action-token correlation everywhere instead of
result.messageId — ensure functions/blocks that build action URLs or store
lookup keys (references: generateMessageId, messageId, renderMessage, and any
subsequent use of result.messageId or threadResolver.getReplyHeaders) continue
to use the minted messageId rather than replacing it with the provider-returned
result.messageId.
🪄 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: aeac3af3-b872-463e-895e-05f022495a76

📥 Commits

Reviewing files that changed from the base of the PR and between 7ef76a1 and fd4d0fe.

📒 Files selected for processing (10)
  • apps/api/src/app/agents/agent-email-actions.controller.ts
  • apps/api/src/app/agents/agents.module.ts
  • apps/api/src/app/agents/services/agent-email-action-token.service.ts
  • apps/api/src/app/agents/services/chat-sdk.service.spec.ts
  • apps/api/src/app/agents/services/chat-sdk.service.ts
  • packages/chat-adapter-email/src/adapter.ts
  • packages/chat-adapter-email/src/card-renderer.tsx
  • packages/chat-adapter-email/src/index.ts
  • packages/chat-adapter-email/src/message-renderer.ts
  • packages/chat-adapter-email/src/types.ts

Comment thread apps/api/src/app/agents/agent-email-actions.controller.ts Outdated
Comment thread apps/api/src/app/agents/agent-email-actions.controller.ts
Comment thread packages/chat-adapter-email/src/adapter.ts
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: 2

🤖 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.

Inline comments:
In `@apps/api/src/app/agents/services/agent-email-action-token.service.ts`:
- Around line 10-31: The AgentEmailActionClaims interface currently includes
PII/internal identifiers in the JWT payload; change the token payload to be
opaque by only including a unique jti (and minimal non-identifying metadata like
exp if needed) in AgentEmailActionClaims and remove agentId, agentIdentifier,
integrationIdentifier, environmentId, organizationId, threadId, messageId, and
userIdentifier from the JWT. Persist the full action context server-side
(DB/cache) keyed by the jti when issuing the token in the token-creation
function(s) (e.g., the create/sign method in
agent-email-action-token.service.ts) and, in the token verification/consume path
(verify/validate method in the same service), load the action context from
storage by jti to perform authorization and routing. Update any callers that
read fields from the JWT (token consumers) to instead fetch the context using
the jti (e.g., resolveActionFromToken or equivalent handler) and ensure
single-use enforcement still clears the stored context on consume.
- Around line 61-65: Validate that the API_ROOT_URL scheme is HTTPS before
building the email action URL: after computing base (variable base) check its
URL scheme and throw an error if it is not "https:" except when the host is
explicitly allowed for local/dev (e.g., "localhost", "127.0.0.1" or a configured
dev host/port); update the logic in the function that builds url (`const base =
...` / `const url = ...`) to parse base, enforce the scheme rule, and only
permit "http" for those explicit local hosts, otherwise reject to prevent
issuing tokens over insecure HTTP.
🪄 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: 885915de-cd3a-4202-857e-bfa036c769a0

📥 Commits

Reviewing files that changed from the base of the PR and between fd4d0fe and c2b36e9.

📒 Files selected for processing (3)
  • apps/api/src/app/agents/agent-email-actions.controller.ts
  • apps/api/src/app/agents/services/agent-email-action-token.service.ts
  • packages/chat-adapter-email/src/adapter.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/chat-adapter-email/src/adapter.ts
  • apps/api/src/app/agents/agent-email-actions.controller.ts

Comment thread apps/api/src/app/agents/services/agent-email-action-token.service.ts Outdated
Comment thread apps/api/src/app/agents/services/agent-email-action-token.service.ts Outdated
The previous design embedded action context as JWT claims in the URL.
JWT payloads are signed but not encrypted, so any party that sees the
link (corporate email scanners, server logs, browser history, mail
archives) can decode and read the recipient address, internal Novu
identifiers (environmentId, organizationId, agentId), and any
agent-supplied button value (which may carry app data like order IDs).

Switch to opaque tokens: the URL carries only 32 bytes of base64url
randomness, and the full action context lives server-side in Redis,
keyed by that token. Nothing about the action is recoverable from the
URL alone.

- Drop @nestjs/jwt and JwtService — no longer needed.
- Drop AgentEmailActionTokenService.{claimSingleUse, releaseSingleUse,
  verifyActionToken}; replace with peekActionToken (read-only, used by
  GET preview) and consumeActionToken / releaseActionToken (atomic
  GETDEL + transient-failure rollback, used by POST execute).
- TTL drops from 30 days to 3 days (env: AGENT_EMAIL_ACTION_TOKEN_TTL).
- Remove JwtModule.register from AgentsModule — no consumer remains.
- Single "Link expired" page covers both "expired" and "already used"
  for the preview path; execute distinguishes "already submitted".

Single-use semantics are preserved by atomic Redis GETDEL: only one
caller wins per token; the rest see "already submitted". On transient
dispatch failure the entry is restored with the original remaining TTL
so a token can never outlive its natural 3-day expiry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

♻️ Duplicate comments (1)
apps/api/src/app/agents/services/agent-email-action-token.service.ts (1)

78-82: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate HTTPS scheme in API_ROOT_URL before minting action links.

Lines 78-82 only verify that API_ROOT_URL is non-empty, but do not enforce HTTPS. If misconfigured to use http://, the opaque token can be intercepted in transit (email scanner logs, proxy logs, network sniffing). Since these tokens grant access to execute actions on behalf of users, they must only be transmitted over encrypted connections.

🔒 Proposed fix to enforce HTTPS
 const base = (process.env.API_ROOT_URL ?? '').replace(/\/$/, '');
 if (!base) {
   throw new Error('API_ROOT_URL is not configured — cannot build email action URL');
 }
+let parsedBase: URL;
+try {
+  parsedBase = new URL(base);
+} catch {
+  throw new Error('API_ROOT_URL is invalid');
+}
+const isLocalHost = parsedBase.hostname === 'localhost' || parsedBase.hostname === '127.0.0.1';
+if (parsedBase.protocol !== 'https:' && !isLocalHost) {
+  throw new Error('API_ROOT_URL must use https in non-local environments');
+}
 const url = `${base}/v1/agents/email/actions/preview?t=${encodeURIComponent(token)}`;

As per coding guidelines, apps/api/**: "Review with focus on security, authentication, and authorization."

🤖 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/api/src/app/agents/services/agent-email-action-token.service.ts` around
lines 78 - 82, The code only checks that API_ROOT_URL (read into variable base)
is non-empty but does not enforce HTTPS; update the logic in
agent-email-action-token.service.ts where base is computed to parse API_ROOT_URL
(use the URL constructor or equivalent) and verify its protocol is exactly
"https:" (or that it starts with "https://") before building the action URL (the
url variable); if the scheme is missing or not HTTPS, throw a clear error (e.g.,
"API_ROOT_URL must use https://") so tokens are only ever minted for encrypted
endpoints.
🤖 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.

Duplicate comments:
In `@apps/api/src/app/agents/services/agent-email-action-token.service.ts`:
- Around line 78-82: The code only checks that API_ROOT_URL (read into variable
base) is non-empty but does not enforce HTTPS; update the logic in
agent-email-action-token.service.ts where base is computed to parse API_ROOT_URL
(use the URL constructor or equivalent) and verify its protocol is exactly
"https:" (or that it starts with "https://") before building the action URL (the
url variable); if the scheme is missing or not HTTPS, throw a clear error (e.g.,
"API_ROOT_URL must use https://") so tokens are only ever minted for encrypted
endpoints.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 954f748e-583d-4fa9-afdc-7d80dba35c9d

📥 Commits

Reviewing files that changed from the base of the PR and between c2b36e9 and 6ac6be8.

📒 Files selected for processing (4)
  • .cursorignore
  • apps/api/src/app/agents/agent-email-actions.controller.ts
  • apps/api/src/app/agents/agents.module.ts
  • apps/api/src/app/agents/services/agent-email-action-token.service.ts
✅ Files skipped from review due to trivial changes (1)
  • .cursorignore

API_ROOT_URL was only checked for non-emptiness; a misconfigured
http:// base would leak the action token in cleartext on every click.
Tokens are bearer authority, so transit confidentiality matters.

Validate API_ROOT_URL with the URL constructor and require https://
except for loopback hosts (localhost / 127.0.0.1 / ::1), which keeps
local development on http://127.0.0.1:3000 working as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scopsy scopsy marked this pull request as ready for review May 12, 2026 06:40
Round of design-quality improvements on the agent email action flow:

- **Agent identity surfaced** — preview/success pages show the agent's
  display name + a deterministic colored monogram avatar (initials,
  one of 8 palette pairs hashed from the name).
- **Style-aware destructive UI** — `<Button style="danger">` now
  renders a red Confirm button + a "This action cannot be undone"
  warning chip; non-destructive actions skip the warning entirely.
- **Loading state on submit** — clicking Confirm immediately disables
  the button, swaps the label to "Submitting…" with an inline
  spinner, and prevents double-submits.
- **Smooth in-place card swap** — inline fetch() intercepts the form
  submit, parses the server response, and replaces only the `.card`
  element with a fade-out → fade-in transition. No JS = graceful
  fallback to a plain form POST + full reload.
- **Cancel affordance** — secondary button below the primary action;
  attempts window.close() and falls back to a visible hint.
- **Dark mode** — full `@media (prefers-color-scheme: dark)` block
  with dark variants for every page state, including the avatar
  palette and danger styling.
- **Footer with timestamp** — "Sent N min ago by <Agent Name>".
- **Redesigned hierarchy** — action label becomes the headline; the
  redundant "Confirm <label>" button copy is collapsed to "Confirm".
- **Improved close affordance on success page** — "← Close this tab"
  link with the same window.close() + fallback pattern.

Data plumbing:
- `agentName` added to ResolvedAgentConfig (populated from `agent.name`)
- `style` added to ActionUrlBuilder params + extracted from button
  nodes in card-renderer.tsx
- AgentEmailActionClaims gains `agentName` and `style?`
- StoredEntry gains `mintedAt`; peek/consume return shapes updated to
  carry it through for the footer rendering
- JwtModule import already removed in previous commit; nothing else
  in agents.module depended on it

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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: 3

🤖 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.

Inline comments:
In `@apps/api/src/app/agents/agent-email-actions.controller.ts`:
- Around line 108-117: The current catch releases the action token for any
exception from chatSdkService.processEmailAction, which can permit replay after
partial side effects; change the logic to only re-release the token for errors
proven to be pre-dispatch by introducing and checking a specific retry signal
(e.g., a PreDispatchError class or an error property like err.preDispatch ===
true) returned/thrown from processEmailAction. Update
chatSdkService.processEmailAction (and its downstream handlers that call user
onAction) to throw or propagate that specific PreDispatchError when no side
effects have occurred, and in agent-email-actions.controller.ts only call
tokenService.releaseActionToken(token, consumed) when the caught error is that
PreDispatchError (otherwise rethrow or handle without releasing). Reference:
chatSdkService.processEmailAction, tokenService.releaseActionToken, consumed,
token, and the user onAction path.

In `@apps/api/src/app/agents/services/agent-email-action-token.service.ts`:
- Line 6: DEFAULT_TTL_SECONDS was changed to 3 days and the code only reads
AGENT_EMAIL_ACTION_TOKEN_TTL, breaking existing deployments that use
AGENT_EMAIL_ACTION_JWT_TTL and expect a 30-day default; restore backward
compatibility by setting DEFAULT_TTL_SECONDS to 30 days (60*60*24*30) and when
reading the TTL environment, check AGENT_EMAIL_ACTION_TOKEN_TTL first and
fallback to AGENT_EMAIL_ACTION_JWT_TTL before using the default (update the same
logic wherever AGENT_EMAIL_ACTION_TOKEN_TTL is read, e.g., in
AgentEmailActionTokenService and the code around the earlier referenced lines).
- Around line 152-158: The method consumeActionToken currently returns null when
cacheService.client is missing, which the caller treats the same as an
expired/consumed token; change this to return a distinguishable failure or throw
so callers can show a retryable error. Specifically, in consumeActionToken
(agent-email-action-token.service.ts) detect when cacheService.client is falsy
and either throw a dedicated error class (e.g., CacheUnavailableError) or return
a ConsumedActionToken-shaped object with an explicit status like { status:
'CACHE_UNAVAILABLE' }, then update the controller that consumes
consumeActionToken to handle that error/status and render a retryable error page
instead of the "Already submitted" page. Ensure the chosen approach uses the
existing ConsumedActionToken type or a documented error type so callers can
reliably branch on cache-unavailable vs expired/consumed.
🪄 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: 839c8eb4-fda9-4ea6-a70d-bedc3aa359e3

📥 Commits

Reviewing files that changed from the base of the PR and between 77cb9a8 and 4a39dd7.

📒 Files selected for processing (7)
  • apps/api/src/app/agents/agent-email-actions.controller.ts
  • apps/api/src/app/agents/services/agent-config-resolver.service.ts
  • apps/api/src/app/agents/services/agent-email-action-token.service.ts
  • apps/api/src/app/agents/services/chat-sdk.service.ts
  • packages/chat-adapter-email/src/card-renderer.tsx
  • packages/chat-adapter-email/src/index.ts
  • packages/chat-adapter-email/src/types.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/chat-adapter-email/src/index.ts
  • packages/chat-adapter-email/src/types.ts
  • packages/chat-adapter-email/src/card-renderer.tsx
  • apps/api/src/app/agents/services/chat-sdk.service.ts

Comment thread apps/api/src/app/agents/agent-email-actions.controller.ts Outdated
Two of CodeRabbit's three new comments on #11075 (skipping the third
with a documented reason):

1. Critical — release-on-failure could replay post-dispatch side effects
   `processEmailAction` ends with `chat.processAction(...)`, which routes
   into the agent author's `onAction` handler. If that throws after
   running partial non-idempotent work (Slack message sent, DB row
   updated, third-party API called), the previous catch handler would
   re-release the single-use token and the user could replay the same
   email click, duplicating that work.

   Fix: split `processEmailAction` into a pre-dispatch phase (config
   resolution, chat-instance lookup, adapter availability check) and the
   dispatch itself. Pre-dispatch failures are wrapped in a new
   `AgentActionPreDispatchError` — these are provably side-effect-free
   and safe to retry. Anything that escapes from `chat.processAction(...)`
   propagates as-is, and the controller no longer releases the token in
   that branch. The user sees a terminal error; recovery means a fresh
   email link, not a replay of a partially-executed action.

2. Major — cache outage masquerading as "Already submitted"
   When Redis is unreachable, `consumeActionToken` returned `null`, which
   the controller treated as "already used" and rendered the terminal
   "Already submitted" page — silently dropping a valid click. Same
   hazard on `peekActionToken` (cacheService.get throws → uncaught 500
   from NestJS).

   Fix: new `AgentEmailActionCacheUnavailableError` typed error raised
   by both peek and consume on cache failures. Controller catches it and
   renders a new "We're having trouble right now" retry page (HTTP 503).
   The token is *not* consumed on this path, so reloading the page once
   service is restored just works.

Skipped (with reason):
- TTL backward-compat (`AGENT_EMAIL_ACTION_JWT_TTL` fallback + 30-day
  default). Both the old name and 30-day default lived in earlier commits
  on this same unmerged branch — no external deployment can possibly
  depend on them. The rename matches a real semantic change (JWT →
  opaque token); preserving the misleading name would only add noise.
  The 3-day default is an explicit product choice in this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scopsy scopsy merged commit e88dfab into next May 12, 2026
34 checks passed
@scopsy scopsy deleted the feat/agent-email-action-buttons branch May 12, 2026 09:09
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