diff --git a/.env.template b/.env.template index 83cec43..483848d 100644 --- a/.env.template +++ b/.env.template @@ -11,7 +11,8 @@ RDS_PASSWORD=example # Stalwart STALWART_JMAP_URL=http://localhost:8085 STALWART_ADMIN_URL=http://localhost:8085 -STALWART_ADMIN_TOKEN= +STALWART_ADMIN_USER= +STALWART_ADMIN_SECRET= # Auth JWT_SECRET= diff --git a/eslint.config.mjs b/eslint.config.mjs index 015a68f..39a9d18 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,4 +39,10 @@ export default [ ], }, }, + { + files: ['**/*.spec.ts', '**/*.test.ts'], + rules: { + '@typescript-eslint/unbound-method': 'off', + }, + }, ]; diff --git a/migrations/20260325105400-add-deleted-at-to-mail-accounts.js b/migrations/20260325105400-add-deleted-at-to-mail-accounts.js new file mode 100644 index 0000000..91984af --- /dev/null +++ b/migrations/20260325105400-add-deleted-at-to-mail-accounts.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('mail_accounts', 'deleted_at', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('mail_accounts', 'deleted_at'); + }, +}; diff --git a/migrations/20260325105401-add-deleted-at-to-mail-addresses.js b/migrations/20260325105401-add-deleted-at-to-mail-addresses.js new file mode 100644 index 0000000..1cd6d00 --- /dev/null +++ b/migrations/20260325105401-add-deleted-at-to-mail-addresses.js @@ -0,0 +1,16 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('mail_addresses', 'deleted_at', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('mail_addresses', 'deleted_at'); + }, +}; diff --git a/src/app.module.spec.ts b/src/app.module.spec.ts deleted file mode 100644 index 2cc290a..0000000 --- a/src/app.module.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { describe, it, expect } from 'vitest'; -import { AppModule } from './app.module'; - -describe('AppModule', () => { - it('should compile the module', async () => { - const module = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - expect(module).toBeDefined(); - }); -}); diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 475b1f2..3601416 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -15,7 +15,8 @@ export default () => ({ stalwart: { url: process.env.STALWART_JMAP_URL ?? 'http://localhost:8085', adminUrl: process.env.STALWART_ADMIN_URL ?? 'http://localhost:8085', - adminToken: process.env.STALWART_ADMIN_TOKEN ?? '', + adminUser: process.env.STALWART_ADMIN_USER ?? 'mail-api', + adminSecret: process.env.STALWART_ADMIN_SECRET ?? '', masterUser: process.env.STALWART_MASTER_USER ?? 'master', masterPassword: process.env.STALWART_MASTER_PASSWORD ?? '', }, diff --git a/src/modules/account/account-provider.port.ts b/src/modules/account/account-provider.port.ts new file mode 100644 index 0000000..4b43479 --- /dev/null +++ b/src/modules/account/account-provider.port.ts @@ -0,0 +1,13 @@ +import type { AccountInfo, CreateAccountParams } from './account.types.js'; + +export abstract class AccountProvider { + abstract createAccount(params: CreateAccountParams): Promise; + abstract deleteAccount(name: string): Promise; + abstract getAccount(name: string): Promise; + abstract addAddress(name: string, address: string): Promise; + abstract removeAddress(name: string, address: string): Promise; + abstract setPrimaryAddress( + currentName: string, + newPrimaryAddress: string, + ): Promise; +} diff --git a/src/modules/account/account.module.ts b/src/modules/account/account.module.ts index ddbe841..ec529b8 100644 --- a/src/modules/account/account.module.ts +++ b/src/modules/account/account.module.ts @@ -1,11 +1,16 @@ import { Module } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; +import { StalwartModule } from '../infrastructure/stalwart/stalwart.module.js'; +import { AccountService } from './account.service.js'; import { MailAccountModel, MailAddressModel, MailDomainModel, MailProviderAccountModel, } from './models/index.js'; +import { AccountRepository } from './repositories/account.repository.js'; +import { AddressRepository } from './repositories/address.repository.js'; +import { DomainRepository } from './repositories/domain.repository.js'; @Module({ imports: [ @@ -15,7 +20,14 @@ import { MailDomainModel, MailProviderAccountModel, ]), + StalwartModule, ], - exports: [SequelizeModule], + providers: [ + AccountRepository, + AddressRepository, + DomainRepository, + AccountService, + ], + exports: [AccountService], }) export class AccountModule {} diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts new file mode 100644 index 0000000..3b79473 --- /dev/null +++ b/src/modules/account/account.service.spec.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { + ConflictException, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { AccountService } from './account.service.js'; +import { AccountProvider } from './account-provider.port.js'; +import { MailAccount } from './domain/mail-account.domain.js'; +import { MailDomain } from './domain/mail-domain.domain.js'; +import { MailAddress } from './domain/mail-address.domain.js'; +import { AccountRepository } from './repositories/account.repository.js'; +import { AddressRepository } from './repositories/address.repository.js'; +import { DomainRepository } from './repositories/domain.repository.js'; +import { + newMailAccountAttributes, + newMailAddressAttributes, + newMailDomainAttributes, +} from '../../../test/fixtures.js'; + +describe('AccountService', () => { + let service: AccountService; + let provider: DeepMocked; + let accounts: DeepMocked; + let addresses: DeepMocked; + let domains: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AccountService], + }) + .useMocker(() => createMock()) + .compile(); + + service = module.get(AccountService); + provider = module.get(AccountProvider); + accounts = module.get(AccountRepository); + addresses = module.get(AddressRepository); + domains = module.get(DomainRepository); + }); + + describe('getAccount', () => { + it('when account exists, then returns it', async () => { + const attrs = newMailAccountAttributes(); + const account = MailAccount.build(attrs); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + const result = await service.getAccount(attrs.driveUserUuid); + + expect(accounts.findByDriveUserUuid).toHaveBeenCalledWith( + attrs.driveUserUuid, + ); + expect(result).toBe(account); + }); + + it('when account does not exist, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + + await expect(service.getAccount('unknown-uuid')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('deleteAccount', () => { + it('when account has a principal name, then deletes from provider and DB', async () => { + const attrs = newMailAccountAttributes(); + const account = MailAccount.build(attrs); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.deleteAccount(attrs.driveUserUuid); + + expect(provider.deleteAccount).toHaveBeenCalledWith( + account.providerAccountId, + ); + expect(accounts.delete).toHaveBeenCalledWith(account.id); + }); + + it('when account has no principal name, then only deletes from DB', async () => { + const attrs = newMailAccountAttributes({ + addresses: [ + newMailAddressAttributes({ + isDefault: true, + providerExternalId: null, + }), + ], + }); + const account = MailAccount.build(attrs); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.deleteAccount(attrs.driveUserUuid); + + expect(provider.deleteAccount).not.toHaveBeenCalled(); + expect(accounts.delete).toHaveBeenCalledWith(account.id); + }); + + it('when account does not exist, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + + await expect(service.deleteAccount('unknown')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('addAddress', () => { + it('when all conditions met, then creates address and links provider', async () => { + const accountAttrs = newMailAccountAttributes(); + const account = MailAccount.build(accountAttrs); + const domainAttrs = newMailDomainAttributes(); + const domain = MailDomain.build(domainAttrs); + const newAddr = 'new@example.com'; + const createdAddress = MailAddress.build( + newMailAddressAttributes({ address: newAddr }), + ); + + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(domain); + addresses.findByAddress.mockResolvedValue(null); + addresses.create.mockResolvedValue(createdAddress); + + await service.addAddress( + accountAttrs.driveUserUuid, + newAddr, + domainAttrs.domain, + ); + + expect(addresses.create).toHaveBeenCalledWith({ + mailAccountId: account.id, + address: newAddr, + domainId: domain.id, + isDefault: false, + }); + expect(provider.addAddress).toHaveBeenCalledWith( + account.providerAccountId, + newAddr, + ); + expect(addresses.createProviderLink).toHaveBeenCalledWith({ + mailAddressId: createdAddress.id, + provider: 'stalwart', + externalId: account.providerAccountId, + }); + }); + + it('when account not found, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + domains.findByDomain.mockResolvedValue( + MailDomain.build(newMailDomainAttributes()), + ); + addresses.findByAddress.mockResolvedValue(null); + + await expect( + service.addAddress('unknown', 'a@b.com', 'b.com'), + ).rejects.toThrow(NotFoundException); + }); + + it('when domain not found, then throws NotFoundException', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(null); + addresses.findByAddress.mockResolvedValue(null); + + await expect( + service.addAddress(account.driveUserUuid, 'a@b.com', 'unknown.com'), + ).rejects.toThrow(NotFoundException); + }); + + it('when address already exists, then throws ConflictException', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + const domain = MailDomain.build(newMailDomainAttributes()); + const existing = MailAddress.build(newMailAddressAttributes()); + + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(domain); + addresses.findByAddress.mockResolvedValue(existing); + + await expect( + service.addAddress( + account.driveUserUuid, + existing.address, + domain.domain, + ), + ).rejects.toThrow(ConflictException); + }); + + it('when provider fails, then rolls back the created address', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + const domain = MailDomain.build(newMailDomainAttributes()); + const createdAddress = MailAddress.build( + newMailAddressAttributes({ address: 'new@example.com' }), + ); + + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(domain); + addresses.findByAddress.mockResolvedValue(null); + addresses.create.mockResolvedValue(createdAddress); + provider.addAddress.mockRejectedValue(new Error('provider down')); + + await expect( + service.addAddress( + account.driveUserUuid, + 'new@example.com', + domain.domain, + ), + ).rejects.toThrow('provider down'); + + expect(addresses.delete).toHaveBeenCalledWith(createdAddress.id); + expect(addresses.createProviderLink).not.toHaveBeenCalled(); + }); + + it('when account has no principal name, then throws UnprocessableEntityException', async () => { + const account = MailAccount.build( + newMailAccountAttributes({ + addresses: [ + newMailAddressAttributes({ + isDefault: true, + providerExternalId: null, + }), + ], + }), + ); + const domain = MailDomain.build(newMailDomainAttributes()); + + accounts.findByDriveUserUuid.mockResolvedValue(account); + domains.findByDomain.mockResolvedValue(domain); + addresses.findByAddress.mockResolvedValue(null); + + await expect( + service.addAddress( + account.driveUserUuid, + 'new@example.com', + domain.domain, + ), + ).rejects.toThrow(UnprocessableEntityException); + }); + }); + + describe('removeAddress', () => { + it('when address exists and is not default, then removes it', async () => { + const nonDefaultAddr = newMailAddressAttributes({ isDefault: false }); + const account = MailAccount.build( + newMailAccountAttributes({ + addresses: [ + newMailAddressAttributes({ isDefault: true }), + nonDefaultAddr, + ], + }), + ); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.removeAddress( + account.driveUserUuid, + nonDefaultAddr.address, + ); + + expect(provider.removeAddress).toHaveBeenCalledWith( + account.providerAccountId, + nonDefaultAddr.address, + ); + expect(addresses.deleteProviderLink).toHaveBeenCalledWith( + nonDefaultAddr.id, + ); + expect(addresses.delete).toHaveBeenCalledWith(nonDefaultAddr.id); + }); + + it('when address is default, then throws UnprocessableEntityException', async () => { + const defaultAddr = newMailAddressAttributes({ isDefault: true }); + const account = MailAccount.build( + newMailAccountAttributes({ addresses: [defaultAddr] }), + ); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await expect( + service.removeAddress(account.driveUserUuid, defaultAddr.address), + ).rejects.toThrow(UnprocessableEntityException); + }); + + it('when address not found for account, then throws NotFoundException', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await expect( + service.removeAddress(account.driveUserUuid, 'nonexistent@mail.com'), + ).rejects.toThrow(NotFoundException); + }); + + it('when account not found, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + + await expect(service.removeAddress('unknown', 'a@b.com')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('setPrimaryAddress', () => { + it('when address exists and is not default, then sets it as primary', async () => { + const defaultAddr = newMailAddressAttributes({ isDefault: true }); + const otherAddr = newMailAddressAttributes({ isDefault: false }); + const account = MailAccount.build( + newMailAccountAttributes({ + addresses: [defaultAddr, otherAddr], + }), + ); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.setPrimaryAddress(account.driveUserUuid, otherAddr.address); + + expect(provider.setPrimaryAddress).toHaveBeenCalledWith( + account.providerAccountId, + otherAddr.address, + ); + expect(addresses.setDefault).toHaveBeenCalledWith( + otherAddr.id, + account.id, + ); + expect(addresses.updateAllProviderExternalIds).toHaveBeenCalledWith( + account.id, + otherAddr.address, + ); + }); + + it('when address is already default, then does nothing', async () => { + const defaultAddr = newMailAddressAttributes({ isDefault: true }); + const account = MailAccount.build( + newMailAccountAttributes({ addresses: [defaultAddr] }), + ); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await service.setPrimaryAddress( + account.driveUserUuid, + defaultAddr.address, + ); + + expect(provider.setPrimaryAddress).not.toHaveBeenCalled(); + expect(addresses.setDefault).not.toHaveBeenCalled(); + }); + + it('when address not found for account, then throws NotFoundException', async () => { + const account = MailAccount.build(newMailAccountAttributes()); + accounts.findByDriveUserUuid.mockResolvedValue(account); + + await expect( + service.setPrimaryAddress( + account.driveUserUuid, + 'nonexistent@mail.com', + ), + ).rejects.toThrow(NotFoundException); + }); + + it('when account not found, then throws NotFoundException', async () => { + accounts.findByDriveUserUuid.mockResolvedValue(null); + + await expect( + service.setPrimaryAddress('unknown', 'a@b.com'), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts new file mode 100644 index 0000000..8bc679b --- /dev/null +++ b/src/modules/account/account.service.ts @@ -0,0 +1,167 @@ +import { + ConflictException, + Injectable, + Logger, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { AccountProvider } from './account-provider.port.js'; +import { MailAccount } from './domain/mail-account.domain.js'; +import { AccountRepository } from './repositories/account.repository.js'; +import { AddressRepository } from './repositories/address.repository.js'; +import { DomainRepository } from './repositories/domain.repository.js'; + +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name); + + constructor( + private readonly provider: AccountProvider, + private readonly accounts: AccountRepository, + private readonly addresses: AddressRepository, + private readonly domains: DomainRepository, + ) {} + + async getAccount(driveUserUuid: string): Promise { + return this.getAccountOrFail(driveUserUuid); + } + + async deleteAccount(driveUserUuid: string): Promise { + const account = await this.getAccountOrFail(driveUserUuid); + + if (account.providerAccountId) { + await this.provider.deleteAccount(account.providerAccountId); + } + + await this.accounts.delete(account.id); + this.logger.log(`Deleted account for drive user '${driveUserUuid}'`); + } + + async addAddress( + driveUserUuid: string, + address: string, + domainName: string, + ): Promise { + const [account, domain, existing] = await Promise.all([ + this.accounts.findByDriveUserUuid(driveUserUuid), + this.domains.findByDomain(domainName), + this.addresses.findByAddress(address), + ]); + + if (!account) { + throw new NotFoundException( + `No mail account for drive user '${driveUserUuid}'`, + ); + } + const providerAccountId = this.requireProviderAccountId(account); + + if (!domain) { + throw new NotFoundException(`Domain '${domainName}' not found`); + } + if (existing) { + throw new ConflictException(`Address '${address}' already exists`); + } + + const newAddress = await this.addresses.create({ + mailAccountId: account.id, + address, + domainId: domain.id, + isDefault: false, + }); + + try { + await this.provider.addAddress(providerAccountId, address); + } catch (error) { + await this.addresses.delete(newAddress.id); + throw error; + } + + await this.addresses.createProviderLink({ + mailAddressId: newAddress.id, + provider: 'stalwart', + externalId: providerAccountId, + }); + + this.logger.log(`Added address '${address}' to account '${driveUserUuid}'`); + } + + async removeAddress(driveUserUuid: string, address: string): Promise { + const account = await this.getAccountOrFail(driveUserUuid); + + const addressRecord = account.addresses.find((a) => a.address === address); + if (!addressRecord) { + throw new NotFoundException( + `Address '${address}' not found for this account`, + ); + } + + if (addressRecord.isDefault) { + throw new UnprocessableEntityException( + 'Cannot remove the default address', + ); + } + + const providerAccountId = this.requireProviderAccountId(account); + + await this.provider.removeAddress(providerAccountId, address); + await Promise.all([ + this.addresses.deleteProviderLink(addressRecord.id), + this.addresses.delete(addressRecord.id), + ]); + + this.logger.log( + `Removed address '${address}' from account '${driveUserUuid}'`, + ); + } + + async setPrimaryAddress( + driveUserUuid: string, + newAddress: string, + ): Promise { + const account = await this.getAccountOrFail(driveUserUuid); + + const addressRecord = account.addresses.find( + (a) => a.address === newAddress, + ); + if (!addressRecord) { + throw new NotFoundException( + `Address '${newAddress}' not found for this account`, + ); + } + + if (addressRecord.isDefault) return; + + const providerAccountId = this.requireProviderAccountId(account); + + await this.provider.setPrimaryAddress(providerAccountId, newAddress); + + await Promise.all([ + this.addresses.setDefault(addressRecord.id, account.id), + this.addresses.updateAllProviderExternalIds(account.id, newAddress), + ]); + + this.logger.log( + `Set primary address to '${newAddress}' for account '${driveUserUuid}'`, + ); + } + + private async getAccountOrFail(driveUserUuid: string): Promise { + const account = await this.accounts.findByDriveUserUuid(driveUserUuid); + if (!account) { + throw new NotFoundException( + `No mail account for drive user '${driveUserUuid}'`, + ); + } + return account; + } + + private requireProviderAccountId(account: MailAccount): string { + const id = account.providerAccountId; + if (!id) { + throw new UnprocessableEntityException( + 'Account has no primary address with a provider link', + ); + } + return id; + } +} diff --git a/src/modules/account/account.types.ts b/src/modules/account/account.types.ts new file mode 100644 index 0000000..4fb8f02 --- /dev/null +++ b/src/modules/account/account.types.ts @@ -0,0 +1,14 @@ +export interface CreateAccountParams { + accountId: string; + primaryAddress: string; + displayName: string; + password: string; + quota?: number; +} + +export interface AccountInfo { + name: string; + displayName: string; + emails: string[]; + quota: number; +} diff --git a/src/modules/account/domain/mail-account.domain.ts b/src/modules/account/domain/mail-account.domain.ts new file mode 100644 index 0000000..f0fda32 --- /dev/null +++ b/src/modules/account/domain/mail-account.domain.ts @@ -0,0 +1,37 @@ +import { + MailAddress, + type MailAddressAttributes, +} from './mail-address.domain.js'; + +export interface MailAccountAttributes { + id: string; + driveUserUuid: string; + addresses: MailAddressAttributes[]; + createdAt: Date; + updatedAt: Date; +} + +export class MailAccount { + readonly id!: string; + readonly driveUserUuid!: string; + readonly addresses!: MailAddress[]; + readonly createdAt!: Date; + readonly updatedAt!: Date; + + private constructor(attributes: MailAccountAttributes) { + Object.assign(this, attributes); + this.addresses = attributes.addresses.map((a) => MailAddress.build(a)); + } + + static build(attributes: MailAccountAttributes): MailAccount { + return new MailAccount(attributes); + } + + get defaultAddress(): MailAddress | undefined { + return this.addresses.find((a) => a.isDefault); + } + + get providerAccountId(): string | null { + return this.defaultAddress?.providerExternalId ?? null; + } +} diff --git a/src/modules/account/domain/mail-address.domain.ts b/src/modules/account/domain/mail-address.domain.ts new file mode 100644 index 0000000..c50f292 --- /dev/null +++ b/src/modules/account/domain/mail-address.domain.ts @@ -0,0 +1,29 @@ +export interface MailAddressAttributes { + id: string; + mailAccountId: string; + address: string; + domainId: string; + isDefault: boolean; + providerExternalId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export class MailAddress { + readonly id!: string; + readonly mailAccountId!: string; + readonly address!: string; + readonly domainId!: string; + readonly isDefault!: boolean; + readonly providerExternalId!: string | null; + readonly createdAt!: Date; + readonly updatedAt!: Date; + + private constructor(attributes: MailAddressAttributes) { + Object.assign(this, attributes); + } + + static build(attributes: MailAddressAttributes): MailAddress { + return new MailAddress(attributes); + } +} diff --git a/src/modules/account/domain/mail-domain.domain.ts b/src/modules/account/domain/mail-domain.domain.ts new file mode 100644 index 0000000..b88cb24 --- /dev/null +++ b/src/modules/account/domain/mail-domain.domain.ts @@ -0,0 +1,23 @@ +export interface MailDomainAttributes { + id: string; + domain: string; + status: string; + createdAt: Date; + updatedAt: Date; +} + +export class MailDomain { + readonly id!: string; + readonly domain!: string; + readonly status!: string; + readonly createdAt!: Date; + readonly updatedAt!: Date; + + private constructor(attributes: MailDomainAttributes) { + Object.assign(this, attributes); + } + + static build(attributes: MailDomainAttributes): MailDomain { + return new MailDomain(attributes); + } +} diff --git a/src/modules/account/models/mail-account.model.ts b/src/modules/account/models/mail-account.model.ts index f97e870..de99767 100644 --- a/src/modules/account/models/mail-account.model.ts +++ b/src/modules/account/models/mail-account.model.ts @@ -14,6 +14,7 @@ import { MailAddressModel } from './mail-address.model.js'; @Table({ underscored: true, timestamps: true, + paranoid: true, tableName: 'mail_accounts', }) export class MailAccountModel extends Model { @@ -27,6 +28,9 @@ export class MailAccountModel extends Model { @Column(DataType.UUID) declare driveUserUuid: string; + @Column(DataType.DATE) + declare deletedAt: Date | null; + @HasMany(() => MailAddressModel) declare addresses: MailAddressModel[]; } diff --git a/src/modules/account/models/mail-address.model.ts b/src/modules/account/models/mail-address.model.ts index 1d07754..8b90abf 100644 --- a/src/modules/account/models/mail-address.model.ts +++ b/src/modules/account/models/mail-address.model.ts @@ -19,6 +19,7 @@ import { MailProviderAccountModel } from './mail-provider-account.model.js'; @Table({ underscored: true, timestamps: true, + paranoid: true, tableName: 'mail_addresses', }) export class MailAddressModel extends Model { @@ -49,6 +50,9 @@ export class MailAddressModel extends Model { @Column(DataType.BOOLEAN) declare isDefault: boolean; + @Column(DataType.DATE) + declare deletedAt: Date | null; + @BelongsTo(() => MailAccountModel) declare account: MailAccountModel; diff --git a/src/modules/account/repositories/account.repository.ts b/src/modules/account/repositories/account.repository.ts new file mode 100644 index 0000000..47f3696 --- /dev/null +++ b/src/modules/account/repositories/account.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { MailAccount } from '../domain/mail-account.domain.js'; +import { MailAccountModel } from '../models/mail-account.model.js'; +import { MailAddressModel } from '../models/mail-address.model.js'; +import { MailProviderAccountModel } from '../models/mail-provider-account.model.js'; +import { toAddressAttributes } from './address.repository.js'; + +@Injectable() +export class AccountRepository { + constructor( + @InjectModel(MailAccountModel) + private readonly accountModel: typeof MailAccountModel, + ) {} + + async findByDriveUserUuid(uuid: string): Promise { + const model = await this.accountModel.findOne({ + where: { driveUserUuid: uuid }, + include: [ + { + model: MailAddressModel, + include: [MailProviderAccountModel], + }, + ], + }); + + return model ? this.toDomain(model) : null; + } + + async delete(id: string): Promise { + await this.accountModel.destroy({ where: { id } }); + } + + private toDomain(model: MailAccountModel): MailAccount { + return MailAccount.build({ + id: model.id, + driveUserUuid: model.driveUserUuid, + createdAt: model.createdAt as Date, + updatedAt: model.updatedAt as Date, + addresses: (model.addresses ?? []).map(toAddressAttributes), + }); + } +} diff --git a/src/modules/account/repositories/address.repository.ts b/src/modules/account/repositories/address.repository.ts new file mode 100644 index 0000000..82dfbc9 --- /dev/null +++ b/src/modules/account/repositories/address.repository.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { Sequelize } from 'sequelize-typescript'; +import { + MailAddress, + type MailAddressAttributes, +} from '../domain/mail-address.domain.js'; +import { MailAddressModel } from '../models/mail-address.model.js'; +import { MailProviderAccountModel } from '../models/mail-provider-account.model.js'; + +export function toAddressAttributes( + model: MailAddressModel, +): MailAddressAttributes { + return { + id: model.id, + mailAccountId: model.mailAccountId, + address: model.address, + domainId: model.domainId, + isDefault: model.isDefault, + providerExternalId: model.providerAccount?.externalId ?? null, + createdAt: model.createdAt as Date, + updatedAt: model.updatedAt as Date, + }; +} + +@Injectable() +export class AddressRepository { + constructor( + @InjectModel(MailAddressModel) + private readonly addressModel: typeof MailAddressModel, + @InjectModel(MailProviderAccountModel) + private readonly providerAccountModel: typeof MailProviderAccountModel, + private readonly sequelize: Sequelize, + ) {} + + async findByAddress(address: string): Promise { + const model = await this.addressModel.findOne({ + where: { address }, + include: [MailProviderAccountModel], + }); + + return model ? MailAddress.build(toAddressAttributes(model)) : null; + } + + async findDefaultForAccount( + mailAccountId: string, + ): Promise { + const model = await this.addressModel.findOne({ + where: { mailAccountId, isDefault: true }, + include: [MailProviderAccountModel], + }); + + return model ? MailAddress.build(toAddressAttributes(model)) : null; + } + + async findAllForAccount(mailAccountId: string): Promise { + const models = await this.addressModel.findAll({ + where: { mailAccountId }, + include: [MailProviderAccountModel], + }); + + return models.map((m) => MailAddress.build(toAddressAttributes(m))); + } + + async create(params: { + mailAccountId: string; + address: string; + domainId: string; + isDefault: boolean; + }): Promise { + const model = await this.addressModel.create(params); + return MailAddress.build(toAddressAttributes(model)); + } + + async delete(id: string): Promise { + await this.addressModel.destroy({ where: { id } }); + } + + async setDefault(addressId: string, mailAccountId: string): Promise { + await this.sequelize.query( + `UPDATE mail_addresses SET is_default = (id = :addressId) WHERE mail_account_id = :mailAccountId`, + { replacements: { addressId, mailAccountId } }, + ); + } + + async createProviderLink(params: { + mailAddressId: string; + provider: string; + externalId: string; + }): Promise { + await this.providerAccountModel.create(params); + } + + async deleteProviderLink(mailAddressId: string): Promise { + await this.providerAccountModel.destroy({ where: { mailAddressId } }); + } + + async updateAllProviderExternalIds( + mailAccountId: string, + newExternalId: string, + ): Promise { + await this.sequelize.query( + `UPDATE mail_provider_accounts SET external_id = :newExternalId + WHERE mail_address_id IN (SELECT id FROM mail_addresses WHERE mail_account_id = :mailAccountId)`, + { replacements: { newExternalId, mailAccountId } }, + ); + } +} diff --git a/src/modules/account/repositories/domain.repository.ts b/src/modules/account/repositories/domain.repository.ts new file mode 100644 index 0000000..92a7452 --- /dev/null +++ b/src/modules/account/repositories/domain.repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { MailDomain } from '../domain/mail-domain.domain.js'; +import { MailDomainModel } from '../models/mail-domain.model.js'; + +@Injectable() +export class DomainRepository { + constructor( + @InjectModel(MailDomainModel) + private readonly domainModel: typeof MailDomainModel, + ) {} + + async findByDomain(domain: string): Promise { + const model = await this.domainModel.findOne({ where: { domain } }); + return model ? this.toDomain(model) : null; + } + + private toDomain(model: MailDomainModel): MailDomain { + return MailDomain.build({ + id: model.id, + domain: model.domain, + status: model.status, + createdAt: model.createdAt as Date, + updatedAt: model.updatedAt as Date, + }); + } +} diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts new file mode 100644 index 0000000..fe44ab9 --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { StalwartAccountProvider } from './stalwart-account.provider.js'; +import { StalwartService } from './stalwart.service.js'; +import { newCreateAccountParams } from '../../../../test/fixtures.js'; + +describe('StalwartAccountProvider', () => { + let provider: StalwartAccountProvider; + let stalwart: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [StalwartAccountProvider], + }) + .useMocker(() => createMock()) + .compile(); + + provider = module.get(StalwartAccountProvider); + stalwart = module.get(StalwartService); + }); + + describe('createAccount', () => { + it('when called, then creates principal with correct shape', async () => { + const params = newCreateAccountParams(); + + await provider.createAccount(params); + + expect(stalwart.createPrincipal).toHaveBeenCalledWith({ + type: 'individual', + name: params.primaryAddress, + description: params.displayName, + secrets: [params.password], + emails: [params.primaryAddress], + quota: params.quota, + }); + }); + + it('when quota is undefined, then defaults to 0', async () => { + const params = newCreateAccountParams({ quota: undefined }); + + await provider.createAccount(params); + + expect(stalwart.createPrincipal).toHaveBeenCalledWith( + expect.objectContaining({ quota: 0 }), + ); + }); + }); + + describe('deleteAccount', () => { + it('when called, then delegates to stalwart service', async () => { + await provider.deleteAccount('user@example.com'); + + expect(stalwart.deletePrincipal).toHaveBeenCalledWith('user@example.com'); + }); + }); + + describe('getAccount', () => { + it('when principal exists, then returns account info', async () => { + stalwart.getPrincipal.mockResolvedValue({ + name: 'user@example.com', + type: 'individual', + description: 'User Name', + emails: ['user@example.com', 'alias@example.com'], + quota: 5_000_000, + }); + + const result = await provider.getAccount('user@example.com'); + + expect(result).toEqual({ + name: 'user@example.com', + displayName: 'User Name', + emails: ['user@example.com', 'alias@example.com'], + quota: 5_000_000, + }); + }); + + it('when principal does not exist, then returns null', async () => { + stalwart.getPrincipal.mockResolvedValue(null); + + const result = await provider.getAccount('nonexistent@example.com'); + + expect(result).toBeNull(); + }); + + it('when principal has no optional fields, then uses defaults', async () => { + stalwart.getPrincipal.mockResolvedValue({ + name: 'user@example.com', + type: 'individual', + }); + + const result = await provider.getAccount('user@example.com'); + + expect(result).toEqual({ + name: 'user@example.com', + displayName: '', + emails: [], + quota: 0, + }); + }); + }); + + describe('addAddress', () => { + it('when called, then patches principal with addItem action', async () => { + await provider.addAddress('user@example.com', 'alias@example.com'); + + expect(stalwart.patchPrincipal).toHaveBeenCalledWith('user@example.com', [ + { action: 'addItem', field: 'emails', value: 'alias@example.com' }, + ]); + }); + }); + + describe('removeAddress', () => { + it('when called, then patches principal with removeItem action', async () => { + await provider.removeAddress('user@example.com', 'alias@example.com'); + + expect(stalwart.patchPrincipal).toHaveBeenCalledWith('user@example.com', [ + { + action: 'removeItem', + field: 'emails', + value: 'alias@example.com', + }, + ]); + }); + }); + + describe('setPrimaryAddress', () => { + it('when account exists, then recreates with new name and reordered emails', async () => { + const existingPrincipal = { + name: 'old@example.com', + type: 'individual', + description: 'User', + secrets: ['pass'], + emails: ['old@example.com', 'new@example.com', 'other@example.com'], + quota: 1000, + }; + stalwart.getPrincipal.mockResolvedValue(existingPrincipal); + + await provider.setPrimaryAddress('old@example.com', 'new@example.com'); + + expect(stalwart.deletePrincipal).toHaveBeenCalledWith('old@example.com'); + expect(stalwart.createPrincipal).toHaveBeenCalledWith({ + ...existingPrincipal, + name: 'new@example.com', + emails: ['new@example.com', 'old@example.com', 'other@example.com'], + }); + }); + + it('when account does not exist, then throws error', async () => { + stalwart.getPrincipal.mockResolvedValue(null); + + await expect( + provider.setPrimaryAddress( + 'nonexistent@example.com', + 'new@example.com', + ), + ).rejects.toThrow("Account 'nonexistent@example.com' not found"); + }); + }); +}); diff --git a/src/modules/infrastructure/stalwart/stalwart-account.provider.ts b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts new file mode 100644 index 0000000..674926a --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart-account.provider.ts @@ -0,0 +1,91 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AccountProvider } from '../../account/account-provider.port.js'; +import type { + AccountInfo, + CreateAccountParams, +} from '../../account/account.types.js'; +import { StalwartService } from './stalwart.service.js'; + +@Injectable() +export class StalwartAccountProvider extends AccountProvider { + private readonly logger = new Logger(StalwartAccountProvider.name); + + constructor(private readonly stalwart: StalwartService) { + super(); + } + + async createAccount(params: CreateAccountParams): Promise { + await this.stalwart.createPrincipal({ + type: 'individual', + name: params.primaryAddress, + description: params.displayName, + secrets: [params.password], + emails: [params.primaryAddress], + quota: params.quota ?? 0, + }); + + this.logger.log(`Created account '${params.primaryAddress}'`); + } + + async deleteAccount(name: string): Promise { + await this.stalwart.deletePrincipal(name); + this.logger.log(`Deleted account '${name}'`); + } + + async getAccount(name: string): Promise { + const principal = await this.stalwart.getPrincipal(name); + if (!principal) return null; + + return { + name: principal.name, + displayName: principal.description ?? '', + emails: principal.emails ?? [], + quota: principal.quota ?? 0, + }; + } + + async addAddress(name: string, address: string): Promise { + await this.stalwart.patchPrincipal(name, [ + { action: 'addItem', field: 'emails', value: address }, + ]); + + this.logger.log(`Added address '${address}' to '${name}'`); + } + + async removeAddress(name: string, address: string): Promise { + await this.stalwart.patchPrincipal(name, [ + { action: 'removeItem', field: 'emails', value: address }, + ]); + + this.logger.log(`Removed address '${address}' from '${name}'`); + } + + async setPrimaryAddress( + currentName: string, + newPrimaryAddress: string, + ): Promise { + // Stalwart uses the principal name as the login. + // Changing the primary address means renaming the principal. + // Current REST API does not support rename — we must recreate. + const existing = await this.stalwart.getPrincipal(currentName); + if (!existing) { + throw new Error(`Account '${currentName}' not found`); + } + + const updatedEmails = [ + newPrimaryAddress, + ...(existing.emails ?? []).filter((e) => e !== newPrimaryAddress), + ]; + + await this.stalwart.deletePrincipal(currentName); + await this.stalwart.createPrincipal({ + ...existing, + name: newPrimaryAddress, + emails: updatedEmails, + }); + + this.logger.warn( + `Renamed account '${currentName}' → '${newPrimaryAddress}' (delete + recreate)`, + ); + } +} diff --git a/src/modules/infrastructure/stalwart/stalwart.module.ts b/src/modules/infrastructure/stalwart/stalwart.module.ts new file mode 100644 index 0000000..0d838f0 --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AccountProvider } from '../../account/account-provider.port.js'; +import { StalwartService } from './stalwart.service.js'; +import { StalwartAccountProvider } from './stalwart-account.provider.js'; + +@Module({ + providers: [ + StalwartService, + { provide: AccountProvider, useClass: StalwartAccountProvider }, + ], + exports: [AccountProvider], +}) +export class StalwartModule {} diff --git a/src/modules/infrastructure/stalwart/stalwart.service.spec.ts b/src/modules/infrastructure/stalwart/stalwart.service.spec.ts new file mode 100644 index 0000000..e9f27cf --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart.service.spec.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { type ConfigService } from '@nestjs/config'; +import { StalwartService, StalwartApiError } from './stalwart.service.js'; + +// Mock undici Client +const mockRequest = vi.fn(); +vi.mock('undici', () => ({ + Client: vi.fn().mockImplementation(() => ({ + request: mockRequest, + close: vi.fn(), + })), +})); + +function createConfigService(): ConfigService { + const config: Record = { + 'stalwart.adminUrl': 'http://localhost:8080', + 'stalwart.adminUser': 'admin', + 'stalwart.adminSecret': 'secret', + }; + return { + getOrThrow: vi.fn((key: string) => { + const value = config[key]; + if (!value) throw new Error(`Missing config: ${key}`); + return value; + }), + } as unknown as ConfigService; +} + +function mockResponse(statusCode: number, responseBody: string | object) { + const text = + typeof responseBody === 'string' + ? responseBody + : JSON.stringify(responseBody); + return { statusCode, body: { text: vi.fn().mockResolvedValue(text) } }; +} + +describe('StalwartService', () => { + let service: StalwartService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new StalwartService(createConfigService()); + service.onModuleInit(); + }); + + describe('createPrincipal', () => { + it('when server returns 201, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(201, 'ok')); + + await expect( + service.createPrincipal({ + name: 'user@test.com', + type: 'individual', + }), + ).resolves.toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/api/principal', + }), + ); + }); + + it('when server returns 200, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(200, 'ok')); + + await expect( + service.createPrincipal({ + name: 'user@test.com', + type: 'individual', + }), + ).resolves.toBeUndefined(); + }); + + it('when server returns error, then throws StalwartApiError', async () => { + mockRequest.mockResolvedValue(mockResponse(409, 'conflict')); + + await expect( + service.createPrincipal({ + name: 'user@test.com', + type: 'individual', + }), + ).rejects.toThrow(StalwartApiError); + }); + }); + + describe('getPrincipal', () => { + it('when principal exists, then returns parsed data', async () => { + const principal = { + name: 'user@test.com', + type: 'individual', + emails: ['user@test.com'], + }; + mockRequest.mockResolvedValue(mockResponse(200, { data: principal })); + + const result = await service.getPrincipal('user@test.com'); + + expect(result).toEqual(principal); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/api/principal/user%40test.com', + }), + ); + }); + + it('when principal not found, then returns null', async () => { + mockRequest.mockResolvedValue(mockResponse(404, 'not found')); + + const result = await service.getPrincipal('unknown@test.com'); + + expect(result).toBeNull(); + }); + + it('when server returns error, then throws StalwartApiError', async () => { + mockRequest.mockResolvedValue(mockResponse(500, 'internal error')); + + await expect(service.getPrincipal('user@test.com')).rejects.toThrow( + StalwartApiError, + ); + }); + }); + + describe('patchPrincipal', () => { + it('when server returns 200, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(200, 'ok')); + + await expect( + service.patchPrincipal('user@test.com', [ + { action: 'addItem', field: 'emails', value: 'alias@test.com' }, + ]), + ).resolves.toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PATCH', + path: '/api/principal/user%40test.com', + }), + ); + }); + + it('when server returns 204, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(204, '')); + + await expect( + service.patchPrincipal('user@test.com', [ + { action: 'set', field: 'quota', value: 1000 }, + ]), + ).resolves.toBeUndefined(); + }); + + it('when server returns error, then throws StalwartApiError', async () => { + mockRequest.mockResolvedValue(mockResponse(400, 'bad request')); + + await expect(service.patchPrincipal('user@test.com', [])).rejects.toThrow( + StalwartApiError, + ); + }); + }); + + describe('deletePrincipal', () => { + it('when server returns 200, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(200, 'ok')); + + await expect( + service.deletePrincipal('user@test.com'), + ).resolves.toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + path: '/api/principal/user%40test.com', + }), + ); + }); + + it('when server returns 204, then succeeds', async () => { + mockRequest.mockResolvedValue(mockResponse(204, '')); + + await expect( + service.deletePrincipal('user@test.com'), + ).resolves.toBeUndefined(); + }); + + it('when server returns error, then throws StalwartApiError', async () => { + mockRequest.mockResolvedValue(mockResponse(404, 'not found')); + + await expect(service.deletePrincipal('unknown@test.com')).rejects.toThrow( + StalwartApiError, + ); + }); + }); + + describe('headers', () => { + it('when request is made, then includes Basic auth with correct credentials', async () => { + mockRequest.mockResolvedValue(mockResponse(200, { data: {} })); + + await service.getPrincipal('test'); + + const expectedAuth = `Basic ${Buffer.from('admin:secret').toString('base64')}`; + const callArgs = mockRequest.mock.calls[0]![0] as { + headers: Record; + }; + expect(callArgs.headers.authorization).toBe(expectedAuth); + expect(callArgs.headers['content-type']).toBe('application/json'); + expect(callArgs.headers.accept).toBe('application/json'); + }); + }); +}); diff --git a/src/modules/infrastructure/stalwart/stalwart.service.ts b/src/modules/infrastructure/stalwart/stalwart.service.ts new file mode 100644 index 0000000..ea7337d --- /dev/null +++ b/src/modules/infrastructure/stalwart/stalwart.service.ts @@ -0,0 +1,169 @@ +import { + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Client } from 'undici'; + +interface StalwartPrincipal { + name: string; + type: string; + description?: string; + secrets?: string[]; + emails?: string[]; + quota?: number; + memberOf?: string[]; + roles?: string[]; + lists?: string[]; + enabledPermissions?: string[]; + disabledPermissions?: string[]; +} + +type PatchAction = 'set' | 'addItem' | 'removeItem'; + +interface PatchOperation { + action: PatchAction; + field: string; + value: unknown; +} + +@Injectable() +export class StalwartService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(StalwartService.name); + private readonly adminUrl: string; + private readonly adminUser: string; + private readonly adminSecret: string; + private httpClient!: Client; + + constructor(private readonly configService: ConfigService) { + this.adminUrl = this.configService.getOrThrow('stalwart.adminUrl'); + this.adminUser = + this.configService.getOrThrow('stalwart.adminUser'); + this.adminSecret = this.configService.getOrThrow( + 'stalwart.adminSecret', + ); + } + + onModuleInit() { + this.httpClient = new Client(this.adminUrl, { + allowH2: true, + keepAliveTimeout: 30_000, + pipelining: 1, + }); + this.logger.log( + `Stalwart admin client initialized targeting ${this.adminUrl}`, + ); + } + + async onModuleDestroy() { + await this.httpClient.close(); + } + + async createPrincipal(principal: StalwartPrincipal): Promise { + const { statusCode, body } = await this.httpClient.request({ + method: 'POST', + path: '/api/principal', + headers: this.headers(), + body: JSON.stringify(principal), + }); + + const text = await body.text(); + + if (statusCode !== 200 && statusCode !== 201) { + throw new StalwartApiError( + `Failed to create principal '${principal.name}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + } + + async getPrincipal(name: string): Promise { + const { statusCode, body } = await this.httpClient.request({ + method: 'GET', + path: `/api/principal/${encodeURIComponent(name)}`, + headers: this.headers(), + }); + + const text = await body.text(); + + if (statusCode === 404) { + return null; + } + + if (statusCode !== 200) { + throw new StalwartApiError( + `Failed to get principal '${name}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + + const response = JSON.parse(text) as { data: StalwartPrincipal }; + return response.data; + } + + async patchPrincipal( + name: string, + operations: PatchOperation[], + ): Promise { + const { statusCode, body } = await this.httpClient.request({ + method: 'PATCH', + path: `/api/principal/${encodeURIComponent(name)}`, + headers: this.headers(), + body: JSON.stringify(operations), + }); + + const text = await body.text(); + + if (statusCode !== 200 && statusCode !== 204) { + throw new StalwartApiError( + `Failed to patch principal '${name}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + } + + async deletePrincipal(name: string): Promise { + const { statusCode, body } = await this.httpClient.request({ + method: 'DELETE', + path: `/api/principal/${encodeURIComponent(name)}`, + headers: this.headers(), + }); + + const text = await body.text(); + + if (statusCode !== 200 && statusCode !== 204) { + throw new StalwartApiError( + `Failed to delete principal '${name}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + } + + private headers(): Record { + const credentials = Buffer.from( + `${this.adminUser}:${this.adminSecret}`, + ).toString('base64'); + return { + authorization: `Basic ${credentials}`, + 'content-type': 'application/json', + accept: 'application/json', + }; + } +} + +export class StalwartApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly details: string, + ) { + super(message); + this.name = 'StalwartApiError'; + } +} diff --git a/test/fixtures.ts b/test/fixtures.ts index 703c6ab..d608133 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -16,6 +16,14 @@ import type { Identity, } from '../src/modules/infrastructure/jmap/jmap.types.js'; +import type { MailAccountAttributes } from '../src/modules/account/domain/mail-account.domain.js'; +import type { MailAddressAttributes } from '../src/modules/account/domain/mail-address.domain.js'; +import type { MailDomainAttributes } from '../src/modules/account/domain/mail-domain.domain.js'; +import type { + AccountInfo, + CreateAccountParams, +} from '../src/modules/account/account.types.js'; + const random = new Chance(); // ── Helpers ──────────────────────────────────────────────────────── @@ -24,15 +32,17 @@ function randomId(): string { return random.hash({ length: 24 }); } +function randomUuid(): string { + return random.guid({ version: 4 }); +} + function randomISODate(): string { - return random.date({ year: 2025 }).toISOString(); + return random.date({ year: 2025 }).toString(); } // ── Domain Fixtures ──────────────────────────────────────────────── -export function newEmailAddress( - attrs?: Partial, -): EmailAddress { +export function newEmailAddress(attrs?: Partial): EmailAddress { return { name: random.name(), email: random.email(), @@ -40,9 +50,7 @@ export function newEmailAddress( }; } -export function newMailbox( - attrs?: Partial, -): Mailbox { +export function newMailbox(attrs?: Partial): Mailbox { return { id: randomId(), name: random.word(), @@ -61,9 +69,7 @@ export function newMailbox( }; } -export function newEmailSummary( - attrs?: Partial, -): EmailSummary { +export function newEmailSummary(attrs?: Partial): EmailSummary { return { id: randomId(), threadId: randomId(), @@ -80,9 +86,7 @@ export function newEmailSummary( }; } -export function newEmail( - attrs?: Partial, -): Email { +export function newEmail(attrs?: Partial): Email { const summary = newEmailSummary(attrs); return { ...summary, @@ -96,9 +100,7 @@ export function newEmail( }; } -export function newSendEmailDto( - attrs?: Partial, -): SendEmailDto { +export function newSendEmailDto(attrs?: Partial): SendEmailDto { return { to: [newEmailAddress()], subject: random.sentence({ words: 5 }), @@ -118,6 +120,77 @@ export function newDraftEmailDto( }; } +export function newMailAddressAttributes( + attrs?: Partial, +): MailAddressAttributes { + return { + id: randomUuid(), + mailAccountId: randomUuid(), + address: random.email(), + domainId: randomUuid(), + isDefault: false, + providerExternalId: random.email(), + createdAt: new Date(), + updatedAt: new Date(), + ...attrs, + }; +} + +export function newMailAccountAttributes( + attrs?: Partial, +): MailAccountAttributes { + const accountId = attrs?.id ?? randomUuid(); + return { + id: accountId, + driveUserUuid: randomUuid(), + addresses: [ + newMailAddressAttributes({ + mailAccountId: accountId, + isDefault: true, + }), + ], + createdAt: new Date(), + updatedAt: new Date(), + ...attrs, + }; +} + +export function newMailDomainAttributes( + attrs?: Partial, +): MailDomainAttributes { + return { + id: randomUuid(), + domain: random.domain(), + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + ...attrs, + }; +} + +export function newCreateAccountParams( + attrs?: Partial, +): CreateAccountParams { + return { + accountId: randomUuid(), + primaryAddress: random.email(), + displayName: random.name(), + password: random.hash({ length: 16 }), + quota: random.natural({ min: 1_000_000, max: 10_000_000 }), + ...attrs, + }; +} + +export function newAccountInfo(attrs?: Partial): AccountInfo { + return { + name: random.email(), + displayName: random.name(), + emails: [random.email(), random.email()], + quota: random.natural({ min: 1_000_000, max: 10_000_000 }), + ...attrs, + }; +} + // ── JMAP Fixtures ────────────────────────────────────────────────── // EmailAddress is structurally identical in both domain and JMAP types @@ -125,9 +198,7 @@ export const newJmapEmailAddress = newEmailAddress as ( attrs?: Partial, ) => JmapEmailAddress; -export function newJmapMailbox( - attrs?: Partial, -): JmapMailbox { +export function newJmapMailbox(attrs?: Partial): JmapMailbox { return { id: randomId(), name: random.word(), @@ -150,9 +221,7 @@ export function newJmapMailbox( }; } -export function newJmapEmail( - attrs?: Partial, -): JmapEmail { +export function newJmapEmail(attrs?: Partial): JmapEmail { const textPartId = randomId(); const htmlPartId = randomId(); @@ -194,9 +263,7 @@ export function newJmapEmail( }; } -export function newJmapIdentity( - attrs?: Partial, -): Identity { +export function newJmapIdentity(attrs?: Partial): Identity { return { id: randomId(), name: random.name(),