diff --git a/package-lock.json b/package-lock.json index 81a70e8..0ef9d8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@nestjs/axios": "^4.0.1", "@nestjs/cli": "^11.0.16", "@nestjs/common": "^11.1.15", "@nestjs/config": "^4.0.3", @@ -17,7 +16,6 @@ "@nestjs/event-emitter": "^3.0.1", "@nestjs/platform-express": "^11.1.15", "@nestjs/swagger": "^11.2.6", - "axios": "^1.13.6", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "cross-env": "^10.1.0", @@ -29,17 +27,21 @@ "reflect-metadata": "^0.2.2", "rimraf": "^6.1.3", "rxjs": "^7.8.2", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "undici": "^7.22.0" }, "devDependencies": { "@eslint/js": "^9.18.0", + "@golevelup/ts-vitest": "^3.0.0", "@internxt/eslint-config-internxt": "2.0.1", "@nestjs/schematics": "^11.0.9", "@nestjs/testing": "^11.1.15", "@swc/core": "^1.15.18", + "@types/chance": "^1.1.7", "@types/express": "^5.0.6", "@types/node": "^22.15.0", "@vitest/coverage-v8": "^3.2.4", + "chance": "^1.1.13", "eslint": "^9.18.0", "eslint-plugin-prettier": "^5.5.5", "globals": "^16.1.0", @@ -195,6 +197,12 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -903,6 +911,13 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@golevelup/ts-vitest": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@golevelup/ts-vitest/-/ts-vitest-3.0.0.tgz", + "integrity": "sha512-1rhgwzsFk3PcGYpTSuMUws94iuCUY5NAmDqWryonTbgq70WrUx9PBCp/5L/NziaZvmD+79k6/bbZX7mTSghPrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1462,17 +1477,6 @@ "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, - "node_modules/@nestjs/axios": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", - "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", - "license": "MIT", - "peerDependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0", - "axios": "^1.3.1", - "rxjs": "^7.0.0" - } - }, "node_modules/@nestjs/cli": { "version": "11.0.16", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.16.tgz", @@ -1884,6 +1888,13 @@ } } }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -2513,6 +2524,13 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/chance": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@types/chance/-/chance-1.1.7.tgz", + "integrity": "sha512-40you9610GTQPJyvjMBgmj9wiDO6qXhbfjizNYod/fmvLSfUUxURAJMTD8tjmbcZSsyYE5iEUox61AAcCjW/wQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3006,16 +3024,6 @@ } } }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", @@ -3448,27 +3456,11 @@ "js-tokens": "^10.0.0" } }, - "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -3480,18 +3472,6 @@ "node": ">=8.0.0" } }, - "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3760,6 +3740,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chance": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/chance/-/chance-1.1.13.tgz", + "integrity": "sha512-V6lQCljcLznE7tUYUM9EOAnnKXbctE6j/rdQkYOHIWbfGQbrzTsAXNW9CdU5XCo4ArXQCj/rb6HgxPlmGJcaUg==", + "dev": true, + "license": "MIT" + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -3943,6 +3930,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4209,6 +4197,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4406,6 +4395,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4714,11 +4704,14 @@ } }, "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } }, "node_modules/esutils": { "version": "2.0.3", @@ -4977,26 +4970,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5045,6 +5018,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -5061,6 +5035,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5070,6 +5045,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -5345,6 +5321,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5709,9 +5686,10 @@ } }, "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -7027,12 +7005,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -8524,6 +8496,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -8773,6 +8754,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/package.json b/package.json index 94bcac5..28ff66d 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "prepare": "husky" }, "dependencies": { - "@nestjs/axios": "^4.0.1", "@nestjs/cli": "^11.0.16", "@nestjs/common": "^11.1.15", "@nestjs/config": "^4.0.3", @@ -29,7 +28,6 @@ "@nestjs/event-emitter": "^3.0.1", "@nestjs/platform-express": "^11.1.15", "@nestjs/swagger": "^11.2.6", - "axios": "^1.13.6", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "cross-env": "^10.1.0", @@ -41,17 +39,21 @@ "reflect-metadata": "^0.2.2", "rimraf": "^6.1.3", "rxjs": "^7.8.2", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "undici": "^7.22.0" }, "devDependencies": { "@eslint/js": "^9.18.0", + "@golevelup/ts-vitest": "^3.0.0", "@internxt/eslint-config-internxt": "2.0.1", "@nestjs/schematics": "^11.0.9", "@nestjs/testing": "^11.1.15", "@swc/core": "^1.15.18", + "@types/chance": "^1.1.7", "@types/express": "^5.0.6", "@types/node": "^22.15.0", "@vitest/coverage-v8": "^3.2.4", + "chance": "^1.1.13", "eslint": "^9.18.0", "eslint-plugin-prettier": "^5.5.5", "globals": "^16.1.0", diff --git a/src/app.module.ts b/src/app.module.ts index 2e5b186..2e8c781 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,7 +5,7 @@ import { LoggerModule } from 'nestjs-pino'; import { nanoid } from 'nanoid'; import configuration from './config/configuration'; import { HealthModule } from './modules/health/health.module'; -import { JmapModule } from './modules/jmap/jmap.module'; +import { JmapModule } from './modules/infrastructure/jmap/jmap.module'; import { EmailModule } from './modules/email/email.module'; import { AuthModule } from './modules/auth/auth.module'; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 28d662a..138fe43 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -8,6 +8,8 @@ export default () => ({ url: process.env.STALWART_JMAP_URL ?? 'http://localhost:8085', adminUrl: process.env.STALWART_ADMIN_URL ?? 'http://localhost:8085', adminToken: process.env.STALWART_ADMIN_TOKEN ?? '', + masterUser: process.env.STALWART_MASTER_USER ?? 'master', + masterPassword: process.env.STALWART_MASTER_PASSWORD ?? '', }, secrets: { diff --git a/src/modules/email/email.controller.spec.ts b/src/modules/email/email.controller.spec.ts new file mode 100644 index 0000000..362bf12 --- /dev/null +++ b/src/modules/email/email.controller.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { EmailController, STUB_USER } from './email.controller.js'; +import { EmailService } from './email.service.js'; +import { newMailbox, newEmailSummary } from '../../../test/fixtures.js'; + +describe('EmailController', () => { + let controller: EmailController; + let emailService: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [EmailController], + }) + .useMocker(() => createMock()) + .compile(); + + controller = module.get(EmailController); + emailService = module.get(EmailService); + }); + + describe('getMailboxes', () => { + it('When getMailboxes is called, then it returns the mailboxes', async () => { + const mailboxes = [newMailbox(), newMailbox()]; + emailService.getMailboxes.mockResolvedValue(mailboxes); + + const result = await controller.getMailboxes(); + + expect(result).toBe(mailboxes); + }); + }); + + describe('list', () => { + it('When list is called with no query params, then it uses defaults', async () => { + const response = { emails: [newEmailSummary()], total: 1 }; + emailService.listEmails.mockResolvedValue(response); + + const result = await controller.list('inbox'); + + expect(emailService.listEmails.mock.calls[0]).toEqual([ + STUB_USER, + 'inbox', + 20, + 0, + ]); + expect(result).toBe(response); + }); + + it('When list is called with limit and position, then it parses them', async () => { + emailService.listEmails.mockResolvedValue({ emails: [], total: 0 }); + + await controller.list('sent', '10', '5'); + + expect(emailService.listEmails.mock.calls[0]).toEqual([ + STUB_USER, + 'sent', + 10, + 5, + ]); + }); + + it('When list is called with non-numeric strings, then it falls back to defaults', async () => { + emailService.listEmails.mockResolvedValue({ emails: [], total: 0 }); + + await controller.list('inbox', 'abc', 'xyz'); + + expect(emailService.listEmails.mock.calls[0]).toEqual([ + STUB_USER, + 'inbox', + 20, + 0, + ]); + }); + }); +}); diff --git a/src/modules/email/email.controller.ts b/src/modules/email/email.controller.ts index 17bc2d4..3de426a 100644 --- a/src/modules/email/email.controller.ts +++ b/src/modules/email/email.controller.ts @@ -1,19 +1,178 @@ -import { Controller, Get, Param } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { EmailUsecase } from './email.usecase'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { EmailService } from './email.service.js'; +import { + DraftEmailRequestDto, + EmailCreatedResponseDto, + EmailListResponseDto, + EmailResponseDto, + MailboxResponseDto, + SendEmailRequestDto, + UpdateEmailRequestDto, +} from './email.dto.js'; +import type { MailboxType } from './email.types.js'; +// TODO: Replace with actual authenticated user from AuthGuard +export const STUB_USER = 'jose@codekishi.com'; + +@ApiBearerAuth() @ApiTags('Email') @Controller('email') export class EmailController { - constructor(private readonly emailUsecase: EmailUsecase) {} + constructor(private readonly emailService: EmailService) {} + + @Get('mailboxes') + @ApiOperation({ + summary: 'List mailboxes', + description: + 'Returns every mailbox for the authenticated user, including folder counts.', + }) + @ApiOkResponse({ type: [MailboxResponseDto] }) + getMailboxes() { + return this.emailService.getMailboxes(STUB_USER); + } @Get() - list() { - return this.emailUsecase.list(); + @ApiOperation({ + summary: 'List emails', + description: + 'Paginated list of email summaries for a given mailbox. Defaults to the inbox.', + }) + @ApiQuery({ + name: 'mailbox', + required: false, + enum: ['inbox', 'drafts', 'sent', 'trash', 'spam', 'archive'], + description: 'Mailbox to list. Defaults to `inbox`.', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Maximum number of emails to return. Defaults to `20`.', + example: 20, + }) + @ApiQuery({ + name: 'position', + required: false, + type: Number, + description: 'Zero-based offset for pagination. Defaults to `0`.', + example: 0, + }) + @ApiOkResponse({ type: EmailListResponseDto }) + list( + @Query('mailbox') mailbox: MailboxType = 'inbox', + @Query('limit') limit?: string, + @Query('position') position?: string, + ) { + return this.emailService.listEmails( + STUB_USER, + mailbox, + limit ? Number(limit) || 20 : 20, + position ? Number(position) || 0 : 0, + ); } @Get(':id') + @ApiOperation({ + summary: 'Get email by ID', + description: + 'Returns the full email including body content, headers, and metadata.', + }) + @ApiParam({ name: 'id', description: 'Email ID' }) + @ApiOkResponse({ type: EmailResponseDto }) + @ApiNotFoundResponse({ description: 'Email not found' }) get(@Param('id') id: string) { - return this.emailUsecase.get(id); + return this.emailService.getEmail(STUB_USER, id); + } + + @Post('send') + @ApiOperation({ + summary: 'Send an email', + description: + 'Composes and sends an email on behalf of the authenticated user. ' + + 'At least one recipient in `to` is required.', + }) + @ApiBody({ type: SendEmailRequestDto }) + @ApiOkResponse({ + type: EmailCreatedResponseDto, + description: 'Email sent successfully', + }) + send(@Body() dto: SendEmailRequestDto) { + return this.emailService.sendEmail(STUB_USER, dto); + } + + @Post('drafts') + @ApiOperation({ + summary: 'Save a draft', + description: + 'Creates a new draft email. All fields are optional so partial drafts can be saved.', + }) + @ApiBody({ type: DraftEmailRequestDto }) + @ApiOkResponse({ + type: EmailCreatedResponseDto, + description: 'Draft saved successfully', + }) + saveDraft(@Body() dto: DraftEmailRequestDto) { + return this.emailService.saveDraft(STUB_USER, dto); + } + + @Patch(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Update an email', + description: + 'Partially update an email: move it to another mailbox, mark as read/unread, ' + + 'or flag/unflag. Multiple operations can be combined in a single request.', + }) + @ApiParam({ name: 'id', description: 'Email ID' }) + @ApiBody({ type: UpdateEmailRequestDto }) + @ApiNoContentResponse({ description: 'Email updated successfully' }) + @ApiNotFoundResponse({ description: 'Email not found' }) + async update(@Param('id') id: string, @Body() body: UpdateEmailRequestDto) { + const ops: Promise[] = []; + if (body.mailbox !== undefined) { + ops.push(this.emailService.moveEmail(STUB_USER, id, body.mailbox)); + } + if (body.isRead !== undefined) { + ops.push(this.emailService.markAsRead(STUB_USER, id, body.isRead)); + } + if (body.isFlagged !== undefined) { + ops.push(this.emailService.markAsFlagged(STUB_USER, id, body.isFlagged)); + } + await Promise.all(ops); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete an email', + description: 'Permanently deletes an email by ID.', + }) + @ApiParam({ name: 'id', description: 'Email ID' }) + @ApiNoContentResponse({ description: 'Email deleted successfully' }) + @ApiNotFoundResponse({ description: 'Email not found' }) + delete(@Param('id') id: string) { + return this.emailService.deleteEmail(STUB_USER, id); } } diff --git a/src/modules/email/email.dto.ts b/src/modules/email/email.dto.ts new file mode 100644 index 0000000..c44002c --- /dev/null +++ b/src/modules/email/email.dto.ts @@ -0,0 +1,178 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import type { MailboxType } from './email.types.js'; + +export class EmailAddressDto { + @ApiPropertyOptional({ example: 'Alice Smith' }) + name?: string; + + @ApiProperty({ example: 'alice@internxt.me' }) + email!: string; +} + +export class SendEmailRequestDto { + @ApiProperty({ + type: [EmailAddressDto], + description: 'Primary recipients (at least one required)', + }) + to!: EmailAddressDto[]; + + @ApiPropertyOptional({ type: [EmailAddressDto] }) + cc?: EmailAddressDto[]; + + @ApiPropertyOptional({ type: [EmailAddressDto] }) + bcc?: EmailAddressDto[]; + + @ApiProperty({ example: 'Weekly sync notes' }) + subject!: string; + + @ApiPropertyOptional({ + example: 'Hi team, here are the notes from today…', + description: 'Plain-text version of the email body', + }) + textBody?: string; + + @ApiPropertyOptional({ + example: '

