Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@novu/framework": "workspace:*",
"@novu/maily-render": "workspace:*",
"@novu/notifications": "workspace:*",
"@novu/chat-adapter-email": "workspace:*",
"@novu/shared": "workspace:*",
"@novu/stateless": "workspace:*",
"@novu/testing": "workspace:*",
Expand Down
30 changes: 29 additions & 1 deletion apps/api/src/app/agents/agents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import { ListAgentsCommand } from './usecases/list-agents/list-agents.command';
import { ListAgents } from './usecases/list-agents/list-agents.usecase';
import { RemoveAgentIntegrationCommand } from './usecases/remove-agent-integration/remove-agent-integration.command';
import { RemoveAgentIntegration } from './usecases/remove-agent-integration/remove-agent-integration.usecase';
import { SendAgentTestEmailCommand } from './usecases/send-agent-test-email/send-agent-test-email.command';
import { SendAgentTestEmail } from './usecases/send-agent-test-email/send-agent-test-email.usecase';
import { UpdateAgentCommand } from './usecases/update-agent/update-agent.command';
import { UpdateAgent } from './usecases/update-agent/update-agent.usecase';
import { UpdateAgentIntegrationCommand } from './usecases/update-agent-integration/update-agent-integration.command';
Expand All @@ -79,7 +81,8 @@ export class AgentsController {
private readonly listAgentIntegrationsUsecase: ListAgentIntegrations,
private readonly updateAgentIntegrationUsecase: UpdateAgentIntegration,
private readonly removeAgentIntegrationUsecase: RemoveAgentIntegration,
private readonly listAgentEmojiUsecase: ListAgentEmoji
private readonly listAgentEmojiUsecase: ListAgentEmoji,
private readonly sendAgentTestEmailUsecase: SendAgentTestEmail
) {}

@Get('/emoji')
Expand Down Expand Up @@ -257,6 +260,31 @@ export class AgentsController {
);
}

