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
119 changes: 119 additions & 0 deletions apps/api/src/app/agents/services/chat-sdk.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { ChannelTypeEnum, EmailProviderIdEnum } from '@novu/shared';
import { expect } from 'chai';
import sinon from 'sinon';
import { ChatSdkService } from './chat-sdk.service';

describe('ChatSdkService', () => {
describe('buildSendEmailCallback', () => {
it('should skip custom MIME alternatives for unsupported outbound providers', async () => {
const logger = {
warn: sinon.stub(),
error: sinon.stub(),
debug: sinon.stub(),
info: sinon.stub(),
};
const integrationRepository = {
findOne: sinon.stub().resolves({
_id: 'outbound-integration-id',
_environmentId: 'env-id',
_organizationId: 'org-id',
providerId: EmailProviderIdEnum.Resend,
channel: ChannelTypeEnum.EMAIL,
credentials: {},
active: true,
}),
};
const service = new ChatSdkService(logger as any, {} as any, {} as any, {} as any, integrationRepository as any);
const sendEmail = (service as any).buildSendEmailCallback(
{
environmentId: 'env-id',
organizationId: 'org-id',
credentials: {},
},
'outbound-integration-id'
);

const result = await sendEmail({
from: 'agent@example.com',
to: 'user@gmail.com',
subject: 'Re: Hello',
text: '👀',
html: '<p>👀</p>',
alternatives: [
{
contentType: 'text/vnd.google.email-reaction+json',
content: JSON.stringify({ version: 1, emoji: '👀' }),
},
],
messageId: '<reaction@example.com>',
inReplyTo: '<original@example.com>',
references: '<original@example.com>',
});

expect(result).to.deep.equal({ messageId: '<reaction@example.com>' });
expect(logger.warn.calledOnce).to.equal(true);
expect(logger.warn.firstCall.args[0]).to.deep.equal({
providerId: EmailProviderIdEnum.Resend,
outboundIntegrationId: 'outbound-integration-id',
});
expect(logger.warn.firstCall.args[1]).to.include('does not support custom MIME alternatives');
expect(
integrationRepository.findOne.calledOnceWithMatch({
_id: 'outbound-integration-id',
channel: ChannelTypeEnum.EMAIL,
})
).to.equal(true);
});

it('should not claim success when unsupported MIME alternatives omit messageId', async () => {
const logger = {
warn: sinon.stub(),
error: sinon.stub(),
debug: sinon.stub(),
info: sinon.stub(),
};
const integrationRepository = {
findOne: sinon.stub().resolves({
_id: 'outbound-integration-id',
_environmentId: 'env-id',
_organizationId: 'org-id',
providerId: EmailProviderIdEnum.Resend,
channel: ChannelTypeEnum.EMAIL,
credentials: {},
active: true,
}),
};
const service = new ChatSdkService(logger as any, {} as any, {} as any, {} as any, integrationRepository as any);
const sendEmail = (service as any).buildSendEmailCallback(
{
environmentId: 'env-id',
organizationId: 'org-id',
credentials: {},
},
'outbound-integration-id'
);

const result = await sendEmail({
from: 'agent@example.com',
to: 'user@gmail.com',
subject: 'Re: Hello',
text: '👀',
html: '<p>👀</p>',
alternatives: [
{
contentType: 'text/vnd.google.email-reaction+json',
content: JSON.stringify({ version: 1, emoji: '👀' }),
},
],
});

expect(result).to.deep.equal({ messageId: undefined });
expect(logger.warn.calledOnce).to.equal(true);
expect(logger.warn.firstCall.args[0]).to.deep.equal({
providerId: EmailProviderIdEnum.Resend,
outboundIntegrationId: 'outbound-integration-id',
});
expect(logger.warn.firstCall.args[1]).to.include('no messageId was supplied');
});
});
});
44 changes: 43 additions & 1 deletion apps/api/src/app/agents/services/chat-sdk.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ function wrapMsgId(id: string): string {

const MAX_CACHED_INSTANCES = 200;
const INSTANCE_TTL_MS = 1000 * 60 * 30;
// EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS is a deliberate allowlist for providers that preserve custom MIME
// alternatives used by Gmail reactions; Braze, Brevo, Mailgun, Mailjet, Mailtrap, Mandrill, Plunk, Postmark,
// Resend, SparkPost, and similar providers are excluded until their SDK paths are verified.
const EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS = new Set<string>([
EmailProviderIdEnum.CustomSMTP,
EmailProviderIdEnum.Outlook365,
EmailProviderIdEnum.SendGrid,
EmailProviderIdEnum.SES,
]);

/**
* Holds a cached Chat instance alongside a mutable pointer to the current
Expand Down Expand Up @@ -320,10 +329,14 @@ export class ChatSdkService implements OnModuleDestroy {
subject: string;
html: string;
text?: string;
alternatives?: Array<{
contentType: string;
content: string | Buffer;
}>;
inReplyTo?: string;
references?: string;
messageId?: string;
}) => Promise<{ messageId: string }> {
}) => Promise<{ messageId?: string }> {
return async (params) => {
if (!outboundIntegrationId) {
throw new BadRequestException(
Expand Down Expand Up @@ -357,6 +370,34 @@ export class ChatSdkService implements OnModuleDestroy {
);
}

const hasUnsupportedAlternatives =
params.alternatives?.length && !EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS.has(integration.providerId);
if (hasUnsupportedAlternatives) {
// NovuEmailAdapterImpl.addReaction supplies a reaction Message-ID; any custom MIME alternative caller must do
// the same so skipped unsupported sends don't claim provider delivery.
if (!params.messageId) {
this.logger.warn(
{
providerId: integration.providerId,
outboundIntegrationId,
},
'Skipping email with custom MIME alternatives because the outbound provider is unsupported and no messageId was supplied'
);

return { messageId: undefined };
}

this.logger.warn(
{
providerId: integration.providerId,
outboundIntegrationId,
},
'Skipping email reaction because the outbound provider does not support custom MIME alternatives'
);

return { messageId: params.messageId };
}

const decrypted = decryptCredentials(integration.credentials);
const mailFactory = new MailFactory();
const handler = mailFactory.getHandler({ ...integration, credentials: decrypted }, params.from);
Expand All @@ -366,6 +407,7 @@ export class ChatSdkService implements OnModuleDestroy {
subject: params.subject,
html: params.html,
text: params.text,
alternatives: params.alternatives,
from: params.from,
senderName: config.credentials.senderName || undefined,
headers: {
Expand Down
145 changes: 137 additions & 8 deletions packages/chat-adapter-email/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ import type { NovuEmailAdapterConfig, NovuEmailRawMessage, NovuEmailThreadId } f
import { generateMessageId, hashMessageId, parseEmailAddress } from './utils.js';
import { WebhookHandler } from './webhook-handler.js';

const GMAIL_REACTION_CONTENT_TYPE = 'text/vnd.google.email-reaction+json';
const GMAIL_MESSAGE_ID_DOMAINS = new Set(['mail.gmail.com']);
const MAX_REACTION_REFERENCE_IDS = 20;
// The email acknowledgement flow currently emits "eyes"; other named reactions are intentionally supported only when
// they have an explicit Unicode mapping that Gmail can validate as a single emoji.
const EMAIL_REACTION_EMOJI_BY_NAME: Record<string, string> = {
eyes: '👀',
thumbs_up: '👍',
heart: '❤️',
laugh: '😂',
tada: '🎉',
};

class NotImplementedError extends Error {
constructor(method: string) {
super(`${method} is not supported by the email adapter`);
Expand Down Expand Up @@ -51,7 +64,10 @@ export class NovuEmailAdapterImpl implements Adapter<NovuEmailThreadId, NovuEmai

const chatModule = await import('chat');
this.parseMarkdownFn = chatModule.parseMarkdown;
this.messageParser.setChatModule(chatModule.Message as any, chatModule.parseMarkdown);
this.messageParser.setChatModule(
chatModule.Message as unknown as Parameters<MessageParser['setChatModule']>[0],
chatModule.parseMarkdown
);
}

// -- Thread ID methods --
Expand Down Expand Up @@ -138,9 +154,7 @@ export class NovuEmailAdapterImpl implements Adapter<NovuEmailThreadId, NovuEmai
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 fromHeader = this.config.senderName ? `${this.config.senderName} <${agentAddress}>` : agentAddress;

const messageId = generateMessageId(agentAddress);
const replyHeaders = await this.threadResolver.getReplyHeaders(threadId);
Expand Down Expand Up @@ -180,6 +194,48 @@ export class NovuEmailAdapterImpl implements Adapter<NovuEmailThreadId, NovuEmai
return { id: sentMessageId, raw, threadId };
}

async addReaction(threadId: string, messageId: string, emoji: unknown): Promise<void> {
const decoded = this.threadResolver.decodeThreadId(threadId);
if (!this.isGmailMessageId(messageId)) {
return;
}

const agentAddress = await this.threadResolver.getAgentAddress(threadId);
if (!agentAddress) {
throw new Error(`No agent address found for thread ${threadId} — cannot determine From address for reaction`);
}

let reactionEmoji: string;
try {
reactionEmoji = this.toReactionEmoji(emoji);
} catch {
return;
}

const reactionMessageId = generateMessageId(agentAddress);
const storedSubject = await this.threadResolver.getSubject(threadId);
const subject = storedSubject ? this.toReplySubject(storedSubject) : 'New message';
Comment on lines +215 to +217
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor: 'New message' is an odd subject for a reaction.

A reaction should be threaded against a prior inbound message, so storedSubject should always be populated by the time addReaction runs. If it isn't, falling back to 'New message' will produce a reaction email with a generic subject and rely solely on In-Reply-To/References for threading — which Gmail will mostly handle, but the visible subject in the recipient's client will be misleading. Consider either dropping the reaction (consistent with the other silent no-op paths in this method) or logging a warning so the missing-subject case is observable.

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

In `@packages/chat-adapter-email/src/adapter.ts` around lines 215 - 217, The
fallback subject 'New message' for reactions is misleading; in addReaction,
change behavior when threadResolver.getSubject(threadId) returns null: do not
send a reaction with a generic subject—match other silent no-op paths by logging
a warning and returning early. Update the addReaction flow to check
storedSubject (from threadResolver.getSubject), if absent call logger.warn with
context (threadId, reactionMessageId from generateMessageId(agentAddress)) and
return without calling toReplySubject or sending the email; keep existing
behavior when storedSubject exists.

const replyHeaders = await this.threadResolver.getReplyHeaders(threadId);
const references = this.buildReactionReferences(replyHeaders?.References, messageId);

await this.config.sendEmail({
from: agentAddress,
to: decoded.recipientAddress,
subject,
text: reactionEmoji,
html: `<p>${reactionEmoji}</p>`,
alternatives: [
{
contentType: GMAIL_REACTION_CONTENT_TYPE,
content: JSON.stringify({ version: 1, emoji: reactionEmoji }),
},
],
messageId: reactionMessageId,
inReplyTo: messageId,
references,
});
}

/**
* Normalize AdapterPostableMessage variants into a uniform shape.
*/
Expand Down Expand Up @@ -263,11 +319,84 @@ export class NovuEmailAdapterImpl implements Adapter<NovuEmailThreadId, NovuEmai
throw new NotImplementedError('deleteMessage');
}