Hi team, here are the notes from today…

', + description: 'HTML version of the email body', + }) + htmlBody?: string; +} + +export class DraftEmailRequestDto { + @ApiPropertyOptional({ type: [EmailAddressDto] }) + to?: EmailAddressDto[]; + + @ApiPropertyOptional({ type: [EmailAddressDto] }) + cc?: EmailAddressDto[]; + + @ApiPropertyOptional({ type: [EmailAddressDto] }) + bcc?: EmailAddressDto[]; + + @ApiPropertyOptional({ example: 'Draft: project update' }) + subject?: string; + + @ApiPropertyOptional({ example: 'Still working on this…' }) + textBody?: string; + + @ApiPropertyOptional({ example: '

Still working on this…

' }) + htmlBody?: string; +} + +export class UpdateEmailRequestDto { + @ApiPropertyOptional({ + enum: ['inbox', 'drafts', 'sent', 'trash', 'spam', 'archive'], + description: 'Move the email to this mailbox', + example: 'trash', + }) + mailbox?: MailboxType; + + @ApiPropertyOptional({ + description: 'Mark the email as read or unread', + example: true, + }) + isRead?: boolean; + + @ApiPropertyOptional({ + description: 'Flag or unflag the email', + example: false, + }) + isFlagged?: boolean; +} + +export class MailboxResponseDto { + @ApiProperty({ example: 'f3a1b2c4-…' }) + id!: string; + + @ApiProperty({ example: 'Inbox' }) + name!: string; + + @ApiProperty({ + enum: ['inbox', 'drafts', 'sent', 'trash', 'spam', 'archive'], + nullable: true, + example: 'inbox', + }) + type!: MailboxType | null; + + @ApiProperty({ nullable: true, example: null }) + parentId!: string | null; + + @ApiProperty({ example: 142 }) + totalEmails!: number; + + @ApiProperty({ example: 3 }) + unreadEmails!: number; +} + +export class EmailSummaryResponseDto { + @ApiProperty({ example: 'Ma1f09b…' }) + id!: string; + + @ApiProperty({ example: 'T1a2b3c…' }) + threadId!: string; + + @ApiProperty({ type: [EmailAddressDto] }) + from!: EmailAddressDto[]; + + @ApiProperty({ type: [EmailAddressDto] }) + to!: EmailAddressDto[]; + + @ApiProperty({ example: 'Weekly sync notes' }) + subject!: string; + + @ApiProperty({ example: '2025-06-15T10:30:00Z' }) + receivedAt!: string; + + @ApiProperty({ example: 'Hi team, here are the notes from…' }) + preview!: string; + + @ApiProperty({ example: true }) + isRead!: boolean; + + @ApiProperty({ example: false }) + isFlagged!: boolean; + + @ApiProperty({ example: false }) + hasAttachment!: boolean; + + @ApiProperty({ example: 4096, description: 'Size in bytes' }) + size!: number; +} + +export class EmailResponseDto extends EmailSummaryResponseDto { + @ApiProperty({ type: [EmailAddressDto] }) + cc!: EmailAddressDto[]; + + @ApiProperty({ type: [EmailAddressDto] }) + bcc!: EmailAddressDto[]; + + @ApiProperty({ type: [EmailAddressDto] }) + replyTo!: EmailAddressDto[]; + + @ApiProperty({ nullable: true, example: '2025-06-15T10:29:55Z' }) + sentAt!: string | null; + + @ApiProperty({ nullable: true, example: 'Hi team, here are the notes…' }) + textBody!: string | null; + + @ApiProperty({ + nullable: true, + example: '

Hi team, here are the notes…

