diff --git a/apps/api/src/app/agents/agents.controller.ts b/apps/api/src/app/agents/agents.controller.ts index 462069b5773..216c05cad3a 100644 --- a/apps/api/src/app/agents/agents.controller.ts +++ b/apps/api/src/app/agents/agents.controller.ts @@ -16,7 +16,13 @@ import { } from '@nestjs/common'; import { ApiExcludeController, ApiOperation } from '@nestjs/swagger'; import { ProductFeature, RequirePermissions } from '@novu/application-generic'; -import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, ProductFeatureKeyEnum, UserSessionData } from '@novu/shared'; +import { + ApiRateLimitCategoryEnum, + DirectionEnum, + PermissionsEnum, + ProductFeatureKeyEnum, + UserSessionData, +} from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards'; @@ -40,6 +46,7 @@ import { UpdateAgentIntegrationRequestDto, UpdateAgentRequestDto, } from './dtos'; +import { SendAgentTestEmailRequestDto } from './dtos/send-agent-test-email-request.dto'; import { AgentConversationEnabledGuard } from './guards/agent-conversation-enabled.guard'; import { AddAgentIntegrationCommand } from './usecases/add-agent-integration/add-agent-integration.command'; import { AddAgentIntegration } from './usecases/add-agent-integration/add-agent-integration.usecase'; @@ -166,6 +173,7 @@ export class AgentsController { organizationId: user.organizationId, agentIdentifier: identifier, integrationIdentifier: body.integrationIdentifier, + providerId: body.providerId, }) ); } @@ -274,7 +282,8 @@ export class AgentsController { @RequirePermissions(PermissionsEnum.AGENT_WRITE) sendAgentTestEmail( @UserSession() user: UserSessionData, - @Param('identifier') identifier: string + @Param('identifier') identifier: string, + @Body() body: SendAgentTestEmailRequestDto ): Promise<{ success: boolean }> { return this.sendAgentTestEmailUsecase.execute( SendAgentTestEmailCommand.create({ @@ -282,6 +291,7 @@ export class AgentsController { environmentId: user.environmentId, organizationId: user.organizationId, agentIdentifier: identifier, + targetAddress: body.targetAddress, }) ); } diff --git a/apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts b/apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts index e9455d66a78..75a56a80a9e 100644 --- a/apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts +++ b/apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts @@ -1,11 +1,22 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, ValidateIf } from 'class-validator'; export class AddAgentIntegrationRequestDto { - @ApiProperty({ + @ApiPropertyOptional({ description: 'The integration identifier (same as in the integration store), not the internal document _id.', }) + @ValidateIf((o) => !o.providerId) @IsString() @IsNotEmpty() - integrationIdentifier: string; + integrationIdentifier?: string; + + @ApiPropertyOptional({ + description: + 'Provider ID to auto-create a dedicated integration (e.g. novu-agent-email). ' + + 'When set, the server creates the integration if one does not already exist for this agent.', + }) + @ValidateIf((o) => !o.integrationIdentifier) + @IsString() + @IsNotEmpty() + providerId?: string; } diff --git a/apps/api/src/app/agents/dtos/send-agent-test-email-request.dto.ts b/apps/api/src/app/agents/dtos/send-agent-test-email-request.dto.ts new file mode 100644 index 00000000000..e1d7ee1d12b --- /dev/null +++ b/apps/api/src/app/agents/dtos/send-agent-test-email-request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty } from 'class-validator'; + +export class SendAgentTestEmailRequestDto { + @ApiProperty({ description: 'Full inbound email address to send the test to (e.g. support@acme.com)' }) + @IsEmail() + @IsNotEmpty() + targetAddress: string; +} diff --git a/apps/api/src/app/agents/services/chat-sdk.service.ts b/apps/api/src/app/agents/services/chat-sdk.service.ts index 27578ec7a37..87e88b33f60 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.ts @@ -307,7 +307,6 @@ export class ChatSdkService implements OnModuleDestroy { token: c.token ?? null, phoneNumberIdentification: c.phoneNumberIdentification ?? null, connectionAccessToken: connectionAccessToken ?? null, - replyDomain: c.replyDomain ?? null, outboundIntegrationId: c.outboundIntegrationId ?? null, }); } @@ -315,7 +314,16 @@ export class ChatSdkService implements OnModuleDestroy { private buildSendEmailCallback( config: ResolvedAgentConfig, outboundIntegrationId: string | undefined - ): (params: { to: string; subject: string; html: string; text?: string; inReplyTo?: string; references?: string; messageId?: string }) => Promise<{ messageId: string }> { + ): (params: { + from: string; + to: string; + subject: string; + html: string; + text?: string; + inReplyTo?: string; + references?: string; + messageId?: string; + }) => Promise<{ messageId: string }> { return async (params) => { if (!outboundIntegrationId) { throw new BadRequestException( @@ -351,22 +359,21 @@ export class ChatSdkService implements OnModuleDestroy { const decrypted = decryptCredentials(integration.credentials); const mailFactory = new MailFactory(); - const handler = mailFactory.getHandler( - { ...integration, credentials: decrypted }, - config.credentials.replyDomain - ); + const handler = mailFactory.getHandler({ ...integration, credentials: decrypted }, params.from); const mailOptions: IEmailOptions = { to: [params.to], subject: params.subject, html: params.html, text: params.text, - from: config.credentials.replyDomain, + from: params.from, senderName: config.credentials.senderName || undefined, headers: { ...(params.messageId ? { 'Message-ID': wrapMsgId(params.messageId) } : {}), ...(params.inReplyTo ? { 'In-Reply-To': wrapMsgId(params.inReplyTo) } : {}), - ...(params.references ? { References: params.references.split(/\s+/).filter(Boolean).map(wrapMsgId).join(' ') } : {}), + ...(params.references + ? { References: params.references.split(/\s+/).filter(Boolean).map(wrapMsgId).join(' ') } + : {}), }, }; @@ -475,20 +482,17 @@ export class ChatSdkService implements OnModuleDestroy { }; } case AgentPlatformEnum.EMAIL: { - const { replyDomain, senderName, outboundIntegrationId } = credentials; + const { senderName, outboundIntegrationId } = credentials; - if (!replyDomain || !credentials.secretKey) { - throw new BadRequestException( - 'Email agent integration requires replyDomain and secretKey credentials' - ); + if (!credentials.secretKey) { + throw new BadRequestException('Email agent integration requires secretKey credentials'); } const { createNovuEmailAdapter } = await esmImport('@novu/chat-adapter-email'); return { email: createNovuEmailAdapter({ - fromAddress: replyDomain, - fromName: senderName, + senderName, signingSecret: credentials.secretKey, sendEmail: this.buildSendEmailCallback(config, outboundIntegrationId), }), diff --git a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts b/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts index a9517d35e05..12e38607f68 100644 --- a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts +++ b/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; @@ -8,6 +8,10 @@ export class AddAgentIntegrationCommand extends EnvironmentWithUserCommand { agentIdentifier: string; @IsString() - @IsNotEmpty() - integrationIdentifier: string; + @IsOptional() + integrationIdentifier?: string; + + @IsString() + @IsOptional() + providerId?: string; } diff --git a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts b/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts index 9b984da91b1..d7a2c620ee0 100644 --- a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts +++ b/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts @@ -1,11 +1,30 @@ import { randomBytes } from 'node:crypto'; -import { ConflictException, HttpException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + HttpException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { encryptSecret } from '@novu/application-generic'; -import { AgentIntegrationRepository, AgentRepository, CommunityOrganizationRepository, IntegrationRepository } from '@novu/dal'; -import { ApiServiceLevelEnum, EmailProviderIdEnum, FeatureNameEnum, getFeatureForTierAsBoolean } from '@novu/shared'; +import { + AgentIntegrationRepository, + AgentRepository, + CommunityOrganizationRepository, + IntegrationEntity, + IntegrationRepository, +} from '@novu/dal'; +import { + ApiServiceLevelEnum, + EmailProviderIdEnum, + FeatureNameEnum, + getFeatureForTierAsBoolean, +} from '@novu/shared'; import type { AgentIntegrationResponseDto } from '../../dtos'; import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; +import { FindOrCreateNovuEmail } from '../find-or-create-novu-email/find-or-create-novu-email.usecase'; import { AddAgentIntegrationCommand } from './add-agent-integration.command'; @Injectable() @@ -14,10 +33,19 @@ export class AddAgentIntegration { private readonly agentRepository: AgentRepository, private readonly integrationRepository: IntegrationRepository, private readonly agentIntegrationRepository: AgentIntegrationRepository, - private readonly organizationRepository: CommunityOrganizationRepository + private readonly organizationRepository: CommunityOrganizationRepository, + private readonly findOrCreateNovuEmail: FindOrCreateNovuEmail ) {} async execute(command: AddAgentIntegrationCommand): Promise { + if (!command.integrationIdentifier && !command.providerId) { + throw new BadRequestException('Either integrationIdentifier or providerId must be provided.'); + } + + if (command.integrationIdentifier && command.providerId) { + throw new BadRequestException('Provide exactly one of integrationIdentifier or providerId, not both.'); + } + const agent = await this.agentRepository.findOne( { identifier: command.agentIdentifier, @@ -31,6 +59,21 @@ export class AddAgentIntegration { throw new NotFoundException(`Agent with identifier "${command.agentIdentifier}" was not found.`); } + if (command.providerId === EmailProviderIdEnum.NovuAgent) { + return this.findOrCreateNovuEmail.execute(agent._id, command.environmentId, command.organizationId); + } + + if (!command.integrationIdentifier) { + throw new BadRequestException('integrationIdentifier is required when providerId is not NovuAgent.'); + } + + return this.linkExistingIntegration(agent._id, command); + } + + private async linkExistingIntegration( + agentId: string, + command: AddAgentIntegrationCommand + ): Promise { const integration = await this.integrationRepository.findOne( { identifier: command.integrationIdentifier, @@ -44,9 +87,23 @@ export class AddAgentIntegration { throw new NotFoundException(`Integration with identifier "${command.integrationIdentifier}" was not found.`); } + if (integration.providerId === EmailProviderIdEnum.NovuAgent) { + await this.enforceEmailTier(command.organizationId); + await this.enforceSingletonEmail(agentId, command); + await this.seedEmailSecretKey(integration._id, command.environmentId, command.organizationId); + } + + return this.createLink(agentId, integration, command); + } + + private async createLink( + agentId: string, + integration: Pick, + command: AddAgentIntegrationCommand + ): Promise { const existingLink = await this.agentIntegrationRepository.findOne( { - _agentId: agent._id, + _agentId: agentId, _integrationId: integration._id, _environmentId: command.environmentId, _organizationId: command.organizationId, @@ -58,13 +115,8 @@ export class AddAgentIntegration { throw new ConflictException('This integration is already linked to the agent.'); } - if (integration.providerId === EmailProviderIdEnum.NovuAgent) { - await this.enforceEmailTier(command.organizationId); - await this.prepareNovuEmailIntegration(agent._id, integration._id, command); - } - const link = await this.agentIntegrationRepository.create({ - _agentId: agent._id, + _agentId: agentId, _integrationId: integration._id, _environmentId: command.environmentId, _organizationId: command.organizationId, @@ -83,25 +135,8 @@ export class AddAgentIntegration { } } - /** - * Enforces the singleton constraint (one NovuAgent email integration per - * agent) and seeds the `secretKey` credential the email adapter needs for - * HMAC verification of inbound webhook payloads. - */ - private async prepareNovuEmailIntegration( - agentId: string, - integrationId: string, - command: AddAgentIntegrationCommand - ): Promise { - await this.enforceSingletonEmail(agentId, command); - await this.seedEmailSecretKey(integrationId, command.environmentId, command.organizationId); - } - - private async enforceSingletonEmail( - agentId: string, - command: AddAgentIntegrationCommand - ): Promise { - const existingLinks = await this.agentIntegrationRepository.find( + private async enforceSingletonEmail(agentId: string, command: AddAgentIntegrationCommand): Promise { + const links = await this.agentIntegrationRepository.find( { _agentId: agentId, _environmentId: command.environmentId, @@ -110,10 +145,10 @@ export class AddAgentIntegration { '*' ); - if (existingLinks.length === 0) return; + if (links.length === 0) return; - const linkedIntegrationIds = existingLinks.map((link) => link._integrationId); - const linkedEmailIntegrations = await this.integrationRepository.find( + const linkedIntegrationIds = links.map((l) => l._integrationId); + const existing = await this.integrationRepository.find( { _id: { $in: linkedIntegrationIds }, _environmentId: command.environmentId, @@ -123,7 +158,7 @@ export class AddAgentIntegration { '_id' ); - if (linkedEmailIntegrations.length > 0) { + if (existing.length > 0) { throw new ConflictException('Only one email integration per agent is allowed.'); } } @@ -133,11 +168,14 @@ export class AddAgentIntegration { environmentId: string, organizationId: string ): Promise { - const dedicatedSecret = randomBytes(32).toString('hex'); - await this.integrationRepository.update( - { _id: integrationId, _environmentId: environmentId, _organizationId: organizationId }, - { $set: { 'credentials.secretKey': encryptSecret(dedicatedSecret) } } + { + _id: integrationId, + _environmentId: environmentId, + _organizationId: organizationId, + 'credentials.secretKey': { $exists: false }, + }, + { $set: { 'credentials.secretKey': encryptSecret(randomBytes(32).toString('hex')) } } ); } } diff --git a/apps/api/src/app/agents/usecases/cleanup-novu-email/cleanup-novu-email.usecase.ts b/apps/api/src/app/agents/usecases/cleanup-novu-email/cleanup-novu-email.usecase.ts new file mode 100644 index 00000000000..5b7428b4821 --- /dev/null +++ b/apps/api/src/app/agents/usecases/cleanup-novu-email/cleanup-novu-email.usecase.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@nestjs/common'; +import { PinoLogger } from '@novu/application-generic'; +import { AgentIntegrationRepository, DomainRepository, IntegrationRepository } from '@novu/dal'; +import { EmailProviderIdEnum } from '@novu/shared'; +import { ClientSession } from 'mongoose'; + +const LOG_CONTEXT = 'CleanupNovuEmail'; + +@Injectable() +export class CleanupNovuEmail { + constructor( + private readonly agentIntegrationRepository: AgentIntegrationRepository, + private readonly integrationRepository: IntegrationRepository, + private readonly domainRepository: DomainRepository, + private readonly logger: PinoLogger + ) {} + + /** + * Removes all NovuAgent email resources tied to an agent: + * domain routes pointing to the agent, and any NovuAgent Integration documents. + * Must be called within a transaction session. + */ + async cleanupForAgent( + agentId: string, + environmentId: string, + organizationId: string, + session: ClientSession | null + ): Promise { + await this.domainRepository.removeRoutesByDestination(environmentId, organizationId, agentId, { session }); + + const novuIntegrationIds = await this.findNovuEmailIntegrationIds( + agentId, + environmentId, + organizationId, + session + ); + + for (const integrationId of novuIntegrationIds) { + await this.integrationRepository.delete( + { _id: integrationId, _environmentId: environmentId, _organizationId: organizationId }, + { session } + ); + this.logger.info({ agentId, integrationId }, 'Deleted orphaned NovuAgent integration', LOG_CONTEXT); + } + } + + /** + * Removes NovuAgent resources for a specific integration being unlinked. + * Cleans domain routes and deletes the integration if it's a NovuAgent type. + */ + async cleanupForIntegration( + agentId: string, + integrationId: string, + environmentId: string, + organizationId: string, + session: ClientSession | null + ): Promise { + const integration = await this.integrationRepository.findOne( + { + _id: integrationId, + _environmentId: environmentId, + _organizationId: organizationId, + providerId: EmailProviderIdEnum.NovuAgent, + }, + '_id', + { session } + ); + + if (!integration) return; + + await this.domainRepository.removeRoutesByDestination(environmentId, organizationId, agentId, { session }); + + await this.integrationRepository.delete( + { _id: integration._id, _environmentId: environmentId, _organizationId: organizationId }, + { session } + ); + + this.logger.info( + { agentId, integrationId: integration._id }, + 'Cleaned up NovuAgent integration and domain routes', + LOG_CONTEXT + ); + } + + private async findNovuEmailIntegrationIds( + agentId: string, + environmentId: string, + organizationId: string, + session: ClientSession | null + ): Promise { + const links = await this.agentIntegrationRepository.find( + { _agentId: agentId, _environmentId: environmentId, _organizationId: organizationId }, + ['_integrationId'], + { session } + ); + + const integrationIds = links.map((l) => l._integrationId).filter(Boolean); + if (integrationIds.length === 0) return []; + + const novuIntegrations = await this.integrationRepository.find( + { + _id: { $in: integrationIds }, + _environmentId: environmentId, + _organizationId: organizationId, + providerId: EmailProviderIdEnum.NovuAgent, + }, + '_id', + { session } + ); + + return novuIntegrations.map((i) => i._id); + } +} diff --git a/apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts b/apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts index 5913787d1f2..3b10bf2844e 100644 --- a/apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts +++ b/apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts @@ -1,13 +1,15 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { AgentIntegrationRepository, AgentRepository } from '@novu/dal'; +import { CleanupNovuEmail } from '../cleanup-novu-email/cleanup-novu-email.usecase'; import { DeleteAgentCommand } from './delete-agent.command'; @Injectable() export class DeleteAgent { constructor( private readonly agentRepository: AgentRepository, - private readonly agentIntegrationRepository: AgentIntegrationRepository + private readonly agentIntegrationRepository: AgentIntegrationRepository, + private readonly cleanupNovuEmail: CleanupNovuEmail ) {} async execute(command: DeleteAgentCommand): Promise { @@ -24,16 +26,31 @@ export class DeleteAgent { throw new NotFoundException(`Agent with identifier "${command.identifier}" was not found.`); } - await this.agentIntegrationRepository.delete({ - _agentId: agent._id, - _environmentId: command.environmentId, - _organizationId: command.organizationId, - }); + await this.agentRepository.withTransaction(async (session) => { + await this.cleanupNovuEmail.cleanupForAgent( + agent._id, + command.environmentId, + command.organizationId, + session + ); + + await this.agentIntegrationRepository.delete( + { + _agentId: agent._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + { session } + ); - await this.agentRepository.delete({ - _id: agent._id, - _environmentId: command.environmentId, - _organizationId: command.organizationId, + await this.agentRepository.delete( + { + _id: agent._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + { session } + ); }); } } diff --git a/apps/api/src/app/agents/usecases/find-or-create-novu-email/find-or-create-novu-email.usecase.ts b/apps/api/src/app/agents/usecases/find-or-create-novu-email/find-or-create-novu-email.usecase.ts new file mode 100644 index 00000000000..67cc07d7dc7 --- /dev/null +++ b/apps/api/src/app/agents/usecases/find-or-create-novu-email/find-or-create-novu-email.usecase.ts @@ -0,0 +1,138 @@ +import { randomBytes } from 'node:crypto'; +import { ConflictException, HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { encryptSecret } from '@novu/application-generic'; +import { + AgentIntegrationRepository, + CommunityOrganizationRepository, + IntegrationEntity, + IntegrationRepository, +} from '@novu/dal'; +import { + ApiServiceLevelEnum, + ChannelTypeEnum, + EmailProviderIdEnum, + FeatureNameEnum, + getFeatureForTierAsBoolean, + providers, + slugify, +} from '@novu/shared'; +import { ClientSession } from 'mongoose'; +import shortid from 'shortid'; + +import type { AgentIntegrationResponseDto } from '../../dtos'; +import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; + +@Injectable() +export class FindOrCreateNovuEmail { + constructor( + private readonly integrationRepository: IntegrationRepository, + private readonly agentIntegrationRepository: AgentIntegrationRepository, + private readonly organizationRepository: CommunityOrganizationRepository + ) {} + + /** + * Find the agent's existing NovuAgent integration link, or create a new + * Integration + link atomically. Idempotent — safe to call concurrently. + */ + async execute( + agentId: string, + environmentId: string, + organizationId: string + ): Promise { + await this.enforceEmailTier(organizationId); + + const existing = await this.findExistingLink(agentId, environmentId, organizationId); + if (existing) return existing; + + return this.agentIntegrationRepository.withTransaction(async (session) => { + const recheck = await this.findExistingLink(agentId, environmentId, organizationId); + if (recheck) return recheck; + + const displayName = providers.find((p) => p.id === EmailProviderIdEnum.NovuAgent)?.displayName ?? 'Novu Email'; + const identifier = `${slugify(displayName)}-${shortid.generate()}`; + + const integration = await this.integrationRepository.create( + { + providerId: EmailProviderIdEnum.NovuAgent, + channel: ChannelTypeEnum.EMAIL, + credentials: { secretKey: encryptSecret(randomBytes(32).toString('hex')) }, + configurations: {}, + name: displayName, + identifier, + active: true, + _environmentId: environmentId, + _organizationId: organizationId, + } as any, + { session } + ); + + return this.createLink(agentId, integration, environmentId, organizationId, session); + }); + } + + async findExistingLink( + agentId: string, + environmentId: string, + organizationId: string + ): Promise { + const links = await this.agentIntegrationRepository.find( + { _agentId: agentId, _environmentId: environmentId, _organizationId: organizationId }, + '*' + ); + + if (links.length === 0) return null; + + const linkedIntegrationIds = links.map((l) => l._integrationId); + const emailIntegration = await this.integrationRepository.findOne( + { + _id: { $in: linkedIntegrationIds } as unknown as string, + _environmentId: environmentId, + _organizationId: organizationId, + providerId: EmailProviderIdEnum.NovuAgent, + }, + '_id identifier name providerId channel active' + ); + + if (!emailIntegration) return null; + + const link = links.find((l) => l._integrationId === emailIntegration._id); + if (!link) return null; + + return toAgentIntegrationResponse(link, emailIntegration); + } + + private async createLink( + agentId: string, + integration: Pick, + environmentId: string, + organizationId: string, + session: ClientSession | null + ): Promise { + const existingLink = await this.agentIntegrationRepository.findOne( + { _agentId: agentId, _integrationId: integration._id, _environmentId: environmentId, _organizationId: organizationId }, + ['_id'], + { session } + ); + + if (existingLink) { + throw new ConflictException('This integration is already linked to the agent.'); + } + + const link = await this.agentIntegrationRepository.create( + { _agentId: agentId, _integrationId: integration._id, _environmentId: environmentId, _organizationId: organizationId }, + { session } + ); + + return toAgentIntegrationResponse(link, integration); + } + + private async enforceEmailTier(organizationId: string): Promise { + const organization = await this.organizationRepository.findById(organizationId); + const tier = organization?.apiServiceLevel ?? ApiServiceLevelEnum.FREE; + const allowed = getFeatureForTierAsBoolean(FeatureNameEnum.AGENT_EMAIL_INTEGRATION, tier); + + if (!allowed) { + throw new HttpException('Payment Required', HttpStatus.PAYMENT_REQUIRED); + } + } +} diff --git a/apps/api/src/app/agents/usecases/index.ts b/apps/api/src/app/agents/usecases/index.ts index 9b3eb5ee09c..9110c28ebae 100644 --- a/apps/api/src/app/agents/usecases/index.ts +++ b/apps/api/src/app/agents/usecases/index.ts @@ -1,6 +1,8 @@ import { AddAgentIntegration } from './add-agent-integration/add-agent-integration.usecase'; +import { CleanupNovuEmail } from './cleanup-novu-email/cleanup-novu-email.usecase'; import { CreateAgent } from './create-agent/create-agent.usecase'; import { DeleteAgent } from './delete-agent/delete-agent.usecase'; +import { FindOrCreateNovuEmail } from './find-or-create-novu-email/find-or-create-novu-email.usecase'; import { GetAgent } from './get-agent/get-agent.usecase'; import { HandleAgentReply } from './handle-agent-reply/handle-agent-reply.usecase'; import { ListAgentEmoji } from './list-agent-emoji/list-agent-emoji.usecase'; @@ -18,6 +20,8 @@ export const USE_CASES = [ UpdateAgent, DeleteAgent, AddAgentIntegration, + CleanupNovuEmail, + FindOrCreateNovuEmail, ListAgentEmoji, ListAgentIntegrations, UpdateAgentIntegration, diff --git a/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts b/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts index 7a4852cbe32..ad4fcc8dd9f 100644 --- a/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts +++ b/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts @@ -1,13 +1,15 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { AgentIntegrationRepository, AgentRepository } from '@novu/dal'; +import { CleanupNovuEmail } from '../cleanup-novu-email/cleanup-novu-email.usecase'; import { RemoveAgentIntegrationCommand } from './remove-agent-integration.command'; @Injectable() export class RemoveAgentIntegration { constructor( private readonly agentRepository: AgentRepository, - private readonly agentIntegrationRepository: AgentIntegrationRepository + private readonly agentIntegrationRepository: AgentIntegrationRepository, + private readonly cleanupNovuEmail: CleanupNovuEmail ) {} async execute(command: RemoveAgentIntegrationCommand): Promise { @@ -24,17 +26,30 @@ export class RemoveAgentIntegration { throw new NotFoundException(`Agent with identifier "${command.agentIdentifier}" was not found.`); } - const deleted = await this.agentIntegrationRepository.findOneAndDelete({ - _id: command.agentIntegrationId, - _agentId: agent._id, - _environmentId: command.environmentId, - _organizationId: command.organizationId, - }); + await this.agentIntegrationRepository.withTransaction(async (session) => { + const deleted = await this.agentIntegrationRepository.findOneAndDelete( + { + _id: command.agentIntegrationId, + _agentId: agent._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + { session } + ); + + if (!deleted) { + throw new NotFoundException( + `Agent-integration link "${command.agentIntegrationId}" was not found for this agent.` + ); + } - if (!deleted) { - throw new NotFoundException( - `Agent-integration link "${command.agentIntegrationId}" was not found for this agent.` + await this.cleanupNovuEmail.cleanupForIntegration( + agent._id, + deleted._integrationId, + command.environmentId, + command.organizationId, + session ); - } + }); } } diff --git a/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.command.ts b/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.command.ts index dbac63741b7..b72bafdc59c 100644 --- a/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.command.ts +++ b/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.command.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; @@ -6,4 +6,8 @@ export class SendAgentTestEmailCommand extends EnvironmentWithUserCommand { @IsString() @IsNotEmpty() agentIdentifier: string; + + @IsEmail() + @IsNotEmpty() + targetAddress: string; } diff --git a/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts b/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts index 8afa142dcb3..49b6878928c 100644 --- a/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts +++ b/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts @@ -5,14 +5,8 @@ import { ChannelTypeEnum, EmailProviderIdEnum, IEmailOptions } from '@novu/share import { SendAgentTestEmailCommand } from './send-agent-test-email.command'; -const CATCH_ALL_ADDRESS = '*'; - function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } @Injectable() @@ -61,18 +55,6 @@ export class SendAgentTestEmail { throw new BadRequestException('No Novu Email integration found for this agent.'); } - const inboundAddress = emailIntegration.credentials?.inboundAddress as string | undefined; - const inboundDomain = emailIntegration.credentials?.inboundDomain as string | undefined; - - if (!inboundAddress || !inboundDomain) { - throw new BadRequestException('Inbound address is not configured. Set the address and domain first.'); - } - - // Wildcard catch-all routes cannot be emailed directly — use a synthetic local part - // that will still match the catch-all route in DomainRouteStrategy - const effectiveLocalPart = - inboundAddress === CATCH_ALL_ADDRESS ? `novu-test-${agent._id.slice(-8)}` : inboundAddress; - const to = `${effectiveLocalPart}@${inboundDomain}`; const outboundIntegrationId = emailIntegration.credentials?.outboundIntegrationId as string | undefined; const senderIntegration = await this.findSenderIntegration( @@ -85,7 +67,7 @@ export class SendAgentTestEmail { const escapedName = escapeHtml(agent.name); const mailOptions: IEmailOptions = { - to: [to], + to: [command.targetAddress], subject: `Test email for agent "${agent.name}"`, html: [ '
', diff --git a/apps/api/src/app/domains/usecases/update-domain/update-domain.usecase.ts b/apps/api/src/app/domains/usecases/update-domain/update-domain.usecase.ts index 8f45430c043..d9852c4b8c2 100644 --- a/apps/api/src/app/domains/usecases/update-domain/update-domain.usecase.ts +++ b/apps/api/src/app/domains/usecases/update-domain/update-domain.usecase.ts @@ -1,5 +1,5 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { DomainRepository } from '@novu/dal'; +import { AgentRepository, DomainRepository } from '@novu/dal'; import { DomainRouteTypeEnum } from '@novu/shared'; import { DomainResponseDto } from '../../dtos/domain-response.dto'; @@ -9,7 +9,10 @@ import { UpdateDomainCommand } from './update-domain.command'; @Injectable() export class UpdateDomain { - constructor(private readonly domainRepository: DomainRepository) {} + constructor( + private readonly domainRepository: DomainRepository, + private readonly agentRepository: AgentRepository + ) {} async execute(command: UpdateDomainCommand): Promise { const domain = await this.domainRepository.findOneByIdAndEnvironment( @@ -38,6 +41,8 @@ export class UpdateDomain { seen.add(key); } + await this.validateAgentDestinations(command); + const updated = await this.domainRepository.findOneAndUpdate( { _id: command.domainId, @@ -63,4 +68,30 @@ export class UpdateDomain { expectedDnsRecords: buildExpectedDnsRecords(domain.name), }; } + + private async validateAgentDestinations(command: UpdateDomainCommand): Promise { + const agentDestinations = [ + ...new Set( + command.routes!.filter((r) => r.type === DomainRouteTypeEnum.AGENT && r.destination).map((r) => r.destination!) + ), + ]; + + if (agentDestinations.length === 0) return; + + const found = await this.agentRepository.find( + { + _id: { $in: agentDestinations }, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + const foundIds = new Set(found.map((a) => a._id)); + const missing = agentDestinations.filter((id) => !foundIds.has(id)); + + if (missing.length > 0) { + throw new NotFoundException(`Agent(s) ${missing.join(', ')} referenced in route destinations do not exist.`); + } + } } diff --git a/apps/dashboard/src/api/agents.ts b/apps/dashboard/src/api/agents.ts index 3deffeba21b..156992b7ddf 100644 --- a/apps/dashboard/src/api/agents.ts +++ b/apps/dashboard/src/api/agents.ts @@ -230,7 +230,8 @@ export function listAgentIntegrations(params: ListAgentIntegrationsParams): Prom } export type AddAgentIntegrationBody = { - integrationIdentifier: string; + integrationIdentifier?: string; + providerId?: string; }; type AgentIntegrationLinkEnvelope = { data: AgentIntegrationLink }; @@ -260,9 +261,13 @@ export function removeAgentIntegration( export async function sendAgentTestEmail( environment: IEnvironment, - agentIdentifier: string + agentIdentifier: string, + targetAddress: string ): Promise<{ success: boolean }> { - return post<{ success: boolean }>(`/agents/${encodeURIComponent(agentIdentifier)}/test-email`, { environment }); + return post<{ success: boolean }>(`/agents/${encodeURIComponent(agentIdentifier)}/test-email`, { + environment, + body: { targetAddress }, + }); } export type AgentEmojiEntry = { diff --git a/apps/dashboard/src/components/agents/email-setup-guide.tsx b/apps/dashboard/src/components/agents/email-setup-guide.tsx index 00b0631b5a8..2f10b2c7254 100644 --- a/apps/dashboard/src/components/agents/email-setup-guide.tsx +++ b/apps/dashboard/src/components/agents/email-setup-guide.tsx @@ -1,36 +1,19 @@ -import { - ChannelTypeEnum, - DomainStatusEnum, - emailProviders as emailProviderConfigs, - EmailProviderIdEnum, - type IIntegration, -} from '@novu/shared'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { EmailProviderIdEnum } from '@novu/shared'; +import { useMutation } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; -import { RiAddLine, RiExpandUpDownLine, RiKey2Line, RiLoader4Line, RiMailSendLine, RiSearchLine } from 'react-icons/ri'; -import { Link, useNavigate } from 'react-router-dom'; +import { RiKey2Line, RiLoader4Line, RiMailSendLine } from 'react-icons/ri'; +import { Link } from 'react-router-dom'; import { type AgentResponse, sendAgentTestEmail } from '@/api/agents'; -import { type DomainResponse } from '@/api/domains'; -import { createIntegration } from '@/api/integrations'; -import { ProviderIcon } from '@/components/integrations/components/provider-icon'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/primitives/command'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover'; import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; import { requireEnvironment, useEnvironment } from '@/context/environment/hooks'; import { useFetchIntegrations } from '@/hooks/use-fetch-integrations'; -import { QueryKeys } from '@/utils/query-keys'; -import { buildRoute, ROUTES } from '@/utils/routes'; +import { ROUTES } from '@/utils/routes'; import { cn } from '@/utils/ui'; +import { InboundAddressConfig } from './inbound-address-config'; +import { OutboundProviderSelect } from './outbound-provider-select'; import { IntegrationCredentialsSidebar, ListeningStatus, SetupButton, SetupStep } from './setup-guide-primitives'; import { deriveStepStatus } from './setup-guide-step-utils'; -import { CATCH_ALL_ADDRESS, useEmailSetupCredentials } from './use-email-setup-credentials'; +import { useEmailSetupCredentials } from './use-email-setup-credentials'; export type EmailSetupGuideProps = { agent: AgentResponse; @@ -40,425 +23,6 @@ export type EmailSetupGuideProps = { embedded?: boolean; }; -type OutboundDropdownItem = { - providerId: string; - displayName: string; - integration?: IIntegration; - isDemo: boolean; -}; - -function DemoBadge() { - return ( - - Demo - - ); -} - -const OUTBOUND_EMAIL_PROVIDERS = emailProviderConfigs.filter( - (p) => p.id !== EmailProviderIdEnum.NovuAgent -); - -function buildOutboundItems(allIntegrations: IIntegration[] | undefined): OutboundDropdownItem[] { - const integrationsByProvider = new Map(); - for (const i of allIntegrations ?? []) { - if (i.channel !== ChannelTypeEnum.EMAIL) continue; - if (i.providerId === EmailProviderIdEnum.NovuAgent) continue; - const list = integrationsByProvider.get(i.providerId) ?? []; - list.push(i); - integrationsByProvider.set(i.providerId, list); - } - - const items: OutboundDropdownItem[] = []; - for (const cfg of OUTBOUND_EMAIL_PROVIDERS) { - const existing = integrationsByProvider.get(cfg.id); - const isDemo = cfg.id === EmailProviderIdEnum.Novu; - if (existing?.length) { - for (const integration of existing) { - items.push({ - providerId: cfg.id, - displayName: integration.name || cfg.displayName, - integration, - isDemo, - }); - } - } - if (!isDemo) { - items.push({ providerId: cfg.id, displayName: cfg.displayName, isDemo: false }); - } - } - - return items; -} - -function getItemKey(item: OutboundDropdownItem, index: number): string { - return item.integration ? `${item.providerId}-${item.integration._id}` : `${item.providerId}-new-${index}`; -} - -function OutboundProviderSelect({ - selectedId, - onSelect, -}: { - selectedId: string | undefined; - onSelect: (integrationId: string) => void; -}) { - const [open, setOpen] = useState(false); - const [pendingKey, setPendingKey] = useState(null); - const { integrations } = useFetchIntegrations(); - const { currentEnvironment } = useEnvironment(); - const queryClient = useQueryClient(); - - const items = useMemo(() => buildOutboundItems(integrations), [integrations]); - - const selected = useMemo( - () => (selectedId ? items.find((i) => i.integration?._id === selectedId) : undefined), - [items, selectedId] - ); - - const isBusy = pendingKey !== null; - - const createMutation = useMutation({ - mutationFn: async (vars: { providerId: string; name: string }) => { - const environment = requireEnvironment(currentEnvironment, 'No environment selected'); - const response = await createIntegration( - { - providerId: vars.providerId, - channel: ChannelTypeEnum.EMAIL, - credentials: {}, - configurations: {}, - name: vars.name, - active: true, - _environmentId: environment._id, - }, - environment - ); - - return response.data; - }, - }); - - async function handleSelect(item: OutboundDropdownItem, index: number) { - if (isBusy) return; - if (!currentEnvironment?._id) { - showErrorToast('No environment selected.', 'Cannot select provider'); - - return; - } - - const key = getItemKey(item, index); - setPendingKey(key); - - try { - if (item.integration) { - onSelect(item.integration._id); - } else { - const count = (integrations ?? []).filter((i) => i.providerId === item.providerId).length; - const created = await createMutation.mutateAsync({ - providerId: item.providerId, - name: `${item.displayName} ${count + 1}`, - }); - await queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment._id] }); - onSelect(created._id); - } - setOpen(false); - } catch (err) { - const message = err instanceof Error ? err.message : 'Could not select provider.'; - showErrorToast(message, 'Selection failed'); - } finally { - setPendingKey(null); - } - } - - return ( -
-
- Send emails via - -
- -
- - - - - - - -
- - -
- - No email providers found. - - {items.map((item, index) => { - const key = getItemKey(item, index); - const isRowPending = pendingKey === key; - - return ( - { - void handleSelect(item, index); - }} - className={cn( - 'flex items-center gap-2 rounded-md p-1', - item.integration?._id === selectedId && 'bg-bg-muted' - )} - > -
- - - {item.displayName} - - {item.isDemo && } -
- {isRowPending && ( - - )} - {!isRowPending && item.integration && ( - - {item.integration.identifier} - - )} - {!isRowPending && !item.integration && ( - - )} -
- ); - })} -
-
-
-
-
-
- - {selected?.isDemo && ( -

- This is a demo provider for development and testing. Switch to a production provider (e.g. Resend, SendGrid) - before going live. -

- )} -
- ); -} - -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -function InboundAddressConfig({ - localPart, - domainName, - domains, - replyFrom, - onLocalPartChange, - onLocalPartBlur, - onDomainChange, - onReplyFromChange, - onReplyFromBlur, -}: { - localPart: string; - domainName: string; - domains: DomainResponse[]; - replyFrom: string; - onLocalPartChange: (v: string) => void; - onLocalPartBlur: () => void; - onDomainChange: (v: string) => void; - onReplyFromChange: (v: string) => void; - onReplyFromBlur: () => void; -}) { - const [domainOpen, setDomainOpen] = useState(false); - const { currentEnvironment } = useEnvironment(); - const navigate = useNavigate(); - - const domainsPath = currentEnvironment?.slug - ? buildRoute(ROUTES.DOMAINS, { environmentSlug: currentEnvironment.slug }) - : ROUTES.INTEGRATIONS; - - const verifiedDomains = domains.filter( - (d) => d.status === DomainStatusEnum.VERIFIED && d.mxRecordConfigured - ); - - const isCatchAll = localPart === CATCH_ALL_ADDRESS; - const [replyFromError, setReplyFromError] = useState(false); - - function handleReplyFromBlur() { - if (!replyFrom) return; - const valid = EMAIL_PATTERN.test(replyFrom); - setReplyFromError(!valid); - if (valid) onReplyFromBlur(); - } - - return ( -
-
-
- onLocalPartChange(e.target.value)} - onBlur={onLocalPartBlur} - /> -
- @ - - - - - - -
- - -
- - No domains found. - - {verifiedDomains.map((d) => ( - { - onDomainChange(d.name); - setDomainOpen(false); - }} - className={cn( - 'flex items-center gap-2 rounded-md p-1', - d.name === domainName && 'bg-bg-muted' - )} - > - {d.name} - - ))} - { - setDomainOpen(false); - navigate(domainsPath); - }} - className="flex items-center gap-2 rounded-md p-1" - > - Add domain - - - - -
-
-
-
- {isCatchAll && ( - <> -

- Catch-all: every email sent to this domain routes to this agent. -

-
- Reply-from address -
- { - setReplyFromError(false); - onReplyFromChange(e.target.value); - }} - onBlur={handleReplyFromBlur} - /> -
- {replyFromError ? ( -

Enter a valid email address.

- ) : ( -

- The From address shown to recipients in outbound replies. -

- )} -
- - )} - -

- - Configure custom domains - - {' by adding them to Novu.'} -

-
- ); -} - export function EmailSetupGuide({ agent, integrationId, @@ -470,22 +34,7 @@ export function EmailSetupGuide({ const { integrations } = useFetchIntegrations(); const [isCredentialsSidebarOpen, setIsCredentialsSidebarOpen] = useState(false); - const [isCredentialsSaved, setIsCredentialsSaved] = useState(false); - - const testEmailMutation = useMutation({ - mutationFn: async () => { - const environment = requireEnvironment(currentEnvironment, 'No environment selected'); - - return sendAgentTestEmail(environment, agent.identifier); - }, - onSuccess: () => { - showSuccessToast('Test email sent to the configured inbound address.'); - }, - onError: (err) => { - const message = err instanceof Error ? err.message : 'Could not send test email.'; - showErrorToast(message, 'Test email failed'); - }, - }); + const [testConnected, setTestConnected] = useState(false); const emailIntegration = useMemo( () => integrations?.find((i) => i._id === integrationId && i.providerId === EmailProviderIdEnum.NovuAgent), @@ -494,39 +43,47 @@ export function EmailSetupGuide({ const { outboundId, - localPart, - domainName, - replyFrom, + configuredAddresses, domains, needsCredentialsStep, + hasOutboundCredentials, outboundProviderConfig, - setLocalPart, - setReplyFrom, onOutboundSelect, - onLocalPartBlur, - onDomainChange, - onReplyFromBlur, + addAddress, + removeAddress, } = useEmailSetupCredentials({ emailIntegration, integrations, agent }); - function handleOutboundSelect(id: string) { - setIsCredentialsSaved(false); - onOutboundSelect(id); - } + const testEmailMutation = useMutation({ + mutationFn: async () => { + const environment = requireEnvironment(currentEnvironment, 'No environment selected'); + const first = configuredAddresses[0]; + if (!first) throw new Error('No inbound address configured.'); + const targetAddress = first.address === '*' ? `test@${first.domain}` : `${first.address}@${first.domain}`; + await sendAgentTestEmail(environment, agent.identifier, targetAddress); + }, + onSuccess: () => showSuccessToast('Test email sent.'), + onError: (err) => { + const message = err instanceof Error ? err.message : 'Could not send test email.'; + showErrorToast(message, 'Test email failed'); + }, + }); + // Step indices — credentials step is conditionally inserted const base = stepOffset; - const credentialsStepIndex = base + 1; const inboundStepIndex = needsCredentialsStep ? base + 2 : base + 1; const testStepIndex = inboundStepIndex + 1; + const hasAddresses = configuredAddresses.length > 0; + const firstIncompleteStep = useMemo(() => { if (!outboundId) return base; - if (needsCredentialsStep && !isCredentialsSaved) return base + 1; - if (!localPart || !domainName) return inboundStepIndex; - if (localPart === CATCH_ALL_ADDRESS && !replyFrom) return inboundStepIndex; + if (needsCredentialsStep && !hasOutboundCredentials) return credentialsStepIndex; + if (!hasAddresses) return inboundStepIndex; + if (!testConnected) return testStepIndex; - return testStepIndex; - }, [base, outboundId, needsCredentialsStep, isCredentialsSaved, localPart, domainName, replyFrom, inboundStepIndex, testStepIndex]); + return testStepIndex + 1; + }, [base, outboundId, needsCredentialsStep, hasOutboundCredentials, credentialsStepIndex, hasAddresses, inboundStepIndex, testStepIndex, testConnected]); const stepsColumn = ( <> @@ -543,12 +100,7 @@ export function EmailSetupGuide({ } - rightContent={ - - } + rightContent={} /> {needsCredentialsStep && ( @@ -586,19 +138,14 @@ export function EmailSetupGuide({ index={inboundStepIndex} status={deriveStepStatus(inboundStepIndex, firstIncompleteStep)} sectionLabel="SETUP RECEIVING EMAILS" - title="Configure inbound address" - description="Inbound emails are received through Novu. Subscribers will send emails to this address to talk to your agent, and replies to workflow notifications also route here." + title="Configure inbound addresses" + description="Add one or more email addresses across different domains. Subscribers send emails to these addresses to talk to your agent." rightContent={ } /> @@ -607,7 +154,7 @@ export function EmailSetupGuide({ index={testStepIndex} status={deriveStepStatus(testStepIndex, firstIncompleteStep)} title="Test connection" - description="Send an email to the inbound address and verify it reaches your agent handler." + description="Send a test email to verify the full inbound pipeline reaches your agent." rightContent={ { + setTestConnected(true); + onStepsCompleted?.(); + }} connectedMessage="Your email integration is connected. This agent is ready to receive emails." - listeningMessage="Send an email to the configured inbound address to verify configuration." + listeningMessage="Send a test email to verify the inbound pipeline reaches your agent." /> ); - const credentialsSidebar = outboundId && needsCredentialsStep ? ( - setIsCredentialsSidebarOpen(false)} - onSaveSuccess={() => setIsCredentialsSaved(true)} - /> - ) : null; + const credentialsSidebar = + outboundId && needsCredentialsStep ? ( + setIsCredentialsSidebarOpen(false)} + /> + ) : null; if (embedded) { return ( diff --git a/apps/dashboard/src/components/agents/inbound-address-config.tsx b/apps/dashboard/src/components/agents/inbound-address-config.tsx new file mode 100644 index 00000000000..cb9738a02e4 --- /dev/null +++ b/apps/dashboard/src/components/agents/inbound-address-config.tsx @@ -0,0 +1,182 @@ +import { DomainStatusEnum } from '@novu/shared'; +import { useState } from 'react'; +import { RiAddLine, RiCloseLine, RiExpandUpDownLine, RiSearchLine } from 'react-icons/ri'; +import { Link, useNavigate } from 'react-router-dom'; +import { type DomainResponse } from '@/api/domains'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover'; +import { useEnvironment } from '@/context/environment/hooks'; +import { buildRoute, ROUTES } from '@/utils/routes'; +import { cn } from '@/utils/ui'; +import { type ConfiguredAddress } from './use-email-setup-credentials'; + +export function InboundAddressConfig({ + configuredAddresses, + domains, + onAddAddress, + onRemoveAddress, +}: { + configuredAddresses: ConfiguredAddress[]; + domains: DomainResponse[]; + onAddAddress: (address: string, domain: DomainResponse) => void; + onRemoveAddress: (address: string, domainId: string) => void; +}) { + const [localPart, setLocalPart] = useState(''); + const [domainName, setDomainName] = useState(''); + const [domainOpen, setDomainOpen] = useState(false); + const { currentEnvironment } = useEnvironment(); + const navigate = useNavigate(); + + const domainsPath = currentEnvironment?.slug + ? buildRoute(ROUTES.DOMAINS, { environmentSlug: currentEnvironment.slug }) + : ROUTES.INTEGRATIONS; + + const verifiedDomains = domains.filter((d) => d.status === DomainStatusEnum.VERIFIED && d.mxRecordConfigured); + + const LOCAL_PART_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+$/; + + function handleAdd() { + const trimmed = localPart.trim(); + if (!trimmed || !domainName) return; + if (!LOCAL_PART_RE.test(trimmed)) return; + const domain = domains.find((d) => d.name === domainName); + if (!domain) return; + if (configuredAddresses.some((a) => a.address === trimmed && a.domain === domainName)) return; + onAddAddress(trimmed, domain); + setLocalPart(''); + } + + return ( +
+ {configuredAddresses.length > 0 && ( +
+ {configuredAddresses.map((addr) => { + const full = `${addr.address}@${addr.domain}`; + + return ( +
+ + {addr.address === '*' ? `*@${addr.domain}` : full} + + +
+ ); + })} +
+ )} + +
+
+ setLocalPart(e.target.value.replace(/\s/g, ''))} + onKeyDown={(e) => { + if (e.key === 'Enter') handleAdd(); + }} + /> +
+ @ + + + + + + +
+ + +
+ + No domains found. + + {verifiedDomains.map((d) => ( + { + setDomainName(d.name); + setDomainOpen(false); + }} + className={cn('flex items-center gap-2 rounded-md p-1', d.name === domainName && 'bg-bg-muted')} + > + {d.name} + + ))} + { + setDomainOpen(false); + navigate(domainsPath); + }} + className="flex items-center gap-2 rounded-md p-1" + > + Add domain + + + + +
+
+
+ +
+ +

+ + Configure custom domains + + {' by adding them to Novu. You can add multiple addresses across different domains.'} +

+
+ ); +} diff --git a/apps/dashboard/src/components/agents/outbound-provider-select.tsx b/apps/dashboard/src/components/agents/outbound-provider-select.tsx new file mode 100644 index 00000000000..7a77a9d7d3a --- /dev/null +++ b/apps/dashboard/src/components/agents/outbound-provider-select.tsx @@ -0,0 +1,271 @@ +import { + ChannelTypeEnum, + EmailProviderIdEnum, + emailProviders as emailProviderConfigs, + type IIntegration, +} from '@novu/shared'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { RiAddLine, RiExpandUpDownLine, RiLoader4Line, RiSearchLine } from 'react-icons/ri'; +import { createIntegration } from '@/api/integrations'; +import { ProviderIcon } from '@/components/integrations/components/provider-icon'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover'; +import { showErrorToast } from '@/components/primitives/sonner-helpers'; +import { requireEnvironment, useEnvironment } from '@/context/environment/hooks'; +import { useFetchIntegrations } from '@/hooks/use-fetch-integrations'; +import { QueryKeys } from '@/utils/query-keys'; +import { cn } from '@/utils/ui'; + +type OutboundDropdownItem = { + providerId: string; + displayName: string; + integration?: IIntegration; + isDemo: boolean; +}; + +const OUTBOUND_EMAIL_PROVIDERS = emailProviderConfigs.filter((p) => p.id !== EmailProviderIdEnum.NovuAgent); + +function DemoBadge() { + return ( + + Demo + + ); +} + +function buildOutboundItems(allIntegrations: IIntegration[] | undefined): OutboundDropdownItem[] { + const integrationsByProvider = new Map(); + for (const i of allIntegrations ?? []) { + if (i.channel !== ChannelTypeEnum.EMAIL) continue; + if (i.providerId === EmailProviderIdEnum.NovuAgent) continue; + const list = integrationsByProvider.get(i.providerId) ?? []; + list.push(i); + integrationsByProvider.set(i.providerId, list); + } + + const items: OutboundDropdownItem[] = []; + for (const cfg of OUTBOUND_EMAIL_PROVIDERS) { + const existing = integrationsByProvider.get(cfg.id); + const isDemo = cfg.id === EmailProviderIdEnum.Novu; + if (existing?.length) { + for (const integration of existing) { + items.push({ + providerId: cfg.id, + displayName: integration.name || cfg.displayName, + integration, + isDemo, + }); + } + } + if (!isDemo) { + items.push({ providerId: cfg.id, displayName: cfg.displayName, isDemo: false }); + } + } + + return items; +} + +function getItemKey(item: OutboundDropdownItem, index: number): string { + return item.integration ? `${item.providerId}-${item.integration._id}` : `${item.providerId}-new-${index}`; +} + +export function OutboundProviderSelect({ + selectedId, + onSelect, +}: { + selectedId: string | undefined; + onSelect: (integrationId: string) => void; +}) { + const [open, setOpen] = useState(false); + const [pendingKey, setPendingKey] = useState(null); + const { integrations } = useFetchIntegrations(); + const { currentEnvironment } = useEnvironment(); + const queryClient = useQueryClient(); + + const items = useMemo(() => buildOutboundItems(integrations), [integrations]); + + const selected = useMemo( + () => (selectedId ? items.find((i) => i.integration?._id === selectedId) : undefined), + [items, selectedId] + ); + + const isBusy = pendingKey !== null; + + const createMutation = useMutation({ + mutationFn: async (vars: { providerId: string; name: string }) => { + const environment = requireEnvironment(currentEnvironment, 'No environment selected'); + const response = await createIntegration( + { + providerId: vars.providerId, + channel: ChannelTypeEnum.EMAIL, + credentials: {}, + configurations: {}, + name: vars.name, + active: true, + _environmentId: environment._id, + }, + environment + ); + + return response.data; + }, + }); + + async function handleSelect(item: OutboundDropdownItem, index: number) { + if (isBusy) return; + if (!currentEnvironment?._id) { + showErrorToast('No environment selected.', 'Cannot select provider'); + + return; + } + + const key = getItemKey(item, index); + setPendingKey(key); + + try { + if (item.integration) { + onSelect(item.integration._id); + } else { + const existingNames = new Set( + (integrations ?? []).filter((i) => i.providerId === item.providerId).map((i) => i.name) + ); + let suffix = existingNames.size + 1; + while (existingNames.has(`${item.displayName} ${suffix}`)) suffix += 1; + const created = await createMutation.mutateAsync({ + providerId: item.providerId, + name: `${item.displayName} ${suffix}`, + }); + await queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id] }); + onSelect(created._id); + } + setOpen(false); + } catch (err) { + const message = err instanceof Error ? err.message : 'Could not select provider.'; + showErrorToast(message, 'Selection failed'); + } finally { + setPendingKey(null); + } + } + + return ( +
+
+ Send emails via + +
+ +
+ + + + + + + +
+ + +
+ + No email providers found. + + {items.map((item, index) => { + const key = getItemKey(item, index); + const isRowPending = pendingKey === key; + + return ( + { + void handleSelect(item, index); + }} + className={cn( + 'flex items-center gap-2 rounded-md p-1', + item.integration?._id === selectedId && 'bg-bg-muted' + )} + > +
+ + + {item.displayName} + + {item.isDemo && } +
+ {isRowPending && ( + + )} + {!isRowPending && item.integration && ( + + {item.integration.identifier} + + )} + {!isRowPending && !item.integration && } +
+ ); + })} +
+
+
+
+
+
+ + {selected?.isDemo && ( +

+ This is a demo provider for development and testing. Switch to a production provider (e.g. Resend, SendGrid) + before going live. +

+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/agents/provider-dropdown.tsx b/apps/dashboard/src/components/agents/provider-dropdown.tsx index 1af5b76bac7..65cd6f5dfe8 100644 --- a/apps/dashboard/src/components/agents/provider-dropdown.tsx +++ b/apps/dashboard/src/components/agents/provider-dropdown.tsx @@ -8,7 +8,14 @@ import { } from '@novu/shared'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; -import { RiAddLine, RiExpandUpDownLine, RiExternalLinkLine, RiLoader4Line, RiLockStarLine, RiSearchLine } from 'react-icons/ri'; +import { + RiAddLine, + RiExpandUpDownLine, + RiExternalLinkLine, + RiLoader4Line, + RiLockStarLine, + RiSearchLine, +} from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { addAgentIntegration, getAgentDetailQueryKey, getAgentIntegrationsQueryKey } from '@/api/agents'; import { NovuApiError } from '@/api/api.client'; @@ -31,8 +38,8 @@ import { useFetchIntegrations } from '@/hooks/use-fetch-integrations'; import { useIsAgentEmailAvailable } from '@/hooks/use-is-agent-email-available'; import { QueryKeys } from '@/utils/query-keys'; import { ROUTES } from '@/utils/routes'; -import { openInNewTab } from '@/utils/url'; import { cn } from '@/utils/ui'; +import { openInNewTab } from '@/utils/url'; type DropdownItem = { providerId: string; @@ -86,6 +93,18 @@ function buildDropdownItems( const existing = integrationsByProvider.get(cp.providerId); + if (cp.providerId === EmailProviderIdEnum.NovuAgent) { + // NovuAgent is 1:1 with the agent — never list existing integrations + // (they belong to other agents). Always offer a single "create new" row. + supported.push({ + providerId: cp.providerId, + displayName: providerConfig?.displayName || cp.displayName, + comingSoon: false, + requiresBusinessTier: cp.requiresBusinessTier ?? false, + }); + continue; + } + if (existing?.length) { for (const integration of existing) { supported.push({ @@ -98,15 +117,12 @@ function buildDropdownItems( } } - const isSingleton = cp.providerId === EmailProviderIdEnum.NovuAgent; - if (!(isSingleton && existing?.length)) { - supported.push({ - providerId: cp.providerId, - displayName: providerConfig?.displayName || cp.displayName, - comingSoon: false, - requiresBusinessTier: cp.requiresBusinessTier ?? false, - }); - } + supported.push({ + providerId: cp.providerId, + displayName: providerConfig?.displayName || cp.displayName, + comingSoon: false, + requiresBusinessTier: cp.requiresBusinessTier ?? false, + }); } return { supported, comingSoon }; @@ -150,34 +166,47 @@ export function ProviderDropdown({ [integrations] ); - const hasLinkedNovuAgent = useMemo(() => { - if (!linkedIntegrationIds?.size || !integrations?.length) return false; - - return integrations.some( - (i) => i.providerId === EmailProviderIdEnum.NovuAgent && linkedIntegrationIds.has(i._id) - ); - }, [integrations, linkedIntegrationIds]); - const supported = useMemo(() => { let items = allSupported; - if (excludeLinked && linkedIntegrationIds?.size) { - items = items.filter((item) => !item.integration || !linkedIntegrationIds.has(item.integration._id)); + const linkedNovuAgent = integrations?.find( + (i) => i.providerId === EmailProviderIdEnum.NovuAgent && linkedIntegrationIds?.has(i._id) + ); + if (linkedNovuAgent) { + const cfg = novuProviders.find((p) => p.id === linkedNovuAgent.providerId); + items = items.map((item) => + item.providerId === EmailProviderIdEnum.NovuAgent && !item.integration + ? { + ...item, + displayName: linkedNovuAgent.name || cfg?.displayName || item.displayName, + integration: linkedNovuAgent as IIntegration, + } + : item + ); } - if (hasLinkedNovuAgent) { - items = items.filter((item) => item.providerId !== EmailProviderIdEnum.NovuAgent); + if (excludeLinked && linkedIntegrationIds?.size) { + items = items.filter((item) => !item.integration || !linkedIntegrationIds.has(item.integration._id)); } return items; - }, [allSupported, excludeLinked, linkedIntegrationIds, hasLinkedNovuAgent]); + }, [allSupported, excludeLinked, linkedIntegrationIds, integrations]); const selected = useMemo(() => { if (selectedIntegrationId) { const fromList = supported.find((item) => item.integration?._id === selectedIntegrationId); + if (fromList) return fromList; + + const fromAll = integrations?.find((i) => i._id === selectedIntegrationId); + if (fromAll) { + const cfg = novuProviders.find((p) => p.id === fromAll.providerId); - if (fromList) { - return fromList; + return { + providerId: fromAll.providerId, + displayName: fromAll.name || cfg?.displayName || fromAll.providerId, + comingSoon: false, + requiresBusinessTier: false, + }; } } @@ -194,15 +223,15 @@ export function ProviderDropdown({ } return undefined; - }, [selectedIntegrationId, fallbackProviderId, supported]); + }, [selectedIntegrationId, fallbackProviderId, supported, integrations]); const isBusy = pendingItemKey !== null; const addAgentIntegrationMutation = useMutation({ - mutationFn: async (integrationIdentifier: string) => { + mutationFn: async (body: { integrationIdentifier?: string; providerId?: string }) => { const environment = requireEnvironment(currentEnvironment, 'No environment selected'); - return addAgentIntegration(environment, agentIdentifier, { integrationIdentifier }); + return addAgentIntegration(environment, agentIdentifier, body); }, }); @@ -265,12 +294,17 @@ export function ProviderDropdown({ } try { - if (item.integration) { + if (item.providerId === EmailProviderIdEnum.NovuAgent) { + const link = await addAgentIntegrationMutation.mutateAsync({ providerId: item.providerId }); + showSuccessToast('Integration linked', `${link.integration.name ?? 'Novu Email'} was added to this agent.`); + onSelect(item.providerId, link.integration as unknown as IIntegration); + setOpen(false); + } else if (item.integration) { const alreadyLinked = linkedIntegrationIds?.has(item.integration._id); if (!alreadyLinked) { try { - await addAgentIntegrationMutation.mutateAsync(item.integration.identifier); + await addAgentIntegrationMutation.mutateAsync({ integrationIdentifier: item.integration.identifier }); showSuccessToast('Integration linked', `${item.integration.name} was added to this agent.`); } catch (linkErr) { if (!isAlreadyLinkedToAgentConflict(linkErr)) { @@ -282,15 +316,14 @@ export function ProviderDropdown({ onSelect(item.providerId, item.integration); setOpen(false); } else { - const isSingletonProvider = item.providerId === EmailProviderIdEnum.NovuAgent; const sameProviderCount = (integrations ?? []).filter((i) => i.providerId === item.providerId).length; - const uniqueName = isSingletonProvider ? item.displayName : `${item.displayName} ${sameProviderCount + 1}`; + const uniqueName = sameProviderCount > 0 ? `${item.displayName} ${sameProviderCount + 1}` : item.displayName; const created = await createIntegrationMutation.mutateAsync({ providerId: item.providerId, name: uniqueName, }); - await addAgentIntegrationMutation.mutateAsync(created.identifier); + await addAgentIntegrationMutation.mutateAsync({ integrationIdentifier: created.identifier }); showSuccessToast('Integration linked', `${created.name} was added to this agent.`); onSelect(item.providerId, created); setOpen(false); @@ -399,11 +432,14 @@ export function ProviderDropdown({
)} - {!isRowPending && !isLocked && item.integration && item.providerId !== EmailProviderIdEnum.NovuAgent && ( - - {item.integration.identifier} - - )} + {!isRowPending && + !isLocked && + item.integration && + item.providerId !== EmailProviderIdEnum.NovuAgent && ( + + {item.integration.identifier} + + )} {!isRowPending && !isLocked && !item.integration && ( )} @@ -426,9 +462,7 @@ export function ProviderDropdown({ > {isLocked ? ( - - {rowContent} - + {rowContent} - ); } diff --git a/apps/dashboard/src/components/agents/setup-guide-primitives.tsx b/apps/dashboard/src/components/agents/setup-guide-primitives.tsx index f1c13c4ddce..b4d0c394975 100644 --- a/apps/dashboard/src/components/agents/setup-guide-primitives.tsx +++ b/apps/dashboard/src/components/agents/setup-guide-primitives.tsx @@ -283,7 +283,7 @@ export function IntegrationCredentialsSidebar({ integrationId: string; isOpen: boolean; onClose: () => void; - onSaveSuccess: () => void; + onSaveSuccess?: () => void; }) { const { integrations } = useFetchIntegrations(); const { mutateAsync: updateIntegration, isPending: isUpdating } = useUpdateIntegration(); @@ -310,7 +310,7 @@ export function IntegrationCredentialsSidebar({ }); showSuccessToast('Integration updated successfully'); - onSaveSuccess(); + onSaveSuccess?.(); onClose(); } catch (error: unknown) { handleIntegrationError(error, 'update'); diff --git a/apps/dashboard/src/components/agents/use-email-setup-credentials.ts b/apps/dashboard/src/components/agents/use-email-setup-credentials.ts index ac2d37de06d..03629269711 100644 --- a/apps/dashboard/src/components/agents/use-email-setup-credentials.ts +++ b/apps/dashboard/src/components/agents/use-email-setup-credentials.ts @@ -5,7 +5,7 @@ import { type IIntegration, } from '@novu/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type AgentResponse } from '@/api/agents'; import { type DomainResponse, type UpdateDomainBody, fetchDomains, updateDomain } from '@/api/domains'; import { showErrorToast } from '@/components/primitives/sonner-helpers'; @@ -13,12 +13,11 @@ import { requireEnvironment, useEnvironment } from '@/context/environment/hooks' import { useUpdateIntegration } from '@/hooks/use-update-integration'; import { QueryKeys } from '@/utils/query-keys'; -export const CATCH_ALL_ADDRESS = '*'; - -function deriveReplyDomain(localPart: string, domain: string): string | undefined { - if (!localPart || !domain || localPart === CATCH_ALL_ADDRESS) return undefined; - return `${localPart}@${domain}`; -} +export type ConfiguredAddress = { + address: string; + domain: string; + domainId: string; +}; export function useEmailSetupCredentials({ emailIntegration, @@ -33,50 +32,28 @@ export function useEmailSetupCredentials({ const { mutateAsync: updateIntegration } = useUpdateIntegration(); const queryClient = useQueryClient(); - // domainId is a mutation variable (not a closure) so upsertAgentRoute can be - // called with any domain at event time — useUpdateDomain can't be used here - // because it bakes a single domainId into its mutationFn closure at call time. - const { mutate: updateDomainRoutes } = useMutation({ - // biome-ignore lint/style/noNonNullAssertion: currentEnvironment is guaranteed non-null when triggered from a user interaction - mutationFn: ({ domainId, body }: { domainId: string; body: UpdateDomainBody }) => - updateDomain(domainId, body, currentEnvironment!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchDomains, currentEnvironment?._id] }); - }, - onError: () => { - showErrorToast('Could not create inbound route on the domain.', 'Route creation failed'); - }, - }); - const [outboundId, setOutboundId] = useState(''); - const [localPart, setLocalPart] = useState(''); - const [domainName, setDomainName] = useState(''); - const [replyFrom, setReplyFrom] = useState(''); - // Write-through cache keeps the full credentials snapshot between queued saves const serverCredentials = emailIntegration?.credentials ?? {}; const credentialsRef = useRef>(serverCredentials as Record); + const pendingKeysRef = useRef>(new Set()); useEffect(() => { - credentialsRef.current = { ...credentialsRef.current, ...serverCredentials }; + const merged = { ...serverCredentials } as Record; + for (const [key, value] of Object.entries(credentialsRef.current)) { + if (pendingKeysRef.current.has(key)) { + merged[key] = value; + } + } + credentialsRef.current = merged; }, [emailIntegration]); - // Initialize from server state once per unique integration ID. - // Keying off _id (not the object) ensures re-hydration when the user switches - // between integrations without the component remounting. - const initializedForId = useRef(undefined); + const hasInitializedFromServer = useRef(false); useEffect(() => { - if (!emailIntegration || initializedForId.current === emailIntegration._id) return; - initializedForId.current = emailIntegration._id; + if (!emailIntegration || hasInitializedFromServer.current) return; + hasInitializedFromServer.current = true; const creds = emailIntegration.credentials ?? {}; if (creds.outboundIntegrationId) setOutboundId(creds.outboundIntegrationId as string); - if (creds.inboundAddress) setLocalPart(creds.inboundAddress as string); - if (creds.inboundDomain) setDomainName(creds.inboundDomain as string); - // Catch-all replyDomain can't be auto-computed, so restore it explicitly - if (creds.inboundAddress === CATCH_ALL_ADDRESS && creds.replyDomain) { - setReplyFrom(creds.replyDomain as string); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [emailIntegration?._id]); + }, [emailIntegration]); const domainsQuery = useQuery({ queryKey: [QueryKeys.fetchDomains, currentEnvironment?._id], @@ -85,24 +62,54 @@ export function useEmailSetupCredentials({ }); const domains = domainsQuery.data ?? []; + const configuredAddresses = useMemo(() => { + if (!agent._id) return []; + + const result: ConfiguredAddress[] = []; + for (const domain of domains) { + for (const route of domain.routes ?? []) { + if (route.type === DomainRouteTypeEnum.AGENT && route.destination === agent._id) { + result.push({ address: route.address, domain: domain.name, domainId: domain._id }); + } + } + } + + return result; + }, [domains, agent._id]); + const outboundIntegration = useMemo( () => (outboundId ? integrations?.find((i) => i._id === outboundId) : undefined), [integrations, outboundId] ); const isOutboundDemo = outboundIntegration?.providerId === EmailProviderIdEnum.Novu; const needsCredentialsStep = Boolean(outboundIntegration) && !isOutboundDemo; + const hasOutboundCredentials = useMemo(() => { + if (!outboundIntegration) return false; + const providerConfig = emailProviderConfigs.find((p) => p.id === outboundIntegration.providerId); + if (!providerConfig) return false; + const requiredKeys = providerConfig.credentials.filter((c) => c.required).map((c) => c.key); + if (requiredKeys.length === 0) return true; + const creds = (outboundIntegration.credentials ?? {}) as Record; + + return requiredKeys.every((key) => { + const val = creds[key]; + + return val !== undefined && val !== null && val !== ''; + }); + }, [outboundIntegration]); const outboundProviderConfig = useMemo( () => (outboundIntegration ? emailProviderConfigs.find((p) => p.id === outboundIntegration.providerId) : undefined), [outboundIntegration] ); - // Serialized save queue prevents out-of-order writes when multiple fields change quickly const saveQueueRef = useRef>(Promise.resolve()); function saveCredentials(patch: Record) { if (!emailIntegration) return; credentialsRef.current = { ...credentialsRef.current, ...patch }; + for (const key of Object.keys(patch)) pendingKeysRef.current.add(key); const snapshot = { ...credentialsRef.current }; + const patchKeys = Object.keys(patch); saveQueueRef.current = saveQueueRef.current .then(() => updateIntegration({ @@ -118,81 +125,100 @@ export function useEmailSetupCredentials({ }, }) ) - .then(() => undefined) + .then(() => { + for (const key of patchKeys) pendingKeysRef.current.delete(key); + }) .catch((err: unknown) => { + for (const key of patchKeys) pendingKeysRef.current.delete(key); const message = err instanceof Error ? err.message : 'Could not save credentials.'; showErrorToast(message, 'Settings not saved'); }); } - function upsertAgentRoute(address: string, domain: DomainResponse) { - if (!currentEnvironment || !agent._id) return; - const existingRoutes = domain.routes ?? []; - if (existingRoutes.some((r) => r.address === address && r.type === DomainRouteTypeEnum.AGENT && r.destination === agent._id)) { - return; - } - const updatedRoutes = [ - // Remove same-address AGENT routes AND any orphaned routes from this agent - // (e.g. leftover 'wine-bot' route when the user switches to a different address) - ...existingRoutes.filter( - (r) => !(r.type === DomainRouteTypeEnum.AGENT && (r.address === address || r.destination === agent._id)) - ), - { address, type: DomainRouteTypeEnum.AGENT, destination: agent._id }, - ]; - updateDomainRoutes({ domainId: domain._id, body: { routes: updatedRoutes } }); - } + const { mutate: mutateDomainRoutes } = useMutation({ + mutationFn: ({ domainId, body }: { domainId: string; body: UpdateDomainBody }) => + updateDomain(domainId, body, requireEnvironment(currentEnvironment, 'No environment selected')), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchDomains, currentEnvironment?._id] }); + }, + }); + + const addAddress = useCallback( + (address: string, domain: DomainResponse) => { + if (!currentEnvironment || !agent._id) return; + const existingRoutes = domain.routes ?? []; + + const ownRoute = existingRoutes.find( + (r) => r.address === address && r.type === DomainRouteTypeEnum.AGENT && r.destination === agent._id + ); + if (ownRoute) return; + + const conflicting = existingRoutes.find( + (r) => r.address === address && r.type === DomainRouteTypeEnum.AGENT && r.destination !== agent._id + ); + if (conflicting) { + showErrorToast( + `"${address}@${domain.name}" is already routed to another agent. Each address can only route to one agent.`, + 'Address already in use' + ); + + return; + } + + mutateDomainRoutes( + { + domainId: domain._id, + body: { + routes: [...existingRoutes, { address, type: DomainRouteTypeEnum.AGENT, destination: agent._id }], + }, + }, + { + onError: (err) => { + const message = err instanceof Error ? err.message : 'Could not create inbound route on the domain.'; + showErrorToast(message, 'Route creation failed'); + }, + } + ); + }, + [currentEnvironment, agent._id, mutateDomainRoutes] + ); + + const removeAddress = useCallback( + (address: string, domainId: string) => { + if (!currentEnvironment || !agent._id) return; + const domain = domains.find((d) => d._id === domainId); + if (!domain) return; + const updatedRoutes = (domain.routes ?? []).filter( + (r) => !(r.address === address && r.type === DomainRouteTypeEnum.AGENT && r.destination === agent._id) + ); + mutateDomainRoutes( + { domainId: domain._id, body: { routes: updatedRoutes } }, + { + onError: () => { + showErrorToast('Could not remove inbound route from the domain.', 'Route removal failed'); + }, + } + ); + }, + [currentEnvironment, agent._id, domains, mutateDomainRoutes] + ); function onOutboundSelect(id: string) { setOutboundId(id); saveCredentials({ outboundIntegrationId: id }); } - function onLocalPartBlur() { - if (!localPart || localPart === credentialsRef.current.inboundAddress) return; - const isCatchAll = localPart === CATCH_ALL_ADDRESS; - if (!isCatchAll) setReplyFrom(''); - const replyDomain = deriveReplyDomain(localPart, domainName); - const patch: Record = { inboundAddress: localPart }; - if (replyDomain) patch.replyDomain = replyDomain; - // Explicitly clear any previously auto-computed replyDomain when entering catch-all mode - else if (isCatchAll) patch.replyDomain = ''; - saveCredentials(patch); - if (domainName) { - const domain = domains.find((d) => d.name === domainName); - if (domain) upsertAgentRoute(localPart, domain); - } - } - - function onDomainChange(name: string) { - setDomainName(name); - const replyDomain = deriveReplyDomain(localPart, name); - saveCredentials({ inboundDomain: name, ...(replyDomain ? { replyDomain } : {}) }); - if (localPart) { - const domain = domains.find((d) => d.name === name); - if (domain) upsertAgentRoute(localPart, domain); - } - } - - function onReplyFromBlur() { - if (!replyFrom || replyFrom === credentialsRef.current.replyDomain) return; - saveCredentials({ replyDomain: replyFrom }); - } - return { outboundId, - localPart, - domainName, - replyFrom, + configuredAddresses, domains, outboundIntegration, isOutboundDemo, needsCredentialsStep, + hasOutboundCredentials, outboundProviderConfig, - setLocalPart, - setReplyFrom, onOutboundSelect, - onLocalPartBlur, - onDomainChange, - onReplyFromBlur, + addAddress, + removeAddress, }; } diff --git a/libs/application-generic/src/dtos/credentials.dto.ts b/libs/application-generic/src/dtos/credentials.dto.ts index e6368f9a4f4..e1b8e670a69 100644 --- a/libs/application-generic/src/dtos/credentials.dto.ts +++ b/libs/application-generic/src/dtos/credentials.dto.ts @@ -257,23 +257,8 @@ export class CredentialsDto implements ICredentials { @IsString() signingSecret?: string; - @ApiPropertyOptional() - @IsOptional() - @IsString() - replyDomain?: string; - @ApiPropertyOptional() @IsOptional() @IsString() outboundIntegrationId?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - inboundAddress?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - inboundDomain?: string; } diff --git a/libs/dal/src/repositories/base-repository-v2.ts b/libs/dal/src/repositories/base-repository-v2.ts index 94fa416a9d5..08b0886c8e8 100644 --- a/libs/dal/src/repositories/base-repository-v2.ts +++ b/libs/dal/src/repositories/base-repository-v2.ts @@ -393,8 +393,15 @@ export class BaseRepositoryV2 { return this.mapProjectedEntity(data) as T_MappedEntity; } - async findOneAndDelete(query: FilterQuery & T_Enforcement): Promise { - const data = await this.MongooseModel.findOneAndDelete(query).lean(); + async findOneAndDelete( + query: FilterQuery & T_Enforcement, + options: { session?: ClientSession | null } = {} + ): Promise { + const { session } = options; + const builder = this.MongooseModel.findOneAndDelete(query); + if (session) builder.session(session); + + const data = await builder.lean(); if (!data) return null; return this.mapProjectedEntity(data) as T_MappedEntity; @@ -514,17 +521,14 @@ export class BaseRepositoryV2 { */ async withTransaction(fn: (session: ClientSession | null) => Promise) { const session = await this._model.db.startSession(); - let executed = false; try { return await session.withTransaction(async (txnSession) => { - executed = true; - return fn(txnSession); }); } catch (error) { const errorMessage = (error as Error)?.message || ''; - if (errorMessage === 'Transaction numbers are only allowed on a replica set member or mongos' && !executed) { + if (errorMessage.includes('Transaction numbers are only allowed on')) { return fn(null); } diff --git a/libs/dal/src/repositories/domain/domain.repository.ts b/libs/dal/src/repositories/domain/domain.repository.ts index 177d92d085b..e4cd56d9e6c 100644 --- a/libs/dal/src/repositories/domain/domain.repository.ts +++ b/libs/dal/src/repositories/domain/domain.repository.ts @@ -1,5 +1,5 @@ import { DirectionEnum } from '@novu/shared'; -import { FilterQuery } from 'mongoose'; +import { ClientSession, FilterQuery } from 'mongoose'; import type { EnforceEnvOrOrgIds } from '../../types'; import { SortOrder } from '../../types/sort-order'; @@ -55,6 +55,23 @@ export class DomainRepository extends BaseRepositoryV2 { + await this.update( + { _environmentId: environmentId, _organizationId: organizationId, 'routes.destination': destination }, + { $pull: { routes: { destination } } }, + { session: options.session } + ); + } + async findByEnvironment(environmentId: string, organizationId: string): Promise { return this.find( { diff --git a/libs/dal/src/repositories/integration/integration.repository.ts b/libs/dal/src/repositories/integration/integration.repository.ts index 6a1595105f2..198fc0fd337 100644 --- a/libs/dal/src/repositories/integration/integration.repository.ts +++ b/libs/dal/src/repositories/integration/integration.repository.ts @@ -1,5 +1,5 @@ import { NOVU_PROVIDERS } from '@novu/shared'; -import { FilterQuery } from 'mongoose'; +import { ClientSession, FilterQuery } from 'mongoose'; import { SoftDeleteModel } from 'mongoose-delete'; import { DalException } from '../../shared'; import type { EnforceEnvOrOrgIds, IDeleteResult } from '../../types'; @@ -20,7 +20,7 @@ export class IntegrationRepository extends BaseRepository { return super.find(query, select, options); } @@ -64,12 +64,15 @@ export class IntegrationRepository extends BaseRepository { - return await super.create(data); + async create(data: IntegrationQuery, options: { session?: ClientSession | null } = {}): Promise { + return await super.create(data, options); } - async delete(query: IntegrationQuery) { - return await this.integration.delete({ _id: query._id, _organizationId: query._organizationId }); + async delete(query: IntegrationQuery, options: { session?: ClientSession | null } = {}) { + const q = this.integration.delete({ _id: query._id, _organizationId: query._organizationId }); + if (options.session) q.session(options.session); + + return await q; } async deleteMany(query: IntegrationQuery): Promise { diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index a02b8ba980f..9aa99a5bef5 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -67,10 +67,7 @@ const integrationSchema = new Schema( servicePlanId: Schema.Types.String, tenantId: Schema.Types.String, signingSecret: Schema.Types.String, - replyDomain: Schema.Types.String, outboundIntegrationId: Schema.Types.String, - inboundAddress: Schema.Types.String, - inboundDomain: Schema.Types.String, AppIOBaseUrl: Schema.Types.String, AppIOSubscriptionId: Schema.Types.String, AppIOBearerToken: Schema.Types.String, diff --git a/packages/chat-adapter-email/src/adapter.ts b/packages/chat-adapter-email/src/adapter.ts index 3100aa53295..49fbe23225c 100644 --- a/packages/chat-adapter-email/src/adapter.ts +++ b/packages/chat-adapter-email/src/adapter.ts @@ -40,7 +40,7 @@ export class NovuEmailAdapterImpl implements Adapter { - return this.messageParser.parse(raw, this.config.fromAddress); + const agentAddress = raw.to[0] ?? ''; + + return this.messageParser.parse(raw, agentAddress); } // -- Outbound -- @@ -125,11 +131,16 @@ export class NovuEmailAdapterImpl implements Adapter` - : this.config.fromAddress; + const agentAddress = await this.threadResolver.getAgentAddress(threadId); + if (!agentAddress) { + throw new Error(`No agent address found for thread ${threadId} — cannot determine From address for reply`); + } + + const fromHeader = this.config.senderName + ? `${this.config.senderName} <${agentAddress}>` + : agentAddress; - const messageId = generateMessageId(this.config.fromAddress); + const messageId = generateMessageId(agentAddress); const replyHeaders = await this.threadResolver.getReplyHeaders(threadId); const storedSubject = await this.threadResolver.getSubject(threadId); const subject = storedSubject @@ -139,6 +150,7 @@ export class NovuEmailAdapterImpl implements Adapter(threadSubjectKey(threadId))) ?? undefined; } + async trackAgentAddress(threadId: string, address: string): Promise { + const state = this.getState(); + await state.set(agentAddressKey(threadId), address, STATE_TTL_MS); + } + + async getAgentAddress(threadId: string): Promise { + const state = this.getState(); + + return (await state.get(agentAddressKey(threadId))) ?? undefined; + } + /** * Extract candidate message IDs from In-Reply-To and References headers. * Handles both RFC 2822 whitespace-separated format and JSON-encoded arrays. diff --git a/packages/chat-adapter-email/src/types.ts b/packages/chat-adapter-email/src/types.ts index c8e701feded..0d9c1f9fa7f 100644 --- a/packages/chat-adapter-email/src/types.ts +++ b/packages/chat-adapter-email/src/types.ts @@ -1,14 +1,15 @@ import type { Adapter } from 'chat'; + export type { EmailWebhookPayload, NovuEmailAttachment } from '@novu/shared'; export interface NovuEmailAdapterConfig { - fromAddress: string; - fromName?: string; + senderName?: string; signingSecret: string; sendEmail: (params: SendEmailParams) => Promise<{ messageId: string }>; } export interface SendEmailParams { + from: string; to: string; subject: string; html: string; diff --git a/packages/shared/src/entities/integration/credential.interface.ts b/packages/shared/src/entities/integration/credential.interface.ts index c73046cd18c..c9c43718248 100644 --- a/packages/shared/src/entities/integration/credential.interface.ts +++ b/packages/shared/src/entities/integration/credential.interface.ts @@ -54,8 +54,5 @@ export interface ICredentials { servicePlanId?: string; tenantId?: string; signingSecret?: string; - replyDomain?: string; outboundIntegrationId?: string; - inboundAddress?: string; - inboundDomain?: string; } diff --git a/packages/shared/src/types/providers.ts b/packages/shared/src/types/providers.ts index d6389133bc1..0e5690d97c0 100644 --- a/packages/shared/src/types/providers.ts +++ b/packages/shared/src/types/providers.ts @@ -53,10 +53,7 @@ export enum CredentialsKeyEnum { ServicePlanId = 'servicePlanId', TenantId = 'tenantId', SigningSecret = 'signingSecret', - ReplyDomain = 'replyDomain', OutboundIntegrationId = 'outboundIntegrationId', - InboundAddress = 'inboundAddress', - InboundDomain = 'inboundDomain', } export type ConfigurationKey = keyof IConfigurations;