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
34 changes: 34 additions & 0 deletions src/modules/email/email.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,40 @@ describe('EmailController', () => {
});
});

describe('reply', () => {
const PARENT_ID = 'parent-id';
const baseDto = {
to: [{ email: 'alice@internxt.me' }],
};

test('When replying, then the parent id from the path and delivery mode are forwarded to the service', async () => {
const dto = { ...baseDto, deliveryMode: 'EXTERNAL' as const };
emailService.replyEmail.mockResolvedValue({ id: 'reply-id' });

await controller.reply(userEmail, PARENT_ID, dto);

expect(emailService.replyEmail).toHaveBeenCalledWith(
userEmail,
PARENT_ID,
dto,
'EXTERNAL',
);
});

test('When replying without a delivery mode, then it is forwarded as undefined so the service defaults to internal', async () => {
emailService.replyEmail.mockResolvedValue({ id: 'reply-id' });

await controller.reply(userEmail, PARENT_ID, baseDto);

expect(emailService.replyEmail).toHaveBeenCalledWith(
userEmail,
PARENT_ID,
baseDto,
undefined,
);
});
});

describe('getThread', () => {
it('when the user opens an email, then the entire conversation around it is returned', async () => {
const emails = [
Expand Down
24 changes: 24 additions & 0 deletions src/modules/email/email.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
LookupRecipientKeysRequestDto,
LookupRecipientKeysResponseDto,
MailboxResponseDto,
ReplyEmailRequestDto,
SearchEmailQueryDto,
MailDomainDto,
SendEmailRequestDto,
Expand Down Expand Up @@ -232,6 +233,29 @@ export class EmailController {
return this.emailService.sendEmail(email, dto);
}

@Post(':id/reply')
@ApiOperation({
summary: 'Reply to an email',
description: 'Sends a reply to a given email',
})
@ApiParam({
name: 'id',
description: 'JMAP id of the email being replied to',
})
@ApiBody({ type: ReplyEmailRequestDto })
@ApiOkResponse({
type: EmailCreatedResponseDto,
description: 'Reply sent successfully',
})
@ApiNotFoundResponse({ description: 'Original email not found' })
reply(
@MailAddress('address') email: string,
@Param('id') id: string,
@Body() dto: ReplyEmailRequestDto,
) {
return this.emailService.replyEmail(email, id, dto, dto.deliveryMode);
}

@Post('drafts')
@ApiOperation({
summary: 'Save a draft',
Expand Down
15 changes: 14 additions & 1 deletion src/modules/email/email.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsEmail } from 'class-validator';
import type { MailboxType, MailDeliveryMode } from './email.types.js';
import { MailDomainStatus } from '../account/domain/mail-domain.domain.js';
Expand Down Expand Up @@ -138,6 +138,19 @@ export class SendEmailRequestDto {
draftId?: string;
}

export class ReplyEmailRequestDto extends OmitType(SendEmailRequestDto, [
'inReplyToEmailId',
'subject',
] as const) {
@ApiPropertyOptional({
example: 'Re: Weekly sync notes',
description:
'Subject of the reply. Optional — when omitted, a `Re:`-prefixed subject ' +
'is derived from the original email.',
})
subject?: string;
}

export class LookupRecipientKeysRequestDto {
@ApiProperty({
type: [String],
Expand Down
135 changes: 86 additions & 49 deletions src/modules/email/email.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,45 +364,102 @@ describe('EmailService', () => {
);
});

it('when replying to an existing email, then the reply is delivered into the same conversation', async () => {
const dto = newSendEmailDto({ inReplyToEmailId: 'parent-id' });
const threading = {
messageId: ['<parent@example.com>'],
references: ['<parent@example.com>'],
};
provider.getThreadingHeaders.mockResolvedValue(threading);
provider.sendEmail.mockResolvedValue({ id: 'reply-id' });
test('When sending an email, then no conversation is looked up because sends are not threaded', async () => {
const dto = newSendEmailDto();
provider.sendEmail.mockResolvedValue({ id: 'plain-id' });

await service.sendEmail(userEmail, dto);

expect(provider.getThreadingHeaders).not.toHaveBeenCalled();
expect(provider.sendEmail).toHaveBeenCalledWith(
userEmail,
dto,
undefined,
);
});
});

describe('Replying an email', () => {
const PARENT_ID = 'parent-id';
const THREADING = {
messageId: ['<parent@example.com>'],
references: ['<root@example.com>', '<parent@example.com>'],
parentSubject: 'Weekly sync notes',
};

test('When replying, then the reply is delivered into the same conversation', async () => {
const dto = newSendEmailDto();
provider.getThreadingHeaders.mockResolvedValue(THREADING);
provider.sendEmail.mockResolvedValue({ id: 'reply-id' });

await service.replyEmail(userEmail, PARENT_ID, dto);

expect(provider.getThreadingHeaders).toHaveBeenCalledWith(
userEmail,
'parent-id',
PARENT_ID,
);
expect(provider.sendEmail).toHaveBeenCalledWith(
userEmail,
dto,
threading,
expect.objectContaining({ to: dto.to }),
THREADING,
);
});

it('when replying to an email that no longer exists, then the user is told the original was not found', async () => {
const dto = newSendEmailDto({ inReplyToEmailId: 'missing-id' });
provider.getThreadingHeaders.mockResolvedValue(null);
test('When the reply omits a subject, then a "Re:"-prefixed subject is derived from the original', async () => {
const dto = newSendEmailDto({ subject: undefined });
provider.getThreadingHeaders.mockResolvedValue(THREADING);
provider.sendEmail.mockResolvedValue({ id: 'reply-id' });

await expect(service.sendEmail(userEmail, dto)).rejects.toThrow(
NotFoundException,
await service.replyEmail(userEmail, PARENT_ID, dto);

expect(provider.sendEmail).toHaveBeenCalledWith(
userEmail,
expect.objectContaining({ subject: 'Re: Weekly sync notes' }),
THREADING,
);
});

test('When the reply provides a subject, then it is used as-is', async () => {
const dto = newSendEmailDto({ subject: 'A different subject' });
provider.getThreadingHeaders.mockResolvedValue(THREADING);
provider.sendEmail.mockResolvedValue({ id: 'reply-id' });

await service.replyEmail(userEmail, PARENT_ID, dto);

expect(provider.sendEmail).toHaveBeenCalledWith(
userEmail,
expect.objectContaining({ subject: 'A different subject' }),
THREADING,
);
expect(provider.sendEmail).not.toHaveBeenCalled();
});

it('when sending an email that is not a reply, then no conversation is looked up', async () => {
test('When the original email does not exist, then an error indicating so is thrown', async () => {
const dto = newSendEmailDto();
provider.sendEmail.mockResolvedValue({ id: 'plain-id' });
provider.getThreadingHeaders.mockResolvedValue(null);

await service.sendEmail(userEmail, dto);
await expect(
service.replyEmail(userEmail, 'missing-id', dto),
).rejects.toThrow(NotFoundException);
expect(provider.sendEmail).not.toHaveBeenCalled();
});

expect(provider.getThreadingHeaders).not.toHaveBeenCalled();
test('When is an external delivery (3rd party users), then the reply is dispatched over SMTP with the threading headers', async () => {
const dto = newSendEmailDto({ encryption: undefined });
provider.getThreadingHeaders.mockResolvedValue(THREADING);
configService.getOrThrow.mockReturnValue(
Buffer.from('server-priv-key').toString('base64'),
);
smtp.sendRaw.mockResolvedValue({ messageId: '<reply-sent@example.com>' });
provider.saveToSent.mockResolvedValue({ id: 'sent-id' });

await service.replyEmail(userEmail, PARENT_ID, dto, 'EXTERNAL');

expect(smtp.sendRaw).toHaveBeenCalledWith(
expect.objectContaining({
inReplyTo: THREADING.messageId[0],
references: THREADING.references,
}),
);
});
});

Expand Down Expand Up @@ -509,6 +566,7 @@ describe('EmailService', () => {
textBody: expect.stringContaining('INTERNXT-ENCRYPTED-EMAIL-v1'),
}),
undefined,
'msg-2',
);
expect(result).toEqual({ id: 'msg-2' });
});
Expand Down Expand Up @@ -538,47 +596,26 @@ describe('EmailService', () => {
);
});

it('when replying to an external recipient, then the conversation thread is carried through SMTP and the saved copy', async () => {
test('When delivering externally, then the Sent copy reuses the Message-ID assigned by SMTP so both copies thread together', async () => {
const dto = newSendEmailDto({
inReplyToEmailId: 'parent-id',
textBody: 'replying out',
textBody: 'sending out',
encryption: undefined,
});
const threading = {
messageId: ['<parent@example.com>'],
references: ['<grandparent@example.com>', '<parent@example.com>'],
};
provider.getThreadingHeaders.mockResolvedValue(threading);
const smtpMessageId = '<delivered@external.com>';
configService.getOrThrow.mockReturnValue(
Buffer.from('server-priv-key').toString('base64'),
);
smtp.sendRaw.mockResolvedValue({ messageId: 'msg-3' });
provider.saveToSent.mockResolvedValue({ id: 'sent-3' });
smtp.sendRaw.mockResolvedValue({ messageId: smtpMessageId });
provider.saveToSent.mockResolvedValue({ id: 'sent-id' });

await service.sendExternalEmail(userEmail, dto);

expect(smtp.sendRaw).toHaveBeenCalledWith(
expect.objectContaining({
inReplyTo: '<parent@example.com>',
references: ['<grandparent@example.com>', '<parent@example.com>'],
}),
);
expect(provider.saveToSent).toHaveBeenCalledWith(
userEmail,
expect.any(Object),
threading,
);
});

it('when replying to an external recipient and the original no longer exists, then the reply is rejected before reaching SMTP', async () => {
const dto = newSendEmailDto({ inReplyToEmailId: 'missing-id' });
provider.getThreadingHeaders.mockResolvedValue(null);

await expect(service.sendExternalEmail(userEmail, dto)).rejects.toThrow(
NotFoundException,
undefined,
smtpMessageId,
);
expect(smtp.sendRaw).not.toHaveBeenCalled();
expect(provider.saveToSent).not.toHaveBeenCalled();
});
});

Expand Down
Loading
Loading