Skip to content

Commit cbf256a

Browse files
authored
Merge pull request #68 from internxt/feature/draft-emails
[PB-1974]: Feature/draft emails
2 parents bcf453d + 8a91114 commit cbf256a

11 files changed

Lines changed: 603 additions & 41 deletions

src/modules/email/email.controller.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,11 +232,13 @@ export class EmailController {
232232
@ApiOperation({
233233
summary: 'Save a draft',
234234
description:
235-
'Creates a new draft email. All fields are optional so partial drafts can be saved.',
235+
'Creates a new draft email. All fields are optional so partial drafts can be saved. ' +
236+
"Pass `encryption` to store the body encrypted only with the sender's key — the sender " +
237+
'is the only reader and can decrypt it on retrieval.',
236238
})
237239
@ApiBody({ type: DraftEmailRequestDto })
238240
@ApiOkResponse({
239-
type: EmailCreatedResponseDto,
241+
type: EmailResponseDto,
240242
description: 'Draft saved successfully',
241243
})
242244
saveDraft(
@@ -246,6 +248,55 @@ export class EmailController {
246248
return this.emailService.saveDraft(email, dto);
247249
}
248250

251+
@Patch('drafts/:id')
252+
@ApiOperation({
253+
summary: 'Update a draft',
254+
description:
255+
'Updates a draft email. All fields are optional so partial drafts can be saved. ' +
256+
'Pass `encryption` with a fresh envelope to replace the stored body — the previous ' +
257+
'envelope is dropped together with the destroyed draft.',
258+
})
259+
@ApiBody({ type: DraftEmailRequestDto })
260+
@ApiOkResponse({
261+
type: EmailResponseDto,
262+
description: 'Draft updated successfully',
263+
})
264+
@ApiNotFoundResponse({ description: 'Draft not found' })
265+
updateDraft(
266+
@MailAddress('address') email: string,
267+
@Param('id') draftId: string,
268+
@Body() dto: DraftEmailRequestDto,
269+
) {
270+
return this.emailService.updateDraft(email, draftId, dto);
271+
}
272+
273+
@Get('drafts/:id')
274+
@ApiOperation({
275+
summary: 'Get a draft',
276+
description: 'Returns a draft',
277+
})
278+
@ApiParam({ name: 'id', description: 'Draft ID' })
279+
@ApiOkResponse({ type: EmailResponseDto })
280+
@ApiNotFoundResponse({ description: 'Draft not found' })
281+
getDraft(@MailAddress('address') email: string, @Param('id') id: string) {
282+
return this.emailService.getDraft(email, id);
283+
}
284+
285+
@Delete('drafts/:id')
286+
@HttpCode(HttpStatus.NO_CONTENT)
287+
@ApiOperation({
288+
summary: 'Discard a draft',
289+
description:
290+
'Permanently discards a draft by ID. Returns 404 if the email exists ' +
291+
'but is not a draft.',
292+
})
293+
@ApiParam({ name: 'id', description: 'Draft ID' })
294+
@ApiNoContentResponse({ description: 'Draft discarded successfully' })
295+
@ApiNotFoundResponse({ description: 'Draft not found' })
296+
discardDraft(@MailAddress('address') email: string, @Param('id') id: string) {
297+
return this.emailService.discardDraft(email, id);
298+
}
299+
249300
@Post('attachment')
250301
@ApiOperation({
251302
summary: 'Upload an attachment',

src/modules/email/email.dto.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ export class SendEmailRequestDto {
123123
description: 'JMAP id of the email being replied to (for threading)',
124124
})
125125
inReplyToEmailId?: string;
126+
127+
@ApiPropertyOptional({
128+
example: 'Ma1f09b…',
129+
description:
130+
'JMAP id of the draft being sent. When present, the draft is destroyed ' +
131+
'after the email is sent so it no longer appears in the Drafts folder.',
132+
})
133+
draftId?: string;
126134
}
127135

128136
export class LookupRecipientKeysRequestDto {
@@ -174,6 +182,15 @@ export class DraftEmailRequestDto {
174182
@ApiPropertyOptional({ example: '<p>Still working on this…</p>' })
175183
htmlBody?: string;
176184

185+
@ApiPropertyOptional({
186+
type: EncryptionBlockDto,
187+
description:
188+
'When present, the draft body is stored encrypted. Only the sender can ' +
189+
'decrypt it later, so wrappedKeys / attachmentWrappedKeys should contain ' +
190+
"a single entry built from the sender's own public key.",
191+
})
192+
encryption?: EncryptionBlockDto;
193+
177194
@ApiPropertyOptional({ type: [AttachmentRefDto] })
178195
attachments?: AttachmentRefDto[];
179196
}
@@ -274,6 +291,9 @@ export class EmailSummaryResponseDto {
274291
@ApiProperty({ example: false })
275292
isFlagged!: boolean;
276293

294+
@ApiProperty({ example: false })
295+
isDraft!: boolean;
296+
277297
@ApiProperty({ example: false })
278298
hasAttachment!: boolean;
279299

src/modules/email/email.service.spec.ts

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
2-
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { describe, it, test, expect, beforeEach, vi } from 'vitest';
33
import { Test } from '@nestjs/testing';
44
import { NotFoundException, BadRequestException } from '@nestjs/common';
55
import { ConfigService } from '@nestjs/config';
@@ -254,9 +254,9 @@ describe('EmailService', () => {
254254
it('when opening a conversation by an id that does not exist, then the user is told it was not found', async () => {
255255
provider.getThread.mockResolvedValue([]);
256256

257-
await expect(
258-
service.getThread(userEmail, 'nonexistent'),
259-
).rejects.toThrow(NotFoundException);
257+
await expect(service.getThread(userEmail, 'nonexistent')).rejects.toThrow(
258+
NotFoundException,
259+
);
260260
});
261261

262262
it('when a conversation contains end-to-end encrypted messages, then the encrypted previews and wrapped keys are attached for the client to decrypt', async () => {
@@ -349,7 +349,11 @@ describe('EmailService', () => {
349349

350350
await service.sendEmail(userEmail, dto);
351351

352-
expect(provider.sendEmail).toHaveBeenCalledWith(userEmail, dto, undefined);
352+
expect(provider.sendEmail).toHaveBeenCalledWith(
353+
userEmail,
354+
dto,
355+
undefined,
356+
);
353357
});
354358

355359
it('when replying to an existing email, then the reply is delivered into the same conversation', async () => {
@@ -367,7 +371,11 @@ describe('EmailService', () => {
367371
userEmail,
368372
'parent-id',
369373
);
370-
expect(provider.sendEmail).toHaveBeenCalledWith(userEmail, dto, threading);
374+
expect(provider.sendEmail).toHaveBeenCalledWith(
375+
userEmail,
376+
dto,
377+
threading,
378+
);
371379
});
372380

373381
it('when replying to an email that no longer exists, then the user is told the original was not found', async () => {
@@ -521,10 +529,7 @@ describe('EmailService', () => {
521529
expect(smtp.sendRaw).toHaveBeenCalledWith(
522530
expect.objectContaining({
523531
inReplyTo: '<parent@example.com>',
524-
references: [
525-
'<grandparent@example.com>',
526-
'<parent@example.com>',
527-
],
532+
references: ['<grandparent@example.com>', '<parent@example.com>'],
528533
}),
529534
);
530535
expect(provider.saveToSent).toHaveBeenCalledWith(
@@ -570,12 +575,104 @@ describe('EmailService', () => {
570575
describe('saveDraft', () => {
571576
it('when called, then delegates to provider', async () => {
572577
const dto = newDraftEmailDto();
573-
provider.saveDraft.mockResolvedValue({ id: 'draft-id' });
578+
const savedDraft = newEmail({ isDraft: true });
579+
provider.saveDraft.mockResolvedValue(savedDraft);
574580

575581
const result = await service.saveDraft(userEmail, dto);
576582

577583
expect(provider.saveDraft).toHaveBeenCalledWith(userEmail, dto);
578-
expect(result).toEqual({ id: 'draft-id' });
584+
expect(result).toBe(savedDraft);
585+
});
586+
587+
it('when DTO has encryption block, then serializes it into textBody and clears htmlBody', async () => {
588+
const encryption = newEncryptionBlock();
589+
const dto = newDraftEmailDto({
590+
encryption,
591+
htmlBody: '<p>original</p>',
592+
});
593+
provider.saveDraft.mockResolvedValue(newEmail({ isDraft: true }));
594+
595+
await service.saveDraft(userEmail, dto);
596+
597+
const expectedBundle = Buffer.from(JSON.stringify(encryption)).toString(
598+
'base64',
599+
);
600+
expect(provider.saveDraft).toHaveBeenCalledWith(
601+
userEmail,
602+
expect.objectContaining({
603+
textBody: `INTERNXT-ENCRYPTED-EMAIL-v1\n${expectedBundle}`,
604+
htmlBody: undefined,
605+
}),
606+
);
607+
});
608+
});
609+
610+
describe('updateDraft', () => {
611+
it('when called, then delegates to provider', async () => {
612+
const dto = newDraftEmailDto();
613+
const updatedDraft = newEmail({ isDraft: true });
614+
provider.updateDraft.mockResolvedValue(updatedDraft);
615+
616+
const result = await service.updateDraft(userEmail, 'draft-id', dto);
617+
618+
expect(provider.updateDraft).toHaveBeenCalledWith(
619+
userEmail,
620+
'draft-id',
621+
dto,
622+
);
623+
expect(result).toBe(updatedDraft);
624+
});
625+
626+
it('when DTO has encryption block, then serializes it into textBody and clears htmlBody', async () => {
627+
const encryption = newEncryptionBlock();
628+
const dto = newDraftEmailDto({
629+
encryption,
630+
htmlBody: '<p>original</p>',
631+
});
632+
provider.updateDraft.mockResolvedValue(newEmail({ isDraft: true }));
633+
634+
await service.updateDraft(userEmail, 'draft-id', dto);
635+
636+
const expectedBundle = Buffer.from(JSON.stringify(encryption)).toString(
637+
'base64',
638+
);
639+
expect(provider.updateDraft).toHaveBeenCalledWith(
640+
userEmail,
641+
'draft-id',
642+
expect.objectContaining({
643+
textBody: `INTERNXT-ENCRYPTED-EMAIL-v1\n${expectedBundle}`,
644+
htmlBody: undefined,
645+
}),
646+
);
647+
});
648+
649+
test('When the user tries to update a draft that does not exist, then they are told it was not found', async () => {
650+
provider.updateDraft.mockResolvedValue(null);
651+
652+
await expect(
653+
service.updateDraft(userEmail, 'missing-draft', newDraftEmailDto()),
654+
).rejects.toThrow(NotFoundException);
655+
});
656+
});
657+
658+
describe('Discard Draft', () => {
659+
test('When the user discards an existing draft, then it is removed from their mailbox', async () => {
660+
const draft = newEmail({ isDraft: true });
661+
provider.getDraft.mockResolvedValue(draft);
662+
provider.discardDraft.mockResolvedValue(undefined);
663+
664+
await service.discardDraft(userEmail, draft.id);
665+
666+
expect(provider.discardDraft).toHaveBeenCalledWith(userEmail, draft.id);
667+
});
668+
669+
test('When the user tries to discard a draft that does not exist, then they are told it was not found', async () => {
670+
provider.getDraft.mockResolvedValue(null);
671+
672+
await expect(
673+
service.discardDraft(userEmail, 'missing-draft'),
674+
).rejects.toThrow(NotFoundException);
675+
expect(provider.discardDraft).not.toHaveBeenCalled();
579676
});
580677
});
581678

src/modules/email/email.service.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,45 @@ export class EmailService {
278278
);
279279
}
280280

281-
saveDraft(userEmail: string, dto: DraftEmailDto): Promise<{ id: string }> {
282-
return this.mail.saveDraft(userEmail, dto);
281+
saveDraft(userEmail: string, dto: DraftEmailDto): Promise<Email> {
282+
return this.mail.saveDraft(userEmail, this.packDraftEnvelope(dto));
283+
}
284+
285+
async updateDraft(
286+
userEmail: string,
287+
draftId: string,
288+
dto: DraftEmailDto,
289+
): Promise<Email> {
290+
const result = await this.mail.updateDraft(
291+
userEmail,
292+
draftId,
293+
this.packDraftEnvelope(dto),
294+
);
295+
if (!result) {
296+
throw new NotFoundException(`Draft ${draftId} not found`);
297+
}
298+
return result;
299+
}
300+
301+
private packDraftEnvelope(dto: DraftEmailDto): DraftEmailDto {
302+
if (!dto.encryption) return dto;
303+
return {
304+
...dto,
305+
textBody: packEnvelope(dto.encryption),
306+
htmlBody: undefined,
307+
};
308+
}
309+
310+
getDraft(userEmail: string, id: string): Promise<Email | null> {
311+
return this.mail.getDraft(userEmail, id);
312+
}
313+
314+
async discardDraft(userEmail: string, id: string): Promise<void> {
315+
const draft = await this.mail.getDraft(userEmail, id);
316+
if (!draft) {
317+
throw new NotFoundException(`Draft ${id} not found`);
318+
}
319+
await this.mail.discardDraft(userEmail, id);
283320
}
284321

285322
moveEmail(userEmail: string, id: string, target: MailboxType): Promise<void> {

src/modules/email/email.types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface EmailSummary {
3939
preview: string;
4040
isRead: boolean;
4141
isFlagged: boolean;
42+
isDraft: boolean;
4243
hasAttachment: boolean;
4344
size: number;
4445
encryption?: EncryptedSummaryFields | null;
@@ -93,6 +94,7 @@ export interface SendEmailDto {
9394
encryption?: EncryptionBlock;
9495
attachments?: EmailAttachment[];
9596
inReplyToEmailId?: string;
97+
draftId?: string;
9698
}
9799

98100
export interface ThreadingHeaders {
@@ -101,12 +103,14 @@ export interface ThreadingHeaders {
101103
}
102104

103105
export interface DraftEmailDto {
106+
draftId?: string;
104107
to?: EmailAddress[];
105108
cc?: EmailAddress[];
106109
bcc?: EmailAddress[];
107110
subject?: string;
108111
textBody?: string;
109112
htmlBody?: string;
113+
encryption?: EncryptionBlock;
110114
attachments?: EmailAttachment[];
111115
}
112116

src/modules/email/mail-provider.port.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,14 @@ export abstract class MailProvider {
4141
parentId: string,
4242
): Promise<ThreadingHeaders | null>;
4343
abstract getThread(userEmail: string, emailId: string): Promise<Email[]>;
44-
abstract saveDraft(
44+
abstract saveDraft(userEmail: string, dto: DraftEmailDto): Promise<Email>;
45+
abstract updateDraft(
4546
userEmail: string,
47+
draftId: string,
4648
dto: DraftEmailDto,
47-
): Promise<{ id: string }>;
49+
): Promise<Email | null>;
50+
abstract getDraft(userEmail: string, id: string): Promise<Email | null>;
51+
abstract discardDraft(userEmail: string, id: string): Promise<void>;
4852
abstract moveEmail(
4953
userEmail: string,
5054
id: string,

src/modules/infrastructure/jmap/jmap-mail.mapper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export function mapJmapEmailToSummary(e: JmapEmail): EmailSummary {
7070
preview: e.preview ?? '',
7171
isRead: !!e.keywords?.['$seen'],
7272
isFlagged: !!e.keywords?.['$flagged'],
73+
isDraft: !!e.keywords?.['$draft'],
7374
hasAttachment: !!e.hasAttachment,
7475
size: e.size,
7576
};

0 commit comments

Comments
 (0)