diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts index f04a600..d9562a5 100644 --- a/src/modules/email/email.service.spec.ts +++ b/src/modules/email/email.service.spec.ts @@ -28,6 +28,8 @@ import { newEncryptionBlock, newEncryptedWrappedKey, newThreadingHeaders, + newSendEmailResult, + newUpdateDraftResult, } from '../../../test/fixtures.js'; import { ENCRYPTED_PREFIX, packEnvelope } from './email-encryption.js'; import { @@ -48,6 +50,11 @@ describe('EmailService', () => { let configService: DeepMocked; let usage: DeepMocked; const userEmail = 'test@example.com'; + const BUCKET_CONTEXT = { + mailAddressId: 'address-1', + userUuid: 'user-1', + networkBucketId: 'bucket-1', + }; beforeEach(async () => { vi.clearAllMocks(); @@ -312,7 +319,9 @@ describe('EmailService', () => { describe('sendEmail', () => { it('when sending an email with recipients, then it gets delivered through the mail provider', async () => { const dto = newSendEmailDto(); - provider.sendEmail.mockResolvedValue({ id: 'created-id' }); + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'created-id' }), + ); const result = await service.sendEmail(userEmail, dto); @@ -336,7 +345,9 @@ describe('EmailService', () => { it('when sending an end-to-end encrypted email, then the encrypted payload is delivered and the plain HTML body is discarded', async () => { const encryption = newEncryptionBlock(); const dto = newSendEmailDto({ encryption, htmlBody: '

original

' }); - provider.sendEmail.mockResolvedValue({ id: 'enc-id' }); + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'enc-id' }), + ); await service.sendEmail(userEmail, dto); @@ -355,7 +366,9 @@ describe('EmailService', () => { it('when sending a plain email, then the body is delivered as the user wrote it', async () => { const dto = newSendEmailDto({ htmlBody: '

hello

' }); - provider.sendEmail.mockResolvedValue({ id: 'plain-id' }); + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'plain-id' }), + ); await service.sendEmail(userEmail, dto); @@ -368,7 +381,9 @@ describe('EmailService', () => { 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' }); + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'plain-id' }), + ); await service.sendEmail(userEmail, dto); @@ -396,7 +411,9 @@ describe('EmailService', () => { test('When replying, then the recipient is derived from the original sender, not the caller', async () => { provider.getThreadingHeaders.mockResolvedValue(THREADING); - provider.sendEmail.mockResolvedValue({ id: 'reply-id' }); + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'reply-id' }), + ); await service.replyEmail(userEmail, PARENT_ID, { textBody: 'ok' }); @@ -413,7 +430,9 @@ describe('EmailService', () => { test('When replying to all the users involved in a conversation, then the other participants are cc’d and the caller is excluded', async () => { provider.getThreadingHeaders.mockResolvedValue(THREADING); - provider.sendEmail.mockResolvedValue({ id: 'reply-id' }); + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'reply-id' }), + ); await service.replyEmail(userEmail, PARENT_ID, { textBody: 'ok', @@ -430,7 +449,9 @@ describe('EmailService', () => { test('When the caller adds extra cc, then it is merged with the derived recipients', async () => { const extra = { email: 'extra@example.com' }; provider.getThreadingHeaders.mockResolvedValue(THREADING); - provider.sendEmail.mockResolvedValue({ id: 'reply-id' }); + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'reply-id' }), + ); await service.replyEmail(userEmail, PARENT_ID, { textBody: 'ok', @@ -446,7 +467,9 @@ describe('EmailService', () => { test('When the reply omits a subject, then a "Re:"-prefixed subject is derived from the original', async () => { provider.getThreadingHeaders.mockResolvedValue(THREADING); - provider.sendEmail.mockResolvedValue({ id: 'reply-id' }); + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'reply-id' }), + ); await service.replyEmail(userEmail, PARENT_ID, { textBody: 'ok' }); @@ -459,7 +482,9 @@ describe('EmailService', () => { test('When the reply provides a subject, then it is used as-is', async () => { provider.getThreadingHeaders.mockResolvedValue(THREADING); - provider.sendEmail.mockResolvedValue({ id: 'reply-id' }); + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'reply-id' }), + ); await service.replyEmail(userEmail, PARENT_ID, { textBody: 'ok', @@ -499,7 +524,9 @@ describe('EmailService', () => { Buffer.from('server-priv-key').toString('base64'), ); smtp.sendRaw.mockResolvedValue({ messageId: '' }); - provider.saveToSent.mockResolvedValue({ id: 'sent-id' }); + provider.saveToSent.mockResolvedValue( + newSendEmailResult({ id: 'sent-id' }), + ); await service.replyEmail( userEmail, @@ -541,7 +568,9 @@ describe('EmailService', () => { Buffer.from('server-priv-key').toString('base64'), ); smtp.sendRaw.mockResolvedValue({ messageId: 'msg-1' }); - provider.saveToSent.mockResolvedValue({ id: 'sent-1' }); + provider.saveToSent.mockResolvedValue( + newSendEmailResult({ id: 'sent-1' }), + ); const result = await service.sendExternalEmail(userEmail, dto); @@ -599,7 +628,9 @@ describe('EmailService', () => { }); mockedDecrypt.mockResolvedValue(new Uint8Array([9, 9, 9])); smtp.sendRaw.mockResolvedValue({ messageId: 'msg-2' }); - provider.saveToSent.mockResolvedValue({ id: 'sent-2' }); + provider.saveToSent.mockResolvedValue( + newSendEmailResult({ id: 'sent-2' }), + ); const result = await service.sendExternalEmail(userEmail, dto); @@ -639,7 +670,9 @@ describe('EmailService', () => { attachmentsSessionKey: new Uint8Array([1, 2, 3, 4]), }); smtp.sendRaw.mockResolvedValue({ messageId: 'msg-html' }); - provider.saveToSent.mockResolvedValue({ id: 'sent-html' }); + provider.saveToSent.mockResolvedValue( + newSendEmailResult({ id: 'sent-html' }), + ); await service.sendExternalEmail(userEmail, dto); @@ -661,7 +694,9 @@ describe('EmailService', () => { Buffer.from('server-priv-key').toString('base64'), ); smtp.sendRaw.mockResolvedValue({ messageId: smtpMessageId }); - provider.saveToSent.mockResolvedValue({ id: 'sent-id' }); + provider.saveToSent.mockResolvedValue( + newSendEmailResult({ id: 'sent-id' }), + ); await service.sendExternalEmail(userEmail, dto); @@ -734,7 +769,9 @@ describe('EmailService', () => { it('when called, then delegates to provider', async () => { const dto = newDraftEmailDto(); const updatedDraft = newEmail({ isDraft: true }); - provider.updateDraft.mockResolvedValue(updatedDraft); + provider.updateDraft.mockResolvedValue( + newUpdateDraftResult({ draft: updatedDraft }), + ); const result = await service.updateDraft(userEmail, 'draft-id', dto); @@ -752,7 +789,9 @@ describe('EmailService', () => { encryption, htmlBody: '

original

', }); - provider.updateDraft.mockResolvedValue(newEmail({ isDraft: true })); + provider.updateDraft.mockResolvedValue( + newUpdateDraftResult({ draft: newEmail({ isDraft: true }) }), + ); await service.updateDraft(userEmail, 'draft-id', dto); @@ -792,7 +831,7 @@ describe('EmailService', () => { test('When the user discards an existing draft, then it is removed from their mailbox', async () => { const draft = newEmail({ isDraft: true }); provider.getDraft.mockResolvedValue(draft); - provider.discardDraft.mockResolvedValue(undefined); + provider.discardDraft.mockResolvedValue({ deletedEntryKey: null }); await service.discardDraft(userEmail, draft.id); @@ -835,11 +874,9 @@ describe('EmailService', () => { it('when the message is permanently destroyed, then releases the quota entry on the address bucket', async () => { provider.deleteEmail.mockResolvedValue({ deletedEntryKey: '42:7' }); - accountService.findBucketContextByAddress.mockResolvedValue({ - mailAddressId: 'address-1', - userUuid: 'user-1', - networkBucketId: 'bucket-1', - }); + accountService.findBucketContextByAddress.mockResolvedValue( + BUCKET_CONTEXT, + ); await service.deleteEmail(userEmail, 'email-id'); @@ -868,11 +905,9 @@ describe('EmailService', () => { it('when releasing the quota entry fails, then the deletion still succeeds', async () => { provider.deleteEmail.mockResolvedValue({ deletedEntryKey: '42:7' }); - accountService.findBucketContextByAddress.mockResolvedValue({ - mailAddressId: 'address-1', - userUuid: 'user-1', - networkBucketId: 'bucket-1', - }); + accountService.findBucketContextByAddress.mockResolvedValue( + BUCKET_CONTEXT, + ); usage.releaseStoredMessage.mockRejectedValue(new Error('Bridge down')); await expect( @@ -881,6 +916,89 @@ describe('EmailService', () => { }); }); + describe('quota release on the compose paths', () => { + beforeEach(() => { + accountService.findBucketContextByAddress.mockResolvedValue( + BUCKET_CONTEXT, + ); + }); + + it('when sending consumes a draft, then the draft quota entry is released', async () => { + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'sent-id', deletedEntryKey: '42:7' }), + ); + + await service.sendEmail(userEmail, newSendEmailDto({ draftId: 'c' })); + + expect(usage.releaseStoredMessage).toHaveBeenCalledWith({ + userUuid: 'user-1', + bucketId: 'bucket-1', + entryKey: '42:7', + }); + }); + + it('when sending does not consume a draft, then no quota entry is released', async () => { + provider.sendEmail.mockResolvedValue( + newSendEmailResult({ id: 'sent-id' }), + ); + + await service.sendEmail(userEmail, newSendEmailDto()); + + expect(usage.releaseStoredMessage).not.toHaveBeenCalled(); + }); + + it('when an externally delivered email consumes a draft, then the draft quota entry is released', async () => { + configService.getOrThrow.mockReturnValue( + Buffer.from('server-priv-key').toString('base64'), + ); + smtp.sendRaw.mockResolvedValue({ messageId: 'smtp-id' }); + provider.saveToSent.mockResolvedValue( + newSendEmailResult({ id: 'sent-id', deletedEntryKey: '42:8' }), + ); + + await service.sendExternalEmail( + userEmail, + newSendEmailDto({ draftId: 'c' }), + ); + + expect(usage.releaseStoredMessage).toHaveBeenCalledWith({ + userUuid: 'user-1', + bucketId: 'bucket-1', + entryKey: '42:8', + }); + }); + + it('when a draft is updated, then the replaced draft quota entry is released', async () => { + provider.updateDraft.mockResolvedValue( + newUpdateDraftResult({ + draft: newEmail({ isDraft: true }), + deletedEntryKey: '42:9', + }), + ); + + await service.updateDraft(userEmail, 'draft-id', newDraftEmailDto()); + + expect(usage.releaseStoredMessage).toHaveBeenCalledWith({ + userUuid: 'user-1', + bucketId: 'bucket-1', + entryKey: '42:9', + }); + }); + + it('when a draft is discarded, then its quota entry is released', async () => { + provider.getDraft.mockResolvedValue(newEmail({ isDraft: true })); + provider.discardDraft.mockResolvedValue({ deletedEntryKey: '42:10' }); + + await service.discardDraft(userEmail, 'draft-id'); + + expect(usage.releaseStoredMessage).toHaveBeenCalledWith({ + userUuid: 'user-1', + bucketId: 'bucket-1', + entryKey: '42:10', + }); + }); + }); + describe('markAsRead', () => { it('when called with true, then delegates to provider', async () => { provider.markAsRead.mockResolvedValue(undefined); diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts index 17b6c31..e230f0e 100644 --- a/src/modules/email/email.service.ts +++ b/src/modules/email/email.service.ts @@ -27,8 +27,10 @@ import type { MailboxType, ReplyEmailDto, SearchEmailDto, + QuotaEntryKey, SendEmailDto, ThreadingHeaders, + UpdateDraftResult, } from './email.types.js'; import { isEncryptedBody, @@ -229,7 +231,15 @@ export class EmailService { }; } - return this.mail.sendEmail(userEmail, dto, threading); + const { id, deletedEntryKey } = await this.mail.sendEmail( + userEmail, + dto, + threading, + ); + + await this.releaseQuotaEntry(userEmail, deletedEntryKey); + + return { id }; } private async dispatchExternal( @@ -274,7 +284,7 @@ export class EmailService { references: threading?.references, }); - await this.mail.saveToSent( + const { deletedEntryKey } = await this.mail.saveToSent( userEmail, { ...dto, @@ -285,6 +295,8 @@ export class EmailService { messageId, ); + await this.releaseQuotaEntry(userEmail, deletedEntryKey); + return { id: messageId }; } @@ -352,7 +364,7 @@ export class EmailService { draftId: string, dto: DraftEmailDto, ): Promise { - let result: Email | null; + let result: UpdateDraftResult | null; try { result = await this.mail.updateDraft( userEmail, @@ -368,7 +380,10 @@ export class EmailService { if (!result) { throw new NotFoundException(`Draft ${draftId} not found`); } - return result; + + await this.releaseQuotaEntry(userEmail, result.deletedEntryKey); + + return result.draft; } private packDraftEnvelope(dto: DraftEmailDto): DraftEmailDto { @@ -389,7 +404,9 @@ export class EmailService { if (!draft) { throw new NotFoundException(`Draft ${id} not found`); } - await this.mail.discardDraft(userEmail, id); + const { deletedEntryKey } = await this.mail.discardDraft(userEmail, id); + + await this.releaseQuotaEntry(userEmail, deletedEntryKey); } moveEmail(userEmail: string, id: string, target: MailboxType): Promise { @@ -398,27 +415,28 @@ export class EmailService { async deleteEmail(userEmail: string, id: string): Promise { const { deletedEntryKey } = await this.mail.deleteEmail(userEmail, id); - if (!deletedEntryKey) return; await this.releaseQuotaEntry(userEmail, deletedEntryKey); } private async releaseQuotaEntry( userEmail: string, - entryKey: string, + entryKey: QuotaEntryKey | null, ): Promise { - const context = - await this.accountService.findBucketContextByAddress(userEmail); - - if (!context?.networkBucketId) { - this.logger.warn( - { userEmail, entryKey }, - 'Destroyed message has no network bucket; skipping quota release', - ); - return; - } + if (!entryKey) return; try { + const context = + await this.accountService.findBucketContextByAddress(userEmail); + + if (!context?.networkBucketId) { + this.logger.warn( + { userEmail, entryKey }, + 'Destroyed message has no network bucket; skipping quota release', + ); + return; + } + await this.usage.releaseStoredMessage({ userUuid: context.userUuid, bucketId: context.networkBucketId, diff --git a/src/modules/email/email.types.ts b/src/modules/email/email.types.ts index 9731cad..3bce666 100644 --- a/src/modules/email/email.types.ts +++ b/src/modules/email/email.types.ts @@ -22,8 +22,20 @@ export interface Mailbox { export type MailDeliveryMode = 'INTERNXT' | 'EXTERNAL'; -export interface DeleteEmailResult { - deletedEntryKey: string | null; +export type QuotaEntryKey = string; + +export interface DestroyedMessage { + deletedEntryKey: QuotaEntryKey | null; +} + +export type DeleteEmailResult = DestroyedMessage; + +export interface SendEmailResult extends DestroyedMessage { + id: string; +} + +export interface UpdateDraftResult extends DestroyedMessage { + draft: Email; } export interface EncryptedSummaryFields { diff --git a/src/modules/email/mail-provider.port.ts b/src/modules/email/mail-provider.port.ts index c2674a4..51dc7e3 100644 --- a/src/modules/email/mail-provider.port.ts +++ b/src/modules/email/mail-provider.port.ts @@ -16,7 +16,9 @@ import type { MailboxType, SearchEmailDto, SendEmailDto, + SendEmailResult, ThreadingHeaders, + UpdateDraftResult, } from './email.types.js'; export class DraftUpdateConflictError extends Error { @@ -49,14 +51,14 @@ export abstract class MailProvider { userEmail: string, dto: SendEmailDto, threading?: ThreadingHeaders, - ): Promise<{ id: string }>; + ): Promise; abstract search(params: SearchEmailDto): Promise; abstract saveToSent( userEmail: string, dto: SendEmailDto, threading?: ThreadingHeaders, messageId?: string, - ): Promise<{ id: string }>; + ): Promise; abstract getThreadingHeaders( userEmail: string, parentId: string, @@ -67,9 +69,12 @@ export abstract class MailProvider { userEmail: string, draftId: string, dto: DraftEmailDto, - ): Promise; + ): Promise; abstract getDraft(userEmail: string, id: string): Promise; - abstract discardDraft(userEmail: string, id: string): Promise; + abstract discardDraft( + userEmail: string, + id: string, + ): Promise; abstract moveEmail( userEmail: string, id: string, diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts index b9986e0..95e98bb 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts @@ -345,7 +345,10 @@ describe('JmapMailProvider', () => { const dto = newSendEmailDto(); const result = await provider.sendEmail('user@test.com', dto); - expect(result).toEqual({ id: 'created-email-id' }); + expect(result).toEqual({ + id: 'created-email-id', + deletedEntryKey: null, + }); }); it('When email creation fails, then it throws', async () => { @@ -372,6 +375,7 @@ describe('JmapMailProvider', () => { test('When sending from an existing draft, then the new email is created with the latest content and the previous draft is removed in the same operation', async () => { const sentMailbox = newJmapMailbox({ role: 'sent' }); const identity = newJmapIdentity(); + jmapService.getPrimaryAccountId.mockResolvedValue('b'); jmapService.request.mockResolvedValueOnce( jmapResponse({ list: [identity] }), @@ -386,15 +390,50 @@ describe('JmapMailProvider', () => { ), ); - const dto = newSendEmailDto({ draftId: 'old-draft-id' }); + const dto = newSendEmailDto({ draftId: 'c' }); const result = await provider.sendEmail('user@test.com', dto); const lastCall = jmapService.request.mock.calls.at(-1)!; const [emailSetName, emailSetArgs] = lastCall[1][0]!; expect(emailSetName).toBe('Email/set'); - expect(emailSetArgs['destroy']).toEqual(['old-draft-id']); + expect(emailSetArgs['destroy']).toEqual(['c']); expect(emailSetArgs['create']).toBeDefined(); - expect(result).toEqual({ id: 'sent-email-id' }); + expect(result).toEqual({ + id: 'sent-email-id', + deletedEntryKey: '1:2', + }); + }); + + test('When the draft could not be destroyed while sending, then no quota entry key is returned so its usage is not released', async () => { + const sentMailbox = newJmapMailbox({ role: 'sent' }); + const identity = newJmapIdentity(); + jmapService.getPrimaryAccountId.mockResolvedValue('b'); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [identity] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [sentMailbox] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapMultiResponse( + { + created: { draft: { id: 'sent-email-id' } }, + notDestroyed: { c: { type: 'notFound' } }, + }, + { created: { submission: { id: 'sub-id' } } }, + ), + ); + + const result = await provider.sendEmail( + 'user@test.com', + newSendEmailDto({ draftId: 'c' }), + ); + + expect(result).toEqual({ + id: 'sent-email-id', + deletedEntryKey: null, + }); }); test('When sending without a draftId, then the Email/set call does not include any destroy operation', async () => { @@ -511,11 +550,18 @@ describe('JmapMailProvider', () => { expect(result).toBeNull(); }); - test('When the draft is updated, then the updated email is returned to the user', async () => { + test('When the draft is updated, then the new draft is returned and the destroyed one comes back as a quota entry key', async () => { const draftsMailbox = newJmapMailbox({ role: 'drafts' }); const identity = newJmapIdentity(); - const existingDraft = newJmapEmail({ keywords: { $draft: true } }); - const updatedDraft = newJmapEmail({ keywords: { $draft: true } }); + const existingDraft = newJmapEmail({ + id: 'c', + keywords: { $draft: true }, + }); + const updatedDraft = newJmapEmail({ + id: 'd', + keywords: { $draft: true }, + }); + jmapService.getPrimaryAccountId.mockResolvedValue('b'); jmapService.request.mockResolvedValueOnce( jmapResponse({ list: [identity] }), @@ -540,7 +586,8 @@ describe('JmapMailProvider', () => { ); expect(result).not.toBeNull(); - expect(result!.id).toBe(updatedDraft.id); + expect(result!.draft.id).toBe(updatedDraft.id); + expect(result!.deletedEntryKey).toBe('1:2'); }); test('When the draft is updated, then the destroy+create is guarded with ifInState from the prior read', async () => { @@ -614,7 +661,7 @@ describe('JmapMailProvider', () => { newDraftEmailDto(), ); - expect(result!.id).toBe(updatedDraft.id); + expect(result!.draft.id).toBe(updatedDraft.id); const setCalls = jmapService.request.mock.calls.filter( (call) => call[1][0]![0] === 'Email/set', ); @@ -694,17 +741,30 @@ describe('JmapMailProvider', () => { }); describe('Discarding Draft', () => { - test('When discarding a draft, then the draft is removed from the user mailbox', async () => { + test('When discarding a draft, then the draft is removed from the user mailbox and its quota entry key is returned', async () => { + jmapService.getPrimaryAccountId.mockResolvedValue('b'); jmapService.request.mockResolvedValueOnce( - jmapResponse({ destroyed: ['draft-1'] }), + jmapResponse({ destroyed: ['c'] }), ); - await provider.discardDraft('user@test.com', 'draft-1'); + const result = await provider.discardDraft('user@test.com', 'c'); const lastCall = jmapService.request.mock.calls.at(-1)!; const [methodName, methodArgs] = lastCall[1][0]!; expect(methodName).toBe('Email/set'); - expect(methodArgs['destroy']).toEqual(['draft-1']); + expect(methodArgs['destroy']).toEqual(['c']); + expect(result).toEqual({ deletedEntryKey: '1:2' }); + }); + + test('When the discarded draft id cannot be decoded, then no quota entry key is returned instead of failing the discard', async () => { + jmapService.getPrimaryAccountId.mockResolvedValue('account-123'); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ destroyed: ['draft-1'] }), + ); + + await expect( + provider.discardDraft('user@test.com', 'draft-1'), + ).resolves.toEqual({ deletedEntryKey: null }); }); test('When the draft cannot be removed, then the user gets an error describing the reason', async () => { diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.ts index b5450b9..77a9d39 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.ts @@ -15,8 +15,11 @@ import type { Mailbox, MailboxType, SearchEmailFilter, + QuotaEntryKey, SendEmailDto, + SendEmailResult, ThreadingHeaders, + UpdateDraftResult, } from '../../email/email.types.js'; import { decodeStalwartIdBig } from '../stalwart/stalwart-id.codec.js'; import { @@ -427,7 +430,7 @@ export class JmapMailProvider extends MailProvider { userEmail: string, dto: SendEmailDto, threading?: ThreadingHeaders, - ): Promise<{ id: string }> { + ): Promise { const session = await this.jmap.getSession(userEmail); const [accountId, identity, sentMailboxId] = await Promise.all([ this.jmap.getPrimaryAccountId(userEmail, session), @@ -479,7 +482,29 @@ export class JmapMailProvider extends MailProvider { throw new Error('Failed to create email for sending'); } - return { id: createdId }; + return { + id: createdId, + deletedEntryKey: dto.draftId + ? this.entryKeyIfDestroyed(accountId, dto.draftId, emailResult) + : null, + }; + } + + private entryKeyIfDestroyed( + accountId: string, + emailId: string, + result: JmapSetResponse, + ): QuotaEntryKey | null { + const notDestroyed = result.notDestroyed?.[emailId]; + + if (notDestroyed) { + this.logger.warn( + `Could not destroy message ${emailId}: ${notDestroyed.description ?? notDestroyed.type}`, + ); + return null; + } + + return this.buildEntryKey(accountId, emailId); } private async destroyDraft( @@ -487,26 +512,26 @@ export class JmapMailProvider extends MailProvider { accountId: string, draftId: string, session: JmapSession, - ): Promise { + ): Promise { try { const response = await this.jmap.request>( userEmail, [['Email/set', { accountId, destroy: [draftId] }, 'r0']], { session }, ); - const notDestroyed = - response.methodResponses[0]![1].notDestroyed?.[draftId]; - if (notDestroyed) { - this.logger.warn( - `Could not destroy draft ${draftId} after sending: ${notDestroyed.description ?? notDestroyed.type}`, - ); - } + + return this.entryKeyIfDestroyed( + accountId, + draftId, + response.methodResponses[0]![1], + ); } catch (err) { this.logger.warn( `Failed to destroy draft ${draftId} after sending: ${ err instanceof Error ? err.message : String(err) }`, ); + return null; } } @@ -515,7 +540,7 @@ export class JmapMailProvider extends MailProvider { dto: SendEmailDto, threading?: ThreadingHeaders, messageId?: string, - ): Promise<{ id: string }> { + ): Promise { const session = await this.jmap.getSession(userEmail); const [accountId, identity, sentMailboxId] = await Promise.all([ this.jmap.getPrimaryAccountId(userEmail, session), @@ -551,11 +576,12 @@ export class JmapMailProvider extends MailProvider { throw new Error('Failed to save email to Sent'); } - if (dto.draftId) { - await this.destroyDraft(userEmail, accountId, dto.draftId, session); - } - - return { id: createdId }; + return { + id: createdId, + deletedEntryKey: dto.draftId + ? await this.destroyDraft(userEmail, accountId, dto.draftId, session) + : null, + }; } async getThreadingHeaders( @@ -726,7 +752,7 @@ export class JmapMailProvider extends MailProvider { userEmail: string, draftId: string, dto: DraftEmailDto, - ): Promise { + ): Promise { const session = await this.jmap.getSession(userEmail); const [accountId, identity, draftsMailboxId] = await Promise.all([ this.jmap.getPrimaryAccountId(userEmail, session), @@ -823,13 +849,19 @@ export class JmapMailProvider extends MailProvider { throw new Error('Failed to fetch the updated draft'); } - return updatedDraft; + return { + draft: updatedDraft, + deletedEntryKey: this.buildEntryKey(accountId, draftId), + }; } throw new DraftUpdateConflictError(draftId); } - async discardDraft(userEmail: string, id: string): Promise { + async discardDraft( + userEmail: string, + id: string, + ): Promise { const session = await this.jmap.getSession(userEmail); const accountId = await this.jmap.getPrimaryAccountId(userEmail, session); const response = await this.jmap.request>( @@ -843,6 +875,8 @@ export class JmapMailProvider extends MailProvider { `Failed to discard draft ${id}: ${notDestroyed.description ?? notDestroyed.type}`, ); } + + return { deletedEntryKey: this.buildEntryKey(accountId, id) }; } async getDraft(userEmail: string, id: string): Promise { @@ -934,10 +968,22 @@ export class JmapMailProvider extends MailProvider { return { deletedEntryKey: null }; } - private buildEntryKey(accountId: string, emailId: string): string { - const numericAccountId = decodeStalwartIdBig(accountId) & 0xffffffffn; - const documentId = decodeStalwartIdBig(emailId) & 0xffffffffn; - return `${numericAccountId}:${documentId}`; + private buildEntryKey( + accountId: string, + emailId: string, + ): QuotaEntryKey | null { + try { + const numericAccountId = decodeStalwartIdBig(accountId) & 0xffffffffn; + const documentId = decodeStalwartIdBig(emailId) & 0xffffffffn; + return `${numericAccountId}:${documentId}`; + } catch (err) { + this.logger.error( + `Could not derive a quota entry key from account '${accountId}' and email '${emailId}': ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } } async markAsRead( diff --git a/test/fixtures.ts b/test/fixtures.ts index d6c2123..ce1e171 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -11,6 +11,8 @@ import type { EncryptedWrappedKey, EncryptionBlock, ThreadingHeaders, + SendEmailResult, + UpdateDraftResult, } from '../src/modules/email/email.types.js'; import type { UserPayload } from '../src/modules/auth/jwt-payload.dto.js'; import { @@ -441,3 +443,23 @@ export function newMailQuota(attrs?: Partial): MailQuota { ...attrs, }; } + +export function newSendEmailResult( + attrs?: Partial, +): SendEmailResult { + return { + id: randomId(), + deletedEntryKey: null, + ...attrs, + }; +} + +export function newUpdateDraftResult( + attrs?: Partial, +): UpdateDraftResult { + return { + draft: newEmail({ isDraft: true }), + deletedEntryKey: null, + ...attrs, + }; +}