-
Notifications
You must be signed in to change notification settings - Fork 4.3k
feat(api-service, framework): wire email action buttons to onAction handler fixes NV-7422 #11075
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
fd4d0fe
feat(agents): wire email action buttons to onAction handler
scopsy c2b36e9
feat: add
scopsy 45a5094
fix(agents): keep PII out of email action URLs (CodeRabbit feedback)
scopsy 6ac6be8
Update .cursorignore
scopsy 77cb9a8
fix(agents): enforce https for email action URLs (CodeRabbit feedback)
scopsy 4a39dd7
feat(agents): UX polish for email action confirmation pages
scopsy cc5a71d
fix(agents): narrow retry-on-failure + don't mask cache outages
scopsy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
287 changes: 287 additions & 0 deletions
287
apps/api/src/app/agents/agent-email-actions.controller.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,287 @@ | ||
| import { Body, Controller, Get, HttpStatus, Post, Query, Res } from '@nestjs/common'; | ||
| import { ApiExcludeController } from '@nestjs/swagger'; | ||
| import { PinoLogger } from '@novu/application-generic'; | ||
| import { Response } from 'express'; | ||
| import { | ||
| AgentEmailActionTokenService, | ||
| type VerifiedAgentEmailActionClaims, | ||
| } from './services/agent-email-action-token.service'; | ||
| import { ChatSdkService } from './services/chat-sdk.service'; | ||
|
|
||
| const EXECUTE_PATH = '/v1/agents/email/actions/execute'; | ||
|
|
||
| /** | ||
| * Public, unauthenticated endpoints that handle clicks from `<Button>` action elements | ||
| * rendered inside agent-sent emails. The click flow is intentionally two-step to defeat | ||
| * URL-prefetchers in email clients (Outlook Safe Links, Mimecast, etc.): | ||
| * | ||
| * GET /v1/agents/email/actions/preview?t=<jwt> — verify token, render confirm HTML. | ||
| * Does NOT mutate any state. | ||
| * POST /v1/agents/email/actions/execute — verify, single-use claim, dispatch | ||
| * to chat SDK's processAction, render | ||
| * animated success HTML. | ||
| */ | ||
| @Controller('/agents/email/actions') | ||
| @ApiExcludeController() | ||
| export class AgentEmailActionsController { | ||
| constructor( | ||
| private readonly tokenService: AgentEmailActionTokenService, | ||
| private readonly chatSdkService: ChatSdkService, | ||
| private readonly logger: PinoLogger | ||
| ) { | ||
| this.logger.setContext(this.constructor.name); | ||
| } | ||
|
|
||
| @Get('/preview') | ||
| async preview(@Query('t') token: string | undefined, @Res() res: Response): Promise<void> { | ||
| if (!token) { | ||
| this.sendHtml( | ||
| res, | ||
| HttpStatus.BAD_REQUEST, | ||
| renderErrorPage('Invalid link', 'This action link is missing or malformed.') | ||
| ); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const claims = this.tokenService.verifyActionToken(token); | ||
| this.sendHtml( | ||
| res, | ||
| HttpStatus.OK, | ||
| renderConfirmPage({ | ||
| label: claims.label || claims.actionId, | ||
| token, | ||
| executeUrl: EXECUTE_PATH, | ||
| }) | ||
| ); | ||
| } catch (err) { | ||
| this.logger.debug({ err }, 'Rejecting agent email action preview'); | ||
| this.sendHtml( | ||
| res, | ||
| HttpStatus.OK, | ||
| renderErrorPage('Link expired', 'This action link is no longer valid. It may have expired.') | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| @Post('/execute') | ||
| async execute(@Body('t') token: string | undefined, @Res() res: Response): Promise<void> { | ||
| if (!token) { | ||
| this.sendHtml(res, HttpStatus.BAD_REQUEST, renderErrorPage('Invalid request', 'Missing action token.')); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| let claims: VerifiedAgentEmailActionClaims; | ||
| try { | ||
| claims = this.tokenService.verifyActionToken(token); | ||
| } catch (err) { | ||
| this.logger.debug({ err }, 'Rejecting agent email action execute'); | ||
| this.sendHtml( | ||
| res, | ||
| HttpStatus.OK, | ||
| renderErrorPage('Link expired', 'This action link is no longer valid. It may have expired.') | ||
| ); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| const claimed = await this.tokenService.claimSingleUse(claims.jti, claims.exp); | ||
| if (!claimed) { | ||
| this.sendHtml(res, HttpStatus.OK, renderAlreadySubmittedPage({ label: claims.label || claims.actionId })); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| try { | ||
| await this.chatSdkService.processEmailAction(claims); | ||
| } catch (err) { | ||
| this.logger.error(err, `Failed to process agent email action ${claims.actionId} for agent ${claims.agentId}`); | ||
| this.sendHtml( | ||
| res, | ||
| HttpStatus.OK, | ||
| renderErrorPage( | ||
| 'Something went wrong', | ||
| 'We could not submit this action. Please try again from the email, or contact your agent operator.' | ||
| ) | ||
| ); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| this.sendHtml(res, HttpStatus.OK, renderSuccessPage({ label: claims.label || claims.actionId })); | ||
| } | ||
|
|
||
| private sendHtml(res: Response, status: HttpStatus, body: string): void { | ||
| res.status(status).type('text/html; charset=utf-8').send(body); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function escapeHtml(value: string): string { | ||
| return value | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, '''); | ||
| } | ||
|
|
||
| const PAGE_STYLES = ` | ||
| *,*::before,*::after { box-sizing: border-box; } | ||
| html, body { margin: 0; padding: 0; height: 100%; } | ||
| body { | ||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; | ||
| background: #f7f7f8; | ||
| color: #18181b; | ||
| min-height: 100vh; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| padding: 24px; | ||
| -webkit-font-smoothing: antialiased; | ||
| } | ||
| .card { | ||
| background: #ffffff; | ||
| border-radius: 12px; | ||
| padding: 40px 32px; | ||
| width: 100%; | ||
| max-width: 440px; | ||
| box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.06); | ||
| text-align: center; | ||
| animation: fadeIn 240ms ease-out both; | ||
| } | ||
| h1 { margin: 0 0 8px; font-size: 20px; font-weight: 600; } | ||
| p { margin: 0 0 24px; color: #52525b; font-size: 14px; line-height: 1.5; } | ||
| .label { | ||
| display: inline-block; | ||
| margin-bottom: 24px; | ||
| padding: 8px 14px; | ||
| background: #f4f4f5; | ||
| border-radius: 6px; | ||
| color: #18181b; | ||
| font-weight: 500; | ||
| font-size: 14px; | ||
| max-width: 100%; | ||
| overflow-wrap: anywhere; | ||
| } | ||
| button.primary { | ||
| appearance: none; | ||
| border: 0; | ||
| cursor: pointer; | ||
| background: #18181b; | ||
| color: #ffffff; | ||
| padding: 12px 20px; | ||
| border-radius: 8px; | ||
| font-size: 14px; | ||
| font-weight: 500; | ||
| width: 100%; | ||
| transition: background 120ms ease; | ||
| } | ||
| button.primary:hover { background: #27272a; } | ||
| .footer { margin-top: 20px; font-size: 12px; color: #a1a1aa; } | ||
| .check { | ||
| width: 64px; | ||
| height: 64px; | ||
| margin: 0 auto 20px; | ||
| border-radius: 50%; | ||
| background: #ecfdf5; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| animation: pop 360ms cubic-bezier(0.16, 1, 0.3, 1) both; | ||
| } | ||
| .check svg { width: 32px; height: 32px; } | ||
| .check svg path { | ||
| stroke: #059669; | ||
| stroke-width: 3; | ||
| fill: none; | ||
| stroke-linecap: round; | ||
| stroke-linejoin: round; | ||
| stroke-dasharray: 48; | ||
| stroke-dashoffset: 48; | ||
| animation: stroke 420ms 200ms ease-out forwards; | ||
| } | ||
| .info-icon { | ||
| width: 64px; | ||
| height: 64px; | ||
| margin: 0 auto 20px; | ||
| border-radius: 50%; | ||
| background: #f4f4f5; | ||
| color: #52525b; | ||
| font-size: 28px; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| } | ||
| @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } | ||
| @keyframes pop { 0% { transform: scale(0.6); opacity: 0; } 60% { transform: scale(1.06); opacity: 1; } 100% { transform: scale(1); opacity: 1; } } | ||
| @keyframes stroke { to { stroke-dashoffset: 0; } } | ||
| `; | ||
|
|
||
| function pageShell(title: string, body: string): string { | ||
| return `<!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <meta name="robots" content="noindex,nofollow" /> | ||
| <meta name="referrer" content="no-referrer" /> | ||
| <title>${escapeHtml(title)}</title> | ||
| <style>${PAGE_STYLES}</style> | ||
| </head> | ||
| <body>${body}</body> | ||
| </html>`; | ||
| } | ||
|
|
||
| function renderConfirmPage(params: { label: string; token: string; executeUrl: string }): string { | ||
| const body = ` | ||
| <div class="card"> | ||
| <h1>Confirm action</h1> | ||
| <p>You're about to submit the following action to the agent. This cannot be undone.</p> | ||
| <div class="label">${escapeHtml(params.label)}</div> | ||
| <form method="POST" action="${escapeHtml(params.executeUrl)}" autocomplete="off"> | ||
| <input type="hidden" name="t" value="${escapeHtml(params.token)}" /> | ||
| <button type="submit" class="primary">Confirm ${escapeHtml(params.label)}</button> | ||
| </form> | ||
| <div class="footer">Sent by your Novu agent</div> | ||
| </div>`; | ||
|
|
||
| return pageShell(`Confirm: ${params.label}`, body); | ||
| } | ||
|
|
||
| function renderSuccessPage(params: { label: string }): string { | ||
| const body = ` | ||
| <div class="card"> | ||
| <div class="check"> | ||
| <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7" /></svg> | ||
| </div> | ||
| <h1>Action submitted</h1> | ||
| <p>Your agent received <strong>${escapeHtml(params.label)}</strong> and is processing it.</p> | ||
| <div class="footer">You can close this tab.</div> | ||
| </div>`; | ||
|
|
||
| return pageShell('Action submitted', body); | ||
| } | ||
|
|
||
| function renderAlreadySubmittedPage(params: { label: string }): string { | ||
| const body = ` | ||
| <div class="card"> | ||
| <div class="info-icon" aria-hidden="true">✓</div> | ||
| <h1>Already submitted</h1> | ||
| <p>The action <strong>${escapeHtml(params.label)}</strong> has already been received. You can close this tab.</p> | ||
| </div>`; | ||
|
|
||
| return pageShell('Already submitted', body); | ||
| } | ||
|
|
||
| function renderErrorPage(title: string, message: string): string { | ||
| const body = ` | ||
| <div class="card"> | ||
| <div class="info-icon" aria-hidden="true">!</div> | ||
| <h1>${escapeHtml(title)}</h1> | ||
| <p>${escapeHtml(message)}</p> | ||
| </div>`; | ||
|
|
||
| return pageShell(title, body); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.