diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts index 705f1ca..f422d9d 100644 --- a/src/modules/email/email.service.ts +++ b/src/modules/email/email.service.ts @@ -12,6 +12,7 @@ import type { EmailListResponse, EmailSummary, ListEmails, + MailQuota, Mailbox, MailboxType, SearchEmailDto, @@ -162,4 +163,8 @@ export class EmailService { ): Promise { return this.mail.downloadAttachment(payload); } + + getQuota(userEmail: string): Promise { + return this.mail.getQuota(userEmail); + } } diff --git a/src/modules/email/email.types.ts b/src/modules/email/email.types.ts index 1fb3525..c9c76c6 100644 --- a/src/modules/email/email.types.ts +++ b/src/modules/email/email.types.ts @@ -123,3 +123,8 @@ export interface SearchEmailFilter { unread?: boolean; hasAttachment?: boolean; } + +export interface MailQuota { + used: number; + limit: number; +} diff --git a/src/modules/email/mail-provider.port.ts b/src/modules/email/mail-provider.port.ts index a0a7053..b140357 100644 --- a/src/modules/email/mail-provider.port.ts +++ b/src/modules/email/mail-provider.port.ts @@ -9,6 +9,7 @@ import type { Email, EmailListResponse, ListEmails, + MailQuota, Mailbox, MailboxType, SearchEmailDto, @@ -54,4 +55,5 @@ export abstract class MailProvider { abstract downloadAttachment( payload: DownloadAttachmentPayload, ): Promise; + abstract getQuota(userEmail: string): Promise; } diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts index 74488e2..8c2a4f9 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts @@ -8,6 +8,7 @@ import { newJmapMailbox, newJmapEmail, newJmapIdentity, + newJmapQuota, newSendEmailDto, newDraftEmailDto, } from '../../../../test/fixtures.js'; @@ -584,4 +585,34 @@ describe('JmapMailProvider', () => { expect(result).toBe(stored); }); }); + + describe('getQuota', () => { + it('when quota exists, then returns used and limit from octets quota', async () => { + const quota = newJmapQuota({ used: 500_000, hardLimit: 1_000_000 }); + jmapService.request.mockResolvedValue(jmapResponse({ list: [quota] })); + + const result = await provider.getQuota('user@test.com'); + + expect(result).toEqual({ used: 500_000, limit: 1_000_000 }); + }); + + it('when no octets quota exists, then returns zeros', async () => { + const nonOctetsQuota = newJmapQuota({ resourceType: 'count' }); + jmapService.request.mockResolvedValue( + jmapResponse({ list: [nonOctetsQuota] }), + ); + + const result = await provider.getQuota('user@test.com'); + + expect(result).toEqual({ used: 0, limit: 0 }); + }); + + it('when quota list is empty, then returns zeros', async () => { + jmapService.request.mockResolvedValue(jmapResponse({ list: [] })); + + const result = await provider.getQuota('user@test.com'); + + expect(result).toEqual({ used: 0, limit: 0 }); + }); + }); }); diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.ts index fb7282a..db78600 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.ts @@ -5,17 +5,19 @@ import type { Email, EmailListResponse, ListEmails, + MailQuota, Mailbox, MailboxType, SearchEmailFilter, SendEmailDto, } from '../../email/email.types.js'; -import { JmapService } from './jmap.service.js'; +import { JMAP_QUOTA_CAPABILITIES, JmapService } from './jmap.service.js'; import type { DownloadAttachmentPayload, DownloadAttachmentResponse, Email as JmapEmail, Identity, + JmapQuota, Mailbox as JmapMailbox, JmapGetResponse, JmapQueryResponse, @@ -442,6 +444,24 @@ export class JmapMailProvider extends MailProvider { return this.jmap.downloadAttachment(payload); } + async getQuota(userEmail: string): Promise { + const accountId = await this.jmap.getPrimaryAccountId(userEmail); + + const response = await this.jmap.request>( + userEmail, + [['Quota/get', { accountId }, 'r0']], + JMAP_QUOTA_CAPABILITIES, + ); + + const quotas = response.methodResponses[0]![1].list; + const bytesQuota = quotas.find((q) => q.resourceType === 'octets'); + + return { + used: bytesQuota?.used ?? 0, + limit: bytesQuota?.hardLimit ?? 0, + }; + } + private async setKeyword( userEmail: string, id: string, diff --git a/src/modules/infrastructure/jmap/jmap.service.ts b/src/modules/infrastructure/jmap/jmap.service.ts index b31c405..242ed29 100644 --- a/src/modules/infrastructure/jmap/jmap.service.ts +++ b/src/modules/infrastructure/jmap/jmap.service.ts @@ -23,6 +23,7 @@ import type { const JMAP_CAPABILITY_CORE = 'urn:ietf:params:jmap:core'; const JMAP_CAPABILITY_MAIL = 'urn:ietf:params:jmap:mail'; const JMAP_CAPABILITY_SUBMISSION = 'urn:ietf:params:jmap:submission'; +export const JMAP_CAPABILITY_QUOTA = 'urn:ietf:params:jmap:quota'; const JMAP_MAIL_CAPABILITIES = [ JMAP_CAPABILITY_CORE, @@ -30,6 +31,11 @@ const JMAP_MAIL_CAPABILITIES = [ JMAP_CAPABILITY_SUBMISSION, ] as const; +export const JMAP_QUOTA_CAPABILITIES = [ + JMAP_CAPABILITY_CORE, + JMAP_CAPABILITY_QUOTA, +] as const; + const SESSION_TTL_MS = 30_000; interface CachedSession { diff --git a/src/modules/infrastructure/jmap/jmap.types.ts b/src/modules/infrastructure/jmap/jmap.types.ts index c051ab0..5fd441a 100644 --- a/src/modules/infrastructure/jmap/jmap.types.ts +++ b/src/modules/infrastructure/jmap/jmap.types.ts @@ -242,3 +242,15 @@ export interface DeliveryStatus { delivered: 'queued' | 'yes' | 'no' | 'unknown'; displayed: 'yes' | 'unknown'; } + +export interface JmapQuota { + id: ID; + resourceType: string; + used: number; + hardLimit: number; + scope: string; + name: string; + description?: string; + warnLimit?: number; + softLimit?: number; +} diff --git a/test/fixtures.ts b/test/fixtures.ts index 5884ba6..5cd950e 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -33,8 +33,10 @@ import type { EmailAddress as JmapEmailAddress, MailboxRole, Identity, + JmapQuota, } from '../src/modules/infrastructure/jmap/jmap.types.js'; import type { DeepMocked } from '@golevelup/ts-vitest'; +import type { MailQuota } from '../src/modules/email/email.types.js'; export type DeepPartial = T extends Array @@ -379,3 +381,23 @@ export function newSearchEmailDto( ...attrs, }; } + +export function newJmapQuota(attrs?: Partial): JmapQuota { + return { + id: randomId(), + resourceType: 'octets', + used: random.natural({ min: 0, max: 1_000_000_000 }), + hardLimit: random.natural({ min: 1_000_000_000, max: 10_000_000_000 }), + scope: 'account', + name: 'Mail storage', + ...attrs, + }; +} + +export function newMailQuota(attrs?: Partial): MailQuota { + return { + used: random.natural({ min: 0, max: 1_000_000_000 }), + limit: random.natural({ min: 1_000_000_000, max: 10_000_000_000 }), + ...attrs, + }; +}