async addReaction(_threadId: string, _messageId: string, _emoji: string): Promise<void> {
throw new NotImplementedError('addReaction');
async removeReaction(_threadId: string, _messageId: string, _emoji: string): Promise<void> {
// Gmail's email reaction MIME format only defines adding a reaction, so removeReaction is intentionally a no-op.
}

async removeReaction(_threadId: string, _messageId: string, _emoji: string): Promise<void> {
throw new NotImplementedError('removeReaction');
private isGmailMessageId(messageId: string): boolean {
const domain = messageId.trim().replace(/^<|>$/g, '').split('@').at(-1)?.toLowerCase();

return !!domain && GMAIL_MESSAGE_ID_DOMAINS.has(domain);
}

private toReplySubject(subject: string): string {
return /^re:/i.test(subject) ? subject : `Re: ${subject}`;
}

private toReactionEmoji(emoji: unknown): string {
const emojiName = this.toEmojiName(emoji);
if (emojiName && EMAIL_REACTION_EMOJI_BY_NAME[emojiName]) {
return EMAIL_REACTION_EMOJI_BY_NAME[emojiName];
}

if (typeof emoji === 'string' && this.isSingleEmojiGrapheme(emoji)) {
return emoji;
}

throw new Error(`Unsupported email reaction emoji: ${emojiName ?? String(emoji)}`);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private isSingleEmojiGrapheme(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) {
return false;
}

const graphemes = this.segmentGraphemes(trimmed);
const [firstGrapheme] = graphemes;

return (
graphemes.length === 1 &&
firstGrapheme !== undefined &&
/[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u.test(firstGrapheme)
);
}

private segmentGraphemes(value: string): string[] {
const Segmenter = (
Intl as unknown as {
Segmenter?: new (
locale: string,
options: { granularity: 'grapheme' }
) => { segment(input: string): Iterable<{ segment: string }> };
}
).Segmenter;

if (!Segmenter) {
return Array.from(value);
}

return Array.from(new Segmenter('en', { granularity: 'grapheme' }).segment(value), ({ segment }) => segment);
}

private toEmojiName(emoji: unknown): string | undefined {
if (typeof emoji === 'string') {
return emoji;
}

if (emoji && typeof emoji === 'object' && 'name' in emoji && typeof emoji.name === 'string') {
return emoji.name;
}

return undefined;
}

private buildReactionReferences(references: string | undefined, messageId: string): string {
const ids = references?.split(/\s+/).filter(Boolean) ?? [];
if (!ids.includes(messageId)) {
ids.push(messageId);
}

return ids.slice(-MAX_REACTION_REFERENCE_IDS).join(' ');
}
}
Loading
Loading