diff --git a/.env.template b/.env.template index 293c4c6..0e60fa8 100644 --- a/.env.template +++ b/.env.template @@ -16,6 +16,8 @@ STALWART_JMAP_URL=http://localhost:8085 STALWART_ADMIN_URL=http://localhost:8085 STALWART_ADMIN_USER= STALWART_ADMIN_SECRET= +STALWART_SMTP_HOST=localhost +STALWART_SMTP_PORT=587 # Auth JWT_SECRET= @@ -23,3 +25,4 @@ GATEWAY_PUBLIC_SECRET= # External APIs PAYMENTS_API_URL= +SERVER_PRIVATE_KEY= diff --git a/package-lock.json b/package-lock.json index ed122b8..a6e09a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,9 +26,11 @@ "cross-env": "^10.1.0", "dayjs": "^1.11.20", "helmet": "^8.1.0", + "internxt-crypto": "^1.5.0", "ioredis": "^5.10.1", "nanoid": "^5.1.5", "nestjs-pino": "^4.6.0", + "nodemailer": "^8.0.10", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.20.0", @@ -53,6 +55,7 @@ "@types/express": "^5.0.6", "@types/multer": "^2.1.0", "@types/node": "^22.15.0", + "@types/nodemailer": "^8.0.0", "@types/passport-jwt": "^4.0.1", "@vitest/coverage-v8": "^4.1.8", "chance": "^1.1.13", @@ -1428,6 +1431,45 @@ "reflect-metadata": "^0.1.13 || ^0.2.0" } }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1441,6 +1483,35 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nuxt/opencollective": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", @@ -1813,6 +1884,40 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1866,6 +1971,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1882,6 +1988,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1898,6 +2005,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1914,6 +2022,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1930,6 +2039,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1946,6 +2056,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1962,6 +2073,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1978,6 +2090,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1994,6 +2107,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2010,6 +2124,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2224,6 +2339,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -5087,6 +5212,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5145,7 +5276,6 @@ "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, "license": "MIT", "bin": { "husky": "bin.js" @@ -5262,6 +5392,33 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/internxt-crypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/internxt-crypto/-/internxt-crypto-1.5.0.tgz", + "integrity": "sha512-t/gjzVuer28IkNij3kf2f42CBo4AHzK+evcXyEp3qdSG1A/7Tex77ck7imwvsYH1Ngq6D/AIWvEt6LHX2ftm0w==", + "dependencies": { + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@noble/post-quantum": "^0.6.1", + "@scure/bip39": "^2.2.0", + "hash-wasm": "^4.12.0", + "husky": "^9.1.7", + "uuid": "^14.0.0" + } + }, + "node_modules/internxt-crypto/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/ioredis": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", @@ -6711,6 +6868,15 @@ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", diff --git a/package.json b/package.json index 179c832..2408108 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,11 @@ "cross-env": "^10.1.0", "dayjs": "^1.11.20", "helmet": "^8.1.0", + "internxt-crypto": "^1.5.0", "ioredis": "^5.10.1", "nanoid": "^5.1.5", "nestjs-pino": "^4.6.0", + "nodemailer": "^8.0.10", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.20.0", @@ -73,6 +75,7 @@ "@types/express": "^5.0.6", "@types/multer": "^2.1.0", "@types/node": "^22.15.0", + "@types/nodemailer": "^8.0.0", "@types/passport-jwt": "^4.0.1", "@vitest/coverage-v8": "^4.1.8", "chance": "^1.1.13", diff --git a/src/config/configuration.ts b/src/config/configuration.ts index dcfeee1..11f7620 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -19,6 +19,12 @@ export default () => ({ adminSecret: process.env.STALWART_ADMIN_SECRET ?? '', masterUser: process.env.STALWART_MASTER_USER ?? 'master', masterPassword: process.env.STALWART_MASTER_PASSWORD ?? '', + smtpHost: process.env.STALWART_SMTP_HOST ?? 'localhost', + smtpPort: Number.parseInt(process.env.STALWART_SMTP_PORT ?? '465', 10), + }, + + crypto: { + serverPrivateKey: process.env.SERVER_PRIVATE_KEY ?? '', }, accounts: { diff --git a/src/modules/email/email.controller.spec.ts b/src/modules/email/email.controller.spec.ts index 294f023..b28f3ff 100644 --- a/src/modules/email/email.controller.spec.ts +++ b/src/modules/email/email.controller.spec.ts @@ -59,6 +59,51 @@ describe('EmailController', () => { }); }); + describe('send', () => { + const baseDto = { + to: [{ email: 'alice@internxt.me' }], + subject: 'hi', + }; + + it('when deliveryMode is EXTERNAL with attachments, then sends an email to an external source', async () => { + const dto = { + ...baseDto, + deliveryMode: 'EXTERNAL' as const, + attachments: [ + { blobId: 'b1', name: 'f.txt', type: 'text/plain', size: 10 }, + ], + }; + emailService.sendExternalEmail.mockResolvedValue({ id: 'mixed-id' }); + + await controller.send(userEmail, dto); + + expect(emailService.sendExternalEmail).toHaveBeenCalledWith( + userEmail, + dto, + ); + expect(emailService.sendEmail).not.toHaveBeenCalled(); + }); + + it('when deliveryMode is INTERNXT, then routes to sendEmail', async () => { + const dto = { ...baseDto, deliveryMode: 'INTERNXT' as const }; + emailService.sendEmail.mockResolvedValue({ id: 'internal-id' }); + + await controller.send(userEmail, dto); + + expect(emailService.sendEmail).toHaveBeenCalledWith(userEmail, dto); + expect(emailService.sendExternalEmail).not.toHaveBeenCalled(); + }); + + it('when deliveryMode is missing, then routes to sendEmail', async () => { + emailService.sendEmail.mockResolvedValue({ id: 'default-id' }); + + await controller.send(userEmail, baseDto); + + expect(emailService.sendEmail).toHaveBeenCalledWith(userEmail, baseDto); + expect(emailService.sendExternalEmail).not.toHaveBeenCalled(); + }); + }); + describe('getDomains', () => { it('when getDomains is called, then it returns the active domains', async () => { const domains = [ diff --git a/src/modules/email/email.controller.ts b/src/modules/email/email.controller.ts index 820f0a9..8685828 100644 --- a/src/modules/email/email.controller.ts +++ b/src/modules/email/email.controller.ts @@ -221,6 +221,9 @@ export class EmailController { @MailAddress('address') email: string, @Body() dto: SendEmailRequestDto, ) { + if (dto.deliveryMode === 'EXTERNAL') { + return this.emailService.sendExternalEmail(email, dto); + } return this.emailService.sendEmail(email, dto); } diff --git a/src/modules/email/email.dto.ts b/src/modules/email/email.dto.ts index 5e1753f..5f07b52 100644 --- a/src/modules/email/email.dto.ts +++ b/src/modules/email/email.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayMinSize, IsArray, IsEmail } from 'class-validator'; -import type { MailboxType } from './email.types.js'; +import type { MailboxType, MailDeliveryMode } from './email.types.js'; import { MailDomainStatus } from '../account/domain/mail-domain.domain.js'; export class MailDomainDto { @@ -114,6 +114,9 @@ export class SendEmailRequestDto { @ApiPropertyOptional({ type: [AttachmentRefDto] }) attachments?: AttachmentRefDto[]; + + @ApiPropertyOptional({ enum: ['INTERNXT', 'EXTERNAL'], example: 'INTERNXT' }) + deliveryMode?: MailDeliveryMode; } export class LookupRecipientKeysRequestDto { diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts index 2cf5a3a..f93dbf5 100644 --- a/src/modules/email/email.module.ts +++ b/src/modules/email/email.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { JmapModule } from '../infrastructure/jmap/jmap.module.js'; +import { SmtpModule } from '../infrastructure/smtp/smtp.module.js'; import { ProvisioningModule } from '../provisioning/provisioning.module.js'; import { EmailController } from './email.controller.js'; import { EmailService } from './email.service.js'; import { Reflector } from '@nestjs/core'; @Module({ - imports: [JmapModule, ProvisioningModule], + imports: [JmapModule, SmtpModule, ProvisioningModule], controllers: [EmailController], providers: [EmailService, Reflector], }) diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts index 392cf68..32a57a1 100644 --- a/src/modules/email/email.service.spec.ts +++ b/src/modules/email/email.service.spec.ts @@ -1,10 +1,14 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Test } from '@nestjs/testing'; import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { Readable } from 'node:stream'; import { EmailService } from './email.service.js'; import { MailProvider } from './mail-provider.port.js'; import { AccountService } from '../account/account.service.js'; +import { StalwartSmtpService } from '../infrastructure/smtp/stalwart-smtp.service.js'; import { newMailbox, newEmail, @@ -16,14 +20,28 @@ import { newEncryptedWrappedKey, } from '../../../test/fixtures.js'; import { ENCRYPTED_PREFIX, packEnvelope } from './email-encryption.js'; +import { + unwrapAttachmentKey, + decryptAttachment, + decryptBody, +} from './server-crypto.js'; + +vi.mock('./server-crypto.js', () => ({ + unwrapAttachmentKey: vi.fn(), + decryptAttachment: vi.fn(), + decryptBody: vi.fn(), +})); describe('EmailService', () => { let service: EmailService; let provider: DeepMocked; let accountService: DeepMocked; + let smtp: DeepMocked; + let configService: DeepMocked; const userEmail = 'test@example.com'; beforeEach(async () => { + vi.clearAllMocks(); const module = await Test.createTestingModule({ providers: [EmailService], }) @@ -33,6 +51,8 @@ describe('EmailService', () => { service = module.get(EmailService); provider = module.get>(MailProvider); accountService = module.get>(AccountService); + smtp = module.get>(StalwartSmtpService); + configService = module.get>(ConfigService); }); describe('getMailboxes', () => { @@ -278,6 +298,115 @@ describe('EmailService', () => { }); }); + describe('sendExternalEmail', () => { + const mockedUnwrap = vi.mocked(unwrapAttachmentKey); + const mockedDecrypt = vi.mocked(decryptAttachment); + const mockedDecryptBody = vi.mocked(decryptBody); + + it('when DTO has empty recipients, then throws BadRequestException', async () => { + const dto = newSendEmailDto({ to: [] }); + + await expect(service.sendExternalEmail(userEmail, dto)).rejects.toThrow( + BadRequestException, + ); + expect(smtp.sendRaw).not.toHaveBeenCalled(); + }); + + it('when DTO has no attachments and no encryption, then sends through SMTP and saves to Sent', async () => { + const dto = newSendEmailDto({ + attachments: undefined, + encryption: undefined, + textBody: 'hello', + }); + configService.getOrThrow.mockReturnValue( + Buffer.from('server-priv-key').toString('base64'), + ); + smtp.sendRaw.mockResolvedValue({ messageId: 'msg-1' }); + provider.saveToSent.mockResolvedValue({ id: 'sent-1' }); + + const result = await service.sendExternalEmail(userEmail, dto); + + expect(smtp.sendRaw).toHaveBeenCalledWith( + expect.objectContaining({ + userEmail, + to: dto.to, + subject: dto.subject, + text: 'hello', + attachments: undefined, + }), + ); + expect(provider.saveToSent).toHaveBeenCalled(); + expect(result).toEqual({ id: 'msg-1' }); + }); + + it('when DTO has attachments but no wrapped keys, then throws BadRequestException', async () => { + const dto = newSendEmailDto({ + attachments: [ + { blobId: 'b1', name: 'f.txt', type: 'text/plain', size: 10 }, + ], + encryption: undefined, + }); + configService.getOrThrow.mockReturnValue( + Buffer.from('server-priv-key').toString('base64'), + ); + + await expect(service.sendExternalEmail(userEmail, dto)).rejects.toThrow( + BadRequestException, + ); + expect(smtp.sendRaw).not.toHaveBeenCalled(); + }); + + it('when DTO has encrypted body and attachments, then decrypts both, sends plain via SMTP and saves cipher to Sent', async () => { + const wrappedKey = newEncryptedWrappedKey(); + const encryption = newEncryptionBlock({ + attachmentWrappedKeys: [wrappedKey], + }); + const dto = newSendEmailDto({ + attachments: [ + { blobId: 'b1', name: 'photo.jpg', type: 'image/jpeg', size: 1024 }, + ], + encryption, + textBody: 'check the attachment', + }); + configService.getOrThrow.mockReturnValue( + Buffer.from('server-priv-key').toString('base64'), + ); + mockedDecryptBody.mockResolvedValue('plain body text'); + const attachmentKey = new Uint8Array([1, 2, 3, 4]); + mockedUnwrap.mockResolvedValue(attachmentKey); + provider.downloadAttachment.mockResolvedValue({ + stream: Readable.from([Buffer.from('cipher-bytes')]), + contentType: 'image/jpeg', + contentLength: 12, + }); + mockedDecrypt.mockResolvedValue(new Uint8Array([9, 9, 9])); + smtp.sendRaw.mockResolvedValue({ messageId: 'msg-2' }); + provider.saveToSent.mockResolvedValue({ id: 'sent-2' }); + + const result = await service.sendExternalEmail(userEmail, dto); + + expect(smtp.sendRaw).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'plain body text', + attachments: [ + { + filename: 'photo.jpg', + content: Buffer.from([9, 9, 9]), + contentType: 'image/jpeg', + }, + ], + }), + ); + expect(provider.saveToSent).toHaveBeenCalledWith( + userEmail, + expect.objectContaining({ + textBody: expect.stringContaining('INTERNXT-ENCRYPTED-EMAIL-v1'), + }), + ); + expect(result).toEqual({ id: 'msg-2' }); + }); + }); + describe('lookupRecipientKeys', () => { it('when called, then delegates to accountService and wraps the result', async () => { const recipients = [ diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts index 705f1ca..d45a2f4 100644 --- a/src/modules/email/email.service.ts +++ b/src/modules/email/email.service.ts @@ -3,6 +3,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { AccountService } from '../account/account.service.js'; import { MailProvider } from './mail-provider.port.js'; import type { @@ -29,12 +30,32 @@ import { UploadAttachmentPayload, UploadAttachmentResponse, } from '../infrastructure/jmap/jmap.types.js'; +import { + StalwartSmtpService, + type SmtpAttachment, +} from '../infrastructure/smtp/stalwart-smtp.service.js'; +import { + decryptAttachment, + decryptBody, + unwrapAttachmentKey, +} from './server-crypto.js'; +import type { Readable } from 'node:stream'; + +async function streamToBuffer(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string)); + } + return Buffer.concat(chunks); +} @Injectable() export class EmailService { constructor( private readonly mail: MailProvider, private readonly accountService: AccountService, + private readonly smtp: StalwartSmtpService, + private readonly configService: ConfigService, ) {} getMailboxes(userEmail: string): Promise { @@ -127,6 +148,98 @@ export class EmailService { return this.mail.sendEmail(userEmail, dto); } + async sendExternalEmail( + userEmail: string, + dto: SendEmailDto, + ): Promise<{ id: string }> { + if (dto.to.length === 0) { + throw new BadRequestException('At least one recipient is required'); + } + + const serverPrivateKey = Buffer.from( + this.configService.getOrThrow('crypto.serverPrivateKey'), + 'base64', + ); + + const [plainBody, attachments] = await Promise.all([ + this.decryptBodyForExternalDelivery(dto, serverPrivateKey), + this.decryptAttachmentsForExternalDelivery( + userEmail, + dto, + serverPrivateKey, + ), + ]); + + const { messageId } = await this.smtp.sendRaw({ + userEmail, + to: dto.to, + cc: dto.cc, + bcc: dto.bcc, + subject: dto.subject, + text: plainBody ?? dto.textBody, + attachments, + }); + + // Need to save the mail to Sent manually as the smtp service does not save it for us + await this.mail.saveToSent(userEmail, { + ...dto, + textBody: dto.encryption ? packEnvelope(dto.encryption) : dto.textBody, + htmlBody: undefined, + }); + + return { id: messageId }; + } + + private async decryptBodyForExternalDelivery( + dto: SendEmailDto, + serverPrivateKey: Uint8Array, + ): Promise { + if (!dto.encryption?.encryptedText || !dto.encryption.wrappedKeys?.length) { + return undefined; + } + return decryptBody( + dto.encryption.encryptedText, + dto.encryption.wrappedKeys, + serverPrivateKey, + ); + } + + private async decryptAttachmentsForExternalDelivery( + userEmail: string, + dto: SendEmailDto, + serverPrivateKey: Uint8Array, + ): Promise { + if (!dto.attachments?.length) return undefined; + + const wrappedKeys = dto.encryption?.attachmentWrappedKeys; + if (!wrappedKeys?.length) { + throw new BadRequestException( + 'attachmentWrappedKeys are required when sending attachments in MIXED mode', + ); + } + + const attachmentKey = await unwrapAttachmentKey( + wrappedKeys, + serverPrivateKey, + ); + + return Promise.all( + dto.attachments.map(async (a) => { + const { stream } = await this.mail.downloadAttachment({ + userEmail, + blobId: a.blobId, + }); + const ciphertext = await streamToBuffer(stream); + const plaintext = await decryptAttachment(ciphertext, attachmentKey); + return { + filename: a.name, + content: Buffer.from(plaintext), + contentType: a.type, + }; + }), + ); + } + saveDraft(userEmail: string, dto: DraftEmailDto): Promise<{ id: string }> { return this.mail.saveDraft(userEmail, dto); } diff --git a/src/modules/email/email.types.ts b/src/modules/email/email.types.ts index 1008f6c..4ab1b1a 100644 --- a/src/modules/email/email.types.ts +++ b/src/modules/email/email.types.ts @@ -20,6 +20,8 @@ export interface Mailbox { unreadEmails: number; } +export type MailDeliveryMode = 'INTERNXT' | 'EXTERNAL'; + export interface EncryptedSummaryFields { encryptedPreview: string; wrappedKeys: EncryptedWrappedKey[]; diff --git a/src/modules/email/mail-provider.port.ts b/src/modules/email/mail-provider.port.ts index a0a7053..5f1833a 100644 --- a/src/modules/email/mail-provider.port.ts +++ b/src/modules/email/mail-provider.port.ts @@ -28,6 +28,10 @@ export abstract class MailProvider { dto: SendEmailDto, ): Promise<{ id: string }>; abstract search(params: SearchEmailDto): Promise; + abstract saveToSent( + userEmail: string, + dto: SendEmailDto, + ): Promise<{ id: string }>; abstract saveDraft( userEmail: string, dto: DraftEmailDto, diff --git a/src/modules/email/server-crypto.spec.ts b/src/modules/email/server-crypto.spec.ts new file mode 100644 index 0000000..f94b852 --- /dev/null +++ b/src/modules/email/server-crypto.spec.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { newEncryptedWrappedKey } from '../../../test/fixtures.js'; + +vi.mock('internxt-crypto/email-crypto', () => ({ + decryptKeysHybrid: vi.fn(), + decryptEmailHybrid: vi.fn(), +})); + +vi.mock('internxt-crypto', () => ({ + decryptSymmetrically: vi.fn(), +})); + +import { + decryptKeysHybrid, + decryptEmailHybrid, +} from 'internxt-crypto/email-crypto'; +import { decryptSymmetrically } from 'internxt-crypto'; +import { + unwrapAttachmentKey, + decryptBody, + decryptAttachment, +} from './server-crypto.js'; + +describe('server-crypto', () => { + const mockedDecryptKeysHybrid = vi.mocked(decryptKeysHybrid); + const mockedDecryptEmailHybrid = vi.mocked(decryptEmailHybrid); + const mockedDecryptSymmetrically = vi.mocked(decryptSymmetrically); + const serverPrivateKey = new Uint8Array([1, 2, 3, 4]); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('unwrapAttachmentKey', () => { + it('When there is a wrapped key that matches the server, then the attachment key is returned', async () => { + const wrappedKey = newEncryptedWrappedKey(); + const expectedKey = new Uint8Array([9, 9, 9]); + mockedDecryptKeysHybrid.mockResolvedValue(expectedKey); + + const result = await unwrapAttachmentKey([wrappedKey], serverPrivateKey); + + expect(result).toBe(expectedKey); + }); + + it('When the first key does not match but the second does, then it keeps trying until it finds the right one', async () => { + const wrongKey = newEncryptedWrappedKey(); + const rightKey = newEncryptedWrappedKey(); + const expectedKey = new Uint8Array([7, 7, 7]); + mockedDecryptKeysHybrid + .mockRejectedValueOnce(new Error('integrity check failed')) + .mockResolvedValueOnce(expectedKey); + + const result = await unwrapAttachmentKey( + [wrongKey, rightKey], + serverPrivateKey, + ); + + expect(mockedDecryptKeysHybrid).toHaveBeenCalledTimes(2); + expect(result).toBe(expectedKey); + }); + + it('When none of the wrapped keys match the server, then an error is thrown', async () => { + mockedDecryptKeysHybrid.mockRejectedValue( + new Error('integrity check failed'), + ); + + await expect( + unwrapAttachmentKey( + [newEncryptedWrappedKey(), newEncryptedWrappedKey()], + serverPrivateKey, + ), + ).rejects.toThrow(); + }); + + it('When the wrapped keys array is empty, then an error is thrown immediately', async () => { + await expect(unwrapAttachmentKey([], serverPrivateKey)).rejects.toThrow(); + + expect(mockedDecryptKeysHybrid).not.toHaveBeenCalled(); + }); + }); + + describe('decryptBody', () => { + it('When the server key matches, then the decrypted body text is returned', async () => { + const wrappedKey = newEncryptedWrappedKey(); + mockedDecryptEmailHybrid.mockResolvedValue({ text: 'Hello world' }); + + const result = await decryptBody( + 'encrypted-text', + [wrappedKey], + serverPrivateKey, + ); + + expect(result).toBe('Hello world'); + }); + + it('When the first key does not match but the second does, then it keeps trying until it finds the right one', async () => { + const wrongKey = newEncryptedWrappedKey(); + const rightKey = newEncryptedWrappedKey(); + mockedDecryptEmailHybrid + .mockRejectedValueOnce(new Error('integrity check failed')) + .mockResolvedValueOnce({ text: 'Decrypted body' }); + + const result = await decryptBody( + 'encrypted-text', + [wrongKey, rightKey], + serverPrivateKey, + ); + + expect(mockedDecryptEmailHybrid).toHaveBeenCalledTimes(2); + expect(result).toBe('Decrypted body'); + }); + + it('When none of the wrapped keys match the server, then an error is thrown', async () => { + mockedDecryptEmailHybrid.mockRejectedValue( + new Error('integrity check failed'), + ); + + await expect( + decryptBody( + 'encrypted-text', + [newEncryptedWrappedKey(), newEncryptedWrappedKey()], + serverPrivateKey, + ), + ).rejects.toThrow(); + }); + + it('When the wrapped keys array is empty, then an error is thrown immediately', async () => { + await expect( + decryptBody('encrypted-text', [], serverPrivateKey), + ).rejects.toThrow(); + + expect(mockedDecryptEmailHybrid).not.toHaveBeenCalled(); + }); + }); + + describe('decryptAttachment', () => { + it('When given encrypted bytes and the attachment key, then the decrypted bytes are returned', async () => { + const ciphertext = new Uint8Array([10, 20, 30]); + const attachmentKey = new Uint8Array([1, 2, 3]); + const plaintext = new Uint8Array([99, 98, 97]); + mockedDecryptSymmetrically.mockResolvedValue(plaintext); + + const result = await decryptAttachment(ciphertext, attachmentKey); + + expect(mockedDecryptSymmetrically).toHaveBeenCalledWith( + attachmentKey, + ciphertext, + ); + expect(result).toBe(plaintext); + }); + }); +}); diff --git a/src/modules/email/server-crypto.ts b/src/modules/email/server-crypto.ts new file mode 100644 index 0000000..5b8f26d --- /dev/null +++ b/src/modules/email/server-crypto.ts @@ -0,0 +1,73 @@ +import { + decryptKeysHybrid, + decryptEmailHybrid, +} from 'internxt-crypto/email-crypto'; +import { decryptSymmetrically } from 'internxt-crypto'; +import type { EncryptedWrappedKey } from './email.types.js'; + +async function trialUnwrapKey( + wrappedKeys: EncryptedWrappedKey[], + serverPrivateKey: Uint8Array, +): Promise { + if (!wrappedKeys.length) throw new Error('No wrapped keys provided'); + for (const wrappedKey of wrappedKeys) { + try { + return await decryptKeysHybrid( + { + hybridCiphertext: wrappedKey.hybridCiphertext, + encryptedKey: wrappedKey.encryptedKey, + encryptedForEmail: '', + }, + serverPrivateKey, + ); + } catch { + // not our key, try the next one + } + } + throw new Error( + 'None of the wrapped keys could be decrypted with the server private key', + ); +} + +export async function unwrapAttachmentKey( + wrappedKeys: EncryptedWrappedKey[], + serverPrivateKey: Uint8Array, +): Promise { + return trialUnwrapKey(wrappedKeys, serverPrivateKey); +} + +export async function decryptBody( + encryptedText: string, + wrappedKeys: EncryptedWrappedKey[], + serverPrivateKey: Uint8Array, +): Promise { + if (!wrappedKeys.length) throw new Error('No wrapped keys provided for body'); + for (const wrappedKey of wrappedKeys) { + try { + const { text } = await decryptEmailHybrid( + { + encryptedKey: { + hybridCiphertext: wrappedKey.hybridCiphertext, + encryptedKey: wrappedKey.encryptedKey, + encryptedForEmail: '', + }, + encEmail: { encText: encryptedText }, + }, + serverPrivateKey, + ); + return text; + } catch { + // not our key, try the next one + } + } + throw new Error( + 'None of the wrapped keys could be used to decrypt the body with the server private key', + ); +} + +export async function decryptAttachment( + ciphertext: Uint8Array, + attachmentKey: Uint8Array, +): Promise { + return decryptSymmetrically(attachmentKey, ciphertext); +} diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.ts index fb7282a..bb50b85 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.ts @@ -307,6 +307,34 @@ export class JmapMailProvider extends MailProvider { return { id: createdId }; } + async saveToSent( + userEmail: string, + dto: SendEmailDto, + ): Promise<{ id: string }> { + const [accountId, identity, sentMailboxId] = await Promise.all([ + this.jmap.getPrimaryAccountId(userEmail), + this.resolveIdentity(userEmail), + this.resolveMailboxId(userEmail, 'sent'), + ]); + + const emailCreate = mapSendDtoToJmapCreate(dto, sentMailboxId, { + name: identity.name, + email: identity.email, + }); + + const response = await this.jmap.request>( + userEmail, + [['Email/set', { accountId, create: { sent: emailCreate } }, 'r0']], + ); + + const createdId = response.methodResponses[0]![1].created?.['sent']?.id; + if (!createdId) { + throw new Error('Failed to save email to Sent'); + } + + return { id: createdId }; + } + async saveDraft( userEmail: string, dto: DraftEmailDto, diff --git a/src/modules/infrastructure/smtp/smtp.module.ts b/src/modules/infrastructure/smtp/smtp.module.ts new file mode 100644 index 0000000..bb66736 --- /dev/null +++ b/src/modules/infrastructure/smtp/smtp.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { StalwartSmtpService } from './stalwart-smtp.service.js'; + +@Module({ + providers: [StalwartSmtpService], + exports: [StalwartSmtpService], +}) +export class SmtpModule {} diff --git a/src/modules/infrastructure/smtp/stalwart-smtp.service.spec.ts b/src/modules/infrastructure/smtp/stalwart-smtp.service.spec.ts new file mode 100644 index 0000000..e1a952b --- /dev/null +++ b/src/modules/infrastructure/smtp/stalwart-smtp.service.spec.ts @@ -0,0 +1,168 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Test } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { StalwartSmtpService } from './stalwart-smtp.service.js'; + +const mockSendMail = vi.fn(); +const mockClose = vi.fn(); + +vi.mock('nodemailer', () => ({ + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + close: mockClose, + })), +})); + +import { createTransport } from 'nodemailer'; + +describe('StalwartSmtpService', () => { + let service: StalwartSmtpService; + let configService: DeepMocked; + + beforeEach(async () => { + vi.clearAllMocks(); + + const config: Record = { + 'stalwart.smtpHost': 'mail-server', + 'stalwart.smtpPort': 465, + 'stalwart.masterUser': 'mail-api@inxt.me', + 'stalwart.masterPassword': 'secret', + }; + + const module = await Test.createTestingModule({ + providers: [StalwartSmtpService], + }) + .useMocker((token) => { + if (token === ConfigService) { + return createMock({ + getOrThrow: vi.fn((key: string) => config[key]), + }); + } + return createMock(); + }) + .compile(); + + service = module.get(StalwartSmtpService); + configService = module.get(ConfigService); + }); + + describe('sendRaw', () => { + it('When sending an email, then it connects to the configured SMTP server', async () => { + mockSendMail.mockResolvedValue({ messageId: 'msg-1' }); + + await service.sendRaw({ + userEmail: 'alice@inxt.me', + to: [{ email: 'bob@external.com' }], + subject: 'Hello', + }); + + expect(createTransport).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'mail-server', + port: 465, + }), + ); + }); + + it('When sending an email, then it authenticates as the sender via master-user impersonation', async () => { + mockSendMail.mockResolvedValue({ messageId: 'msg-1' }); + + await service.sendRaw({ + userEmail: 'alice@inxt.me', + to: [{ email: 'bob@external.com' }], + subject: 'Hello', + }); + + expect(createTransport).toHaveBeenCalledWith( + expect.objectContaining({ + auth: { + user: 'alice@inxt.me%mail-api@inxt.me', + pass: 'secret', + }, + }), + ); + }); + + it('When sending an email to multiple recipients with display names, then all addresses are formatted correctly', async () => { + mockSendMail.mockResolvedValue({ messageId: 'msg-1' }); + + await service.sendRaw({ + userEmail: 'alice@inxt.me', + to: [ + { name: 'Bob Smith', email: 'bob@external.com' }, + { email: 'carol@external.com' }, + ], + subject: 'Hello', + }); + + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: [ + { name: 'Bob Smith', address: 'bob@external.com' }, + 'carol@external.com', + ], + }), + ); + }); + + it('When sending an email with attachments, then the attachments are included in the message', async () => { + mockSendMail.mockResolvedValue({ messageId: 'msg-2' }); + const attachment = { + filename: 'report.pdf', + content: Buffer.from('pdf-bytes'), + contentType: 'application/pdf', + }; + + await service.sendRaw({ + userEmail: 'alice@inxt.me', + to: [{ email: 'bob@external.com' }], + subject: 'Report', + attachments: [attachment], + }); + + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ attachments: [attachment] }), + ); + }); + + it('When the email is sent successfully, then the message ID returned by the server is returned', async () => { + mockSendMail.mockResolvedValue({ messageId: 'unique-msg-id' }); + + const result = await service.sendRaw({ + userEmail: 'alice@inxt.me', + to: [{ email: 'bob@external.com' }], + subject: 'Hello', + }); + + expect(result).toEqual({ messageId: 'unique-msg-id' }); + }); + + it('When the SMTP server rejects the message, then the error is propagated to the caller', async () => { + mockSendMail.mockRejectedValue(new Error('Connection refused')); + + await expect( + service.sendRaw({ + userEmail: 'alice@inxt.me', + to: [{ email: 'bob@external.com' }], + subject: 'Hello', + }), + ).rejects.toThrow('Connection refused'); + }); + + it('When sending fails, then the connection is always closed to avoid leaks', async () => { + mockSendMail.mockRejectedValue(new Error('SMTP error')); + + await expect( + service.sendRaw({ + userEmail: 'alice@inxt.me', + to: [{ email: 'bob@external.com' }], + subject: 'Hello', + }), + ).rejects.toThrow(); + + expect(mockClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/infrastructure/smtp/stalwart-smtp.service.ts b/src/modules/infrastructure/smtp/stalwart-smtp.service.ts new file mode 100644 index 0000000..21f9924 --- /dev/null +++ b/src/modules/infrastructure/smtp/stalwart-smtp.service.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createTransport } from 'nodemailer'; +import type { EmailAddress } from '../../email/email.types.js'; +import Mail from 'nodemailer/lib/mailer/index.js'; + +export interface SmtpAttachment { + filename: string; + content: Buffer; + contentType: string; +} + +export interface SendRawPayload { + userEmail: string; + to: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + subject: string; + text?: string; + html?: string; + attachments?: SmtpAttachment[]; +} + +@Injectable() +export class StalwartSmtpService { + private readonly logger = new Logger(StalwartSmtpService.name); + private readonly host: string; + private readonly port: number; + private readonly masterUser: string; + private readonly masterPassword: string; + + constructor(private readonly configService: ConfigService) { + this.host = this.configService.getOrThrow('stalwart.smtpHost'); + this.port = this.configService.getOrThrow('stalwart.smtpPort'); + this.masterUser = this.configService.getOrThrow( + 'stalwart.masterUser', + ); + this.masterPassword = this.configService.getOrThrow( + 'stalwart.masterPassword', + ); + } + + async sendRaw(payload: SendRawPayload): Promise<{ messageId: string }> { + const transporter = createTransport({ + host: this.host, + port: this.port, + secure: true, + tls: { rejectUnauthorized: false }, + auth: { + user: `${payload.userEmail}%${this.masterUser}`, + pass: this.masterPassword, + }, + }); + + try { + const { messageId } = await transporter.sendMail({ + from: payload.userEmail, + to: formatAddresses(payload.to), + cc: payload.cc ? formatAddresses(payload.cc) : undefined, + bcc: payload.bcc ? formatAddresses(payload.bcc) : undefined, + subject: payload.subject, + text: payload.text, + html: payload.html, + attachments: payload.attachments, + }); + this.logger.debug(`SMTP sent for ${payload.userEmail}: ${messageId}`); + return { messageId }; + } finally { + transporter.close(); + } + } +} + +function formatAddresses(addresses: EmailAddress[]): (string | Mail.Address)[] { + return addresses.map((a) => + a.name + ? { + name: a.name, + address: a.email, + } + : a.email, + ); +}