@Post('/:identifier/test-email')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Send a test email to the agent inbound address',
description:
'Sends a test email to the configured inbound address using the agent outbound provider (or the Novu demo integration as fallback). Used to verify the inbound email pipeline.',
})
@ApiNotFoundResponse({
description: 'The agent was not found.',
})
@RequirePermissions(PermissionsEnum.AGENT_WRITE)
sendAgentTestEmail(
@UserSession() user: UserSessionData,
@Param('identifier') identifier: string
): Promise<{ success: boolean }> {
return this.sendAgentTestEmailUsecase.execute(
SendAgentTestEmailCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
agentIdentifier: identifier,
})
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Put('/:identifier/bridge')
@ApiResponse(AgentResponseDto)
@ApiOperation({
Expand Down
100 changes: 98 additions & 2 deletions apps/api/src/app/agents/services/chat-sdk.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
import { CacheService, PinoLogger } from '@novu/application-generic';
import { CacheService, decryptCredentials, MailFactory, PinoLogger } from '@novu/application-generic';
import { IntegrationRepository } from '@novu/dal';
import type { SentMessageInfo } from '@novu/framework';
import { ChannelTypeEnum, EmailProviderIdEnum, type IEmailOptions } from '@novu/shared';
import type { AdapterPostableMessage, Chat, EmojiValue, Message, ReactionEvent, Thread } from 'chat';
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
import { LRUCache } from 'lru-cache';
Expand All @@ -12,6 +14,13 @@ import { sendWebResponse, toWebRequest } from '../utils/express-to-web-request';
import { AgentConfigResolver, ResolvedAgentConfig } from './agent-config-resolver.service';
import { AgentInboundHandler } from './agent-inbound-handler.service';

/** Ensure a Message-ID value is wrapped in RFC 5322 angle brackets. */
function wrapMsgId(id: string): string {
const trimmed = id.trim();

return trimmed.startsWith('<') && trimmed.endsWith('>') ? trimmed : `<${trimmed}>`;
}

/**
* ICredentials field mapping per platform adapter:
*
Expand Down Expand Up @@ -58,7 +67,8 @@ export class ChatSdkService implements OnModuleDestroy {
private readonly logger: PinoLogger,
private readonly cacheService: CacheService,
private readonly agentConfigResolver: AgentConfigResolver,
private readonly inboundHandler: AgentInboundHandler
private readonly inboundHandler: AgentInboundHandler,
private readonly integrationRepository: IntegrationRepository
) {
this.instances = new LRUCache<string, CachedChat>({
max: MAX_CACHED_INSTANCES,
Expand Down Expand Up @@ -296,9 +306,75 @@ export class ChatSdkService implements OnModuleDestroy {
token: c.token ?? null,
phoneNumberIdentification: c.phoneNumberIdentification ?? null,
connectionAccessToken: connectionAccessToken ?? null,
replyDomain: c.replyDomain ?? null,
outboundIntegrationId: c.outboundIntegrationId ?? null,
});
}

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 }> {
return async (params) => {
if (!outboundIntegrationId) {
throw new BadRequestException(
'Email agent integration requires an outbound email provider (outboundIntegrationId). ' +
'Configure one in the agent email setup.'
);
}

const integration = await this.integrationRepository.findOne({
_id: outboundIntegrationId,
_environmentId: config.environmentId,
_organizationId: config.organizationId,
channel: ChannelTypeEnum.EMAIL,
});

if (!integration) {
throw new BadRequestException(
`Outbound email integration ${outboundIntegrationId} not found or does not belong to this environment`
);
}

if (integration.providerId === EmailProviderIdEnum.NovuAgent) {
throw new BadRequestException(
`Integration ${outboundIntegrationId} is the inbound NovuAgent provider and cannot be used as an outbound sender`
);
}

if (!integration.active) {
throw new BadRequestException(
`Outbound email integration ${outboundIntegrationId} (${integration.providerId}) is inactive`
);
}

const decrypted = decryptCredentials(integration.credentials);
const mailFactory = new MailFactory();
const handler = mailFactory.getHandler(
{ ...integration, credentials: decrypted },
config.credentials.replyDomain
);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const mailOptions: IEmailOptions = {
to: [params.to],
subject: params.subject,
html: params.html,
text: params.text,
from: config.credentials.replyDomain,
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(' ') } : {}),
},
};

const result = await handler.send(mailOptions);

return { messageId: result?.id || params.messageId || '' };
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private async createChatInstance(
instanceKey: string,
platform: AgentPlatformEnum,
Expand Down Expand Up @@ -397,6 +473,26 @@ export class ChatSdkService implements OnModuleDestroy {
}),
};
}
case AgentPlatformEnum.EMAIL: {
const { replyDomain, senderName, outboundIntegrationId } = credentials;

if (!replyDomain || !credentials.secretKey) {
throw new BadRequestException(
'Email agent integration requires replyDomain and secretKey credentials'
);
}

const { createNovuEmailAdapter } = await esmImport('@novu/chat-adapter-email');

return {
email: createNovuEmailAdapter({
fromAddress: replyDomain,
fromName: senderName,
signingSecret: credentials.secretKey,
sendEmail: this.buildSendEmailCallback(config, outboundIntegrationId),
}),
};
}
default:
throw new BadRequestException(`Unsupported platform: ${platform}`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { randomBytes } from 'node:crypto';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { encryptSecret } from '@novu/application-generic';
import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal';
import { EmailProviderIdEnum } from '@novu/shared';

import type { AgentIntegrationResponseDto } from '../../dtos';
import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper';
import { AddAgentIntegrationCommand } from './add-agent-integration.command';
Expand Down Expand Up @@ -53,6 +57,10 @@ export class AddAgentIntegration {
throw new ConflictException('This integration is already linked to the agent.');
}

if (integration.providerId === EmailProviderIdEnum.NovuAgent) {
await this.prepareNovuEmailIntegration(agent._id, integration._id, command);
}

const link = await this.agentIntegrationRepository.create({
_agentId: agent._id,
_integrationId: integration._id,
Expand All @@ -62,4 +70,62 @@ export class AddAgentIntegration {

return toAgentIntegrationResponse(link, integration);
}

/**
* 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<void> {
await this.enforceSingletonEmail(agentId, command);
await this.seedEmailSecretKey(integrationId, command.environmentId, command.organizationId);
}

private async enforceSingletonEmail(
agentId: string,
command: AddAgentIntegrationCommand
): Promise<void> {
const existingLinks = await this.agentIntegrationRepository.find(
{
_agentId: agentId,
_environmentId: command.environmentId,
_organizationId: command.organizationId,
},
'*'
);

if (existingLinks.length === 0) return;

const linkedIntegrationIds = existingLinks.map((link) => link._integrationId);
const linkedEmailIntegrations = await this.integrationRepository.find(
{
_id: { $in: linkedIntegrationIds },
_environmentId: command.environmentId,
_organizationId: command.organizationId,
providerId: EmailProviderIdEnum.NovuAgent,
},
'_id'
);

if (linkedEmailIntegrations.length > 0) {
throw new ConflictException('Only one email integration per agent is allowed.');
}
}
Comment thread
ChmaraX marked this conversation as resolved.

private async seedEmailSecretKey(
integrationId: string,
environmentId: string,
organizationId: string
): Promise<void> {
const dedicatedSecret = randomBytes(32).toString('hex');

await this.integrationRepository.update(
{ _id: integrationId, _environmentId: environmentId, _organizationId: organizationId },
{ $set: { 'credentials.secretKey': encryptSecret(dedicatedSecret) } }
);
}
Comment thread
ChmaraX marked this conversation as resolved.
}
2 changes: 2 additions & 0 deletions apps/api/src/app/agents/usecases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ListAgentEmoji } from './list-agent-emoji/list-agent-emoji.usecase';
import { ListAgentIntegrations } from './list-agent-integrations/list-agent-integrations.usecase';
import { ListAgents } from './list-agents/list-agents.usecase';
import { RemoveAgentIntegration } from './remove-agent-integration/remove-agent-integration.usecase';
import { SendAgentTestEmail } from './send-agent-test-email/send-agent-test-email.usecase';
import { UpdateAgent } from './update-agent/update-agent.usecase';
import { UpdateAgentIntegration } from './update-agent-integration/update-agent-integration.usecase';

Expand All @@ -22,4 +23,5 @@ export const USE_CASES = [
UpdateAgentIntegration,
RemoveAgentIntegration,
HandleAgentReply,
SendAgentTestEmail,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IsNotEmpty, IsString } from 'class-validator';

import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';

export class SendAgentTestEmailCommand extends EnvironmentWithUserCommand {
@IsString()
@IsNotEmpty()
agentIdentifier: string;
}
Loading
Loading