', + }) + htmlBody!: string | null; +} + +export class EmailListResponseDto { + @ApiProperty({ type: [EmailSummaryResponseDto] }) + emails!: EmailSummaryResponseDto[]; + + @ApiProperty({ example: 142, description: 'Total emails in the mailbox' }) + total!: number; +} + +export class EmailCreatedResponseDto { + @ApiProperty({ + example: 'Ma1f09b…', + description: 'ID of the created email', + }) + id!: string; +} diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts index 8ccb044..822c0d6 100644 --- a/src/modules/email/email.module.ts +++ b/src/modules/email/email.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; -import { JmapModule } from '../jmap/jmap.module'; -import { EmailController } from './email.controller'; -import { EmailUsecase } from './email.usecase'; +import { JmapModule } from '../infrastructure/jmap/jmap.module.js'; +import { EmailController } from './email.controller.js'; +import { EmailService } from './email.service.js'; @Module({ imports: [JmapModule], controllers: [EmailController], - providers: [EmailUsecase], + providers: [EmailService], }) export class EmailModule {} diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts new file mode 100644 index 0000000..67ddd40 --- /dev/null +++ b/src/modules/email/email.service.spec.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { EmailService } from './email.service.js'; +import { type MailProvider } from './mail-provider.port.js'; +import { + newMailbox, + newEmail, + newEmailSummary, + newSendEmailDto, + newDraftEmailDto, +} from '../../../test/fixtures.js'; + +type MockMailProvider = { + [K in keyof MailProvider]: ReturnType; +}; + +function createMockMailProvider(): MockMailProvider { + return { + getMailboxes: vi.fn(), + listEmails: vi.fn(), + getEmail: vi.fn(), + sendEmail: vi.fn(), + saveDraft: vi.fn(), + moveEmail: vi.fn(), + deleteEmail: vi.fn(), + markAsRead: vi.fn(), + markAsFlagged: vi.fn(), + }; +} + +describe('EmailService', () => { + let service: EmailService; + let provider: MockMailProvider; + const userEmail = 'test@example.com'; + + beforeEach(() => { + provider = createMockMailProvider(); + service = new EmailService(provider); + }); + + describe('getMailboxes', () => { + it('when called, then delegates to mail provider', async () => { + const mailboxes = [newMailbox(), newMailbox()]; + provider.getMailboxes.mockResolvedValue(mailboxes); + + const result = await service.getMailboxes(userEmail); + + expect(provider.getMailboxes).toHaveBeenCalledWith(userEmail); + expect(result).toBe(mailboxes); + }); + }); + + describe('listEmails', () => { + it('when called, then delegates with all parameters', async () => { + const response = { + emails: [newEmailSummary()], + total: 1, + }; + provider.listEmails.mockResolvedValue(response); + + const result = await service.listEmails(userEmail, 'inbox', 20, 0); + + expect(provider.listEmails).toHaveBeenCalledWith( + userEmail, + 'inbox', + 20, + 0, + ); + expect(result).toBe(response); + }); + }); + + describe('getEmail', () => { + it('when email exists, then returns it', async () => { + const email = newEmail(); + provider.getEmail.mockResolvedValue(email); + + const result = await service.getEmail(userEmail, email.id); + + expect(result).toBe(email); + }); + + it('when email does not exist, then throws NotFoundException', async () => { + provider.getEmail.mockResolvedValue(null); + + await expect(service.getEmail(userEmail, 'nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('sendEmail', () => { + it('when DTO has recipients, then delegates to provider', async () => { + const dto = newSendEmailDto(); + provider.sendEmail.mockResolvedValue({ id: 'created-id' }); + + const result = await service.sendEmail(userEmail, dto); + + expect(provider.sendEmail).toHaveBeenCalledWith(userEmail, dto); + expect(result).toEqual({ id: 'created-id' }); + }); + + it('when DTO has empty recipients, then throws BadRequestException', async () => { + const dto = newSendEmailDto({ to: [] }); + + await expect(service.sendEmail(userEmail, dto)).rejects.toThrow( + BadRequestException, + ); + expect(provider.sendEmail).not.toHaveBeenCalled(); + }); + }); + + describe('saveDraft', () => { + it('when called, then delegates to provider', async () => { + const dto = newDraftEmailDto(); + provider.saveDraft.mockResolvedValue({ id: 'draft-id' }); + + const result = await service.saveDraft(userEmail, dto); + + expect(provider.saveDraft).toHaveBeenCalledWith(userEmail, dto); + expect(result).toEqual({ id: 'draft-id' }); + }); + }); + + describe('moveEmail', () => { + it('when called, then delegates to provider', async () => { + provider.moveEmail.mockResolvedValue(undefined); + + await service.moveEmail(userEmail, 'email-id', 'trash'); + + expect(provider.moveEmail).toHaveBeenCalledWith( + userEmail, + 'email-id', + 'trash', + ); + }); + }); + + describe('deleteEmail', () => { + it('when called, then delegates to provider', async () => { + provider.deleteEmail.mockResolvedValue(undefined); + + await service.deleteEmail(userEmail, 'email-id'); + + expect(provider.deleteEmail).toHaveBeenCalledWith(userEmail, 'email-id'); + }); + }); + + describe('markAsRead', () => { + it('when called with true, then delegates to provider', async () => { + provider.markAsRead.mockResolvedValue(undefined); + + await service.markAsRead(userEmail, 'email-id', true); + + expect(provider.markAsRead).toHaveBeenCalledWith( + userEmail, + 'email-id', + true, + ); + }); + }); + + describe('markAsFlagged', () => { + it('when called with false, then delegates to provider', async () => { + provider.markAsFlagged.mockResolvedValue(undefined); + + await service.markAsFlagged(userEmail, 'email-id', false); + + expect(provider.markAsFlagged).toHaveBeenCalledWith( + userEmail, + 'email-id', + false, + ); + }); + }); +}); diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts new file mode 100644 index 0000000..4640bdf --- /dev/null +++ b/src/modules/email/email.service.ts @@ -0,0 +1,74 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { MailProvider } from './mail-provider.port.js'; +import type { + DraftEmailDto, + Email, + EmailListResponse, + Mailbox, + MailboxType, + SendEmailDto, +} from './email.types.js'; + +@Injectable() +export class EmailService { + constructor(private readonly mail: MailProvider) {} + + getMailboxes(userEmail: string): Promise { + return this.mail.getMailboxes(userEmail); + } + + listEmails( + userEmail: string, + mailbox: MailboxType, + limit: number, + position: number, + ): Promise { + return this.mail.listEmails(userEmail, mailbox, limit, position); + } + + async getEmail(userEmail: string, id: string): Promise { + const email = await this.mail.getEmail(userEmail, id); + if (!email) { + throw new NotFoundException(`Email ${id} not found`); + } + return email; + } + + async sendEmail( + userEmail: string, + dto: SendEmailDto, + ): Promise<{ id: string }> { + if (dto.to.length === 0) { + throw new BadRequestException('At least one recipient is required'); + } + return this.mail.sendEmail(userEmail, dto); + } + + saveDraft(userEmail: string, dto: DraftEmailDto): Promise<{ id: string }> { + return this.mail.saveDraft(userEmail, dto); + } + + moveEmail(userEmail: string, id: string, target: MailboxType): Promise { + return this.mail.moveEmail(userEmail, id, target); + } + + deleteEmail(userEmail: string, id: string): Promise { + return this.mail.deleteEmail(userEmail, id); + } + + markAsRead(userEmail: string, id: string, read: boolean): Promise { + return this.mail.markAsRead(userEmail, id, read); + } + + markAsFlagged( + userEmail: string, + id: string, + flagged: boolean, + ): Promise { + return this.mail.markAsFlagged(userEmail, id, flagged); + } +} diff --git a/src/modules/email/email.types.ts b/src/modules/email/email.types.ts new file mode 100644 index 0000000..7a64f22 --- /dev/null +++ b/src/modules/email/email.types.ts @@ -0,0 +1,67 @@ +export type MailboxType = + | 'inbox' + | 'drafts' + | 'sent' + | 'trash' + | 'spam' + | 'archive'; + +export interface EmailAddress { + name?: string; + email: string; +} + +export interface Mailbox { + id: string; + name: string; + type: MailboxType | null; + parentId: string | null; + totalEmails: number; + unreadEmails: number; +} + +export interface EmailSummary { + id: string; + threadId: string; + from: EmailAddress[]; + to: EmailAddress[]; + subject: string; + receivedAt: string; + preview: string; + isRead: boolean; + isFlagged: boolean; + hasAttachment: boolean; + size: number; +} + +export interface Email extends EmailSummary { + cc: EmailAddress[]; + bcc: EmailAddress[]; + replyTo: EmailAddress[]; + sentAt: string | null; + textBody: string | null; + htmlBody: string | null; +} + +export interface SendEmailDto { + to: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + subject: string; + textBody?: string; + htmlBody?: string; +} + +export interface DraftEmailDto { + to?: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + subject?: string; + textBody?: string; + htmlBody?: string; +} + +export interface EmailListResponse { + emails: EmailSummary[]; + total: number; +} diff --git a/src/modules/email/email.usecase.ts b/src/modules/email/email.usecase.ts deleted file mode 100644 index 1805443..0000000 --- a/src/modules/email/email.usecase.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class EmailUsecase { - list() { - return []; - } - - get(_id: string) { - return {}; - } -} diff --git a/src/modules/email/mail-provider.port.ts b/src/modules/email/mail-provider.port.ts new file mode 100644 index 0000000..f3caab7 --- /dev/null +++ b/src/modules/email/mail-provider.port.ts @@ -0,0 +1,43 @@ +import type { + DraftEmailDto, + Email, + EmailListResponse, + Mailbox, + MailboxType, + SendEmailDto, +} from './email.types.js'; + +export abstract class MailProvider { + abstract getMailboxes(userEmail: string): Promise; + abstract listEmails( + userEmail: string, + mailbox: MailboxType, + limit: number, + position: number, + ): Promise; + abstract getEmail(userEmail: string, id: string): Promise; + abstract sendEmail( + userEmail: string, + dto: SendEmailDto, + ): Promise<{ id: string }>; + abstract saveDraft( + userEmail: string, + dto: DraftEmailDto, + ): Promise<{ id: string }>; + abstract moveEmail( + userEmail: string, + id: string, + target: MailboxType, + ): Promise; + abstract deleteEmail(userEmail: string, id: string): Promise; + abstract markAsRead( + userEmail: string, + id: string, + read: boolean, + ): Promise; + abstract markAsFlagged( + userEmail: string, + id: string, + flagged: boolean, + ): Promise; +} diff --git a/src/modules/health/health.controller.spec.ts b/src/modules/health/health.controller.spec.ts new file mode 100644 index 0000000..135c301 --- /dev/null +++ b/src/modules/health/health.controller.spec.ts @@ -0,0 +1,21 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { HealthController } from './health.controller.js'; + +describe('HealthController', () => { + let controller: HealthController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + }).compile(); + + controller = module.get(HealthController); + }); + + describe('check', () => { + it('When called, then it returns status ok', () => { + expect(controller.check()).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/src/modules/infrastructure/jmap/jmap-mail.mapper.spec.ts b/src/modules/infrastructure/jmap/jmap-mail.mapper.spec.ts new file mode 100644 index 0000000..f304db5 --- /dev/null +++ b/src/modules/infrastructure/jmap/jmap-mail.mapper.spec.ts @@ -0,0 +1,492 @@ +import { describe, it, expect } from 'vitest'; +import { + mapJmapRoleToMailboxType, + mapMailboxTypeToJmapRole, + mapJmapMailbox, + mapJmapEmailToSummary, + mapJmapEmailToDetail, + mapSendDtoToJmapCreate, + mapDraftDtoToJmapCreate, +} from './jmap-mail.mapper.js'; +import type { MailboxRole } from './jmap.types.js'; +import type { DraftEmailDto, MailboxType } from '../../email/email.types.js'; +import { + newJmapMailbox, + newJmapEmail, + newJmapEmailAddress, + newSendEmailDto, + newDraftEmailDto, + newEmailAddress, +} from '../../../../test/fixtures.js'; + +describe('jmap-mail.mapper', () => { + describe('mapJmapRoleToMailboxType', () => { + const directMappings: [MailboxRole, MailboxType][] = [ + ['inbox', 'inbox'], + ['drafts', 'drafts'], + ['sent', 'sent'], + ['trash', 'trash'], + ['archive', 'archive'], + ]; + + it.each(directMappings)( + 'when role is "%s", then returns "%s"', + (role, expected) => { + expect(mapJmapRoleToMailboxType(role)).toBe(expected); + }, + ); + + it('when role is "junk", then returns "spam"', () => { + expect(mapJmapRoleToMailboxType('junk')).toBe('spam'); + }); + + it('when role is null, then returns null', () => { + expect(mapJmapRoleToMailboxType(null)).toBeNull(); + }); + + it('when role is an unknown value, then returns null', () => { + expect(mapJmapRoleToMailboxType('flagged' as MailboxRole)).toBeNull(); + expect(mapJmapRoleToMailboxType('important' as MailboxRole)).toBeNull(); + expect(mapJmapRoleToMailboxType('subscribed' as MailboxRole)).toBeNull(); + }); + }); + + describe('mapMailboxTypeToJmapRole', () => { + const reverseMappings: [MailboxType, MailboxRole][] = [ + ['inbox', 'inbox'], + ['drafts', 'drafts'], + ['sent', 'sent'], + ['trash', 'trash'], + ['archive', 'archive'], + ]; + + it.each(reverseMappings)( + 'when type is "%s", then returns "%s"', + (type, expected) => { + expect(mapMailboxTypeToJmapRole(type)).toBe(expected); + }, + ); + + it('when type is "spam", then returns "junk"', () => { + expect(mapMailboxTypeToJmapRole('spam')).toBe('junk'); + }); + }); + + describe('mapJmapMailbox', () => { + it('when given a JMAP mailbox, then maps id, name, parentId, and counts', () => { + const jmapMailbox = newJmapMailbox({ role: 'inbox' }); + + const result = mapJmapMailbox(jmapMailbox); + + expect(result.id).toBe(jmapMailbox.id); + expect(result.name).toBe(jmapMailbox.name); + expect(result.parentId).toBe(jmapMailbox.parentId); + expect(result.totalEmails).toBe(jmapMailbox.totalEmails); + expect(result.unreadEmails).toBe(jmapMailbox.unreadEmails); + }); + + it('when given a JMAP mailbox with role, then maps role to type', () => { + const jmapMailbox = newJmapMailbox({ role: 'junk' }); + + const result = mapJmapMailbox(jmapMailbox); + + expect(result.type).toBe('spam'); + }); + + it('when given a JMAP mailbox with null role, then type is null', () => { + const jmapMailbox = newJmapMailbox({ role: null }); + + const result = mapJmapMailbox(jmapMailbox); + + expect(result.type).toBeNull(); + }); + + it('when given a JMAP mailbox, then drops sortOrder, isSubscribed, and thread counts', () => { + const jmapMailbox = newJmapMailbox({ + sortOrder: 5, + isSubscribed: true, + totalThreads: 100, + unreadThreads: 10, + }); + + const result = mapJmapMailbox(jmapMailbox); + + expect(result).not.toHaveProperty('sortOrder'); + expect(result).not.toHaveProperty('isSubscribed'); + expect(result).not.toHaveProperty('totalThreads'); + expect(result).not.toHaveProperty('unreadThreads'); + }); + }); + + describe('mapJmapEmailToSummary', () => { + it('when email has $seen keyword, then isRead is true', () => { + const jmapEmail = newJmapEmail({ + keywords: { $seen: true }, + }); + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.isRead).toBe(true); + }); + + it('when email lacks $seen keyword, then isRead is false', () => { + const jmapEmail = newJmapEmail({ keywords: {} }); + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.isRead).toBe(false); + }); + + it('when email has $flagged keyword, then isFlagged is true', () => { + const jmapEmail = newJmapEmail({ + keywords: { $flagged: true }, + }); + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.isFlagged).toBe(true); + }); + + it('when email lacks $flagged keyword, then isFlagged is false', () => { + const jmapEmail = newJmapEmail({ keywords: {} }); + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.isFlagged).toBe(false); + }); + + it('when email has both $seen and $flagged, then both booleans are true', () => { + const jmapEmail = newJmapEmail({ + keywords: { $seen: true, $flagged: true }, + }); + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.isRead).toBe(true); + expect(result.isFlagged).toBe(true); + }); + + it('when email has no keywords object, then isRead and isFlagged are false', () => { + const jmapEmail = newJmapEmail(); + // @ts-expect-error simulating missing keywords from JMAP + jmapEmail.keywords = undefined; + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.isRead).toBe(false); + expect(result.isFlagged).toBe(false); + }); + + it('when given a JMAP email, then maps scalar fields directly', () => { + const jmapEmail = newJmapEmail(); + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.id).toBe(jmapEmail.id); + expect(result.threadId).toBe(jmapEmail.threadId); + expect(result.subject).toBe(jmapEmail.subject); + expect(result.receivedAt).toBe(jmapEmail.receivedAt); + expect(result.preview).toBe(jmapEmail.preview); + expect(result.size).toBe(jmapEmail.size); + expect(result.hasAttachment).toBe(jmapEmail.hasAttachment); + }); + + it('when email has no from/to, then returns empty arrays', () => { + const jmapEmail = newJmapEmail({ from: undefined, to: undefined }); + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.from).toEqual([]); + expect(result.to).toEqual([]); + }); + + it('when email has from/to addresses, then maps them directly', () => { + const from = [newJmapEmailAddress()]; + const to = [newJmapEmailAddress(), newJmapEmailAddress()]; + const jmapEmail = newJmapEmail({ from, to }); + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.from).toEqual(from); + expect(result.to).toEqual(to); + }); + + it('when email has no subject, then defaults to empty string', () => { + const jmapEmail = newJmapEmail({ subject: undefined }); + + const result = mapJmapEmailToSummary(jmapEmail); + + expect(result.subject).toBe(''); + }); + }); + + describe('mapJmapEmailToDetail', () => { + it('when email has bodyValues, then extracts text body content', () => { + const partId = 'text-part'; + const textContent = 'Hello world'; + const jmapEmail = newJmapEmail({ + textBody: [{ partId, type: 'text/plain' }], + bodyValues: { + [partId]: { + value: textContent, + isEncodingProblem: false, + isTruncated: false, + }, + }, + }); + + const result = mapJmapEmailToDetail(jmapEmail); + + expect(result.textBody).toBe(textContent); + }); + + it('when email has bodyValues, then extracts html body content', () => { + const partId = 'html-part'; + const htmlContent = '

Hello

'; + const jmapEmail = newJmapEmail({ + htmlBody: [{ partId, type: 'text/html' }], + bodyValues: { + [partId]: { + value: htmlContent, + isEncodingProblem: false, + isTruncated: false, + }, + }, + }); + + const result = mapJmapEmailToDetail(jmapEmail); + + expect(result.htmlBody).toBe(htmlContent); + }); + + it('when email has no bodyValues, then body fields are null', () => { + const jmapEmail = newJmapEmail({ bodyValues: undefined }); + + const result = mapJmapEmailToDetail(jmapEmail); + + expect(result.textBody).toBeNull(); + expect(result.htmlBody).toBeNull(); + }); + + it('when email has empty textBody array, then textBody is null', () => { + const jmapEmail = newJmapEmail({ textBody: [], bodyValues: {} }); + + const result = mapJmapEmailToDetail(jmapEmail); + + expect(result.textBody).toBeNull(); + }); + + it('when given a JMAP email, then includes all summary fields', () => { + const jmapEmail = newJmapEmail(); + + const detail = mapJmapEmailToDetail(jmapEmail); + const summary = mapJmapEmailToSummary(jmapEmail); + + expect(detail.id).toBe(summary.id); + expect(detail.threadId).toBe(summary.threadId); + expect(detail.from).toEqual(summary.from); + expect(detail.to).toEqual(summary.to); + expect(detail.subject).toBe(summary.subject); + expect(detail.isRead).toBe(summary.isRead); + expect(detail.isFlagged).toBe(summary.isFlagged); + }); + + it('when email has cc/bcc/replyTo, then maps them', () => { + const cc = [newJmapEmailAddress()]; + const bcc = [newJmapEmailAddress()]; + const replyTo = [newJmapEmailAddress()]; + const jmapEmail = newJmapEmail({ cc, bcc, replyTo }); + + const result = mapJmapEmailToDetail(jmapEmail); + + expect(result.cc).toEqual(cc); + expect(result.bcc).toEqual(bcc); + expect(result.replyTo).toEqual(replyTo); + }); + + it('when email has no cc/bcc/replyTo, then defaults to empty arrays', () => { + const jmapEmail = newJmapEmail({ + cc: undefined, + bcc: undefined, + replyTo: undefined, + }); + + const result = mapJmapEmailToDetail(jmapEmail); + + expect(result.cc).toEqual([]); + expect(result.bcc).toEqual([]); + expect(result.replyTo).toEqual([]); + }); + + it('when email has no sentAt, then sentAt is null', () => { + const jmapEmail = newJmapEmail({ sentAt: undefined }); + + const result = mapJmapEmailToDetail(jmapEmail); + + expect(result.sentAt).toBeNull(); + }); + }); + + describe('mapSendDtoToJmapCreate', () => { + it('when given a send DTO and mailbox ID, then sets mailboxIds', () => { + const dto = newSendEmailDto(); + const mailboxId = 'sent-mailbox-id'; + + const result = mapSendDtoToJmapCreate(dto, mailboxId); + + expect(result.mailboxIds).toEqual({ [mailboxId]: true }); + }); + + it('when given a send DTO, then sets $seen keyword', () => { + const dto = newSendEmailDto(); + + const result = mapSendDtoToJmapCreate(dto, 'mid'); + + expect(result.keywords).toEqual({ $seen: true }); + }); + + it('when DTO has to and subject, then maps them directly', () => { + const to = [newEmailAddress()]; + const dto = newSendEmailDto({ to, subject: 'Test subject' }); + + const result = mapSendDtoToJmapCreate(dto, 'mid'); + + expect(result.to).toEqual(to); + expect(result.subject).toBe('Test subject'); + }); + + it('when DTO has cc and bcc, then includes them', () => { + const cc = [newEmailAddress()]; + const bcc = [newEmailAddress()]; + const dto = newSendEmailDto({ cc, bcc }); + + const result = mapSendDtoToJmapCreate(dto, 'mid'); + + expect(result.cc).toEqual(cc); + expect(result.bcc).toEqual(bcc); + }); + + it('when DTO has no cc and bcc, then omits them', () => { + const dto = newSendEmailDto({ cc: undefined, bcc: undefined }); + + const result = mapSendDtoToJmapCreate(dto, 'mid'); + + expect(result.cc).toBeUndefined(); + expect(result.bcc).toBeUndefined(); + }); + + it('when DTO has textBody, then creates text body part and bodyValues', () => { + const dto = newSendEmailDto({ textBody: 'Hello' }); + + const result = mapSendDtoToJmapCreate(dto, 'mid'); + + expect(result.textBody).toEqual([{ partId: 'text', type: 'text/plain' }]); + expect(result.bodyValues?.['text']?.value).toBe('Hello'); + }); + + it('when DTO has htmlBody, then creates html body part and bodyValues', () => { + const dto = newSendEmailDto({ htmlBody: '

Hi

' }); + + const result = mapSendDtoToJmapCreate(dto, 'mid'); + + expect(result.htmlBody).toEqual([{ partId: 'html', type: 'text/html' }]); + expect(result.bodyValues?.['html']?.value).toBe('

Hi

'); + }); + + it('when DTO has both textBody and htmlBody, then bodyValues contains both', () => { + const dto = newSendEmailDto({ + textBody: 'Hello', + htmlBody: '

Hello

', + }); + + const result = mapSendDtoToJmapCreate(dto, 'mid'); + + expect(result.bodyValues?.['text']?.value).toBe('Hello'); + expect(result.bodyValues?.['html']?.value).toBe('

Hello

'); + }); + + it('when DTO has no body content, then omits body parts and bodyValues', () => { + const dto = newSendEmailDto({ + textBody: undefined, + htmlBody: undefined, + }); + + const result = mapSendDtoToJmapCreate(dto, 'mid'); + + expect(result.textBody).toBeUndefined(); + expect(result.htmlBody).toBeUndefined(); + expect(result.bodyValues).toBeUndefined(); + }); + }); + + describe('mapDraftDtoToJmapCreate', () => { + it('when given a draft DTO, then sets $draft keyword', () => { + const dto = newDraftEmailDto(); + + const result = mapDraftDtoToJmapCreate(dto, 'mid'); + + expect(result.keywords).toEqual({ $draft: true }); + }); + + it('when given a draft DTO and mailbox ID, then sets mailboxIds', () => { + const dto = newDraftEmailDto(); + const mailboxId = 'drafts-mailbox-id'; + + const result = mapDraftDtoToJmapCreate(dto, mailboxId); + + expect(result.mailboxIds).toEqual({ [mailboxId]: true }); + }); + + it('when draft DTO has all fields empty, then creates minimal object', () => { + const dto: DraftEmailDto = {}; + + const result = mapDraftDtoToJmapCreate(dto, 'mid'); + + expect(result.to).toBeUndefined(); + expect(result.cc).toBeUndefined(); + expect(result.bcc).toBeUndefined(); + expect(result.subject).toBeUndefined(); + expect(result.textBody).toBeUndefined(); + expect(result.htmlBody).toBeUndefined(); + expect(result.mailboxIds).toEqual({ mid: true }); + expect(result.keywords).toEqual({ $draft: true }); + }); + + it('when draft DTO has optional fields set, then include them', () => { + const to = [newEmailAddress()]; + const cc = [newEmailAddress()]; + const dto = newDraftEmailDto({ + to, + cc, + subject: 'Draft subject', + textBody: 'Draft text', + }); + + const result = mapDraftDtoToJmapCreate(dto, 'mid'); + + expect(result.to).toEqual(to); + expect(result.cc).toEqual(cc); + expect(result.subject).toBe('Draft subject'); + expect(result.bodyValues?.['text']?.value).toBe('Draft text'); + }); + }); + + describe('roundtrip consistency', () => { + it('when mapping all mailbox types through both directions, then roundtrips are consistent', () => { + const types: MailboxType[] = [ + 'inbox', + 'drafts', + 'sent', + 'trash', + 'spam', + 'archive', + ]; + + for (const type of types) { + const role = mapMailboxTypeToJmapRole(type); + const backToType = mapJmapRoleToMailboxType(role); + expect(backToType).toBe(type); + } + }); + }); +}); diff --git a/src/modules/infrastructure/jmap/jmap-mail.mapper.ts b/src/modules/infrastructure/jmap/jmap-mail.mapper.ts new file mode 100644 index 0000000..47c6749 --- /dev/null +++ b/src/modules/infrastructure/jmap/jmap-mail.mapper.ts @@ -0,0 +1,164 @@ +import type { + Email as DomainEmail, + EmailSummary, + Mailbox as DomainMailbox, + MailboxType, + SendEmailDto, + DraftEmailDto, +} from '../../email/email.types.js'; +import type { + Email as JmapEmail, + EmailCreate as JmapEmailCreate, + Mailbox as JmapMailbox, + MailboxRole, +} from './jmap.types.js'; + +const JMAP_ROLE_TO_MAILBOX_TYPE: Record = { + inbox: 'inbox', + drafts: 'drafts', + sent: 'sent', + trash: 'trash', + junk: 'spam', + archive: 'archive', +}; + +const MAILBOX_TYPE_TO_JMAP_ROLE: Record = { + inbox: 'inbox', + drafts: 'drafts', + sent: 'sent', + trash: 'trash', + spam: 'junk', + archive: 'archive', +}; + +export function mapJmapRoleToMailboxType( + role: MailboxRole | null, +): MailboxType | null { + if (!role) return null; + return JMAP_ROLE_TO_MAILBOX_TYPE[role] ?? null; +} + +export function mapMailboxTypeToJmapRole(type: MailboxType): MailboxRole { + return MAILBOX_TYPE_TO_JMAP_ROLE[type]; +} + +export function mapJmapMailbox(m: JmapMailbox): DomainMailbox { + return { + id: m.id, + name: m.name, + type: mapJmapRoleToMailboxType(m.role), + parentId: m.parentId, + totalEmails: m.totalEmails, + unreadEmails: m.unreadEmails, + }; +} + +export function mapJmapEmailToSummary(e: JmapEmail): EmailSummary { + return { + id: e.id, + threadId: e.threadId, + from: e.from ?? [], + to: e.to ?? [], + subject: e.subject ?? '', + receivedAt: e.receivedAt, + preview: e.preview ?? '', + isRead: !!e.keywords?.['$seen'], + isFlagged: !!e.keywords?.['$flagged'], + hasAttachment: !!e.hasAttachment, + size: e.size, + }; +} + +export function mapJmapEmailToDetail(e: JmapEmail): DomainEmail { + const summary = mapJmapEmailToSummary(e); + + let textBody: string | null = null; + let htmlBody: string | null = null; + + if (e.bodyValues) { + const textPartId = e.textBody?.[0]?.partId; + if (textPartId && e.bodyValues[textPartId]) { + textBody = e.bodyValues[textPartId].value; + } + + const htmlPartId = e.htmlBody?.[0]?.partId; + if (htmlPartId && e.bodyValues[htmlPartId]) { + htmlBody = e.bodyValues[htmlPartId].value; + } + } + + return { + ...summary, + cc: e.cc ?? [], + bcc: e.bcc ?? [], + replyTo: e.replyTo ?? [], + sentAt: e.sentAt ?? null, + textBody, + htmlBody, + }; +} + +function applyBodyParts( + email: JmapEmailCreate, + textBody?: string, + htmlBody?: string, +): void { + if (textBody) { + email.textBody = [{ partId: 'text', type: 'text/plain' }]; + email.bodyValues = { + text: { + value: textBody, + isEncodingProblem: false, + isTruncated: false, + }, + }; + } + + if (htmlBody) { + email.htmlBody = [{ partId: 'html', type: 'text/html' }]; + email.bodyValues = { + ...email.bodyValues, + html: { + value: htmlBody, + isEncodingProblem: false, + isTruncated: false, + }, + }; + } +} + +export function mapSendDtoToJmapCreate( + dto: SendEmailDto, + mailboxId: string, +): JmapEmailCreate { + const email: JmapEmailCreate = { + mailboxIds: { [mailboxId]: true }, + to: dto.to, + subject: dto.subject, + keywords: { $seen: true }, + }; + + if (dto.cc) email.cc = dto.cc; + if (dto.bcc) email.bcc = dto.bcc; + applyBodyParts(email, dto.textBody, dto.htmlBody); + + return email; +} + +export function mapDraftDtoToJmapCreate( + dto: DraftEmailDto, + mailboxId: string, +): JmapEmailCreate { + const email: JmapEmailCreate = { + mailboxIds: { [mailboxId]: true }, + keywords: { $draft: true }, + }; + + if (dto.to) email.to = dto.to; + if (dto.cc) email.cc = dto.cc; + if (dto.bcc) email.bcc = dto.bcc; + if (dto.subject) email.subject = dto.subject; + applyBodyParts(email, dto.textBody, dto.htmlBody); + + return email; +} diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts new file mode 100644 index 0000000..8671815 --- /dev/null +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { JmapMailProvider } from './jmap-mail.provider.js'; +import { JmapService } from './jmap.service.js'; +import { + newJmapMailbox, + newJmapEmail, + newJmapIdentity, + newSendEmailDto, + newDraftEmailDto, +} from '../../../../test/fixtures.js'; +import type { JmapResponse } from './jmap.types.js'; + +function jmapResponse(data: T): JmapResponse { + return { + methodResponses: [['Method/response', data, 'r0']], + sessionState: 'state-0', + } as unknown as JmapResponse; +} + +function jmapMultiResponse(...responses: unknown[]): JmapResponse { + return { + methodResponses: responses.map((data, i) => [ + 'Method/response', + data, + `r${i}`, + ]), + sessionState: 'state-0', + } as unknown as JmapResponse; +} + +describe('JmapMailProvider', () => { + let provider: JmapMailProvider; + let jmapService: DeepMocked; + + const accountId = 'account-123'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [JmapMailProvider], + }) + .useMocker(() => createMock()) + .compile(); + + provider = module.get(JmapMailProvider); + jmapService = module.get(JmapService); + + jmapService.getPrimaryAccountId.mockResolvedValue(accountId); + }); + + describe('getMailboxes', () => { + it('When called, then it returns mapped mailboxes', async () => { + const jmapMailboxes = [ + newJmapMailbox({ role: 'inbox' }), + newJmapMailbox({ role: 'sent' }), + ]; + jmapService.request.mockResolvedValue( + jmapResponse({ list: jmapMailboxes }), + ); + + const result = await provider.getMailboxes('user@test.com'); + + expect(result).toHaveLength(2); + expect(result[0]!.id).toBe(jmapMailboxes[0]!.id); + expect(result[0]!.type).toBe('inbox'); + expect(result[1]!.type).toBe('sent'); + }); + }); + + describe('listEmails', () => { + it('When called, then it returns email summaries and total count', async () => { + const inboxMailbox = newJmapMailbox({ role: 'inbox' }); + const jmapEmails = [newJmapEmail(), newJmapEmail()]; + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [inboxMailbox] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapMultiResponse( + { ids: jmapEmails.map((e) => e.id), total: 42 }, + { list: jmapEmails }, + ), + ); + + const result = await provider.listEmails('user@test.com', 'inbox', 20, 0); + + expect(result.emails).toHaveLength(2); + expect(result.total).toBe(42); + }); + }); + + describe('getEmail', () => { + it('When email exists, then it returns the mapped email detail', async () => { + const jmapEmail = newJmapEmail(); + jmapService.request.mockResolvedValue( + jmapResponse({ list: [jmapEmail] }), + ); + + const result = await provider.getEmail('user@test.com', jmapEmail.id); + + expect(result).not.toBeNull(); + expect(result!.id).toBe(jmapEmail.id); + }); + + it('When email does not exist, then it returns null', async () => { + jmapService.request.mockResolvedValue(jmapResponse({ list: [] })); + + const result = await provider.getEmail('user@test.com', 'nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('sendEmail', () => { + it('When email is created and submitted, then it returns the created id', async () => { + const sentMailbox = newJmapMailbox({ role: 'sent' }); + const identity = newJmapIdentity(); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [identity] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [sentMailbox] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapMultiResponse( + { created: { draft: { id: 'created-email-id' } } }, + { created: { submission: { id: 'sub-id' } } }, + ), + ); + + const dto = newSendEmailDto(); + const result = await provider.sendEmail('user@test.com', dto); + + expect(result).toEqual({ id: 'created-email-id' }); + }); + + it('When email creation fails, then it throws', async () => { + const sentMailbox = newJmapMailbox({ role: 'sent' }); + const identity = newJmapIdentity(); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [identity] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [sentMailbox] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapMultiResponse({ created: null }, { created: null }), + ); + + const dto = newSendEmailDto(); + + await expect(provider.sendEmail('user@test.com', dto)).rejects.toThrow( + 'Failed to create email for sending', + ); + }); + }); + + describe('saveDraft', () => { + it('When draft is saved, then it returns the created id', async () => { + const draftsMailbox = newJmapMailbox({ role: 'drafts' }); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [draftsMailbox] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ created: { draft: { id: 'draft-id' } } }), + ); + + const dto = newDraftEmailDto(); + const result = await provider.saveDraft('user@test.com', dto); + + expect(result).toEqual({ id: 'draft-id' }); + }); + + it('When draft creation fails, then it throws', async () => { + const draftsMailbox = newJmapMailbox({ role: 'drafts' }); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [draftsMailbox] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ created: null }), + ); + + const dto = newDraftEmailDto(); + + await expect(provider.saveDraft('user@test.com', dto)).rejects.toThrow( + 'Failed to save draft', + ); + }); + }); + + describe('moveEmail', () => { + it('When called, then it sends an Email/set update with new mailboxIds', async () => { + const trashMailbox = newJmapMailbox({ role: 'trash' }); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [trashMailbox] }), + ); + jmapService.request.mockResolvedValueOnce(jmapResponse({})); + + await provider.moveEmail('user@test.com', 'email-1', 'trash'); + + const lastCall = jmapService.request.mock.calls.at(-1)!; + const methodArgs = lastCall[1][0]![1]; + const update = methodArgs['update'] as Record; + expect(update['email-1']).toEqual({ + mailboxIds: { [trashMailbox.id]: true }, + }); + }); + }); + + describe('deleteEmail', () => { + it('When email is in trash, then it permanently destroys it', async () => { + const trashMailbox = newJmapMailbox({ role: 'trash' }); + const emailInTrash = newJmapEmail({ + mailboxIds: { [trashMailbox.id]: true }, + }); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [emailInTrash] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [trashMailbox] }), + ); + jmapService.request.mockResolvedValueOnce(jmapResponse({})); + + await provider.deleteEmail('user@test.com', emailInTrash.id); + + const lastCall = jmapService.request.mock.calls.at(-1)!; + const methodArgs = lastCall[1][0]![1]; + expect(methodArgs['destroy']).toEqual([emailInTrash.id]); + }); + + it('When email is not in trash, then it moves it to trash', async () => { + const trashMailbox = newJmapMailbox({ role: 'trash' }); + const emailNotInTrash = newJmapEmail({ + mailboxIds: { 'other-mailbox': true }, + }); + + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [emailNotInTrash] }), + ); + jmapService.request.mockResolvedValueOnce( + jmapResponse({ list: [trashMailbox] }), + ); + jmapService.request.mockResolvedValueOnce(jmapResponse({})); + + await provider.deleteEmail('user@test.com', emailNotInTrash.id); + + const lastCall = jmapService.request.mock.calls.at(-1)!; + const methodArgs = lastCall[1][0]![1]; + const update = methodArgs['update'] as Record; + expect(update[emailNotInTrash.id]).toEqual({ + mailboxIds: { [trashMailbox.id]: true }, + }); + }); + + it('When email does not exist, then it returns without error', async () => { + jmapService.request.mockResolvedValue(jmapResponse({ list: [] })); + + await expect( + provider.deleteEmail('user@test.com', 'nonexistent'), + ).resolves.toBeUndefined(); + }); + }); + + describe('markAsRead', () => { + it('When called with true, then it sets the $seen keyword', async () => { + jmapService.request.mockResolvedValue(jmapResponse({})); + + await provider.markAsRead('user@test.com', 'email-1', true); + + const lastCall = jmapService.request.mock.calls.at(-1)!; + const methodArgs = lastCall[1][0]![1]; + const update = methodArgs['update'] as Record; + expect(update['email-1']).toEqual({ 'keywords/$seen': true }); + }); + + it('When called with false, then it clears the $seen keyword', async () => { + jmapService.request.mockResolvedValue(jmapResponse({})); + + await provider.markAsRead('user@test.com', 'email-1', false); + + const lastCall = jmapService.request.mock.calls.at(-1)!; + const methodArgs = lastCall[1][0]![1]; + const update = methodArgs['update'] as Record; + expect(update['email-1']).toEqual({ 'keywords/$seen': null }); + }); + }); + + describe('markAsFlagged', () => { + it('When called with true, then it sets the $flagged keyword', async () => { + jmapService.request.mockResolvedValue(jmapResponse({})); + + await provider.markAsFlagged('user@test.com', 'email-1', true); + + const lastCall = jmapService.request.mock.calls.at(-1)!; + const methodArgs = lastCall[1][0]![1]; + const update = methodArgs['update'] as Record; + expect(update['email-1']).toEqual({ 'keywords/$flagged': true }); + }); + }); +}); diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.ts new file mode 100644 index 0000000..1784797 --- /dev/null +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.ts @@ -0,0 +1,407 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MailProvider } from '../../email/mail-provider.port.js'; +import type { + DraftEmailDto, + Email, + EmailListResponse, + Mailbox, + MailboxType, + SendEmailDto, +} from '../../email/email.types.js'; +import { JmapService } from './jmap.service.js'; +import type { + Email as JmapEmail, + Identity, + Mailbox as JmapMailbox, + JmapGetResponse, + JmapQueryResponse, + JmapSetResponse, +} from './jmap.types.js'; +import { + mapJmapMailbox, + mapJmapEmailToSummary, + mapJmapEmailToDetail, + mapJmapRoleToMailboxType, + mapSendDtoToJmapCreate, + mapDraftDtoToJmapCreate, +} from './jmap-mail.mapper.js'; + +const EMAIL_LIST_PROPERTIES = [ + 'id', + 'threadId', + 'mailboxIds', + 'from', + 'to', + 'subject', + 'receivedAt', + 'preview', + 'keywords', + 'hasAttachment', + 'size', +] as const; + +const EMAIL_DETAIL_PROPERTIES = [ + ...EMAIL_LIST_PROPERTIES, + 'cc', + 'bcc', + 'replyTo', + 'sentAt', + 'textBody', + 'htmlBody', + 'bodyValues', +] as const; + +interface TimedCache { + value: T; + expiresAt: number; +} + +const CACHE_TTL_MS = 60_000; + +@Injectable() +export class JmapMailProvider extends MailProvider { + private readonly logger = new Logger(JmapMailProvider.name); + private readonly mailboxCache = new Map< + string, + TimedCache> + >(); + private readonly identityCache = new Map>(); + + constructor(private readonly jmap: JmapService) { + super(); + } + + async getMailboxes(userEmail: string): Promise { + const accountId = await this.jmap.getPrimaryAccountId(userEmail); + + const response = await this.jmap.request>( + userEmail, + [['Mailbox/get', { accountId }, 'r0']], + ); + + const jmapMailboxes = response.methodResponses[0]![1].list; + this.updateMailboxCache(userEmail, jmapMailboxes); + + return jmapMailboxes.map(mapJmapMailbox); + } + + async listEmails( + userEmail: string, + mailbox: MailboxType, + limit: number, + position: number, + ): Promise { + const [accountId, mailboxId] = await Promise.all([ + this.jmap.getPrimaryAccountId(userEmail), + this.resolveMailboxId(userEmail, mailbox), + ]); + + const response = await this.jmap.request(userEmail, [ + [ + 'Email/query', + { + accountId, + filter: { inMailbox: mailboxId }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit, + position, + }, + 'r0', + ], + [ + 'Email/get', + { + accountId, + '#ids': { resultOf: 'r0', name: 'Email/query', path: '/ids' }, + properties: EMAIL_LIST_PROPERTIES, + }, + 'r1', + ], + ]); + + const queryResult = response.methodResponses[0]![1] as JmapQueryResponse; + const getResult = response + .methodResponses[1]![1] as JmapGetResponse; + + return { + emails: getResult.list.map(mapJmapEmailToSummary), + total: queryResult.total ?? 0, + }; + } + + async getEmail(userEmail: string, id: string): Promise { + const accountId = await this.jmap.getPrimaryAccountId(userEmail); + + const response = await this.jmap.request>( + userEmail, + [ + [ + 'Email/get', + { + accountId, + ids: [id], + properties: EMAIL_DETAIL_PROPERTIES, + fetchTextBodyValues: true, + fetchHTMLBodyValues: true, + }, + 'r0', + ], + ], + ); + + const email = response.methodResponses[0]![1].list[0]; + return email ? mapJmapEmailToDetail(email) : null; + } + + async sendEmail( + userEmail: string, + dto: SendEmailDto, + ): Promise<{ id: string }> { + const [accountId, identityId, sentMailboxId] = await Promise.all([ + this.jmap.getPrimaryAccountId(userEmail), + this.resolveIdentityId(userEmail), + this.resolveMailboxId(userEmail, 'sent'), + ]); + + const emailCreate = mapSendDtoToJmapCreate(dto, sentMailboxId); + + const response = await this.jmap.request(userEmail, [ + [ + 'Email/set', + { + accountId, + create: { draft: emailCreate }, + }, + 'r0', + ], + [ + 'EmailSubmission/set', + { + accountId, + create: { + submission: { + identityId, + emailId: '#draft', + }, + }, + }, + 'r1', + ], + ]); + + const emailResult = response + .methodResponses[0]![1] as JmapSetResponse; + + const createdId = emailResult.created?.['draft']?.id; + if (!createdId) { + throw new Error('Failed to create email for sending'); + } + + return { id: createdId }; + } + + async saveDraft( + userEmail: string, + dto: DraftEmailDto, + ): Promise<{ id: string }> { + const [accountId, draftsMailboxId] = await Promise.all([ + this.jmap.getPrimaryAccountId(userEmail), + this.resolveMailboxId(userEmail, 'drafts'), + ]); + + const emailCreate = mapDraftDtoToJmapCreate(dto, draftsMailboxId); + + const response = await this.jmap.request>( + userEmail, + [ + [ + 'Email/set', + { + accountId, + create: { draft: emailCreate }, + }, + 'r0', + ], + ], + ); + + const createdId = response.methodResponses[0]![1].created?.['draft']?.id; + if (!createdId) { + throw new Error('Failed to save draft'); + } + + return { id: createdId }; + } + + async moveEmail( + userEmail: string, + id: string, + target: MailboxType, + ): Promise { + const [accountId, targetMailboxId] = await Promise.all([ + this.jmap.getPrimaryAccountId(userEmail), + this.resolveMailboxId(userEmail, target), + ]); + + await this.jmap.request>(userEmail, [ + [ + 'Email/set', + { + accountId, + update: { + [id]: { mailboxIds: { [targetMailboxId]: true } }, + }, + }, + 'r0', + ], + ]); + } + + async deleteEmail(userEmail: string, id: string): Promise { + const accountId = await this.jmap.getPrimaryAccountId(userEmail); + + const response = await this.jmap.request>( + userEmail, + [ + [ + 'Email/get', + { accountId, ids: [id], properties: ['mailboxIds'] }, + 'r0', + ], + ], + ); + + const email = response.methodResponses[0]![1].list[0]; + if (!email) return; + + const trashMailboxId = await this.resolveMailboxId(userEmail, 'trash'); + const isInTrash = !!email.mailboxIds[trashMailboxId]; + + if (isInTrash) { + await this.jmap.request>(userEmail, [ + ['Email/set', { accountId, destroy: [id] }, 'r0'], + ]); + } else { + await this.jmap.request>(userEmail, [ + [ + 'Email/set', + { + accountId, + update: { [id]: { mailboxIds: { [trashMailboxId]: true } } }, + }, + 'r0', + ], + ]); + } + } + + async markAsRead( + userEmail: string, + id: string, + read: boolean, + ): Promise { + return this.setKeyword(userEmail, id, '$seen', read); + } + + async markAsFlagged( + userEmail: string, + id: string, + flagged: boolean, + ): Promise { + return this.setKeyword(userEmail, id, '$flagged', flagged); + } + + private async setKeyword( + userEmail: string, + id: string, + keyword: string, + value: boolean, + ): Promise { + const accountId = await this.jmap.getPrimaryAccountId(userEmail); + + await this.jmap.request>(userEmail, [ + [ + 'Email/set', + { + accountId, + update: { + [id]: { [`keywords/${keyword}`]: value ? true : null }, + }, + }, + 'r0', + ], + ]); + } + + private async resolveMailboxId( + userEmail: string, + type: MailboxType, + ): Promise { + const cached = this.mailboxCache.get(userEmail); + if (cached && cached.expiresAt > Date.now()) { + const id = cached.value.get(type); + if (id) return id; + } + + const accountId = await this.jmap.getPrimaryAccountId(userEmail); + const response = await this.jmap.request>( + userEmail, + [['Mailbox/get', { accountId }, 'r0']], + ); + + const jmapMailboxes = response.methodResponses[0]![1].list; + this.updateMailboxCache(userEmail, jmapMailboxes); + + const id = this.mailboxCache.get(userEmail)?.value.get(type); + if (!id) { + throw new Error(`Mailbox with role '${type}' not found`); + } + + return id; + } + + private async resolveIdentityId(userEmail: string): Promise { + const cached = this.identityCache.get(userEmail); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const accountId = await this.jmap.getPrimaryAccountId(userEmail); + + const response = await this.jmap.request>( + userEmail, + [['Identity/get', { accountId }, 'r0']], + ); + + const identity = response.methodResponses[0]![1].list[0]; + if (!identity) { + throw new Error('No identity found for user'); + } + + this.identityCache.set(userEmail, { + value: identity.id, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + + return identity.id; + } + + private updateMailboxCache( + userEmail: string, + mailboxes: JmapMailbox[], + ): void { + const roles = new Map(); + + for (const mb of mailboxes) { + const type = mapJmapRoleToMailboxType(mb.role); + if (type) { + roles.set(type, mb.id); + } + } + + this.mailboxCache.set(userEmail, { + value: roles, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + } +} diff --git a/src/modules/infrastructure/jmap/jmap.module.ts b/src/modules/infrastructure/jmap/jmap.module.ts new file mode 100644 index 0000000..b1f876f --- /dev/null +++ b/src/modules/infrastructure/jmap/jmap.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { MailProvider } from '../../email/mail-provider.port.js'; +import { JmapService } from './jmap.service.js'; +import { JmapMailProvider } from './jmap-mail.provider.js'; + +@Module({ + providers: [ + JmapService, + { provide: MailProvider, useClass: JmapMailProvider }, + ], + exports: [MailProvider], +}) +export class JmapModule {} diff --git a/src/modules/infrastructure/jmap/jmap.service.ts b/src/modules/infrastructure/jmap/jmap.service.ts new file mode 100644 index 0000000..7bcce34 --- /dev/null +++ b/src/modules/infrastructure/jmap/jmap.service.ts @@ -0,0 +1,175 @@ +import { + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Client } from 'undici'; +import type { + ID, + JmapInvocation, + JmapMethodCall, + JmapRequest, + JmapResponse, + JmapSession, +} from './jmap.types.js'; + +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'; + +const JMAP_MAIL_CAPABILITIES = [ + JMAP_CAPABILITY_CORE, + JMAP_CAPABILITY_MAIL, + JMAP_CAPABILITY_SUBMISSION, +] as const; + +const SESSION_TTL_MS = 30_000; + +interface CachedSession { + session: JmapSession; + expiresAt: number; +} + +@Injectable() +export class JmapService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(JmapService.name); + private readonly stalwartUrl: string; + private readonly masterUser: string; + private readonly masterPassword: string; + private readonly sessionCache = new Map(); // TODO: Implement cache ? + private httpClient!: Client; + + constructor(private readonly configService: ConfigService) { + this.stalwartUrl = this.configService.getOrThrow('stalwart.url'); + this.masterUser = this.configService.getOrThrow( + 'stalwart.masterUser', + ); + this.masterPassword = this.configService.getOrThrow( + 'stalwart.masterPassword', + ); + } + + onModuleInit() { + this.httpClient = new Client(this.stalwartUrl, { + allowH2: true, + keepAliveTimeout: 30_000, + pipelining: 1, + }); + this.logger.log(`JMAP client initialized targeting ${this.stalwartUrl}`); + } + + async onModuleDestroy() { + await this.httpClient.close(); + } + + private buildAuthHeader(userEmail: string): string { + const credentials = Buffer.from( + `${userEmail}%${this.masterUser}:${this.masterPassword}`, + ).toString('base64'); + return `Basic ${credentials}`; + } + + async getSession(userEmail: string): Promise { + const cached = this.sessionCache.get(userEmail); + if (cached && cached.expiresAt > Date.now()) { + return cached.session; + } + + this.logger.debug( + `JMAP session request: url=${this.stalwartUrl}/jmap/session user=${userEmail}%${this.masterUser}`, + ); + + const { statusCode, body } = await this.httpClient.request({ + method: 'GET', + path: '/jmap/session', + headers: { + authorization: this.buildAuthHeader(userEmail), + accept: 'application/json', + }, + }); + + const text = await body.text(); + + if (statusCode !== 200) { + throw new JmapError( + `Failed to fetch JMAP session: HTTP ${statusCode}`, + text, + ); + } + + const data = JSON.parse(text) as JmapSession; + + this.sessionCache.set(userEmail, { + session: data, + expiresAt: Date.now() + SESSION_TTL_MS, + }); + + return data; + } + + async request( + userEmail: string, + methodCalls: JmapMethodCall[], + using: readonly string[] = JMAP_MAIL_CAPABILITIES, + ): Promise[]>> { + const session = await this.getSession(userEmail); + + const requestBody: JmapRequest = { + using: using as string[], + methodCalls, + }; + + const apiPath = new URL(session.apiUrl).pathname; + + const { statusCode, body } = await this.httpClient.request({ + method: 'POST', + path: apiPath, + headers: { + authorization: this.buildAuthHeader(userEmail), + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const text = await body.text(); + + if (statusCode !== 200) { + throw new JmapError(`JMAP request failed: HTTP ${statusCode}`, text); + } + + const response = JSON.parse(text) as JmapResponse[]>; + + const errors = response.methodResponses.filter( + ([name]) => name === 'error', + ); + if (errors.length > 0) { + throw new JmapError('JMAP method error', errors); + } + + return response; + } + + async getPrimaryAccountId(userEmail: string): Promise { + const session = await this.getSession(userEmail); + const accountId = session.primaryAccounts?.[JMAP_CAPABILITY_MAIL]; + + if (!accountId) { + throw new JmapError('No primary mail account found', session); + } + + return accountId; + } +} + +export class JmapError extends Error { + constructor( + message: string, + public readonly details: unknown, + ) { + super(message); + this.name = 'JmapError'; + } +} diff --git a/src/modules/infrastructure/jmap/jmap.types.ts b/src/modules/infrastructure/jmap/jmap.types.ts new file mode 100644 index 0000000..f51ddcf --- /dev/null +++ b/src/modules/infrastructure/jmap/jmap.types.ts @@ -0,0 +1,214 @@ +/** + * JMAP types for the subset of RFC 8620 / RFC 8621 we use. + * Based on jmap-rfc-types (MIT) — kept local to avoid build issues + * with the package shipping raw .ts source files. + */ + +export type ID = string; + +// ── Session ───────────────────────────────────────────────────────── + +export interface JmapSession { + capabilities: Record; + accounts: Record; + primaryAccounts: Record; + username: string; + apiUrl: string; + downloadUrl: string; + uploadUrl: string; + eventSourceUrl: string; + state: string; +} + +export interface JmapAccount { + name: string; + isPersonal: boolean; + isReadOnly: boolean; + accountCapabilities: Record; +} + +// ── Request / Response ────────────────────────────────────────────── + +export type JmapMethodCall = [ + method: string, + args: Record, + id: string, +]; + +export type JmapInvocation = [ + name: string, + response: T, + methodCallId: string, +]; + +export interface JmapRequest { + using: string[]; + methodCalls: JmapMethodCall[]; + createdIds?: Record; +} + +export interface JmapResponse { + methodResponses: T; + sessionState: string; + createdIds?: Record; +} + +// ── Standard method responses ─────────────────────────────────────── + +export interface JmapGetResponse { + accountId: ID; + state: string; + list: T[]; + notFound: ID[]; +} + +export interface JmapQueryResponse { + accountId: ID; + queryState: string; + canCalculateChanges: boolean; + position: number; + ids: ID[]; + total?: number; +} + +export interface JmapSetResponse { + accountId: ID; + oldState: string | null; + newState: string; + created: Record | null; + updated: Record | null; + destroyed: ID[] | null; + notCreated: Record | null; + notUpdated: Record | null; + notDestroyed: Record | null; +} + +export interface JmapSetError { + type: string; + description?: string; + properties?: string[]; +} + +// ── Mail entities (RFC 8621) ──────────────────────────────────────── + +export interface EmailAddress { + name?: string; + email: string; +} + +export interface EmailBodyPart { + partId?: string; + blobId?: ID; + size?: number; + name?: string; + type?: string; + charset?: string; + disposition?: string; + cid?: string; + subParts?: EmailBodyPart[]; +} + +export interface EmailBodyValue { + value: string; + isEncodingProblem: boolean; + isTruncated: boolean; +} + +export type MailboxRole = + | 'all' + | 'archive' + | 'drafts' + | 'flagged' + | 'important' + | 'inbox' + | 'junk' + | 'sent' + | 'subscribed' + | 'trash'; + +export interface Mailbox { + id: ID; + name: string; + parentId: ID | null; + role: MailboxRole | null; + sortOrder: number; + totalEmails: number; + unreadEmails: number; + totalThreads: number; + unreadThreads: number; + isSubscribed: boolean; +} + +export interface Email { + id: ID; + blobId: ID; + threadId: ID; + mailboxIds: Record; + keywords: Record; + size: number; + receivedAt: string; + sentAt?: string; + from?: EmailAddress[]; + to?: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + replyTo?: EmailAddress[]; + subject?: string; + preview?: string; + hasAttachment?: boolean; + textBody?: EmailBodyPart[]; + htmlBody?: EmailBodyPart[]; + attachments?: EmailBodyPart[]; + bodyValues?: Record; +} + +export type EmailCreate = Partial< + Omit< + Email, + 'id' | 'blobId' | 'threadId' | 'size' | 'hasAttachment' | 'preview' + > +>; + +export interface EmailFilterCondition { + inMailbox?: ID; + inMailboxOtherThan?: ID[]; + before?: string; + after?: string; + minSize?: number; + maxSize?: number; + hasKeyword?: string; + notKeyword?: string; + from?: string; + to?: string; + cc?: string; + subject?: string; + body?: string; + text?: string; +} + +export interface Identity { + id: ID; + name: string; + email: string; + replyTo?: EmailAddress[]; + bcc?: EmailAddress[]; + textSignature: string; + htmlSignature: string; + mayDelete: boolean; +} + +export interface EmailSubmission { + id: ID; + identityId: ID; + emailId: ID; + threadId: ID; + sendAt: string; + undoStatus: 'pending' | 'final' | 'canceled'; + deliveryStatus: Record | null; +} + +export interface DeliveryStatus { + smtpReply: string; + delivered: 'queued' | 'yes' | 'no' | 'unknown'; + displayed: 'yes' | 'unknown'; +} diff --git a/src/modules/jmap/jmap.module.ts b/src/modules/jmap/jmap.module.ts deleted file mode 100644 index cfc1433..0000000 --- a/src/modules/jmap/jmap.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { JmapService } from './jmap.service'; - -@Module({ - imports: [HttpModule], - providers: [JmapService], - exports: [JmapService], -}) -export class JmapModule {} diff --git a/src/modules/jmap/jmap.service.ts b/src/modules/jmap/jmap.service.ts deleted file mode 100644 index 5298019..0000000 --- a/src/modules/jmap/jmap.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { firstValueFrom } from 'rxjs'; - -@Injectable() -export class JmapService { - private readonly stalwartUrl: string; - - constructor( - private readonly httpService: HttpService, - private readonly configService: ConfigService, - ) { - this.stalwartUrl = - this.configService.get('stalwart.url') ?? 'http://localhost:8085'; - } - - async request(method: string, params: Record) { - const response = await firstValueFrom( - this.httpService.post(`${this.stalwartUrl}/jmap`, { - using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], - methodCalls: [[method, params, '0']], - }), - ); - return response.data as unknown; - } -} diff --git a/test/fixtures.ts b/test/fixtures.ts new file mode 100644 index 0000000..703c6ab --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,209 @@ +import Chance from 'chance'; +import type { + Mailbox, + EmailAddress, + EmailSummary, + Email, + SendEmailDto, + DraftEmailDto, + MailboxType, +} from '../src/modules/email/email.types.js'; +import type { + Mailbox as JmapMailbox, + Email as JmapEmail, + EmailAddress as JmapEmailAddress, + MailboxRole, + Identity, +} from '../src/modules/infrastructure/jmap/jmap.types.js'; + +const random = new Chance(); + +// ── Helpers ──────────────────────────────────────────────────────── + +function randomId(): string { + return random.hash({ length: 24 }); +} + +function randomISODate(): string { + return random.date({ year: 2025 }).toISOString(); +} + +// ── Domain Fixtures ──────────────────────────────────────────────── + +export function newEmailAddress( + attrs?: Partial, +): EmailAddress { + return { + name: random.name(), + email: random.email(), + ...attrs, + }; +} + +export function newMailbox( + attrs?: Partial, +): Mailbox { + return { + id: randomId(), + name: random.word(), + type: random.pickone([ + 'inbox', + 'drafts', + 'sent', + 'trash', + 'spam', + 'archive', + ]), + parentId: null, + totalEmails: random.natural({ max: 500 }), + unreadEmails: random.natural({ max: 100 }), + ...attrs, + }; +} + +export function newEmailSummary( + attrs?: Partial, +): EmailSummary { + return { + id: randomId(), + threadId: randomId(), + from: [newEmailAddress()], + to: [newEmailAddress()], + subject: random.sentence({ words: 5 }), + receivedAt: randomISODate(), + preview: random.sentence({ words: 10 }), + isRead: random.bool(), + isFlagged: random.bool(), + hasAttachment: random.bool(), + size: random.natural({ min: 100, max: 100_000 }), + ...attrs, + }; +} + +export function newEmail( + attrs?: Partial, +): Email { + const summary = newEmailSummary(attrs); + return { + ...summary, + cc: [], + bcc: [], + replyTo: [], + sentAt: randomISODate(), + textBody: random.paragraph(), + htmlBody: `

${random.paragraph()}

`, + ...attrs, + }; +} + +export function newSendEmailDto( + attrs?: Partial, +): SendEmailDto { + return { + to: [newEmailAddress()], + subject: random.sentence({ words: 5 }), + textBody: random.paragraph(), + ...attrs, + }; +} + +export function newDraftEmailDto( + attrs?: Partial, +): DraftEmailDto { + return { + to: [newEmailAddress()], + subject: random.sentence({ words: 3 }), + textBody: random.paragraph(), + ...attrs, + }; +} + +// ── JMAP Fixtures ────────────────────────────────────────────────── + +// EmailAddress is structurally identical in both domain and JMAP types +export const newJmapEmailAddress = newEmailAddress as ( + attrs?: Partial, +) => JmapEmailAddress; + +export function newJmapMailbox( + attrs?: Partial, +): JmapMailbox { + return { + id: randomId(), + name: random.word(), + parentId: null, + role: random.pickone([ + 'inbox', + 'drafts', + 'sent', + 'trash', + 'junk', + 'archive', + ]), + sortOrder: random.natural({ max: 10 }), + totalEmails: random.natural({ max: 500 }), + unreadEmails: random.natural({ max: 100 }), + totalThreads: random.natural({ max: 300 }), + unreadThreads: random.natural({ max: 50 }), + isSubscribed: random.bool(), + ...attrs, + }; +} + +export function newJmapEmail( + attrs?: Partial, +): JmapEmail { + const textPartId = randomId(); + const htmlPartId = randomId(); + + return { + id: randomId(), + blobId: randomId(), + threadId: randomId(), + mailboxIds: { [randomId()]: true }, + keywords: { + $seen: random.bool(), + $flagged: random.bool(), + }, + size: random.natural({ min: 100, max: 100_000 }), + receivedAt: randomISODate(), + sentAt: randomISODate(), + from: [newJmapEmailAddress()], + to: [newJmapEmailAddress()], + cc: [], + bcc: [], + replyTo: [], + subject: random.sentence({ words: 5 }), + preview: random.sentence({ words: 10 }), + hasAttachment: random.bool(), + textBody: [{ partId: textPartId, type: 'text/plain' }], + htmlBody: [{ partId: htmlPartId, type: 'text/html' }], + bodyValues: { + [textPartId]: { + value: random.paragraph(), + isEncodingProblem: false, + isTruncated: false, + }, + [htmlPartId]: { + value: `

${random.paragraph()}

`, + isEncodingProblem: false, + isTruncated: false, + }, + }, + ...attrs, + }; +} + +export function newJmapIdentity( + attrs?: Partial, +): Identity { + return { + id: randomId(), + name: random.name(), + email: random.email(), + textSignature: '', + htmlSignature: '', + mayDelete: true, + ...attrs, + }; +} diff --git a/tsconfig.json b/tsconfig.json index 0c85b2b..50ea75d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "target": "ES2023", "sourceMap": true, "outDir": "./dist", + "rootDir": "./", "baseUrl": "./", "incremental": true, "skipLibCheck": true, diff --git a/vitest.config.ts b/vitest.config.ts index 0c72444..976b98c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ globals: true, root: './', include: ['src/**/*.spec.ts'], + coverage: { + reporter: ['text', 'lcov'], + }, }, plugins: [swc.vite()], });